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