001/* 002 * SonarQube 003 * Copyright (C) 2009-2016 SonarSource SA 004 * mailto:contact AT sonarsource DOT com 005 * 006 * This program is free software; you can redistribute it and/or 007 * modify it under the terms of the GNU Lesser General Public 008 * License as published by the Free Software Foundation; either 009 * version 3 of the License, or (at your option) any later version. 010 * 011 * This program is distributed in the hope that it will be useful, 012 * but WITHOUT ANY WARRANTY; without even the implied warranty of 013 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 014 * Lesser General Public License for more details. 015 * 016 * You should have received a copy of the GNU Lesser General Public License 017 * along with this program; if not, write to the Free Software Foundation, 018 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 019 */ 020package org.sonar.api.server.ws; 021 022import com.google.common.base.Joiner; 023import com.google.common.collect.ImmutableList; 024import com.google.common.collect.ImmutableMap; 025import com.google.common.collect.Maps; 026import com.google.common.collect.Sets; 027import java.io.IOException; 028import java.net.URL; 029import java.nio.charset.StandardCharsets; 030import java.util.Collection; 031import java.util.Collections; 032import java.util.List; 033import java.util.Map; 034import java.util.Set; 035import javax.annotation.CheckForNull; 036import javax.annotation.Nullable; 037import javax.annotation.concurrent.Immutable; 038import org.apache.commons.io.FilenameUtils; 039import org.apache.commons.io.IOUtils; 040import org.apache.commons.lang.StringUtils; 041import org.sonar.api.ExtensionPoint; 042import org.sonar.api.server.ServerSide; 043import org.sonar.api.utils.log.Logger; 044import org.sonar.api.utils.log.Loggers; 045 046import static com.google.common.base.Preconditions.checkArgument; 047import static com.google.common.base.Preconditions.checkState; 048import static com.google.common.base.Strings.isNullOrEmpty; 049import static java.lang.String.format; 050import static java.util.Arrays.asList; 051 052/** 053 * Defines a web service. Note that contrary to the deprecated {@link org.sonar.api.web.Webservice} 054 * the ws is fully implemented in Java and does not require any Ruby on Rails code. 055 * <br> 056 * <br> 057 * The classes implementing this extension point must be declared by {@link org.sonar.api.Plugin}. 058 * <br> 059 * <h3>How to use</h3> 060 * <pre> 061 * public class HelloWs implements WebService { 062 * {@literal @}Override 063 * public void define(Context context) { 064 * NewController controller = context.createController("api/hello"); 065 * controller.setDescription("Web service example"); 066 * 067 * // create the URL /api/hello/show 068 * controller.createAction("show") 069 * .setDescription("Entry point") 070 * .setHandler(new RequestHandler() { 071 * {@literal @}Override 072 * public void handle(Request request, Response response) { 073 * // read request parameters and generates response output 074 * response.newJsonWriter() 075 * .beginObject() 076 * .prop("hello", request.mandatoryParam("key")) 077 * .endObject() 078 * .close(); 079 * } 080 * }) 081 * .createParam("key").setDescription("Example key").setRequired(true); 082 * 083 * // important to apply changes 084 * controller.done(); 085 * } 086 * } 087 * </pre> 088 * 089 * Since version 5.5, a web service can call another web service to get some data. See {@link Request#localConnector()} 090 * provided by {@link RequestHandler#handle(Request, Response)}. 091 * 092 * @since 4.2 093 */ 094@ServerSide 095@ExtensionPoint 096public interface WebService extends Definable<WebService.Context> { 097 098 class Context { 099 private final Map<String, Controller> controllers = Maps.newHashMap(); 100 101 /** 102 * Create a new controller. 103 * <br> 104 * Structure of request URL is <code>http://<server>/<controller path>/<action path>?<parameters></code>. 105 * 106 * @param path the controller path must not start or end with "/". It is recommended to start with "api/" 107 * and to use lower-case format with underscores, for example "api/coding_rules". Usual actions 108 * are "search", "list", "show", "create" and "delete". 109 * the plural form is recommended - ex: api/projects 110 */ 111 public NewController createController(String path) { 112 return new NewController(this, path); 113 } 114 115 private void register(NewController newController) { 116 if (controllers.containsKey(newController.path)) { 117 throw new IllegalStateException( 118 format("The web service '%s' is defined multiple times", newController.path)); 119 } 120 controllers.put(newController.path, new Controller(newController)); 121 } 122 123 @CheckForNull 124 public Controller controller(String key) { 125 return controllers.get(key); 126 } 127 128 public List<Controller> controllers() { 129 return ImmutableList.copyOf(controllers.values()); 130 } 131 } 132 133 class NewController { 134 private final Context context; 135 private final String path; 136 private String description; 137 private String since; 138 private final Map<String, NewAction> actions = Maps.newHashMap(); 139 140 private NewController(Context context, String path) { 141 if (StringUtils.isBlank(path)) { 142 throw new IllegalArgumentException("WS controller path must not be empty"); 143 } 144 if (StringUtils.startsWith(path, "/") || StringUtils.endsWith(path, "/")) { 145 throw new IllegalArgumentException("WS controller path must not start or end with slash: " + path); 146 } 147 this.context = context; 148 this.path = path; 149 } 150 151 /** 152 * Important - this method must be called in order to apply changes and make the 153 * controller available in {@link org.sonar.api.server.ws.WebService.Context#controllers()} 154 */ 155 public void done() { 156 context.register(this); 157 } 158 159 /** 160 * Optional description (accept HTML) 161 */ 162 public NewController setDescription(@Nullable String s) { 163 this.description = s; 164 return this; 165 } 166 167 /** 168 * Optional version when the controller was created 169 */ 170 public NewController setSince(@Nullable String s) { 171 this.since = s; 172 return this; 173 } 174 175 public NewAction createAction(String actionKey) { 176 if (actions.containsKey(actionKey)) { 177 throw new IllegalStateException( 178 format("The action '%s' is defined multiple times in the web service '%s'", actionKey, path)); 179 } 180 NewAction action = new NewAction(actionKey); 181 actions.put(actionKey, action); 182 return action; 183 } 184 } 185 186 @Immutable 187 class Controller { 188 private final String path; 189 private final String description; 190 private final String since; 191 private final Map<String, Action> actions; 192 193 private Controller(NewController newController) { 194 checkState(!newController.actions.isEmpty(), format("At least one action must be declared in the web service '%s'", newController.path)); 195 this.path = newController.path; 196 this.description = newController.description; 197 this.since = newController.since; 198 ImmutableMap.Builder<String, Action> mapBuilder = ImmutableMap.builder(); 199 for (NewAction newAction : newController.actions.values()) { 200 mapBuilder.put(newAction.key, new Action(this, newAction)); 201 } 202 this.actions = mapBuilder.build(); 203 } 204 205 public String path() { 206 return path; 207 } 208 209 @CheckForNull 210 public String description() { 211 return description; 212 } 213 214 @CheckForNull 215 public String since() { 216 return since; 217 } 218 219 @CheckForNull 220 public Action action(String actionKey) { 221 return actions.get(actionKey); 222 } 223 224 public Collection<Action> actions() { 225 return actions.values(); 226 } 227 228 /** 229 * Returns true if all the actions are for internal use 230 * 231 * @see org.sonar.api.server.ws.WebService.Action#isInternal() 232 * @since 4.3 233 */ 234 public boolean isInternal() { 235 for (Action action : actions()) { 236 if (!action.isInternal()) { 237 return false; 238 } 239 } 240 return true; 241 } 242 } 243 244 class NewAction { 245 private final String key; 246 private String deprecatedKey; 247 private String description; 248 private String since; 249 private String deprecatedSince; 250 private boolean post = false; 251 private boolean isInternal = false; 252 private RequestHandler handler; 253 private Map<String, NewParam> newParams = Maps.newHashMap(); 254 private URL responseExample = null; 255 256 private NewAction(String key) { 257 this.key = key; 258 } 259 260 public NewAction setDeprecatedKey(@Nullable String s) { 261 this.deprecatedKey = s; 262 return this; 263 } 264 265 /** 266 * Used in Orchestrator 267 */ 268 public NewAction setDescription(@Nullable String description) { 269 this.description = description; 270 return this; 271 } 272 273 /** 274 * @since 5.6 275 */ 276 public NewAction setDescription(@Nullable String description, Object... descriptionArgument) { 277 this.description = description == null ? null : String.format(description, descriptionArgument); 278 return this; 279 } 280 281 public NewAction setSince(@Nullable String s) { 282 this.since = s; 283 return this; 284 } 285 286 /** 287 * @since 5.3 288 */ 289 public NewAction setDeprecatedSince(@Nullable String deprecatedSince) { 290 this.deprecatedSince = deprecatedSince; 291 return this; 292 } 293 294 public NewAction setPost(boolean b) { 295 this.post = b; 296 return this; 297 } 298 299 public NewAction setInternal(boolean b) { 300 this.isInternal = b; 301 return this; 302 } 303 304 public NewAction setHandler(RequestHandler h) { 305 this.handler = h; 306 return this; 307 } 308 309 /** 310 * Link to the document containing an example of response. Content must be UTF-8 encoded. 311 * <br> 312 * Example: 313 * <pre> 314 * newAction.setResponseExample(getClass().getResource("/org/sonar/my-ws-response-example.json")); 315 * </pre> 316 * 317 * @since 4.4 318 */ 319 public NewAction setResponseExample(@Nullable URL url) { 320 this.responseExample = url; 321 return this; 322 } 323 324 public NewParam createParam(String paramKey) { 325 checkState(!newParams.containsKey(paramKey), 326 format("The parameter '%s' is defined multiple times in the action '%s'", paramKey, key)); 327 NewParam newParam = new NewParam(paramKey); 328 newParams.put(paramKey, newParam); 329 return newParam; 330 } 331 332 /** 333 * @deprecated since 4.4. Use {@link #createParam(String paramKey)} instead. 334 */ 335 @Deprecated 336 public NewAction createParam(String paramKey, @Nullable String description) { 337 createParam(paramKey).setDescription(description); 338 return this; 339 } 340 341 /** 342 * Add predefined parameters related to pagination of results. 343 */ 344 public NewAction addPagingParams(int defaultPageSize) { 345 createParam(Param.PAGE) 346 .setDescription("1-based page number") 347 .setExampleValue("42") 348 .setDeprecatedKey("pageIndex") 349 .setDefaultValue("1"); 350 351 createParam(Param.PAGE_SIZE) 352 .setDescription("Page size. Must be greater than 0.") 353 .setExampleValue("20") 354 .setDeprecatedKey("pageSize") 355 .setDefaultValue(String.valueOf(defaultPageSize)); 356 return this; 357 } 358 359 /** 360 * Add predefined parameters related to pagination of results with a maximum page size. 361 * Note the maximum is a documentation only feature. It does not check anything. 362 */ 363 public NewAction addPagingParams(int defaultPageSize, int maxPageSize) { 364 addPageParam(); 365 addPageSize(defaultPageSize, maxPageSize); 366 return this; 367 } 368 369 public NewAction addPageParam() { 370 createParam(Param.PAGE) 371 .setDescription("1-based page number") 372 .setExampleValue("42") 373 .setDeprecatedKey("pageIndex") 374 .setDefaultValue("1"); 375 return this; 376 } 377 378 public NewAction addPageSize(int defaultPageSize, int maxPageSize) { 379 createParam(Param.PAGE_SIZE) 380 .setDescription("Page size. Must be greater than 0 and less than " + maxPageSize) 381 .setExampleValue("20") 382 .setDeprecatedKey("pageSize") 383 .setDefaultValue(String.valueOf(defaultPageSize)); 384 return this; 385 } 386 387 /** 388 * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#FIELDS}, which is 389 * used to restrict the number of fields returned in JSON response. 390 */ 391 public NewAction addFieldsParam(Collection<?> possibleValues) { 392 createFieldsParam(possibleValues); 393 return this; 394 } 395 396 public NewParam createFieldsParam(Collection<?> possibleValues) { 397 return createParam(Param.FIELDS) 398 .setDescription("Comma-separated list of the fields to be returned in response. All the fields are returned by default.") 399 .setPossibleValues(possibleValues); 400 } 401 402 /** 403 * 404 * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#TEXT_QUERY}, which is 405 * used to search for a subset of fields containing the supplied string. 406 * <p> 407 * The fields must be in the <strong>plural</strong> form (ex: "names", "keys"). 408 * </p> 409 */ 410 public NewAction addSearchQuery(String exampleValue, String... pluralFields) { 411 String actionDescription = format("Limit search to %s that contain the supplied string.", Joiner.on(" or ").join(pluralFields)); 412 createParam(Param.TEXT_QUERY) 413 .setDescription(actionDescription) 414 .setExampleValue(exampleValue); 415 return this; 416 } 417 418 /** 419 * Add predefined parameters related to sorting of results. 420 */ 421 public <V> NewAction addSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) { 422 createSortParams(possibleValues, defaultValue, defaultAscending); 423 return this; 424 } 425 426 /** 427 * Add predefined parameters related to sorting of results. 428 */ 429 public <V> NewParam createSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) { 430 createParam(Param.ASCENDING) 431 .setDescription("Ascending sort") 432 .setBooleanPossibleValues() 433 .setDefaultValue(defaultAscending); 434 435 return createParam(Param.SORT) 436 .setDescription("Sort field") 437 .setDeprecatedKey("sort") 438 .setDefaultValue(defaultValue) 439 .setPossibleValues(possibleValues); 440 } 441 442 /** 443 * Add 'selected=(selected|deselected|all)' for select-list oriented WS. 444 */ 445 public NewAction addSelectionModeParam() { 446 createParam(Param.SELECTED) 447 .setDescription("Depending on the value, show only selected items (selected=selected), deselected items (selected=deselected), " + 448 "or all items with their selection status (selected=all).") 449 .setDefaultValue(SelectionMode.SELECTED.value()) 450 .setPossibleValues(SelectionMode.possibleValues()); 451 return this; 452 } 453 } 454 455 @Immutable 456 class Action { 457 private static final Logger LOGGER = Loggers.get(Action.class); 458 459 private final String key; 460 private final String deprecatedKey; 461 private final String path; 462 private final String description; 463 private final String since; 464 private final String deprecatedSince; 465 private final boolean post; 466 private final boolean isInternal; 467 private final RequestHandler handler; 468 private final Map<String, Param> params; 469 private final URL responseExample; 470 471 private Action(Controller controller, NewAction newAction) { 472 this.key = newAction.key; 473 this.deprecatedKey = newAction.deprecatedKey; 474 this.path = format("%s/%s", controller.path(), key); 475 this.description = newAction.description; 476 this.since = newAction.since; 477 this.deprecatedSince = newAction.deprecatedSince; 478 this.post = newAction.post; 479 this.isInternal = newAction.isInternal; 480 this.responseExample = newAction.responseExample; 481 this.handler = newAction.handler; 482 483 checkState(this.handler != null, "RequestHandler is not set on action " + path); 484 logWarningIf(isNullOrEmpty(this.description), "Description is not set on action " + path); 485 logWarningIf(isNullOrEmpty(this.since), "Since is not set on action " + path); 486 logWarningIf(!this.post && this.responseExample == null, "The response example is not set on action " + path); 487 488 ImmutableMap.Builder<String, Param> paramsBuilder = ImmutableMap.builder(); 489 for (NewParam newParam : newAction.newParams.values()) { 490 paramsBuilder.put(newParam.key, new Param(this, newParam)); 491 } 492 this.params = paramsBuilder.build(); 493 } 494 495 private static void logWarningIf(boolean condition, String message) { 496 if (condition) { 497 LOGGER.warn(message); 498 } 499 } 500 501 public String key() { 502 return key; 503 } 504 505 public String deprecatedKey() { 506 return deprecatedKey; 507 } 508 509 public String path() { 510 return path; 511 } 512 513 @CheckForNull 514 public String description() { 515 return description; 516 } 517 518 /** 519 * Set if different than controller. 520 */ 521 @CheckForNull 522 public String since() { 523 return since; 524 } 525 526 @CheckForNull 527 public String deprecatedSince() { 528 return deprecatedSince; 529 } 530 531 public boolean isPost() { 532 return post; 533 } 534 535 public boolean isInternal() { 536 return isInternal; 537 } 538 539 public RequestHandler handler() { 540 return handler; 541 } 542 543 /** 544 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 545 */ 546 @CheckForNull 547 public URL responseExample() { 548 return responseExample; 549 } 550 551 /** 552 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 553 */ 554 @CheckForNull 555 public String responseExampleAsString() { 556 try { 557 if (responseExample != null) { 558 return StringUtils.trim(IOUtils.toString(responseExample, StandardCharsets.UTF_8)); 559 } 560 return null; 561 } catch (IOException e) { 562 throw new IllegalStateException("Fail to load " + responseExample, e); 563 } 564 } 565 566 /** 567 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 568 */ 569 @CheckForNull 570 public String responseExampleFormat() { 571 if (responseExample != null) { 572 return StringUtils.lowerCase(FilenameUtils.getExtension(responseExample.getFile())); 573 } 574 return null; 575 } 576 577 @CheckForNull 578 public Param param(String key) { 579 return params.get(key); 580 } 581 582 public Collection<Param> params() { 583 return params.values(); 584 } 585 586 @Override 587 public String toString() { 588 return path; 589 } 590 } 591 592 class NewParam { 593 private String key; 594 private String since; 595 private String deprecatedSince; 596 private String deprecatedKey; 597 private String description; 598 private String exampleValue; 599 private String defaultValue; 600 private boolean required = false; 601 private Set<String> possibleValues = null; 602 603 private NewParam(String key) { 604 this.key = key; 605 } 606 607 /** 608 * @since 5.3 609 */ 610 public NewParam setSince(@Nullable String since) { 611 this.since = since; 612 return this; 613 } 614 615 /** 616 * @since 5.3 617 */ 618 public NewParam setDeprecatedSince(@Nullable String deprecatedSince) { 619 this.deprecatedSince = deprecatedSince; 620 return this; 621 } 622 623 /** 624 * @since 5.0 625 */ 626 public NewParam setDeprecatedKey(@Nullable String s) { 627 this.deprecatedKey = s; 628 return this; 629 } 630 631 public NewParam setDescription(@Nullable String description) { 632 this.description = description; 633 return this; 634 } 635 636 /** 637 * @since 5.6 638 */ 639 public NewParam setDescription(@Nullable String description, Object... descriptionArgument) { 640 this.description = description == null ? null : String.format(description, descriptionArgument); 641 return this; 642 } 643 644 /** 645 * Is the parameter required or optional ? Default value is false (optional). 646 * 647 * @since 4.4 648 */ 649 public NewParam setRequired(boolean b) { 650 this.required = b; 651 return this; 652 } 653 654 /** 655 * @since 4.4 656 */ 657 public NewParam setExampleValue(@Nullable Object s) { 658 this.exampleValue = (s != null) ? s.toString() : null; 659 return this; 660 } 661 662 /** 663 * Exhaustive list of possible values when it makes sense, for example 664 * list of severities. 665 * 666 * @since 4.4 667 */ 668 public NewParam setPossibleValues(@Nullable Object... values) { 669 return setPossibleValues(values == null ? Collections.emptyList() : asList(values)); 670 } 671 672 /** 673 * @since 4.4 674 */ 675 public NewParam setBooleanPossibleValues() { 676 return setPossibleValues("true", "false", "yes", "no"); 677 } 678 679 /** 680 * Exhaustive list of possible values when it makes sense, for example 681 * list of severities. 682 * 683 * @since 4.4 684 */ 685 public NewParam setPossibleValues(@Nullable Collection<?> values) { 686 if (values == null || values.isEmpty()) { 687 this.possibleValues = null; 688 } else { 689 this.possibleValues = Sets.newLinkedHashSet(); 690 for (Object value : values) { 691 this.possibleValues.add(value.toString()); 692 } 693 } 694 return this; 695 } 696 697 /** 698 * @since 4.4 699 */ 700 public NewParam setDefaultValue(@Nullable Object o) { 701 this.defaultValue = (o != null) ? o.toString() : null; 702 return this; 703 } 704 705 @Override 706 public String toString() { 707 return key; 708 } 709 } 710 711 enum SelectionMode { 712 SELECTED("selected"), DESELECTED("deselected"), ALL("all"); 713 714 private final String paramValue; 715 716 private static final Map<String, SelectionMode> BY_VALUE = Maps.uniqueIndex(asList(values()), input -> input.paramValue); 717 718 SelectionMode(String paramValue) { 719 this.paramValue = paramValue; 720 } 721 722 public String value() { 723 return paramValue; 724 } 725 726 public static SelectionMode fromParam(String paramValue) { 727 checkArgument(BY_VALUE.containsKey(paramValue)); 728 return BY_VALUE.get(paramValue); 729 } 730 731 public static Collection<String> possibleValues() { 732 return BY_VALUE.keySet(); 733 } 734 } 735 736 @Immutable 737 class Param { 738 public static final String TEXT_QUERY = "q"; 739 public static final String PAGE = "p"; 740 public static final String PAGE_SIZE = "ps"; 741 public static final String FIELDS = "f"; 742 public static final String SORT = "s"; 743 public static final String ASCENDING = "asc"; 744 public static final String FACETS = "facets"; 745 public static final String SELECTED = "selected"; 746 747 private final String key; 748 private final String since; 749 private final String deprecatedSince; 750 private final String deprecatedKey; 751 private final String description; 752 private final String exampleValue; 753 private final String defaultValue; 754 private final boolean required; 755 private final Set<String> possibleValues; 756 757 protected Param(Action action, NewParam newParam) { 758 this.key = newParam.key; 759 this.since = newParam.since; 760 this.deprecatedSince = newParam.deprecatedSince; 761 this.deprecatedKey = newParam.deprecatedKey; 762 this.description = newParam.description; 763 this.exampleValue = newParam.exampleValue; 764 this.defaultValue = newParam.defaultValue; 765 this.required = newParam.required; 766 this.possibleValues = newParam.possibleValues; 767 if (required && defaultValue != null) { 768 throw new IllegalArgumentException(format("Default value must not be set on parameter '%s?%s' as it's marked as required", action, key)); 769 } 770 } 771 772 public String key() { 773 return key; 774 } 775 776 /** 777 * @since 5.3 778 */ 779 @CheckForNull 780 public String since() { 781 return since; 782 } 783 784 /** 785 * @since 5.3 786 */ 787 @CheckForNull 788 public String deprecatedSince() { 789 return deprecatedSince; 790 } 791 792 /** 793 * @since 5.0 794 */ 795 @CheckForNull 796 public String deprecatedKey() { 797 return deprecatedKey; 798 } 799 800 @CheckForNull 801 public String description() { 802 return description; 803 } 804 805 /** 806 * @since 4.4 807 */ 808 @CheckForNull 809 public String exampleValue() { 810 return exampleValue; 811 } 812 813 /** 814 * Is the parameter required or optional ? 815 * 816 * @since 4.4 817 */ 818 public boolean isRequired() { 819 return required; 820 } 821 822 /** 823 * @since 4.4 824 */ 825 @CheckForNull 826 public Set<String> possibleValues() { 827 return possibleValues; 828 } 829 830 /** 831 * @since 4.4 832 */ 833 @CheckForNull 834 public String defaultValue() { 835 return defaultValue; 836 } 837 838 @Override 839 public String toString() { 840 return key; 841 } 842 } 843 844 /** 845 * Executed once at server startup. 846 */ 847 @Override 848 void define(Context context); 849 850}