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(**options)
    Liminal.compile(schema, source_name: name, **options)
  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, **options)
  generated = Liminal.compile(schema, source_name: source_name, **options)
  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.