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