permittable 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +155 -0
- data/README.md +361 -33
- data/lib/permittable/audit.rb +268 -0
- data/lib/permittable/authored_values.rb +63 -0
- data/lib/permittable/column_guard.rb +133 -6
- data/lib/permittable/contract.rb +11 -2
- data/lib/permittable/error_envelope.rb +79 -4
- data/lib/permittable/field_group.rb +72 -0
- data/lib/permittable/generator.rb +822 -51
- data/lib/permittable/json_schema/ecma_pattern.rb +248 -0
- data/lib/permittable/json_schema.rb +252 -47
- data/lib/permittable/open_api.rb +247 -42
- data/lib/permittable/railtie.rb +1 -0
- data/lib/permittable/rspec.rb +451 -25
- data/lib/permittable/tasks/audit.rake +34 -0
- data/lib/permittable/tasks/generate.rake +3 -2
- data/lib/permittable/version.rb +1 -1
- data/lib/permittable.rb +985 -116
- metadata +9 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
|
|
3
|
+
module Permittable
|
|
4
|
+
module JsonSchema
|
|
5
|
+
# Ruby Regexp source → an ECMA-262 `pattern` that compiles under the `u`
|
|
6
|
+
# flag, which is how Ajv — the validator most clients reach for — builds
|
|
7
|
+
# every pattern (`new RegExp(pattern, "u")`). Unicode mode is strict where
|
|
8
|
+
# Ruby is forgiving: `\#`, `\-` and `\ ` are identity escapes Ruby
|
|
9
|
+
# accepts and Unicode mode rejects, as are a bare `{`, a quantified
|
|
10
|
+
# lookahead, and `\x7`. Other constructs compile but MEAN something else
|
|
11
|
+
# there — `{,3}` is a literal without the flag, `&&` two ampersands — so
|
|
12
|
+
# a source that merely compiles is not yet a translation.
|
|
13
|
+
#
|
|
14
|
+
# Hence one tokenizer pass rather than a scan and a few gsubs: it reads
|
|
15
|
+
# escape pairs and character classes as units (so `\\A` stays a literal
|
|
16
|
+
# backslash and A, and `*+` inside a class is two literals rather than a
|
|
17
|
+
# possessive quantifier), and it is a whitelist — every token is either
|
|
18
|
+
# rewritten into something both engines read identically or the whole
|
|
19
|
+
# source is refused, and a refusal exports the regexp as
|
|
20
|
+
# x-permittable-pattern. A construct nobody anticipated is therefore
|
|
21
|
+
# refused rather than published, because a wrong pattern in published
|
|
22
|
+
# docs is worse than a missing one.
|
|
23
|
+
#
|
|
24
|
+
# Refused on purpose, each for a reason ECMA-262 cannot carry:
|
|
25
|
+
# * Ruby's ^ and $, which anchor a LINE where ECMA-262 without the m
|
|
26
|
+
# flag anchors the whole string: /^\d{5}$/ accepts "evil\n12345" at
|
|
27
|
+
# runtime. \A and \z translate exactly.
|
|
28
|
+
# * \Z \h \K \R \G \e \g and the other Ruby-only escapes, and octal
|
|
29
|
+
# escapes, which Unicode mode rejects.
|
|
30
|
+
# * \b and \B outside a class. Ruby's word boundary counts a non-ASCII
|
|
31
|
+
# letter as a word character (its \w does not) and Unicode mode's is
|
|
32
|
+
# ASCII-only, so /\Aa\b/ rejects "a\u00e9" at runtime while ^a\b
|
|
33
|
+
# accepts it, and \B diverges the other way.
|
|
34
|
+
# * Backreferences. One to a group that took no part FAILS in Ruby and
|
|
35
|
+
# matches "" in ECMA-262 — /(a)?\1b/ rejects "b" at runtime and its
|
|
36
|
+
# translation would accept it — and \12 is a backreference or an
|
|
37
|
+
# octal escape depending on how many groups precede it.
|
|
38
|
+
# * Atomic, comment, quoted-name, flag, absence and conditional groups.
|
|
39
|
+
# * Possessive and stacked quantifiers, `{,n}`, and `{n}?`, which is
|
|
40
|
+
# "{n}, optionally" in Ruby and a lazy — so still exact — {n} in
|
|
41
|
+
# ECMA-262.
|
|
42
|
+
# * `\p{...}`, whose property names differ between the two.
|
|
43
|
+
# * Nested classes, POSIX brackets and `&&`; \S in a class beside
|
|
44
|
+
# anything but \s.
|
|
45
|
+
# * A repeated group name, a SyntaxError in Unicode mode before ES2025.
|
|
46
|
+
module EcmaPattern
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Ruby's \s, as ECMA-262 escapes. Ruby's \s is ASCII-only; ECMA-262's
|
|
50
|
+
# also matches NBSP, U+2028, U+FEFF and every other Unicode space, so
|
|
51
|
+
# publishing \s verbatim would document a field accepting what the
|
|
52
|
+
# server rejects. Spelled as escapes so a class can splice it in.
|
|
53
|
+
SPACE = ' \t\n\v\f\r'.freeze
|
|
54
|
+
|
|
55
|
+
# Escapes that name the same single character in both dialects, inside
|
|
56
|
+
# a class or out. `\0` only on its own: followed by a digit it is octal
|
|
57
|
+
# in Ruby and a SyntaxError in Unicode mode. `\x` only with two digits,
|
|
58
|
+
# where Ruby also accepts one.
|
|
59
|
+
CHARACTER_ESCAPE = /[tnrfv]|0(?!\d)|x\h{2}|u\h{4}|u\{\h{1,6}\}/
|
|
60
|
+
|
|
61
|
+
# ECMA-262's syntax characters (and `/`): the only identity escapes
|
|
62
|
+
# Unicode mode allows, and the way a literal one has to be spelled.
|
|
63
|
+
SYNTAX_ESCAPE = %r{[\^$\\.*+?()\[\]{}|/]}
|
|
64
|
+
|
|
65
|
+
# A group name both dialects accept. ASCII only; a non-ASCII name makes
|
|
66
|
+
# the regexp encoding-fixed, which ecma_pattern refuses before this.
|
|
67
|
+
GROUP_NAME = /[A-Za-z_]\w*/
|
|
68
|
+
|
|
69
|
+
# nil when the source uses anything that cannot be carried faithfully.
|
|
70
|
+
def translate(source)
|
|
71
|
+
scanner = StringScanner.new(source)
|
|
72
|
+
out = +""
|
|
73
|
+
quantifiable = false # can the token just emitted take a quantifier?
|
|
74
|
+
lookaround = [] # per open group: is it a lookaround?
|
|
75
|
+
names = []
|
|
76
|
+
until scanner.eos?
|
|
77
|
+
if scanner.skip(/\\/)
|
|
78
|
+
text, quantifiable = escape(scanner)
|
|
79
|
+
elsif scanner.skip(/\[/)
|
|
80
|
+
text = char_class(scanner)
|
|
81
|
+
quantifiable = true
|
|
82
|
+
elsif scanner.scan(/\(\?<(#{GROUP_NAME})>/o)
|
|
83
|
+
# Unicode mode (before ES2025) rejects a repeated name; Ruby allows it.
|
|
84
|
+
return nil if names.include?(scanner[1])
|
|
85
|
+
|
|
86
|
+
names << scanner[1]
|
|
87
|
+
lookaround << false
|
|
88
|
+
text = scanner.matched
|
|
89
|
+
quantifiable = false
|
|
90
|
+
elsif scanner.scan(/\((?!\?)|\(\?(?:[:=!]|<[=!])/)
|
|
91
|
+
lookaround << scanner.matched.end_with?("=", "!")
|
|
92
|
+
text = scanner.matched
|
|
93
|
+
quantifiable = false
|
|
94
|
+
elsif scanner.skip(/\)/)
|
|
95
|
+
return nil if lookaround.empty?
|
|
96
|
+
|
|
97
|
+
text = ")"
|
|
98
|
+
# A quantified lookaround is a SyntaxError in Unicode mode.
|
|
99
|
+
quantifiable = !lookaround.pop
|
|
100
|
+
elsif scanner.scan(/[*+?]|\{\d+(?:,\d*)?\}/)
|
|
101
|
+
# A quantifier on a quantifier is possessive (`*+`) or nested
|
|
102
|
+
# (`a{2}{3}`) in Ruby and a SyntaxError in ECMA-262; one on an
|
|
103
|
+
# assertion or a group opening is a SyntaxError there too.
|
|
104
|
+
return nil unless quantifiable
|
|
105
|
+
|
|
106
|
+
text = scanner.matched
|
|
107
|
+
if scanner.skip(/\?/)
|
|
108
|
+
return nil if text.start_with?("{") && !text.include?(",")
|
|
109
|
+
|
|
110
|
+
text += "?"
|
|
111
|
+
end
|
|
112
|
+
quantifiable = false
|
|
113
|
+
elsif scanner.check(/\(\?|\{,\d+\}|[\^$]/)
|
|
114
|
+
return nil
|
|
115
|
+
elsif scanner.skip(/\./)
|
|
116
|
+
# Ruby's dot stops only at \n; ECMA-262's (without the s flag)
|
|
117
|
+
# also at \r, U+2028 and U+2029.
|
|
118
|
+
text = '[^\n]'
|
|
119
|
+
quantifiable = true
|
|
120
|
+
elsif scanner.skip(/\|/)
|
|
121
|
+
text = "|"
|
|
122
|
+
quantifiable = false
|
|
123
|
+
elsif scanner.scan(/[{}\]]/)
|
|
124
|
+
# Literal in Ruby when they form no quantifier or class; bare,
|
|
125
|
+
# each is a SyntaxError in Unicode mode.
|
|
126
|
+
text = "\\#{scanner.matched}"
|
|
127
|
+
quantifiable = true
|
|
128
|
+
else
|
|
129
|
+
text = scanner.getch
|
|
130
|
+
quantifiable = true
|
|
131
|
+
end
|
|
132
|
+
return nil unless text
|
|
133
|
+
|
|
134
|
+
out << text
|
|
135
|
+
end
|
|
136
|
+
out
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# An escape outside a class, the backslash already consumed: the
|
|
140
|
+
# translated text and whether a quantifier may follow it.
|
|
141
|
+
def escape(scanner)
|
|
142
|
+
if scanner.skip(/A/) then ["^", false]
|
|
143
|
+
elsif scanner.skip(/z/) then ["$", false]
|
|
144
|
+
elsif scanner.skip(/s/) then ["[#{SPACE}]", true]
|
|
145
|
+
elsif scanner.skip(/S/) then ["[^#{SPACE}]", true]
|
|
146
|
+
elsif scanner.scan(/[dDwW]/) then ["\\#{scanner.matched}", true]
|
|
147
|
+
else [literal_escape(scanner), true]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The rest of a class, `[` already consumed. Ranges are only carried
|
|
152
|
+
# between two single characters: `[\w-z]` is a RegexpError in Ruby on
|
|
153
|
+
# either side of the hyphen (a class escape cannot end or start a
|
|
154
|
+
# range), so that source can never reach this method as a real Regexp;
|
|
155
|
+
# the refusal for it below is a defensive backstop, not something a
|
|
156
|
+
# live disagreement between the two engines depends on. A hyphen right
|
|
157
|
+
# after a just-closed range, `[a-c-e]`, is NOT a different range in
|
|
158
|
+
# Unicode mode — both engines read it as the set {a, b, c, -, e},
|
|
159
|
+
# rejecting "d" — so it is carried as a literal member, which may
|
|
160
|
+
# itself reopen a range (`[a-z--x]`, Ruby's own reading of a second
|
|
161
|
+
# hyphen after the first).
|
|
162
|
+
def char_class(scanner)
|
|
163
|
+
negated = scanner.skip(/\^/)
|
|
164
|
+
# A leading ] is a literal in Ruby and ends an empty class in ECMA-262.
|
|
165
|
+
return nil if scanner.check(/\]/)
|
|
166
|
+
|
|
167
|
+
members = +""
|
|
168
|
+
count = 0
|
|
169
|
+
space = complement = false
|
|
170
|
+
previous = nil # :char, :set (a class escape like \d) or :range
|
|
171
|
+
until scanner.skip(/\]/)
|
|
172
|
+
# A nested class or POSIX bracket, or an intersection.
|
|
173
|
+
return nil if scanner.eos? || scanner.check(/\[|&&/)
|
|
174
|
+
|
|
175
|
+
if previous == :range && scanner.check(/-(?!\])/)
|
|
176
|
+
# A range cannot start from another range's own end: both
|
|
177
|
+
# engines read this hyphen as an ordinary member, not a new
|
|
178
|
+
# range operator. It may itself reopen a range on the next
|
|
179
|
+
# iteration, exactly as Ruby reads a repeated hyphen.
|
|
180
|
+
scanner.skip(/-/)
|
|
181
|
+
members << "-"
|
|
182
|
+
previous = :char
|
|
183
|
+
elsif previous && scanner.check(/-(?!\])/)
|
|
184
|
+
return nil unless previous == :char
|
|
185
|
+
|
|
186
|
+
scanner.skip(/-/)
|
|
187
|
+
# Checked again past the hyphen: `[$-&&%]` is an (empty)
|
|
188
|
+
# intersection in Ruby and a class accepting "%" in ECMA-262.
|
|
189
|
+
return nil if scanner.check(/\[|&&/)
|
|
190
|
+
|
|
191
|
+
text, kind = class_atom(scanner)
|
|
192
|
+
return nil unless kind == :char
|
|
193
|
+
|
|
194
|
+
members << "-" << text
|
|
195
|
+
previous = :range
|
|
196
|
+
elsif scanner.skip(/\\S/)
|
|
197
|
+
complement = true
|
|
198
|
+
previous = :set
|
|
199
|
+
else
|
|
200
|
+
text, previous = class_atom(scanner)
|
|
201
|
+
return nil unless text
|
|
202
|
+
|
|
203
|
+
space ||= text == SPACE
|
|
204
|
+
members << text
|
|
205
|
+
end
|
|
206
|
+
count += 1
|
|
207
|
+
end
|
|
208
|
+
return "[#{'^' if negated}#{members}]" unless complement
|
|
209
|
+
|
|
210
|
+
# \S cannot be spliced in as members, but two classes containing it
|
|
211
|
+
# can be written exactly. With \s beside it the class covers every
|
|
212
|
+
# character whatever either escape means, so ECMA-262's wider \s is
|
|
213
|
+
# harmless there — the `[\s\S]` any-character idiom. On its own it is
|
|
214
|
+
# the complement of Ruby's \s. Beside anything else it is refused.
|
|
215
|
+
return negated ? '[^\s\S]' : '[\s\S]' if space
|
|
216
|
+
return nil unless count == 1
|
|
217
|
+
|
|
218
|
+
negated ? "[#{SPACE}]" : "[^#{SPACE}]"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# One member of a class: its text and :char or :set.
|
|
222
|
+
def class_atom(scanner)
|
|
223
|
+
return [scanner.getch, :char] unless scanner.skip(/\\/)
|
|
224
|
+
|
|
225
|
+
if scanner.skip(/s/) then [SPACE, :set]
|
|
226
|
+
elsif scanner.scan(/[dDwW]/) then ["\\#{scanner.matched}", :set]
|
|
227
|
+
# Both an escaped - and a backspace \b are valid in a Unicode-mode
|
|
228
|
+
# class, and mean what they mean in Ruby.
|
|
229
|
+
elsif scanner.scan(/[-b]/) then ["\\#{scanner.matched}", :char]
|
|
230
|
+
else
|
|
231
|
+
text = literal_escape(scanner)
|
|
232
|
+
[text, :char] if text
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# An escape that names one character. A letter or digit not listed is
|
|
237
|
+
# a Ruby-only escape (or octal) and refuses the translation; any other
|
|
238
|
+
# character is an identity escape, which Unicode mode accepts only for
|
|
239
|
+
# syntax characters — so those stay escaped and the rest (`\#`, `\-`
|
|
240
|
+
# outside a class, `\ `, `\_`) are written bare, meaning the same thing.
|
|
241
|
+
def literal_escape(scanner)
|
|
242
|
+
if scanner.scan(CHARACTER_ESCAPE) || scanner.scan(SYNTAX_ESCAPE) then "\\#{scanner.matched}"
|
|
243
|
+
elsif scanner.scan(/[^A-Za-z0-9]/m) then scanner.matched
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "permittable/json_schema/ecma_pattern"
|
|
2
|
+
|
|
1
3
|
module Permittable
|
|
2
4
|
# Converts frozen contract data — the rule and field hashes built by
|
|
3
5
|
# ContractBuilder — into JSON Schema (draft 2020-12, the dialect OpenAPI 3.1
|
|
@@ -7,13 +9,41 @@ module Permittable
|
|
|
7
9
|
# actually enforces.
|
|
8
10
|
#
|
|
9
11
|
# The exported schema describes the DECLARED INPUT SHAPE in its canonical
|
|
10
|
-
# JSON encoding.
|
|
12
|
+
# JSON encoding. Three deliberate consequences:
|
|
11
13
|
# * Coercion additionally accepts string-encoded scalars ("42", "true")
|
|
12
14
|
# for form/query payloads; the schema documents the JSON types only.
|
|
15
|
+
# * Coercion reads an explicit `null` as ABSENCE, so `{"age": null}` means
|
|
16
|
+
# the same as `{}`. JSON Schema cannot say that — `type: integer` reads
|
|
17
|
+
# null as a present value of the wrong type — so the schema rejects a
|
|
18
|
+
# null the server would accept and ignore.
|
|
13
19
|
# * `validate:`/`transform:`/`finalize` are opaque callables — they never
|
|
14
20
|
# change what a client may SEND, so fields carrying them are flagged
|
|
15
21
|
# with `x-permittable-*` extensions rather than mistranslated.
|
|
16
22
|
#
|
|
23
|
+
# All three leave the schema STRICTER than the server, never looser: a
|
|
24
|
+
# client that validates against the published document is conservative,
|
|
25
|
+
# never surprised by a 422. spec/schema_conformance_spec.rb holds that line,
|
|
26
|
+
# asserting the direction of every divergence it permits.
|
|
27
|
+
#
|
|
28
|
+
# Where a rule has no JSON Schema keyword, the schema is LOOSER instead,
|
|
29
|
+
# and a client following it can still earn a 422. Each such rule stays
|
|
30
|
+
# visible on its own field, and the conformance spec labels each one
|
|
31
|
+
# rather than letting it pass as agreement:
|
|
32
|
+
# * a `:json` field's `max_depth:` — `x-permittable-max-depth`;
|
|
33
|
+
# * a Range of non-numbers (`in: "a".."m"`) — `x-permittable-range`;
|
|
34
|
+
# * a `validate:` proc — `x-permittable-custom-validation`;
|
|
35
|
+
# * a `normalize:` step, which the server runs BEFORE it checks, so
|
|
36
|
+
# " " passes a minLength of 3 and is then absent —
|
|
37
|
+
# `x-permittable-normalize`;
|
|
38
|
+
# * the string encoding of :decimal, :date and :datetime, whose validity
|
|
39
|
+
# rests on `format`, an annotation in draft 2020-12 — so "abc", "NaN"
|
|
40
|
+
# and "2026-02-30" pass the schema and fail the cast;
|
|
41
|
+
# * the string encoding of a BOUNDED :decimal, which minimum/maximum
|
|
42
|
+
# (number-only keywords) never see — the numeric bound is published
|
|
43
|
+
# for a client that parses first.
|
|
44
|
+
# (A `normalize:` step also runs the safe way: " abcdefghij " is too
|
|
45
|
+
# long for a maxLength of 10 and fine once squished.)
|
|
46
|
+
#
|
|
17
47
|
# Emission is deterministic (fixed key insertion order, declaration-order
|
|
18
48
|
# properties) so generated documents are committable and diff-stable.
|
|
19
49
|
module JsonSchema
|
|
@@ -31,27 +61,6 @@ module Permittable
|
|
|
31
61
|
datetime: { "type" => "string", "format" => "date-time" }
|
|
32
62
|
}.freeze
|
|
33
63
|
|
|
34
|
-
# Ruby regexp constructs with no ECMA-262 equivalent (\Z, \h, \K, \R, \G,
|
|
35
|
-
# inline flag groups, absence operator, conditionals, POSIX classes,
|
|
36
|
-
# possessive quantifiers) — and Ruby's ^ and $, which anchor a LINE where
|
|
37
|
-
# ECMA-262 without the m flag anchors the whole string. /^\d{5}$/ accepts
|
|
38
|
-
# "evil\n12345" at runtime, so emitting its source as `pattern` would
|
|
39
|
-
# publish a rule stricter than the server enforces, and an export from
|
|
40
|
-
# contract data is supposed to make that impossible. Straight after a [
|
|
41
|
-
# neither is an anchor — ^ is class negation and $ is a literal — so both
|
|
42
|
-
# stay translatable there. Otherwise the scan is deliberately over-eager
|
|
43
|
-
# on escaped lookalikes, because a wrong pattern in published docs is
|
|
44
|
-
# worse than a missing one.
|
|
45
|
-
UNTRANSLATABLE = /
|
|
46
|
-
\\[ZhHKRG] |
|
|
47
|
-
\(\?[a-z-]+[:)] |
|
|
48
|
-
\(\?~ |
|
|
49
|
-
\(\?\( |
|
|
50
|
-
\[\[: |
|
|
51
|
-
[*+?]\+ |
|
|
52
|
-
(?<![\\\[])[\^$]
|
|
53
|
-
/x
|
|
54
|
-
|
|
55
64
|
# Request-body schema for one rule from `permittable_contracts` /
|
|
56
65
|
# `permit_rule_for`: the object schema of its fields, wrapped in the
|
|
57
66
|
# `root:` envelope when the rule declares one. The wrapper itself stays
|
|
@@ -105,9 +114,18 @@ module Permittable
|
|
|
105
114
|
schema
|
|
106
115
|
end
|
|
107
116
|
|
|
117
|
+
# deep_dup, not dup: .freeze is shallow, so the Array nested in an entry
|
|
118
|
+
# like :decimal ("type" => %w[string number]) stays live inside the frozen
|
|
119
|
+
# top-level Hash, and a shallow .dup would hand every :decimal field's
|
|
120
|
+
# exported schema that SAME Array. Nothing in this file mutates it in
|
|
121
|
+
# place (nullify! rebinds "type" to a new Array), but the exported
|
|
122
|
+
# document is caller-owned data, and a caller appending to it would
|
|
123
|
+
# otherwise silently rewrite the constant for every schema exported
|
|
124
|
+
# afterward in the process.
|
|
108
125
|
def scalar_schema(field)
|
|
109
|
-
schema = SCALAR_SCHEMAS.fetch(field[:type]).
|
|
110
|
-
|
|
126
|
+
schema = SCALAR_SCHEMAS.fetch(field[:type]).deep_dup
|
|
127
|
+
apply_format_name!(schema, field)
|
|
128
|
+
apply_in!(schema, field)
|
|
111
129
|
apply_string_bounds!(schema, field)
|
|
112
130
|
apply_pattern!(schema, field[:format])
|
|
113
131
|
schema
|
|
@@ -126,20 +144,48 @@ module Permittable
|
|
|
126
144
|
schema
|
|
127
145
|
end
|
|
128
146
|
|
|
147
|
+
# A `format:` preset also names the JSON Schema `format` keyword the
|
|
148
|
+
# ecosystem understands, which a hand-written Regexp cannot. `pattern` is
|
|
149
|
+
# still emitted next to it: in draft 2020-12 `format` is an annotation
|
|
150
|
+
# unless a validator opts into asserting it, so the pattern is what
|
|
151
|
+
# actually enforces.
|
|
152
|
+
def apply_format_name!(schema, field)
|
|
153
|
+
json = FORMATS.dig(field[:format_name], :json)
|
|
154
|
+
schema["format"] = json if json
|
|
155
|
+
end
|
|
156
|
+
|
|
129
157
|
def array_schema(field, unknown:)
|
|
130
158
|
schema = { "type" => "array" }
|
|
131
159
|
min, max = length_bounds(field[:length])
|
|
132
160
|
schema["minItems"] = min if min
|
|
133
161
|
schema["maxItems"] = max if max
|
|
134
|
-
|
|
162
|
+
# deep_dup here for the same reason as scalar_schema: a scalar `of:`
|
|
163
|
+
# otherwise hands every array field the SAME nested Array/Hash from
|
|
164
|
+
# SCALAR_SCHEMAS.
|
|
165
|
+
schema["items"] =
|
|
166
|
+
field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).deep_dup
|
|
135
167
|
schema
|
|
136
168
|
end
|
|
137
169
|
|
|
138
|
-
|
|
170
|
+
# A list is stored cast by the field's type, so its enum is what the
|
|
171
|
+
# runtime compares against; `in_published` overrides the members an
|
|
172
|
+
# exact re-encoding would get wrong (see Coercion.published_in_member).
|
|
173
|
+
# An object that only answers include? says nothing a schema can list —
|
|
174
|
+
# annotate flags it as custom validation instead.
|
|
175
|
+
def apply_in!(schema, field)
|
|
176
|
+
allowed = field[:in]
|
|
139
177
|
return unless allowed
|
|
178
|
+
return if opaque_in?(allowed)
|
|
140
179
|
|
|
141
180
|
unless allowed.is_a?(Range)
|
|
142
|
-
|
|
181
|
+
# The same numeric-vs-string rule default:/example: use (decimal_json)
|
|
182
|
+
# applies here too — a :decimal in: member is otherwise always
|
|
183
|
+
# exported as a string, so a numerically-exported default: is no
|
|
184
|
+
# longer a member of its own enum's exported list. in_published (a
|
|
185
|
+
# :date/:datetime member kept in its authored String form, see
|
|
186
|
+
# Coercion.published_in_member) takes precedence over the cast
|
|
187
|
+
# members when both apply.
|
|
188
|
+
schema["enum"] = (field[:in_published] || allowed).map { |v| json_value(v, decimal: :number) }
|
|
143
189
|
return
|
|
144
190
|
end
|
|
145
191
|
# Runtime bounds-checks Ranges with cover?; numeric endpoints map onto
|
|
@@ -149,8 +195,102 @@ module Permittable
|
|
|
149
195
|
schema["x-permittable-range"] = allowed.inspect
|
|
150
196
|
return
|
|
151
197
|
end
|
|
152
|
-
|
|
153
|
-
|
|
198
|
+
type = field[:type]
|
|
199
|
+
min = json_bound(allowed, "minimum", type) if allowed.begin
|
|
200
|
+
schema["minimum"] = min if min
|
|
201
|
+
keyword = allowed.exclude_end? ? "exclusiveMaximum" : "maximum"
|
|
202
|
+
max = json_bound(allowed, keyword, type) if allowed.end
|
|
203
|
+
schema[keyword] = max if max
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The types whose cast turns a JSON number into the value `in:` compares,
|
|
207
|
+
# so a published bound can be checked against the server's own verdict.
|
|
208
|
+
NUMERIC_TYPES = %i[integer float decimal].freeze
|
|
209
|
+
|
|
210
|
+
# minimum/maximum must be JSON numbers — the metaschema says so — so a
|
|
211
|
+
# bound is NOT an authored value for json_value, which renders a
|
|
212
|
+
# BigDecimal as its precision-safe string and made `in:
|
|
213
|
+
# BigDecimal("0.01")..BigDecimal("999.99")` publish an invalid document.
|
|
214
|
+
# Integer and Float pass through; any other Numeric (BigDecimal,
|
|
215
|
+
# Rational) becomes an Integer when it is one, else a Float.
|
|
216
|
+
#
|
|
217
|
+
# An infinite endpoint (Float::INFINITY, BigDecimal("Infinity")) means
|
|
218
|
+
# "no bound", and neither it nor NaN — which compares to nothing — is a
|
|
219
|
+
# JSON number, so both are omitted: nil. (to_i on either raises, which
|
|
220
|
+
# used to take the whole export down with it.)
|
|
221
|
+
#
|
|
222
|
+
# A Float cannot hold every decimal. to_f rounds to the NEAREST double,
|
|
223
|
+
# which can land on the wrong side of the bound: 0.1000000000000000001
|
|
224
|
+
# becomes 0.1, and a client sending 0.1 passes `minimum: 0.1` and is then
|
|
225
|
+
# refused. How far is "wrong" depends on the field's TYPE, not on the
|
|
226
|
+
# bound: a :decimal reads the number back as BigDecimal("0.1") and
|
|
227
|
+
# compares exactly, while a :float compares the Float through
|
|
228
|
+
# BigDecimal#<=>, which reads it at limited precision and so needs a few
|
|
229
|
+
# doubles more. Rather than model either, the bound asks the server:
|
|
230
|
+
# while the most extreme value the published keyword admits would be
|
|
231
|
+
# refused by the field's own cast and comparison, the bound moves one
|
|
232
|
+
# double INWARD. The published range can then only be narrower than the
|
|
233
|
+
# enforced one — the safe direction — and stops at the first double the
|
|
234
|
+
# server accepts. (It never moves outward: where a lossy comparison
|
|
235
|
+
# would also accept a few doubles beyond the nearest one, those stay
|
|
236
|
+
# unpublished.) A decimal of up to 15 significant digits — every price —
|
|
237
|
+
# round-trips through a double, so it is emitted as written.
|
|
238
|
+
#
|
|
239
|
+
# An INTEGRAL bound past Float::MAX (`10**400`) needs none of this: a
|
|
240
|
+
# JSON number literal has no size limit, so it is exact as published,
|
|
241
|
+
# with no Float rounding to guard against in the first place. Asking the
|
|
242
|
+
# server would instead break it — the field's own cast runs the bound
|
|
243
|
+
# through `to_f`, which overflows a value this large to Infinity — so
|
|
244
|
+
# the loop below walked the bound to Infinity and dropped it, though
|
|
245
|
+
# master published it as `minimum: 10**400` outright. It is returned
|
|
246
|
+
# here before the loop runs. A FRACTIONAL bound past Float::MAX has no
|
|
247
|
+
# such escape (there is no arbitrary-precision JSON number this exporter
|
|
248
|
+
# emits without going through Float) and stays omitted, same as a
|
|
249
|
+
# genuinely infinite bound — see CHANGELOG.
|
|
250
|
+
def json_bound(range, keyword, type)
|
|
251
|
+
value = keyword == "minimum" ? range.begin : range.end
|
|
252
|
+
return nil unless value.finite?
|
|
253
|
+
|
|
254
|
+
bound = value.is_a?(Float) || value != value.to_i ? value.to_f : value.to_i
|
|
255
|
+
return bound if bound.is_a?(Integer) && !bound.to_f.finite?
|
|
256
|
+
|
|
257
|
+
step = keyword == "minimum" ? :next_float : :prev_float
|
|
258
|
+
# A fractional bound past Float::MAX converts to Infinity, which no
|
|
259
|
+
# step moves; it is then as unrepresentable as an infinite one.
|
|
260
|
+
bound = bound.to_f.public_send(step) until !bound.finite? || honoured?(range, keyword, type, bound)
|
|
261
|
+
bound if bound.finite?
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Would the server accept the most extreme value `keyword: bound`
|
|
265
|
+
# admits? Only the one side is asked — a range narrower than a double
|
|
266
|
+
# can span admits no double at all, and checking both ends would never
|
|
267
|
+
# settle. A non-numeric field type has no cast to ask, so its bound is
|
|
268
|
+
# published as converted.
|
|
269
|
+
def honoured?(range, keyword, type, bound)
|
|
270
|
+
return true unless NUMERIC_TYPES.include?(type)
|
|
271
|
+
|
|
272
|
+
side = keyword == "minimum" ? (range.begin..) : Range.new(nil, range.end, range.exclude_end?)
|
|
273
|
+
status, value = Coercion.cast(type, admitted_extreme(keyword, type, bound))
|
|
274
|
+
status == :ok && side.cover?(value)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The value nearest the bound that the published keyword still lets
|
|
278
|
+
# through: the bound itself for minimum/maximum, the double (or, on an
|
|
279
|
+
# :integer field, the integer) just below it for exclusiveMaximum.
|
|
280
|
+
def admitted_extreme(keyword, type, bound)
|
|
281
|
+
if type == :integer
|
|
282
|
+
return bound.ceil if keyword == "minimum"
|
|
283
|
+
return bound.floor if keyword == "maximum"
|
|
284
|
+
|
|
285
|
+
return bound.ceil - 1
|
|
286
|
+
end
|
|
287
|
+
keyword == "exclusiveMaximum" ? bound.to_f.prev_float : bound
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# A host's own include?-answering object, kept by the contract as given —
|
|
291
|
+
# the same predicate the contract used to decide it was not a list.
|
|
292
|
+
def opaque_in?(allowed)
|
|
293
|
+
!allowed.nil? && !allowed.is_a?(Range) && Coercion.in_list(allowed).nil?
|
|
154
294
|
end
|
|
155
295
|
|
|
156
296
|
def apply_string_bounds!(schema, field)
|
|
@@ -175,17 +315,19 @@ module Permittable
|
|
|
175
315
|
end
|
|
176
316
|
end
|
|
177
317
|
|
|
178
|
-
#
|
|
179
|
-
# Flagged regexps bail entirely (JSON Schema's `pattern` has
|
|
180
|
-
# slot, and /x//m/i all change semantics)
|
|
181
|
-
#
|
|
318
|
+
# Ruby → ECMA-262 translation, conservative by construction — see
|
|
319
|
+
# EcmaPattern. Flagged regexps bail entirely (JSON Schema's `pattern` has
|
|
320
|
+
# no flag slot, and /x//m/i all change semantics).
|
|
321
|
+
#
|
|
322
|
+
# A `format:` preset goes through the same translation as an app's own
|
|
323
|
+
# regexp and needs no exemption: the tokenizer reads a class as a unit,
|
|
324
|
+
# so the `*+` inside :email's class is two literals rather than a
|
|
325
|
+
# possessive quantifier, and :email's `\#` is written as the bare `#`
|
|
326
|
+
# Unicode mode requires.
|
|
182
327
|
def ecma_pattern(regexp)
|
|
183
328
|
return nil unless regexp.options.zero?
|
|
184
329
|
|
|
185
|
-
|
|
186
|
-
return nil if source.match?(UNTRANSLATABLE)
|
|
187
|
-
|
|
188
|
-
source.gsub('\A', "^").gsub('\z', "$")
|
|
330
|
+
EcmaPattern.translate(regexp.source)
|
|
189
331
|
end
|
|
190
332
|
|
|
191
333
|
# length: reasons about characters on strings and element count on
|
|
@@ -202,35 +344,98 @@ module Permittable
|
|
|
202
344
|
end
|
|
203
345
|
|
|
204
346
|
# Documentation keys shared by every field kind. `default:`/`example:`
|
|
205
|
-
# are
|
|
347
|
+
# are stored as the contract casts them (Date, Time, BigDecimal), so they
|
|
206
348
|
# are re-encoded as JSON scalars.
|
|
349
|
+
#
|
|
350
|
+
# The :decimal numeric-export rule (decimal_json) is scoped to a
|
|
351
|
+
# :decimal field's OWN value and never recurses into a :json field's
|
|
352
|
+
# contents: those are opaque and pass through uncast, so a BigDecimal
|
|
353
|
+
# found inside one is documented the same way it always was — a string,
|
|
354
|
+
# via BigDecimal#to_s("F") — rather than reinterpreted as a JSON number
|
|
355
|
+
# (silently changing a value outside any :decimal schema's justification)
|
|
356
|
+
# or coerced through Float, where a non-finite BigDecimal (Infinity, NaN)
|
|
357
|
+
# would crash JSON.generate.
|
|
358
|
+
#
|
|
359
|
+
# A `sensitive:` field's `default:`/`example:` are OMITTED rather than
|
|
360
|
+
# published: this exported document is the one channel meant to leave
|
|
361
|
+
# the app (client-generator tooling, a public docs endpoint), unlike the
|
|
362
|
+
# request log a `sensitive:` value is otherwise only redacted from, so
|
|
363
|
+
# shipping the real value here would defeat the redaction entirely.
|
|
364
|
+
# Omitting — rather than a placeholder string — matches how every other
|
|
365
|
+
# untranslatable or opaque fact in this file is handled: left out, with
|
|
366
|
+
# the `x-permittable-*` extension (here, `writeOnly`/`x-permittable-
|
|
367
|
+
# sensitive`) as the only signal that something is missing.
|
|
207
368
|
def annotate(schema, field)
|
|
208
|
-
|
|
209
|
-
|
|
369
|
+
decimal_mode = field[:kind] == JSON_TYPE ? :string : :number
|
|
370
|
+
unless field[:sensitive]
|
|
371
|
+
schema["default"] = json_value(field[:default], decimal: decimal_mode) if field.key?(:default)
|
|
372
|
+
schema["examples"] = [json_value(field[:example], decimal: decimal_mode)] if field.key?(:example)
|
|
373
|
+
end
|
|
210
374
|
schema["description"] = field[:desc] if field[:desc]
|
|
211
375
|
if field[:sensitive]
|
|
212
376
|
schema["writeOnly"] = true
|
|
213
377
|
schema["x-permittable-sensitive"] = true
|
|
214
378
|
end
|
|
215
|
-
schema["x-permittable-custom-validation"] = true if field[:validate]
|
|
379
|
+
schema["x-permittable-custom-validation"] = true if field[:validate] || opaque_in?(field[:in])
|
|
216
380
|
schema["x-permittable-transformed"] = true if field[:transform]
|
|
381
|
+
apply_normalize!(schema, field)
|
|
217
382
|
schema
|
|
218
383
|
end
|
|
219
384
|
|
|
220
|
-
|
|
385
|
+
# The server checks the NORMALIZED string, so minLength/maxLength/pattern
|
|
386
|
+
# describe a value the client never sends: under `normalize: :squish`,
|
|
387
|
+
# " " satisfies a minLength of 3 and then squishes to "" (absent), and
|
|
388
|
+
# " a " satisfies it and squishes to "a". JSON Schema has no keyword for
|
|
389
|
+
# "transform, then check", so the step is flagged rather than dropped —
|
|
390
|
+
# by the preset's name, which a client can apply itself, or `true` for a
|
|
391
|
+
# host proc, which is as opaque as `transform:`. The preset is recovered
|
|
392
|
+
# by identity from the resolved callable, because the contract stores the
|
|
393
|
+
# lambda it runs rather than the name it was declared by.
|
|
394
|
+
def apply_normalize!(schema, field)
|
|
395
|
+
normalizer = field[:normalize]
|
|
396
|
+
return unless normalizer
|
|
397
|
+
|
|
398
|
+
preset = NORMALIZERS.key(normalizer)
|
|
399
|
+
schema["x-permittable-normalize"] = preset ? preset.to_s : true
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def json_value(value, decimal: :string)
|
|
221
403
|
case value
|
|
222
|
-
when Array then value.map { |v| json_value(v) }
|
|
404
|
+
when Array then value.map { |v| json_value(v, decimal: decimal) }
|
|
223
405
|
# An authored `:json` default/example is a whole hash; its values get
|
|
224
406
|
# the same re-encoding as any other authored scalar.
|
|
225
|
-
when Hash then value.to_h { |k, v| [k.to_s, json_value(v)] }
|
|
226
|
-
when BigDecimal then value.to_s("F")
|
|
227
|
-
when Time then value.
|
|
407
|
+
when Hash then value.to_h { |k, v| [k.to_s, json_value(v, decimal: decimal)] }
|
|
408
|
+
when BigDecimal then decimal == :number ? decimal_json(value) : value.to_s("F")
|
|
409
|
+
when Time then exact_iso8601(value.getutc)
|
|
228
410
|
# DateTime subclasses Date, so it must match first.
|
|
229
|
-
when DateTime then value.to_time.
|
|
411
|
+
when DateTime then exact_iso8601(value.to_time.getutc)
|
|
230
412
|
when Date then value.iso8601
|
|
231
413
|
when Symbol then value.to_s
|
|
232
414
|
else value
|
|
233
415
|
end
|
|
234
416
|
end
|
|
417
|
+
|
|
418
|
+
# A :decimal default/example is stored cast — a BigDecimal, even when it
|
|
419
|
+
# was authored as `1.5` — and exporting every BigDecimal as a string
|
|
420
|
+
# turned the number such a default had always been published as into
|
|
421
|
+
# "1.5". So it is a JSON number whenever a Float carries it exactly (the
|
|
422
|
+
# Float's shortest text reads back as the same BigDecimal), which is what
|
|
423
|
+
# a JSON number is to most consumers anyway, and a string only when the
|
|
424
|
+
# precision would otherwise be lost. Both spellings are within the
|
|
425
|
+
# :decimal schema's own `["string", "number"]`.
|
|
426
|
+
def decimal_json(value)
|
|
427
|
+
float = value.to_f
|
|
428
|
+
BigDecimal(float.to_s) == value ? float : value.to_s("F")
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# iso8601 prints whole seconds unless told otherwise, and a sub-second
|
|
432
|
+
# instant re-encoded that way names a DIFFERENT instant — one an `in:`
|
|
433
|
+
# listing the original refuses. So as many fractional digits as the
|
|
434
|
+
# value has, up to the nanoseconds Time#nsec can report.
|
|
435
|
+
def exact_iso8601(time)
|
|
436
|
+
nsec = time.nsec
|
|
437
|
+
digits = nsec.zero? ? 0 : 9 - nsec.to_s.rjust(9, "0")[/0*\z/].length
|
|
438
|
+
time.iso8601(digits)
|
|
439
|
+
end
|
|
235
440
|
end
|
|
236
441
|
end
|