···11+{0 OCaml OpenAPI}
22+33+{1 Overview}
44+55+[openapi] generates type-safe OCaml API clients from OpenAPI 3.x
66+specifications. The generated code uses:
77+88+- {b jsont} for JSON encoding/decoding
99+- {b requests} for HTTP client (Eio-based)
1010+- {b ptime} for date-time handling
1111+1212+{1 Installation}
1313+1414+{[
1515+opam install openapi
1616+]}
1717+1818+{1 Generating a Client}
1919+2020+Use the [openapi-gen] CLI tool to generate OCaml code from an OpenAPI spec:
2121+2222+{[
2323+# Basic generation
2424+openapi-gen generate spec.json -o ./my_api -n my_api
2525+2626+# With dune regeneration rules
2727+openapi-gen generate spec.json -o ./my_api -n my_api --regen
2828+]}
2929+3030+{2 CLI Options}
3131+3232+{ul
3333+{- [-o], [--output] — Output directory for generated code (required)}
3434+{- [-n], [--name] — Package name for generated library (defaults to API title)}
3535+{- [--regen] — Include dune.inc rules for [dune build @gen --auto-promote]}}
3636+3737+{1 Generated Code Structure}
3838+3939+The generator produces a complete dune library:
4040+4141+{[
4242+my_api/
4343+├── dune # Library configuration (wrapped)
4444+├── dune.inc # Regeneration rules (if --regen used)
4545+├── types.ml # Type definitions with jsont codecs
4646+├── types.mli # Type interfaces
4747+├── client.ml # API client functions
4848+├── client.mli # Client interface
4949+├── my_api.ml # Main wrapped module
5050+└── my_api.mli # Main module interface
5151+]}
5252+5353+{1 Using Generated Code}
5454+5555+{2 Accessing Types}
5656+5757+All schema types are generated as modules within [Types]:
5858+5959+{[
6060+(* Access a type *)
6161+let user : My_api.Types.User.t = {
6262+ id = 123;
6363+ name = "Alice";
6464+ email = Some "alice@example.com";
6565+}
6666+6767+(* Encode to JSON *)
6868+let json = Jsont.encode My_api.Types.User.t_jsont user
6969+7070+(* Decode from JSON *)
7171+let user' = Jsont.decode My_api.Types.User.t_jsont json
7272+]}
7373+7474+{2 Making API Requests}
7575+7676+Create a client and call API operations:
7777+7878+{[
7979+let () =
8080+ Eio_main.run @@ fun env ->
8181+ Eio.Switch.run @@ fun sw ->
8282+8383+ (* Create the client *)
8484+ let client = My_api.Client.create ~sw env
8585+ ~base_url:"https://api.example.com" in
8686+8787+ (* Make a request - returns typed value *)
8888+ let user = My_api.Client.get_user ~id:"123" client () in
8989+ Printf.printf "User: %s\n" user.name
9090+9191+ (* List endpoints return typed lists *)
9292+ let users = My_api.Client.list_users client () in
9393+ List.iter (fun u -> Printf.printf "- %s\n" u.name) users
9494+]}
9595+9696+{2 Request Bodies}
9797+9898+For POST/PUT/PATCH requests, pass the typed value directly:
9999+100100+{[
101101+(* Create typed request body *)
102102+let new_user : My_api.Types.CreateUserDto.t = {
103103+ name = "Bob";
104104+ email = "bob@example.com";
105105+} in
106106+107107+(* Pass as the body parameter - encoding is automatic *)
108108+let created = My_api.Client.create_user ~body:new_user client ()
109109+]}
110110+111111+{1 Keeping Generated Code Updated}
112112+113113+If you used [--regen], the generated [dune.inc] includes rules to regenerate
114114+the client when the spec changes:
115115+116116+{[
117117+# Regenerate and promote changes
118118+dune build @gen --auto-promote
119119+]}
120120+121121+This is useful for CI pipelines to ensure generated code stays in sync with
122122+the OpenAPI specification.
123123+124124+{1 Library Modules}
125125+126126+{2 Core Modules}
127127+128128+{ul
129129+{- {!module:Openapi.Spec} — OpenAPI 3.x specification types with jsont codecs}
130130+{- {!module:Openapi.Codegen} — Code generation from spec to OCaml}
131131+{- {!module:Openapi.Runtime} — Runtime utilities for generated clients}}
132132+133133+{2 Runtime Utilities}
134134+135135+The {!module:Openapi.Runtime} module provides helpers used by generated code:
136136+137137+{[
138138+(* Path template rendering *)
139139+Openapi.Runtime.Path.render
140140+ ~params:[("userId", "123"); ("postId", "456")]
141141+ "/users/{userId}/posts/{postId}"
142142+(* => "/users/123/posts/456" *)
143143+144144+(* Query string encoding *)
145145+Openapi.Runtime.Query.encode [("page", "1"); ("limit", "10")]
146146+(* => "?page=1&limit=10" *)
147147+]}
148148+149149+{1 Example: Immich API}
150150+151151+Here's a complete example generating a client for the Immich photo server:
152152+153153+{[
154154+# Generate the client
155155+openapi-gen generate immich-openapi-specs.json -o ./immich -n immich
156156+157157+# In your code:
158158+let () =
159159+ Eio_main.run @@ fun env ->
160160+ Eio.Switch.run @@ fun sw ->
161161+ let client = Immich.Client.create ~sw env
162162+ ~base_url:"http://localhost:2283/api" in
163163+164164+ (* List albums *)
165165+ let albums_json = Immich.Client.get_all_albums client () in
166166+167167+ (* Get server info *)
168168+ let info = Immich.Client.get_server_info client () in
169169+ ...
170170+]}
171171+172172+{1 Limitations}
173173+174174+{2 Schema Generation}
175175+176176+{ul
177177+{- {b oneOf/anyOf} — Union types are mapped to [Jsont.json]. Proper
178178+ implementation would generate OCaml variant types with discriminator-based
179179+ decoding. See {{:#union-types}Union Types} below for details.}
180180+{- {b allOf} — Composition schemas are mapped to [Jsont.json]. Proper
181181+ implementation would merge all referenced schemas into a single record type.}
182182+{- {b additionalProperties} — Dynamic object properties are parsed but not
183183+ used in code generation. Objects with [additionalProperties: true] become
184184+ [Jsont.json].}
185185+{- {b Recursive schemas} — Schemas that reference themselves are not fully
186186+ supported and may cause infinite loops during generation.}
187187+{- {b Nested $ref} — References to references are not resolved; only direct
188188+ schema references work.}}
189189+190190+{2 Client Generation}
191191+192192+{ul
193193+{- {b Error responses} — Error schemas (4xx, 5xx) are not generated. Errors
194194+ are raised as exceptions with the HTTP status code and body text.}
195195+{- {b Authentication} — Security schemes (apiKey, http, oauth2) are parsed
196196+ but not applied to requests. Add headers manually via the requests session.}
197197+{- {b Header parameters} — Header parameters are parsed but not included in
198198+ generated function signatures.}
199199+{- {b Cookie parameters} — Cookie parameters are parsed but not included in
200200+ generated functions.}
201201+{- {b Parameter references} — [$ref] in parameters are skipped; only inline
202202+ parameters are used.}}
203203+204204+{2 Content Types}
205205+206206+{ul
207207+{- {b File uploads} — [multipart/form-data] is not supported. Binary file
208208+ uploads require special handling not yet implemented.}
209209+{- {b XML} — Only [application/json] content types are supported.}
210210+{- {b Form encoding} — [application/x-www-form-urlencoded] is not supported.}}
211211+212212+{2 Advanced Features}
213213+214214+{ul
215215+{- {b Callbacks} — Webhook callbacks are parsed but no server code is
216216+ generated.}
217217+{- {b Links} — Response links are parsed but not used in code generation.}
218218+{- {b External references} — Only internal [$ref] pointers starting with
219219+ [#/] are supported. External file references are not resolved.}}
220220+221221+{1:union-types Implementing Union Types}
222222+223223+To properly support [oneOf]/[anyOf], the generator would need to:
224224+225225+{ol
226226+{- Analyze schemas in the union to determine variant names}
227227+{- Use the [discriminator] property if present to determine the tag field}
228228+{- Generate an OCaml variant type with one constructor per schema}
229229+{- Generate a decoder that:
230230+ {ul
231231+ {- Reads the discriminator field if present}
232232+ {- Pattern matches to select the appropriate decoder}
233233+ {- Falls back to trying each decoder in order for anyOf}}}
234234+{- Generate an encoder that pattern matches on the variant}}
235235+236236+Example of what generated code might look like:
237237+238238+{[
239239+(* For oneOf with discriminator *)
240240+type pet =
241241+ | Dog of Dog.t
242242+ | Cat of Cat.t
243243+244244+let pet_jsont : pet Jsont.t =
245245+ (* Read discriminator field "petType" to determine variant *)
246246+ ...
247247+]}
248248+249249+{1 See Also}
250250+251251+{ul
252252+{- {{:https://spec.openapis.org/oas/v3.0.3} OpenAPI 3.0 Specification}}
253253+{- {{:https://erratique.ch/software/jsont} jsont documentation}}
254254+{- {{:https://github.com/tarides/requests} requests library}}}
···11+(** Code generation from OpenAPI specifications.
22+33+ This module generates OCaml code from parsed OpenAPI specs:
44+ - Nested module structure grouped by common schema prefixes
55+ - Abstract types with accessor and constructor functions
66+ - Client functions placed in relevant type modules
77+ - Proper Eio error handling with context
88+*)
99+1010+module Spec = Openapi_spec
1111+1212+(** {1 Name Conversion} *)
1313+1414+module Name = struct
1515+ module StringSet = Set.Make(String)
1616+1717+ let ocaml_keywords = StringSet.of_list [
1818+ "and"; "as"; "assert"; "asr"; "begin"; "class"; "constraint"; "do"; "done";
1919+ "downto"; "else"; "end"; "exception"; "external"; "false"; "for"; "fun";
2020+ "function"; "functor"; "if"; "in"; "include"; "inherit"; "initializer";
2121+ "land"; "lazy"; "let"; "lor"; "lsl"; "lsr"; "lxor"; "match"; "method";
2222+ "mod"; "module"; "mutable"; "new"; "nonrec"; "object"; "of"; "open"; "or";
2323+ "private"; "rec"; "sig"; "struct"; "then"; "to"; "true"; "try"; "type";
2424+ "val"; "virtual"; "when"; "while"; "with"
2525+ ]
2626+2727+ let escape_keyword s =
2828+ if StringSet.mem s ocaml_keywords then s ^ "_" else s
2929+3030+ let to_snake_case s =
3131+ let buf = Buffer.create (String.length s) in
3232+ let prev_upper = ref false in
3333+ String.iteri (fun i c ->
3434+ match c with
3535+ | 'A'..'Z' ->
3636+ if i > 0 && not !prev_upper then Buffer.add_char buf '_';
3737+ Buffer.add_char buf (Char.lowercase_ascii c);
3838+ prev_upper := true
3939+ | 'a'..'z' | '0'..'9' | '_' ->
4040+ Buffer.add_char buf c;
4141+ prev_upper := false
4242+ | '-' | ' ' | '.' | '/' ->
4343+ Buffer.add_char buf '_';
4444+ prev_upper := false
4545+ | _ ->
4646+ prev_upper := false
4747+ ) s;
4848+ escape_keyword (Buffer.contents buf)
4949+5050+ let to_module_name s =
5151+ let snake = to_snake_case s in
5252+ let parts = String.split_on_char '_' snake in
5353+ String.concat "" (List.map String.capitalize_ascii parts)
5454+5555+ let to_type_name s = String.lowercase_ascii (to_snake_case s)
5656+5757+ let to_variant_name s = String.capitalize_ascii (to_snake_case s)
5858+5959+ (** Split a schema name into prefix and suffix for nested modules.
6060+ E.g., "AlbumResponseDto" -> ("Album", "ResponseDto") *)
6161+ let split_schema_name (name : string) : string * string =
6262+ (* Common suffixes to look for *)
6363+ let suffixes = [
6464+ "ResponseDto"; "RequestDto"; "CreateDto"; "UpdateDto"; "Dto";
6565+ "Response"; "Request"; "Create"; "Update"; "Config"; "Info";
6666+ "Status"; "Type"; "Entity"; "Item"; "Entry"; "Data"; "Result"
6767+ ] in
6868+ let found = List.find_opt (fun suffix ->
6969+ String.length name > String.length suffix &&
7070+ String.ends_with ~suffix name
7171+ ) suffixes in
7272+ match found with
7373+ | Some suffix ->
7474+ let prefix_len = String.length name - String.length suffix in
7575+ let prefix = String.sub name 0 prefix_len in
7676+ if prefix = "" then (name, "T")
7777+ else (prefix, suffix)
7878+ | None ->
7979+ (* No known suffix, use as-is with submodule T *)
8080+ (name, "T")
8181+8282+ let operation_name ~(method_ : string) ~(path : string) ~(operation_id : string option) =
8383+ match operation_id with
8484+ | Some id -> to_snake_case id
8585+ | None ->
8686+ let method_name = String.lowercase_ascii method_ in
8787+ let path_parts = String.split_on_char '/' path
8888+ |> List.filter (fun s -> s <> "" && not (String.length s > 0 && s.[0] = '{'))
8989+ in
9090+ let path_name = String.concat "_" (List.map to_snake_case path_parts) in
9191+ method_name ^ "_" ^ path_name
9292+end
9393+9494+(** {1 OCamldoc Helpers} *)
9595+9696+let escape_doc s =
9797+ let s = String.concat "\\}" (String.split_on_char '}' s) in
9898+ String.concat "\\{" (String.split_on_char '{' s)
9999+100100+let format_doc ?(indent=0) description =
101101+ let prefix = String.make indent ' ' in
102102+ match description with
103103+ | None | Some "" -> ""
104104+ | Some desc -> Printf.sprintf "%s(** %s *)\n" prefix (escape_doc desc)
105105+106106+let format_doc_block ?(indent=0) ~summary ?description () =
107107+ let prefix = String.make indent ' ' in
108108+ match summary, description with
109109+ | None, None -> ""
110110+ | Some s, None -> Printf.sprintf "%s(** %s *)\n" prefix (escape_doc s)
111111+ | None, Some d -> Printf.sprintf "%s(** %s *)\n" prefix (escape_doc d)
112112+ | Some s, Some d ->
113113+ Printf.sprintf "%s(** %s\n\n%s %s *)\n" prefix (escape_doc s) prefix (escape_doc d)
114114+115115+let format_param_doc name description =
116116+ match description with
117117+ | None | Some "" -> ""
118118+ | Some d -> Printf.sprintf " @param %s %s\n" name (escape_doc d)
119119+120120+(** {1 JSON Helpers} *)
121121+122122+let json_string = function
123123+ | Jsont.String (s, _) -> Some s
124124+ | _ -> None
125125+126126+let json_object = function
127127+ | Jsont.Object (mems, _) -> Some mems
128128+ | _ -> None
129129+130130+let get_ref json =
131131+ Option.bind (json_object json) (fun mems ->
132132+ List.find_map (fun ((n, _), v) ->
133133+ if n = "$ref" then json_string v else None
134134+ ) mems)
135135+136136+let get_member name json =
137137+ Option.bind (json_object json) (fun mems ->
138138+ List.find_map (fun ((n, _), v) ->
139139+ if n = name then Some v else None
140140+ ) mems)
141141+142142+let get_string_member name json =
143143+ Option.bind (get_member name json) json_string
144144+145145+(** {1 Schema Analysis} *)
146146+147147+let schema_name_from_ref (ref_ : string) : string option =
148148+ match String.split_on_char '/' ref_ with
149149+ | ["#"; "components"; "schemas"; name] -> Some name
150150+ | _ -> None
151151+152152+let rec find_refs_in_json (json : Jsont.json) : string list =
153153+ match json with
154154+ | Jsont.Object (mems, _) ->
155155+ (match List.find_map (fun ((n, _), v) ->
156156+ if n = "$ref" then json_string v else None) mems with
157157+ | Some ref_ -> Option.to_list (schema_name_from_ref ref_)
158158+ | None -> List.concat_map (fun (_, v) -> find_refs_in_json v) mems)
159159+ | Jsont.Array (items, _) -> List.concat_map find_refs_in_json items
160160+ | _ -> []
161161+162162+let find_schema_dependencies (schema : Spec.schema) : string list =
163163+ let from_properties = List.concat_map (fun (_, json) -> find_refs_in_json json) schema.properties in
164164+ let refs_from_list = Option.fold ~none:[] ~some:(List.concat_map find_refs_in_json) in
165165+ let from_items = Option.fold ~none:[] ~some:find_refs_in_json schema.items in
166166+ List.sort_uniq String.compare
167167+ (from_properties @ from_items @ refs_from_list schema.all_of
168168+ @ refs_from_list schema.one_of @ refs_from_list schema.any_of)
169169+170170+(** {1 Module Tree Structure} *)
171171+172172+module StringMap = Map.Make(String)
173173+module StringSet = Set.Make(String)
174174+175175+(** {1 Topological Sort} *)
176176+177177+(** Kahn's algorithm for topological sorting.
178178+ Returns nodes in dependency order (dependencies first). *)
179179+let topological_sort (nodes : string list) (deps : string -> string list) : string list =
180180+ (* Build adjacency list and in-degree map *)
181181+ let nodes_set = StringSet.of_list nodes in
182182+ let in_degree = List.fold_left (fun m node ->
183183+ StringMap.add node 0 m
184184+ ) StringMap.empty nodes in
185185+ let adj = List.fold_left (fun m node ->
186186+ StringMap.add node [] m
187187+ ) StringMap.empty nodes in
188188+ (* Add edges: if A depends on B, add edge B -> A *)
189189+ let (in_degree, adj) = List.fold_left (fun (in_degree, adj) node ->
190190+ let node_deps = deps node |> List.filter (fun d -> StringSet.mem d nodes_set) in
191191+ let in_degree = StringMap.add node (List.length node_deps) in_degree in
192192+ let adj = List.fold_left (fun adj dep ->
193193+ let existing = Option.value ~default:[] (StringMap.find_opt dep adj) in
194194+ StringMap.add dep (node :: existing) adj
195195+ ) adj node_deps in
196196+ (in_degree, adj)
197197+ ) (in_degree, adj) nodes in
198198+ (* Start with nodes that have no dependencies *)
199199+ let queue = List.filter (fun n ->
200200+ StringMap.find n in_degree = 0
201201+ ) nodes in
202202+ let rec process queue in_degree result =
203203+ match queue with
204204+ | [] -> List.rev result
205205+ | node :: rest ->
206206+ let result = node :: result in
207207+ let dependents = Option.value ~default:[] (StringMap.find_opt node adj) in
208208+ let (queue', in_degree) = List.fold_left (fun (q, deg) dep ->
209209+ let new_deg = StringMap.find dep deg - 1 in
210210+ let deg = StringMap.add dep new_deg deg in
211211+ if new_deg = 0 then (dep :: q, deg) else (q, deg)
212212+ ) (rest, in_degree) dependents in
213213+ process queue' in_degree result
214214+ in
215215+ process queue in_degree []
216216+217217+type field_info = {
218218+ ocaml_name : string;
219219+ json_name : string;
220220+ ocaml_type : string;
221221+ base_type : string;
222222+ is_optional : bool;
223223+ is_required : bool;
224224+ description : string option;
225225+}
226226+227227+type schema_info = {
228228+ original_name : string;
229229+ prefix : string;
230230+ suffix : string;
231231+ schema : Spec.schema;
232232+ fields : field_info list;
233233+ is_enum : bool;
234234+ enum_variants : (string * string) list; (* ocaml_name, json_value *)
235235+ description : string option;
236236+}
237237+238238+type operation_info = {
239239+ func_name : string;
240240+ operation_id : string option;
241241+ summary : string option;
242242+ description : string option;
243243+ tags : string list;
244244+ path : string;
245245+ method_ : string;
246246+ path_params : (string * string * string option * bool) list; (* ocaml, json, desc, required *)
247247+ query_params : (string * string * string option * bool) list;
248248+ body_schema_ref : string option;
249249+ response_schema_ref : string option;
250250+}
251251+252252+type module_node = {
253253+ name : string;
254254+ schemas : schema_info list;
255255+ operations : operation_info list;
256256+ dependencies : StringSet.t; (* Other prefix modules this depends on *)
257257+ children : module_node StringMap.t;
258258+}
259259+260260+let empty_node name = { name; schemas = []; operations = []; dependencies = StringSet.empty; children = StringMap.empty }
261261+262262+(** {1 Type Resolution} *)
263263+264264+let rec type_of_json_schema (json : Jsont.json) : string * bool =
265265+ (* Check if the schema is nullable *)
266266+ let is_nullable = match get_member "nullable" json with
267267+ | Some (Jsont.Bool (b, _)) -> b
268268+ | _ -> false
269269+ in
270270+ match get_ref json with
271271+ | Some ref_ ->
272272+ (match schema_name_from_ref ref_ with
273273+ | Some name ->
274274+ let prefix, suffix = Name.split_schema_name name in
275275+ (Printf.sprintf "%s.%s.t" (Name.to_module_name prefix) (Name.to_module_name suffix), is_nullable)
276276+ | None -> ("Jsont.json", is_nullable))
277277+ | None ->
278278+ match get_string_member "type" json with
279279+ | Some "string" ->
280280+ (match get_string_member "format" json with
281281+ | Some "date-time" -> ("Ptime.t", is_nullable)
282282+ | _ -> ("string", is_nullable))
283283+ | Some "integer" ->
284284+ (match get_string_member "format" json with
285285+ | Some "int64" -> ("int64", is_nullable)
286286+ | Some "int32" -> ("int32", is_nullable)
287287+ | _ -> ("int", is_nullable))
288288+ | Some "number" -> ("float", is_nullable)
289289+ | Some "boolean" -> ("bool", is_nullable)
290290+ | Some "array" ->
291291+ (match get_member "items" json with
292292+ | Some items ->
293293+ let (elem_type, _) = type_of_json_schema items in
294294+ (elem_type ^ " list", is_nullable)
295295+ | None -> ("Jsont.json list", is_nullable))
296296+ | Some "object" -> ("Jsont.json", is_nullable)
297297+ | _ -> ("Jsont.json", is_nullable)
298298+299299+let rec jsont_of_base_type = function
300300+ | "string" -> "Jsont.string"
301301+ | "int" -> "Jsont.int"
302302+ | "int32" -> "Jsont.int32"
303303+ | "int64" -> "Jsont.int64"
304304+ | "float" -> "Jsont.number"
305305+ | "bool" -> "Jsont.bool"
306306+ | "Ptime.t" -> "Openapi.Runtime.ptime_jsont"
307307+ | "Jsont.json" -> "Jsont.json"
308308+ | s when String.ends_with ~suffix:" list" s ->
309309+ let elem = String.sub s 0 (String.length s - 5) in
310310+ Printf.sprintf "(Jsont.list %s)" (jsont_of_base_type elem)
311311+ | s when String.ends_with ~suffix:".t" s ->
312312+ let module_path = String.sub s 0 (String.length s - 2) in
313313+ module_path ^ ".jsont"
314314+ | _ -> "Jsont.json"
315315+316316+(** {1 Schema Processing} *)
317317+318318+let analyze_schema (name : string) (schema : Spec.schema) : schema_info =
319319+ let prefix, suffix = Name.split_schema_name name in
320320+ let is_enum = Option.is_some schema.enum in
321321+ let enum_variants = match schema.enum with
322322+ | Some values ->
323323+ List.filter_map (fun json ->
324324+ match json with
325325+ | Jsont.String (s, _) -> Some (Name.to_variant_name s, s)
326326+ | _ -> None
327327+ ) values
328328+ | None -> []
329329+ in
330330+ let fields = List.map (fun (field_name, field_json) ->
331331+ let ocaml_name = Name.to_snake_case field_name in
332332+ let is_required = List.mem field_name schema.required in
333333+ let (base_type, json_nullable) = type_of_json_schema field_json in
334334+ let is_optional = json_nullable || not is_required in
335335+ let ocaml_type = if is_optional then base_type ^ " option" else base_type in
336336+ let description = get_string_member "description" field_json in
337337+ { ocaml_name; json_name = field_name; ocaml_type; base_type; is_optional; is_required; description }
338338+ ) schema.properties in
339339+ { original_name = name; prefix; suffix; schema; fields; is_enum; enum_variants;
340340+ description = schema.description }
341341+342342+(** {1 Operation Processing} *)
343343+344344+let analyze_operation ~path ~method_ (op : Spec.operation) : operation_info =
345345+ let func_name = Name.operation_name ~method_ ~path ~operation_id:op.operation_id in
346346+ let params = List.filter_map (fun (p : Spec.parameter Spec.or_ref) ->
347347+ match p with Spec.Value p -> Some p | Spec.Ref _ -> None
348348+ ) op.parameters in
349349+350350+ let path_params = List.filter_map (fun (p : Spec.parameter) ->
351351+ if p.in_ = Spec.Path then
352352+ Some (Name.to_snake_case p.name, p.name, p.description, p.required)
353353+ else None
354354+ ) params in
355355+356356+ let query_params = List.filter_map (fun (p : Spec.parameter) ->
357357+ if p.in_ = Spec.Query then
358358+ Some (Name.to_snake_case p.name, p.name, p.description, p.required)
359359+ else None
360360+ ) params in
361361+362362+ let body_schema_ref = match op.request_body with
363363+ | Some (Spec.Value (rb : Spec.request_body)) ->
364364+ List.find_map (fun (ct, (media : Spec.media_type)) ->
365365+ if String.length ct >= 16 && String.sub ct 0 16 = "application/json" then
366366+ match media.schema with
367367+ | Some (Spec.Ref r) -> schema_name_from_ref r
368368+ | _ -> None
369369+ else None
370370+ ) rb.content
371371+ | _ -> None
372372+ in
373373+374374+ let response_schema_ref =
375375+ let find_in_content content =
376376+ List.find_map (fun (ct, (media : Spec.media_type)) ->
377377+ if String.length ct >= 16 && String.sub ct 0 16 = "application/json" then
378378+ match media.schema with
379379+ | Some (Spec.Ref r) -> schema_name_from_ref r
380380+ | Some (Spec.Value s) when s.type_ = Some "array" ->
381381+ Option.bind s.items (fun items -> Option.bind (get_ref items) schema_name_from_ref)
382382+ | _ -> None
383383+ else None
384384+ ) content
385385+ in
386386+ let try_status status =
387387+ List.find_map (fun (code, resp) ->
388388+ if code = status then
389389+ match resp with
390390+ | Spec.Value (r : Spec.response) -> find_in_content r.content
391391+ | _ -> None
392392+ else None
393393+ ) op.responses.responses
394394+ in
395395+ match try_status "200" with
396396+ | Some r -> Some r
397397+ | None -> match try_status "201" with
398398+ | Some r -> Some r
399399+ | None -> match op.responses.default with
400400+ | Some (Spec.Value (r : Spec.response)) -> find_in_content r.content
401401+ | _ -> None
402402+ in
403403+404404+ { func_name; operation_id = op.operation_id; summary = op.summary;
405405+ description = op.description; tags = op.tags; path; method_;
406406+ path_params; query_params; body_schema_ref; response_schema_ref }
407407+408408+(** {1 Module Tree Building} *)
409409+410410+(** Extract prefix module dependencies from a schema's fields *)
411411+let schema_prefix_deps (schema : schema_info) : StringSet.t =
412412+ let deps = List.filter_map (fun (f : field_info) ->
413413+ (* Check if the type references another module *)
414414+ if String.contains f.base_type '.' then
415415+ (* Extract first component before the dot *)
416416+ match String.split_on_char '.' f.base_type with
417417+ | prefix :: _ when prefix <> "Jsont" && prefix <> "Ptime" && prefix <> "Openapi" ->
418418+ Some prefix
419419+ | _ -> None
420420+ else None
421421+ ) schema.fields in
422422+ StringSet.of_list deps
423423+424424+(** Extract prefix module dependencies from an operation's types *)
425425+let operation_prefix_deps (op : operation_info) : StringSet.t =
426426+ let body_dep = match op.body_schema_ref with
427427+ | Some name ->
428428+ let prefix, _ = Name.split_schema_name name in
429429+ Some (Name.to_module_name prefix)
430430+ | None -> None
431431+ in
432432+ let response_dep = match op.response_schema_ref with
433433+ | Some name ->
434434+ let prefix, _ = Name.split_schema_name name in
435435+ Some (Name.to_module_name prefix)
436436+ | None -> None
437437+ in
438438+ StringSet.of_list (List.filter_map Fun.id [body_dep; response_dep])
439439+440440+let build_module_tree (schemas : schema_info list) (operations : operation_info list) : module_node * string list =
441441+ let root = empty_node "Root" in
442442+443443+ (* Add schemas to tree and track dependencies *)
444444+ let root = List.fold_left (fun root schema ->
445445+ let prefix_mod = Name.to_module_name schema.prefix in
446446+ let child = match StringMap.find_opt prefix_mod root.children with
447447+ | Some c -> c
448448+ | None -> empty_node prefix_mod
449449+ in
450450+ let schema_deps = schema_prefix_deps schema in
451451+ (* Remove self-dependency *)
452452+ let schema_deps = StringSet.remove prefix_mod schema_deps in
453453+ let child = { child with
454454+ schemas = schema :: child.schemas;
455455+ dependencies = StringSet.union child.dependencies schema_deps
456456+ } in
457457+ { root with children = StringMap.add prefix_mod child root.children }
458458+ ) root schemas in
459459+460460+ (* Add operations to tree based on response type, and track operation dependencies *)
461461+ let root = List.fold_left (fun root op ->
462462+ match op.response_schema_ref with
463463+ | Some ref_name ->
464464+ let prefix, _ = Name.split_schema_name ref_name in
465465+ let prefix_mod = Name.to_module_name prefix in
466466+ let child = match StringMap.find_opt prefix_mod root.children with
467467+ | Some c -> c
468468+ | None -> empty_node prefix_mod
469469+ in
470470+ let op_deps = operation_prefix_deps op in
471471+ (* Remove self-dependency *)
472472+ let op_deps = StringSet.remove prefix_mod op_deps in
473473+ let child = { child with
474474+ operations = op :: child.operations;
475475+ dependencies = StringSet.union child.dependencies op_deps
476476+ } in
477477+ { root with children = StringMap.add prefix_mod child root.children }
478478+ | None ->
479479+ (* Put in Client module for operations without typed response *)
480480+ let child = match StringMap.find_opt "Client" root.children with
481481+ | Some c -> c
482482+ | None -> empty_node "Client"
483483+ in
484484+ let op_deps = operation_prefix_deps op in
485485+ let op_deps = StringSet.remove "Client" op_deps in
486486+ let child = { child with
487487+ operations = op :: child.operations;
488488+ dependencies = StringSet.union child.dependencies op_deps
489489+ } in
490490+ { root with children = StringMap.add "Client" child root.children }
491491+ ) root operations in
492492+493493+ (* Get sorted list of module names (dependencies first) *)
494494+ let module_names = StringMap.fold (fun name _ acc -> name :: acc) root.children [] in
495495+ let deps_of name =
496496+ match StringMap.find_opt name root.children with
497497+ | Some node -> StringSet.elements node.dependencies
498498+ | None -> []
499499+ in
500500+ let sorted = topological_sort module_names deps_of in
501501+502502+ (root, sorted)
503503+504504+(** {1 Code Generation} *)
505505+506506+let gen_enum_impl (schema : schema_info) : string =
507507+ let doc = format_doc schema.description in
508508+ if schema.enum_variants = [] then
509509+ Printf.sprintf "%stype t = string\n\nlet jsont = Jsont.string" doc
510510+ else
511511+ let type_def = Printf.sprintf "%stype t = [\n%s\n]" doc
512512+ (String.concat "\n" (List.map (fun (v, _) -> " | `" ^ v) schema.enum_variants))
513513+ in
514514+ let dec_cases = String.concat "\n" (List.map (fun (v, raw) ->
515515+ Printf.sprintf " | %S -> `%s" raw v
516516+ ) schema.enum_variants) in
517517+ let enc_cases = String.concat "\n" (List.map (fun (v, raw) ->
518518+ Printf.sprintf " | `%s -> %S" v raw
519519+ ) schema.enum_variants) in
520520+ Printf.sprintf {|%s
521521+522522+let jsont : t Jsont.t =
523523+ Jsont.map Jsont.string ~kind:%S
524524+ ~dec:(function
525525+%s
526526+ | s -> Jsont.Error.msgf Jsont.Meta.none "Unknown value: %%s" s)
527527+ ~enc:(function
528528+%s)|} type_def schema.original_name dec_cases enc_cases
529529+530530+let gen_enum_intf (schema : schema_info) : string =
531531+ let doc = format_doc schema.description in
532532+ if schema.enum_variants = [] then
533533+ Printf.sprintf "%stype t = string\n\nval jsont : t Jsont.t" doc
534534+ else
535535+ let type_def = Printf.sprintf "%stype t = [\n%s\n]" doc
536536+ (String.concat "\n" (List.map (fun (v, _) -> " | `" ^ v) schema.enum_variants))
537537+ in
538538+ Printf.sprintf "%s\n\nval jsont : t Jsont.t" type_def
539539+540540+(** Localize an OCaml type string by stripping the current_prefix module *)
541541+let localize_type ~current_prefix (type_str : string) : string =
542542+ (* Handle patterns like "User.ResponseDto.t" -> "ResponseDto.t" if current_prefix = "User" *)
543543+ let prefix_dot = current_prefix ^ "." in
544544+ let strip_prefix s =
545545+ if String.length s >= String.length prefix_dot &&
546546+ String.sub s 0 (String.length prefix_dot) = prefix_dot then
547547+ String.sub s (String.length prefix_dot) (String.length s - String.length prefix_dot)
548548+ else s
549549+ in
550550+ (* Handle "X list", "X option", and nested combinations *)
551551+ let rec localize s =
552552+ if String.ends_with ~suffix:" list" s then
553553+ let elem = String.sub s 0 (String.length s - 5) in
554554+ (localize elem) ^ " list"
555555+ else if String.ends_with ~suffix:" option" s then
556556+ let elem = String.sub s 0 (String.length s - 7) in
557557+ (localize elem) ^ " option"
558558+ else
559559+ strip_prefix s
560560+ in
561561+ localize type_str
562562+563563+(** Localize a jsont codec string by stripping the current_prefix module *)
564564+let rec localize_jsont ~current_prefix (jsont_str : string) : string =
565565+ let prefix_dot = current_prefix ^ "." in
566566+ (* Handle patterns like "User.ResponseDto.jsont" -> "ResponseDto.jsont" *)
567567+ (* Also handle "(Jsont.list User.ResponseDto.jsont)" *)
568568+ if String.length jsont_str >= String.length prefix_dot then
569569+ if String.sub jsont_str 0 (String.length prefix_dot) = prefix_dot then
570570+ String.sub jsont_str (String.length prefix_dot) (String.length jsont_str - String.length prefix_dot)
571571+ else if String.length jsont_str > 12 && String.sub jsont_str 0 12 = "(Jsont.list " then
572572+ let inner = String.sub jsont_str 12 (String.length jsont_str - 13) in
573573+ "(Jsont.list " ^ localize_jsont ~current_prefix inner ^ ")"
574574+ else
575575+ jsont_str
576576+ else
577577+ jsont_str
578578+579579+let gen_record_impl ~current_prefix (schema : schema_info) : string =
580580+ let loc_type = localize_type ~current_prefix in
581581+ let loc_jsont = localize_jsont ~current_prefix in
582582+ let doc = format_doc schema.description in
583583+ if schema.fields = [] then
584584+ Printf.sprintf "%stype t = Jsont.json\n\nlet jsont = Jsont.json\n\nlet v () = Jsont.Null ((), Jsont.Meta.none)" doc
585585+ else
586586+ (* Private type definition *)
587587+ let type_fields = String.concat "\n" (List.map (fun (f : field_info) ->
588588+ let field_doc = match f.description with
589589+ | Some d -> Printf.sprintf " (** %s *)" (escape_doc d)
590590+ | None -> ""
591591+ in
592592+ Printf.sprintf " %s : %s;%s" f.ocaml_name (loc_type f.ocaml_type) field_doc
593593+ ) schema.fields) in
594594+595595+ let type_def = Printf.sprintf "%stype t = {\n%s\n}" doc type_fields in
596596+597597+ (* Constructor function v *)
598598+ let required_fields = List.filter (fun (f : field_info) -> not f.is_optional) schema.fields in
599599+ let optional_fields = List.filter (fun (f : field_info) -> f.is_optional) schema.fields in
600600+ let v_params =
601601+ (List.map (fun (f : field_info) -> Printf.sprintf "~%s" f.ocaml_name) required_fields) @
602602+ (List.map (fun (f : field_info) -> Printf.sprintf "?%s" f.ocaml_name) optional_fields) @
603603+ ["()"]
604604+ in
605605+ let v_body = String.concat "; " (List.map (fun (f : field_info) -> f.ocaml_name) schema.fields) in
606606+ let v_func = Printf.sprintf "let v %s = { %s }" (String.concat " " v_params) v_body in
607607+608608+ (* Accessor functions *)
609609+ let accessors = String.concat "\n" (List.map (fun (f : field_info) ->
610610+ Printf.sprintf "let %s t = t.%s" f.ocaml_name f.ocaml_name
611611+ ) schema.fields) in
612612+613613+ (* Jsont codec *)
614614+ let make_params = String.concat " " (List.map (fun (f : field_info) -> f.ocaml_name) schema.fields) in
615615+ let jsont_members = String.concat "\n" (List.map (fun (f : field_info) ->
616616+ let codec = loc_jsont (jsont_of_base_type f.base_type) in
617617+ if f.is_optional then
618618+ if f.is_required then
619619+ Printf.sprintf " |> Jsont.Object.mem %S (Jsont.option %s)\n ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun r -> r.%s)"
620620+ f.json_name codec f.ocaml_name
621621+ else
622622+ Printf.sprintf " |> Jsont.Object.opt_mem %S %s ~enc:(fun r -> r.%s)"
623623+ f.json_name codec f.ocaml_name
624624+ else
625625+ Printf.sprintf " |> Jsont.Object.mem %S %s ~enc:(fun r -> r.%s)"
626626+ f.json_name codec f.ocaml_name
627627+ ) schema.fields) in
628628+629629+ Printf.sprintf {|%s
630630+631631+%s
632632+633633+%s
634634+635635+let jsont : t Jsont.t =
636636+ Jsont.Object.map ~kind:%S
637637+ (fun %s -> { %s })
638638+%s
639639+ |> Jsont.Object.skip_unknown
640640+ |> Jsont.Object.finish|}
641641+ type_def v_func accessors schema.original_name make_params v_body jsont_members
642642+643643+let gen_record_intf ~current_prefix (schema : schema_info) : string =
644644+ let loc_type = localize_type ~current_prefix in
645645+ let doc = format_doc schema.description in
646646+ if schema.fields = [] then
647647+ Printf.sprintf "%stype t\n\nval jsont : t Jsont.t\n\nval v : unit -> t" doc
648648+ else
649649+ (* Abstract type *)
650650+ let type_decl = Printf.sprintf "%stype t" doc in
651651+652652+ (* Constructor signature *)
653653+ let required_fields = List.filter (fun (f : field_info) -> not f.is_optional) schema.fields in
654654+ let optional_fields = List.filter (fun (f : field_info) -> f.is_optional) schema.fields in
655655+ let v_param_docs = String.concat ""
656656+ ((List.map (fun (f : field_info) -> format_param_doc f.ocaml_name f.description) required_fields) @
657657+ (List.map (fun (f : field_info) -> format_param_doc f.ocaml_name f.description) optional_fields))
658658+ in
659659+ let v_params =
660660+ (List.map (fun (f : field_info) -> Printf.sprintf "%s:%s" f.ocaml_name (loc_type f.base_type)) required_fields) @
661661+ (List.map (fun (f : field_info) -> Printf.sprintf "?%s:%s" f.ocaml_name (loc_type f.base_type)) optional_fields) @
662662+ ["unit"; "t"]
663663+ in
664664+ let v_doc = if v_param_docs = "" then "(** Construct a value *)\n"
665665+ else Printf.sprintf "(** Construct a value\n%s*)\n" v_param_docs in
666666+ let v_sig = Printf.sprintf "%sval v : %s" v_doc (String.concat " -> " v_params) in
667667+668668+ (* Accessor signatures *)
669669+ let accessor_sigs = String.concat "\n\n" (List.map (fun (f : field_info) ->
670670+ let acc_doc = match f.description with
671671+ | Some d -> Printf.sprintf "(** %s *)\n" (escape_doc d)
672672+ | None -> ""
673673+ in
674674+ Printf.sprintf "%sval %s : t -> %s" acc_doc f.ocaml_name (loc_type f.ocaml_type)
675675+ ) schema.fields) in
676676+677677+ Printf.sprintf "%s\n\n%s\n\n%s\n\nval jsont : t Jsont.t"
678678+ type_decl v_sig accessor_sigs
679679+680680+(** Format a jsont codec reference, stripping the current_prefix if present *)
681681+let format_jsont_ref ~current_prefix (schema_ref : string) : string =
682682+ let prefix, suffix = Name.split_schema_name schema_ref in
683683+ let prefix_mod = Name.to_module_name prefix in
684684+ let suffix_mod = Name.to_module_name suffix in
685685+ if prefix_mod = current_prefix then
686686+ Printf.sprintf "%s.jsont" suffix_mod
687687+ else
688688+ Printf.sprintf "%s.%s.jsont" prefix_mod suffix_mod
689689+690690+let gen_operation_impl ~current_prefix (op : operation_info) : string =
691691+ let doc = format_doc_block ~summary:op.summary ?description:op.description () in
692692+ let param_docs = String.concat ""
693693+ ((List.map (fun (n, _, d, _) -> format_param_doc n d) op.path_params) @
694694+ (List.map (fun (n, _, d, _) -> format_param_doc n d) op.query_params)) in
695695+ let full_doc = if param_docs = "" then doc
696696+ else if doc = "" then Printf.sprintf "(**\n%s*)\n" param_docs
697697+ else String.sub doc 0 (String.length doc - 3) ^ param_docs ^ "*)\n" in
698698+699699+ let path_args = List.map (fun (n, _, _, _) -> Printf.sprintf "~%s" n) op.path_params in
700700+ let query_args = List.map (fun (n, _, _, req) ->
701701+ if req then Printf.sprintf "~%s" n else Printf.sprintf "?%s" n
702702+ ) op.query_params in
703703+ (* DELETE and HEAD don't support body in the requests library *)
704704+ let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in
705705+ let body_arg = match op.body_schema_ref, method_supports_body with
706706+ | Some _, true -> ["~body"]
707707+ | _ -> []
708708+ in
709709+ let all_args = path_args @ query_args @ body_arg @ ["client"; "()"] in
710710+711711+ let path_render =
712712+ if op.path_params = [] then Printf.sprintf "%S" op.path
713713+ else
714714+ let bindings = List.map (fun (ocaml, json, _, _) ->
715715+ Printf.sprintf "(%S, %s)" json ocaml
716716+ ) op.path_params in
717717+ Printf.sprintf "Openapi.Runtime.Path.render ~params:[%s] %S"
718718+ (String.concat "; " bindings) op.path
719719+ in
720720+721721+ let query_build =
722722+ if op.query_params = [] then "\"\""
723723+ else
724724+ let parts = List.map (fun (ocaml, json, _, req) ->
725725+ if req then Printf.sprintf "Openapi.Runtime.Query.singleton ~key:%S ~value:%s" json ocaml
726726+ else Printf.sprintf "Openapi.Runtime.Query.optional ~key:%S ~value:%s" json ocaml
727727+ ) op.query_params in
728728+ Printf.sprintf "Openapi.Runtime.Query.encode (List.concat [%s])" (String.concat "; " parts)
729729+ in
730730+731731+ let method_lower = String.lowercase_ascii op.method_ in
732732+ let body_codec = match op.body_schema_ref with
733733+ | Some name -> format_jsont_ref ~current_prefix name
734734+ | None -> "Jsont.json"
735735+ in
736736+ (* DELETE and HEAD don't support body in the requests library *)
737737+ let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in
738738+ let http_call = match op.body_schema_ref, method_supports_body with
739739+ | Some _, true ->
740740+ Printf.sprintf "Requests.%s client.session ~body:(Requests.Body.json (Openapi.Runtime.Json.encode_json %s body)) url"
741741+ method_lower body_codec
742742+ | Some _, false ->
743743+ (* Method doesn't support body - ignore the body parameter *)
744744+ Printf.sprintf "Requests.%s client.session url" method_lower
745745+ | None, _ ->
746746+ Printf.sprintf "Requests.%s client.session url" method_lower
747747+ in
748748+749749+ let response_codec = match op.response_schema_ref with
750750+ | Some name -> format_jsont_ref ~current_prefix name
751751+ | None -> "Jsont.json"
752752+ in
753753+754754+ let decode = if response_codec = "Jsont.json" then
755755+ "Requests.Response.json response"
756756+ else
757757+ Printf.sprintf "Openapi.Runtime.Json.decode_json_exn %s (Requests.Response.json response)" response_codec
758758+ in
759759+760760+ Printf.sprintf {|%slet %s %s =
761761+ let op_name = %S in
762762+ let url_path = %s in
763763+ let query = %s in
764764+ let url = client.base_url ^ url_path ^ query in
765765+ let response =
766766+ try %s
767767+ with Eio.Io _ as ex ->
768768+ let bt = Printexc.get_raw_backtrace () in
769769+ Eio.Exn.reraise_with_context ex bt "calling %%s %%s" %S url
770770+ in
771771+ if Requests.Response.ok response then
772772+ %s
773773+ else
774774+ raise (Openapi.Runtime.Api_error {
775775+ operation = op_name;
776776+ method_ = %S;
777777+ url;
778778+ status = Requests.Response.status_code response;
779779+ body = Requests.Response.text response;
780780+ })|}
781781+ full_doc op.func_name (String.concat " " all_args)
782782+ op.func_name path_render query_build http_call op.method_ decode op.method_
783783+784784+(** Format a type reference, stripping the current_prefix if present *)
785785+let format_type_ref ~current_prefix (schema_ref : string) : string =
786786+ let prefix, suffix = Name.split_schema_name schema_ref in
787787+ let prefix_mod = Name.to_module_name prefix in
788788+ let suffix_mod = Name.to_module_name suffix in
789789+ if prefix_mod = current_prefix then
790790+ (* Local reference - use unqualified name *)
791791+ Printf.sprintf "%s.t" suffix_mod
792792+ else
793793+ Printf.sprintf "%s.%s.t" prefix_mod suffix_mod
794794+795795+let gen_operation_intf ~current_prefix (op : operation_info) : string =
796796+ let doc = format_doc_block ~summary:op.summary ?description:op.description () in
797797+ let param_docs = String.concat ""
798798+ ((List.map (fun (n, _, d, _) -> format_param_doc n d) op.path_params) @
799799+ (List.map (fun (n, _, d, _) -> format_param_doc n d) op.query_params)) in
800800+ let full_doc = if param_docs = "" then doc
801801+ else if doc = "" then Printf.sprintf "(**\n%s*)\n" param_docs
802802+ else String.sub doc 0 (String.length doc - 3) ^ param_docs ^ "*)\n" in
803803+804804+ let path_args = List.map (fun (n, _, _, _) -> Printf.sprintf "%s:string" n) op.path_params in
805805+ let query_args = List.map (fun (n, _, _, req) ->
806806+ if req then Printf.sprintf "%s:string" n else Printf.sprintf "?%s:string" n
807807+ ) op.query_params in
808808+ let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in
809809+ let body_arg = match op.body_schema_ref, method_supports_body with
810810+ | Some name, true -> [Printf.sprintf "body:%s" (format_type_ref ~current_prefix name)]
811811+ | _ -> []
812812+ in
813813+ let response_type = match op.response_schema_ref with
814814+ | Some name -> format_type_ref ~current_prefix name
815815+ | None -> "Jsont.json"
816816+ in
817817+ let all_args = path_args @ query_args @ body_arg @ ["t"; "unit"; response_type] in
818818+819819+ Printf.sprintf "%sval %s : %s" full_doc op.func_name (String.concat " -> " all_args)
820820+821821+(** {1 Full Module Generation} *)
822822+823823+let gen_submodule_impl ~current_prefix (schema : schema_info) : string =
824824+ let suffix_mod = Name.to_module_name schema.suffix in
825825+ let content = if schema.is_enum then gen_enum_impl schema else gen_record_impl ~current_prefix schema in
826826+ let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
827827+ Printf.sprintf "module %s = struct\n%s\nend" suffix_mod indented
828828+829829+let gen_submodule_intf ~current_prefix (schema : schema_info) : string =
830830+ let suffix_mod = Name.to_module_name schema.suffix in
831831+ let content = if schema.is_enum then gen_enum_intf schema else gen_record_intf ~current_prefix schema in
832832+ let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
833833+ Printf.sprintf "module %s : sig\n%s\nend" suffix_mod indented
834834+835835+(** Extract suffix module dependencies within the same prefix *)
836836+let schema_suffix_deps ~current_prefix (schema : schema_info) : string list =
837837+ List.filter_map (fun (f : field_info) ->
838838+ (* Check if the type references a sibling module (same prefix) *)
839839+ if String.contains f.base_type '.' then
840840+ match String.split_on_char '.' f.base_type with
841841+ | prefix :: suffix :: _ when prefix = current_prefix ->
842842+ Some (Name.to_module_name suffix)
843843+ | _ -> None
844844+ else None
845845+ ) schema.fields
846846+847847+(** Sort schemas within a prefix module by their dependencies *)
848848+let sort_schemas_by_deps ~current_prefix (schemas : schema_info list) : schema_info list =
849849+ let suffix_of schema = Name.to_module_name schema.suffix in
850850+ let suffix_names = List.map suffix_of schemas in
851851+ let deps_of suffix =
852852+ match List.find_opt (fun s -> suffix_of s = suffix) schemas with
853853+ | Some schema -> schema_suffix_deps ~current_prefix schema |> List.filter (fun d -> List.mem d suffix_names)
854854+ | None -> []
855855+ in
856856+ let sorted = topological_sort suffix_names deps_of in
857857+ List.filter_map (fun suffix ->
858858+ List.find_opt (fun s -> suffix_of s = suffix) schemas
859859+ ) sorted
860860+861861+let gen_prefix_module_impl (node : module_node) : string =
862862+ let sorted_schemas = sort_schemas_by_deps ~current_prefix:node.name node.schemas in
863863+ let schema_mods = List.map (gen_submodule_impl ~current_prefix:node.name) sorted_schemas in
864864+ let op_impls = List.map (gen_operation_impl ~current_prefix:node.name) (List.rev node.operations) in
865865+ let content = String.concat "\n\n" (schema_mods @ op_impls) in
866866+ let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
867867+ Printf.sprintf "module %s = struct\n%s\nend" node.name indented
868868+869869+let gen_prefix_module_intf (node : module_node) : string =
870870+ let sorted_schemas = sort_schemas_by_deps ~current_prefix:node.name node.schemas in
871871+ let schema_mods = List.map (gen_submodule_intf ~current_prefix:node.name) sorted_schemas in
872872+ let op_intfs = List.map (gen_operation_intf ~current_prefix:node.name) (List.rev node.operations) in
873873+ let content = String.concat "\n\n" (schema_mods @ op_intfs) in
874874+ let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
875875+ Printf.sprintf "module %s : sig\n%s\nend" node.name indented
876876+877877+(** {1 Top-Level Generation} *)
878878+879879+type config = {
880880+ output_dir : string;
881881+ package_name : string;
882882+ spec_path : string option;
883883+}
884884+885885+let generate_ml (spec : Spec.t) (package_name : string) : string =
886886+ let api_desc = Option.value ~default:"Generated API client." spec.info.description in
887887+888888+ (* Collect schemas *)
889889+ let schemas = match spec.components with
890890+ | None -> []
891891+ | Some c -> List.filter_map (fun (name, sor) ->
892892+ match sor with
893893+ | Spec.Ref _ -> None
894894+ | Spec.Value s -> Some (analyze_schema name s)
895895+ ) c.schemas
896896+ in
897897+898898+ (* Collect operations *)
899899+ let operations = List.concat_map (fun (path, pi) ->
900900+ let ops = [
901901+ ("GET", pi.Spec.get); ("POST", pi.post); ("PUT", pi.put);
902902+ ("DELETE", pi.delete); ("PATCH", pi.patch);
903903+ ("HEAD", pi.head); ("OPTIONS", pi.options);
904904+ ] in
905905+ List.filter_map (fun (method_, op_opt) ->
906906+ Option.map (fun op -> analyze_operation ~path ~method_ op) op_opt
907907+ ) ops
908908+ ) spec.paths in
909909+910910+ (* Build module tree *)
911911+ let (tree, sorted_modules) = build_module_tree schemas operations in
912912+913913+ (* Generate top-level client type and functions *)
914914+ let client_impl = {|type t = {
915915+ session : Requests.t;
916916+ base_url : string;
917917+}
918918+919919+let create ?session ~sw env ~base_url =
920920+ let session = match session with
921921+ | Some s -> s
922922+ | None -> Requests.create ~sw env
923923+ in
924924+ { session; base_url }
925925+926926+let base_url t = t.base_url
927927+let session t = t.session|} in
928928+929929+ (* Generate prefix modules in dependency order *)
930930+ let prefix_mods = List.filter_map (fun name ->
931931+ match StringMap.find_opt name tree.children with
932932+ | None -> None
933933+ | Some node ->
934934+ if node.name = "Client" then
935935+ (* Generate Client operations inline *)
936936+ let ops = List.map (gen_operation_impl ~current_prefix:"Client") (List.rev node.operations) in
937937+ if ops = [] then None
938938+ else
939939+ let content = String.concat "\n\n" ops in
940940+ let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
941941+ Some (Printf.sprintf "module Client = struct\n%s\nend" indented)
942942+ else
943943+ Some (gen_prefix_module_impl node)
944944+ ) sorted_modules in
945945+946946+ Printf.sprintf {|(** {1 %s}
947947+948948+ %s
949949+950950+ @version %s *)
951951+952952+%s
953953+954954+%s
955955+|}
956956+ (Name.to_module_name package_name) (escape_doc api_desc) spec.info.version
957957+ client_impl (String.concat "\n\n" prefix_mods)
958958+959959+let generate_mli (spec : Spec.t) (package_name : string) : string =
960960+ let api_desc = Option.value ~default:"Generated API client." spec.info.description in
961961+962962+ (* Collect schemas *)
963963+ let schemas = match spec.components with
964964+ | None -> []
965965+ | Some c -> List.filter_map (fun (name, sor) ->
966966+ match sor with
967967+ | Spec.Ref _ -> None
968968+ | Spec.Value s -> Some (analyze_schema name s)
969969+ ) c.schemas
970970+ in
971971+972972+ (* Collect operations *)
973973+ let operations = List.concat_map (fun (path, pi) ->
974974+ let ops = [
975975+ ("GET", pi.Spec.get); ("POST", pi.post); ("PUT", pi.put);
976976+ ("DELETE", pi.delete); ("PATCH", pi.patch);
977977+ ("HEAD", pi.head); ("OPTIONS", pi.options);
978978+ ] in
979979+ List.filter_map (fun (method_, op_opt) ->
980980+ Option.map (fun op -> analyze_operation ~path ~method_ op) op_opt
981981+ ) ops
982982+ ) spec.paths in
983983+984984+ (* Build module tree *)
985985+ let (tree, sorted_modules) = build_module_tree schemas operations in
986986+987987+ (* Generate top-level client type and function interfaces *)
988988+ let client_intf = {|type t
989989+990990+val create :
991991+ ?session:Requests.t ->
992992+ sw:Eio.Switch.t ->
993993+ < net : _ Eio.Net.t ; fs : Eio.Fs.dir_ty Eio.Path.t ; clock : _ Eio.Time.clock ; .. > ->
994994+ base_url:string ->
995995+ t
996996+997997+val base_url : t -> string
998998+val session : t -> Requests.t|} in
999999+10001000+ (* Generate prefix modules in dependency order *)
10011001+ let prefix_mods = List.filter_map (fun name ->
10021002+ match StringMap.find_opt name tree.children with
10031003+ | None -> None
10041004+ | Some node ->
10051005+ if node.name = "Client" then
10061006+ let ops = List.map (gen_operation_intf ~current_prefix:"Client") (List.rev node.operations) in
10071007+ if ops = [] then None
10081008+ else
10091009+ let content = String.concat "\n\n" ops in
10101010+ let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
10111011+ Some (Printf.sprintf "module Client : sig\n%s\nend" indented)
10121012+ else
10131013+ Some (gen_prefix_module_intf node)
10141014+ ) sorted_modules in
10151015+10161016+ Printf.sprintf {|(** {1 %s}
10171017+10181018+ %s
10191019+10201020+ @version %s *)
10211021+10221022+%s
10231023+10241024+%s
10251025+|}
10261026+ (Name.to_module_name package_name) (escape_doc api_desc) spec.info.version
10271027+ client_intf (String.concat "\n\n" prefix_mods)
10281028+10291029+let generate_dune (package_name : string) : string =
10301030+ Printf.sprintf {|(library
10311031+ (name %s)
10321032+ (libraries openapi jsont jsont.bytesrw requests ptime eio)
10331033+ (wrapped true))
10341034+10351035+(include dune.inc)
10361036+|} package_name
10371037+10381038+let generate_dune_inc ~(spec_path : string option) (package_name : string) : string =
10391039+ match spec_path with
10401040+ | None -> "; No spec path provided - regeneration rules not generated\n"
10411041+ | Some path ->
10421042+ Printf.sprintf {|; Generated rules for OpenAPI code regeneration
10431043+; Run: dune build @gen --auto-promote
10441044+10451045+(rule
10461046+ (alias gen)
10471047+ (mode (promote (until-clean)))
10481048+ (targets %s.ml %s.mli)
10491049+ (deps %s)
10501050+ (action
10511051+ (run openapi-gen generate -o . -n %s %%{deps})))
10521052+|} package_name package_name path package_name
10531053+10541054+let generate ~(config : config) (spec : Spec.t) : (string * string) list =
10551055+ let package_name = config.package_name in
10561056+ [
10571057+ ("dune", generate_dune package_name);
10581058+ ("dune.inc", generate_dune_inc ~spec_path:config.spec_path package_name);
10591059+ (package_name ^ ".ml", generate_ml spec package_name);
10601060+ (package_name ^ ".mli", generate_mli spec package_name);
10611061+ ]
10621062+10631063+let write_files ~(output_dir : string) (files : (string * string) list) : unit =
10641064+ List.iter (fun (filename, content) ->
10651065+ let path = Filename.concat output_dir filename in
10661066+ let oc = open_out path in
10671067+ output_string oc content;
10681068+ close_out oc
10691069+ ) files
+156
lib/openapi_nestjs.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** NestJS-style API error handling.
77+88+ NestJS/Express applications return errors in a standard format:
99+ {[
1010+ {
1111+ "message": "Missing required permission: person.read",
1212+ "error": "Forbidden",
1313+ "statusCode": 403,
1414+ "correlationId": "koskgk9d"
1515+ }
1616+ ]}
1717+1818+ This module provides types and utilities for parsing and handling
1919+ these errors in a structured way.
2020+2121+ {2 Usage}
2222+2323+ {[
2424+ match Immich.People.get_all_people client () with
2525+ | people -> ...
2626+ | exception Openapi.Runtime.Api_error e ->
2727+ match Openapi.Nestjs.of_api_error e with
2828+ | Some nestjs_error ->
2929+ Fmt.epr "Error: %s (correlation: %s)@."
3030+ nestjs_error.message
3131+ (Option.value ~default:"none" nestjs_error.correlation_id)
3232+ | None ->
3333+ (* Not a NestJS error, use raw body *)
3434+ Fmt.epr "Error: %s@." e.body
3535+ ]}
3636+*)
3737+3838+(** {1 Error Types} *)
3939+4040+(** A structured NestJS HTTP exception. *)
4141+type t = {
4242+ status_code : int;
4343+ (** HTTP status code (e.g., 403, 404, 500). *)
4444+4545+ error : string option;
4646+ (** Error category (e.g., "Forbidden", "Not Found", "Internal Server Error"). *)
4747+4848+ message : string;
4949+ (** Human-readable error message. Can be a single string or concatenated
5050+ from an array of validation messages. *)
5151+5252+ correlation_id : string option;
5353+ (** Request correlation ID for debugging/support. *)
5454+}
5555+5656+(** {1 JSON Codec} *)
5757+5858+(** Jsont codec for NestJS errors.
5959+6060+ Handles both string and array message formats:
6161+ - {[ "message": "error text" ]}
6262+ - {[ "message": ["validation error 1", "validation error 2"] ]} *)
6363+let jsont : t Jsont.t =
6464+ (* Message can be string or array of strings *)
6565+ let message_jsont =
6666+ Jsont.map Jsont.json ~kind:"message"
6767+ ~dec:(fun json ->
6868+ match json with
6969+ | Jsont.String (s, _) -> s
7070+ | Jsont.Array (items, _) ->
7171+ items
7272+ |> List.filter_map (function
7373+ | Jsont.String (s, _) -> Some s
7474+ | _ -> None)
7575+ |> String.concat "; "
7676+ | _ -> "Unknown error")
7777+ ~enc:(fun s -> Jsont.String (s, Jsont.Meta.none))
7878+ in
7979+ Jsont.Object.map ~kind:"NestjsError"
8080+ (fun status_code error message correlation_id ->
8181+ { status_code; error; message; correlation_id })
8282+ |> Jsont.Object.mem "statusCode" Jsont.int ~enc:(fun e -> e.status_code)
8383+ |> Jsont.Object.opt_mem "error" Jsont.string ~enc:(fun e -> e.error)
8484+ |> Jsont.Object.mem "message" message_jsont ~enc:(fun e -> e.message)
8585+ |> Jsont.Object.opt_mem "correlationId" Jsont.string ~enc:(fun e -> e.correlation_id)
8686+ |> Jsont.Object.skip_unknown
8787+ |> Jsont.Object.finish
8888+8989+(** {1 Parsing} *)
9090+9191+(** Parse a JSON string into a NestJS error.
9292+ Returns [None] if the string is not valid NestJS error JSON. *)
9393+let of_string (s : string) : t option =
9494+ match Jsont_bytesrw.decode_string jsont s with
9595+ | Ok e -> Some e
9696+ | Error _ -> None
9797+9898+(** Parse an {!Openapi.Runtime.Api_error} into a structured NestJS error.
9999+ Returns [None] if the error body is not valid NestJS error JSON. *)
100100+let of_api_error (e : Openapi_runtime.api_error) : t option =
101101+ of_string e.body
102102+103103+(** {1 Convenience Functions} *)
104104+105105+(** Check if this is a permission/authorization error (401 or 403). *)
106106+let is_auth_error (e : t) : bool =
107107+ e.status_code = 401 || e.status_code = 403
108108+109109+(** Check if this is a "not found" error (404). *)
110110+let is_not_found (e : t) : bool =
111111+ e.status_code = 404
112112+113113+(** Check if this is a validation error (400 with message array). *)
114114+let is_validation_error (e : t) : bool =
115115+ e.status_code = 400
116116+117117+(** Check if this is a server error (5xx). *)
118118+let is_server_error (e : t) : bool =
119119+ e.status_code >= 500 && e.status_code < 600
120120+121121+(** {1 Pretty Printing} *)
122122+123123+(** Pretty-print a NestJS error. *)
124124+let pp ppf (e : t) =
125125+ match e.correlation_id with
126126+ | Some cid ->
127127+ Format.fprintf ppf "%s [%d] (correlationId: %s)"
128128+ e.message e.status_code cid
129129+ | None ->
130130+ Format.fprintf ppf "%s [%d]" e.message e.status_code
131131+132132+(** Convert to a human-readable string. *)
133133+let to_string (e : t) : string =
134134+ Format.asprintf "%a" pp e
135135+136136+(** {1 Exception Handling} *)
137137+138138+(** Exception for NestJS-specific errors.
139139+ Use this when you want to distinguish NestJS errors from generic API errors. *)
140140+exception Error of t
141141+142142+(** Register a pretty printer for the exception. *)
143143+let () =
144144+ Printexc.register_printer (function
145145+ | Error e -> Some (Format.asprintf "Nestjs.Error: %a" pp e)
146146+ | _ -> None)
147147+148148+(** Handle an {!Openapi.Runtime.Api_error}, converting it to a NestJS error
149149+ if possible.
150150+151151+ @raise Error if the error body parses as a NestJS error
152152+ @raise Openapi.Runtime.Api_error if parsing fails (re-raises original) *)
153153+let raise_if_nestjs (e : Openapi_runtime.api_error) =
154154+ match of_api_error e with
155155+ | Some nestjs -> raise (Error nestjs)
156156+ | None -> raise (Openapi_runtime.Api_error e)
+104
lib/openapi_nestjs.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** NestJS-style API error handling.
77+88+ NestJS/Express applications return errors in a standard format:
99+ {[
1010+ {
1111+ "message": "Missing required permission: person.read",
1212+ "error": "Forbidden",
1313+ "statusCode": 403,
1414+ "correlationId": "koskgk9d"
1515+ }
1616+ ]}
1717+1818+ This module provides types and utilities for parsing and handling
1919+ these errors in a structured way.
2020+2121+ {2 Usage}
2222+2323+ {[
2424+ match Immich.People.get_all_people client () with
2525+ | people -> ...
2626+ | exception Openapi.Runtime.Api_error e ->
2727+ match Openapi.Nestjs.of_api_error e with
2828+ | Some nestjs_error ->
2929+ Fmt.epr "Error: %s (correlation: %s)@."
3030+ nestjs_error.message
3131+ (Option.value ~default:"none" nestjs_error.correlation_id)
3232+ | None ->
3333+ (* Not a NestJS error, use raw body *)
3434+ Fmt.epr "Error: %s@." e.body
3535+ ]}
3636+*)
3737+3838+(** {1 Error Types} *)
3939+4040+(** A structured NestJS HTTP exception. *)
4141+type t = {
4242+ status_code : int;
4343+ (** HTTP status code (e.g., 403, 404, 500). *)
4444+4545+ error : string option;
4646+ (** Error category (e.g., "Forbidden", "Not Found", "Internal Server Error"). *)
4747+4848+ message : string;
4949+ (** Human-readable error message. Can be a single string or concatenated
5050+ from an array of validation messages. *)
5151+5252+ correlation_id : string option;
5353+ (** Request correlation ID for debugging/support. *)
5454+}
5555+5656+(** {1 JSON Codec} *)
5757+5858+val jsont : t Jsont.t
5959+(** Jsont codec for NestJS errors. *)
6060+6161+(** {1 Parsing} *)
6262+6363+val of_string : string -> t option
6464+(** Parse a JSON string into a NestJS error.
6565+ Returns [None] if the string is not valid NestJS error JSON. *)
6666+6767+val of_api_error : Openapi_runtime.api_error -> t option
6868+(** Parse an {!Openapi_runtime.api_error} into a structured NestJS error.
6969+ Returns [None] if the error body is not valid NestJS error JSON. *)
7070+7171+(** {1 Convenience Functions} *)
7272+7373+val is_auth_error : t -> bool
7474+(** Check if this is a permission/authorization error (401 or 403). *)
7575+7676+val is_not_found : t -> bool
7777+(** Check if this is a "not found" error (404). *)
7878+7979+val is_validation_error : t -> bool
8080+(** Check if this is a validation error (400 with message array). *)
8181+8282+val is_server_error : t -> bool
8383+(** Check if this is a server error (5xx). *)
8484+8585+(** {1 Pretty Printing} *)
8686+8787+val pp : Format.formatter -> t -> unit
8888+(** Pretty-print a NestJS error. *)
8989+9090+val to_string : t -> string
9191+(** Convert to a human-readable string. *)
9292+9393+(** {1 Exception Handling} *)
9494+9595+exception Error of t
9696+(** Exception for NestJS-specific errors.
9797+ Use this when you want to distinguish NestJS errors from generic API errors. *)
9898+9999+val raise_if_nestjs : Openapi_runtime.api_error -> 'a
100100+(** Handle an {!Openapi_runtime.api_error}, converting it to a NestJS error
101101+ if possible.
102102+103103+ @raise Error if the error body parses as a NestJS error
104104+ @raise Openapi_runtime.Api_error if parsing fails (re-raises original) *)
+200
lib/openapi_runtime.ml
···11+(** Runtime utilities for generated OpenAPI clients.
22+33+ This module provides utilities used by generated client code:
44+ - Path template rendering
55+ - Query parameter building
66+ - JSON encoding/decoding helpers
77+*)
88+99+(** {1 Path Templates} *)
1010+1111+module Path = struct
1212+ (** Render a path template like "/users/{id}/posts/{postId}" with parameters *)
1313+ let render ~(params : (string * string) list) (template : string) : string =
1414+ List.fold_left
1515+ (fun path (name, value) ->
1616+ match String.split_on_char '{' path with
1717+ | [only] -> only
1818+ | parts ->
1919+ String.concat "" (List.mapi (fun i part ->
2020+ if i = 0 then part
2121+ else
2222+ match String.split_on_char '}' part with
2323+ | [var; rest] when var = name -> value ^ rest
2424+ | _ -> "{" ^ part
2525+ ) parts))
2626+ template params
2727+2828+ (** Extract parameter names from a path template *)
2929+ let parameters (template : string) : string list =
3030+ let rec extract acc s =
3131+ match String.index_opt s '{' with
3232+ | None -> List.rev acc
3333+ | Some i ->
3434+ let rest = String.sub s (i + 1) (String.length s - i - 1) in
3535+ match String.index_opt rest '}' with
3636+ | None -> List.rev acc
3737+ | Some j ->
3838+ let name = String.sub rest 0 j in
3939+ let remaining = String.sub rest (j + 1) (String.length rest - j - 1) in
4040+ extract (name :: acc) remaining
4141+ in
4242+ extract [] template
4343+end
4444+4545+(** {1 Query Parameters} *)
4646+4747+module Query = struct
4848+ type param = string * string
4949+5050+ (** Helper for optional parameters with custom stringifier *)
5151+ let optional_with ~key ~value ~to_string : param list =
5252+ Option.fold ~none:[] ~some:(fun v -> [(key, to_string v)]) value
5353+5454+ let singleton ~key ~value : param list = [(key, value)]
5555+5656+ let optional ~key ~value : param list =
5757+ optional_with ~key ~value ~to_string:Fun.id
5858+5959+ let list ~key ~values : param list =
6060+ List.map (fun v -> (key, v)) values
6161+6262+ let int ~key ~value : param list = [(key, string_of_int value)]
6363+6464+ let int_opt ~key ~value : param list =
6565+ optional_with ~key ~value ~to_string:string_of_int
6666+6767+ let bool ~key ~value : param list =
6868+ [(key, if value then "true" else "false")]
6969+7070+ let bool_opt ~key ~value : param list =
7171+ optional_with ~key ~value ~to_string:(fun b -> if b then "true" else "false")
7272+7373+ let float ~key ~value : param list = [(key, string_of_float value)]
7474+7575+ let float_opt ~key ~value : param list =
7676+ optional_with ~key ~value ~to_string:string_of_float
7777+7878+ let encode (params : param list) : string =
7979+ if params = [] then ""
8080+ else
8181+ "?" ^
8282+ String.concat "&" (List.map (fun (k, v) ->
8383+ (* URL encode the value *)
8484+ let encode_char c =
8585+ match c with
8686+ | 'A'..'Z' | 'a'..'z' | '0'..'9' | '-' | '_' | '.' | '~' ->
8787+ String.make 1 c
8888+ | c ->
8989+ Printf.sprintf "%%%02X" (Char.code c)
9090+ in
9191+ let encoded_v = String.to_seq v
9292+ |> Seq.map encode_char
9393+ |> List.of_seq
9494+ |> String.concat ""
9595+ in
9696+ k ^ "=" ^ encoded_v
9797+ ) params)
9898+end
9999+100100+(** {1 JSON Helpers} *)
101101+102102+module Json = struct
103103+ let decode codec s =
104104+ Jsont_bytesrw.decode_string codec s
105105+106106+ let decode' codec s =
107107+ Jsont_bytesrw.decode_string' codec s
108108+109109+ let encode codec v =
110110+ Jsont_bytesrw.encode_string codec v
111111+112112+ let encode' codec v =
113113+ Jsont_bytesrw.encode_string' codec v
114114+115115+ let encode_compact codec v =
116116+ Jsont_bytesrw.encode_string ~format:Jsont.Minify codec v
117117+118118+ (** Decode a Jsont.json value through a codec.
119119+ Encodes to string then decodes - not optimal but works. *)
120120+ let decode_json (codec : 'a Jsont.t) (json : Jsont.json) : ('a, string) result =
121121+ match Jsont_bytesrw.encode_string Jsont.json json with
122122+ | Ok s -> Jsont_bytesrw.decode_string codec s
123123+ | Error e -> Error e
124124+125125+ (** Decode a Jsont.json value, raising on error *)
126126+ let decode_json_exn (codec : 'a Jsont.t) (json : Jsont.json) : 'a =
127127+ match decode_json codec json with
128128+ | Ok v -> v
129129+ | Error e -> failwith e
130130+131131+ (** Encode a value to Jsont.json *)
132132+ let encode_json (codec : 'a Jsont.t) (v : 'a) : Jsont.json =
133133+ match Jsont_bytesrw.encode_string codec v with
134134+ | Ok s ->
135135+ (match Jsont_bytesrw.decode_string Jsont.json s with
136136+ | Ok json -> json
137137+ | Error _ -> Jsont.Null ((), Jsont.Meta.none))
138138+ | Error _ -> Jsont.Null ((), Jsont.Meta.none)
139139+end
140140+141141+(** {1 HTTP Method} *)
142142+143143+type http_method = Get | Post | Put | Patch | Delete | Head | Options
144144+145145+let string_of_method = function
146146+ | Get -> "GET"
147147+ | Post -> "POST"
148148+ | Put -> "PUT"
149149+ | Patch -> "PATCH"
150150+ | Delete -> "DELETE"
151151+ | Head -> "HEAD"
152152+ | Options -> "OPTIONS"
153153+154154+(** {1 Common Types} *)
155155+156156+(** ISO 8601 date-time codec *)
157157+let ptime_jsont : Ptime.t Jsont.t =
158158+ Jsont.map Jsont.string ~kind:"datetime"
159159+ ~dec:(fun s ->
160160+ match Ptime.of_rfc3339 s with
161161+ | Ok (t, _, _) -> t
162162+ | Error _ -> Jsont.Error.msgf Jsont.Meta.none "Invalid datetime: %s" s)
163163+ ~enc:(fun t -> Ptime.to_rfc3339 t)
164164+165165+(** UUID as string *)
166166+let uuid_jsont : string Jsont.t = Jsont.string
167167+168168+(** Base64 encoded bytes *)
169169+let base64_jsont : string Jsont.t = Jsont.string
170170+171171+(** {1 Nullable wrapper} *)
172172+173173+let nullable (codec : 'a Jsont.t) : 'a option Jsont.t =
174174+ Jsont.option codec
175175+176176+(** {1 Any JSON value wrapper} *)
177177+178178+type json = Jsont.json
179179+180180+let json_jsont : json Jsont.t = Jsont.json
181181+182182+(** {1 API Errors} *)
183183+184184+(** Error raised when an API call fails with a non-2xx status code *)
185185+type api_error = {
186186+ operation : string;
187187+ method_ : string;
188188+ url : string;
189189+ status : int;
190190+ body : string;
191191+}
192192+193193+exception Api_error of api_error
194194+195195+let () =
196196+ Printexc.register_printer (function
197197+ | Api_error e ->
198198+ Some (Printf.sprintf "Api_error: %s %s returned %d: %s"
199199+ e.method_ e.url e.status e.body)
200200+ | _ -> None)