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