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