View Javadoc
1   /*
2    * #%L
3    * wcm.io
4    * %%
5    * Copyright (C) 2023 wcm.io
6    * %%
7    * Licensed under the Apache License, Version 2.0 (the "License");
8    * you may not use this file except in compliance with the License.
9    * You may obtain a copy of the License at
10   *
11   *      http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the License is distributed on an "AS IS" BASIS,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the License for the specific language governing permissions and
17   * limitations under the License.
18   * #L%
19   */
20  package io.wcm.siteapi.openapi.validator;
21  
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.net.URL;
25  import java.nio.charset.StandardCharsets;
26  import java.util.Iterator;
27  import java.util.concurrent.ConcurrentHashMap;
28  import java.util.concurrent.ConcurrentMap;
29  import java.util.regex.Pattern;
30  
31  import org.apache.commons.io.IOUtils;
32  import org.jetbrains.annotations.NotNull;
33  import org.jetbrains.annotations.Nullable;
34  import org.openapi4j.core.exception.ResolutionException;
35  import org.openapi4j.core.model.v3.OAI3;
36  import org.openapi4j.core.model.v3.OAI3Context;
37  import org.openapi4j.core.util.TreeUtil;
38  import org.openapi4j.core.validation.ValidationException;
39  import org.openapi4j.core.validation.ValidationResults.ValidationItem;
40  import org.openapi4j.parser.model.v3.OpenApi3;
41  import org.openapi4j.parser.validation.v3.OpenApi3Validator;
42  import org.openapi4j.schema.validator.ValidationContext;
43  import org.openapi4j.schema.validator.v3.SchemaValidator;
44  
45  import com.fasterxml.jackson.databind.JsonNode;
46  import com.fasterxml.jackson.databind.node.MissingNode;
47  
48  /**
49   * Reads and validates an OAS3 YAML specification.
50   * Gives access to {@link OpenApiSchemaValidator} instances for each path/suffix defined in the specification.
51   */
52  public final class OpenApiSpec {
53  
54    private final URL url;
55    private final String version;
56    private final JsonNode rootNode;
57    private final ValidationContext<OAI3> validationContext;
58    private final ConcurrentMap<String, OpenApiSchemaValidator> validators = new ConcurrentHashMap<>();
59  
60    /**
61     * Create instance with given spec files.
62     * @param path Resource Path to OAS3 spec
63     * @param version Spec version or empty string
64     * @throws SpecInvalidException If reading OAS3 spec fails.
65     */
66    public OpenApiSpec(@NotNull String path, @NotNull String version) {
67      this(toUrl(path), version);
68    }
69  
70    private static URL toUrl(@NotNull String path) {
71      URL url = OpenApiSpec.class.getClassLoader().getResource(path);
72      if (url == null) {
73        throw new IllegalArgumentException("File not found in class path: " + path);
74      }
75      return url;
76    }
77  
78    /**
79     * Create instance with given spec files.
80     * @param url URL pointing to OAS3 spec
81     * @param version Spec version or empty string
82     * @throws SpecInvalidException If reading OAS3 spec fails.
83     */
84    public OpenApiSpec(@NotNull URL url, @NotNull String version) {
85      this.url = url;
86      this.version = version;
87      try {
88        String specContent = readFileContent(url);
89        rootNode = TreeUtil.yaml.readTree(specContent);
90        OAI3Context apiContext = new OAI3Context(url, rootNode);
91        validationContext = new ValidationContext<>(apiContext);
92        validateSpec(apiContext, rootNode, url);
93      }
94      catch (IOException | ResolutionException ex) {
95        throw new SpecInvalidException("Unable to load specification " + url + ": " + ex.getMessage(), ex);
96      }
97    }
98  
99    /**
100    * Gets YAML content of spec file.
101    * @param url Spec URL
102    * @return YAML content
103    * @throws IOException I/O exception
104    */
105   private static String readFileContent(@NotNull URL url) throws IOException {
106     try (InputStream is = url.openStream()) {
107       if (is == null) {
108         throw new IllegalArgumentException("File does not exist: " + url);
109       }
110       String json = IOUtils.toString(is, StandardCharsets.UTF_8);
111       /*
112        * Apply hotfix to schema JSON - insert slash before ${contentPath} placeholder.
113        * The original schema is not a valid because the path keys do not start with "/" -
114        * although they actually do when the path parameters are injected, but OAS3 does not
115        * support slashes in path parameters yet.
116        * See https://github.com/OAI/OpenAPI-Specification/issues/892
117        */
118       return json.replace("\"{contentPath}", "\"/{contentPath}");
119     }
120   }
121 
122   /**
123    * Validates the spec for OAS3 conformance.
124    * @param context OAS3 context
125    * @param rootNode Spec root node
126    * @param url Spec URL
127    */
128   @SuppressWarnings("null")
129   private static void validateSpec(OAI3Context context, JsonNode rootNode, URL url) {
130     OpenApi3 api = TreeUtil.json.convertValue(rootNode, OpenApi3.class);
131     api.setContext(context);
132     try {
133       OpenApi3Validator.instance().validate(api);
134     }
135     catch (ValidationException ex) {
136       // put all validation errors in a single message
137       StringBuilder result = new StringBuilder();
138       result.append(ex.getMessage());
139       for (ValidationItem item : ex.results().items()) {
140         result.append("\n").append(item.toString());
141       }
142       throw new SpecInvalidException("Specification is invalid: " + url + " - " + result.toString(), ex);
143     }
144   }
145 
146   /**
147    * @return Specification URL.
148    */
149   public @NotNull URL getURL() {
150     return this.url;
151   }
152 
153   /**
154    * @return Spec version (derived from file name) or empty string.
155    */
156   public @NotNull String getVersion() {
157     return this.version;
158   }
159 
160   /**
161    * Get Schema for default response of operation mapped to given suffix.
162    *
163    * <p>
164    * It looks for a path definition ending with <code>/{suffix}.json</code> in the spec
165    * and returns the JSON schema defined in the YAML for HTTP 200 GET response with <code>application/json</code>
166    * content type.
167    * </p>
168    *
169    * <p>
170    * See <a href=
171    * "https://github.com/wcm-io/io.wcm.site-api.openapi-validator/blob/develop/src/test/resources/site-api-spec/site-api.yaml">site-api.yaml</a>
172    * as minimal example for a valid specification.
173    * </p>
174    *
175    * @param suffix Suffix ID
176    * @return Schema JSON node
177    */
178   public @NotNull OpenApiSchemaValidator getSchemaValidator(@NotNull String suffix) {
179     // cache validators per suffixId in map
180     return validators.computeIfAbsent(suffix, this::buildSchemaValidator);
181   }
182 
183   /**
184    * Get Schema for default response of operation mapped to given suffix.
185    * @param suffix Suffix ID
186    * @return Schema JSON node
187    */
188   private @NotNull OpenApiSchemaValidator buildSchemaValidator(@NotNull String suffix) {
189     JsonNode matchingPath = findMatchingPathNode(suffix);
190     if (matchingPath == null) {
191       throw new IllegalArgumentException("No matching path definition found for suffix: " + suffix);
192     }
193     // ~1 = / in JSON pointer syntax
194     String pointer = "/get/responses/200/content/application~1json/schema";
195     JsonNode schemaNode = matchingPath.at(pointer);
196     if (schemaNode == null || schemaNode instanceof MissingNode) {
197       throw new IllegalArgumentException("No matching JSON schema definition at: " + pointer + ", suffix: " + suffix);
198     }
199     SchemaValidator schemaValidator = new SchemaValidator(validationContext, null, schemaNode);
200     return new OpenApiSchemaValidator(suffix, schemaValidator);
201   }
202 
203   /**
204    * Find a path definition in OAS3 spec that ends with the given suffix extension.
205    * @param suffix Suffix
206    * @return Path node or null
207    */
208   private @Nullable JsonNode findMatchingPathNode(@NotNull String suffix) {
209     Pattern endsWithSuffixPattern = Pattern.compile("^.+/" + Pattern.quote(suffix) + ".json$");
210     JsonNode paths = rootNode.findValue("paths");
211     if (paths != null) {
212       Iterator<String> fieldNames = paths.fieldNames();
213       while (fieldNames.hasNext()) {
214         String path = fieldNames.next();
215         if (endsWithSuffixPattern.matcher(path).matches()) {
216           return paths.findValue(path);
217         }
218       }
219     }
220     return null;
221   }
222 
223   @Override
224   public String toString() {
225     return this.url.toString();
226   }
227 
228 }