diff --git a/src/core/jsonld/CMakeLists.txt b/src/core/jsonld/CMakeLists.txt index 66152d423..a6fd1b7fa 100644 --- a/src/core/jsonld/CMakeLists.txt +++ b/src/core/jsonld/CMakeLists.txt @@ -3,6 +3,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME jsonld SOURCES jsonld.cc jsonld_iri_expansion.cc jsonld_create_term_definition.cc jsonld_context_processing.cc jsonld_value_expansion.cc jsonld_expansion.cc + jsonld_is_expanded.cc jsonld_algorithms.h jsonld_keywords.h) if(SOURCEMETA_CORE_INSTALL) diff --git a/src/core/jsonld/include/sourcemeta/core/jsonld.h b/src/core/jsonld/include/sourcemeta/core/jsonld.h index 572f10e52..4334effc6 100644 --- a/src/core/jsonld/include/sourcemeta/core/jsonld.h +++ b/src/core/jsonld/include/sourcemeta/core/jsonld.h @@ -85,6 +85,26 @@ auto jsonld_expand(const JSON &input, const JSON &expand_context, const JSONLDResolver &resolver = {}, const JSONLDVersion version = JSONLDVersion::V1_1) -> JSON; +/// @ingroup jsonld +/// +/// Determine whether a document is in the JSON-LD 1.1 expanded document form: +/// an array of node objects that carries no context and whose keys are all +/// absolute IRIs, blank node identifiers, or keywords. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json(R"([ +/// { "http://schema.org/name": [ { "@value": "Sourcemeta" } ] } +/// ])")}; +/// +/// assert(sourcemeta::core::jsonld_is_expanded(document)); +/// ``` +SOURCEMETA_CORE_JSONLD_EXPORT +auto jsonld_is_expanded(const JSON &document) -> bool; + } // namespace sourcemeta::core #endif diff --git a/src/core/jsonld/jsonld_is_expanded.cc b/src/core/jsonld/jsonld_is_expanded.cc new file mode 100644 index 000000000..154aab251 --- /dev/null +++ b/src/core/jsonld/jsonld_is_expanded.cc @@ -0,0 +1,239 @@ +#include +#include +#include +#include + +#include "jsonld_keywords.h" + +#include // std::uint8_t +#include // std::string_view +#include // std::pair +#include // std::vector + +namespace sourcemeta::core { + +namespace { + +// A blank node identifier is the prefix "_:" followed by a label (JSON-LD 1.1 +// Section 3.5). +auto is_blank_node(const std::string_view value) -> bool { + return value.size() > 2 && value.starts_with("_:"); +} + +// A property term in expanded form is an absolute IRI or a blank node +// identifier (JSON-LD 1.1 Section 9, "node object"). +auto is_term(const std::string_view value) -> bool { + return URI::is_iri(value) || is_blank_node(value); +} + +// @id and @type carry IRI references, which may be relative, or blank node +// identifiers (JSON-LD 1.1 Section 9, "node object"). +auto is_reference(const std::string_view value) -> bool { + return URI::is_iri_reference(value) || is_blank_node(value); +} + +// What a position is expected to be, so that the traversal can validate it +// without recursion: a node object, or a property array item that is a value, +// node, list, or set object. +enum class Expect : std::uint8_t { Node, Item }; + +using Pending = std::vector>; + +// "A value object MUST NOT contain any other entries" beyond @value, @type, +// @language, @direction, and @index, and "MUST NOT contain both @type and +// @language" (JSON-LD 1.1 Section 9, "value object"). A value object is a leaf: +// it carries no positions to traverse further. +auto is_value_object(const JSON &value) -> bool { + for (const auto &entry : value.as_object()) { + if (!entry.key_equals(KEYWORD_VALUE, KEYWORD_VALUE_HASH) && + !entry.key_equals(KEYWORD_TYPE, KEYWORD_TYPE_HASH) && + !entry.key_equals(KEYWORD_LANGUAGE, KEYWORD_LANGUAGE_HASH) && + !entry.key_equals(KEYWORD_DIRECTION, KEYWORD_DIRECTION_HASH) && + !entry.key_equals(KEYWORD_INDEX, KEYWORD_INDEX_HASH)) { + return false; + } + } + + const auto *const contents{value.try_at(KEYWORD_VALUE, KEYWORD_VALUE_HASH)}; + if (contents == nullptr) { + return false; + } + + const auto *const type{value.try_at(KEYWORD_TYPE, KEYWORD_TYPE_HASH)}; + const auto *const language{ + value.try_at(KEYWORD_LANGUAGE, KEYWORD_LANGUAGE_HASH)}; + const auto *const direction{ + value.try_at(KEYWORD_DIRECTION, KEYWORD_DIRECTION_HASH)}; + + if (type != nullptr && (language != nullptr || direction != nullptr)) { + return false; + } + + // When the datatype is @json the value is preserved verbatim, otherwise it is + // a scalar literal. + if (type != nullptr) { + if (!type->is_string()) { + return false; + } + if (type->to_string() != KEYWORD_JSON && + (!URI::is_iri(type->to_string()) || contents->is_object() || + contents->is_array())) { + return false; + } + } else if (contents->is_object() || contents->is_array()) { + return false; + } + + if (language != nullptr && + (!language->is_string() || !is_langtag(language->to_string()) || + !contents->is_string())) { + return false; + } + + if (direction != nullptr && + (!direction->is_string() || + (direction->to_string() != "ltr" && direction->to_string() != "rtl") || + !contents->is_string())) { + return false; + } + + const auto *const index{value.try_at(KEYWORD_INDEX, KEYWORD_INDEX_HASH)}; + return index == nullptr || index->is_string(); +} + +// Validate one node object, appending its descendant positions to the traversal +// rather than recursing. "A node object MUST NOT contain the @value, @list, or +// @set keywords" (JSON-LD 1.1 Section 9, "node object"). @id and @type carry +// IRI references, which may be relative, whereas property terms are absolute +// IRIs or blank node identifiers. +auto validate_node(const JSON &value, Pending &pending) -> bool { + if (!value.is_object()) { + return false; + } + + for (const auto &entry : value.as_object()) { + const JSON::StringView key{entry.first}; + if (!key.empty() && key.front() == '@') { + if (entry.key_equals(KEYWORD_ID, KEYWORD_ID_HASH)) { + if (!entry.second.is_string() || + !is_reference(entry.second.to_string())) { + return false; + } + } else if (entry.key_equals(KEYWORD_INDEX, KEYWORD_INDEX_HASH)) { + if (!entry.second.is_string()) { + return false; + } + } else if (entry.key_equals(KEYWORD_TYPE, KEYWORD_TYPE_HASH)) { + if (!entry.second.is_array()) { + return false; + } + for (const auto &item : entry.second.as_array()) { + if (!item.is_string() || !is_reference(item.to_string())) { + return false; + } + } + } else if (entry.key_equals(KEYWORD_GRAPH, KEYWORD_GRAPH_HASH) || + entry.key_equals(KEYWORD_INCLUDED, KEYWORD_INCLUDED_HASH)) { + if (!entry.second.is_array()) { + return false; + } + for (const auto &item : entry.second.as_array()) { + pending.emplace_back(&item, Expect::Node); + } + } else if (entry.key_equals(KEYWORD_REVERSE, KEYWORD_REVERSE_HASH)) { + if (!entry.second.is_object()) { + return false; + } + for (const auto &reverse : entry.second.as_object()) { + if (!is_term(reverse.first) || !reverse.second.is_array()) { + return false; + } + for (const auto &item : reverse.second.as_array()) { + pending.emplace_back(&item, Expect::Node); + } + } + } else { + return false; + } + } else if (!is_term(key) || !entry.second.is_array()) { + return false; + } else { + // "The value of an expanded property is an array" (JSON-LD 1.1 Section 9) + // whose entries are value, node, list, or set objects. + for (const auto &item : entry.second.as_array()) { + pending.emplace_back(&item, Expect::Item); + } + } + } + + return true; +} + +// Validate one list or set object, appending its entries to the traversal. It +// "MUST NOT contain any other entries" beyond its defining keyword and an +// optional @index (JSON-LD 1.1 Section 9, "list object" and "set object"). +auto validate_list_or_set(const JSON &value, const JSON::StringView keyword, + const JSON::Object::hash_type keyword_hash, + Pending &pending) -> bool { + for (const auto &entry : value.as_object()) { + if (entry.key_equals(keyword, keyword_hash)) { + if (!entry.second.is_array()) { + return false; + } + for (const auto &item : entry.second.as_array()) { + pending.emplace_back(&item, Expect::Item); + } + } else if (entry.key_equals(KEYWORD_INDEX, KEYWORD_INDEX_HASH)) { + if (!entry.second.is_string()) { + return false; + } + } else { + return false; + } + } + return true; +} + +// Validate one property array item, dispatching on its kind. +auto validate_item(const JSON &value, Pending &pending) -> bool { + if (!value.is_object()) { + return false; + } + if (value.defines(KEYWORD_VALUE, KEYWORD_VALUE_HASH)) { + return is_value_object(value); + } + if (value.defines(KEYWORD_LIST, KEYWORD_LIST_HASH)) { + return validate_list_or_set(value, KEYWORD_LIST, KEYWORD_LIST_HASH, + pending); + } + if (value.defines(KEYWORD_SET, KEYWORD_SET_HASH)) { + return validate_list_or_set(value, KEYWORD_SET, KEYWORD_SET_HASH, pending); + } + return validate_node(value, pending); +} + +} // namespace + +auto jsonld_is_expanded(const JSON &document) -> bool { + if (!document.is_array()) { + return false; + } + + Pending pending; + for (const auto &item : document.as_array()) { + pending.emplace_back(&item, Expect::Node); + } + + while (!pending.empty()) { + const auto [value, expect] = pending.back(); + pending.pop_back(); + if (!(expect == Expect::Node ? validate_node(*value, pending) + : validate_item(*value, pending))) { + return false; + } + } + + return true; +} + +} // namespace sourcemeta::core diff --git a/test/jsonld/CMakeLists.txt b/test/jsonld/CMakeLists.txt index afb7c49d3..11d7515a3 100644 --- a/test/jsonld/CMakeLists.txt +++ b/test/jsonld/CMakeLists.txt @@ -1,5 +1,6 @@ sourcemeta_googletest(NAMESPACE sourcemeta PROJECT core NAME jsonld - SOURCES jsonld_expand_test.cc jsonld_expand_error_test.cc) + SOURCES jsonld_expand_test.cc jsonld_expand_error_test.cc + jsonld_is_expanded_test.cc) target_link_libraries(sourcemeta_core_jsonld_unit PRIVATE sourcemeta::core::jsonld) diff --git a/test/jsonld/jsonld_is_expanded_test.cc b/test/jsonld/jsonld_is_expanded_test.cc new file mode 100644 index 000000000..13ab675f1 --- /dev/null +++ b/test/jsonld/jsonld_is_expanded_test.cc @@ -0,0 +1,341 @@ +#include + +#include +#include + +TEST(JSONLD_is_expanded, empty_array) { + const auto document = sourcemeta::core::parse_json("[]"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, single_node_with_id) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": "http://example.com/a" } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, node_with_iri_property) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": "bar" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, node_with_type_array) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": "http://example.com/a", "@type": [ "http://example.com/T" ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_with_datatype) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@value": "v", "@type": "http://example.com/t" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_with_language) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": "v", "@language": "en" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_with_language_and_direction) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@value": "v", "@language": "ar", "@direction": "rtl" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_json_literal) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@value": { "foo": "bar" }, "@type": "@json" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_native_number) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": 4 } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_native_boolean) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": true } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_with_index) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": "v", "@index": "i" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, list_object) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@list": [ { "@value": "a" } ] } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, nested_list_object) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@list": [ { "@list": [ { "@value": "a" } ] } ] } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, set_object) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@set": [ { "@value": "a" } ] } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, graph_object) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@graph": [ { "@id": "http://example.com/a" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, reverse_object) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@reverse": { + "http://example.com/p": [ { "@id": "http://example.com/a" } ] } } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, included_block) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": "http://example.com/a", + "@included": [ { "@id": "http://example.com/b" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, blank_node_identifier) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": "_:b0" } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, blank_node_property_key) { + const auto document = sourcemeta::core::parse_json(R"([ + { "_:b0": [ { "@value": "x" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, empty_blank_node_identifier) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": "_:" } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, empty_blank_node_property_key) { + const auto document = sourcemeta::core::parse_json(R"([ + { "_:": [ { "@value": "x" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, top_level_object) { + const auto document = sourcemeta::core::parse_json(R"({ + "@id": "http://example.com/a" + })"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, top_level_scalar) { + const auto document = sourcemeta::core::parse_json(R"("foo")"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, top_level_value_object) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@value": "x" } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, top_level_list_object) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@list": [ { "@value": "x" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, context_present) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@context": {}, "@id": "http://example.com/a" } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, relative_iri_property_key) { + const auto document = sourcemeta::core::parse_json(R"([ + { "foo": [ { "@value": "x" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, id_not_string) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": [ "http://example.com/a" ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, id_null) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": null } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, id_relative_reference) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@id": "../document-relative" } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, type_relative_reference) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@type": [ "#document-relative" ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, type_not_array) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@type": "http://example.com/T" } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, type_entry_not_string) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@type": [ [ "http://example.com/T" ] ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, unnormalized_valid_property_key) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/vocabulary/./rel2#fragment": [ + { "@value": "x" } ] } + ])"); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, invalid_iri_property_key) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/vocabulary/./rel2##fragment-works": [ + { "@value": "#fragment-works" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, relative_property_key) { + const auto document = sourcemeta::core::parse_json(R"([ + { "vocabulary/rel": [ { "@value": "x" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, property_value_not_array) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": { "@value": "x" } } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, property_value_bare_scalar) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ "x" ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_with_extra_property) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@value": "x", "http://example.com/bar": [] } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_type_and_language) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@value": "x", "@type": "http://example.com/t", "@language": "en" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_object_without_json_type) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": { "a": 1 } } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_invalid_language) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": "x", "@language": "not a tag" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_object_invalid_direction) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@value": "x", "@language": "en", "@direction": "up" } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, list_object_with_extra_property) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ + { "@list": [], "http://example.com/bar": [] } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, value_and_list_together) { + const auto document = sourcemeta::core::parse_json(R"([ + { "http://example.com/foo": [ { "@value": "x", "@list": [] } ] } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, unknown_keyword_form_key) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@foo": "x" } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} + +TEST(JSONLD_is_expanded, reverse_value_not_array) { + const auto document = sourcemeta::core::parse_json(R"([ + { "@reverse": { "http://example.com/p": { "@id": "http://example.com/a" } } } + ])"); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(document)); +} diff --git a/test/jsonld/jsonld_suite.cc b/test/jsonld/jsonld_suite.cc index f6ed35f83..75f05f26f 100644 --- a/test/jsonld/jsonld_suite.cc +++ b/test/jsonld/jsonld_suite.cc @@ -21,9 +21,25 @@ struct JSONLDExpandCase { sourcemeta::core::JSON::String base_iri; sourcemeta::core::JSONLDVersion version; bool negative; + bool valid_expanded_output; std::optional expand_context; }; +// A few expand fixtures are correct expansion output yet not valid expanded +// documents, because expansion builds IRIs by concatenation without validation +// (JSON-LD 1.1 API Section 5.2). Their output still matches the fixture, but +// the validator must reject it rather than accept it as expanded form. +auto produces_valid_expanded_output(const std::string_view identifier) -> bool { + // A property IRI with a second "#" inside the fragment, which is not a valid + // IRI + return identifier != "#t0111" && + // The same pathological property IRI with a relative vocabulary base + identifier != "#t0112" && + // A keyword-form IRI is ignored, leaving a null @id (the manifest + // entry is non-normative and documents this) + identifier != "#t0122"; +} + class JSONLDExpandTest : public testing::Test { public: explicit JSONLDExpandTest(JSONLDExpandCase test_case) @@ -74,17 +90,25 @@ class JSONLDExpandTest : public testing::Test { } } else { const auto expected{sourcemeta::core::read_json(test_case.expect)}; - if (test_case.expand_context.has_value()) { - const auto context{ - sourcemeta::core::read_json(test_case.expand_context.value())}; - EXPECT_EQ(sourcemeta::core::jsonld_expand(input, context, - test_case.base_iri, resolver, - test_case.version), - expected); + const auto actual{ + test_case.expand_context.has_value() + ? sourcemeta::core::jsonld_expand( + input, + sourcemeta::core::read_json( + test_case.expand_context.value()), + test_case.base_iri, resolver, test_case.version) + : sourcemeta::core::jsonld_expand(input, test_case.base_iri, + resolver, test_case.version)}; + EXPECT_EQ(actual, expected); + // Every expansion output, and every expected fixture, is by definition a + // valid expanded document, save for the few that are correct output yet + // not valid documents, which the validator must reject instead. + if (test_case.valid_expanded_output) { + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(actual)); + EXPECT_TRUE(sourcemeta::core::jsonld_is_expanded(expected)); } else { - EXPECT_EQ(sourcemeta::core::jsonld_expand(input, test_case.base_iri, - resolver, test_case.version), - expected); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(actual)); + EXPECT_FALSE(sourcemeta::core::jsonld_is_expanded(expected)); } } } @@ -123,6 +147,8 @@ auto register_case(const sourcemeta::core::JSON &entry, test_case.base_iri = base_prefix + input_relative; test_case.version = sourcemeta::core::JSONLDVersion::V1_1; test_case.negative = negative; + test_case.valid_expanded_output = + produces_valid_expanded_output(entry.at("@id").to_string()); if (entry.defines("option")) { const auto &option{entry.at("option")};