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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ No behaviour changes: this release adds tests and documentation only.
- **`Permittable.error_format = :problem` — RFC 9457 problem details.** For a public API the standard shape for an error is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html), and the gem rendered only its own envelope (`ErrorEnvelope`'s own comment named this as the change it was waiting for). One app-wide setting now renders `application/problem+json` with `type` / `title` / `status` / `detail` / `instance` members and the field violations as the `errors` extension member — the identical `{ param:, code: }` entries (plus `message:` when the field declares one) the default envelope puts in `details`, so nothing about violation reporting changes, only the wrapper. `title` describes the problem **type** rather than the instance, so a missing `root:` reads "Malformed request" (400) and a field violation "Invalid parameters" (422); `status` is numeric, resolved without needing Rack for the statuses this gem raises; `instance` is the request path, omitted rather than guessed when the host cannot name one. `Permittable.problem_base_uri` gives each problem type a real URI, and until it is set `type` is RFC 9457's own default of `"about:blank"`. Choosing `:problem` deliberately **opts out of `render_error` delegation** — a host envelope and a problem document are two answers to the same question, and the explicit setting is the one honoured. The setting is app-wide rather than per-contract because the error format of an API is a property of the API.
- **Exported OpenAPI follows the configured error format.** With `:problem` set, the shared response components describe the problem schema under `application/problem+json` instead of the envelope under `application/json`. Unlike a rule's monitor mode — which the exporter reads only from contract data, never from runtime configuration — the error format has no per-contract declaration to read, and an export runs inside the app that made the setting, so reading it is what keeps the documented response shape from drifting from the rendered one.
Contracts that don't opt in are byte-for-byte unaffected: the default format is `:envelope` and the exported envelope schema is unchanged (the golden fixture still matches).
- **`format:` presets — `:email`, `:uuid`, `:url`, `:slug`, `:hostname`.** The regexps every app writes by hand, named once, mirroring how `normalize:` already works. `:email` is deliberately `URI::MailTo::EMAIL_REGEXP` *itself* — the regexp Rails apps already paste into their contracts — so adopting the preset cannot change which addresses an endpoint accepts. The rest avoid flags and Ruby-only constructs so they translate to ECMA-262 and export as real patterns. A preset name is resolved to its `Regexp` at class load, so request-time matching stays a plain `Regexp#match?` and an authored `default:`/`example:` is checked against the resolved pattern like any other; an unknown preset fails at class load listing the presets, and a `format:` that is neither a `Regexp` nor a preset name now fails too (previously a String was silently accepted and behaved as `String#match?`, which is not what anyone meant).
- **Presets export the JSON Schema `format` keyword**, which a hand-written Regexp cannot: `"format": "email"` / `"uuid"` / `"uri"` / `"hostname"`, alongside the `pattern` that still does the asserting (in draft 2020-12 `format` is an annotation unless a validator opts in). A preset's pattern is authored by this gem rather than by the app, so it skips the deliberately over-eager untranslatable-construct scan — it has to: the RFC-derived `:email` pattern contains `*+` inside a character class, which that scan reads as a possessive quantifier, so the most common format in Rails would otherwise have published no pattern at all. App-authored regexps keep the conservative treatment unchanged.
- **The RSpec matcher speaks both spellings**: `matching(:email)` asserts the preset by name, `matching(/re/)` the Regexp itself, and a mismatch says which of the two the contract declares.

### Changed
- **The compatibility range is now tested rather than asserted, and narrowed to what passes.** CI ran one combination — the newest of everything — while the gemspec advertised `activesupport >= 5.0, < 9`. Testing the range surfaced two real problems. On **activesupport 5.0 and 5.1 a contract cannot be declared at all**: the registry is a `class_attribute ... default: []`, and `default:` arrived in Rails 5.2, so `permit_params` died on `NoMethodError: undefined method '+' for nil`. And on **activesupport ≤ 7.0.8.4, `require "permittable"` itself raised** `NameError: uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger`, because concurrent-ruby 1.3.5 stopped requiring `logger` for them. The floor is now **`>= 6.1`** — the oldest line the full suite is run against — and the load failure is fixed with one stdlib `require "logger"` ahead of `require "active_support"`, so the gem loads whatever the host's own boot order.
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ Adopting on an existing API with live traffic? Skip ahead to [Adopting on a live
- [Declaring a contract](#declaring-a-contract)
- [The field DSL](#the-field-dsl)
- [Field options](#field-options)
- [`format:` presets](#format-presets)
- [Types and strict coercion](#types-and-strict-coercion)
- [Free-form hashes](#free-form-hashes-json)
- [Absence, defaults, and partial updates](#absence-defaults-and-partial-updates)
Expand Down Expand Up @@ -292,7 +293,7 @@ Which options are legal depends on the field kind — anything else raises at cl
| Option | Scalar | Array | Nested | Meaning |
|---|:---:|:---:|:---:|---|
| `in:` | ✅ | — | — | Allowed values: a `Range` (bounds-checked with `cover?`) or an `Array` |
| `format:` | ✅¹ | — | — | Regexp the value must match |
| `format:` | ✅¹ | — | — | Regexp the value must match, or a [preset name](#format-presets): `:email`, `:uuid`, `:url`, `:slug`, `:hostname` |
| `length:` | ✅¹ | ✅ | — | `Range` or `Integer`. Character count on strings, **element count** on arrays, where it short-circuits — see [the field DSL](#the-field-dsl) |
| `normalize:` | ✅¹ | — | — | `:squish`, `:strip`, `:downcase`, `:upcase`, `:email`, or a Proc. Runs **first** — before the absence rule, so a value that normalizes to `""` is absent |
| `default:` | ✅ | ✅ | — | Value used when the field is absent. Validated against the field's own contract at class load, then stored normalized and frozen (each request gets its own copy) |
Expand Down Expand Up @@ -326,6 +327,30 @@ The visible consequence: a value that violates *both* its length and its format
optional :slug, :string, validate: ->(v) { v.match?(/\A[a-z0-9-]+\z/) || :malformed_slug }
```

### `format:` presets

The regexps every app writes by hand, named once:

```ruby
required :email, :string, format: :email
required :id, :string, format: :uuid
optional :website, :string, format: :url
optional :slug, :string, format: :slug
optional :host, :string, format: :hostname
```

| Preset | Matches | Exported JSON Schema `format` |
|---|---|---|
| `:email` | Exactly `URI::MailTo::EMAIL_REGEXP` — the regexp Rails apps already paste in, so switching to the preset cannot change which addresses an endpoint accepts | `email` |
| `:uuid` | A canonical `8-4-4-4-12` UUID, either case | `uuid` |
| `:url` | An `http`/`https` URL. A **shape** check, not a reachability guarantee — but it does reject `javascript:` and other schemes | `uri` |
| `:slug` | Lowercase, digits, single hyphens between segments | — |
| `:hostname` | A DNS hostname (label rules, no trailing dot) | `hostname` |

A preset carries something a hand-written Regexp cannot: the JSON Schema **`format` keyword** the wider ecosystem understands, so [exported docs](#exporting-openapi-docs-that-cannot-drift) say `"format": "uuid"` rather than only a wall of `pattern`. The `pattern` is still emitted next to it — in draft 2020-12 `format` is an annotation unless a validator opts into asserting it, so the pattern is what actually enforces.

An unknown preset name fails at class load, listing the presets. Passing a `Regexp` directly works exactly as before, and the RSpec matcher speaks both spellings: `matching(:email)` asserts the preset, `matching(/re/)` the Regexp.

### Types and strict coercion

Coercion is **deliberately strict**, and deliberately *not* `ActiveModel::Type`. Rails' casts are lenient by design — `"abc".to_i` is `0`, `Boolean.cast("abc")` is `true` — and silently corrupting untrusted input is precisely what a contract must not do. A value the type cannot faithfully represent is a **violation, not a guess**.
Expand Down Expand Up @@ -947,7 +972,8 @@ A bad contract is a programmer error, so it fails when the class loads — never
- A field declared twice in one contract
- An unknown option for the field's kind, listing what *is* allowed
- An unknown type, listing the supported ones
- An unknown `normalize:` preset, listing the presets
- An unknown `normalize:` or `format:` preset, listing the presets
- A `format:` that is neither a `Regexp` nor a preset name
- `format:`, `length:`, or `normalize:` on a non-`:string` field
- `length:` that isn't a non-negative `Integer` or a `Range`; `in:` that doesn't respond to `include?`
- A bound **no value could satisfy**: a reversed or empty `Range` (`in: 65..18`, `length: 5..2`, `length: 3...3`), an empty `in:` set, or a `length:` of 0 on a `required` field (where `""` already violates as `missing`)
Expand Down
46 changes: 46 additions & 0 deletions lib/permittable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
require "bigdecimal"
require "date"
require "time"
require "uri" # URI::MailTo::EMAIL_REGEXP backs the :email format preset

require "permittable/version"
require "permittable/error_envelope"
Expand Down Expand Up @@ -235,6 +236,28 @@ module Permittable
inner.arity == 2 ? inner.call(key, value) : inner.call(key, value, original)
end.freeze

# Named `format:` presets — the regexps every app writes by hand, defined
# once. A preset carries something a hand-written Regexp cannot: the JSON
# Schema `format` keyword the ecosystem understands, so an exported schema
# says `"format": "uuid"` and not only a wall of pattern.
#
# :email is deliberately URI::MailTo::EMAIL_REGEXP itself, the regexp Rails
# apps already paste into their contracts, so adopting the preset cannot
# change which addresses an endpoint accepts. The rest avoid flags and
# Ruby-only constructs (no \h, no /i) so they translate to ECMA-262 and
# export as a real `pattern` rather than an x-permittable-pattern
# extension. :url and :hostname are shape checks, not reachability
# guarantees.
FORMATS = {
email: { pattern: URI::MailTo::EMAIL_REGEXP, json: "email" }.freeze,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Using URI::MailTo::EMAIL_REGEXP under the hood for :email maintains exact parity with the regexp Rails developers commonly use, avoiding unexpected validation divergences.

uuid: { pattern: /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/,
json: "uuid" }.freeze,
url: { pattern: %r{\Ahttps?://[^\s/?\#]+[^\s]*\z}, json: "uri" }.freeze,
slug: { pattern: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ }.freeze,
hostname: { pattern: /\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\z/,
json: "hostname" }.freeze
}.freeze

NORMALIZERS = {
squish: ->(v) { v.squish },
strip: ->(v) { v.strip },
Expand Down Expand Up @@ -859,6 +882,7 @@ def validate_scalar_opts!(field)
validate_required_length!(field)
validate_callable!(name, :validate, field[:validate]) if field.key?(:validate)
validate_callable!(name, :transform, field[:transform]) if field.key?(:transform)
resolve_format!(field)
resolve_normalizer!(field)
validate_authored_value!(field, :default)
validate_authored_value!(field, :example)
Expand Down Expand Up @@ -963,6 +987,28 @@ def validate_callable!(name, opt, value)
raise ArgumentError, "#{LABEL}: :#{opt} for field :#{name} must be callable"
end

# A Symbol (or String) `format:` names a preset; a Regexp is used as
# given. Resolving here means request-time matching stays a plain
# Regexp#match?, and an authored `default:`/`example:` is checked against
# the resolved pattern like any other. The preset NAME is kept on the
# field so exporters and the RSpec matcher can speak in presets.
def resolve_format!(field)
preset = field[:format]
return if preset.nil? || preset.is_a?(Regexp)

unless preset.is_a?(Symbol) || preset.is_a?(String)
raise ArgumentError, "#{LABEL}: :format for field :#{field[:name]} must be a Regexp or a preset name " \
"(presets: #{FORMATS.keys.join(', ')})"
end

spec = FORMATS.fetch(preset.to_sym) do
raise ArgumentError, "#{LABEL}: unknown :format preset :#{preset} for field :#{field[:name]} " \
"(presets: #{FORMATS.keys.join(', ')}, or pass a Regexp)"
end
field[:format_name] = preset.to_sym
field[:format] = spec[:pattern]
end

def resolve_normalizer!(field)
normalizer = field[:normalize]
return if normalizer.nil?
Expand Down
29 changes: 24 additions & 5 deletions lib/permittable/json_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,12 @@ def nullify!(schema, field)

def scalar_schema(field)
schema = SCALAR_SCHEMAS.fetch(field[:type]).dup
apply_format_name!(schema, field)
apply_in!(schema, field[:in])
apply_string_bounds!(schema, field)
apply_pattern!(schema, field[:format])
# A preset's pattern is authored by this gem rather than by the app, so
# it needs no heuristic — see apply_pattern!.
apply_pattern!(schema, field[:format], vouched: !field[:format_name].nil?)
schema
end

Expand All @@ -135,6 +138,16 @@ def opaque_schema(field)
schema
end

# A `format:` preset also names the JSON Schema `format` keyword the
# ecosystem understands, which a hand-written Regexp cannot. `pattern` is
# still emitted next to it: in draft 2020-12 `format` is an annotation
# unless a validator opts into asserting it, so the pattern is what
# actually enforces.
def apply_format_name!(schema, field)
json = FORMATS.dig(field[:format_name], :json)
schema["format"] = json if json
end

def array_schema(field, unknown:)
schema = { "type" => "array" }
min, max = length_bounds(field[:length])
Expand Down Expand Up @@ -173,10 +186,16 @@ def apply_string_bounds!(schema, field)
schema["maxLength"] = max if max
end

def apply_pattern!(schema, regexp)
# `vouched:` marks a pattern this gem authored (a `format:` preset), which
# is known translatable and so skips the conservative scan. It has to:
# the RFC-derived :email pattern contains `*+` inside a character class,
# which UNTRANSLATABLE reads — deliberately over-eagerly — as a
# possessive quantifier, and the most common format in Rails would
# otherwise publish no pattern at all.
def apply_pattern!(schema, regexp, vouched: false)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The vouched: true parameter thoughtfully permits gem-authored presets to bypass the overly conservative regex translation checks, ensuring RFC email patterns emit valid JSON Schema patterns.

return unless regexp

pattern = ecma_pattern(regexp)
pattern = ecma_pattern(regexp, vouched: vouched)
if pattern
schema["pattern"] = pattern
else
Expand All @@ -188,11 +207,11 @@ def apply_pattern!(schema, regexp)
# Flagged regexps bail entirely (JSON Schema's `pattern` has no flag
# slot, and /x//m/i all change semantics), as does any source containing
# an untranslatable construct.
def ecma_pattern(regexp)
def ecma_pattern(regexp, vouched: false)
return nil unless regexp.options.zero?

source = regexp.source
return nil if source.match?(UNTRANSLATABLE)
return nil if !vouched && source.match?(UNTRANSLATABLE)

source.gsub('\A', "^").gsub('\z', "$")
end
Expand Down
17 changes: 17 additions & 0 deletions lib/permittable/rspec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ def check_mismatch(field, key, value)
when :array then "expected an array field, but it is declared with `#{field[:kind]}`" unless field[:kind] == :array
when :of then "expected an array of :#{value}, but it is of: :#{field[:of]}" unless field[:of] == value
when :required then required_mismatch(field, value)
when :format then format_mismatch(field, value)
when :virtual, :sensitive, :nullable then "expected the field to be #{key}, but it is not" unless field[key]
else option_mismatch(field, key, value)
end
Expand All @@ -217,6 +218,22 @@ def required_mismatch(field, required)
"expected the field to be #{expected}, but it is #{actual}" unless actual == expected
end

# `matching(:email)` asserts the preset by name, `matching(/re/)` the
# Regexp itself.
def format_mismatch(field, expected)
return option_mismatch(field, :format, expected) unless expected.is_a?(Symbol)
return if field[:format_name] == expected

"expected format: :#{expected}, but the contract #{declared_format(field)}"
end

def declared_format(field)
return "declares format: :#{field[:format_name]}" if field[:format_name]
return "declares format: #{field[:format].inspect}" if field[:format]

"does not declare format:"
end

def option_mismatch(field, key, value)
return if field.key?(key) && field[key] == value

Expand Down
33 changes: 33 additions & 0 deletions spec/json_schema_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,39 @@ def property(name, **opts, &contract)
end
end

describe "format: presets" do
it "emits the JSON Schema format keyword alongside the pattern it still asserts" do
schema = property("id") { required :id, :string, format: :uuid }
expect(schema).to eq(
"type" => "string", "format" => "uuid", "minLength" => 1,
"pattern" => "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
end

it "maps :url onto uri and :hostname onto hostname" do
expect(property("site") { optional :site, :string, format: :url }["format"]).to eq("uri")
expect(property("host") { optional :host, :string, format: :hostname }["format"]).to eq("hostname")
end

it "emits a translated pattern with no format keyword for a preset JSON Schema has no name for" do
schema = property("slug") { optional :slug, :string, format: :slug }
expect(schema.key?("format")).to be(false)
expect(schema["pattern"]).to eq("^[a-z0-9]+(?:-[a-z0-9]+)*$")
end

it "emails export as a real pattern, not the extension a flagged regexp would need" do
schema = property("email") { optional :email, :string, format: :email }
expect(schema["format"]).to eq("email")
expect(schema["pattern"]).to start_with("^[a-zA-Z0-9")
expect(schema.key?("x-permittable-pattern")).to be(false)
end

it "says nothing extra for a hand-written Regexp" do
expect(property("code") { optional :code, :string, format: /\A[A-Z]{3}\z/ })
.to eq("type" => "string", "pattern" => "^[A-Z]{3}$")
end
end

describe "documentation annotations" do
it "emits default:, desc:, and example: as default / description / examples" do
prop = property("plan") do
Expand Down
17 changes: 17 additions & 0 deletions spec/matchers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ def failure_of
.to include("expected type :string, but the contract declares :json")
end

it "checks a format: preset by name, and a Regexp by value" do
presets = Class.new(FakeController) do
include Permittable

permit_params(:create) do
required :email, :string, format: :email
required :code, :string, format: /\A[A-Z]{3}\z/
end
end
expect(presets).to permit_param(:email).matching(:email)
expect(presets).to permit_param(:code).matching(/\A[A-Z]{3}\z/)
expect(failure_of { expect(presets).to permit_param(:email).matching(:uuid) })
.to include("expected format: :uuid, but the contract declares format: :email")
expect(failure_of { expect(presets).to permit_param(:code).matching(:uuid) })
.to include("expected format: :uuid, but the contract declares format: /\\A[A-Z]{3}\\z/")
end

it "checks arrays with as_array and an element type" do
expect(controller).to permit_param(:tags).for_action(:create).as_array
expect(controller).to permit_param(:tags).for_action(:create).as_array(of: :string)
Expand Down
Loading
Loading