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}