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