Module: Servus::Schema

Defined in:
lib/servus/schema.rb,
lib/servus/schema/ref.rb,
lib/servus/schema/path.rb,
lib/servus/schema/cache.rb,
lib/servus/schema/errors.rb,
lib/servus/schema/compiler.rb,
lib/servus/schema/declaration.rb

Overview

Registry of reusable JSON Schema fragments, and the entry point for compiling a schema that references them.

Servus services and events declare their contracts inline via the schema DSL. That keeps a service's inputs and outputs visible in the file that implements it. The cost of inline-only declaration is duplication: the same amount or timestamp shape gets copy-pasted across every service that touches it.

Registered fragments close that gap without giving up explicitness. A fragment is registered under a key, and services reference into it with a standard JSON Schema $ref. A service that references a shared type is still explicitly declaring that type — it just names it once.

Lookups never return nil. An unregistered key raises UnknownKeyError at the point of use, because the alternative — silently skipping validation for a service that appears to declare a contract — is the worst failure mode this system has.

Examples:

Registering a fragment

Servus::Schema.register('core', {
  '$defs' => {
    'amount' => { 'type' => 'integer', 'minimum' => 0 }
  }
})

Referencing it from a service

class Treasury::TransferGold::Service < Servus::Base
  schema arguments: {
    type: 'object',
    required: ['gold_dragons'],
    properties: {
      gold_dragons: { '$ref' => '#/core/$defs/amount' }
    }
  }
end

See Also:

Defined Under Namespace

Modules: Declaration, Path Classes: Cache, CircularReferenceError, Compiler, DepthExceededError, Error, InvalidKeyError, InvalidRefError, Ref, RefNotFoundError, UnknownKeyError

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.cacheServus::Schema::Cache (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Memoized ref resolutions and the generation counter derived from them.



55
56
57
# File 'lib/servus/schema.rb', line 55

def cache
  @cache
end

Class Method Details

.compile(schema, context: nil) ⇒ Hash?

Compiles a schema, replacing every $ref with the fragment it names.

Parameters:

  • schema (Hash, nil)

    the authored schema

  • context (String, nil) (defaults to: nil)

    label used in error messages, e.g. "Treasury::TransferGold::Service arguments schema"

Returns:

  • (Hash, nil)

    the compiled schema, or nil if schema was nil

Raises:

  • (Error)

    if any ref cannot be resolved



188
189
190
191
192
# File 'lib/servus/schema.rb', line 188

def compile(schema, context: nil)
  return nil if schema.nil?

  Compiler.new(context: context).compile(schema)
end

.compile_allHash{String => Hash}

Compiles every registered fragment, resolving all +$ref+s.

Returns a hash of key to compiled fragment, mirroring the registry's own shape so keys stay addressable and the result serializes straight to JSON. Useful for producing a single schema asset for an API description, a docs build, client codegen, or a CI freshness check.

Examples:

File.write('schema.json', JSON.pretty_generate(Servus::Schema.compile_all))

Returns:

  • (Hash{String => Hash})

    every fragment, refs resolved

Raises:

  • (Error)

    if any fragment contains a ref that cannot be resolved



156
157
158
# File 'lib/servus/schema.rb', line 156

def compile_all
  keys.to_h { |key| [key, compile(fetch(key), context: "schema fragment #{key.inspect}")] }
end

.fetch(key, *path) ⇒ ActiveSupport::HashWithIndifferentAccess, Object

Returns a registered fragment, or a definition within one.

Given no path, returns the whole fragment. Given path segments, walks them as literal keys — the same addressing a $ref uses, so fetch(key, *path) reads exactly what ref(key, *path) points at.

A missing path raises rather than returning nil. Reaching for the fragment and calling dig would return nil on a typo, which is the silent failure this registry exists to prevent.

Fragments are returned as authored, with any +$ref+s intact. Use compile to resolve them.

Examples:

Servus::Schema.fetch('core')
Servus::Schema.fetch('core', '$defs', 'amount')

Parameters:

  • key (String, Symbol)

    the fragment key

  • path (Array<String, Symbol>)

    segments to walk within the fragment

Returns:

  • (ActiveSupport::HashWithIndifferentAccess, Object)

    the frozen fragment or definition

Raises:



112
113
114
115
116
117
# File 'lib/servus/schema.rb', line 112

def fetch(key, *path)
  key = key.to_s
  fragment = @registry.fetch(key) { raise UnknownKeyError.for(key, available: @registry.keys) }

  Path.walk(fragment, key, path.map(&:to_s))
end

.generationInteger

Monotonic counter bumped whenever the registry changes.

Consumers memoize compiled schemas alongside the generation they were compiled under, and recompile when it moves. That makes registry updates propagate without any explicit dependency tracking.

Returns:

  • (Integer)


64
# File 'lib/servus/schema.rb', line 64

def generation = cache.generation

.keysArray<String>

Returns registered keys, sorted.

Returns:

  • (Array<String>)

    registered keys, sorted



161
162
163
# File 'lib/servus/schema.rb', line 161

def keys
  @registry.keys.sort
end

.ref(key, *path) ⇒ Hash

Builds a $ref pointing at a registered fragment.

Prefer this over hand-writing ref strings — it is typo-proof in the separator and prefix, which are the parts people get wrong.

Examples:

Servus::Schema.ref('core', '$defs', 'amount')
# => { "$ref" => "#/core/$defs/amount" }

Parameters:

  • key (String, Symbol)

    the fragment key

  • path (Array<String, Symbol>)

    segments to walk within the fragment

Returns:

  • (Hash)

    a $ref hash



177
178
179
# File 'lib/servus/schema.rb', line 177

def ref(key, *path)
  { '$ref' => "#/#{[key, *path].map(&:to_s).join('/')}" }
end

.register(key, fragment) ⇒ ActiveSupport::HashWithIndifferentAccess

Registers a reusable schema fragment under key.

Re-registering an equal value is a silent no-op, so calling this from a Rails to_prepare block is safe. Re-registering a different value replaces it, logs an override, and bumps generation — which invalidates every compiled schema that referenced it.

Examples:

Servus::Schema.register('core', { '$defs' => { 'id' => { 'type' => 'integer' } } })

Parameters:

  • key (String, Symbol)

    the fragment key, referenced as #/<key>/...

  • fragment (Hash)

    the schema fragment

Returns:

  • (ActiveSupport::HashWithIndifferentAccess)

    the normalized fragment

Raises:

  • (InvalidKeyError)

    if the key is blank or contains a /

  • (ArgumentError)

    if the fragment is not a Hash



81
82
83
84
85
86
87
88
# File 'lib/servus/schema.rb', line 81

def register(key, fragment)
  key = normalize_key(key)
  normalized = normalize_fragment(key, fragment)

  cache.invalidate! if store(key, normalized)

  normalized
end

.reset!void

This method returns an undefined value.

Clears the registry. Intended for test suites.



197
198
199
# File 'lib/servus/schema.rb', line 197

def reset!
  restore({}.freeze)
end

.resolve(key, *path) ⇒ Hash, Object

Returns a fragment, or a definition within one, with all +$ref+s resolved.

The compiled counterpart to fetch: same addressing, but the result is self-contained and ready to validate against. This is usually what application code outside a service wants — a controller validating a request body, a serializer checking a response shape.

Results are memoized, so asking repeatedly for the same address is cheap.

Examples:

Servus::Schema.resolve('endpoints::trades::create', '$defs', 'request')
# => { "type" => "object", "properties" => { "price" => { "type" => "integer" } } }

Parameters:

  • key (String, Symbol)

    the fragment key

  • path (Array<String, Symbol>)

    segments to walk within the fragment

Returns:

  • (Hash, Object)

    the compiled fragment or definition

Raises:



138
139
140
141
142
# File 'lib/servus/schema.rb', line 138

def resolve(key, *path)
  pointer = ref(key, *path)

  compile(pointer, context: "schema #{pointer['$ref']}")
end

.restore(snapshot) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Restores a snapshot taken by snapshot.

Parameters:

  • snapshot (Hash)


214
215
216
217
# File 'lib/servus/schema.rb', line 214

def restore(snapshot)
  @mutex.synchronize { @registry = snapshot }
  cache.invalidate!
end

.snapshotHash

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Captures the registry state so a test can restore it afterwards.

Returns:

  • (Hash)

    an opaque snapshot for restore



205
206
207
# File 'lib/servus/schema.rb', line 205

def snapshot
  @registry
end