From e23e92c001285bd5b9df928f405ca07b955840f9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Wed, 28 Oct 2026 19:08:17 -0300 Subject: [PATCH] Make `externalDocs/url` absolute in OpenAPI during bundling Signed-off-by: Juan Cruz Viotti --- DEPENDENCIES | 4 +- test/CMakeLists.txt | 5 + ...ass_openapi_external_docs_absolute.clitest | 42 +++++ ...penapi_external_docs_document_base.clitest | 42 +++++ ...ss_openapi_external_docs_nested_id.clitest | 52 +++++++ ...penapi_external_docs_other_dialect.clitest | 44 ++++++ ...pass_openapi_external_docs_resolve.clitest | 89 +++++++++++ vendor/blaze/DEPENDENCIES | 2 +- vendor/blaze/src/compiler/compile.cc | 10 +- vendor/blaze/src/convert/convert.cc | 55 +++---- vendor/blaze/src/convert/helpers.h | 25 +++ .../carried_annotation_becomes_keyword.h | 55 +++++++ .../prefix_promoted_draft_2019_09_keywords.h | 34 +--- .../rules/prefix_promoted_draft_7_keywords.h | 34 +--- .../prefix_promoted_openapi_3_1_keywords.h | 37 +---- .../rules/shadow_reserved_carried_name.h | 61 ++++++++ .../rules/upgrade_openapi_3_0_to_draft_4.h | 77 +++++++--- vendor/blaze/src/editor/editor.cc | 6 +- .../editor/include/sourcemeta/blaze/editor.h | 4 +- .../sourcemeta/core/jsonpointer_pointer.h | 12 +- vendor/core/src/core/jsonschema/bundle.cc | 123 +++++++++++++++ vendor/core/src/core/jsonschema/helpers.h | 3 + vendor/core/src/core/mcp/parameter_headers.cc | 105 ++++++++++--- vendor/core/src/core/openapi/bundle.cc | 67 ++++++-- vendor/core/src/core/openapi/helpers.h | 145 +++++++++++++++++- vendor/core/src/core/openapi/server.h | 7 + .../core/uri/include/sourcemeta/core/uri.h | 5 + 27 files changed, 947 insertions(+), 198 deletions(-) create mode 100644 test/bundle/pass_openapi_external_docs_absolute.clitest create mode 100644 test/bundle/pass_openapi_external_docs_document_base.clitest create mode 100644 test/bundle/pass_openapi_external_docs_nested_id.clitest create mode 100644 test/bundle/pass_openapi_external_docs_other_dialect.clitest create mode 100644 test/bundle/pass_openapi_external_docs_resolve.clitest create mode 100644 vendor/blaze/src/convert/rules/carried_annotation_becomes_keyword.h create mode 100644 vendor/blaze/src/convert/rules/shadow_reserved_carried_name.h diff --git a/DEPENDENCIES b/DEPENDENCIES index 4b8454b44..027e71aa6 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,4 +1,4 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core d18c4cfc0cebf9e29562acc442fce78f8677b2d5 -blaze https://github.com/sourcemeta/blaze 2c5efbd14f5f24dbe3860be8c8d80279edc20d10 +core https://github.com/sourcemeta/core fbdf367f3b443dd05fdbe7bae6685f86c8332e4f +blaze https://github.com/sourcemeta/blaze ad08d100148b60b03ee5f30c40f8f11ea9a13aca ctrf https://github.com/ctrf-io/ctrf 93ea827d951390190171d37443bff169cf47c808 diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0f6ab1b94..4792d6973 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -855,6 +855,11 @@ add_jsonschema_test(bundle/pass_openapi_format) add_jsonschema_test(bundle/pass_resolve_yaml_from_json) add_jsonschema_test(bundle/pass_resolve_json_from_yaml) add_jsonschema_test(bundle/pass_openapi_configuration_resolve) +add_jsonschema_test(bundle/pass_openapi_external_docs_resolve) +add_jsonschema_test(bundle/pass_openapi_external_docs_nested_id) +add_jsonschema_test(bundle/pass_openapi_external_docs_document_base) +add_jsonschema_test(bundle/pass_openapi_external_docs_absolute) +add_jsonschema_test(bundle/pass_openapi_external_docs_other_dialect) add_jsonschema_test(bundle/fail_openapi_self_position) add_jsonschema_test(bundle/fail_openapi_version_stdin) add_jsonschema_test(bundle/fail_default_dialect_config_extension_mismatch) diff --git a/test/bundle/pass_openapi_external_docs_absolute.clitest b/test/bundle/pass_openapi_external_docs_absolute.clitest new file mode 100644 index 000000000..d6692c994 --- /dev/null +++ b/test/bundle/pass_openapi_external_docs_absolute.clitest @@ -0,0 +1,42 @@ +WRITE openapi.json UNTIL EOF +{ + "openapi": "3.1.1", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "pet": { + "$id": "https://example.com/schemas/pet", + "externalDocs": { "url": "https://docs.example.com/pet.html" }, + "type": "object" + } + } + } +} +EOF + +RUN bundle openapi.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +1> { +1> "openapi": "3.1.1", +1> "info": { +1> "title": "Example", +1> "version": "1.0.0" +1> }, +1> "paths": {}, +1> "components": { +1> "schemas": { +1> "pet": { +1> "$id": "https://example.com/schemas/pet", +1> "externalDocs": { +1> "url": "https://docs.example.com/pet.html" +1> }, +1> "type": "object" +1> } +1> } +1> } +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/bundle/pass_openapi_external_docs_document_base.clitest b/test/bundle/pass_openapi_external_docs_document_base.clitest new file mode 100644 index 000000000..f9eb10be1 --- /dev/null +++ b/test/bundle/pass_openapi_external_docs_document_base.clitest @@ -0,0 +1,42 @@ +WRITE openapi.json UNTIL EOF +{ + "openapi": "3.2.0", + "$self": "https://example.com/openapi", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "pet": { + "externalDocs": { "url": "docs/pet.html" }, + "type": "object" + } + } + } +} +EOF + +RUN bundle openapi.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +1> { +1> "openapi": "3.2.0", +1> "$self": "https://example.com/openapi", +1> "info": { +1> "title": "Example", +1> "version": "1.0.0" +1> }, +1> "paths": {}, +1> "components": { +1> "schemas": { +1> "pet": { +1> "externalDocs": { +1> "url": "https://example.com/docs/pet.html" +1> }, +1> "type": "object" +1> } +1> } +1> } +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/bundle/pass_openapi_external_docs_nested_id.clitest b/test/bundle/pass_openapi_external_docs_nested_id.clitest new file mode 100644 index 000000000..fc6a8786c --- /dev/null +++ b/test/bundle/pass_openapi_external_docs_nested_id.clitest @@ -0,0 +1,52 @@ +WRITE openapi.json UNTIL EOF +{ + "openapi": "3.1.1", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "pet": { + "$id": "https://example.com/schemas/pet", + "type": "object", + "properties": { + "collar": { + "$id": "https://example.com/other/collar", + "externalDocs": { "url": "collar.html" } + } + } + } + } + } +} +EOF + +RUN bundle openapi.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +1> { +1> "openapi": "3.1.1", +1> "info": { +1> "title": "Example", +1> "version": "1.0.0" +1> }, +1> "paths": {}, +1> "components": { +1> "schemas": { +1> "pet": { +1> "$id": "https://example.com/schemas/pet", +1> "type": "object", +1> "properties": { +1> "collar": { +1> "$id": "https://example.com/other/collar", +1> "externalDocs": { +1> "url": "https://example.com/other/collar.html" +1> } +1> } +1> } +1> } +1> } +1> } +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/bundle/pass_openapi_external_docs_other_dialect.clitest b/test/bundle/pass_openapi_external_docs_other_dialect.clitest new file mode 100644 index 000000000..9ae29a88a --- /dev/null +++ b/test/bundle/pass_openapi_external_docs_other_dialect.clitest @@ -0,0 +1,44 @@ +WRITE openapi.json UNTIL EOF +{ + "openapi": "3.1.1", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "pet": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/pet", + "externalDocs": { "url": "./docs/pet.html" }, + "type": "object" + } + } + } +} +EOF + +RUN bundle openapi.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +1> { +1> "openapi": "3.1.1", +1> "info": { +1> "title": "Example", +1> "version": "1.0.0" +1> }, +1> "paths": {}, +1> "components": { +1> "schemas": { +1> "pet": { +1> "$schema": "https://json-schema.org/draft/2020-12/schema", +1> "$id": "https://example.com/schemas/pet", +1> "externalDocs": { +1> "url": "./docs/pet.html" +1> }, +1> "type": "object" +1> } +1> } +1> } +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/test/bundle/pass_openapi_external_docs_resolve.clitest b/test/bundle/pass_openapi_external_docs_resolve.clitest new file mode 100644 index 000000000..eb0325452 --- /dev/null +++ b/test/bundle/pass_openapi_external_docs_resolve.clitest @@ -0,0 +1,89 @@ +WRITE openapi.json UNTIL EOF +{ + "openapi": "3.1.1", + "info": { "title": "Example", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "https://example.com/schemas/pet" } + } + } + } + } + } + } + } +} +EOF + +WRITE pet.json UNTIL EOF +{ + "$id": "https://example.com/schemas/pet", + "$schema": "https://spec.openapis.org/oas/3.1/dialect/base", + "externalDocs": { "url": "./docs/pet.html" }, + "type": "object", + "properties": { + "name": { + "externalDocs": { "url": "docs/name.html" }, + "type": "string" + } + } +} +EOF + +RUN bundle openapi.json --resolve pet.json STDIN /dev/null IN . INTO result.txt EXPECTING 0 + +WRITE expected.txt UNTIL EOF +1> { +1> "openapi": "3.1.1", +1> "info": { +1> "title": "Example", +1> "version": "1.0.0" +1> }, +1> "paths": { +1> "/pets": { +1> "get": { +1> "responses": { +1> "200": { +1> "description": "OK", +1> "content": { +1> "application/json": { +1> "schema": { +1> "$ref": "https://example.com/schemas/pet" +1> } +1> } +1> } +1> } +1> } +1> } +1> } +1> }, +1> "components": { +1> "schemas": { +1> "pet": { +1> "$schema": "https://spec.openapis.org/oas/3.1/dialect/base", +1> "$id": "https://example.com/schemas/pet", +1> "externalDocs": { +1> "url": "https://example.com/schemas/docs/pet.html" +1> }, +1> "type": "object", +1> "properties": { +1> "name": { +1> "externalDocs": { +1> "url": "https://example.com/schemas/docs/name.html" +1> }, +1> "type": "string" +1> } +1> } +1> } +1> } +1> } +1> } +EOF + +COMPARE result.txt AGAINST expected.txt diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index 2be881d73..7b5cbce32 100644 --- a/vendor/blaze/DEPENDENCIES +++ b/vendor/blaze/DEPENDENCIES @@ -1,3 +1,3 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 7e3e5ee79ae79d2111a976d8d365cc6818661b47 +core https://github.com/sourcemeta/core fbdf367f3b443dd05fdbe7bae6685f86c8332e4f jsonschema-test-suite https://github.com/json-schema-org/JSON-Schema-Test-Suite 6648e8194c69697b2e1a15fe76a06a480b183a51 diff --git a/vendor/blaze/src/compiler/compile.cc b/vendor/blaze/src/compiler/compile.cc index 32dbdded6..c4824bbf4 100644 --- a/vendor/blaze/src/compiler/compile.cc +++ b/vendor/blaze/src/compiler/compile.cc @@ -429,10 +429,16 @@ auto compile_subschema(const sourcemeta::blaze::Context &context, // Bases must not contain fragments assert(!schema_context.base.fragment().has_value()); + + // `SchemaContext` holds this pointer by reference, so it has to outlive the + // loop. A temporary in the range-initializer would only do so through the + // C++23 lifetime extension, which leaks it when the compiler throws from + // here, as the depth and instruction limits do + const auto keyword_pointer{ + schema_context.relative_pointer.concat(make_weak_pointer(keyword))}; for (auto &&step : context.compiler( context, - {.relative_pointer = schema_context.relative_pointer.concat( - make_weak_pointer(keyword)), + {.relative_pointer = keyword_pointer, .schema = schema_context.schema, .vocabularies = schema_context.vocabularies, .base = schema_context.base, diff --git a/vendor/blaze/src/convert/convert.cc b/vendor/blaze/src/convert/convert.cc index 7d4c353b8..d559edfe5 100644 --- a/vendor/blaze/src/convert/convert.cc +++ b/vendor/blaze/src/convert/convert.cc @@ -491,6 +491,7 @@ auto apply(const std::vector &rules, sourcemeta::core::JSON &schema, } } +#include "rules/carried_annotation_becomes_keyword.h" #include "rules/definitions_to_defs.h" #include "rules/dependencies_to_dependent.h" #include "rules/dialect_override_becomes_dollar_schema.h" @@ -509,6 +510,7 @@ auto apply(const std::vector &rules, sourcemeta::core::JSON &schema, #include "rules/prefix_promoted_draft_7_keywords.h" #include "rules/prefix_promoted_openapi_3_1_keywords.h" #include "rules/sanitize_draft_4_anchors.h" +#include "rules/shadow_reserved_carried_name.h" #include "rules/shadow_stray_dialect_declaration.h" #include "rules/upgrade_2019_09_to_2020_12.h" #include "rules/upgrade_2020_12_to_openapi_3_1.h" @@ -533,9 +535,14 @@ auto convert(sourcemeta::core::JSON &schema, // spelling the ladder does not name is one it cannot answer for, so these // run to completion first rather than alongside std::vector spellings; - spellings.reserve(8); + spellings.reserve(9); spellings.push_back(make_rule()); spellings.push_back(make_rule()); + + // Not a spelling, but it belongs to the same moment: what the author wrote + // has to be moved out of the conversion's own namespace before any rule + // writes into it, or the two can no longer be told apart + spellings.push_back(make_rule()); spellings.push_back(make_rule()); spellings.push_back(make_rule()); spellings.push_back(make_rule()); @@ -558,27 +565,6 @@ auto convert(sourcemeta::core::JSON &schema, // the rungs carry on from there rules.push_back(make_rule(target)); - // Whether this conversion starts from OpenAPI 3.0. Usually only the caller - // can say, because the Schema Object admits no `$schema` member of its own, - // so a document written in that dialect is named from the outside. The - // exception is the override marker, which names a dialect for a document that - // cannot declare one, and which `declared_dialect` reads ahead of `$schema` - // exactly as core frames it. - // - // So the question is what the document says it is, and only where it says - // nothing does the default the caller passed decide. Asking merely whether a - // `$schema` member is present answers both halves wrongly: a document naming - // this dialect through the marker is read as not being on it, and one naming - // another dialect that way is read as being on it. - // - // What this buys is the keywords the fork carries across as themselves. Each - // is author data to the rung that promotes it, and shadowing it there would - // undo the fork, so the rules that would are told to leave it be - const auto declared{declared_dialect(schema)}; - const auto from_openapi_3_0{ - names_openapi_3_0_dialect(declared) || - (declared.empty() && names_openapi_3_0_dialect(default_dialect))}; - rules.push_back(make_rule()); rules.push_back(make_rule()); @@ -598,10 +584,7 @@ auto convert(sourcemeta::core::JSON &schema, target == ConvertTarget::Draft202012 || target == ConvertTarget::OpenAPI31 || target == ConvertTarget::OpenAPI32) { - rules.push_back(make_rule( - from_openapi_3_0 - ? std::vector{"readOnly", "writeOnly"} - : std::vector{})); + rules.push_back(make_rule()); rules.push_back(make_rule()); } @@ -609,9 +592,7 @@ auto convert(sourcemeta::core::JSON &schema, target == ConvertTarget::Draft202012 || target == ConvertTarget::OpenAPI31 || target == ConvertTarget::OpenAPI32) { - rules.push_back(make_rule( - from_openapi_3_0 ? std::vector{"deprecated"} - : std::vector{})); + rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); rules.push_back(make_rule()); @@ -626,11 +607,7 @@ auto convert(sourcemeta::core::JSON &schema, if (target == ConvertTarget::OpenAPI31 || target == ConvertTarget::OpenAPI32) { - rules.push_back(make_rule( - from_openapi_3_0 - ? std::vector{"example", "discriminator", - "externalDocs", "xml"} - : std::vector{})); + rules.push_back(make_rule()); rules.push_back(make_rule()); } @@ -638,8 +615,18 @@ auto convert(sourcemeta::core::JSON &schema, rules.push_back(make_rule()); } + // What the OpenAPI 3.0 fork parked goes back under its own name once every + // rung is behind the document. It cannot run alongside the ladder: a rung + // reads the dialect in front of it and would promote the very keyword the + // parking was there to keep from it + std::vector restorations; + restorations.reserve(1); + restorations.push_back(make_rule()); + apply(spellings, schema, walker, resolver, default_dialect, default_id, true); apply(rules, schema, walker, resolver, default_dialect, default_id, false); + apply(restorations, schema, walker, resolver, default_dialect, default_id, + false); } } // namespace sourcemeta::blaze diff --git a/vendor/blaze/src/convert/helpers.h b/vendor/blaze/src/convert/helpers.h index 994839dbb..197c0a7fe 100644 --- a/vendor/blaze/src/convert/helpers.h +++ b/vendor/blaze/src/convert/helpers.h @@ -157,6 +157,31 @@ inline auto names_openapi_3_0_dialect(const std::string_view dialect) -> bool { OPENAPI_3_0_DIALECTS.cend(); } +// What the OpenAPI 3.0 fork can hand to a target as the keyword it already +// was, rather than as the author data every rung in between would read it as. +// Three became keywords of a later draft with the meaning this dialect gave +// them, and four are named by the OpenAPI dialects above it +constexpr std::array CARRIED_ANNOTATIONS{ + {"readOnly", "writeOnly", "deprecated", "example", "discriminator", + "externalDocs", "xml"}}; + +// Where the fork parks one of those while the ladder climbs past it. +// +// Leaving the keyword under its own name would ask every rung that promotes it +// to recognise it and stand down, and no rung can: by the time one runs, the +// dialect the fork moved off is gone from the document, and a position the +// fork rewrote reads exactly like one written in the dialect it now declares. +// A name no dialect reads needs no such recognition, so the ladder carries it +// as the author data it looks like and the last phase gives the name back +constexpr std::string_view CARRIED_PREFIX{"x-sourcemeta-carried-"}; + +inline auto carried_name(const std::string_view annotation) + -> sourcemeta::core::JSON::String { + sourcemeta::core::JSON::String result{CARRIED_PREFIX}; + result.append(annotation); + return result; +} + // Fold an OpenAPI 3.0 `nullable` into the `type` beside it, which is the one // member of that dialect carrying an assertion rather than an annotation. Both // targets of the fork accept a type array, so the same rewrite serves each. diff --git a/vendor/blaze/src/convert/rules/carried_annotation_becomes_keyword.h b/vendor/blaze/src/convert/rules/carried_annotation_becomes_keyword.h new file mode 100644 index 000000000..dd8a37b30 --- /dev/null +++ b/vendor/blaze/src/convert/rules/carried_annotation_becomes_keyword.h @@ -0,0 +1,55 @@ +// Give back the name of an annotation the OpenAPI 3.0 fork parked, now that +// every rung that would have promoted it is behind the document. +// +// This runs in a phase of its own rather than among the rungs. A rung reads +// the dialect in front of it and this reads what the fork left behind, so one +// scheduled alongside the other would hand a rung the very keyword the parking +// was there to keep from it +class CarriedAnnotationBecomesKeyword final : public SchemaTransformRule { +public: + CarriedAnnotationBecomesKeyword() + : SchemaTransformRule{"carried_annotation_becomes_keyword"} {}; + + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { + ONLY_CONTINUE_IF(schema.is_object()); + + for (const auto &annotation : CARRIED_ANNOTATIONS) { + if (schema.defines(carried_name(annotation))) { + return true; + } + } + + return false; + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + this->renames_.clear(); + for (const auto &annotation : CARRIED_ANNOTATIONS) { + const auto parked{carried_name(annotation)}; + if (!schema.defines(parked)) { + continue; + } + + // Nothing can be standing here. The fork took this name's own member + // away as it parked it, and no rung writes one back + sourcemeta::core::JSON::String restored{annotation}; + this->renames_.emplace_back(sourcemeta::core::Pointer{parked}, + sourcemeta::core::Pointer{restored}); + schema.rename(parked, std::move(restored)); + } + } + + [[nodiscard]] auto relocations() const -> std::vector override { + return this->renames_; + } + +private: + mutable std::vector renames_; +}; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h index 12253f6e3..c2a2293b2 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_draft_2019_09_keywords.h @@ -1,9 +1,7 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { public: - PrefixPromoted201909Keywords( - std::vector &&keywords_left_alone = {}) - : SchemaTransformRule{"prefix_promoted_2019_09_keywords"}, - keywords_left_alone_{std::move(keywords_left_alone)} {}; + PrefixPromoted201909Keywords() + : SchemaTransformRule{"prefix_promoted_2019_09_keywords"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, @@ -19,7 +17,7 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { promoted_keyword_is_author_data(schema, PROMOTING_DIALECT)); for (const auto &keyword : KEYWORDS) { - if (schema.defines(keyword) && !this->is_left_alone(schema, keyword)) { + if (schema.defines(keyword)) { return true; } } @@ -32,8 +30,7 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; - if (!schema.defines(keyword_name) || - this->is_left_alone(schema, keyword)) { + if (!schema.defines(keyword_name)) { continue; } @@ -69,28 +66,5 @@ class PrefixPromoted201909Keywords final : public SchemaTransformRule { "unevaluatedProperties", "maxContains", "minContains", "contentSchema", "deprecated"}}; - // A keyword the caller asked to carry across as the keyword it already was, - // rather than as the author data it would otherwise be read as. - // - // The caller names the dialect of a document, not of each position in it, and - // a document may embed a resource written in another dialect whose members - // are exactly the author data this rule exists to protect. What tells the two - // apart is the value: the source dialect constrained the keyword it named, so - // one carrying a shape that dialect never allowed was never that keyword - [[nodiscard]] auto is_left_alone(const sourcemeta::core::JSON &schema, - const std::string_view keyword) const - -> bool { - if (std::ranges::find(this->keywords_left_alone_, keyword) == - this->keywords_left_alone_.cend()) { - return false; - } - - // Only a boolean is the annotation this names. A value of any other shape - // was never that keyword, whatever it is called, and carrying it across as - // one would leave an output the target's own meta-schema rejects - return schema.at(sourcemeta::core::JSON::String{keyword}).is_boolean(); - } - - const std::vector keywords_left_alone_; mutable std::unordered_map renames_; }; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h index 9fccf545b..a9570c7d9 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_draft_7_keywords.h @@ -1,9 +1,7 @@ class PrefixPromotedDraft7Keywords final : public SchemaTransformRule { public: - PrefixPromotedDraft7Keywords( - std::vector &&keywords_left_alone = {}) - : SchemaTransformRule{"prefix_promoted_draft_7_keywords"}, - keywords_left_alone_{std::move(keywords_left_alone)} {}; + PrefixPromotedDraft7Keywords() + : SchemaTransformRule{"prefix_promoted_draft_7_keywords"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, @@ -19,7 +17,7 @@ class PrefixPromotedDraft7Keywords final : public SchemaTransformRule { promoted_keyword_is_author_data(schema, PROMOTING_DIALECT)); for (const auto &keyword : KEYWORDS) { - if (schema.defines(keyword) && !this->is_left_alone(schema, keyword)) { + if (schema.defines(keyword)) { return true; } } @@ -32,8 +30,7 @@ class PrefixPromotedDraft7Keywords final : public SchemaTransformRule { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; - if (!schema.defines(keyword_name) || - this->is_left_alone(schema, keyword)) { + if (!schema.defines(keyword_name)) { continue; } @@ -67,28 +64,5 @@ class PrefixPromotedDraft7Keywords final : public SchemaTransformRule { {"$comment", "if", "then", "else", "readOnly", "writeOnly", "contentMediaType", "contentEncoding"}}; - // A keyword the caller asked to carry across as the keyword it already was, - // rather than as the author data it would otherwise be read as. - // - // The caller names the dialect of a document, not of each position in it, and - // a document may embed a resource written in another dialect whose members - // are exactly the author data this rule exists to protect. What tells the two - // apart is the value: the source dialect constrained the keyword it named, so - // one carrying a shape that dialect never allowed was never that keyword - [[nodiscard]] auto is_left_alone(const sourcemeta::core::JSON &schema, - const std::string_view keyword) const - -> bool { - if (std::ranges::find(this->keywords_left_alone_, keyword) == - this->keywords_left_alone_.cend()) { - return false; - } - - // Only a boolean is the annotation these two name. A value of any other - // shape was never that keyword, whatever it is called, and carrying it - // across as one would leave an output the target's own meta-schema rejects - return schema.at(sourcemeta::core::JSON::String{keyword}).is_boolean(); - } - - const std::vector keywords_left_alone_; mutable std::unordered_map renames_; }; diff --git a/vendor/blaze/src/convert/rules/prefix_promoted_openapi_3_1_keywords.h b/vendor/blaze/src/convert/rules/prefix_promoted_openapi_3_1_keywords.h index d39b66e1b..9518bb4ee 100644 --- a/vendor/blaze/src/convert/rules/prefix_promoted_openapi_3_1_keywords.h +++ b/vendor/blaze/src/convert/rules/prefix_promoted_openapi_3_1_keywords.h @@ -1,9 +1,7 @@ class PrefixPromotedOpenAPI31Keywords final : public SchemaTransformRule { public: - PrefixPromotedOpenAPI31Keywords( - std::vector &&keywords_left_alone = {}) - : SchemaTransformRule{"prefix_promoted_openapi_3_1_keywords"}, - keywords_left_alone_{std::move(keywords_left_alone)} {}; + PrefixPromotedOpenAPI31Keywords() + : SchemaTransformRule{"prefix_promoted_openapi_3_1_keywords"} {}; [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, @@ -27,8 +25,7 @@ class PrefixPromotedOpenAPI31Keywords final : public SchemaTransformRule { promoted_keyword_is_author_data(schema, OPENAPI_3_1_DIALECT)); for (const auto &keyword : KEYWORDS) { - if (schema.defines(sourcemeta::core::JSON::String{keyword}) && - !this->is_left_alone(schema, keyword)) { + if (schema.defines(sourcemeta::core::JSON::String{keyword})) { return true; } } @@ -41,8 +38,7 @@ class PrefixPromotedOpenAPI31Keywords final : public SchemaTransformRule { this->renames_.clear(); for (const auto &keyword : KEYWORDS) { const std::string keyword_name{keyword}; - if (!schema.defines(keyword_name) || - this->is_left_alone(schema, keyword)) { + if (!schema.defines(keyword_name)) { continue; } @@ -72,30 +68,5 @@ class PrefixPromotedOpenAPI31Keywords final : public SchemaTransformRule { static inline const std::array KEYWORDS{ {"example", "discriminator", "externalDocs", "xml"}}; - // A keyword the caller asked to carry across as the keyword it already was, - // rather than as the author data it would otherwise be read as. - // - // The caller names the dialect of a document, not of each position in it, and - // a document may embed a resource written in another dialect whose members - // are exactly the author data this rule exists to protect. What tells the two - // apart is the value: the source dialect constrained the keyword it named, so - // one carrying a shape that dialect never allowed was never that keyword - [[nodiscard]] auto is_left_alone(const sourcemeta::core::JSON &schema, - const std::string_view keyword) const - -> bool { - if (std::ranges::find(this->keywords_left_alone_, keyword) == - this->keywords_left_alone_.cend()) { - return false; - } - - // Three of these name an object and `example` names anything at all. A - // value of another shape was never the keyword it is named after, and - // carrying it across as one would leave an output the target's own - // meta-schema rejects - return keyword == "example" || - schema.at(sourcemeta::core::JSON::String{keyword}).is_object(); - } - - const std::vector keywords_left_alone_; mutable std::unordered_map renames_; }; diff --git a/vendor/blaze/src/convert/rules/shadow_reserved_carried_name.h b/vendor/blaze/src/convert/rules/shadow_reserved_carried_name.h new file mode 100644 index 000000000..3c22d7928 --- /dev/null +++ b/vendor/blaze/src/convert/rules/shadow_reserved_carried_name.h @@ -0,0 +1,61 @@ +// The OpenAPI 3.0 fork parks an annotation under a name of the conversion's +// own while the ladder climbs past the rung that would promote it, and the +// last phase reads it back from there. A member the author wrote under one of +// those names would be read back as a keyword they never asked for, so it is +// moved aside here, before any rung is planned, which is the last moment at +// which the author's member and the conversion's own can still be told apart. +// +// Only the names the fork actually parks under are claimed. The extension +// prefix is the author's namespace and the conversion takes no more of it than +// it writes in +class ShadowReservedCarriedName final : public SchemaTransformRule { +public: + ShadowReservedCarriedName() + : SchemaTransformRule{"shadow_reserved_carried_name"} {}; + + [[nodiscard]] auto condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::SchemaVocabularies &, + const Site &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> bool override { + ONLY_CONTINUE_IF(schema.is_object()); + + for (const auto &annotation : CARRIED_ANNOTATIONS) { + if (schema.defines(carried_name(annotation))) { + return true; + } + } + + return false; + } + + auto transform(sourcemeta::core::JSON &schema, const Site &) const + -> void override { + this->renames_.clear(); + for (const auto &annotation : CARRIED_ANNOTATIONS) { + const auto parked{carried_name(annotation)}; + if (!schema.defines(parked)) { + continue; + } + + sourcemeta::core::JSON::String shadowed{"x-"}; + shadowed.append(parked); + while (schema.defines(shadowed)) { + shadowed.insert(0, "x-"); + } + + this->renames_.emplace_back(sourcemeta::core::Pointer{parked}, + sourcemeta::core::Pointer{shadowed}); + schema.rename(parked, std::move(shadowed)); + } + } + + [[nodiscard]] auto relocations() const -> std::vector override { + return this->renames_; + } + +private: + mutable std::vector renames_; +}; diff --git a/vendor/blaze/src/convert/rules/upgrade_openapi_3_0_to_draft_4.h b/vendor/blaze/src/convert/rules/upgrade_openapi_3_0_to_draft_4.h index fa0efd4eb..0963ee4e0 100644 --- a/vendor/blaze/src/convert/rules/upgrade_openapi_3_0_to_draft_4.h +++ b/vendor/blaze/src/convert/rules/upgrade_openapi_3_0_to_draft_4.h @@ -19,7 +19,7 @@ class UpgradeOpenAPI30ToDraft4 final : public SchemaTransformRule { return holds_openapi_3_0_override(schema) || holds_ignored_sibling(schema) || - this->has_pending_openapi_3_0_pattern(schema) || + has_pending_openapi_3_0_pattern(schema) || holds_promoted_keyword(schema) || holds_movable_definitions(schema) || holds_pending_shape(schema) || at_dialect_declaration(schema, site); @@ -87,12 +87,15 @@ class UpgradeOpenAPI30ToDraft4 final : public SchemaTransformRule { } for (const auto &keyword : KEYWORDS) { - if (!schema.defines(sourcemeta::core::JSON::String{keyword}) || - this->carries_as_keyword(schema, keyword)) { + if (!schema.defines(sourcemeta::core::JSON::String{keyword})) { continue; } - this->shadow(schema, keyword); + if (this->carries_as_keyword(schema, keyword)) { + this->carry(schema, keyword); + } else { + this->shadow(schema, keyword); + } } // Taken out before the shadowing below, so that what moves is the map the @@ -233,17 +236,35 @@ class UpgradeOpenAPI30ToDraft4 final : public SchemaTransformRule { schema.rename(keyword_name, std::move(prefixed_name)); } + // Park an annotation under the name the last phase reads it back from. + // + // Handing it to the ladder under its own name would ask every rung that + // promotes that name to recognise this one and stand down, and no rung can: + // by the time one runs, the dialect this edge moved off is gone from the + // document, and a position this edge rewrote reads exactly like one written + // in the dialect it now declares. Under a name no dialect reads, the + // annotation climbs as the author data it looks like and no rung has to be + // told anything at all. + // + // The name is free wherever this runs, because a member the author wrote + // under it is moved aside before any rung is planned + auto carry(sourcemeta::core::JSON &schema, + const std::string_view keyword) const -> void { + const sourcemeta::core::JSON::String keyword_name{keyword}; + auto parked{carried_name(keyword)}; + this->renames_.emplace(keyword_name, parked); + schema.rename(keyword_name, std::move(parked)); + } + // Whether this subschema still carries something of the dialect that the - // transform will act on. Each of those is either consumed or shadowed, so the - // answer turns false once it has run and the conversion stops finding work - // here. One carried across as itself is not work, or the answer would never - // turn false - [[nodiscard]] auto - has_pending_openapi_3_0_pattern(const sourcemeta::core::JSON &subschema) const + // transform will act on. Each of those is consumed, shadowed or parked, so + // the answer turns false once it has run and the conversion stops finding + // work here + static auto + has_pending_openapi_3_0_pattern(const sourcemeta::core::JSON &subschema) -> bool { for (const auto &keyword : KEYWORDS) { - if (subschema.defines(sourcemeta::core::JSON::String{keyword}) && - !this->carries_as_keyword(subschema, keyword)) { + if (subschema.defines(sourcemeta::core::JSON::String{keyword})) { return true; } } @@ -261,16 +282,24 @@ class UpgradeOpenAPI30ToDraft4 final : public SchemaTransformRule { // Draft 4 cannot express any of the three, so what the waypoint should carry // is only answerable from where the document is going. The cost is that // converting by way of Draft 4 no longer reproduces the direct result for - // these three, which `convertsuite` records per fixture + // these three, which `convertsuite` records per fixture. + // + // The value answers as well as the target. This dialect constrained each of + // these, so a member carrying a shape it never allowed was never the keyword + // it is named after, and handing that to the target would leave an output + // the target's own meta-schema rejects. It goes across as the author data it + // has to be instead [[nodiscard]] auto carries_as_keyword(const sourcemeta::core::JSON &subschema, const std::string_view keyword) const -> bool { + const auto &value{subschema.at(sourcemeta::core::JSON::String{keyword})}; + if (keyword == "readOnly" || keyword == "writeOnly") { - return this->keeps_read_only_; + return this->keeps_read_only_ && value.is_boolean(); } if (keyword == "deprecated") { - return this->keeps_deprecated_; + return this->keeps_deprecated_ && value.is_boolean(); } if (!this->keeps_openapi_annotations_) { @@ -279,18 +308,20 @@ class UpgradeOpenAPI30ToDraft4 final : public SchemaTransformRule { // Of the four the OpenAPI dialects name for themselves, only this one is // stated more loosely by 3.0 than by the dialects above it, so only this - // one has a shape to answer for + // one has more than a shape to answer for if (keyword == "discriminator") { - const auto &discriminator{ - subschema.at(sourcemeta::core::JSON::String{keyword})}; - return this->is_expressible_discriminator(discriminator) && - mapping_survives_the_move(discriminator); + return this->is_expressible_discriminator(value) && + mapping_survives_the_move(value); + } + + if (keyword == "xml" || keyword == "externalDocs") { + return value.is_object(); } // `nullable` is not among them: no OpenAPI dialect above 3.0 has it, as a - // type union says it there instead - return keyword == "xml" || keyword == "externalDocs" || - keyword == "example"; + // type union says it there instead. `example` names anything at all, so + // there is no shape of it to refuse + return keyword == "example"; } // A `mapping` value may name a schema by reference, and the frame indexes no diff --git a/vendor/blaze/src/editor/editor.cc b/vendor/blaze/src/editor/editor.cc index 51b7c1c1e..dbdf84b5b 100644 --- a/vendor/blaze/src/editor/editor.cc +++ b/vendor/blaze/src/editor/editor.cc @@ -87,8 +87,10 @@ auto for_editor(sourcemeta::core::JSON &schema, sourcemeta::core::SchemaFrame::Mode::References, schema, walker, resolver, default_dialect}; - // Otherwise the input is not bundled - assert(frame.standalone()); + // Otherwise the input is not bundled. A meta-schema the caller never + // meant to embed is not a reference this needs resolved, as an unresolved + // one is rewritten below + assert(frame.standalone_ignoring_metaschemas()); // Collect reference changes frame.for_each_reference( diff --git a/vendor/blaze/src/editor/include/sourcemeta/blaze/editor.h b/vendor/blaze/src/editor/include/sourcemeta/blaze/editor.h index 58dce3ece..570dc9cd2 100644 --- a/vendor/blaze/src/editor/include/sourcemeta/blaze/editor.h +++ b/vendor/blaze/src/editor/include/sourcemeta/blaze/editor.h @@ -31,7 +31,9 @@ namespace sourcemeta::blaze { /// might be edge cases that we cannot cover. The real solution is for popular /// editors to fix their JSON Schema language support. /// -/// Note that the input schema is expected to be already bundled. +/// Note that the input schema is expected to be already bundled. Meta-schema +/// declarations may remain external, as this function rewrites those to the +/// base dialect in force at the subschema that declares them. /// /// ```cpp /// #include diff --git a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h index d498171ab..4b2ce9e90 100644 --- a/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h +++ b/vendor/core/src/core/jsonpointer/include/sourcemeta/core/jsonpointer_pointer.h @@ -536,7 +536,9 @@ template class GenericPointer { /// ``` [[nodiscard]] auto concat(const GenericPointer &other) const -> GenericPointer { - GenericPointer result{*this}; + GenericPointer result; + result.reserve(this->data_.size() + other.data_.size()); + result.push_back(*this); result.push_back(other); return result; } @@ -553,7 +555,9 @@ template class GenericPointer { /// ``` [[nodiscard]] auto concat(const Token::Property &property) const -> GenericPointer { - GenericPointer result{*this}; + GenericPointer result; + result.reserve(this->data_.size() + 1); + result.push_back(*this); result.push_back(property); return result; } @@ -570,7 +574,9 @@ template class GenericPointer { /// ``` [[nodiscard]] auto concat(const Token::Index &index) const -> GenericPointer { - GenericPointer result{*this}; + GenericPointer result; + result.reserve(this->data_.size() + 1); + result.push_back(*this); result.push_back(index); return result; } diff --git a/vendor/core/src/core/jsonschema/bundle.cc b/vendor/core/src/core/jsonschema/bundle.cc index cfa3dfbed..76e51433a 100644 --- a/vendor/core/src/core/jsonschema/bundle.cc +++ b/vendor/core/src/core/jsonschema/bundle.cc @@ -76,6 +76,123 @@ auto rebase_reference(const JSON::String &base, return result.recompose(); } +// The URI that a relative reference spelled against a base resolves to, or +// nothing where the spelling already names its target on its own, where the +// base cannot settle what it names, or where what is spelled is no URI +// reference at all. An absolute base is what the last of those asks for, as +// resolving against a relative one settles nothing and the module merges what +// it can rather than refusing +// +// Resolution is the whole of it, with no normalisation after. RFC 3986, +// section 6.2.3 holds that "two URIs that differ only by the suffix \"#\" are +// considered different regardless of the scheme", and the normalisation that +// drops such a fragment answers to section 8.2.1 of the JSON Schema +// specification, which asks it of an identifier rather than of every URI a +// schema writes. Nothing is lost by leaving it out, as section 5.2.2 removes +// dot segments as part of resolving rather than as normalisation +auto restated_uri(const std::string_view uri, const std::string_view base) + -> std::optional { + try { + URI result{uri}; + if (!result.is_relative()) { + return std::nullopt; + } + + const URI anchor{base}; + if (!anchor.is_absolute()) { + return std::nullopt; + } + + result.resolve_from(anchor); + return result.recompose(); + } catch (const URIParseError &) { + return std::nullopt; + } +} + +// OpenAPI Specification 3.1.1, Section 4.8.24 puts an External Documentation +// Object on a Schema Object, and Section 4.8.11 makes its `url` "The URI for +// the target documentation". Section 4.6 has every URI a description writes +// resolve against the base in effect where it sits, which is the same footing +// a reference stands on, so this one is spelled out exactly as one, wherever +// it sits and whatever base it answers to. A document that bundling has +// gathered is meant to travel away from the bases its parts were read +// against, and spelling every relative URI out is what lets each of them go +// on naming what it named. Leaving one of two identical resources behind +// would also make them differ over nothing +// +// What that base is comes from the framing rather than from this field. It is +// the nearest enclosing identifier wherever the dialect reserves a keyword for +// one, and otherwise the identity the document itself was read under, which is +// the only base a revision that reserves no such keyword can offer +// +// Earlier revisions read the field the other way around. OpenAPI +// Specification 3.0.3 calls it a URL and has every relative URL resolve +// "using the URLs defined in the Server Object as a Base URI", carving out +// nothing but a reference, and 3.1.0 says as much of a URL too. Only 3.0.4 +// and 3.1.1 put the field back on the footing of every other URI, the former +// leaving the choice "implementation_defined" while naming this reading +// RECOMMENDED. One rule serves every revision here, as a Schema Object +// carries no Server Object of its own and nothing at this level can reach +// one, so the older reading is not a choice this has to make. It becomes one +// where a whole description is bundled, which does have those URLs in view +// +// RFC 3986, section 4.4 spares an empty or fragment-only reference from this +// elsewhere, a same-document reference needing no base beyond the one already +// in effect. That reasoning does not reach here, as the document one of those +// leads back to is the one the schema was read from rather than whichever one +// it ends up in +auto restate_external_documentation(JSON &subschema, const SchemaFrame &frame, + const SchemaWalker &walker, + const SchemaResolver &resolver) -> void { + // Which keywords a dialect is made of is settled per schema resource rather + // than per subschema, and the locations of one resource come together, so + // the answer for the last dialect seen carries to the next subschema. An + // empty dialect names nothing this could ask about, which is what the + // initial state already stands for + std::string_view current_dialect; + bool dialect_documents{false}; + + frame.for_each_subschema([&subschema, &frame, &walker, &resolver, + ¤t_dialect, + &dialect_documents](const auto &location) -> void { + if (location.dialect != current_dialect) { + current_dialect = location.dialect; + dialect_documents = dialect_defines( + walker, frame.vocabularies(location, resolver), "externalDocs"); + } + + // A dialect that gives the keyword no meaning leaves it holding whatever + // the schema put there, which moving cannot break + if (!dialect_documents) { + return; + } + + auto &schema{get(subschema, location.pointer)}; + if (!schema.is_object()) { + return; + } + + auto *documentation{ + schema.try_at("externalDocs"sv, JSONSCHEMA_HASH_EXTERNAL_DOCS)}; + if (documentation == nullptr || !documentation->is_object()) { + return; + } + + auto *written{documentation->try_at("url"sv, JSONSCHEMA_HASH_URL)}; + if (written == nullptr || !written->is_string()) { + return; + } + + // What a description spells there is its own to answer for, as bundling is + // not where a Schema Object is checked + auto restated{restated_uri(written->to_string(), location.base)}; + if (restated.has_value()) { + *written = JSON{std::move(restated).value()}; + } + }); +} + // The dialect a schema declares, falling back to the given default auto declared_dialect(const JSON &schema, const std::string_view default_dialect) @@ -712,6 +829,12 @@ auto embed_references( depth == 0 ? default_base : std::string_view{}, remaining}; charge(remaining, frame); + // Every schema this reaches, the one the caller handed over as much as every + // remote gathered into it, is spelled out against the base it was read + // against, which is what the reference walks below do for a reference and + // what keeps two identical resources identical + restate_external_documentation(subschema, frame, walker, resolver); + // 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 diff --git a/vendor/core/src/core/jsonschema/helpers.h b/vendor/core/src/core/jsonschema/helpers.h index 036c939ce..d35024245 100644 --- a/vendor/core/src/core/jsonschema/helpers.h +++ b/vendor/core/src/core/jsonschema/helpers.h @@ -32,6 +32,9 @@ constexpr auto JSONSCHEMA_HASH_RECURSIVE_ANCHOR{ constexpr auto JSONSCHEMA_HASH_VOCABULARY{JSON::Object::hash("$vocabulary"sv)}; constexpr auto JSONSCHEMA_HASH_DEFS{JSON::Object::hash("$defs"sv)}; constexpr auto JSONSCHEMA_HASH_DEFINITIONS{JSON::Object::hash("definitions"sv)}; +constexpr auto JSONSCHEMA_HASH_EXTERNAL_DOCS{ + JSON::Object::hash("externalDocs"sv)}; +constexpr auto JSONSCHEMA_HASH_URL{JSON::Object::hash("url"sv)}; constexpr auto EXTENSION_DEFINITIONS_KEYWORD{"x-definitions"sv}; constexpr auto DIALECT_OVERRIDE_KEYWORD{ "x-sourcemeta-dialect-override-subschema"sv}; diff --git a/vendor/core/src/core/mcp/parameter_headers.cc b/vendor/core/src/core/mcp/parameter_headers.cc index 877989dfa..3f433fed7 100644 --- a/vendor/core/src/core/mcp/parameter_headers.cc +++ b/vendor/core/src/core/mcp/parameter_headers.cc @@ -7,13 +7,16 @@ #include #include -#include // std::set -#include // std::string, std::to_string -#include // std::vector +#include // std::sort, std::lower_bound, std::lexicographical_compare +#include // std::next +#include // std::set +#include // std::string, std::to_string +#include // std::vector namespace { using sourcemeta::core::JSON; using sourcemeta::core::MCPHeaderParameter; +constexpr JSON::StringView PARAMETER_HEADER_PREFIX{"Mcp-Param-"}; auto value_at(const JSON &arguments, const MCPHeaderParameter ¶meter) -> const JSON * { @@ -33,12 +36,23 @@ auto value_at(const JSON &arguments, const MCPHeaderParameter ¶meter) // The specification constrains each mirrored parameter name, type and location // separately, so each constraint reports itself // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#schema-extension +struct NameLess { + auto operator()(const JSON::StringView left, + const JSON::StringView right) const -> bool { + return std::lexicographical_compare( + left.begin(), left.end(), right.begin(), right.end(), + [](const char left_character, const char right_character) { + return sourcemeta::core::to_lowercase(left_character) < + sourcemeta::core::to_lowercase(right_character); + }); + } +}; + auto require_parameters(const std::span parameters) -> void { - std::set names; + std::set names; + std::size_t index{0}; for (const auto ¶meter : parameters) { - std::string name{parameter.name}; - sourcemeta::core::to_lowercase(name); // A name must be non-empty and match HTTP field-name token syntax, which // also rules out control characters sourcemeta::core::internal::require( @@ -55,9 +69,21 @@ auto require_parameters(const std::span parameters) parameter.type == JSON::Type::Integer || parameter.type == JSON::Type::Boolean, "Parameter headers only mirror strings, integers and booleans"); + bool unique{true}; + if (parameters.size() <= 8) { + for (std::size_t previous{0}; previous < index; ++previous) { + if (sourcemeta::core::equals_ignore_case(parameters[previous].name, + parameter.name)) { + unique = false; + break; + } + } + } else { + unique = names.insert(parameter.name).second; + } + ++index; sourcemeta::core::internal::require( - names.insert(std::move(name)).second, - "Parameter header names must be case-insensitively unique"); + unique, "Parameter header names must be case-insensitively unique"); } } @@ -120,7 +146,8 @@ auto mcp_make_parameter_headers( if (const auto *value{value_at(arguments, parameter)}; value) { internal::require(safe_parameter(*value, parameter), "Invalid mirrored argument type or unsafe integer"); - result.emplace_back("Mcp-Param-" + std::string{parameter.name}, + result.emplace_back(std::string{PARAMETER_HEADER_PREFIX} + + std::string{parameter.name}, mcp_encode_header_value(parameter_text(*value))); } } @@ -141,24 +168,61 @@ auto mcp_validate_parameter_headers( return mcp_make_error(version, &identifier, JSONRPC_CODE_INVALID_PARAMS, "Invalid tool arguments"); } + // Index only larger collections. Small requests keep the allocation-free + // scan; unrelated headers and duplicates of unused names remain opaque. + std::vector> indexed; + const bool use_index{parameters.size() > 8 && headers.size() > 16}; + if (use_index) { + indexed.reserve(headers.size()); + for (const auto &entry : headers) { + if (starts_with_ignore_case(entry.first, PARAMETER_HEADER_PREFIX)) { + indexed.emplace_back(entry.first.substr(PARAMETER_HEADER_PREFIX.size()), + entry.second); + } + } + std::sort(indexed.begin(), indexed.end(), + [](const auto &left, const auto &right) { + return NameLess{}(left.first, right.first); + }); + } for (const auto ¶meter : parameters) { - const std::string name{"Mcp-Param-" + std::string{parameter.name}}; - std::optional header; - for (const auto &candidate : headers) { - if (equals_ignore_case(candidate.first, name)) { - if (header) { + if (use_index) { + const auto found{std::lower_bound(indexed.begin(), indexed.end(), + parameter.name, + [](const auto &entry, const auto name) { + return NameLess{}(entry.first, name); + })}; + if (found != indexed.end() && + equals_ignore_case(found->first, parameter.name)) { + header = found->second; + if (std::next(found) != indexed.end() && + equals_ignore_case(std::next(found)->first, parameter.name)) { return mcp_make_error_header_mismatch(version, &identifier, "Duplicate parameter header"); } - header = candidate.second; + } + } else { + for (const auto &candidate : headers) { + if (starts_with_ignore_case(candidate.first, PARAMETER_HEADER_PREFIX) && + equals_ignore_case( + candidate.first.substr(PARAMETER_HEADER_PREFIX.size()), + parameter.name)) { + if (header) { + return mcp_make_error_header_mismatch(version, &identifier, + "Duplicate parameter header"); + } + header = candidate.second; + } } } const auto *value{value_at(arguments, parameter)}; if (value == nullptr) { if (header) { - return mcp_make_error_header_mismatch(version, &identifier, name, - *header); + return mcp_make_error_header_mismatch( + version, &identifier, + std::string{PARAMETER_HEADER_PREFIX} + std::string{parameter.name}, + *header); } continue; } @@ -184,9 +248,10 @@ auto mcp_validate_parameter_headers( } } if (!matches) { - return mcp_make_error_header_mismatch(version, &identifier, name, - header.value_or(""), - parameter_text(*value)); + return mcp_make_error_header_mismatch( + version, &identifier, + std::string{PARAMETER_HEADER_PREFIX} + std::string{parameter.name}, + header.value_or(""), parameter_text(*value)); } } return std::nullopt; diff --git a/vendor/core/src/core/openapi/bundle.cc b/vendor/core/src/core/openapi/bundle.cc index 69c5d6357..af7f70c63 100644 --- a/vendor/core/src/core/openapi/bundle.cc +++ b/vendor/core/src/core/openapi/bundle.cc @@ -316,6 +316,15 @@ auto absolutize_schemas(sourcemeta::core::JSON &value, continue; } + // One that names its target on its own needs no base, so moving the + // Object holding it cannot change what it names and what the description + // wrote is what travels. A mapping that goes by a name is not one of + // those, as Section 4.3.3 resolves a name from the entry document rather + // than from where it sits, which is what writing the URI out settles + if (sourcemeta::core::openapi_names_its_own_target(written->to_string())) { + continue; + } + if (written->to_string() != discriminator.destination) { sourcemeta::core::set(value, held, sourcemeta::core::JSON{discriminator.destination}); @@ -355,9 +364,12 @@ auto absolutize_schemas(sourcemeta::core::JSON &value, // The frame of the shell never reaches inside a Schema Object, so the one // URI a schema carries that is neither a reference nor an identifier is // written back here - frame.for_each_location([&value, &origin, &moved, &frame, &walker, - &resolver](const auto, const auto &, - const auto &location) -> void { + // Asking for every location would reach a place that a reference names + // without it being a schema of its own, a keyword value such as the instance + // an enumeration or a constant holds, and writing a URI into one of those + // would be rewriting what a description says an instance must look like + frame.for_each_subschema([&value, &origin, &moved, &frame, &walker, + &resolver](const auto &location) -> void { if (!location.pointer.starts_with(moved)) { return; } @@ -383,11 +395,11 @@ auto absolutize_schemas(sourcemeta::core::JSON &value, return; } - const auto absolute{sourcemeta::core::openapi_resolve_uri( + const auto restated{sourcemeta::core::openapi_restated_uri( written->to_string(), sourcemeta::core::JSON::String{location.base})}; - if (absolute.has_value()) { + if (restated.has_value()) { sourcemeta::core::set( - value, held, sourcemeta::core::JSON{absolute.value().recompose()}); + value, held, sourcemeta::core::JSON{restated.value().recompose()}); } }); @@ -1421,10 +1433,41 @@ auto absolutize(JSON &value, const OpenAPIWalk &remote, const Pointer &origin, continue; } - const auto absolute{ - openapi_reference_target(written->to_string(), remote)}; - if (absolute.has_value()) { - set(value, held, JSON{absolute.value().recompose()}); + const auto restated{ + openapi_restated_uri(written->to_string(), remote.base)}; + if (restated.has_value()) { + set(value, held, JSON{restated.value().recompose()}); + } + } + } +} + +// And so is every URI the document the caller gave carries, which moving +// nothing leaves resolving against a base that the bundle is meant to travel +// away from. Spelling those out is what keeps one documentation URI from +// reading differently to another over nothing but which document it was +// written in, as what is gathered in is spelled out where it came from and +// what is already here would otherwise be left as it was. Only the shell is +// reached from here, a Schema Object being spelled out by JSON Schema +// bundling under the same rule +auto restate_entry_uris(sourcemeta::core::JSON &document, + const sourcemeta::core::OpenAPIWalk &walk) -> void { + for (const auto &entry : walk.locations) { + for (const auto &field : + sourcemeta::core::openapi_embedded_uri_fields(entry.second.type)) { + const auto held{ + (entry.second.pointer).concat(sourcemeta::core::JSON::String{field})}; + const auto *written{sourcemeta::core::try_get(document, held)}; + if (written == nullptr || !written->is_string()) { + continue; + } + + const auto restated{sourcemeta::core::openapi_restated_uri( + written->to_string(), walk.base)}; + if (restated.has_value()) { + sourcemeta::core::set( + document, held, + sourcemeta::core::JSON{restated.value().recompose()}); } } } @@ -1908,6 +1951,10 @@ auto bundle_internal(JSON &document, const SchemaWalker &walker, names.tags = walk.tags; names.tag_names = walk.tag_names; named = true; + // On this pass alone, as nothing has been gathered in yet and so every + // place the walk reports is one the caller wrote. What arrives later is + // spelled out against the base it was read under instead + restate_entry_uris(document, walk); } charge(remaining, walk.locations.size()); diff --git a/vendor/core/src/core/openapi/helpers.h b/vendor/core/src/core/openapi/helpers.h index 327542133..d8ef8576c 100644 --- a/vendor/core/src/core/openapi/helpers.h +++ b/vendor/core/src/core/openapi/helpers.h @@ -19,7 +19,7 @@ #include // std::set #include // std::span #include // std::string_view -#include // std::pair, std::move +#include // std::pair, std::move, std::unreachable #include // std::vector namespace sourcemeta::core { @@ -69,20 +69,40 @@ struct OpenAPIField { // reading URL does not, as Section 4.7 resolves those "using the URLs defined // in the Server Object as a Base URL" rather than against any document. The // endpoints of a Security Scheme Object and of an OAuth Flow Object are that -// second kind, and moving one leaves what it names untouched because the -// Server Objects it reads against are not what moved. +// second kind, every row of them reading URL: an authorization endpoint is +// "The authorization URL to be used for this flow. This MUST be in the form of +// a URL", and the rest say as much. +// +// Those are left alone because nothing here can settle them, not because +// moving settles nothing. The Server Objects such a field reads against are +// the ones of whichever description holds it, and no part of this carries a +// remote's own along, so moving an Object of that kind does change which URLs +// it answers to. Writing it back against the base of a document would be +// resolving it against the one thing Section 4.7 says it is not relative to, +// which is worse than leaving it. Carrying server context is what it would +// take, and nothing here does. // // A `$ref` and an `operationRef` are left out, as the frame records those as // references of their own. A Server Object `url` is left out too, as Section // 4.8.5 makes it a URL template rather than a URI reference, and resolving one -// through a URI would mangle the variables it is written with. So is every -// field of the Objects that only ever sit at the root of a document, as those -// never move anywhere +// through a URI would mangle the variables it is written with, which is why it +// has a resolution of its own. +// +// Sitting at the root of a document is no reason to be left out, as what this +// answers for is spelled out wherever it sits rather than only where it moved. +// The `url` of a Contact Object and of a License Object, and the +// `termsOfService` of an Info Object, are left out for the reason above +// instead: Section 4.6 of 3.0.4 reads each of them "A URL [...] This MUST be +// in the form of a URL", which answers to the Server Object. From 3.1.1 every +// one of them reads URI, which would bring them in, and telling those two +// apart is a question of the revision that nothing here answers yet constexpr std::array OPENAPI_URI_FIELDS_EXTERNAL_DOCS{ {"url"sv}}; constexpr std::array OPENAPI_URI_FIELDS_EXAMPLE{ {"externalValue"sv}}; +// Every kind is named rather than left to a fallback, so that one added later +// has to be answered for rather than silently reading as carrying no URI inline auto openapi_embedded_uri_fields(const OpenAPIObjectKind kind) noexcept -> std::span { switch (kind) { @@ -90,9 +110,54 @@ inline auto openapi_embedded_uri_fields(const OpenAPIObjectKind kind) noexcept return OPENAPI_URI_FIELDS_EXTERNAL_DOCS; case OpenAPIObjectKind::Example: return OPENAPI_URI_FIELDS_EXAMPLE; - default: + + // Whose URI fields are URLs, and so answer to the Server Objects of + // whichever description holds them rather than to any document + case OpenAPIObjectKind::SecurityScheme: + case OpenAPIObjectKind::OAuthFlow: + case OpenAPIObjectKind::Server: + case OpenAPIObjectKind::ServerVariable: + + // Whose only URI is one the frame records as a reference of its own, and + // which the rebasing of references settles rather than this + case OpenAPIObjectKind::Reference: + case OpenAPIObjectKind::Link: + case OpenAPIObjectKind::Schema: + + // Whose one URI is the identity of the document itself, which establishing + // a base is what reads rather than anything that writes + case OpenAPIObjectKind::Document: + + // Whose URI fields read as URLs in 3.0 and as URIs from 3.1.1, which the + // comment above leaves open + case OpenAPIObjectKind::Info: + case OpenAPIObjectKind::Contact: + case OpenAPIObjectKind::License: + + // Which carry no URI field of their own at all + case OpenAPIObjectKind::PathItem: + case OpenAPIObjectKind::Parameter: + case OpenAPIObjectKind::RequestBody: + case OpenAPIObjectKind::Response: + case OpenAPIObjectKind::Header: + case OpenAPIObjectKind::Callbacks: + case OpenAPIObjectKind::MediaType: + case OpenAPIObjectKind::Components: + case OpenAPIObjectKind::Paths: + case OpenAPIObjectKind::Operation: + case OpenAPIObjectKind::Encoding: + case OpenAPIObjectKind::Responses: + case OpenAPIObjectKind::Tag: + case OpenAPIObjectKind::OAuthFlows: + case OpenAPIObjectKind::SecurityRequirement: return {}; } + + // Clang settles that no other value arrives, so only GCC and MSVC need to + // be told that the end of this function is never reached +#if !defined(__clang__) + std::unreachable(); +#endif } /// What a Path Item Object declares that the endpoints reaching it need. Two @@ -537,6 +602,72 @@ inline auto openapi_resolve_uri(const JSON::StringView reference, } } +// What a relative reference spelled against a base comes to, for a field that +// a description writes and bundling writes back rather than compares. Nothing +// comes back where the spelling already names its target on its own, as a URI +// that needs no base cannot be made to name somewhere else by moving, so +// resolving it would only spend what the description wrote. +// +// Unlike the resolution a reference goes through, this one does not +// canonicalise. RFC 3986, section 6.2.3 holds that "two URIs that differ only +// by the suffix \"#\" are considered different regardless of the scheme", and +// the normalisation that drops such a fragment answers to JSON Schema +// 2020-12, section 8.2.1, which asks it of an identifier rather than of every +// URI a description writes. Resolution alone is enough and takes nothing away: +// section 5.2.2 removes dot segments as part of resolving rather than as +// normalisation. +// +// One base serves every revision, though they do not agree. Section 4.6 of +// 3.0.3 resolves every relative URL "using the URLs defined in the Server +// Object as a Base URI" and carves out nothing but a reference, and 3.1.0 says +// as much of a URL, while 3.0.4 reopens the field by leaving it +// "implementation_defined" between that and the process a reference goes +// through and naming the latter RECOMMENDED "For compatibility with future +// versions of this specification", which 3.1.1 then settles by calling the +// field a URI. Telling those apart would mean telling patch releases apart, +// which this module does not do anywhere. It would also only be half a +// telling, as what sits inside a Schema Object is resolved by JSON Schema +// bundling against a base of its own whatever revision is in force, so a +// description would answer to two readings at once. And the earlier reading is +// underdetermined where it would matter most, a description being free to +// declare several Server Objects +// +// What that costs is worth writing down: under the earlier reading a relative +// URL answers to Server Objects, and no part of bundling carries a document's +// own along, so for those revisions a bundled description does not read quite +// as the one it came from +inline auto openapi_restated_uri(const JSON::StringView reference, + const JSON::String &base) + -> std::optional { + if (base.empty()) { + return std::nullopt; + } + + try { + URI target{JSON::String{reference}}; + if (!target.is_relative()) { + return std::nullopt; + } + + target.resolve_from(URI{base}); + return target; + } catch (const URIParseError &) { + return std::nullopt; + } +} + +// Whether what is spelled names its target without a base, which is what +// decides between carrying it over as written and resolving it. What cannot be +// read as a URI reference at all is no such spelling +inline auto openapi_names_its_own_target(const JSON::StringView reference) + -> bool { + try { + return !URI{JSON::String{reference}}.is_relative(); + } catch (const URIParseError &) { + return false; + } +} + // The document a location key names, which is that key up to its fragment, // which is why nothing repeats it on the entry a key leads to inline auto openapi_document_uri(const JSON::String &uri) -> JSON::String { diff --git a/vendor/core/src/core/openapi/server.h b/vendor/core/src/core/openapi/server.h index 43a9e053f..8a3541d31 100644 --- a/vendor/core/src/core/openapi/server.h +++ b/vendor/core/src/core/openapi/server.h @@ -318,6 +318,13 @@ inline auto openapi_resolve_server_url(const JSON::StringView address, const auto variable{address.find('{')}; // A template that declares no variable is a URI reference already if (variable == JSON::StringView::npos) { + // And one that names its target on its own needs no base, so what the + // description wrote travels as written, which is what the branches below + // already do for a template that names one + if (openapi_names_its_own_target(address)) { + return JSON::String{address}; + } + const auto resolved{openapi_resolve_uri(address, base)}; return resolved.has_value() ? std::optional{resolved.value().recompose()} diff --git a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h index 03e6be9b1..07669b107 100644 --- a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h +++ b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h @@ -645,6 +645,11 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// uri.canonicalize(); /// assert(uri.recompose() == "http://example.com/TEST"); /// ``` + /// + /// A fragment that is present and empty is removed too, so a trailing number + /// sign does not survive. RFC 3986 section 6.2.3 holds two URIs that differ + /// only by that suffix to be different, so a caller that must keep what it + /// was given should not canonicalize. auto canonicalize() -> URI &; /// Convert a URI into a filesystem path. If the URI is not under the `file`