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.
@@ -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. Two deliberate consequences:
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]).dup
110
- apply_in!(schema, field[:in])
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
- schema["items"] = field[:fields] ? object(field[:fields], unknown: unknown) : SCALAR_SCHEMAS.fetch(field[:of]).dup
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
- def apply_in!(schema, allowed)
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
- schema["enum"] = allowed.map { |v| json_value(v) }
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
- schema["minimum"] = json_value(allowed.begin) if allowed.begin
153
- schema[allowed.exclude_end? ? "exclusiveMaximum" : "maximum"] = json_value(allowed.end) if allowed.end
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
- # Conservative Ruby → ECMA-262 translation: \A/\z anchors become ^/$.
179
- # Flagged regexps bail entirely (JSON Schema's `pattern` has no flag
180
- # slot, and /x//m/i all change semantics), as does any source containing
181
- # an untranslatable construct.
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
- source = regexp.source
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 authored values (possibly Date/Time/BigDecimal literals), so they
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
- schema["default"] = json_value(field[:default]) if field.key?(:default)
209
- schema["examples"] = [json_value(field[:example])] if field.key?(:example)
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
- def json_value(value)
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.utc.iso8601
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.utc.iso8601
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