diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a3285..681d0d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 0df9286..26bc2be 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) | @@ -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**. @@ -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`) diff --git a/lib/permittable.rb b/lib/permittable.rb index 351f7df..07f0e99 100644 --- a/lib/permittable.rb +++ b/lib/permittable.rb @@ -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" @@ -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, + 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 }, @@ -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) @@ -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? diff --git a/lib/permittable/json_schema.rb b/lib/permittable/json_schema.rb index 9424353..e5ad9fc 100644 --- a/lib/permittable/json_schema.rb +++ b/lib/permittable/json_schema.rb @@ -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 @@ -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]) @@ -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) return unless regexp - pattern = ecma_pattern(regexp) + pattern = ecma_pattern(regexp, vouched: vouched) if pattern schema["pattern"] = pattern else @@ -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 diff --git a/lib/permittable/rspec.rb b/lib/permittable/rspec.rb index 2e28bb8..acf7b56 100644 --- a/lib/permittable/rspec.rb +++ b/lib/permittable/rspec.rb @@ -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 @@ -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 diff --git a/spec/json_schema_spec.rb b/spec/json_schema_spec.rb index 51609f6..6fcafee 100644 --- a/spec/json_schema_spec.rb +++ b/spec/json_schema_spec.rb @@ -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 diff --git a/spec/matchers_spec.rb b/spec/matchers_spec.rb index 0a0036d..7e00f23 100644 --- a/spec/matchers_spec.rb +++ b/spec/matchers_spec.rb @@ -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) diff --git a/spec/permittable_spec.rb b/spec/permittable_spec.rb index e4f3505..547a5bf 100644 --- a/spec/permittable_spec.rb +++ b/spec/permittable_spec.rb @@ -561,6 +561,74 @@ def rejected(key, value, &decl) end end + describe "format: presets" do + def format_violations(value, preset) + violations_for({ v: value }) { permit_params(:create) { required :v, :string, format: preset } }.details + end + + it "accepts the same emails URI::MailTo::EMAIL_REGEXP does, which is what apps write by hand" do + expect(permit({ v: "a.b+c@example.co.uk" }) { permit_params(:create) { required :v, :string, format: :email } }[:v]) + .to eq("a.b+c@example.co.uk") + expect(format_violations("nope", :email)).to eq([{ param: "v", code: "format" }]) + expect(Permittable::FORMATS[:email][:pattern]).to eq(URI::MailTo::EMAIL_REGEXP) + end + + it "matches a canonical UUID in either case, and nothing else" do + %w[123e4567-e89b-12d3-a456-426614174000 123E4567-E89B-12D3-A456-426614174000].each do |uuid| + expect(permit({ v: uuid }) { permit_params(:create) { required :v, :string, format: :uuid } }[:v]).to eq(uuid) + end + %w[123e4567e89b12d3a456426614174000 123e4567-e89b-12d3-a456-42661417400 zzz].each do |bad| + expect(format_violations(bad, :uuid)).to eq([{ param: "v", code: "format" }]), "for #{bad}" + end + end + + it "matches an http(s) URL and rejects other schemes or whitespace" do + expect(permit({ v: "https://a.example/x?y=1" }) { permit_params(:create) { required :v, :string, format: :url } }[:v]) + .to eq("https://a.example/x?y=1") + ["ftp://a.example", "javascript:alert(1)", "http://a b", "example.com"].each do |bad| + expect(format_violations(bad, :url)).to eq([{ param: "v", code: "format" }]), "for #{bad}" + end + end + + it "matches a lowercase hyphenated slug" do + expect(permit({ v: "my-post-2" }) { permit_params(:create) { required :v, :string, format: :slug } }[:v]) + .to eq("my-post-2") + ["My-Post", "-leading", "trailing-", "double--hyphen", "under_score"].each do |bad| + expect(format_violations(bad, :slug)).to eq([{ param: "v", code: "format" }]), "for #{bad}" + end + end + + it "resolves the preset to its Regexp on the frozen field, and remembers the name" do + klass = permittable_class { permit_params(:create) { required :v, :string, format: :uuid } } + field = klass.permit_rule_for("create")[:fields].first + expect(field[:format]).to be_a(Regexp) + expect(field[:format_name]).to eq(:uuid) + end + + it "leaves a Regexp passed directly alone, with no preset name" do + klass = permittable_class { permit_params(:create) { required :v, :string, format: /\Ax\z/ } } + field = klass.permit_rule_for("create")[:fields].first + expect(field[:format]).to eq(/\Ax\z/) + expect(field.key?(:format_name)).to be(false) + end + + it "rejects an unknown preset at class load, listing the presets" do + expect { permittable_class { permit_params(:create) { required :v, :string, format: :postcode } } } + .to raise_error(ArgumentError, + /unknown :format preset :postcode for field :v \(presets: email, uuid, url, slug, hostname, or pass a Regexp\)/) + end + + it "still refuses format: on a non-string field" do + expect { permittable_class { permit_params(:create) { required :v, :integer, format: :uuid } } } + .to raise_error(ArgumentError, /:format is only supported on :string fields/) + end + + it "checks an authored default:/example: against the resolved preset at class load" do + expect { permittable_class { permit_params(:create) { optional :v, :string, format: :slug, default: "Nope" } } } + .to raise_error(ArgumentError, /:default for field :v violates its own contract \(format\)/) + end + end + describe "rule ordering: the cheap bound before the expensive one" do # A Regexp subclass, so it satisfies any `format:` type check while # recording whether the contract ever consulted it.