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 }