001/* 002 * SonarQube, open source software quality management tool. 003 * Copyright (C) 2008-2014 SonarSource 004 * mailto:contact AT sonarsource DOT com 005 * 006 * SonarQube 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 * SonarQube 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 createParam(Param.FIELDS) 391 .setDescription("Comma-separated list of the fields to be returned in response. All the fields are returned by default.") 392 .setPossibleValues(possibleValues); 393 return this; 394 } 395 396 /**$ 397 * 398 * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#TEXT_QUERY}, which is 399 * used to search for a subset of fields containing the supplied string.<br /> 400 * The fields must be in the <strong>plural</strong> form (ex: "names", "keys") 401 */ 402 public NewAction addSearchQuery(String exampleValue, String... pluralFields) { 403 String actionDescription = format("Limit search to %s that contain the supplied string.", Joiner.on(" or ").join(pluralFields)); 404 createParam(Param.TEXT_QUERY) 405 .setDescription(actionDescription) 406 .setExampleValue(exampleValue); 407 return this; 408 } 409 410 /** 411 * Add predefined parameters related to sorting of results. 412 */ 413 public <V> NewAction addSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) { 414 createParam(Param.SORT) 415 .setDescription("Sort field") 416 .setDeprecatedKey("sort") 417 .setDefaultValue(defaultValue) 418 .setPossibleValues(possibleValues); 419 420 createParam(Param.ASCENDING) 421 .setDescription("Ascending sort") 422 .setBooleanPossibleValues() 423 .setDefaultValue(defaultAscending); 424 return this; 425 } 426 427 /** 428 * Add 'selected=(selected|deselected|all)' for select-list oriented WS. 429 */ 430 public NewAction addSelectionModeParam() { 431 createParam(Param.SELECTED) 432 .setDescription("Depending on the value, show only selected items (selected=selected), deselected items (selected=deselected), " + 433 "or all items with their selection status (selected=all).") 434 .setDefaultValue(SelectionMode.SELECTED.value()) 435 .setPossibleValues(SelectionMode.possibleValues()); 436 return this; 437 } 438 } 439 440 @Immutable 441 class Action { 442 private static final Logger LOGGER = Loggers.get(Action.class); 443 444 private final String key; 445 private final String deprecatedKey; 446 private final String path; 447 private final String description; 448 private final String since; 449 private final String deprecatedSince; 450 private final boolean post; 451 private final boolean isInternal; 452 private final RequestHandler handler; 453 private final Map<String, Param> params; 454 private final URL responseExample; 455 456 private Action(Controller controller, NewAction newAction) { 457 this.key = newAction.key; 458 this.deprecatedKey = newAction.deprecatedKey; 459 this.path = format("%s/%s", controller.path(), key); 460 this.description = newAction.description; 461 this.since = newAction.since; 462 this.deprecatedSince = newAction.deprecatedSince; 463 this.post = newAction.post; 464 this.isInternal = newAction.isInternal; 465 this.responseExample = newAction.responseExample; 466 this.handler = newAction.handler; 467 468 checkState(this.handler != null, "RequestHandler is not set on action " + path); 469 logWarningIf(isNullOrEmpty(this.description), "Description?is not set on action " + path); 470 logWarningIf(isNullOrEmpty(this.since), "Since is not set on action " + path); 471 logWarningIf(!this.post && this.responseExample == null, "The response example is not set on action " + path); 472 473 ImmutableMap.Builder<String, Param> paramsBuilder = ImmutableMap.builder(); 474 for (NewParam newParam : newAction.newParams.values()) { 475 paramsBuilder.put(newParam.key, new Param(this, newParam)); 476 } 477 this.params = paramsBuilder.build(); 478 } 479 480 private static void logWarningIf(boolean condition, String message) { 481 if (condition) { 482 LOGGER.warn(message); 483 } 484 } 485 486 public String key() { 487 return key; 488 } 489 490 public String deprecatedKey() { 491 return deprecatedKey; 492 } 493 494 public String path() { 495 return path; 496 } 497 498 @CheckForNull 499 public String description() { 500 return description; 501 } 502 503 /** 504 * Set if different than controller. 505 */ 506 @CheckForNull 507 public String since() { 508 return since; 509 } 510 511 @CheckForNull 512 public String deprecatedSince() { 513 return deprecatedSince; 514 } 515 516 public boolean isPost() { 517 return post; 518 } 519 520 public boolean isInternal() { 521 return isInternal; 522 } 523 524 public RequestHandler handler() { 525 return handler; 526 } 527 528 /** 529 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 530 */ 531 @CheckForNull 532 public URL responseExample() { 533 return responseExample; 534 } 535 536 /** 537 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 538 */ 539 @CheckForNull 540 public String responseExampleAsString() { 541 try { 542 if (responseExample != null) { 543 return StringUtils.trim(IOUtils.toString(responseExample, StandardCharsets.UTF_8)); 544 } 545 return null; 546 } catch (IOException e) { 547 throw new IllegalStateException("Fail to load " + responseExample, e); 548 } 549 } 550 551 /** 552 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 553 */ 554 @CheckForNull 555 public String responseExampleFormat() { 556 if (responseExample != null) { 557 return StringUtils.lowerCase(FilenameUtils.getExtension(responseExample.getFile())); 558 } 559 return null; 560 } 561 562 @CheckForNull 563 public Param param(String key) { 564 return params.get(key); 565 } 566 567 public Collection<Param> params() { 568 return params.values(); 569 } 570 571 @Override 572 public String toString() { 573 return path; 574 } 575 } 576 577 class NewParam { 578 private String key; 579 private String since; 580 private String deprecatedSince; 581 private String deprecatedKey; 582 private String description; 583 private String exampleValue; 584 private String defaultValue; 585 private boolean required = false; 586 private Set<String> possibleValues = null; 587 588 private NewParam(String key) { 589 this.key = key; 590 } 591 592 public NewParam setSince(@Nullable String since) { 593 this.since = since; 594 return this; 595 } 596 597 public NewParam setDeprecatedSince(@Nullable String deprecatedSince) { 598 this.deprecatedSince = deprecatedSince; 599 return this; 600 } 601 602 /** 603 * @since 5.0 604 */ 605 public NewParam setDeprecatedKey(@Nullable String s) { 606 this.deprecatedKey = s; 607 return this; 608 } 609 610 public NewParam setDescription(@Nullable String s) { 611 this.description = s; 612 return this; 613 } 614 615 /** 616 * Is the parameter required or optional ? Default value is false (optional). 617 * 618 * @since 4.4 619 */ 620 public NewParam setRequired(boolean b) { 621 this.required = b; 622 return this; 623 } 624 625 /** 626 * @since 4.4 627 */ 628 public NewParam setExampleValue(@Nullable Object s) { 629 this.exampleValue = ((s != null) ? s.toString() : null); 630 return this; 631 } 632 633 /** 634 * Exhaustive list of possible values when it makes sense, for example 635 * list of severities. 636 * 637 * @since 4.4 638 */ 639 public NewParam setPossibleValues(@Nullable Object... values) { 640 return setPossibleValues(values == null ? Collections.emptyList() : Arrays.asList(values)); 641 } 642 643 /** 644 * @since 4.4 645 */ 646 public NewParam setBooleanPossibleValues() { 647 return setPossibleValues("true", "false", "yes", "no"); 648 } 649 650 /** 651 * Exhaustive list of possible values when it makes sense, for example 652 * list of severities. 653 * 654 * @since 4.4 655 */ 656 public NewParam setPossibleValues(@Nullable Collection<?> values) { 657 if (values == null || values.isEmpty()) { 658 this.possibleValues = null; 659 } else { 660 this.possibleValues = Sets.newLinkedHashSet(); 661 for (Object value : values) { 662 this.possibleValues.add(value.toString()); 663 } 664 } 665 return this; 666 } 667 668 /** 669 * @since 4.4 670 */ 671 public NewParam setDefaultValue(@Nullable Object o) { 672 this.defaultValue = ((o != null) ? o.toString() : null); 673 return this; 674 } 675 676 @Override 677 public String toString() { 678 return key; 679 } 680 } 681 682 enum SelectionMode { 683 SELECTED("selected"), DESELECTED("deselected"), ALL("all"); 684 685 private final String paramValue; 686 687 private static final Map<String, SelectionMode> BY_VALUE = Maps.uniqueIndex(Arrays.asList(values()), new Function<SelectionMode, String>() { 688 @Override 689 public String apply(@Nonnull SelectionMode input) { 690 return input.paramValue; 691 } 692 }); 693 694 private SelectionMode(String paramValue) { 695 this.paramValue = paramValue; 696 } 697 698 public String value() { 699 return paramValue; 700 } 701 702 public static SelectionMode fromParam(String paramValue) { 703 checkArgument(BY_VALUE.containsKey(paramValue)); 704 return BY_VALUE.get(paramValue); 705 } 706 707 public static Collection<String> possibleValues() { 708 return BY_VALUE.keySet(); 709 } 710 } 711 712 @Immutable 713 class Param { 714 public static final String TEXT_QUERY = "q"; 715 public static final String PAGE = "p"; 716 public static final String PAGE_SIZE = "ps"; 717 public static final String FIELDS = "f"; 718 public static final String SORT = "s"; 719 public static final String ASCENDING = "asc"; 720 public static final String FACETS = "facets"; 721 public static final String SELECTED = "selected"; 722 723 private final String key; 724 private final String since; 725 private final String deprecatedSince; 726 private final String deprecatedKey; 727 private final String description; 728 private final String exampleValue; 729 private final String defaultValue; 730 private final boolean required; 731 private final Set<String> possibleValues; 732 733 protected Param(Action action, NewParam newParam) { 734 this.key = newParam.key; 735 this.since = newParam.since; 736 this.deprecatedSince = newParam.deprecatedSince; 737 this.deprecatedKey = newParam.deprecatedKey; 738 this.description = newParam.description; 739 this.exampleValue = newParam.exampleValue; 740 this.defaultValue = newParam.defaultValue; 741 this.required = newParam.required; 742 this.possibleValues = newParam.possibleValues; 743 if (required && defaultValue != null) { 744 throw new IllegalArgumentException(format("Default value must not be set on parameter '%s?%s' as it's marked as required", action, key)); 745 } 746 } 747 748 public String key() { 749 return key; 750 } 751 752 /** 753 * @since 5.3 754 */ 755 @CheckForNull 756 public String since() { 757 return since; 758 } 759 760 /** 761 * @since 5.3 762 */ 763 @CheckForNull 764 public String deprecatedSince() { 765 return deprecatedSince; 766 } 767 768 /** 769 * @since 5.0 770 */ 771 @CheckForNull 772 public String deprecatedKey() { 773 return deprecatedKey; 774 } 775 776 @CheckForNull 777 public String description() { 778 return description; 779 } 780 781 /** 782 * @since 4.4 783 */ 784 @CheckForNull 785 public String exampleValue() { 786 return exampleValue; 787 } 788 789 /** 790 * Is the parameter required or optional ? 791 * 792 * @since 4.4 793 */ 794 public boolean isRequired() { 795 return required; 796 } 797 798 /** 799 * @since 4.4 800 */ 801 @CheckForNull 802 public Set<String> possibleValues() { 803 return possibleValues; 804 } 805 806 /** 807 * @since 4.4 808 */ 809 @CheckForNull 810 public String defaultValue() { 811 return defaultValue; 812 } 813 814 @Override 815 public String toString() { 816 return key; 817 } 818 } 819 820 /** 821 * Executed once at server startup. 822 */ 823 @Override 824 void define(Context context); 825 826}