Liminal
Liminal strictly compiles declarative Dry::Schema#to_ast structure and predicates into deterministic OpenAPI 3.0
Schema Objects. It is useful when a dry-schema definition is the application source of truth and silently dropping a
validation constraint would make generated API documentation misleading.
The Ruby API lives under Liminal; it does not use Dry:: and is not an official dry-rb project.
Compatibility
| Concern | Supported |
|---|---|
| Ruby | 3.2 and newer |
| dry-schema | 1.13.4 through 1.16.x (>= 1.13.4, < 1.17) |
| OpenAPI | 3.0.x only |
| OpenAPI integration | Provided separately by the liminal-openapi companion gem |
The dependency matrix runs the same real dry-schema definitions against 1.13, 1.14, 1.15, and 1.16. OpenAPI 3.1 syntax,
including type arrays and numeric exclusiveMinimum values, is rejected.
Installation
Add the gem to your bundle:
gem "liminal", "~> 0.1.0"
Then run bundle install and require liminal.
Compilation
require "dry/schema"
require "liminal"
schema = Dry::Schema.Params do
required(:name).filled(:string)
optional(:score).filled(:float, gt?: 0)
end
openapi_schema = Liminal.compile(
schema,
dialect: :openapi_3_0,
source_name: "CreateEventSchema"
)
The result is a plain recursively string-keyed Hash. Required fields become required; optional fields do not.
Arrays always include items, empty required arrays are omitted, and stable AST order produces stable output.
Liminal depends on dry-schema, not dry-validation. Applications using contracts can expose a convenience method:
class Contracts::Base < Dry::Validation::Contract
def self.openapi_schema(**)
Liminal.compile(schema, source_name: name, **)
end
end
Dry::Schema::Params may coerce input strings before validation, while OpenAPI describes their post-coercion JSON
representation. Use Dry::Schema::JSON when differential tests should compare non-coercing behavior directly.
Exclusions
Array paths are canonical and preserve property names containing dots:
Liminal.compile(
schema,
exclude: [[:message_id], %i[data internal_metadata]]
)
Exclusions happen before the field rule is compiled, remove the field from its parent's required list, and can conceal
an intentionally undocumented unsupported predicate. Unknown paths raise Liminal::UnknownExclusionPathError. Dotted
strings are accepted only as a convenience.
Overrides
An enrichment override deep-merges documentation or representable structural details into a successfully generated field:
overrides = {
%i[data attributes event_at] => {
"format" => "date",
"description" => "Calendar date"
}
}
required arrays are unioned during enrichment. An authoritative override may replace a field whose node or predicate
cannot be compiled, but it must contain one of type, $ref, oneOf, anyOf, or allOf:
overrides = {
[:opaque_id] => {"type" => "string", "format" => "uuid"}
}
A description alone never hides an unsupported construct. Unknown paths raise Liminal::UnknownOverridePathError.
An override cannot target an excluded path or one of its descendants; contradictory path configuration raises
Liminal::InvalidOverrideError. A parent override may enrich a schema after one of its descendants is excluded.
OpenAPI 3.1-only keywords are rejected. OpenAPI 3.0 forbids $ref siblings, so use allOf when a reference also needs a
description:
{ "allOf" => [{ "$ref" => "#/components/schemas/User" }], "description" => "User" }
Schema composition
Liminal::Schema.normalize is the public boundary for precompiled Schema
Objects. It copies the input, normalizes object keys, validates OpenAPI 3.0
keywords and nested values, and produces deterministic keyword order:
normalized = Liminal::Schema.normalize({type: "string"})
required = Liminal::Schema.required(schema, :mobile_phone)
exclusive = Liminal::Schema.exactly_one(schema, :mobile_phone, :email)
combined = Liminal::Schema.all_of(base_schema, detail_schema)
Precompiled values must already be JSON-native: strings, booleans, nil,
integers, finite floats, arrays, and string- or symbol-keyed hashes. Ambiguous
string/symbol keys and Ruby-only scalar values fail validation instead of being
silently coerced.
required unions the named properties with an existing required array.
exactly_one emits one oneOf required-property branch per name and preserves
an existing oneOf as a separate allOf constraint. all_of keeps each input
as an independent branch. Applying a combinator to a $ref uses allOf, since
OpenAPI 3.0 forbids $ref siblings.
Predicate extensions
The default registry is immutable. with returns a copy:
predicates = Liminal::PredicateRegistry.default.with(:uuid_v4?) do |_arguments, context|
raise "unexpected dialect" unless context.dialect.name == :openapi_3_0
{"type" => "string", "format" => "uuid"}
end
Liminal.compile(schema, predicates: predicates)
Handlers receive semantic arguments with dry input placeholders removed and a context containing the canonical path and
dialect. They must return a Hash. Duplicate registration requires replace: true; incompatible keywords and types are
rejected.
Built-in predicates cover arrays, booleans, dates and times, numbers, hashes, integers, strings, type? for the documented
Ruby primitive classes, equality and inclusion enums, safe regular expressions, filled and size constraints, numeric
boundaries, and integer parity. and, or, nullable implications, and each are compiled without exposing raw AST nodes.
Ruby \A and \z anchors are translated to their ECMA-262 equivalents. Raw ^
and $ anchors are rejected because their Ruby line semantics cannot be
preserved safely.
Strict failures and custom rules
Unsupported AST nodes and predicates raise contextual Liminal::UnsupportedNodeError or
Liminal::UnsupportedPredicateError, including the source, canonical field path, and failing operation. Compilation never
has a permissive mode.
A dry-validation rule such as this is arbitrary Ruby and is not part of the declarative schema AST:
rule("data.attributes.event_at") do
ValidateIso8601Date.call(date: value)
end
Liminal does not execute, introspect, or pretend to translate such rules. Represent structural documentation explicitly with an override. Database checks and cross-field business rules belong in endpoint prose rather than a Schema Object.
Components
Register a generated component in any mutable OpenAPI document:
reference = Liminal::Components.register(
document: openapi_document,
name: :calendar_event,
schema: openapi_schema
)
# => {"$ref"=>"#/components/schemas/calendar_event"}
Missing maps are created. A document may use either string or symbol container
keys, but declaring both logical forms is rejected as ambiguous. Component
names may contain only letters, digits, periods, hyphens, and underscores.
Re-registering the same normalized schema is idempotent; a different schema or
duplicate string/symbol component key raises Liminal::ComponentCollisionError.
Returned references are immutable.
Rswag boundary
The core gem contains no Rails, Active Support, RSpec, or Rswag runtime dependency and performs no global monkey-patching. Until a separately versioned Rswag companion is requested, an application-side opt-in helper can keep the integration at the metadata boundary:
def request_body_from_schema(schema:, component:, parameter_name:, source_name: nil, **)
generated = Liminal.compile(schema, source_name: source_name, **)
reference = Liminal::Components.register(
document: selected_openapi_document,
name: component,
schema: generated
)
parameter name: parameter_name, in: :body, required: true, schema: reference
end
Application action-to-contract metadata remains outside Liminal. Request examples remain Rswag metadata and are not embedded into structural schemas.
Development
Run bundle exec rake for randomized RSpec and RuboCop checks, COVERAGE=true bundle exec rspec for line and branch
coverage, and bundle exec rake build to build the gem locally. The development suite validates a generated minimal
document with openapi3_parser and compares representative non-coercing payload behavior with json_schemer.
The project is versioned from 0.1.0 while its extension API is being proven. No release or publication is performed by
the implementation workflow.