diff --git a/docs/rdf.markdown b/docs/rdf.markdown index 307768e8..61ac4cbf 100644 --- a/docs/rdf.markdown +++ b/docs/rdf.markdown @@ -71,6 +71,30 @@ Schema, so annotated schemas remain valid for every other tool. | `x-jsonld-self` | An [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) URI template | scalar or object subschema | Mint the node `@id` from instance values, such as `https://www.iso.org/iso-4217/{this}` | | `x-jsonld-override` | A boolean | any subschema | Give the schema object's own `x-jsonld-*` values precedence over conflicting ones from subschemas beneath it, such as a sibling `$ref` | +The guarantee this command makes is syntactic: if resolution succeeds, the +output is well-formed JSON-LD. Resolution errors enforce only what that +guarantee needs, such as keyword value grammar, annotation placement, and +single-value consistency, and every error cites the schema location of the +offending annotation. The command does not judge whether the IRIs, datatypes, +and language tags you declare make semantic sense. A schema can declare a +datatype whose lexical space its values never fit, or a misspelled ontology +term, and the command emits the annotations faithfully as written. That +correctness is on you as the schema author. In the future, we plan to provide +lint rules that catch more of these mistakes statically at design time, where +they belong, instead of paying for the checks on every promotion. + +> [!NOTE] +> As a deliberate deviation from JSON-LD 1.1, language tags must be written in +> canonical BCP 47 form, both as `x-jsonld-language` values and as the map +> keys of an `@language` container in instance data. For example, `en-US` is +> accepted while `en-us` is rejected. This keeps language tag equality a plain +> cheap string comparison everywhere in the engine. + +The variables of an `x-jsonld-self` URI template are matched verbatim against +instance property names, so a variable like `{+meta.slug}` binds a property +literally named `meta.slug`. There is no dotted path traversal into nested +objects. + For example, consider the following product catalog schema, which validates products and maps them to [schema.org](https://schema.org) at the same time: diff --git a/src/command_rdf.cc b/src/command_rdf.cc index ca719e2f..29088f5f 100644 --- a/src/command_rdf.cc +++ b/src/command_rdf.cc @@ -47,6 +47,8 @@ auto facet_name(const sourcemeta::blaze::JSONLDFacet facet) return "container"; case sourcemeta::blaze::JSONLDFacet::Self: return "self"; + case sourcemeta::blaze::JSONLDFacet::Override: + return "override"; default: std::unreachable(); } @@ -189,13 +191,20 @@ auto sourcemeta::jsonschema::rdf(const sourcemeta::core::Options &options) std::move(error.message), std::string{facet_name(error.facet)}, std::move(error.instance_location), + std::move(error.schema_location), + std::move(error.conflicting_schema_location), + std::move(error.inert_override_location), instance_from_stdin ? stdin_path() : instance_path}; } - throw RdfResolutionError{ - std::move(error.message), std::string{facet_name(error.facet)}, - std::move(error.instance_location), - instance_from_stdin ? stdin_path() : instance_path}; + throw RdfResolutionError{std::move(error.message), + std::string{facet_name(error.facet)}, + std::move(error.instance_location), + std::move(error.schema_location), + std::move(error.conflicting_schema_location), + std::move(error.inert_override_location), + instance_from_stdin ? stdin_path() + : instance_path}; } auto document{std::get(std::move(outcome))}; diff --git a/src/error.h b/src/error.h index 3559763a..83f07049 100644 --- a/src/error.h +++ b/src/error.h @@ -23,6 +23,7 @@ #include // std::function #include // std::initializer_list #include // std::cout, std::cerr +#include // std::optional #include // std::runtime_error #include // std::string #include // std::is_base_of_v, std::is_same_v @@ -238,9 +239,15 @@ class RdfResolutionError : public std::runtime_error { public: RdfResolutionError(std::string message, std::string facet, sourcemeta::core::Pointer instance_location, + std::string schema_location, + std::optional conflicting_schema_location, + std::optional inert_override_location, std::filesystem::path path) : std::runtime_error{std::move(message)}, facet_{std::move(facet)}, instance_location_{std::move(instance_location)}, + schema_location_{std::move(schema_location)}, + conflicting_schema_location_{std::move(conflicting_schema_location)}, + inert_override_location_{std::move(inert_override_location)}, path_{std::move(path)} {} [[nodiscard]] auto facet() const noexcept -> const std::string & { @@ -252,6 +259,20 @@ class RdfResolutionError : public std::runtime_error { return this->instance_location_; } + [[nodiscard]] auto schema_location() const noexcept -> const std::string & { + return this->schema_location_; + } + + [[nodiscard]] auto conflicting_schema_location() const noexcept + -> const std::optional & { + return this->conflicting_schema_location_; + } + + [[nodiscard]] auto inert_override_location() const noexcept + -> const std::optional & { + return this->inert_override_location_; + } + [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { return this->path_; } @@ -259,6 +280,9 @@ class RdfResolutionError : public std::runtime_error { private: std::string facet_; sourcemeta::core::Pointer instance_location_; + std::string schema_location_; + std::optional conflicting_schema_location_; + std::optional inert_override_location_; std::filesystem::path path_; }; @@ -590,6 +614,54 @@ inline auto print_exception(const bool is_json, const Exception &exception) } } + if constexpr (requires(const Exception ¤t) { + { + current.schema_location() + } -> std::convertible_to; + }) { + if (is_json) { + error_json.assign("schemaLocation", + sourcemeta::core::JSON{exception.schema_location()}); + } else { + std::cerr << " at schema location " << exception.schema_location() + << "\n"; + } + } + + if constexpr (requires(const Exception ¤t) { + { + current.conflicting_schema_location() + } -> std::convertible_to &>; + }) { + const auto &conflicting_location{exception.conflicting_schema_location()}; + if (conflicting_location.has_value()) { + if (is_json) { + error_json.assign("conflictingSchemaLocation", + sourcemeta::core::JSON{conflicting_location.value()}); + } else { + std::cerr << " at conflicting schema location " + << conflicting_location.value() << "\n"; + } + } + } + + if constexpr (requires(const Exception ¤t) { + { + current.inert_override_location() + } -> std::convertible_to &>; + }) { + const auto &override_location{exception.inert_override_location()}; + if (override_location.has_value()) { + if (is_json) { + error_json.assign("inertOverrideLocation", + sourcemeta::core::JSON{override_location.value()}); + } else { + std::cerr << " at inert override location " + << override_location.value() << "\n"; + } + } + } + if constexpr (requires(const Exception ¤t) { { current.path() @@ -863,10 +935,30 @@ inline auto try_catch(const sourcemeta::core::Options &options, } catch (const PositionError &error) { const auto is_json{options.contains("json")}; print_exception(is_json, error); + if (!is_json && error.inert_override_location().has_value()) { + std::cerr << "\nThe x-jsonld-override mark was ignored because it does " + "not enclose the\n"; + std::cerr << "conflicting annotation. Move the conflicting annotation, " + "or the reference\n"; + std::cerr << "that brings it in, inside the overriding object for the " + "override to\n"; + std::cerr << "take effect\n"; + } + return EXIT_SCHEMA_INPUT_ERROR; } catch (const RdfResolutionError &error) { const auto is_json{options.contains("json")}; print_exception(is_json, error); + if (!is_json && error.inert_override_location().has_value()) { + std::cerr << "\nThe x-jsonld-override mark was ignored because it does " + "not enclose the\n"; + std::cerr << "conflicting annotation. Move the conflicting annotation, " + "or the reference\n"; + std::cerr << "that brings it in, inside the overriding object for the " + "override to\n"; + std::cerr << "take effect\n"; + } + return EXIT_SCHEMA_INPUT_ERROR; } catch (const UnsupportedDialectRdfError &error) { const auto is_json{options.contains("json")}; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index be337d68..7a10c553 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1023,8 +1023,10 @@ add_jsonschema_test_unix(rdf/fail_no_arguments) add_jsonschema_test_unix(rdf/fail_no_instance) add_jsonschema_test_unix(rdf/fail_resolution_container_conflict) add_jsonschema_test_unix(rdf/fail_resolution_datatype_conflict) +add_jsonschema_test_unix(rdf/fail_resolution_inert_override) add_jsonschema_test_unix(rdf/fail_resolution_invalid_language) add_jsonschema_test_unix(rdf/fail_resolution_invalid_predicate_iri) +add_jsonschema_test_unix(rdf/fail_resolution_override_not_boolean) add_jsonschema_test_unix(rdf/fail_resolution_self_unbound) add_jsonschema_test_unix(rdf/fail_resolution_type_on_literal) add_jsonschema_test_unix(rdf/fail_resolve_invalid_json) diff --git a/test/rdf/fail_resolution_container_conflict.sh b/test/rdf/fail_resolution_container_conflict.sh index 5f75f6b5..776ce0bd 100755 --- a/test/rdf/fail_resolution_container_conflict.sh +++ b/test/rdf/fail_resolution_container_conflict.sh @@ -40,6 +40,8 @@ error: A JSON-LD container cannot be assigned more than one value at column 3 at instance location "/tags" at facet "container" + at schema location file://$(realpath "$TMP")/schema.json#/properties/tags/allOf/0/x-jsonld-container + at conflicting schema location file://$(realpath "$TMP")/schema.json#/properties/tags/allOf/1/x-jsonld-container at file path $(realpath "$TMP")/instance.json EOF @@ -58,6 +60,8 @@ cat << EOF > "$TMP/expected.txt" "column": 3, "instanceLocation": "/tags", "facet": "container", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/tags/allOf/0/x-jsonld-container", + "conflictingSchemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/tags/allOf/1/x-jsonld-container", "filePath": "$(realpath "$TMP")/instance.json" } EOF diff --git a/test/rdf/fail_resolution_datatype_conflict.sh b/test/rdf/fail_resolution_datatype_conflict.sh index a6b5474d..e5ff4fc9 100755 --- a/test/rdf/fail_resolution_datatype_conflict.sh +++ b/test/rdf/fail_resolution_datatype_conflict.sh @@ -39,6 +39,8 @@ error: A JSON-LD datatype cannot be assigned more than one value at column 3 at instance location "/price" at facet "datatype" + at schema location file://$(realpath "$TMP")/schema.json#/properties/price/allOf/0/x-jsonld-datatype + at conflicting schema location file://$(realpath "$TMP")/schema.json#/properties/price/allOf/1/x-jsonld-datatype at file path $(realpath "$TMP")/instance.json EOF @@ -57,6 +59,8 @@ cat << EOF > "$TMP/expected.txt" "column": 3, "instanceLocation": "/price", "facet": "datatype", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/price/allOf/0/x-jsonld-datatype", + "conflictingSchemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/price/allOf/1/x-jsonld-datatype", "filePath": "$(realpath "$TMP")/instance.json" } EOF diff --git a/test/rdf/fail_resolution_inert_override.sh b/test/rdf/fail_resolution_inert_override.sh new file mode 100755 index 00000000..b01ddc44 --- /dev/null +++ b/test/rdf/fail_resolution_inert_override.sh @@ -0,0 +1,78 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "price": { + "type": "number", + "x-jsonld-id": "https://schema.org/price", + "allOf": [ + { + "x-jsonld-datatype": "http://www.w3.org/2001/XMLSchema#decimal", + "x-jsonld-override": true + }, + { "x-jsonld-datatype": "http://www.w3.org/2001/XMLSchema#double" } + ] + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{ "price": 1 } +EOF + +"$1" rdf "$TMP/schema.json" "$TMP/instance.json" 2> "$TMP/stderr.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +error: A JSON-LD datatype cannot be assigned more than one value + at line 1 + at column 3 + at instance location "/price" + at facet "datatype" + at schema location file://$(realpath "$TMP")/schema.json#/properties/price/allOf/0/x-jsonld-datatype + at conflicting schema location file://$(realpath "$TMP")/schema.json#/properties/price/allOf/1/x-jsonld-datatype + at inert override location file://$(realpath "$TMP")/schema.json#/properties/price/allOf/0/x-jsonld-override + at file path $(realpath "$TMP")/instance.json + +The x-jsonld-override mark was ignored because it does not enclose the +conflicting annotation. Move the conflicting annotation, or the reference +that brings it in, inside the overriding object for the override to +take effect +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" rdf "$TMP/schema.json" "$TMP/instance.json" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "A JSON-LD datatype cannot be assigned more than one value", + "line": 1, + "column": 3, + "instanceLocation": "/price", + "facet": "datatype", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/price/allOf/0/x-jsonld-datatype", + "conflictingSchemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/price/allOf/1/x-jsonld-datatype", + "inertOverrideLocation": "file://$(realpath "$TMP")/schema.json#/properties/price/allOf/0/x-jsonld-override", + "filePath": "$(realpath "$TMP")/instance.json" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/rdf/fail_resolution_invalid_language.sh b/test/rdf/fail_resolution_invalid_language.sh index d3c90fdb..57ce5a0e 100755 --- a/test/rdf/fail_resolution_invalid_language.sh +++ b/test/rdf/fail_resolution_invalid_language.sh @@ -36,6 +36,7 @@ error: The value of x-jsonld-language must be a canonical BCP 47 language tag at column 3 at instance location "/name" at facet "language" + at schema location file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-language at file path $(realpath "$TMP")/instance.json EOF @@ -54,6 +55,7 @@ cat << EOF > "$TMP/expected.txt" "column": 3, "instanceLocation": "/name", "facet": "language", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-language", "filePath": "$(realpath "$TMP")/instance.json" } EOF diff --git a/test/rdf/fail_resolution_invalid_predicate_iri.sh b/test/rdf/fail_resolution_invalid_predicate_iri.sh index 1233c3d4..1b33a825 100755 --- a/test/rdf/fail_resolution_invalid_predicate_iri.sh +++ b/test/rdf/fail_resolution_invalid_predicate_iri.sh @@ -32,6 +32,7 @@ error: The value of x-jsonld-id must be an absolute IRI at column 3 at instance location "/name" at facet "predicate" + at schema location file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-id at file path $(realpath "$TMP")/instance.json EOF @@ -50,6 +51,7 @@ cat << EOF > "$TMP/expected.txt" "column": 3, "instanceLocation": "/name", "facet": "predicate", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-id", "filePath": "$(realpath "$TMP")/instance.json" } EOF diff --git a/test/rdf/fail_resolution_override_not_boolean.sh b/test/rdf/fail_resolution_override_not_boolean.sh new file mode 100755 index 00000000..a5fc2f18 --- /dev/null +++ b/test/rdf/fail_resolution_override_not_boolean.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/schema.json" +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "x-jsonld-id": "https://schema.org/name", + "x-jsonld-override": "yes" + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{ "name": "Ada" } +EOF + +"$1" rdf "$TMP/schema.json" "$TMP/instance.json" 2> "$TMP/stderr.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +error: The value of x-jsonld-override must be a boolean + at line 1 + at column 3 + at instance location "/name" + at facet "override" + at schema location file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-override + at file path $(realpath "$TMP")/instance.json +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" rdf "$TMP/schema.json" "$TMP/instance.json" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The value of x-jsonld-override must be a boolean", + "line": 1, + "column": 3, + "instanceLocation": "/name", + "facet": "override", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-override", + "filePath": "$(realpath "$TMP")/instance.json" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/rdf/fail_resolution_self_unbound.sh b/test/rdf/fail_resolution_self_unbound.sh index 511372a4..fb4a5df1 100755 --- a/test/rdf/fail_resolution_self_unbound.sh +++ b/test/rdf/fail_resolution_self_unbound.sh @@ -34,6 +34,7 @@ error: A JSON-LD self identity template variable must bind to an instance value at column 1 at instance location "" at facet "self" + at schema location file://$(realpath "$TMP")/schema.json#/x-jsonld-self at file path $(realpath "$TMP")/instance.json EOF @@ -52,6 +53,7 @@ cat << EOF > "$TMP/expected.txt" "column": 1, "instanceLocation": "", "facet": "self", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/x-jsonld-self", "filePath": "$(realpath "$TMP")/instance.json" } EOF diff --git a/test/rdf/fail_resolution_type_on_literal.sh b/test/rdf/fail_resolution_type_on_literal.sh index 72996642..0781c6c3 100755 --- a/test/rdf/fail_resolution_type_on_literal.sh +++ b/test/rdf/fail_resolution_type_on_literal.sh @@ -36,6 +36,7 @@ error: A JSON-LD type can only be assigned to an object value at column 3 at instance location "/name" at facet "type" + at schema location file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-type at file path $(realpath "$TMP")/instance.json EOF @@ -54,6 +55,7 @@ cat << EOF > "$TMP/expected.txt" "column": 3, "instanceLocation": "/name", "facet": "type", + "schemaLocation": "file://$(realpath "$TMP")/schema.json#/properties/name/x-jsonld-type", "filePath": "$(realpath "$TMP")/instance.json" } EOF