Skip to content

Commit d3027c0

Browse files
support $self keyword coming in v3.2
see OAI/OpenAPI-Specification#4389
1 parent d796bfb commit d3027c0

File tree

4 files changed

+223
-59
lines changed

4 files changed

+223
-59
lines changed

Changes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Revision history for OpenAPI-Modern
33
{{$NEXT}}
44
- no longer require the user to define a custom metaschema when
55
changing jsonSchemaDialect (we dynamically create one for you)
6+
- pre-emptive support for '$self' property in the OpenAPI document
7+
(to be officially added in v3.2)
68

79
0.083 2025-02-28 23:48:29Z
810
- fix Sereal serialization and deserialization hooks

lib/JSON/Schema/Modern/Document/OpenAPI.pm

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ has '+evaluator' => (
5151
required => 1,
5252
);
5353

54+
has '+schema' => (
55+
isa => HashRef,
56+
);
57+
5458
has '+metaschema_uri' => (
5559
lazy => 1,
5660
default => DEFAULT_BASE_METASCHEMA,
@@ -113,6 +117,11 @@ sub traverse ($self, $evaluator, $config_override = {}) {
113117
type => 'object',
114118
required => ['openapi'],
115119
properties => {
120+
'$self' => {
121+
type => 'string',
122+
format => 'uri-reference',
123+
pattern => '', # just here for the callback so we can customize the error
124+
},
116125
openapi => {
117126
type => 'string',
118127
pattern => '', # just here for the callback so we can customize the error
@@ -131,7 +140,11 @@ sub traverse ($self, $evaluator, $config_override = {}) {
131140
validate_formats => 1,
132141
callbacks => {
133142
pattern => sub ($data, $schema, $state) {
134-
return $data =~ /^3\.1\.[0-9]+(-.+)?$/ ? 1 : E($state, 'unrecognized openapi version %s', $data);
143+
return E($state, 'unrecognized openapi version %s', $data)
144+
if $state->{data_path} eq '/openapi' and $data !~ /^3\.1\.[0-9]+(-.+)?$/;
145+
return E($state, '$self cannot contain a fragment')
146+
if $state->{data_path} eq '/$self' and $data =~ /#/;
147+
return 1;
135148
},
136149
},
137150
},
@@ -141,8 +154,9 @@ sub traverse ($self, $evaluator, $config_override = {}) {
141154
return $state;
142155
}
143156

144-
# in v3.2, we will use the resolved form of the uri in '$self'
145-
$self->_set_canonical_uri($state->{initial_schema_uri} = $self->original_uri);
157+
# determine canonical uri using rules from §?? (v3.2) "Establishing the Base URI"
158+
$self->_set_canonical_uri($state->{initial_schema_uri} =
159+
Mojo::URL->new($schema->{'$self'}//())->to_abs($self->retrieval_uri));
146160

147161
# /jsonSchemaDialect: https://spec.openapis.org/oas/v3.1#specifying-schema-dialects
148162
{
@@ -172,6 +186,14 @@ sub traverse ($self, $evaluator, $config_override = {}) {
172186
if not $self->_has_metaschema_uri and $json_schema_dialect ne DEFAULT_DIALECT;
173187
}
174188

189+
$state->{identifiers}{$state->{initial_schema_uri}} = {
190+
path => '',
191+
canonical_uri => $state->{initial_schema_uri},
192+
specification_version => $state->{spec_version},
193+
vocabularies => $state->{vocabularies}, # reference, not copy
194+
configs => {},
195+
};
196+
175197
# evaluate the document against its metaschema to find any errors, to identify all schema
176198
# resources within to add to the global resource index, and to extract all operationIds
177199
my (@json_schema_paths, @operation_paths, %bad_path_item_refs, @servers_paths);
@@ -370,6 +392,14 @@ sub _add_vocab_and_default_schemas ($self) {
370392
$js->add_document($base.'/latest', $document);
371393
}
372394
}
395+
396+
# dirty hack! patch in support for $self, until v3.2
397+
$js->{_resource_index}{'https://spec.openapis.org/oas/3.1/schema/2024-11-14'}{document}->schema->{properties}{'$self'} = {
398+
type => 'string',
399+
format => 'uri-reference',
400+
'$comment' => 'MUST NOT be empty, and MUST NOT contain a fragment',
401+
pattern => '^[^#]+$',
402+
} if exists $self->schema->{'$self'};
373403
}
374404

375405
# https://spec.openapis.org/oas/v3.1#schema-object
@@ -407,14 +437,15 @@ sub _traverse_schema ($self, $state) {
407437
}
408438

409439
foreach my $anchor (sort keys $new->{anchors}->%*) {
410-
if (my $existing_anchor = $existing->{anchors}{$anchor}) {
440+
if (my $existing_anchor = ($existing->{anchors}//{})->{$anchor}) {
411441
()= E({ %$state, schema_path => $new->{anchors}{$anchor}{path} },
412442
'duplicate anchor uri "%s" found (original at path "%s")',
413443
$new->{canonical_uri}->clone->fragment($anchor),
414444
$existing->{anchors}{$anchor}{path});
415445
next;
416446
}
417447

448+
use autovivification 'store';
418449
$existing->{anchors}{$anchor} = $new->{anchors}{$anchor};
419450
}
420451
}

t/document-toplevel.t

Lines changed: 183 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -42,36 +42,23 @@ subtest 'basic construction' => sub {
4242
);
4343
};
4444

45-
subtest 'top level document fields' => sub {
46-
my $doc = JSON::Schema::Modern::Document::OpenAPI->new(
47-
canonical_uri => 'http://localhost:1234/api',
48-
evaluator => my $js = JSON::Schema::Modern->new,
49-
schema => 1,
50-
);
51-
52-
cmp_result(
53-
[ map $_->TO_JSON, $doc->errors ],
54-
[
55-
{
56-
instanceLocation => '',
57-
keywordLocation => '/type',
58-
absoluteKeywordLocation => DEFAULT_METASCHEMA.'#/type',
59-
error => 'got integer, not object',
60-
},
61-
],
45+
subtest 'top level document checks' => sub {
46+
die_result(
47+
sub {
48+
JSON::Schema::Modern::Document::OpenAPI->new(
49+
canonical_uri => 'http://localhost:1234/api',
50+
evaluator => JSON::Schema::Modern->new,
51+
schema => 1,
52+
);
53+
},
54+
qr/^Value "1" did not pass type constraint "HashRef"/,
6255
'document is wrong type',
6356
);
6457

65-
is(
66-
document_result($doc),
67-
q!'': got integer, not object!,
68-
'stringified errors',
69-
);
7058

71-
72-
$doc = JSON::Schema::Modern::Document::OpenAPI->new(
59+
my $doc = JSON::Schema::Modern::Document::OpenAPI->new(
7360
canonical_uri => 'http://localhost:1234/api',
74-
evaluator => $js = JSON::Schema::Modern->new,
61+
evaluator => my $js = JSON::Schema::Modern->new,
7562
schema => {},
7663
);
7764
cmp_result(
@@ -190,6 +177,45 @@ ERRORS
190177
ERRORS
191178

192179

180+
$doc = JSON::Schema::Modern::Document::OpenAPI->new(
181+
canonical_uri => 'http://localhost:1234/api',
182+
evaluator => $js,
183+
schema => {
184+
openapi => OAS_VERSION,
185+
info => {
186+
title => 'my title',
187+
version => '1.2.3',
188+
},
189+
'$self' => '#frag\\ment',
190+
},
191+
);
192+
193+
cmp_result(
194+
[ map $_->TO_JSON, $doc->errors ],
195+
[
196+
{
197+
instanceLocation => '/$self',
198+
keywordLocation => '/properties/$self/pattern',
199+
absoluteKeywordLocation => DEFAULT_METASCHEMA.'#/properties/$self/pattern',
200+
error => '$self cannot contain a fragment',
201+
},
202+
{
203+
instanceLocation => '/$self',
204+
keywordLocation => '/properties/$self/format',
205+
absoluteKeywordLocation => DEFAULT_METASCHEMA.'#/properties/$self/format',
206+
error => 'not a valid uri-reference string',
207+
},
208+
{
209+
instanceLocation => '',
210+
keywordLocation => '/properties',
211+
absoluteKeywordLocation => DEFAULT_METASCHEMA.'#/properties',
212+
error => 'not all properties are valid',
213+
},
214+
],
215+
'invalid $self uri, with custom error message',
216+
);
217+
218+
193219
$doc = JSON::Schema::Modern::Document::OpenAPI->new(
194220
canonical_uri => 'http://localhost:1234/api',
195221
evaluator => $js,
@@ -463,6 +489,138 @@ ERRORS
463489
}),
464490
'dialect resources are properly stored on the evaluator',
465491
);
492+
493+
494+
# relative $self, absolute original_uri - $self is resolved with original_uri
495+
$doc = JSON::Schema::Modern::Document::OpenAPI->new(
496+
canonical_uri => 'http://localhost:1234/foo/api.json',
497+
evaluator => $js,
498+
schema => {
499+
openapi => OAS_VERSION,
500+
info => {
501+
title => 'my title',
502+
version => '1.2.3',
503+
},
504+
'$self' => 'user/api.json', # the 'user' family of APIs
505+
paths => {},
506+
},
507+
);
508+
509+
is($doc->original_uri, 'http://localhost:1234/foo/api.json', 'retrieval uri');
510+
is($doc->canonical_uri, 'http://localhost:1234/foo/user/api.json', 'canonical uri is $self resolved against retrieval uri');
511+
cmp_deeply(
512+
$doc->{resource_index},
513+
{
514+
'http://localhost:1234/foo/user/api.json' => {
515+
canonical_uri => str('http://localhost:1234/foo/user/api.json'),
516+
path => '',
517+
specification_version => 'draft2020-12',
518+
vocabularies => bag(map 'JSON::Schema::Modern::Vocabulary::'.$_,
519+
qw(Core Applicator Validation FormatAnnotation Content MetaData Unevaluated OpenAPI)),
520+
configs => {},
521+
},
522+
},
523+
'resource is properly indexed',
524+
);
525+
526+
527+
# absolute $self, absolute original_uri - $self is used as is
528+
$doc = JSON::Schema::Modern::Document::OpenAPI->new(
529+
canonical_uri => 'http://localhost:1234/foo/api.json',
530+
evaluator => $js,
531+
schema => {
532+
openapi => OAS_VERSION,
533+
info => {
534+
title => 'my title',
535+
version => '1.2.3',
536+
},
537+
'$self' => 'http://localhost:5555/user/api.json', # the 'user' family of APIs
538+
paths => {},
539+
},
540+
);
541+
542+
is($doc->original_uri, 'http://localhost:1234/foo/api.json', 'retrieval uri');
543+
is($doc->canonical_uri, 'http://localhost:5555/user/api.json', 'canonical uri is $self, already absolute');
544+
cmp_deeply(
545+
$doc->{resource_index},
546+
{
547+
'http://localhost:5555/user/api.json' => {
548+
canonical_uri => str('http://localhost:5555/user/api.json'),
549+
path => '',
550+
specification_version => 'draft2020-12',
551+
vocabularies => bag(map 'JSON::Schema::Modern::Vocabulary::'.$_,
552+
qw(Core Applicator Validation FormatAnnotation Content MetaData Unevaluated OpenAPI)),
553+
configs => {},
554+
},
555+
},
556+
'resource is properly indexed',
557+
);
558+
559+
560+
# relative $self, relative original_uri - $self is resolved with original_uri
561+
$doc = JSON::Schema::Modern::Document::OpenAPI->new(
562+
canonical_uri => 'foo/api.json',
563+
evaluator => $js,
564+
schema => {
565+
openapi => OAS_VERSION,
566+
info => {
567+
title => 'my title',
568+
version => '1.2.3',
569+
},
570+
'$self' => 'user/api.json', # the 'user' family of APIs
571+
paths => {},
572+
},
573+
);
574+
575+
is($doc->original_uri, 'foo/api.json', 'retrieval uri');
576+
is($doc->canonical_uri, 'foo/user/api.json', 'canonical uri is $self resolved against retrieval uri');
577+
cmp_deeply(
578+
$doc->{resource_index},
579+
{
580+
'foo/user/api.json' => {
581+
canonical_uri => str('foo/user/api.json'),
582+
path => '',
583+
specification_version => 'draft2020-12',
584+
vocabularies => bag(map 'JSON::Schema::Modern::Vocabulary::'.$_,
585+
qw(Core Applicator Validation FormatAnnotation Content MetaData Unevaluated OpenAPI)),
586+
configs => {},
587+
},
588+
},
589+
'resource is properly indexed',
590+
);
591+
592+
593+
# absolute $self, relative original_uri - $self is used as is
594+
$doc = JSON::Schema::Modern::Document::OpenAPI->new(
595+
canonical_uri => 'foo/api.json',
596+
evaluator => $js,
597+
schema => {
598+
openapi => OAS_VERSION,
599+
info => {
600+
title => 'my title',
601+
version => '1.2.3',
602+
},
603+
'$self' => 'http://localhost:5555/user/api.json', # the 'user' family of APIs
604+
paths => {},
605+
},
606+
);
607+
608+
is($doc->original_uri, 'foo/api.json', 'retrieval uri');
609+
is($doc->canonical_uri, 'http://localhost:5555/user/api.json', 'canonical uri is $self, already absolute');
610+
cmp_deeply(
611+
$doc->{resource_index},
612+
{
613+
'http://localhost:5555/user/api.json' => {
614+
canonical_uri => str('http://localhost:5555/user/api.json'),
615+
path => '',
616+
specification_version => 'draft2020-12',
617+
vocabularies => bag(map 'JSON::Schema::Modern::Vocabulary::'.$_,
618+
qw(Core Applicator Validation FormatAnnotation Content MetaData Unevaluated OpenAPI)),
619+
configs => {},
620+
},
621+
},
622+
'resource is properly indexed',
623+
);
466624
};
467625

468626
done_testing;

t/openapi-constructor.t

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -85,38 +85,11 @@ subtest 'missing arguments' => sub {
8585
};
8686

8787
subtest 'document errors' => sub {
88-
my $result;
89-
try {
90-
OpenAPI::Modern->new(
91-
openapi_uri => '/api',
92-
openapi_schema => [ 'this is not a valid openapi document' ],
93-
)
94-
}
95-
catch ($e) {
96-
$result = $e;
97-
}
98-
99-
cmp_result(
100-
$result->TO_JSON,
101-
{
102-
valid => false,
103-
errors => [
104-
{
105-
instanceLocation => '',
106-
keywordLocation => '/type',
107-
absoluteKeywordLocation => DEFAULT_METASCHEMA.'#/type',
108-
error => 'got array, not object',
109-
},
110-
],
111-
},
88+
die_result(
89+
sub { OpenAPI::Modern->new(openapi_uri => '/api', openapi_schema => [ 'invalid openapi document' ]) },
90+
qr/^Reference \["invalid openapi document"\] did not pass type constraint "HashRef"/,
11291
'bad document causes validation to immediately fail',
11392
);
114-
115-
is(
116-
"$result",
117-
q!'': got array, not object!,
118-
'exception during construction serializes the error correctly',
119-
);
12093
};
12194

12295
subtest 'construct with document' => sub {

0 commit comments

Comments
 (0)