OpenAPI generator for OCaml with Requests/Eio/Jsont

Complete OpenAPI generator with typed responses and nullable fields

Major enhancements to the OpenAPI code generator:

1. allOf composition support:
- Added resolve_schema_ref to look up referenced schemas
- Added flatten_all_of to recursively merge properties from composed schemas
- Added expand_schema to resolve allOf before generating types
- VideoDetails now contains all 50+ fields from Video merged in

2. Property type reference resolution:
- Updated type_of_json_schema to handle allOf with $ref in properties
- Properties like "id: allOf: [$ref: '#/components/schemas/id']" now
resolve to the correct type

3. Nullable field handling:
- Added is_nullable tracking to field_info
- Added nullable combinators to runtime (nullable_any, nullable_string,
nullable_ptime, nullable_int, nullable_float, nullable_bool)
- Fields marked "nullable: true" use these combinators to handle both
absent and explicit JSON null values

4. public_name in generated dune files:
- Libraries are now public by default for proper dependency management

The peertube library now generates fully typed records with proper
accessors for all schemas, including composed types like VideoDetails.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+189 -39
+146 -39
lib/openapi_codegen.ml
··· 149 149 | ["#"; "components"; "schemas"; name] -> Some name 150 150 | _ -> None 151 151 152 + (** Resolve a schema reference to its definition *) 153 + let resolve_schema_ref ~(components : Spec.components option) (ref_str : string) : Spec.schema option = 154 + match schema_name_from_ref ref_str with 155 + | None -> None 156 + | Some name -> 157 + match components with 158 + | None -> None 159 + | Some comps -> 160 + List.find_map (fun (n, s_or_ref) -> 161 + if n = name then 162 + match s_or_ref with 163 + | Spec.Value s -> Some s 164 + | Spec.Ref _ -> None (* Nested refs not supported *) 165 + else None 166 + ) comps.schemas 167 + 168 + (** Flatten allOf composition by merging properties from all schemas *) 169 + let rec flatten_all_of ~(components : Spec.components option) (schemas : Jsont.json list) : (string * Jsont.json) list * string list = 170 + List.fold_left (fun (props, reqs) json -> 171 + match get_ref json with 172 + | Some ref_str -> 173 + (* Resolve the reference and get its properties *) 174 + (match resolve_schema_ref ~components ref_str with 175 + | Some schema -> 176 + let (nested_props, nested_reqs) = 177 + match schema.all_of with 178 + | Some all_of -> flatten_all_of ~components all_of 179 + | None -> (schema.properties, schema.required) 180 + in 181 + (props @ nested_props, reqs @ nested_reqs) 182 + | None -> (props, reqs)) 183 + | None -> 184 + (* Inline schema - get properties directly *) 185 + let inline_props = match get_member "properties" json with 186 + | Some (Jsont.Object (mems, _)) -> 187 + List.map (fun ((n, _), v) -> (n, v)) mems 188 + | _ -> [] 189 + in 190 + let inline_reqs = match get_member "required" json with 191 + | Some (Jsont.Array (items, _)) -> 192 + List.filter_map (function Jsont.String (s, _) -> Some s | _ -> None) items 193 + | _ -> [] 194 + in 195 + (props @ inline_props, reqs @ inline_reqs) 196 + ) ([], []) schemas 197 + 198 + (** Expand a schema by resolving allOf composition *) 199 + let expand_schema ~(components : Spec.components option) (schema : Spec.schema) : Spec.schema = 200 + match schema.all_of with 201 + | None -> schema 202 + | Some all_of_jsons -> 203 + let (all_props, all_reqs) = flatten_all_of ~components all_of_jsons in 204 + (* Merge with any direct properties on the schema *) 205 + let merged_props = schema.properties @ all_props in 206 + let merged_reqs = schema.required @ all_reqs in 207 + (* Deduplicate by property name, keeping later definitions *) 208 + let seen = Hashtbl.create 32 in 209 + let deduped_props = List.filter (fun (name, _) -> 210 + if Hashtbl.mem seen name then false 211 + else (Hashtbl.add seen name (); true) 212 + ) (List.rev merged_props) |> List.rev in 213 + let deduped_reqs = List.sort_uniq String.compare merged_reqs in 214 + { schema with properties = deduped_props; required = deduped_reqs; all_of = None } 215 + 152 216 let rec find_refs_in_json (json : Jsont.json) : string list = 153 217 match json with 154 218 | Jsont.Object (mems, _) -> ··· 224 288 base_type : string; 225 289 is_optional : bool; 226 290 is_required : bool; 291 + is_nullable : bool; (** JSON schema nullable: true *) 227 292 description : string option; 228 293 } 229 294 ··· 279 344 (Printf.sprintf "%s.%s.t" (Name.to_module_name prefix) (Name.to_module_name suffix), is_nullable) 280 345 | None -> ("Jsont.json", is_nullable)) 281 346 | None -> 282 - match get_string_member "type" json with 283 - | Some "string" -> 284 - (match get_string_member "format" json with 285 - | Some "date-time" -> ("Ptime.t", is_nullable) 286 - | _ -> ("string", is_nullable)) 287 - | Some "integer" -> 288 - (match get_string_member "format" json with 289 - | Some "int64" -> ("int64", is_nullable) 290 - | Some "int32" -> ("int32", is_nullable) 291 - | _ -> ("int", is_nullable)) 292 - | Some "number" -> ("float", is_nullable) 293 - | Some "boolean" -> ("bool", is_nullable) 294 - | Some "array" -> 295 - (match get_member "items" json with 296 - | Some items -> 297 - let (elem_type, _) = type_of_json_schema items in 298 - (elem_type ^ " list", is_nullable) 299 - | None -> ("Jsont.json list", is_nullable)) 300 - | Some "object" -> ("Jsont.json", is_nullable) 301 - | _ -> ("Jsont.json", is_nullable) 347 + (* Check for allOf with a single $ref - common pattern for type aliasing *) 348 + (match get_member "allOf" json with 349 + | Some (Jsont.Array ([item], _)) -> 350 + (* Single item allOf - try to resolve it *) 351 + type_of_json_schema item 352 + | Some (Jsont.Array (items, _)) when List.length items > 0 -> 353 + (* Multiple allOf items - try to find a $ref among them *) 354 + (match List.find_map (fun item -> 355 + match get_ref item with 356 + | Some ref_ -> schema_name_from_ref ref_ 357 + | None -> None 358 + ) items with 359 + | Some name -> 360 + let prefix, suffix = Name.split_schema_name name in 361 + (Printf.sprintf "%s.%s.t" (Name.to_module_name prefix) (Name.to_module_name suffix), is_nullable) 362 + | None -> ("Jsont.json", is_nullable)) 363 + | _ -> 364 + match get_string_member "type" json with 365 + | Some "string" -> 366 + (match get_string_member "format" json with 367 + | Some "date-time" -> ("Ptime.t", is_nullable) 368 + | _ -> ("string", is_nullable)) 369 + | Some "integer" -> 370 + (match get_string_member "format" json with 371 + | Some "int64" -> ("int64", is_nullable) 372 + | Some "int32" -> ("int32", is_nullable) 373 + | _ -> ("int", is_nullable)) 374 + | Some "number" -> ("float", is_nullable) 375 + | Some "boolean" -> ("bool", is_nullable) 376 + | Some "array" -> 377 + (match get_member "items" json with 378 + | Some items -> 379 + let (elem_type, _) = type_of_json_schema items in 380 + (elem_type ^ " list", is_nullable) 381 + | None -> ("Jsont.json list", is_nullable)) 382 + | Some "object" -> ("Jsont.json", is_nullable) 383 + | _ -> ("Jsont.json", is_nullable)) 302 384 303 385 let rec jsont_of_base_type = function 304 386 | "string" -> "Jsont.string" ··· 317 399 module_path ^ ".jsont" 318 400 | _ -> "Jsont.json" 319 401 402 + (** Generate a nullable codec wrapper for types that need to handle explicit JSON nulls *) 403 + let nullable_jsont_of_base_type = function 404 + | "string" -> "Openapi.Runtime.nullable_string" 405 + | "int" -> "Openapi.Runtime.nullable_int" 406 + | "float" -> "Openapi.Runtime.nullable_float" 407 + | "bool" -> "Openapi.Runtime.nullable_bool" 408 + | "Ptime.t" -> "Openapi.Runtime.nullable_ptime" 409 + | base_type -> 410 + (* For other types, wrap with nullable_any *) 411 + Printf.sprintf "(Openapi.Runtime.nullable_any %s)" (jsont_of_base_type base_type) 412 + 320 413 (** {1 Schema Processing} *) 321 414 322 - let analyze_schema (name : string) (schema : Spec.schema) : schema_info = 415 + let analyze_schema ~(components : Spec.components option) (name : string) (schema : Spec.schema) : schema_info = 416 + (* First expand allOf composition *) 417 + let expanded = expand_schema ~components schema in 323 418 let prefix, suffix = Name.split_schema_name name in 324 - let is_enum = Option.is_some schema.enum in 325 - let enum_variants = match schema.enum with 419 + let is_enum = Option.is_some expanded.enum in 420 + let enum_variants = match expanded.enum with 326 421 | Some values -> 327 422 List.filter_map (fun json -> 328 423 match json with ··· 333 428 in 334 429 let fields = List.map (fun (field_name, field_json) -> 335 430 let ocaml_name = Name.to_snake_case field_name in 336 - let is_required = List.mem field_name schema.required in 431 + let is_required = List.mem field_name expanded.required in 337 432 let (base_type, json_nullable) = type_of_json_schema field_json in 338 - let is_optional = json_nullable || not is_required in 433 + let is_nullable = json_nullable in 434 + let is_optional = is_nullable || not is_required in 339 435 let ocaml_type = if is_optional then base_type ^ " option" else base_type in 340 436 let description = get_string_member "description" field_json in 341 - { ocaml_name; json_name = field_name; ocaml_type; base_type; is_optional; is_required; description } 342 - ) schema.properties in 437 + { ocaml_name; json_name = field_name; ocaml_type; base_type; is_optional; is_required; is_nullable; description } 438 + ) expanded.properties in 343 439 (* Check if schema references itself *) 344 - let deps = find_schema_dependencies schema in 440 + let deps = find_schema_dependencies expanded in 345 441 let is_recursive = List.mem name deps in 346 - { original_name = name; prefix; suffix; schema; fields; is_enum; enum_variants; 347 - description = schema.description; is_recursive } 442 + { original_name = name; prefix; suffix; schema = expanded; fields; is_enum; enum_variants; 443 + description = expanded.description; is_recursive } 348 444 349 445 (** {1 Operation Processing} *) 350 446 ··· 700 796 (* Jsont codec *) 701 797 let make_params = String.concat " " (List.map (fun (f : field_info) -> f.ocaml_name) schema.fields) in 702 798 let jsont_members = String.concat "\n" (List.map (fun (f : field_info) -> 703 - let codec = loc_jsont (jsont_of_base_type f.base_type) in 704 - if f.is_optional then 799 + (* Determine the right codec based on nullable/required status: 800 + - nullable + required: use nullable codec with mem (field must be present, can be null) 801 + - nullable + not required: use nullable codec with opt_mem (field may be absent or null) 802 + - not nullable + required: use base codec with mem 803 + - not nullable + not required: use base codec with opt_mem *) 804 + if f.is_nullable then 805 + let nullable_codec = loc_jsont (nullable_jsont_of_base_type f.base_type) in 705 806 if f.is_required then 706 - Printf.sprintf " |> Jsont.Object.mem %S (Jsont.option %s)\n ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun r -> r.%s)" 707 - f.json_name codec f.ocaml_name 807 + Printf.sprintf " |> Jsont.Object.mem %S %s\n ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun r -> r.%s)" 808 + f.json_name nullable_codec f.ocaml_name 708 809 else 709 - Printf.sprintf " |> Jsont.Object.opt_mem %S %s ~enc:(fun r -> r.%s)" 710 - f.json_name codec f.ocaml_name 810 + Printf.sprintf " |> Jsont.Object.mem %S %s\n ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun r -> r.%s)" 811 + f.json_name nullable_codec f.ocaml_name 812 + else if f.is_optional then 813 + let codec = loc_jsont (jsont_of_base_type f.base_type) in 814 + Printf.sprintf " |> Jsont.Object.opt_mem %S %s ~enc:(fun r -> r.%s)" 815 + f.json_name codec f.ocaml_name 711 816 else 817 + let codec = loc_jsont (jsont_of_base_type f.base_type) in 712 818 Printf.sprintf " |> Jsont.Object.mem %S %s ~enc:(fun r -> r.%s)" 713 819 f.json_name codec f.ocaml_name 714 820 ) schema.fields) in ··· 1015 1121 | Some c -> List.filter_map (fun (name, sor) -> 1016 1122 match sor with 1017 1123 | Spec.Ref _ -> None 1018 - | Spec.Value s -> Some (analyze_schema name s) 1124 + | Spec.Value s -> Some (analyze_schema ~components:spec.components name s) 1019 1125 ) c.schemas 1020 1126 in 1021 1127 ··· 1093 1199 | Some c -> List.filter_map (fun (name, sor) -> 1094 1200 match sor with 1095 1201 | Spec.Ref _ -> None 1096 - | Spec.Value s -> Some (analyze_schema name s) 1202 + | Spec.Value s -> Some (analyze_schema ~components:spec.components name s) 1097 1203 ) c.schemas 1098 1204 in 1099 1205 ··· 1161 1267 let generate_dune (package_name : string) : string = 1162 1268 Printf.sprintf {|(library 1163 1269 (name %s) 1270 + (public_name %s) 1164 1271 (libraries openapi jsont jsont.bytesrw requests ptime eio) 1165 1272 (wrapped true)) 1166 1273 1167 1274 (include dune.inc) 1168 - |} package_name 1275 + |} package_name package_name 1169 1276 1170 1277 let generate_dune_inc ~(spec_path : string option) (package_name : string) : string = 1171 1278 match spec_path with
+43
lib/openapi_runtime.ml
··· 173 173 let nullable (codec : 'a Jsont.t) : 'a option Jsont.t = 174 174 Jsont.option codec 175 175 176 + (** Nullable combinator that handles explicit JSON null values. 177 + Use this for fields marked as "nullable: true" in OpenAPI specs. 178 + Unlike Jsont.option, this properly decodes explicit null as None. *) 179 + let nullable_any (base_codec : 'a Jsont.t) : 'a option Jsont.t = 180 + let null_codec = Jsont.null None in 181 + let some_codec = Jsont.map base_codec 182 + ~kind:"nullable_some" 183 + ~dec:(fun v -> Some v) 184 + ~enc:(function Some v -> v | None -> failwith "unreachable") 185 + in 186 + (* Use Jsont.any to dispatch based on the JSON value type *) 187 + Jsont.any 188 + ~dec_null:null_codec 189 + ~dec_string:some_codec 190 + ~dec_number:some_codec 191 + ~dec_bool:some_codec 192 + ~dec_array:some_codec 193 + ~dec_object:some_codec 194 + ~enc:(function 195 + | None -> null_codec 196 + | Some _ -> some_codec) 197 + () 198 + 199 + (** Nullable string that handles both absent and explicit null *) 200 + let nullable_string : string option Jsont.t = 201 + nullable_any Jsont.string 202 + 203 + (** Nullable ptime that handles both absent and explicit null *) 204 + let nullable_ptime : Ptime.t option Jsont.t = 205 + nullable_any ptime_jsont 206 + 207 + (** Nullable int that handles both absent and explicit null *) 208 + let nullable_int : int option Jsont.t = 209 + nullable_any Jsont.int 210 + 211 + (** Nullable float that handles both absent and explicit null *) 212 + let nullable_float : float option Jsont.t = 213 + nullable_any Jsont.number 214 + 215 + (** Nullable bool that handles both absent and explicit null *) 216 + let nullable_bool : bool option Jsont.t = 217 + nullable_any Jsont.bool 218 + 176 219 (** {1 Any JSON value wrapper} *) 177 220 178 221 type json = Jsont.json