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 */ 020 package org.sonar.api.server.ws; 021 022 import com.google.common.base.Charsets; 023 import com.google.common.collect.ImmutableList; 024 import com.google.common.collect.ImmutableMap; 025 import com.google.common.collect.Maps; 026 import com.google.common.collect.Sets; 027 import org.apache.commons.io.FilenameUtils; 028 import org.apache.commons.io.IOUtils; 029 import org.apache.commons.lang.StringUtils; 030 import org.sonar.api.ServerExtension; 031 032 import javax.annotation.CheckForNull; 033 import javax.annotation.Nullable; 034 import javax.annotation.concurrent.Immutable; 035 import java.io.IOException; 036 import java.net.URL; 037 import java.util.Arrays; 038 import java.util.Collection; 039 import java.util.List; 040 import java.util.Map; 041 import java.util.Set; 042 043 /** 044 * Defines a web service. Note that contrary to the deprecated {@link org.sonar.api.web.Webservice} 045 * the ws is fully implemented in Java and does not require any Ruby on Rails code. 046 * <p/> 047 * <p/> 048 * The classes implementing this extension point must be declared in {@link org.sonar.api.SonarPlugin#getExtensions()}. 049 * <p/> 050 * <h3>How to use</h3> 051 * <pre> 052 * public class HelloWs implements WebService { 053 * {@literal @}Override 054 * public void define(Context context) { 055 * NewController controller = context.createController("api/hello"); 056 * controller.setDescription("Web service example"); 057 * 058 * // create the URL /api/hello/show 059 * controller.createAction("show") 060 * .setDescription("Entry point") 061 * .setHandler(new RequestHandler() { 062 * {@literal @}Override 063 * public void handle(Request request, Response response) { 064 * // read request parameters and generates response output 065 * response.newJsonWriter() 066 * .prop("hello", request.mandatoryParam("key")) 067 * .close(); 068 * } 069 * }) 070 * .createParam("key").setDescription("Example key").setRequired(true); 071 * 072 * // important to apply changes 073 * controller.done(); 074 * } 075 * } 076 * </pre> 077 * <h3>How to test</h3> 078 * <pre> 079 * public class HelloWsTest { 080 * WebService ws = new HelloWs(); 081 * 082 * {@literal @}Test 083 * public void should_define_ws() throws Exception { 084 * // WsTester is available in the Maven artifact org.codehaus.sonar:sonar-testing-harness 085 * WsTester tester = new WsTester(ws); 086 * WebService.Controller controller = tester.controller("api/hello"); 087 * assertThat(controller).isNotNull(); 088 * assertThat(controller.path()).isEqualTo("api/hello"); 089 * assertThat(controller.description()).isNotEmpty(); 090 * assertThat(controller.actions()).hasSize(1); 091 * 092 * WebService.Action show = controller.action("show"); 093 * assertThat(show).isNotNull(); 094 * assertThat(show.key()).isEqualTo("show"); 095 * assertThat(index.handler()).isNotNull(); 096 * } 097 * } 098 * </pre> 099 * 100 * @since 4.2 101 */ 102 public interface WebService extends ServerExtension { 103 104 class Context { 105 private final Map<String, Controller> controllers = Maps.newHashMap(); 106 107 /** 108 * Create a new controller. 109 * <p/> 110 * Structure of request URL is <code>http://<server>/<>controller path>/<action path>?<parameters></code>. 111 * 112 * @param path the controller path must not start or end with "/". It is recommended to start with "api/" 113 * and to use lower-case format with underscores, for example "api/coding_rules". Usual actions 114 * are "search", "list", "show", "create" and "delete" 115 */ 116 public NewController createController(String path) { 117 return new NewController(this, path); 118 } 119 120 private void register(NewController newController) { 121 if (controllers.containsKey(newController.path)) { 122 throw new IllegalStateException( 123 String.format("The web service '%s' is defined multiple times", newController.path) 124 ); 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 } 186 NewAction action = new NewAction(actionKey); 187 actions.put(actionKey, action); 188 return action; 189 } 190 } 191 192 @Immutable 193 class Controller { 194 private final String path, description, since; 195 private final Map<String, Action> actions; 196 197 private Controller(NewController newController) { 198 if (newController.actions.isEmpty()) { 199 throw new IllegalStateException( 200 String.format("At least one action must be declared in the web service '%s'", newController.path) 201 ); 202 } 203 this.path = newController.path; 204 this.description = newController.description; 205 this.since = newController.since; 206 ImmutableMap.Builder<String, Action> mapBuilder = ImmutableMap.builder(); 207 for (NewAction newAction : newController.actions.values()) { 208 mapBuilder.put(newAction.key, new Action(this, newAction)); 209 } 210 this.actions = mapBuilder.build(); 211 } 212 213 public String path() { 214 return path; 215 } 216 217 @CheckForNull 218 public String description() { 219 return description; 220 } 221 222 @CheckForNull 223 public String since() { 224 return since; 225 } 226 227 @CheckForNull 228 public Action action(String actionKey) { 229 return actions.get(actionKey); 230 } 231 232 public Collection<Action> actions() { 233 return actions.values(); 234 } 235 236 /** 237 * Returns true if all the actions are for internal use 238 * 239 * @see org.sonar.api.server.ws.WebService.Action#isInternal() 240 * @since 4.3 241 */ 242 public boolean isInternal() { 243 for (Action action : actions()) { 244 if (!action.isInternal()) { 245 return false; 246 } 247 } 248 return true; 249 } 250 } 251 252 class NewAction { 253 private final String key; 254 private String description, since; 255 private boolean post = false, isInternal = false; 256 private RequestHandler handler; 257 private Map<String, NewParam> newParams = Maps.newHashMap(); 258 private URL responseExample = null; 259 260 private NewAction(String key) { 261 this.key = key; 262 } 263 264 public NewAction setDescription(@Nullable String s) { 265 this.description = s; 266 return this; 267 } 268 269 public NewAction setSince(@Nullable String s) { 270 this.since = s; 271 return this; 272 } 273 274 public NewAction setPost(boolean b) { 275 this.post = b; 276 return this; 277 } 278 279 public NewAction setInternal(boolean b) { 280 this.isInternal = b; 281 return this; 282 } 283 284 public NewAction setHandler(RequestHandler h) { 285 this.handler = h; 286 return this; 287 } 288 289 /** 290 * Link to the document containing an example of response. Content must be UTF-8 encoded. 291 * <p/> 292 * Example: 293 * <pre> 294 * newAction.setResponseExample(getClass().getResource("/org/sonar/my-ws-response-example.json")); 295 * </pre> 296 * 297 * @since 4.4 298 */ 299 public NewAction setResponseExample(@Nullable URL url) { 300 this.responseExample = url; 301 return this; 302 } 303 304 public NewParam createParam(String paramKey) { 305 if (newParams.containsKey(paramKey)) { 306 throw new IllegalStateException( 307 String.format("The parameter '%s' is defined multiple times in the action '%s'", paramKey, key) 308 ); 309 } 310 NewParam newParam = new NewParam(paramKey); 311 newParams.put(paramKey, newParam); 312 return newParam; 313 } 314 315 /** 316 * @deprecated since 4.4. Use {@link #createParam(String paramKey)} instead. 317 */ 318 @Deprecated 319 public NewAction createParam(String paramKey, @Nullable String description) { 320 createParam(paramKey).setDescription(description); 321 return this; 322 } 323 } 324 325 @Immutable 326 class Action { 327 private final String key, path, description, since; 328 private final boolean post, isInternal; 329 private final RequestHandler handler; 330 private final Map<String, Param> params; 331 private final URL responseExample; 332 333 private Action(Controller controller, NewAction newAction) { 334 this.key = newAction.key; 335 this.path = String.format("%s/%s", controller.path(), key); 336 this.description = newAction.description; 337 this.since = StringUtils.defaultIfBlank(newAction.since, controller.since); 338 this.post = newAction.post; 339 this.isInternal = newAction.isInternal; 340 this.responseExample = newAction.responseExample; 341 342 if (newAction.handler == null) { 343 throw new IllegalArgumentException("RequestHandler is not set on action " + path); 344 } 345 this.handler = newAction.handler; 346 347 ImmutableMap.Builder<String, Param> mapBuilder = ImmutableMap.builder(); 348 for (NewParam newParam : newAction.newParams.values()) { 349 mapBuilder.put(newParam.key, new Param(newParam)); 350 } 351 this.params = mapBuilder.build(); 352 } 353 354 public String key() { 355 return key; 356 } 357 358 public String path() { 359 return path; 360 } 361 362 @CheckForNull 363 public String description() { 364 return description; 365 } 366 367 /** 368 * Set if different than controller. 369 */ 370 @CheckForNull 371 public String since() { 372 return since; 373 } 374 375 public boolean isPost() { 376 return post; 377 } 378 379 public boolean isInternal() { 380 return isInternal; 381 } 382 383 public RequestHandler handler() { 384 return handler; 385 } 386 387 /** 388 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 389 */ 390 @CheckForNull 391 public URL responseExample() { 392 return responseExample; 393 } 394 395 /** 396 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 397 */ 398 @CheckForNull 399 public String responseExampleAsString() { 400 try { 401 if (responseExample != null) { 402 return StringUtils.trim(IOUtils.toString(responseExample, Charsets.UTF_8)); 403 } 404 return null; 405 } catch (IOException e) { 406 throw new IllegalStateException("Fail to load " + responseExample, e); 407 } 408 } 409 410 /** 411 * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL) 412 */ 413 @CheckForNull 414 public String responseExampleFormat() { 415 if (responseExample != null) { 416 return StringUtils.lowerCase(FilenameUtils.getExtension(responseExample.getFile())); 417 } 418 return null; 419 } 420 421 @CheckForNull 422 public Param param(String key) { 423 return params.get(key); 424 } 425 426 public Collection<Param> params() { 427 return params.values(); 428 } 429 430 @Override 431 public String toString() { 432 return path; 433 } 434 } 435 436 class NewParam { 437 private String key, description, exampleValue, defaultValue; 438 private boolean required = false; 439 private Set<String> possibleValues = null; 440 441 private NewParam(String key) { 442 this.key = key; 443 } 444 445 public NewParam setDescription(@Nullable String s) { 446 this.description = s; 447 return this; 448 } 449 450 /** 451 * Is the parameter required or optional ? Default value is false (optional). 452 * 453 * @since 4.4 454 */ 455 public NewParam setRequired(boolean b) { 456 this.required = b; 457 return this; 458 } 459 460 /** 461 * @since 4.4 462 */ 463 public NewParam setExampleValue(@Nullable Object s) { 464 this.exampleValue = (s != null ? s.toString() : null); 465 return this; 466 } 467 468 /** 469 * Exhaustive list of possible values when it makes sense, for example 470 * list of severities. 471 * 472 * @since 4.4 473 */ 474 public NewParam setPossibleValues(@Nullable Object... values) { 475 return setPossibleValues(values == null ? (Collection) null : Arrays.asList(values)); 476 } 477 478 /** 479 * @since 4.4 480 */ 481 public NewParam setBooleanPossibleValues() { 482 return setPossibleValues("true", "false"); 483 } 484 485 /** 486 * Exhaustive list of possible values when it makes sense, for example 487 * list of severities. 488 * 489 * @since 4.4 490 */ 491 public NewParam setPossibleValues(@Nullable Collection values) { 492 if (values == null) { 493 this.possibleValues = null; 494 } else { 495 this.possibleValues = Sets.newLinkedHashSet(); 496 for (Object value : values) { 497 this.possibleValues.add(value.toString()); 498 } 499 } 500 return this; 501 } 502 503 /** 504 * @since 4.4 505 */ 506 public NewParam setDefaultValue(@Nullable Object o) { 507 this.defaultValue = (o != null ? o.toString() : null); 508 return this; 509 } 510 511 @Override 512 public String toString() { 513 return key; 514 } 515 } 516 517 @Immutable 518 class Param { 519 private final String key, description, exampleValue, defaultValue; 520 private final boolean required; 521 private final Set<String> possibleValues; 522 523 public Param(NewParam newParam) { 524 this.key = newParam.key; 525 this.description = newParam.description; 526 this.exampleValue = newParam.exampleValue; 527 this.defaultValue = newParam.defaultValue; 528 this.required = newParam.required; 529 this.possibleValues = newParam.possibleValues; 530 } 531 532 public String key() { 533 return key; 534 } 535 536 @CheckForNull 537 public String description() { 538 return description; 539 } 540 541 /** 542 * @since 4.4 543 */ 544 @CheckForNull 545 public String exampleValue() { 546 return exampleValue; 547 } 548 549 /** 550 * Is the parameter required or optional ? 551 * 552 * @since 4.4 553 */ 554 public boolean isRequired() { 555 return required; 556 } 557 558 /** 559 * @since 4.4 560 */ 561 @CheckForNull 562 public Set<String> possibleValues() { 563 return possibleValues; 564 } 565 566 /** 567 * @since 4.4 568 */ 569 @CheckForNull 570 public String defaultValue() { 571 return defaultValue; 572 } 573 574 @Override 575 public String toString() { 576 return key; 577 } 578 } 579 580 /** 581 * Executed once at server startup. 582 */ 583 void define(Context context); 584 585 }