OapiCodemode.Resolve (oapi_codemode v0.5.0)

Copy Markdown View Source

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>, kind one of OapiCodemode.Ingest.component_kinds/0 — a stored component, read in the layer's bulk SpecStore.components/3 call. 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 — into paths, into components/examples (not a stored kind), into an array element, partway into a component — goes through SpecStore.pointer/4 with 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/4 walks arrays (RFC 6901), which Deref's maps-only walk never did, so a ref like #/x-shared/1/schema resolves here and was $unresolved before. More resolution, never less.
  • Path Item $refs are resolved at decompose time, and by different rules: Ingest.resolve_path_items/1 merges the ref node's siblings over the target (OpenAPI 3.1 lets summary/description sit beside the $ref) and bounds its walk by hop count, where Deref replaces the node with the target, dropping siblings, and detects cycles with a visited set. So a path item written as a $ref with 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 $unresolved rather 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

budget()

@type budget() :: non_neg_integer() | :infinity

The resolved bytes a read may spend.

mode()

@type mode() :: :request | :describe

Which sections a read needs resolved.

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

operation(store, ref, op_id, opts)

@spec operation(
  OapiCodemode.SpecStore.store(),
  OapiCodemode.SpecStore.ref(),
  String.t(),
  keyword()
) ::
  {:ok, resolved()} | {:error, :not_found | term()}

Resolves one stored operation's $refs.

Options:

  • :forrequired, :request or :describe. :request resolves parameters and the request body; :describe also resolves responses.
  • :max_bytes — resolved bytes this read may spend, or :infinity (the default).