OpenApiSpec.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2023 wcm.io
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */
package io.wcm.siteapi.openapi.validator;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.regex.Pattern;

import org.apache.commons.io.IOUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.openapi4j.core.exception.ResolutionException;
import org.openapi4j.core.model.v3.OAI3;
import org.openapi4j.core.model.v3.OAI3Context;
import org.openapi4j.core.util.TreeUtil;
import org.openapi4j.core.validation.ValidationException;
import org.openapi4j.core.validation.ValidationResults.ValidationItem;
import org.openapi4j.parser.model.v3.OpenApi3;
import org.openapi4j.parser.validation.v3.OpenApi3Validator;
import org.openapi4j.schema.validator.ValidationContext;
import org.openapi4j.schema.validator.v3.SchemaValidator;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.MissingNode;

/**
 * Reads and validates an OAS3 YAML specification.
 * Gives access to {@link OpenApiSchemaValidator} instances for each path/suffix defined in the specification.
 */
public final class OpenApiSpec {

  private final URL url;
  private final String version;
  private final JsonNode rootNode;
  private final ValidationContext<OAI3> validationContext;
  private final ConcurrentMap<String, OpenApiSchemaValidator> validators = new ConcurrentHashMap<>();

  /**
   * Create instance with given spec files.
   * @param path Resource Path to OAS3 spec
   * @param version Spec version or empty string
   * @throws SpecInvalidException If reading OAS3 spec fails.
   */
  public OpenApiSpec(@NotNull String path, @NotNull String version) {
    this(toUrl(path), version);
  }

  private static URL toUrl(@NotNull String path) {
    URL url = OpenApiSpec.class.getClassLoader().getResource(path);
    if (url == null) {
      throw new IllegalArgumentException("File not found in class path: " + path);
    }
    return url;
  }

  /**
   * Create instance with given spec files.
   * @param url URL pointing to OAS3 spec
   * @param version Spec version or empty string
   * @throws SpecInvalidException If reading OAS3 spec fails.
   */
  public OpenApiSpec(@NotNull URL url, @NotNull String version) {
    this.url = url;
    this.version = version;
    try {
      String specContent = readFileContent(url);
      rootNode = TreeUtil.yaml.readTree(specContent);
      OAI3Context apiContext = new OAI3Context(url, rootNode);
      validationContext = new ValidationContext<>(apiContext);
      validateSpec(apiContext, rootNode, url);
    }
    catch (IOException | ResolutionException ex) {
      throw new SpecInvalidException("Unable to load specification " + url + ": " + ex.getMessage(), ex);
    }
  }

  /**
   * Gets YAML content of spec file.
   * @param url Spec URL
   * @return YAML content
   * @throws IOException I/O exception
   */
  private static String readFileContent(@NotNull URL url) throws IOException {
    try (InputStream is = url.openStream()) {
      if (is == null) {
        throw new IllegalArgumentException("File does not exist: " + url);
      }
      String json = IOUtils.toString(is, StandardCharsets.UTF_8);
      /*
       * Apply hotfix to schema JSON - insert slash before ${contentPath} placeholder.
       * The original schema is not a valid because the path keys do not start with "/" -
       * although they actually do when the path parameters are injected, but OAS3 does not
       * support slashes in path parameters yet.
       * See https://github.com/OAI/OpenAPI-Specification/issues/892
       */
      return json.replace("\"{contentPath}", "\"/{contentPath}");
    }
  }

  /**
   * Validates the spec for OAS3 conformance.
   * @param context OAS3 context
   * @param rootNode Spec root node
   * @param url Spec URL
   */
  @SuppressWarnings("null")
  private static void validateSpec(OAI3Context context, JsonNode rootNode, URL url) {
    OpenApi3 api = TreeUtil.json.convertValue(rootNode, OpenApi3.class);
    api.setContext(context);
    try {
      OpenApi3Validator.instance().validate(api);
    }
    catch (ValidationException ex) {
      // put all validation errors in a single message
      StringBuilder result = new StringBuilder();
      result.append(ex.getMessage());
      for (ValidationItem item : ex.results().items()) {
        result.append("\n").append(item.toString());
      }
      throw new SpecInvalidException("Specification is invalid: " + url + " - " + result.toString(), ex);
    }
  }

  /**
   * @return Specification URL.
   */
  public @NotNull URL getURL() {
    return this.url;
  }

  /**
   * @return Spec version (derived from file name) or empty string.
   */
  public @NotNull String getVersion() {
    return this.version;
  }

  /**
   * Get Schema for default response of operation mapped to given suffix.
   *
   * <p>
   * It looks for a path definition ending with <code>/{suffix}.json</code> in the spec
   * and returns the JSON schema defined in the YAML for HTTP 200 GET response with <code>application/json</code>
   * content type.
   * </p>
   *
   * <p>
   * See <a href=
   * "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>
   * as minimal example for a valid specification.
   * </p>
   *
   * @param suffix Suffix ID
   * @return Schema JSON node
   */
  public @NotNull OpenApiSchemaValidator getSchemaValidator(@NotNull String suffix) {
    // cache validators per suffixId in map
    return validators.computeIfAbsent(suffix, this::buildSchemaValidator);
  }

  /**
   * Get Schema for default response of operation mapped to given suffix.
   * @param suffix Suffix ID
   * @return Schema JSON node
   */
  private @NotNull OpenApiSchemaValidator buildSchemaValidator(@NotNull String suffix) {
    JsonNode matchingPath = findMatchingPathNode(suffix);
    if (matchingPath == null) {
      throw new IllegalArgumentException("No matching path definition found for suffix: " + suffix);
    }
    // ~1 = / in JSON pointer syntax
    String pointer = "/get/responses/200/content/application~1json/schema";
    JsonNode schemaNode = matchingPath.at(pointer);
    if (schemaNode == null || schemaNode instanceof MissingNode) {
      throw new IllegalArgumentException("No matching JSON schema definition at: " + pointer + ", suffix: " + suffix);
    }
    SchemaValidator schemaValidator = new SchemaValidator(validationContext, null, schemaNode);
    return new OpenApiSchemaValidator(suffix, schemaValidator);
  }

  /**
   * Find a path definition in OAS3 spec that ends with the given suffix extension.
   * @param suffix Suffix
   * @return Path node or null
   */
  private @Nullable JsonNode findMatchingPathNode(@NotNull String suffix) {
    Pattern endsWithSuffixPattern = Pattern.compile("^.+/" + Pattern.quote(suffix) + ".json$");
    JsonNode paths = rootNode.findValue("paths");
    if (paths != null) {
      Iterator<String> fieldNames = paths.fieldNames();
      while (fieldNames.hasNext()) {
        String path = fieldNames.next();
        if (endsWithSuffixPattern.matcher(path).matches()) {
          return paths.findValue(path);
        }
      }
    }
    return null;
  }

  @Override
  public String toString() {
    return this.url.toString();
  }

}