diff --git a/docs/configuration.md b/docs/configuration.md index 2e8e2de19..36c0b5086 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -148,6 +148,7 @@ contain the actual schema definitions that power your instance. | `/ignore` | Array | No | None | An array of file paths (relative to the configuration file location) to exclude from the schema collection. See the [JSON Schema CLI configuration](https://github.com/sourcemeta/jsonschema/blob/main/docs/configuration.markdown) for more information | | `/x-sourcemeta-one:evaluate` | Boolean | No | `true` | When set to `false`, disable the evaluation API for this schema collection. This is useful if you will never make use of the [evaluation API](api.md) and want to speed up the generation of the instance | | `/x-sourcemeta-one:alert` | String | No | N/A | When set, provide a human-readable alert on both the API and the HTML explorer for every schema in the collection. This is useful to provide any important message to consumers. The web explorer renders this as Markdown | +| `/x-sourcemeta-one:priority` | Integer | No | `100` | A hint, from `0` (least important) to `100` (most important), that signals the relative importance of this collection compared to others in the same instance. Consumers may use this to rank or filter collections | !!! warning diff --git a/src/configuration/include/sourcemeta/one/configuration.h b/src/configuration/include/sourcemeta/one/configuration.h index bb3bbfb8c..58a0ba395 100644 --- a/src/configuration/include/sourcemeta/one/configuration.h +++ b/src/configuration/include/sourcemeta/one/configuration.h @@ -7,6 +7,8 @@ #include +#include // std::clamp +#include // std::uint8_t #include // std::filesystem::path #include // std::optional #include // std::string @@ -70,6 +72,17 @@ struct Configuration { return value == nullptr || !value->is_boolean() || value->to_boolean(); } + [[nodiscard]] static auto priority(const Collection &collection) + -> std::uint8_t { + const auto *value{collection.extra.try_at("x-sourcemeta-one:priority")}; + if (value == nullptr || !value->is_integer()) { + return 100; + } + return static_cast( + std::clamp(value->to_integer(), 0, + 100)); + } + [[nodiscard]] auto is_collection_base(const std::filesystem::path &relative_path) const -> bool { const auto match{this->entries.find(relative_path)}; diff --git a/src/configuration/read.cc b/src/configuration/read.cc index 05927b62a..3c5d910e9 100644 --- a/src/configuration/read.cc +++ b/src/configuration/read.cc @@ -51,8 +51,8 @@ auto dereference(const std::filesystem::path &base, sourcemeta::core::JSON &input, const sourcemeta::core::Pointer &location, std::unordered_set &visited, - std::unordered_set &all_files, const bool is_root) - -> void { + std::unordered_set &all_files, const bool is_root, + const std::filesystem::path &self_path) -> void { assert(base.is_absolute()); if (!input.is_object()) { return; @@ -74,7 +74,7 @@ auto dereference(const std::filesystem::path &base, auto extension{read_file(base, new_location, target_path)}; if (extension.is_object()) { dereference(target_path, extension, new_location, visited, all_files, - true); + true, self_path); accumulator.merge(std::move(extension).as_object()); } @@ -86,7 +86,7 @@ auto dereference(const std::filesystem::path &base, accumulator.merge(input.as_object()); input = std::move(accumulator); assert(!input.defines("extends")); - dereference(base, input, location, visited, all_files, is_root); + dereference(base, input, location, visited, all_files, is_root, self_path); // Read included files } else if (!location.empty() && input.defines("include") && @@ -103,7 +103,8 @@ auto dereference(const std::filesystem::path &base, } all_files.emplace(target_path.native()); input.into(read_file(base, new_location, target_path)); - dereference(target_path, input, new_location, visited, all_files, is_root); + dereference(target_path, input, new_location, visited, all_files, is_root, + self_path); visited.erase(target_path.native()); // Revisit and relativize paths @@ -117,6 +118,12 @@ auto dereference(const std::filesystem::path &base, input.assign("x-sourcemeta-one:path", sourcemeta::core::JSON{base.string()}); } + if (!input.defines("x-sourcemeta-one:priority") && + sourcemeta::core::is_under_path(base, self_path)) { + input.assign("x-sourcemeta-one:priority", + sourcemeta::core::JSON{ + static_cast(0)}); + } // Recurse on children, if any } else if (input.defines("contents") && input.at("contents").is_object()) { @@ -128,8 +135,8 @@ auto dereference(const std::filesystem::path &base, [](const auto &entry) { return entry.first; }); for (const auto &key : keys) { dereference(base, input.at("contents").at(key), - location.concat({"contents", key}), visited, all_files, - false); + location.concat({"contents", key}), visited, all_files, false, + self_path); } } } @@ -210,7 +217,8 @@ auto Configuration::read(const std::filesystem::path &configuration_path, configuration_files.emplace(canonical_config); std::unordered_set visited; visited.emplace(canonical_config); - dereference(configuration_path, data, {}, visited, configuration_files, true); + dereference(configuration_path, data, {}, visited, configuration_files, true, + self_path); if (data.is_object() && data.defines("url") && data.defines("contents") && data.at("contents").is_object()) { diff --git a/src/configuration/schema/collection.json b/src/configuration/schema/collection.json index 762076162..052611dfc 100644 --- a/src/configuration/schema/collection.json +++ b/src/configuration/schema/collection.json @@ -27,6 +27,11 @@ "x-sourcemeta-one:path": { "type": "string" }, + "x-sourcemeta-one:priority": { + "type": "integer", + "maximum": 100, + "minimum": 0 + }, "baseUri": { "$comment": "TODO: Use Format Assertion when supported on Blaze", "type": "string", diff --git a/test/cli/index/common/configuration-long.sh b/test/cli/index/common/configuration-long.sh index f616d789e..d6f7f21f6 100755 --- a/test/cli/index/common/configuration-long.sh +++ b/test/cli/index/common/configuration-long.sh @@ -48,6 +48,7 @@ cat << EOF > "$TMP/expected.txt" "schemas": { "path": "$ONE_PREFIX/share/sourcemeta/one/self/v1/schemas", "x-sourcemeta-one:path": "$ONE_PREFIX/share/sourcemeta/one/self/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "https://sourcemeta.com/" } } diff --git a/test/cli/index/common/configuration-short.sh b/test/cli/index/common/configuration-short.sh index f8fcc48b3..e0815e872 100755 --- a/test/cli/index/common/configuration-short.sh +++ b/test/cli/index/common/configuration-short.sh @@ -48,6 +48,7 @@ cat << EOF > "$TMP/expected.txt" "schemas": { "path": "$ONE_PREFIX/share/sourcemeta/one/self/v1/schemas", "x-sourcemeta-one:path": "$ONE_PREFIX/share/sourcemeta/one/self/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "https://sourcemeta.com/" } } diff --git a/test/unit/configuration/configuration_read_test.cc b/test/unit/configuration/configuration_read_test.cc index 0e6f08665..6d7827973 100644 --- a/test/unit/configuration/configuration_read_test.cc +++ b/test/unit/configuration/configuration_read_test.cc @@ -41,6 +41,7 @@ TEST(Configuration_read, read_valid_001) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -107,6 +108,7 @@ TEST(Configuration_read, read_valid_002) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -160,6 +162,7 @@ TEST(Configuration_read, read_valid_003) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -212,6 +215,7 @@ TEST(Configuration_read, read_valid_004) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -267,6 +271,7 @@ TEST(Configuration_read, read_valid_005) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -322,6 +327,7 @@ TEST(Configuration_read, read_valid_006) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -363,6 +369,7 @@ TEST(Configuration_read, read_valid_007) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -409,6 +416,7 @@ TEST(Configuration_read, read_valid_008) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -455,6 +463,7 @@ TEST(Configuration_read, read_valid_009) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -498,6 +507,7 @@ TEST(Configuration_read, read_valid_010) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -537,7 +547,8 @@ TEST(Configuration_read, read_valid_011) { "contents": { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", - "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json" + "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0 } } } @@ -584,6 +595,7 @@ TEST(Configuration_read, read_valid_012) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -636,6 +648,7 @@ TEST(Configuration_read, read_valid_013) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -692,6 +705,7 @@ TEST(Configuration_read, read_valid_014) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -904,6 +918,7 @@ TEST(Configuration_read, read_valid_016_api_explicit_object) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } @@ -960,6 +975,7 @@ TEST(Configuration_read, read_valid_018_api_true_coerced_to_object) { "schemas": { "path": "SELF_DIRECTORY/v1/schemas", "x-sourcemeta-one:path": "SELF_DIRECTORY/v1/one.json", + "x-sourcemeta-one:priority": 0, "baseUri": "http://localhost:8000" } } diff --git a/test/unit/configuration/configuration_test.cc b/test/unit/configuration/configuration_test.cc index 41452fb4d..03b1188e6 100644 --- a/test/unit/configuration/configuration_test.cc +++ b/test/unit/configuration/configuration_test.cc @@ -21,6 +21,16 @@ .property, \ value); +#define EXPECT_PRIORITY(configuration, path, value) \ + EXPECT_TRUE((configuration).entries.contains(path)); \ + EXPECT_TRUE( \ + std::holds_alternative( \ + (configuration).entries.at(path))); \ + EXPECT_EQ(sourcemeta::one::Configuration::priority( \ + std::get( \ + (configuration).entries.at(path))), \ + value); + TEST(Configuration, valid_001) { const auto configuration_path{std::filesystem::path{STUB_DIRECTORY} / "parse_valid_001.json"}; @@ -91,6 +101,8 @@ TEST(Configuration, valid_001) { EXPECT_COLLECTION(configuration, "example/extension", extra.at("x-sourcemeta-one:path"), sourcemeta::core::JSON{configuration_path.string()}); + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "example/extension", 100); } TEST(Configuration, valid_002) { @@ -116,6 +128,8 @@ TEST(Configuration, valid_002) { EXPECT_PAGE(configuration, "test", title, "A sample schema folder"); EXPECT_PAGE(configuration, "test", description, "For testing purposes"); EXPECT_PAGE(configuration, "test", github, "sourcemeta/one"); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); } TEST(Configuration, valid_003) { @@ -157,6 +171,9 @@ TEST(Configuration, valid_003) { EXPECT_COLLECTION(configuration, "example", resolve.size(), 0); EXPECT_COLLECTION(configuration, "example", lint.rules.size(), 0); EXPECT_COLLECTION(configuration, "example", ignore.size(), 0); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "example", 100); } TEST(Configuration, valid_004) { @@ -208,6 +225,9 @@ TEST(Configuration, valid_004) { extra.defines("x-sourcemeta-one:path"), true); EXPECT_COLLECTION(configuration, "example", extra.at("x-sourcemeta-one:path"), sourcemeta::core::JSON{configuration_path.string()}); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "example", 100); } TEST(Configuration, valid_005) { @@ -259,6 +279,9 @@ TEST(Configuration, valid_005) { extra.defines("x-sourcemeta-one:path"), true); EXPECT_COLLECTION(configuration, "example", extra.at("x-sourcemeta-one:path"), sourcemeta::core::JSON{configuration_path.string()}); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "example", 100); } TEST(Configuration, valid_006) { @@ -310,6 +333,9 @@ TEST(Configuration, valid_006) { extra.defines("x-sourcemeta-one:path"), true); EXPECT_COLLECTION(configuration, "example", extra.at("x-sourcemeta-one:path"), sourcemeta::core::JSON{configuration_path.string()}); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "example", 100); } TEST(Configuration, valid_007) { @@ -361,6 +387,9 @@ TEST(Configuration, valid_007) { extra.defines("x-sourcemeta-one:path"), true); EXPECT_COLLECTION(configuration, "example", extra.at("x-sourcemeta-one:path"), sourcemeta::core::JSON{configuration_path.string()}); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "example", 100); } TEST(Configuration, valid_008) { @@ -397,6 +426,9 @@ TEST(Configuration, valid_008) { std::filesystem::weakly_canonical(std::filesystem::path{STUB_DIRECTORY} / "folder" / "rules" / "my_rule.py")); EXPECT_COLLECTION(configuration, "example", ignore.size(), 0); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "example", 100); } TEST(Configuration, base_path_none) { @@ -585,6 +617,8 @@ TEST(Configuration, valid_009_api_enabled) { EXPECT_PAGE(configuration, "self/v1", website, std::nullopt); EXPECT_COLLECTION(configuration, "self/v1/schemas", absolute_path, std::filesystem::path{SELF_DIRECTORY} / "v1" / "schemas"); + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); } TEST(Configuration, valid_010_api_disabled) { @@ -603,3 +637,62 @@ TEST(Configuration, valid_010_api_disabled) { EXPECT_EQ(configuration.entries.size(), 0); } + +TEST(Configuration, valid_011_priority_explicit) { + const auto configuration_path{std::filesystem::path{STUB_DIRECTORY} / + "parse_valid_011_priority_explicit.json"}; + const auto raw_configuration{ + sourcemeta::one::Configuration::read(configuration_path, SELF_DIRECTORY)}; + const auto configuration{sourcemeta::one::Configuration::parse( + raw_configuration, configuration_path, configuration_path.parent_path())}; + + EXPECT_PRIORITY(configuration, "self/v1/schemas", 0); + EXPECT_PRIORITY(configuration, "low/schemas", 0); + EXPECT_PRIORITY(configuration, "mid/schemas", 50); + EXPECT_PRIORITY(configuration, "high/schemas", 100); +} + +TEST(Configuration, invalid_priority_above_maximum) { + const auto configuration_path{std::filesystem::path{STUB_DIRECTORY} / + "parse_invalid_priority_above.json"}; + const auto raw_configuration{ + sourcemeta::one::Configuration::read(configuration_path, SELF_DIRECTORY)}; + EXPECT_THROW(sourcemeta::one::Configuration::parse( + raw_configuration, configuration_path, + configuration_path.parent_path()), + sourcemeta::one::ConfigurationValidationError); +} + +TEST(Configuration, invalid_priority_below_minimum) { + const auto configuration_path{std::filesystem::path{STUB_DIRECTORY} / + "parse_invalid_priority_below.json"}; + const auto raw_configuration{ + sourcemeta::one::Configuration::read(configuration_path, SELF_DIRECTORY)}; + EXPECT_THROW(sourcemeta::one::Configuration::parse( + raw_configuration, configuration_path, + configuration_path.parent_path()), + sourcemeta::one::ConfigurationValidationError); +} + +TEST(Configuration, priority_helper_clamps_out_of_range) { + sourcemeta::one::Configuration::Collection collection; + collection.extra.assign( + "x-sourcemeta-one:priority", + sourcemeta::core::JSON{ + static_cast(200)}); + EXPECT_EQ(sourcemeta::one::Configuration::priority(collection), 100); + + collection.extra.assign( + "x-sourcemeta-one:priority", + sourcemeta::core::JSON{static_cast(-5)}); + EXPECT_EQ(sourcemeta::one::Configuration::priority(collection), 0); +} + +TEST(Configuration, priority_helper_defaults_when_missing_or_wrong_type) { + sourcemeta::one::Configuration::Collection collection; + EXPECT_EQ(sourcemeta::one::Configuration::priority(collection), 100); + + collection.extra.assign("x-sourcemeta-one:priority", + sourcemeta::core::JSON{"high"}); + EXPECT_EQ(sourcemeta::one::Configuration::priority(collection), 100); +} diff --git a/test/unit/configuration/stub/parse_invalid_priority_above.json b/test/unit/configuration/stub/parse_invalid_priority_above.json new file mode 100644 index 000000000..4467c5941 --- /dev/null +++ b/test/unit/configuration/stub/parse_invalid_priority_above.json @@ -0,0 +1,15 @@ +{ + "url": "http://localhost:8000", + "html": false, + "contents": { + "example": { + "contents": { + "schemas": { + "baseUri": "https://example.com/foo", + "path": "./schemas/example/extension", + "x-sourcemeta-one:priority": 200 + } + } + } + } +} diff --git a/test/unit/configuration/stub/parse_invalid_priority_below.json b/test/unit/configuration/stub/parse_invalid_priority_below.json new file mode 100644 index 000000000..dd0b14421 --- /dev/null +++ b/test/unit/configuration/stub/parse_invalid_priority_below.json @@ -0,0 +1,15 @@ +{ + "url": "http://localhost:8000", + "html": false, + "contents": { + "example": { + "contents": { + "schemas": { + "baseUri": "https://example.com/foo", + "path": "./schemas/example/extension", + "x-sourcemeta-one:priority": -1 + } + } + } + } +} diff --git a/test/unit/configuration/stub/parse_valid_011_priority_explicit.json b/test/unit/configuration/stub/parse_valid_011_priority_explicit.json new file mode 100644 index 000000000..01bfb7c20 --- /dev/null +++ b/test/unit/configuration/stub/parse_valid_011_priority_explicit.json @@ -0,0 +1,36 @@ +{ + "url": "http://localhost:8000", + "html": false, + "contents": { + "low": { + "title": "Low", + "contents": { + "schemas": { + "baseUri": "https://example.com/low", + "path": "./schemas/example/extension", + "x-sourcemeta-one:priority": 0 + } + } + }, + "mid": { + "title": "Mid", + "contents": { + "schemas": { + "baseUri": "https://example.com/mid", + "path": "./schemas/example/extension", + "x-sourcemeta-one:priority": 50 + } + } + }, + "high": { + "title": "High", + "contents": { + "schemas": { + "baseUri": "https://example.com/high", + "path": "./schemas/example/extension", + "x-sourcemeta-one:priority": 100 + } + } + } + } +}