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 */
020package org.sonar.api.server.ws;
021
022import com.google.common.collect.ImmutableList;
023import com.google.common.collect.ImmutableMap;
024import com.google.common.collect.Maps;
025import org.apache.commons.lang.StringUtils;
026import org.sonar.api.ServerExtension;
027
028import javax.annotation.CheckForNull;
029import javax.annotation.Nullable;
030import javax.annotation.concurrent.Immutable;
031import java.util.Collection;
032import java.util.List;
033import 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 */
095public 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}