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}