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}