diff --git a/schema-validator/src/main/java/com/networknt/aws/lambda/validator/SchemaValidator.java b/schema-validator/src/main/java/com/networknt/aws/lambda/validator/SchemaValidator.java index 52ed649..36791a0 100644 --- a/schema-validator/src/main/java/com/networknt/aws/lambda/validator/SchemaValidator.java +++ b/schema-validator/src/main/java/com/networknt/aws/lambda/validator/SchemaValidator.java @@ -70,12 +70,15 @@ public SchemaValidator() { /** * Build a new validator for the given API specification. + * Components are snapshotted at construction. Later changes to the API overlay are not observed; + * construct a new validator after changing the specification. * * @param api The API to build the validator for. If provided, is used to retrieve schemas in components * for use in references. */ public SchemaValidator(final OpenApi3 api) { - this.jsonNode = api == null ? null : Overlay.toJson((OpenApi3Impl)api).get(COMPONENTS_FIELD); + JsonNode components = api == null ? null : Overlay.toJson((OpenApi3Impl)api).get(COMPONENTS_FIELD); + this.jsonNode = components == null ? null : components.deepCopy(); this.pathType = PathType.JSON_POINTER; } @@ -114,10 +117,11 @@ private Status doValidate(final JsonNode value, final JsonNode schema, boolean t List processingReport = null; try { + JsonNode validationSchema = schema.deepCopy(); if(jsonNode != null) { - ((ObjectNode)schema).set(COMPONENTS_FIELD, jsonNode); + ((ObjectNode)validationSchema).set(COMPONENTS_FIELD, jsonNode.deepCopy()); } - Schema jsonSchema = getRegistry(typeLoose, nullableKeywordEnabled, validationPathType).getSchema(schema); + Schema jsonSchema = getRegistry(typeLoose, nullableKeywordEnabled, validationPathType).getSchema(validationSchema); processingReport = jsonSchema.validate(value); } catch (Exception e) { LOG.error("Unable to validate the value against the OpenAPI schema", e); diff --git a/schema-validator/src/test/java/com/networknt/aws/lambda/validator/SchemaIsolationTest.java b/schema-validator/src/test/java/com/networknt/aws/lambda/validator/SchemaIsolationTest.java new file mode 100644 index 0000000..9906723 --- /dev/null +++ b/schema-validator/src/test/java/com/networknt/aws/lambda/validator/SchemaIsolationTest.java @@ -0,0 +1,87 @@ +package com.networknt.aws.lambda.validator; + +import com.networknt.jackson.JacksonDefaults; +import com.networknt.oas.OpenApiParser; +import com.networknt.oas.model.OpenApi3; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SchemaIsolationTest { + private static OpenApi3 api(String type) throws Exception { + // Resolution base for the inline document; the parser does not read this file. + var documentUrl = Path.of("schema-isolation.json").toUri().toURL(); + return (OpenApi3) new OpenApiParser().parse(""" + {"openapi":"3.0.3","info":{"title":"isolation","version":"1"}, + "paths":{},"components":{"schemas":{"Value":{"type":"%s"}}}} + """.formatted(type), documentUrl, false); + } + + private static JsonNode reference() { + return JacksonDefaults.mapper().readTree("{\"$ref\":\"#/components/schemas/Value\"}"); + } + + @Test + void validationNeverAddsComponentsToTheCallerSchema() throws Exception { + var validator = new SchemaValidator(api("integer")); + var schema = reference(); + var original = schema.deepCopy(); + assertNull(validator.validate(JacksonDefaults.mapper().readTree("7"), schema, false, false)); + assertNotNull(validator.validate(JacksonDefaults.mapper().readTree("\"invalid\""), schema, false, false)); + assertEquals(original, schema); + assertFalse(schema.has("components")); + } + + @Test + void callerOwnedComponentsRemainUnchanged() throws Exception { + var schema = JacksonDefaults.mapper().readTree(""" + {"$ref":"#/components/schemas/Value", + "components":{"schemas":{"Value":{"type":"string"}}}} + """); + var original = schema.deepCopy(); + var validator = new SchemaValidator(api("integer")); + // API components still take precedence for this validation, as before. + assertNull(validator.validate(JacksonDefaults.mapper().readTree("7"), schema, false, false)); + assertEquals(original, schema); + } + + @Test + void concurrentValidationKeepsCallerInputsIsolated() throws Exception { + var integerValidator = new SchemaValidator(api("integer")); + var stringValidator = new SchemaValidator(api("string")); + var schema = reference(); + var original = schema.deepCopy(); + var number = JacksonDefaults.mapper().readTree("7"); + var text = JacksonDefaults.mapper().readTree("\"7\""); + var tasks = new ArrayList>(); + for (int i = 0; i < 40; i++) { + tasks.add(() -> { + assertNull(integerValidator.validate(number, schema, false, false)); + assertNotNull(stringValidator.validate(number, schema, false, false)); + assertNull(stringValidator.validate(text, schema, false, false)); + assertNotNull(integerValidator.validate(text, schema, false, false)); + assertNull(integerValidator.validate(text, schema, true, false)); + return null; + }); + } + var executor = Executors.newFixedThreadPool(4); + try { + for (var future : executor.invokeAll(tasks, 10, TimeUnit.SECONDS)) future.get(1, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS)); + } + assertEquals(original, schema); + } +}