001package jmri.server.json.schema; 002 003import com.fasterxml.jackson.databind.JsonNode; 004import com.fasterxml.jackson.databind.ObjectMapper; 005import com.networknt.schema.JsonSchema; 006import com.networknt.schema.JsonSchemaFactory; 007import com.networknt.schema.SchemaValidatorsConfig; 008import com.networknt.schema.ValidationMessage; 009 010import java.io.IOException; 011import java.util.HashMap; 012import java.util.HashSet; 013import java.util.Map; 014import java.util.ServiceLoader; 015import java.util.Set; 016import javax.annotation.Nonnull; 017import jmri.InstanceManagerAutoDefault; 018import jmri.server.json.JSON; 019import jmri.server.json.JsonException; 020import jmri.server.json.JsonHttpService; 021import jmri.server.json.JsonRequest; 022import jmri.spi.JsonServiceFactory; 023 024/** 025 * Cache for mapping {@link jmri.server.json.JsonHttpService}s to types for 026 * getting schemas. 027 * 028 * @author Randall Wood Copyright 2018 029 */ 030public class JsonSchemaServiceCache implements InstanceManagerAutoDefault { 031 032 private Map<String, Map<String, Set<JsonHttpService>>> services = new HashMap<>(); 033 private SchemaValidatorsConfig config = new SchemaValidatorsConfig(); 034 private final Map<String, Set<String>> clientTypes = new HashMap<>(); 035 private final Map<String, Set<String>> serverTypes = new HashMap<>(); 036 private final Map<String, Map<String, JsonSchema>> clientSchemas = new HashMap<>(); 037 private final Map<String, Map<String, JsonSchema>> serverSchemas = new HashMap<>(); 038 private final ObjectMapper mapper = new ObjectMapper(); 039 040 public JsonSchemaServiceCache() { 041 Map<String, String> map = new HashMap<>(); 042 try { 043 for (JsonNode mapping : mapper 044 .readTree(JsonSchemaServiceCache.class.getResource("/jmri/server/json/schema-map.json"))) { 045 map.put(mapping.get("publicURL").asText(), 046 mapping.get("localURL").asText()); 047 } 048 } catch (IOException ex) { 049 log.error("Unable to read JMRI resources for JSON schema mapping", ex); 050 } 051 config.setUriMappings(map); 052 } 053 054 /** 055 * Get the services known to this cache that support a specific JSON type. 056 * 057 * @param type the JSON type requested 058 * @param version the JSON protocol version requested 059 * @return the supporting services or an empty set if none 060 * @throws NullPointerException if version is not a known version 061 */ 062 @Nonnull 063 public synchronized Set<JsonHttpService> getServices(@Nonnull String type, @Nonnull String version) { 064 cacheServices(version); 065 return services.get(version).getOrDefault(type, new HashSet<>()); 066 } 067 068 /** 069 * Get all types of JSON messages. 070 * 071 * @param version the JSON protocol version 072 * @return the union of the results from {@link #getClientTypes} and 073 * {@link #getServerTypes} 074 */ 075 @Nonnull 076 public synchronized Set<String> getTypes(String version) { 077 Set<String> set = getClientTypes(version); 078 set.addAll(getServerTypes(version)); 079 return set; 080 } 081 082 /** 083 * Get the types of JSON messages expected from clients. 084 * 085 * @param version the JSON protocol version 086 * @return the message types 087 */ 088 @Nonnull 089 public synchronized Set<String> getClientTypes(String version) { 090 cacheServices(version); 091 return new HashSet<>(clientTypes.get(version)); 092 } 093 094 /** 095 * Get the types of JSON messages this application sends. 096 * 097 * @param version the JSON protocol version 098 * @return the message types 099 */ 100 @Nonnull 101 public synchronized Set<String> getServerTypes(String version) { 102 cacheServices(version); 103 return new HashSet<>(serverTypes.get(version)); 104 } 105 106 /** 107 * Get the client schema for JSON messages or for specific JSON data schema. 108 * 109 * @param type the type; use {@link JSON#JSON} to get the schema for 110 * messages, or any other value for a data schema 111 * @param request the JSON request 112 * @return the requested schema 113 * @throws JsonException if unable to get schema due to errors 114 * processing schema 115 * @throws IllegalArgumentException if no JSON service provides schemas for 116 * type 117 */ 118 @Nonnull 119 public JsonSchema getClientSchema(@Nonnull String type, @Nonnull JsonRequest request) throws JsonException { 120 return getSchema(type, false, clientSchemas, request); 121 } 122 123 /** 124 * Get the server schema for JSON messages or for specific JSON data schema. 125 * 126 * @param type the type; use {@link JSON#JSON} to get the schema for 127 * messages, or any other value for a data schema 128 * @param request the JSON request 129 * @return the requested schema 130 * @throws JsonException if unable to get schema due to errors 131 * processing schema 132 * @throws IllegalArgumentException if no JSON service provides schemas for 133 * type 134 */ 135 @Nonnull 136 public JsonSchema getServerSchema(@Nonnull String type, @Nonnull JsonRequest request) throws JsonException { 137 return getSchema(type, true, serverSchemas, request); 138 } 139 140 private synchronized JsonSchema getSchema(@Nonnull String type, boolean server, 141 @Nonnull Map<String, Map<String, JsonSchema>> map, @Nonnull JsonRequest request) throws JsonException { 142 cacheServices(request.version); 143 JsonSchema result = map.computeIfAbsent(request.version, v -> new HashMap<>()).get(type); 144 if (result == null) { 145 for (JsonHttpService service : getServices(type, request.version)) { 146 log.debug("Processing {} with {}", type, service); 147 result = JsonSchemaFactory.getInstance() 148 .getSchema(service.doSchema(type, server, request).path(JSON.DATA).path(JSON.SCHEMA), config); 149 if (result != null) { 150 map.get(request.version).put(type, result); 151 break; 152 } 153 } 154 if (result == null) { 155 throw new IllegalArgumentException( 156 "type \"" + type + "\" is not a valid JSON " + (server ? "server" : "client") + " type"); 157 } 158 } 159 return result; 160 } 161 162 /** 163 * Validate a JSON message against the schema for JSON messages and data. 164 * 165 * @param message the message to validate 166 * @param server true if message is from the JSON server; false otherwise 167 * @param request the JSON request 168 * @throws JsonException if the message does not validate 169 */ 170 public void validateMessage(@Nonnull JsonNode message, boolean server, @Nonnull JsonRequest request) 171 throws JsonException { 172 log.trace("validateMessage(\"{}\", \"{}\", \"{}\", ...)", message, server, request); 173 Map<String, Map<String, JsonSchema>> map = server ? serverSchemas : clientSchemas; 174 validateJsonNode(message, JSON.JSON, server, map, request); 175 if (message.isArray()) { 176 for (JsonNode item : message) { 177 validateMessage(item, server, request); 178 } 179 } else { 180 String type = message.path(JSON.TYPE).asText(); 181 JsonNode data = message.path(JSON.DATA); 182 if (!data.isMissingNode()) { 183 if (!data.isArray()) { 184 validateJsonNode(data, type, server, map, request); 185 } else { 186 validateMessage(data, server, request); 187 } 188 } 189 } 190 } 191 192 /** 193 * Validate a JSON data object against the schema for JSON messages and 194 * data. 195 * 196 * @param type the type of data object 197 * @param data the data object to validate 198 * @param server true if message is from the JSON server; false otherwise 199 * @param request the JSON request 200 * @throws JsonException if the message does not validate 201 */ 202 public void validateData(@Nonnull String type, @Nonnull JsonNode data, boolean server, @Nonnull JsonRequest request) 203 throws JsonException { 204 log.trace("validateData(\"{}\", \"{}\", \"{}\", \"{}\", ...)", type, data, server, request); 205 Map<String, Map<String, JsonSchema>> map = server ? serverSchemas : clientSchemas; 206 if (data.isArray()) { 207 for (JsonNode item : data) { 208 validateData(type, item, server, request); 209 } 210 } else { 211 validateJsonNode(data, type, server, map, request); 212 } 213 } 214 215 private void validateJsonNode(@Nonnull JsonNode node, @Nonnull String type, boolean server, 216 @Nonnull Map<String, Map<String, JsonSchema>> map, @Nonnull JsonRequest request) throws JsonException { 217 log.trace("validateJsonNode(\"{}\", \"{}\", \"{}\", ...)", node, type, server); 218 Set<ValidationMessage> errors = null; 219 try { 220 errors = getSchema(type, server, map, request).validate(node); 221 } catch (JsonException ex) { 222 log.error("Unable to validate JSON schemas", ex); 223 } 224 if (errors != null && !errors.isEmpty()) { 225 log.warn("Errors validating {}", node); 226 errors.forEach(error -> log.warn("JSON Validation Error: {}\n\t{}\n\t{}\n\t{}", error.getCode(), 227 error.getMessage(), 228 error.getPath(), error.getType())); 229 throw new JsonException(server ? 500 : 400, Bundle.getMessage(request.locale, JsonException.LOGGED_ERROR), 230 request.id); 231 } 232 } 233 234 private void cacheServices(String version) { 235 Set<String> versionedClientTypes = clientTypes.computeIfAbsent(version, v -> new HashSet<>()); 236 Set<String> versionedServerTypes = serverTypes.computeIfAbsent(version, v -> new HashSet<>()); 237 Map<String, Set<JsonHttpService>> versionedServices = 238 services.computeIfAbsent(version, v -> new HashMap<>()); 239 if (versionedServices.isEmpty()) { 240 for (JsonServiceFactory<?, ?> factory : ServiceLoader.load(JsonServiceFactory.class)) { 241 JsonHttpService service = factory.getHttpService(mapper, JSON.V5); 242 for (String type : factory.getTypes(JSON.V5)) { 243 Set<JsonHttpService> set = versionedServices.computeIfAbsent(type, v -> new HashSet<>()); 244 versionedClientTypes.add(type); 245 versionedServerTypes.add(type); 246 set.add(service); 247 } 248 for (String type : factory.getSentTypes(JSON.V5)) { 249 Set<JsonHttpService> set = versionedServices.computeIfAbsent(type, v -> new HashSet<>()); 250 versionedServerTypes.add(type); 251 set.add(service); 252 } 253 for (String type : factory.getReceivedTypes(JSON.V5)) { 254 Set<JsonHttpService> set = versionedServices.computeIfAbsent(type, v -> new HashSet<>()); 255 versionedClientTypes.add(type); 256 set.add(service); 257 } 258 } 259 } 260 } 261 262 private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(JsonSchemaServiceCache.class); 263}