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    import java.io.IOException;
036    import java.net.URL;
037    import java.util.Arrays;
038    import java.util.Collection;
039    import java.util.List;
040    import java.util.Map;
041    import 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     */
102    public 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    }