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}