OpenAPI generator for OCaml with Requests/Eio/Jsont
at main 2404 lines 102 kB view raw
1(** Code generation from OpenAPI specifications. 2 3 This module generates OCaml code from parsed OpenAPI specs: 4 - Nested module structure grouped by common schema prefixes 5 - Abstract types with accessor and constructor functions 6 - Client functions placed in relevant type modules 7 - Proper Eio error handling with context 8*) 9 10module Spec = Openapi_spec 11 12(** {1 Name Conversion} *) 13 14module Name = struct 15 module StringSet = Set.Make(String) 16 17 let ocaml_keywords = StringSet.of_list [ 18 "and"; "as"; "assert"; "asr"; "begin"; "class"; "constraint"; "do"; "done"; 19 "downto"; "else"; "end"; "exception"; "external"; "false"; "for"; "fun"; 20 "function"; "functor"; "if"; "in"; "include"; "inherit"; "initializer"; 21 "land"; "lazy"; "let"; "lor"; "lsl"; "lsr"; "lxor"; "match"; "method"; 22 "mod"; "module"; "mutable"; "new"; "nonrec"; "object"; "of"; "open"; "or"; 23 "private"; "rec"; "sig"; "struct"; "then"; "to"; "true"; "try"; "type"; 24 "val"; "virtual"; "when"; "while"; "with" 25 ] 26 27 let escape_keyword s = 28 if StringSet.mem s ocaml_keywords then s ^ "_" else s 29 30 let to_snake_case s = 31 let buf = Buffer.create (String.length s) in 32 let prev_upper = ref false in 33 String.iteri (fun i c -> 34 match c with 35 | 'A'..'Z' -> 36 if i > 0 && not !prev_upper then Buffer.add_char buf '_'; 37 Buffer.add_char buf (Char.lowercase_ascii c); 38 prev_upper := true 39 | 'a'..'z' | '0'..'9' | '_' -> 40 Buffer.add_char buf c; 41 prev_upper := false 42 | '-' | ' ' | '.' | '/' -> 43 Buffer.add_char buf '_'; 44 prev_upper := false 45 | _ -> 46 prev_upper := false 47 ) s; 48 escape_keyword (Buffer.contents buf) 49 50 let to_module_name s = 51 let snake = to_snake_case s in 52 let parts = String.split_on_char '_' snake in 53 String.concat "" (List.map String.capitalize_ascii parts) 54 55 let to_type_name s = String.lowercase_ascii (to_snake_case s) 56 57 let to_variant_name s = String.capitalize_ascii (to_snake_case s) 58 59 (** Split a schema name into prefix and suffix for nested modules. 60 E.g., "AlbumResponseDto" -> ("Album", "ResponseDto") *) 61 let split_schema_name (name : string) : string * string = 62 (* Common suffixes to look for *) 63 let suffixes = [ 64 "ResponseDto"; "RequestDto"; "CreateDto"; "UpdateDto"; "Dto"; 65 "Response"; "Request"; "Create"; "Update"; "Config"; "Info"; 66 "Status"; "Type"; "Entity"; "Item"; "Entry"; "Data"; "Result" 67 ] in 68 let found = List.find_opt (fun suffix -> 69 String.length name > String.length suffix && 70 String.ends_with ~suffix name 71 ) suffixes in 72 match found with 73 | Some suffix -> 74 let prefix_len = String.length name - String.length suffix in 75 let prefix = String.sub name 0 prefix_len in 76 if prefix = "" then (name, "T") 77 else (prefix, suffix) 78 | None -> 79 (* No known suffix, use as-is with submodule T *) 80 (name, "T") 81 82 let operation_name ~(method_ : string) ~(path : string) ~(operation_id : string option) = 83 match operation_id with 84 | Some id -> to_snake_case id 85 | None -> 86 let method_name = String.lowercase_ascii method_ in 87 let path_parts = String.split_on_char '/' path 88 |> List.filter (fun s -> s <> "" && not (String.length s > 0 && s.[0] = '{')) 89 in 90 let path_name = String.concat "_" (List.map to_snake_case path_parts) in 91 method_name ^ "_" ^ path_name 92end 93 94(** {1 OCamldoc Helpers} *) 95 96let escape_doc s = 97 let s = String.concat "\\}" (String.split_on_char '}' s) in 98 String.concat "\\{" (String.split_on_char '{' s) 99 100let format_doc ?(indent=0) description = 101 let prefix = String.make indent ' ' in 102 match description with 103 | None | Some "" -> "" 104 | Some desc -> Printf.sprintf "%s(** %s *)\n" prefix (escape_doc desc) 105 106let format_doc_block ?(indent=0) ~summary ?description () = 107 let prefix = String.make indent ' ' in 108 match summary, description with 109 | None, None -> "" 110 | Some s, None -> Printf.sprintf "%s(** %s *)\n" prefix (escape_doc s) 111 | None, Some d -> Printf.sprintf "%s(** %s *)\n" prefix (escape_doc d) 112 | Some s, Some d -> 113 Printf.sprintf "%s(** %s\n\n%s %s *)\n" prefix (escape_doc s) prefix (escape_doc d) 114 115let format_param_doc name description = 116 match description with 117 | None | Some "" -> "" 118 | Some d -> Printf.sprintf " @param %s %s\n" name (escape_doc d) 119 120(** {1 JSON Helpers} *) 121 122let json_string = function 123 | Jsont.String (s, _) -> Some s 124 | _ -> None 125 126let json_object = function 127 | Jsont.Object (mems, _) -> Some mems 128 | _ -> None 129 130let get_ref json = 131 Option.bind (json_object json) (fun mems -> 132 List.find_map (fun ((n, _), v) -> 133 if n = "$ref" then json_string v else None 134 ) mems) 135 136let get_member name json = 137 Option.bind (json_object json) (fun mems -> 138 List.find_map (fun ((n, _), v) -> 139 if n = name then Some v else None 140 ) mems) 141 142let get_string_member name json = 143 Option.bind (get_member name json) json_string 144 145(** {1 Schema Analysis} *) 146 147let schema_name_from_ref (ref_ : string) : string option = 148 match String.split_on_char '/' ref_ with 149 | ["#"; "components"; "schemas"; name] -> Some name 150 | _ -> None 151 152(** Resolve a schema reference to its definition *) 153let 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 *) 169let 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 *) 199let 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 216let rec find_refs_in_json (json : Jsont.json) : string list = 217 match json with 218 | Jsont.Object (mems, _) -> 219 (match List.find_map (fun ((n, _), v) -> 220 if n = "$ref" then json_string v else None) mems with 221 | Some ref_ -> Option.to_list (schema_name_from_ref ref_) 222 | None -> List.concat_map (fun (_, v) -> find_refs_in_json v) mems) 223 | Jsont.Array (items, _) -> List.concat_map find_refs_in_json items 224 | _ -> [] 225 226let find_schema_dependencies (schema : Spec.schema) : string list = 227 let from_properties = List.concat_map (fun (_, json) -> find_refs_in_json json) schema.properties in 228 let refs_from_list = Option.fold ~none:[] ~some:(List.concat_map find_refs_in_json) in 229 let from_items = Option.fold ~none:[] ~some:find_refs_in_json schema.items in 230 List.sort_uniq String.compare 231 (from_properties @ from_items @ refs_from_list schema.all_of 232 @ refs_from_list schema.one_of @ refs_from_list schema.any_of) 233 234(** {1 Module Tree Structure} *) 235 236module StringMap = Map.Make(String) 237module StringSet = Set.Make(String) 238 239(** {1 Forward Reference Tracking} 240 241 Track which modules come after the current module in the sorted order. 242 This is used to detect forward references and replace them with Jsont.json. *) 243 244let forward_refs : StringSet.t ref = ref StringSet.empty 245 246let set_forward_refs mods = forward_refs := StringSet.of_list mods 247 248let is_forward_ref module_name = 249 StringSet.mem module_name !forward_refs 250 251(** {1 Topological Sort} *) 252 253(** Kahn's algorithm for topological sorting. 254 Returns nodes in dependency order (dependencies first). 255 Self-dependencies are ignored (they don't affect ordering). *) 256let topological_sort (nodes : string list) (deps : string -> string list) : string list = 257 (* Build adjacency list and in-degree map *) 258 let nodes_set = StringSet.of_list nodes in 259 let in_degree = List.fold_left (fun m node -> 260 StringMap.add node 0 m 261 ) StringMap.empty nodes in 262 let adj = List.fold_left (fun m node -> 263 StringMap.add node [] m 264 ) StringMap.empty nodes in 265 (* Add edges: if A depends on B, add edge B -> A 266 Ignore self-dependencies (node depending on itself) *) 267 let (in_degree, adj) = List.fold_left (fun (in_degree, adj) node -> 268 let node_deps = deps node 269 |> List.filter (fun d -> StringSet.mem d nodes_set && d <> node) in 270 let in_degree = StringMap.add node (List.length node_deps) in_degree in 271 let adj = List.fold_left (fun adj dep -> 272 let existing = Option.value ~default:[] (StringMap.find_opt dep adj) in 273 StringMap.add dep (node :: existing) adj 274 ) adj node_deps in 275 (in_degree, adj) 276 ) (in_degree, adj) nodes in 277 (* Start with nodes that have no dependencies *) 278 let queue = List.filter (fun n -> 279 StringMap.find n in_degree = 0 280 ) nodes in 281 let rec process queue in_degree result processed = 282 match queue with 283 | [] -> 284 (* Check for remaining nodes (cycles) - break cycles by picking one node *) 285 let remaining = List.filter (fun n -> 286 not (StringSet.mem n processed) && StringMap.find n in_degree > 0 287 ) nodes in 288 (match remaining with 289 | [] -> List.rev result 290 | node :: _ -> 291 (* Pick a node from the cycle and add it, then continue *) 292 let result = node :: result in 293 let processed = StringSet.add node processed in 294 let dependents = Option.value ~default:[] (StringMap.find_opt node adj) in 295 let (queue', in_degree) = List.fold_left (fun (q, deg) dep -> 296 if StringSet.mem dep processed then (q, deg) 297 else 298 let new_deg = StringMap.find dep deg - 1 in 299 let deg = StringMap.add dep new_deg deg in 300 if new_deg = 0 then (dep :: q, deg) else (q, deg) 301 ) ([], in_degree) dependents in 302 process queue' in_degree result processed) 303 | node :: rest -> 304 let result = node :: result in 305 let processed = StringSet.add node processed in 306 let dependents = Option.value ~default:[] (StringMap.find_opt node adj) in 307 let (queue', in_degree) = List.fold_left (fun (q, deg) dep -> 308 if StringSet.mem dep processed then (q, deg) 309 else 310 let new_deg = StringMap.find dep deg - 1 in 311 let deg = StringMap.add dep new_deg deg in 312 if new_deg = 0 then (dep :: q, deg) else (q, deg) 313 ) (rest, in_degree) dependents in 314 process queue' in_degree result processed 315 in 316 process queue in_degree [] StringSet.empty 317 318(** Validation constraints extracted from JSON Schema *) 319type validation_constraints = { 320 minimum : float option; 321 maximum : float option; 322 exclusive_minimum : float option; 323 exclusive_maximum : float option; 324 min_length : int option; 325 max_length : int option; 326 pattern : string option; 327 min_items : int option; 328 max_items : int option; 329 unique_items : bool; 330} 331 332let empty_constraints = { 333 minimum = None; maximum = None; 334 exclusive_minimum = None; exclusive_maximum = None; 335 min_length = None; max_length = None; pattern = None; 336 min_items = None; max_items = None; unique_items = false; 337} 338 339let has_constraints c = 340 c.minimum <> None || c.maximum <> None || 341 c.exclusive_minimum <> None || c.exclusive_maximum <> None || 342 c.min_length <> None || c.max_length <> None || c.pattern <> None || 343 c.min_items <> None || c.max_items <> None || c.unique_items 344 345(** Inline union variant for field-level oneOf/anyOf *) 346type inline_union_variant = 347 | Ref_variant of string * string (** variant_name, schema_ref *) 348 | Prim_variant of string * string (** variant_name, primitive_type (string, int, etc.) *) 349 350(** Field-level union info *) 351type field_union_info = { 352 field_variants : inline_union_variant list; 353 field_union_style : [ `OneOf | `AnyOf ]; 354} 355 356type field_info = { 357 ocaml_name : string; 358 json_name : string; 359 ocaml_type : string; 360 base_type : string; 361 is_optional : bool; 362 is_required : bool; 363 is_nullable : bool; (** JSON schema nullable: true *) 364 description : string option; 365 constraints : validation_constraints; (** Validation constraints *) 366 field_union : field_union_info option; (** Inline union type info *) 367 default_value : string option; (** OCaml literal for default value *) 368} 369 370(** Union variant info for oneOf/anyOf schemas *) 371type union_variant = { 372 variant_name : string; (** OCaml constructor name: "Crop" *) 373 schema_ref : string; (** Schema name: "AssetEditActionCrop" *) 374} 375 376(** Union type info for oneOf/anyOf schemas *) 377type union_info = { 378 discriminator_field : string option; (** e.g., "type" or "action" *) 379 discriminator_mapping : (string * string) list; (** tag -> schema_ref *) 380 variants : union_variant list; 381 style : [ `OneOf | `AnyOf ]; 382} 383 384type schema_info = { 385 original_name : string; 386 prefix : string; 387 suffix : string; 388 schema : Spec.schema; 389 fields : field_info list; 390 is_enum : bool; 391 enum_variants : (string * string) list; (* ocaml_name, json_value *) 392 description : string option; 393 is_recursive : bool; (* true if schema references itself *) 394 is_union : bool; (** true if this is a oneOf/anyOf schema *) 395 union_info : union_info option; 396} 397 398(** Error response info for typed error handling *) 399type error_response = { 400 status_code : string; (** "400", "404", "5XX", "default" *) 401 schema_ref : string option; (** Reference to error schema if present *) 402 error_description : string; 403} 404 405type operation_info = { 406 func_name : string; 407 operation_id : string option; 408 summary : string option; 409 description : string option; 410 tags : string list; 411 path : string; 412 method_ : string; 413 path_params : (string * string * string option * bool) list; (* ocaml, json, desc, required *) 414 query_params : (string * string * string option * bool) list; 415 body_schema_ref : string option; 416 response_schema_ref : string option; 417 error_responses : error_response list; (** Typed error responses *) 418} 419 420type module_node = { 421 name : string; 422 schemas : schema_info list; 423 operations : operation_info list; 424 dependencies : StringSet.t; (* Other prefix modules this depends on *) 425 children : module_node StringMap.t; 426} 427 428let empty_node name = { name; schemas = []; operations = []; dependencies = StringSet.empty; children = StringMap.empty } 429 430(** {1 Type Resolution} *) 431 432(** Extract validation constraints from a JSON schema *) 433let extract_constraints (json : Jsont.json) : validation_constraints = 434 let get_float name = 435 match get_member name json with 436 | Some (Jsont.Number (f, _)) -> Some f 437 | _ -> None 438 in 439 let get_int name = 440 match get_member name json with 441 | Some (Jsont.Number (f, _)) -> Some (int_of_float f) 442 | _ -> None 443 in 444 let get_bool name = 445 match get_member name json with 446 | Some (Jsont.Bool (b, _)) -> b 447 | _ -> false 448 in 449 { 450 minimum = get_float "minimum"; 451 maximum = get_float "maximum"; 452 exclusive_minimum = get_float "exclusiveMinimum"; 453 exclusive_maximum = get_float "exclusiveMaximum"; 454 min_length = get_int "minLength"; 455 max_length = get_int "maxLength"; 456 pattern = get_string_member "pattern" json; 457 min_items = get_int "minItems"; 458 max_items = get_int "maxItems"; 459 unique_items = get_bool "uniqueItems"; 460 } 461 462(** Extract and convert a default value to an OCaml literal. 463 Returns None if no default or if the default can't be represented. *) 464let extract_default_value (json : Jsont.json) (base_type : string) : string option = 465 match get_member "default" json with 466 | None -> None 467 | Some default_json -> 468 match default_json, base_type with 469 | Jsont.Bool (b, _), "bool" -> 470 Some (if b then "true" else "false") 471 | Jsont.Number (f, _), "int" -> 472 Some (Printf.sprintf "%d" (int_of_float f)) 473 | Jsont.Number (f, _), "int32" -> 474 Some (Printf.sprintf "%ldl" (Int32.of_float f)) 475 | Jsont.Number (f, _), "int64" -> 476 Some (Printf.sprintf "%LdL" (Int64.of_float f)) 477 | Jsont.Number (f, _), "float" -> 478 let s = Printf.sprintf "%g" f in 479 (* Ensure it's a valid float literal *) 480 if String.contains s '.' || String.contains s 'e' then Some s 481 else Some (s ^ ".") 482 | Jsont.String (s, _), "string" -> 483 Some (Printf.sprintf "%S" s) 484 | Jsont.String (s, _), t when String.contains t '.' -> 485 (* Enum type reference like "AlbumUserRole.T.t" - use backtick variant *) 486 Some (Printf.sprintf "`%s" (Name.to_variant_name s)) 487 | Jsont.Null _, _ -> 488 Some "None" (* For nullable fields *) 489 | Jsont.Array ([], _), t when String.ends_with ~suffix:" list" t -> 490 Some "[]" 491 | _ -> None (* Complex defaults not yet supported *) 492 493(** Analyze inline oneOf/anyOf for field-level unions *) 494let analyze_field_union (json : Jsont.json) : field_union_info option = 495 let extract_variants style items = 496 let variants = List.filter_map (fun item -> 497 match get_ref item with 498 | Some ref_ -> 499 schema_name_from_ref ref_ |> Option.map (fun schema_ref -> 500 let variant_name = Name.to_module_name schema_ref in 501 Ref_variant (variant_name, schema_ref)) 502 | None -> 503 (* Check for primitive type *) 504 match get_string_member "type" item with 505 | Some "string" -> Some (Prim_variant ("String", "string")) 506 | Some "integer" -> Some (Prim_variant ("Int", "int")) 507 | Some "number" -> Some (Prim_variant ("Float", "float")) 508 | Some "boolean" -> Some (Prim_variant ("Bool", "bool")) 509 | Some "null" -> Some (Prim_variant ("Null", "unit")) 510 | _ -> None 511 ) items in 512 if List.length variants >= 2 then 513 Some { field_variants = variants; field_union_style = style } 514 else 515 None 516 in 517 match get_member "oneOf" json with 518 | Some (Jsont.Array (items, _)) -> extract_variants `OneOf items 519 | _ -> 520 match get_member "anyOf" json with 521 | Some (Jsont.Array (items, _)) -> extract_variants `AnyOf items 522 | _ -> None 523 524(** Check if a field union has any schema references (which may have ordering issues) *) 525let field_union_has_refs (union : field_union_info) : bool = 526 List.exists (fun v -> 527 match v with 528 | Ref_variant _ -> true 529 | Prim_variant _ -> false 530 ) union.field_variants 531 532(** Generate polymorphic variant type string for inline union. 533 For unions with only primitive types, generate proper polymorphic variants. 534 For unions with schema references, fall back to Jsont.json to avoid module ordering issues. *) 535let poly_variant_type_of_union (union : field_union_info) : string = 536 (* If any variant references a schema, we can't reliably generate types 537 at analysis time due to module ordering. Use Jsont.json instead. *) 538 if field_union_has_refs union then 539 "Jsont.json" 540 else 541 let variants = List.map (fun v -> 542 match v with 543 | Ref_variant (name, schema_ref) -> 544 let prefix, suffix = Name.split_schema_name schema_ref in 545 Printf.sprintf "`%s of %s.%s.t" name (Name.to_module_name prefix) (Name.to_module_name suffix) 546 | Prim_variant (name, prim_type) -> 547 Printf.sprintf "`%s of %s" name prim_type 548 ) union.field_variants in 549 Printf.sprintf "[ %s ]" (String.concat " | " variants) 550 551(** Type resolution result with full info *) 552type type_resolution = { 553 resolved_type : string; 554 resolved_nullable : bool; 555 resolved_constraints : validation_constraints; 556 resolved_union : field_union_info option; 557} 558 559let rec resolve_type_full (json : Jsont.json) : type_resolution = 560 (* Check if the schema is nullable *) 561 let is_nullable = match get_member "nullable" json with 562 | Some (Jsont.Bool (b, _)) -> b 563 | _ -> false 564 in 565 let constraints = extract_constraints json in 566 567 (* Check for oneOf/anyOf first *) 568 match analyze_field_union json with 569 | Some union -> 570 let poly_type = poly_variant_type_of_union union in 571 { resolved_type = poly_type; resolved_nullable = is_nullable; 572 resolved_constraints = constraints; resolved_union = Some union } 573 | None -> 574 match get_ref json with 575 | Some ref_ -> 576 (match schema_name_from_ref ref_ with 577 | Some name -> 578 let prefix, suffix = Name.split_schema_name name in 579 { resolved_type = Printf.sprintf "%s.%s.t" (Name.to_module_name prefix) (Name.to_module_name suffix); 580 resolved_nullable = is_nullable; resolved_constraints = constraints; resolved_union = None } 581 | None -> 582 { resolved_type = "Jsont.json"; resolved_nullable = is_nullable; 583 resolved_constraints = constraints; resolved_union = None }) 584 | None -> 585 (* Check for allOf with a single $ref - common pattern for type aliasing *) 586 (match get_member "allOf" json with 587 | Some (Jsont.Array ([item], _)) -> 588 (* Single item allOf - try to resolve it *) 589 resolve_type_full item 590 | Some (Jsont.Array (items, _)) when List.length items > 0 -> 591 (* Multiple allOf items - try to find a $ref among them *) 592 (match List.find_map (fun item -> 593 match get_ref item with 594 | Some ref_ -> schema_name_from_ref ref_ 595 | None -> None 596 ) items with 597 | Some name -> 598 let prefix, suffix = Name.split_schema_name name in 599 { resolved_type = Printf.sprintf "%s.%s.t" (Name.to_module_name prefix) (Name.to_module_name suffix); 600 resolved_nullable = is_nullable; resolved_constraints = constraints; resolved_union = None } 601 | None -> 602 { resolved_type = "Jsont.json"; resolved_nullable = is_nullable; 603 resolved_constraints = constraints; resolved_union = None }) 604 | _ -> 605 let resolved_type = match get_string_member "type" json with 606 | Some "string" -> 607 (match get_string_member "format" json with 608 | Some "date-time" -> "Ptime.t" 609 | _ -> "string") 610 | Some "integer" -> 611 (match get_string_member "format" json with 612 | Some "int64" -> "int64" 613 | Some "int32" -> "int32" 614 | _ -> "int") 615 | Some "number" -> "float" 616 | Some "boolean" -> "bool" 617 | Some "array" -> 618 (match get_member "items" json with 619 | Some items -> 620 let elem = resolve_type_full items in 621 elem.resolved_type ^ " list" 622 | None -> "Jsont.json list") 623 | Some "object" -> "Jsont.json" 624 | _ -> "Jsont.json" 625 in 626 { resolved_type; resolved_nullable = is_nullable; 627 resolved_constraints = constraints; resolved_union = None }) 628 629(** Simple type resolution for backward compatibility *) 630let rec type_of_json_schema (json : Jsont.json) : string * bool = 631 let result = resolve_type_full json in 632 (result.resolved_type, result.resolved_nullable) 633 634let rec jsont_of_base_type = function 635 | "string" -> "Jsont.string" 636 | "int" -> "Jsont.int" 637 | "int32" -> "Jsont.int32" 638 | "int64" -> "Jsont.int64" 639 | "float" -> "Jsont.number" 640 | "bool" -> "Jsont.bool" 641 | "Ptime.t" -> "Openapi.Runtime.ptime_jsont" 642 | "Jsont.json" -> "Jsont.json" 643 | s when String.ends_with ~suffix:" list" s -> 644 let elem = String.sub s 0 (String.length s - 5) in 645 Printf.sprintf "(Jsont.list %s)" (jsont_of_base_type elem) 646 | s when String.ends_with ~suffix:".t" s -> 647 let module_path = String.sub s 0 (String.length s - 2) in 648 module_path ^ ".jsont" 649 | _ -> "Jsont.json" 650 651(** Generate a nullable codec wrapper for types that need to handle explicit JSON nulls *) 652let nullable_jsont_of_base_type = function 653 | "string" -> "Openapi.Runtime.nullable_string" 654 | "int" -> "Openapi.Runtime.nullable_int" 655 | "float" -> "Openapi.Runtime.nullable_float" 656 | "bool" -> "Openapi.Runtime.nullable_bool" 657 | "Ptime.t" -> "Openapi.Runtime.nullable_ptime" 658 | base_type -> 659 (* For other types, wrap with nullable_any *) 660 Printf.sprintf "(Openapi.Runtime.nullable_any %s)" (jsont_of_base_type base_type) 661 662(** Format a float value for OCaml code, wrapping negative numbers in parentheses 663 and ensuring the value is formatted as a float (with decimal point) *) 664let format_float_arg (name : string) (v : float) : string = 665 (* Format as float with at least one decimal place *) 666 let str = Printf.sprintf "%g" v in 667 let float_str = 668 if String.contains str '.' || String.contains str 'e' || String.contains str 'E' then 669 str 670 else 671 str ^ "." 672 in 673 if v < 0.0 then 674 Printf.sprintf "~%s:(%s)" name float_str 675 else 676 Printf.sprintf "~%s:%s" name float_str 677 678(** Generate a validated codec wrapper based on constraints *) 679let validated_jsont (constraints : validation_constraints) (base_codec : string) (base_type : string) : string = 680 if not (has_constraints constraints) then 681 base_codec 682 else 683 match base_type with 684 | "string" -> 685 let args = List.filter_map Fun.id [ 686 Option.map (fun v -> Printf.sprintf "~min_length:%d" v) constraints.min_length; 687 Option.map (fun v -> Printf.sprintf "~max_length:%d" v) constraints.max_length; 688 Option.map (fun v -> Printf.sprintf "~pattern:%S" v) constraints.pattern; 689 ] in 690 if args = [] then base_codec 691 else Printf.sprintf "(Openapi.Runtime.validated_string %s %s)" (String.concat " " args) base_codec 692 | "int" | "int32" | "int64" -> 693 let args = List.filter_map Fun.id [ 694 Option.map (format_float_arg "minimum") constraints.minimum; 695 Option.map (format_float_arg "maximum") constraints.maximum; 696 Option.map (format_float_arg "exclusive_minimum") constraints.exclusive_minimum; 697 Option.map (format_float_arg "exclusive_maximum") constraints.exclusive_maximum; 698 ] in 699 if args = [] then base_codec 700 else Printf.sprintf "(Openapi.Runtime.validated_int %s %s)" (String.concat " " args) base_codec 701 | "float" -> 702 let args = List.filter_map Fun.id [ 703 Option.map (format_float_arg "minimum") constraints.minimum; 704 Option.map (format_float_arg "maximum") constraints.maximum; 705 Option.map (format_float_arg "exclusive_minimum") constraints.exclusive_minimum; 706 Option.map (format_float_arg "exclusive_maximum") constraints.exclusive_maximum; 707 ] in 708 if args = [] then base_codec 709 else Printf.sprintf "(Openapi.Runtime.validated_float %s %s)" (String.concat " " args) base_codec 710 | s when String.ends_with ~suffix:" list" s -> 711 let args = List.filter_map Fun.id [ 712 Option.map (fun v -> Printf.sprintf "~min_items:%d" v) constraints.min_items; 713 Option.map (fun v -> Printf.sprintf "~max_items:%d" v) constraints.max_items; 714 (if constraints.unique_items then Some "~unique_items:true" else None); 715 ] in 716 if args = [] then base_codec 717 else 718 (* Extract element codec from "(Jsont.list elem_codec)" pattern. 719 validated_list takes elem_codec directly, not the wrapped list. *) 720 let elem_codec = 721 if String.length base_codec > 12 && String.sub base_codec 0 12 = "(Jsont.list " then 722 (* Extract from "(Jsont.list X)" -> "X" *) 723 String.sub base_codec 12 (String.length base_codec - 13) 724 else 725 (* Fallback: can't extract, skip validation *) 726 "" 727 in 728 if elem_codec = "" then base_codec 729 else Printf.sprintf "(Openapi.Runtime.validated_list %s %s)" (String.concat " " args) elem_codec 730 | _ -> base_codec 731 732(** Generate a jsont codec for a polymorphic variant union type. 733 For unions with schema refs, returns Jsont.json (matching the fallback type). 734 For primitive-only unions, generates a proper polymorphic variant codec. *) 735let jsont_of_field_union ~current_prefix:_ (union : field_union_info) : string = 736 (* If union has schema refs, we've already fallen back to Jsont.json type *) 737 if field_union_has_refs union then 738 "Jsont.json" 739 else 740 (* Primitive-only union - generate polymorphic variant codec *) 741 let decoders = List.map (fun v -> 742 match v with 743 | Ref_variant _ -> failwith "unreachable: ref variant in primitive-only union" 744 | Prim_variant (name, prim_type) -> 745 let codec = jsont_of_base_type prim_type in 746 Printf.sprintf {|(fun json -> 747 match Openapi.Runtime.Json.decode_json %s json with 748 | Ok v -> Some (`%s v) 749 | Error _ -> None)|} codec name 750 ) union.field_variants in 751 752 let encoders = List.map (fun v -> 753 match v with 754 | Ref_variant _ -> failwith "unreachable: ref variant in primitive-only union" 755 | Prim_variant (name, prim_type) -> 756 let codec = jsont_of_base_type prim_type in 757 Printf.sprintf " | `%s v -> Openapi.Runtime.Json.encode_json %s v" name codec 758 ) union.field_variants in 759 760 Printf.sprintf {|(Jsont.map Jsont.json ~kind:"poly_union" 761 ~dec:(Openapi.Runtime.poly_union_decoder [ 762 %s 763 ]) 764 ~enc:(function 765%s))|} 766 (String.concat ";\n " decoders) 767 (String.concat "\n" encoders) 768 769(** {1 Schema Processing} *) 770 771(** Extract variant name from a schema ref, stripping common prefixes *) 772let variant_name_from_ref (ref_ : string) (parent_name : string) : string = 773 match schema_name_from_ref ref_ with 774 | None -> "Unknown" 775 | Some name -> 776 (* Try to strip parent prefix for shorter names *) 777 let parent_prefix = match String.split_on_char '_' (Name.to_snake_case parent_name) with 778 | first :: _ -> Name.to_module_name first 779 | [] -> "" 780 in 781 if String.length name > String.length parent_prefix && 782 String.sub name 0 (String.length parent_prefix) = parent_prefix then 783 Name.to_module_name (String.sub name (String.length parent_prefix) 784 (String.length name - String.length parent_prefix)) 785 else 786 Name.to_module_name name 787 788(** Analyze oneOf/anyOf schemas to extract union information *) 789let analyze_union ~(name : string) (schema : Spec.schema) : union_info option = 790 let extract_variants style json_list = 791 let variants = List.filter_map (fun json -> 792 match get_ref json with 793 | Some ref_ -> 794 schema_name_from_ref ref_ |> Option.map (fun schema_ref -> 795 let variant_name = variant_name_from_ref ref_ name in 796 { variant_name; schema_ref }) 797 | None -> None (* Skip inline schemas for now *) 798 ) json_list in 799 if variants = [] then None 800 else 801 let discriminator_field = Option.map (fun (d : Spec.discriminator) -> 802 d.property_name) schema.discriminator in 803 let discriminator_mapping = Option.fold ~none:[] 804 ~some:(fun (d : Spec.discriminator) -> d.mapping) schema.discriminator in 805 Some { 806 discriminator_field; 807 discriminator_mapping; 808 variants; 809 style; 810 } 811 in 812 match schema.one_of, schema.any_of with 813 | Some items, _ -> extract_variants `OneOf items 814 | None, Some items -> extract_variants `AnyOf items 815 | None, None -> None 816 817let analyze_schema ~(components : Spec.components option) (name : string) (schema : Spec.schema) : schema_info = 818 (* First expand allOf composition *) 819 let expanded = expand_schema ~components schema in 820 let prefix, suffix = Name.split_schema_name name in 821 let is_enum = Option.is_some expanded.enum in 822 let enum_variants = match expanded.enum with 823 | Some values -> 824 List.filter_map (fun json -> 825 match json with 826 | Jsont.String (s, _) -> Some (Name.to_variant_name s, s) 827 | _ -> None 828 ) values 829 | None -> [] 830 in 831 (* Check for oneOf/anyOf union types *) 832 let union_info = analyze_union ~name expanded in 833 let is_union = Option.is_some union_info in 834 let fields = List.map (fun (field_name, field_json) -> 835 let ocaml_name = Name.to_snake_case field_name in 836 let is_required = List.mem field_name expanded.required in 837 let resolved = resolve_type_full field_json in 838 let base_type = resolved.resolved_type in 839 let is_nullable = resolved.resolved_nullable in 840 let default_value = extract_default_value field_json base_type in 841 (* Field is optional in record type if: 842 - nullable (can be null) OR 843 - not required AND no default (may be absent with no fallback) 844 Fields with defaults are NOT optional - they always have a value *) 845 let has_default = Option.is_some default_value in 846 let is_optional = is_nullable || (not is_required && not has_default) in 847 let ocaml_type = if is_optional then base_type ^ " option" else base_type in 848 let description = get_string_member "description" field_json in 849 { ocaml_name; json_name = field_name; ocaml_type; base_type; is_optional; 850 is_required; is_nullable; description; 851 constraints = resolved.resolved_constraints; 852 field_union = resolved.resolved_union; 853 default_value } 854 ) expanded.properties in 855 (* Check if schema references itself *) 856 let deps = find_schema_dependencies expanded in 857 let is_recursive = List.mem name deps in 858 { original_name = name; prefix; suffix; schema = expanded; fields; is_enum; enum_variants; 859 description = expanded.description; is_recursive; is_union; union_info } 860 861(** {1 Operation Processing} *) 862 863(** Extract parameter name from a $ref like "#/components/parameters/idOrUUID" *) 864let param_name_from_ref ref_str = 865 let prefix = "#/components/parameters/" in 866 if String.length ref_str > String.length prefix && 867 String.sub ref_str 0 (String.length prefix) = prefix then 868 Some (String.sub ref_str (String.length prefix) 869 (String.length ref_str - String.length prefix)) 870 else None 871 872(** Resolve a parameter reference or return inline parameter *) 873let resolve_parameter ~(components : Spec.components option) (p : Spec.parameter Spec.or_ref) : Spec.parameter option = 874 match p with 875 | Spec.Value param -> Some param 876 | Spec.Ref ref_str -> 877 match param_name_from_ref ref_str with 878 | None -> None 879 | Some name -> 880 match components with 881 | None -> None 882 | Some comps -> 883 List.find_map (fun (n, p_or_ref) -> 884 if n = name then 885 match p_or_ref with 886 | Spec.Value param -> Some param 887 | Spec.Ref _ -> None (* Nested refs not supported *) 888 else None 889 ) comps.parameters 890 891let analyze_operation ~(spec : Spec.t) ~(path_item_params : Spec.parameter Spec.or_ref list) 892 ~path ~method_ (op : Spec.operation) : operation_info = 893 let func_name = Name.operation_name ~method_ ~path ~operation_id:op.operation_id in 894 (* Merge path_item parameters with operation parameters, operation takes precedence *) 895 let all_param_refs = path_item_params @ op.parameters in 896 let params = List.filter_map (resolve_parameter ~components:spec.components) all_param_refs in 897 898 let path_params = List.filter_map (fun (p : Spec.parameter) -> 899 if p.in_ = Spec.Path then 900 Some (Name.to_snake_case p.name, p.name, p.description, p.required) 901 else None 902 ) params in 903 904 let query_params = List.filter_map (fun (p : Spec.parameter) -> 905 if p.in_ = Spec.Query then 906 Some (Name.to_snake_case p.name, p.name, p.description, p.required) 907 else None 908 ) params in 909 910 let body_schema_ref = match op.request_body with 911 | Some (Spec.Value (rb : Spec.request_body)) -> 912 List.find_map (fun (ct, (media : Spec.media_type)) -> 913 if String.length ct >= 16 && String.sub ct 0 16 = "application/json" then 914 match media.schema with 915 | Some (Spec.Ref r) -> schema_name_from_ref r 916 | _ -> None 917 else None 918 ) rb.content 919 | _ -> None 920 in 921 922 let find_in_content content = 923 List.find_map (fun (ct, (media : Spec.media_type)) -> 924 if String.length ct >= 16 && String.sub ct 0 16 = "application/json" then 925 match media.schema with 926 | Some (Spec.Ref r) -> schema_name_from_ref r 927 | Some (Spec.Value s) when s.type_ = Some "array" -> 928 Option.bind s.items (fun items -> Option.bind (get_ref items) schema_name_from_ref) 929 | _ -> None 930 else None 931 ) content 932 in 933 934 let response_schema_ref = 935 let try_status status = 936 List.find_map (fun (code, resp) -> 937 if code = status then 938 match resp with 939 | Spec.Value (r : Spec.response) -> find_in_content r.content 940 | _ -> None 941 else None 942 ) op.responses.responses 943 in 944 match try_status "200" with 945 | Some r -> Some r 946 | None -> match try_status "201" with 947 | Some r -> Some r 948 | None -> match op.responses.default with 949 | Some (Spec.Value (r : Spec.response)) -> find_in_content r.content 950 | _ -> None 951 in 952 953 (* Extract error responses (4xx, 5xx, default) *) 954 let error_responses = 955 let is_error_code code = 956 code = "default" || 957 (try int_of_string code >= 400 with _ -> 958 String.length code = 3 && (code.[0] = '4' || code.[0] = '5')) 959 in 960 List.filter_map (fun (code, resp) -> 961 if is_error_code code then 962 match resp with 963 | Spec.Value (r : Spec.response) -> 964 let schema_ref = find_in_content r.content in 965 Some { status_code = code; schema_ref; error_description = r.description } 966 | Spec.Ref _ -> 967 Some { status_code = code; schema_ref = None; error_description = "" } 968 else None 969 ) op.responses.responses 970 in 971 972 { func_name; operation_id = op.operation_id; summary = op.summary; 973 description = op.description; tags = op.tags; path; method_; 974 path_params; query_params; body_schema_ref; response_schema_ref; error_responses } 975 976(** {1 Module Tree Building} *) 977 978(** Extract prefix module dependencies from a schema's fields *) 979let schema_prefix_deps (schema : schema_info) : StringSet.t = 980 let deps = List.filter_map (fun (f : field_info) -> 981 (* Check if the type references another module *) 982 if String.contains f.base_type '.' then 983 (* Extract first component before the dot *) 984 match String.split_on_char '.' f.base_type with 985 | prefix :: _ when prefix <> "Jsont" && prefix <> "Ptime" && prefix <> "Openapi" -> 986 Some prefix 987 | _ -> None 988 else None 989 ) schema.fields in 990 StringSet.of_list deps 991 992(** Extract prefix module dependencies from an operation's types *) 993let operation_prefix_deps (op : operation_info) : StringSet.t = 994 let body_dep = match op.body_schema_ref with 995 | Some name -> 996 let prefix, _ = Name.split_schema_name name in 997 Some (Name.to_module_name prefix) 998 | None -> None 999 in 1000 let response_dep = match op.response_schema_ref with 1001 | Some name -> 1002 let prefix, _ = Name.split_schema_name name in 1003 Some (Name.to_module_name prefix) 1004 | None -> None 1005 in 1006 StringSet.of_list (List.filter_map Fun.id [body_dep; response_dep]) 1007 1008let build_module_tree (schemas : schema_info list) (operations : operation_info list) : module_node * string list = 1009 let root = empty_node "Root" in 1010 1011 (* Build set of known schema names for validation *) 1012 let known_schemas = StringSet.of_list (List.map (fun s -> s.original_name) schemas) in 1013 1014 (* Add schemas to tree and track dependencies *) 1015 let root = List.fold_left (fun root schema -> 1016 let prefix_mod = Name.to_module_name schema.prefix in 1017 let child = match StringMap.find_opt prefix_mod root.children with 1018 | Some c -> c 1019 | None -> empty_node prefix_mod 1020 in 1021 let schema_deps = schema_prefix_deps schema in 1022 (* Remove self-dependency *) 1023 let schema_deps = StringSet.remove prefix_mod schema_deps in 1024 let child = { child with 1025 schemas = schema :: child.schemas; 1026 dependencies = StringSet.union child.dependencies schema_deps 1027 } in 1028 { root with children = StringMap.add prefix_mod child root.children } 1029 ) root schemas in 1030 1031 (* Add operations to tree based on response type, and track operation dependencies. 1032 Only use response_schema_ref if the schema actually exists in components/schemas. *) 1033 let root = List.fold_left (fun root op -> 1034 (* Check if response schema actually exists *) 1035 let valid_response_ref = match op.response_schema_ref with 1036 | Some name when StringSet.mem name known_schemas -> Some name 1037 | _ -> None 1038 in 1039 match valid_response_ref with 1040 | Some ref_name -> 1041 let prefix, _ = Name.split_schema_name ref_name in 1042 let prefix_mod = Name.to_module_name prefix in 1043 let child = match StringMap.find_opt prefix_mod root.children with 1044 | Some c -> c 1045 | None -> empty_node prefix_mod 1046 in 1047 let op_deps = operation_prefix_deps op in 1048 (* Remove self-dependency *) 1049 let op_deps = StringSet.remove prefix_mod op_deps in 1050 let child = { child with 1051 operations = op :: child.operations; 1052 dependencies = StringSet.union child.dependencies op_deps 1053 } in 1054 { root with children = StringMap.add prefix_mod child root.children } 1055 | None -> 1056 (* Put in Client module for operations without valid typed response *) 1057 let child = match StringMap.find_opt "Client" root.children with 1058 | Some c -> c 1059 | None -> empty_node "Client" 1060 in 1061 let op_deps = operation_prefix_deps op in 1062 let op_deps = StringSet.remove "Client" op_deps in 1063 let child = { child with 1064 operations = op :: child.operations; 1065 dependencies = StringSet.union child.dependencies op_deps 1066 } in 1067 { root with children = StringMap.add "Client" child root.children } 1068 ) root operations in 1069 1070 (* Get sorted list of module names (dependencies first) *) 1071 let module_names = StringMap.fold (fun name _ acc -> name :: acc) root.children [] in 1072 let deps_of name = 1073 match StringMap.find_opt name root.children with 1074 | Some node -> StringSet.elements node.dependencies 1075 | None -> [] 1076 in 1077 let sorted = topological_sort module_names deps_of in 1078 1079 (root, sorted) 1080 1081(** {1 Code Generation} *) 1082 1083let gen_enum_impl (schema : schema_info) : string = 1084 let doc = format_doc schema.description in 1085 if schema.enum_variants = [] then 1086 Printf.sprintf "%stype t = string\n\nlet jsont = Jsont.string" doc 1087 else 1088 let type_def = Printf.sprintf "%stype t = [\n%s\n]" doc 1089 (String.concat "\n" (List.map (fun (v, _) -> " | `" ^ v) schema.enum_variants)) 1090 in 1091 let dec_cases = String.concat "\n" (List.map (fun (v, raw) -> 1092 Printf.sprintf " | %S -> `%s" raw v 1093 ) schema.enum_variants) in 1094 let enc_cases = String.concat "\n" (List.map (fun (v, raw) -> 1095 Printf.sprintf " | `%s -> %S" v raw 1096 ) schema.enum_variants) in 1097 Printf.sprintf {|%s 1098 1099let jsont : t Jsont.t = 1100 Jsont.map Jsont.string ~kind:%S 1101 ~dec:(function 1102%s 1103 | s -> Jsont.Error.msgf Jsont.Meta.none "Unknown value: %%s" s) 1104 ~enc:(function 1105%s)|} type_def schema.original_name dec_cases enc_cases 1106 1107let gen_enum_intf (schema : schema_info) : string = 1108 let doc = format_doc schema.description in 1109 if schema.enum_variants = [] then 1110 Printf.sprintf "%stype t = string\n\nval jsont : t Jsont.t" doc 1111 else 1112 let type_def = Printf.sprintf "%stype t = [\n%s\n]" doc 1113 (String.concat "\n" (List.map (fun (v, _) -> " | `" ^ v) schema.enum_variants)) 1114 in 1115 Printf.sprintf "%s\n\nval jsont : t Jsont.t" type_def 1116 1117(** {2 Union Type Generation} *) 1118 1119(** Format a union variant type reference for code generation *) 1120let format_union_type_ref ~current_prefix (schema_ref : string) : string = 1121 let prefix, suffix = Name.split_schema_name schema_ref in 1122 let prefix_mod = Name.to_module_name prefix in 1123 let suffix_mod = Name.to_module_name suffix in 1124 if prefix_mod = current_prefix then 1125 Printf.sprintf "%s.t" suffix_mod 1126 else if is_forward_ref prefix_mod then 1127 "Jsont.json" 1128 else 1129 Printf.sprintf "%s.%s.t" prefix_mod suffix_mod 1130 1131(** Format a union variant jsont codec reference *) 1132let format_union_jsont_ref ~current_prefix (schema_ref : string) : string = 1133 let prefix, suffix = Name.split_schema_name schema_ref in 1134 let prefix_mod = Name.to_module_name prefix in 1135 let suffix_mod = Name.to_module_name suffix in 1136 if prefix_mod <> current_prefix && is_forward_ref prefix_mod then 1137 "Jsont.json" 1138 else if prefix_mod = current_prefix then 1139 Printf.sprintf "%s.jsont" suffix_mod 1140 else 1141 Printf.sprintf "%s.%s.jsont" prefix_mod suffix_mod 1142 1143(** Generate a discriminator-based jsont codec for union types. 1144 Uses Jsont.Object.Case for tag-based discrimination. *) 1145let gen_union_jsont_discriminator ~current_prefix (schema : schema_info) (union : union_info) (field : string) : string = 1146 (* Generate case definitions *) 1147 let cases = List.map (fun (v : union_variant) -> 1148 let codec_ref = format_union_jsont_ref ~current_prefix v.schema_ref in 1149 (* Look up the tag value in discriminator mapping, or default to snake_case variant name *) 1150 let tag_value = match List.find_opt (fun (_, ref_) -> 1151 match schema_name_from_ref ref_ with 1152 | Some name -> name = v.schema_ref 1153 | None -> false 1154 ) union.discriminator_mapping with 1155 | Some (tag, _) -> tag 1156 | None -> Name.to_snake_case v.variant_name 1157 in 1158 Printf.sprintf {| let case_%s = 1159 Jsont.Object.Case.map %S %s ~dec:(fun v -> %s v) 1160 in|} 1161 (Name.to_snake_case v.variant_name) 1162 tag_value 1163 codec_ref 1164 v.variant_name 1165 ) union.variants in 1166 1167 let enc_cases = List.map (fun (v : union_variant) -> 1168 Printf.sprintf " | %s v -> Jsont.Object.Case.value case_%s v" 1169 v.variant_name (Name.to_snake_case v.variant_name) 1170 ) union.variants in 1171 1172 let case_list = List.map (fun (v : union_variant) -> 1173 Printf.sprintf "make case_%s" (Name.to_snake_case v.variant_name) 1174 ) union.variants in 1175 1176 Printf.sprintf {|let jsont : t Jsont.t = 1177%s 1178 let enc_case = function 1179%s 1180 in 1181 let cases = Jsont.Object.Case.[%s] in 1182 Jsont.Object.map ~kind:%S Fun.id 1183 |> Jsont.Object.case_mem %S Jsont.string ~enc:Fun.id ~enc_case cases 1184 ~tag_to_string:Fun.id ~tag_compare:String.compare 1185 |> Jsont.Object.finish|} 1186 (String.concat "\n" cases) 1187 (String.concat "\n" enc_cases) 1188 (String.concat "; " case_list) 1189 schema.original_name 1190 field 1191 1192(** Generate a try-each jsont codec for union types without discriminator. 1193 Attempts to decode each variant in order until one succeeds. *) 1194let gen_union_jsont_try_each ~current_prefix (schema : schema_info) (union : union_info) : string = 1195 let try_cases = List.mapi (fun i (v : union_variant) -> 1196 let codec_ref = format_union_jsont_ref ~current_prefix v.schema_ref in 1197 let prefix = if i = 0 then " " else " " in 1198 let error_prefix = if i = List.length union.variants - 1 then 1199 Printf.sprintf {|%sJsont.Error.msgf Jsont.Meta.none "No variant matched for %s"|} prefix schema.original_name 1200 else 1201 "" 1202 in 1203 Printf.sprintf {|%smatch Openapi.Runtime.Json.decode_json %s json with 1204%s| Ok v -> %s v 1205%s| Error _ -> 1206%s|} 1207 prefix codec_ref prefix v.variant_name prefix error_prefix 1208 ) union.variants in 1209 1210 let enc_cases = List.map (fun (v : union_variant) -> 1211 let codec_ref = format_union_jsont_ref ~current_prefix v.schema_ref in 1212 Printf.sprintf " | %s v -> Openapi.Runtime.Json.encode_json %s v" 1213 v.variant_name codec_ref 1214 ) union.variants in 1215 1216 Printf.sprintf {|let jsont : t Jsont.t = 1217 let decode json = 1218%s 1219 in 1220 Jsont.map Jsont.json ~kind:%S 1221 ~dec:decode 1222 ~enc:(function 1223%s)|} 1224 (String.concat "" try_cases) 1225 schema.original_name 1226 (String.concat "\n" enc_cases) 1227 1228(** Generate implementation code for a union type schema *) 1229let gen_union_impl ~current_prefix (schema : schema_info) : string = 1230 match schema.union_info with 1231 | None -> failwith "gen_union_impl called on non-union schema" 1232 | Some union -> 1233 let doc = format_doc schema.description in 1234 1235 (* Type definition with variant constructors *) 1236 let type_def = Printf.sprintf "%stype t =\n%s" doc 1237 (String.concat "\n" (List.map (fun (v : union_variant) -> 1238 Printf.sprintf " | %s of %s" v.variant_name 1239 (format_union_type_ref ~current_prefix v.schema_ref) 1240 ) union.variants)) 1241 in 1242 1243 (* Jsont codec - discriminator-based or try-each *) 1244 let jsont_code = match union.discriminator_field with 1245 | Some field -> gen_union_jsont_discriminator ~current_prefix schema union field 1246 | None -> gen_union_jsont_try_each ~current_prefix schema union 1247 in 1248 1249 Printf.sprintf "%s\n\n%s" type_def jsont_code 1250 1251(** Generate interface code for a union type schema *) 1252let gen_union_intf ~current_prefix (schema : schema_info) : string = 1253 match schema.union_info with 1254 | None -> failwith "gen_union_intf called on non-union schema" 1255 | Some union -> 1256 let doc = format_doc schema.description in 1257 let type_def = Printf.sprintf "%stype t =\n%s" doc 1258 (String.concat "\n" (List.map (fun (v : union_variant) -> 1259 Printf.sprintf " | %s of %s" v.variant_name 1260 (format_union_type_ref ~current_prefix v.schema_ref) 1261 ) union.variants)) 1262 in 1263 Printf.sprintf "%s\n\nval jsont : t Jsont.t" type_def 1264 1265(** Localize an OCaml type string by stripping the current_prefix and current_suffix modules. 1266 When generating code inside a submodule, self-references need to be unqualified. *) 1267let localize_type ~current_prefix ~current_suffix (type_str : string) : string = 1268 (* Handle patterns like "User.ResponseDto.t" -> "ResponseDto.t" if current_prefix = "User" 1269 And further "ResponseDto.t" -> "t" if current_suffix = "ResponseDto" *) 1270 let prefix_dot = current_prefix ^ "." in 1271 let suffix_dot = current_suffix ^ "." in 1272 let full_path = current_prefix ^ "." ^ current_suffix ^ "." in 1273 let strip_prefix s = 1274 (* First try to strip full path (Prefix.Suffix.) *) 1275 if String.length s >= String.length full_path && 1276 String.sub s 0 (String.length full_path) = full_path then 1277 String.sub s (String.length full_path) (String.length s - String.length full_path) 1278 (* Then try just prefix *) 1279 else if String.length s >= String.length prefix_dot && 1280 String.sub s 0 (String.length prefix_dot) = prefix_dot then 1281 let rest = String.sub s (String.length prefix_dot) (String.length s - String.length prefix_dot) in 1282 (* If the rest starts with our suffix, strip that too *) 1283 if String.length rest >= String.length suffix_dot && 1284 String.sub rest 0 (String.length suffix_dot) = suffix_dot then 1285 String.sub rest (String.length suffix_dot) (String.length rest - String.length suffix_dot) 1286 else rest 1287 else s 1288 in 1289 (* Handle "X list", "X option", and nested combinations *) 1290 let rec localize s = 1291 if String.ends_with ~suffix:" list" s then 1292 let elem = String.sub s 0 (String.length s - 5) in 1293 (localize elem) ^ " list" 1294 else if String.ends_with ~suffix:" option" s then 1295 let elem = String.sub s 0 (String.length s - 7) in 1296 (localize elem) ^ " option" 1297 else 1298 strip_prefix s 1299 in 1300 localize type_str 1301 1302(** Localize a jsont codec string by stripping the current_prefix and current_suffix modules *) 1303let rec localize_jsont ~current_prefix ~current_suffix (jsont_str : string) : string = 1304 let prefix_dot = current_prefix ^ "." in 1305 let suffix_dot = current_suffix ^ "." in 1306 let full_path = current_prefix ^ "." ^ current_suffix ^ "." in 1307 let strip_prefix s = 1308 (* First try to strip full path (Prefix.Suffix.) *) 1309 if String.length s >= String.length full_path && 1310 String.sub s 0 (String.length full_path) = full_path then 1311 String.sub s (String.length full_path) (String.length s - String.length full_path) 1312 (* Then try just prefix *) 1313 else if String.length s >= String.length prefix_dot && 1314 String.sub s 0 (String.length prefix_dot) = prefix_dot then 1315 let rest = String.sub s (String.length prefix_dot) (String.length s - String.length prefix_dot) in 1316 (* If the rest starts with our suffix, strip that too *) 1317 if String.length rest >= String.length suffix_dot && 1318 String.sub rest 0 (String.length suffix_dot) = suffix_dot then 1319 String.sub rest (String.length suffix_dot) (String.length rest - String.length suffix_dot) 1320 else rest 1321 else s 1322 in 1323 (* Handle patterns like "User.ResponseDto.jsont" -> "ResponseDto.jsont" -> "jsont" 1324 Also handle "(Jsont.list User.ResponseDto.jsont)" *) 1325 if String.length jsont_str > 12 && String.sub jsont_str 0 12 = "(Jsont.list " then 1326 let inner = String.sub jsont_str 12 (String.length jsont_str - 13) in 1327 "(Jsont.list " ^ localize_jsont ~current_prefix ~current_suffix inner ^ ")" 1328 else 1329 strip_prefix jsont_str 1330 1331let gen_record_impl ~current_prefix ~current_suffix (schema : schema_info) : string = 1332 (* For recursive schemas, self-referential fields need to use Jsont.json 1333 to avoid OCaml's let rec restrictions on non-functional values. 1334 Also handle forward references to modules that come later in the sort order. *) 1335 let is_forward_reference type_str = 1336 (* Extract prefix from type like "People.Update.t" *) 1337 match String.split_on_char '.' type_str with 1338 | prefix :: _ when prefix <> current_prefix && is_forward_ref prefix -> true 1339 | _ -> false 1340 in 1341 let loc_type s = 1342 let localized = localize_type ~current_prefix ~current_suffix s in 1343 if schema.is_recursive && localized = "t" then "Jsont.json" 1344 else if schema.is_recursive && localized = "t list" then "Jsont.json list" 1345 else if schema.is_recursive && localized = "t option" then "Jsont.json option" 1346 else if schema.is_recursive && localized = "t list option" then "Jsont.json list option" 1347 (* Handle forward references - use Jsont.json for types from modules not yet defined *) 1348 else if is_forward_reference s then 1349 if String.ends_with ~suffix:" option" localized then "Jsont.json option" 1350 else if String.ends_with ~suffix:" list" localized then "Jsont.json list" 1351 else if String.ends_with ~suffix:" list option" localized then "Jsont.json list option" 1352 else "Jsont.json" 1353 else localized 1354 in 1355 let is_forward_jsont_ref jsont_str = 1356 (* Extract prefix from jsont like "People.Update.jsont", "(Jsont.list People.Update.jsont)", 1357 or "(Openapi.Runtime.nullable_any People.Update.jsont)" *) 1358 let s = 1359 if String.length jsont_str > 12 && String.sub jsont_str 0 12 = "(Jsont.list " then 1360 String.sub jsont_str 12 (String.length jsont_str - 13) 1361 else if String.length jsont_str > 31 && String.sub jsont_str 0 31 = "(Openapi.Runtime.nullable_any " then 1362 String.sub jsont_str 31 (String.length jsont_str - 32) 1363 else jsont_str 1364 in 1365 match String.split_on_char '.' s with 1366 | prefix :: _ when prefix <> current_prefix && is_forward_ref prefix -> true 1367 | _ -> false 1368 in 1369 let loc_jsont s = 1370 let localized = localize_jsont ~current_prefix ~current_suffix s in 1371 if schema.is_recursive && localized = "jsont" then "Jsont.json" 1372 else if schema.is_recursive && localized = "(Jsont.list jsont)" then 1373 "(Jsont.list Jsont.json)" 1374 (* Handle forward references in jsont codecs *) 1375 else if is_forward_jsont_ref s then 1376 if String.length localized > 12 && String.sub localized 0 12 = "(Jsont.list " then 1377 "(Jsont.list Jsont.json)" 1378 else if String.length s > 31 && String.sub s 0 31 = "(Openapi.Runtime.nullable_any " then 1379 (* For nullable forward refs, Jsont.json can decode nulls too *) 1380 "Jsont.json" 1381 else "Jsont.json" 1382 else localized 1383 in 1384 let doc = format_doc schema.description in 1385 if schema.fields = [] then 1386 Printf.sprintf "%stype t = Jsont.json\n\nlet jsont = Jsont.json\n\nlet v () = Jsont.Null ((), Jsont.Meta.none)" doc 1387 else 1388 (* Private type definition *) 1389 let type_fields = String.concat "\n" (List.map (fun (f : field_info) -> 1390 let field_doc = match f.description with 1391 | Some d -> Printf.sprintf " (** %s *)" (escape_doc d) 1392 | None -> "" 1393 in 1394 Printf.sprintf " %s : %s;%s" f.ocaml_name (loc_type f.ocaml_type) field_doc 1395 ) schema.fields) in 1396 1397 let type_def = Printf.sprintf "%stype t = {\n%s\n}" doc type_fields in 1398 1399 (* Constructor function v 1400 - Required fields (no default, not optional): ~field 1401 - Fields with defaults: ?(field=default) 1402 - Optional fields (no default, is_optional): ?field *) 1403 let required_fields = List.filter (fun (f : field_info) -> 1404 not f.is_optional && Option.is_none f.default_value 1405 ) schema.fields in 1406 let default_fields = List.filter (fun (f : field_info) -> 1407 Option.is_some f.default_value 1408 ) schema.fields in 1409 let optional_fields = List.filter (fun (f : field_info) -> 1410 f.is_optional && Option.is_none f.default_value 1411 ) schema.fields in 1412 let v_params = 1413 (List.map (fun (f : field_info) -> Printf.sprintf "~%s" f.ocaml_name) required_fields) @ 1414 (List.map (fun (f : field_info) -> 1415 Printf.sprintf "?(%s=%s)" f.ocaml_name (Option.get f.default_value) 1416 ) default_fields) @ 1417 (List.map (fun (f : field_info) -> Printf.sprintf "?%s" f.ocaml_name) optional_fields) @ 1418 ["()"] 1419 in 1420 let v_body = String.concat "; " (List.map (fun (f : field_info) -> f.ocaml_name) schema.fields) in 1421 let v_func = Printf.sprintf "let v %s = { %s }" (String.concat " " v_params) v_body in 1422 1423 (* Accessor functions *) 1424 let accessors = String.concat "\n" (List.map (fun (f : field_info) -> 1425 Printf.sprintf "let %s t = t.%s" f.ocaml_name f.ocaml_name 1426 ) schema.fields) in 1427 1428 (* Jsont codec *) 1429 let make_params = String.concat " " (List.map (fun (f : field_info) -> f.ocaml_name) schema.fields) in 1430 let jsont_members = String.concat "\n" (List.map (fun (f : field_info) -> 1431 (* Determine the right codec based on nullable/required/default status: 1432 - nullable: use nullable codec, dec_absent depends on default 1433 - optional with default: use mem with dec_absent:(Some default) 1434 - optional without default: use opt_mem 1435 - required: use mem 1436 - field union: use polymorphic variant codec 1437 - with validation: use validated codec *) 1438 let base_codec = 1439 match f.field_union with 1440 | Some union -> 1441 (* Field-level union - generate inline polymorphic variant codec *) 1442 jsont_of_field_union ~current_prefix union 1443 | None -> 1444 (* Regular field - may need validation *) 1445 let raw_codec = jsont_of_base_type f.base_type in 1446 let localized = loc_jsont raw_codec in 1447 if has_constraints f.constraints then 1448 validated_jsont f.constraints localized f.base_type 1449 else 1450 localized 1451 in 1452 if f.is_nullable then 1453 let nullable_codec = 1454 match f.field_union with 1455 | Some _ -> Printf.sprintf "(Openapi.Runtime.nullable_any %s)" base_codec 1456 | None -> loc_jsont (nullable_jsont_of_base_type f.base_type) 1457 in 1458 (* For nullable fields, dec_absent depends on default: 1459 - No default: None (absent = null) 1460 - Default is "None" (JSON null): None 1461 - Default is a value: (Some value) *) 1462 let dec_absent = match f.default_value with 1463 | Some "None" -> "None" (* Default is null *) 1464 | Some def -> Printf.sprintf "(Some %s)" def 1465 | None -> "None" 1466 in 1467 Printf.sprintf " |> Jsont.Object.mem %S %s\n ~dec_absent:%s ~enc_omit:Option.is_none ~enc:(fun r -> r.%s)" 1468 f.json_name nullable_codec dec_absent f.ocaml_name 1469 else if f.is_optional then 1470 (* Optional non-nullable field without default - use opt_mem *) 1471 Printf.sprintf " |> Jsont.Object.opt_mem %S %s ~enc:(fun r -> r.%s)" 1472 f.json_name base_codec f.ocaml_name 1473 else 1474 (* Required or has default - use mem, possibly with dec_absent *) 1475 (match f.default_value with 1476 | Some def -> 1477 Printf.sprintf " |> Jsont.Object.mem %S %s ~dec_absent:%s ~enc:(fun r -> r.%s)" 1478 f.json_name base_codec def f.ocaml_name 1479 | None -> 1480 Printf.sprintf " |> Jsont.Object.mem %S %s ~enc:(fun r -> r.%s)" 1481 f.json_name base_codec f.ocaml_name) 1482 ) schema.fields) in 1483 1484 Printf.sprintf {|%s 1485 1486%s 1487 1488%s 1489 1490let jsont : t Jsont.t = 1491 Jsont.Object.map ~kind:%S 1492 (fun %s -> { %s }) 1493%s 1494 |> Jsont.Object.skip_unknown 1495 |> Jsont.Object.finish|} 1496 type_def v_func accessors schema.original_name make_params v_body jsont_members 1497 1498let gen_record_intf ~current_prefix ~current_suffix (schema : schema_info) : string = 1499 (* For recursive schemas, self-referential fields need to use Jsont.json 1500 to avoid OCaml's let rec restrictions on non-functional values. 1501 Also handle forward references to modules that come later in the sort order. *) 1502 let is_forward_reference type_str = 1503 match String.split_on_char '.' type_str with 1504 | prefix :: _ when prefix <> current_prefix && is_forward_ref prefix -> true 1505 | _ -> false 1506 in 1507 let loc_type s = 1508 let localized = localize_type ~current_prefix ~current_suffix s in 1509 if schema.is_recursive && localized = "t" then "Jsont.json" 1510 else if schema.is_recursive && localized = "t list" then "Jsont.json list" 1511 else if schema.is_recursive && localized = "t option" then "Jsont.json option" 1512 else if schema.is_recursive && localized = "t list option" then "Jsont.json list option" 1513 (* Handle forward references *) 1514 else if is_forward_reference s then 1515 if String.ends_with ~suffix:" option" localized then "Jsont.json option" 1516 else if String.ends_with ~suffix:" list" localized then "Jsont.json list" 1517 else if String.ends_with ~suffix:" list option" localized then "Jsont.json list option" 1518 else "Jsont.json" 1519 else localized 1520 in 1521 let doc = format_doc schema.description in 1522 if schema.fields = [] then 1523 (* Expose that the type is Jsont.json for opaque types - allows users to pattern match *) 1524 Printf.sprintf "%stype t = Jsont.json\n\nval jsont : t Jsont.t\n\nval v : unit -> t" doc 1525 else 1526 (* Abstract type *) 1527 let type_decl = Printf.sprintf "%stype t" doc in 1528 1529 (* Constructor signature 1530 - Required fields (no default, not optional): field:type 1531 - Fields with defaults: ?field:type (optional parameter) 1532 - Optional fields (no default, is_optional): ?field:type *) 1533 let required_fields = List.filter (fun (f : field_info) -> 1534 not f.is_optional && Option.is_none f.default_value 1535 ) schema.fields in 1536 let default_fields = List.filter (fun (f : field_info) -> 1537 Option.is_some f.default_value 1538 ) schema.fields in 1539 let optional_fields = List.filter (fun (f : field_info) -> 1540 f.is_optional && Option.is_none f.default_value 1541 ) schema.fields in 1542 let v_param_docs = String.concat "" 1543 ((List.map (fun (f : field_info) -> format_param_doc f.ocaml_name f.description) required_fields) @ 1544 (List.map (fun (f : field_info) -> format_param_doc f.ocaml_name f.description) default_fields) @ 1545 (List.map (fun (f : field_info) -> format_param_doc f.ocaml_name f.description) optional_fields)) 1546 in 1547 let v_params = 1548 (List.map (fun (f : field_info) -> Printf.sprintf "%s:%s" f.ocaml_name (loc_type f.base_type)) required_fields) @ 1549 (List.map (fun (f : field_info) -> Printf.sprintf "?%s:%s" f.ocaml_name (loc_type f.ocaml_type)) default_fields) @ 1550 (List.map (fun (f : field_info) -> Printf.sprintf "?%s:%s" f.ocaml_name (loc_type f.base_type)) optional_fields) @ 1551 ["unit"; "t"] 1552 in 1553 let v_doc = if v_param_docs = "" then "(** Construct a value *)\n" 1554 else Printf.sprintf "(** Construct a value\n%s*)\n" v_param_docs in 1555 let v_sig = Printf.sprintf "%sval v : %s" v_doc (String.concat " -> " v_params) in 1556 1557 (* Accessor signatures *) 1558 let accessor_sigs = String.concat "\n\n" (List.map (fun (f : field_info) -> 1559 let acc_doc = match f.description with 1560 | Some d -> Printf.sprintf "(** %s *)\n" (escape_doc d) 1561 | None -> "" 1562 in 1563 Printf.sprintf "%sval %s : t -> %s" acc_doc f.ocaml_name (loc_type f.ocaml_type) 1564 ) schema.fields) in 1565 1566 Printf.sprintf "%s\n\n%s\n\n%s\n\nval jsont : t Jsont.t" 1567 type_decl v_sig accessor_sigs 1568 1569(** Format a jsont codec reference, stripping the current_prefix if present. 1570 Returns Jsont.json for forward references to avoid unbound module errors. *) 1571let format_jsont_ref ~current_prefix (schema_ref : string) : string = 1572 let prefix, suffix = Name.split_schema_name schema_ref in 1573 let prefix_mod = Name.to_module_name prefix in 1574 let suffix_mod = Name.to_module_name suffix in 1575 (* Check if this is a forward reference to a module that hasn't been defined yet *) 1576 if prefix_mod <> current_prefix && is_forward_ref prefix_mod then 1577 "Jsont.json" 1578 else if prefix_mod = current_prefix then 1579 Printf.sprintf "%s.jsont" suffix_mod 1580 else 1581 Printf.sprintf "%s.%s.jsont" prefix_mod suffix_mod 1582 1583(** Check if a schema exists - used to validate refs before generating code *) 1584let schema_exists_ref = ref (fun (_ : string) -> true) 1585let set_known_schemas (schemas : schema_info list) = 1586 let known = StringSet.of_list (List.map (fun s -> s.original_name) schemas) in 1587 schema_exists_ref := (fun name -> StringSet.mem name known) 1588 1589let gen_operation_impl ~current_prefix (op : operation_info) : string = 1590 let doc = format_doc_block ~summary:op.summary ?description:op.description () in 1591 let param_docs = String.concat "" 1592 ((List.map (fun (n, _, d, _) -> format_param_doc n d) op.path_params) @ 1593 (List.map (fun (n, _, d, _) -> format_param_doc n d) op.query_params)) in 1594 let full_doc = if param_docs = "" then doc 1595 else if doc = "" then Printf.sprintf "(**\n%s*)\n" param_docs 1596 else String.sub doc 0 (String.length doc - 3) ^ "\n" ^ param_docs ^ "*)\n" in 1597 1598 (* Only use body/response refs if schema actually exists *) 1599 let valid_body_ref = match op.body_schema_ref with 1600 | Some name when !schema_exists_ref name -> Some name 1601 | _ -> None 1602 in 1603 let valid_response_ref = match op.response_schema_ref with 1604 | Some name when !schema_exists_ref name -> Some name 1605 | _ -> None 1606 in 1607 1608 let path_args = List.map (fun (n, _, _, _) -> Printf.sprintf "~%s" n) op.path_params in 1609 let query_args = List.map (fun (n, _, _, req) -> 1610 if req then Printf.sprintf "~%s" n else Printf.sprintf "?%s" n 1611 ) op.query_params in 1612 (* DELETE and HEAD don't support body in the requests library *) 1613 let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in 1614 let body_arg = match valid_body_ref, method_supports_body with 1615 | Some _, true -> ["~body"] 1616 | _ -> [] 1617 in 1618 let all_args = path_args @ query_args @ body_arg @ ["client"; "()"] in 1619 1620 let path_render = 1621 if op.path_params = [] then Printf.sprintf "%S" op.path 1622 else 1623 let bindings = List.map (fun (ocaml, json, _, _) -> 1624 Printf.sprintf "(%S, %s)" json ocaml 1625 ) op.path_params in 1626 Printf.sprintf "Openapi.Runtime.Path.render ~params:[%s] %S" 1627 (String.concat "; " bindings) op.path 1628 in 1629 1630 let query_build = 1631 if op.query_params = [] then "\"\"" 1632 else 1633 let parts = List.map (fun (ocaml, json, _, req) -> 1634 if req then Printf.sprintf "Openapi.Runtime.Query.singleton ~key:%S ~value:%s" json ocaml 1635 else Printf.sprintf "Openapi.Runtime.Query.optional ~key:%S ~value:%s" json ocaml 1636 ) op.query_params in 1637 Printf.sprintf "Openapi.Runtime.Query.encode (List.concat [%s])" (String.concat "; " parts) 1638 in 1639 1640 let method_lower = String.lowercase_ascii op.method_ in 1641 let body_codec = match valid_body_ref with 1642 | Some name -> format_jsont_ref ~current_prefix name 1643 | None -> "Jsont.json" 1644 in 1645 (* DELETE and HEAD don't support body in the requests library *) 1646 let method_supports_body' = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in 1647 let http_call = match valid_body_ref, method_supports_body' with 1648 | Some _, true -> 1649 Printf.sprintf "Requests.%s client.session ~body:(Requests.Body.json (Openapi.Runtime.Json.encode_json %s body)) url" 1650 method_lower body_codec 1651 | Some _, false -> 1652 (* Method doesn't support body - ignore the body parameter *) 1653 Printf.sprintf "Requests.%s client.session url" method_lower 1654 | None, _ -> 1655 Printf.sprintf "Requests.%s client.session url" method_lower 1656 in 1657 1658 let response_codec = match valid_response_ref with 1659 | Some name -> format_jsont_ref ~current_prefix name 1660 | None -> "Jsont.json" 1661 in 1662 1663 let decode = if response_codec = "Jsont.json" then 1664 "Requests.Response.json response" 1665 else 1666 Printf.sprintf "Openapi.Runtime.Json.decode_json_exn %s (Requests.Response.json response)" response_codec 1667 in 1668 1669 (* Generate typed error parsing if we have error schemas *) 1670 let valid_error_responses = List.filter_map (fun (err : error_response) -> 1671 match err.schema_ref with 1672 | Some name when !schema_exists_ref name -> 1673 let codec = format_jsont_ref ~current_prefix name in 1674 Some (err.status_code, codec, name) 1675 | _ -> None 1676 ) op.error_responses in 1677 1678 let error_handling = 1679 if valid_error_responses = [] then 1680 (* No typed errors - simple error with parsed JSON fallback *) 1681 {|let body = Requests.Response.text response in 1682 let parsed_body = 1683 match Jsont_bytesrw.decode_string Jsont.json body with 1684 | Ok json -> Some (Openapi.Runtime.Json json) 1685 | Error _ -> Some (Openapi.Runtime.Raw body) 1686 in 1687 raise (Openapi.Runtime.Api_error { 1688 operation = op_name; 1689 method_ = |} ^ Printf.sprintf "%S" op.method_ ^ {|; 1690 url; 1691 status = Requests.Response.status_code response; 1692 body; 1693 parsed_body; 1694 })|} 1695 else 1696 (* Generate try-parse for each error type *) 1697 let parser_cases = List.map (fun (code, codec, ref_) -> 1698 Printf.sprintf {| | %s -> 1699 (match Openapi.Runtime.Json.decode_json %s (Requests.Response.json response) with 1700 | Ok v -> Some (Openapi.Runtime.Typed (%S, Openapi.Runtime.Json.encode_json %s v)) 1701 | Error _ -> None)|} 1702 code codec ref_ codec 1703 ) valid_error_responses in 1704 1705 Printf.sprintf {|let body = Requests.Response.text response in 1706 let status = Requests.Response.status_code response in 1707 let parsed_body = match status with 1708%s 1709 | _ -> 1710 (match Jsont_bytesrw.decode_string Jsont.json body with 1711 | Ok json -> Some (Openapi.Runtime.Json json) 1712 | Error _ -> Some (Openapi.Runtime.Raw body)) 1713 in 1714 raise (Openapi.Runtime.Api_error { 1715 operation = op_name; 1716 method_ = %S; 1717 url; 1718 status; 1719 body; 1720 parsed_body; 1721 })|} 1722 (String.concat "\n" parser_cases) 1723 op.method_ 1724 in 1725 1726 Printf.sprintf {|%slet %s %s = 1727 let op_name = %S in 1728 let url_path = %s in 1729 let query = %s in 1730 let url = client.base_url ^ url_path ^ query in 1731 let response = 1732 try %s 1733 with Eio.Io _ as ex -> 1734 let bt = Printexc.get_raw_backtrace () in 1735 Eio.Exn.reraise_with_context ex bt "calling %%s %%s" %S url 1736 in 1737 if Requests.Response.ok response then 1738 %s 1739 else 1740 %s|} 1741 full_doc op.func_name (String.concat " " all_args) 1742 op.func_name path_render query_build http_call op.method_ decode error_handling 1743 1744(** Format a type reference, stripping the current_prefix if present *) 1745let format_type_ref ~current_prefix (schema_ref : string) : string = 1746 let prefix, suffix = Name.split_schema_name schema_ref in 1747 let prefix_mod = Name.to_module_name prefix in 1748 let suffix_mod = Name.to_module_name suffix in 1749 if prefix_mod = current_prefix then 1750 (* Local reference - use unqualified name *) 1751 Printf.sprintf "%s.t" suffix_mod 1752 else if is_forward_ref prefix_mod then 1753 (* Forward reference to module not yet defined - use Jsont.json *) 1754 "Jsont.json" 1755 else 1756 Printf.sprintf "%s.%s.t" prefix_mod suffix_mod 1757 1758let gen_operation_intf ~current_prefix (op : operation_info) : string = 1759 let doc = format_doc_block ~summary:op.summary ?description:op.description () in 1760 let param_docs = String.concat "" 1761 ((List.map (fun (n, _, d, _) -> format_param_doc n d) op.path_params) @ 1762 (List.map (fun (n, _, d, _) -> format_param_doc n d) op.query_params)) in 1763 let full_doc = if param_docs = "" then doc 1764 else if doc = "" then Printf.sprintf "(**\n%s*)\n" param_docs 1765 else String.sub doc 0 (String.length doc - 3) ^ "\n" ^ param_docs ^ "*)\n" in 1766 1767 (* Only use body/response refs if schema actually exists *) 1768 let valid_body_ref = match op.body_schema_ref with 1769 | Some name when !schema_exists_ref name -> Some name 1770 | _ -> None 1771 in 1772 let valid_response_ref = match op.response_schema_ref with 1773 | Some name when !schema_exists_ref name -> Some name 1774 | _ -> None 1775 in 1776 1777 let path_args = List.map (fun (n, _, _, _) -> Printf.sprintf "%s:string" n) op.path_params in 1778 let query_args = List.map (fun (n, _, _, req) -> 1779 if req then Printf.sprintf "%s:string" n else Printf.sprintf "?%s:string" n 1780 ) op.query_params in 1781 let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in 1782 let body_arg = match valid_body_ref, method_supports_body with 1783 | Some name, true -> [Printf.sprintf "body:%s" (format_type_ref ~current_prefix name)] 1784 | _ -> [] 1785 in 1786 let response_type = match valid_response_ref with 1787 | Some name -> format_type_ref ~current_prefix name 1788 | None -> "Jsont.json" 1789 in 1790 let all_args = path_args @ query_args @ body_arg @ ["t"; "unit"; response_type] in 1791 1792 Printf.sprintf "%sval %s : %s" full_doc op.func_name (String.concat " -> " all_args) 1793 1794(** {1 Two-Phase Module Generation} 1795 1796 To solve the module ordering problem for union types that reference multiple 1797 schemas, we use a two-phase generation approach within each prefix module: 1798 1799 Phase 1 - Types module: Generate all type definitions first, ordered only by 1800 TYPE dependencies (A.t contains B.t). No codec dependencies matter here. 1801 1802 Phase 2 - Full modules: Generate full modules with [include Types.X] plus 1803 codecs. These are ordered by CODEC dependencies (A.jsont uses B.jsont). 1804 Since all types exist in the Types module, any type can be referenced. 1805 Since codecs are ordered by their own dependencies, any needed codec 1806 exists when referenced. 1807 1808 This allows union types to reference multiple sibling schemas' codecs 1809 without forward reference issues. *) 1810 1811(** {2 Phase 1: Type-Only Generation} *) 1812 1813(** Generate type-only content for an enum schema (for Types module) *) 1814let gen_enum_type_only (schema : schema_info) : string = 1815 let doc = format_doc schema.description in 1816 if schema.enum_variants = [] then 1817 Printf.sprintf "%stype t = string" doc 1818 else 1819 Printf.sprintf "%stype t = [\n%s\n]" doc 1820 (String.concat "\n" (List.map (fun (v, _) -> " | `" ^ v) schema.enum_variants)) 1821 1822(** Generate type-only content for a union schema (for Types module). 1823 Type references use Types.Sibling.t format within the Types module. *) 1824let gen_union_type_only ~current_prefix (schema : schema_info) : string = 1825 match schema.union_info with 1826 | None -> failwith "gen_union_type_only called on non-union schema" 1827 | Some union -> 1828 let doc = format_doc schema.description in 1829 (* In Types module, reference siblings as Sibling.t (same namespace) *) 1830 let format_type_in_types (schema_ref : string) : string = 1831 let prefix, suffix = Name.split_schema_name schema_ref in 1832 let prefix_mod = Name.to_module_name prefix in 1833 let suffix_mod = Name.to_module_name suffix in 1834 if prefix_mod = current_prefix then 1835 Printf.sprintf "%s.t" suffix_mod 1836 else if is_forward_ref prefix_mod then 1837 "Jsont.json" (* Cross-prefix forward ref *) 1838 else 1839 Printf.sprintf "%s.%s.t" prefix_mod suffix_mod 1840 in 1841 Printf.sprintf "%stype t =\n%s" doc 1842 (String.concat "\n" (List.map (fun (v : union_variant) -> 1843 Printf.sprintf " | %s of %s" v.variant_name (format_type_in_types v.schema_ref) 1844 ) union.variants)) 1845 1846(** Generate type-only content for a record schema (for Types module) *) 1847let gen_record_type_only ~current_prefix ~current_suffix (schema : schema_info) : string = 1848 let is_forward_reference type_str = 1849 match String.split_on_char '.' type_str with 1850 | prefix :: _ when prefix <> current_prefix && is_forward_ref prefix -> true 1851 | _ -> false 1852 in 1853 let loc_type s = 1854 let localized = localize_type ~current_prefix ~current_suffix s in 1855 if schema.is_recursive && localized = "t" then "Jsont.json" 1856 else if schema.is_recursive && localized = "t list" then "Jsont.json list" 1857 else if schema.is_recursive && localized = "t option" then "Jsont.json option" 1858 else if schema.is_recursive && localized = "t list option" then "Jsont.json list option" 1859 else if is_forward_reference s then 1860 if String.ends_with ~suffix:" option" localized then "Jsont.json option" 1861 else if String.ends_with ~suffix:" list" localized then "Jsont.json list" 1862 else if String.ends_with ~suffix:" list option" localized then "Jsont.json list option" 1863 else "Jsont.json" 1864 else localized 1865 in 1866 let doc = format_doc schema.description in 1867 if schema.fields = [] then 1868 Printf.sprintf "%stype t = Jsont.json" doc 1869 else 1870 let type_fields = String.concat "\n" (List.map (fun (f : field_info) -> 1871 let field_doc = match f.description with 1872 | Some d -> Printf.sprintf " (** %s *)" (escape_doc d) 1873 | None -> "" 1874 in 1875 Printf.sprintf " %s : %s;%s" f.ocaml_name (loc_type f.ocaml_type) field_doc 1876 ) schema.fields) in 1877 Printf.sprintf "%stype t = {\n%s\n}" doc type_fields 1878 1879(** Generate a type-only submodule for the Types module *) 1880let gen_type_only_submodule ~current_prefix (schema : schema_info) : string = 1881 let suffix_mod = Name.to_module_name schema.suffix in 1882 let content = 1883 if schema.is_union then gen_union_type_only ~current_prefix schema 1884 else if schema.is_enum then gen_enum_type_only schema 1885 else gen_record_type_only ~current_prefix ~current_suffix:suffix_mod schema 1886 in 1887 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 1888 Printf.sprintf " module %s = struct\n%s\n end" suffix_mod indented 1889 1890(** {2 Phase 2: Codec-Only Generation (with include Types.X)} *) 1891 1892(** Generate codec content for an enum schema (includes Types.X) *) 1893let gen_enum_codec_only (schema : schema_info) : string = 1894 let suffix_mod = Name.to_module_name schema.suffix in 1895 if schema.enum_variants = [] then 1896 Printf.sprintf "include Types.%s\nlet jsont = Jsont.string" suffix_mod 1897 else 1898 let dec_cases = String.concat "\n" (List.map (fun (v, raw) -> 1899 Printf.sprintf " | %S -> `%s" raw v 1900 ) schema.enum_variants) in 1901 let enc_cases = String.concat "\n" (List.map (fun (v, raw) -> 1902 Printf.sprintf " | `%s -> %S" v raw 1903 ) schema.enum_variants) in 1904 Printf.sprintf {|include Types.%s 1905 1906let jsont : t Jsont.t = 1907 Jsont.map Jsont.string ~kind:%S 1908 ~dec:(function 1909%s 1910 | s -> Jsont.Error.msgf Jsont.Meta.none "Unknown value: %%s" s) 1911 ~enc:(function 1912%s)|} suffix_mod schema.original_name dec_cases enc_cases 1913 1914(** Generate codec content for a union schema (includes Types.X) *) 1915let gen_union_codec_only ~current_prefix (schema : schema_info) : string = 1916 match schema.union_info with 1917 | None -> failwith "gen_union_codec_only called on non-union schema" 1918 | Some union -> 1919 let suffix_mod = Name.to_module_name schema.suffix in 1920 (* Jsont codec - discriminator-based or try-each *) 1921 let jsont_code = match union.discriminator_field with 1922 | Some field -> gen_union_jsont_discriminator ~current_prefix schema union field 1923 | None -> gen_union_jsont_try_each ~current_prefix schema union 1924 in 1925 Printf.sprintf "include Types.%s\n\n%s" suffix_mod jsont_code 1926 1927(** Generate codec content for a record schema (includes Types.X) *) 1928let gen_record_codec_only ~current_prefix ~current_suffix (schema : schema_info) : string = 1929 let suffix_mod = Name.to_module_name schema.suffix in 1930 (* Note: loc_type is not needed here since types come from Types.X via include *) 1931 let is_forward_jsont_ref jsont_str = 1932 let s = 1933 if String.length jsont_str > 12 && String.sub jsont_str 0 12 = "(Jsont.list " then 1934 String.sub jsont_str 12 (String.length jsont_str - 13) 1935 else if String.length jsont_str > 31 && String.sub jsont_str 0 31 = "(Openapi.Runtime.nullable_any " then 1936 String.sub jsont_str 31 (String.length jsont_str - 32) 1937 else jsont_str 1938 in 1939 match String.split_on_char '.' s with 1940 | prefix :: _ when prefix <> current_prefix && is_forward_ref prefix -> true 1941 | _ -> false 1942 in 1943 let loc_jsont s = 1944 let localized = localize_jsont ~current_prefix ~current_suffix s in 1945 if schema.is_recursive && localized = "jsont" then "Jsont.json" 1946 else if schema.is_recursive && localized = "(Jsont.list jsont)" then "(Jsont.list Jsont.json)" 1947 else if is_forward_jsont_ref s then 1948 if String.length localized > 12 && String.sub localized 0 12 = "(Jsont.list " then "(Jsont.list Jsont.json)" 1949 else if String.length s > 31 && String.sub s 0 31 = "(Openapi.Runtime.nullable_any " then "Jsont.json" 1950 else "Jsont.json" 1951 else localized 1952 in 1953 if schema.fields = [] then 1954 Printf.sprintf "include Types.%s\nlet jsont = Jsont.json\nlet v () = Jsont.Null ((), Jsont.Meta.none)" suffix_mod 1955 else 1956 (* Constructor function v 1957 - Required fields (no default, not optional): ~field 1958 - Fields with defaults: ?(field=default) 1959 - Optional fields (no default, is_optional): ?field *) 1960 let required_fields = List.filter (fun (f : field_info) -> 1961 not f.is_optional && Option.is_none f.default_value 1962 ) schema.fields in 1963 let default_fields = List.filter (fun (f : field_info) -> 1964 Option.is_some f.default_value 1965 ) schema.fields in 1966 let optional_fields = List.filter (fun (f : field_info) -> 1967 f.is_optional && Option.is_none f.default_value 1968 ) schema.fields in 1969 let v_params = 1970 (List.map (fun (f : field_info) -> Printf.sprintf "~%s" f.ocaml_name) required_fields) @ 1971 (List.map (fun (f : field_info) -> 1972 Printf.sprintf "?(%s=%s)" f.ocaml_name (Option.get f.default_value) 1973 ) default_fields) @ 1974 (List.map (fun (f : field_info) -> Printf.sprintf "?%s" f.ocaml_name) optional_fields) @ 1975 ["()"] 1976 in 1977 let v_body = String.concat "; " (List.map (fun (f : field_info) -> f.ocaml_name) schema.fields) in 1978 let v_func = Printf.sprintf "let v %s = { %s }" (String.concat " " v_params) v_body in 1979 1980 (* Accessor functions *) 1981 let accessors = String.concat "\n" (List.map (fun (f : field_info) -> 1982 Printf.sprintf "let %s t = t.%s" f.ocaml_name f.ocaml_name 1983 ) schema.fields) in 1984 1985 (* Jsont codec *) 1986 let make_params = String.concat " " (List.map (fun (f : field_info) -> f.ocaml_name) schema.fields) in 1987 let jsont_members = String.concat "\n" (List.map (fun (f : field_info) -> 1988 let base_codec = 1989 match f.field_union with 1990 | Some union -> jsont_of_field_union ~current_prefix union 1991 | None -> 1992 let raw_codec = jsont_of_base_type f.base_type in 1993 let localized = loc_jsont raw_codec in 1994 if has_constraints f.constraints then validated_jsont f.constraints localized f.base_type 1995 else localized 1996 in 1997 if f.is_nullable then 1998 let nullable_codec = 1999 match f.field_union with 2000 | Some _ -> Printf.sprintf "(Openapi.Runtime.nullable_any %s)" base_codec 2001 | None -> loc_jsont (nullable_jsont_of_base_type f.base_type) 2002 in 2003 (* For nullable fields, dec_absent depends on default: 2004 - No default: None (absent = null) 2005 - Default is "None" (JSON null): None 2006 - Default is a value: (Some value) *) 2007 let dec_absent = match f.default_value with 2008 | Some "None" -> "None" (* Default is null *) 2009 | Some def -> Printf.sprintf "(Some %s)" def 2010 | None -> "None" 2011 in 2012 Printf.sprintf " |> Jsont.Object.mem %S %s\n ~dec_absent:%s ~enc_omit:Option.is_none ~enc:(fun r -> r.%s)" 2013 f.json_name nullable_codec dec_absent f.ocaml_name 2014 else if f.is_optional then 2015 (* Optional non-nullable field without default - use opt_mem *) 2016 Printf.sprintf " |> Jsont.Object.opt_mem %S %s ~enc:(fun r -> r.%s)" 2017 f.json_name base_codec f.ocaml_name 2018 else 2019 (* Required or has default - use mem, possibly with dec_absent *) 2020 (match f.default_value with 2021 | Some def -> 2022 Printf.sprintf " |> Jsont.Object.mem %S %s ~dec_absent:%s ~enc:(fun r -> r.%s)" 2023 f.json_name base_codec def f.ocaml_name 2024 | None -> 2025 Printf.sprintf " |> Jsont.Object.mem %S %s ~enc:(fun r -> r.%s)" 2026 f.json_name base_codec f.ocaml_name) 2027 ) schema.fields) in 2028 2029 Printf.sprintf {|include Types.%s 2030 2031%s 2032 2033%s 2034 2035let jsont : t Jsont.t = 2036 Jsont.Object.map ~kind:%S 2037 (fun %s -> { %s }) 2038%s 2039 |> Jsont.Object.skip_unknown 2040 |> Jsont.Object.finish|} 2041 suffix_mod v_func accessors schema.original_name make_params v_body jsont_members 2042 2043(** Generate a codec-only submodule (uses include Types.X) *) 2044let gen_codec_only_submodule ~current_prefix (schema : schema_info) : string = 2045 let suffix_mod = Name.to_module_name schema.suffix in 2046 let content = 2047 if schema.is_union then gen_union_codec_only ~current_prefix schema 2048 else if schema.is_enum then gen_enum_codec_only schema 2049 else gen_record_codec_only ~current_prefix ~current_suffix:suffix_mod schema 2050 in 2051 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2052 Printf.sprintf "module %s = struct\n%s\nend" suffix_mod indented 2053 2054(** {2 Codec Dependency Extraction} 2055 2056 For the two-phase approach, we need to order codecs by their codec dependencies 2057 (which codecs reference other codecs), separate from type dependencies. *) 2058 2059(** Extract codec dependencies for a schema - which sibling codecs does this schema's codec reference? *) 2060let schema_codec_deps ~current_prefix (schema : schema_info) : string list = 2061 (* For union types, the codec references all variant codecs *) 2062 let union_deps = match schema.union_info with 2063 | None -> [] 2064 | Some union -> 2065 List.filter_map (fun (v : union_variant) -> 2066 let prefix, suffix = Name.split_schema_name v.schema_ref in 2067 let prefix_mod = Name.to_module_name prefix in 2068 if prefix_mod = current_prefix then 2069 Some (Name.to_module_name suffix) 2070 else None 2071 ) union.variants 2072 in 2073 (* For records, codecs reference field type codecs *) 2074 let field_deps = List.filter_map (fun (f : field_info) -> 2075 if String.contains f.base_type '.' then 2076 match String.split_on_char '.' f.base_type with 2077 | prefix :: suffix :: _ when prefix = current_prefix -> 2078 Some (Name.to_module_name suffix) 2079 | _ -> None 2080 else None 2081 ) schema.fields in 2082 union_deps @ field_deps |> List.sort_uniq String.compare 2083 2084(** {1 Full Module Generation} *) 2085 2086let gen_submodule_impl ~current_prefix (schema : schema_info) : string = 2087 let suffix_mod = Name.to_module_name schema.suffix in 2088 let content = 2089 if schema.is_union then gen_union_impl ~current_prefix schema 2090 else if schema.is_enum then gen_enum_impl schema 2091 else gen_record_impl ~current_prefix ~current_suffix:suffix_mod schema in 2092 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2093 Printf.sprintf "module %s = struct\n%s\nend" suffix_mod indented 2094 2095let gen_submodule_intf ~current_prefix (schema : schema_info) : string = 2096 let suffix_mod = Name.to_module_name schema.suffix in 2097 let content = 2098 if schema.is_union then gen_union_intf ~current_prefix schema 2099 else if schema.is_enum then gen_enum_intf schema 2100 else gen_record_intf ~current_prefix ~current_suffix:suffix_mod schema in 2101 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2102 Printf.sprintf "module %s : sig\n%s\nend" suffix_mod indented 2103 2104(** Extract suffix module dependencies within the same prefix *) 2105let schema_suffix_deps ~current_prefix (schema : schema_info) : string list = 2106 List.filter_map (fun (f : field_info) -> 2107 (* Check if the type references a sibling module (same prefix) *) 2108 if String.contains f.base_type '.' then 2109 match String.split_on_char '.' f.base_type with 2110 | prefix :: suffix :: _ when prefix = current_prefix -> 2111 Some (Name.to_module_name suffix) 2112 | _ -> None 2113 else None 2114 ) schema.fields 2115 2116(** Sort schemas within a prefix module by their TYPE dependencies. 2117 Used for ordering types in the Types module. *) 2118let sort_schemas_by_type_deps ~current_prefix (schemas : schema_info list) : schema_info list = 2119 let suffix_of schema = Name.to_module_name schema.suffix in 2120 let suffix_names = List.map suffix_of schemas in 2121 let deps_of suffix = 2122 match List.find_opt (fun s -> suffix_of s = suffix) schemas with 2123 | Some schema -> schema_suffix_deps ~current_prefix schema |> List.filter (fun d -> List.mem d suffix_names) 2124 | None -> [] 2125 in 2126 let sorted = topological_sort suffix_names deps_of in 2127 List.filter_map (fun suffix -> 2128 List.find_opt (fun s -> suffix_of s = suffix) schemas 2129 ) sorted 2130 2131(** Sort schemas within a prefix module by their CODEC dependencies. 2132 Used for ordering full modules with codecs. *) 2133let sort_schemas_by_codec_deps ~current_prefix (schemas : schema_info list) : schema_info list = 2134 let suffix_of schema = Name.to_module_name schema.suffix in 2135 let suffix_names = List.map suffix_of schemas in 2136 let deps_of suffix = 2137 match List.find_opt (fun s -> suffix_of s = suffix) schemas with 2138 | Some schema -> schema_codec_deps ~current_prefix schema |> List.filter (fun d -> List.mem d suffix_names) 2139 | None -> [] 2140 in 2141 let sorted = topological_sort suffix_names deps_of in 2142 List.filter_map (fun suffix -> 2143 List.find_opt (fun s -> suffix_of s = suffix) schemas 2144 ) sorted 2145 2146(** Generate a prefix module using two-phase generation: 2147 Phase 1: Types module with all type definitions 2148 Phase 2: Full modules with include Types.X + codecs *) 2149let gen_prefix_module_impl (node : module_node) : string = 2150 if node.schemas = [] then 2151 (* No schemas - just generate operations *) 2152 let op_impls = List.map (gen_operation_impl ~current_prefix:node.name) (List.rev node.operations) in 2153 if op_impls = [] then 2154 Printf.sprintf "module %s = struct\nend" node.name 2155 else 2156 let content = String.concat "\n\n" op_impls in 2157 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2158 Printf.sprintf "module %s = struct\n%s\nend" node.name indented 2159 else 2160 (* Phase 1: Generate Types module with all type definitions *) 2161 let type_sorted_schemas = sort_schemas_by_type_deps ~current_prefix:node.name node.schemas in 2162 let type_mods = List.map (gen_type_only_submodule ~current_prefix:node.name) type_sorted_schemas in 2163 let types_content = String.concat "\n\n" type_mods in 2164 let types_module = Printf.sprintf "module Types = struct\n%s\nend" types_content in 2165 2166 (* Phase 2: Generate full modules with codecs, sorted by codec dependencies *) 2167 let codec_sorted_schemas = sort_schemas_by_codec_deps ~current_prefix:node.name node.schemas in 2168 let codec_mods = List.map (gen_codec_only_submodule ~current_prefix:node.name) codec_sorted_schemas in 2169 2170 (* Operations *) 2171 let op_impls = List.map (gen_operation_impl ~current_prefix:node.name) (List.rev node.operations) in 2172 2173 let content = String.concat "\n\n" ([types_module] @ codec_mods @ op_impls) in 2174 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2175 Printf.sprintf "module %s = struct\n%s\nend" node.name indented 2176 2177let gen_prefix_module_intf (node : module_node) : string = 2178 (* For interfaces, we don't need the two-phase approach. 2179 Just sort by type dependencies and generate full interfaces. *) 2180 let sorted_schemas = sort_schemas_by_type_deps ~current_prefix:node.name node.schemas in 2181 let schema_mods = List.map (gen_submodule_intf ~current_prefix:node.name) sorted_schemas in 2182 let op_intfs = List.map (gen_operation_intf ~current_prefix:node.name) (List.rev node.operations) in 2183 let content = String.concat "\n\n" (schema_mods @ op_intfs) in 2184 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2185 Printf.sprintf "module %s : sig\n%s\nend" node.name indented 2186 2187(** {1 Top-Level Generation} *) 2188 2189type config = { 2190 output_dir : string; 2191 package_name : string; 2192 spec_path : string option; 2193} 2194 2195let generate_ml (spec : Spec.t) (package_name : string) : string = 2196 let api_desc = Option.value ~default:"Generated API client." spec.info.description in 2197 2198 (* Collect schemas *) 2199 let schemas = match spec.components with 2200 | None -> [] 2201 | Some c -> List.filter_map (fun (name, sor) -> 2202 match sor with 2203 | Spec.Ref _ -> None 2204 | Spec.Value s -> Some (analyze_schema ~components:spec.components name s) 2205 ) c.schemas 2206 in 2207 2208 (* Set known schemas for validation during code generation *) 2209 set_known_schemas schemas; 2210 2211 (* Collect operations *) 2212 let operations = List.concat_map (fun (path, (pi : Spec.path_item)) -> 2213 let path_item_params = pi.parameters in 2214 let ops = [ 2215 ("GET", pi.Spec.get); ("POST", pi.post); ("PUT", pi.put); 2216 ("DELETE", pi.delete); ("PATCH", pi.patch); 2217 ("HEAD", pi.head); ("OPTIONS", pi.options); 2218 ] in 2219 List.filter_map (fun (method_, op_opt) -> 2220 Option.map (fun op -> analyze_operation ~spec ~path_item_params ~path ~method_ op) op_opt 2221 ) ops 2222 ) spec.paths in 2223 2224 (* Build module tree *) 2225 let (tree, sorted_modules) = build_module_tree schemas operations in 2226 2227 (* Generate top-level client type and functions *) 2228 let client_impl = {|type t = { 2229 session : Requests.t; 2230 base_url : string; 2231} 2232 2233let create ?session ~sw env ~base_url = 2234 let session = match session with 2235 | Some s -> s 2236 | None -> Requests.create ~sw env 2237 in 2238 { session; base_url } 2239 2240let base_url t = t.base_url 2241let session t = t.session|} in 2242 2243 (* Generate prefix modules in dependency order, tracking forward references *) 2244 let rec gen_with_forward_refs remaining_modules acc = 2245 match remaining_modules with 2246 | [] -> List.rev acc 2247 | name :: rest -> 2248 (* Set forward refs to modules that come after this one *) 2249 set_forward_refs rest; 2250 let result = match StringMap.find_opt name tree.children with 2251 | None -> None 2252 | Some node -> 2253 if node.name = "Client" then 2254 (* Generate Client operations inline *) 2255 let ops = List.map (gen_operation_impl ~current_prefix:"Client") (List.rev node.operations) in 2256 if ops = [] then None 2257 else 2258 let content = String.concat "\n\n" ops in 2259 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2260 Some (Printf.sprintf "module Client = struct\n%s\nend" indented) 2261 else 2262 Some (gen_prefix_module_impl node) 2263 in 2264 gen_with_forward_refs rest (match result with Some r -> r :: acc | None -> acc) 2265 in 2266 let prefix_mods = gen_with_forward_refs sorted_modules [] in 2267 2268 Printf.sprintf {|(** {1 %s} 2269 2270 %s 2271 2272 @version %s *) 2273 2274%s 2275 2276%s 2277|} 2278 (Name.to_module_name package_name) (escape_doc api_desc) spec.info.version 2279 client_impl (String.concat "\n\n" prefix_mods) 2280 2281let generate_mli (spec : Spec.t) (package_name : string) : string = 2282 let api_desc = Option.value ~default:"Generated API client." spec.info.description in 2283 2284 (* Collect schemas *) 2285 let schemas = match spec.components with 2286 | None -> [] 2287 | Some c -> List.filter_map (fun (name, sor) -> 2288 match sor with 2289 | Spec.Ref _ -> None 2290 | Spec.Value s -> Some (analyze_schema ~components:spec.components name s) 2291 ) c.schemas 2292 in 2293 2294 (* Set known schemas for validation during code generation *) 2295 set_known_schemas schemas; 2296 2297 (* Collect operations *) 2298 let operations = List.concat_map (fun (path, (pi : Spec.path_item)) -> 2299 let path_item_params = pi.parameters in 2300 let ops = [ 2301 ("GET", pi.Spec.get); ("POST", pi.post); ("PUT", pi.put); 2302 ("DELETE", pi.delete); ("PATCH", pi.patch); 2303 ("HEAD", pi.head); ("OPTIONS", pi.options); 2304 ] in 2305 List.filter_map (fun (method_, op_opt) -> 2306 Option.map (fun op -> analyze_operation ~spec ~path_item_params ~path ~method_ op) op_opt 2307 ) ops 2308 ) spec.paths in 2309 2310 (* Build module tree *) 2311 let (tree, sorted_modules) = build_module_tree schemas operations in 2312 2313 (* Generate top-level client type and function interfaces *) 2314 let client_intf = {|type t 2315 2316val create : 2317 ?session:Requests.t -> 2318 sw:Eio.Switch.t -> 2319 < net : _ Eio.Net.t ; fs : Eio.Fs.dir_ty Eio.Path.t ; clock : _ Eio.Time.clock ; .. > -> 2320 base_url:string -> 2321 t 2322 2323val base_url : t -> string 2324val session : t -> Requests.t|} in 2325 2326 (* Generate prefix modules in dependency order, tracking forward references *) 2327 let rec gen_with_forward_refs remaining_modules acc = 2328 match remaining_modules with 2329 | [] -> List.rev acc 2330 | name :: rest -> 2331 (* Set forward refs to modules that come after this one *) 2332 set_forward_refs rest; 2333 let result = match StringMap.find_opt name tree.children with 2334 | None -> None 2335 | Some node -> 2336 if node.name = "Client" then 2337 let ops = List.map (gen_operation_intf ~current_prefix:"Client") (List.rev node.operations) in 2338 if ops = [] then None 2339 else 2340 let content = String.concat "\n\n" ops in 2341 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in 2342 Some (Printf.sprintf "module Client : sig\n%s\nend" indented) 2343 else 2344 Some (gen_prefix_module_intf node) 2345 in 2346 gen_with_forward_refs rest (match result with Some r -> r :: acc | None -> acc) 2347 in 2348 let prefix_mods = gen_with_forward_refs sorted_modules [] in 2349 2350 Printf.sprintf {|(** {1 %s} 2351 2352 %s 2353 2354 @version %s *) 2355 2356%s 2357 2358%s 2359|} 2360 (Name.to_module_name package_name) (escape_doc api_desc) spec.info.version 2361 client_intf (String.concat "\n\n" prefix_mods) 2362 2363let generate_dune (package_name : string) : string = 2364 Printf.sprintf {|(library 2365 (name %s) 2366 (public_name %s) 2367 (libraries openapi jsont jsont.bytesrw requests ptime eio) 2368 (wrapped true)) 2369 2370(include dune.inc) 2371|} package_name package_name 2372 2373let generate_dune_inc ~(spec_path : string option) (package_name : string) : string = 2374 match spec_path with 2375 | None -> "; No spec path provided - regeneration rules not generated\n" 2376 | Some path -> 2377 Printf.sprintf {|; Generated rules for OpenAPI code regeneration 2378; Run: dune build @gen --auto-promote 2379 2380(rule 2381 (alias gen) 2382 (mode (promote (until-clean))) 2383 (targets %s.ml %s.mli) 2384 (deps %s) 2385 (action 2386 (run openapi-gen generate -o . -n %s %%{deps}))) 2387|} package_name package_name path package_name 2388 2389let generate ~(config : config) (spec : Spec.t) : (string * string) list = 2390 let package_name = config.package_name in 2391 [ 2392 ("dune", generate_dune package_name); 2393 ("dune.inc", generate_dune_inc ~spec_path:config.spec_path package_name); 2394 (package_name ^ ".ml", generate_ml spec package_name); 2395 (package_name ^ ".mli", generate_mli spec package_name); 2396 ] 2397 2398let write_files ~(output_dir : string) (files : (string * string) list) : unit = 2399 List.iter (fun (filename, content) -> 2400 let path = Filename.concat output_dir filename in 2401 let oc = open_out path in 2402 output_string oc content; 2403 close_out oc 2404 ) files