Skip to content

JSON Schema

Wiretype.Schema walks a description once and prints it as JSON Schema 2020-12, as zod/mini and as Melange, so the three cannot disagree. It knows no HTTP: a web framework’s document is built from it, and so can anybody’s.

A walk is in a direction: what a request’s body reads (Decode) and what an answer writes (Encode). A context collects what one document’s walks share – its components, and what was found loose or wrong on the way:

user.ml
type user = {
id : int; [@read_only] [@default 0]
email : string;
password : string; [@write_only] [@default ""]
nickname : string option;
}
[@@deriving wiretype]
(** A user of the shop. *)
user_schemas.ml
module J = Wiretype
let ctx = J.Schema.create ()
let request = J.Schema.walk ctx Decode ~at:"body" User.user_json
let answer = J.Schema.walk ctx Encode ~at:"answer" User.user_json
let () =
List.iter
(fun (name, schema) ->
Printf.printf "%s: %s\n" name
(J.Value.to_string (J.Schema.Json_schema.of_t schema)))
(J.Schema.components ctx);
print_endline
(J.Value.to_string
(J.Schema.Json_schema.document (J.Schema.components ctx) request));
print_endline
(J.Value.to_string
(J.Schema.Json_schema.of_t ~defs:"#/components/schemas/" answer))

It prints each component first:

Terminal window
UserInput: {"description":"A user of the shop.","type":"object","properties":{"email":{"type":"string"},"password":{"type":"string"},"nickname":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["email"]}
User: {"description":"A user of the shop.","type":"object","properties":{"id":{"type":"integer"},"email":{"type":"string"},"nickname":{"type":"string"}},"required":["email"]}

at names where the description is used, for what is reported.

A named object is a component: each object with a kind, but for a union’s case, named by it – order line is OrderLine (Schema.name_of_kind). A request’s schema and an answer’s differ where a member is read-only, write-only, or an opt_mem a request may give as null, and the request’s is then <Name>Input, at any depth. A name follows from the description and the direction alone, never from what else was walked or in what order.

A component is walked from its own root wherever it is reached, so kinds that hold each other in a ring of any length refer to each other, and a recursive description with no kind is expanded to the same depth inside each. Schema.components lists each after the components it refers to, but for one it is recursive with, which comes later.

  • An object that refuses members it does not describe says so.
  • Bounds – ranges, multiple_of, lengths, counts – are in each.
  • A doc, wherever it is given – an object, a member, an enum, a kind, a map, a union – is JSON Schema’s description.
  • A deprecated member is deprecated, and a member’s examples are its examples.
  • A kind is its format.

The program above prints in three ways. Json_schema.of_t prints one schema, and it printed each component first. Json_schema.document is a whole schema: the root, with its components under $defs:

Terminal window
{"$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"#/$defs/UserInput","$defs":{"UserInput":{...},"User":{...}}}

Json_schema.of_t refers to a component at defs, "#/$defs/" unless given; OpenAPI’s is "#/components/schemas/", which the program’s last line uses for the answer:

Terminal window
{"$ref":"#/components/schemas/User"}

What a walk could not make right is in Schema.errors, and a schema printed in spite of one says something the description does not, so check it before printing:

Error What it is
Kind_shared two different descriptions under one kind’s name
Kind_not_a_name a kind whose name does not begin with a letter
Key_not_text a map’s key whose description is not text
Unwritable a case’s tag, or an example, that cannot be written: an example outside its member’s bounds among them

What a schema can say only loosely is printed loosely and listed in Schema.loose, never guessed at: any JSON (Value.json), which says nothing of its shape; a recursive description with no kind, where its expansion stops; an integer’s bound past 2^53, which is left out since a double holds it only roughly; and a value of several sorts that none of its descriptions reads.

loose.ml
module J = Wiretype
let ctx = J.Schema.create ()
let item member =
J.Object.map ~kind:"item" Fun.id
|> J.Object.mem member J.int ~enc:Fun.id
|> J.Object.finish
let walk at description =
ignore (J.Schema.walk ctx Encode ~at description : J.Schema.t)
let () =
walk "first" (item "a");
walk "second" (item "b");
walk "big" (J.int_bounded ~max:(1 lsl 60) ());
walk "extra" J.Value.json;
List.iter
(fun e -> print_endline (J.Schema.error_to_string e))
(J.Schema.errors ctx);
List.iter print_endline (J.Schema.loose ctx)
Terminal window
second: Two different descriptions share the kind that names Item. (kind_shared)
big: a bound past 2^53, which a double holds only roughly
extra: any JSON (Wiretype.Value.json), which says nothing of its shape

A walk gives a Schema.t, a plain variant: Object, Array, Tagged, Ref and the rest, each with what it says. A printer of your own reads it as the three printers do, and a schema built by hand prints as one walked. The reference lists every case.