diff --git a/src/main/java/com/networknt/schema/SchemaRegistry.java b/src/main/java/com/networknt/schema/SchemaRegistry.java index 38ed062b1..a4e4a9df0 100644 --- a/src/main/java/com/networknt/schema/SchemaRegistry.java +++ b/src/main/java/com/networknt/schema/SchemaRegistry.java @@ -469,9 +469,10 @@ public SchemaLoader getSchemaLoader() { * @return the schema */ protected Schema newSchema(SchemaLocation schemaUri, JsonNode schemaNode) { + SchemaLocation schemaLocation = getSchemaLocation(schemaUri); + validateSchemaNodeNotNull(schemaLocation, schemaNode); final SchemaContext schemaContext = createSchemaContext(schemaNode); - Schema jsonSchema = doCreate(schemaContext, getSchemaLocation(schemaUri), - schemaNode, null, false); + Schema jsonSchema = doCreate(schemaContext, schemaLocation, schemaNode, null, false); preload(jsonSchema); return jsonSchema; } @@ -507,10 +508,46 @@ public Schema create(SchemaContext schemaContext, SchemaLocation schemaLocation, private Schema doCreate(SchemaContext schemaContext, SchemaLocation schemaLocation, JsonNode schemaNode, Schema parentSchema, boolean suppressSubSchemaRetrieval) { - return Schema.from(withDialect(schemaContext, schemaNode), schemaLocation, schemaNode, + return doCreate(schemaContext, schemaLocation, schemaNode, parentSchema, suppressSubSchemaRetrieval, true); + } + + private Schema doCreate(SchemaContext schemaContext, SchemaLocation schemaLocation, + JsonNode schemaNode, Schema parentSchema, boolean suppressSubSchemaRetrieval, + boolean validateSchemaNodeType) { + validateSchemaNodeNotNull(schemaLocation, schemaNode); + SchemaContext schemaContextToUse = withDialect(schemaContext, schemaNode); + if (validateSchemaNodeType) { + validateSchemaNodeType(schemaLocation, schemaNode, schemaContextToUse); + } + return Schema.from(schemaContextToUse, schemaLocation, schemaNode, parentSchema, suppressSubSchemaRetrieval); } + private void validateSchemaNodeNotNull(SchemaLocation schemaLocation, JsonNode schemaNode) { + if (schemaNode == null) { + throw new SchemaException("Schema at " + schemaLocation + " must not be null"); + } + } + + private void validateSchemaNodeType(SchemaLocation schemaLocation, JsonNode schemaNode, + SchemaContext schemaContext) { + if (schemaNode.isObject()) { + return; + } + if (schemaNode.isBoolean() && supportsBooleanSchema(schemaContext)) { + return; + } + String expected = supportsBooleanSchema(schemaContext) ? "object or boolean" : "object"; + throw new SchemaException("Schema at " + schemaLocation + " must be " + expected + " but was " + + schemaNode.getNodeType()); + } + + private boolean supportsBooleanSchema(SchemaContext schemaContext) { + return schemaContext != null + && schemaContext.getDialect().getSpecificationVersion().getOrder() >= SpecificationVersion.DRAFT_6 + .getOrder(); + } + /** * Determines the schema context to use for the schema given the parent schema * context. @@ -657,6 +694,7 @@ public Schema getSchema(final InputStream schemaStream, InputFormat inputFormat) */ public Schema getSchema(final SchemaLocation schemaUri) { Schema schema = loadSchema(schemaUri); + validateSchemaNodeType(schema.getSchemaLocation(), schema.getSchemaNode(), schema.getSchemaContext()); preload(schema); return schema; } @@ -722,6 +760,20 @@ public Schema getSchema(final JsonNode jsonNode) { * @return the schema */ public Schema loadSchema(final SchemaLocation schemaUri) { + return loadSchema(schemaUri, true); + } + + /** + * Loads the schema. + * + * @param schemaUri the absolute IRI of the schema which can map to the + * retrieval IRI. + * @param validateLoadedSchema true to validate that the loaded node is a schema; + * false when loading a document container to resolve a + * fragment before using it as a schema + * @return the schema + */ + public Schema loadSchema(final SchemaLocation schemaUri, boolean validateLoadedSchema) { if (schemaCacheEnabled) { // ConcurrentHashMap computeIfAbsent does not allow calls that result in a // recursive update to the map. @@ -731,19 +783,28 @@ public Schema loadSchema(final SchemaLocation schemaUri) { synchronized (this) { // acquire lock on shared registry object to prevent deadlock cachedUriSchema = schemaCache.get(schemaUri); if (cachedUriSchema == null) { - cachedUriSchema = getMappedSchema(schemaUri); + cachedUriSchema = validateLoadedSchema ? getMappedSchema(schemaUri) + : getMappedSchema(schemaUri, false); if (cachedUriSchema != null) { schemaCache.put(schemaUri, cachedUriSchema); } } } } + if (validateLoadedSchema && cachedUriSchema != null) { + validateSchemaNodeType(cachedUriSchema.getSchemaLocation(), cachedUriSchema.getSchemaNode(), + cachedUriSchema.getSchemaContext()); + } return cachedUriSchema; } - return getMappedSchema(schemaUri); + return validateLoadedSchema ? getMappedSchema(schemaUri) : getMappedSchema(schemaUri, false); } protected Schema getMappedSchema(final SchemaLocation schemaUri) { + return getMappedSchema(schemaUri, true); + } + + protected Schema getMappedSchema(final SchemaLocation schemaUri, boolean validateLoadedSchema) { InputStreamSource inputStreamSource = this.schemaLoader.getSchemaResource(schemaUri.getAbsoluteIri()); if (inputStreamSource != null) { try (InputStream inputStream = inputStreamSource.getInputStream()) { @@ -762,13 +823,13 @@ protected Schema getMappedSchema(final SchemaLocation schemaUri) { // Schema without fragment SchemaContext schemaContext = new SchemaContext(dialect, this); return doCreate(schemaContext, schemaUri, schemaNode, null, - true /* retrieved via id, resolving will not change anything */); + true /* retrieved via id, resolving will not change anything */, validateLoadedSchema); } else { // Schema with fragment pointing to sub schema final SchemaContext schemaContext = createSchemaContext(schemaNode); SchemaLocation documentLocation = new SchemaLocation(schemaUri.getAbsoluteIri()); Schema document = doCreate(schemaContext, documentLocation, schemaNode, null, - false); + false, false); return document.getRefSchema(schemaUri.getFragment()); } } catch (IOException e) { diff --git a/src/main/java/com/networknt/schema/keyword/RefValidator.java b/src/main/java/com/networknt/schema/keyword/RefValidator.java index a769399a0..b8fe4c19a 100644 --- a/src/main/java/com/networknt/schema/keyword/RefValidator.java +++ b/src/main/java/com/networknt/schema/keyword/RefValidator.java @@ -60,28 +60,30 @@ static SchemaRef getRefSchema(Schema parentSchema, SchemaContext schemaContext, refUri = refValue; } - // This will determine the correct absolute uri for the refUri. This decision will take into - // account the current uri of the parent schema. - String schemaUriFinal = resolve(parentSchema, refUri); - SchemaLocation schemaLocation = SchemaLocation.of(schemaUriFinal); - // This should retrieve schemas regardless of the protocol that is in the uri. - return new SchemaRef(getSupplier(() -> { - Schema schemaResource = schemaContext.getSchemaResources().get(schemaUriFinal); - if (schemaResource == null) { - schemaResource = schemaContext.getSchemaRegistry().loadSchema(schemaLocation); - if (schemaResource != null) { - copySchemaResources(schemaContext, schemaResource); - } - } + // This will determine the correct absolute uri for the refUri. This decision will take into + // account the current uri of the parent schema. + String schemaUriFinal = resolve(parentSchema, refUri); + SchemaLocation schemaLocation = SchemaLocation.of(schemaUriFinal); + String fragment = index < 0 ? null : refValue.substring(index); + boolean validateLoadedSchema = fragment == null || !SchemaLocation.Fragment.isJsonPointerFragment(fragment); + // This should retrieve schemas regardless of the protocol that is in the uri. + return new SchemaRef(getSupplier(() -> { + Schema schemaResource = schemaContext.getSchemaResources().get(schemaUriFinal); + if (schemaResource == null) { + schemaResource = schemaContext.getSchemaRegistry().loadSchema(schemaLocation, validateLoadedSchema); + if (schemaResource != null) { + copySchemaResources(schemaContext, schemaResource); + } + } if (index < 0) { if (schemaResource == null) { return null; - } - return schemaResource; - } else { - String newRefValue = refValue.substring(index); - String find = schemaLocation.getAbsoluteIri() + newRefValue; - Schema findSchemaResource = schemaContext.getSchemaResources().get(find); + } + return schemaResource; + } else { + String newRefValue = fragment; + String find = schemaLocation.getAbsoluteIri() + newRefValue; + Schema findSchemaResource = schemaContext.getSchemaResources().get(find); if (findSchemaResource == null) { findSchemaResource = schemaContext.getDynamicAnchors().get(find); } diff --git a/src/test/java/com/networknt/schema/DependentRequiredTest.java b/src/test/java/com/networknt/schema/DependentRequiredTest.java index 55a68b35e..bc39ff6ec 100644 --- a/src/test/java/com/networknt/schema/DependentRequiredTest.java +++ b/src/test/java/com/networknt/schema/DependentRequiredTest.java @@ -19,8 +19,8 @@ class DependentRequiredTest { " \"$schema\":\"https://json-schema.org/draft/2019-09/schema\"," + " \"type\": \"object\"," + " \"properties\": {" + - " \"optional\": \"string\"," + - " \"requiredWhenOptionalPresent\": \"string\"" + + " \"optional\": { \"type\": \"string\" }," + + " \"requiredWhenOptionalPresent\": { \"type\": \"string\" }" + " }," + " \"dependentRequired\": {" + " \"optional\": [ \"requiredWhenOptionalPresent\" ]," + @@ -63,4 +63,4 @@ private static List whenValidate(String content) throws JacksonException return schema.validate(mapper.readTree(content)); } -} \ No newline at end of file +} diff --git a/src/test/java/com/networknt/schema/Issue1174Test.java b/src/test/java/com/networknt/schema/Issue1174Test.java new file mode 100644 index 000000000..7e1d6225c --- /dev/null +++ b/src/test/java/com/networknt/schema/Issue1174Test.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 the original author or authors. + * + * 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. + */ +package com.networknt.schema; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.node.JsonNodeFactory; + +class Issue1174Test { + @Test + void textNodeShouldNotBeAcceptedAsRootSchema() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7); + + SchemaException exception = assertThrows(SchemaException.class, + () -> registry.getSchema(JsonNodeFactory.instance.textNode("false"))); + + assertTrue(exception.getMessage().contains("must be object or boolean")); + assertTrue(exception.getMessage().contains("STRING")); + } + + @Test + void textNodeContainingJsonShouldNotBeAcceptedAsRootSchema() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7); + + SchemaException exception = assertThrows(SchemaException.class, + () -> registry.getSchema(JsonNodeFactory.instance.textNode("{\"type\":\"string\"}"))); + + assertTrue(exception.getMessage().contains("must be object or boolean")); + assertTrue(exception.getMessage().contains("STRING")); + } + + @Test + void textNodeShouldNotBeAcceptedAsLoadedRootSchema() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7, + builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\""))); + + SchemaException exception = assertThrows(SchemaException.class, + () -> registry.getSchema(SchemaLocation.of("https://www.example.org/text-schema"))); + + assertTrue(exception.getMessage().contains("must be object or boolean")); + assertTrue(exception.getMessage().contains("STRING")); + } + + @Test + void textNodeShouldNotBeAcceptedAsReferencedRootSchema() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7, + builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\""))); + Schema schema = registry.getSchema("{\"$ref\":\"https://www.example.org/text-schema\"}"); + + SchemaException exception = assertThrows(SchemaException.class, () -> schema.validate("42", InputFormat.JSON)); + + assertTrue(exception.getMessage().contains("must be object or boolean")); + assertTrue(exception.getMessage().contains("STRING")); + } + + @Test + void textNodeShouldNotBeAcceptedAsReferencedDocumentFragmentSchema() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7, + builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\""))); + Schema schema = registry.getSchema("{\"$ref\":\"https://www.example.org/text-schema#\"}"); + + SchemaException exception = assertThrows(SchemaException.class, () -> schema.validate("42", InputFormat.JSON)); + + assertTrue(exception.getMessage().contains("must be object or boolean")); + assertTrue(exception.getMessage().contains("STRING")); + } + + @Test + void textNodeShouldNotBeAcceptedAsReferencedAnchorSchema() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7, + builder -> builder.schemas(Collections.singletonMap("https://www.example.org/text-schema", "\"false\""))); + Schema schema = registry.getSchema("{\"$ref\":\"https://www.example.org/text-schema#myAnchor\"}"); + + SchemaException exception = assertThrows(SchemaException.class, () -> schema.validate("42", InputFormat.JSON)); + + assertTrue(exception.getMessage().contains("must be object or boolean")); + assertTrue(exception.getMessage().contains("STRING")); + } + + @Test + void textNodeShouldNotBeAcceptedAsSubSchema() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7); + + SchemaException exception = assertThrows(SchemaException.class, + () -> registry.getSchema("{\"type\":\"object\",\"properties\":{\"a\":\"false\"}}")); + + assertTrue(exception.getMessage().contains("must be object or boolean")); + assertTrue(exception.getMessage().contains("STRING")); + } + + @Test + void booleanSchemaShouldBeAcceptedForDraft7() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7); + Schema schema = registry.getSchema("false"); + + List errors = schema.validate("42", InputFormat.JSON); + + assertEquals(1, errors.size()); + } + + @Test + void loadedBooleanSchemaShouldBeAcceptedForDraft7() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_7, + builder -> builder.schemas(Collections.singletonMap("https://www.example.org/false-schema", "false"))); + Schema schema = registry.getSchema(SchemaLocation.of("https://www.example.org/false-schema")); + + List errors = schema.validate("42", InputFormat.JSON); + + assertEquals(1, errors.size()); + } + + @Test + void booleanSchemaShouldNotBeAcceptedForDraft4() { + SchemaRegistry registry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_4); + + SchemaException exception = assertThrows(SchemaException.class, () -> registry.getSchema("false")); + + assertTrue(exception.getMessage().contains("must be object")); + assertTrue(exception.getMessage().contains("BOOLEAN")); + } +} diff --git a/src/test/java/com/networknt/schema/SchemaTest.java b/src/test/java/com/networknt/schema/SchemaTest.java index ff1ac400b..2220f489d 100644 --- a/src/test/java/com/networknt/schema/SchemaTest.java +++ b/src/test/java/com/networknt/schema/SchemaTest.java @@ -44,12 +44,12 @@ void concurrency() throws Exception { + " \"name\": {\r\n" + " \"type\": \"string\",\r\n" + " \"description\": \"The name\"\r\n" - + " },\r\n" - + " \"required\": [\r\n" - + " \"name\"\r\n" - + " ]\r\n" - + " }\r\n" - + "}"; + + " }\r\n" + + " },\r\n" + + " \"required\": [\r\n" + + " \"name\"\r\n" + + " ]\r\n" + + "}"; String inputData = "{\r\n" + " \"name\": 1\r\n" + "}"; diff --git a/src/test/java/com/networknt/schema/UnknownMetaSchemaTest.java b/src/test/java/com/networknt/schema/UnknownMetaSchemaTest.java index f6858446c..c7f7aa9b6 100644 --- a/src/test/java/com/networknt/schema/UnknownMetaSchemaTest.java +++ b/src/test/java/com/networknt/schema/UnknownMetaSchemaTest.java @@ -12,9 +12,9 @@ class UnknownMetaSchemaTest { - private final String schema1 = "{\"$schema\":\"http://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"},\"required\":[\"data\"]}}"; - private final String schema2 = "{\"$schema\":\"https://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"},\"required\":[\"data\"]}}"; - private final String schema3 = "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"},\"required\":[\"data\"]}}"; + private final String schema1 = "{\"$schema\":\"http://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"}},\"required\":[\"data\"]}"; + private final String schema2 = "{\"$schema\":\"https://json-schema.org/draft-07/schema\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"}},\"required\":[\"data\"]}"; + private final String schema3 = "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"thingModel\",\"description\":\"description of thing\",\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"integer\"}},\"required\":[\"data\"]}"; private final String json = "{\"data\":1}"; diff --git a/src/test/resources/draft2020-12/issue656.json b/src/test/resources/draft2020-12/issue656.json index fc012ce0f..7923214b1 100644 --- a/src/test/resources/draft2020-12/issue656.json +++ b/src/test/resources/draft2020-12/issue656.json @@ -85,7 +85,9 @@ "type": "string", "format": "date" }, - "frequency": "integer" + "frequency": { + "type": "integer" + } }, "required": [ "drug", @@ -109,12 +111,7 @@ }, "discharge-instructions": { "type": "string" - }, - "required": [ - "surgery-date", - "discharge-instructions" - ], - "unevaluatedProperties": false + } } } } @@ -160,4 +157,4 @@ } ] } -] \ No newline at end of file +] diff --git a/src/test/resources/schema/issue456-v7.json b/src/test/resources/schema/issue456-v7.json index 46fbe0896..f31dd560a 100644 --- a/src/test/resources/schema/issue456-v7.json +++ b/src/test/resources/schema/issue456-v7.json @@ -5,7 +5,9 @@ "description": "Test description", "type": "object", "properties": { - "id": "string", + "id": { + "type": "string" + }, "details": { "oneOf": [ {