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