Current section

Files

Jump to
oaspec src oaspec internal codegen context.gleam
Raw

src/oaspec/internal/codegen/context.gleam

import gleam/dict
import gleam/list
import gleam/option.{type Option, None, Some}
import oaspec/config.{type Config}
import oaspec/internal/openapi/operations
import oaspec/internal/openapi/resolver
import oaspec/internal/openapi/schema.{
type SchemaMetadata, type SchemaObject, type SchemaRef, Inline, Reference,
}
import oaspec/internal/openapi/spec.{
type HttpMethod, type OpenApiSpec, type Operation, type Resolved,
}
import oaspec/internal/util/naming
/// The version of oaspec used for generated code headers.
pub const version = "0.63.0"
/// One analyzed operation: its `operationId` (synthesized when missing),
/// the operation record with path-level parameters, security, and servers
/// already merged in, the URL path it lives under, and the HTTP method.
pub type AnalyzedOperation =
#(String, Operation(Resolved), String, HttpMethod)
/// Context for code generation, carrying all needed state.
/// Only accepts a resolved spec — codegen must not operate on unresolved ASTs.
///
/// Opaque: external callers construct via `new/2` and read fields via
/// the accessors `spec/1` / `config/1` / `operations/1`. The shape is
/// free to evolve (e.g. add more derived caches) without rippling into
/// every pattern match across the codebase.
pub opaque type Context {
Context(
spec: OpenApiSpec(Resolved),
config: Config,
operations: List(AnalyzedOperation),
schema_cache: dict.Dict(String, Result(SchemaObject, resolver.ResolveError)),
component_type_names: dict.Dict(String, Nil),
)
}
/// Create a new generation context from a resolved spec. The list of
/// analyzed operations (with merged path-level params, effective security,
/// effective servers, and synthesized operationIds) plus the component
/// schema-resolution cache are computed once here so every codegen pass can
/// read them via `operations/1` / `resolve_schema_ref/2` instead of
/// rebuilding the same analysis at unrelated call sites (issue #371).
pub fn new(spec: OpenApiSpec(Resolved), config: Config) -> Context {
Context(
spec:,
config:,
operations: operations.collect_operations(spec),
schema_cache: build_schema_cache(spec),
component_type_names: build_component_type_names(spec),
)
}
/// The resolved OpenAPI spec this context wraps.
pub fn spec(ctx: Context) -> OpenApiSpec(Resolved) {
ctx.spec
}
/// The generation config this context wraps.
pub fn config(ctx: Context) -> Config {
ctx.config
}
/// The shared analyzed operations list, precomputed at context construction.
/// Every codegen pass should read this rather than recompute via
/// `operations.collect_operations` directly.
pub fn operations(ctx: Context) -> List(AnalyzedOperation) {
ctx.operations
}
/// Resolve a schema ref through the shared analyzed cache.
///
/// Inline schemas are returned directly. Canonical component refs are served
/// from the cache; anything else falls back to the resolver to preserve the
/// original error shape for unexpected refs.
pub fn resolve_schema_ref(
schema_ref: SchemaRef,
ctx: Context,
) -> Result(SchemaObject, resolver.ResolveError) {
case schema_ref {
Inline(schema_obj) -> Ok(schema_obj)
Reference(ref:, ..) ->
case dict.get(ctx.schema_cache, ref) {
Ok(resolved) -> resolved
// nolint: thrown_away_error -- cache miss falls back to the resolver so non-canonical refs still get the right error/result
Error(_) -> resolver.resolve_schema_ref(schema_ref, ctx.spec)
}
}
}
/// Read metadata for any schema ref through the shared analyzed query layer.
pub fn schema_metadata(
schema_ref: SchemaRef,
ctx: Context,
) -> Option(SchemaMetadata) {
case resolve_schema_ref(schema_ref, ctx) {
Ok(schema_obj) -> Some(schema.get_metadata(schema_obj))
// nolint: thrown_away_error -- unresolved refs have no metadata; callers treat this as absence and the validator reports the underlying ref error separately
Error(_) -> None
}
}
/// Pre-computed set of every component schema name mapped through
/// `naming.schema_to_type_name`, exposed as a `Dict(String, Nil)` so
/// `dict.has_key` is the collision-check primitive. Without this,
/// every inline-enum / synthetic-list-suffix collision check would
/// rebuild the full mapped list, blowing up to O(N_schemas²) on
/// large specs.
pub fn component_type_names(ctx: Context) -> dict.Dict(String, Nil) {
ctx.component_type_names
}
fn build_component_type_names(
spec: OpenApiSpec(Resolved),
) -> dict.Dict(String, Nil) {
case spec.components {
Some(components) ->
list.fold(dict.keys(components.schemas), dict.new(), fn(acc, name) {
dict.insert(acc, naming.schema_to_type_name(name), Nil)
})
None -> dict.new()
}
}
fn build_schema_cache(
spec: OpenApiSpec(Resolved),
) -> dict.Dict(String, Result(SchemaObject, resolver.ResolveError)) {
case spec.components {
Some(components) ->
list.fold(dict.to_list(components.schemas), dict.new(), fn(acc, entry) {
let #(name, _schema_ref) = entry
let ref = component_schema_ref(name)
dict.insert(
acc,
ref,
resolver.resolve_schema_ref(Reference(ref:, name:), spec),
)
})
None -> dict.new()
}
}
fn component_schema_ref(name: String) -> String {
"#/components/schemas/" <> name
}
/// Target for a generated file, indicating where it should be written.
pub type FileTarget {
SharedTarget
ServerTarget
ClientTarget
}
/// How the writer should treat a `GeneratedFile` that already exists on
/// disk. Most generated files are sealed (`Overwrite`) — the user is
/// expected not to touch them and the generator clobbers any local
/// changes on every run. `SkipIfExists` is for files the generator
/// emits ONCE as a starting point, then leaves alone so the user can
/// own the contents (Issue #247: `handlers.gleam` panic stubs).
pub type WriteMode {
Overwrite
SkipIfExists
}
/// A generated file with its path, content, output target, and write
/// mode. `write_mode` defaults to `Overwrite` for every file the
/// generator owns end-to-end.
pub type GeneratedFile {
GeneratedFile(
path: String,
content: String,
target: FileTarget,
write_mode: WriteMode,
)
}