Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle more parsing cases (int and str formats, additional props: fix #10, #11 and #13) #12

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# History

## WIP

- Add options `--json-ocaml-type`, `--only-matching`,
`--avoid-dangling-refs`, and `--skip-doc`.

## 0.0.3
- Add decimal string format support
- Add unix-time int format support
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ You can call `jsonschema2atd` and `atdgen` in your `dune` file to generate OCaml
(run %{bin:atdgen} -t %{deps}))))
```

Other options can be used to control the output:

- `--json-ocaml-type KEYWORD:MODULE.PATH:TYPE-NAME` to control the defitiion of
the `json` type used as default/fallback.
- `--only-matching REGEXP` to limit the JSONSchema types to convert, when used
together with `--avoid-dangling-refs`, missing types are replaced with `json`.

See also `jsonschema2atd --help`.

## ToDo

- [X] Base types
Expand Down
44 changes: 39 additions & 5 deletions bin/main.ml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ module Input_format = struct
let all = [ JSONSchema; OpenAPI ]
end

let generate_atd input_format paths =
let generate_atd state input_format paths =
let generate =
match input_format with
| Input_format.JSONSchema -> Generator.make_atd_of_jsonschema
| OpenAPI -> Generator.make_atd_of_openapi
| Input_format.JSONSchema -> Generator.make_atd_of_jsonschema ~state
| OpenAPI -> Generator.make_atd_of_openapi ~state
in
print_endline (Generator.base (String.concat " " (List.map Filename.basename paths)));
print_endline (Generator.base state (String.concat " " (List.map Filename.basename paths)));
let root =
match paths with
| [ _ ] -> `Default
Expand All @@ -44,8 +44,42 @@ let input_format_term =

let main =
let doc = "Generate an ATD file from a list of JSON Schema / OpenAPI document" in
let state_term =
Term.(
const (fun skip_doc pad toplevel_types avoid_dangling_refs json_ocaml_type ->
Generator.
{
with_doc = not skip_doc;
protect_against_duplicates = (if pad then Some (ref []) else None);
toplevel_types;
avoid_dangling_refs;
json_ocaml_type;
}
)
$ Arg.(value (flag (info [ "skip-doc" ] ~doc:"Skip documentation annotations.")))
$ Arg.(value (flag (info [ "protect-against-duplicates" ] ~doc:"Make sure no duplicate types are generated.")))
$ (const (function
| [] -> `All
| some -> `Only some
)
$ Arg.(
value (opt_all string [] (info [ "only-matching" ] ~docv:"REGEXP" ~doc:"Only output types matching REGEXP."))
)
Copy link
Collaborator

@ixzzd ixzzd Dec 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thinking this is a nice addition, thank you @smondet
Could you please add also tests for this one and mention it in the doc?

)
$ Arg.(value (flag (info [ "avoid-dangling-refs" ] ~doc:"Convert dangling refs to json.")))
$ Arg.(
let keyword = enum [ "module", `Module; "from", `From ] in
value
(opt (t3 ~sep:':' keyword string string) Generator.default_state.json_ocaml_type
(info [ "json-ocaml-type" ] ~docv:"KEYWORD:MODULE.PATH:TYPE-NAME"
~doc:"Use an alternate Mod.type for `json`, e.g. from:My_mod.Submod:json_type."
Copy link
Collaborator

@ixzzd ixzzd Dec 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is useful 👍

)
)
)
)
in
let paths = Arg.(non_empty & pos_all file [] & info [] ~docv:"FILES" ~doc) in
let term = Term.(const generate_atd $ input_format_term $ paths) in
let term = Term.(const generate_atd $ state_term $ input_format_term $ paths) in
let info = Cmd.info "jsonschema2atd" ~doc ~version:(Version.get ()) in
Cmd.v info term

Expand Down
153 changes: 108 additions & 45 deletions lib/generator.ml
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,54 @@ open Json_schema_t
open Printf
open Utils

let record_field_name str =
type state = {
with_doc : bool;
protect_against_duplicates : string list ref option;
toplevel_types : [ `All | `Only of string list ];
avoid_dangling_refs : bool;
json_ocaml_type : [ `From | `Module ] * string * string;
}

let default_state =
{
with_doc = true;
protect_against_duplicates = None;
toplevel_types = `All;
avoid_dangling_refs = false;
json_ocaml_type = `Module, "Yojson.Basic", "t";
}

let record_field_name _state str =
let cleaned_field_name = Utils.sanitize_name str in
if String.equal str cleaned_field_name then str else sprintf {|%s <json name="%s">|} cleaned_field_name str

let doc_annotation text = sprintf {|<doc text=%S>|} text
let doc_annotation state text = if state.with_doc then sprintf {|<doc text=%S>|} text else ""
let defined_types : string list ref = ref []

let define_type ~doc ~name ~type_ =
let define_type state ~doc ~name ~type_ =
let doc =
match doc with
| None -> ""
| Some doc -> doc_annotation doc
| Some doc -> doc_annotation state doc
in
sprintf "type %s = %s %s\n" (type_name name) type_ doc
let out () = sprintf "type %s = %s %s\n" (type_name name) type_ doc in
begin
match state.protect_against_duplicates with
| None -> out ()
| Some defined_types -> begin
match List.exists (( = ) name) !defined_types with
| false ->
defined_types := name :: !defined_types;
out ()
| true ->
eprintf "Warning: Ignoring duplicate type %S.\n" name;
""
end
end

let process_int_type schema =
let process_int_type _state schema =
match schema.format with
| None | Some `Int32 | Some `UnixTime -> "int"
| None | Some `Int32 | Some `UnixTime | Some `Enum -> "int"
| Some `Int64 -> "int64"
| _ -> failwith "int has unexpected format"

Expand Down Expand Up @@ -74,7 +105,7 @@ let make_atd_default_value enum json_value =
failwith (sprintf "only string enum default values are supported, can't process: %s" (Yojson.Basic.to_string json))
| None, json -> ocaml_value_of_json json

let nullable = Printf.sprintf "%s nullable"
let nullable = sprintf "%s nullable"

let merge_all_of schema =
match schema.all_of with
Expand Down Expand Up @@ -134,17 +165,17 @@ let merge_all_of schema =
nullable = schemas |> List.exists (fun schema -> schema.nullable);
}

let rec process_schema_type ~ancestors (schema : schema) =
let schema = merge_all_of schema in
let rec process_schema_type state ~ancestors (input_schema : schema) =
let schema = merge_all_of input_schema in
let maybe_nullable type_ = if schema.nullable then nullable type_ else type_ in
match schema.one_of with
| Some schemas -> process_one_of ~ancestors schemas
| Some schemas -> process_one_of state ~ancestors schemas
| None ->
match schema.enum, schema.typ with
| Some enums, Some String -> process_string_enums enums
| Some enums, Some String -> process_string_enums state enums
| Some _, Some Integer ->
(* this is more lenient than it should *)
maybe_nullable (process_int_type schema)
maybe_nullable (process_int_type state schema)
| Some _, Some Number ->
(* this is more lenient than it should *)
maybe_nullable "float"
Expand All @@ -154,44 +185,55 @@ let rec process_schema_type ~ancestors (schema : schema) =
| Some _, _ -> failwith "only string enums are supported"
| None, _ ->
match schema.typ with
| Some Integer -> maybe_nullable (process_int_type schema)
| Some Integer -> maybe_nullable (process_int_type state schema)
| Some Number -> maybe_nullable "float"
| Some String -> maybe_nullable "string"
| Some Boolean -> maybe_nullable "bool"
| Some Array -> maybe_nullable (process_array_type ~ancestors schema |> String.concat " ")
| Some Object -> process_object_type ~ancestors schema
| Some Array -> maybe_nullable (process_array_type state ~ancestors schema |> String.concat " ")
| Some Object -> process_object_type state ~ancestors schema
| None ->
(* fallback to untyped if schema type is not defined *)
maybe_nullable "json"
ksprintf maybe_nullable "json (* %s *)"
(String.concat ","
(List.map
(function
| Ref s -> s
| _ -> "_"
)
(Option.value ~default:[ Ref (String.concat "/" (List.rev ancestors)) ] input_schema.all_of)
)
)

and process_array_type ~ancestors schema =
and process_array_type state ~ancestors schema =
match schema.items with
| Some schema_or_ref -> [ make_type_from_schema_or_ref ~ancestors schema_or_ref; "list" ]
| Some schema_or_ref -> [ make_type_from_schema_or_ref state ~ancestors schema_or_ref; "list" ]
| None -> failwith "items is not specified for array"

and process_nested_schema_type ~ancestors schema =
and process_nested_schema_type state ~ancestors schema =
match merge_all_of schema with
| { one_of = Some _; _ } | { typ = Some Object; properties = Some _; _ } | { enum = Some _; _ } ->
let nested_type_name = concat_camelCase (List.rev ancestors) in
let nested =
define_type ~name:nested_type_name ~type_:(process_schema_type ~ancestors schema) ~doc:schema.description
define_type state ~name:nested_type_name
~type_:(process_schema_type state ~ancestors schema)
~doc:schema.description
in
Buffer.add_string output (nested ^ "\n");
type_name nested_type_name
| _ -> process_schema_type ~ancestors schema
| _ -> process_schema_type state ~ancestors schema

and process_object_type ~ancestors schema =
and process_object_type state ~ancestors schema =
let is_required field_name = List.exists (String.equal field_name) schema.required in
let make_record_field (field_name, schema_or_ref) =
let type_ = make_type_from_schema_or_ref ~ancestors:(field_name :: ancestors) schema_or_ref in
let record_field_name = record_field_name field_name in
let type_ = make_type_from_schema_or_ref state ~ancestors:(field_name :: ancestors) schema_or_ref in
let record_field_name = record_field_name state field_name in
let doc =
let content =
match schema_or_ref with
| Ref _ -> None
| Obj schema -> schema.description
in
Option.map doc_annotation content |> Option.value ~default:""
Option.map (doc_annotation state) content |> Option.value ~default:""
in
match schema_or_ref, is_required field_name with
| Obj { default = Some default; enum; _ }, _ ->
Expand All @@ -200,33 +242,41 @@ and process_object_type ~ancestors schema =
| _, false -> sprintf " ?%s %s: %s option;" record_field_name doc type_
in
match schema.properties with
| Some [] -> sprintf "{\n dummy: unit\n}"
| Some properties -> sprintf "{\n%s\n}" (properties |> List.map make_record_field |> String.concat "\n")
| None -> "json"

and make_type_from_schema_or_ref ~ancestors (schema_or_ref : schema or_ref) =
and make_type_from_schema_or_ref state ~ancestors (schema_or_ref : schema or_ref) =
match schema_or_ref, ancestors with
| Obj schema, ([] | [ _ ]) -> process_schema_type ~ancestors schema
| Obj schema, ancestors -> process_nested_schema_type ~ancestors schema
| Ref ref_, _ -> type_name (get_ref_name ref_)
| Obj schema, ([] | [ _ ]) -> process_schema_type state ~ancestors schema
| Obj schema, ancestors -> process_nested_schema_type state ~ancestors schema
| Ref ref_, _ -> begin
match
(not state.avoid_dangling_refs)
|| List.exists (fun (name, _schema) -> String.equal (get_ref_name ref_) name) !input_toplevel_schemas
with
| true -> type_name (get_ref_name ref_)
| false -> sprintf "json (* %s *)" ref_
end

and process_one_of ~ancestors (schemas_or_refs : schema or_ref list) =
and process_one_of state ~ancestors (schemas_or_refs : schema or_ref list) =
let determine_variant_name = function
| Ref ref_ -> variant_name (get_ref_name ref_)
| Obj schema ->
match (merge_all_of schema).typ with
| Some Array -> concat_camelCase (process_array_type ~ancestors schema)
| Some Array -> concat_camelCase (process_array_type state ~ancestors schema)
| Some Object -> "Json"
| _ -> variant_name (process_schema_type ~ancestors schema)
| _ -> variant_name (process_schema_type state ~ancestors schema)
in
let make_one_of_variant schema_or_ref =
let variant_name = determine_variant_name schema_or_ref in
sprintf " | %s of %s" variant_name
(make_type_from_schema_or_ref ~ancestors:(variant_name :: ancestors) schema_or_ref)
(make_type_from_schema_or_ref state ~ancestors:(variant_name :: ancestors) schema_or_ref)
in
let variants = List.map make_one_of_variant schemas_or_refs |> String.concat "\n" in
sprintf "[\n%s\n] <json adapter.ocaml=\"Jsonschema2atd_runtime.Adapter.One_of\">" variants

and process_string_enums enums =
and process_string_enums _state enums =
let enums =
List.map
(function
Expand All @@ -243,27 +293,40 @@ and process_string_enums enums =
let variants = List.map make_enum_variant enums |> String.concat "\n" in
sprintf "[\n%s\n]" variants

let process_schemas (schemas : (string * schema or_ref) list) =
let process_schemas state (schemas : (string * schema or_ref) list) =
List.fold_left
(fun acc (name, schema_or_ref) ->
let doc =
match schema_or_ref with
| Ref _ -> None
| Obj schema -> schema.description
in
define_type ~doc ~name ~type_:(make_type_from_schema_or_ref ~ancestors:[ name ] schema_or_ref) :: acc
define_type state ~doc ~name ~type_:(make_type_from_schema_or_ref state ~ancestors:[ name ] schema_or_ref) :: acc
)
[] schemas

let base from =
let base state from =
let keyword, module_path, type_t = state.json_ocaml_type in
sprintf
{|(* Generated by jsonschema2atd from %s *)
type json <ocaml module="Yojson.Basic" t="t"> = abstract
type json <ocaml %s="%s" t="%s"> = abstract
type int64 = int <ocaml repr="int64">
|}
from
( match keyword with
| `From -> "from"
| `Module -> "module"
)
module_path type_t

let make_atd_of_schemas schemas =
let make_atd_of_schemas state schemas =
let schemas =
match state.toplevel_types with
| `All -> schemas
| `Only l ->
let res = List.map (ksprintf Str.regexp "^%s$") l in
List.filter (fun (name, _) -> List.exists (fun re -> Str.string_match re name 0) res) schemas
in
input_toplevel_schemas :=
List.filter_map
(function
Expand All @@ -272,23 +335,23 @@ let make_atd_of_schemas schemas =
)
schemas;
Buffer.clear output;
Buffer.add_string output (String.concat "\n" (process_schemas schemas));
Buffer.add_string output (String.concat "\n" (process_schemas state schemas));
Buffer.contents output

let make_atd_of_jsonschema ?(root = "root") input =
let make_atd_of_jsonschema ?(root = "root") ?(state = default_state) input =
let schema = Json_schema_j.schema_of_string input in
let root_type_name = Option.value ~default:root schema.title in
let defs =
let defs = List.concat_map Utils.list_of_nonempty [ schema.defs; schema.definitions ] in
List.map (fun (name, schema) -> name, Obj schema) defs
in
make_atd_of_schemas ([ root_type_name, Obj schema ] @ defs)
make_atd_of_schemas state ([ root_type_name, Obj schema ] @ defs)

let make_atd_of_openapi ?root:_ input =
let make_atd_of_openapi ?root:_ ?(state = default_state) input =
let root = Openapi_j.root_of_string input in
match root.components with
| None -> failwith "components are empty"
| Some components ->
match components.schemas with
| Some schemas -> make_atd_of_schemas schemas
| Some schemas -> make_atd_of_schemas state schemas
| None -> failwith "components schemas are empty"
Loading
Loading