diff --git a/src/core/jsonschema/bundle.cc b/src/core/jsonschema/bundle.cc index 7a55a8780..5fe3b546c 100644 --- a/src/core/jsonschema/bundle.cc +++ b/src/core/jsonschema/bundle.cc @@ -665,6 +665,14 @@ auto embed_references( depth == 0 ? default_base : std::string_view{}, remaining}; charge(remaining, frame); + // Framing holds the base it resolves against to its canonical spelling, so + // every reference destination it reports carries that spelling rather than + // the one the caller wrote. Settling it once here is what lets a reference + // be compared against it without parsing the base over again for each + const JSON::String canonical_base{depth == 0 && !default_base.empty() + ? URI::canonicalize(default_base) + : JSON::String{}}; + std::vector> deferred; std::vector> ref_rewrites; @@ -685,6 +693,16 @@ auto embed_references( "Could not resolve schema reference"); } + // And so is one whose base is the very document being bundled. A wrapper + // format puts the schemas it holds below a root that is no schema + // resource of its own, so nothing framed answers to the document's base + // and the test above passes it over, even though the fragment is all that + // leads nowhere + if (!canonical_base.empty() && reference.base == canonical_base) { + throw SchemaReferenceError(reference.destination, to_pointer(pointer), + "Could not resolve schema reference"); + } + if (reference.base.empty()) { throw SchemaReferenceError(reference.destination, to_pointer(pointer), "Could not resolve schema reference"); diff --git a/src/core/jsonschema/frame.cc b/src/core/jsonschema/frame.cc index 8958f28e1..b021aec08 100644 --- a/src/core/jsonschema/frame.cc +++ b/src/core/jsonschema/frame.cc @@ -402,6 +402,43 @@ auto canonicalize_pointer_fragment(sourcemeta::core::URI &uri) -> void { } } +// A static location and a dynamic one of the same spelling are two places, so +// a URI that names either is looked for under both +template +auto locate(const Locations &locations, + const sourcemeta::core::JSON::String &uri) + -> std::optional< + std::reference_wrapper> { + const auto static_result{ + locations.find({sourcemeta::core::SchemaReferenceType::Static, uri})}; + if (static_result != locations.cend()) { + return static_result->second; + } + + const auto dynamic_result{ + locations.find({sourcemeta::core::SchemaReferenceType::Dynamic, uri})}; + if (dynamic_result != locations.cend()) { + return dynamic_result->second; + } + + return std::nullopt; +} + +// The spelling that every location is framed under and that every reference +// destination is held to, which is what makes a URI that comes from anywhere +// else comparable to either of them +auto canonicalize_reference(const sourcemeta::core::JSON::String &uri) + -> std::optional { + try { + sourcemeta::core::URI result{uri}; + result.canonicalize(); + canonicalize_pointer_fragment(result); + return result.recompose(); + } catch (const sourcemeta::core::URIParseError &) { + return std::nullopt; + } +} + auto set_base_and_fragment(sourcemeta::core::SchemaFrame::Reference &entry) -> void { const std::string_view destination_view{entry.destination}; @@ -2080,19 +2117,24 @@ auto SchemaFrame::traverse( auto SchemaFrame::traverse(const std::string_view uri) const -> std::optional> { const sourcemeta::core::JSON::String uri_string{uri}; - const auto static_result{ - this->locations_.find({SchemaReferenceType::Static, uri_string})}; - if (static_result != this->locations_.cend()) { - return static_result->second; - } - - const auto dynamic_result{ - this->locations_.find({SchemaReferenceType::Dynamic, uri_string})}; - if (dynamic_result != this->locations_.cend()) { - return dynamic_result->second; + const auto direct{locate(this->locations_, uri_string)}; + if (direct.has_value()) { + return direct; + } + + // Framing writes every location down under the canonical spelling of its + // URI, and holds the destination of every reference to that same spelling, + // so one that reaches here spelled any other way names what a reference + // spelled identically would reach. RFC 3986 Section 6.2.2.2 leaves a + // percent-encoded reserved character where it stands, and RFC 6901 Section + // 6 only asks for the characters a fragment disallows to be encoded, so one + // pointer has more than one spelling to begin with + const auto canonical{canonicalize_reference(uri_string)}; + if (!canonical.has_value() || canonical.value() == uri_string) { + return std::nullopt; } - return std::nullopt; + return locate(this->locations_, canonical.value()); } auto SchemaFrame::traverse(const sourcemeta::core::WeakPointer &pointer) const diff --git a/src/core/openapi/bundle.cc b/src/core/openapi/bundle.cc index c7614e627..eae16ace4 100644 --- a/src/core/openapi/bundle.cc +++ b/src/core/openapi/bundle.cc @@ -55,20 +55,36 @@ struct OpenAPIPending { }; // What the description reaches for and does not hold. A reference that names -// the document being read and lands nowhere is left alone, as OpenAPI -// Specification 3.1.1, Section 4.8.23 holds a `$ref` to the form of a URI and -// says nothing about it having to resolve, so there is nothing to fetch here -// and nothing this specification lets us report +// the document being read and lands nowhere is settled here rather than +// collected, as there is no document left to fetch that could supply the +// place it named. Nothing asks for that. OpenAPI Specification 3.1.1, Section +// 4.8.23 holds a `$ref` to the form of a URI and says nothing about it having +// to resolve, so turning one of these down is a choice this makes rather than +// a rule it follows. +// +// What makes the choice is that nothing is left to wait for. The one restraint +// this specification writes down is Section 4.3.1's "requirement to parse +// complete documents before deeming a Schema Object reference to be +// unresolvable", and a Schema Object reference never reaches here, so holding +// back would be waiting on a document that no reference named. Saying nothing +// instead would hand back a result that the very same reading calls +// incomplete, while a reference into another document that lands nowhere is +// already turned down once that document has been read auto pending(const sourcemeta::core::OpenAPIWalk &walk) -> std::vector { std::vector result; for (const auto &entry : walk.references) { - if (walk.locations.contains(entry.second.destination) || - sourcemeta::core::openapi_within_document(entry.second.destination, - walk.base)) { + if (walk.locations.contains(entry.second.destination)) { continue; } + if (sourcemeta::core::openapi_within_document(entry.second.destination, + walk.base)) { + throw sourcemeta::core::OpenAPIReferenceError{ + walk.base, entry.second.origin, entry.second.destination, + "This reference must name a place within the document it points at"}; + } + result.push_back({.origin = entry.second.origin, .destination = entry.second.destination, .expected = entry.second.expected, @@ -83,12 +99,18 @@ auto pending(const sourcemeta::core::OpenAPIWalk &walk) // references above only because a single one of those Objects may name // several schemes, which is more than one entry keyed by where it sits for (const auto &entry : walk.security_references) { - if (walk.locations.contains(entry.second.destination) || - sourcemeta::core::openapi_within_document(entry.second.destination, - walk.base)) { + if (walk.locations.contains(entry.second.destination)) { continue; } + if (sourcemeta::core::openapi_within_document(entry.second.destination, + walk.base)) { + throw sourcemeta::core::OpenAPIReferenceError{ + walk.base, entry.second.origin, entry.second.destination, + "This security requirement must name a Security Scheme Object " + "within the document it points at"}; + } + result.push_back({.origin = entry.second.origin, .destination = entry.second.destination, .expected = entry.second.expected, diff --git a/src/core/openapi/include/sourcemeta/core/openapi.h b/src/core/openapi/include/sourcemeta/core/openapi.h index c68e137c2..b976d99ff 100644 --- a/src/core/openapi/include/sourcemeta/core/openapi.h +++ b/src/core/openapi/include/sourcemeta/core/openapi.h @@ -996,9 +996,10 @@ using OpenAPIResolverResult = OwnedOrReference; /// @ingroup openapi /// How bundling reaches the other documents that an OpenAPI Description is -/// split across. Every document handed back must itself be an OpenAPI -/// Description, and a URI that names nothing is reported by handing back no -/// value. For example: +/// split across. A URI that names nothing is reported by handing back no +/// value, and whatever else a URI names is handed back as it stands, for +/// bundling to decide whether it is a document a description may span. For +/// example: /// /// ```cpp /// #include diff --git a/src/core/yaml/include/sourcemeta/core/yaml.h b/src/core/yaml/include/sourcemeta/core/yaml.h index 8274b590d..39a75960c 100644 --- a/src/core/yaml/include/sourcemeta/core/yaml.h +++ b/src/core/yaml/include/sourcemeta/core/yaml.h @@ -70,6 +70,26 @@ auto parse_yaml(std::basic_istream &stream) SOURCEMETA_CORE_YAML_EXPORT auto parse_yaml(const JSON::String &input) -> JSON; +/// @ingroup yaml +/// +/// Create a JSON document from a YAML string, reporting whether the string +/// held more documents after the one that was read. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// #include +/// +/// bool more_documents{false}; +/// const sourcemeta::core::JSON document = +/// sourcemeta::core::parse_yaml("hello: world\n---\nsecond: document", +/// more_documents); +/// assert(more_documents); +/// ``` +SOURCEMETA_CORE_YAML_EXPORT +auto parse_yaml(const JSON::String &input, bool &more_documents) -> JSON; + /// @ingroup yaml /// /// Read a JSON document from a file location that represents a YAML file. The @@ -92,6 +112,28 @@ auto parse_yaml(const JSON::String &input) -> JSON; SOURCEMETA_CORE_YAML_EXPORT auto read_yaml(const std::filesystem::path &path) -> JSON; +/// @ingroup yaml +/// +/// Read a JSON document from a file location that represents a YAML file, +/// reporting whether the file held more documents after the one that was +/// read. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// #include +/// #include +/// +/// bool more_documents{false}; +/// const std::filesystem::path path{"test.yaml"}; +/// const sourcemeta::core::JSON document = +/// sourcemeta::core::read_yaml(path, more_documents); +/// assert(!more_documents); +/// ``` +SOURCEMETA_CORE_YAML_EXPORT +auto read_yaml(const std::filesystem::path &path, bool &more_documents) -> JSON; + /// @ingroup yaml /// /// Parse a YAML document from a C++ standard input stream into an existing @@ -113,6 +155,18 @@ SOURCEMETA_CORE_YAML_EXPORT auto parse_yaml(const JSON::String &input, JSON &output, const JSON::ParseCallback &callback) -> void; +/// @ingroup yaml +/// +/// Parse a YAML string into an existing JSON value, invoking the given +/// callback during parsing and reporting whether the string held more +/// documents after the one that was read. The result is constructed directly +/// into the given reference rather than returned by value to ensure that +/// references passed through the parse callback remain valid after parsing +/// completes. +SOURCEMETA_CORE_YAML_EXPORT +auto parse_yaml(const JSON::String &input, bool &more_documents, JSON &output, + const JSON::ParseCallback &callback) -> void; + /// @ingroup yaml /// /// Read a YAML file into an existing JSON value, invoking the given callback @@ -123,6 +177,17 @@ SOURCEMETA_CORE_YAML_EXPORT auto read_yaml(const std::filesystem::path &path, JSON &output, const JSON::ParseCallback &callback) -> void; +/// @ingroup yaml +/// +/// Read a YAML file into an existing JSON value, invoking the given callback +/// during parsing and reporting whether the file held more documents after +/// the one that was read. The result is constructed directly into the given +/// reference rather than returned by value to ensure that references passed +/// through the parse callback remain valid after parsing completes. +SOURCEMETA_CORE_YAML_EXPORT +auto read_yaml(const std::filesystem::path &path, bool &more_documents, + JSON &output, const JSON::ParseCallback &callback) -> void; + /// @ingroup yaml /// /// Read a JSON document from a file location that represents a YAML file or a diff --git a/src/core/yaml/parser.h b/src/core/yaml/parser.h index 3b60fd2c3..73a04bf61 100644 --- a/src/core/yaml/parser.h +++ b/src/core/yaml/parser.h @@ -237,7 +237,12 @@ class Parser { } } - auto validate_end_of_stream() -> void { + // Whether anything stood after the document that was read, which is what a + // caller holding the whole input has no other way of learning. YAML 1.2.2 + // Section 9.2 makes a stream "zero or more documents", so one that carries + // more than the single document handed back is well formed rather than + // malformed, and saying so is all there is to report + auto validate_end_of_stream() -> bool { // Whatever stands after the document that was read is checked rather than // handed over, so nothing of it is announced to whoever is listening for // the one document the caller gets @@ -258,7 +263,7 @@ class Parser { bool saw_document_end{this->document_ended_}; this->skip_document_ends(token, saw_document_end); if (token.type == TokenType::StreamEnd) { - return; + return false; } while (token.type != TokenType::StreamEnd) { // Section 9.2: a document that is not terminated by a document end @@ -271,7 +276,7 @@ class Parser { opened_document = true; token = this->next_token(); if (token.type == TokenType::StreamEnd) { - return; + return true; } // Section 8.2.3 admits an empty node, so an explicit document may // carry nothing of its own and be closed by its end marker directly @@ -310,6 +315,8 @@ class Parser { token = this->next_token(); this->skip_document_ends(token, saw_document_end); } + + return true; } private: diff --git a/src/core/yaml/yaml.cc b/src/core/yaml/yaml.cc index 6aed27ccb..d99620307 100644 --- a/src/core/yaml/yaml.cc +++ b/src/core/yaml/yaml.cc @@ -52,6 +52,11 @@ auto parse_yaml(std::basic_istream &stream) } auto parse_yaml(const JSON::String &input) -> JSON { + bool more_documents{false}; + return parse_yaml(input, more_documents); +} + +auto parse_yaml(const JSON::String &input, bool &more_documents) -> JSON { yaml::Lexer lexer{input}; yaml::Parser parser{&lexer, nullptr}; auto result{parser.parse()}; @@ -60,22 +65,22 @@ auto parse_yaml(const JSON::String &input) -> JSON { // so whatever stands after the first is content nothing could reach. The // stream overloads are the ones that read a document at a time, and those // leave the rest where the next call will find it - parser.validate_end_of_stream(); + more_documents = parser.validate_end_of_stream(); return result; } auto read_yaml(const std::filesystem::path &path) -> JSON { + bool more_documents{false}; + return read_yaml(path, more_documents); +} + +auto read_yaml(const std::filesystem::path &path, bool &more_documents) + -> JSON { const auto input{read_file_to_string(path)}; try { - yaml::Lexer lexer{input}; - yaml::Parser parser{&lexer, nullptr}; - auto result{parser.parse()}; - - parser.validate_end_of_stream(); - - return result; + return parse_yaml(input, more_documents); } catch (const YAMLParseError &error) { // For producing better error messages throw YAMLFileParseError(path, error); @@ -101,23 +106,31 @@ auto parse_yaml(std::basic_istream &stream, auto parse_yaml(const JSON::String &input, JSON &output, const JSON::ParseCallback &callback) -> void { + bool more_documents{false}; + parse_yaml(input, more_documents, output, callback); +} + +auto parse_yaml(const JSON::String &input, bool &more_documents, JSON &output, + const JSON::ParseCallback &callback) -> void { yaml::Lexer lexer{input}; yaml::Parser parser{&lexer, &callback}; output = parser.parse(); - parser.validate_end_of_stream(); + more_documents = parser.validate_end_of_stream(); } auto read_yaml(const std::filesystem::path &path, JSON &output, const JSON::ParseCallback &callback) -> void { + bool more_documents{false}; + read_yaml(path, more_documents, output, callback); +} + +auto read_yaml(const std::filesystem::path &path, bool &more_documents, + JSON &output, const JSON::ParseCallback &callback) -> void { const auto input{read_file_to_string(path)}; try { - yaml::Lexer lexer{input}; - yaml::Parser parser{&lexer, &callback}; - output = parser.parse(); - - parser.validate_end_of_stream(); + parse_yaml(input, more_documents, output, callback); } catch (const YAMLParseError &error) { // For producing better error messages throw YAMLFileParseError(path, error); diff --git a/test/jsonschema/bundle/mixed/custom_paths_with_a_dangling_fragment_of_a_base_that_is_not_canonical.json b/test/jsonschema/bundle/mixed/custom_paths_with_a_dangling_fragment_of_a_base_that_is_not_canonical.json new file mode 100644 index 000000000..68d3a08d7 --- /dev/null +++ b/test/jsonschema/bundle/mixed/custom_paths_with_a_dangling_fragment_of_a_base_that_is_not_canonical.json @@ -0,0 +1,39 @@ +{ + "schema": { + "components": { + "schemas": { + "Person": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Nope" + } + } + } + } + } + }, + "resolver": {}, + "defaultDialect": "https://json-schema.org/draft/2020-12/schema", + "container": "/x-bundled", + "paths": [ + "/components/schemas/Person" + ], + "defaultBase": "HTTPS://WWW.SOURCEMETA.COM:443/./openapi.json", + "result": {}, + "errors": { + "nonOfficialMetaschemas": { + "type": "SchemaReferenceError", + "message": "Could not resolve schema reference", + "identifier": "https://www.sourcemeta.com/openapi.json#/components/schemas/Nope", + "location": "/components/schemas/Person/properties/pet/$ref" + }, + "references": { + "type": "SchemaReferenceError", + "message": "Could not resolve schema reference", + "identifier": "https://www.sourcemeta.com/openapi.json#/components/schemas/Nope", + "location": "/components/schemas/Person/properties/pet/$ref" + } + }, + "inserted": {} +} diff --git a/test/jsonschema/bundle/mixed/custom_paths_with_a_dangling_fragment_of_the_entry_document.json b/test/jsonschema/bundle/mixed/custom_paths_with_a_dangling_fragment_of_the_entry_document.json new file mode 100644 index 000000000..dccdd40ee --- /dev/null +++ b/test/jsonschema/bundle/mixed/custom_paths_with_a_dangling_fragment_of_the_entry_document.json @@ -0,0 +1,39 @@ +{ + "schema": { + "components": { + "schemas": { + "Person": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Nope" + } + } + } + } + } + }, + "resolver": {}, + "defaultDialect": "https://json-schema.org/draft/2020-12/schema", + "container": "/x-bundled", + "paths": [ + "/components/schemas/Person" + ], + "defaultBase": "https://www.sourcemeta.com/openapi.json", + "result": {}, + "errors": { + "nonOfficialMetaschemas": { + "type": "SchemaReferenceError", + "message": "Could not resolve schema reference", + "identifier": "https://www.sourcemeta.com/openapi.json#/components/schemas/Nope", + "location": "/components/schemas/Person/properties/pet/$ref" + }, + "references": { + "type": "SchemaReferenceError", + "message": "Could not resolve schema reference", + "identifier": "https://www.sourcemeta.com/openapi.json#/components/schemas/Nope", + "location": "/components/schemas/Person/properties/pet/$ref" + } + }, + "inserted": {} +} diff --git a/test/jsonschema/jsonschema_frame_test.cc b/test/jsonschema/jsonschema_frame_test.cc index e8628eaca..459ce8aca 100644 --- a/test/jsonschema/jsonschema_frame_test.cc +++ b/test/jsonschema/jsonschema_frame_test.cc @@ -2357,3 +2357,81 @@ TEST(mode_reads_back_what_the_frame_was_given) { EXPECT_TRUE(locations.mode() == sourcemeta::core::SchemaFrame::Mode::Locations); } + +// RFC 6901 Section 6 only asks for the characters that the fragment rule of +// RFC 3986 disallows to be percent-encoded, and `:` is not one of them, so a +// pointer has one spelling with it encoded and one without. Resolving a +// reference reads the fragment back as a pointer, which settles the two on one +// spelling, and a URI handed over from anywhere else is settled the same way +TEST(traverse_a_pointer_fragment_that_percent_encodes_a_reserved_character) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/escaped", + "$ref": "#/$defs/a%3Ab", + "$defs": { "a:b": { "type": "string" } } + })JSON"); + + const sourcemeta::core::SchemaFrame frame{ + sourcemeta::core::SchemaFrame::Mode::References, document, + sourcemeta::core::schema_walker, sourcemeta::core::schema_resolver}; + + EXPECT_EQ(sourcemeta::core::to_string( + frame.traverse("https://example.com/escaped#/$defs/a:b") + .value() + .get() + .pointer), + "/$defs/a:b"); + EXPECT_EQ(sourcemeta::core::to_string( + frame.traverse("https://example.com/escaped#/$defs/a%3Ab") + .value() + .get() + .pointer), + "/$defs/a:b"); + EXPECT_EQ(sourcemeta::core::to_string( + frame.traverse("https://example.com/escaped#/$defs/a%3ab") + .value() + .get() + .pointer), + "/$defs/a:b"); +} + +// Reference resolution lowercases the scheme and the host and takes out a +// default port, so a URI that names a location only once that has happened +// names it here too +TEST(traverse_a_uri_that_is_not_normalised) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema", + "$defs": { "foo": { "$anchor": "bar" } } + })JSON"); + + const sourcemeta::core::SchemaFrame frame{ + sourcemeta::core::SchemaFrame::Mode::Locations, document, + sourcemeta::core::schema_walker, sourcemeta::core::schema_resolver}; + + EXPECT_EQ(sourcemeta::core::to_string( + frame.traverse("HTTPS://EXAMPLE.COM:443/schema#bar") + .value() + .get() + .pointer), + "/$defs/foo"); + EXPECT_EQ(sourcemeta::core::to_string( + frame.traverse("https://example.com/./schema#bar") + .value() + .get() + .pointer), + "/$defs/foo"); +} + +TEST(traverse_a_uri_that_does_not_parse) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schema" + })JSON"); + + const sourcemeta::core::SchemaFrame frame{ + sourcemeta::core::SchemaFrame::Mode::Locations, document, + sourcemeta::core::schema_walker, sourcemeta::core::schema_resolver}; + + EXPECT_FALSE(frame.traverse("http://[").has_value()); +} diff --git a/test/openapi/bundle/3.0/reference_into_this_document_that_lands_nowhere.json b/test/openapi/bundle/3.0/reference_into_this_document_that_lands_nowhere.json index e7d8a2abd..bc249489a 100644 --- a/test/openapi/bundle/3.0/reference_into_this_document_that_lands_nowhere.json +++ b/test/openapi/bundle/3.0/reference_into_this_document_that_lands_nowhere.json @@ -17,25 +17,10 @@ "defaultBase": "https://example.com/openapi.json", "openapiResolver": {}, "schemaResolver": {}, - "result": { - "openapi": "3.0.4", - "info": { - "title": "Example", - "version": "1.0.0" - }, - "components": { - "responses": { - "R": { - "$ref": "#/components/responses/Missing" - } - } - }, - "paths": {} - }, - "inserted": [], - "reads": {}, - "schemaReads": {}, - "standalone": false, - "relocatable": false, - "operations": [] + "error": { + "type": "OpenAPIReferenceError", + "message": "This reference must name a place within the document it points at", + "location": "/components/responses/R/$ref", + "identifier": "https://example.com/openapi.json#/components/responses/Missing" + } } diff --git a/test/openapi/bundle/3.0/schema_object_reference_into_this_document_that_lands_nowhere.json b/test/openapi/bundle/3.0/schema_object_reference_into_this_document_that_lands_nowhere.json new file mode 100644 index 000000000..57a5def73 --- /dev/null +++ b/test/openapi/bundle/3.0/schema_object_reference_into_this_document_that_lands_nowhere.json @@ -0,0 +1,31 @@ +{ + "document": { + "openapi": "3.0.4", + "info": { + "title": "Example", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Person": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Nope" + } + } + } + } + } + }, + "defaultBase": "https://example.com/openapi.json", + "openapiResolver": {}, + "schemaResolver": {}, + "error": { + "type": "SchemaReferenceError", + "message": "Could not resolve schema reference", + "location": "/components/schemas/Person/properties/pet/$ref", + "identifier": "https://example.com/openapi.json#/components/schemas/Nope" + } +} diff --git a/test/openapi/bundle/3.1/reference_into_this_document_that_lands_nowhere.json b/test/openapi/bundle/3.1/reference_into_this_document_that_lands_nowhere.json index 714e798a8..4905b391a 100644 --- a/test/openapi/bundle/3.1/reference_into_this_document_that_lands_nowhere.json +++ b/test/openapi/bundle/3.1/reference_into_this_document_that_lands_nowhere.json @@ -16,24 +16,10 @@ "defaultBase": "https://example.com/openapi.json", "openapiResolver": {}, "schemaResolver": {}, - "result": { - "openapi": "3.1.1", - "info": { - "title": "Example", - "version": "1.0.0" - }, - "components": { - "responses": { - "R": { - "$ref": "#/components/responses/Missing" - } - } - } - }, - "inserted": [], - "reads": {}, - "schemaReads": {}, - "standalone": false, - "relocatable": false, - "operations": [] + "error": { + "type": "OpenAPIReferenceError", + "message": "This reference must name a place within the document it points at", + "location": "/components/responses/R/$ref", + "identifier": "https://example.com/openapi.json#/components/responses/Missing" + } } diff --git a/test/openapi/bundle/3.1/schema_object_reference_into_this_document_that_lands_nowhere.json b/test/openapi/bundle/3.1/schema_object_reference_into_this_document_that_lands_nowhere.json new file mode 100644 index 000000000..c3ea099b3 --- /dev/null +++ b/test/openapi/bundle/3.1/schema_object_reference_into_this_document_that_lands_nowhere.json @@ -0,0 +1,31 @@ +{ + "document": { + "openapi": "3.1.1", + "info": { + "title": "Example", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Person": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Nope" + } + } + } + } + } + }, + "defaultBase": "https://example.com/openapi.json", + "openapiResolver": {}, + "schemaResolver": {}, + "error": { + "type": "SchemaReferenceError", + "message": "Could not resolve schema reference", + "location": "/components/schemas/Person/properties/pet/$ref", + "identifier": "https://example.com/openapi.json#/components/schemas/Nope" + } +} diff --git a/test/openapi/bundle/3.2/reference_into_this_document_that_lands_nowhere.json b/test/openapi/bundle/3.2/reference_into_this_document_that_lands_nowhere.json index 1de317765..311d78fbb 100644 --- a/test/openapi/bundle/3.2/reference_into_this_document_that_lands_nowhere.json +++ b/test/openapi/bundle/3.2/reference_into_this_document_that_lands_nowhere.json @@ -16,24 +16,10 @@ "defaultBase": "https://example.com/openapi.json", "openapiResolver": {}, "schemaResolver": {}, - "result": { - "openapi": "3.2.0", - "info": { - "title": "Example", - "version": "1.0.0" - }, - "components": { - "responses": { - "R": { - "$ref": "#/components/responses/Missing" - } - } - } - }, - "inserted": [], - "reads": {}, - "schemaReads": {}, - "standalone": false, - "relocatable": false, - "operations": [] + "error": { + "type": "OpenAPIReferenceError", + "message": "This reference must name a place within the document it points at", + "location": "/components/responses/R/$ref", + "identifier": "https://example.com/openapi.json#/components/responses/Missing" + } } diff --git a/test/openapi/bundle/3.2/schema_object_reference_into_this_document_that_lands_nowhere.json b/test/openapi/bundle/3.2/schema_object_reference_into_this_document_that_lands_nowhere.json new file mode 100644 index 000000000..4145dcfe5 --- /dev/null +++ b/test/openapi/bundle/3.2/schema_object_reference_into_this_document_that_lands_nowhere.json @@ -0,0 +1,31 @@ +{ + "document": { + "openapi": "3.2.0", + "info": { + "title": "Example", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "Person": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Nope" + } + } + } + } + } + }, + "defaultBase": "https://example.com/openapi.json", + "openapiResolver": {}, + "schemaResolver": {}, + "error": { + "type": "SchemaReferenceError", + "message": "Could not resolve schema reference", + "location": "/components/schemas/Person/properties/pet/$ref", + "identifier": "https://example.com/openapi.json#/components/schemas/Nope" + } +} diff --git a/test/openapi/bundle/3.2/security_requirement_names_one_scheme_this_document_lacks_by_two_spellings.json b/test/openapi/bundle/3.2/security_requirement_names_one_scheme_this_document_lacks_by_two_spellings.json index 0296b5779..0cbec38f5 100644 --- a/test/openapi/bundle/3.2/security_requirement_names_one_scheme_this_document_lacks_by_two_spellings.json +++ b/test/openapi/bundle/3.2/security_requirement_names_one_scheme_this_document_lacks_by_two_spellings.json @@ -28,36 +28,10 @@ "defaultBase": "https://example.com/openapi.json", "openapiResolver": {}, "schemaResolver": {}, - "result": { - "openapi": "3.2.0", - "info": { - "title": "Example", - "version": "1.0.0" - }, - "security": [ - { - "#/components/securitySchemes/Missing": [ - "read" - ], - "openapi.json#/components/securitySchemes/Missing": [ - "write" - ] - } - ], - "components": { - "securitySchemes": { - "Local": { - "type": "apiKey", - "name": "key", - "in": "header" - } - } - } - }, - "inserted": [], - "reads": {}, - "schemaReads": {}, - "standalone": false, - "relocatable": false, - "operations": [] + "error": { + "type": "OpenAPIReferenceError", + "message": "This security requirement must name a Security Scheme Object within the document it points at", + "location": "/security/0/#~1components~1securitySchemes~1Missing", + "identifier": "https://example.com/openapi.json#/components/securitySchemes/Missing" + } } diff --git a/test/yaml/yaml_parse_test.cc b/test/yaml/yaml_parse_test.cc index f91304a3a..a969dfd17 100644 --- a/test/yaml/yaml_parse_test.cc +++ b/test/yaml/yaml_parse_test.cc @@ -5601,3 +5601,126 @@ TEST(explicit_key_with_no_value) { EXPECT_EQ(result, expected); } + +// YAML 1.2.2 Section 9.2 makes a stream "zero or more documents", so a file +// that carries more than one is well formed rather than malformed. An entry +// point that hands back a single document has nowhere to put the rest, so +// what it can say is that there was more to read +TEST(parse_yaml_reports_a_single_document) { + bool more_documents{true}; + const auto result{sourcemeta::core::parse_yaml("foo: 1", more_documents)}; + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_FALSE(more_documents); +} + +TEST(parse_yaml_reports_a_document_that_another_follows) { + bool more_documents{false}; + const auto result{ + sourcemeta::core::parse_yaml("foo: 1\n---\nbar: 2", more_documents)}; + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(more_documents); +} + +TEST(parse_yaml_reports_a_document_closed_by_an_end_marker) { + bool more_documents{true}; + const auto result{ + sourcemeta::core::parse_yaml("foo: 1\n...\n", more_documents)}; + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_FALSE(more_documents); +} + +// YAML 1.2.2 Section 9.1.4: "Since the existence of the document is indicated +// by this marker, the document itself may be completely empty". So a +// directives end marker with nothing after it opens a document of its own +// rather than trailing the one before it +TEST(parse_yaml_reports_an_empty_document_that_follows) { + bool more_documents{false}; + const auto result{ + sourcemeta::core::parse_yaml("foo: 1\n---\n", more_documents)}; + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(more_documents); +} + +TEST(parse_yaml_with_a_callback_reports_a_document_that_another_follows) { + bool more_documents{false}; + sourcemeta::core::JSON result{nullptr}; + sourcemeta::core::parse_yaml("foo: 1\n---\nbar: 2", more_documents, result, + nullptr); + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(more_documents); +} + +TEST(parse_yaml_with_a_callback_reports_a_single_document) { + bool more_documents{true}; + sourcemeta::core::JSON result{nullptr}; + sourcemeta::core::parse_yaml("foo: 1", more_documents, result, nullptr); + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_FALSE(more_documents); +} + +TEST(read_yaml_reports_a_document_that_another_follows) { + bool more_documents{false}; + const auto result{sourcemeta::core::read_yaml( + std::filesystem::path{STUBS_PATH} / "multi_document_objects.yaml", + more_documents)}; + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(more_documents); +} + +TEST(read_yaml_reports_a_single_document) { + bool more_documents{true}; + const auto result{sourcemeta::core::read_yaml( + std::filesystem::path{STUBS_PATH} / "test_1.yaml", more_documents)}; + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": "bar", "baz": 2 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_FALSE(more_documents); +} + +TEST(read_yaml_with_a_callback_reports_a_document_that_another_follows) { + bool more_documents{false}; + sourcemeta::core::JSON result{nullptr}; + sourcemeta::core::read_yaml(std::filesystem::path{STUBS_PATH} / + "multi_document_objects.yaml", + more_documents, result, nullptr); + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": 1 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_TRUE(more_documents); +} + +TEST(read_yaml_with_a_callback_reports_a_single_document) { + bool more_documents{true}; + sourcemeta::core::JSON result{nullptr}; + sourcemeta::core::read_yaml(std::filesystem::path{STUBS_PATH} / "test_1.yaml", + more_documents, result, nullptr); + const sourcemeta::core::JSON expected{ + sourcemeta::core::parse_json(R"JSON({ "foo": "bar", "baz": 2 })JSON")}; + EXPECT_EQ(result, expected); + EXPECT_FALSE(more_documents); +} + +TEST(read_yaml_reporting_a_file_that_does_not_parse) { + bool more_documents{false}; + try { + const auto result{sourcemeta::core::read_yaml( + std::filesystem::path{STUBS_PATH} / "invalid.yaml", more_documents)}; + FAIL(); + } catch (const sourcemeta::core::YAMLFileParseError &error) { + EXPECT_EQ(error.path(), std::filesystem::path{STUBS_PATH} / "invalid.yaml"); + } +}