OapiCodemode.SpecStore behaviour (oapi_codemode v0.5.0)

Copy Markdown View Source

The seam between the library and wherever specs actually live.

The library stops owning "the spec" as an in-memory blob: it holds a store{module, handle} — and a ref naming one projection of one document, and reads the pieces it needs when it needs them. handle is whatever the implementation wants to be handed back (an ETS table, an Ecto repo), so a store is a value that can be put in a registry entry.

Every read names its projection. ref is {spec_id, decomposer_version}: a registry built while OapiCodemode.Ingest.decomposer_version/0 was N keeps reading version N for its lifetime, even after a deploy whose decomposer derives something different from the same evidence. Stores are therefore append-only in the version dimension — a new version is a new projection alongside the old, never a mutation of it.

Nothing referenced is inlined by a store. operation/3 hands back payload $refs exactly as the document wrote them; resolving them is read-time work done against components/3 (bulk, for the stored component kinds) and pointer/4 (the slow path, for same-document pointers that are not components — into paths, examples, anywhere $ref may legally point). Both are bounded: pointer/4 takes a byte budget — Jason-encoded bytes, or :infinity — and answers {:error, :too_large} rather than allocating an oversized target.

Two implementations: OapiCodemode.SpecStore.ETS here, and Gentility.Integrations.SpecStore.Ecto downstream. Both are held to OapiCodemode.SpecStoreCase, the conformance suite shipped in lib/.

The module-level functions mirror the callbacks one for one and take the {module, handle} tuple, so callers hold a store and call SpecStore.index(store, ref) instead of unpacking tuples at every site.

Summary

Types

A component's {kind, name} address, e.g. {"schemas", "Pet"}.

Opaque to the library: an ETS table, an Ecto.Repo, a connection.

Decoded JSON, as stored.

One projection of one document — every read names the version it wants.

A read's answer.

A store's identifier for one document (one canonical byte string).

An implementation plus whatever it wants handed back on every call.

Callbacks

Bulk component fetch: one call per breadth-first level of a resolve.

The slim search index: every operation, no schemas.

What the registry reads once, at registration.

One operation, payload $refs preserved exactly as the document wrote them.

Any same-document "#/..." pointer, served from the canonical document.

Persists a decomposed document and returns the ref for its projection.

Types

component_key()

@type component_key() :: {String.t(), String.t()}

A component's {kind, name} address, e.g. {"schemas", "Pet"}.

handle()

@type handle() :: term()

Opaque to the library: an ETS table, an Ecto.Repo, a connection.

json()

@type json() :: term()

Decoded JSON, as stored.

ref()

@type ref() :: {spec_id(), pos_integer()}

One projection of one document — every read names the version it wants.

result(t)

@type result(t) :: {:ok, t} | {:error, :not_found | term()}

A read's answer.

:not_found is the one error every store must speak, and the only one the library branches on — an unknown ref, an unknown operation id, a pointer that names nothing. pointer/4 adds :too_large. Anything else is the store's own trouble (a dead connection, a timeout), passed up unread.

spec_id()

@type spec_id() :: term()

A store's identifier for one document (one canonical byte string).

store()

@type store() :: {module(), handle()}

An implementation plus whatever it wants handed back on every call.

Callbacks

components(handle, ref, list)

@callback components(handle(), ref(), [component_key()]) ::
  result(%{required(component_key()) => json()})

Bulk component fetch: one call per breadth-first level of a resolve.

Names the store does not hold are simply absent from the map — a dangling $ref is the resolver's $unresolved marker, not an error.

index(handle, ref)

The slim search index: every operation, no schemas.

In document order — the order %Decomposed{} carries its operations in, which OapiCodemode.Ingest derives from the document itself (paths in lexical order, methods in a fixed order within a path). A store persists that order and hands it back; it does not impose one of its own. Search results and search_apis listings are read in it, so an implementation that returns rows in whatever order the storage engine felt like — a SELECT with no ORDER BY — is not conformant.

meta(handle, ref)

@callback meta(handle(), ref()) :: result(OapiCodemode.SpecStore.Meta.t())

What the registry reads once, at registration.

operation(handle, ref, op_id)

@callback operation(handle(), ref(), op_id :: String.t()) ::
  result(OapiCodemode.Operation.t())

One operation, payload $refs preserved exactly as the document wrote them.

pointer(handle, ref, pointer, max_bytes)

@callback pointer(
  handle(),
  ref(),
  pointer :: String.t(),
  max_bytes :: non_neg_integer() | :infinity
) :: result(json())

Any same-document "#/..." pointer, served from the canonical document.

The slow path, for refs that are not stored components. The returned term is bounded: a target whose encoded size exceeds max_bytes comes back {:error, :too_large}, which the resolver turns into $truncated. :infinity asks for the target whatever its size — an unbounded resolve names no ceiling of its own rather than invent one.

The measure is Jason-encoded bytes, the same measure OapiCodemode.Ingest caps a stored node by and OapiCodemode.Resolve charges a budget in, so a resolve's arithmetic and a store's refusals agree. A store may be stricter — refuse a target this measure would have allowed, because its own encoding is fatter or because it bounds the read before allocating — but never more lenient: a caller's budget is the one number keeping a 20 MB schema out of a sandbox, and a store that stretches it silently breaks that promise.

put(handle, t)

@callback put(handle(), OapiCodemode.Decomposed.t()) :: {:ok, ref()} | {:error, term()}

Persists a decomposed document and returns the ref for its projection.

Content-addressed and idempotent: %Decomposed{} carries the canonical bytes and their hash, so putting a document a store already holds returns the existing ref. A %Decomposed{} whose decomposer_version the store has no projection for is stored alongside the versions it has — refs handed out earlier keep reading what they always read.

Functions

components(arg, ref, keys)

@spec components(store(), ref(), [component_key()]) ::
  result(%{required(component_key()) => json()})

See components/3.

index(arg, ref)

See index/2.

meta(arg, ref)

See meta/2.

operation(arg, ref, op_id)

@spec operation(store(), ref(), String.t()) :: result(OapiCodemode.Operation.t())

See operation/3.

pointer(arg, ref, pointer, max_bytes)

@spec pointer(store(), ref(), String.t(), non_neg_integer() | :infinity) ::
  result(json())

See pointer/4.

put(arg, decomposed)

@spec put(store(), OapiCodemode.Decomposed.t()) :: {:ok, ref()} | {:error, term()}

See put/2.