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