001/*
002 * SonarQube
003 * Copyright (C) 2009-2016 SonarSource SA
004 * mailto:contact AT sonarsource DOT com
005 *
006 * This program 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 * This program 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      createFieldsParam(possibleValues);
391      return this;
392    }
393
394    public NewParam createFieldsParam(Collection<?> possibleValues) {
395      return createParam(Param.FIELDS)
396        .setDescription("Comma-separated list of the fields to be returned in response. All the fields are returned by default.")
397        .setPossibleValues(possibleValues);
398    }
399
400    /**$
401     *
402     * Creates the parameter {@link org.sonar.api.server.ws.WebService.Param#TEXT_QUERY}, which is
403     * used to search for a subset of fields containing the supplied string.<br />
404     * The fields must be in the <strong>plural</strong> form (ex: "names", "keys")
405     */
406    public NewAction addSearchQuery(String exampleValue, String... pluralFields) {
407      String actionDescription = format("Limit search to %s that contain the supplied string.", Joiner.on(" or ").join(pluralFields));
408      createParam(Param.TEXT_QUERY)
409        .setDescription(actionDescription)
410        .setExampleValue(exampleValue);
411      return this;
412    }
413
414    /**
415     * Add predefined parameters related to sorting of results.
416     */
417    public <V> NewAction addSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) {
418      createSortParams(possibleValues, defaultValue, defaultAscending);
419      return this;
420    }
421
422    /**
423     * Add predefined parameters related to sorting of results.
424     */
425    public <V> NewParam createSortParams(Collection<V> possibleValues, @Nullable V defaultValue, boolean defaultAscending) {
426      createParam(Param.ASCENDING)
427        .setDescription("Ascending sort")
428        .setBooleanPossibleValues()
429        .setDefaultValue(defaultAscending);
430
431      return createParam(Param.SORT)
432        .setDescription("Sort field")
433        .setDeprecatedKey("sort")
434        .setDefaultValue(defaultValue)
435        .setPossibleValues(possibleValues);
436    }
437
438    /**
439     * Add 'selected=(selected|deselected|all)' for select-list oriented WS.
440     */
441    public NewAction addSelectionModeParam() {
442      createParam(Param.SELECTED)
443        .setDescription("Depending on the value, show only selected items (selected=selected), deselected items (selected=deselected), " +
444          "or all items with their selection status (selected=all).")
445        .setDefaultValue(SelectionMode.SELECTED.value())
446        .setPossibleValues(SelectionMode.possibleValues());
447      return this;
448    }
449  }
450
451  @Immutable
452  class Action {
453    private static final Logger LOGGER = Loggers.get(Action.class);
454
455    private final String key;
456    private final String deprecatedKey;
457    private final String path;
458    private final String description;
459    private final String since;
460    private final String deprecatedSince;
461    private final boolean post;
462    private final boolean isInternal;
463    private final RequestHandler handler;
464    private final Map<String, Param> params;
465    private final URL responseExample;
466
467    private Action(Controller controller, NewAction newAction) {
468      this.key = newAction.key;
469      this.deprecatedKey = newAction.deprecatedKey;
470      this.path = format("%s/%s", controller.path(), key);
471      this.description = newAction.description;
472      this.since = newAction.since;
473      this.deprecatedSince = newAction.deprecatedSince;
474      this.post = newAction.post;
475      this.isInternal = newAction.isInternal;
476      this.responseExample = newAction.responseExample;
477      this.handler = newAction.handler;
478
479      checkState(this.handler != null, "RequestHandler is not set on action " + path);
480      logWarningIf(isNullOrEmpty(this.description), "Description is not set on action " + path);
481      logWarningIf(isNullOrEmpty(this.since), "Since is not set on action " + path);
482      logWarningIf(!this.post && this.responseExample == null, "The response example is not set on action " + path);
483
484      ImmutableMap.Builder<String, Param> paramsBuilder = ImmutableMap.builder();
485      for (NewParam newParam : newAction.newParams.values()) {
486        paramsBuilder.put(newParam.key, new Param(this, newParam));
487      }
488      this.params = paramsBuilder.build();
489    }
490
491    private static void logWarningIf(boolean condition, String message) {
492      if (condition) {
493        LOGGER.warn(message);
494      }
495    }
496
497    public String key() {
498      return key;
499    }
500
501    public String deprecatedKey() {
502      return deprecatedKey;
503    }
504
505    public String path() {
506      return path;
507    }
508
509    @CheckForNull
510    public String description() {
511      return description;
512    }
513
514    /**
515     * Set if different than controller.
516     */
517    @CheckForNull
518    public String since() {
519      return since;
520    }
521
522    @CheckForNull
523    public String deprecatedSince() {
524      return deprecatedSince;
525    }
526
527    public boolean isPost() {
528      return post;
529    }
530
531    public boolean isInternal() {
532      return isInternal;
533    }
534
535    public RequestHandler handler() {
536      return handler;
537    }
538
539    /**
540     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
541     */
542    @CheckForNull
543    public URL responseExample() {
544      return responseExample;
545    }
546
547    /**
548     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
549     */
550    @CheckForNull
551    public String responseExampleAsString() {
552      try {
553        if (responseExample != null) {
554          return StringUtils.trim(IOUtils.toString(responseExample, StandardCharsets.UTF_8));
555        }
556        return null;
557      } catch (IOException e) {
558        throw new IllegalStateException("Fail to load " + responseExample, e);
559      }
560    }
561
562    /**
563     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
564     */
565    @CheckForNull
566    public String responseExampleFormat() {
567      if (responseExample != null) {
568        return StringUtils.lowerCase(FilenameUtils.getExtension(responseExample.getFile()));
569      }
570      return null;
571    }
572
573    @CheckForNull
574    public Param param(String key) {
575      return params.get(key);
576    }
577
578    public Collection<Param> params() {
579      return params.values();
580    }
581
582    @Override
583    public String toString() {
584      return path;
585    }
586  }
587
588  class NewParam {
589    private String key;
590    private String since;
591    private String deprecatedSince;
592    private String deprecatedKey;
593    private String description;
594    private String exampleValue;
595    private String defaultValue;
596    private boolean required = false;
597    private Set<String> possibleValues = null;
598
599    private NewParam(String key) {
600      this.key = key;
601    }
602
603    public NewParam setSince(@Nullable String since) {
604      this.since = since;
605      return this;
606    }
607
608    public NewParam setDeprecatedSince(@Nullable String deprecatedSince) {
609      this.deprecatedSince = deprecatedSince;
610      return this;
611    }
612
613    /**
614     * @since 5.0
615     */
616    public NewParam setDeprecatedKey(@Nullable String s) {
617      this.deprecatedKey = s;
618      return this;
619    }
620
621    public NewParam setDescription(@Nullable String s) {
622      this.description = s;
623      return this;
624    }
625
626    /**
627     * Is the parameter required or optional ? Default value is false (optional).
628     *
629     * @since 4.4
630     */
631    public NewParam setRequired(boolean b) {
632      this.required = b;
633      return this;
634    }
635
636    /**
637     * @since 4.4
638     */
639    public NewParam setExampleValue(@Nullable Object s) {
640      this.exampleValue = (s != null) ? s.toString() : null;
641      return this;
642    }
643
644    /**
645     * Exhaustive list of possible values when it makes sense, for example
646     * list of severities.
647     *
648     * @since 4.4
649     */
650    public NewParam setPossibleValues(@Nullable Object... values) {
651      return setPossibleValues(values == null ? Collections.emptyList() : Arrays.asList(values));
652    }
653
654    /**
655     * @since 4.4
656     */
657    public NewParam setBooleanPossibleValues() {
658      return setPossibleValues("true", "false", "yes", "no");
659    }
660
661    /**
662     * Exhaustive list of possible values when it makes sense, for example
663     * list of severities.
664     *
665     * @since 4.4
666     */
667    public NewParam setPossibleValues(@Nullable Collection<?> values) {
668      if (values == null || values.isEmpty()) {
669        this.possibleValues = null;
670      } else {
671        this.possibleValues = Sets.newLinkedHashSet();
672        for (Object value : values) {
673          this.possibleValues.add(value.toString());
674        }
675      }
676      return this;
677    }
678
679    /**
680     * @since 4.4
681     */
682    public NewParam setDefaultValue(@Nullable Object o) {
683      this.defaultValue = (o != null) ? o.toString() : null;
684      return this;
685    }
686
687    @Override
688    public String toString() {
689      return key;
690    }
691  }
692
693  enum SelectionMode {
694    SELECTED("selected"), DESELECTED("deselected"), ALL("all");
695
696    private final String paramValue;
697
698    private static final Map<String, SelectionMode> BY_VALUE = Maps.uniqueIndex(Arrays.asList(values()), new Function<SelectionMode, String>() {
699      @Override
700      public String apply(@Nonnull SelectionMode input) {
701        return input.paramValue;
702      }
703    });
704
705    private SelectionMode(String paramValue) {
706      this.paramValue = paramValue;
707    }
708
709    public String value() {
710      return paramValue;
711    }
712
713    public static SelectionMode fromParam(String paramValue) {
714      checkArgument(BY_VALUE.containsKey(paramValue));
715      return BY_VALUE.get(paramValue);
716    }
717
718    public static Collection<String> possibleValues() {
719      return BY_VALUE.keySet();
720    }
721  }
722
723  @Immutable
724  class Param {
725    public static final String TEXT_QUERY = "q";
726    public static final String PAGE = "p";
727    public static final String PAGE_SIZE = "ps";
728    public static final String FIELDS = "f";
729    public static final String SORT = "s";
730    public static final String ASCENDING = "asc";
731    public static final String FACETS = "facets";
732    public static final String SELECTED = "selected";
733
734    private final String key;
735    private final String since;
736    private final String deprecatedSince;
737    private final String deprecatedKey;
738    private final String description;
739    private final String exampleValue;
740    private final String defaultValue;
741    private final boolean required;
742    private final Set<String> possibleValues;
743
744    protected Param(Action action, NewParam newParam) {
745      this.key = newParam.key;
746      this.since = newParam.since;
747      this.deprecatedSince = newParam.deprecatedSince;
748      this.deprecatedKey = newParam.deprecatedKey;
749      this.description = newParam.description;
750      this.exampleValue = newParam.exampleValue;
751      this.defaultValue = newParam.defaultValue;
752      this.required = newParam.required;
753      this.possibleValues = newParam.possibleValues;
754      if (required && defaultValue != null) {
755        throw new IllegalArgumentException(format("Default value must not be set on parameter '%s?%s' as it's marked as required", action, key));
756      }
757    }
758
759    public String key() {
760      return key;
761    }
762
763    /**
764     * @since 5.3
765     */
766    @CheckForNull
767    public String since() {
768      return since;
769    }
770
771    /**
772     * @since 5.3
773     */
774    @CheckForNull
775    public String deprecatedSince() {
776      return deprecatedSince;
777    }
778
779    /**
780     * @since 5.0
781     */
782    @CheckForNull
783    public String deprecatedKey() {
784      return deprecatedKey;
785    }
786
787    @CheckForNull
788    public String description() {
789      return description;
790    }
791
792    /**
793     * @since 4.4
794     */
795    @CheckForNull
796    public String exampleValue() {
797      return exampleValue;
798    }
799
800    /**
801     * Is the parameter required or optional ?
802     *
803     * @since 4.4
804     */
805    public boolean isRequired() {
806      return required;
807    }
808
809    /**
810     * @since 4.4
811     */
812    @CheckForNull
813    public Set<String> possibleValues() {
814      return possibleValues;
815    }
816
817    /**
818     * @since 4.4
819     */
820    @CheckForNull
821    public String defaultValue() {
822      return defaultValue;
823    }
824
825    @Override
826    public String toString() {
827      return key;
828    }
829  }
830
831  /**
832   * Executed once at server startup.
833   */
834  @Override
835  void define(Context context);
836
837}