File Coverage

blib/lib/OpenAPI/Modern/Utilities.pm
Criterion Covered Total %
statement 199 199 100.0
branch 34 36 94.4
condition 21 24 87.5
subroutine 47 47 100.0
pod 0 9 0.0
total 301 315 95.5


line stmt bran cond sub pod time code
1 17     17   121 use strictures 2;
  17         152  
  17         646  
2             package OpenAPI::Modern::Utilities;
3             # vim: set ts=8 sts=2 sw=2 tw=100 et :
4             # ABSTRACT: Internal utilities and common definitions for OpenAPI::Modern
5              
6             our $VERSION = '0.139';
7              
8 17     17   6685 use 5.020;
  17         51  
9 17     17   65 use strictures 2;
  17         68  
  17         437  
10 17     17   5057 use stable 0.031 'postderef';
  17         297  
  17         106  
11 17     17   3194 use experimental 'signatures';
  17         42  
  17         68  
12 17     17   981 no autovivification warn => qw(fetch store exists delete);
  17         49  
  17         130  
13 17     17   1113 use if "$]" >= 5.022, experimental => 're_strict';
  17         50  
  17         413  
14 17     17   1240 no if "$]" >= 5.031009, feature => 'indirect';
  17         30  
  17         1019  
15 17     17   85 no if "$]" >= 5.033001, feature => 'multidimensional';
  17         32  
  17         830  
16 17     17   77 no if "$]" >= 5.033006, feature => 'bareword_filehandles';
  17         21  
  17         734  
17 17     17   63 no if "$]" >= 5.041009, feature => 'smartmatch';
  17         23  
  17         587  
18 17     17   93 no feature 'switch';
  17         24  
  17         488  
19 17     17   200 use File::ShareDir 'dist_dir';
  17         49  
  17         1631  
20 17     17   76 use List::Util 1.45 'uniqstr';
  17         282  
  17         1068  
21 17     17   74 use Scalar::Util 'looks_like_number';
  17         35  
  17         797  
22 17     17   81 use Mojo::Util qw(url_unescape url_escape);
  17         26  
  17         2450  
23 17     17   82 use Carp 'croak';
  17         32  
  17         897  
24 17     17   68 use if "$]" < 5.041010, 'List::Util' => 'any';
  17         49  
  17         656  
25 17     17   68 use if "$]" >= 5.041010, experimental => 'keyword_any';
  17         41  
  17         279  
26 17     17   1244 use JSON::Schema::Modern::Utilities 0.625 qw(register_schema load_cached_document true false);
  17         426  
  17         926  
27 17     17   122 use namespace::clean;
  17         32  
  17         182  
28              
29 17     17   5108 use Exporter 'import';
  17         26  
  17         1686  
30              
31             our @EXPORT = qw(
32             SUPPORTED_OAD_VERSIONS
33             OAS_VERSIONS
34             DEFAULT_DIALECT
35             DEFAULT_BASE_METASCHEMA
36             DEFAULT_METASCHEMA
37             STRICT_METASCHEMA
38             STRICT_DIALECT
39             OAS_VOCABULARY
40             );
41              
42             our @EXPORT_OK = qw(
43             OAS_SCHEMAS
44             add_vocab_and_default_schemas
45             uri_decode
46             uri_encode
47             uri_encode_strict
48             intersect_types
49             coerce_primitive
50             is_cookie_name
51             is_cookie_value
52             elem
53             );
54              
55             our %EXPORT_TAGS = (
56             constants => \@EXPORT,
57             );
58              
59             # it is likely the case that we can support a version beyond what's stated here -- but we may not,
60             # so we'll warn to that effect. Every effort will be made to upgrade this implementation to fully
61             # support the latest point release as soon as possible.
62 17     17   89 use constant SUPPORTED_OAD_VERSIONS => [ '3.0.4', '3.1.2', '3.2.0' ];
  17         25  
  17         2445  
63              
64             # in most things, e.g. schemas, we only use major.minor as the version number
65 17     17   91 use constant OAS_VERSIONS => [ map s/^\d+\.\d+\K\.\d+\z//r, SUPPORTED_OAD_VERSIONS->@* ];
  17         86  
  17         1243  
66              
67             # see https://spec.openapis.org/#openapi-specification-schemas for the latest links
68             # these are updated automatically at build time via 'update-schemas'
69              
70             # the main OpenAPI document schema, with permissive (unvalidated) JSON Schemas
71 17         1575 use constant DEFAULT_METASCHEMA => {
72             '3.0' => 'https://spec.openapis.org/oas/3.0/schema/2024-10-18',
73             '3.1' => 'https://spec.openapis.org/oas/3.1/schema/2025-11-23',
74             '3.2' => 'https://spec.openapis.org/oas/3.2/schema/2025-11-23',
75 17     17   80 };
  17         23  
76              
77             # metaschema for JSON Schemas contained within OpenAPI documents:
78             # standard JSON Schema (presently draft2020-12) + OpenAPI vocabulary
79             use constant DEFAULT_DIALECT => {
80 17         1095 '3.0' => DEFAULT_METASCHEMA->{'3.0'}.'#/definitions/Schema',
81             '3.1' => 'https://spec.openapis.org/oas/3.1/dialect/2024-11-10',
82             '3.2' => 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17',
83 17     17   85 };
  17         33  
84              
85             # OpenAPI document schema that forces the use of the JSON Schema dialect (no $schema overrides
86             # permitted)
87 17         947 use constant DEFAULT_BASE_METASCHEMA => {
88             '3.0' => 'https://spec.openapis.org/oas/3.0/schema/2024-10-18', # same as standard
89             '3.1' => 'https://spec.openapis.org/oas/3.1/schema-base/2025-11-23',
90             '3.2' => 'https://spec.openapis.org/oas/3.2/schema-base/2025-11-23',
91 17     17   97 };
  17         29  
92              
93             # OpenAPI vocabulary definition
94 17         772 use constant OAS_VOCABULARY => {
95             '3.1' => 'https://spec.openapis.org/oas/3.1/meta/2024-11-10',
96             '3.2' => 'https://spec.openapis.org/oas/3.2/meta/2025-09-17',
97 17     17   159 };
  17         42  
98              
99             # an OpenAPI schema and JSON Schema dialect which prohibit unknown keywords
100 17         901 use constant STRICT_METASCHEMA => {
101             '3.1' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/3.1/strict-schema.json',
102             '3.2' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/3.2/strict-schema.json',
103 17     17   77 };
  17         38  
104              
105 17         2616 use constant STRICT_DIALECT => {
106             '3.1' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/3.1/strict-dialect.json',
107             '3.2' => 'https://raw.githubusercontent.com/karenetheridge/OpenAPI-Modern/master/share/3.2/strict-dialect.json',
108 17     17   64 };
  17         24  
109              
110             # => (under share/) - for internal use only!
111             use constant _BUNDLED_SCHEMAS => {
112             map +(
113             DEFAULT_METASCHEMA->{$_} => 'oas/'.$_.'/schema.json',
114             $_ eq '3.0' ? () : (
115             DEFAULT_DIALECT->{$_} => 'oas/'.$_.'/dialect.json',
116             DEFAULT_BASE_METASCHEMA->{$_} => 'oas/'.$_.'/schema-base.json',
117             OAS_VOCABULARY->{$_} => 'oas/'.$_.'/vocabulary.json',
118             STRICT_METASCHEMA->{$_} => $_.'/strict-schema.json',
119 17 100       2436 STRICT_DIALECT->{$_} => $_.'/strict-dialect.json',
120             )
121             ), OAS_VERSIONS->@*
122 17     17   81 };
  17         24  
123              
124             # these are all loadable on demand, via JSON::Schema::Modern::load_cached_document,
125             # and also made available as s//latest/
126             # { => [ , , .. ]
127             use constant OAS_SCHEMAS => {
128             map {
129 17         31 my $version = $_;
  51         88  
130 51         26758 $version => [ grep m{/oas/$version/}, keys _BUNDLED_SCHEMAS->%* ]
131             } OAS_VERSIONS->@*
132 17     17   82 };
  17         25  
133              
134              
135 395     395 0 669014 sub add_vocab_and_default_schemas ($evaluator, $version = OAS_VERSIONS->[-1]) {
  395         607  
  395         807  
  395         513  
136 395         2223 $evaluator->add_vocabulary('JSON::Schema::Modern::Vocabulary::OpenAPI');
137              
138 12         24 $evaluator->add_format_validation(int32 => +{
139             type => 'number',
140 12     12   261268 sub => sub ($x) {
  12         15  
141 12         78 require Math::BigInt; Math::BigInt->VERSION(1.999701);
  12         215  
142 12         79 $x = Math::BigInt->new($x);
143 12 100       1663 return if $x->is_nan;
144 10         93 my $bound = Math::BigInt->new(2) ** 31;
145 10 100       4780 $x >= -$bound && $x < $bound;
146             }
147 395         32099 });
148              
149 20         38 $evaluator->add_format_validation(int64 => +{
150             type => 'number',
151 20     20   143364 sub => sub ($x) {
  20         30  
152 20         129 require Math::BigInt; Math::BigInt->VERSION(1.999701);
  20         378  
153 20         144 $x = Math::BigInt->new($x);
154 20 100       2393 return if $x->is_nan;
155 18         143 my $bound = Math::BigInt->new(2) ** 63;
156 18 100       9993 $x >= -$bound && $x < $bound;
157             }
158 395         505783 });
159              
160 395     12   326321 $evaluator->add_format_validation(float => +{ type => 'number', sub => sub ($x) { 1 } });
  12         36  
  12         122722  
  12         24  
  12         21  
161 395     12   321206 $evaluator->add_format_validation(double => +{ type => 'number', sub => sub ($x) { 1 } });
  12         39  
  12         137007  
  12         26  
  12         18  
162 395     2   320423 $evaluator->add_format_validation(password => +{ type => 'string', sub => sub ($) { 1 } });
  2         7  
  2         31470  
  2         5  
163              
164 395         318149 my $OWS = q{[\x09\x20]*};
165 395         918 my $TOKEN = q{[a-zA-Z0-9!#$%&'*+.^_`|~-]+};
166 395         759 my $QUOTED_STRING = q{"(?:[\x09\20\x21\x23-\x5B\x5D-\x7E\x80-\xFF]|\x5C[\x09\x20-\x7E\x80-\xFF])*"};
167 475         941 $evaluator->add_format_validation('media-range' => +{
168             type => 'string',
169 475     475   1413660 sub => sub ($x) {
  475         692  
170             # see ABNF at RFC9110 Appendix A
171 475         8926 return 0+!!($x =~ m{^$TOKEN/$TOKEN(?:$OWS;$OWS$TOKEN=(?:$TOKEN|$QUOTED_STRING))*\z});
172             },
173 395         2990 });
174              
175 395         320269 foreach my $uri (OAS_SCHEMAS->{$version}->@*) {
176 1508         2243191 my $document = load_cached_document($evaluator, $uri);
177              
178             # add "latest" alias for each of these documents, mapping to the same document object
179 1508         17845810 $evaluator->add_document(($document->canonical_uri =~ s{/\d{4}-\d{2}-\d{2}\z}{}r).'/latest', $document);
180             }
181             }
182              
183             # url-percent-decode and UTF-8-decode a string
184 1326     1326 0 25246 sub uri_decode ($str) {
  1326         1740  
  1326         1305  
185 1326         2838 Encode::decode('UTF-8', url_unescape($str), Encode::DIE_ON_ERR);
186             }
187              
188             # UTF-8-encode and url-percent-encode a string (only encoding characters that MUST be encoded)
189 75     75 0 6104 sub uri_encode ($str) {
  75         89  
  75         74  
190 75         260 url_escape(Encode::encode('UTF-8', $str, Encode::DIE_ON_ERR), '^A-Za-z0-9\-._~!$&\'()*+,;=:@');
191             }
192              
193             # UTF-8-encode and url-percent-encode a string (encoding all of reserved, gen-delims and sub-delims)
194 62     62 0 118 sub uri_encode_strict ($str) {
  62         132  
  62         98  
195 62         269 url_escape(Encode::encode('UTF-8', $str, Encode::DIE_ON_ERR));
196             }
197              
198             # find the intersection of all the lists, number and integer as equivalent
199 1672     1672 0 2151 sub intersect_types (@lol) {
  1672         2345  
  1672         2025  
200 1672         2003 my $count = @lol;
201 1672         2324 my %vals;
202 1672         3728 while (my $list = shift @lol) {
203 1879 100       12933 ++$vals{$_} foreach uniqstr map +($_ eq 'integer' ? 'number' : $_), @$list;
204             }
205              
206 1672         8422 return grep $vals{$_} == $count, keys %vals;
207             }
208              
209             # Given a reference to a string, coerce it to the best-matching primitive in the allowed list
210             # other than object and array (which must be deserialized according to style rules first)
211             # The core types are: (array, object, null, boolean, string, number)
212             # Returns validity status, allowing the caller to fall back to the string value or generate an error.
213 585     585 0 55291 sub coerce_primitive ($dataref, $types = []) {
  585         786  
  585         861  
  585         753  
214 585 100       1319 return if not @$types; # no type specified; indicate failure
215 576 100       1425 return if not defined $$dataref; # null is an error
216 575 100       1190 return if ref $$dataref; # booleans, arrays, objects are errors
217              
218 569         1026 my $data = $$dataref; # make copy to avoid unwanted mutation of the original
219              
220 569 100 100     1610 $$dataref = undef, return 1 if $data eq '' and elem('null', $types);
221              
222 535 100       943 if (elem('boolean', $types)) {
223 102 100 100     841 $$dataref = false, return 1 if $data eq '0' or $data eq 'false' or $data eq '';
      100        
224 54 100 100     428 $$dataref = true, return 1 if $data eq '1' or $data eq 'true';
225             }
226              
227 453 100 100     763 $$dataref = 0+$$dataref, return 1 if elem('number', $types) and looks_like_number($$dataref);
228              
229 282 100       646 $$dataref = ''.$$dataref, return 1 if elem('string', $types);
230             }
231              
232             # RFC6265 §3.1 and §4.2.1
233             # cookie-header = "Cookie:" OWS cookie-string OWS
234             # cookie-string = cookie-pair *( ";" SP cookie-pair )
235             # cookie-pair = cookie-name "=" cookie-value
236             # cookie-name = token (defined in RFC2616 §2.2)
237             # cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
238             # cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E ; US-ASCII characters excluding
239             # CTLs, whitespace, DQUOTE, comma, semicolon, and backslash
240              
241 281     281 0 353 sub is_cookie_name ($name) {
  281         403  
  281         334  
242 281   66     2080 !!(defined $name && $name =~ /^[A-Za-z0-9!#\$%&'*+.^_`|~-]+\z/);
243             }
244              
245 275     275 0 345 sub is_cookie_value ($value) {
  275         416  
  275         411  
246 275   66     2141 !!(defined $value && $value =~ /^("?)[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*\1\z/);
247             }
248              
249             # are any $items a member of $set?
250 4252     4252 0 1074973 sub elem ($items, $set) {
  4252         6010  
  4252         4859  
  4252         4338  
251 4252 50       8806 croak 'set is not an array' if ref $set ne 'ARRAY';
252 4252 100       9468 $items = [ $items ] if ref $items ne 'ARRAY';
253              
254 4252         7461 any {
255 6080         8463 my $x = $_;
256 6080 50 66     8606 any { defined $x ? (defined $_ && $x eq $_) : (!defined $_) } @$items
  6403         37783  
257             }
258             @$set;
259             }
260              
261             {
262             # make all bundled schemas available via JSON::Schema::Modern::load_cached_document
263             my $share_dir = dist_dir('OpenAPI-Modern');
264             foreach my $uri (keys _BUNDLED_SCHEMAS->%*) {
265             register_schema($uri, $share_dir.'/'._BUNDLED_SCHEMAS->{$uri});
266             }
267             }
268              
269             1;
270              
271             __END__