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://&lt;server&gt;/&lt>controller path&gt;/&lt;action path&gt;?&lt;parameters&gt;</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}