001package jmri.server.json;
002
003import static jmri.server.json.JSON.FORCE_DELETE;
004import static jmri.server.json.JSON.CONFLICT;
005
006import com.fasterxml.jackson.databind.JsonNode;
007import com.fasterxml.jackson.databind.ObjectMapper;
008import com.fasterxml.jackson.databind.SerializationFeature;
009import com.fasterxml.jackson.databind.node.ArrayNode;
010import com.fasterxml.jackson.databind.node.ObjectNode;
011
012import java.io.IOException;
013import javax.annotation.Nonnull;
014import javax.annotation.CheckForNull;
015import javax.servlet.http.HttpServletResponse;
016
017/**
018 * Provide HTTP method handlers for JSON RESTful messages
019 * <p>
020 * It is recommended that this class be as lightweight as possible, by relying
021 * either on a helper stored in the InstanceManager, or a helper with static
022 * methods.
023 * <h2>Message ID Handling</h2>
024 * <p>
025 * A message ID from a client is a positive integer greater than zero, to be
026 * passed back unchanged to the client so the client can track direct responses
027 * to requests (this is not needed in the RESTful API, but is available in the
028 * RESTful API). The Message ID (or zero if none) is passed into most public
029 * methods of JsonHttpService as the {@code id} parameter. When creating an
030 * object that is to be embedded in another object as a property, it is
031 * permissable to pass the additive inverse of the ID to ensure the ID is not
032 * included in the embedded object, but allow any error messages to be thrown
033 * with the correct message ID.
034 * <p>
035 * Note that to ensure this works, only create a complete object with
036 * {@link #message(String, JsonNode, String, int)} or one of its variants.
037 *
038 * @author Randall Wood
039 */
040public abstract class JsonHttpService {
041
042    protected final ObjectMapper mapper;
043
044    /**
045     * Create an HTTP handler for a JSON service.
046     *
047     * @param mapper the ObjectMapper to create new JSON nodes
048     */
049    protected JsonHttpService(@Nonnull ObjectMapper mapper) {
050        this.mapper = mapper;
051        this.mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
052    }
053
054    /**
055     * Respond to an HTTP GET request for the requested name.
056     * <p>
057     * If name is null, return a list of all objects for the given type, if
058     * appropriate.
059     * <p>
060     * This method should throw a 500 Internal Server Error if type is not
061     * recognized.
062     *
063     * @param type    the type of the requested object
064     * @param name    the system name of the requested object
065     * @param data    JSON data set of attributes of the requested object
066     * @param request the JSON request
067     * @return a JSON description of the requested object
068     * @throws JsonException if the named object does not exist or other error
069     *                       occurs
070     */
071    @Nonnull
072    public abstract JsonNode doGet(@Nonnull String type, @Nonnull String name, @Nonnull JsonNode data,
073            @Nonnull JsonRequest request)
074            throws JsonException;
075
076    /**
077     * Respond to an HTTP POST request for the requested name.
078     * <p>
079     * This method should throw a 400 Invalid Request error if the named object
080     * does not exist.
081     *
082     * @param type    the type of the requested object
083     * @param name    the system name of the requested object
084     * @param data    JSON data set of attributes of the requested object to be
085     *                updated
086     * @param request the JSON request
087     * @return a JSON description of the requested object after updates have
088     *         been applied
089     * @throws JsonException if the named object does not exist or other error
090     *                       occurs
091     */
092    @Nonnull
093    public abstract JsonNode doPost(@Nonnull String type, @Nonnull String name, @Nonnull JsonNode data,
094            @Nonnull JsonRequest request) throws JsonException;
095
096    /**
097     * Respond to an HTTP PUT request for the requested name.
098     * <p>
099     * Throw an HTTP 405 Method Not Allowed exception if new objects of the type
100     * are not intended to be addable.
101     *
102     * @param type    the type of the requested object
103     * @param name    the system name of the requested object
104     * @param data    JSON data set of attributes of the requested object to be
105     *                created or updated
106     * @param request the JSON request
107     * @return a JSON description of the requested object
108     * @throws JsonException if the method is not allowed or other error occurs
109     */
110    @Nonnull
111    public JsonNode doPut(@Nonnull String type, @Nonnull String name, @Nonnull JsonNode data,
112            @Nonnull JsonRequest request)
113            throws JsonException {
114        throw new JsonException(HttpServletResponse.SC_METHOD_NOT_ALLOWED,
115                Bundle.getMessage(request.locale, "PutNotAllowed", type), request.id);
116    }
117
118    /**
119     * Respond to an HTTP DELETE request for the requested name.
120     * <p>
121     * Throw an HTTP 405 Method Not Allowed exception if the object is not
122     * intended to be removable.
123     * <p>
124     * Do not throw an error if the requested object does not exist.
125     *
126     * @param type    the type of the deleted object
127     * @param name    the system name of the deleted object
128     * @param data    additional data
129     * @param request the JSON request
130     * @throws JsonException if this method is not allowed or other error occurs
131     */
132    public void doDelete(@Nonnull String type, @Nonnull String name, @Nonnull JsonNode data,
133            @Nonnull JsonRequest request)
134            throws JsonException {
135        throw new JsonException(HttpServletResponse.SC_METHOD_NOT_ALLOWED,
136                Bundle.getMessage(request.locale, "DeleteNotAllowed", type), request.id);
137    }
138
139    /**
140     * Respond to an HTTP GET request for a list of items of type.
141     * <p>
142     * This is called by the {@link jmri.web.servlet.json.JsonServlet} to handle
143     * get requests for a type, but no name. Services that do not have named
144     * objects, such as the {@link jmri.server.json.time.JsonTimeHttpService}
145     * should respond to this with a list containing a single JSON object.
146     * Services that can't return a list may throw a 400 Bad Request
147     * JsonException in this case.
148     *
149     * @param type    the type of the requested list
150     * @param data    JSON data set of attributes of the requested objects
151     * @param request the JSON request
152     * @return a JSON list or message containing type {@value JSON#LIST}, the
153     *         list as data, and the passed in id
154     * @throws JsonException may be thrown by concrete implementations
155     */
156    @Nonnull
157    public abstract JsonNode doGetList(@Nonnull String type, @Nonnull JsonNode data, @Nonnull JsonRequest request)
158            throws JsonException;
159
160    /**
161     * Get the JSON Schema for the {@code data} property of the requested type
162     * of JSON object. It is a invalid for implementations to not return a valid
163     * schema that clients can use to validate a request to or response from the
164     * JSON services.
165     * <p>
166     * Note that a schema must be contained in a standard object as:
167     * <p>
168     * {@code {"type":"schema", "data":{"schema":<em>schema</em>,
169     * "server":boolean}} }
170     * <p>
171     * If using {@link #doSchema(String, boolean, String, String, int)}, an
172     * implementation can be as simple as: {@code
173     * return doSchema(type, server, "path/to/client/schema.json", "path/to/server/schema.json", id);
174     * }
175     *
176     * @param type    the type for which a schema is requested
177     * @param server  true if the schema is for a message from the server; false
178     *                if the schema is for a message from the client
179     * @param request the JSON request
180     * @return a JSON Schema valid for the type
181     * @throws JsonException if an error occurs preparing schema; if type is is
182     *                       not a type handled by this service, this must be
183     *                       thrown with an error code of 500 and the localized
184     *                       message ERROR_UNKNOWN_TYPE
185     */
186    @Nonnull
187    public abstract JsonNode doSchema(@Nonnull String type, boolean server, @Nonnull JsonRequest request)
188            throws JsonException;
189
190    /**
191     * Helper to make implementing
192     * {@link #doSchema(String, boolean, JsonRequest)} easier. Throws a
193     * JsonException based on an IOException or NullPointerException if unable
194     * to read the schemas as resources.
195     *
196     * @param type         the type for which a schema is requested
197     * @param server       true if the schema is for a message from the server;
198     *                     false if the schema is for a message from the client
199     * @param serverSchema the path to the schema for a response object of type
200     * @param clientSchema the path to the schema for a request object of type
201     * @param id           the message id set by the client
202     * @return a JSON Schema valid for the type
203     * @throws JsonException if an error occurs preparing schema
204     */
205    @Nonnull
206    protected final JsonNode doSchema(@Nonnull String type, boolean server, @Nonnull String serverSchema,
207            @Nonnull String clientSchema, int id) throws JsonException {
208        JsonNode schema;
209        try {
210            if (server) {
211                schema = this.mapper.readTree(this.getClass().getClassLoader().getResource(serverSchema));
212            } else {
213                schema = this.mapper.readTree(this.getClass().getClassLoader().getResource(clientSchema));
214            }
215        } catch (
216                IOException |
217                IllegalArgumentException ex) {
218            throw new JsonException(500, ex, id);
219        }
220        return this.doSchema(type, server, schema, id);
221    }
222
223    /**
224     * Helper to make implementing
225     * {@link #doSchema(String, boolean, JsonRequest)} easier.
226     *
227     * @param type   the type for which a schema is requested
228     * @param server true if the schema is for a message from the server; false
229     *               if the schema is for a message from the client
230     * @param schema the schema for a response object of type
231     * @param id     the message id set by the client
232     * @return a JSON Schema valid for the type
233     */
234    @Nonnull
235    protected final JsonNode doSchema(@Nonnull String type, boolean server, @Nonnull JsonNode schema, int id) {
236        ObjectNode data = mapper.createObjectNode();
237        data.put(JSON.NAME, type);
238        data.put(JSON.SERVER, server);
239        data.set(JSON.SCHEMA, schema);
240        return message(JSON.SCHEMA, data, id);
241    }
242
243    /**
244     * Get the in-use ObjectMapper for this service.
245     *
246     * @return the object mapper
247     */
248    @Nonnull
249    public final ObjectMapper getObjectMapper() {
250        return this.mapper;
251    }
252
253    /**
254     * Verify a deletion token. If the token is not valid any pending deletion
255     * tokens for the type and name are also deleted.
256     *
257     * @param type  the type of object pending deletion
258     * @param name  the name of object pending deletion
259     * @param token the token previously provided to client
260     * @return true if token was provided to client and no other delete attempt
261     *         was made by client with a different or missing token since token
262     *         was issued to client; false otherwise
263     */
264    public final boolean acceptForceDeleteToken(@Nonnull String type, @Nonnull String name,
265            @CheckForNull String token) {
266        return JsonDeleteTokenManager.getDefault().acceptToken(type, name, token);
267    }
268
269    /**
270     * Throw an HTTP CONFLICT (409) exception when an object is requested to be
271     * deleted and it is in use. This exception will include a token that can be
272     * used to force deletion by the client and may include a JSON list of the
273     * objects using the object for which deletion was requested.
274     *
275     * @param type      the type of object in conflicting state
276     * @param name      the name of the object in conflicting state
277     * @param conflicts the using objects of this object; may be empty
278     * @param request   the JSON request
279     * @throws JsonException the exception
280     */
281    public final void throwDeleteConflictException(@Nonnull String type, @Nonnull String name,
282            @Nonnull ArrayNode conflicts,
283            @Nonnull JsonRequest request) throws JsonException {
284        ObjectNode data = mapper.createObjectNode();
285        data.put(FORCE_DELETE, JsonDeleteTokenManager.getDefault().getToken(type, name));
286        if (conflicts.size() != 0) {
287            data.set(CONFLICT, conflicts);
288        }
289        throw new JsonException(HttpServletResponse.SC_CONFLICT,
290                Bundle.getMessage(request.locale, "ErrorDeleteConflict", type, name), data, request.id);
291    }
292
293    /**
294     * Create a message node from an array.
295     *
296     * @param data the array
297     * @param id   the message id provided by the client or its additive inverse
298     * @return if id is a positive, non-zero integer, return a message of type
299     *         {@value JSON#LIST} with data as the data and id set; otherwise
300     *         return data without modification
301     * @see #message(String, JsonNode, String, int)
302     * @see #message(String, JsonNode, int)
303     * @see #message(ObjectMapper, ArrayNode, String, int)
304     * @see #message(ObjectMapper, String, JsonNode, String, int)
305     */
306    public final JsonNode message(@Nonnull ArrayNode data, int id) {
307        return message(mapper, data, null, id);
308    }
309
310    /**
311     * Create a message node without an explicit method.
312     *
313     * @param type the message type
314     * @param data the message data
315     * @param id   the message id provided by the client or its additive inverse
316     * @return a message node without a method property; an id property is only
317     *         present if id is greater than zero
318     * @see #message(ArrayNode, int)
319     * @see #message(String, JsonNode, String, int)
320     * @see #message(ObjectMapper, ArrayNode, String, int)
321     * @see #message(ObjectMapper, String, JsonNode, String, int)
322     */
323    public final ObjectNode message(@Nonnull String type, @Nonnull JsonNode data, int id) {
324        return message(type, data, null, id);
325    }
326
327    /**
328     * Create a message node.
329     *
330     * @param type   the message type
331     * @param data   the message data
332     * @param method the message method
333     * @param id     the message id provided by the client or its additive
334     *               inverse
335     * @return a message node; an id proper
336     * @see #message(ArrayNode, int)
337     * @see #message(String, JsonNode, int)
338     * @see #message(ObjectMapper, ArrayNode, String, int)
339     * @see #message(ObjectMapper, String, JsonNode, String, int)
340     */
341    public final ObjectNode message(@Nonnull String type, @Nonnull JsonNode data, @CheckForNull String method, int id) {
342        return message(mapper, type, data, method, id);
343    }
344
345    /**
346     * Create a message node from an array.
347     *
348     * @param mapper the ObjectMapper to use to construct the message
349     * @param data   the array
350     * @param method the message method
351     * @param id     the message id provided by the client or its additive
352     *               inverse
353     * @return if id is a positive, non-zero integer, return a message of type
354     *         {@value JSON#LIST} with data as the data and id set; otherwise
355     *         just return data without modification
356     * @see #message(ArrayNode, int)
357     * @see #message(String, JsonNode, String, int)
358     * @see #message(String, JsonNode, int)
359     * @see #message(ObjectMapper, String, JsonNode, String, int)
360     */
361    public static final JsonNode message(@Nonnull ObjectMapper mapper, @Nonnull ArrayNode data,
362            @CheckForNull String method, int id) {
363        return (id > 0) ? message(mapper, JSON.LIST, data, method, id) : data;
364    }
365
366    /**
367     * Create a message node.
368     *
369     * @param mapper the ObjectMapper to use to construct the message
370     * @param type   the message type
371     * @param data   the message data
372     * @param method the message method or null
373     * @param id     the message id provided by the client or its additive
374     *               inverse
375     * @return a message node; if method is null, no method property is
376     *         included; if id is not greater than zero, no id property is
377     *         included
378     * @see #message(ArrayNode, int)
379     * @see #message(String, JsonNode, String, int)
380     * @see #message(String, JsonNode, int)
381     * @see #message(ObjectMapper, ArrayNode, String, int)
382     */
383    public static final ObjectNode message(@Nonnull ObjectMapper mapper, @Nonnull String type, @Nonnull JsonNode data,
384            @CheckForNull String method, int id) {
385        ObjectNode root = mapper.createObjectNode();
386        root.put(JSON.TYPE, type);
387        root.set(JSON.DATA, data);
388        if (method != null) {
389            root.put(JSON.METHOD, method);
390        }
391        if (id > 0) {
392            root.put(JSON.ID, id);
393        }
394        return root;
395    }
396}