Packages
oaspec
0.53.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/codegen/client_ir.gleam
import gleam/dict
import gleam/list
import gleam/option.{type Option, None, Some}
import oaspec/config
import oaspec/internal/codegen/context.{type Context}
import oaspec/internal/codegen/guards
import oaspec/internal/codegen/import_analysis
import oaspec/internal/openapi/schema.{Inline, Reference}
import oaspec/internal/openapi/spec.{ParameterSchema, Value}
import oaspec/internal/util/content_type as ct_util
/// Structured import / helper requirements for client generation.
pub type ClientRequirements {
ClientRequirements(
needs_bool: Bool,
needs_float: Bool,
has_multi_content_response: Bool,
has_form_urlencoded: Bool,
has_multipart: Bool,
needs_list: Bool,
needs_dyn_decode: Bool,
needs_json: Bool,
needs_string: Bool,
needs_typed_schemas: Bool,
needs_option_type: Bool,
needs_some_ctor: Bool,
needs_none_ctor: Bool,
needs_int: Bool,
needs_bytes_helper: Bool,
needs_text_helper: Bool,
needs_uri: Bool,
needs_guards: Bool,
)
}
/// Compute semantic requirements for the generated client module.
pub fn analyze(ctx: Context) -> ClientRequirements {
let operations = context.operations(ctx)
let all_params =
list.flat_map(operations, fn(op) {
let #(_, operation, _, _) = op
list.filter_map(operation.parameters, fn(ref_p) {
case ref_p {
Value(p) -> Ok(p)
_ -> Error(Nil)
}
})
})
let needs_bool =
list.any(all_params, fn(p) {
case p.payload {
ParameterSchema(Inline(schema.BooleanSchema(..))) -> True
_ -> False
}
})
let needs_float_param =
list.any(all_params, fn(p) {
case p.payload {
ParameterSchema(Inline(schema.NumberSchema(..))) -> True
_ -> False
}
})
let has_multi_content_response =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) -> list.length(dict.to_list(response.content)) > 1
_ -> False
}
})
})
let has_form_urlencoded =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
case operation.request_body {
Some(Value(rb)) ->
list.any(dict.to_list(rb.content), fn(ce) {
let #(key, _) = ce
key == "application/x-www-form-urlencoded"
})
_ -> False
}
})
let has_multipart =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
case operation.request_body {
Some(Value(rb)) ->
list.any(dict.to_list(rb.content), fn(ce) {
let #(key, _) = ce
key == "multipart/form-data"
})
_ -> False
}
})
// Issue #387: when any response declares `headers:`, the client
// assembles a typed headers record by calling `list.key_find` on
// `resp.headers`. That makes `gleam/list` mandatory and (for any
// optional header field) brings `Some`/`None` into the body of the
// function. Track both signals here so the import builder picks up
// the right shapes.
let has_response_headers =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) -> dict.size(response.headers) > 0
_ -> False
}
})
})
let has_optional_response_headers =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(dict.to_list(response.headers), fn(h_entry) {
let #(_, header) = h_entry
!header.required
})
_ -> False
}
})
})
let needs_list =
has_form_urlencoded
|| has_multi_content_response
|| has_response_headers
|| list.any(all_params, fn(p) { p.in_ == spec.InQuery })
|| list.any(all_params, fn(p) { p.in_ == spec.InCookie })
|| list.any(all_params, fn(p) { p.in_ == spec.InHeader })
|| list.any(all_params, fn(p) {
case p.payload {
ParameterSchema(Inline(schema.ArraySchema(..))) -> True
ParameterSchema(Reference(..) as sr) ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.ArraySchema(..)) -> True
_ -> False
}
_ -> False
}
})
let needs_dyn_decode =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(dict.to_list(response.content), fn(ce) {
let #(media_type_name, mt) = ce
// Issue #504: */* and application/octet-stream responses
// decode through `bytes_body`, not `dyn_decode`, so they
// must not pull the dynamic-decode import in even when
// their inline schema is a primitive.
case ct_util.from_string(media_type_name) {
ct_util.TextPlain -> False
ct_util.ApplicationOctetStream | ct_util.Wildcard -> False
_ ->
case mt.schema {
// Issue #493 / CodeRabbit follow-up: array
// responses keyed by `$ref` items also go
// through `dyn_decode.list(...)` now (instead
// of the synthetic `decode_<name>_list`
// wrapper), so the import must fire for them
// too. Without this, generated client code
// references `dyn_decode.list` without
// importing the module.
Some(Inline(schema.ArraySchema(items: _, ..))) -> True
Some(Inline(schema.StringSchema(..))) -> True
Some(Inline(schema.IntegerSchema(..))) -> True
Some(Inline(schema.NumberSchema(..))) -> True
Some(Inline(schema.BooleanSchema(..))) -> True
_ -> False
}
}
})
_ -> False
}
})
})
let needs_json =
needs_dyn_decode
|| list.any(operations, fn(op) {
let #(_, operation, _, _) = op
case operation.request_body {
Some(Value(rb)) ->
list.any(dict.to_list(rb.content), fn(ce) {
let #(ct_name, mt) = ce
// Issue #485: an `application/octet-stream` body is
// wrapped via `transport.BytesBody`, not
// `json.to_string`, so a `type: string, format: binary`
// request body must not pull in `gleam/json`.
case ct_util.from_string(ct_name) {
ct_util.ApplicationOctetStream | ct_util.Wildcard -> False
_ ->
case mt.schema {
Some(Inline(schema.StringSchema(..))) -> True
Some(Inline(schema.IntegerSchema(..))) -> True
Some(Inline(schema.NumberSchema(..))) -> True
Some(Inline(schema.BooleanSchema(..))) -> True
_ -> False
}
}
})
_ -> False
}
})
let needs_string =
has_multi_content_response
|| has_form_urlencoded
|| has_multipart
|| list.any(all_params, fn(p) {
p.in_ == spec.InPath || p.in_ == spec.InCookie
})
let needs_typed_schemas =
import_analysis.operations_need_typed_schemas(operations)
// `needs_option_type` represents the bare `Option(...)` type
// appearing in client function SIGNATURES. Optional response
// headers do not surface in signatures (only as `Some`/`None`
// constructors inside function bodies), so they are not added
// here — Issue #387.
let needs_option_type =
import_analysis.operations_have_optional_params(operations)
|| import_analysis.operations_have_optional_body(operations)
let needs_some_ctor =
needs_option_type
|| has_optional_response_headers
|| any_operation_has_server(ctx)
let needs_none_ctor =
needs_option_type
|| has_optional_response_headers
|| any_operation_has_no_server(ctx)
// Issue #387: typed response headers (`type: integer`, `type: number`)
// are extracted via `int.parse` / `float.parse`, so we must import
// those modules whenever any operation declares such a header.
let has_int_response_header =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(dict.to_list(response.headers), fn(h_entry) {
let #(_, header) = h_entry
case header.schema {
Some(Inline(schema.IntegerSchema(..))) -> True
_ -> False
}
})
_ -> False
}
})
})
let has_float_response_header =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(dict.to_list(response.headers), fn(h_entry) {
let #(_, header) = h_entry
case header.schema {
Some(Inline(schema.NumberSchema(..))) -> True
_ -> False
}
})
_ -> False
}
})
})
let needs_int =
has_int_response_header
|| list.any(all_params, fn(p) {
case p.payload {
ParameterSchema(Inline(schema.IntegerSchema(..))) -> True
ParameterSchema(Inline(schema.ArraySchema(
items: Inline(schema.IntegerSchema(..)),
..,
))) -> True
ParameterSchema(Reference(..) as sr) ->
case context.resolve_schema_ref(sr, ctx) {
Ok(schema.IntegerSchema(..)) -> True
Ok(schema.ArraySchema(items: Inline(schema.IntegerSchema(..)), ..)) ->
True
_ -> False
}
_ -> False
}
})
let needs_float = needs_float_param || has_float_response_header
let needs_bytes_helper =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(dict.to_list(response.content), fn(ce) {
let #(name, _) = ce
case ct_util.from_string(name) {
ct_util.ApplicationOctetStream | ct_util.Wildcard -> True
_ -> False
}
})
_ -> False
}
})
})
let needs_text_helper =
list.any(operations, fn(op) {
let #(_, operation, _, _) = op
list.any(dict.to_list(operation.responses), fn(entry) {
let #(_, ref_or) = entry
case ref_or {
Value(response) ->
list.any(dict.to_list(response.content), fn(ce) {
let #(_, mt) = ce
case mt.schema {
Some(_) -> True
None -> False
}
})
_ -> False
}
})
})
let needs_uri =
has_form_urlencoded
|| list.any(all_params, fn(p) {
p.in_ == spec.InPath || p.in_ == spec.InCookie
})
let needs_guards =
config.validate(context.config(ctx))
&& list.any(operations, fn(op) {
let #(_, operation, _, _) = op
case operation.request_body {
Some(Value(rb)) -> {
let content_entries = dict.to_list(rb.content)
case content_entries {
[#(_, mt)] ->
case mt.schema {
Some(schema.Reference(name:, ..)) ->
guards.schema_has_validator(name, ctx)
_ -> False
}
_ -> False
}
}
_ -> False
}
})
ClientRequirements(
needs_bool: needs_bool,
needs_float: needs_float,
has_multi_content_response: has_multi_content_response,
has_form_urlencoded: has_form_urlencoded,
has_multipart: has_multipart,
needs_list: needs_list,
needs_dyn_decode: needs_dyn_decode,
needs_json: needs_json,
needs_string: needs_string,
needs_typed_schemas: needs_typed_schemas,
needs_option_type: needs_option_type,
needs_some_ctor: needs_some_ctor,
needs_none_ctor: needs_none_ctor,
needs_int: needs_int,
needs_bytes_helper: needs_bytes_helper,
needs_text_helper: needs_text_helper,
needs_uri: needs_uri,
needs_guards: needs_guards,
)
}
/// Optional `gleam/option` import line for client signatures and ctors.
fn option_import(requirements: ClientRequirements) -> Option(String) {
case
requirements.needs_option_type,
requirements.needs_some_ctor,
requirements.needs_none_ctor
{
True, _, _ -> Some("gleam/option.{type Option, None, Some}")
False, True, True -> Some("gleam/option.{None, Some}")
False, True, False -> Some("gleam/option.{Some}")
False, False, True -> Some("gleam/option.{None}")
False, False, False -> None
}
}
/// Render the final import list from structured requirements.
pub fn imports(requirements: ClientRequirements, ctx: Context) -> List(String) {
let base_imports = [
"gleam/result",
"oaspec/transport",
config.package(context.config(ctx)) <> "/decode",
config.package(context.config(ctx)) <> "/response_types",
]
let base_imports = case requirements.needs_int {
True -> ["gleam/int", ..base_imports]
False -> base_imports
}
let base_imports = case option_import(requirements) {
Some(import_line) -> [import_line, ..base_imports]
None -> base_imports
}
let base_imports = case requirements.needs_typed_schemas {
True ->
list.append(
[
config.package(context.config(ctx)) <> "/types",
config.package(context.config(ctx)) <> "/encode",
],
base_imports,
)
False -> base_imports
}
let base_imports = [
config.package(context.config(ctx)) <> "/request_types",
..base_imports
]
let base_imports = case requirements.needs_string {
True -> ["gleam/string", ..base_imports]
False -> base_imports
}
let imports = case requirements.needs_dyn_decode {
True -> ["gleam/dynamic/decode as dyn_decode", ..base_imports]
False -> base_imports
}
let imports = case requirements.needs_json {
True -> ["gleam/json", ..imports]
False -> imports
}
let imports = case requirements.needs_bool {
True -> ["gleam/bool", ..imports]
False -> imports
}
let imports = case requirements.needs_float {
True -> ["gleam/float", ..imports]
False -> imports
}
let imports = case requirements.needs_list {
True -> ["gleam/list", ..imports]
False -> imports
}
let imports = case requirements.needs_uri {
True -> ["gleam/uri", ..imports]
False -> imports
}
case requirements.needs_guards {
True -> [config.package(context.config(ctx)) <> "/guards", ..imports]
False -> imports
}
}
fn any_operation_has_server(ctx: Context) -> Bool {
list.any(context.operations(ctx), fn(op) {
let #(_, operation, _, _) = op
case operation.servers {
[] ->
case context.spec(ctx).servers {
[] -> False
_ -> True
}
_ -> True
}
})
}
fn any_operation_has_no_server(ctx: Context) -> Bool {
list.any(context.operations(ctx), fn(op) {
let #(_, operation, _, _) = op
case operation.servers {
[] ->
case context.spec(ctx).servers {
[] -> True
_ -> False
}
_ -> False
}
})
}