Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/validate.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jsonschema validate <schema.json|.yaml> <instance.json|.jsonl|.yaml|directory...
[--benchmark/-b] [--loop <iterations>] [--extension/-e <extension>]
[--ignore/-i <schemas-or-directories>] [--trace/-t] [--fast/-f]
[--template/-m <template.json>] [--json/-j] [--entrypoint/-p <pointer|uri>]
[--path/-P <pointer>]
```

The most popular use case of JSON Schema is to validate JSON documents. The
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might want to explain more here the relationship with --entrypoint. Essentially the idea is to use --path to focus on a specific PART of the document that is a schema. And on top of what you can use --entrypoint to focus on a specific subschema of that schema that is PART of a document.

This case is also worth adding tests for

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`.
62 changes: 55 additions & 7 deletions src/command_validate.cc
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, we probably want something like this for the compile command too

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Vaibhav701161 I think this comment is still applicable. We need to support --path on the compile command too

Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <iostream> // std::cerr
#include <string> // std::string
#include <string_view> // std::string_view
#include <utility> // std::as_const

#include "command.h"
#include "configuration.h"
Expand Down Expand Up @@ -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)};
Expand All @@ -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{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is the right error here. --path is not even a positional argument. I think you can probably implement a good handler for PointerParseError that prints the problematic pointer, the column of the error, etc?

"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};
}
Expand Down Expand Up @@ -291,16 +327,22 @@ 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<sourcemeta::core::SchemaKeywordError>(
schema_resolution_base, error);
} catch (const sourcemeta::core::SchemaFrameError &error) {
throw sourcemeta::core::FileError<sourcemeta::core::SchemaFrameError>(
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<sourcemeta::core::SchemaReferenceError>(
schema_resolution_base, std::string{error.identifier()},
error.location(), error.what());
Expand Down Expand Up @@ -399,6 +441,12 @@ auto sourcemeta::jsonschema::validate(const sourcemeta::core::Options &options)
throw sourcemeta::core::FileError<sourcemeta::core::SchemaFrameError>(
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<sourcemeta::core::SchemaReferenceError>(
schema_resolution_base, std::string{error.identifier()},
error.location(), error.what());
Expand Down
83 changes: 81 additions & 2 deletions src/error.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -446,6 +504,19 @@ inline auto print_exception(const bool is_json, const Exception &exception)
}
}

if constexpr (requires(const Exception &current) {
{ current.pointer() } -> std::convertible_to<std::string>;
}) {
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 &current) { current.location(); }) {
if (is_json) {
error_json.assign("location",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<sourcemeta::core::SchemaReferenceError>
Expand Down
4 changes: 4 additions & 0 deletions src/main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Global Options:
[--benchmark/-b] [--loop <iterations>] [--extension/-e <extension>]
[--ignore/-i <schemas-or-directories>] [--trace/-t] [--fast/-f]
[--template/-m <template.json>] [--entrypoint/-p <pointer|uri>]
[--path/-P <pointer>]

Validate one or more instances against the given schema.

Expand All @@ -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 <extension>]
[--ignore/-i <schemas-or-directories>] [--trace/-t]

Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions test/validate/fail_path_invalid_pointer.sh
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this error is not great. You want to print the actual problematic pointer with something like at pointer and the column number maybe with at column

}
EOF

diff "$TMP/stdout.txt" "$TMP/expected.txt"
Loading
Loading