Read-time $ref resolution: one stored operation row plus the store, in,
one usable operation out.
Decomposition inlines nothing, so an operation row holds the document's
own $refs. This is where they are followed — once per read, for the one
operation being read, instead of once per document at ingest. It is the
one home for that work, for both runtime readers of a spec: the proxy
asks for: :request and gets only what a request needs (parameters and
the request body), search's describe asks for: :describe and also gets
responses.
The two phases, and why there are two
Resolution is a fetch pass followed by a substitute pass.
The fetch pass walks the operation's $refs breadth first and asks the
store for a whole layer at a time: the component $refs of a layer are
one SpecStore.components/3 call — or one per lexical chunk of them, see
"The budget" — so a section costs two or three round trips no matter how
many refs it names. Each answer is scanned
for the refs it names, and that is the next layer, until a layer holds
nothing new. Sections are fetched one after another — parameters, then the
request body, then responses — sharing one table, so a ref two sections
name is fetched once. The result is a table from ref string to outcome,
which the second pass reads instead of the store.
The substitute pass is OapiCodemode.Ingest.Deref's walk with the
document swapped for that table — the same stack, the same
cache-only-clean-expansions rule, the same $circular naming. Keeping it
a separate pass is what makes the two paths produce byte-identical output:
a breadth-first substitution would flatten a cyclic graph differently
from a depth-first one, so only the fetching is breadth first.
Where a ref goes
#/components/<kind>/<name>,kindone ofOapiCodemode.Ingest.component_kinds/0— a stored component, read in the layer's bulkSpecStore.components/3call. A name the store does not hold is%{"$unresolved" => ref}, because a dangling ref is a fact about the document, not a store failure.- any other same-document
#/…pointer — intopaths, intocomponents/examples(not a stored kind), into an array element, partway into a component — goes throughSpecStore.pointer/4with what is left of the byte budget. Refs like these are rare, and the store answers them off the canonical document. - anything else (
other.json#/X, a URL) is external and cannot be resolved from one document:%{"$unresolved" => ref}.
A ref reached while it is already being expanded is %{"$circular" => name}, name being the pointer's last segment — Deref's rule, kept
because sandbox code already reads that marker.
The budget
max_bytes: bounds the resolved bytes: the encoded size (as
Jason-encoded bytes) of each target the store hands back, summed across
the whole operation. A ref that appears five times is fetched, charged and
expanded once. When a target does not fit in what is left, that ref becomes
%{"$truncated" => true} and nothing under it is fetched; smaller refs
later in the same layer still resolve if they fit.
Charging order is deterministic, and deliberately so: sections are charged
in order — parameters, then the request body, then responses — and within a
section refs are taken in lexical order within each breadth-first layer.
That gives the property the two modes are read against: :request's
charging sequence is a strict prefix of :describe's, so under the same
budget the two modes' parameters and request_body are identical, and
responses can only ever be truncated after them. A read that asks for
more can therefore never get less of what a request needs.
It bounds fetch volume too, not just output. A layer's components are fetched in lexical chunks, and once the budget is spent no further chunk is asked for — so a 1-byte budget cannot drag a 20 MB layer out of the store to refuse it. One over-budget component may still be fetched before it is refused: the store is asked for a chunk, and only the answer says how big it was.
The default is :infinity. The budget is a policy, and the policies
differ (the proxy resolves one operation it is about to send; describe
spends a shared per-run allowance across many), so the default declines to
invent one and each caller states its own.
What comes back
A plain map, not an %OapiCodemode.Operation{}: the struct documents
fields that hold $refs and marker-free document JSON, and this is the
other thing — refs gone, markers possible. Its keys are the struct's,
with parameters and request_body resolved, plus responses resolved
only under for: :describe. Under for: :request the key is absent
rather than nil, since nil already means "the document declared no
responses" and "we did not look" deserves to fail loudly.
id, method, path, segments, summary, description, tags,
deprecated, param_index and security pass through exactly as stored.
security names schemes, and the schemes themselves were resolved at
decompose time into OapiCodemode.SpecStore.Meta, so there is nothing here
to resolve.
request_body is %{"required", "content_type", "schema"} or nil, whatever
shape the document wrote. The stored node is the document's own
(%OapiCodemode.Operation{}), so every body — inline, a Reference Object,
a Reference Object standing in for the content map or for one media
object — is resolved here and then put through
OapiCodemode.Ingest.Normalize.extract_body/1. One order, one
implementation: bodies that mean the same thing arrive the same by
construction rather than by two code paths agreeing.
Errors
SpecStore.operation/3 is read first and its {:error, :not_found} is
returned as-is: an unknown operation id, or a ref whose projection has
vanished, is a hard error. Degrading it into an operation full of
$unresolved markers would hand a caller something that looks like a
document with bad refs, and the difference matters — one is the tenant's
spec, the other is our storage. Store errors from components/3 and
pointer/4 propagate for the same reason; only :not_found and
:too_large, which are statements about the document, become markers.
Known divergences from Deref
Two, both on documents Deref handled worse:
pointer/4walks arrays (RFC 6901), whichDeref's maps-only walk never did, so a ref like#/x-shared/1/schemaresolves here and was$unresolvedbefore. More resolution, never less.- Path Item
$refs are resolved at decompose time, and by different rules:Ingest.resolve_path_items/1merges the ref node's siblings over the target (OpenAPI 3.1 letssummary/descriptionsit beside the$ref) and bounds its walk by hop count, whereDerefreplaces the node with the target, dropping siblings, and detects cycles with a visited set. So a path item written as a$refwith siblings can yield different operations on the two paths — 3.1's rules on the store path, 3.0's on the old one — and a cyclic path-item chain ends up$unresolvedrather than$circular. Nothing to fix here: the refs that decide which operations exist cannot be a read-time question.
One thing both paths now do differently from the pipeline they replace, and
therefore diverge from together: extract_body/1 declines to name a media
type when content's keys are not media types, which happens only when a
Reference Object stood where the map of media types belongs — a thing
OpenAPI forbids. Extraction used to report the resolved target's own first
key ("content", "schema") as the Content-Type; nil is better than a
header the document never wrote.
Summary
Types
The resolved bytes a read may spend.
Which sections a read needs resolved.
One resolved operation. See "What comes back".
Functions
Resolves one stored operation's $refs.
Types
@type budget() :: non_neg_integer() | :infinity
The resolved bytes a read may spend.
@type mode() :: :request | :describe
Which sections a read needs resolved.
@type resolved() :: %{ :id => String.t(), :method => String.t(), :path => String.t(), :segments => [String.t() | {:param, String.t()}], :summary => String.t() | nil, :description => String.t() | nil, :tags => [String.t()], :deprecated => boolean(), :param_index => [map()], :security => list() | nil, :parameters => [term()], :request_body => map() | nil, optional(:responses) => map() | nil }
One resolved operation. See "What comes back".
Functions
@spec operation( OapiCodemode.SpecStore.store(), OapiCodemode.SpecStore.ref(), String.t(), keyword() ) :: {:ok, resolved()} | {:error, :not_found | term()}
Resolves one stored operation's $refs.
Options:
:for— required,:requestor:describe.:requestresolves parameters and the request body;:describealso resolves responses.:max_bytes— resolved bytes this read may spend, or:infinity(the default).