diff --git a/pom.xml b/pom.xml
index 66fe36e..521b03c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -56,11 +56,32 @@
3.12.2
test
-
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 2.22.0
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ 5.1.1
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ 5.1.1
+ test
+
+
org.junit.jupiter
- junit-jupiter
- 5.5.1
+ junit-jupiter-engine
+ 5.1.1
test
diff --git a/src/main/java/software/amazon/cloudformation/resource/Validator.java b/src/main/java/software/amazon/cloudformation/resource/Validator.java
index 09e6439..3ee761d 100644
--- a/src/main/java/software/amazon/cloudformation/resource/Validator.java
+++ b/src/main/java/software/amazon/cloudformation/resource/Validator.java
@@ -20,7 +20,11 @@
import lombok.Builder;
import org.everit.json.schema.Schema;
+import org.everit.json.schema.loader.SchemaClient;
import org.everit.json.schema.loader.SchemaLoader;
+import org.everit.json.schema.loader.SchemaLoader.SchemaLoaderBuilder;
+import org.everit.json.schema.loader.internal.DefaultSchemaClient;
+import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
@@ -28,35 +32,52 @@
public class Validator implements SchemaValidator {
- private static final String JSON_SCHEMA_ID = "https://json-schema.org/draft-07/schema";
+ private static final String ID_KEY = "$id";
private static final String JSON_SCHEMA_PATH = "/schema/schema";
- private static final String METASCHEMA_PATH = "/schema/provider.definition.schema.v1.json";
+ private static final String RESOURCE_DEFINITION_SCHEMA_PATH = "/schema/provider.definition.schema.v1.json";
+ /**
+ * resource definition schema ("resource schema schema"). All resource schemas
+ * are validated against this one and JSON schema draft v7 below.
+ */
private final JSONObject definitionSchemaJsonObject;
+ /**
+ * locally cached draft-07 JSON schema. All resource schemas are validated
+ * against it
+ */
private final JSONObject jsonSchemaObject;
+ /**
+ * this is what SchemaLoader uses to download remote $refs. Not necessarily an
+ * HTTP client, see the docs for details. We override the default SchemaClient
+ * client in unit tests to be able to control how remote refs are resolved.
+ */
+ private final SchemaClient downloader;
+
+ Validator(SchemaClient downloader) {
+ this(loadResourceAsJSON(JSON_SCHEMA_PATH), loadResourceAsJSON(RESOURCE_DEFINITION_SCHEMA_PATH), downloader);
+ }
+
+ private Validator(JSONObject jsonSchema,
+ JSONObject definitionSchema,
+ SchemaClient downloader) {
+ this.jsonSchemaObject = jsonSchema;
+ this.definitionSchemaJsonObject = definitionSchema;
+ this.downloader = downloader;
+ }
@Builder
public Validator() {
- // local copy of the draft-07 schema used to avoid remote reference calls
- jsonSchemaObject = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(JSON_SCHEMA_PATH)));
- definitionSchemaJsonObject = new JSONObject(new JSONTokener(this.getClass().getResourceAsStream(METASCHEMA_PATH)));
+ this(new DefaultSchemaClient());
}
@Override
public void validateObject(final JSONObject modelObject, final JSONObject definitionSchemaObject) throws ValidationException {
+ final SchemaLoaderBuilder loader = getSchemaLoader(definitionSchemaObject);
+
try {
- final URI schemaURI = new URI(JSON_SCHEMA_ID);
- final SchemaLoader loader = SchemaLoader.builder().schemaJson(definitionSchemaObject)
- // registers the local schema with the draft-07 url
- .registerSchemaByURI(schemaURI, jsonSchemaObject).draftV7Support().build();
- final Schema schema = loader.load().build();
-
- try {
- schema.validate(modelObject); // throws a ValidationException if this object is invalid
- } catch (final org.everit.json.schema.ValidationException e) {
- throw ValidationException.newScrubbedException(e);
- }
- } catch (final URISyntaxException e) {
- throw new RuntimeException("Invalid URI format for JSON schema.");
+ final Schema schema = loader.build().load().build();
+ schema.validate(modelObject); // throws a ValidationException if this object is invalid
+ } catch (final org.everit.json.schema.ValidationException e) {
+ throw ValidationException.newScrubbedException(e);
}
}
@@ -69,6 +90,82 @@ public void validateObject(final JSONObject modelObject, final JSONObject defini
*/
public void validateResourceDefinition(final JSONObject definition) throws ValidationException {
validateObject(definition, definitionSchemaJsonObject);
+ // validateObject cannot validate schema-specific attributes. For example if definition
+ // contains "propertyA": { "$ref":"./some-non-existent-location.json#definitions/PropertyX"}
+ // validateObject will succeed, because all it cares about is that "$ref" is a URI
+ // In order to validate that $ref points at an existing location in an existing document
+ // we have to "load" the schema
+ loadResourceSchema(definition);
+ }
+
+ public Schema loadResourceSchema(final JSONObject resourceDefinition) {
+ return getResourceSchemaBuilder(resourceDefinition).build();
+ }
+
+ /**
+ * returns Schema.Builder with pre-loaded JSON draft-07 meta-schema and resource definition meta-schema
+ * (resource.definition.schema.v1.json). Resulting Schema.Builder can be used to build a schema that
+ * can be used to validate parts of CloudFormation template.
+ *
+ * @param resourceDefinition - actual resource definition (not resource definition schema)
+ * @return
+ */
+ public Schema.Builder> getResourceSchemaBuilder(final JSONObject resourceDefinition) {
+ final SchemaLoaderBuilder loaderBuilder = getSchemaLoader(resourceDefinition);
+ registerMetaSchema(loaderBuilder, definitionSchemaJsonObject);
+
+ final SchemaLoader loader = loaderBuilder.build();
+ try {
+ return loader.load();
+ } catch (org.everit.json.schema.SchemaException e) {
+ throw new ValidationException(e.getMessage(), e.getSchemaLocation(), e);
+ }
+ }
+
+ /**
+ * Convenience method - creates a SchemaLoaderBuilder with cached JSON draft-07 meta-schema
+ *
+ * @param schemaObject
+ * @return
+ */
+ private SchemaLoaderBuilder getSchemaLoader(JSONObject schemaObject) {
+ final SchemaLoaderBuilder builder = SchemaLoader
+ .builder()
+ .schemaJson(schemaObject)
+ .draftV7Support()
+ .schemaClient(downloader);
+ // registers the local schema with the draft-07 url
+ registerMetaSchema(builder, jsonSchemaObject);
+ return builder;
}
+ /**
+ * Register a meta-schema with the SchemaLoaderBuilder. The meta-schema $id is used to generate schema URI
+ * This has the effect of caching the meta-schema. When SchemaLoaderBuilder is used to build the Schema object,
+ * the cached version will be used. No calls to remote URLs will be made.
+ * Validator caches JSON schema (/resources/schema) and Resource Definition Schema
+ * (/resources/provider.definition.schema.v1.json)
+ *
+ * @param loaderBuilder
+ * @param schema meta-schema JSONObject to be cached. Must have a valid $id property
+ */
+ void registerMetaSchema(final SchemaLoaderBuilder loaderBuilder, JSONObject schema) {
+ try {
+ String id = schema.getString(ID_KEY);
+ if (id.isEmpty()) {
+ throw new ValidationException("Invalid $id value", "$id", "[empty string]");
+ }
+ final URI uri = new URI(id);
+ loaderBuilder.registerSchemaByURI(uri, schema);
+ } catch (URISyntaxException e) {
+ throw new ValidationException("Invalid $id value", "$id", e);
+ } catch (JSONException e) {
+ // $id is missing or not a string
+ throw new ValidationException("Invalid $id value", "$id", e);
+ }
+ }
+
+ private static JSONObject loadResourceAsJSON(String path) {
+ return new JSONObject(new JSONTokener(Validator.class.getResourceAsStream(path)));
+ }
}
diff --git a/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java b/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java
index 0b553b3..6209804 100644
--- a/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java
+++ b/src/main/java/software/amazon/cloudformation/resource/exceptions/ValidationException.java
@@ -48,6 +48,15 @@ public ValidationException(final String message,
this(message, Collections.emptyList(), keyword, schemaPointer);
}
+ public ValidationException(final String message,
+ final String schemaPointer,
+ final Exception cause) {
+ super(message, cause);
+ this.causingExceptions = Collections.emptyList();
+ this.keyword = "";
+ this.schemaPointer = schemaPointer;
+ }
+
public ValidationException(final String message,
final List causingExceptions,
final String keyword,
diff --git a/src/main/resources/schema/provider.definition.schema.v1.json b/src/main/resources/schema/provider.definition.schema.v1.json
index a656626..12174ba 100644
--- a/src/main/resources/schema/provider.definition.schema.v1.json
+++ b/src/main/resources/schema/provider.definition.schema.v1.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
- "$id": "provider.definition.schema.v1.json",
+ "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/provider.definition.schema.v1.json",
"title": "CloudFormation Resource Provider Definition MetaSchema",
"description": "This schema validates a CloudFormation resource provider definition.",
"definitions": {
diff --git a/src/main/resources/schema/schema b/src/main/resources/schema/schema
index 2261e0d..33627e9 100644
--- a/src/main/resources/schema/schema
+++ b/src/main/resources/schema/schema
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
- "$id": "https://json-schema.org/draft-07/schema#",
+ "$id": "https://json-schema.org/draft-07/schema",
"title": "Core schema meta-schema",
"definitions": {
"schemaArray": {
diff --git a/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java b/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java
new file mode 100644
index 0000000..9a8147f
--- /dev/null
+++ b/src/test/java/software/amazon/cloudformation/resource/ValidatorRefResolutionTests.java
@@ -0,0 +1,126 @@
+/*
+* Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+*
+* Licensed under the Apache License, Version 2.0 (the "License").
+* You may not use this file except in compliance with the License.
+* A copy of the License is located at
+*
+* http://aws.amazon.com/apache2.0
+*
+* or in the "license" file accompanying this file. This file 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.
+*/
+package software.amazon.cloudformation.resource;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static software.amazon.cloudformation.resource.ValidatorTest.loadJSON;
+
+import org.everit.json.schema.Schema;
+import org.everit.json.schema.loader.SchemaClient;
+import org.json.JSONObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.verification.VerificationMode;
+
+import software.amazon.cloudformation.resource.exceptions.ValidationException;
+
+/**
+ *
+ */
+@ExtendWith(MockitoExtension.class)
+public class ValidatorRefResolutionTests {
+
+ public static final String RESOURCE_DEFINITION_PATH = "/valid-with-refs.json";
+ private final static String COMMON_TYPES_PATH = "/common.types.v1.json";
+ private final String expectedRefUrl = "https://schema.cloudformation.us-east-1.amazonaws.com/common.types.v1.json";
+
+ @Mock
+ private SchemaClient downloader;
+ private Validator validator;
+
+ @BeforeEach
+ public void beforeEach() {
+ when(downloader.get(expectedRefUrl)).thenAnswer(x -> ValidatorTest.getResourceAsStream(COMMON_TYPES_PATH));
+
+ this.validator = new Validator(downloader);
+ }
+
+ @Test
+ public void loadResourceSchema_validRelativeRef_shouldSucceed() {
+
+ JSONObject schema = loadJSON(RESOURCE_DEFINITION_PATH);
+ validator.validateResourceDefinition(schema);
+
+ // valid-with-refs.json contains two refs pointing at locations inside
+ // common.types.v1.json
+ // Everit will attempt to download the remote schema once for each $ref - it
+ // doesn't cache
+ // remote schemas. Expect the downloader to be called twice
+ verify(downloader, twice()).get(expectedRefUrl);
+ }
+
+ /**
+ * expect a valid resource schema contains a ref to a non-existent property in a
+ * remote meta-schema
+ */
+ @Test
+ public void loadResourceSchema_invalidRelativeRef_shouldThrow() {
+
+ JSONObject badSchema = loadJSON("/invalid-bad-ref.json");
+
+ assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> {
+ validator.validateResourceDefinition(badSchema);
+ });
+ }
+
+ /** example of using Validator to validate a json data files */
+ @Test
+ public void validateTemplateAgainstResourceSchema_valid_shouldSucceed() {
+
+ JSONObject resourceDefinition = loadJSON(RESOURCE_DEFINITION_PATH);
+ Schema schema = validator.loadResourceSchema(resourceDefinition);
+
+ schema.validate(getSampleTemplate());
+ }
+
+ /**
+ * template that contains an invalid value in one of its properties fails
+ * validation
+ */
+ @Test
+ public void validateTemplateAgainsResourceSchema_invalid_shoudThrow() {
+ JSONObject resourceDefinition = loadJSON(RESOURCE_DEFINITION_PATH);
+ Schema schema = validator.loadResourceSchema(resourceDefinition);
+
+ final JSONObject template = getSampleTemplate();
+ template.put("propertyB", "not.an.IP.address");
+
+ assertThatExceptionOfType(org.everit.json.schema.ValidationException.class).isThrownBy(() -> schema.validate(template));
+ }
+
+ /**
+ * resource schema located at RESOURCE_DEFINITION_PATH declares two properties:
+ * "Time" in ISO 8601 format (UTC only) and "propertyB" - an IP address Both
+ * fields are declares as refs to common.types.v1.json. "Time" is marked as
+ * required property getSampleTemplate constructs a JSON object with a single
+ * Time property.
+ */
+ private JSONObject getSampleTemplate() {
+ final JSONObject template = new JSONObject();
+ template.put("Time", "2019-12-12T10:10:22.212Z");
+ return template;
+ }
+
+ private static VerificationMode twice() {
+ return Mockito.times(2);
+ }
+
+}
diff --git a/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java b/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java
index cee1a36..3bddefe 100644
--- a/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java
+++ b/src/test/java/software/amazon/cloudformation/resource/ValidatorTest.java
@@ -24,6 +24,7 @@
import java.util.Collections;
import java.util.List;
+import org.everit.json.schema.loader.SchemaLoader;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.junit.jupiter.api.BeforeEach;
@@ -35,6 +36,7 @@
import software.amazon.cloudformation.resource.exceptions.ValidationException;
public class ValidatorTest {
+ private static final String RESOURCE_DEFINITION_SCHEMA_PATH = "/schema/provider.definition.schema.v1.json";
private static final String TEST_SCHEMA_PATH = "/test-schema.json";
private static final String TEST_VALUE_SCHEMA_PATH = "/scrubbed-values-schema.json";
private static final String TYPE_NAME_KEY = "typeName";
@@ -333,14 +335,6 @@ public void validateDefinition_validHandlerSection_shouldNotThrow() {
validator.validateResourceDefinition(definition);
}
- @Test
- public void validateDefinition_validRelativeRef_shouldNotThrow() {
- final JSONObject definition = new JSONObject(new JSONTokener(this.getClass()
- .getResourceAsStream("/valid-with-relative-ref.json")));
-
- validator.validateResourceDefinition(definition);
- }
-
@ParameterizedTest
@ValueSource(strings = { "ftp://example.com", "http://example.com", "git://example.com", "https://", })
public void validateDefinition_nonMatchingDocumentationUrl_shouldThrow(final String documentationUrl) {
@@ -418,4 +412,42 @@ public void validateExample_exampleResource_shouldBeValid() throws IOException {
}
}
+ /**
+ * trivial coverage test: cannot cache a schema if it has an invalid $id
+ */
+ @ParameterizedTest
+ @ValueSource(strings = { ":invalid/uri", "" })
+ public void registerMetaSchema_invalidRelativeRef_shouldThrow(String uri) {
+
+ JSONObject badSchema = loadJSON(RESOURCE_DEFINITION_SCHEMA_PATH);
+ badSchema.put("$id", uri);
+ assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> {
+ validator.registerMetaSchema(SchemaLoader.builder(), badSchema);
+ });
+ }
+
+ /**
+ * trivial coverage test: cannot cache a schema if it has no $id
+ */
+ @Test
+ public void registerMetaSchema_nullId_shouldThrow() {
+ JSONObject badSchema = loadJSON(RESOURCE_DEFINITION_SCHEMA_PATH);
+ badSchema.remove("$id");
+ assertThatExceptionOfType(ValidationException.class).isThrownBy(() -> {
+ validator.registerMetaSchema(SchemaLoader.builder(), badSchema);
+ });
+ }
+
+ static JSONObject loadJSON(String path) {
+ try {
+ return new JSONObject(new JSONTokener(ValidatorTest.getResourceAsStream(path)));
+ } catch (Throwable ex) {
+ System.out.println("path: " + path);
+ throw ex;
+ }
+ }
+
+ static InputStream getResourceAsStream(String path) {
+ return ValidatorRefResolutionTests.class.getResourceAsStream(path);
+ }
}
diff --git a/src/test/resources/common.types.v1.json b/src/test/resources/common.types.v1.json
new file mode 100644
index 0000000..17b5319
--- /dev/null
+++ b/src/test/resources/common.types.v1.json
@@ -0,0 +1,34 @@
+{
+ "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/common.types.v1.json",
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Common Resource Type Schemas",
+ "definitions": {
+ "cidrBlock": {
+ "$comment": "TODO: regex could be more strict, for example this allows the cidr 999.999.999.999 and 999.999.999.999/32",
+ "description": "Classless Inter-Domain Routing (CIDR) block",
+ "type": "string",
+ "pattern": "^([0-9]{1,3}.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$"
+ },
+ "ipV6CidrBlock": {
+ "$comment": "TODO: find sane regex for this",
+ "description": "IPV6 Classless Inter-Domain Routing (CIDR) block",
+ "type": "string"
+ },
+ "iso8601UTC": {
+ "description": "The date value in ISO 8601 format. The timezone is always UTC. (YYYY-MM-DDThh:mm:ssZ)",
+ "type": "string",
+ "pattern": "^([0-2]\\d{3})-(0[0-9]|1[0-2])-([0-2]\\d|3[01])T([01]\\d|2[0-4]):([0-5]\\d):([0-6]\\d)((\\.\\d{3})?)Z$"
+ },
+ "ipv4Address": {
+ "description": "IPV4 address",
+ "type": "string"
+ },
+ "ipv6Address": {
+ "description": "IPV6 address",
+ "type": "string"
+ }
+ },
+ "primaryIdentifier": [
+ "#/"
+ ]
+}
diff --git a/src/test/resources/invalid-bad-ref.json b/src/test/resources/invalid-bad-ref.json
new file mode 100644
index 0000000..09006e4
--- /dev/null
+++ b/src/test/resources/invalid-bad-ref.json
@@ -0,0 +1,21 @@
+{
+ "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/resource-definition-with-refs.json",
+ "typeName": "CFN::Test::Resource",
+ "description": "propertyB definition uses a ref pointer to a non-existent remote schema location",
+ "sourceUrl": "https://mycorp.com/my-repo.git",
+ "properties": {
+ "Time": {
+ "$ref": "./common.types.v1.json#/definitions/iso8601UTC"
+ },
+ "propertyB": {
+ "type": "array",
+ "items": {
+ "$ref": "./common.types.v1.json#/definitions/badProperty"
+ }
+ }
+ },
+ "primaryIdentifier": [
+ "/properties/Time"
+ ],
+ "additionalProperties": false
+}
diff --git a/src/test/resources/valid-with-refs.json b/src/test/resources/valid-with-refs.json
new file mode 100644
index 0000000..1cb1606
--- /dev/null
+++ b/src/test/resources/valid-with-refs.json
@@ -0,0 +1,22 @@
+{
+ "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/resource-definition-with-refs.json",
+ "$schema": "https://schema.cloudformation.us-east-1.amazonaws.com/provider.definition.schema.v1.json",
+ "typeName": "CFN::Test::Resource",
+ "description": "Simple resource definition with ref pointers to definitions in a remote schema",
+ "sourceUrl": "https://mycorp.com/my-repo.git",
+ "properties": {
+ "Time": {
+ "$ref": "./common.types.v1.json#/definitions/iso8601UTC"
+ },
+ "propertyB": {
+ "type": "array",
+ "items": {
+ "$ref": "./common.types.v1.json#/definitions/ipv4Address"
+ }
+ }
+ },
+ "primaryIdentifier": [
+ "/properties/Time"
+ ],
+ "additionalProperties": false
+}
diff --git a/src/test/resources/valid-with-relative-ref.json b/src/test/resources/valid-with-relative-ref.json
deleted file mode 100644
index 9c36a2b..0000000
--- a/src/test/resources/valid-with-relative-ref.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "$id": "https://schema.cloudformation.us-east-1.amazonaws.com/valid-with-relative-ref.json#",
- "typeName": "AWS::Test::WithExternalRefs",
- "description": "A test schema for unit tests.",
- "properties": {
- "StringProperty": {
- "$ref": "aws-ecs-taskdefinition.json#/properties/blah"
- }
- },
- "additionalProperties": false,
- "primaryIdentifier": [
- "/properties/StringProperty"
- ]
-}