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