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.Charsets;
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 org.apache.commons.io.FilenameUtils;
028import org.apache.commons.io.IOUtils;
029import org.apache.commons.lang.StringUtils;
030import org.sonar.api.ServerExtension;
031
032import javax.annotation.CheckForNull;
033import javax.annotation.Nullable;
034import javax.annotation.concurrent.Immutable;
035import java.io.IOException;
036import java.net.URL;
037import java.util.Arrays;
038import java.util.Collection;
039import java.util.List;
040import java.util.Map;
041import java.util.Set;
042
043/**
044 * Defines a web service. Note that contrary to the deprecated {@link org.sonar.api.web.Webservice}
045 * the ws is fully implemented in Java and does not require any Ruby on Rails code.
046 * <p/>
047 * <p/>
048 * The classes implementing this extension point must be declared in {@link org.sonar.api.SonarPlugin#getExtensions()}.
049 * <p/>
050 * <h3>How to use</h3>
051 * <pre>
052 * public class HelloWs implements WebService {
053 *   {@literal @}Override
054 *   public void define(Context context) {
055 *     NewController controller = context.createController("api/hello");
056 *     controller.setDescription("Web service example");
057 *
058 *     // create the URL /api/hello/show
059 *     controller.createAction("show")
060 *       .setDescription("Entry point")
061 *       .setHandler(new RequestHandler() {
062 *         {@literal @}Override
063 *         public void handle(Request request, Response response) {
064 *           // read request parameters and generates response output
065 *           response.newJsonWriter()
066 *             .prop("hello", request.mandatoryParam("key"))
067 *             .close();
068 *         }
069 *      })
070 *      .createParam("key").setDescription("Example key").setRequired(true);
071 *
072 *    // important to apply changes
073 *    controller.done();
074 *   }
075 * }
076 * </pre>
077 * <h3>How to test</h3>
078 * <pre>
079 * public class HelloWsTest {
080 *   WebService ws = new HelloWs();
081 *
082 *   {@literal @}Test
083 *   public void should_define_ws() throws Exception {
084 *     // WsTester is available in the Maven artifact org.codehaus.sonar:sonar-testing-harness
085 *     WsTester tester = new WsTester(ws);
086 *     WebService.Controller controller = tester.controller("api/hello");
087 *     assertThat(controller).isNotNull();
088 *     assertThat(controller.path()).isEqualTo("api/hello");
089 *     assertThat(controller.description()).isNotEmpty();
090 *     assertThat(controller.actions()).hasSize(1);
091 *
092 *     WebService.Action show = controller.action("show");
093 *     assertThat(show).isNotNull();
094 *     assertThat(show.key()).isEqualTo("show");
095 *     assertThat(index.handler()).isNotNull();
096 *   }
097 * }
098 * </pre>
099 *
100 * @since 4.2
101 */
102public interface WebService extends ServerExtension {
103
104  class Context {
105    private final Map<String, Controller> controllers = Maps.newHashMap();
106
107    /**
108     * Create a new controller.
109     * <p/>
110     * Structure of request URL is <code>http://&lt;server&gt;/&lt>controller path&gt;/&lt;action path&gt;?&lt;parameters&gt;</code>.
111     *
112     * @param path the controller path must not start or end with "/". It is recommended to start with "api/"
113     *             and to use lower-case format with underscores, for example "api/coding_rules". Usual actions
114     *             are "search", "list", "show", "create" and "delete"
115     */
116    public NewController createController(String path) {
117      return new NewController(this, path);
118    }
119
120    private void register(NewController newController) {
121      if (controllers.containsKey(newController.path)) {
122        throw new IllegalStateException(
123          String.format("The web service '%s' is defined multiple times", newController.path)
124        );
125      }
126      controllers.put(newController.path, new Controller(newController));
127    }
128
129    @CheckForNull
130    public Controller controller(String key) {
131      return controllers.get(key);
132    }
133
134    public List<Controller> controllers() {
135      return ImmutableList.copyOf(controllers.values());
136    }
137  }
138
139  class NewController {
140    private final Context context;
141    private final String path;
142    private String description, since;
143    private final Map<String, NewAction> actions = Maps.newHashMap();
144
145    private NewController(Context context, String path) {
146      if (StringUtils.isBlank(path)) {
147        throw new IllegalArgumentException("WS controller path must not be empty");
148      }
149      if (StringUtils.startsWith(path, "/") || StringUtils.endsWith(path, "/")) {
150        throw new IllegalArgumentException("WS controller path must not start or end with slash: " + path);
151      }
152      this.context = context;
153      this.path = path;
154    }
155
156    /**
157     * Important - this method must be called in order to apply changes and make the
158     * controller available in {@link org.sonar.api.server.ws.WebService.Context#controllers()}
159     */
160    public void done() {
161      context.register(this);
162    }
163
164    /**
165     * Optional description (accept HTML)
166     */
167    public NewController setDescription(@Nullable String s) {
168      this.description = s;
169      return this;
170    }
171
172    /**
173     * Optional version when the controller was created
174     */
175    public NewController setSince(@Nullable String s) {
176      this.since = s;
177      return this;
178    }
179
180    public NewAction createAction(String actionKey) {
181      if (actions.containsKey(actionKey)) {
182        throw new IllegalStateException(
183          String.format("The action '%s' is defined multiple times in the web service '%s'", actionKey, path)
184        );
185      }
186      NewAction action = new NewAction(actionKey);
187      actions.put(actionKey, action);
188      return action;
189    }
190  }
191
192  @Immutable
193  class Controller {
194    private final String path, description, since;
195    private final Map<String, Action> actions;
196
197    private Controller(NewController newController) {
198      if (newController.actions.isEmpty()) {
199        throw new IllegalStateException(
200          String.format("At least one action must be declared in the web service '%s'", newController.path)
201        );
202      }
203      this.path = newController.path;
204      this.description = newController.description;
205      this.since = newController.since;
206      ImmutableMap.Builder<String, Action> mapBuilder = ImmutableMap.builder();
207      for (NewAction newAction : newController.actions.values()) {
208        mapBuilder.put(newAction.key, new Action(this, newAction));
209      }
210      this.actions = mapBuilder.build();
211    }
212
213    public String path() {
214      return path;
215    }
216
217    @CheckForNull
218    public String description() {
219      return description;
220    }
221
222    @CheckForNull
223    public String since() {
224      return since;
225    }
226
227    @CheckForNull
228    public Action action(String actionKey) {
229      return actions.get(actionKey);
230    }
231
232    public Collection<Action> actions() {
233      return actions.values();
234    }
235
236    /**
237     * Returns true if all the actions are for internal use
238     *
239     * @see org.sonar.api.server.ws.WebService.Action#isInternal()
240     * @since 4.3
241     */
242    public boolean isInternal() {
243      for (Action action : actions()) {
244        if (!action.isInternal()) {
245          return false;
246        }
247      }
248      return true;
249    }
250  }
251
252  class NewAction {
253    private final String key;
254    private String description, since;
255    private boolean post = false, isInternal = false;
256    private RequestHandler handler;
257    private Map<String, NewParam> newParams = Maps.newHashMap();
258    private URL responseExample = null;
259
260    private NewAction(String key) {
261      this.key = key;
262    }
263
264    public NewAction setDescription(@Nullable String s) {
265      this.description = s;
266      return this;
267    }
268
269    public NewAction setSince(@Nullable String s) {
270      this.since = s;
271      return this;
272    }
273
274    public NewAction setPost(boolean b) {
275      this.post = b;
276      return this;
277    }
278
279    public NewAction setInternal(boolean b) {
280      this.isInternal = b;
281      return this;
282    }
283
284    public NewAction setHandler(RequestHandler h) {
285      this.handler = h;
286      return this;
287    }
288
289    /**
290     * Link to the document containing an example of response. Content must be UTF-8 encoded.
291     * <p/>
292     * Example:
293     * <pre>
294     *   newAction.setResponseExample(getClass().getResource("/org/sonar/my-ws-response-example.json"));
295     * </pre>
296     *
297     * @since 4.4
298     */
299    public NewAction setResponseExample(@Nullable URL url) {
300      this.responseExample = url;
301      return this;
302    }
303
304    public NewParam createParam(String paramKey) {
305      if (newParams.containsKey(paramKey)) {
306        throw new IllegalStateException(
307          String.format("The parameter '%s' is defined multiple times in the action '%s'", paramKey, key)
308        );
309      }
310      NewParam newParam = new NewParam(paramKey);
311      newParams.put(paramKey, newParam);
312      return newParam;
313    }
314
315    /**
316     * @deprecated since 4.4. Use {@link #createParam(String paramKey)} instead.
317     */
318    @Deprecated
319    public NewAction createParam(String paramKey, @Nullable String description) {
320      createParam(paramKey).setDescription(description);
321      return this;
322    }
323  }
324
325  @Immutable
326  class Action {
327    private final String key, path, description, since;
328    private final boolean post, isInternal;
329    private final RequestHandler handler;
330    private final Map<String, Param> params;
331    private final URL responseExample;
332
333    private Action(Controller controller, NewAction newAction) {
334      this.key = newAction.key;
335      this.path = String.format("%s/%s", controller.path(), key);
336      this.description = newAction.description;
337      this.since = StringUtils.defaultIfBlank(newAction.since, controller.since);
338      this.post = newAction.post;
339      this.isInternal = newAction.isInternal;
340      this.responseExample = newAction.responseExample;
341
342      if (newAction.handler == null) {
343        throw new IllegalArgumentException("RequestHandler is not set on action " + path);
344      }
345      this.handler = newAction.handler;
346
347      ImmutableMap.Builder<String, Param> mapBuilder = ImmutableMap.builder();
348      for (NewParam newParam : newAction.newParams.values()) {
349        mapBuilder.put(newParam.key, new Param(newParam));
350      }
351      this.params = mapBuilder.build();
352    }
353
354    public String key() {
355      return key;
356    }
357
358    public String path() {
359      return path;
360    }
361
362    @CheckForNull
363    public String description() {
364      return description;
365    }
366
367    /**
368     * Set if different than controller.
369     */
370    @CheckForNull
371    public String since() {
372      return since;
373    }
374
375    public boolean isPost() {
376      return post;
377    }
378
379    public boolean isInternal() {
380      return isInternal;
381    }
382
383    public RequestHandler handler() {
384      return handler;
385    }
386
387    /**
388     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
389     */
390    @CheckForNull
391    public URL responseExample() {
392      return responseExample;
393    }
394
395    /**
396     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
397     */
398    @CheckForNull
399    public String responseExampleAsString() {
400      try {
401        if (responseExample != null) {
402          return StringUtils.trim(IOUtils.toString(responseExample, Charsets.UTF_8));
403        }
404        return null;
405      } catch (IOException e) {
406        throw new IllegalStateException("Fail to load " + responseExample, e);
407      }
408    }
409
410    /**
411     * @see org.sonar.api.server.ws.WebService.NewAction#setResponseExample(java.net.URL)
412     */
413    @CheckForNull
414    public String responseExampleFormat() {
415      if (responseExample != null) {
416        return StringUtils.lowerCase(FilenameUtils.getExtension(responseExample.getFile()));
417      }
418      return null;
419    }
420
421    @CheckForNull
422    public Param param(String key) {
423      return params.get(key);
424    }
425
426    public Collection<Param> params() {
427      return params.values();
428    }
429
430    @Override
431    public String toString() {
432      return path;
433    }
434  }
435
436  class NewParam {
437    private String key, description, exampleValue, defaultValue;
438    private boolean required = false;
439    private Set<String> possibleValues = null;
440
441    private NewParam(String key) {
442      this.key = key;
443    }
444
445    public NewParam setDescription(@Nullable String s) {
446      this.description = s;
447      return this;
448    }
449
450    /**
451     * Is the parameter required or optional ? Default value is false (optional).
452     *
453     * @since 4.4
454     */
455    public NewParam setRequired(boolean b) {
456      this.required = b;
457      return this;
458    }
459
460    /**
461     * @since 4.4
462     */
463    public NewParam setExampleValue(@Nullable Object s) {
464      this.exampleValue = (s != null ? s.toString() : null);
465      return this;
466    }
467
468    /**
469     * Exhaustive list of possible values when it makes sense, for example
470     * list of severities.
471     *
472     * @since 4.4
473     */
474    public NewParam setPossibleValues(@Nullable Object... values) {
475      return setPossibleValues(values == null ? (Collection) null : Arrays.asList(values));
476    }
477
478    /**
479     * @since 4.4
480     */
481    public NewParam setBooleanPossibleValues() {
482      return setPossibleValues("true", "false");
483    }
484
485    /**
486     * Exhaustive list of possible values when it makes sense, for example
487     * list of severities.
488     *
489     * @since 4.4
490     */
491    public NewParam setPossibleValues(@Nullable Collection values) {
492      if (values == null) {
493        this.possibleValues = null;
494      } else {
495        this.possibleValues = Sets.newLinkedHashSet();
496        for (Object value : values) {
497          this.possibleValues.add(value.toString());
498        }
499      }
500      return this;
501    }
502
503    /**
504     * @since 4.4
505     */
506    public NewParam setDefaultValue(@Nullable Object o) {
507      this.defaultValue = (o != null ? o.toString() : null);
508      return this;
509    }
510
511    @Override
512    public String toString() {
513      return key;
514    }
515  }
516
517  @Immutable
518  class Param {
519    private final String key, description, exampleValue, defaultValue;
520    private final boolean required;
521    private final Set<String> possibleValues;
522
523    public Param(NewParam newParam) {
524      this.key = newParam.key;
525      this.description = newParam.description;
526      this.exampleValue = newParam.exampleValue;
527      this.defaultValue = newParam.defaultValue;
528      this.required = newParam.required;
529      this.possibleValues = newParam.possibleValues;
530    }
531
532    public String key() {
533      return key;
534    }
535
536    @CheckForNull
537    public String description() {
538      return description;
539    }
540
541    /**
542     * @since 4.4
543     */
544    @CheckForNull
545    public String exampleValue() {
546      return exampleValue;
547    }
548
549    /**
550     * Is the parameter required or optional ?
551     *
552     * @since 4.4
553     */
554    public boolean isRequired() {
555      return required;
556    }
557
558    /**
559     * @since 4.4
560     */
561    @CheckForNull
562    public Set<String> possibleValues() {
563      return possibleValues;
564    }
565
566    /**
567     * @since 4.4
568     */
569    @CheckForNull
570    public String defaultValue() {
571      return defaultValue;
572    }
573
574    @Override
575    public String toString() {
576      return key;
577    }
578  }
579
580  /**
581   * Executed once at server startup.
582   */
583  void define(Context context);
584
585}