Packages
oaspec
0.8.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/codegen/types.gleam
import gleam/dict
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import oaspec/codegen/context.{type Context, type GeneratedFile, GeneratedFile}
import oaspec/codegen/ir_build
import oaspec/codegen/ir_render
import oaspec/codegen/schema_dispatch
import oaspec/openapi/resolver
import oaspec/openapi/schema.{
type SchemaObject, type SchemaRef, AllOfSchema, AnyOfSchema, ArraySchema,
BooleanSchema, Inline, IntegerSchema, NumberSchema, ObjectSchema, OneOfSchema,
Reference, StringSchema,
}
import oaspec/openapi/spec.{type SpecStage, Value}
import oaspec/util/http
import oaspec/util/naming
import oaspec/util/string_extra as se
/// Generate type definitions from OpenAPI schemas.
pub fn generate(ctx: Context) -> List(GeneratedFile) {
let types_content = generate_types(ctx)
let request_types_content = generate_request_types(ctx)
let response_types_content = generate_response_types(ctx)
[
GeneratedFile(
path: "types.gleam",
content: types_content,
target: context.SharedTarget,
),
GeneratedFile(
path: "request_types.gleam",
content: request_types_content,
target: context.SharedTarget,
),
GeneratedFile(
path: "response_types.gleam",
content: response_types_content,
target: context.SharedTarget,
),
]
}
/// Generate types from component schemas and anonymous types from operations.
/// Delegates to the IR pipeline: build IR declarations, then render to source.
fn generate_types(ctx: Context) -> String {
ir_build.build_types_module(ctx)
|> ir_render.render()
}
/// Convert a SchemaRef to a qualified Gleam type string (with types. prefix).
/// Used in response_types and request_types where types are in a separate module.
fn schema_ref_to_type_qualified(
ref: SchemaRef,
op_id: String,
suffix: String,
ctx: Context,
) -> String {
case ref {
Inline(schema_obj) ->
schema_to_gleam_type_qualified(schema_obj, op_id, suffix, ctx)
Reference(name:, ..) -> {
"types." <> naming.schema_to_type_name(name)
}
}
}
/// Convert a schema to a qualified Gleam type with types. prefix for compound types.
fn schema_to_gleam_type_qualified(
schema_obj: SchemaObject,
op_id: String,
suffix: String,
ctx: Context,
) -> String {
case schema_obj {
ArraySchema(items:, ..) ->
case items {
Reference(name:, ..) -> {
"List(types." <> naming.schema_to_type_name(name) <> ")"
}
_ -> schema_to_gleam_type(schema_obj, ctx)
}
// Inline objects, oneOf, anyOf, allOf → reference the anonymous type
ObjectSchema(..) -> {
let type_name = naming.schema_to_type_name(op_id) <> suffix
"types." <> type_name
}
OneOfSchema(schemas:, ..) -> {
let all_refs =
list.all(schemas, fn(s) {
case s {
Reference(..) -> True
_ -> False
}
})
case all_refs {
True -> {
let type_name = naming.schema_to_type_name(op_id) <> suffix
"types." <> type_name
}
False -> schema_to_gleam_type(schema_obj, ctx)
}
}
AnyOfSchema(schemas:, ..) -> {
let all_refs =
list.all(schemas, fn(s) {
case s {
Reference(..) -> True
_ -> False
}
})
case all_refs {
True -> {
let type_name = naming.schema_to_type_name(op_id) <> suffix
"types." <> type_name
}
False -> schema_to_gleam_type(schema_obj, ctx)
}
}
AllOfSchema(..) -> {
let type_name = naming.schema_to_type_name(op_id) <> suffix
"types." <> type_name
}
_ -> schema_to_gleam_type(schema_obj, ctx)
}
}
/// Convert a SchemaRef to a Gleam type string.
pub fn schema_ref_to_type(ref: SchemaRef, _ctx: Context) -> String {
case ref {
Inline(schema) -> schema_dispatch.schema_type(schema)
Reference(name:, ..) -> naming.schema_to_type_name(name)
}
}
/// Convert a schema object to a Gleam type string.
/// Delegates to schema_dispatch for the centralized type mapping.
pub fn schema_to_gleam_type(schema: SchemaObject, _ctx: Context) -> String {
schema_dispatch.schema_type(schema)
}
/// Generate request types for all operations.
fn generate_request_types(ctx: Context) -> String {
let operations = collect_operations(ctx)
// Only import Option if any operation has optional parameters or optional body
let needs_option =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
let has_optional_params =
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) -> !p.required
_ -> False
}
})
let has_optional_body = case operation.request_body {
Some(Value(rb)) -> !rb.required
_ -> False
}
has_optional_params || has_optional_body
})
// Check if types module is needed ($ref params or non-primitive body)
let needs_types =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
let has_ref_params =
list.any(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) ->
case p.schema {
Some(Reference(..)) -> True
_ -> False
}
_ -> False
}
})
let has_typed_body = case operation.request_body {
Some(Value(rb)) ->
list.any(dict.to_list(rb.content), fn(ce) {
let #(_, mt) = ce
case mt.schema {
Some(Reference(..)) -> True
Some(Inline(schema.ObjectSchema(..))) -> True
Some(Inline(schema.AllOfSchema(..))) -> True
_ -> False
}
})
_ -> False
}
has_ref_params || has_typed_body
})
let base_imports = case needs_types {
True -> [ctx.config.package <> "/types"]
False -> []
}
let imports = case needs_option {
True -> ["gleam/option.{type Option}", ..base_imports]
False -> base_imports
}
let sb =
se.file_header(context.version)
|> se.imports(imports)
let sb =
list.fold(operations, sb, fn(sb, op) {
let #(op_id, operation, _path, _method) = op
generate_request_type(sb, op_id, operation, ctx)
})
se.to_string(sb)
}
/// Generate a single request type for an operation.
fn generate_request_type(
sb: se.StringBuilder,
op_id: String,
operation: spec.Operation(SpecStage),
ctx: Context,
) -> se.StringBuilder {
let type_name = naming.schema_to_type_name(op_id) <> "Request"
let params = operation.parameters
case list.is_empty(params) && option.is_none(operation.request_body) {
True -> sb
False -> {
let sb = case operation.description {
Some(desc) -> sb |> se.doc_comment(desc)
None -> sb
}
let sb = sb |> se.line("pub type " <> type_name <> " {")
let sb = sb |> se.indent(1, type_name <> "(")
let sb =
list.index_fold(params, sb, fn(sb, ref_p, idx) {
case ref_p {
Value(param) -> {
let field_name = naming.to_snake_case(param.name)
let field_type = case param.schema {
Some(Inline(StringSchema(..))) -> "String"
Some(Inline(IntegerSchema(..))) -> "Int"
Some(Inline(NumberSchema(..))) -> "Float"
Some(Inline(BooleanSchema(..))) -> "Bool"
Some(Inline(ArraySchema(items:, ..))) -> {
let item_type = case items {
Inline(StringSchema(..)) -> "String"
Inline(IntegerSchema(..)) -> "Int"
Inline(NumberSchema(..)) -> "Float"
Inline(BooleanSchema(..)) -> "Bool"
Reference(name:, ..) -> naming.schema_to_type_name(name)
_ -> "String"
}
"List(" <> item_type <> ")"
}
Some(Reference(name:, ..)) ->
"types." <> naming.schema_to_type_name(name)
_ -> "String"
}
let final_type = case param.required {
True -> field_type
False -> "Option(" <> field_type <> ")"
}
let has_more = idx < list.length(params) - 1
let has_body = option.is_some(operation.request_body)
let trailing = case has_more || has_body {
True -> ","
False -> ""
}
sb |> se.indent(2, field_name <> ": " <> final_type <> trailing)
}
_ -> sb
}
})
let sb = case operation.request_body {
Some(Value(rb)) -> {
let body_type = extract_request_body_type(rb, op_id, ctx)
let wrapped = case rb.required {
True -> body_type
False -> "Option(" <> body_type <> ")"
}
sb |> se.indent(2, "body: " <> wrapped)
}
_ -> sb
}
sb
|> se.indent(1, ")")
|> se.line("}")
|> se.blank_line()
}
}
}
/// Check if any response variant references the types module.
fn responses_need_types_import(
operations: List(
#(String, spec.Operation(SpecStage), String, spec.HttpMethod),
),
_ctx: Context,
) -> Bool {
list.any(operations, fn(op) {
let #(_op_id, operation, _path, _method) = op
let responses = dict.to_list(operation.responses)
list.any(responses, fn(entry) {
let #(_status_code, ref_or) = entry
case ref_or {
Value(response) -> {
let content_entries = dict.to_list(response.content)
case content_entries {
[] -> False
[_, _, ..] -> False
[#(media_type_name, media_type)] ->
case media_type_name {
"text/plain"
| "application/xml"
| "text/xml"
| "application/octet-stream" -> False
_ ->
case media_type.schema {
Some(Reference(..)) -> True
Some(Inline(ArraySchema(items: Reference(..), ..))) -> True
Some(Inline(ObjectSchema(..))) -> True
Some(Inline(OneOfSchema(..))) -> True
Some(Inline(AnyOfSchema(..))) -> True
Some(Inline(AllOfSchema(..))) -> True
_ -> False
}
}
}
}
_ -> False
}
})
})
}
/// Generate response types for all operations.
fn generate_response_types(ctx: Context) -> String {
let operations = collect_operations(ctx)
let needs_types = responses_need_types_import(operations, ctx)
let imports = case needs_types {
True -> [ctx.config.package <> "/types"]
False -> []
}
let sb =
se.file_header(context.version)
|> se.imports(imports)
let sb =
list.fold(operations, sb, fn(sb, op) {
let #(op_id, operation, _path, _method) = op
generate_response_type(sb, op_id, operation, ctx)
})
se.to_string(sb)
}
/// Generate a response type for an operation.
fn generate_response_type(
sb: se.StringBuilder,
op_id: String,
operation: spec.Operation(SpecStage),
ctx: Context,
) -> se.StringBuilder {
let type_name = naming.schema_to_type_name(op_id) <> "Response"
let responses = dict.to_list(operation.responses)
case list.is_empty(responses) {
True -> sb
False -> {
let sb = sb |> se.line("pub type " <> type_name <> " {")
let sb =
list.fold(responses, sb, fn(sb, entry) {
let #(status_code, ref_or) = entry
case ref_or {
Value(response) -> {
let variant_name = status_code_to_variant(status_code, type_name)
let content_entries = dict.to_list(response.content)
case content_entries {
[] -> sb |> se.indent(1, variant_name)
// Multiple content types: use String to stay type-safe
// since different media types may decode to different Gleam types
[_, _, ..] -> sb |> se.indent(1, variant_name <> "(String)")
[#(media_type_name, media_type)] ->
case media_type_name {
// text/plain, XML, octet-stream: always use String type
"text/plain"
| "application/xml"
| "text/xml"
| "application/octet-stream" ->
case media_type.schema {
Some(_) ->
sb
|> se.indent(1, variant_name <> "(String)")
None -> sb |> se.indent(1, variant_name)
}
// JSON and other content types use schema-derived type
_ ->
case media_type.schema {
Some(ref) -> {
let suffix =
"Response" <> http.status_code_suffix(status_code)
let inner_type =
schema_ref_to_type_qualified(
ref,
op_id,
suffix,
ctx,
)
sb
|> se.indent(
1,
variant_name <> "(" <> inner_type <> ")",
)
}
None -> sb |> se.indent(1, variant_name)
}
}
}
}
_ -> sb
}
})
sb
|> se.line("}")
|> se.blank_line()
}
}
}
/// Convert an HTTP status code to a Gleam variant name.
/// Prefixed with the type name to avoid duplicate constructors across types.
fn status_code_to_variant(code: String, type_name: String) -> String {
type_name <> http.status_code_suffix(code)
}
/// Result of merging allOf sub-schemas.
pub type MergedAllOf {
MergedAllOf(
properties: dict.Dict(String, SchemaRef),
required: List(String),
additional_properties: Option(SchemaRef),
additional_properties_untyped: Bool,
)
}
/// Merge allOf sub-schemas: properties, required, and additionalProperties.
/// Non-object sub-schemas (primitives, arrays) are included as a synthetic
/// "value" property to preserve their constraints.
pub fn merge_allof_schemas(
schemas: List(SchemaRef),
ctx: Context,
) -> MergedAllOf {
list.index_fold(
schemas,
MergedAllOf(
properties: dict.new(),
required: [],
additional_properties: None,
additional_properties_untyped: False,
),
fn(acc, s_ref, idx) {
let resolved = case s_ref {
Inline(obj) -> Ok(obj)
Reference(..) -> resolver.resolve_schema_ref(s_ref, ctx.spec)
}
case resolved {
Ok(ObjectSchema(
properties:,
required:,
additional_properties:,
additional_properties_untyped:,
..,
)) -> {
let merged_ap = case
acc.additional_properties,
additional_properties
{
None, ap -> ap
existing, _ -> existing
}
let merged_ap_untyped =
acc.additional_properties_untyped || additional_properties_untyped
MergedAllOf(
properties: dict.merge(acc.properties, properties),
required: list.append(acc.required, required),
additional_properties: merged_ap,
additional_properties_untyped: merged_ap_untyped,
)
}
// Non-object sub-schemas: add as a synthetic "value" field
Ok(schema_obj) -> {
let field_name = case idx {
0 -> "value"
n -> "value_" <> int.to_string(n)
}
MergedAllOf(
..acc,
properties: dict.insert(
acc.properties,
field_name,
Inline(schema_obj),
),
required: [field_name, ..acc.required],
)
}
_ -> acc
}
},
)
}
/// Collect all operations from the spec with their IDs, paths, and methods.
pub fn collect_operations(
ctx: Context,
) -> List(#(String, spec.Operation(SpecStage), String, spec.HttpMethod)) {
let paths =
list.sort(dict.to_list(ctx.spec.paths), fn(a, b) {
string.compare(a.0, b.0)
})
list.flat_map(paths, fn(entry) {
let #(path, ref_or) = entry
case ref_or {
Value(path_item) -> {
let ops = [
#(path_item.get, spec.Get),
#(path_item.post, spec.Post),
#(path_item.put, spec.Put),
#(path_item.delete, spec.Delete),
#(path_item.patch, spec.Patch),
#(path_item.head, spec.Head),
#(path_item.options, spec.Options),
#(path_item.trace, spec.Trace),
]
list.filter_map(ops, fn(op_entry) {
let #(maybe_op, method) = op_entry
case maybe_op {
Some(operation) -> {
// Merge path-level parameters with operation parameters.
// Operation params take precedence by (name, in) key per OpenAPI spec.
let op_param_keys =
list.filter_map(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) -> Ok(#(p.name, p.in_))
_ -> Error(Nil)
}
})
let inherited_params =
list.filter(path_item.parameters, fn(ref_p) {
case ref_p {
Value(p) -> !list.contains(op_param_keys, #(p.name, p.in_))
_ -> True
}
})
let merged_params =
list.append(inherited_params, operation.parameters)
// Inherit top-level security if operation doesn't define its own.
// operation.security = None → inherit, Some([]) → no security,
// Some([...]) → use operation-level.
let effective_security = case operation.security {
Some(sec) -> sec
None -> ctx.spec.security
}
let operation =
spec.Operation(
..operation,
parameters: merged_params,
security: Some(effective_security),
)
let op_id = case operation.operation_id {
Some(id) -> id
None ->
spec.method_to_lower(method)
<> "_"
<> string.replace(path, "/", "_")
|> string.replace("{", "")
|> string.replace("}", "")
}
Ok(#(op_id, operation, path, method))
}
None -> Error(Nil)
}
})
}
_ -> []
}
})
}
/// Check if a schema has typed or untyped additionalProperties that would need Dict.
pub fn schema_has_additional_properties(
schema_ref: SchemaRef,
ctx: Context,
) -> Bool {
case schema_ref {
Inline(ObjectSchema(additional_properties: Some(_), ..)) -> True
Inline(ObjectSchema(additional_properties_untyped: True, ..)) -> True
Inline(AllOfSchema(schemas:, ..)) ->
list.any(schemas, fn(s) { schema_has_additional_properties(s, ctx) })
Reference(..) ->
case resolver.resolve_schema_ref(schema_ref, ctx.spec) {
Ok(schema_obj) ->
schema_has_additional_properties(Inline(schema_obj), ctx)
Error(_) -> False
}
_ -> False
}
}
/// Check if a schema has untyped additionalProperties (needs Dynamic import).
pub fn schema_has_untyped_additional_properties(
schema_ref: SchemaRef,
ctx: Context,
) -> Bool {
case schema_ref {
Inline(ObjectSchema(additional_properties_untyped: True, ..)) -> True
Inline(AllOfSchema(schemas:, ..)) ->
list.any(schemas, fn(s) {
schema_has_untyped_additional_properties(s, ctx)
})
Reference(..) ->
case resolver.resolve_schema_ref(schema_ref, ctx.spec) {
Ok(schema_obj) ->
schema_has_untyped_additional_properties(Inline(schema_obj), ctx)
Error(_) -> False
}
_ -> False
}
}
/// Check if a schema has any optional or nullable fields that would need Option.
pub fn schema_has_optional_fields(schema_ref: SchemaRef, ctx: Context) -> Bool {
case schema_ref {
Inline(ObjectSchema(properties:, required:, ..)) -> {
let has_optional =
dict.to_list(properties)
|> list.any(fn(entry) {
let #(prop_name, prop_ref) = entry
!list.contains(required, prop_name)
|| schema_ref_is_nullable(prop_ref, ctx)
})
has_optional
}
Inline(AllOfSchema(schemas:, ..)) ->
list.any(schemas, fn(s) { schema_has_optional_fields(s, ctx) })
Reference(..) ->
case resolver.resolve_schema_ref(schema_ref, ctx.spec) {
Ok(schema_obj) -> schema_has_optional_fields(Inline(schema_obj), ctx)
Error(_) -> False
}
_ -> False
}
}
/// Check if a SchemaRef is nullable, resolving $ref if needed.
fn schema_ref_is_nullable(ref: SchemaRef, ctx: Context) -> Bool {
case ref {
Inline(s) -> schema.is_nullable(s)
Reference(..) ->
case resolver.resolve_schema_ref(ref, ctx.spec) {
Ok(s) -> schema.is_nullable(s)
Error(_) -> False
}
}
}
/// Check if a SchemaRef has readOnly metadata, resolving $ref if needed.
pub fn schema_ref_is_read_only(ref: SchemaRef, ctx: Context) -> Bool {
case ref {
Inline(s) -> schema.get_metadata(s).read_only
Reference(..) ->
case resolver.resolve_schema_ref(ref, ctx.spec) {
Ok(s) -> schema.get_metadata(s).read_only
Error(_) -> False
}
}
}
/// Check if a SchemaRef has writeOnly metadata, resolving $ref if needed.
pub fn schema_ref_is_write_only(ref: SchemaRef, ctx: Context) -> Bool {
case ref {
Inline(s) -> schema.get_metadata(s).write_only
Reference(..) ->
case resolver.resolve_schema_ref(ref, ctx.spec) {
Ok(s) -> schema.get_metadata(s).write_only
Error(_) -> False
}
}
}
/// Filter readOnly properties from an ObjectSchema for request body context.
/// Returns a new schema with readOnly properties removed.
pub fn filter_read_only_properties(
schema_obj: SchemaObject,
ctx: Context,
) -> SchemaObject {
case schema_obj {
ObjectSchema(
metadata:,
properties:,
required:,
additional_properties:,
additional_properties_untyped:,
min_properties:,
max_properties:,
) -> {
let filtered_props =
dict.filter(properties, fn(_name, prop_ref) {
!schema_ref_is_read_only(prop_ref, ctx)
})
let filtered_required =
list.filter(required, fn(name) {
case dict.get(filtered_props, name) {
Ok(_) -> True
Error(_) -> False
}
})
ObjectSchema(
metadata:,
properties: filtered_props,
required: filtered_required,
additional_properties:,
additional_properties_untyped:,
min_properties:,
max_properties:,
)
}
_ -> schema_obj
}
}
/// Filter writeOnly properties from an ObjectSchema for response body context.
/// Returns a new schema with writeOnly properties removed.
pub fn filter_write_only_properties(
schema_obj: SchemaObject,
ctx: Context,
) -> SchemaObject {
case schema_obj {
ObjectSchema(
metadata:,
properties:,
required:,
additional_properties:,
additional_properties_untyped:,
min_properties:,
max_properties:,
) -> {
let filtered_props =
dict.filter(properties, fn(_name, prop_ref) {
!schema_ref_is_write_only(prop_ref, ctx)
})
let filtered_required =
list.filter(required, fn(name) {
case dict.get(filtered_props, name) {
Ok(_) -> True
Error(_) -> False
}
})
ObjectSchema(
metadata:,
properties: filtered_props,
required: filtered_required,
additional_properties:,
additional_properties_untyped:,
min_properties:,
max_properties:,
)
}
_ -> schema_obj
}
}
/// Extract the Gleam type for a request body from its content media types.
/// Uses types. prefix since request body schemas live in the types module.
fn extract_request_body_type(
rb: spec.RequestBody(SpecStage),
op_id: String,
ctx: Context,
) -> String {
let content_entries = dict.to_list(rb.content)
case content_entries {
[#(_media_type, media_type), ..] ->
case media_type.schema {
Some(Reference(name:, ..)) ->
"types." <> naming.schema_to_type_name(name)
Some(Inline(schema_obj)) ->
extract_inline_request_body_type(schema_obj, op_id, ctx)
_ -> "String"
}
[] -> "String"
}
}
/// Extract the type for an inline request body schema.
fn extract_inline_request_body_type(
schema_obj: SchemaObject,
op_id: String,
ctx: Context,
) -> String {
case schema_obj {
ObjectSchema(..) -> {
let type_name = naming.schema_to_type_name(op_id) <> "RequestBody"
"types." <> type_name
}
AllOfSchema(..) -> {
let type_name = naming.schema_to_type_name(op_id) <> "RequestBody"
"types." <> type_name
}
StringSchema(..) -> "String"
IntegerSchema(..) -> "Int"
NumberSchema(..) -> "Float"
BooleanSchema(..) -> "Bool"
_ -> schema_to_gleam_type(schema_obj, ctx)
}
}