Packages
oaspec
0.61.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/external_loader.gleam
//// Support for external `$ref` values that point at component definitions
//// in a sibling YAML/JSON file — `./other.yaml#/components/schemas/Foo`
//// style. Walks `components.schemas`, pulls referenced schemas from the
//// target file into the main spec, and rewrites the refs to local form.
////
//// Supported shapes:
//// - top-level component schema entries (`components.schemas.Foo: $ref: ...`)
//// - ObjectSchema property values (`properties.field: $ref: ...`)
//// - ArraySchema item values (`items: $ref: ...`)
//// - ObjectSchema additionalProperties values (`additionalProperties: $ref: ...`)
//// - composition branches (`oneOf`, `anyOf`, `allOf` variant refs)
//// - `components.parameters.*.schema: $ref: ...` (parameter schema only)
//// - `components.parameters.*.content.*.schema: $ref: ...`
//// - `components.request_bodies.*.content.*.schema: $ref: ...`
//// - `components.responses.*.content.*.schema: $ref: ...`
//// - operation-level `parameters[*].schema` / `parameters[*].content.*.schema`
//// on both `paths.<path>.parameters` and `paths.<path>.<method>.parameters`
//// - operation-level `requestBody.content.*.schema`
//// - operation-level `responses.<code>.content.*.schema`
//// - `components.headers.*.schema` and every `Response.headers.*.schema`
//// (both under `components.responses` and operation-level responses)
//// - refs inside `components.path_items.*` (reusable PathItem
//// definitions, walked via the same `rewrite_path_item` helper
//// used at the top level)
//// - refs inside `operation.callbacks.*.entries.*` PathItems (recursive)
//// - whole-object external refs for `components.parameters`,
//// `components.requestBodies`, `components.responses`, and
//// `components.pathItems` (e.g., `$ref: './other.yaml#/components/parameters/Foo'`)
//// - one level of alias chaining inside the same external file
//// (`LegacyX: $ref: "#/components/schemas/X"` in the external file).
//// Cross-file chains collapse naturally because parse_file runs
//// the resolver on every loaded file before handing it back, so a
//// target that is itself an external ref is already inline by the
//// time we look it up.
////
//// Out of scope (see issue #98 parent):
//// - alias chains longer than one hop inside the same file
//// - HTTP/HTTPS URLs
////
//// Cyclic external refs (e.g., `A.yaml` → `B.yaml` → `A.yaml`) used
//// to loop forever, but are now detected: `parser.parse_file`
//// threads a visited-file stack through every recursive load and
//// emits a dedicated `invalid_value` diagnostic that shows the cycle
//// path instead of recursing indefinitely (issue #233).
////
//// Name collisions — when an external ref would overwrite an existing local
//// schema, or when two external refs pull in the same fragment name from
//// different files — are surfaced as `Diagnostic` errors rather than
//// silently dropping one side.
import filepath
import gleam/dict
import gleam/list
import gleam/option.{None, Some}
import gleam/result
import oaspec/internal/openapi/external_loader_planner as planner
import oaspec/internal/openapi/schema.{type SchemaRef, Inline, Reference}
import oaspec/internal/openapi/spec.{
type Components, type OpenApiSpec, type Unresolved, Components, OpenApiSpec,
}
import oaspec/internal/util/http
import oaspec/openapi/diagnostic.{type Diagnostic}
/// Load every `components.schemas` entry whose value is an external
/// filesystem ref, merge the referenced schema into the main spec, and
/// rewrite the entry to a local ref. `base_dir` is the directory of the
/// file this spec was loaded from (used to resolve relative paths).
///
/// If `base_dir` is None (spec loaded from string), external refs are
/// treated as unresolvable and passed through unchanged — downstream
/// validation still rejects them.
pub fn resolve_external_component_refs(
spec: OpenApiSpec(Unresolved),
base_dir: option.Option(String),
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
) -> Result(OpenApiSpec(Unresolved), Diagnostic) {
case spec.components, base_dir {
Some(components), Some(dir) -> {
let original_local_names =
planner.local_schema_names(dict.to_list(components.schemas))
use #(top_resolved, top_imported) <- result.try(process_components(
components,
dir,
parse_file,
original_local_names,
))
use whole_resolved <- result.try(process_whole_object_refs(
top_resolved,
dir,
parse_file,
))
use #(nested_resolved, nested_imports) <- result.try(
process_nested_property_refs(
whole_resolved,
dir,
parse_file,
original_local_names,
top_imported,
),
)
use #(param_resolved, param_imports) <- result.try(
process_parameter_schemas(
nested_resolved,
dir,
parse_file,
original_local_names,
nested_imports,
),
)
use #(body_resolved, body_imports) <- result.try(
process_body_response_schemas(
param_resolved,
dir,
parse_file,
original_local_names,
param_imports,
),
)
use #(header_resolved, header_imports) <- result.try(
process_component_headers(
body_resolved,
dir,
parse_file,
original_local_names,
body_imports,
),
)
use #(path_items_resolved, path_items_imports) <- result.try(
process_component_path_items(
header_resolved,
dir,
parse_file,
original_local_names,
header_imports,
),
)
use #(new_paths, op_final_components) <- result.try(
process_operation_schemas(
spec.paths,
path_items_resolved,
dir,
parse_file,
original_local_names,
path_items_imports,
),
)
Ok(
OpenApiSpec(
..spec,
paths: new_paths,
components: Some(op_final_components),
),
)
}
_, _ -> Ok(spec)
}
}
fn process_components(
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
original_local_names: List(String),
) -> Result(#(Components(Unresolved), dict.Dict(String, String)), Diagnostic) {
let entries = dict.to_list(components.schemas)
use #(new_entries, imported) <- result.try(
list.try_fold(entries, #([], dict.new()), fn(acc, entry) {
let #(pending, imported) = acc
let #(name, schema_ref) = entry
case planner.extract_external_ref(schema_ref) {
Some(#(rel_path, fragment_name)) -> {
let resolved_path = filepath.join(base_dir, rel_path)
use _ <- result.try(planner.check_local_collision(
name,
fragment_name,
resolved_path,
original_local_names,
))
use _ <- result.try(planner.check_cross_file_collision(
fragment_name,
resolved_path,
imported,
))
use loaded <- result.try(parse_file(resolved_path))
use target <- result.try(planner.find_external_schema(
loaded,
fragment_name,
resolved_path,
))
// Rewrite the entry to a local ref pointing at the imported schema,
// and emit the target schema under the same fragment name so a
// local lookup succeeds.
let local_ref =
Reference(
ref: "#/components/schemas/" <> fragment_name,
name: fragment_name,
)
let pending = [
#(name, local_ref),
#(fragment_name, target),
..pending
]
let imported = dict.insert(imported, fragment_name, resolved_path)
Ok(#(pending, imported))
}
None -> Ok(#([#(name, schema_ref), ..pending], imported))
}
}),
)
let merged =
list.fold(new_entries, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
Ok(#(Components(..components, schemas: merged), imported))
}
/// Walk `components.parameters`, `request_bodies`, `responses`, and
/// `path_items`: for each `Ref(ref_str)` entry that is an external
/// component ref (e.g., `./other.yaml#/components/parameters/Foo`),
/// load the external file and replace the entry with `Value(loaded_object)`.
/// Runs after `process_components` and before `process_nested_property_refs`.
fn process_whole_object_refs(
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
) -> Result(Components(Unresolved), Diagnostic) {
// parameters
use new_parameters <- result.try(resolve_ref_or_dict(
components.parameters,
base_dir,
parse_file,
"/components/parameters/",
planner.find_external_parameter,
))
// request_bodies
use new_request_bodies <- result.try(resolve_ref_or_dict(
components.request_bodies,
base_dir,
parse_file,
"/components/requestBodies/",
planner.find_external_request_body,
))
// responses
use new_responses <- result.try(resolve_ref_or_dict(
components.responses,
base_dir,
parse_file,
"/components/responses/",
planner.find_external_response,
))
// path_items
use new_path_items <- result.try(resolve_ref_or_dict(
components.path_items,
base_dir,
parse_file,
"/components/pathItems/",
planner.find_external_path_item,
))
Ok(
Components(
..components,
parameters: new_parameters,
request_bodies: new_request_bodies,
responses: new_responses,
path_items: new_path_items,
),
)
}
/// Generic helper that walks a `Dict(String, RefOr(a))`, resolving any
/// `Ref(ref_str)` whose fragment matches `target_prefix` by loading the
/// external file and looking up the named object via `finder`.
fn resolve_ref_or_dict(
entries: dict.Dict(String, spec.RefOr(a)),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
target_prefix: String,
finder: fn(OpenApiSpec(Unresolved), String, String) -> Result(a, Diagnostic),
) -> Result(dict.Dict(String, spec.RefOr(a)), Diagnostic) {
let pairs = dict.to_list(entries)
use new_pairs <- result.try(
list.try_fold(pairs, [], fn(acc, entry) {
let #(name, ref_or_val) = entry
case ref_or_val {
spec.Ref(ref_str) ->
case planner.extract_external_component_ref(ref_str) {
Some(#(file_path, prefix, obj_name)) if prefix == target_prefix -> {
let resolved_path = filepath.join(base_dir, file_path)
use loaded <- result.try(parse_file(resolved_path))
use target <- result.try(finder(loaded, obj_name, resolved_path))
Ok([#(name, spec.Value(target)), ..acc])
}
_ -> Ok([#(name, ref_or_val), ..acc])
}
spec.Value(_) -> Ok([#(name, ref_or_val), ..acc])
}
}),
)
Ok(
list.fold(new_pairs, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
}),
)
}
/// Walk each `components.schemas` entry that is an `Inline(ObjectSchema)` and
/// hoist any property whose value is a relative-file `$ref`. The referenced
/// schema is added to `components.schemas` under its fragment name, and the
/// property is rewritten to a local `#/components/schemas/<fragment>` ref.
///
/// Runs after `process_components` so top-level external refs are already
/// merged in. Collisions are surfaced rather than silently resolved:
/// - if the nested fragment name matches a schema that was originally
/// authored inline in the main spec, error (silent-shadowing guard);
/// - if two refs pull the same fragment name from different source files
/// (whether nested or top-level), error;
/// - same-file re-imports remain idempotent.
fn process_nested_property_refs(
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
original_local_names: List(String),
top_imported: dict.Dict(String, String),
) -> Result(
#(Components(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
let entries = dict.to_list(components.schemas)
// Seed the nested-import tracker with every top-level external import so
// a nested property that re-imports the same fragment from a different
// file is caught as a cross-file collision.
let seeded_imports =
dict.fold(top_imported, dict.new(), fn(d, frag_name, source_path) {
dict.insert(d, frag_name, #(source_path, no_target()))
})
use #(rewritten, imports) <- result.try(
list.try_fold(entries, #([], seeded_imports), fn(acc, entry) {
let #(rewritten_acc, imports) = acc
let #(name, schema_ref) = entry
case schema_ref {
Inline(schema.ObjectSchema(
metadata:,
properties:,
required:,
additional_properties:,
min_properties:,
max_properties:,
)) -> {
use #(new_properties, props_imports) <- result.try(
rewrite_object_properties(
properties,
base_dir,
parse_file,
imports,
original_local_names,
),
)
use #(new_additional_properties, new_imports) <- result.try(
rewrite_additional_properties(
additional_properties,
base_dir,
parse_file,
props_imports,
original_local_names,
),
)
let new_obj =
schema.ObjectSchema(
metadata:,
properties: new_properties,
required:,
additional_properties: new_additional_properties,
min_properties:,
max_properties:,
)
Ok(#([#(name, Inline(new_obj)), ..rewritten_acc], new_imports))
}
Inline(schema.ArraySchema(
metadata:,
items:,
min_items:,
max_items:,
unique_items:,
)) -> {
use #(new_items, new_imports) <- result.try(maybe_hoist_ref(
items,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_arr =
schema.ArraySchema(
metadata:,
items: new_items,
min_items:,
max_items:,
unique_items:,
)
Ok(#([#(name, Inline(new_arr)), ..rewritten_acc], new_imports))
}
Inline(schema.AllOfSchema(metadata:, schemas: branches)) -> {
use #(new_branches, new_imports) <- result.try(rewrite_schema_list(
branches,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_all = schema.AllOfSchema(metadata:, schemas: new_branches)
Ok(#([#(name, Inline(new_all)), ..rewritten_acc], new_imports))
}
Inline(schema.OneOfSchema(metadata:, schemas: branches, discriminator:)) -> {
use #(new_branches, new_imports) <- result.try(rewrite_schema_list(
branches,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_one =
schema.OneOfSchema(metadata:, schemas: new_branches, discriminator:)
Ok(#([#(name, Inline(new_one)), ..rewritten_acc], new_imports))
}
Inline(schema.AnyOfSchema(metadata:, schemas: branches, discriminator:)) -> {
use #(new_branches, new_imports) <- result.try(rewrite_schema_list(
branches,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_any =
schema.AnyOfSchema(metadata:, schemas: new_branches, discriminator:)
Ok(#([#(name, Inline(new_any)), ..rewritten_acc], new_imports))
}
_ -> Ok(#([#(name, schema_ref), ..rewritten_acc], imports))
}
}),
)
let merged =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
// Nested imports populate any slot the rewritten pass hasn't filled.
// Seeded top-level entries carry a placeholder schema (`no_target`)
// and are only skipped — they were already merged during the top-level
// pass.
let merged =
dict.fold(imports, merged, fn(d, frag_name, pair) {
let #(_source_path, target_schema) = pair
case dict.has_key(d, frag_name), target_schema {
True, _ -> d
False, Inline(_) -> dict.insert(d, frag_name, target_schema)
False, _ -> d
}
})
Ok(#(Components(..components, schemas: merged), imports))
}
/// Placeholder target used to seed the nested-import tracker with top-level
/// imports whose schema was already merged. `Reference("", "")` is never
/// emitted as a real value — the merge step filters it out by matching only
/// on `Inline(_)` targets.
fn no_target() -> SchemaRef {
Reference(ref: "", name: "")
}
/// Walk `spec.paths`: for each inline `PathItem`, rewrite its path-level
/// `parameters` list, every populated HTTP method `Operation` (via
/// `rewrite_operation`), and feed any newly imported schemas into
/// `components.schemas`. `RefOr.Ref` path items passing pointers to
/// external files are left untouched.
fn process_operation_schemas(
paths: dict.Dict(String, spec.RefOr(spec.PathItem(Unresolved))),
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
original_local_names: List(String),
seeded_imports: dict.Dict(String, #(String, SchemaRef)),
) -> Result(
#(
dict.Dict(String, spec.RefOr(spec.PathItem(Unresolved))),
Components(Unresolved),
),
Diagnostic,
) {
let entries = dict.to_list(paths)
use #(rewritten, final_imports) <- result.try(
list.try_fold(entries, #([], seeded_imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(path_key, ref_or_item) = entry
case ref_or_item {
spec.Value(path_item) -> {
use #(new_path_item, new_imports) <- result.try(rewrite_path_item(
path_item,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#(
[#(path_key, spec.Value(new_path_item)), ..collected],
new_imports,
))
}
spec.Ref(_) -> Ok(#([#(path_key, ref_or_item), ..collected], imports))
}
}),
)
let new_paths =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
let new_schemas =
dict.fold(final_imports, components.schemas, fn(d, frag_name, pair) {
let #(_source_path, target_schema) = pair
case dict.has_key(d, frag_name), target_schema {
True, _ -> d
False, Inline(_) -> dict.insert(d, frag_name, target_schema)
False, _ -> d
}
})
Ok(#(new_paths, Components(..components, schemas: new_schemas)))
}
/// Rewrite each `RefOr(Parameter)` in a parameter list, threading the
/// imports tracker so collisions across operation-level and components
/// refs remain visible.
fn rewrite_parameter_list(
parameters: List(spec.RefOr(spec.Parameter(Unresolved))),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(
List(spec.RefOr(spec.Parameter(Unresolved))),
dict.Dict(String, #(String, SchemaRef)),
),
Diagnostic,
) {
use #(collected, new_imports) <- result.try(
list.try_fold(parameters, #([], imports), fn(acc, item) {
let #(done, imports) = acc
use #(new_item, new_imports) <- result.try(rewrite_ref_or_parameter(
item,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#([new_item, ..done], new_imports))
}),
)
Ok(#(list.reverse(collected), new_imports))
}
/// Rewrite both the path-level `parameters` list and every populated
/// method slot on a `PathItem`. Shared by the top-level paths walker
/// and the callback walker so callbacks recurse into the same helper.
fn rewrite_path_item(
path_item: spec.PathItem(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(spec.PathItem(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
use #(new_parameters, imports_after_params) <- result.try(
rewrite_parameter_list(
path_item.parameters,
base_dir,
parse_file,
imports,
original_local_names,
),
)
rewrite_path_item_methods(
spec.PathItem(..path_item, parameters: new_parameters),
base_dir,
parse_file,
imports_after_params,
original_local_names,
)
}
/// Rewrite every populated HTTP-method slot on a `PathItem`. Each inline
/// `Operation` has its own `parameters`, `request_body`, and `responses`
/// dicts rewritten.
fn rewrite_path_item_methods(
path_item: spec.PathItem(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(spec.PathItem(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
use #(get_op, imports1) <- result.try(rewrite_optional_operation(
path_item.get,
base_dir,
parse_file,
imports,
original_local_names,
))
use #(post_op, imports2) <- result.try(rewrite_optional_operation(
path_item.post,
base_dir,
parse_file,
imports1,
original_local_names,
))
use #(put_op, imports3) <- result.try(rewrite_optional_operation(
path_item.put,
base_dir,
parse_file,
imports2,
original_local_names,
))
use #(delete_op, imports4) <- result.try(rewrite_optional_operation(
path_item.delete,
base_dir,
parse_file,
imports3,
original_local_names,
))
use #(patch_op, imports5) <- result.try(rewrite_optional_operation(
path_item.patch,
base_dir,
parse_file,
imports4,
original_local_names,
))
use #(head_op, imports6) <- result.try(rewrite_optional_operation(
path_item.head,
base_dir,
parse_file,
imports5,
original_local_names,
))
use #(options_op, imports7) <- result.try(rewrite_optional_operation(
path_item.options,
base_dir,
parse_file,
imports6,
original_local_names,
))
use #(trace_op, final_imports) <- result.try(rewrite_optional_operation(
path_item.trace,
base_dir,
parse_file,
imports7,
original_local_names,
))
Ok(#(
spec.PathItem(
..path_item,
get: get_op,
post: post_op,
put: put_op,
delete: delete_op,
patch: patch_op,
head: head_op,
options: options_op,
trace: trace_op,
),
final_imports,
))
}
fn rewrite_optional_operation(
op: option.Option(spec.Operation(Unresolved)),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(
option.Option(spec.Operation(Unresolved)),
dict.Dict(String, #(String, SchemaRef)),
),
Diagnostic,
) {
case op {
Some(operation) -> {
use #(new_op, new_imports) <- result.try(rewrite_operation(
operation,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#(Some(new_op), new_imports))
}
None -> Ok(#(None, imports))
}
}
fn rewrite_operation(
operation: spec.Operation(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(spec.Operation(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
use #(new_params, imports_after_params) <- result.try(rewrite_parameter_list(
operation.parameters,
base_dir,
parse_file,
imports,
original_local_names,
))
use #(new_body, imports_after_body) <- result.try(
rewrite_optional_request_body(
operation.request_body,
base_dir,
parse_file,
imports_after_params,
original_local_names,
),
)
use #(new_responses, imports_after_responses) <- result.try(
rewrite_response_map(
operation.responses,
base_dir,
parse_file,
imports_after_body,
original_local_names,
),
)
use #(new_callbacks, final_imports) <- result.try(rewrite_callback_map(
operation.callbacks,
base_dir,
parse_file,
imports_after_responses,
original_local_names,
))
Ok(#(
spec.Operation(
..operation,
callbacks: new_callbacks,
parameters: new_params,
request_body: new_body,
responses: new_responses,
),
final_imports,
))
}
/// Walk `operation.callbacks`: for each callback, rewrite every inline
/// PathItem in its `entries` dict via the same `rewrite_path_item`
/// helper used at the top level. `Ref` entries pointing at external
/// PathItem objects pass through unchanged, and now so do top-level
/// `Ref` callback entries (e.g. `myEvent: { $ref: '#/components/callbacks/foo' }`),
/// which we keep as refs rather than inlining.
fn rewrite_callback_map(
callbacks: dict.Dict(String, spec.RefOr(spec.Callback(Unresolved))),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(
dict.Dict(String, spec.RefOr(spec.Callback(Unresolved))),
dict.Dict(String, #(String, SchemaRef)),
),
Diagnostic,
) {
let entries = dict.to_list(callbacks)
use #(rewritten, new_imports) <- result.try(
list.try_fold(entries, #([], imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(name, ref_or_callback) = entry
case ref_or_callback {
spec.Ref(_) as r -> Ok(#([#(name, r), ..collected], imports))
spec.Value(callback) -> {
use #(new_entries, new_imports) <- result.try(
rewrite_callback_entries(
callback.entries,
base_dir,
parse_file,
imports,
original_local_names,
),
)
let new_callback = spec.Value(spec.Callback(entries: new_entries))
Ok(#([#(name, new_callback), ..collected], new_imports))
}
}
}),
)
let new_callbacks =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
Ok(#(new_callbacks, new_imports))
}
/// Rewrite each PathItem inside a Callback.entries dict. The entries
/// map URL-expression strings to `RefOr(PathItem)`; inline PathItems
/// recurse via `rewrite_path_item`, Ref entries pass through.
fn rewrite_callback_entries(
entries: dict.Dict(String, spec.RefOr(spec.PathItem(Unresolved))),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(
dict.Dict(String, spec.RefOr(spec.PathItem(Unresolved))),
dict.Dict(String, #(String, SchemaRef)),
),
Diagnostic,
) {
let pairs = dict.to_list(entries)
use #(rewritten, new_imports) <- result.try(
list.try_fold(pairs, #([], imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(url_expr, ref_or_item) = entry
case ref_or_item {
spec.Value(path_item) -> {
use #(new_path_item, new_imports) <- result.try(rewrite_path_item(
path_item,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#(
[#(url_expr, spec.Value(new_path_item)), ..collected],
new_imports,
))
}
spec.Ref(_) -> Ok(#([#(url_expr, ref_or_item), ..collected], imports))
}
}),
)
let new_map =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
Ok(#(new_map, new_imports))
}
fn rewrite_optional_request_body(
body: option.Option(spec.RefOr(spec.RequestBody(Unresolved))),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(
option.Option(spec.RefOr(spec.RequestBody(Unresolved))),
dict.Dict(String, #(String, SchemaRef)),
),
Diagnostic,
) {
case body {
Some(spec.Value(rb)) -> {
use #(new_content, new_imports) <- result.try(rewrite_media_type_map(
rb.content,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_rb = spec.RequestBody(..rb, content: new_content)
Ok(#(Some(spec.Value(new_rb)), new_imports))
}
Some(spec.Ref(_)) -> Ok(#(body, imports))
None -> Ok(#(None, imports))
}
}
fn rewrite_response_map(
responses: dict.Dict(
http.HttpStatusCode,
spec.RefOr(spec.Response(Unresolved)),
),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(
dict.Dict(http.HttpStatusCode, spec.RefOr(spec.Response(Unresolved))),
dict.Dict(String, #(String, SchemaRef)),
),
Diagnostic,
) {
let entries = dict.to_list(responses)
use #(rewritten, new_imports) <- result.try(
list.try_fold(entries, #([], imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(status, ref_or_resp) = entry
case ref_or_resp {
spec.Value(resp) -> {
use #(new_resp, new_imports) <- result.try(rewrite_response_value(
resp,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#([#(status, spec.Value(new_resp)), ..collected], new_imports))
}
spec.Ref(_) -> Ok(#([#(status, ref_or_resp), ..collected], imports))
}
}),
)
let new_map =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
Ok(#(new_map, new_imports))
}
/// Walk `components.parameters`: for each inline `Parameter` whose payload is
/// a `ParameterSchema(SchemaRef)` pointing at a relative-file external ref,
/// hoist the target schema into `components.schemas` and rewrite the inner
/// ref. Parameters expressed via `content` media type maps, `Ref` placeholders
/// pointing at other parameter objects, and local refs all pass through.
fn process_parameter_schemas(
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
original_local_names: List(String),
seeded_imports: dict.Dict(String, #(String, SchemaRef)),
) -> Result(
#(Components(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
let entries = dict.to_list(components.parameters)
use #(rewritten, imports) <- result.try(
list.try_fold(entries, #([], seeded_imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(name, ref_or_param) = entry
use #(new_param, new_imports) <- result.try(rewrite_ref_or_parameter(
ref_or_param,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#([#(name, new_param), ..collected], new_imports))
}),
)
let new_parameters =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
// Merge any newly-imported schemas into components.schemas that were not
// already present. Seeded entries carry a placeholder target and are
// filtered out by matching on `Inline(_)`.
let new_schemas =
dict.fold(imports, components.schemas, fn(d, frag_name, pair) {
let #(_source_path, target_schema) = pair
case dict.has_key(d, frag_name), target_schema {
True, _ -> d
False, Inline(_) -> dict.insert(d, frag_name, target_schema)
False, _ -> d
}
})
Ok(#(
Components(..components, parameters: new_parameters, schemas: new_schemas),
imports,
))
}
/// Walk `components.request_bodies` and `components.responses`, hoisting
/// any external `$ref` found on a `MediaType.schema` field inside the
/// `content` dict. The imports tracker is shared with earlier passes so
/// cross-file collisions across every component kind stay honest.
/// Entries whose top-level value is a `Ref(...)` (reference to an
/// external body/response object) pass through untouched — handling those
/// requires hoisting the body/response itself, not just its schema.
fn process_body_response_schemas(
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
original_local_names: List(String),
seeded_imports: dict.Dict(String, #(String, SchemaRef)),
) -> Result(
#(Components(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
let body_entries = dict.to_list(components.request_bodies)
use #(new_bodies, imports_after_bodies) <- result.try(
list.try_fold(body_entries, #([], seeded_imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(name, ref_or_body) = entry
case ref_or_body {
spec.Value(body) -> {
use #(new_content, new_imports) <- result.try(rewrite_media_type_map(
body.content,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_body = spec.RequestBody(..body, content: new_content)
Ok(#([#(name, spec.Value(new_body)), ..collected], new_imports))
}
spec.Ref(_) -> Ok(#([#(name, ref_or_body), ..collected], imports))
}
}),
)
let response_entries = dict.to_list(components.responses)
use #(new_responses, final_imports) <- result.try(
list.try_fold(response_entries, #([], imports_after_bodies), fn(acc, entry) {
let #(collected, imports) = acc
let #(name, ref_or_resp) = entry
case ref_or_resp {
spec.Value(resp) -> {
use #(new_resp, new_imports) <- result.try(rewrite_response_value(
resp,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#([#(name, spec.Value(new_resp)), ..collected], new_imports))
}
spec.Ref(_) -> Ok(#([#(name, ref_or_resp), ..collected], imports))
}
}),
)
let new_request_bodies =
list.fold(new_bodies, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
let new_responses_dict =
list.fold(new_responses, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
let new_schemas =
dict.fold(final_imports, components.schemas, fn(d, frag_name, pair) {
let #(_source_path, target_schema) = pair
case dict.has_key(d, frag_name), target_schema {
True, _ -> d
False, Inline(_) -> dict.insert(d, frag_name, target_schema)
False, _ -> d
}
})
Ok(#(
Components(
..components,
request_bodies: new_request_bodies,
responses: new_responses_dict,
schemas: new_schemas,
),
final_imports,
))
}
/// Rewrite a single `RefOr(Parameter)` by hoisting external refs inside
/// either a `ParameterSchema` or a `ParameterContent` payload. `Ref`
/// entries (external parameter-object refs) pass through unchanged.
fn rewrite_ref_or_parameter(
ref_or_param: spec.RefOr(spec.Parameter(spec.Unresolved)),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(
spec.RefOr(spec.Parameter(spec.Unresolved)),
dict.Dict(String, #(String, SchemaRef)),
),
Diagnostic,
) {
case ref_or_param {
spec.Value(p) ->
case p.payload {
spec.ParameterSchema(schema_ref) -> {
use #(new_ref, new_imports) <- result.try(maybe_hoist_ref(
schema_ref,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_param =
spec.Parameter(..p, payload: spec.ParameterSchema(new_ref))
Ok(#(spec.Value(new_param), new_imports))
}
spec.ParameterContent(content) -> {
use #(new_content, new_imports) <- result.try(rewrite_media_type_map(
content,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_param =
spec.Parameter(..p, payload: spec.ParameterContent(new_content))
Ok(#(spec.Value(new_param), new_imports))
}
}
spec.Ref(_) -> Ok(#(ref_or_param, imports))
}
}
/// Rewrite both the `content` media map and the `headers` map of a
/// single Response value. Shared by the components-side and operation-
/// side response walkers so header schemas get the same external-ref
/// treatment as body schemas.
fn rewrite_response_value(
response: spec.Response(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(spec.Response(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
use #(new_content, imports_after_content) <- result.try(
rewrite_media_type_map(
response.content,
base_dir,
parse_file,
imports,
original_local_names,
),
)
use #(new_headers, new_imports) <- result.try(rewrite_header_map(
response.headers,
base_dir,
parse_file,
imports_after_content,
original_local_names,
))
Ok(#(
spec.Response(..response, content: new_content, headers: new_headers),
new_imports,
))
}
/// Walk a `Dict(String, Header)` and hoist any external `$ref` found
/// on `Header.schema`. Headers are not `RefOr(Header)`, so no
/// placeholder handling — just iterate the dict and rewrite each
/// entry that has a schema.
fn rewrite_header_map(
headers: dict.Dict(String, spec.Header),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(dict.Dict(String, spec.Header), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
let entries = dict.to_list(headers)
use #(rewritten, new_imports) <- result.try(
list.try_fold(entries, #([], imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(name, header) = entry
case header.schema {
Some(ref) -> {
use #(new_ref, new_imports) <- result.try(maybe_hoist_ref(
ref,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_header = spec.Header(..header, schema: Some(new_ref))
Ok(#([#(name, new_header), ..collected], new_imports))
}
None -> Ok(#([#(name, header), ..collected], imports))
}
}),
)
let new_map =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
Ok(#(new_map, new_imports))
}
/// Walk `components.headers` (plain `Dict(String, Header)`, not
/// `RefOr`) and hoist any external `$ref` found on `Header.schema`.
/// Runs after the other components-side passes so imported schemas
/// continue to flow into `components.schemas`.
fn process_component_headers(
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
original_local_names: List(String),
seeded_imports: dict.Dict(String, #(String, SchemaRef)),
) -> Result(
#(Components(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
use #(new_headers, final_imports) <- result.try(rewrite_header_map(
components.headers,
base_dir,
parse_file,
seeded_imports,
original_local_names,
))
let new_schemas =
dict.fold(final_imports, components.schemas, fn(d, frag_name, pair) {
let #(_source_path, target_schema) = pair
case dict.has_key(d, frag_name), target_schema {
True, _ -> d
False, Inline(_) -> dict.insert(d, frag_name, target_schema)
False, _ -> d
}
})
Ok(#(
Components(..components, headers: new_headers, schemas: new_schemas),
final_imports,
))
}
/// Walk `components.path_items`: for each inline `PathItem`, rewrite
/// its parameters, method operations, and callbacks via the shared
/// `rewrite_path_item` helper — the same one used for top-level
/// `spec.paths` and callback PathItems. `Ref` entries pointing at
/// external PathItem objects pass through unchanged.
fn process_component_path_items(
components: Components(Unresolved),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
original_local_names: List(String),
seeded_imports: dict.Dict(String, #(String, SchemaRef)),
) -> Result(
#(Components(Unresolved), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
let entries = dict.to_list(components.path_items)
use #(rewritten, final_imports) <- result.try(
list.try_fold(entries, #([], seeded_imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(name, ref_or_item) = entry
case ref_or_item {
spec.Value(path_item) -> {
use #(new_path_item, new_imports) <- result.try(rewrite_path_item(
path_item,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#([#(name, spec.Value(new_path_item)), ..collected], new_imports))
}
spec.Ref(_) -> Ok(#([#(name, ref_or_item), ..collected], imports))
}
}),
)
let new_path_items =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
let new_schemas =
dict.fold(final_imports, components.schemas, fn(d, frag_name, pair) {
let #(_source_path, target_schema) = pair
case dict.has_key(d, frag_name), target_schema {
True, _ -> d
False, Inline(_) -> dict.insert(d, frag_name, target_schema)
False, _ -> d
}
})
Ok(#(
Components(..components, path_items: new_path_items, schemas: new_schemas),
final_imports,
))
}
/// Walk every MediaType in a content-type dict and hoist any external ref
/// found on `MediaType.schema`. MediaType values with no schema pass
/// through unchanged.
fn rewrite_media_type_map(
content: dict.Dict(String, spec.MediaType),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(dict.Dict(String, spec.MediaType), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
let entries = dict.to_list(content)
use #(rewritten, new_imports) <- result.try(
list.try_fold(entries, #([], imports), fn(acc, entry) {
let #(collected, imports) = acc
let #(media_key, media) = entry
case media.schema {
Some(ref) -> {
use #(new_ref, new_imports) <- result.try(maybe_hoist_ref(
ref,
base_dir,
parse_file,
imports,
original_local_names,
))
let new_media = spec.MediaType(..media, schema: Some(new_ref))
Ok(#([#(media_key, new_media), ..collected], new_imports))
}
None -> Ok(#([#(media_key, media), ..collected], imports))
}
}),
)
let new_content =
list.fold(rewritten, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
Ok(#(new_content, new_imports))
}
/// Walk the properties dict of an ObjectSchema; for each property that is a
/// relative-file `$ref`, resolve it, stage the target schema for merging,
/// and rewrite the property to a local ref. Non-external property refs pass
/// through unchanged.
fn rewrite_object_properties(
properties: dict.Dict(String, SchemaRef),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(dict.Dict(String, SchemaRef), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
let entries = dict.to_list(properties)
use #(new_entries, new_imports) <- result.try(
list.try_fold(entries, #([], imports), fn(acc, entry) {
let #(prop_acc, imports) = acc
let #(prop_name, prop_ref) = entry
use #(new_ref, new_imports) <- result.try(maybe_hoist_ref(
prop_ref,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#([#(prop_name, new_ref), ..prop_acc], new_imports))
}),
)
let new_properties =
list.fold(new_entries, dict.new(), fn(d, pair) {
dict.insert(d, pair.0, pair.1)
})
Ok(#(new_properties, new_imports))
}
/// Walk an ObjectSchema's `additionalProperties` value; when it is a
/// `Typed(SchemaRef)` whose ref is external, hoist the target schema and
/// rewrite the inner ref to a local reference. `Forbidden` and `Untyped`
/// pass through untouched.
fn rewrite_additional_properties(
additional: schema.AdditionalProperties,
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(schema.AdditionalProperties, dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
case additional {
schema.Typed(ref) -> {
use #(new_ref, new_imports) <- result.try(maybe_hoist_ref(
ref,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#(schema.Typed(new_ref), new_imports))
}
_ -> Ok(#(additional, imports))
}
}
/// Walk a `List(SchemaRef)` produced by an allOf/oneOf/anyOf composition,
/// hoisting any relative-file external ref the same way property values
/// and array items are handled. Branches that are inline or local refs
/// pass through unchanged.
fn rewrite_schema_list(
branches: List(SchemaRef),
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(
#(List(SchemaRef), dict.Dict(String, #(String, SchemaRef))),
Diagnostic,
) {
use #(rewritten, new_imports) <- result.try(
list.try_fold(branches, #([], imports), fn(acc, branch) {
let #(collected, imports) = acc
use #(new_branch, new_imports) <- result.try(maybe_hoist_ref(
branch,
base_dir,
parse_file,
imports,
original_local_names,
))
Ok(#([new_branch, ..collected], new_imports))
}),
)
Ok(#(list.reverse(rewritten), new_imports))
}
/// Core external-ref hoisting step shared by the property-value and
/// array-items resolvers. If `schema_ref` is a relative-file external ref,
/// the referenced schema is staged for merging and the ref is rewritten to
/// a local `#/components/schemas/<fragment>` ref. Otherwise the ref passes
/// through unchanged.
fn maybe_hoist_ref(
schema_ref: SchemaRef,
base_dir: String,
parse_file: fn(String) -> Result(OpenApiSpec(Unresolved), Diagnostic),
imports: dict.Dict(String, #(String, SchemaRef)),
original_local_names: List(String),
) -> Result(#(SchemaRef, dict.Dict(String, #(String, SchemaRef))), Diagnostic) {
case planner.extract_external_ref(schema_ref) {
Some(#(rel_path, fragment_name)) -> {
let resolved_path = filepath.join(base_dir, rel_path)
use _ <- result.try(planner.check_nested_local_collision(
fragment_name,
resolved_path,
original_local_names,
))
use _ <- result.try(planner.check_nested_cross_file_collision(
fragment_name,
resolved_path,
imports,
))
use loaded <- result.try(parse_file(resolved_path))
use target <- result.try(planner.find_external_schema(
loaded,
fragment_name,
resolved_path,
))
let local_ref =
Reference(
ref: "#/components/schemas/" <> fragment_name,
name: fragment_name,
)
let new_imports =
dict.insert(imports, fragment_name, #(resolved_path, target))
Ok(#(local_ref, new_imports))
}
None -> Ok(#(schema_ref, imports))
}
}