diff --git a/docs/validate.markdown b/docs/validate.markdown index 8a750ffc..d84471f9 100644 --- a/docs/validate.markdown +++ b/docs/validate.markdown @@ -11,6 +11,7 @@ jsonschema validate ] [--extension/-e ] [--ignore/-i ] [--trace/-t] [--fast/-f] [--template/-m ] [--json/-j] [--entrypoint/-p ] + [--path/-P ] ``` The most popular use case of JSON Schema is to validate JSON documents. The @@ -190,3 +191,24 @@ jsonschema validate path/to/my/schema.json path/to/instances/ \ jsonschema validate path/to/my/schema.json path/to/my/instance.json \ --entrypoint '/$defs/MyType' ``` + +### Extract and validate against a sub-schema using a JSON Pointer + +The `--path`/`-P` option extracts a sub-schema from the input document using a +[JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901) before validation. This +is useful for validating instances against schemas embedded in larger documents, +such as OpenAPI specifications. + +```sh +jsonschema validate path/to/openapi.json path/to/instance.json \ + --path '/components/schemas/User' +``` + +The JSON Pointer must resolve to a value in the document that is a valid JSON +Schema. If the pointer does not resolve, the CLI will report an error with the +attempted pointer path. + +> [!WARNING] +> Extracting a sub-schema with `--path` may break `$ref` references that point +> outside the selected subtree, since only the targeted sub-schema is used for +> validation. This option cannot be used together with `--template`/`-m`. diff --git a/src/command_validate.cc b/src/command_validate.cc index ae31e737..01888797 100644 --- a/src/command_validate.cc +++ b/src/command_validate.cc @@ -13,6 +13,7 @@ #include // std::cerr #include // std::string #include // std::string_view +#include // std::as_const #include "command.h" #include "configuration.h" @@ -247,6 +248,12 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) schema_path, std::make_error_code(std::errc::is_a_directory)}; } + if (options.contains("path") && !options.at("path").empty() && + options.contains("template") && !options.at("template").empty()) { + throw OptionConflictError{ + "The --path option cannot be used with --template"}; + } + const auto schema_config_base{schema_from_stdin ? std::filesystem::current_path() : std::filesystem::path(schema_path)}; @@ -258,11 +265,40 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) read_configuration(options, configuration_path, schema_config_base)}; const auto dialect{default_dialect(options, configuration)}; - const auto schema{schema_from_stdin - ? read_from_stdin().document - : sourcemeta::core::read_yaml_or_json(schema_path)}; + auto schema = schema_from_stdin + ? read_from_stdin().document + : sourcemeta::core::read_yaml_or_json(schema_path); + + std::string path_pointer_string; + + if (options.contains("path") && !options.at("path").empty()) { + sourcemeta::core::Pointer pointer; + try { + pointer = + sourcemeta::core::to_pointer(std::string{options.at("path").front()}); + } catch (const sourcemeta::core::PointerParseError &) { + throw PositionalArgumentError{ + "The JSON Pointer is not valid", + "jsonschema validate path/to/schema.json path/to/instance.json " + "--path '/components/schemas/User'"}; + } + + path_pointer_string = sourcemeta::core::to_string(pointer); - if (!sourcemeta::core::is_schema(schema)) { + const auto *const result = sourcemeta::core::try_get(schema, pointer); + if (!result) { + throw PathResolutionError{schema_resolution_base, path_pointer_string}; + } + + sourcemeta::core::JSON subschema{*result}; + schema = std::move(subschema); + + if (!sourcemeta::core::is_schema(schema)) { + throw NotSchemaError{schema_from_stdin ? stdin_path() + : schema_resolution_base, + path_pointer_string}; + } + } else if (!sourcemeta::core::is_schema(schema)) { throw NotSchemaError{schema_from_stdin ? stdin_path() : schema_resolution_base}; } @@ -291,9 +327,9 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) const sourcemeta::core::JSON bundled{[&]() { try { - return sourcemeta::core::bundle(schema, sourcemeta::core::schema_walker, - custom_resolver, dialect, - schema_default_id); + return sourcemeta::core::bundle( + std::as_const(schema), sourcemeta::core::schema_walker, + custom_resolver, dialect, schema_default_id); } catch (const sourcemeta::core::SchemaKeywordError &error) { throw sourcemeta::core::FileError( schema_resolution_base, error); @@ -301,6 +337,12 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) throw sourcemeta::core::FileError( schema_resolution_base, error); } catch (const sourcemeta::core::SchemaReferenceError &error) { + if (!path_pointer_string.empty()) { + throw PathSchemaReferenceError{ + schema_resolution_base, std::string{error.identifier()}, + error.location(), path_pointer_string, error.what()}; + } + throw sourcemeta::core::FileError( schema_resolution_base, std::string{error.identifier()}, error.location(), error.what()); @@ -399,6 +441,12 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options) throw sourcemeta::core::FileError( schema_resolution_base, error); } catch (const sourcemeta::core::SchemaReferenceError &error) { + if (!path_pointer_string.empty()) { + throw PathSchemaReferenceError{ + schema_resolution_base, std::string{error.identifier()}, + error.location(), path_pointer_string, error.what()}; + } + throw sourcemeta::core::FileError( schema_resolution_base, std::string{error.identifier()}, error.location(), error.what()); diff --git a/src/error.h b/src/error.h index 75c5649c..e9b35529 100644 --- a/src/error.h +++ b/src/error.h @@ -62,18 +62,76 @@ class InvalidOptionEnumerationValueError : public std::runtime_error { class NotSchemaError : public std::runtime_error { public: - NotSchemaError(std::filesystem::path path) + NotSchemaError(std::filesystem::path path, std::string pointer = {}) : std::runtime_error{"The schema file you provided does not represent a " "valid JSON " "Schema"}, - path_{std::move(path)} {} + path_{std::move(path)}, pointer_{std::move(pointer)} {} [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { return this->path_; } + [[nodiscard]] auto pointer() const noexcept -> const std::string & { + return this->pointer_; + } + private: std::filesystem::path path_; + std::string pointer_; +}; + +class PathResolutionError : public std::runtime_error { +public: + PathResolutionError(std::filesystem::path path, std::string pointer) + : std::runtime_error{"The JSON Pointer does not resolve to a value in " + "the document"}, + path_{std::move(path)}, pointer_{std::move(pointer)} {} + + [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { + return this->path_; + } + + [[nodiscard]] auto pointer() const noexcept -> const std::string & { + return this->pointer_; + } + +private: + std::filesystem::path path_; + std::string pointer_; +}; + +class PathSchemaReferenceError : public std::runtime_error { +public: + PathSchemaReferenceError(std::filesystem::path path, std::string identifier, + sourcemeta::core::Pointer location, + std::string pointer, std::string message) + : std::runtime_error{std::move(message)}, path_{std::move(path)}, + identifier_{std::move(identifier)}, location_{std::move(location)}, + pointer_{std::move(pointer)} {} + + [[nodiscard]] auto path() const noexcept -> const std::filesystem::path & { + return this->path_; + } + + [[nodiscard]] auto identifier() const noexcept -> const std::string & { + return this->identifier_; + } + + [[nodiscard]] auto location() const noexcept + -> const sourcemeta::core::Pointer & { + return this->location_; + } + + [[nodiscard]] auto pointer() const noexcept -> const std::string & { + return this->pointer_; + } + +private: + std::filesystem::path path_; + std::string identifier_; + sourcemeta::core::Pointer location_; + std::string pointer_; }; class YAMLInputError : public std::runtime_error { @@ -446,6 +504,19 @@ inline auto print_exception(const bool is_json, const Exception &exception) } } + if constexpr (requires(const Exception ¤t) { + { current.pointer() } -> std::convertible_to; + }) { + if (!exception.pointer().empty()) { + if (is_json) { + error_json.assign("pointer", + sourcemeta::core::JSON{exception.pointer()}); + } else { + std::cerr << " at path " << exception.pointer() << "\n"; + } + } + } + if constexpr (requires(const Exception ¤t) { current.location(); }) { if (is_json) { error_json.assign("location", @@ -560,6 +631,10 @@ inline auto try_catch(const sourcemeta::core::Options &options, const auto is_json{options.contains("json")}; print_exception(is_json, error); return EXIT_OTHER_INPUT_ERROR; + } catch (const PathResolutionError &error) { + const auto is_json{options.contains("json")}; + print_exception(is_json, error); + return EXIT_INVALID_CLI_ARGUMENTS; } catch (const NotSchemaError &error) { const auto is_json{options.contains("json")}; print_exception(is_json, error); @@ -663,6 +738,10 @@ inline auto try_catch(const sourcemeta::core::Options &options, "Try tools like https://regex101.com to debug further\n"; } + return EXIT_SCHEMA_INPUT_ERROR; + } catch (const PathSchemaReferenceError &error) { + const auto is_json{options.contains("json")}; + print_exception(is_json, error); return EXIT_SCHEMA_INPUT_ERROR; } catch ( const sourcemeta::core::FileError diff --git a/src/main.cc b/src/main.cc index 11448124..dacbc35f 100644 --- a/src/main.cc +++ b/src/main.cc @@ -38,6 +38,7 @@ Global Options: [--benchmark/-b] [--loop ] [--extension/-e ] [--ignore/-i ] [--trace/-t] [--fast/-f] [--template/-m ] [--entrypoint/-p ] + [--path/-P ] Validate one or more instances against the given schema. @@ -52,6 +53,8 @@ Global Options: for error reporting purposes. Make sure they match or you will get non-sense results. + Use --path/-P to validate against a sub-schema by JSON Pointer. + metaschema [schemas-or-directories...] [--extension/-e ] [--ignore/-i ] [--trace/-t] @@ -182,6 +185,7 @@ auto jsonschema_main(const std::string &program, const std::string &command, app.option("template", {"m"}); app.option("loop", {"l"}); app.option("entrypoint", {"p"}); + app.option("path", {"P"}); app.parse(argc, argv, {.skip = 1}); sourcemeta::jsonschema::validate(app); return EXIT_SUCCESS; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d067d7db..bba96602 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -242,6 +242,12 @@ add_jsonschema_test_unix(validate/fail_entrypoint_invalid_pointer_escape) add_jsonschema_test_unix(validate/fail_entrypoint_invalid_uri_parse) add_jsonschema_test_unix(validate/fail_entrypoint_mismatch) add_jsonschema_test_unix(validate/fail_entrypoint_with_template) +add_jsonschema_test_unix(validate/pass_path_openapi) +add_jsonschema_test_unix(validate/fail_path_not_found) +add_jsonschema_test_unix(validate/fail_path_not_schema) +add_jsonschema_test_unix(validate/fail_path_with_template) +add_jsonschema_test_unix(validate/fail_path_ref_outside_subtree) +add_jsonschema_test_unix(validate/fail_path_invalid_pointer) add_jsonschema_test_unix(validate/pass_config_ignore) add_jsonschema_test_unix(validate/pass_config_ignore_with_cli) add_jsonschema_test_unix(validate/pass_stdin_instance) diff --git a/test/validate/fail_path_invalid_pointer.sh b/test/validate/fail_path_invalid_pointer.sh new file mode 100755 index 00000000..21671ca8 --- /dev/null +++ b/test/validate/fail_path_invalid_pointer.sh @@ -0,0 +1,47 @@ +#!/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" +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path 'invalid~pointer' > "$TMP/output.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Invalid CLI arguments +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +error: The JSON Pointer is not valid + +For example: jsonschema validate path/to/schema.json path/to/instance.json --path '/components/schemas/User' +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path 'invalid~pointer' --json > "$TMP/stdout.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The JSON Pointer is not valid" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_not_found.sh b/test/validate/fail_path_not_found.sh new file mode 100755 index 00000000..40fdf6a7 --- /dev/null +++ b/test/validate/fail_path_not_found.sh @@ -0,0 +1,55 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/document.json" +{ + "components": { + "schemas": { + "User": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + } + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/NonExistent" 2> "$TMP/stderr.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Invalid CLI arguments (path not found) +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +error: The JSON Pointer does not resolve to a value in the document + at file path $(realpath "$TMP")/document.json + at path /components/schemas/NonExistent +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/NonExistent" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The JSON Pointer does not resolve to a value in the document", + "filePath": "$(realpath "$TMP")/document.json", + "pointer": "/components/schemas/NonExistent" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_not_schema.sh b/test/validate/fail_path_not_schema.sh new file mode 100755 index 00000000..4c86ca59 --- /dev/null +++ b/test/validate/fail_path_not_schema.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/document.json" +{ + "components": { + "schemas": { + "User": [ "not", "a", "schema" ] + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{ "foo": "bar" } +EOF + +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" 2> "$TMP/stderr.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +error: The schema file you provided does not represent a valid JSON Schema + at file path $(realpath "$TMP")/document.json + at path /components/schemas/User +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The schema file you provided does not represent a valid JSON Schema", + "filePath": "$(realpath "$TMP")/document.json", + "pointer": "/components/schemas/User" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_ref_outside_subtree.sh b/test/validate/fail_path_ref_outside_subtree.sh new file mode 100755 index 00000000..86b76ebf --- /dev/null +++ b/test/validate/fail_path_ref_outside_subtree.sh @@ -0,0 +1,64 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/document.json" +{ + "components": { + "schemas": { + "Address": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + }, + "User": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/components/schemas/Address" + } + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" 2> "$TMP/stderr.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error: $ref outside extracted subtree cannot be resolved during bundling +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +error: Could not resolve schema reference + at identifier file://$(realpath "$TMP")/document.json#/components/schemas/Address + at file path $(realpath "$TMP")/document.json + at path /components/schemas/User + at location "/\$ref" +EOF + +diff "$TMP/stderr.txt" "$TMP/expected.txt" + +# JSON error +"$1" validate "$TMP/document.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --json > "$TMP/stdout.txt" \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Schema input error +test "$EXIT_CODE" = "4" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "Could not resolve schema reference", + "identifier": "file://$(realpath "$TMP")/document.json#/components/schemas/Address", + "filePath": "$(realpath "$TMP")/document.json", + "pointer": "/components/schemas/User", + "location": "/\$ref" +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected.txt" diff --git a/test/validate/fail_path_with_template.sh b/test/validate/fail_path_with_template.sh new file mode 100755 index 00000000..b31d8ed0 --- /dev/null +++ b/test/validate/fail_path_with_template.sh @@ -0,0 +1,51 @@ +#!/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" +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{} +EOF + +cat << 'EOF' > "$TMP/template.json" +[] +EOF + +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path "/foo" --template "$TMP/template.json" \ + > "$TMP/output.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Invalid CLI arguments +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +error: The --path option cannot be used with --template +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" + +"$1" validate "$TMP/schema.json" "$TMP/instance.json" \ + --path "/foo" --template "$TMP/template.json" --json \ + > "$TMP/output.txt" 2>&1 \ + && EXIT_CODE="$?" || EXIT_CODE="$?" +# Invalid CLI arguments +test "$EXIT_CODE" = "5" + +cat << EOF > "$TMP/expected.txt" +{ + "error": "The --path option cannot be used with --template" +} +EOF + +diff "$TMP/output.txt" "$TMP/expected.txt" diff --git a/test/validate/pass_path_openapi.sh b/test/validate/pass_path_openapi.sh new file mode 100755 index 00000000..16df0493 --- /dev/null +++ b/test/validate/pass_path_openapi.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +set -o errexit +set -o nounset + +TMP="$(mktemp -d)" +clean() { rm -rf "$TMP"; } +trap clean EXIT + +cat << 'EOF' > "$TMP/openapi.json" +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "User": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" + } + } + } +} +EOF + +cat << 'EOF' > "$TMP/instance.json" +{ "name": "John", "age": 30 } +EOF + +"$1" validate "$TMP/openapi.json" "$TMP/instance.json" \ + --path "/components/schemas/User" + +# Verbose run +"$1" validate "$TMP/openapi.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --verbose 2> "$TMP/stderr.txt" + +cat << EOF > "$TMP/expected_verbose.txt" +ok: $(realpath "$TMP")/instance.json + matches $(realpath "$TMP")/openapi.json +EOF + +diff "$TMP/stderr.txt" "$TMP/expected_verbose.txt" + +# JSON run +"$1" validate "$TMP/openapi.json" "$TMP/instance.json" \ + --path "/components/schemas/User" --json > "$TMP/stdout.txt" + +cat << 'EOF' > "$TMP/expected_json.txt" +{ + "valid": true +} +EOF + +diff "$TMP/stdout.txt" "$TMP/expected_json.txt"