Skip to content

Commit

Permalink
Fix ResourceTypeSchema for schemas containing oneOf and conditionals (a…
Browse files Browse the repository at this point in the history
  • Loading branch information
vladtsir committed Feb 2, 2020
1 parent 6452ea1 commit f70b161
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 106 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,33 +25,36 @@

import org.everit.json.schema.JSONPointer;
import org.everit.json.schema.JSONPointerException;
import org.everit.json.schema.ObjectSchema;
import org.everit.json.schema.PublicJSONPointer;
import org.everit.json.schema.loader.SchemaLoader;
import org.everit.json.schema.Schema;
import org.json.JSONObject;
import org.json.JSONTokener;

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

@Getter
public class ResourceTypeSchema extends ObjectSchema {
public class ResourceTypeSchema {

private static final Validator VALIDATOR = new Validator();

private final Map<String, Object> unprocessedProperties = new HashMap<>();

private final String sourceUrl;
private final String documentationUrl;
private final String typeName;
private final String schemaUrl; // $schema

private final List<JSONPointer> createOnlyProperties = new ArrayList<>();
private final List<JSONPointer> deprecatedProperties = new ArrayList<>();
private final List<JSONPointer> primaryIdentifier = new ArrayList<>();
private final List<List<JSONPointer>> additionalIdentifiers = new ArrayList<>();
private final List<JSONPointer> readOnlyProperties = new ArrayList<>();
private final List<JSONPointer> writeOnlyProperties = new ArrayList<>();
private final Schema schema;

public ResourceTypeSchema(final ObjectSchema.Builder builder) {
super(builder);
public ResourceTypeSchema(Schema schema) {

super.getUnprocessedProperties().forEach(this.unprocessedProperties::put);
this.schema = schema;
schema.getUnprocessedProperties().forEach(this.unprocessedProperties::put);

this.sourceUrl = this.unprocessedProperties.containsKey("sourceUrl")
? this.unprocessedProperties.get("sourceUrl").toString()
Expand All @@ -67,6 +70,11 @@ public ResourceTypeSchema(final ObjectSchema.Builder builder) {
this.typeName = this.unprocessedProperties.get("typeName").toString();
this.unprocessedProperties.remove("typeName");

this.schemaUrl = this.unprocessedProperties.containsKey("$schema")
? this.unprocessedProperties.get("$schema").toString()
: null;
this.unprocessedProperties.remove("$schema");

this.unprocessedProperties.computeIfPresent("createOnlyProperties", (k, v) -> {
((ArrayList<?>) v).forEach(p -> this.createOnlyProperties.add(new JSONPointer(p.toString())));
return null;
Expand Down Expand Up @@ -102,19 +110,14 @@ public ResourceTypeSchema(final ObjectSchema.Builder builder) {
});
}

public static ResourceTypeSchema load(final JSONObject schemaJson) {
// first validate incoming resource schema against definition schema
Validator.builder().build().validateObject(schemaJson, new JSONObject(new JSONTokener(ResourceTypeSchema.class
.getResourceAsStream(SchemaValidator.DEFINITION_SCHEMA_PATH))));
public static ResourceTypeSchema load(final JSONObject resourceDefinition) {

// now extract identifiers from resource schema
final SchemaLoader loader = SchemaLoader.builder().schemaJson(schemaJson)
// registers the local schema with the draft-07 url
.draftV7Support().build();

final ObjectSchema.Builder builder = (ObjectSchema.Builder) loader.load();
Schema schema = VALIDATOR.loadResourceDefinitionSchema(resourceDefinition);
return new ResourceTypeSchema(schema);
}

return new ResourceTypeSchema(builder);
public String getDescription() {
return schema.getDescription();
}

public List<String> getCreateOnlyPropertiesAsStrings() throws ValidationException {
Expand Down Expand Up @@ -143,7 +146,6 @@ public List<String> getWriteOnlyPropertiesAsStrings() throws ValidationException
return this.writeOnlyProperties.stream().map(JSONPointer::toString).collect(Collectors.toList());
}

@Override
public Map<String, Object> getUnprocessedProperties() {
return Collections.unmodifiableMap(this.unprocessedProperties);
}
Expand Down Expand Up @@ -171,4 +173,8 @@ public static void removeProperty(final PublicJSONPointer property, final JSONOb
// do nothing, as this indicates the model does not have a value for the pointer
}
}

public void validate(JSONObject json) {
getSchema().validate(json);
}
}
106 changes: 75 additions & 31 deletions src/main/java/software/amazon/cloudformation/resource/Validator.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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;

Expand All @@ -35,7 +36,7 @@ public class Validator implements SchemaValidator {
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 ID_KEY = "$id";
private static final String JSON_SCHEMA_PATH = "/schema/schema";
private static final String RESOURCE_DEFINITION_SCHEMA_PATH = "/schema/provider.definition.schema.v1.json";

Expand All @@ -50,7 +51,6 @@ public class Validator implements SchemaValidator {
* 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
Expand All @@ -75,6 +75,15 @@ public Validator() {
this(new DefaultSchemaClient());
}

/**
* builds a Schema instance that can be used to validate Resource Definition Schema as a JSON object
*/
private Schema makeResourceDefinitionSchema() {
SchemaLoaderBuilder builder = getSchemaLoader();
builder.schemaJson(definitionSchemaJsonObject);
return builder.build().load().build();
}

@Override
public void validateObject(final JSONObject modelObject, final JSONObject definitionSchemaObject) throws ValidationException {
final SchemaLoaderBuilder loader = getSchemaLoader(definitionSchemaObject);
Expand All @@ -88,44 +97,49 @@ public void validateObject(final JSONObject modelObject, final JSONObject defini
}

/**
* Perform JSON Schema validation for the input resource definition against the
* Performs JSON Schema validation for the input resource definition against the
* resource provider definition schema
*
* @param definition JSON-encoded resource definition
* @param resourceDefinition JSON-encoded resource definition
* @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();
void validateResourceDefinition(final JSONObject resourceDefinition) {
// loading resource definition always performs validation
loadResourceDefinitionSchema(resourceDefinition);
}

/**
* 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.
* create a Schema instance that can be used to validate CloudFormation resources.
*
* @param resourceDefinition - actual resource definition (not resource definition schema)
* @return
* @param resourceDefinition - CloudFormation Resource Provider Schema (Resource Definition)
* @throws ValidationException if supplied <code>resourceDefinition</code> is invalid.
* @return - Schema instance for the given Resource Definition
*/
public Schema.Builder<?> getResourceSchemaBuilder(final JSONObject resourceDefinition) {
final SchemaLoaderBuilder loaderBuilder = getSchemaLoader(resourceDefinition);
loaderBuilder.registerSchemaByURI(RESOURCE_DEFINITION_SCHEMA_URI, definitionSchemaJsonObject);
public Schema loadResourceDefinitionSchema(final JSONObject resourceDefinition) {

// inject/replace $schema URI to ensure that provider definition schema is used
resourceDefinition.put("$schema", RESOURCE_DEFINITION_SCHEMA_URI.toString());

final SchemaLoader loader = loaderBuilder.build();
try {
return loader.load();
} catch (org.everit.json.schema.SchemaException e) {
// step 1: validate resourceDefinition as a JSON object
// this validator 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
Schema resourceDefValidator = makeResourceDefinitionSchema();
resourceDefValidator.validate(resourceDefinition);

// step 2: load resource definition as a Schema that can be used to validate resource models;
// definitionSchemaJsonObject becomes a meta-schema
SchemaLoaderBuilder builder = getSchemaLoader();
registerMetaSchema(builder, jsonSchemaObject);
builder.schemaJson(resourceDefinition);
// when resource definition is loaded as a schema, $refs are resolved and validated
return builder.build().load().build();
} catch (final org.everit.json.schema.ValidationException e) {
throw ValidationException.newScrubbedException(e);
} catch (final org.everit.json.schema.SchemaException e) {
throw new ValidationException(e.getMessage(), e.getSchemaLocation(), e);
}
}
Expand All @@ -137,14 +151,18 @@ public Schema.Builder<?> getResourceSchemaBuilder(final JSONObject resourceDefin
* @return
*/
private SchemaLoaderBuilder getSchemaLoader(JSONObject schemaObject) {
return getSchemaLoader().schemaJson(schemaObject);
}

/** get schema-builder preloaded with JSON draft V7 meta-schema */
private SchemaLoaderBuilder getSchemaLoader() {
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
// draftV7 schema is registered twice because - once for HTTP and once for HTTPS URIs
builder.registerSchemaByURI(JSON_SCHEMA_URI_HTTP, jsonSchemaObject);
builder.registerSchemaByURI(JSON_SCHEMA_URI_HTTPS, jsonSchemaObject);

Expand All @@ -163,4 +181,30 @@ static URI newURI(final String uri) {
throw new RuntimeException(uri);
}
}

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

0 comments on commit f70b161

Please sign in to comment.