No description
Find a file
2026-07-20 16:11:42 +02:00
.github/workflows genesis 2026-07-16 14:51:57 +02:00
gemfiles genesis 2026-07-16 14:51:57 +02:00
lib Better NULL_FRAGMENT identifier 2026-07-20 16:11:42 +02:00
spec genesis 2026-07-16 14:51:57 +02:00
.gitattributes genesis 2026-07-16 14:51:57 +02:00
.gitignore genesis 2026-07-16 14:51:57 +02:00
.rspec genesis 2026-07-16 14:51:57 +02:00
.rubocop.yml genesis 2026-07-16 14:51:57 +02:00
CHANGELOG.md genesis 2026-07-16 14:51:57 +02:00
CONTRIBUTING.md genesis 2026-07-16 14:51:57 +02:00
Gemfile genesis 2026-07-16 14:51:57 +02:00
Gemfile.lock Adds Gemfile.lock 2026-07-20 16:10:51 +02:00
LICENSE.txt genesis 2026-07-16 14:51:57 +02:00
liminal.gemspec genesis 2026-07-16 14:51:57 +02:00
liminal.png Better readme 2026-07-20 16:11:27 +02:00
Rakefile genesis 2026-07-16 14:51:57 +02:00
README.md Better readme 2026-07-20 16:11:27 +02:00

Liminal

Logo

Inside the boundaries

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
Rswag See liminal-rswag 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 (duh!).

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. 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" }

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.

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. String and symbol document keys and component names are treated logically alike. Re-registering the same normalized schema is idempotent; a different schema raises Liminal::ComponentCollisionError.

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.