···1212 close_in ic;
1313 s
14141515-(** Parse spec file and run action, handling errors uniformly *)
1515+(** Check if a file appears to be YAML based on extension or content *)
1616+let is_yaml_file path content =
1717+ let ext = Filename.extension path |> String.lowercase_ascii in
1818+ ext = ".yaml" || ext = ".yml" ||
1919+ (* Also detect YAML by content if no clear extension *)
2020+ (ext <> ".json" && String.length content > 0 &&
2121+ (content.[0] = '#' || String.sub content 0 (min 7 (String.length content)) = "openapi"))
2222+2323+(** Parse spec file and run action, handling errors uniformly.
2424+ Automatically handles both JSON and YAML formats using jsont codecs. *)
1625let with_spec spec_path f =
1726 let spec_content = read_file spec_path in
1818- match Openapi.Spec.of_string spec_content with
2727+ let result =
2828+ if is_yaml_file spec_path spec_content then begin
2929+ Logs.info (fun m -> m "Detected YAML format");
3030+ Yamlt.decode_string Openapi.Spec.jsont spec_content
3131+ end else
3232+ Openapi.Spec.of_string spec_content
3333+ in
3434+ match result with
1935 | Error e ->
2036 Logs.err (fun m -> m "Failed to parse OpenAPI spec: %s" e);
2137 1
···8197open Cmdliner
82988399let spec_path =
8484- let doc = "Path to the OpenAPI specification file (JSON)." in
100100+ let doc = "Path to the OpenAPI specification file (JSON or YAML)." in
85101 Arg.(required & pos 0 (some file) None & info [] ~docv:"SPEC" ~doc)
8610287103let output_dir =
+142-41
lib/openapi_codegen.ml
···175175(** {1 Topological Sort} *)
176176177177(** Kahn's algorithm for topological sorting.
178178- Returns nodes in dependency order (dependencies first). *)
178178+ Returns nodes in dependency order (dependencies first).
179179+ Self-dependencies are ignored (they don't affect ordering). *)
179180let topological_sort (nodes : string list) (deps : string -> string list) : string list =
180181 (* Build adjacency list and in-degree map *)
181182 let nodes_set = StringSet.of_list nodes in
···185186 let adj = List.fold_left (fun m node ->
186187 StringMap.add node [] m
187188 ) StringMap.empty nodes in
188188- (* Add edges: if A depends on B, add edge B -> A *)
189189+ (* Add edges: if A depends on B, add edge B -> A
190190+ Ignore self-dependencies (node depending on itself) *)
189191 let (in_degree, adj) = List.fold_left (fun (in_degree, adj) node ->
190190- let node_deps = deps node |> List.filter (fun d -> StringSet.mem d nodes_set) in
192192+ let node_deps = deps node
193193+ |> List.filter (fun d -> StringSet.mem d nodes_set && d <> node) in
191194 let in_degree = StringMap.add node (List.length node_deps) in_degree in
192195 let adj = List.fold_left (fun adj dep ->
193196 let existing = Option.value ~default:[] (StringMap.find_opt dep adj) in
···233236 is_enum : bool;
234237 enum_variants : (string * string) list; (* ocaml_name, json_value *)
235238 description : string option;
239239+ is_recursive : bool; (* true if schema references itself *)
236240}
237241238242type operation_info = {
···336340 let description = get_string_member "description" field_json in
337341 { ocaml_name; json_name = field_name; ocaml_type; base_type; is_optional; is_required; description }
338342 ) schema.properties in
343343+ (* Check if schema references itself *)
344344+ let deps = find_schema_dependencies schema in
345345+ let is_recursive = List.mem name deps in
339346 { original_name = name; prefix; suffix; schema; fields; is_enum; enum_variants;
340340- description = schema.description }
347347+ description = schema.description; is_recursive }
341348342349(** {1 Operation Processing} *)
343350···440447let build_module_tree (schemas : schema_info list) (operations : operation_info list) : module_node * string list =
441448 let root = empty_node "Root" in
442449450450+ (* Build set of known schema names for validation *)
451451+ let known_schemas = StringSet.of_list (List.map (fun s -> s.original_name) schemas) in
452452+443453 (* Add schemas to tree and track dependencies *)
444454 let root = List.fold_left (fun root schema ->
445455 let prefix_mod = Name.to_module_name schema.prefix in
···457467 { root with children = StringMap.add prefix_mod child root.children }
458468 ) root schemas in
459469460460- (* Add operations to tree based on response type, and track operation dependencies *)
470470+ (* Add operations to tree based on response type, and track operation dependencies.
471471+ Only use response_schema_ref if the schema actually exists in components/schemas. *)
461472 let root = List.fold_left (fun root op ->
462462- match op.response_schema_ref with
473473+ (* Check if response schema actually exists *)
474474+ let valid_response_ref = match op.response_schema_ref with
475475+ | Some name when StringSet.mem name known_schemas -> Some name
476476+ | _ -> None
477477+ in
478478+ match valid_response_ref with
463479 | Some ref_name ->
464480 let prefix, _ = Name.split_schema_name ref_name in
465481 let prefix_mod = Name.to_module_name prefix in
···476492 } in
477493 { root with children = StringMap.add prefix_mod child root.children }
478494 | None ->
479479- (* Put in Client module for operations without typed response *)
495495+ (* Put in Client module for operations without valid typed response *)
480496 let child = match StringMap.find_opt "Client" root.children with
481497 | Some c -> c
482498 | None -> empty_node "Client"
···537553 in
538554 Printf.sprintf "%s\n\nval jsont : t Jsont.t" type_def
539555540540-(** Localize an OCaml type string by stripping the current_prefix module *)
541541-let localize_type ~current_prefix (type_str : string) : string =
542542- (* Handle patterns like "User.ResponseDto.t" -> "ResponseDto.t" if current_prefix = "User" *)
556556+(** Localize an OCaml type string by stripping the current_prefix and current_suffix modules.
557557+ When generating code inside a submodule, self-references need to be unqualified. *)
558558+let localize_type ~current_prefix ~current_suffix (type_str : string) : string =
559559+ (* Handle patterns like "User.ResponseDto.t" -> "ResponseDto.t" if current_prefix = "User"
560560+ And further "ResponseDto.t" -> "t" if current_suffix = "ResponseDto" *)
543561 let prefix_dot = current_prefix ^ "." in
562562+ let suffix_dot = current_suffix ^ "." in
563563+ let full_path = current_prefix ^ "." ^ current_suffix ^ "." in
544564 let strip_prefix s =
545545- if String.length s >= String.length prefix_dot &&
565565+ (* First try to strip full path (Prefix.Suffix.) *)
566566+ if String.length s >= String.length full_path &&
567567+ String.sub s 0 (String.length full_path) = full_path then
568568+ String.sub s (String.length full_path) (String.length s - String.length full_path)
569569+ (* Then try just prefix *)
570570+ else if String.length s >= String.length prefix_dot &&
546571 String.sub s 0 (String.length prefix_dot) = prefix_dot then
547547- String.sub s (String.length prefix_dot) (String.length s - String.length prefix_dot)
572572+ let rest = String.sub s (String.length prefix_dot) (String.length s - String.length prefix_dot) in
573573+ (* If the rest starts with our suffix, strip that too *)
574574+ if String.length rest >= String.length suffix_dot &&
575575+ String.sub rest 0 (String.length suffix_dot) = suffix_dot then
576576+ String.sub rest (String.length suffix_dot) (String.length rest - String.length suffix_dot)
577577+ else rest
548578 else s
549579 in
550580 (* Handle "X list", "X option", and nested combinations *)
···560590 in
561591 localize type_str
562592563563-(** Localize a jsont codec string by stripping the current_prefix module *)
564564-let rec localize_jsont ~current_prefix (jsont_str : string) : string =
593593+(** Localize a jsont codec string by stripping the current_prefix and current_suffix modules *)
594594+let rec localize_jsont ~current_prefix ~current_suffix (jsont_str : string) : string =
565595 let prefix_dot = current_prefix ^ "." in
566566- (* Handle patterns like "User.ResponseDto.jsont" -> "ResponseDto.jsont" *)
567567- (* Also handle "(Jsont.list User.ResponseDto.jsont)" *)
568568- if String.length jsont_str >= String.length prefix_dot then
569569- if String.sub jsont_str 0 (String.length prefix_dot) = prefix_dot then
570570- String.sub jsont_str (String.length prefix_dot) (String.length jsont_str - String.length prefix_dot)
571571- else if String.length jsont_str > 12 && String.sub jsont_str 0 12 = "(Jsont.list " then
572572- let inner = String.sub jsont_str 12 (String.length jsont_str - 13) in
573573- "(Jsont.list " ^ localize_jsont ~current_prefix inner ^ ")"
574574- else
575575- jsont_str
596596+ let suffix_dot = current_suffix ^ "." in
597597+ let full_path = current_prefix ^ "." ^ current_suffix ^ "." in
598598+ let strip_prefix s =
599599+ (* First try to strip full path (Prefix.Suffix.) *)
600600+ if String.length s >= String.length full_path &&
601601+ String.sub s 0 (String.length full_path) = full_path then
602602+ String.sub s (String.length full_path) (String.length s - String.length full_path)
603603+ (* Then try just prefix *)
604604+ else if String.length s >= String.length prefix_dot &&
605605+ String.sub s 0 (String.length prefix_dot) = prefix_dot then
606606+ let rest = String.sub s (String.length prefix_dot) (String.length s - String.length prefix_dot) in
607607+ (* If the rest starts with our suffix, strip that too *)
608608+ if String.length rest >= String.length suffix_dot &&
609609+ String.sub rest 0 (String.length suffix_dot) = suffix_dot then
610610+ String.sub rest (String.length suffix_dot) (String.length rest - String.length suffix_dot)
611611+ else rest
612612+ else s
613613+ in
614614+ (* Handle patterns like "User.ResponseDto.jsont" -> "ResponseDto.jsont" -> "jsont"
615615+ Also handle "(Jsont.list User.ResponseDto.jsont)" *)
616616+ if String.length jsont_str > 12 && String.sub jsont_str 0 12 = "(Jsont.list " then
617617+ let inner = String.sub jsont_str 12 (String.length jsont_str - 13) in
618618+ "(Jsont.list " ^ localize_jsont ~current_prefix ~current_suffix inner ^ ")"
576619 else
577577- jsont_str
620620+ strip_prefix jsont_str
578621579579-let gen_record_impl ~current_prefix (schema : schema_info) : string =
580580- let loc_type = localize_type ~current_prefix in
581581- let loc_jsont = localize_jsont ~current_prefix in
622622+let gen_record_impl ~current_prefix ~current_suffix (schema : schema_info) : string =
623623+ (* For recursive schemas, self-referential fields need to use Jsont.json
624624+ to avoid OCaml's let rec restrictions on non-functional values *)
625625+ let loc_type s =
626626+ let localized = localize_type ~current_prefix ~current_suffix s in
627627+ if schema.is_recursive && localized = "t" then "Jsont.json"
628628+ else if schema.is_recursive && localized = "t list" then "Jsont.json list"
629629+ else if schema.is_recursive && localized = "t option" then "Jsont.json option"
630630+ else if schema.is_recursive && localized = "t list option" then "Jsont.json list option"
631631+ else localized
632632+ in
633633+ let loc_jsont s =
634634+ let localized = localize_jsont ~current_prefix ~current_suffix s in
635635+ if schema.is_recursive && localized = "jsont" then "Jsont.json"
636636+ else if schema.is_recursive && localized = "(Jsont.list jsont)" then
637637+ "(Jsont.list Jsont.json)"
638638+ else localized
639639+ in
582640 let doc = format_doc schema.description in
583641 if schema.fields = [] then
584642 Printf.sprintf "%stype t = Jsont.json\n\nlet jsont = Jsont.json\n\nlet v () = Jsont.Null ((), Jsont.Meta.none)" doc
···640698 |> Jsont.Object.finish|}
641699 type_def v_func accessors schema.original_name make_params v_body jsont_members
642700643643-let gen_record_intf ~current_prefix (schema : schema_info) : string =
644644- let loc_type = localize_type ~current_prefix in
701701+let gen_record_intf ~current_prefix ~current_suffix (schema : schema_info) : string =
702702+ (* For recursive schemas, self-referential fields need to use Jsont.json
703703+ to avoid OCaml's let rec restrictions on non-functional values *)
704704+ let loc_type s =
705705+ let localized = localize_type ~current_prefix ~current_suffix s in
706706+ if schema.is_recursive && localized = "t" then "Jsont.json"
707707+ else if schema.is_recursive && localized = "t list" then "Jsont.json list"
708708+ else if schema.is_recursive && localized = "t option" then "Jsont.json option"
709709+ else if schema.is_recursive && localized = "t list option" then "Jsont.json list option"
710710+ else localized
711711+ in
645712 let doc = format_doc schema.description in
646713 if schema.fields = [] then
647714 Printf.sprintf "%stype t\n\nval jsont : t Jsont.t\n\nval v : unit -> t" doc
···687754 else
688755 Printf.sprintf "%s.%s.jsont" prefix_mod suffix_mod
689756757757+(** Check if a schema exists - used to validate refs before generating code *)
758758+let schema_exists_ref = ref (fun (_ : string) -> true)
759759+let set_known_schemas (schemas : schema_info list) =
760760+ let known = StringSet.of_list (List.map (fun s -> s.original_name) schemas) in
761761+ schema_exists_ref := (fun name -> StringSet.mem name known)
762762+690763let gen_operation_impl ~current_prefix (op : operation_info) : string =
691764 let doc = format_doc_block ~summary:op.summary ?description:op.description () in
692765 let param_docs = String.concat ""
···694767 (List.map (fun (n, _, d, _) -> format_param_doc n d) op.query_params)) in
695768 let full_doc = if param_docs = "" then doc
696769 else if doc = "" then Printf.sprintf "(**\n%s*)\n" param_docs
697697- else String.sub doc 0 (String.length doc - 3) ^ param_docs ^ "*)\n" in
770770+ else String.sub doc 0 (String.length doc - 3) ^ "\n" ^ param_docs ^ "*)\n" in
771771+772772+ (* Only use body/response refs if schema actually exists *)
773773+ let valid_body_ref = match op.body_schema_ref with
774774+ | Some name when !schema_exists_ref name -> Some name
775775+ | _ -> None
776776+ in
777777+ let valid_response_ref = match op.response_schema_ref with
778778+ | Some name when !schema_exists_ref name -> Some name
779779+ | _ -> None
780780+ in
698781699782 let path_args = List.map (fun (n, _, _, _) -> Printf.sprintf "~%s" n) op.path_params in
700783 let query_args = List.map (fun (n, _, _, req) ->
···702785 ) op.query_params in
703786 (* DELETE and HEAD don't support body in the requests library *)
704787 let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in
705705- let body_arg = match op.body_schema_ref, method_supports_body with
788788+ let body_arg = match valid_body_ref, method_supports_body with
706789 | Some _, true -> ["~body"]
707790 | _ -> []
708791 in
···729812 in
730813731814 let method_lower = String.lowercase_ascii op.method_ in
732732- let body_codec = match op.body_schema_ref with
815815+ let body_codec = match valid_body_ref with
733816 | Some name -> format_jsont_ref ~current_prefix name
734817 | None -> "Jsont.json"
735818 in
736819 (* DELETE and HEAD don't support body in the requests library *)
737737- let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in
738738- let http_call = match op.body_schema_ref, method_supports_body with
820820+ let method_supports_body' = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in
821821+ let http_call = match valid_body_ref, method_supports_body' with
739822 | Some _, true ->
740823 Printf.sprintf "Requests.%s client.session ~body:(Requests.Body.json (Openapi.Runtime.Json.encode_json %s body)) url"
741824 method_lower body_codec
···746829 Printf.sprintf "Requests.%s client.session url" method_lower
747830 in
748831749749- let response_codec = match op.response_schema_ref with
832832+ let response_codec = match valid_response_ref with
750833 | Some name -> format_jsont_ref ~current_prefix name
751834 | None -> "Jsont.json"
752835 in
···799882 (List.map (fun (n, _, d, _) -> format_param_doc n d) op.query_params)) in
800883 let full_doc = if param_docs = "" then doc
801884 else if doc = "" then Printf.sprintf "(**\n%s*)\n" param_docs
802802- else String.sub doc 0 (String.length doc - 3) ^ param_docs ^ "*)\n" in
885885+ else String.sub doc 0 (String.length doc - 3) ^ "\n" ^ param_docs ^ "*)\n" in
886886+887887+ (* Only use body/response refs if schema actually exists *)
888888+ let valid_body_ref = match op.body_schema_ref with
889889+ | Some name when !schema_exists_ref name -> Some name
890890+ | _ -> None
891891+ in
892892+ let valid_response_ref = match op.response_schema_ref with
893893+ | Some name when !schema_exists_ref name -> Some name
894894+ | _ -> None
895895+ in
803896804897 let path_args = List.map (fun (n, _, _, _) -> Printf.sprintf "%s:string" n) op.path_params in
805898 let query_args = List.map (fun (n, _, _, req) ->
806899 if req then Printf.sprintf "%s:string" n else Printf.sprintf "?%s:string" n
807900 ) op.query_params in
808901 let method_supports_body = not (List.mem op.method_ ["DELETE"; "HEAD"; "OPTIONS"]) in
809809- let body_arg = match op.body_schema_ref, method_supports_body with
902902+ let body_arg = match valid_body_ref, method_supports_body with
810903 | Some name, true -> [Printf.sprintf "body:%s" (format_type_ref ~current_prefix name)]
811904 | _ -> []
812905 in
813813- let response_type = match op.response_schema_ref with
906906+ let response_type = match valid_response_ref with
814907 | Some name -> format_type_ref ~current_prefix name
815908 | None -> "Jsont.json"
816909 in
···822915823916let gen_submodule_impl ~current_prefix (schema : schema_info) : string =
824917 let suffix_mod = Name.to_module_name schema.suffix in
825825- let content = if schema.is_enum then gen_enum_impl schema else gen_record_impl ~current_prefix schema in
918918+ let content = if schema.is_enum then gen_enum_impl schema
919919+ else gen_record_impl ~current_prefix ~current_suffix:suffix_mod schema in
826920 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
827921 Printf.sprintf "module %s = struct\n%s\nend" suffix_mod indented
828922829923let gen_submodule_intf ~current_prefix (schema : schema_info) : string =
830924 let suffix_mod = Name.to_module_name schema.suffix in
831831- let content = if schema.is_enum then gen_enum_intf schema else gen_record_intf ~current_prefix schema in
925925+ let content = if schema.is_enum then gen_enum_intf schema
926926+ else gen_record_intf ~current_prefix ~current_suffix:suffix_mod schema in
832927 let indented = String.split_on_char '\n' content |> List.map (fun l -> " " ^ l) |> String.concat "\n" in
833928 Printf.sprintf "module %s : sig\n%s\nend" suffix_mod indented
834929···895990 ) c.schemas
896991 in
897992993993+ (* Set known schemas for validation during code generation *)
994994+ set_known_schemas schemas;
995995+898996 (* Collect operations *)
899997 let operations = List.concat_map (fun (path, pi) ->
900998 let ops = [
···9681066 | Spec.Value s -> Some (analyze_schema name s)
9691067 ) c.schemas
9701068 in
10691069+10701070+ (* Set known schemas for validation during code generation *)
10711071+ set_known_schemas schemas;
97110729721073 (* Collect operations *)
9731074 let operations = List.concat_map (fun (path, pi) ->