Packages
oaspec
0.29.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/dedup.gleam
import gleam/dict.{type Dict}
import gleam/int
import gleam/list
import gleam/option.{None, Some}
import oaspec/internal/openapi/schema.{
type SchemaObject, type SchemaRef, AnyOfSchema, Forbidden, Inline,
ObjectSchema, OneOfSchema, Reference, Typed, Unspecified, Untyped,
}
import oaspec/internal/openapi/spec.{type OpenApiSpec, Components, OpenApiSpec}
import oaspec/internal/util/naming
/// Deduplicate names within schemas to avoid collisions in generated code.
/// This is a pre-processing pass that runs after hoisting and before validation.
///
/// Scope is intentionally limited: operationId / function-name uniqueness is
/// enforced by `oaspec/internal/codegen/validate.gleam` with a hard error, not by a
/// silent rename, because renaming mutates the generated public API surface
/// without telling the user (see issue #237). Property name and enum variant
/// deduplication is done at codegen time via dedup_property_names/1 and
/// dedup_enum_variants/1 to preserve JSON wire names.
pub fn dedup(spec: OpenApiSpec(stage)) -> OpenApiSpec(stage) {
spec
|> dedup_schemas
}
/// Recurse into nested schemas within components (e.g. oneOf children).
fn dedup_schemas(spec: OpenApiSpec(stage)) -> OpenApiSpec(stage) {
case spec.components {
None -> spec
Some(components) -> {
let new_schemas =
dict.to_list(components.schemas)
|> list.map(fn(entry) {
let #(name, schema_ref) = entry
#(name, dedup_schema_ref(schema_ref))
})
|> dict.from_list()
OpenApiSpec(
..spec,
components: Some(Components(..components, schemas: new_schemas)),
)
}
}
}
fn dedup_schema_ref(schema_ref: SchemaRef) -> SchemaRef {
case schema_ref {
Reference(..) -> schema_ref
Inline(schema_obj) -> Inline(dedup_schema_object(schema_obj))
}
}
fn dedup_schema_object(schema_obj: SchemaObject) -> SchemaObject {
case schema_obj {
ObjectSchema(properties:, additional_properties:, ..) as obj -> {
// Only recurse into child schemas — do NOT rename property keys.
let new_props =
dict.to_list(properties)
|> list.map(fn(entry) {
let #(name, prop_ref) = entry
#(name, dedup_schema_ref(prop_ref))
})
|> dict.from_list()
ObjectSchema(
..obj,
properties: new_props,
additional_properties: case additional_properties {
Typed(ap) -> Typed(dedup_schema_ref(ap))
Forbidden -> Forbidden
Untyped -> Untyped
Unspecified -> Unspecified
},
)
}
// Do NOT rename enum values — they are JSON wire values.
// Gleam variant deduplication is handled at codegen time via
// dedup_enum_variants/1.
OneOfSchema(metadata:, schemas:, discriminator:) ->
OneOfSchema(
metadata:,
schemas: list.map(schemas, dedup_schema_ref),
discriminator:,
)
AnyOfSchema(metadata:, schemas:, discriminator:) ->
AnyOfSchema(
metadata:,
schemas: list.map(schemas, dedup_schema_ref),
discriminator:,
)
_ -> schema_obj
}
}
/// Given a list of original property names (JSON wire names), return a list
/// of deduped snake_case Gleam field names. The returned list is parallel
/// to the input: result[i] is the Gleam name for input[i].
pub fn dedup_property_names(prop_names: List(String)) -> List(String) {
let snake_names = list.map(prop_names, naming.to_snake_case)
deduplicate_strings(snake_names)
}
/// Given the parameters of a single operation, return a parallel list of
/// deduped snake_case Gleam field names. Parameters whose wire names map to
/// the same snake_case field (e.g. `id` in path AND `id` in query) get the
/// same `_2`/`_3` suffix treatment used for property names. The reserved
/// label `body` is taken first so a parameter literally named `body` is
/// renamed instead of clashing with the request type's body field.
///
/// The function is order-sensitive: the first occurrence keeps its base
/// snake_case form, later occurrences get the suffix. Pass the parameters
/// in the same order the spec lists them so type emission, server dispatch,
/// and client builder agree on the final field name.
pub fn dedup_param_field_names(
params: List(spec.Parameter(stage)),
) -> List(String) {
let snake_names = list.map(params, fn(p) { naming.to_snake_case(p.name) })
let with_body_reserved = ["body", ..snake_names]
case deduplicate_strings(with_body_reserved) {
[_body, ..rest] -> rest
_ -> []
}
}
/// Given a list of original enum values (JSON wire values), return a list
/// of deduped PascalCase Gleam variant suffixes. The returned list is
/// parallel to the input.
pub fn dedup_enum_variants(enum_values: List(String)) -> List(String) {
let pascal_names = list.map(enum_values, naming.to_pascal_case)
deduplicate_strings(pascal_names)
}
/// Deduplicate a list of strings by appending "_2", "_3", etc. for duplicates.
/// The chosen suffix skips any name that already appears elsewhere in the
/// input (so a later literal `foo_2` keeps its label and an earlier
/// duplicate `foo` advances to `foo_3`) and any suffix this call has
/// already handed out, so the output has no collisions in either
/// direction.
fn deduplicate_strings(names: List(String)) -> List(String) {
let input_names =
list.fold(names, dict.new(), fn(acc, name) { dict.insert(acc, name, True) })
let #(result_rev, _) =
list.fold(names, #([], dict.new()), fn(acc, name) {
let #(result, claimed) = acc
case dict.has_key(claimed, name) {
False -> #([name, ..result], dict.insert(claimed, name, True))
True -> {
let unique_name = next_unique_name(name, input_names, claimed, 2)
#([unique_name, ..result], dict.insert(claimed, unique_name, True))
}
}
})
list.reverse(result_rev)
}
/// Pick the first `base_<n>` suffix that collides neither with another
/// literal input name nor with a name this call has already minted.
fn next_unique_name(
base: String,
input_names: Dict(String, Bool),
claimed: Dict(String, Bool),
suffix: Int,
) -> String {
let candidate = base <> "_" <> int.to_string(suffix)
case
dict.has_key(input_names, candidate) || dict.has_key(claimed, candidate)
{
True -> next_unique_name(base, input_names, claimed, suffix + 1)
False -> candidate
}
}