Packages
oaspec
0.49.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.0
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.1
0.58.0
0.57.0
0.56.0
0.55.0
0.54.0
0.53.0
0.52.0
0.51.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.3
0.6.1
0.6.0
0.5.0
0.4.0
0.3.0
0.1.3
Generate Gleam code from OpenAPI 3.x specifications
Current section
Files
Jump to
Current section
Files
src/oaspec/internal/openapi/resolve.gleam
import gleam/bool
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/set
import gleam/string
import oaspec/internal/openapi/spec.{
type Callback, type Components, type OpenApiSpec, type Operation,
type Parameter, type PathItem, type RefOr, type RequestBody, type Resolved,
type Response, type Unresolved, Callback, Components, Operation, PathItem, Ref,
Value,
}
import oaspec/openapi/diagnostic.{type Diagnostic, NoSourceLoc}
/// Resolve all RefOr aliases in the spec.
/// Call after parse and normalize, before capability_check and codegen.
/// Resolves both component-level aliases and inline $ref within operations.
pub fn resolve(
spec: OpenApiSpec(Unresolved),
) -> Result(OpenApiSpec(Resolved), List(Diagnostic)) {
use resolved <- result.try(resolve_internal(spec))
Ok(coerce_stage(resolved))
}
/// Safe phantom type cast — `stage` has no runtime representation, so
/// `coerce_stage` is the identity function under a different phantom
/// label. Declared as a target-specific external so the cast is a true
/// no-op on both BEAM and JavaScript runtimes (avoids the cost of a
/// destructure-and-rebuild that pure Gleam would otherwise require).
@external(erlang, "gleam_stdlib", "identity")
@external(javascript, "../gleam_stdlib/gleam_stdlib.mjs", "identity")
fn coerce_stage(spec: OpenApiSpec(a)) -> OpenApiSpec(b)
/// Internal resolve that preserves the input stage parameter.
fn resolve_internal(
spec: OpenApiSpec(stage),
) -> Result(OpenApiSpec(stage), List(Diagnostic)) {
let empty_components =
Components(
schemas: dict.new(),
parameters: dict.new(),
request_bodies: dict.new(),
responses: dict.new(),
security_schemes: dict.new(),
path_items: dict.new(),
headers: dict.new(),
examples: dict.new(),
links: dict.new(),
callbacks: dict.new(),
)
case spec.components {
None -> {
// Even without components, paths/webhooks may contain inline $ref
// that must be resolved (or reported as errors).
use resolved_paths <- result.try(resolve_inline_paths(
spec.paths,
empty_components,
))
use resolved_webhooks <- result.try(resolve_inline_paths(
spec.webhooks,
empty_components,
))
Ok(
spec.OpenApiSpec(
..spec,
paths: resolved_paths,
webhooks: resolved_webhooks,
),
)
}
Some(components) -> {
use parameters <- result.try(
resolve_component_dict(components.parameters, "components.parameters")
|> result.map_error(fn(e) { [e] }),
)
use request_bodies <- result.try(
resolve_component_dict(
components.request_bodies,
"components.requestBodies",
)
|> result.map_error(fn(e) { [e] }),
)
use responses <- result.try(
resolve_component_dict(components.responses, "components.responses")
|> result.map_error(fn(e) { [e] }),
)
use security_schemes <- result.try(
resolve_component_dict(
components.security_schemes,
"components.securitySchemes",
)
|> result.map_error(fn(e) { [e] }),
)
use path_items <- result.try(
resolve_component_dict(components.path_items, "components.pathItems")
|> result.map_error(fn(e) { [e] }),
)
let resolved_components =
Components(
..components,
parameters: parameters,
request_bodies: request_bodies,
responses: responses,
security_schemes: security_schemes,
path_items: path_items,
)
// Resolve inline $ref in paths and webhooks
use resolved_paths <- result.try(resolve_inline_paths(
spec.paths,
resolved_components,
))
use resolved_webhooks <- result.try(resolve_inline_paths(
spec.webhooks,
resolved_components,
))
Ok(
spec.OpenApiSpec(
..spec,
components: Some(resolved_components),
paths: resolved_paths,
webhooks: resolved_webhooks,
),
)
}
}
}
/// Resolve all aliases in a component dict.
/// After resolution, all entries are Value.
fn resolve_component_dict(
entries: Dict(String, RefOr(a)),
context: String,
) -> Result(Dict(String, RefOr(a)), Diagnostic) {
dict.to_list(entries)
|> list.try_fold(entries, fn(acc, entry) {
let #(name, value) = entry
case value {
Value(_) -> Ok(acc)
Ref(ref) -> {
use resolved <- result.try(resolve_alias(
entries,
ref,
context <> "." <> name,
set.new(),
))
Ok(dict.insert(acc, name, Value(resolved)))
}
}
})
}
/// Follow a $ref chain to find the concrete value.
fn resolve_alias(
entries: Dict(String, RefOr(a)),
ref: String,
context: String,
seen: set.Set(String),
) -> Result(a, Diagnostic) {
use <- bool.guard(
set.contains(seen, ref),
Error(diagnostic.resolve_error(
path: context,
detail: "Circular component alias detected: " <> ref,
hint: Some(
"Check that component $ref chains don't form a cycle. Each $ref must eventually point to a concrete definition.",
),
loc: NoSourceLoc,
)),
)
let new_seen = set.insert(seen, ref)
let ref_name = extract_ref_name(ref)
case dict.get(entries, ref_name) {
Ok(Value(value)) -> Ok(value)
Ok(Ref(next_ref)) -> resolve_alias(entries, next_ref, context, new_seen)
Error(_) ->
Error(diagnostic.resolve_error(
path: context,
detail: "Unresolved component alias: "
<> ref
<> " — target '"
<> ref_name
<> "' not found.",
hint: Some(
"Verify the target component exists and the $ref path is spelled correctly.",
),
loc: NoSourceLoc,
))
}
}
/// Extract the last segment of a $ref path.
fn extract_ref_name(ref: String) -> String {
ref
|> string.split("/")
|> list.last
|> result.unwrap("unknown")
}
/// Validate that a $ref string points to the expected component kind.
/// Returns Ok(name) if valid, Error(diagnostic) if wrong kind or external.
fn validate_ref_kind(
ref_str: String,
expected_prefix: String,
context_path: String,
) -> Result(String, Diagnostic) {
use <- bool.guard(
string.starts_with(ref_str, expected_prefix),
Ok(extract_ref_name(ref_str)),
)
Error(diagnostic.resolve_error(
path: context_path,
detail: "$ref '"
<> ref_str
<> "' points to wrong component kind; expected prefix '"
<> expected_prefix
<> "'",
hint: Some(
"Use the correct $ref prefix for the component type (e.g., #/components/schemas/ for schemas).",
),
loc: NoSourceLoc,
))
}
// ============================================================================
// Inline ref resolution: resolve Ref(...) within paths, operations, etc.
// ============================================================================
/// Resolve inline refs in a paths dict by looking up components.
fn resolve_inline_paths(
paths: Dict(String, RefOr(PathItem(stage))),
components: Components(stage),
) -> Result(Dict(String, RefOr(PathItem(stage))), List(Diagnostic)) {
dict.to_list(paths)
|> list.try_fold(dict.new(), fn(acc, entry) {
let #(path, ref_or) = entry
case ref_or {
Ref(ref_str) ->
case resolve_path_item_ref(ref_str, path, components) {
Ok(resolved) -> Ok(dict.insert(acc, path, resolved))
Error(e) -> Error([e])
}
Value(pi) ->
case resolve_inline_path_item(pi, path, components) {
Ok(resolved_pi) -> Ok(dict.insert(acc, path, Value(resolved_pi)))
Error(errs) -> Error(errs)
}
}
})
}
/// Resolve a path-level $ref by looking it up in components.pathItems.
fn resolve_path_item_ref(
ref_str: String,
path: String,
components: Components(stage),
) -> Result(RefOr(PathItem(stage)), Diagnostic) {
use ref_name <- result.try(validate_ref_kind(
ref_str,
"#/components/pathItems/",
"paths." <> path,
))
case dict.get(components.path_items, ref_name) {
Ok(Value(pi)) ->
case resolve_inline_path_item(pi, path, components) {
Ok(resolved_pi) -> Ok(Value(resolved_pi))
Error(errs) -> {
// Take the first diagnostic from the inner resolution
case errs {
[first, ..] -> Error(first)
[] ->
Error(diagnostic.resolve_error(
path: "paths." <> path,
detail: "Unresolved $ref: "
<> ref_str
<> " — target not found in components",
hint: Some(
"Verify the referenced component exists and the $ref path is spelled correctly.",
),
loc: NoSourceLoc,
))
}
}
}
Ok(Ref(_)) ->
Error(diagnostic.resolve_error(
path: "paths." <> path,
detail: "Unresolved $ref: "
<> ref_str
<> " — target not found in components",
hint: Some(
"Verify the referenced component exists and the $ref path is spelled correctly.",
),
loc: NoSourceLoc,
))
Error(_) ->
Error(diagnostic.resolve_error(
path: "paths." <> path,
detail: "Unresolved $ref: "
<> ref_str
<> " — target not found in components",
hint: Some(
"Verify the referenced component exists and the $ref path is spelled correctly.",
),
loc: NoSourceLoc,
))
}
}
/// Resolve inline refs within a path item.
fn resolve_inline_path_item(
pi: PathItem(stage),
path: String,
components: Components(stage),
) -> Result(PathItem(stage), List(Diagnostic)) {
use get <- result.try(
option_try(pi.get, resolve_inline_operation(_, path, components)),
)
use post <- result.try(
option_try(pi.post, resolve_inline_operation(_, path, components)),
)
use put <- result.try(
option_try(pi.put, resolve_inline_operation(_, path, components)),
)
use delete <- result.try(
option_try(pi.delete, resolve_inline_operation(_, path, components)),
)
use patch <- result.try(
option_try(pi.patch, resolve_inline_operation(_, path, components)),
)
use head <- result.try(
option_try(pi.head, resolve_inline_operation(_, path, components)),
)
use options <- result.try(
option_try(pi.options, resolve_inline_operation(_, path, components)),
)
use trace <- result.try(
option_try(pi.trace, resolve_inline_operation(_, path, components)),
)
use parameters <- result.try(
list.try_map(pi.parameters, fn(p) {
resolve_param_ref(p, path, components)
|> result.map_error(fn(e) { [e] })
}),
)
Ok(
PathItem(
..pi,
get: get,
post: post,
put: put,
delete: delete,
patch: patch,
head: head,
options: options,
trace: trace,
parameters: parameters,
),
)
}
/// Resolve inline refs within an operation.
fn resolve_inline_operation(
op: Operation(stage),
path: String,
components: Components(stage),
) -> Result(Operation(stage), List(Diagnostic)) {
use parameters <- result.try(
list.try_map(op.parameters, fn(p) {
resolve_param_ref(p, path, components)
|> result.map_error(fn(e) { [e] })
}),
)
use request_body <- result.try(
option_try(op.request_body, fn(rb) {
resolve_request_body_ref(rb, path, components)
|> result.map_error(fn(e) { [e] })
}),
)
use responses <- result.try(
try_map_values(op.responses, fn(_code, ref_or) {
resolve_response_ref(ref_or, path, components)
|> result.map_error(fn(e) { [e] })
}),
)
use callbacks <- result.try(
try_map_values(op.callbacks, fn(_name, ref_or_cb) {
resolve_callback_ref(ref_or_cb, path, components)
}),
)
Ok(
Operation(
..op,
parameters: parameters,
request_body: request_body,
responses: responses,
callbacks: callbacks,
),
)
}
/// Resolve a `RefOr(Callback)`: top-level `$ref` entries are walked
/// through `components.callbacks` — aliases (`Foo -> Ref(Bar)`) are
/// allowed up to a small bound, but the chain must eventually land on
/// a concrete `Value(Callback)`. Cycles, dangling targets, and
/// non-callback refs become a single `invalid_value` diagnostic.
fn resolve_callback_ref(
ref_or: RefOr(Callback(stage)),
path: String,
components: Components(stage),
) -> Result(RefOr(Callback(stage)), List(Diagnostic)) {
case ref_or {
Ref(ref_str) ->
follow_callback_ref_chain(ref_str, path, components, [])
|> result.map(fn(_) { Ref(ref_str) })
|> result.map_error(fn(e) { [e] })
Value(cb) ->
case resolve_inline_callback(cb, path, components) {
Ok(resolved) -> Ok(Value(resolved))
Error(errs) -> Error(errs)
}
}
}
/// Walk a callback `$ref` chain until it lands on `Value(Callback)`,
/// refusing cycles and nonsensical targets. `seen` is the list of ref
/// strings we have already visited on this chain.
fn follow_callback_ref_chain(
ref_str: String,
path: String,
components: Components(stage),
seen: List(String),
) -> Result(Nil, Diagnostic) {
use <- bool.guard(
list.contains(seen, ref_str),
Error(diagnostic.invalid_value(
path: "paths." <> path <> ".callbacks",
detail: "Callback $ref chain is cyclic; visited: "
<> string.join(list.reverse([ref_str, ..seen]), " -> ")
<> ".",
loc: diagnostic.NoSourceLoc,
)),
)
use ref_name <- result.try(validate_ref_kind(
ref_str,
"#/components/callbacks/",
"paths." <> path,
))
case dict.get(components.callbacks, ref_name) {
Ok(Value(_)) -> Ok(Nil)
Ok(Ref(next_ref)) ->
follow_callback_ref_chain(next_ref, path, components, [ref_str, ..seen])
Error(_) ->
Error(diagnostic.invalid_value(
path: "paths." <> path <> ".callbacks",
detail: "Callback $ref '"
<> ref_str
<> "' does not resolve to an entry in components.callbacks.",
loc: diagnostic.NoSourceLoc,
))
}
}
/// Resolve inline refs within a callback.
fn resolve_inline_callback(
cb: Callback(stage),
path: String,
components: Components(stage),
) -> Result(Callback(stage), List(Diagnostic)) {
use entries <- result.try(
try_map_values(cb.entries, fn(_url, ref_or) {
case ref_or {
Ref(ref_str) ->
resolve_path_item_ref(ref_str, path, components)
|> result.map_error(fn(e) { [e] })
Value(pi) ->
case resolve_inline_path_item(pi, path, components) {
Ok(resolved_pi) -> Ok(Value(resolved_pi))
Error(errs) -> Error(errs)
}
}
}),
)
Ok(Callback(entries: entries))
}
/// Resolve a parameter Ref by looking it up in components.parameters.
fn resolve_param_ref(
ref_or: RefOr(Parameter(stage)),
path: String,
components: Components(stage),
) -> Result(RefOr(Parameter(stage)), Diagnostic) {
case ref_or {
Value(_) -> Ok(ref_or)
Ref(ref_str) -> {
use ref_name <- result.try(validate_ref_kind(
ref_str,
"#/components/parameters/",
"paths." <> path,
))
case dict.get(components.parameters, ref_name) {
Ok(Value(p)) -> Ok(Value(p))
_ ->
Error(diagnostic.resolve_error(
path: "paths." <> path,
detail: "Unresolved $ref: "
<> ref_str
<> " — target not found in components.parameters",
hint: Some("Verify the parameter exists in components.parameters."),
loc: NoSourceLoc,
))
}
}
}
}
/// Resolve a request body Ref by looking it up in components.request_bodies.
fn resolve_request_body_ref(
ref_or: RefOr(RequestBody(stage)),
path: String,
components: Components(stage),
) -> Result(RefOr(RequestBody(stage)), Diagnostic) {
case ref_or {
Value(_) -> Ok(ref_or)
Ref(ref_str) -> {
use ref_name <- result.try(validate_ref_kind(
ref_str,
"#/components/requestBodies/",
"paths." <> path,
))
case dict.get(components.request_bodies, ref_name) {
Ok(Value(rb)) -> Ok(Value(rb))
_ ->
Error(diagnostic.resolve_error(
path: "paths." <> path,
detail: "Unresolved $ref: "
<> ref_str
<> " — target not found in components.requestBodies",
hint: Some(
"Verify the request body exists in components.requestBodies.",
),
loc: NoSourceLoc,
))
}
}
}
}
/// Resolve a response Ref by looking it up in components.responses.
fn resolve_response_ref(
ref_or: RefOr(Response(stage)),
path: String,
components: Components(stage),
) -> Result(RefOr(Response(stage)), Diagnostic) {
case ref_or {
Value(_) -> Ok(ref_or)
Ref(ref_str) -> {
use ref_name <- result.try(validate_ref_kind(
ref_str,
"#/components/responses/",
"paths." <> path,
))
case dict.get(components.responses, ref_name) {
Ok(Value(r)) -> Ok(Value(r))
_ ->
Error(diagnostic.resolve_error(
path: "paths." <> path,
detail: "Unresolved $ref: "
<> ref_str
<> " — target not found in components.responses",
hint: Some("Verify the response exists in components.responses."),
loc: NoSourceLoc,
))
}
}
}
}
/// Map over an Option value with a fallible function.
fn option_try(
opt: Option(a),
f: fn(a) -> Result(a, List(Diagnostic)),
) -> Result(Option(a), List(Diagnostic)) {
case opt {
Some(v) -> result.map(f(v), Some)
None -> Ok(None)
}
}
/// Map over dict values with a fallible function, collecting first error.
fn try_map_values(
d: Dict(k, a),
f: fn(k, a) -> Result(a, List(Diagnostic)),
) -> Result(Dict(k, a), List(Diagnostic)) {
dict.to_list(d)
|> list.try_fold(dict.new(), fn(acc, entry) {
let #(key, value) = entry
use new_value <- result.try(f(key, value))
Ok(dict.insert(acc, key, new_value))
})
}