Skip to content

Commit

Permalink
Validate ref (#69)
Browse files Browse the repository at this point in the history
* Validate $ref correctly (#23)
* Inject $schema value into resource definitions during validation
  • Loading branch information
vladtsir authored and RJ Lohan committed Jan 6, 2020
1 parent df118a2 commit bf28c53
Show file tree
Hide file tree
Showing 11 changed files with 348 additions and 45 deletions.
27 changes: 24 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,32 @@
<version>3.12.2</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter -->
<!-- https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>2.22.0</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.1.1</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-params -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.1.1</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-engine -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.5.1</version>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.1.1</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
Expand Down
128 changes: 110 additions & 18 deletions src/main/java/software/amazon/cloudformation/resource/Validator.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,43 +20,70 @@
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.JSONObject;
import org.json.JSONTokener;

import software.amazon.cloudformation.resource.exceptions.ValidationException;

public class Validator implements SchemaValidator {

private static final String JSON_SCHEMA_ID = "https://json-schema.org/draft-07/schema";
private static final URI JSON_SCHEMA_URI_HTTPS = newURI("https://json-schema.org/draft-07/schema");
private static final URI JSON_SCHEMA_URI_HTTP = newURI("http://json-schema.org/draft-07/schema");
private static final URI RESOURCE_DEFINITION_SCHEMA_URI = newURI(
"https://schema.cloudformation.us-east-1.amazonaws.com/provider.definition.schema.v1.json");

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);
}
}

Expand All @@ -68,7 +95,72 @@ public void validateObject(final JSONObject modelObject, final JSONObject defini
* @throws ValidationException Thrown for any schema validation errors
*/
public void validateResourceDefinition(final JSONObject definition) throws ValidationException {
// inject/replace $schema URI to ensure that provider definition schema is used
definition.put("$schema", RESOURCE_DEFINITION_SCHEMA_URI.toString());
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);
loaderBuilder.registerSchemaByURI(RESOURCE_DEFINITION_SCHEMA_URI, 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
// registered twice because we've seen some confusion around this in the past
builder.registerSchemaByURI(JSON_SCHEMA_URI_HTTP, jsonSchemaObject);
builder.registerSchemaByURI(JSON_SCHEMA_URI_HTTPS, jsonSchemaObject);

return builder;
}

private static JSONObject loadResourceAsJSON(String path) {
return new JSONObject(new JSONTokener(Validator.class.getResourceAsStream(path)));
}

/** wrapper around new URI that throws an unchecked exception */
static URI newURI(final String uri) {
try {
return new URI(uri);
} catch (URISyntaxException e) {
throw new RuntimeException(uri);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValidationException> causingExceptions,
final String keyword,
Expand Down
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/schema/schema
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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.times;
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.junit.jupiter.MockitoExtension;

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, times(2)).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() {
return new JSONObject().put("Time", "2019-12-12T10:10:22.212Z");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -333,14 +333,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) {
Expand Down Expand Up @@ -418,4 +410,16 @@ public void validateExample_exampleResource_shouldBeValid() throws IOException {
}
}

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);
}
}
Loading

0 comments on commit bf28c53

Please sign in to comment.