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 the services known to this cache that support a specific JSON type
071     * for version 5 of the JSON protocol.
072     * 
073     * @param type the JSON type requested
074     * @return the supporting services or an empty set if none
075     * @deprecated since 4.19.2; use {@link #getServices(String, String)}
076     *             instead
077     */
078    @Deprecated
079    @Nonnull
080    public synchronized Set<JsonHttpService> getServices(@Nonnull String type) {
081        return getServices(type, JSON.V5);
082    }
083
084    /**
085     * Get all types of JSON messages.
086     *
087     * @param version the JSON protocol version
088     * @return the union of the results from {@link #getClientTypes()} and
089     *         {@link #getServerTypes()}
090     */
091    @Nonnull
092    public synchronized Set<String> getTypes(String version) {
093        Set<String> set = getClientTypes(version);
094        set.addAll(getServerTypes(version));
095        return set;
096    }
097
098    /**
099     * Get the types of JSON messages expected from clients.
100     *
101     * @param version the JSON protocol version
102     * @return the message types
103     */
104    @Nonnull
105    public synchronized Set<String> getClientTypes(String version) {
106        cacheServices(version);
107        return new HashSet<>(clientTypes.get(version));
108    }
109
110    /**
111     * Get the types of JSON messages this application sends.
112     *
113     * @param version the JSON protocol version
114     * @return the message types
115     */
116    @Nonnull
117    public synchronized Set<String> getServerTypes(String version) {
118        cacheServices(version);
119        return new HashSet<>(serverTypes.get(version));
120    }
121
122    /**
123     * Get all types of JSON messages.
124     *
125     * @return the union of the results from {@link #getClientTypes()} and
126     *         {@link #getServerTypes()}
127     * @deprecated since 4.19.2; use {@link #getTypes(String)} instead
128     */
129    @Deprecated
130    @Nonnull
131    public synchronized Set<String> getTypes() {
132        return getTypes(JSON.V5);
133    }
134
135    /**
136     * Get the types of JSON messages expected from clients.
137     *
138     * @return the message types
139     * @deprecated since 4.19.2; use {@link #getClientTypes(String)} instead
140     */
141    @Deprecated
142    @Nonnull
143    public synchronized Set<String> getClientTypes() {
144        return getClientTypes(JSON.V5);
145    }
146
147    /**
148     * Get the types of JSON messages this application sends.
149     *
150     * @return the message types
151     * @deprecated since 4.19.2; use {@link #getServerTypes(String)} instead
152     */
153    @Deprecated
154    @Nonnull
155    public synchronized Set<String> getServerTypes() {
156        return getServerTypes(JSON.V5);
157    }
158
159    /**
160     * Get the client schema for JSON messages or for specific JSON data schema.
161     *
162     * @param type    the type; use {@link JSON#JSON} to get the schema for
163     *                messages, or any other value for a data schema
164     * @param request the JSON request
165     * @return the requested schema
166     * @throws JsonException            if unable to get schema due to errors
167     *                                  processing schema
168     * @throws IllegalArgumentException if no JSON service provides schemas for
169     *                                  type
170     */
171    @Nonnull
172    public JsonSchema getClientSchema(@Nonnull String type, @Nonnull JsonRequest request) throws JsonException {
173        return getSchema(type, false, clientSchemas, request);
174    }
175
176    /**
177     * Get the client schema for JSON messages or for specific JSON data schema.
178     *
179     * @param type   the type; use {@link JSON#JSON} to get the schema for
180     *               messages, or any other value for a data schema
181     * @param locale the locale for error messages, if any
182     * @param id     message id set by client
183     * @return the requested schema
184     * @throws JsonException            if unable to get schema due to errors
185     *                                  processing schema
186     * @throws IllegalArgumentException if no JSON service provides schemas for
187     *                                  type
188     * @deprecated since 4.19.2; use
189     *             {@link #getClientSchema(String, JsonRequest)} instead
190     */
191    @Deprecated
192    @Nonnull
193    public JsonSchema getClientSchema(@Nonnull String type, @Nonnull Locale locale, int id) throws JsonException {
194        return getClientSchema(type, new JsonRequest(locale, JSON.V5, JSON.GET, id));
195    }
196
197    /**
198     * Get the server schema for JSON messages or for specific JSON data schema.
199     *
200     * @param type    the type; use {@link JSON#JSON} to get the schema for
201     *                messages, or any other value for a data schema
202     * @param request the JSON request
203     * @return the requested schema
204     * @throws JsonException            if unable to get schema due to errors
205     *                                  processing schema
206     * @throws IllegalArgumentException if no JSON service provides schemas for
207     *                                  type
208     */
209    @Nonnull
210    public JsonSchema getServerSchema(@Nonnull String type, @Nonnull JsonRequest request) throws JsonException {
211        return getSchema(type, true, serverSchemas, request);
212    }
213
214    /**
215     * Get the server schema for JSON messages or for specific JSON data schema.
216     *
217     * @param type   the type; use {@link JSON#JSON} to get the schema for
218     *               messages, or any other value for a data schema
219     * @param locale the locale for error messages, if any
220     * @param id     message id set by client
221     * @return the requested schema
222     * @throws JsonException            if unable to get schema due to errors
223     *                                  processing schema
224     * @throws IllegalArgumentException if no JSON service provides schemas for
225     *                                  type
226     * @deprecated since 4.19.2; use
227     *             {@link #getServerSchema(String, JsonRequest)} instead
228     */
229    @Deprecated
230    @Nonnull
231    public JsonSchema getServerSchema(@Nonnull String type, @Nonnull Locale locale, int id) throws JsonException {
232        return getServerSchema(type, new JsonRequest(locale, JSON.V5, JSON.GET, id));
233    }
234
235    private synchronized JsonSchema getSchema(@Nonnull String type, boolean server,
236            @Nonnull Map<String, Map<String, JsonSchema>> map, @Nonnull JsonRequest request) throws JsonException {
237        cacheServices(request.version);
238        JsonSchema result = map.computeIfAbsent(request.version, v -> new HashMap<>()).get(type);
239        if (result == null) {
240            for (JsonHttpService service : getServices(type, request.version)) {
241                log.debug("Processing {} with {}", type, service);
242                result = JsonSchemaFactory.getInstance()
243                        .getSchema(service.doSchema(type, server, request).path(JSON.DATA).path(JSON.SCHEMA), config);
244                if (result != null) {
245                    map.get(request.version).put(type, result);
246                    break;
247                }
248            }
249            if (result == null) {
250                throw new IllegalArgumentException(
251                        "type \"" + type + "\" is not a valid JSON " + (server ? "server" : "client") + " type");
252            }
253        }
254        return result;
255    }
256
257    /**
258     * Validate a JSON message against the schema for JSON messages and data.
259     *
260     * @param message the message to validate
261     * @param server  true if message is from the JSON server; false otherwise
262     * @param request the JSON request
263     * @throws JsonException if the message does not validate
264     */
265    public void validateMessage(@Nonnull JsonNode message, boolean server, @Nonnull JsonRequest request)
266            throws JsonException {
267        log.trace("validateMessage(\"{}\", \"{}\", \"{}\", ...)", message, server, request);
268        Map<String, Map<String, JsonSchema>> map = server ? serverSchemas : clientSchemas;
269        validateJsonNode(message, JSON.JSON, server, map, request);
270        if (message.isArray()) {
271            for (JsonNode item : message) {
272                validateMessage(item, server, request);
273            }
274        } else {
275            String type = message.path(JSON.TYPE).asText();
276            JsonNode data = message.path(JSON.DATA);
277            if (!data.isMissingNode()) {
278                if (!data.isArray()) {
279                    validateJsonNode(data, type, server, map, request);
280                } else {
281                    validateMessage(data, server, request);
282                }
283            }
284        }
285    }
286
287    /**
288     * Validate a JSON message against the schema for JSON messages and data.
289     *
290     * @param message the message to validate
291     * @param server  true if message is from the JSON server; false otherwise
292     * @param locale  the locale for any exceptions that need to be reported to
293     *                clients
294     * @param id      the id to be included with any exceptions reported to
295     *                clients
296     * @throws JsonException if the message does not validate
297     * @deprecated since 4.19.2; use
298     *             {@link #validateMessage(JsonNode, boolean, JsonRequest)}
299     *             instead
300     */
301    @Deprecated
302    public void validateMessage(@Nonnull JsonNode message, boolean server, @Nonnull Locale locale, int id)
303            throws JsonException {
304        validateMessage(message, server, new JsonRequest(locale, JSON.V5, JSON.GET, id));
305    }
306
307    /**
308     * Validate a JSON data object against the schema for JSON messages and
309     * data.
310     *
311     * @param type    the type of data object
312     * @param data    the data object to validate
313     * @param server  true if message is from the JSON server; false otherwise
314     * @param request the JSON request
315     * @throws JsonException if the message does not validate
316     */
317    public void validateData(@Nonnull String type, @Nonnull JsonNode data, boolean server, @Nonnull JsonRequest request)
318            throws JsonException {
319        log.trace("validateData(\"{}\", \"{}\", \"{}\", \"{}\", ...)", type, data, server, request);
320        Map<String, Map<String, JsonSchema>> map = server ? serverSchemas : clientSchemas;
321        if (data.isArray()) {
322            for (JsonNode item : data) {
323                validateData(type, item, server, request);
324            }
325        } else {
326            validateJsonNode(data, type, server, map, request);
327        }
328    }
329
330    /**
331     * Validate a JSON data object against the schema for JSON messages and
332     * data.
333     *
334     * @param type   the type of data object
335     * @param data   the data object to validate
336     * @param server true if message is from the JSON server; false otherwise
337     * @param locale the locale for any exceptions that need to be reported to
338     *               clients
339     * @param id     the id to be included with any exceptions reported to
340     *               clients
341     * @throws JsonException if the message does not validate
342     * @deprecated since 4.19.2; use
343     *             {@link #validateData(String, JsonNode, boolean, JsonRequest)}
344     *             instead
345     */
346    @Deprecated
347    public void validateData(@Nonnull String type, @Nonnull JsonNode data, boolean server, @Nonnull Locale locale,
348            int id) throws JsonException {
349        validateData(type, data, server, new JsonRequest(locale, JSON.V5, JSON.GET, id));
350    }
351
352    private void validateJsonNode(@Nonnull JsonNode node, @Nonnull String type, boolean server,
353            @Nonnull Map<String, Map<String, JsonSchema>> map, @Nonnull JsonRequest request) throws JsonException {
354        log.trace("validateJsonNode(\"{}\", \"{}\", \"{}\", ...)", node, type, server);
355        Set<ValidationMessage> errors = null;
356        try {
357            errors = getSchema(type, server, map, request).validate(node);
358        } catch (JsonException ex) {
359            log.error("Unable to validate JSON schemas", ex);
360        }
361        if (errors != null && !errors.isEmpty()) {
362            log.warn("Errors validating {}", node);
363            errors.forEach(error -> log.warn("JSON Validation Error: {}\n\t{}\n\t{}\n\t{}", error.getCode(),
364                    error.getMessage(),
365                    error.getPath(), error.getType()));
366            throw new JsonException(server ? 500 : 400, Bundle.getMessage(request.locale, JsonException.LOGGED_ERROR),
367                    request.id);
368        }
369    }
370
371    private void cacheServices(String version) {
372        Set<String> versionedClientTypes = clientTypes.computeIfAbsent(version, v -> new HashSet<>());
373        Set<String> versionedServerTypes = serverTypes.computeIfAbsent(version, v -> new HashSet<>());
374        Map<String, Set<JsonHttpService>> versionedServices =
375                services.computeIfAbsent(version, v -> new HashMap<>());
376        if (versionedServices.isEmpty()) {
377            for (JsonServiceFactory<?, ?> factory : ServiceLoader.load(JsonServiceFactory.class)) {
378                JsonHttpService service = factory.getHttpService(mapper, JSON.V5);
379                for (String type : factory.getTypes(JSON.V5)) {
380                    Set<JsonHttpService> set = versionedServices.computeIfAbsent(type, v -> new HashSet<>());
381                    versionedClientTypes.add(type);
382                    versionedServerTypes.add(type);
383                    set.add(service);
384                }
385                for (String type : factory.getSentTypes(JSON.V5)) {
386                    Set<JsonHttpService> set = versionedServices.computeIfAbsent(type, v -> new HashSet<>());
387                    versionedServerTypes.add(type);
388                    set.add(service);
389                }
390                for (String type : factory.getReceivedTypes(JSON.V5)) {
391                    Set<JsonHttpService> set = versionedServices.computeIfAbsent(type, v -> new HashSet<>());
392                    versionedClientTypes.add(type);
393                    set.add(service);
394                }
395            }
396        }
397    }
398
399    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(JsonSchemaServiceCache.class);
400}