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