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.SpecVersion;
009import com.networknt.schema.ValidationMessage;
010
011import java.io.IOException;
012import java.util.HashMap;
013import java.util.HashSet;
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 Map<String, String> schemaMappings = new HashMap<>();
036    private final Map<String, Set<String>> clientTypes = new HashMap<>();
037    private final Map<String, Set<String>> serverTypes = new HashMap<>();
038    private final Map<String, Map<String, JsonSchema>> clientSchemas = new HashMap<>();
039    private final Map<String, Map<String, JsonSchema>> serverSchemas = new HashMap<>();
040    private final ObjectMapper mapper = new ObjectMapper();
041
042    public JsonSchemaServiceCache() {
043        try {
044            for (JsonNode mapping : mapper
045                    .readTree(JsonSchemaServiceCache.class.getResource("/jmri/server/json/schema-map.json"))) {
046                schemaMappings.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    }
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(SpecVersion.VersionFlag.V202012, builder -> 
148                        builder.schemaMappers(schemaMappers -> schemaMappers.mappings(schemaMappings)))
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.getEvaluationPath(), 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}