001    /*
002     * SonarQube, open source software quality management tool.
003     * Copyright (C) 2008-2013 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.collect.ImmutableList;
023    import com.google.common.collect.ImmutableMap;
024    import com.google.common.collect.Maps;
025    import org.apache.commons.lang.StringUtils;
026    import org.sonar.api.ServerExtension;
027    
028    import javax.annotation.CheckForNull;
029    import javax.annotation.Nullable;
030    import javax.annotation.concurrent.Immutable;
031    import java.util.Collection;
032    import java.util.List;
033    import java.util.Map;
034    
035    /**
036     * Defines a web service. Note that contrary to the deprecated {@link org.sonar.api.web.Webservice}
037     * the ws is fully implemented in Java and does not require any Ruby on Rails code.
038     *
039     * <p/>
040     * The classes implementing this extension point must be declared in {@link org.sonar.api.SonarPlugin#getExtensions()}.
041     *
042     * <h2>How to use</h2>
043     * <pre>
044     * public class HelloWs implements WebService {
045     *   @Override
046     *   public void define(Context context) {
047     *     NewController controller = context.createController("api/hello");
048     *     controller.setDescription("Web service example");
049     *
050     *     // create the URL /api/hello/show
051     *     controller.createAction("show")
052     *       .setDescription("Entry point")
053     *       .setHandler(new RequestHandler() {
054     *         @Override
055     *         public void handle(Request request, Response response) {
056     *           // read request parameters and generates response output
057     *           response.newJsonWriter()
058     *             .prop("hello", request.mandatoryParam("key"))
059     *             .close();
060     *         }
061     *      })
062     *      .createParam("key", "Example key");
063     *
064     *    // important to apply changes
065     *    controller.done();
066     *   }
067     * }
068     * </pre>
069     * <h2>How to unit test</h2>
070     * <pre>
071     * public class HelloWsTest {
072     *   WebService ws = new HelloWs();
073     *
074     *   @Test
075     *   public void should_define_ws() throws Exception {
076     *     // WsTester is available in the Maven artifact org.codehaus.sonar:sonar-plugin-api
077     *     // with type "test-jar"
078     *     WsTester tester = new WsTester(ws);
079     *     WebService.Controller controller = tester.controller("api/hello");
080     *     assertThat(controller).isNotNull();
081     *     assertThat(controller.path()).isEqualTo("api/hello");
082     *     assertThat(controller.description()).isNotEmpty();
083     *     assertThat(controller.actions()).hasSize(1);
084     *
085     *     WebService.Action show = controller.action("show");
086     *     assertThat(show).isNotNull();
087     *     assertThat(show.key()).isEqualTo("show");
088     *     assertThat(index.handler()).isNotNull();
089     *   }
090     * }
091     * </pre>
092     *
093     * @since 4.2
094     */
095    public interface WebService extends ServerExtension {
096    
097      class Context {
098        private final Map<String, Controller> controllers = Maps.newHashMap();
099    
100        /**
101         * Create a new controller.
102         * <p/>
103         * Structure of request URL is <code>http://&lt;server&gt;/&lt>controller path&gt;/&lt;action path&gt;?&lt;parameters&gt;</code>.
104         *
105         * @param path the controller path must not start or end with "/". It is recommended to start with "api/"
106         */
107        public NewController createController(String path) {
108          return new NewController(this, path);
109        }
110    
111        private void register(NewController newController) {
112          if (controllers.containsKey(newController.path)) {
113            throw new IllegalStateException(
114              String.format("The web service '%s' is defined multiple times", newController.path)
115            );
116          }
117          controllers.put(newController.path, new Controller(newController));
118        }
119    
120        @CheckForNull
121        public Controller controller(String key) {
122          return controllers.get(key);
123        }
124    
125        public List<Controller> controllers() {
126          return ImmutableList.copyOf(controllers.values());
127        }
128      }
129    
130      class NewController {
131        private final Context context;
132        private final String path;
133        private String description, since;
134        private final Map<String, NewAction> actions = Maps.newHashMap();
135    
136        private NewController(Context context, String path) {
137          if (StringUtils.isBlank(path)) {
138            throw new IllegalArgumentException("WS controller path must not be empty");
139          }
140          if (StringUtils.startsWith(path, "/") || StringUtils.endsWith(path, "/")) {
141            throw new IllegalArgumentException("WS controller path must not start or end with slash: " + path);
142          }
143          this.context = context;
144          this.path = path;
145        }
146    
147        /**
148         * Important - this method must be called in order to apply changes and make the
149         * controller available in {@link org.sonar.api.server.ws.WebService.Context#controllers()}
150         */
151        public void done() {
152          context.register(this);
153        }
154    
155        /**
156         * Optional plain-text description
157         */
158        public NewController setDescription(@Nullable String s) {
159          this.description = s;
160          return this;
161        }
162    
163        /**
164         * Optional version when the controller was created
165         */
166        public NewController setSince(@Nullable String s) {
167          this.since = s;
168          return this;
169        }
170    
171        public NewAction createAction(String actionKey) {
172          if (actions.containsKey(actionKey)) {
173            throw new IllegalStateException(
174              String.format("The action '%s' is defined multiple times in the web service '%s'", actionKey, path)
175            );
176          }
177          NewAction action = new NewAction(actionKey);
178          actions.put(actionKey, action);
179          return action;
180        }
181      }
182    
183      @Immutable
184      class Controller {
185        private final String path, description, since;
186        private final Map<String, Action> actions;
187    
188        private Controller(NewController newController) {
189          if (newController.actions.isEmpty()) {
190            throw new IllegalStateException(
191              String.format("At least one action must be declared in the web service '%s'", newController.path)
192            );
193          }
194          this.path = newController.path;
195          this.description = newController.description;
196          this.since = newController.since;
197          ImmutableMap.Builder<String, Action> mapBuilder = ImmutableMap.builder();
198          for (NewAction newAction : newController.actions.values()) {
199            mapBuilder.put(newAction.key, new Action(this, newAction));
200          }
201          this.actions = mapBuilder.build();
202        }
203    
204        public String path() {
205          return path;
206        }
207    
208        @CheckForNull
209        public String description() {
210          return description;
211        }
212    
213        @CheckForNull
214        public String since() {
215          return since;
216        }
217    
218        @CheckForNull
219        public Action action(String actionKey) {
220          return actions.get(actionKey);
221        }
222    
223        public Collection<Action> actions() {
224          return actions.values();
225        }
226      }
227    
228      class NewAction {
229        private final String key;
230        private String description, since;
231        private boolean post = false, isInternal = false;
232        private RequestHandler handler;
233        private Map<String, NewParam> newParams = Maps.newHashMap();
234    
235        private NewAction(String key) {
236          this.key = key;
237        }
238    
239        public NewAction setDescription(@Nullable String s) {
240          this.description = s;
241          return this;
242        }
243    
244        public NewAction setSince(@Nullable String s) {
245          this.since = s;
246          return this;
247        }
248    
249        public NewAction setPost(boolean b) {
250          this.post = b;
251          return this;
252        }
253    
254        public NewAction setInternal(boolean b) {
255          this.isInternal = b;
256          return this;
257        }
258    
259        public NewAction setHandler(RequestHandler h) {
260          this.handler = h;
261          return this;
262        }
263    
264        public NewParam createParam(String paramKey) {
265          if (newParams.containsKey(paramKey)) {
266            throw new IllegalStateException(
267              String.format("The parameter '%s' is defined multiple times in the action '%s'", paramKey, key)
268            );
269          }
270          NewParam newParam = new NewParam(paramKey);
271          newParams.put(paramKey, newParam);
272          return newParam;
273        }
274    
275        public NewAction createParam(String paramKey, @Nullable String description) {
276          createParam(paramKey).setDescription(description);
277          return this;
278        }
279      }
280    
281      @Immutable
282      class Action {
283        private final String key, path, description, since;
284        private final boolean post, isInternal;
285        private final RequestHandler handler;
286        private final Map<String, Param> params;
287    
288        private Action(Controller controller, NewAction newAction) {
289          this.key = newAction.key;
290          this.path = String.format("%s/%s", controller.path(), key);
291          this.description = newAction.description;
292          this.since = StringUtils.defaultIfBlank(newAction.since, controller.since);
293          this.post = newAction.post;
294          this.isInternal = newAction.isInternal;
295    
296          if (newAction.handler == null) {
297            throw new IllegalStateException("RequestHandler is not set on action " + path);
298          }
299          this.handler = newAction.handler;
300    
301          ImmutableMap.Builder<String, Param> mapBuilder = ImmutableMap.builder();
302          for (NewParam newParam : newAction.newParams.values()) {
303            mapBuilder.put(newParam.key, new Param(newParam));
304          }
305          this.params = mapBuilder.build();
306        }
307    
308        public String key() {
309          return key;
310        }
311    
312        public String path() {
313          return path;
314        }
315    
316        @CheckForNull
317        public String description() {
318          return description;
319        }
320    
321        /**
322         * Set if different than controller.
323         */
324        @CheckForNull
325        public String since() {
326          return since;
327        }
328    
329        public boolean isPost() {
330          return post;
331        }
332    
333        public boolean isInternal() {
334          return isInternal;
335        }
336    
337        public RequestHandler handler() {
338          return handler;
339        }
340    
341        @CheckForNull
342        public Param param(String key) {
343          return params.get(key);
344        }
345    
346        public Collection<Param> params() {
347          return params.values();
348        }
349    
350        @Override
351        public String toString() {
352          return path;
353        }
354      }
355    
356      class NewParam {
357        private String key, description;
358    
359        private NewParam(String key) {
360          this.key = key;
361        }
362    
363        public NewParam setDescription(@Nullable String s) {
364          this.description = s;
365          return this;
366        }
367    
368        @Override
369        public String toString() {
370          return key;
371        }
372      }
373    
374      @Immutable
375      class Param {
376        private final String key, description;
377    
378        public Param(NewParam newParam) {
379          this.key = newParam.key;
380          this.description = newParam.description;
381        }
382    
383        public String key() {
384          return key;
385        }
386    
387        @CheckForNull
388        public String description() {
389          return description;
390        }
391    
392        @Override
393        public String toString() {
394          return key;
395        }
396      }
397    
398      /**
399       * Executed once at server startup.
400       */
401      void define(Context context);
402    
403    }