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