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