001package jmri.web.servlet.json;
002
003import static jmri.server.json.JSON.DATA;
004import static jmri.server.json.JSON.ID;
005import static jmri.server.json.JSON.NAME;
006import static jmri.server.json.JSON.STATE;
007import static jmri.server.json.JSON.V5;
008import static jmri.server.json.JSON.VALUE;
009import static jmri.server.json.JSON.VERSIONS;
010import static jmri.server.json.JsonException.CODE;
011import static jmri.server.json.operations.JsonOperations.LOCATION;
012import static jmri.server.json.power.JsonPowerServiceFactory.POWER;
013import static jmri.web.servlet.ServletUtil.APPLICATION_JSON;
014import static jmri.web.servlet.ServletUtil.UTF8;
015import static jmri.web.servlet.ServletUtil.UTF8_APPLICATION_JSON;
016
017import com.fasterxml.jackson.core.JsonProcessingException;
018import com.fasterxml.jackson.databind.JsonNode;
019import com.fasterxml.jackson.databind.ObjectMapper;
020import com.fasterxml.jackson.databind.node.ArrayNode;
021import com.fasterxml.jackson.databind.node.ObjectNode;
022import java.io.IOException;
023import java.net.URLDecoder;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.HashMap;
027import java.util.HashSet;
028import java.util.Map;
029import java.util.ServiceLoader;
030
031import javax.annotation.Nonnull;
032import javax.servlet.ServletException;
033import javax.servlet.annotation.WebServlet;
034import javax.servlet.http.HttpServlet;
035import javax.servlet.http.HttpServletRequest;
036import javax.servlet.http.HttpServletResponse;
037import jmri.InstanceManager;
038import jmri.server.json.JsonServerPreferences;
039import jmri.server.json.JsonException;
040import jmri.server.json.JsonHttpService;
041import jmri.server.json.JsonRequest;
042import jmri.server.json.JsonWebSocket;
043import jmri.server.json.schema.JsonSchemaServiceCache;
044import jmri.spi.JsonServiceFactory;
045import jmri.util.FileUtil;
046import jmri.web.servlet.ServletUtil;
047import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
048import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
049import org.openide.util.lookup.ServiceProvider;
050import org.slf4j.Logger;
051import org.slf4j.LoggerFactory;
052
053/**
054 * Provide JSON formatted responses to requests for information from the JMRI
055 * Web Server.
056 * <p>
057 * See {@link jmri.server.json} for details on how this Servlet handles JSON
058 * requests.
059 *
060 * @author Randall Wood Copyright (C) 2012, 2013, 2016, 2019
061 */
062@WebServlet(name = "JsonServlet",
063        urlPatterns = {"/json"})
064@ServiceProvider(service = HttpServlet.class)
065public class JsonServlet extends WebSocketServlet {
066
067    private final transient ObjectMapper mapper = new ObjectMapper();
068    private final transient HashMap<String, HashMap<String, HashSet<JsonHttpService>>> services = new HashMap<>();
069    private final transient JsonServerPreferences preferences = InstanceManager.getDefault(JsonServerPreferences.class);
070    private static final Logger log = LoggerFactory.getLogger(JsonServlet.class);
071
072    @Override
073    public void init() throws ServletException {
074        superInit();
075        ServiceLoader.load(JsonServiceFactory.class).forEach(factory -> VERSIONS.stream().forEach(version -> {
076            JsonHttpService service = factory.getHttpService(mapper, version);
077            HashMap<String, HashSet<JsonHttpService>> types = services.computeIfAbsent(version, map -> new HashMap<>());
078            Arrays.stream(factory.getTypes(version))
079                    .forEach(type -> types.computeIfAbsent(type, set -> new HashSet<>()).add(service));
080            Arrays.stream(factory.getReceivedTypes(version))
081                    .forEach(type -> types.computeIfAbsent(type, set -> new HashSet<>()).add(service));
082        }));
083    }
084
085    /**
086     * Package private method to call
087     * {@link org.eclipse.jetty.websocket.servlet.WebSocketServlet#init()} so
088     * this call can be mocked out in unit tests.
089     * 
090     * @throws ServletException if unable to initialize server
091     */
092    void superInit() throws ServletException {
093        super.init();
094    }
095
096    @Override
097    public void configure(WebSocketServletFactory factory) {
098        factory.register(JsonWebSocket.class);
099    }
100
101    /**
102     * Handle HTTP get requests for JSON data. Examples:
103     * <ul>
104     * <li>/json/v5/sensor/IS22 (return data for sensor with system name
105     * "IS22")</li>
106     * <li>/json/v5/sensor (returns a list of all sensors known to JMRI)</li>
107     * </ul>
108     * sample responses:
109     * <ul>
110     * <li>{"type":"sensor","data":{"name":"IS22","userName":"FarEast","comment":null,"inverted":false,"state":4}}</li>
111     * <li>[{"type":"sensor","data":{"name":"IS22","userName":"FarEast","comment":null,"inverted":false,"state":4}}]</li>
112     * </ul>
113     * Note that data will vary for each type. Note that if an array is returned
114     * when requesting a single object, the client must resolve the multiple
115     * objects in the array, since it is possible for plugins to JMRI to provide
116     * their own response, and JMRI is incapable of judging the correctness of
117     * the plugin's response.
118     * <p>
119     * If the request includes a {@literal result} attribute, the content of the
120     * response will be solely the contents of that attribute. This is an aid to
121     * the development and testing of JMRI and clients, but is not considered a
122     * usable feature in production. This capability may be removed without
123     * notice if it is deemed too complex to maintain.
124     * 
125     * @param request  an HttpServletRequest object that contains the request
126     *                 the client has made of the servlet
127     * @param response an HttpServletResponse object that contains the response
128     *                 the servlet sends to the client
129     * @throws java.io.IOException if an input or output error is detected when
130     *                             the servlet handles the GET request
131     */
132    @Override
133    protected void doGet(final HttpServletRequest request, HttpServletResponse response) throws IOException {
134        configureResponse(response);
135        JsonRequest jsonRequest = createJsonRequest(request);
136
137        String[] path = request.getRequestURI().substring(request.getContextPath().length()).split("/"); // NOI18N
138        String[] rest = path;
139        if (path.length > 1 && jsonRequest.version.equals(path[1])) {
140            rest = Arrays.copyOfRange(path, 1, path.length);
141        }
142
143        // echo the contents of result if present and abort further processing
144        if (request.getAttribute("result") != null) {
145            JsonNode result = (JsonNode) request.getAttribute("result");
146            // use HTTP error codes when possible
147            int code = result.path(DATA).path(CODE).asInt(HttpServletResponse.SC_OK);
148            sendMessage(response, code, result, jsonRequest);
149            return;
150        }
151
152        String type = (rest.length > 1) ? URLDecoder.decode(rest[1], UTF8) : null;
153        if (type != null && !type.isEmpty()) {
154            response.setContentType(UTF8_APPLICATION_JSON);
155            InstanceManager.getDefault(ServletUtil.class).setNonCachingHeaders(response);
156            final String name = (rest.length > 2) ? URLDecoder.decode(rest[2], UTF8) : null;
157            ObjectNode parameters = mapper.createObjectNode();
158            for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
159                String value = URLDecoder.decode(entry.getValue()[0], UTF8);
160                log.debug("Setting parameter {} to {}", entry.getKey(), value);
161                try {
162                    parameters
163                            .setAll((ObjectNode) mapper.readTree(String.format("{\"%s\":%s}", entry.getKey(), value)));
164                } catch (JsonProcessingException ex) {
165                    log.error("Unable to parse JSON {\"{}\":{}}", entry.getKey(), value);
166                }
167            }
168            JsonNode reply = null;
169            try {
170                if (name == null) {
171                    if (services.get(jsonRequest.version).get(type) != null) {
172                        ArrayList<JsonNode> lists = new ArrayList<>();
173                        ArrayNode array = mapper.createArrayNode();
174                        JsonException exception = null;
175                        try {
176                            for (JsonHttpService service : services.get(jsonRequest.version).get(type)) {
177                                lists.add(service.doGetList(type, parameters, jsonRequest));
178                            }
179                        } catch (JsonException ex) {
180                            exception = ex;
181                        }
182                        switch (lists.size()) {
183                            case 0:
184                                if (exception != null) {
185                                    throw exception;
186                                }
187                                // either empty array or object with empty data
188                                reply = JsonHttpService.message(mapper, array, null, jsonRequest.id);
189                                break;
190                            case 1:
191                                reply = lists.get(0);
192                                break;
193                            default:
194                                for (JsonNode list : lists) {
195                                    if (list.isArray()) {
196                                        list.forEach(array::add);
197                                    } else if (list.path(DATA).isArray()) {
198                                        list.path(DATA).forEach(array::add);
199                                    }
200                                }
201                                reply = JsonHttpService.message(mapper, array, null, jsonRequest.id);
202                                break;
203                        }
204                    }
205                    if (reply == null) {
206                        log.warn("Requested type '{}' unknown.", type);
207                        throw new JsonException(HttpServletResponse.SC_NOT_FOUND,
208                                JsonBundle.getMessage(request.getLocale(), "ErrorUnknownType", type), jsonRequest.id);
209                    }
210                } else {
211                    if (services.get(jsonRequest.version).get(type) != null) {
212                        ArrayNode array = mapper.createArrayNode();
213                        JsonException exception = null;
214                        try {
215                            for (JsonHttpService service : services.get(jsonRequest.version).get(type)) {
216                                array.add(service.doGet(type, name, parameters, jsonRequest));
217                            }
218                        } catch (JsonException ex) {
219                            exception = ex;
220                        }
221                        switch (array.size()) {
222                            case 0:
223                                if (exception != null) {
224                                    throw exception;
225                                }
226                                reply = array;
227                                break;
228                            case 1:
229                                reply = array.get(0);
230                                break;
231                            default:
232                                reply = array;
233                                break;
234                        }
235                    }
236                    if (reply == null) {
237                        log.warn("Requested type '{}' unknown.", type);
238                        throw new JsonException(HttpServletResponse.SC_NOT_FOUND,
239                                JsonBundle.getMessage(request.getLocale(), "ErrorUnknownType", type), jsonRequest.id);
240                    }
241                }
242            } catch (JsonException ex) {
243                reply = ex.getJsonMessage();
244            }
245            // use HTTP error codes when possible
246            int code = reply.path(DATA).path(CODE).asInt(HttpServletResponse.SC_OK);
247            sendMessage(response, code, reply, jsonRequest);
248        } else {
249            ServletUtil util = InstanceManager.getDefault(ServletUtil.class);
250            response.setContentType(ServletUtil.UTF8_TEXT_HTML); // NOI18N
251            response.getWriter().print(String.format(request.getLocale(),
252                    FileUtil.readURL(FileUtil.findURL(Bundle.getMessage(request.getLocale(), "Json.html"))),
253                    util.getTitle(request.getLocale(), Bundle.getMessage(request.getLocale(), "JsonTitle")),
254                    util.getNavBar(request.getLocale(), request.getContextPath()),
255                    util.getRailroadName(false),
256                    util.getFooter(request.getLocale(), request.getContextPath())));
257
258        }
259    }
260
261    @Override
262    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
263        configureResponse(response);
264        InstanceManager.getDefault(ServletUtil.class).setNonCachingHeaders(response);
265
266        JsonRequest jsonRequest = createJsonRequest(request);
267
268        String[] path = request.getRequestURI().substring(request.getContextPath().length()).split("/"); // NOI18N
269        String[] rest = path;
270        if (path.length >= 1 && jsonRequest.version.equals(path[1])) {
271            rest = Arrays.copyOfRange(path, 1, path.length);
272        }
273
274        String type = (rest.length > 1) ? URLDecoder.decode(rest[1], UTF8) : null;
275        String name = (rest.length > 2) ? URLDecoder.decode(rest[2], UTF8) : null;
276        int id = 0;
277        try {
278            id = Integer.parseInt(request.getParameter(ID));
279        } catch (NumberFormatException ex) {
280            id = 0;
281        }
282        JsonNode data;
283        JsonNode reply = null;
284        try {
285            if (request.getContentType().contains(APPLICATION_JSON)) {
286                data = mapper.readTree(request.getReader());
287                if (!data.path(DATA).isMissingNode()) {
288                    data = data.path(DATA);
289                }
290            } else {
291                data = mapper.createObjectNode();
292                if (request.getParameter(STATE) != null) {
293                    ((ObjectNode) data).put(STATE, Integer.parseInt(request.getParameter(STATE)));
294                } else if (request.getParameter(LOCATION) != null) {
295                    ((ObjectNode) data).put(LOCATION, request.getParameter(LOCATION));
296                } else if (request.getParameter(VALUE) != null) {
297                    // values other than Strings should be sent in a JSON object
298                    ((ObjectNode) data).put(VALUE, request.getParameter(VALUE));
299                }
300            }
301            if (type != null) {
302                // for historical reasons, set the name to POWER on a power
303                // request
304                if (type.equals(POWER)) {
305                    name = POWER;
306                } else if (name == null) {
307                    name = data.path(NAME).asText();
308                }
309                log.debug("POST operation for {}/{} with {}", type, name, data);
310                if (name != null) {
311                    if (services.get(jsonRequest.version).get(type) != null) {
312                        log.debug("Using data: {}", data);
313                        ArrayNode array = mapper.createArrayNode();
314                        JsonException exception = null;
315                        try {
316                            for (JsonHttpService service : services.get(jsonRequest.version).get(type)) {
317                                array.add(service.doPost(type, name, data, jsonRequest));
318                            }
319                        } catch (JsonException ex) {
320                            exception = ex;
321                        }
322                        switch (array.size()) {
323                            case 0:
324                                if (exception != null) {
325                                    throw exception;
326                                }
327                                reply = array;
328                                break;
329                            case 1:
330                                reply = array.get(0);
331                                break;
332                            default:
333                                reply = array;
334                                break;
335                        }
336                    }
337                    if (reply == null) {
338                        log.warn("Requested type '{}' unknown.", type);
339                        throw new JsonException(HttpServletResponse.SC_NOT_FOUND,
340                                JsonBundle.getMessage(request.getLocale(), "ErrorUnknownType", type), id);
341                    }
342                } else {
343                    log.error("Name must be defined.");
344                    throw new JsonException(HttpServletResponse.SC_BAD_REQUEST,
345                            JsonBundle.getMessage(request.getLocale(), "ErrorMissingName"), id);
346                }
347            } else {
348                log.warn("Type not specified.");
349                // TODO: I18N
350                throw new JsonException(HttpServletResponse.SC_BAD_REQUEST, "Type must be specified.", id);
351            }
352        } catch (JsonException ex) {
353            reply = ex.getJsonMessage();
354        }
355        // use HTTP error codes when possible
356        int code = reply.path(DATA).path(CODE).asInt(HttpServletResponse.SC_OK);
357        sendMessage(response, code, reply, jsonRequest);
358    }
359
360    @Override
361    protected void doPut(HttpServletRequest request, HttpServletResponse response) throws IOException {
362        configureResponse(response);
363        InstanceManager.getDefault(ServletUtil.class).setNonCachingHeaders(response);
364
365        JsonRequest jsonRequest = createJsonRequest(request);
366
367        String[] path = request.getRequestURI().substring(request.getContextPath().length()).split("/"); // NOI18N
368        String[] rest = path;
369        if (path.length >= 1 && jsonRequest.version.equals(path[1])) {
370            rest = Arrays.copyOfRange(path, 1, path.length);
371        }
372
373        String type = (rest.length > 1) ? URLDecoder.decode(rest[1], UTF8) : null;
374        String name = (rest.length > 2) ? URLDecoder.decode(rest[2], UTF8) : null;
375        JsonNode data;
376        JsonNode reply = null;
377        try {
378            if (request.getContentType().contains(APPLICATION_JSON)) {
379                data = mapper.readTree(request.getReader());
380                if (!data.path(DATA).isMissingNode()) {
381                    data = data.path(DATA);
382                }
383            } else {
384                throw new JsonException(HttpServletResponse.SC_BAD_REQUEST, "PUT request must be a JSON object",
385                        jsonRequest.id); // need to I18N
386            }
387            if (type != null) {
388                // for historical reasons, set the name to POWER on a power
389                // request
390                if (type.equals(POWER)) {
391                    name = POWER;
392                } else if (name == null) {
393                    name = data.path(NAME).asText();
394                }
395                if (name != null) {
396                    if (services.get(jsonRequest.version).get(type) != null) {
397                        ArrayNode array = mapper.createArrayNode();
398                        JsonException exception = null;
399                        try {
400                            for (JsonHttpService service : services.get(jsonRequest.version).get(type)) {
401                                array.add(service.doPut(type, name, data, jsonRequest));
402                            }
403                        } catch (JsonException ex) {
404                            exception = ex;
405                        }
406                        switch (array.size()) {
407                            case 0:
408                                if (exception != null) {
409                                    throw exception;
410                                }
411                                reply = array;
412                                break;
413                            case 1:
414                                reply = array.get(0);
415                                break;
416                            default:
417                                reply = array;
418                                break;
419                        }
420                    }
421                    if (reply == null) {
422                        // item cannot be created
423                        // TODO: I18N
424                        throw new JsonException(HttpServletResponse.SC_BAD_REQUEST, type + " is not a creatable type",
425                                jsonRequest.id);
426                    }
427                } else {
428                    log.warn("Requested type '{}' unknown.", type);
429                    throw new JsonException(HttpServletResponse.SC_NOT_FOUND,
430                            JsonBundle.getMessage(request.getLocale(), "ErrorUnknownType", type), jsonRequest.id);
431                }
432            } else {
433                log.warn("Type not specified.");
434                // TODO: I18N
435                throw new JsonException(HttpServletResponse.SC_BAD_REQUEST, "Type must be specified.", jsonRequest.id);
436            }
437        } catch (JsonException ex) {
438            reply = ex.getJsonMessage();
439        }
440        // use HTTP error codes when possible
441        int code = reply.path(DATA).path(CODE).asInt(HttpServletResponse.SC_OK);
442        sendMessage(response, code, reply, jsonRequest);
443    }
444
445    @Override
446    protected void doDelete(HttpServletRequest request, HttpServletResponse response)
447            throws ServletException, IOException {
448        configureResponse(response);
449        InstanceManager.getDefault(ServletUtil.class).setNonCachingHeaders(response);
450
451        JsonRequest jsonRequest = createJsonRequest(request);
452
453        String[] path = request.getRequestURI().substring(request.getContextPath().length()).split("/"); // NOI18N
454        String[] rest = path;
455        if (path.length >= 1 && jsonRequest.version.equals(path[1])) {
456            rest = Arrays.copyOfRange(path, 1, path.length);
457        }
458        String type = (rest.length > 1) ? URLDecoder.decode(rest[1], UTF8) : null;
459        String name = (rest.length > 2) ? URLDecoder.decode(rest[2], UTF8) : null;
460        JsonNode reply = mapper.createObjectNode();
461        try {
462            if (type != null) {
463                if (name == null) {
464                    throw new JsonException(HttpServletResponse.SC_BAD_REQUEST, "name must be specified",
465                            jsonRequest.id); // need to I18N
466                }
467                if (services.get(jsonRequest.version).get(type) != null) {
468                    JsonNode data = mapper.createObjectNode();
469                    if (request.getContentType().contains(APPLICATION_JSON)) {
470                        data = mapper.readTree(request.getReader());
471                        if (!data.path(DATA).isMissingNode()) {
472                            data = data.path(DATA);
473                        }
474                    }
475                    for (JsonHttpService service : services.get(jsonRequest.version).get(type)) {
476                        service.doDelete(type, name, data, jsonRequest);
477                    }
478                } else {
479                    log.warn("Requested type '{}' unknown.", type);
480                    throw new JsonException(HttpServletResponse.SC_NOT_FOUND,
481                            JsonBundle.getMessage(request.getLocale(), "ErrorUnknownType", type), jsonRequest.id);
482                }
483            } else {
484                log.debug("Type not specified.");
485                // TODO: I18N
486                throw new JsonException(HttpServletResponse.SC_BAD_REQUEST, "Type must be specified.", jsonRequest.id);
487            }
488        } catch (JsonException ex) {
489            reply = ex.getJsonMessage();
490        }
491        // use HTTP error codes when possible
492        int code = reply.path(DATA).path(CODE).asInt(HttpServletResponse.SC_OK);
493        // only include a response body if something went wrong
494        if (code != HttpServletResponse.SC_OK) {
495            sendMessage(response, code, reply, jsonRequest);
496        }
497    }
498
499    /**
500     * Create a JsonRequest from an HttpServletRequest.
501     * 
502     * @param request the source
503     * @return a new JsonRequest
504     */
505    private JsonRequest createJsonRequest(HttpServletRequest request) {
506        int id = 0;
507        String version = V5;
508        String idParameter = request.getParameter(ID);
509        if (idParameter != null) {
510            try {
511                id = Integer.parseInt(idParameter);
512            } catch (NumberFormatException ex) {
513                id = 0;
514            }
515        }
516
517        String[] path = request.getRequestURI().substring(request.getContextPath().length()).split("/"); // NOI18N
518        if (path.length > 1 && VERSIONS.stream().anyMatch(v -> v.equals(path[1]))) {
519            version = path[1];
520        }
521        return new JsonRequest(request.getLocale(), version, request.getMethod().toLowerCase(), id);
522    }
523
524    /**
525     * Configure common settings for the response.
526     * 
527     * @param response the response to configure
528     */
529    private void configureResponse(HttpServletResponse response) {
530        response.setStatus(HttpServletResponse.SC_OK);
531        response.setContentType(UTF8_APPLICATION_JSON);
532        response.setHeader("Connection", "Keep-Alive"); // NOI18N
533    }
534
535    /**
536     * Send a message to the HTTP client in an HTTP response. This closes the
537     * response to future messages.
538     * <p>
539     * If {@link JsonServerPreferences#getValidateServerMessages()} is
540     * {@code true}, this may send an error message instead of {@code message}
541     * if the message is not schema valid.
542     *
543     * @param response the HTTP response
544     * @param code     the HTTP response code
545     * @param message  the message to send
546     * @param request  the JSON request
547     * @throws IOException if unable to send
548     */
549    private void sendMessage(@Nonnull HttpServletResponse response, int code, @Nonnull JsonNode message,
550            @Nonnull JsonRequest request) throws IOException {
551        if (preferences.getValidateServerMessages()) {
552            try {
553                InstanceManager.getDefault(JsonSchemaServiceCache.class).validateMessage(message, true, request);
554            } catch (JsonException ex) {
555                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
556                response.getWriter().write(mapper.writeValueAsString(ex.getJsonMessage()));
557                return;
558            }
559        }
560        response.setStatus(code);
561        response.getWriter().write(mapper.writeValueAsString(message));
562    }
563}