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