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 public NewAction setDescription(@Nullable String s) { 268 this.description = s; 269 return this; 270 } 271 272 public NewAction setSince(@Nullable String s) { 273 this.since = s; 274 return this; 275 } 276 277 public NewAction setDeprecatedSince(@Nullable String deprecatedSince) { 278 this.deprecatedSince = deprecatedSince; 279 return this; 280 } 281 282 public NewAction setPost(boolean b) { 283 this.post = b; 284 return this; 285 } 286 287 public NewAction setInternal(boolean b) { 288 this.isInternal = b; 289 return this; 290 } 291 292 public NewAction setHandler(RequestHandler h) { 293 this.handler = h; 294 return this; 295 } 296 297 /** 298 * Link to the document containing an example of response. Content must be UTF-8 encoded. 299 * <br> 300 * Example: 301 * <pre> 302 * newAction.setResponseExample(getClass().getResource("/org/sonar/my-ws-response-example.json")); 303 * </pre> 304 * 305 * @since 4.4 306 */ 307 public NewAction setResponseExample(@Nullable URL url) { 308 this.responseExample = url; 309 return this; 310 } 311 312 public NewParam createParam(String paramKey) { 313 checkState(!newParams.containsKey(paramKey), 314 format("The parameter '%s' is defined multiple times in the action '%s'", paramKey, key)); 315 NewParam newParam = new NewParam(paramKey); 316 newParams.put(paramKey, newParam); 317 return newParam; 318 } 319 320 /** 321 * @deprecated since 4.4. Use {@link #createParam(String paramKey)} instead. 322 */ 323 @Deprecated 324 public NewAction createParam(String paramKey, @Nullable String description) { 325 createParam(paramKey).setDescription(description); 326 return this; 327 } 328 329 /** 330 * Add predefined parameters related to pagination of results. 331 */ 332 public NewAction addPagingParams(int defaultPageSize) { 333 createParam(Param.PAGE) 334 .setDescription("1-based page number") 335 .setExampleValue("42") 336 .setDeprecatedKey("pageIndex") 337 .setDefaultValue("1"); 338 339 createParam(Param.PAGE_SIZE) 340 .setDescription("Page size. Must be greater than 0.") 341 .setExampleValue("20") 342 .setDeprecatedKey("pageSize") 343 .setDefaultValue(String.valueOf(defaultPageSize)); 344 return this; 345 } 346 347 /** 348 * Add predefined parameters related to pagination of results with a maximum page size. 349 * Note the maximum is a documentation only feature. It does not check anything. 350 */ 351 public NewAction addPagingParams(int defaultPageSize, int maxPageSize) { 352 addPageParam(); 353 addPageSize(defaultPageSize, maxPageSize); 354 return this; 355 } 356 357 public NewAction addPageParam() { 358 createParam(Param.PAGE) 359 .setDescription("1-based page number") 360 .setExampleValue("42") 361 .setDeprecatedKey("pageIndex") 362 .setDefaultValue("1"); 363 return this; 364 } 365 366 public NewAction addPageSize(int defaultPageSize, int maxPageSize) { 367 createParam(Param.PAGE_SIZE) 368 .setDescription("Page size. Must be greater than 0 and less than " + maxPageSize) 369 .setExampleValue("20") 370 .setDeprecatedKey("pageSize") 371 .setDefaultValue(String.valueOf(defaultPageSize)); 372 return this; 373 } 374 375 /** 376 * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#FIELDS}, which is 377 * used to restrict the number of fields returned in JSON response. 378 */ 379 public NewAction addFieldsParam(Collection<?> possibleValues) { 380 createFieldsParam(possibleValues); 381 return this; 382 } 383 384 public NewParam createFieldsParam(Collection<?> possibleValues) { 385 return createParam(Param.FIELDS) 386 .setDescription("Comma-separated list of the fields to be returned in response. All the fields are returned by default.") 387 .setPossibleValues(possibleValues); 388 } 389 390 /**$ 391 * 392 * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#TEXT_QUERY}, which is 393 * used to search for a subset of fields containing the supplied string. 394 * <p> 395 * The fields must be in the <strong>plural</strong> form (ex: "names", "keys"). 396 * </p> 397 */ 398 public NewAction addSearchQuery(String exampleValue, String... pluralFields) { 399 String actionDescription = format("Limit search to %s that contain the supplied string.", Joiner.on(" or ").join(pluralFields)); 400 createParam(Param.TEXT_QUERY) 401 .setDescription(actionDescription) 402 .setExampleValue(exampleValue); 403 return this; 404 } 405 406 /** 407 * Add predefined parameters related to sorting of results. 408 */ 409 public <V> NewAction addSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) { 410 createSortParams(possibleValues, defaultValue, defaultAscending); 411 return this; 412 } 413 414 /** 415 * Add predefined parameters related to sorting of results. 416 */ 417 public <V> NewParam createSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) { 418 createParam(Param.ASCENDING) 419 .setDescription("Ascending sort") 420 .setBooleanPossibleValues() 421 .setDefaultValue(defaultAscending); 422 423 return createParam(Param.SORT) 424 .setDescription("Sort field") 425 .setDeprecatedKey("sort") 426 .setDefaultValue(defaultValue) 427 .setPossibleValues(possibleValues); 428 } 429 430 /** 431 * Add 'selected=(selected|deselected|all)' for select-list oriented WS. 432 */ 433 public NewAction addSelectionModeParam() { 434 createParam(Param.SELECTED) 435 .setDescription("Depending on the value, show only selected items (selected=selected), deselected items (selected=deselected), " + 436 "or all items with their selection status (selected=all).") 437 .setDefaultValue(SelectionMode.SELECTED.value()) 438 .setPossibleValues(SelectionMode.possibleValues()); 439 return this; 440 } 441 } 442 443 @Immutable 444 class Action { 445 private static final Logger LOGGER = Loggers.get(Action.class); 446 447 private final String key; 448 private final String deprecatedKey; 449 private final String path; 450 private final String description; 451 private final String since; 452 private final String deprecatedSince; 453 private final boolean post; 454 private final boolean isInternal; 455 private final RequestHandler handler; 456 private final Map<String, Param> params; 457 private final URL responseExample; 458 459 private Action(Controller controller, NewAction newAction) { 460 this.key = newAction.key; 461 this.deprecatedKey = newAction.deprecatedKey; 462 this.path = format("%s/%s", controller.path(), key); 463 this.description = newAction.description; 464 this.since = newAction.since; 465 this.deprecatedSince = newAction.deprecatedSince; 466 this.post = newAction.post; 467 this.isInternal = newAction.isInternal; 468 this.responseExample = newAction.responseExample; 469 this.handler = newAction.handler; 470 471 checkState(this.handler != null, "RequestHandler is not set on action " + path); 472 logWarningIf(isNullOrEmpty(this.description), "Description is not set on action " + path); 473 logWarningIf(isNullOrEmpty(this.since), "Since is not set on action " + path); 474 logWarningIf(!this.post && this.responseExample == null, "The response example is not set on action " + path); 475 476 ImmutableMap.Builder<String, Param> paramsBuilder = ImmutableMap.builder(); 477 for (NewParam newParam : newAction.newParams.values()) { 478 paramsBuilder.put(newParam.key, new Param(this, newParam)); 479 } 480 this.params = paramsBuilder.build(); 481 } 482 483 private static void logWarningIf(boolean condition, String message) { 484 if (condition) { 485 LOGGER.warn(message); 486 } 487 } 488 489 public String key() { 490 return key; 491 } 492 493 public String deprecatedKey() { 494 return deprecatedKey; 495 } 496 497 public String path() { 498 return path; 499 } 500 501 @CheckForNull 502 public String description() { 503 return description; 504 } 505 506 /** 507 * Set if different than controller. 508 */ 509 @CheckForNull 510 public String since() { 511 return since; 512 } 513 514 @CheckForNull 515 public String deprecatedSince() { 516 return deprecatedSince; 517 } 518 519 public boolean isPost() { 520 return post; 521 } 522 523 public boolean isInternal() { 524 return isInternal; 525 } 526 527 public RequestHandler handler() { 528 return handler; 529 } 530 531 /** 532 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 533 */ 534 @CheckForNull 535 public URL responseExample() { 536 return responseExample; 537 } 538 539 /** 540 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 541 */ 542 @CheckForNull 543 public String responseExampleAsString() { 544 try { 545 if (responseExample != null) { 546 return StringUtils.trim(IOUtils.toString(responseExample, StandardCharsets.UTF_8)); 547 } 548 return null; 549 } catch (IOException e) { 550 throw new IllegalStateException("Fail to load " + responseExample, e); 551 } 552 } 553 554 /** 555 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 556 */ 557 @CheckForNull 558 public String responseExampleFormat() { 559 if (responseExample != null) { 560 return StringUtils.lowerCase(FilenameUtils.getExtension(responseExample.getFile())); 561 } 562 return null; 563 } 564 565 @CheckForNull 566 public Param param(String key) { 567 return params.get(key); 568 } 569 570 public Collection<Param> params() { 571 return params.values(); 572 } 573 574 @Override 575 public String toString() { 576 return path; 577 } 578 } 579 580 class NewParam { 581 private String key; 582 private String since; 583 private String deprecatedSince; 584 private String deprecatedKey; 585 private String description; 586 private String exampleValue; 587 private String defaultValue; 588 private boolean required = false; 589 private Set<String> possibleValues = null; 590 591 private NewParam(String key) { 592 this.key = key; 593 } 594 595 public NewParam setSince(@Nullable String since) { 596 this.since = since; 597 return this; 598 } 599 600 public NewParam setDeprecatedSince(@Nullable String deprecatedSince) { 601 this.deprecatedSince = deprecatedSince; 602 return this; 603 } 604 605 /** 606 * @since 5.0 607 */ 608 public NewParam setDeprecatedKey(@Nullable String s) { 609 this.deprecatedKey = s; 610 return this; 611 } 612 613 public NewParam setDescription(@Nullable String s) { 614 this.description = s; 615 return this; 616 } 617 618 /** 619 * Is the parameter required or optional ? Default value is false (optional). 620 * 621 * @since 4.4 622 */ 623 public NewParam setRequired(boolean b) { 624 this.required = b; 625 return this; 626 } 627 628 /** 629 * @since 4.4 630 */ 631 public NewParam setExampleValue(@Nullable Object s) { 632 this.exampleValue = (s != null) ? s.toString() : null; 633 return this; 634 } 635 636 /** 637 * Exhaustive list of possible values when it makes sense, for example 638 * list of severities. 639 * 640 * @since 4.4 641 */ 642 public NewParam setPossibleValues(@Nullable Object... values) { 643 return setPossibleValues(values == null ? Collections.emptyList() : Arrays.asList(values)); 644 } 645 646 /** 647 * @since 4.4 648 */ 649 public NewParam setBooleanPossibleValues() { 650 return setPossibleValues("true", "false", "yes", "no"); 651 } 652 653 /** 654 * Exhaustive list of possible values when it makes sense, for example 655 * list of severities. 656 * 657 * @since 4.4 658 */ 659 public NewParam setPossibleValues(@Nullable Collection<?> values) { 660 if (values == null || values.isEmpty()) { 661 this.possibleValues = null; 662 } else { 663 this.possibleValues = Sets.newLinkedHashSet(); 664 for (Object value : values) { 665 this.possibleValues.add(value.toString()); 666 } 667 } 668 return this; 669 } 670 671 /** 672 * @since 4.4 673 */ 674 public NewParam setDefaultValue(@Nullable Object o) { 675 this.defaultValue = (o != null) ? o.toString() : null; 676 return this; 677 } 678 679 @Override 680 public String toString() { 681 return key; 682 } 683 } 684 685 enum SelectionMode { 686 SELECTED("selected"), DESELECTED("deselected"), ALL("all"); 687 688 private final String paramValue; 689 690 private static final Map<String, SelectionMode> BY_VALUE = Maps.uniqueIndex(Arrays.asList(values()), new Function<SelectionMode, String>() { 691 @Override 692 public String apply(@Nonnull SelectionMode input) { 693 return input.paramValue; 694 } 695 }); 696 697 private SelectionMode(String paramValue) { 698 this.paramValue = paramValue; 699 } 700 701 public String value() { 702 return paramValue; 703 } 704 705 public static SelectionMode fromParam(String paramValue) { 706 checkArgument(BY_VALUE.containsKey(paramValue)); 707 return BY_VALUE.get(paramValue); 708 } 709 710 public static Collection<String> possibleValues() { 711 return BY_VALUE.keySet(); 712 } 713 } 714 715 @Immutable 716 class Param { 717 public static final String TEXT_QUERY = "q"; 718 public static final String PAGE = "p"; 719 public static final String PAGE_SIZE = "ps"; 720 public static final String FIELDS = "f"; 721 public static final String SORT = "s"; 722 public static final String ASCENDING = "asc"; 723 public static final String FACETS = "facets"; 724 public static final String SELECTED = "selected"; 725 726 private final String key; 727 private final String since; 728 private final String deprecatedSince; 729 private final String deprecatedKey; 730 private final String description; 731 private final String exampleValue; 732 private final String defaultValue; 733 private final boolean required; 734 private final Set<String> possibleValues; 735 736 protected Param(Action action, NewParam newParam) { 737 this.key = newParam.key; 738 this.since = newParam.since; 739 this.deprecatedSince = newParam.deprecatedSince; 740 this.deprecatedKey = newParam.deprecatedKey; 741 this.description = newParam.description; 742 this.exampleValue = newParam.exampleValue; 743 this.defaultValue = newParam.defaultValue; 744 this.required = newParam.required; 745 this.possibleValues = newParam.possibleValues; 746 if (required && defaultValue != null) { 747 throw new IllegalArgumentException(format("Default value must not be set on parameter '%s?%s' as it's marked as required", action, key)); 748 } 749 } 750 751 public String key() { 752 return key; 753 } 754 755 /** 756 * @since 5.3 757 */ 758 @CheckForNull 759 public String since() { 760 return since; 761 } 762 763 /** 764 * @since 5.3 765 */ 766 @CheckForNull 767 public String deprecatedSince() { 768 return deprecatedSince; 769 } 770 771 /** 772 * @since 5.0 773 */ 774 @CheckForNull 775 public String deprecatedKey() { 776 return deprecatedKey; 777 } 778 779 @CheckForNull 780 public String description() { 781 return description; 782 } 783 784 /** 785 * @since 4.4 786 */ 787 @CheckForNull 788 public String exampleValue() { 789 return exampleValue; 790 } 791 792 /** 793 * Is the parameter required or optional ? 794 * 795 * @since 4.4 796 */ 797 public boolean isRequired() { 798 return required; 799 } 800 801 /** 802 * @since 4.4 803 */ 804 @CheckForNull 805 public Set<String> possibleValues() { 806 return possibleValues; 807 } 808 809 /** 810 * @since 4.4 811 */ 812 @CheckForNull 813 public String defaultValue() { 814 return defaultValue; 815 } 816 817 @Override 818 public String toString() { 819 return key; 820 } 821 } 822 823 /** 824 * Executed once at server startup. 825 */ 826 @Override 827 void define(Context context); 828 829}