Packages

Generate Gleam types, JSON codecs, and a typed XRPC client from atproto lexicon files.

Current section

Files

Jump to
atproto_codegen src atproto_codegen lower.gleam
Raw

src/atproto_codegen/lower.gleam

//// One-way lowering from `atproto_lexicon/ast.LexiconDoc` to the flat view
//// plugins consume. `ast` stays the faithful, bidirectional representation;
//// this module intentionally drops anything today's plugins do not need
//// (method defs, record key strategy, constraint fields, descriptions) while
//// keeping a handle on the source doc so a later constraint-aware plugin can
//// still reach it. Growing the flat shape (e.g. surfacing a constraint) is
//// fine; this module must never grow the ability to go the other way.
////
//// A record/object def whose properties use a field type this pipeline
//// cannot render becomes `FlatUnsupported` rather than emitted wrong. A
//// query/procedure def becomes a `FlatMethod` (consumed by the xrpc-client
//// plugin, not the per-unit codec path); any other non-record/object def
//// (subscription, token, ...) becomes `FlatIgnored`, since no plugin
//// generates code for those yet.
import atproto_lexicon/ast.{type LexiconDoc, type PrimaryDef, type PropertyType}
import gleam/list
import gleam/option.{type Option, None, Some}
pub type FlatType {
TString
TInt
TBool
TCidLink
TBytes
TBlob
TUnknown
TRef(String)
TArray(FlatType)
TUnion(refs: List(String))
TUnsupported(String)
}
pub type FlatField {
FlatField(
json_name: String,
field_type: FlatType,
required: Bool,
nullable: Bool,
)
}
pub type FlatDef {
FlatRecord(nsid: String, properties: List(FlatField))
FlatObject(nsid: String, name: String, properties: List(FlatField))
FlatUnsupported(nsid: String, name: String, reason: String)
FlatMethod(Method)
FlatIgnored
}
pub type MethodKind {
MethodQuery
MethodProcedure
}
pub type MethodError {
MethodError(name: String, description: Option(String))
}
/// A query/procedure body: its wire `encoding` and, when it carries a typed
/// payload, a schema that is either a ref to a def or an inline object. A
/// `None` schema is a real case (e.g. a `multipart/form-data` binary upload).
pub type FlatBody {
FlatBody(encoding: String, schema: Option(FlatBodySchema))
}
/// A body's payload shape. `BodyUnsupported` keeps an unrenderable body
/// (a union body, or an inline object with an unrenderable field) as data
/// so the plugin can raise a generation-time error naming the method,
/// mirroring how `TUnsupported` carries an unrenderable object field type.
pub type FlatBodySchema {
BodyRef(ref: String)
BodyInline(properties: List(FlatField))
BodyUnsupported(reason: String)
}
/// A lowered query/procedure. `params` reuse `FlatField` (with `nullable`
/// always `False`, since `params` has no nullable notion); the plugin
/// derives its function/type names from `nsid`.
pub type Method {
Method(
nsid: String,
kind: MethodKind,
params: List(FlatField),
input: Option(FlatBody),
output: Option(FlatBody),
errors: List(MethodError),
)
}
pub type FlatLexicon {
FlatLexicon(nsid: String, doc: LexiconDoc, defs: List(FlatDef))
}
/// One unit of generated code: a def's own type/codec, or a synthesized
/// union type for one of its union-typed fields. Plugins iterate the same
/// unit list (built once here) so their per-unit outputs line up position
/// for position when the host interleaves them.
pub type CodecUnit {
DefUnit(def: FlatDef)
UnionUnit(def: FlatDef, field: FlatField, refs: List(String))
}
pub fn lower(docs: List(LexiconDoc)) -> List(FlatLexicon) {
list.map(docs, lower_doc)
}
fn lower_doc(doc: LexiconDoc) -> FlatLexicon {
let defs = list.map(doc.defs, fn(pair) { lower_def(doc.id, pair.0, pair.1) })
FlatLexicon(nsid: doc.id, doc:, defs:)
}
fn lower_def(nsid: String, name: String, def: PrimaryDef) -> FlatDef {
case def {
ast.DefRecord(record_def) ->
case lower_properties(record_def.record) {
Ok(props) -> FlatRecord(nsid, props)
Error(reason) -> FlatUnsupported(nsid, name, reason)
}
ast.DefObject(schema) ->
case lower_properties(schema) {
Ok(props) -> FlatObject(nsid, name, props)
Error(reason) -> FlatUnsupported(nsid, name, reason)
}
ast.DefQuery(q) ->
FlatMethod(Method(
nsid:,
kind: MethodQuery,
params: lower_params(q.parameters),
input: None,
output: lower_body(q.output),
errors: lower_errors(q.errors),
))
ast.DefProcedure(p) ->
FlatMethod(Method(
nsid:,
kind: MethodProcedure,
params: lower_params(p.parameters),
input: lower_body(p.input),
output: lower_body(p.output),
errors: lower_errors(p.errors),
))
_ -> FlatIgnored
}
}
fn lower_params(params: Option(ast.ParamsSchema)) -> List(FlatField) {
case params {
None -> []
Some(schema) ->
list.map(schema.properties, fn(pair) {
let #(json_name, prop) = pair
FlatField(
json_name:,
field_type: lower_params_property_type(prop),
required: list.contains(schema.required, json_name),
nullable: False,
)
})
}
}
fn lower_params_property_type(prop: ast.ParamsPropertyType) -> FlatType {
case prop {
ast.ParamsBoolean(_) -> TBool
ast.ParamsInteger(_) -> TInt
ast.ParamsString(_) -> TString
ast.ParamsUnknown(_) -> TUnsupported("unknown")
ast.ParamsArray(_, items, _, _) -> TArray(lower_params_item_type(items))
}
}
fn lower_params_item_type(item: ast.ParamsItemType) -> FlatType {
case item {
ast.ParamsItemBoolean(_) -> TBool
ast.ParamsItemInteger(_) -> TInt
ast.ParamsItemString(_) -> TString
}
}
fn lower_body(body: Option(ast.Body)) -> Option(FlatBody) {
case body {
None -> None
Some(b) ->
Some(FlatBody(encoding: b.encoding, schema: lower_body_schema(b.schema)))
}
}
fn lower_body_schema(schema: Option(ast.BodySchema)) -> Option(FlatBodySchema) {
case schema {
None -> None
Some(ast.BodySchemaRef(ref)) -> Some(BodyRef(ref))
Some(ast.BodySchemaObject(obj)) ->
case lower_properties(obj) {
Ok(props) -> Some(BodyInline(props))
Error(reason) -> Some(BodyUnsupported(reason))
}
Some(ast.BodySchemaUnion(_)) -> Some(BodyUnsupported("union body"))
}
}
fn lower_errors(errors: List(ast.LexiconError)) -> List(MethodError) {
list.map(errors, fn(e) {
MethodError(name: e.name, description: e.description)
})
}
fn lower_properties(
schema: ast.ObjectSchema,
) -> Result(List(FlatField), String) {
let fields =
list.map(schema.properties, fn(pair) {
let #(json_name, prop) = pair
FlatField(
json_name:,
field_type: lower_property_type(prop),
required: list.contains(schema.required, json_name),
nullable: list.contains(schema.nullable, json_name),
)
})
case first_unsupported(fields) {
Some(reason) -> Error(reason)
None -> Ok(fields)
}
}
fn first_unsupported(fields: List(FlatField)) -> Option(String) {
fields
|> list.filter_map(fn(f) {
case unsupported_kind(f.field_type) {
Some(kind) ->
Ok("field `" <> f.json_name <> "` has type `" <> kind <> "`")
None -> Error(Nil)
}
})
|> list.first
|> option.from_result
}
fn unsupported_kind(ft: FlatType) -> Option(String) {
case ft {
TUnsupported(kind) -> Some(kind)
TArray(inner) -> unsupported_kind(inner)
_ -> None
}
}
fn lower_property_type(prop: PropertyType) -> FlatType {
case prop {
ast.PropBoolean(_) -> TBool
ast.PropInteger(_) -> TInt
ast.PropString(_) -> TString
ast.PropBytes(_) -> TBytes
ast.PropCidLink(_) -> TCidLink
ast.PropBlob(_) -> TBlob
ast.PropRef(_, ref) -> TRef(ref)
ast.PropUnion(constraints) -> lower_union(constraints)
ast.PropUnknown(_) -> TUnknown
ast.PropArray(shape) -> TArray(lower_array_item_type(shape.items))
}
}
fn lower_array_item_type(item: ast.ArrayItemType) -> FlatType {
case item {
ast.ItemBoolean(_) -> TBool
ast.ItemInteger(_) -> TInt
ast.ItemString(_) -> TString
ast.ItemBytes(_) -> TBytes
ast.ItemCidLink(_) -> TCidLink
ast.ItemBlob(_) -> TBlob
ast.ItemRef(_, ref) -> TRef(ref)
ast.ItemUnion(constraints) -> lower_union(constraints)
ast.ItemUnknown(_) -> TUnknown
}
}
fn lower_union(constraints: ast.UnionConstraints) -> FlatType {
case constraints.closed {
// A closed union has no honest escape-hatch variant, unsupported until something needs one.
Some(True) -> TUnsupported("closed union")
_ -> TUnion(constraints.refs)
}
}
pub fn properties_of(def: FlatDef) -> List(FlatField) {
case def {
FlatRecord(_, props) -> props
FlatObject(_, _, props) -> props
FlatUnsupported(..) | FlatMethod(_) | FlatIgnored -> []
}
}
/// The lowered method for a def, when it is one. Methods are not emittable
/// through the per-unit codec path (`is_emittable` is `False` for them); the
/// xrpc-client plugin reads them off `lex.defs` via this accessor instead.
pub fn method_of(def: FlatDef) -> Option(Method) {
case def {
FlatMethod(method) -> Some(method)
_ -> None
}
}
pub fn is_emittable(def: FlatDef) -> Bool {
case def {
FlatRecord(..) | FlatObject(..) -> True
FlatUnsupported(..) | FlatMethod(_) | FlatIgnored -> False
}
}
pub fn is_record(def: FlatDef) -> Bool {
case def {
FlatRecord(..) -> True
_ -> False
}
}
pub fn union_of(ft: FlatType) -> Option(List(String)) {
case ft {
TUnion(refs) -> Some(refs)
TArray(inner) -> union_of(inner)
_ -> None
}
}
/// The ordered codec units for one module's emittable defs: each def's own
/// unit, immediately followed by one unit per union-typed field on it, in
/// field order. Both the `types` and `decode-json` plugins fold over this
/// same list so their outputs line up position for position.
pub fn codec_units(defs: List(FlatDef)) -> List(CodecUnit) {
defs
|> list.filter(is_emittable)
|> list.flat_map(fn(def) {
let unions =
properties_of(def)
|> list.filter_map(fn(field) {
case union_of(field.field_type) {
Some(refs) -> Ok(UnionUnit(def, field, refs))
None -> Error(Nil)
}
})
[DefUnit(def), ..unions]
})
}