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