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.Function;
023import com.google.common.base.Joiner;
024import com.google.common.collect.ImmutableList;
025import com.google.common.collect.ImmutableMap;
026import com.google.common.collect.Maps;
027import com.google.common.collect.Sets;
028import java.io.IOException;
029import java.net.URL;
030import java.nio.charset.StandardCharsets;
031import java.util.Arrays;
032import java.util.Collection;
033import java.util.Collections;
034import java.util.List;
035import java.util.Map;
036import java.util.Set;
037import javax.annotation.CheckForNull;
038import javax.annotation.Nonnull;
039import javax.annotation.Nullable;
040import javax.annotation.concurrent.Immutable;
041import org.apache.commons.io.FilenameUtils;
042import org.apache.commons.io.IOUtils;
043import org.apache.commons.lang.StringUtils;
044import org.sonar.api.ExtensionPoint;
045import org.sonar.api.server.ServerSide;
046import org.sonar.api.utils.log.Logger;
047import org.sonar.api.utils.log.Loggers;
048
049import static com.google.common.base.Preconditions.checkArgument;
050import static com.google.common.base.Preconditions.checkState;
051import static com.google.common.base.Strings.isNullOrEmpty;
052import static java.lang.String.format;
053
054/**
055 * Defines a web service. Note that contrary to the deprecated {@link org.sonar.api.web.Webservice}
056 * the ws is fully implemented in Java and does not require any Ruby on Rails code.
057 * <p/>
058 * <p/>
059 * The classes implementing this extension point must be declared in {@link org.sonar.api.SonarPlugin#getExtensions()}.
060 * <p/>
061 * <h3>How to use</h3>
062 * <pre>
063 * public class HelloWs implements WebService {
064 *   {@literal @}Override
065 *   public void define(Context context) {
066 *     NewController controller = context.createController("api/hello");
067 *     controller.setDescription("Web service example");
068 *
069 *     // create the URL /api/hello/show
070 *     controller.createAction("show")
071 *       .setDescription("Entry point")
072 *       .setHandler(new RequestHandler() {
073 *         {@literal @}Override
074 *         public void handle(Request request, Response response) {
075 *           // read request parameters and generates response output
076 *           response.newJsonWriter()
077 *             .beginObject()
078 *             .prop("hello", request.mandatoryParam("key"))
079 *             .endObject()
080 *             .close();
081 *         }
082 *      })
083 *      .createParam("key").setDescription("Example key").setRequired(true);
084 *
085 *    // important to apply changes
086 *    controller.done();
087 *   }
088 * }
089 * </pre>
090 * <h3>How to test</h3>
091 * <pre>
092 * public class HelloWsTest {
093 *   WebService ws = new HelloWs();
094 *
095 *   {@literal @}Test
096 *   public void should_define_ws() throws Exception {
097 *     // WsTester is available in the Maven artifact org.codehaus.sonar:sonar-testing-harness
098 *     WsTester tester = new WsTester(ws);
099 *     WebService.Controller controller = tester.controller("api/hello");
100 *     assertThat(controller).isNotNull();
101 *     assertThat(controller.path()).isEqualTo("api/hello");
102 *     assertThat(controller.description()).isNotEmpty();
103 *     assertThat(controller.actions()).hasSize(1);
104 *
105 *     WebService.Action show = controller.action("show");
106 *     assertThat(show).isNotNull();
107 *     assertThat(show.key()).isEqualTo("show");
108 *     assertThat(index.handler()).isNotNull();
109 *   }
110 * }
111 * </pre>
112 *
113 * @since 4.2
114 */
115@ServerSide
116@ExtensionPoint
117public interface WebService extends Definable<WebService.Context> {
118
119  class Context {
120    private final Map<String, Controller> controllers = Maps.newHashMap();
121
122    /**
123     * Create a new controller.
124     * <p/>
125     * Structure of request URL is <code>http://&lt;server&gt;/&lt>controller path&gt;/&lt;action path&gt;?&lt;parameters&gt;</code>.
126     *
127     * @param path the controller path must not start or end with "/". It is recommended to start with "api/"
128     *             and to use lower-case format with underscores, for example "api/coding_rules". Usual actions
129     *             are "search", "list", "show", "create" and "delete".
130     *             the plural form is recommended - ex: api/projects
131     */
132    public NewController createController(String path) {
133      return new NewController(this, path);
134    }
135
136    private void register(NewController newController) {
137      if (controllers.containsKey(newController.path)) {
138        throw new IllegalStateException(
139          format("The web service '%s' is defined multiple times", newController.path));
140      }
141      controllers.put(newController.path, new Controller(newController));
142    }
143
144    @CheckForNull
145    public Controller controller(String key) {
146      return controllers.get(key);
147    }
148
149    public List<Controller> controllers() {
150      return ImmutableList.copyOf(controllers.values());
151    }
152  }
153
154  class NewController {
155    private final Context context;
156    private final String path;
157    private String description;
158    private String since;
159    private final Map<String, NewAction> actions = Maps.newHashMap();
160
161    private NewController(Context context, String path) {
162      if (StringUtils.isBlank(path)) {
163        throw new IllegalArgumentException("WS controller path must not be empty");
164      }
165      if (StringUtils.startsWith(path, "/") || StringUtils.endsWith(path, "/")) {
166        throw new IllegalArgumentException("WS controller path must not start or end with slash: " + path);
167      }
168      this.context = context;
169      this.path = path;
170    }
171
172    /**
173     * Important - this method must be called in order to apply changes and make the
174     * controller available in {@link org.sonar.api.server.ws.WebService.Context#controllers()}
175     */
176    public void done() {
177      context.register(this);
178    }
179
180    /**
181     * Optional description (accept HTML)
182     */
183    public NewController setDescription(@Nullable String s) {
184      this.description = s;
185      return this;
186    }
187
188    /**
189     * Optional version when the controller was created
190     */
191    public NewController setSince(@Nullable String s) {
192      this.since = s;
193      return this;
194    }
195
196    public NewAction createAction(String actionKey) {
197      if (actions.containsKey(actionKey)) {
198        throw new IllegalStateException(
199          format("The action '%s' is defined multiple times in the web service '%s'", actionKey, path));
200      }
201      NewAction action = new NewAction(actionKey);
202      actions.put(actionKey, action);
203      return action;
204    }
205  }
206
207  @Immutable
208  class Controller {
209    private final String path;
210    private final String description;
211    private final String since;
212    private final Map<String, Action> actions;
213
214    private Controller(NewController newController) {
215      checkState(!newController.actions.isEmpty(), format("At least one action must be declared in the web service '%s'", newController.path));
216      this.path = newController.path;
217      this.description = newController.description;
218      this.since = newController.since;
219      ImmutableMap.Builder<String, Action> mapBuilder = ImmutableMap.builder();
220      for (NewAction newAction : newController.actions.values()) {
221        mapBuilder.put(newAction.key, new Action(this, newAction));
222      }
223      this.actions = mapBuilder.build();
224    }
225
226    public String path() {
227      return path;
228    }
229
230    @CheckForNull
231    public String description() {
232      return description;
233    }
234
235    @CheckForNull
236    public String since() {
237      return since;
238    }
239
240    @CheckForNull
241    public Action action(String actionKey) {
242      return actions.get(actionKey);
243    }
244
245    public Collection<Action> actions() {
246      return actions.values();
247    }
248
249    /**
250     * Returns true if all the actions are for internal use
251     *
252     * @see org.sonar.api.server.ws.WebService.Action#isInternal()
253     * @since 4.3
254     */
255    public boolean isInternal() {
256      for (Action action : actions()) {
257        if (!action.isInternal()) {
258          return false;
259        }
260      }
261      return true;
262    }
263  }
264
265  class NewAction {
266    private final String key;
267    private String deprecatedKey;
268    private String description;
269    private String since;
270    private String deprecatedSince;
271    private boolean post = false;
272    private boolean isInternal = false;
273    private RequestHandler handler;
274    private Map<String, NewParam> newParams = Maps.newHashMap();
275    private URL responseExample = null;
276
277    private NewAction(String key) {
278      this.key = key;
279    }
280
281    public NewAction setDeprecatedKey(@Nullable String s) {
282      this.deprecatedKey = s;
283      return this;
284    }
285
286    public NewAction setDescription(@Nullable String s) {
287      this.description = s;
288      return this;
289    }
290
291    public NewAction setSince(@Nullable String s) {
292      this.since = s;
293      return this;
294    }
295
296    public NewAction setDeprecatedSince(@Nullable String deprecatedSince) {
297      this.deprecatedSince = deprecatedSince;
298      return this;
299    }
300
301    public NewAction setPost(boolean b) {
302      this.post = b;
303      return this;
304    }
305
306    public NewAction setInternal(boolean b) {
307      this.isInternal = b;
308      return this;
309    }
310
311    public NewAction setHandler(RequestHandler h) {
312      this.handler = h;
313      return this;
314    }
315
316    /**
317     * Link to the document containing an example of response. Content must be UTF-8 encoded.
318     * <p/>
319     * Example:
320     * <pre>
321     *   newAction.setResponseExample(getClass().getResource("/org/sonar/my-ws-response-example.json"));
322     * </pre>
323     *
324     * @since 4.4
325     */
326    public NewAction setResponseExample(@Nullable URL url) {
327      this.responseExample = url;
328      return this;
329    }
330
331    public NewParam createParam(String paramKey) {
332      checkState(!newParams.containsKey(paramKey),
333        format("The parameter '%s' is defined multiple times in the action '%s'", paramKey, key));
334      NewParam newParam = new NewParam(paramKey);
335      newParams.put(paramKey, newParam);
336      return newParam;
337    }
338
339    /**
340     * @deprecated since 4.4. Use {@link #createParam(String paramKey)} instead.
341     */
342    @Deprecated
343    public NewAction createParam(String paramKey, @Nullable String description) {
344      createParam(paramKey).setDescription(description);
345      return this;
346    }
347
348    /**
349     * Add predefined parameters related to pagination of results.
350     */
351    public NewAction addPagingParams(int defaultPageSize) {
352      createParam(Param.PAGE)
353        .setDescription("1-based page number")
354        .setExampleValue("42")
355        .setDeprecatedKey("pageIndex")
356        .setDefaultValue("1");
357
358      createParam(Param.PAGE_SIZE)
359        .setDescription("Page size. Must be greater than 0.")
360        .setExampleValue("20")
361        .setDeprecatedKey("pageSize")
362        .setDefaultValue(String.valueOf(defaultPageSize));
363      return this;
364    }
365
366    /**
367     * Add predefined parameters related to pagination of results with a maximum page size.
368     * Note the maximum is a documentation only feature. It does not check anything.
369     */
370    public NewAction addPagingParams(int defaultPageSize, int maxPageSize) {
371      createParam(Param.PAGE)
372        .setDescription("1-based page number")
373        .setExampleValue("42")
374        .setDeprecatedKey("pageIndex")
375        .setDefaultValue("1");
376
377      createParam(Param.PAGE_SIZE)
378        .setDescription("Page size. Must be greater than 0 and less than " + maxPageSize)
379        .setExampleValue("20")
380        .setDeprecatedKey("pageSize")
381        .setDefaultValue(String.valueOf(defaultPageSize));
382      return this;
383    }
384
385    /**
386     * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#FIELDS}, which is
387     * used to restrict the number of fields returned in JSON response.
388     */
389    public NewAction addFieldsParam(Collection<?> possibleValues) {
390      createParam(Param.FIELDS)
391        .setDescription("Comma-separated list of the fields to be returned in response. All the fields are returned by default.")
392        .setPossibleValues(possibleValues);
393      return this;
394    }
395
396    /**$
397     *
398     * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#TEXT_QUERY}, which is
399     * used to search for a subset of fields containing the supplied string.<br />
400     * The fields must be in the <strong>plural</strong> form (ex: "names", "keys")
401     */
402    public NewAction addSearchQuery(String exampleValue, String... pluralFields) {
403      String actionDescription = format("Limit search to %s that contain the supplied string.", Joiner.on(" or ").join(pluralFields));
404      createParam(Param.TEXT_QUERY)
405        .setDescription(actionDescription)
406        .setExampleValue(exampleValue);
407      return this;
408    }
409
410    /**
411     * Add predefined parameters related to sorting of results.
412     */
413    public <V> NewAction addSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) {
414      createParam(Param.SORT)
415        .setDescription("Sort field")
416        .setDeprecatedKey("sort")
417        .setDefaultValue(defaultValue)
418        .setPossibleValues(possibleValues);
419
420      createParam(Param.ASCENDING)
421        .setDescription("Ascending sort")
422        .setBooleanPossibleValues()
423        .setDefaultValue(defaultAscending);
424      return this;
425    }
426
427    /**
428     * Add 'selected=(selected|deselected|all)' for select-list oriented WS.
429     */
430    public NewAction addSelectionModeParam() {
431      createParam(Param.SELECTED)
432        .setDescription("Depending on the value, show only selected items (selected=selected), deselected items (selected=deselected), " +
433          "or all items with their selection status (selected=all).")
434        .setDefaultValue(SelectionMode.SELECTED.value())
435        .setPossibleValues(SelectionMode.possibleValues());
436      return this;
437    }
438  }
439
440  @Immutable
441  class Action {
442    private static final Logger LOGGER = Loggers.get(Action.class);
443
444    private final String key;
445    private final String deprecatedKey;
446    private final String path;
447    private final String description;
448    private final String since;
449    private final String deprecatedSince;
450    private final boolean post;
451    private final boolean isInternal;
452    private final RequestHandler handler;
453    private final Map<String, Param> params;
454    private final URL responseExample;
455
456    private Action(Controller controller, NewAction newAction) {
457      this.key = newAction.key;
458      this.deprecatedKey = newAction.deprecatedKey;
459      this.path = format("%s/%s", controller.path(), key);
460      this.description = newAction.description;
461      this.since = newAction.since;
462      this.deprecatedSince = newAction.deprecatedSince;
463      this.post = newAction.post;
464      this.isInternal = newAction.isInternal;
465      this.responseExample = newAction.responseExample;
466      this.handler = newAction.handler;
467
468      checkState(this.handler != null, "RequestHandler is not set on action " + path);
469      logWarningIf(isNullOrEmpty(this.description), "Description?is not set on action " + path);
470      logWarningIf(isNullOrEmpty(this.since), "Since is not set on action " + path);
471      logWarningIf(!this.post && this.responseExample == null, "The response example is not set on action " + path);
472
473      ImmutableMap.Builder<String, Param> paramsBuilder = ImmutableMap.builder();
474      for (NewParam newParam : newAction.newParams.values()) {
475        paramsBuilder.put(newParam.key, new Param(this, newParam));
476      }
477      this.params = paramsBuilder.build();
478    }
479
480    private static void logWarningIf(boolean condition, String message) {
481      if (condition) {
482        LOGGER.warn(message);
483      }
484    }
485
486    public String key() {
487      return key;
488    }
489
490    public String deprecatedKey() {
491      return deprecatedKey;
492    }
493
494    public String path() {
495      return path;
496    }
497
498    @CheckForNull
499    public String description() {
500      return description;
501    }
502
503    /**
504     * Set if different than controller.
505     */
506    @CheckForNull
507    public String since() {
508      return since;
509    }
510
511    @CheckForNull
512    public String deprecatedSince() {
513      return deprecatedSince;
514    }
515
516    public boolean isPost() {
517      return post;
518    }
519
520    public boolean isInternal() {
521      return isInternal;
522    }
523
524    public RequestHandler handler() {
525      return handler;
526    }
527
528    /**
529     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
530     */
531    @CheckForNull
532    public URL responseExample() {
533      return responseExample;
534    }
535
536    /**
537     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
538     */
539    @CheckForNull
540    public String responseExampleAsString() {
541      try {
542        if (responseExample != null) {
543          return StringUtils.trim(IOUtils.toString(responseExample, StandardCharsets.UTF_8));
544        }
545        return null;
546      } catch (IOException e) {
547        throw new IllegalStateException("Fail to load " + responseExample, e);
548      }
549    }
550
551    /**
552     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
553     */
554    @CheckForNull
555    public String responseExampleFormat() {
556      if (responseExample != null) {
557        return StringUtils.lowerCase(FilenameUtils.getExtension(responseExample.getFile()));
558      }
559      return null;
560    }
561
562    @CheckForNull
563    public Param param(String key) {
564      return params.get(key);
565    }
566
567    public Collection<Param> params() {
568      return params.values();
569    }
570
571    @Override
572    public String toString() {
573      return path;
574    }
575  }
576
577  class NewParam {
578    private String key;
579    private String since;
580    private String deprecatedSince;
581    private String deprecatedKey;
582    private String description;
583    private String exampleValue;
584    private String defaultValue;
585    private boolean required = false;
586    private Set<String> possibleValues = null;
587
588    private NewParam(String key) {
589      this.key = key;
590    }
591
592    public NewParam setSince(@Nullable String since) {
593      this.since = since;
594      return this;
595    }
596
597    public NewParam setDeprecatedSince(@Nullable String deprecatedSince) {
598      this.deprecatedSince = deprecatedSince;
599      return this;
600    }
601
602    /**
603     * @since 5.0
604     */
605    public NewParam setDeprecatedKey(@Nullable String s) {
606      this.deprecatedKey = s;
607      return this;
608    }
609
610    public NewParam setDescription(@Nullable String s) {
611      this.description = s;
612      return this;
613    }
614
615    /**
616     * Is the parameter required or optional ? Default value is false (optional).
617     *
618     * @since 4.4
619     */
620    public NewParam setRequired(boolean b) {
621      this.required = b;
622      return this;
623    }
624
625    /**
626     * @since 4.4
627     */
628    public NewParam setExampleValue(@Nullable Object s) {
629      this.exampleValue = ((s != null) ? s.toString() : null);
630      return this;
631    }
632
633    /**
634     * Exhaustive list of possible values when it makes sense, for example
635     * list of severities.
636     *
637     * @since 4.4
638     */
639    public NewParam setPossibleValues(@Nullable Object... values) {
640      return setPossibleValues(values == null ? Collections.emptyList() : Arrays.asList(values));
641    }
642
643    /**
644     * @since 4.4
645     */
646    public NewParam setBooleanPossibleValues() {
647      return setPossibleValues("true", "false", "yes", "no");
648    }
649
650    /**
651     * Exhaustive list of possible values when it makes sense, for example
652     * list of severities.
653     *
654     * @since 4.4
655     */
656    public NewParam setPossibleValues(@Nullable Collection<?> values) {
657      if (values == null || values.isEmpty()) {
658        this.possibleValues = null;
659      } else {
660        this.possibleValues = Sets.newLinkedHashSet();
661        for (Object value : values) {
662          this.possibleValues.add(value.toString());
663        }
664      }
665      return this;
666    }
667
668    /**
669     * @since 4.4
670     */
671    public NewParam setDefaultValue(@Nullable Object o) {
672      this.defaultValue = ((o != null) ? o.toString() : null);
673      return this;
674    }
675
676    @Override
677    public String toString() {
678      return key;
679    }
680  }
681
682  enum SelectionMode {
683    SELECTED("selected"), DESELECTED("deselected"), ALL("all");
684
685    private final String paramValue;
686
687    private static final Map<String, SelectionMode> BY_VALUE = Maps.uniqueIndex(Arrays.asList(values()), new Function<SelectionMode, String>() {
688      @Override
689      public String apply(@Nonnull SelectionMode input) {
690        return input.paramValue;
691      }
692    });
693
694    private SelectionMode(String paramValue) {
695      this.paramValue = paramValue;
696    }
697
698    public String value() {
699      return paramValue;
700    }
701
702    public static SelectionMode fromParam(String paramValue) {
703      checkArgument(BY_VALUE.containsKey(paramValue));
704      return BY_VALUE.get(paramValue);
705    }
706
707    public static Collection<String> possibleValues() {
708      return BY_VALUE.keySet();
709    }
710  }
711
712  @Immutable
713  class Param {
714    public static final String TEXT_QUERY = "q";
715    public static final String PAGE = "p";
716    public static final String PAGE_SIZE = "ps";
717    public static final String FIELDS = "f";
718    public static final String SORT = "s";
719    public static final String ASCENDING = "asc";
720    public static final String FACETS = "facets";
721    public static final String SELECTED = "selected";
722
723    private final String key;
724    private final String since;
725    private final String deprecatedSince;
726    private final String deprecatedKey;
727    private final String description;
728    private final String exampleValue;
729    private final String defaultValue;
730    private final boolean required;
731    private final Set<String> possibleValues;
732
733    protected Param(Action action, NewParam newParam) {
734      this.key = newParam.key;
735      this.since = newParam.since;
736      this.deprecatedSince = newParam.deprecatedSince;
737      this.deprecatedKey = newParam.deprecatedKey;
738      this.description = newParam.description;
739      this.exampleValue = newParam.exampleValue;
740      this.defaultValue = newParam.defaultValue;
741      this.required = newParam.required;
742      this.possibleValues = newParam.possibleValues;
743      if (required && defaultValue != null) {
744        throw new IllegalArgumentException(format("Default value must not be set on parameter '%s?%s' as it's marked as required", action, key));
745      }
746    }
747
748    public String key() {
749      return key;
750    }
751
752    /**
753     * @since 5.3
754     */
755    @CheckForNull
756    public String since() {
757      return since;
758    }
759
760    /**
761     * @since 5.3
762     */
763    @CheckForNull
764    public String deprecatedSince() {
765      return deprecatedSince;
766    }
767
768    /**
769     * @since 5.0
770     */
771    @CheckForNull
772    public String deprecatedKey() {
773      return deprecatedKey;
774    }
775
776    @CheckForNull
777    public String description() {
778      return description;
779    }
780
781    /**
782     * @since 4.4
783     */
784    @CheckForNull
785    public String exampleValue() {
786      return exampleValue;
787    }
788
789    /**
790     * Is the parameter required or optional ?
791     *
792     * @since 4.4
793     */
794    public boolean isRequired() {
795      return required;
796    }
797
798    /**
799     * @since 4.4
800     */
801    @CheckForNull
802    public Set<String> possibleValues() {
803      return possibleValues;
804    }
805
806    /**
807     * @since 4.4
808     */
809    @CheckForNull
810    public String defaultValue() {
811      return defaultValue;
812    }
813
814    @Override
815    public String toString() {
816      return key;
817    }
818  }
819
820  /**
821   * Executed once at server startup.
822   */
823  @Override
824  void define(Context context);
825
826}