OpenAPI generator for OCaml with Requests/Eio/Jsont
1(** Runtime utilities for generated OpenAPI clients.
2
3 This module provides utilities used by generated client code:
4 - Path template rendering
5 - Query parameter building
6 - JSON encoding/decoding helpers
7*)
8
9(** {1 Path Templates} *)
10
11module Path = struct
12 (** Render a path template like "/users/{id}/posts/{postId}" with parameters *)
13 let render ~(params : (string * string) list) (template : string) : string =
14 List.fold_left
15 (fun path (name, value) ->
16 match String.split_on_char '{' path with
17 | [only] -> only
18 | parts ->
19 String.concat "" (List.mapi (fun i part ->
20 if i = 0 then part
21 else
22 match String.split_on_char '}' part with
23 | [var; rest] when var = name -> value ^ rest
24 | _ -> "{" ^ part
25 ) parts))
26 template params
27
28 (** Extract parameter names from a path template *)
29 let parameters (template : string) : string list =
30 let rec extract acc s =
31 match String.index_opt s '{' with
32 | None -> List.rev acc
33 | Some i ->
34 let rest = String.sub s (i + 1) (String.length s - i - 1) in
35 match String.index_opt rest '}' with
36 | None -> List.rev acc
37 | Some j ->
38 let name = String.sub rest 0 j in
39 let remaining = String.sub rest (j + 1) (String.length rest - j - 1) in
40 extract (name :: acc) remaining
41 in
42 extract [] template
43end
44
45(** {1 Query Parameters} *)
46
47module Query = struct
48 type param = string * string
49
50 (** Helper for optional parameters with custom stringifier *)
51 let optional_with ~key ~value ~to_string : param list =
52 Option.fold ~none:[] ~some:(fun v -> [(key, to_string v)]) value
53
54 let singleton ~key ~value : param list = [(key, value)]
55
56 let optional ~key ~value : param list =
57 optional_with ~key ~value ~to_string:Fun.id
58
59 let list ~key ~values : param list =
60 List.map (fun v -> (key, v)) values
61
62 let int ~key ~value : param list = [(key, string_of_int value)]
63
64 let int_opt ~key ~value : param list =
65 optional_with ~key ~value ~to_string:string_of_int
66
67 let bool ~key ~value : param list =
68 [(key, if value then "true" else "false")]
69
70 let bool_opt ~key ~value : param list =
71 optional_with ~key ~value ~to_string:(fun b -> if b then "true" else "false")
72
73 let float ~key ~value : param list = [(key, string_of_float value)]
74
75 let float_opt ~key ~value : param list =
76 optional_with ~key ~value ~to_string:string_of_float
77
78 let encode (params : param list) : string =
79 if params = [] then ""
80 else
81 "?" ^
82 String.concat "&" (List.map (fun (k, v) ->
83 (* URL encode the value *)
84 let encode_char c =
85 match c with
86 | 'A'..'Z' | 'a'..'z' | '0'..'9' | '-' | '_' | '.' | '~' ->
87 String.make 1 c
88 | c ->
89 Printf.sprintf "%%%02X" (Char.code c)
90 in
91 let encoded_v = String.to_seq v
92 |> Seq.map encode_char
93 |> List.of_seq
94 |> String.concat ""
95 in
96 k ^ "=" ^ encoded_v
97 ) params)
98end
99
100(** {1 JSON Helpers} *)
101
102module Json = struct
103 let decode codec s =
104 Jsont_bytesrw.decode_string codec s
105
106 let decode' codec s =
107 Jsont_bytesrw.decode_string' codec s
108
109 let encode codec v =
110 Jsont_bytesrw.encode_string codec v
111
112 let encode' codec v =
113 Jsont_bytesrw.encode_string' codec v
114
115 let encode_compact codec v =
116 Jsont_bytesrw.encode_string ~format:Jsont.Minify codec v
117
118 (** Decode a Jsont.json value through a codec.
119 Encodes to string then decodes - not optimal but works. *)
120 let decode_json (codec : 'a Jsont.t) (json : Jsont.json) : ('a, string) result =
121 match Jsont_bytesrw.encode_string Jsont.json json with
122 | Ok s -> Jsont_bytesrw.decode_string codec s
123 | Error e -> Error e
124
125 (** Decode a Jsont.json value, raising on error *)
126 let decode_json_exn (codec : 'a Jsont.t) (json : Jsont.json) : 'a =
127 match decode_json codec json with
128 | Ok v -> v
129 | Error e -> failwith e
130
131 (** Encode a value to Jsont.json *)
132 let encode_json (codec : 'a Jsont.t) (v : 'a) : Jsont.json =
133 match Jsont_bytesrw.encode_string codec v with
134 | Ok s ->
135 (match Jsont_bytesrw.decode_string Jsont.json s with
136 | Ok json -> json
137 | Error _ -> Jsont.Null ((), Jsont.Meta.none))
138 | Error _ -> Jsont.Null ((), Jsont.Meta.none)
139end
140
141(** {1 HTTP Method} *)
142
143type http_method = Get | Post | Put | Patch | Delete | Head | Options
144
145let string_of_method = function
146 | Get -> "GET"
147 | Post -> "POST"
148 | Put -> "PUT"
149 | Patch -> "PATCH"
150 | Delete -> "DELETE"
151 | Head -> "HEAD"
152 | Options -> "OPTIONS"
153
154(** {1 Common Types} *)
155
156(** ISO 8601 date-time codec *)
157let ptime_jsont : Ptime.t Jsont.t =
158 Jsont.map Jsont.string ~kind:"datetime"
159 ~dec:(fun s ->
160 match Ptime.of_rfc3339 s with
161 | Ok (t, _, _) -> t
162 | Error _ -> Jsont.Error.msgf Jsont.Meta.none "Invalid datetime: %s" s)
163 ~enc:(fun t -> Ptime.to_rfc3339 t)
164
165(** UUID as string *)
166let uuid_jsont : string Jsont.t = Jsont.string
167
168(** Base64 encoded bytes *)
169let base64_jsont : string Jsont.t = Jsont.string
170
171(** {1 Nullable wrapper} *)
172
173let nullable (codec : 'a Jsont.t) : 'a option Jsont.t =
174 Jsont.option codec
175
176(** Nullable combinator that handles explicit JSON null values.
177 Use this for fields marked as "nullable: true" in OpenAPI specs.
178 Unlike Jsont.option, this properly decodes explicit null as None. *)
179let nullable_any (base_codec : 'a Jsont.t) : 'a option Jsont.t =
180 let null_codec = Jsont.null None in
181 let some_codec = Jsont.map base_codec
182 ~kind:"nullable_some"
183 ~dec:(fun v -> Some v)
184 ~enc:(function Some v -> v | None -> failwith "unreachable")
185 in
186 (* Use Jsont.any to dispatch based on the JSON value type *)
187 Jsont.any
188 ~dec_null:null_codec
189 ~dec_string:some_codec
190 ~dec_number:some_codec
191 ~dec_bool:some_codec
192 ~dec_array:some_codec
193 ~dec_object:some_codec
194 ~enc:(function
195 | None -> null_codec
196 | Some _ -> some_codec)
197 ()
198
199(** Nullable string that handles both absent and explicit null *)
200let nullable_string : string option Jsont.t =
201 nullable_any Jsont.string
202
203(** Nullable ptime that handles both absent and explicit null *)
204let nullable_ptime : Ptime.t option Jsont.t =
205 nullable_any ptime_jsont
206
207(** Nullable int that handles both absent and explicit null *)
208let nullable_int : int option Jsont.t =
209 nullable_any Jsont.int
210
211(** Nullable float that handles both absent and explicit null *)
212let nullable_float : float option Jsont.t =
213 nullable_any Jsont.number
214
215(** Nullable bool that handles both absent and explicit null *)
216let nullable_bool : bool option Jsont.t =
217 nullable_any Jsont.bool
218
219(** {1 Any JSON value wrapper} *)
220
221type json = Jsont.json
222
223let json_jsont : json Jsont.t = Jsont.json
224
225(** {1 Validation} *)
226
227(** Validation error type *)
228type validation_error =
229 | Min_length of { actual: int; min: int }
230 | Max_length of { actual: int; max: int }
231 | Pattern_mismatch of { value: string; pattern: string }
232 | Min_value of { actual: float; min: float; exclusive: bool }
233 | Max_value of { actual: float; max: float; exclusive: bool }
234 | Min_items of { actual: int; min: int }
235 | Max_items of { actual: int; max: int }
236 | Duplicate_items of { count: int }
237
238let validation_error_to_string = function
239 | Min_length { actual; min } ->
240 Printf.sprintf "string too short: %d < %d" actual min
241 | Max_length { actual; max } ->
242 Printf.sprintf "string too long: %d > %d" actual max
243 | Pattern_mismatch { value; pattern } ->
244 Printf.sprintf "value %S does not match pattern %S" value pattern
245 | Min_value { actual; min; exclusive } ->
246 if exclusive then Printf.sprintf "value %g must be > %g" actual min
247 else Printf.sprintf "value %g must be >= %g" actual min
248 | Max_value { actual; max; exclusive } ->
249 if exclusive then Printf.sprintf "value %g must be < %g" actual max
250 else Printf.sprintf "value %g must be <= %g" actual max
251 | Min_items { actual; min } ->
252 Printf.sprintf "array too short: %d < %d items" actual min
253 | Max_items { actual; max } ->
254 Printf.sprintf "array too long: %d > %d items" actual max
255 | Duplicate_items { count } ->
256 Printf.sprintf "array contains %d duplicate items" count
257
258(** Validated string codec with optional length and pattern constraints.
259 Pattern validation uses the Re library with PCRE syntax. *)
260let validated_string
261 ?min_length ?max_length ?pattern
262 (base : string Jsont.t) : string Jsont.t =
263 (* Compile regex if pattern is provided *)
264 let pattern_re = Option.map (fun p ->
265 try Some (Re.compile (Re.Pcre.re p))
266 with _ -> None (* Invalid regex - skip validation *)
267 ) pattern in
268 Jsont.map base ~kind:"validated_string"
269 ~dec:(fun s ->
270 let len = String.length s in
271 (match min_length with
272 | Some min when len < min ->
273 Jsont.Error.msgf Jsont.Meta.none "%s"
274 (validation_error_to_string (Min_length { actual = len; min }))
275 | _ -> ());
276 (match max_length with
277 | Some max when len > max ->
278 Jsont.Error.msgf Jsont.Meta.none "%s"
279 (validation_error_to_string (Max_length { actual = len; max }))
280 | _ -> ());
281 (match pattern_re, pattern with
282 | Some (Some re), Some pat ->
283 if not (Re.execp re s) then
284 Jsont.Error.msgf Jsont.Meta.none "%s"
285 (validation_error_to_string (Pattern_mismatch { value = s; pattern = pat }))
286 | _ -> ());
287 s)
288 ~enc:Fun.id
289
290(** Validated int codec with optional min/max constraints *)
291let validated_int
292 ?minimum ?maximum ?exclusive_minimum ?exclusive_maximum
293 (base : int Jsont.t) : int Jsont.t =
294 Jsont.map base ~kind:"validated_int"
295 ~dec:(fun n ->
296 let f = float_of_int n in
297 (match exclusive_minimum with
298 | Some min when f <= min ->
299 Jsont.Error.msgf Jsont.Meta.none "%s"
300 (validation_error_to_string (Min_value { actual = f; min; exclusive = true }))
301 | _ -> ());
302 (match minimum with
303 | Some min when f < min ->
304 Jsont.Error.msgf Jsont.Meta.none "%s"
305 (validation_error_to_string (Min_value { actual = f; min; exclusive = false }))
306 | _ -> ());
307 (match exclusive_maximum with
308 | Some max when f >= max ->
309 Jsont.Error.msgf Jsont.Meta.none "%s"
310 (validation_error_to_string (Max_value { actual = f; max; exclusive = true }))
311 | _ -> ());
312 (match maximum with
313 | Some max when f > max ->
314 Jsont.Error.msgf Jsont.Meta.none "%s"
315 (validation_error_to_string (Max_value { actual = f; max; exclusive = false }))
316 | _ -> ());
317 n)
318 ~enc:Fun.id
319
320(** Validated float codec with optional min/max constraints *)
321let validated_float
322 ?minimum ?maximum ?exclusive_minimum ?exclusive_maximum
323 (base : float Jsont.t) : float Jsont.t =
324 Jsont.map base ~kind:"validated_float"
325 ~dec:(fun f ->
326 (match exclusive_minimum with
327 | Some min when f <= min ->
328 Jsont.Error.msgf Jsont.Meta.none "%s"
329 (validation_error_to_string (Min_value { actual = f; min; exclusive = true }))
330 | _ -> ());
331 (match minimum with
332 | Some min when f < min ->
333 Jsont.Error.msgf Jsont.Meta.none "%s"
334 (validation_error_to_string (Min_value { actual = f; min; exclusive = false }))
335 | _ -> ());
336 (match exclusive_maximum with
337 | Some max when f >= max ->
338 Jsont.Error.msgf Jsont.Meta.none "%s"
339 (validation_error_to_string (Max_value { actual = f; max; exclusive = true }))
340 | _ -> ());
341 (match maximum with
342 | Some max when f > max ->
343 Jsont.Error.msgf Jsont.Meta.none "%s"
344 (validation_error_to_string (Max_value { actual = f; max; exclusive = false }))
345 | _ -> ());
346 f)
347 ~enc:Fun.id
348
349(** Validated list codec with optional min/max items and uniqueness constraints *)
350let validated_list
351 ?min_items ?max_items ?(unique_items = false)
352 (elem_codec : 'a Jsont.t) : 'a list Jsont.t =
353 let base = Jsont.list elem_codec in
354 Jsont.map base ~kind:"validated_list"
355 ~dec:(fun lst ->
356 let len = List.length lst in
357 (match min_items with
358 | Some min when len < min ->
359 Jsont.Error.msgf Jsont.Meta.none "%s"
360 (validation_error_to_string (Min_items { actual = len; min }))
361 | _ -> ());
362 (match max_items with
363 | Some max when len > max ->
364 Jsont.Error.msgf Jsont.Meta.none "%s"
365 (validation_error_to_string (Max_items { actual = len; max }))
366 | _ -> ());
367 (* Check for unique items using structural equality *)
368 (if unique_items then
369 let rec count_duplicates seen count = function
370 | [] -> count
371 | x :: xs ->
372 if List.exists (( = ) x) seen then
373 count_duplicates seen (count + 1) xs
374 else
375 count_duplicates (x :: seen) count xs
376 in
377 let dup_count = count_duplicates [] 0 lst in
378 if dup_count > 0 then
379 Jsont.Error.msgf Jsont.Meta.none "%s"
380 (validation_error_to_string (Duplicate_items { count = dup_count })));
381 lst)
382 ~enc:Fun.id
383
384(** {1 Polymorphic Variant Codecs for Union Types} *)
385
386(** Create a try-each decoder for polymorphic variant union types.
387 Attempts each decoder in order until one succeeds. *)
388let poly_union_decoder (decoders : (Jsont.json -> 'a option) list) (json : Jsont.json) : 'a =
389 match List.find_map (fun dec -> dec json) decoders with
390 | Some v -> v
391 | None -> Jsont.Error.msgf Jsont.Meta.none "No variant matched for union type"
392
393(** {1 API Errors} *)
394
395(** Parsed error body, in increasing levels of typed-ness *)
396type error_body =
397 | Raw of string (** Unparsed string *)
398 | Json of Jsont.json (** Parsed but untyped JSON *)
399 | Typed of string * Jsont.json (** schema_name, typed value as JSON *)
400
401(** Error raised when an API call fails with a non-2xx status code *)
402type api_error = {
403 operation : string;
404 method_ : string;
405 url : string;
406 status : int;
407 body : string; (** Always keep raw body for debugging *)
408 parsed_body : error_body option; (** Parsed/typed body if available *)
409}
410
411exception Api_error of api_error
412
413let () =
414 Printexc.register_printer (function
415 | Api_error e ->
416 let parsed_info = match e.parsed_body with
417 | None -> ""
418 | Some (Raw _) -> " (raw)"
419 | Some (Json _) -> " (json)"
420 | Some (Typed (schema, _)) -> Printf.sprintf " (typed: %s)" schema
421 in
422 Some (Printf.sprintf "Api_error: %s %s returned %d%s: %s"
423 e.method_ e.url e.status parsed_info e.body)
424 | _ -> None)
425
426(** Helper to try parsing error body with a list of status-keyed parsers *)
427let try_parse_error_body ~status ~body
428 (parsers : (string * (string -> (Jsont.json, string) result)) list) : error_body option =
429 let status_str = string_of_int status in
430 (* Try exact status match first, then wildcard, then default *)
431 let try_parser key =
432 List.find_map (fun (k, parser) ->
433 if k = key then
434 match parser body with
435 | Ok json -> Some json
436 | Error _ -> None
437 else None
438 ) parsers
439 in
440 match try_parser status_str with
441 | Some json -> Some (Json json)
442 | None ->
443 let wildcard = String.make 1 status_str.[0] ^ "XX" in
444 match try_parser wildcard with
445 | Some json -> Some (Json json)
446 | None ->
447 match try_parser "default" with
448 | Some json -> Some (Json json)
449 | None ->
450 (* Try parsing as generic JSON *)
451 match Jsont_bytesrw.decode_string Jsont.json body with
452 | Ok json -> Some (Json json)
453 | Error _ -> Some (Raw body)