···1818 birth_date: string option;
1919 thumbnail_path: string;
2020 is_hidden: bool;
2121-}
2222-2323-type people_response = {
2424- total: int;
2525- visible: int;
2626- people: person list;
2121+ unknown: Jsont.json;
2722}
28232924(** {1 Client Creation} *)
···3328 let requests_session = Requests.set_default_header requests_session "x-api-key" api_key in
3429 { base_url; api_key; requests_session }
35303636-(** {1 JSON Parsing} *)
3131+(** {1 JSON Codecs} *)
37323838-(* Parse a single person from JSON *)
3939-let parse_person json =
4040- let open Ezjsonm in
4141- let id = find json ["id"] |> get_string in
4242- let name = find json ["name"] |> get_string in
4343- let birth_date =
4444- try Some (find json ["birthDate"] |> get_string)
4545- with _ -> None
4646- in
4747- let thumbnail_path = find json ["thumbnailPath"] |> get_string in
4848- let is_hidden =
4949- try find json ["isHidden"] |> get_bool
5050- with _ -> false
3333+(* Jsont codec for person *)
3434+let person_jsont =
3535+ let make id name birth_date thumbnail_path is_hidden unknown =
3636+ { id; name; birth_date; thumbnail_path; is_hidden; unknown }
5137 in
5252- { id; name; birth_date; thumbnail_path; is_hidden }
3838+ let id p = p.id in
3939+ let name p = p.name in
4040+ let birth_date p = p.birth_date in
4141+ let thumbnail_path p = p.thumbnail_path in
4242+ let is_hidden p = p.is_hidden in
4343+ let unknown p = p.unknown in
4444+ Jsont.Object.map ~kind:"Person" make
4545+ |> Jsont.Object.mem "id" Jsont.string ~enc:id
4646+ |> Jsont.Object.mem "name" Jsont.string ~enc:name
4747+ |> Jsont.Object.opt_mem "birthDate" Jsont.string ~enc:birth_date
4848+ |> Jsont.Object.mem "thumbnailPath" Jsont.string ~enc:thumbnail_path
4949+ |> Jsont.Object.mem "isHidden" Jsont.bool ~enc:is_hidden
5050+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
5151+ |> Jsont.Object.finish
53525454-(* Parse people response from JSON *)
5555-let parse_people_response json =
5656- let open Ezjsonm in
5757- let total = find json ["total"] |> get_int in
5858- let visible = find json ["visible"] |> get_int in
5959- let people_json = find json ["people"] in
6060- let people = get_list parse_person people_json in
6161- { total; visible; people }
5353+type people_response = {
5454+ total: int;
5555+ visible: int;
5656+ people: person list;
5757+ unknown: Jsont.json;
5858+}
62596363-(* Parse a list of people from search results *)
6464-let parse_person_list json =
6565- let open Ezjsonm in
6666- get_list parse_person json
6060+(* Jsont codec for people_response *)
6161+let people_response_jsont =
6262+ let make total visible people unknown =
6363+ { total; visible; people; unknown }
6464+ in
6565+ let total r = r.total in
6666+ let visible r = r.visible in
6767+ let people r = r.people in
6868+ let unknown r = r.unknown in
6969+ Jsont.Object.map ~kind:"PeopleResponse" make
7070+ |> Jsont.Object.mem "total" Jsont.int ~enc:total
7171+ |> Jsont.Object.mem "visible" Jsont.int ~enc:visible
7272+ |> Jsont.Object.mem "people" (Jsont.list person_jsont) ~enc:people
7373+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
7474+ |> Jsont.Object.finish
67756876(** {1 API Functions} *)
69777078let fetch_people { base_url; requests_session; _ } =
7179 let open Requests_json_api in
7280 let url = base_url / "api/people" in
7373- get_json_exn requests_session url parse_people_response
8181+ get_json_exn requests_session url people_response_jsont
74827583let fetch_person { base_url; requests_session; _ } ~person_id =
7684 let open Requests_json_api in
7785 let url = base_url / "api/people" / person_id in
7878- get_json_exn requests_session url parse_person
8686+ get_json_exn requests_session url person_jsont
79878088let download_thumbnail { base_url; requests_session; _ } ~fs ~person_id ~output_path =
8189 try
···104112 let open Requests_json_api in
105113 let encoded_name = Uri.pct_encode name in
106114 let url = sprintf "%s/api/search/person?name=%s" base_url encoded_name in
107107- get_json_exn requests_session url parse_person_list
115115+ get_json_exn requests_session url (Jsont.list person_jsont)
+2
stack/immich/immich.mli
···1616 birth_date: string option;
1717 thumbnail_path: string;
1818 is_hidden: bool;
1919+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
1920}
20212122(** Type for the people API response *)
···2324 total: int;
2425 visible: int;
2526 people: person list;
2727+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
2628}
27292830(** {1 Client Creation} *)
···11(** Karakeep API client implementation (Eio version) *)
2233-module J = Ezjsonm
44-53let src = Logs.Src.create "karakeepe" ~doc:"Karakeep API client"
64module Log = (val Logs.src_log src : Logs.LOG)
7566+(** RFC 3339 timestamp support for JSON *)
77+module Rfc3339 = struct
88+ let parse s =
99+ Ptime.of_rfc3339 s |> Result.to_option |> Option.map (fun (t, _, _) -> t)
1010+1111+ let format t = Ptime.to_rfc3339 ~frac_s:6 ~tz_offset_s:0 t
1212+ let _pp ppf t = Format.pp_print_string ppf (format t)
1313+1414+ let jsont =
1515+ let kind = "RFC 3339 timestamp" in
1616+ let dec meta s =
1717+ match parse s with
1818+ | Some t -> t
1919+ | None ->
2020+ Jsont.Error.msgf meta "invalid RFC 3339 timestamp: %S" s
2121+ in
2222+ let enc = Jsont.Base.enc format in
2323+ Jsont.Base.string (Jsont.Base.map ~kind ~dec ~enc ())
2424+end
2525+2626+(** Unknown JSON fields - used when keeping unknown members *)
2727+let json_mems_empty = Jsont.Object ([], Jsont.Meta.none)
2828+829(** Type representing a Karakeep client session *)
930type 'net t_internal = {
1031 api_key: string;
···1940 let http_client = Requests.create ~sw env in
2041 { api_key; base_url; http_client }
21422222-(** Type representing a Karakeep bookmark *)
2323-type bookmark = {
2424- id: string;
2525- title: string option;
2626- url: string;
2727- note: string option;
2828- created_at: Ptime.t;
2929- updated_at: Ptime.t option;
3030- favourited: bool;
3131- archived: bool;
3232- tags: string list;
3333- tagging_status: string option;
3434- summary: string option;
3535- content: (string * string) list;
3636- assets: (string * string) list;
3737-}
4343+(** Tag type for bookmark tags *)
4444+module Tag = struct
4545+ type t = {
4646+ name: string;
4747+ unknown: Jsont.json;
4848+ }
38493939-(** Type for Karakeep API response containing bookmarks *)
4040-type bookmark_response = {
4141- total: int;
4242- data: bookmark list;
4343- next_cursor: string option;
4444-}
5050+ let make name unknown = { name; unknown }
5151+ let name t = t.name
5252+ let unknown t = t.unknown
45534646-(** Parse a date string to Ptime.t, defaulting to epoch if invalid *)
4747-let parse_date str =
4848- match Ptime.of_rfc3339 str with
4949- | Ok (date, _, _) -> date
5050- | Error _ ->
5151- Fmt.epr "Warning: could not parse date '%s'\n" str;
5252- (* Default to epoch time *)
5353- let span_opt = Ptime.Span.of_d_ps (0, 0L) in
5454- match span_opt with
5555- | None -> failwith "Internal error: couldn't create epoch time span"
5656- | Some span ->
5757- match Ptime.of_span span with
5858- | Some t -> t
5959- | None -> failwith "Internal error: couldn't create epoch time"
5454+ let jsont =
5555+ let kind = "Tag" in
5656+ Jsont.Object.map ~kind make
5757+ |> Jsont.Object.mem "name" Jsont.string ~enc:name
5858+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
5959+ |> Jsont.Object.finish
6060+end
60616161-(** Extract a string field from JSON, returns None if not present or not a string *)
6262-let get_string_opt json path =
6363- try Some (J.find json path |> J.get_string)
6464- with _ -> None
6262+(** Content field pair (key-value from content object) *)
6363+module ContentField = struct
6464+ type _t = string * string
65656666-(** Extract a string list field from JSON, returns empty list if not present *)
6767-let get_string_list json path =
6868- try
6969- let items_json = J.find json path in
7070- J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json
7171- with _ -> []
6666+ let _key (k, _) = k
6767+ let _value (_, v) = v
72687373-(** Extract a boolean field from JSON, with default value *)
7474-let get_bool_def json path default =
7575- try J.find json path |> J.get_bool
7676- with _ -> default
6969+ (* Helper to convert Jsont.json to string *)
7070+ let json_to_string = function
7171+ | Jsont.String (s, _) -> s
7272+ | Jsont.Bool (b, _) -> string_of_bool b
7373+ | Jsont.Number (n, _) -> string_of_float n
7474+ | Jsont.Null _ -> "null"
7575+ | _ -> "complex_value"
77767878-(** Parse a single bookmark from Karakeep JSON *)
7979-let parse_bookmark json =
8080- let id =
8181- try J.find json ["id"] |> J.get_string
8282- with e ->
8383- Log.err (fun m -> m "Error parsing bookmark ID: %s@.JSON: %s"
8484- (Printexc.to_string e) (J.value_to_string json));
8585- failwith "Unable to parse bookmark ID"
8686- in
7777+ (* Decode from JSON object members *)
7878+ let of_json_mems mems =
7979+ List.map (fun ((k, _meta), v) -> (k, json_to_string v)) mems
87808888- let title =
8989- try Some (J.find json ["title"] |> J.get_string)
9090- with _ -> None
9191- in
8181+ (* Encode to JSON object members *)
8282+ let to_json_mems fields =
8383+ List.map (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))) fields
8484+end
92859393- let url =
9494- try J.find json ["url"] |> J.get_string
9595- with _ -> try
9696- J.find json ["content"; "url"] |> J.get_string
9797- with _ -> try
9898- J.find json ["content"; "sourceUrl"] |> J.get_string
9999- with _ ->
100100- match J.find_opt json ["content"; "type"] with
101101- | Some (`String "asset") ->
102102- (try J.find json ["content"; "sourceUrl"] |> J.get_string
103103- with _ ->
104104- (match J.find_opt json ["id"] with
105105- | Some (`String id) -> "karakeep-asset://" ^ id
106106- | _ -> failwith "No URL or asset ID found in bookmark"))
107107- | _ ->
108108- Log.err (fun m -> m "No URL found in bookmark@.JSON structure: %s"
109109- (J.value_to_string json));
110110- failwith "No URL found in bookmark"
111111- in
8686+(** Asset type *)
8787+module Asset = struct
8888+ type t = {
8989+ id: string;
9090+ asset_type: string;
9191+ unknown: Jsont.json;
9292+ }
11293113113- let note = get_string_opt json ["note"] in
9494+ let make id asset_type unknown = { id; asset_type; unknown }
9595+ let id t = t.id
9696+ let asset_type t = t.asset_type
9797+ let unknown t = t.unknown
11498115115- let created_at =
116116- try J.find json ["createdAt"] |> J.get_string |> parse_date
117117- with _ ->
118118- try J.find json ["created_at"] |> J.get_string |> parse_date
119119- with _ -> failwith "No creation date found"
120120- in
9999+ let jsont =
100100+ let kind = "Asset" in
101101+ Jsont.Object.map ~kind make
102102+ |> Jsont.Object.mem "id" Jsont.string ~enc:id
103103+ |> Jsont.Object.mem "assetType" Jsont.string ~enc:asset_type
104104+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
105105+ |> Jsont.Object.finish
106106+end
121107122122- let updated_at =
123123- try Some (J.find json ["updatedAt"] |> J.get_string |> parse_date)
124124- with _ ->
125125- try Some (J.find json ["modifiedAt"] |> J.get_string |> parse_date)
126126- with _ -> None
127127- in
108108+(** Karakeep bookmark *)
109109+module Bookmark = struct
110110+ type t = {
111111+ id: string;
112112+ title: string option;
113113+ url: string;
114114+ note: string option;
115115+ created_at: Ptime.t;
116116+ updated_at: Ptime.t option;
117117+ favourited: bool;
118118+ archived: bool;
119119+ tags: string list;
120120+ tagging_status: string option;
121121+ summary: string option;
122122+ content: (string * string) list;
123123+ assets: (string * string) list;
124124+ }
128125129129- let favourited = get_bool_def json ["favourited"] false in
130130- let archived = get_bool_def json ["archived"] false in
131131- let tags = get_string_list json ["tags"] in
132132- let tagging_status = get_string_opt json ["taggingStatus"] in
133133- let summary = get_string_opt json ["summary"] in
126126+ let id t = t.id
127127+ let title t = t.title
128128+ let url t = t.url
129129+ let note t = t.note
130130+ let created_at t = t.created_at
131131+ let updated_at t = t.updated_at
132132+ let favourited t = t.favourited
133133+ let archived t = t.archived
134134+ let tags t = t.tags
135135+ let tagging_status t = t.tagging_status
136136+ let summary t = t.summary
137137+ let content t = t.content
138138+ let assets t = t.assets
134139135135- let content =
136136- try
137137- let content_json = J.find json ["content"] in
138138- let rec extract_fields acc = function
139139- | [] -> acc
140140- | (k, v) :: rest ->
141141- let value = match v with
142142- | `String s -> s
143143- | `Bool b -> string_of_bool b
144144- | `Float f -> string_of_float f
145145- | `Null -> "null"
146146- | _ -> "complex_value"
147147- in
148148- extract_fields ((k, value) :: acc) rest
140140+ let jsont =
141141+ let kind = "Bookmark" in
142142+143143+ (* Constructor for decoding *)
144144+ let make id title url note created_at updated_at favourited archived
145145+ tag_objs tagging_status summary content_obj assets_objs _unknown =
146146+147147+ (* Extract tag names from tag objects *)
148148+ let tags = match tag_objs with
149149+ | Some tags -> List.map Tag.name tags
150150+ | None -> []
151151+ in
152152+153153+ (* Extract content fields from JSON object *)
154154+ let content = match content_obj with
155155+ | Some (Jsont.Object (mems, _)) -> ContentField.of_json_mems mems
156156+ | _ -> []
149157 in
150150- match content_json with
151151- | `O fields -> extract_fields [] fields
152152- | _ -> []
153153- with _ -> []
154154- in
155158156156- let assets =
157157- try
158158- let assets_json = J.find json ["assets"] in
159159- J.get_list (fun asset_json ->
160160- let id = J.find asset_json ["id"] |> J.get_string in
161161- let asset_type =
162162- try J.find asset_json ["assetType"] |> J.get_string
163163- with _ -> "unknown"
164164- in
165165- (id, asset_type)
166166- ) assets_json
167167- with _ -> []
168168- in
159159+ (* Extract asset tuples *)
160160+ let assets = match assets_objs with
161161+ | Some asset_list -> List.map (fun a -> (Asset.id a, Asset.asset_type a)) asset_list
162162+ | None -> []
163163+ in
169164170170- { id; title; url; note; created_at; updated_at; favourited; archived; tags;
171171- tagging_status; summary; content; assets }
165165+ (* Handle URL extraction from content if main URL is missing *)
166166+ let url = match url with
167167+ | Some u -> u
168168+ | None ->
169169+ (* Try to find URL in content *)
170170+ (match List.assoc_opt "url" content with
171171+ | Some u -> u
172172+ | None ->
173173+ (match List.assoc_opt "sourceUrl" content with
174174+ | Some u -> u
175175+ | None ->
176176+ (* Check if it's an asset type *)
177177+ (match List.assoc_opt "type" content with
178178+ | Some "asset" ->
179179+ (match List.assoc_opt "sourceUrl" content with
180180+ | Some u -> u
181181+ | None -> "karakeep-asset://" ^ id)
182182+ | _ -> "unknown://no-url")))
183183+ in
184184+185185+ {
186186+ id;
187187+ title;
188188+ url;
189189+ note;
190190+ created_at;
191191+ updated_at;
192192+ favourited = Option.value ~default:false favourited;
193193+ archived = Option.value ~default:false archived;
194194+ tags;
195195+ tagging_status;
196196+ summary;
197197+ content;
198198+ assets;
199199+ }
200200+ in
201201+202202+ Jsont.Object.map ~kind make
203203+ |> Jsont.Object.mem "id" Jsont.string ~enc:id
204204+ |> Jsont.Object.opt_mem "title" Jsont.string ~enc:title
205205+ |> Jsont.Object.opt_mem "url" Jsont.string ~enc:(fun t -> Some t.url)
206206+ |> Jsont.Object.opt_mem "note" Jsont.string ~enc:note
207207+ |> Jsont.Object.mem "createdAt" Rfc3339.jsont ~enc:created_at
208208+ |> Jsont.Object.opt_mem "updatedAt" Rfc3339.jsont ~enc:updated_at
209209+ |> Jsont.Object.opt_mem "favourited" Jsont.bool ~enc:(fun t -> Some t.favourited)
210210+ |> Jsont.Object.opt_mem "archived" Jsont.bool ~enc:(fun t -> Some t.archived)
211211+ |> Jsont.Object.opt_mem "tags" (Jsont.list Tag.jsont)
212212+ ~enc:(fun t -> if t.tags = [] then None else
213213+ Some (List.map (fun name -> Tag.make name json_mems_empty) t.tags))
214214+ |> Jsont.Object.opt_mem "taggingStatus" Jsont.string ~enc:tagging_status
215215+ |> Jsont.Object.opt_mem "summary" Jsont.string ~enc:summary
216216+ |> Jsont.Object.opt_mem "content" Jsont.json
217217+ ~enc:(fun t -> if t.content = [] then None else
218218+ Some (Jsont.Object (ContentField.to_json_mems t.content, Jsont.Meta.none)))
219219+ |> Jsont.Object.opt_mem "assets" (Jsont.list Asset.jsont)
220220+ ~enc:(fun t -> if t.assets = [] then None else
221221+ Some (List.map (fun (id, asset_type) ->
222222+ Asset.make id asset_type json_mems_empty) t.assets))
223223+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
224224+ |> Jsont.Object.finish
225225+end
226226+227227+(* Compatibility type aliases and accessors *)
228228+type bookmark = Bookmark.t
229229+let bookmark_id = Bookmark.id
230230+let bookmark_title = Bookmark.title
231231+let bookmark_url = Bookmark.url
232232+let bookmark_note = Bookmark.note
233233+let bookmark_created_at = Bookmark.created_at
234234+let bookmark_updated_at = Bookmark.updated_at
235235+let bookmark_favourited = Bookmark.favourited
236236+let bookmark_archived = Bookmark.archived
237237+let bookmark_tags = Bookmark.tags
238238+let bookmark_tagging_status = Bookmark.tagging_status
239239+let bookmark_summary = Bookmark.summary
240240+let bookmark_content = Bookmark.content
241241+let bookmark_assets = Bookmark.assets
242242+243243+(** Karakeep API response containing bookmarks *)
244244+module BookmarkResponse = struct
245245+ type t = {
246246+ total: int;
247247+ data: bookmark list;
248248+ next_cursor: string option;
249249+ }
250250+251251+ let make total data next_cursor = { total; data; next_cursor }
252252+ let total t = t.total
253253+ let data t = t.data
254254+ let next_cursor t = t.next_cursor
255255+256256+ (* Format 1: {total, data, nextCursor} *)
257257+ let format1_jsont =
258258+ let kind = "BookmarkResponse" in
259259+ let make total data next_cursor _unknown =
260260+ { total; data; next_cursor }
261261+ in
262262+ Jsont.Object.map ~kind make
263263+ |> Jsont.Object.mem "total" Jsont.int ~enc:total
264264+ |> Jsont.Object.mem "data" (Jsont.list Bookmark.jsont) ~enc:data
265265+ |> Jsont.Object.opt_mem "nextCursor" Jsont.string ~enc:next_cursor
266266+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
267267+ |> Jsont.Object.finish
268268+269269+ (* Format 2: {bookmarks, nextCursor} *)
270270+ let format2_jsont =
271271+ let kind = "BookmarkResponse" in
272272+ let make data next_cursor _unknown =
273273+ { total = List.length data; data; next_cursor }
274274+ in
275275+ Jsont.Object.map ~kind make
276276+ |> Jsont.Object.mem "bookmarks" (Jsont.list Bookmark.jsont) ~enc:data
277277+ |> Jsont.Object.opt_mem "nextCursor" Jsont.string ~enc:next_cursor
278278+ |> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
279279+ |> Jsont.Object.finish
280280+end
281281+282282+(* Compatibility type aliases and accessors *)
283283+type bookmark_response = BookmarkResponse.t
284284+let response_total = BookmarkResponse.total
285285+let response_data = BookmarkResponse.data
286286+let response_next_cursor = BookmarkResponse.next_cursor
172287173288(** Parse a Karakeep bookmark response - handles multiple API response formats *)
174174-let parse_bookmark_response json =
175175- Log.debug (fun m -> m "Parsing API response: %s" (J.value_to_string json));
289289+let parse_bookmark_response json_str =
290290+ Log.debug (fun m -> m "Parsing API response (%d bytes)" (String.length json_str));
176291177292 (* Try format 1: {total: int, data: [...], nextCursor?: string} *)
178293 let try_format1 () =
179294 Log.debug (fun m -> m "Trying format 1: {total, data, nextCursor}");
180180- let total = J.find json ["total"] |> J.get_int in
181181- let bookmarks_json = J.find json ["data"] in
182182- let data = J.get_list parse_bookmark bookmarks_json in
183183- let next_cursor =
184184- try Some (J.find json ["nextCursor"] |> J.get_string)
185185- with _ -> None
186186- in
187187- Log.debug (fun m -> m "Successfully parsed format 1: %d bookmarks" (List.length data));
188188- { total; data; next_cursor }
295295+ match Jsont_bytesrw.decode_string' BookmarkResponse.format1_jsont json_str with
296296+ | Ok response ->
297297+ Log.debug (fun m -> m "Successfully parsed format 1: %d bookmarks" (List.length response.data));
298298+ response
299299+ | Error e ->
300300+ Log.debug (fun m -> m "Format 1 failed: %s" (Jsont.Error.to_string e));
301301+ raise Not_found
189302 in
190303191191- (* Try format 2: {bookmarks: [...], nextCursor?: string} - no total field *)
304304+ (* Try format 2: {bookmarks: [...], nextCursor?: string} *)
192305 let try_format2 () =
193306 Log.debug (fun m -> m "Trying format 2: {bookmarks, nextCursor}");
194194- let bookmarks_json = J.find json ["bookmarks"] in
195195- let data = J.get_list parse_bookmark bookmarks_json in
196196- let next_cursor =
197197- try Some (J.find json ["nextCursor"] |> J.get_string)
198198- with _ -> None
199199- in
200200- (* Calculate total from data length when total field is missing *)
201201- let total = List.length data in
202202- Log.debug (fun m -> m "Successfully parsed format 2: %d bookmarks" total);
203203- { total; data; next_cursor }
204204- in
205205-206206- (* Try format 3: API error response {error: string, message?: string} *)
207207- let try_error_format () =
208208- Log.debug (fun m -> m "Checking for API error response");
209209- let error = J.find json ["error"] |> J.get_string in
210210- let message =
211211- try J.find json ["message"] |> J.get_string
212212- with _ -> "Unknown error"
213213- in
214214- Log.err (fun m -> m "API returned error: %s - %s" error message);
215215- { total = 0; data = []; next_cursor = None }
307307+ match Jsont_bytesrw.decode_string' BookmarkResponse.format2_jsont json_str with
308308+ | Ok response ->
309309+ Log.debug (fun m -> m "Successfully parsed format 2: %d bookmarks" (List.length response.data));
310310+ response
311311+ | Error e ->
312312+ Log.debug (fun m -> m "Format 2 failed: %s" (Jsont.Error.to_string e));
313313+ raise Not_found
216314 in
217315218218- (* Try format 4: Plain array at root level *)
316316+ (* Try format 3: Plain array at root level *)
219317 let try_array_format () =
220220- Log.debug (fun m -> m "Trying format 4: array at root");
221221- match json with
222222- | `A _ ->
223223- let data = J.get_list parse_bookmark json in
318318+ Log.debug (fun m -> m "Trying format 3: array at root");
319319+ let array_jsont = Jsont.list Bookmark.jsont in
320320+ match Jsont_bytesrw.decode_string' array_jsont json_str with
321321+ | Ok data ->
224322 Log.debug (fun m -> m "Successfully parsed array format: %d bookmarks" (List.length data));
225225- { total = List.length data; data; next_cursor = None }
226226- | _ -> raise Not_found
323323+ BookmarkResponse.make (List.length data) data None
324324+ | Error e ->
325325+ Log.debug (fun m -> m "Array format failed: %s" (Jsont.Error.to_string e));
326326+ raise Not_found
227327 in
228328229329 (* Try each format in order *)
230330 try try_format1 ()
231231- with _ -> (
331331+ with Not_found -> (
232332 try try_format2 ()
233233- with _ -> (
234234- try try_error_format ()
235235- with _ -> (
236236- try try_array_format ()
237237- with _ ->
238238- Log.err (fun m -> m "Failed to parse response in any known format");
239239- Log.debug (fun m -> m "JSON keys: %s"
240240- (match json with
241241- | `O fields -> String.concat ", " (List.map fst fields)
242242- | _ -> "not an object"));
243243- { total = 0; data = []; next_cursor = None }
244244- )
333333+ with Not_found -> (
334334+ try try_array_format ()
335335+ with Not_found ->
336336+ Log.err (fun m -> m "Failed to parse response in any known format");
337337+ Log.debug (fun m -> m "Response preview: %s"
338338+ (if String.length json_str > 200 then String.sub json_str 0 200 ^ "..." else json_str));
339339+ BookmarkResponse.make 0 [] None
245340 )
246341 )
247342···277372 match Requests_json_api.check_ok response with
278373 | Ok body_str ->
279374 Log.debug (fun m -> m "Received %d bytes of response data" (String.length body_str));
280280- (try
281281- let json = J.from_string body_str in
282282- parse_bookmark_response json
283283- with e ->
284284- Log.err (fun m -> m "JSON parsing error: %s" (Printexc.to_string e));
285285- Log.debug (fun m -> m "Response body (first 200 chars): %s"
286286- (if String.length body_str > 200 then String.sub body_str 0 200 ^ "..." else body_str));
287287- raise e)
375375+ parse_bookmark_response body_str
288376 | Error (status_code, _) ->
289377 Log.err (fun m -> m "HTTP error %d" status_code);
290378 failwith (Fmt.str "HTTP error: %d" status_code)
···325413 | _ -> all_bookmarks
326414 in
327415328328- (* Determine if more pages are available:
329329- - If next_cursor is present, there are definitely more pages
330330- - If no next_cursor and we got fewer items than page_size, we're done
331331- - If no next_cursor and total is reliable (> current count), there may be more *)
416416+ (* Determine if more pages are available *)
332417 let more_available =
333418 match response.next_cursor with
334419 | Some _ ->
···338423 let current_count = List.length all_bookmarks in
339424 let got_full_page = List.length response.data = page_size in
340425 let total_indicates_more = response.total > current_count in
341341- (* If we got a full page and total indicates more, continue *)
342426 let has_more = got_full_page && total_indicates_more in
343427 if has_more then
344428 Log.debug (fun m -> m "More pages likely available (%d fetched < %d total)"
···380464 let response = Requests.get client.http_client ~headers url in
381465 match check_ok response with
382466 | Ok body_str ->
383383- let json = J.from_string body_str in
384384- parse_bookmark json
467467+ (match Jsont_bytesrw.decode_string' Bookmark.jsont body_str with
468468+ | Ok bookmark -> bookmark
469469+ | Error e ->
470470+ failwith (Fmt.str "Failed to parse bookmark: %s" (Jsont.Error.to_string e)))
385471 | Error (status_code, _) ->
386472 failwith (Fmt.str "HTTP error: %d" status_code)
387473···405491406492(** Create a new bookmark in Karakeep with optional tags *)
407493let create_bookmark client ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) () =
494494+ let meta = Jsont.Meta.none in
408495 let body_obj = [
409409- ("type", `String "link");
410410- ("url", `String url);
411411- ("favourited", `Bool favourited);
412412- ("archived", `Bool archived);
496496+ (("type", meta), Jsont.String ("link", meta));
497497+ (("url", meta), Jsont.String (url, meta));
498498+ (("favourited", meta), Jsont.Bool (favourited, meta));
499499+ (("archived", meta), Jsont.Bool (archived, meta));
413500 ] in
414501415502 let body_obj = match title with
416416- | Some title_str -> ("title", `String title_str) :: body_obj
503503+ | Some title_str -> (("title", meta), Jsont.String (title_str, meta)) :: body_obj
417504 | None -> body_obj
418505 in
419506420507 let body_obj = match note with
421421- | Some note_str -> ("note", `String note_str) :: body_obj
508508+ | Some note_str -> (("note", meta), Jsont.String (note_str, meta)) :: body_obj
422509 | None -> body_obj
423510 in
424511425425- let body_json = `O body_obj in
426426- let body_str = J.to_string body_json in
512512+ let body_json = Jsont.Object (body_obj, meta) in
513513+ let body_str = match Jsont_bytesrw.encode_string' Jsont.json body_json with
514514+ | Ok s -> s
515515+ | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
516516+ in
427517428518 let headers = Requests.Headers.empty
429519 |> Requests.Headers.set "Authorization" ("Bearer " ^ client.api_key)
···438528 let status_code = Requests.Response.status_code response in
439529 if status_code = 201 || status_code = 200 then begin
440530 let body_str = read_body response in
441441- let json = J.from_string body_str in
442442- let bookmark = parse_bookmark json in
531531+ let bookmark = match Jsont_bytesrw.decode_string' Bookmark.jsont body_str with
532532+ | Ok b -> b
533533+ | Error e -> failwith (Fmt.str "Failed to parse created bookmark: %s" (Jsont.Error.to_string e))
534534+ in
443535444536 match tags with
445537 | Some tag_list when tag_list <> [] ->
446538 let tag_objects = List.map (fun tag_name ->
447447- `O [("tagName", `String tag_name)]
539539+ Jsont.Object ([(("tagName", meta), Jsont.String (tag_name, meta))], meta)
448540 ) tag_list in
449541450450- let tags_body = `O [("tags", `A tag_objects)] in
451451- let tags_body_str = J.to_string tags_body in
542542+ let tags_body = Jsont.Object ([(("tags", meta), Jsont.Array (tag_objects, meta))], meta) in
543543+ let tags_body_str = match Jsont_bytesrw.encode_string' Jsont.json tags_body with
544544+ | Ok s -> s
545545+ | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
546546+ in
452547453548 let tags_url = client.base_url / "api/v1/bookmarks" / bookmark.id / "tags" in
454549 let tags_body = Requests.Body.of_string Requests.Mime.json tags_body_str in
···464559 let error_body = read_body response in
465560 failwith (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body)
466561 end
467467-468468-(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure *)
469469-let to_bushel_link ?base_url bookmark =
470470- let description =
471471- match bookmark.title with
472472- | Some title when title <> "" -> title
473473- | _ ->
474474- let content_title = List.assoc_opt "title" bookmark.content in
475475- match content_title with
476476- | Some title when title <> "" && title <> "null" -> title
477477- | _ -> bookmark.url
478478- in
479479- let date = Ptime.to_date bookmark.created_at in
480480-481481- let metadata =
482482- (match bookmark.summary with Some s -> [("summary", s)] | None -> []) @
483483- (List.filter_map (fun (id, asset_type) ->
484484- match asset_type with
485485- | "screenshot" | "bannerImage" -> Some (asset_type, id)
486486- | _ -> None
487487- ) bookmark.assets) @
488488- (List.filter_map (fun (k, v) ->
489489- if k = "favicon" && v <> "" && v <> "null" then Some ("favicon", v) else None
490490- ) bookmark.content)
491491- in
492492-493493- let karakeep =
494494- match base_url with
495495- | Some url ->
496496- Some {
497497- Bushel.Link.remote_url = url;
498498- id = bookmark.id;
499499- tags = bookmark.tags;
500500- metadata = metadata;
501501- }
502502- | None -> None
503503- in
504504-505505- let bushel_slugs =
506506- List.filter_map (fun tag ->
507507- if String.starts_with ~prefix:"bushel:" tag then
508508- Some (String.sub tag 7 (String.length tag - 7))
509509- else
510510- None
511511- ) bookmark.tags
512512- in
513513-514514- let bushel =
515515- if bushel_slugs = [] then None
516516- else Some { Bushel.Link.slugs = bushel_slugs; tags = [] }
517517- in
518518-519519- { Bushel.Link.url = bookmark.url; date; description; karakeep; bushel }
+23-28
stack/karakeep/karakeep.mli
···1919 t
20202121(** Type representing a Karakeep bookmark *)
2222-type bookmark = {
2323- id: string;
2424- title: string option;
2525- url: string;
2626- note: string option;
2727- created_at: Ptime.t;
2828- updated_at: Ptime.t option;
2929- favourited: bool;
3030- archived: bool;
3131- tags: string list;
3232- tagging_status: string option;
3333- summary: string option;
3434- content: (string * string) list;
3535- assets: (string * string) list;
3636-}
2222+type bookmark
2323+2424+(** Bookmark accessors *)
2525+val bookmark_id : bookmark -> string
2626+val bookmark_title : bookmark -> string option
2727+val bookmark_url : bookmark -> string
2828+val bookmark_note : bookmark -> string option
2929+val bookmark_created_at : bookmark -> Ptime.t
3030+val bookmark_updated_at : bookmark -> Ptime.t option
3131+val bookmark_favourited : bookmark -> bool
3232+val bookmark_archived : bookmark -> bool
3333+val bookmark_tags : bookmark -> string list
3434+val bookmark_tagging_status : bookmark -> string option
3535+val bookmark_summary : bookmark -> string option
3636+val bookmark_content : bookmark -> (string * string) list
3737+val bookmark_assets : bookmark -> (string * string) list
37383839(** Type for Karakeep API response containing bookmarks *)
3939-type bookmark_response = {
4040- total: int;
4141- data: bookmark list;
4242- next_cursor: string option;
4343-}
4040+type bookmark_response
44414545-(** Parse a single bookmark from Karakeep JSON *)
4646-val parse_bookmark : Ezjsonm.value -> bookmark
4242+(** Bookmark response accessors *)
4343+val response_total : bookmark_response -> int
4444+val response_data : bookmark_response -> bookmark list
4545+val response_next_cursor : bookmark_response -> string option
47464848-(** Parse a Karakeep bookmark response *)
4949-val parse_bookmark_response : Ezjsonm.value -> bookmark_response
4747+(** Parse a Karakeep bookmark response from a JSON string *)
4848+val parse_bookmark_response : string -> bookmark_response
50495150(** Fetch bookmarks from a Karakeep instance with pagination support
5251 @param client Karakeep client instance
···9291 t ->
9392 string ->
9493 bookmark
9595-9696-(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure
9797- @param base_url Optional base URL of the Karakeep instance (for karakeep_id) *)
9898-val to_bushel_link : ?base_url:string -> bookmark -> Bushel.Link.t
999410095(** Fetch an asset from the Karakeep server as a binary string
10196 @param client Karakeep client instance
···191191 if String.length body_str > 0 &&
192192 (body_str.[0] = '{' || body_str.[0] = '[') then
193193 try
194194- let json = Yojson.Safe.from_string body_str in
195195- if not quiet then Fmt.pr "[%s]:@." url_str;
196196- Fmt.pr "%a@." (Yojson.Safe.pretty_print ~std:true) json
194194+ match Jsont_bytesrw.decode_string' Jsont.json body_str with
195195+ | Ok json ->
196196+ (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Jsont.json json with
197197+ | Ok pretty ->
198198+ if not quiet then Fmt.pr "[%s]:@." url_str;
199199+ print_string pretty
200200+ | Error _ ->
201201+ if not quiet then Fmt.pr "[%s]:@." url_str;
202202+ print_string body_str)
203203+ | Error _ ->
204204+ if not quiet then Fmt.pr "[%s]:@." url_str;
205205+ print_string body_str
197206 with _ ->
198207 if not quiet then Fmt.pr "[%s]:@." url_str;
199208 print_string body_str
···4343 (Eio.Path.native_exn file) (Mime.to_string mime));
4444 File { file; mime }
45454646-type json =
4747- [ `Null | `Bool of bool | `Float of float | `String of string
4848- | `A of json list | `O of (string * json) list ]
4949-5050-let json json_value =
5151- (* Encode json value to a JSON string *)
5252- let buffer = Buffer.create 1024 in
5353- let encoder = Jsonm.encoder ~minify:true (`Buffer buffer) in
5454-5555- let enc e l =
5656- match Jsonm.encode e (`Lexeme l) with
5757- | `Ok -> ()
5858- | `Partial -> failwith "Unexpected partial with buffer destination"
4646+(* For simple JSON encoding, we just take a Jsont.json value and encode it *)
4747+let json (json_value : Jsont.json) =
4848+ let content = match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value with
4949+ | Ok s -> s
5050+ | Error e ->
5151+ let msg = Jsont.Error.to_string e in
5252+ failwith (Printf.sprintf "Failed to encode JSON: %s" msg)
5953 in
5454+ String { content; mime = Mime.json }
60556161- let rec encode_value v k e =
6262- match v with
6363- | `A vs -> encode_array vs k e
6464- | `O ms -> encode_object ms k e
6565- | `Null | `Bool _ | `Float _ | `String _ as v -> enc e v; k e
6666- and encode_array vs k e =
6767- enc e `As;
6868- encode_array_values vs k e
6969- and encode_array_values vs k e =
7070- match vs with
7171- | v :: vs' -> encode_value v (encode_array_values vs' k) e
7272- | [] -> enc e `Ae; k e
7373- and encode_object ms k e =
7474- enc e `Os;
7575- encode_object_members ms k e
7676- and encode_object_members ms k e =
7777- match ms with
7878- | (n, v) :: ms' ->
7979- enc e (`Name n);
8080- encode_value v (encode_object_members ms' k) e
8181- | [] -> enc e `Oe; k e
8282- in
8383-8484- let finish e =
8585- match Jsonm.encode e `End with
8686- | `Ok -> ()
8787- | `Partial -> failwith "Unexpected partial at end"
8888- in
8989-9090- encode_value json_value finish encoder;
9191-9292- String { content = Buffer.contents buffer; mime = Mime.json }
9393-5656+(* JSON streaming using jsont - we encode the value to string and stream it *)
9457module Json_stream_source = struct
9595- type encode_state =
9696- | Ready (* Ready to encode new lexemes *)
9797- | NeedAwait (* Need to send `Await after previous `Partial *)
9898- | Finished (* All done *)
9999-10058 type t = {
101101- encoder : Jsonm.encoder;
102102- mutable buffer : bytes;
103103- mutable buffer_offset : int;
104104- mutable buffer_len : int;
105105- mutable pending_lexemes : Jsonm.lexeme Queue.t;
106106- mutable encode_state : encode_state;
107107- mutable end_signaled : bool;
108108- writer : (Jsonm.lexeme -> unit) -> unit;
5959+ mutable content : string;
6060+ mutable offset : int;
10961 }
11062111111- let rec single_read t dst =
112112- if t.encode_state = Finished && t.buffer_offset >= t.buffer_len then
6363+ let single_read t dst =
6464+ if t.offset >= String.length t.content then
11365 raise End_of_file
114114- else if t.buffer_offset < t.buffer_len then begin
115115- (* We have data in buffer to copy *)
116116- let available = t.buffer_len - t.buffer_offset in
6666+ else begin
6767+ let available = String.length t.content - t.offset in
11768 let to_copy = min (Cstruct.length dst) available in
118118- Cstruct.blit_from_bytes t.buffer t.buffer_offset dst 0 to_copy;
119119- t.buffer_offset <- t.buffer_offset + to_copy;
6969+ Cstruct.blit_from_string t.content t.offset dst 0 to_copy;
7070+ t.offset <- t.offset + to_copy;
12071 to_copy
121121- end else begin
122122- (* Buffer empty, need to generate more data *)
123123- t.buffer_offset <- 0;
124124- t.buffer_len <- 0;
125125- Jsonm.Manual.dst t.encoder t.buffer 0 (Bytes.length t.buffer);
126126-127127- let rec process_encoding () =
128128- match t.encode_state with
129129- | NeedAwait ->
130130- (* Send `Await after previous `Partial *)
131131- (match Jsonm.encode t.encoder `Await with
132132- | `Ok ->
133133- t.encode_state <- Ready;
134134- process_encoding ()
135135- | `Partial ->
136136- t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
137137- | Ready when not (Queue.is_empty t.pending_lexemes) ->
138138- (* Encode next lexeme *)
139139- let lexeme = Queue.take t.pending_lexemes in
140140- (match Jsonm.encode t.encoder (`Lexeme lexeme) with
141141- | `Ok ->
142142- (* Successfully encoded, continue with next *)
143143- process_encoding ()
144144- | `Partial ->
145145- (* Buffer full, need to flush and await
146146- Note: The lexeme is partially encoded in the encoder's internal state,
147147- we don't need to re-queue it. After `Await, the encoder continues. *)
148148- t.encode_state <- NeedAwait;
149149- t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
150150- | Ready when Queue.is_empty t.pending_lexemes && not t.end_signaled ->
151151- (* All lexemes done, signal end *)
152152- t.end_signaled <- true;
153153- (match Jsonm.encode t.encoder `End with
154154- | `Ok ->
155155- t.encode_state <- Finished;
156156- t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder
157157- | `Partial ->
158158- t.encode_state <- NeedAwait;
159159- t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
160160- | Ready when t.end_signaled ->
161161- (* Continue trying to finish *)
162162- (match Jsonm.encode t.encoder `End with
163163- | `Ok ->
164164- t.encode_state <- Finished;
165165- t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder
166166- | `Partial ->
167167- t.encode_state <- NeedAwait;
168168- t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
169169- | Finished ->
170170- (* All done *)
171171- ()
172172- | _ -> ()
173173- in
174174- process_encoding ();
175175-176176- if t.buffer_len > 0 then
177177- single_read t dst
178178- else if t.encode_state = Finished then
179179- raise End_of_file
180180- else
181181- (* This shouldn't happen - we should always produce some data or be finished *)
182182- raise End_of_file
18372 end
1847318574 let read_methods = []
18675end
18776188188-let json_stream_source_create writer =
189189- let buffer_size = 4096 in
190190- let buffer = Bytes.create buffer_size in
191191- let encoder = Jsonm.encoder ~minify:true (`Manual) in
192192- let pending_lexemes = Queue.create () in
193193-194194- (* Call the writer to populate the queue *)
195195- let encode_lexeme lexeme = Queue.add lexeme pending_lexemes in
196196- writer encode_lexeme;
197197-198198- let t = {
199199- Json_stream_source.encoder;
200200- buffer;
201201- buffer_offset = 0;
202202- buffer_len = 0;
203203- pending_lexemes;
204204- encode_state = Ready;
205205- end_signaled = false;
206206- writer;
207207- } in
7777+let json_stream_source_create json_value =
7878+ (* Encode the entire JSON value to string with minified format *)
7979+ let content = match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value with
8080+ | Ok s -> s
8181+ | Error e ->
8282+ let msg = Jsont.Error.to_string e in
8383+ failwith (Printf.sprintf "Failed to encode JSON stream: %s" msg)
8484+ in
8585+ let t = { Json_stream_source.content; offset = 0 } in
20886 let ops = Eio.Flow.Pi.source (module Json_stream_source) in
20987 Eio.Resource.T (t, ops)
21088211211-let json_stream writer =
212212- let source = json_stream_source_create writer in
8989+let json_stream json_value =
9090+ let source = json_stream_source_create json_value in
21391 Stream { source; mime = Mime.json; length = None }
2149221593let text content =
+14-31
stack/requests/lib/body.mli
···58585959(** {1 Convenience Constructors} *)
60606161-type json =
6262- [ `Null | `Bool of bool | `Float of float | `String of string
6363- | `A of json list | `O of (string * json) list ]
6464-(** JSON value representation, compatible with Jsonm's json type. *)
6565-6666-val json : json -> t
6767-(** [json value] creates a JSON body from a json value.
6161+val json : Jsont.json -> t
6262+(** [json value] creates a JSON body from a Jsont.json value.
6863 The value is encoded to a JSON string with Content-Type: application/json.
69647065 Example:
7166 {[
7272- let body = Body.json (`O [
7373- ("status", `String "success");
7474- ("count", `Float 42.);
7575- ("items", `A [`String "first"; `String "second"])
7676- ])
6767+ let body = Body.json (Jsont.Object ([
6868+ ("status", Jsont.String "success");
6969+ ("count", Jsont.Number 42.);
7070+ ("items", Jsont.Array ([Jsont.String "first"; Jsont.String "second"], Jsont.Meta.none))
7171+ ], Jsont.Meta.none))
7772 ]}
7873*)
79748080-val json_stream : ((Jsonm.lexeme -> unit) -> unit) -> t
8181-(** [json_stream writer] creates a streaming JSON body using jsonm.
8282- The [writer] function is called with a callback that accepts jsonm lexemes
8383- to encode. The body will be streamed as the lexemes are produced.
7575+val json_stream : Jsont.json -> t
7676+(** [json_stream json_value] creates a streaming JSON body from a Jsont.json value.
7777+ The JSON value will be encoded to a minified JSON string and streamed.
84788579 Example:
8680 {[
8787- let body = Body.json_stream (fun encode ->
8888- encode `Os; (* Start object *)
8989- encode (`Name "users");
9090- encode `As; (* Start array *)
9191- List.iter (fun user ->
9292- encode `Os;
9393- encode (`Name "id");
9494- encode (`Float (float_of_int user.id));
9595- encode (`Name "name");
9696- encode (`String user.name);
9797- encode `Oe (* End object *)
9898- ) users;
9999- encode `Ae; (* End array *)
100100- encode `Oe (* End object *)
101101- )
8181+ let large_data = Jsont.Object ([
8282+ ("users", Jsont.Array ([...], Jsont.Meta.none))
8383+ ], Jsont.Meta.none) in
8484+ let body = Body.json_stream large_data
10285 ]}
10386*)
10487
+98-30
stack/requests/lib/cache.ml
···7878 ) parts
7979 | None -> None
80808181+(* JSON codec for cache metadata *)
8282+module Metadata = struct
8383+ type t = {
8484+ status_code : int;
8585+ headers : (string * string) list;
8686+ }
8787+8888+ let make status_code headers = { status_code; headers }
8989+ let status_code t = t.status_code
9090+ let headers t = t.headers
9191+9292+ let t_jsont =
9393+ let header_pair_jsont =
9494+ let dec x y = (x, y) in
9595+ let enc (x, y) i = if i = 0 then x else y in
9696+ Jsont.t2 ~dec ~enc Jsont.string
9797+ in
9898+ Jsont.Object.map ~kind:"CacheMetadata" make
9999+ |> Jsont.Object.mem "status_code" Jsont.int ~enc:status_code
100100+ |> Jsont.Object.mem "headers" (Jsont.list header_pair_jsont) ~enc:headers
101101+ |> Jsont.Object.finish
102102+end
103103+81104let serialize_metadata ~status ~headers =
82105 let status_code = Cohttp.Code.code_of_status status in
83106 let headers_assoc = Cohttp.Header.to_list headers in
8484- let json = `Assoc [
8585- ("status_code", `Int status_code);
8686- ("headers", `Assoc (List.map (fun (k, v) -> (k, `String v)) headers_assoc));
8787- ] in
8888- Yojson.Basic.to_string json
107107+ let metadata = Metadata.make status_code headers_assoc in
108108+ match Jsont_bytesrw.encode_string' Metadata.t_jsont metadata with
109109+ | Ok s -> s
110110+ | Error e -> failwith (Fmt.str "Failed to serialize metadata: %s" (Jsont.Error.to_string e))
8911190112let deserialize_metadata json_str =
91113 try
9292- let open Yojson.Basic.Util in
9393- let json = Yojson.Basic.from_string json_str in
9494- let status_code = json |> member "status_code" |> to_int in
9595- let status = Cohttp.Code.status_of_code status_code in
9696- let headers_json = json |> member "headers" |> to_assoc in
9797- let headers = headers_json
9898- |> List.map (fun (k, v) -> (k, to_string v))
9999- |> Cohttp.Header.of_list in
100100- Some (status, headers)
114114+ match Jsont_bytesrw.decode_string' Metadata.t_jsont json_str with
115115+ | Ok metadata ->
116116+ let status = Cohttp.Code.status_of_code (Metadata.status_code metadata) in
117117+ let headers = Cohttp.Header.of_list (Metadata.headers metadata) in
118118+ Some (status, headers)
119119+ | Error _ -> None
101120 with _ -> None
102121103122let get t ~method_ ~url ~headers =
···396415 | None -> ());
397416 Hashtbl.clear t.memory_cache
398417418418+module Stats = struct
419419+ type cacheio_stats = {
420420+ total_entries : int;
421421+ total_bytes : int;
422422+ expired_entries : int;
423423+ pinned_entries : int;
424424+ temporary_entries : int;
425425+ }
426426+427427+ type t = {
428428+ memory_cache_entries : int;
429429+ cache_backend : string;
430430+ enabled : bool;
431431+ cache_get_requests : bool;
432432+ cache_range_requests : bool;
433433+ cacheio_stats : cacheio_stats option;
434434+ }
435435+436436+ let make_cacheio_stats total_entries total_bytes expired_entries pinned_entries temporary_entries =
437437+ { total_entries; total_bytes; expired_entries; pinned_entries; temporary_entries }
438438+439439+ let make memory_cache_entries cache_backend enabled cache_get_requests cache_range_requests cacheio_stats =
440440+ { memory_cache_entries; cache_backend; enabled; cache_get_requests; cache_range_requests; cacheio_stats }
441441+442442+ let cacheio_stats_jsont =
443443+ Jsont.Object.map ~kind:"CacheioStats" make_cacheio_stats
444444+ |> Jsont.Object.mem "total_entries" Jsont.int ~enc:(fun t -> t.total_entries)
445445+ |> Jsont.Object.mem "total_bytes" Jsont.int ~enc:(fun t -> t.total_bytes)
446446+ |> Jsont.Object.mem "expired_entries" Jsont.int ~enc:(fun t -> t.expired_entries)
447447+ |> Jsont.Object.mem "pinned_entries" Jsont.int ~enc:(fun t -> t.pinned_entries)
448448+ |> Jsont.Object.mem "temporary_entries" Jsont.int ~enc:(fun t -> t.temporary_entries)
449449+ |> Jsont.Object.finish
450450+451451+ let t_jsont =
452452+ Jsont.Object.map ~kind:"CacheStats" make
453453+ |> Jsont.Object.mem "memory_cache_entries" Jsont.int ~enc:(fun t -> t.memory_cache_entries)
454454+ |> Jsont.Object.mem "cache_backend" Jsont.string ~enc:(fun t -> t.cache_backend)
455455+ |> Jsont.Object.mem "enabled" Jsont.bool ~enc:(fun t -> t.enabled)
456456+ |> Jsont.Object.mem "cache_get_requests" Jsont.bool ~enc:(fun t -> t.cache_get_requests)
457457+ |> Jsont.Object.mem "cache_range_requests" Jsont.bool ~enc:(fun t -> t.cache_range_requests)
458458+ |> Jsont.Object.opt_mem "cacheio_stats" cacheio_stats_jsont ~enc:(fun t -> t.cacheio_stats)
459459+ |> Jsont.Object.finish
460460+461461+ let to_string t =
462462+ match Jsont_bytesrw.encode_string' ~format:Jsont.Indent t_jsont t with
463463+ | Ok s -> s
464464+ | Error e ->
465465+ let msg = Jsont.Error.to_string e in
466466+ failwith (Printf.sprintf "Failed to encode stats: %s" msg)
467467+end
468468+399469let stats t =
400470 let cacheio_stats =
401471 match t.cacheio with
402472 | Some cache ->
403473 let stats = Cacheio.stats cache in
404404- `Assoc [
405405- ("total_entries", `Int (Cacheio.Stats.entry_count stats));
406406- ("total_bytes", `Int (Int64.to_int (Cacheio.Stats.total_size stats)));
407407- ("expired_entries", `Int (Cacheio.Stats.expired_count stats));
408408- ("pinned_entries", `Int (Cacheio.Stats.pinned_count stats));
409409- ("temporary_entries", `Int (Cacheio.Stats.temporary_count stats));
410410- ]
411411- | None -> `Assoc []
474474+ Some (Stats.make_cacheio_stats
475475+ (Cacheio.Stats.entry_count stats)
476476+ (Int64.to_int (Cacheio.Stats.total_size stats))
477477+ (Cacheio.Stats.expired_count stats)
478478+ (Cacheio.Stats.pinned_count stats)
479479+ (Cacheio.Stats.temporary_count stats))
480480+ | None -> None
412481 in
413413- `Assoc [
414414- ("memory_cache_entries", `Int (Hashtbl.length t.memory_cache));
415415- ("cache_backend", `String (if Option.is_some t.cacheio then "cacheio" else "memory"));
416416- ("enabled", `Bool t.enabled);
417417- ("cache_get_requests", `Bool t.cache_get_requests);
418418- ("cache_range_requests", `Bool t.cache_range_requests);
419419- ("cacheio_stats", cacheio_stats);
420420- ]482482+ Stats.make
483483+ (Hashtbl.length t.memory_cache)
484484+ (if Option.is_some t.cacheio then "cacheio" else "memory")
485485+ t.enabled
486486+ t.cache_get_requests
487487+ t.cache_range_requests
488488+ cacheio_stats
···11-(** Test stateless One API - each request opens a fresh connection *)
22-33-open Eio.Std
44-55-let test_one_stateless () =
66- (* Initialize RNG for TLS *)
77- Mirage_crypto_rng_unix.use_default ();
88-99- Eio_main.run @@ fun env ->
1010- Switch.run @@ fun sw ->
1111-1212- (* Configure logging to see One request activity *)
1313- Logs.set_reporter (Logs_fmt.reporter ());
1414- Logs.set_level (Some Logs.Info);
1515- Logs.Src.set_level Requests.One.src (Some Logs.Info);
1616-1717- traceln "=== Testing One Stateless API ===\n";
1818- traceln "The One API creates fresh connections for each request (no pooling)\n";
1919-2020- (* Make multiple requests to the same host using stateless One API *)
2121- let start_time = Unix.gettimeofday () in
2222-2323- for i = 1 to 10 do
2424- traceln "Request %d:" i;
2525- let response = Requests.One.get ~sw
2626- ~clock:env#clock ~net:env#net
2727- "http://example.com"
2828- in
2929-3030- traceln " Status: %d" (Requests.Response.status_code response);
3131- traceln " Content-Length: %s"
3232- (match Requests.Response.content_length response with
3333- | Some len -> Int64.to_string len
3434- | None -> "unknown");
3535-3636- (* Connection is fresh for each request - no pooling *)
3737- traceln ""
3838- done;
3939-4040- let elapsed = Unix.gettimeofday () -. start_time in
4141- traceln "All 10 requests completed in %.2f seconds" elapsed;
4242- traceln "Average: %.2f seconds per request" (elapsed /. 10.0);
4343-4444- traceln "\n=== Test completed successfully ==="
4545-4646-let () =
4747- try
4848- test_one_stateless ()
4949- with e ->
5050- traceln "Test failed with exception: %s" (Printexc.to_string e);
5151- Printexc.print_backtrace stdout;
5252- exit 1
-899
stack/requests/test/test_requests.ml
···11-open Eio_main
22-33-let port = ref 8088
44-55-let get_free_port () =
66- let p = !port in
77- incr port;
88- p
99-1010-let string_contains s sub =
1111- try
1212- let _ = Str.search_forward (Str.regexp_string sub) s 0 in
1313- true
1414- with Not_found -> false
1515-1616-module Test_server = struct
1717- open Cohttp_eio
1818-1919- let make_server ~port handler env =
2020- let server_socket =
2121- Eio.Net.listen env#net ~sw:env#sw ~backlog:128 ~reuse_addr:true
2222- (`Tcp (Eio.Net.Ipaddr.V4.loopback, port))
2323- in
2424- let callback _conn req body =
2525- let (resp, body_content) = handler ~request:req ~body in
2626- Server.respond_string () ~status:(Http.Response.status resp)
2727- ~headers:(Http.Response.headers resp)
2828- ~body:body_content
2929- in
3030- let server = Server.make ~callback () in
3131- Server.run server_socket server ~on_error:(fun exn ->
3232- Logs.err (fun m -> m "Server error: %s" (Printexc.to_string exn))
3333- )
3434-3535- let echo_handler ~request ~body =
3636- let uri = Http.Request.resource request in
3737- let meth = Http.Request.meth request in
3838- let headers = Http.Request.headers request in
3939- let body_str = Eio.Flow.read_all body in
4040-4141- let response_body =
4242- `Assoc [
4343- "method", `String (Cohttp.Code.string_of_method meth);
4444- "uri", `String uri;
4545- "headers", `Assoc (
4646- Cohttp.Header.to_lines headers
4747- |> List.map (fun line ->
4848- match String.split_on_char ':' line with
4949- | [k; v] -> (String.trim k, `String (String.trim v))
5050- | _ -> ("", `String line)
5151- )
5252- );
5353- "body", `String body_str;
5454- ]
5555- |> Yojson.Basic.to_string
5656- in
5757-5858- let resp = Http.Response.make ~status:`OK () in
5959- let resp_headers = Cohttp.Header.add_unless_exists
6060- (Http.Response.headers resp) "content-type" "application/json"
6161- in
6262- ({ resp with headers = resp_headers }, response_body)
6363-6464- let status_handler status_code ~request:_ ~body:_ =
6565- let status = Cohttp.Code.status_of_code status_code in
6666- let resp = Http.Response.make ~status () in
6767- (resp, "")
6868-6969- let redirect_handler target_path ~request:_ ~body:_ =
7070- let resp = Http.Response.make ~status:`Moved_permanently () in
7171- let headers = Cohttp.Header.add
7272- (Http.Response.headers resp) "location" target_path
7373- in
7474- ({ resp with headers }, "")
7575-7676- let cookie_handler ~request ~body:_ =
7777- let headers = Http.Request.headers request in
7878- let cookies =
7979- match Cohttp.Header.get headers "cookie" with
8080- | Some cookie_str -> cookie_str
8181- | None -> "no cookies"
8282- in
8383-8484- let resp = Http.Response.make ~status:`OK () in
8585- let resp_headers =
8686- Http.Response.headers resp
8787- |> (fun h -> Cohttp.Header.add h "set-cookie" "test_cookie=test_value; Path=/")
8888- |> (fun h -> Cohttp.Header.add h "set-cookie" "session=abc123; Path=/; HttpOnly")
8989- in
9090- ({ resp with headers = resp_headers },
9191- cookies)
9292-9393- let auth_handler ~request ~body:_ =
9494- let headers = Http.Request.headers request in
9595- let auth_result =
9696- match Cohttp.Header.get headers "authorization" with
9797- | Some auth ->
9898- if String.starts_with ~prefix:"Bearer " auth then
9999- let token = String.sub auth 7 (String.length auth - 7) in
100100- if token = "valid_token" then "authorized"
101101- else "invalid token"
102102- else if String.starts_with ~prefix:"Basic " auth then
103103- "basic auth received"
104104- else "unknown auth"
105105- | None -> "no auth"
106106- in
107107-108108- let status =
109109- if auth_result = "authorized" || auth_result = "basic auth received"
110110- then `OK
111111- else `Unauthorized
112112- in
113113- let resp = Http.Response.make ~status () in
114114- (resp, auth_result)
115115-116116- let json_handler ~request:_ ~body =
117117- let body_str = Eio.Flow.read_all body in
118118- let json =
119119- try
120120- let parsed = Yojson.Basic.from_string body_str in
121121- `Assoc [
122122- "received", parsed;
123123- "echo", `Bool true;
124124- ]
125125- with _ ->
126126- `Assoc [
127127- "error", `String "invalid json";
128128- "received", `String body_str;
129129- ]
130130- in
131131-132132- let resp = Http.Response.make ~status:`OK () in
133133- let resp_headers = Cohttp.Header.add_unless_exists
134134- (Http.Response.headers resp) "content-type" "application/json"
135135- in
136136- ({ resp with headers = resp_headers },
137137- Yojson.Basic.to_string json)
138138-139139- let timeout_handler clock delay ~request:_ ~body:_ =
140140- Eio.Time.sleep clock delay;
141141- let resp = Http.Response.make ~status:`OK () in
142142- (resp,"delayed response")
143143-144144- let chunked_handler _clock chunks ~request:_ ~body:_ =
145145- let resp = Http.Response.make ~status:`OK () in
146146- let body_str = String.concat "" chunks in
147147- (resp,body_str)
148148-149149- let large_response_handler size ~request:_ ~body:_ =
150150- let data = String.make size 'X' in
151151- let resp = Http.Response.make ~status:`OK () in
152152- (resp,data)
153153-154154- let multipart_handler ~request ~body =
155155- let headers = Http.Request.headers request in
156156- let content_type = Cohttp.Header.get headers "content-type" in
157157- let body_str = Eio.Flow.read_all body in
158158-159159- let result =
160160- match content_type with
161161- | Some ct when String.starts_with ~prefix:"multipart/form-data" ct ->
162162- Printf.sprintf "Multipart received: %d bytes" (String.length body_str)
163163- | _ -> "Not multipart"
164164- in
165165-166166- let resp = Http.Response.make ~status:`OK () in
167167- (resp,result)
168168-169169- let router clock ~request ~body =
170170- let uri = Http.Request.resource request in
171171- match uri with
172172- | "/" | "/echo" -> echo_handler ~request ~body
173173- | "/status/200" -> status_handler 200 ~request ~body
174174- | "/status/404" -> status_handler 404 ~request ~body
175175- | "/status/500" -> status_handler 500 ~request ~body
176176- | "/redirect" -> redirect_handler "/redirected" ~request ~body
177177- | "/redirected" ->
178178- let resp = Http.Response.make ~status:`OK () in
179179- (resp,"redirect successful")
180180- | "/cookies" -> cookie_handler ~request ~body
181181- | "/auth" -> auth_handler ~request ~body
182182- | "/json" -> json_handler ~request ~body
183183- | "/timeout" -> timeout_handler clock 2.0 ~request ~body
184184- | "/chunked" ->
185185- chunked_handler clock ["chunk1"; "chunk2"; "chunk3"] ~request ~body
186186- | "/large" -> large_response_handler 10000 ~request ~body
187187- | "/multipart" -> multipart_handler ~request ~body
188188- | _ -> status_handler 404 ~request ~body
189189-190190- let start_server ~port env =
191191- Eio.Fiber.fork ~sw:env#sw (fun () ->
192192- make_server ~port (router env#clock) env
193193- );
194194- Eio.Time.sleep env#clock 0.1
195195-end
196196-197197-let test_get_request () =
198198- run @@ fun env ->
199199- Eio.Switch.run @@ fun sw ->
200200- let port = get_free_port () in
201201- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
202202-203203- let test_env = object
204204- method clock = env#clock
205205- method net = env#net
206206- method sw = sw
207207- end in
208208- Test_server.start_server ~port test_env;
209209-210210- let req = Requests.create ~sw env in
211211- let response = Requests.get req (base_url ^ "/echo") in
212212-213213- Alcotest.(check int) "GET status" 200 (Requests.Response.status_code response);
214214-215215- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
216216- let json = Yojson.Basic.from_string body_str in
217217- let method_str =
218218- json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
219219- in
220220-221221- Alcotest.(check string) "GET method" "GET" method_str
222222-223223-let test_post_request () =
224224- run @@ fun env ->
225225- Eio.Switch.run @@ fun sw ->
226226- let port = get_free_port () in
227227- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
228228-229229- let test_env = object
230230- method clock = env#clock
231231- method net = env#net
232232- method sw = sw
233233- end in
234234- Test_server.start_server ~port test_env;
235235-236236- let req = Requests.create ~sw env in
237237- let body = Requests.Body.text "test post data" in
238238- let response = Requests.post req ~body (base_url ^ "/echo") in
239239-240240- Alcotest.(check int) "POST status" 200 (Requests.Response.status_code response);
241241-242242- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
243243- let json = Yojson.Basic.from_string body_str in
244244- let received_body =
245245- json |> Yojson.Basic.Util.member "body" |> Yojson.Basic.Util.to_string
246246- in
247247-248248- Alcotest.(check string) "POST body" "test post data" received_body
249249-250250-let test_put_request () =
251251- run @@ fun env ->
252252- Eio.Switch.run @@ fun sw ->
253253- let port = get_free_port () in
254254- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
255255-256256- let test_env = object
257257- method clock = env#clock
258258- method net = env#net
259259- method sw = sw
260260- end in
261261- Test_server.start_server ~port test_env;
262262-263263- let req = Requests.create ~sw env in
264264- let body = Requests.Body.text "put data" in
265265- let response = Requests.put req ~body (base_url ^ "/echo") in
266266-267267- Alcotest.(check int) "PUT status" 200 (Requests.Response.status_code response);
268268-269269- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
270270- let json = Yojson.Basic.from_string body_str in
271271- let method_str =
272272- json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
273273- in
274274-275275- Alcotest.(check string) "PUT method" "PUT" method_str
276276-277277-let test_delete_request () =
278278- run @@ fun env ->
279279- Eio.Switch.run @@ fun sw ->
280280- let port = get_free_port () in
281281- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
282282-283283- let test_env = object
284284- method clock = env#clock
285285- method net = env#net
286286- method sw = sw
287287- end in
288288- Test_server.start_server ~port test_env;
289289-290290- let req = Requests.create ~sw env in
291291- let response = Requests.delete req (base_url ^ "/echo") in
292292-293293- Alcotest.(check int) "DELETE status" 200 (Requests.Response.status_code response);
294294-295295- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
296296- let json = Yojson.Basic.from_string body_str in
297297- let method_str =
298298- json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
299299- in
300300-301301- Alcotest.(check string) "DELETE method" "DELETE" method_str
302302-303303-let test_patch_request () =
304304- run @@ fun env ->
305305- Eio.Switch.run @@ fun sw ->
306306- let port = get_free_port () in
307307- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
308308-309309- let test_env = object
310310- method clock = env#clock
311311- method net = env#net
312312- method sw = sw
313313- end in
314314- Test_server.start_server ~port test_env;
315315-316316- let req = Requests.create ~sw env in
317317- let body = Requests.Body.of_string Requests.Mime.json {|{"patch": "data"}|} in
318318- let response = Requests.patch req ~body (base_url ^ "/echo") in
319319-320320- Alcotest.(check int) "PATCH status" 200 (Requests.Response.status_code response);
321321-322322- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
323323- let json = Yojson.Basic.from_string body_str in
324324- let method_str =
325325- json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
326326- in
327327-328328- Alcotest.(check string) "PATCH method" "PATCH" method_str
329329-330330-let test_head_request () =
331331- run @@ fun env ->
332332- Eio.Switch.run @@ fun sw ->
333333- let port = get_free_port () in
334334- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
335335-336336- let test_env = object
337337- method clock = env#clock
338338- method net = env#net
339339- method sw = sw
340340- end in
341341- Test_server.start_server ~port test_env;
342342-343343- let req = Requests.create ~sw env in
344344- let response = Requests.head req (base_url ^ "/echo") in
345345-346346- Alcotest.(check int) "HEAD status" 200 (Requests.Response.status_code response)
347347-348348-let test_options_request () =
349349- run @@ fun env ->
350350- Eio.Switch.run @@ fun sw ->
351351- let port = get_free_port () in
352352- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
353353-354354- let test_env = object
355355- method clock = env#clock
356356- method net = env#net
357357- method sw = sw
358358- end in
359359- Test_server.start_server ~port test_env;
360360-361361- let req = Requests.create ~sw env in
362362- let response = Requests.options req (base_url ^ "/echo") in
363363-364364- Alcotest.(check int) "OPTIONS status" 200 (Requests.Response.status_code response)
365365-366366-let test_custom_headers () =
367367- run @@ fun env ->
368368- Eio.Switch.run @@ fun sw ->
369369- let port = get_free_port () in
370370- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
371371-372372- let test_env = object
373373- method clock = env#clock
374374- method net = env#net
375375- method sw = sw
376376- end in
377377- Test_server.start_server ~port test_env;
378378-379379- let req = Requests.create ~sw env in
380380- let headers =
381381- Requests.Headers.empty
382382- |> Requests.Headers.set "X-Custom-Header" "custom-value"
383383- |> Requests.Headers.set "User-Agent" "test-agent"
384384- in
385385- let response = Requests.get req ~headers (base_url ^ "/echo") in
386386-387387- Alcotest.(check int) "Headers status" 200 (Requests.Response.status_code response);
388388-389389- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
390390- let json = Yojson.Basic.from_string body_str in
391391- let headers_obj = json |> Yojson.Basic.Util.member "headers" in
392392-393393- let custom_header =
394394- headers_obj
395395- |> Yojson.Basic.Util.member "x-custom-header"
396396- |> Yojson.Basic.Util.to_string_option
397397- |> Option.value ~default:""
398398- in
399399-400400- Alcotest.(check string) "Custom header" "custom-value" custom_header
401401-402402-let test_query_params () =
403403- run @@ fun env ->
404404- Eio.Switch.run @@ fun sw ->
405405- let port = get_free_port () in
406406- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
407407-408408- let test_env = object
409409- method clock = env#clock
410410- method net = env#net
411411- method sw = sw
412412- end in
413413- Test_server.start_server ~port test_env;
414414-415415- let req = Requests.create ~sw env in
416416- let params = [("key1", "value1"); ("key2", "value2")] in
417417- let response = Requests.get req ~params (base_url ^ "/echo") in
418418-419419- Alcotest.(check int) "Query params status" 200 (Requests.Response.status_code response);
420420-421421- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
422422- let json = Yojson.Basic.from_string body_str in
423423- let uri = json |> Yojson.Basic.Util.member "uri" |> Yojson.Basic.Util.to_string in
424424-425425- Alcotest.(check bool) "Query params present" true
426426- (string_contains uri "key1=value1" && string_contains uri "key2=value2")
427427-428428-let test_json_body () =
429429- run @@ fun env ->
430430- Eio.Switch.run @@ fun sw ->
431431- let port = get_free_port () in
432432- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
433433-434434- let test_env = object
435435- method clock = env#clock
436436- method net = env#net
437437- method sw = sw
438438- end in
439439- Test_server.start_server ~port test_env;
440440-441441- let req = Requests.create ~sw env in
442442- let json_data = {|{"name": "test", "value": 42}|} in
443443- let body = Requests.Body.of_string Requests.Mime.json json_data in
444444- let response = Requests.post req ~body (base_url ^ "/json") in
445445-446446- Alcotest.(check int) "JSON body status" 200 (Requests.Response.status_code response);
447447-448448- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
449449- let json = Yojson.Basic.from_string body_str in
450450- let received = json |> Yojson.Basic.Util.member "received" in
451451- let name = received |> Yojson.Basic.Util.member "name" |> Yojson.Basic.Util.to_string in
452452-453453- Alcotest.(check string) "JSON field" "test" name
454454-455455-let test_form_data () =
456456- run @@ fun env ->
457457- Eio.Switch.run @@ fun sw ->
458458- let port = get_free_port () in
459459- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
460460-461461- let test_env = object
462462- method clock = env#clock
463463- method net = env#net
464464- method sw = sw
465465- end in
466466- Test_server.start_server ~port test_env;
467467-468468- let req = Requests.create ~sw env in
469469- let form_data = [("field1", "value1"); ("field2", "value2")] in
470470- let body = Requests.Body.form form_data in
471471- let response = Requests.post req ~body (base_url ^ "/echo") in
472472-473473- Alcotest.(check int) "Form data status" 200 (Requests.Response.status_code response);
474474-475475- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
476476- let json = Yojson.Basic.from_string body_str in
477477- let received_body =
478478- json |> Yojson.Basic.Util.member "body" |> Yojson.Basic.Util.to_string
479479- in
480480-481481- Alcotest.(check bool) "Form data encoded" true
482482- (string_contains received_body "field1=value1" &&
483483- string_contains received_body "field2=value2")
484484-485485-let test_status_codes () =
486486- run @@ fun env ->
487487- Eio.Switch.run @@ fun sw ->
488488- let port = get_free_port () in
489489- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
490490-491491- let test_env = object
492492- method clock = env#clock
493493- method net = env#net
494494- method sw = sw
495495- end in
496496- Test_server.start_server ~port test_env;
497497-498498- let req = Requests.create ~sw env in
499499-500500- let resp_200 = Requests.get req (base_url ^ "/status/200") in
501501- Alcotest.(check int) "Status 200" 200 (Requests.Response.status_code resp_200);
502502-503503- let resp_404 = Requests.get req (base_url ^ "/status/404") in
504504- Alcotest.(check int) "Status 404" 404 (Requests.Response.status_code resp_404);
505505-506506- let resp_500 = Requests.get req (base_url ^ "/status/500") in
507507- Alcotest.(check int) "Status 500" 500 (Requests.Response.status_code resp_500)
508508-509509-let test_redirects () =
510510- run @@ fun env ->
511511- Eio.Switch.run @@ fun sw ->
512512- let port = get_free_port () in
513513- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
514514-515515- let test_env = object
516516- method clock = env#clock
517517- method net = env#net
518518- method sw = sw
519519- end in
520520- Test_server.start_server ~port test_env;
521521-522522- let req = Requests.create ~sw ~follow_redirects:true env in
523523- let response = Requests.get req (base_url ^ "/redirect") in
524524-525525- Alcotest.(check int) "Redirect followed" 200 (Requests.Response.status_code response);
526526-527527- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
528528- Alcotest.(check string) "Redirect result" "redirect successful" body_str
529529-530530-let test_no_redirect () =
531531- run @@ fun env ->
532532- Eio.Switch.run @@ fun sw ->
533533- let port = get_free_port () in
534534- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
535535-536536- let test_env = object
537537- method clock = env#clock
538538- method net = env#net
539539- method sw = sw
540540- end in
541541- Test_server.start_server ~port test_env;
542542-543543- let req = Requests.create ~sw env in
544544- let response = Requests.request req ~follow_redirects:false ~method_:`GET (base_url ^ "/redirect") in
545545-546546- Alcotest.(check int) "Redirect not followed" 301
547547- (Requests.Response.status_code response)
548548-549549-let test_cookies () =
550550- run @@ fun env ->
551551- Eio.Switch.run @@ fun sw ->
552552- let port = get_free_port () in
553553- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
554554-555555- let test_env = object
556556- method clock = env#clock
557557- method net = env#net
558558- method sw = sw
559559- end in
560560- Test_server.start_server ~port test_env;
561561-562562- let req = Requests.create ~sw env in
563563-564564- let _first_response = Requests.get req (base_url ^ "/cookies") in
565565-566566- let second_response = Requests.get req (base_url ^ "/cookies") in
567567- let body_str = Requests.Response.body second_response |> Eio.Flow.read_all in
568568-569569- Alcotest.(check bool) "Cookies sent back" true
570570- (string_contains body_str "test_cookie=test_value")
571571-572572-let test_bearer_auth () =
573573- run @@ fun env ->
574574- Eio.Switch.run @@ fun sw ->
575575- let port = get_free_port () in
576576- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
577577-578578- let test_env = object
579579- method clock = env#clock
580580- method net = env#net
581581- method sw = sw
582582- end in
583583- Test_server.start_server ~port test_env;
584584-585585- let req = Requests.create ~sw env in
586586- let auth = Requests.Auth.bearer ~token:"valid_token" in
587587- let response = Requests.get req ~auth (base_url ^ "/auth") in
588588-589589- Alcotest.(check int) "Bearer auth status" 200 (Requests.Response.status_code response);
590590-591591- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
592592- Alcotest.(check string) "Bearer auth result" "authorized" body_str
593593-594594-let test_basic_auth () =
595595- run @@ fun env ->
596596- Eio.Switch.run @@ fun sw ->
597597- let port = get_free_port () in
598598- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
599599-600600- let test_env = object
601601- method clock = env#clock
602602- method net = env#net
603603- method sw = sw
604604- end in
605605- Test_server.start_server ~port test_env;
606606-607607- let req = Requests.create ~sw env in
608608- let auth = Requests.Auth.basic ~username:"user" ~password:"pass" in
609609- let response = Requests.get req ~auth (base_url ^ "/auth") in
610610-611611- Alcotest.(check int) "Basic auth status" 200 (Requests.Response.status_code response);
612612-613613- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
614614- Alcotest.(check string) "Basic auth result" "basic auth received" body_str
615615-616616-let test_timeout () =
617617- run @@ fun env ->
618618- Eio.Switch.run @@ fun sw ->
619619- let port = get_free_port () in
620620- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
621621-622622- let test_env = object
623623- method clock = env#clock
624624- method net = env#net
625625- method sw = sw
626626- end in
627627- Test_server.start_server ~port test_env;
628628-629629- let req = Requests.create ~sw env in
630630- let timeout = Requests.Timeout.create ~total:0.5 () in
631631-632632- let exception_raised =
633633- try
634634- let _ = Requests.get req ~timeout (base_url ^ "/timeout") in
635635- false
636636- with _ -> true
637637- in
638638-639639- Alcotest.(check bool) "Timeout triggered" true exception_raised
640640-641641-let test_concurrent_requests () =
642642- run @@ fun env ->
643643- Eio.Switch.run @@ fun sw ->
644644- let port = get_free_port () in
645645- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
646646-647647- let test_env = object
648648- method clock = env#clock
649649- method net = env#net
650650- method sw = sw
651651- end in
652652- Test_server.start_server ~port test_env;
653653-654654- let req = Requests.create ~sw env in
655655-656656- let r1 = ref None in
657657- let r2 = ref None in
658658- let r3 = ref None in
659659-660660- Eio.Fiber.all [
661661- (fun () -> r1 := Some (Requests.get req (base_url ^ "/status/200")));
662662- (fun () -> r2 := Some (Requests.get req (base_url ^ "/status/404")));
663663- (fun () -> r3 := Some (Requests.get req (base_url ^ "/status/500")));
664664- ];
665665-666666- let r1 = Option.get !r1 in
667667- let r2 = Option.get !r2 in
668668- let r3 = Option.get !r3 in
669669-670670- Alcotest.(check int) "Concurrent 1" 200 (Requests.Response.status_code r1);
671671- Alcotest.(check int) "Concurrent 2" 404 (Requests.Response.status_code r2);
672672- Alcotest.(check int) "Concurrent 3" 500 (Requests.Response.status_code r3)
673673-674674-let test_large_response () =
675675- run @@ fun env ->
676676- Eio.Switch.run @@ fun sw ->
677677- let port = get_free_port () in
678678- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
679679-680680- let test_env = object
681681- method clock = env#clock
682682- method net = env#net
683683- method sw = sw
684684- end in
685685- Test_server.start_server ~port test_env;
686686-687687- let req = Requests.create ~sw env in
688688- let response = Requests.get req (base_url ^ "/large") in
689689-690690- Alcotest.(check int) "Large response status" 200 (Requests.Response.status_code response);
691691-692692- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
693693- Alcotest.(check int) "Large response size" 10000 (String.length body_str)
694694-695695-let test_one_module () =
696696- run @@ fun env ->
697697- Eio.Switch.run @@ fun sw ->
698698- let port = get_free_port () in
699699- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
700700-701701- let test_env = object
702702- method clock = env#clock
703703- method net = env#net
704704- method sw = sw
705705- end in
706706- Test_server.start_server ~port test_env;
707707-708708- let response = Requests.One.get ~sw
709709- ~clock:env#clock ~net:env#net
710710- (base_url ^ "/echo")
711711- in
712712-713713- Alcotest.(check int) "One module status" 200 (Requests.Response.status_code response)
714714-715715-let test_multipart () =
716716- run @@ fun env ->
717717- Eio.Switch.run @@ fun sw ->
718718- let port = get_free_port () in
719719- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
720720-721721- let test_env = object
722722- method clock = env#clock
723723- method net = env#net
724724- method sw = sw
725725- end in
726726- Test_server.start_server ~port test_env;
727727-728728- let req = Requests.create ~sw env in
729729- let parts = [
730730- { Requests.Body.name = "field1";
731731- filename = None;
732732- content_type = Requests.Mime.text;
733733- content = `String "value1" };
734734- { Requests.Body.name = "field2";
735735- filename = Some "test.txt";
736736- content_type = Requests.Mime.text;
737737- content = `String "file content" };
738738- ] in
739739- let body = Requests.Body.multipart parts in
740740- let response = Requests.post req ~body (base_url ^ "/multipart") in
741741-742742- Alcotest.(check int) "Multipart status" 200 (Requests.Response.status_code response);
743743-744744- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
745745- Alcotest.(check bool) "Multipart recognized" true
746746- (String.starts_with ~prefix:"Multipart received:" body_str)
747747-748748-let test_response_headers () =
749749- run @@ fun env ->
750750- Eio.Switch.run @@ fun sw ->
751751- let port = get_free_port () in
752752- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
753753-754754- let test_env = object
755755- method clock = env#clock
756756- method net = env#net
757757- method sw = sw
758758- end in
759759- Test_server.start_server ~port test_env;
760760-761761- let req = Requests.create ~sw env in
762762- let response = Requests.get req (base_url ^ "/json") in
763763-764764- let content_type =
765765- Requests.Response.headers response
766766- |> Requests.Headers.get "content-type"
767767- in
768768-769769- Alcotest.(check (option string)) "Response content-type"
770770- (Some "application/json") content_type
771771-772772-let test_default_headers () =
773773- run @@ fun env ->
774774- Eio.Switch.run @@ fun sw ->
775775- let port = get_free_port () in
776776- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
777777-778778- let test_env = object
779779- method clock = env#clock
780780- method net = env#net
781781- method sw = sw
782782- end in
783783- Test_server.start_server ~port test_env;
784784-785785- let default_headers =
786786- Requests.Headers.empty
787787- |> Requests.Headers.set "X-Default" "default-value"
788788- in
789789- let req = Requests.create ~sw ~default_headers env in
790790- let response = Requests.get req (base_url ^ "/echo") in
791791-792792- let body_str = Requests.Response.body response |> Eio.Flow.read_all in
793793- let json = Yojson.Basic.from_string body_str in
794794- let headers_obj = json |> Yojson.Basic.Util.member "headers" in
795795-796796- let default_header =
797797- headers_obj
798798- |> Yojson.Basic.Util.member "x-default"
799799- |> Yojson.Basic.Util.to_string_option
800800- |> Option.value ~default:""
801801- in
802802-803803- Alcotest.(check string) "Default header present" "default-value" default_header
804804-805805-let test_session_persistence () =
806806- run @@ fun env ->
807807- Eio.Switch.run @@ fun sw ->
808808- let port = get_free_port () in
809809- let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
810810-811811- let test_env = object
812812- method clock = env#clock
813813- method net = env#net
814814- method sw = sw
815815- end in
816816- Test_server.start_server ~port test_env;
817817-818818- let req = Requests.create ~sw env in
819819-820820- let req = Requests.set_default_header req "X-Session" "session-123" in
821821-822822- let auth = Requests.Auth.bearer ~token:"test_token" in
823823- let req = Requests.set_auth req auth in
824824-825825- let response1 = Requests.get req (base_url ^ "/echo") in
826826- let body_str1 = Requests.Response.body response1 |> Eio.Flow.read_all in
827827- let json1 = Yojson.Basic.from_string body_str1 in
828828- let headers1 = json1 |> Yojson.Basic.Util.member "headers" in
829829-830830- let session_header =
831831- headers1
832832- |> Yojson.Basic.Util.member "x-session"
833833- |> Yojson.Basic.Util.to_string_option
834834- |> Option.value ~default:""
835835- in
836836-837837- Alcotest.(check string) "Session header persisted" "session-123" session_header;
838838-839839- let req = Requests.remove_default_header req "X-Session" in
840840-841841- let response2 = Requests.get req (base_url ^ "/echo") in
842842- let body_str2 = Requests.Response.body response2 |> Eio.Flow.read_all in
843843- let json2 = Yojson.Basic.from_string body_str2 in
844844- let headers2 = json2 |> Yojson.Basic.Util.member "headers" in
845845-846846- let session_header2 =
847847- headers2
848848- |> Yojson.Basic.Util.member "x-session"
849849- |> Yojson.Basic.Util.to_string_option
850850- in
851851-852852- Alcotest.(check (option string)) "Session header removed" None session_header2
853853-854854-let () =
855855- Logs.set_reporter (Logs.format_reporter ());
856856- Logs.set_level (Some Logs.Warning);
857857-858858- let open Alcotest in
859859- run "Requests Tests" [
860860- "HTTP Methods", [
861861- test_case "GET request" `Quick test_get_request;
862862- test_case "POST request" `Quick test_post_request;
863863- test_case "PUT request" `Quick test_put_request;
864864- test_case "DELETE request" `Quick test_delete_request;
865865- test_case "PATCH request" `Quick test_patch_request;
866866- test_case "HEAD request" `Quick test_head_request;
867867- test_case "OPTIONS request" `Quick test_options_request;
868868- ];
869869- "Request Features", [
870870- test_case "Custom headers" `Quick test_custom_headers;
871871- test_case "Query parameters" `Quick test_query_params;
872872- test_case "JSON body" `Quick test_json_body;
873873- test_case "Form data" `Quick test_form_data;
874874- test_case "Multipart upload" `Quick test_multipart;
875875- test_case "Default headers" `Quick test_default_headers;
876876- ];
877877- "Response Handling", [
878878- test_case "Status codes" `Quick test_status_codes;
879879- test_case "Response headers" `Quick test_response_headers;
880880- test_case "Large response" `Quick test_large_response;
881881- ];
882882- "Redirects", [
883883- test_case "Follow redirects" `Quick test_redirects;
884884- test_case "No follow redirects" `Quick test_no_redirect;
885885- ];
886886- "Authentication", [
887887- test_case "Bearer auth" `Quick test_bearer_auth;
888888- test_case "Basic auth" `Quick test_basic_auth;
889889- ];
890890- "Session Features", [
891891- test_case "Cookies" `Quick test_cookies;
892892- test_case "Session persistence" `Quick test_session_persistence;
893893- ];
894894- "Advanced", [
895895- test_case "Timeout handling" `Quick test_timeout;
896896- test_case "Concurrent requests" `Quick test_concurrent_requests;
897897- test_case "One module" `Quick test_one_module;
898898- ];
899899- ]
···27272828(** {1 JSON Helpers} *)
29293030-let parse_json parser body_str =
3131- Ezjsonm.from_string body_str |> parser
3030+let parse_json decoder body_str =
3131+ match Jsont_bytesrw.decode_string' decoder body_str with
3232+ | Ok v -> v
3333+ | Error e -> failwith (Fmt.str "JSON parse error: %s" (Jsont.Error.to_string e))
32343333-let parse_json_result parser body_str =
3434- try Ok (parse_json parser body_str)
3535- with exn -> Error (Printexc.to_string exn)
3535+let parse_json_result decoder body_str =
3636+ match Jsont_bytesrw.decode_string' decoder body_str with
3737+ | Ok v -> Ok v
3838+ | Error e -> Error (Jsont.Error.to_string e)
36393737-let get_json_exn session url parser =
4040+let get_json_exn session url decoder =
3841 let response = Requests.get session url in
3942 let status = Requests.Response.status_code response in
4043 if status < 200 || status >= 300 then
4144 failwith (Printf.sprintf "HTTP %d" status);
4242- read_body response |> parse_json parser
4545+ read_body response |> parse_json decoder
43464444-let get_json session url parser =
4747+let get_json session url decoder =
4548 match get_result session url with
4649 | Ok body ->
4747- (match parse_json_result parser body with
5050+ (match parse_json_result decoder body with
4851 | Ok result -> Ok result
4952 | Error msg -> Error (`Json_error msg))
5053 | Error (status, body) -> Error (`Http (status, body))
51545252-let post_json session url json_value =
5353- let body_str = Ezjsonm.value_to_string json_value in
5555+let post_json session url jsont_codec value =
5656+ let body_str = match Jsont_bytesrw.encode_string' jsont_codec value with
5757+ | Ok s -> s
5858+ | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
5959+ in
5460 let body = Requests.Body.of_string Requests.Mime.json body_str in
5561 Requests.post session url ~body
56625757-let post_json_exn session url json_value =
5858- let response = post_json session url json_value in
6363+let post_json_exn session url jsont_codec value =
6464+ let response = post_json session url jsont_codec value in
5965 let status = Requests.Response.status_code response in
6066 if status < 200 || status >= 300 then
6167 failwith (Printf.sprintf "HTTP %d" status);
6268 read_body response
63696464-let post_json_result session url json_value =
7070+let post_json_result session url jsont_codec value =
6571 try
6666- let response = post_json session url json_value in
7272+ let response = post_json session url jsont_codec value in
6773 check_2xx response
6874 with exn ->
6975 Error (0, Printexc.to_string exn)
7676+7777+let post_json_decode_exn session url ~req req_value ~resp =
7878+ let response = post_json session url req req_value in
7979+ let status = Requests.Response.status_code response in
8080+ if status < 200 || status >= 300 then
8181+ failwith (Printf.sprintf "HTTP %d" status);
8282+ read_body response |> parse_json resp
8383+8484+let post_json_decode session url ~req req_value ~resp =
8585+ try
8686+ let response = post_json session url req req_value in
8787+ match check_2xx response with
8888+ | Ok body ->
8989+ (match parse_json_result resp body with
9090+ | Ok result -> Ok result
9191+ | Error msg -> Error (`Json_error msg))
9292+ | Error (status, body) -> Error (`Http (status, body))
9393+ with exn ->
9494+ Error (`Http (0, Printexc.to_string exn))
9595+9696+let put_json_exn session url jsont_codec value =
9797+ let body_str = match Jsont_bytesrw.encode_string' jsont_codec value with
9898+ | Ok s -> s
9999+ | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
100100+ in
101101+ let body = Requests.Body.of_string Requests.Mime.json body_str in
102102+ let response = Requests.put session url ~body in
103103+ let status = Requests.Response.status_code response in
104104+ if status < 200 || status >= 300 then
105105+ failwith (Printf.sprintf "HTTP %d" status);
106106+ read_body response
107107+108108+let put_json_decode_exn session url ~req req_value ~resp =
109109+ let body_str = match Jsont_bytesrw.encode_string' req req_value with
110110+ | Ok s -> s
111111+ | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
112112+ in
113113+ let body = Requests.Body.of_string Requests.Mime.json body_str in
114114+ let response = Requests.put session url ~body in
115115+ let status = Requests.Response.status_code response in
116116+ if status < 200 || status >= 300 then
117117+ failwith (Printf.sprintf "HTTP %d" status);
118118+ read_body response |> parse_json resp
119119+120120+let patch_json_exn session url jsont_codec value =
121121+ let body_str = match Jsont_bytesrw.encode_string' jsont_codec value with
122122+ | Ok s -> s
123123+ | Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
124124+ in
125125+ let body = Requests.Body.of_string Requests.Mime.json body_str in
126126+ let response = Requests.patch session url ~body in
127127+ let status = Requests.Response.status_code response in
128128+ if status < 200 || status >= 300 then
129129+ failwith (Printf.sprintf "HTTP %d" status);
130130+ read_body response
131131+132132+let delete_json_exn session url =
133133+ let response = Requests.delete session url in
134134+ let status = Requests.Response.status_code response in
135135+ if status < 200 || status >= 300 then
136136+ failwith (Printf.sprintf "HTTP %d" status);
137137+ read_body response
7013871139(** {1 URL Helpers} *)
72140
+50-12
stack/requests_json_api/lib/requests_json_api.mli
···88 {[
99 open Requests_json_api
10101111+ (* Define a Jsont codec for your type *)
1212+ type user = { id : int; name : string }
1313+1414+ let user_jsont =
1515+ Jsont.Object.map (fun id name -> { id; name })
1616+ |> Jsont.Object.mem "id" Jsont.int ~enc:(fun u -> u.id)
1717+ |> Jsont.Object.mem "name" Jsont.string ~enc:(fun u -> u.name)
1818+ |> Jsont.Object.finish
1919+2020+ let users_jsont = Jsont.list user_jsont
2121+1122 let fetch_users session =
1212- get_json_exn session (base_url / "users") parse_users
2323+ get_json_exn session (base_url / "users") users_jsont
1324 ]}
1425*)
15261627(** {1 JSON Request Helpers} *)
17281818-val get_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> (Ezjsonm.value -> 'a) -> 'a
1919-(** [get_json_exn session url parser] makes a GET request, checks status is 2xx,
2020- reads and parses JSON body, then applies the parser function.
2929+val get_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a
3030+(** [get_json_exn session url decoder] makes a GET request, checks status is 2xx,
3131+ reads and parses JSON body using the provided Jsont decoder.
2132 Raises [Failure] on any error (HTTP, network, or JSON parse). *)
22332323-val get_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> (Ezjsonm.value -> 'a) ->
3434+val get_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t ->
2435 ('a, [> `Http of int * string | `Json_error of string]) result
2536(** Like [get_json_exn] but returns [Result] instead of raising exceptions.
2637 Returns [Ok parsed_value] on success, or [Error] with details on failure. *)
27382828-val post_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> Ezjsonm.value -> Requests.Response.t
2929-(** [post_json session url json_value] creates a JSON request body and POSTs it to the URL.
3939+val post_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> Requests.Response.t
4040+(** [post_json session url codec value] encodes [value] using the Jsont codec and POSTs it to the URL.
3041 Returns the raw response for custom handling. *)
31423232-val post_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> Ezjsonm.value -> string
4343+val post_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> string
3344(** Like [post_json] but checks status is 2xx and returns the response body as a string.
3445 Raises [Failure] on non-2xx status. *)
35463636-val post_json_result : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> Ezjsonm.value ->
4747+val post_json_result : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a ->
3748 (string, int * string) result
3849(** Like [post_json_exn] but returns [Result] instead of raising.
3950 [Ok body] on 2xx status, [Error (status, body)] otherwise. *)
40515252+val post_json_decode_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string ->
5353+ req:'a Jsont.t -> 'a -> resp:'b Jsont.t -> 'b
5454+(** [post_json_decode_exn session url ~req req_value ~resp] encodes [req_value] using the [req] codec,
5555+ POSTs it to the URL, checks status is 2xx, and decodes the response using the [resp] codec.
5656+ Raises [Failure] on any error. *)
5757+5858+val post_json_decode : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string ->
5959+ req:'a Jsont.t -> 'a -> resp:'b Jsont.t ->
6060+ ('b, [> `Http of int * string | `Json_error of string]) result
6161+(** Like [post_json_decode_exn] but returns [Result] instead of raising. *)
6262+6363+val put_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> string
6464+(** [put_json_exn session url codec value] encodes [value] and PUTs it to the URL.
6565+ Returns response body. Raises [Failure] on non-2xx status. *)
6666+6767+val put_json_decode_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string ->
6868+ req:'a Jsont.t -> 'a -> resp:'b Jsont.t -> 'b
6969+(** Like [post_json_decode_exn] but uses PUT method. *)
7070+7171+val patch_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> string
7272+(** [patch_json_exn session url codec value] encodes [value] and PATCHes it to the URL.
7373+ Returns response body. Raises [Failure] on non-2xx status. *)
7474+7575+val delete_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> string
7676+(** [delete_json_exn session url] makes a DELETE request.
7777+ Returns response body. Raises [Failure] on non-2xx status. *)
7878+4179(** {1 JSON Parsing Helpers} *)
42804343-val parse_json : (Ezjsonm.value -> 'a) -> string -> 'a
4444-(** [parse_json parser body_str] parses a JSON string and applies the parser function.
8181+val parse_json : 'a Jsont.t -> string -> 'a
8282+(** [parse_json decoder body_str] parses a JSON string using the provided Jsont decoder.
4583 Raises exception on parse error. *)
46844747-val parse_json_result : (Ezjsonm.value -> 'a) -> string -> ('a, string) result
8585+val parse_json_result : 'a Jsont.t -> string -> ('a, string) result
4886(** Like [parse_json] but returns [Result] on parse error instead of raising. *)
49875088(** {1 Low-Level Helpers} *)
···3838 with Eio.Io (Eio.Fs.E (Already_exists _), _) -> ()
3939 ) dirs
40404141- let user_of_json json =
4242- let open Yojson.Safe.Util in
4343- try
4444- let feeds_json = json |> member "feeds" |> to_list in
4545- let feeds = List.map (fun feed ->
4646- { River.name = feed |> member "name" |> to_string;
4747- url = feed |> member "url" |> to_string }
4848- ) feeds_json in
4949- Some {
5050- username = json |> member "username" |> to_string;
5151- fullname = json |> member "fullname" |> to_string;
5252- email = json |> member "email" |> to_string_option;
5353- feeds;
5454- last_synced = json |> member "last_synced" |> to_string_option;
5555- }
5656- with _ -> None
4141+ (* JSON codecs for user data *)
4242+4343+ (* Codec for River.source (feed) *)
4444+ let source_jsont =
4545+ let make name url = { River.name; url } in
4646+ Jsont.Object.map ~kind:"Source" make
4747+ |> Jsont.Object.mem "name" Jsont.string ~enc:(fun s -> s.River.name)
4848+ |> Jsont.Object.mem "url" Jsont.string ~enc:(fun s -> s.River.url)
4949+ |> Jsont.Object.finish
5050+5151+ (* Codec for user *)
5252+ let user_jsont =
5353+ let make username fullname email feeds last_synced =
5454+ { username; fullname; email; feeds; last_synced }
5555+ in
5656+ Jsont.Object.map ~kind:"User" make
5757+ |> Jsont.Object.mem "username" Jsont.string ~enc:(fun u -> u.username)
5858+ |> Jsont.Object.mem "fullname" Jsont.string ~enc:(fun u -> u.fullname)
5959+ |> Jsont.Object.opt_mem "email" Jsont.string ~enc:(fun u -> u.email)
6060+ |> Jsont.Object.mem "feeds" (Jsont.list source_jsont) ~enc:(fun u -> u.feeds)
6161+ |> Jsont.Object.opt_mem "last_synced" Jsont.string ~enc:(fun u -> u.last_synced)
6262+ |> Jsont.Object.finish
6363+6464+ let user_of_string s =
6565+ match Jsont_bytesrw.decode_string' user_jsont s with
6666+ | Ok user -> Some user
6767+ | Error err ->
6868+ Log.err (fun m -> m "Failed to parse user JSON: %s" (Jsont.Error.to_string err));
6969+ None
7070+7171+ let user_to_string user =
7272+ match Jsont_bytesrw.encode_string' ~format:Jsont.Indent user_jsont user with
7373+ | Ok s -> s
7474+ | Error err -> failwith ("Failed to encode user: " ^ Jsont.Error.to_string err)
57755876 let load_user state username =
5977 let file = user_file state username in
6078 try
6179 let content = Eio.Path.load file in
6262- let json = Yojson.Safe.from_string content in
6363- user_of_json json
8080+ user_of_string content
6481 with
6582 | Eio.Io (Eio.Fs.E (Not_found _), _) -> None
6683 | e ->
6784 Log.err (fun m -> m "Error loading user %s: %s" username (Printexc.to_string e));
6885 None
69867070- let user_to_json user =
7171- let feeds_json = List.map (fun feed ->
7272- `Assoc [
7373- "name", `String feed.River.name;
7474- "url", `String feed.River.url;
7575- ]
7676- ) user.feeds in
7777- `Assoc [
7878- "username", `String user.username;
7979- "fullname", `String user.fullname;
8080- "email", (match user.email with
8181- | Some e -> `String e
8282- | None -> `Null);
8383- "feeds", `List feeds_json;
8484- "last_synced", (match user.last_synced with
8585- | Some s -> `String s
8686- | None -> `Null);
8787- ]
8888-8987 let save_user state user =
9088 let file = user_file state user.username in
9191- let json = user_to_json user |> Yojson.Safe.to_string ~std:true in
8989+ let json = user_to_string user in
9290 Eio.Path.save ~create:(`Or_truncate 0o644) file json
93919492 let list_users state =
···11(** Resolve a DOI from a Zotero translation server *)
2233-module J = Ezjsonm
44-53(* From the ZTS source code: https://github.com/zotero/translation-server/blob/master/src/formats.js
64 bibtex: "9cb70025-a888-4a29-a210-93ec52da40d4",
75 biblatex: "b6e39b57-8942-4d11-8259-342c46ce395f",
···104102 requests_session: ('clock, 'net) Requests.t;
105103}
106104107107-let create ~sw ~env ?requests_session base_uri =
108108- let requests_session = match requests_session with
109109- | Some session -> session
110110- | None -> Requests.create ~sw env
111111- in
105105+let create ~requests_session base_uri =
112106 { base_uri; requests_session }
113113-114114-let v _base_uri =
115115- failwith "Zotero_translation.v is deprecated. Use Zotero_translation.create ~sw ~env base_uri instead"
116107117108let resolve_doi { base_uri; requests_session } doi =
118109 let body_str = "https://doi.org/" ^ doi in
···123114 let body = Requests.Response.body response |> Eio.Flow.read_all in
124115 if status = 200 then begin
125116 try
126126- let doi_json = J.from_string body in
127127- Ok doi_json
117117+ match Jsont_bytesrw.decode_string' Jsont.json body with
118118+ | Ok doi_json -> Ok doi_json
119119+ | Error e -> Error (`Msg (Jsont.Error.to_string e))
128120 with exn -> Error (`Msg (Printexc.to_string exn))
129121 end else
130122 Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
···138130 let body = Requests.Response.body response |> Eio.Flow.read_all in
139131 if status = 200 then begin
140132 try
141141- let url_json = J.from_string body in
142142- Ok url_json
133133+ match Jsont_bytesrw.decode_string' Jsont.json body with
134134+ | Ok url_json -> Ok url_json
135135+ | Error e -> Error (`Msg (Jsont.Error.to_string e))
143136 with exn -> Error (`Msg (Printexc.to_string exn))
144137 end else
145138 Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
···153146 let body = Requests.Response.body response |> Eio.Flow.read_all in
154147 if status = 200 then begin
155148 try
156156- let doi_json = J.from_string body in
157157- Ok doi_json
149149+ match Jsont_bytesrw.decode_string' Jsont.json body with
150150+ | Ok doi_json -> Ok doi_json
151151+ | Error e -> Error (`Msg (Jsont.Error.to_string e))
158152 with exn -> Error (`Msg (Printexc.to_string exn))
159153 end else
160154 Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
161155162156let export { base_uri; requests_session } format api =
163163- let body_str = J.to_string api in
164164- let uri = Uri.with_query' (export_endp base_uri ) ["format", (format_to_string format)] in
165165- let body = Requests.Body.of_string Requests.Mime.json body_str in
166166- let response = Requests.post requests_session ~body (Uri.to_string uri) in
167167- let status = Requests.Response.status_code response in
168168- let body = Requests.Response.body response |> Eio.Flow.read_all in
169169- if status = 200 then begin
170170- try
171171- match format with
172172- | Bibtex -> Ok (Astring.String.trim body)
173173- | _ -> Ok body
174174- with exn -> Error (`Msg (Printexc.to_string exn))
175175- end else
176176- Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
157157+ match Jsont_bytesrw.encode_string' Jsont.json api with
158158+ | Error e -> Error (`Msg (Jsont.Error.to_string e))
159159+ | Ok body_str ->
160160+ let uri = Uri.with_query' (export_endp base_uri ) ["format", (format_to_string format)] in
161161+ let body = Requests.Body.of_string Requests.Mime.json body_str in
162162+ let response = Requests.post requests_session ~body (Uri.to_string uri) in
163163+ let status = Requests.Response.status_code response in
164164+ let body = Requests.Response.body response |> Eio.Flow.read_all in
165165+ if status = 200 then begin
166166+ try
167167+ match format with
168168+ | Bibtex -> Ok (Astring.String.trim body)
169169+ | _ -> Ok body
170170+ with exn -> Error (`Msg (Printexc.to_string exn))
171171+ end else
172172+ Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
177173178174let unescape_hex s =
179175 let buf = Buffer.create (String.length s) in
···206202 | Ok [bib] ->
207203 let f = Bibtex.fields bib |> Bibtex.SM.bindings |> List.map (fun (k,v) -> k, (unescape_bibtex v)) in
208204 let ty = match Bibtex.type' bib with "inbook" -> "book" | x -> x in
209209- let v = List.fold_left (fun acc (k,v) -> (k,(`String v))::acc) ["bibtype",`String ty] f in
205205+ let v = List.fold_left (fun acc (k,v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))::acc)
206206+ [(("bibtype", Jsont.Meta.none), Jsont.String (ty, Jsont.Meta.none))] f in
210207 v
211208 | Ok _ -> failwith "one bib at a time plz"
212209213210let bib_of_doi zt doi =
214211 prerr_endline ("Fetching " ^ doi);
215215- let v = match resolve_doi zt doi with
216216- | Ok r -> r
212212+ match resolve_doi zt doi with
217213 | Error (`Msg _) ->
218214 Printf.eprintf "%s failed on /web, trying to /search\n%!" doi;
219219- match search_id zt doi with
215215+ begin match search_id zt doi with
220216 | Error (`Msg e) -> failwith e
221221- | Ok r -> r
222222- in
223223- match export zt Bibtex v with
224224- | Error (`Msg e) -> failwith e
225225- | Ok r ->
226226- print_endline r;
227227- r
217217+ | Ok v ->
218218+ match export zt Bibtex v with
219219+ | Error (`Msg e) -> failwith e
220220+ | Ok r ->
221221+ print_endline r;
222222+ r
223223+ end
224224+ | Ok v ->
225225+ match export zt Bibtex v with
226226+ | Error (`Msg e) -> failwith e
227227+ | Ok r ->
228228+ print_endline r;
229229+ r
230230+231231+(* Helper to get string from Jsont.json *)
232232+let get_string = function
233233+ | Jsont.String (s, _) -> s
234234+ | _ -> failwith "Expected string in JSON"
235235+236236+(* Helper to get list from Jsont.json *)
237237+let get_list f = function
238238+ | Jsont.Array (arr, _) -> List.map f arr
239239+ | _ -> failwith "Expected array in JSON"
240240+241241+(* Helper to find a field in Jsont.Object *)
242242+let find_field name = function
243243+ | Jsont.Object (mems, _) ->
244244+ List.find_map (fun ((k, _), v) -> if k = name then Some v else None) mems
245245+ | _ -> None
246246+247247+(* Helper to get a required field as string *)
248248+let get_field name json =
249249+ match find_field name json with
250250+ | Some v -> get_string v
251251+ | None -> failwith ("Missing field: " ^ name)
252252+253253+(* Helper to update a field in a Jsont.Object *)
254254+let update_field name value json =
255255+ match json with
256256+ | Jsont.Object (mems, meta) ->
257257+ let mems' =
258258+ match value with
259259+ | None -> List.filter (fun ((k, _), _) -> k <> name) mems
260260+ | Some v ->
261261+ let without = List.filter (fun ((k, _), _) -> k <> name) mems in
262262+ ((name, Jsont.Meta.none), v) :: without
263263+ in
264264+ Jsont.Object (mems', meta)
265265+ | _ -> json
228266229267let split_authors keys =
268268+ let json = Jsont.Object (keys, Jsont.Meta.none) in
269269+ let author_str = get_field "author" json in
230270 let authors =
231231- List.assoc "author" keys |> J.get_string |>
232232- Astring.String.cuts ~empty:false ~sep:" and " |>
271271+ Astring.String.cuts ~empty:false ~sep:" and " author_str |>
233272 List.map Bibtex.list_value |>
234273 List.map (fun v -> List.rev v |> String.concat " ") |>
235235- List.map (fun x -> `String x)
274274+ List.map (fun x -> Jsont.String (x, Jsont.Meta.none))
236275 in
237276 let keywords =
238238- List.assoc_opt "keywords" keys |> function
277277+ match find_field "keywords" json with
239278 | None -> []
240279 | Some k ->
241241- Astring.String.cuts ~empty:false ~sep:", " (J.get_string k) |>
242242- List.map (fun x -> `String x)
280280+ Astring.String.cuts ~empty:false ~sep:", " (get_string k) |>
281281+ List.map (fun x -> Jsont.String (x, Jsont.Meta.none))
243282 in
244244- J.update (`O keys) ["author"] (Some (`A authors)) |> fun j ->
245245- J.update j ["keywords"] (match keywords with [] -> None | _ -> Some (`A keywords))
283283+ let json' = update_field "author" (Some (Jsont.Array (authors, Jsont.Meta.none))) json in
284284+ let json'' = update_field "keywords"
285285+ (match keywords with [] -> None | _ -> Some (Jsont.Array (keywords, Jsont.Meta.none))) json' in
286286+ match json'' with
287287+ | Jsont.Object (mems, _) -> mems
288288+ | _ -> failwith "Expected object"
246289247290let add_bibtex ~slug y =
248248- let (.%{}) = fun y k -> J.find y [k] in
291291+ let json = Jsont.Object (y, Jsont.Meta.none) in
292292+ let find_opt k = find_field k json in
249293 let add_if_present k f m =
250250- match J.find y [k] with
251251- | v -> Bibtex.SM.add k (f v) m
252252- | exception Not_found -> m in
253253- let string k m = add_if_present k J.get_string m in
254254- let authors m = add_if_present "author" (fun j -> J.get_list J.get_string j |> String.concat " and ") m in
294294+ match find_opt k with
295295+ | Some v -> Bibtex.SM.add k (f v) m
296296+ | None -> m
297297+ in
298298+ let string k m = add_if_present k get_string m in
299299+ let authors m = add_if_present "author" (fun j -> get_list get_string j |> String.concat " and ") m in
255300 let cite_key = Astring.String.map (function '-' -> '_' |x -> x) slug in
256301 let fields = Bibtex.SM.empty in
257257- let type' = y.%{"bibtype"} |> J.get_string |> String.lowercase_ascii in
302302+ let type' = get_field "bibtype" json |> String.lowercase_ascii in
258303 let fields = authors fields |> string "title" |> string "doi" |> string "month" |> string "year" |> string "url" in
259304 let fields = match type' with
260305 | "article" -> string "journal" fields |> string "volume" |> string "number" |> string "pages"
···264309 | "misc" -> string "howpublished" fields
265310 | "techreport" -> string "institution" fields |> string "number" |> string "address"
266311 | b -> prerr_endline ("unknown bibtype " ^ b); fields in
267267- Bibtex.v ~type' ~cite_key ~fields () |> Fmt.str "%a" Bibtex.pp |>
268268- fun bib -> J.update y ["bib"] (Some (`String bib))
312312+ let bib = Bibtex.v ~type' ~cite_key ~fields () |> Fmt.str "%a" Bibtex.pp in
313313+ match update_field "bib" (Some (Jsont.String (bib, Jsont.Meta.none))) json with
314314+ | Jsont.Object (mems, _) -> mems
315315+ | _ -> failwith "Expected object"
269316270317let json_of_doi zt ~slug doi =
271318 let x = bib_of_doi zt doi in
+8-16
stack/zotero-translation/zotero_translation.mli
···2525val format_of_string: string -> format option
26262727(** Create a Zotero Translation client.
2828- @param requests_session Optional Requests session for connection pooling.
2929- If not provided, a new session is created. *)
2828+ @param requests_session Shared Requests session for connection pooling.
2929+ @param base_uri Base URI of the Zotero translation server (e.g., "http://localhost:1969"). *)
3030val create :
3131- sw:Eio.Switch.t ->
3232- env:< clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t;
3333- net: ([> [> `Generic ] Eio.Net.ty ] as 'net) Eio.Resource.t;
3434- fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
3535- ?requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
3131+ requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
3632 string -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t
37333838-(** Deprecated: use [create] instead *)
3939-val v : string -> (_, _) t
4040- [@@deprecated "Use create ~sw ~env base_uri instead"]
4141-4234val resolve_doi: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
4343- string -> (Ezjsonm.t, [>`Msg of string]) result
3535+ string -> (Jsont.json, [>`Msg of string]) result
44364537val resolve_url: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
4646- string -> (Ezjsonm.t, [>`Msg of string]) result
3838+ string -> (Jsont.json, [>`Msg of string]) result
47394840val search_id: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
4949- string -> (Ezjsonm.t, [>`Msg of string]) result
4141+ string -> (Jsont.json, [>`Msg of string]) result
50425143val export: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
5252- format -> Ezjsonm.t -> (string, [>`Msg of string]) result
4444+ format -> Jsont.json -> (string, [>`Msg of string]) result
53455446val json_of_doi : ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
5555- slug:string -> string -> Ezjsonm.value
4747+ slug:string -> string -> Jsont.object'
+217
stack/zulip/ARCHITECTURE.md
···11+# Zulip Library Architecture
22+33+## Overview
44+55+The Zulip OCaml library follows a clean, layered architecture that separates protocol types, encoding concerns, and HTTP communication.
66+77+## Architecture Layers
88+99+```
1010+┌─────────────────────────────────────┐
1111+│ API Modules (Messages, Channels) │ ← High-level operations
1212+├─────────────────────────────────────┤
1313+│ Protocol Types (Message, Channel) │ ← Business logic types with Jsont codecs
1414+├─────────────────────────────────────┤
1515+│ Encode Module │ ← JSON/Form encoding utilities
1616+├─────────────────────────────────────┤
1717+│ Client Module │ ← HTTP request/response handling
1818+├─────────────────────────────────────┤
1919+│ Requests Library (EIO-based) │ ← Low-level HTTP
2020+└─────────────────────────────────────┘
2121+```
2222+2323+## Key Design Principles
2424+2525+### 1. **Protocol Types with Jsont Codecs**
2626+2727+Each Zulip API type (Message, Channel, User, etc.) has:
2828+- A clean OCaml record type
2929+- A `jsont` codec that defines bidirectional JSON conversion
3030+- Accessor functions
3131+- Pretty printer
3232+3333+Example from `channel.ml`:
3434+```ocaml
3535+type t = {
3636+ name : string;
3737+ description : string;
3838+ invite_only : bool;
3939+ history_public_to_subscribers : bool;
4040+}
4141+4242+let jsont =
4343+ Jsont.Object.map ~kind:"Channel" make
4444+ |> Jsont.Object.mem "name" Jsont.string ~enc:name
4545+ |> Jsont.Object.mem "description" Jsont.string ~enc:description
4646+ |> Jsont.Object.mem "invite_only" Jsont.bool ~enc:invite_only
4747+ |> Jsont.Object.mem "history_public_to_subscribers" Jsont.bool ~enc:history_public_to_subscribers
4848+ |> Jsont.Object.finish
4949+```
5050+5151+### 2. **Encode Module: Separation of Encoding Concerns**
5252+5353+The `Encode` module provides clean utilities for converting between OCaml types and wire formats:
5454+5555+```ocaml
5656+(** Convert using a jsont codec *)
5757+val to_json_string : 'a Jsont.t -> 'a -> string
5858+val to_form_urlencoded : 'a Jsont.t -> 'a -> string
5959+val from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result
6060+```
6161+6262+This eliminates the need for:
6363+- ❌ Manual JSON tree walking
6464+- ❌ Round-trip encode→decode conversions
6565+- ❌ Per-type encoding functions
6666+6767+### 3. **Request/Response Types with Codecs**
6868+6969+API operations define request/response types locally with their codecs:
7070+7171+```ocaml
7272+(* In channels.ml *)
7373+module Subscribe_request = struct
7474+ type t = { subscriptions : string list }
7575+7676+ let codec =
7777+ Jsont.Object.(
7878+ map ~kind:"SubscribeRequest" (fun subscriptions -> { subscriptions })
7979+ |> mem "subscriptions" (Jsont.list Jsont.string) ~enc:(fun r -> r.subscriptions)
8080+ |> finish
8181+ )
8282+end
8383+8484+let subscribe client ~channels =
8585+ let req = Subscribe_request.{ subscriptions = channels } in
8686+ let body = Encode.to_form_urlencoded Subscribe_request.codec req in
8787+ let content_type = "application/x-www-form-urlencoded" in
8888+ match Client.request client ~method_:`POST ~path:"/api/v1/users/me/subscriptions"
8989+ ~body ~content_type () with
9090+ | Ok _json -> Ok ()
9191+ | Error err -> Error err
9292+```
9393+9494+### 4. **Type-Safe Decoding**
9595+9696+Response parsing uses codecs directly instead of manual pattern matching:
9797+9898+```ocaml
9999+(* OLD - manual JSON walking *)
100100+match json with
101101+| Jsont.Object (fields, _) ->
102102+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
103103+ (match List.assoc_opt "streams" assoc with
104104+ | Some (Jsont.Array (channel_list, _)) -> ...
105105+106106+(* NEW - type-safe codec *)
107107+let response_codec =
108108+ Jsont.Object.(
109109+ map ~kind:"StreamsResponse" (fun streams -> streams)
110110+ |> mem "streams" (Jsont.list Channel.jsont) ~enc:(fun x -> x)
111111+ |> finish
112112+ )
113113+in
114114+match Encode.from_json response_codec json with
115115+| Ok channels -> Ok channels
116116+| Error msg -> Error (...)
117117+```
118118+119119+## Benefits
120120+121121+### ✅ Type Safety
122122+- Jsont codecs ensure correct JSON structure
123123+- Compilation errors catch schema mismatches
124124+- No runtime type confusion
125125+126126+### ✅ Maintainability
127127+- Protocol changes only require updating codecs
128128+- No manual JSON manipulation scattered through code
129129+- Clear separation of concerns
130130+131131+### ✅ Reusability
132132+- Codecs can be composed and reused
133133+- Encode module works for any jsont-encoded type
134134+- Request/response types are self-documenting
135135+136136+### ✅ Testability
137137+- Easy to test encoding/decoding in isolation
138138+- Mock responses can be type-checked
139139+- Round-trip property testing possible
140140+141141+## Migration Pattern
142142+143143+When adding new API endpoints:
144144+145145+1. **Define the protocol type with codec**:
146146+ ```ocaml
147147+ type my_request = { field1: string; field2: int }
148148+149149+ let my_request_codec =
150150+ Jsont.Object.(
151151+ map ~kind:"MyRequest" (fun field1 field2 -> { field1; field2 })
152152+ |> mem "field1" Jsont.string ~enc:(fun r -> r.field1)
153153+ |> mem "field2" Jsont.int ~enc:(fun r -> r.field2)
154154+ |> finish
155155+ )
156156+ ```
157157+158158+2. **Encode using Encode module**:
159159+ ```ocaml
160160+ let body = Encode.to_form_urlencoded my_request_codec req in
161161+ (* or *)
162162+ let json = Encode.to_json_string my_request_codec req in
163163+ ```
164164+165165+3. **Decode responses with codec**:
166166+ ```ocaml
167167+ match Client.request client ~method_:`POST ~path:"/api/..." ~body () with
168168+ | Ok json ->
169169+ (match Encode.from_json response_codec json with
170170+ | Ok data -> Ok data
171171+ | Error msg -> Error ...)
172172+ ```
173173+174174+## Comparison with Old Approach
175175+176176+### Old (Manual JSON Manipulation):
177177+```ocaml
178178+let send client message =
179179+ let json = Message.to_json message in (* Round-trip conversion *)
180180+ let params = match json with
181181+ | Jsont.Object (fields, _) -> (* Manual pattern matching *)
182182+ List.fold_left (fun acc ((key, _), value) ->
183183+ let str_value = match value with (* More pattern matching *)
184184+ | Jsont.String (s, _) -> s
185185+ | Jsont.Bool (true, _) -> "true"
186186+ | _ -> ""
187187+ in
188188+ (key, str_value) :: acc
189189+ ) [] fields
190190+ | _ -> [] in
191191+ (* ... *)
192192+```
193193+194194+### New (Codec-Based):
195195+```ocaml
196196+let send client message =
197197+ let body = Message.to_form_urlencoded message in (* Clean encoding *)
198198+ let content_type = "application/x-www-form-urlencoded" in
199199+ match Client.request client ~method_:`POST ~path:"/api/v1/messages"
200200+ ~body ~content_type () with
201201+ | Ok response -> Message_response.of_json response
202202+ | Error err -> Error err
203203+```
204204+205205+## Future Enhancements
206206+207207+- **Validation**: Add validation layers on top of codecs
208208+- **Versioning**: Support multiple API versions with codec variants
209209+- **Documentation**: Generate API docs from codec definitions
210210+- **Testing**: Property-based testing with codec round-trips
211211+- **Code Generation**: Consider generating codecs from OpenAPI specs
212212+213213+## References
214214+215215+- Jsont library: https://erratique.ch/software/jsont
216216+- Zulip REST API: https://zulip.com/api/rest
217217+- Original design doc: `CLAUDE.md`
-689
stack/zulip/CLAUDE.md
···11-I would like to build high quality OCaml bindings to the Zulip REST API,
22-documented at https://zulip.com/api/rest. As another reference, the Python
33-`zulip` library from pip is well maintained.
44-55-My target is to use the OCaml EIO direct-style library, with an idiomatic as
66-possible API that implements it. For JSON parsing, using the jsonm library is
77-right. For HTTPS, use cohttp-eio with the tls-eio library. You have access to
88-an OCaml LSP via MCP which provides type hints and other language server
99-features after you complete a `dune build`.
1010-1111-# OCaml Zulip Library Design
1212-1313-Based on analysis of:
1414-- Zulip REST API documentation: https://zulip.com/api/rest
1515-- Python zulip library: https://github.com/zulip/python-zulip-api
1616-- Zulip error handling: https://zulip.com/api/rest-error-handling
1717-- Zulip send message API: https://zulip.com/api/send-message
1818-1919-## Overview
2020-The library follows OCaml best practices with abstract types (`type t`) per module, comprehensive constructors/accessors, and proper pretty printers. Each core concept gets its own module with a clean interface.
2121-2222-## Module Structure
2323-2424-### Authentication (`Zulip.Auth`)
2525-```ocaml
2626-type t (* abstract *)
2727-2828-val create : server_url:string -> email:string -> api_key:string -> t
2929-val from_zuliprc : ?path:string -> unit -> (t, Error.t) result
3030-val server_url : t -> string
3131-val email : t -> string
3232-val to_basic_auth_header : t -> string
3333-val pp : Format.formatter -> t -> unit
3434-```
3535-3636-### Error Handling (`Zulip.Error`)
3737-```ocaml
3838-type code =
3939- | Invalid_api_key
4040- | Request_variable_missing
4141- | Bad_request
4242- | User_deactivated
4343- | Realm_deactivated
4444- | Rate_limit_hit
4545- | Other of string
4646-4747-type t (* abstract *)
4848-4949-val create : code:code -> msg:string -> ?extra:(string * Jsonm.value) list -> unit -> t
5050-val code : t -> code
5151-val message : t -> string
5252-val extra : t -> (string * Jsonm.value) list
5353-val pp : Format.formatter -> t -> unit
5454-val of_json : Jsonm.value -> t option
5555-```
5656-5757-### Message Types (`Zulip.Message_type`)
5858-```ocaml
5959-type t = [ `Direct | `Channel ]
6060-6161-val to_string : t -> string
6262-val of_string : string -> t option
6363-val pp : Format.formatter -> t -> unit
6464-```
6565-6666-### Message (`Zulip.Message`)
6767-```ocaml
6868-type t (* abstract *)
6969-7070-val create :
7171- type_:Message_type.t ->
7272- to_:string list ->
7373- content:string ->
7474- ?topic:string ->
7575- ?queue_id:string ->
7676- ?local_id:string ->
7777- ?read_by_sender:bool ->
7878- unit -> t
7979-8080-val type_ : t -> Message_type.t
8181-val to_ : t -> string list
8282-val content : t -> string
8383-val topic : t -> string option
8484-val queue_id : t -> string option
8585-val local_id : t -> string option
8686-val read_by_sender : t -> bool
8787-val to_json : t -> Jsonm.value
8888-val pp : Format.formatter -> t -> unit
8989-```
9090-9191-### Message Response (`Zulip.Message_response`)
9292-```ocaml
9393-type t (* abstract *)
9494-9595-val id : t -> int
9696-val automatic_new_visibility_policy : t -> string option
9797-val of_json : Jsonm.value -> (t, Error.t) result
9898-val pp : Format.formatter -> t -> unit
9999-```
100100-101101-### Client (`Zulip.Client`)
102102-```ocaml
103103-type t (* abstract *)
104104-105105-val create : #Eio.Env.t -> Auth.t -> t
106106-val with_client : #Eio.Env.t -> Auth.t -> (t -> 'a) -> 'a
107107-108108-val request :
109109- t ->
110110- method_:[`GET | `POST | `PUT | `DELETE | `PATCH] ->
111111- path:string ->
112112- ?params:(string * string) list ->
113113- ?body:string ->
114114- unit ->
115115- (Jsonm.value, Error.t) result
116116-```
117117-118118-### Messages (`Zulip.Messages`)
119119-```ocaml
120120-val send : Client.t -> Message.t -> (Message_response.t, Error.t) result
121121-val edit : Client.t -> message_id:int -> ?content:string -> ?topic:string -> unit -> (unit, Error.t) result
122122-val delete : Client.t -> message_id:int -> (unit, Error.t) result
123123-val get : Client.t -> message_id:int -> (Jsonm.value, Error.t) result
124124-val get_messages :
125125- Client.t ->
126126- ?anchor:string ->
127127- ?num_before:int ->
128128- ?num_after:int ->
129129- ?narrow:string list ->
130130- unit ->
131131- (Jsonm.value, Error.t) result
132132-```
133133-134134-### Channel (`Zulip.Channel`)
135135-```ocaml
136136-type t (* abstract *)
137137-138138-val create :
139139- name:string ->
140140- description:string ->
141141- ?invite_only:bool ->
142142- ?history_public_to_subscribers:bool ->
143143- unit -> t
144144-145145-val name : t -> string
146146-val description : t -> string
147147-val invite_only : t -> bool
148148-val history_public_to_subscribers : t -> bool
149149-val to_json : t -> Jsonm.value
150150-val of_json : Jsonm.value -> (t, Error.t) result
151151-val pp : Format.formatter -> t -> unit
152152-```
153153-154154-### Channels (`Zulip.Channels`)
155155-```ocaml
156156-val create_channel : Client.t -> Channel.t -> (unit, Error.t) result
157157-val delete : Client.t -> name:string -> (unit, Error.t) result
158158-val list : Client.t -> (Channel.t list, Error.t) result
159159-val subscribe : Client.t -> channels:string list -> (unit, Error.t) result
160160-val unsubscribe : Client.t -> channels:string list -> (unit, Error.t) result
161161-```
162162-163163-### User (`Zulip.User`)
164164-```ocaml
165165-type t (* abstract *)
166166-167167-val create :
168168- email:string ->
169169- full_name:string ->
170170- ?is_active:bool ->
171171- ?is_admin:bool ->
172172- ?is_bot:bool ->
173173- unit -> t
174174-175175-val email : t -> string
176176-val full_name : t -> string
177177-val is_active : t -> bool
178178-val is_admin : t -> bool
179179-val is_bot : t -> bool
180180-val to_json : t -> Jsonm.value
181181-val of_json : Jsonm.value -> (t, Error.t) result
182182-val pp : Format.formatter -> t -> unit
183183-```
184184-185185-### Users (`Zulip.Users`)
186186-```ocaml
187187-val list : Client.t -> (User.t list, Error.t) result
188188-val get : Client.t -> email:string -> (User.t, Error.t) result
189189-val create_user : Client.t -> email:string -> full_name:string -> (unit, Error.t) result
190190-val deactivate : Client.t -> email:string -> (unit, Error.t) result
191191-```
192192-193193-### Event Type (`Zulip.Event_type`)
194194-```ocaml
195195-type t =
196196- | Message
197197- | Subscription
198198- | User_activity
199199- | Other of string
200200-201201-val to_string : t -> string
202202-val of_string : string -> t
203203-val pp : Format.formatter -> t -> unit
204204-```
205205-206206-### Event (`Zulip.Event`)
207207-```ocaml
208208-type t (* abstract *)
209209-210210-val id : t -> int
211211-val type_ : t -> Event_type.t
212212-val data : t -> Jsonm.value
213213-val of_json : Jsonm.value -> (t, Error.t) result
214214-val pp : Format.formatter -> t -> unit
215215-```
216216-217217-### Event Queue (`Zulip.Event_queue`)
218218-```ocaml
219219-type t (* abstract *)
220220-221221-val register :
222222- Client.t ->
223223- ?event_types:Event_type.t list ->
224224- unit ->
225225- (t, Error.t) result
226226-227227-val id : t -> string
228228-val get_events : t -> Client.t -> ?last_event_id:int -> unit -> (Event.t list, Error.t) result
229229-val delete : t -> Client.t -> (unit, Error.t) result
230230-val pp : Format.formatter -> t -> unit
231231-```
232232-233233-## EIO Bot Framework Extension
234234-235235-Based on analysis of the Python bot framework at:
236236-- https://github.com/zulip/python-zulip-api/blob/main/zulip_bots/zulip_bots/lib.py
237237-- https://github.com/zulip/python-zulip-api/blob/main/zulip_botserver/zulip_botserver/server.py
238238-239239-### Bot Handler (`Zulip.Bot`)
240240-```ocaml
241241-module Storage : sig
242242- type t (* abstract *)
243243-244244- val create : Client.t -> t
245245- val get : t -> key:string -> string option
246246- val put : t -> key:string -> value:string -> unit
247247- val contains : t -> key:string -> bool
248248-end
249249-250250-module Identity : sig
251251- type t (* abstract *)
252252-253253- val full_name : t -> string
254254- val email : t -> string
255255- val mention_name : t -> string
256256-end
257257-258258-type handler = {
259259- handle_message :
260260- client:Client.t ->
261261- message:Jsonm.value ->
262262- response:(Message.t -> unit) ->
263263- unit;
264264-265265- usage : unit -> string;
266266- description : unit -> string;
267267-}
268268-269269-type t (* abstract *)
270270-271271-val create :
272272- Client.t ->
273273- handler:handler ->
274274- ?storage:Storage.t ->
275275- unit -> t
276276-277277-val identity : t -> Identity.t
278278-val storage : t -> Storage.t
279279-val handle_message : t -> Jsonm.value -> unit
280280-val send_reply : t -> original_message:Jsonm.value -> content:string -> unit
281281-val send_message : t -> Message.t -> unit
282282-```
283283-284284-### Bot Server (`Zulip.Bot_server`)
285285-```ocaml
286286-module Config : sig
287287- type bot_config = {
288288- email : string;
289289- api_key : string;
290290- token : string; (* webhook token *)
291291- server_url : string;
292292- module_name : string;
293293- }
294294-295295- type t (* abstract *)
296296-297297- val create : bot_configs:bot_config list -> ?host:string -> ?port:int -> unit -> t
298298- val from_file : string -> (t, Error.t) result
299299- val from_env : string -> (t, Error.t) result
300300- val host : t -> string
301301- val port : t -> int
302302- val bot_configs : t -> bot_config list
303303-end
304304-305305-type t (* abstract *)
306306-307307-val create : #Eio.Env.t -> Config.t -> (t, Error.t) result
308308-309309-val run : t -> unit
310310-(* Starts the server using EIO structured concurrency *)
311311-312312-val with_server : #Eio.Env.t -> Config.t -> (t -> 'a) -> ('a, Error.t) result
313313-(* Resource-safe server management *)
314314-```
315315-316316-### Bot Registry (`Zulip.Bot_registry`)
317317-```ocaml
318318-type bot_module = {
319319- name : string;
320320- handler : Bot.handler;
321321- create_instance : Client.t -> Bot.t;
322322-}
323323-324324-type t (* abstract *)
325325-326326-val create : unit -> t
327327-val register : t -> bot_module -> unit
328328-val get_handler : t -> email:string -> Bot.t option
329329-val list_bots : t -> string list
330330-331331-(* Dynamic module loading *)
332332-val load_from_file : string -> (bot_module, Error.t) result
333333-val load_from_directory : string -> (bot_module list, Error.t) result
334334-```
335335-336336-### Webhook Handler (`Zulip.Webhook`)
337337-```ocaml
338338-type webhook_event = {
339339- bot_email : string;
340340- token : string;
341341- message : Jsonm.value;
342342- trigger : [`Direct_message | `Mention];
343343-}
344344-345345-type response = {
346346- content : string option;
347347- message_type : Message_type.t option;
348348- to_ : string list option;
349349- topic : string option;
350350-}
351351-352352-val parse_webhook : string -> (webhook_event, Error.t) result
353353-val handle_webhook : Bot_registry.t -> webhook_event -> (response option, Error.t) result
354354-```
355355-356356-### Structured Concurrency Design
357357-358358-The EIO-based server uses structured concurrency to manage multiple bots safely:
359359-360360-```ocaml
361361-(* Example server implementation using EIO *)
362362-let run_server env config =
363363- let registry = Bot_registry.create () in
364364-365365- (* Load and register all configured bots concurrently *)
366366- Eio.Switch.run @@ fun sw ->
367367-368368- (* Start each bot in its own fiber *)
369369- List.iter (fun bot_config ->
370370- Eio.Fiber.fork ~sw (fun () ->
371371- let auth = Auth.create
372372- ~server_url:bot_config.server_url
373373- ~email:bot_config.email
374374- ~api_key:bot_config.api_key in
375375-376376- Client.with_client env auth @@ fun client ->
377377-378378- (* Load bot module *)
379379- match Bot_registry.load_from_file bot_config.module_name with
380380- | Ok bot_module ->
381381- let bot = bot_module.create_instance client in
382382- Bot_registry.register registry bot_module;
383383-384384- (* Keep bot alive and handle events *)
385385- Event_loop.run client bot
386386- | Error e ->
387387- Printf.eprintf "Failed to load bot %s: %s\n"
388388- bot_config.email (Error.message e)
389389- )
390390- ) (Config.bot_configs config);
391391-392392- (* Start HTTP server for webhooks *)
393393- let server_addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, Config.port config) in
394394- Eio.Net.run_server env#net server_addr ~on_error:raise @@ fun flow _addr ->
395395-396396- (* Handle each webhook request concurrently *)
397397- Eio.Switch.run @@ fun req_sw ->
398398- Eio.Fiber.fork ~sw:req_sw (fun () ->
399399- handle_http_request registry flow
400400- )
401401-```
402402-403403-### Event Loop (`Zulip.Event_loop`)
404404-```ocaml
405405-type t (* abstract *)
406406-407407-val create : Client.t -> Bot.t -> t
408408-409409-val run : Client.t -> Bot.t -> unit
410410-(* Runs the event loop using real-time events API *)
411411-412412-val run_webhook_mode : Client.t -> Bot.t -> unit
413413-(* Runs in webhook mode, waiting for HTTP callbacks *)
414414-415415-(* For advanced use cases *)
416416-val with_event_loop :
417417- Client.t ->
418418- Bot.t ->
419419- (Event_queue.t -> unit) ->
420420- unit
421421-```
422422-423423-## Key EIO Advantages
424424-425425-1. **Structured Concurrency**: Each bot runs in its own fiber with proper cleanup
426426-2. **Resource Safety**: Automatic cleanup of connections, event queues, and HTTP servers
427427-3. **Backpressure**: Natural flow control through EIO's cooperative scheduling
428428-4. **Error Isolation**: Bot failures don't crash the entire server
429429-5. **Graceful Shutdown**: Structured teardown of all resources
430430-431431-## Design Principles
432432-433433-1. **Abstract Types**: Each major concept has its own module with abstract `type t`
434434-2. **Constructors**: Clear `create` functions with optional parameters
435435-3. **Accessors**: All fields accessible via dedicated functions
436436-4. **Pretty Printing**: Every type has a `pp` function for debugging
437437-5. **JSON Conversion**: Bidirectional JSON conversion where appropriate
438438-6. **Error Handling**: Consistent `(_, Error.t) result` return types
439439-440440-## Authentication Strategy
441441-442442-- Support zuliprc files and direct credential passing
443443-- Abstract `Auth.t` prevents credential leakage
444444-- HTTP Basic Auth with proper encoding
445445-446446-## EIO Integration
447447-448448-- All operations use EIO's direct-style async
449449-- Resource-safe client management with `with_client`
450450-- Proper cleanup of connections and event queues
451451-452452-## Example Usage
453453-454454-### Simple Message Sending
455455-```ocaml
456456-let () =
457457- Eio_main.run @@ fun env ->
458458- let auth = Zulip.Auth.create
459459- ~server_url:"https://example.zulipchat.com"
460460- ~email:"bot@example.com"
461461- ~api_key:"your-api-key" in
462462-463463- Zulip.Client.with_client env auth @@ fun client ->
464464-465465- let message = Zulip.Message.create
466466- ~type_:`Channel
467467- ~to_:["general"]
468468- ~content:"Hello from OCaml!"
469469- ~topic:"Bots"
470470- () in
471471-472472- match Zulip.Messages.send client message with
473473- | Ok response ->
474474- Printf.printf "Message sent with ID: %d\n"
475475- (Zulip.Message_response.id response)
476476- | Error error ->
477477- Printf.printf "Error: %s\n"
478478- (Zulip.Error.message error)
479479-```
480480-481481-### Simple Bot
482482-```ocaml
483483-let echo_handler = Zulip.Bot.{
484484- handle_message = (fun ~client ~message ~response ->
485485- let content = extract_content message in
486486- let echo_msg = Message.create
487487- ~type_:`Direct
488488- ~to_:[sender_email message]
489489- ~content:("Echo: " ^ content) () in
490490- response echo_msg
491491- );
492492- usage = (fun () -> "Echo bot - repeats your message");
493493- description = (fun () -> "A simple echo bot");
494494-}
495495-496496-let () =
497497- Eio_main.run @@ fun env ->
498498- let auth = Auth.from_zuliprc () |> Result.get_ok in
499499-500500- Client.with_client env auth @@ fun client ->
501501- let bot = Bot.create client ~handler:echo_handler () in
502502- Event_loop.run client bot
503503-```
504504-505505-### Multi-Bot Server
506506-```ocaml
507507-let () =
508508- Eio_main.run @@ fun env ->
509509- let config = Bot_server.Config.from_file "bots.conf" |> Result.get_ok in
510510-511511- Bot_server.with_server env config @@ fun server ->
512512- Bot_server.run server
513513-```
514514-515515-## Package Dependencies
516516-517517-- `eio` - Effects-based I/O
518518-- `cohttp-eio` - HTTP client with EIO support
519519-- `tls-eio` - TLS support for HTTPS
520520-- `jsonm` - Streaming JSON codec
521521-- `uri` - URI parsing and manipulation
522522-- `base64` - Base64 encoding for authentication
523523-524524-# Architecture Analysis: zulip_bot vs zulip_botserver
525525-526526-## Library Separation
527527-528528-### `zulip_bot` - Individual Bot Framework
529529-**Purpose**: Library for building and running a single bot instance
530530-531531-**Key Components**:
532532-- `Bot_handler` - Interface for bot logic with EIO environment access
533533-- `Bot_runner` - Manages lifecycle of one bot (real-time events or webhook mode)
534534-- `Bot_config` - Configuration for a single bot
535535-- `Bot_storage` - Simple in-memory storage for bot state
536536-537537-**Usage Pattern**:
538538-```ocaml
539539-(* Run a single bot directly *)
540540-let my_bot = Bot_handler.create (module My_echo_bot) ~config ~storage ~identity in
541541-let runner = Bot_runner.create ~client ~handler:my_bot in
542542-Bot_runner.run_realtime runner (* Bot connects to Zulip events API directly *)
543543-```
544544-545545-### `zulip_botserver` - Multi-Bot Server Infrastructure
546546-**Purpose**: HTTP server that manages multiple bots via webhooks
547547-548548-**Key Components**:
549549-- `Bot_server` - HTTP server receiving webhook events from Zulip
550550-- `Bot_registry` - Manages multiple bot instances
551551-- `Server_config` - Configuration for multiple bots + server settings
552552-- `Webhook_handler` - Parses incoming webhook requests and routes to appropriate bots
553553-554554-**Usage Pattern**:
555555-```ocaml
556556-(* Run a server hosting multiple bots *)
557557-let registry = Bot_registry.create () in
558558-Bot_registry.register registry echo_bot_module;
559559-Bot_registry.register registry weather_bot_module;
560560-561561-let server = Bot_server.create ~env ~config ~registry in
562562-Bot_server.run server (* HTTP server waits for webhook calls *)
563563-```
564564-565565-## EIO Environment Requirements
566566-567567-### Why Bot Handlers Need Direct EIO Access
568568-569569-Bot handlers require direct access to the EIO environment for legitimate I/O operations beyond HTTP requests to Zulip:
570570-571571-1. **Network Operations**: Custom HTTP requests, API calls to external services
572572-2. **File System Operations**: Reading configuration files, CSV dictionaries, logs
573573-3. **Resource Management**: Proper cleanup via structured concurrency
574574-575575-### Example: URL Checker Bot
576576-```ocaml
577577-module Url_checker_bot : Zulip_bot.Bot_handler.Bot_handler = struct
578578- let handle_message ~config ~storage ~identity ~message ~env =
579579- match parse_command message with
580580- | "!check", url ->
581581- (* Direct EIO network access needed *)
582582- Eio.Switch.run @@ fun sw ->
583583- let client = Cohttp_eio.Client.make ~sw env#net in
584584- let response = Cohttp_eio.Client.head ~sw client (Uri.of_string url) in
585585- let status = Cohttp.Code.code_of_status response.status in
586586- Ok (Response.reply ~content:(format_status_message url status))
587587- | _ -> Ok Response.none
588588-end
589589-```
590590-591591-### Example: CSV Dictionary Bot
592592-```ocaml
593593-module Csv_dict_bot : Zulip_bot.Bot_handler.Bot_handler = struct
594594- let handle_message ~config ~storage ~identity ~message ~env =
595595- match parse_command message with
596596- | "!lookup", term ->
597597- (* Direct EIO file system access needed *)
598598- let csv_path = Bot_config.get_required config ~key:"csv_file" in
599599- let content = Eio.Path.load env#fs (Eio.Path.parse csv_path) in
600600- let matches = search_csv_content content term in
601601- Ok (Response.reply ~content:(format_matches matches))
602602- | _ -> Ok Response.none
603603-end
604604-```
605605-606606-## Refined Bot Handler Interface
607607-608608-Based on analysis, the current EIO environment plumbing is **essential** and should be cleaned up:
609609-610610-```ocaml
611611-(** Clean bot handler interface with direct EIO access *)
612612-module type Bot_handler = sig
613613- val initialize : Bot_config.t -> (unit, Zulip.Error.t) result
614614- val usage : unit -> string
615615- val description : unit -> string
616616-617617- (** Handle message with full EIO environment access *)
618618- val handle_message :
619619- config:Bot_config.t ->
620620- storage:Bot_storage.t ->
621621- identity:Identity.t ->
622622- message:Message_context.t ->
623623- env:#Eio.Env.t -> (* Essential for custom I/O *)
624624- (Response.t, Zulip.Error.t) result
625625-end
626626-627627-type t
628628-629629-(** Single creation interface *)
630630-val create :
631631- (module Bot_handler) ->
632632- config:Bot_config.t ->
633633- storage:Bot_storage.t ->
634634- identity:Identity.t ->
635635- t
636636-637637-(** Single message handler requiring EIO environment *)
638638-val handle_message : t -> #Eio.Env.t -> Message_context.t -> (Response.t, Zulip.Error.t) result
639639-```
640640-641641-## Storage Strategy
642642-643643-Bot storage can be simplified to in-memory key-value storage since it's server-side:
644644-645645-```ocaml
646646-(* In zulip_bot - storage per bot instance *)
647647-module Bot_storage = struct
648648- type t = (string, string) Hashtbl.t (* Simple in-memory key-value *)
649649-650650- let create () = Hashtbl.create 16
651651- let get t ~key = Hashtbl.find_opt t key
652652- let put t ~key ~value = Hashtbl.replace t key value
653653- let contains t ~key = Hashtbl.mem t key
654654-end
655655-656656-(* In zulip_botserver - storage shared across bots *)
657657-module Server_storage = struct
658658- type t = (string * string, string) Hashtbl.t (* (bot_email, key) -> value *)
659659-660660- let create () = Hashtbl.create 64
661661- let get t ~bot_email ~key = Hashtbl.find_opt t (bot_email, key)
662662- let put t ~bot_email ~key ~value = Hashtbl.replace t (bot_email, key) value
663663-end
664664-```
665665-666666-## Interface Cleanup Recommendations
667667-668668-1. **Remove** the problematic `handle_message` function with mock environment
669669-2. **Keep** `handle_message_with_env` but rename to `handle_message`
670670-3. **Use** `#Eio.Env.t` constraint for clean typing
671671-4. **Document** that bot handlers have full EIO access for custom I/O operations
672672-673673-This design maintains flexibility for real-world bot functionality while providing clean, type-safe interfaces.
674674-675675-## Sources and References
676676-677677-This design is based on comprehensive analysis of:
678678-679679-1. **Zulip REST API Documentation**:
680680- - Main API: https://zulip.com/api/rest
681681- - Error Handling: https://zulip.com/api/rest-error-handling
682682- - Send Message: https://zulip.com/api/send-message
683683-684684-2. **Python Zulip Library**:
685685- - Main repository: https://github.com/zulip/python-zulip-api
686686- - Bot framework: https://github.com/zulip/python-zulip-api/blob/main/zulip_bots/zulip_bots/lib.py
687687- - Bot server: https://github.com/zulip/python-zulip-api/blob/main/zulip_botserver/zulip_botserver/server.py
688688-689689-The design adapts these Python patterns to idiomatic OCaml with abstract types, proper error handling, and EIO's structured concurrency for robust, type-safe Zulip integration.
···11+(** Encoding utilities for Zulip API requests *)
22+33+(** Convert a jsont-encoded value to JSON string *)
44+let to_json_string : 'a Jsont.t -> 'a -> string = fun codec value ->
55+ match Jsont_bytesrw.encode_string' codec value with
66+ | Ok s -> s
77+ | Error e -> failwith ("JSON encoding error: " ^ Jsont.Error.to_string e)
88+99+(** Convert a jsont-encoded value to form-urlencoded string *)
1010+let to_form_urlencoded : 'a Jsont.t -> 'a -> string = fun codec value ->
1111+ (* First encode to JSON, then extract fields *)
1212+ let json_str = to_json_string codec value in
1313+ match Jsont_bytesrw.decode_string' Jsont.json json_str with
1414+ | Error e -> failwith ("JSON decode error: " ^ Jsont.Error.to_string e)
1515+ | Ok (Jsont.Object (fields, _)) ->
1616+ (* Convert object fields to form-urlencoded *)
1717+ let encode_value = function
1818+ | Jsont.String (s, _) -> Some (Uri.pct_encode ~component:`Query_value s)
1919+ | Jsont.Bool (b, _) -> Some (string_of_bool b)
2020+ | Jsont.Number (n, _) -> Some (string_of_float n)
2121+ | Jsont.Null _ -> None
2222+ | Jsont.Array (items, _) ->
2323+ (* For arrays, encode as JSON array string *)
2424+ let array_str = "[" ^ String.concat "," (List.filter_map (function
2525+ | Jsont.String (s, _) -> Some ("\"" ^ String.escaped s ^ "\"")
2626+ | Jsont.Number (n, _) -> Some (string_of_float n)
2727+ | Jsont.Bool (b, _) -> Some (string_of_bool b)
2828+ | _ -> None
2929+ ) items) ^ "]" in
3030+ Some array_str
3131+ | Jsont.Object _ -> None (* Skip nested objects *)
3232+ in
3333+3434+ let params = List.filter_map (fun ((key, _), value) ->
3535+ match encode_value value with
3636+ | Some encoded -> Some (key ^ "=" ^ encoded)
3737+ | None -> None
3838+ ) fields in
3939+4040+ String.concat "&" params
4141+ | Ok _ ->
4242+ failwith "Expected JSON object for form encoding"
4343+4444+(** Parse JSON string using a jsont codec *)
4545+let from_json_string : 'a Jsont.t -> string -> ('a, string) result = fun codec json_str ->
4646+ match Jsont_bytesrw.decode_string' codec json_str with
4747+ | Ok v -> Ok v
4848+ | Error e -> Error (Jsont.Error.to_string e)
4949+5050+(** Parse a Jsont.json value using a codec *)
5151+let from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result = fun codec json ->
5252+ let json_str = match Jsont_bytesrw.encode_string' Jsont.json json with
5353+ | Ok s -> s
5454+ | Error e -> failwith ("Failed to re-encode json: " ^ Jsont.Error.to_string e)
5555+ in
5656+ from_json_string codec json_str
+21
stack/zulip/lib/zulip/lib/encode.mli
···11+(** Encoding utilities for Zulip API requests *)
22+33+(** Convert a value to JSON string using its jsont codec *)
44+val to_json_string : 'a Jsont.t -> 'a -> string
55+66+(** Convert a value to application/x-www-form-urlencoded string using its jsont codec
77+88+ The codec should represent a JSON object. Fields will be converted to key=value pairs:
99+ - Strings: URL-encoded
1010+ - Booleans: "true"/"false"
1111+ - Numbers: string representation
1212+ - Arrays: JSON array string "[...]"
1313+ - Null: omitted
1414+ - Nested objects: omitted *)
1515+val to_form_urlencoded : 'a Jsont.t -> 'a -> string
1616+1717+(** Parse JSON string using a jsont codec *)
1818+val from_json_string : 'a Jsont.t -> string -> ('a, string) result
1919+2020+(** Parse a Jsont.json value using a codec *)
2121+val from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result
+35-24
stack/zulip/lib/zulip/lib/event.ml
···88let type_ t = t.type_
99let data t = t.data
10101111-let of_json json =
1111+let pp fmt t = Format.fprintf fmt "Event{id=%d, type=%a}" t.id Event_type.pp t.type_
1212+1313+(* Helper to extract fields from Jsont.json *)
1414+let get_int_field json name =
1515+ match json with
1616+ | Jsont.Object (fields, _) ->
1717+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
1818+ (match List.assoc_opt name assoc with
1919+ | Some (Jsont.Number (n, _)) -> int_of_float n
2020+ | _ -> Jsont.Error.msg Jsont.Meta.none
2121+ (Format.sprintf "Field '%s' not found or not an int" name))
2222+ | _ -> Jsont.Error.msg Jsont.Meta.none "Expected JSON object"
2323+2424+let get_string_field json name =
2525+ match json with
2626+ | Jsont.Object (fields, _) ->
2727+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
2828+ (match List.assoc_opt name assoc with
2929+ | Some (Jsont.String (s, _)) -> s
3030+ | _ -> Jsont.Error.msg Jsont.Meta.none
3131+ (Format.sprintf "Field '%s' not found or not a string" name))
3232+ | _ -> Jsont.Error.msg Jsont.Meta.none "Expected JSON object"
3333+3434+(* Simple decoder that extracts id and type, keeping full JSON as data *)
3535+let of_json_direct json =
1236 try
1313- match json with
1414- | `O fields ->
1515- let get_int key =
1616- match List.assoc key fields with
1717- | `Float f -> int_of_float f
1818- | _ -> failwith ("Expected int for " ^ key) in
1919- let get_string key =
2020- match List.assoc key fields with
2121- | `String s -> s
2222- | _ -> failwith ("Expected string for " ^ key) in
2323- let id = get_int "id" in
2424- let type_str = get_string "type" in
2525- let type_ = Event_type.of_string type_str in
2626- (* The whole event is the data - store it all *)
2727- let data = json in
3737+ let id = get_int_field json "id" in
3838+ let type_str = get_string_field json "type" in
3939+ let type_ = Event_type.of_string type_str in
4040+ Ok { id; type_; data = json }
4141+ with e ->
4242+ Error (Zulip_types.create_error ~code:(Other "json_parse_error")
4343+ ~msg:("Event JSON parsing failed: " ^ Printexc.to_string e) ())
28442929- Ok { id; type_; data }
3030- | _ ->
3131- Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:"Event JSON must be an object" ())
3232- with
3333- | exn ->
3434- Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:("Event JSON parsing failed: " ^ Printexc.to_string exn) ())
3535-3636-let pp fmt t = Format.fprintf fmt "Event{id=%d, type=%a}" t.id Event_type.pp t.type_4545+(* Decode function *)
4646+let of_json json =
4747+ of_json_direct json
+79-42
stack/zulip/lib/zulip/lib/event_queue.ml
···66 id : string;
77}
8899+(* Request/response codecs *)
1010+module Register_request = struct
1111+ type t = { event_types : string list option }
1212+1313+ let codec =
1414+ Jsont.Object.(
1515+ map ~kind:"RegisterRequest" (fun event_types -> { event_types })
1616+ |> opt_mem "event_types" (Jsont.list Jsont.string) ~enc:(fun r -> r.event_types)
1717+ |> finish
1818+ )
1919+end
2020+2121+module Register_response = struct
2222+ type t = { queue_id : string }
2323+2424+ let codec =
2525+ Jsont.Object.(
2626+ map ~kind:"RegisterResponse" (fun queue_id -> { queue_id })
2727+ |> mem "queue_id" Jsont.string ~enc:(fun r -> r.queue_id)
2828+ |> finish
2929+ )
3030+end
3131+932let register client ?event_types () =
1010- let params = match event_types with
1111- | None -> []
1212- | Some types ->
1313- let types_json = "[" ^
1414- String.concat "," (List.map (fun t -> "\"" ^ Event_type.to_string t ^ "\"") types) ^
1515- "]"
1616- in
1717- Log.debug (fun m -> m "Registering with event_types: %s" types_json);
1818- [("event_types", types_json)]
1919- in
2020- match Client.request client ~method_:`POST ~path:"/api/v1/register" ~params () with
2121- | Ok json ->
2222- (match json with
2323- | `O fields ->
2424- (match List.assoc_opt "queue_id" fields with
2525- | Some (`String queue_id) -> Ok { id = queue_id }
2626- | _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Invalid register response: missing queue_id" ()))
2727- | _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Register response must be an object" ()))
3333+ let event_types_str = Option.map (List.map Event_type.to_string) event_types in
3434+ let req = Register_request.{ event_types = event_types_str } in
3535+ let body = Encode.to_form_urlencoded Register_request.codec req in
3636+ let content_type = "application/x-www-form-urlencoded" in
3737+3838+ (match event_types_str with
3939+ | Some types -> Log.debug (fun m -> m "Registering with event_types: %s" (String.concat "," types))
4040+ | None -> ());
4141+4242+ match Client.request client ~method_:`POST ~path:"/api/v1/register" ~body ~content_type () with
4343+ | Ok json ->
4444+ (match Encode.from_json Register_response.codec json with
4545+ | Ok response -> Ok { id = response.queue_id }
4646+ | Error msg -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg ()))
2847 | Error err -> Error err
29483049let id t = t.id
31503232-let get_events t client ?last_event_id () =
3333- let params = [("queue_id", t.id)] @
5151+(* Events response codec - events field is optional (may not be present) *)
5252+module Events_response = struct
5353+ type t = { events : Event.t list }
5454+5555+ (* Custom codec that handles Event.t which has its own of_json *)
5656+ let codec =
5757+ let kind = "EventsResponse" in
5858+ let of_string s =
5959+ match Jsont_bytesrw.decode_string' Jsont.json s with
6060+ | Error e -> Error (Jsont.Error.to_string e)
6161+ | Ok (Jsont.Object (fields, _)) ->
6262+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
6363+ (match List.assoc_opt "events" assoc with
6464+ | Some (Jsont.Array (event_list, _)) ->
6565+ let events = List.fold_left (fun acc event_json ->
6666+ match Event.of_json event_json with
6767+ | Ok event -> event :: acc
6868+ | Error _ -> acc
6969+ ) [] event_list in
7070+ Ok { events = List.rev events }
7171+ | None -> Ok { events = [] }
7272+ | _ -> Error "events field is not an array")
7373+ | Ok _ -> Error "Expected JSON object"
7474+ in
7575+ let enc _t =
7676+ (* Not used for responses, but required by codec *)
7777+ Fmt.str "{\"events\": []}"
7878+ in
7979+ Jsont.of_of_string ~kind of_string ~enc
8080+end
8181+8282+let get_events t client ?last_event_id () =
8383+ let params = [("queue_id", t.id)] @
3484 (match last_event_id with
3585 | None -> []
3686 | Some event_id -> [("last_event_id", string_of_int event_id)]) in
3787 match Client.request client ~method_:`GET ~path:"/api/v1/events" ~params () with
3838- | Ok json ->
3939- (match json with
4040- | `O fields ->
4141- (match List.assoc_opt "events" fields with
4242- | Some (`A event_list) ->
4343- Log.debug (fun m -> m "Got %d raw events from API" (List.length event_list));
4444- let events = List.fold_left (fun acc event_json ->
4545- match Event.of_json event_json with
4646- | Ok event -> event :: acc
4747- | Error e ->
4848- Log.warn (fun m -> m "Failed to parse event: %s" (Zulip_types.error_message e));
4949- acc
5050- ) [] event_list in
5151- Ok (List.rev events)
5252- | Some _other ->
5353- Log.warn (fun m -> m "Events field is not an array");
5454- Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Invalid events response format" ())
5555- | None ->
5656- Log.debug (fun m -> m "No events field in response");
5757- Ok [])
5858- | _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Events response must be an object" ()))
8888+ | Ok json ->
8989+ (match Encode.from_json Events_response.codec json with
9090+ | Ok response ->
9191+ Log.debug (fun m -> m "Got %d events from API" (List.length response.events));
9292+ Ok response.events
9393+ | Error msg ->
9494+ Log.warn (fun m -> m "Failed to parse events response: %s" msg);
9595+ Error (Zulip_types.create_error ~code:(Other "api_error") ~msg ()))
5996 | Error err -> Error err
60976161-let delete t client =
9898+let delete t client =
6299 let params = [("queue_id", t.id)] in
63100 match Client.request client ~method_:`DELETE ~path:"/api/v1/events" ~params () with
64101 | Ok _json -> Ok ()
-206
stack/zulip/lib/zulip/lib/jsonu.ml
···11-(** JSON utility functions for Zulip API *)
22-33-type json = Zulip_types.json
44-55-(** {1 Field extraction utilities} *)
66-77-let get_string fields key =
88- match List.assoc_opt key fields with
99- | Some (`String s) -> Ok s
1010- | Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a string" key) ())
1111- | None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
1212-1313-let get_string_default fields key default =
1414- match get_string fields key with
1515- | Ok s -> s
1616- | Error _ -> default
1717-1818-let get_string_opt fields key =
1919- match List.assoc_opt key fields with
2020- | Some (`String s) -> Some s
2121- | _ -> None
2222-2323-let to_int_flex = function
2424- | `Float f -> int_of_float f
2525- | `String s -> (try int_of_string s with _ -> failwith "Invalid integer string")
2626- | json -> failwith (Printf.sprintf "Expected int or float, got %s" (match json with
2727- | `Null -> "null"
2828- | `Bool _ -> "bool"
2929- | `O _ -> "object"
3030- | `A _ -> "array"
3131- | _ -> "unknown"))
3232-3333-let get_int fields key =
3434- match List.assoc_opt key fields with
3535- | Some json ->
3636- (try Ok (to_int_flex json) with
3737- | Failure msg -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg ()))
3838- | None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
3939-4040-let get_int_default fields key default =
4141- match get_int fields key with
4242- | Ok i -> i
4343- | Error _ -> default
4444-4545-let get_int_opt fields key =
4646- match List.assoc_opt key fields with
4747- | Some json -> (try Some (to_int_flex json) with _ -> None)
4848- | None -> None
4949-5050-let get_float fields key =
5151- match List.assoc_opt key fields with
5252- | Some (`Float f) -> Ok f
5353- | Some (`String s) ->
5454- (try Ok (float_of_string s) with
5555- | _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a valid float" key) ()))
5656- | Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a float" key) ())
5757- | None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
5858-5959-let get_float_default fields key default =
6060- match get_float fields key with
6161- | Ok f -> f
6262- | Error _ -> default
6363-6464-let get_bool fields key =
6565- match List.assoc_opt key fields with
6666- | Some (`Bool b) -> Ok b
6767- | Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a boolean" key) ())
6868- | None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
6969-7070-let get_bool_default fields key default =
7171- match get_bool fields key with
7272- | Ok b -> b
7373- | Error _ -> default
7474-7575-let get_bool_opt fields key =
7676- match List.assoc_opt key fields with
7777- | Some (`Bool b) -> Some b
7878- | _ -> None
7979-8080-let get_object fields key =
8181- match List.assoc_opt key fields with
8282- | Some (`O obj) -> Ok obj
8383- | Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not an object" key) ())
8484- | None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
8585-8686-let get_object_opt fields key =
8787- match List.assoc_opt key fields with
8888- | Some (`O obj) -> Some obj
8989- | _ -> None
9090-9191-let get_array fields key =
9292- match List.assoc_opt key fields with
9393- | Some (`A arr) -> Ok arr
9494- | Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not an array" key) ())
9595- | None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
9696-9797-let get_array_opt fields key =
9898- match List.assoc_opt key fields with
9999- | Some (`A arr) -> Some arr
100100- | _ -> None
101101-102102-(** {1 Type conversion utilities} *)
103103-104104-let to_int_safe = function
105105- | `Float f -> Some (int_of_float f)
106106- | `String s -> (try Some (int_of_string s) with _ -> None)
107107- | _ -> None
108108-109109-let to_string_safe = function
110110- | `String s -> Some s
111111- | _ -> None
112112-113113-let to_bool_safe = function
114114- | `Bool b -> Some b
115115- | _ -> None
116116-117117-let to_float_safe = function
118118- | `Float f -> Some f
119119- | `String s -> (try Some (float_of_string s) with _ -> None)
120120- | _ -> None
121121-122122-(** {1 Object parsing utilities} *)
123123-124124-let with_object context f = function
125125- | `O fields -> f fields
126126- | _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "%s: expected JSON object" context) ())
127127-128128-let with_array context f json =
129129- match json with
130130- | `A items ->
131131- let rec process acc = function
132132- | [] -> Ok (List.rev acc)
133133- | item :: rest ->
134134- match f item with
135135- | Ok v -> process (v :: acc) rest
136136- | Error e -> Error e
137137- in
138138- process [] items
139139- | _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "%s: expected JSON array" context) ())
140140-141141-(** {1 Construction utilities} *)
142142-143143-let optional_field key encoder = function
144144- | Some value -> Some (key, encoder value)
145145- | None -> None
146146-147147-let optional_fields fields =
148148- List.filter_map (fun x -> x) fields
149149-150150-let string_array strings =
151151- `A (List.map (fun s -> `String s) strings)
152152-153153-let int_array ints =
154154- `A (List.map (fun i -> `Float (float_of_int i)) ints)
155155-156156-(** {1 Error handling} *)
157157-158158-let json_error msg =
159159- Zulip_types.create_error ~code:(Other "json_error") ~msg ()
160160-161161-let field_missing_error field =
162162- Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Required field '%s' not found" field) ()
163163-164164-let type_mismatch_error field expected =
165165- Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' type mismatch: expected %s" field expected) ()
166166-167167-let parse_with_error context f =
168168- try f ()
169169- with
170170- | Failure msg -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:(Printf.sprintf "%s: %s" context msg) ())
171171- | exn -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:(Printf.sprintf "%s: %s" context (Printexc.to_string exn)) ())
172172-173173-174174-(** {1 Debugging utilities} *)
175175-176176-let to_string_pretty json =
177177- let rec aux indent = function
178178- | `Null -> "null"
179179- | `Bool b -> string_of_bool b
180180- | `Float f ->
181181- if float_of_int (int_of_float f) = f then
182182- string_of_int (int_of_float f)
183183- else
184184- string_of_float f
185185- | `String s -> Printf.sprintf "%S" s
186186- | `A [] -> "[]"
187187- | `A lst ->
188188- let items = List.map (aux (indent ^ " ")) lst in
189189- Printf.sprintf "[\n%s%s\n%s]"
190190- (indent ^ " ")
191191- (String.concat (",\n" ^ indent ^ " ") items)
192192- indent
193193- | `O [] -> "{}"
194194- | `O fields ->
195195- let items = List.map (fun (k, v) ->
196196- Printf.sprintf "%S: %s" k (aux (indent ^ " ") v)
197197- ) fields in
198198- Printf.sprintf "{\n%s%s\n%s}"
199199- (indent ^ " ")
200200- (String.concat (",\n" ^ indent ^ " ") items)
201201- indent
202202- in
203203- aux "" json
204204-205205-let pp fmt json =
206206- Format.pp_print_string fmt (to_string_pretty json)
-117
stack/zulip/lib/zulip/lib/jsonu.mli
···11-(** JSON utility functions for Zulip API
22-33- This module provides common utilities for working with JSON in the Zulip API,
44- reducing boilerplate and providing consistent error handling. *)
55-66-(** {1 Type definitions} *)
77-88-type json = Zulip_types.json
99-1010-(** {1 Field extraction utilities} *)
1111-1212-(** Extract a string field from a JSON object *)
1313-val get_string : (string * json) list -> string -> (string, Zulip_types.zerror) result
1414-1515-(** Extract a string field with a default value *)
1616-val get_string_default : (string * json) list -> string -> string -> string
1717-1818-(** Extract an optional string field *)
1919-val get_string_opt : (string * json) list -> string -> string option
2020-2121-(** Extract an integer field (handles both int and float representations) *)
2222-val get_int : (string * json) list -> string -> (int, Zulip_types.zerror) result
2323-2424-(** Extract an integer field with a default value *)
2525-val get_int_default : (string * json) list -> string -> int -> int
2626-2727-(** Extract an optional integer field *)
2828-val get_int_opt : (string * json) list -> string -> int option
2929-3030-(** Extract a float field *)
3131-val get_float : (string * json) list -> string -> (float, Zulip_types.zerror) result
3232-3333-(** Extract a float field with a default value *)
3434-val get_float_default : (string * json) list -> string -> float -> float
3535-3636-(** Extract a boolean field *)
3737-val get_bool : (string * json) list -> string -> (bool, Zulip_types.zerror) result
3838-3939-(** Extract a boolean field with a default value *)
4040-val get_bool_default : (string * json) list -> string -> bool -> bool
4141-4242-(** Extract an optional boolean field *)
4343-val get_bool_opt : (string * json) list -> string -> bool option
4444-4545-(** Extract a JSON object field *)
4646-val get_object : (string * json) list -> string -> ((string * json) list, Zulip_types.zerror) result
4747-4848-(** Extract an optional JSON object field *)
4949-val get_object_opt : (string * json) list -> string -> (string * json) list option
5050-5151-(** Extract a JSON array field *)
5252-val get_array : (string * json) list -> string -> (json list, Zulip_types.zerror) result
5353-5454-(** Extract an optional JSON array field *)
5555-val get_array_opt : (string * json) list -> string -> json list option
5656-5757-(** {1 Type conversion utilities} *)
5858-5959-(** Convert JSON to int, handling both int and float representations *)
6060-val to_int_flex : json -> int
6161-6262-(** Safely convert JSON to int *)
6363-val to_int_safe : json -> int option
6464-6565-(** Convert JSON to string *)
6666-val to_string_safe : json -> string option
6767-6868-(** Convert JSON to bool *)
6969-val to_bool_safe : json -> bool option
7070-7171-(** Convert JSON to float *)
7272-val to_float_safe : json -> float option
7373-7474-(** {1 Object parsing utilities} *)
7575-7676-(** Parse a JSON value as an object, applying a function to its fields *)
7777-val with_object : string -> ((string * json) list -> ('a, Zulip_types.zerror) result) -> json -> ('a, Zulip_types.zerror) result
7878-7979-(** Parse a JSON value as an array, applying a function to each element *)
8080-val with_array : string -> (json -> ('a, Zulip_types.zerror) result) -> json -> ('a list, Zulip_types.zerror) result
8181-8282-(** {1 Construction utilities} *)
8383-8484-(** Create an optional field for JSON object construction *)
8585-val optional_field : string -> ('a -> json) -> 'a option -> (string * json) option
8686-8787-(** Create a list of optional fields, filtering out None values *)
8888-val optional_fields : (string * json) option list -> (string * json) list
8989-9090-(** Convert a string list to a JSON array *)
9191-val string_array : string list -> json
9292-9393-(** Convert an int list to a JSON array *)
9494-val int_array : int list -> json
9595-9696-(** {1 Error handling} *)
9797-9898-(** Create a JSON parsing error *)
9999-val json_error : string -> Zulip_types.zerror
100100-101101-(** Create a field missing error *)
102102-val field_missing_error : string -> Zulip_types.zerror
103103-104104-(** Create a type mismatch error *)
105105-val type_mismatch_error : string -> string -> Zulip_types.zerror
106106-107107-(** Wrap a parsing function with exception handling *)
108108-val parse_with_error : string -> (unit -> ('a, Zulip_types.zerror) result) -> ('a, Zulip_types.zerror) result
109109-110110-111111-(** {1 Debugging utilities} *)
112112-113113-(** Convert JSON to a pretty-printed string *)
114114-val to_string_pretty : json -> string
115115-116116-(** Print JSON value for debugging *)
117117-val pp : Format.formatter -> json -> unit
-144
stack/zulip/lib/zulip/lib/jsonu_syntax.ml
···11-(** Syntax module for monadic and applicative JSON parsing *)
22-33-type json = Zulip_types.json
44-type 'a parser = json -> ('a, Zulip_types.zerror) result
55-66-(** Monadic bind operator for sequential parsing with error handling *)
77-let ( let* ) = Result.bind
88-99-(** Map operator for transforming successful results *)
1010-let ( let+ ) x f = Result.map f x
1111-1212-(** Applicative parallel composition *)
1313-let ( and+ ) x y =
1414- match x, y with
1515- | Ok x, Ok y -> Ok (x, y)
1616- | Error e, _ | _, Error e -> Error e
1717-1818-(** Applicative parallel composition for 3 values *)
1919-let ( and++ ) xy z =
2020- match xy, z with
2121- | Ok (x, y), Ok z -> Ok (x, y, z)
2222- | Error e, _ | _, Error e -> Error e
2323-2424-(** Applicative parallel composition for 4 values *)
2525-let ( and+++ ) xyz w =
2626- match xyz, w with
2727- | Ok (x, y, z), Ok w -> Ok (x, y, z, w)
2828- | Error e, _ | _, Error e -> Error e
2929-3030-(** Applicative parallel composition for 5 values *)
3131-let ( and++++ ) xyzw v =
3232- match xyzw, v with
3333- | Ok (x, y, z, w), Ok v -> Ok (x, y, z, w, v)
3434- | Error e, _ | _, Error e -> Error e
3535-3636-(** Alternative operator - try first, if fails try second *)
3737-let ( <|> ) x y =
3838- match x with
3939- | Ok _ -> x
4040- | Error _ -> y
4141-4242-(** Provide a default value if parsing fails *)
4343-let ( |? ) x default =
4444- match x with
4545- | Ok v -> v
4646- | Error _ -> default
4747-4848-(** Convert option to result with error message *)
4949-let required name = function
5050- | Some v -> Ok v
5151- | None -> Error (Zulip_types.create_error ~code:(Other "missing_field") ~msg:(Printf.sprintf "Required field '%s' not found" name) ())
5252-5353-(** Convert option to result with default *)
5454-let default v = function
5555- | Some x -> x
5656- | None -> v
5757-5858-(** Lift a pure value into parser context *)
5959-let pure x = Ok x
6060-6161-(** Fail with an error message *)
6262-let fail msg = Error (Zulip_types.create_error ~code:(Other "parse_error") ~msg ())
6363-6464-(** Map over a list with error handling *)
6565-let traverse f lst =
6666- let rec go acc = function
6767- | [] -> Ok (List.rev acc)
6868- | x :: xs ->
6969- let* v = f x in
7070- go (v :: acc) xs
7171- in
7272- go [] lst
7373-7474-(** Filter and map over a list, dropping errors *)
7575-let filter_map f lst =
7676- List.filter_map (fun x ->
7777- match f x with
7878- | Ok v -> Some v
7979- | Error _ -> None
8080- ) lst
8181-8282-(** Parse a field with a custom parser *)
8383-let field fields key parser =
8484- match List.assoc_opt key fields with
8585- | Some json -> parser json
8686- | None -> Error (Jsonu.field_missing_error key)
8787-8888-(** Parse an optional field with a custom parser *)
8989-let field_opt fields key parser =
9090- match List.assoc_opt key fields with
9191- | Some json ->
9292- (match parser json with
9393- | Ok v -> Ok (Some v)
9494- | Error _ -> Ok None)
9595- | None -> Ok None
9696-9797-(** Parse a field with a default value if missing or fails *)
9898-let field_or fields key parser default =
9999- match List.assoc_opt key fields with
100100- | Some json ->
101101- (match parser json with
102102- | Ok v -> Ok v
103103- | Error _ -> Ok default)
104104- | None -> Ok default
105105-106106-(** Common parsers *)
107107-let string = function
108108- | `String s -> Ok s
109109- | _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected string" ())
110110-111111-let int = function
112112- | `Float f -> Ok (int_of_float f)
113113- | `String s ->
114114- (try Ok (int_of_string s)
115115- with _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected integer" ()))
116116- | _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected integer" ())
117117-118118-let float = function
119119- | `Float f -> Ok f
120120- | `String s ->
121121- (try Ok (float_of_string s)
122122- with _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected float" ()))
123123- | _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected float" ())
124124-125125-let bool = function
126126- | `Bool b -> Ok b
127127- | _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected boolean" ())
128128-129129-let array parser = function
130130- | `A items -> traverse parser items
131131- | _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected array" ())
132132-133133-let object_ = function
134134- | `O fields -> Ok fields
135135- | _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected object" ())
136136-137137-(** Run a parser on JSON *)
138138-let parse parser json = parser json
139139-140140-(** Run a parser with error context *)
141141-let with_context ctx parser json =
142142- match parser json with
143143- | Ok v -> Ok v
144144- | Error e -> Error (Zulip_types.create_error ~code:(Zulip_types.error_code e) ~msg:(Printf.sprintf "%s: %s" ctx (Zulip_types.error_message e)) ())
-96
stack/zulip/lib/zulip/lib/jsonu_syntax.mli
···11-(** Syntax module for monadic and applicative JSON parsing
22-33- This module provides binding operators and combinators to make JSON parsing
44- more ergonomic and composable. It enables code like:
55-66- {[
77- let parse_user json =
88- with_object "user" @@ fun fields ->
99- let+ user_id = field fields "user_id" int
1010- and+ email = field fields "email" string
1111- and+ full_name = field fields "full_name" string in
1212- { user_id; email; full_name }
1313- ]}
1414-*)
1515-1616-type json = Zulip_types.json
1717-type 'a parser = json -> ('a, Zulip_types.zerror) result
1818-1919-(** {1 Binding Operators} *)
2020-2121-(** Monadic bind operator for sequential parsing with error handling *)
2222-val ( let* ) : ('a, 'e) result -> ('a -> ('b, 'e) result) -> ('b, 'e) result
2323-2424-(** Map operator for transforming successful results *)
2525-val ( let+ ) : ('a, 'e) result -> ('a -> 'b) -> ('b, 'e) result
2626-2727-(** Applicative parallel composition for independent field extraction *)
2828-val ( and+ ) : ('a, 'e) result -> ('b, 'e) result -> ('a * 'b, 'e) result
2929-val ( and++ ) : ('a * 'b, 'e) result -> ('c, 'e) result -> ('a * 'b * 'c, 'e) result
3030-val ( and+++ ) : ('a * 'b * 'c, 'e) result -> ('d, 'e) result -> ('a * 'b * 'c * 'd, 'e) result
3131-val ( and++++ ) : ('a * 'b * 'c * 'd, 'e) result -> ('f, 'e) result -> ('a * 'b * 'c * 'd * 'f, 'e) result
3232-3333-(** {1 Alternative and Default Operators} *)
3434-3535-(** Alternative operator - try first parser, if fails try second *)
3636-val ( <|> ) : ('a, 'e) result -> ('a, 'e) result -> ('a, 'e) result
3737-3838-(** Provide a default value if parsing fails *)
3939-val ( |? ) : ('a, 'e) result -> 'a -> 'a
4040-4141-(** {1 Field Extraction} *)
4242-4343-(** Parse a required field with a custom parser *)
4444-val field : (string * json) list -> string -> 'a parser -> ('a, Zulip_types.zerror) result
4545-4646-(** Parse an optional field with a custom parser *)
4747-val field_opt : (string * json) list -> string -> 'a parser -> ('a option, Zulip_types.zerror) result
4848-4949-(** Parse a field with a default value if missing or fails *)
5050-val field_or : (string * json) list -> string -> 'a parser -> 'a -> ('a, Zulip_types.zerror) result
5151-5252-(** {1 Basic Parsers} *)
5353-5454-(** Parse a JSON string *)
5555-val string : string parser
5656-5757-(** Parse a JSON number as integer (handles both int and float) *)
5858-val int : int parser
5959-6060-(** Parse a JSON number as float *)
6161-val float : float parser
6262-6363-(** Parse a JSON boolean *)
6464-val bool : bool parser
6565-6666-(** Parse a JSON array with a parser for elements *)
6767-val array : 'a parser -> 'a list parser
6868-6969-(** Parse a JSON object to get its fields *)
7070-val object_ : json -> ((string * json) list, Zulip_types.zerror) result
7171-7272-(** {1 Utility Functions} *)
7373-7474-(** Convert option to result with error message *)
7575-val required : string -> 'a option -> ('a, Zulip_types.zerror) result
7676-7777-(** Get value from option with default *)
7878-val default : 'a -> 'a option -> 'a
7979-8080-(** Lift a pure value into parser context *)
8181-val pure : 'a -> ('a, 'e) result
8282-8383-(** Fail with an error message *)
8484-val fail : string -> ('a, Zulip_types.zerror) result
8585-8686-(** Map over a list with error handling *)
8787-val traverse : ('a -> ('b, Zulip_types.zerror) result) -> 'a list -> ('b list, Zulip_types.zerror) result
8888-8989-(** Filter and map over a list, dropping errors *)
9090-val filter_map : ('a -> ('b, Zulip_types.zerror) result) -> 'a list -> 'b list
9191-9292-(** Run a parser on JSON *)
9393-val parse : 'a parser -> json -> ('a, Zulip_types.zerror) result
9494-9595-(** Run a parser with error context *)
9696-val with_context : string -> 'a parser -> 'a parser
+35-21
stack/zulip/lib/zulip/lib/message.ml
···1919let local_id t = t.local_id
2020let read_by_sender t = t.read_by_sender
21212222-let to_json t =
2323- let base_fields = [
2424- ("type", `String (Message_type.to_string t.type_));
2525- ("to", `A (List.map (fun s -> `String s) t.to_));
2626- ("content", `String t.content);
2727- ("read_by_sender", `Bool t.read_by_sender);
2828- ] in
2929- let with_topic = match t.topic with
3030- | Some topic -> ("topic", `String topic) :: base_fields
3131- | None -> base_fields in
3232- let with_queue_id = match t.queue_id with
3333- | Some qid -> ("queue_id", `String qid) :: with_topic
3434- | None -> with_topic in
3535- let with_local_id = match t.local_id with
3636- | Some lid -> ("local_id", `String lid) :: with_queue_id
3737- | None -> with_queue_id in
3838- `O with_local_id
2222+let pp fmt t = Format.fprintf fmt "Message{type=%a, to=%s, content=%s}"
2323+ Message_type.pp t.type_
2424+ (String.concat "," t.to_)
2525+ t.content
39264040-let pp fmt t = Format.fprintf fmt "Message{type=%a, to=%s, content=%s}"
4141- Message_type.pp t.type_
4242- (String.concat "," t.to_)
4343- t.content2727+(* Jsont codec for Message_type.t *)
2828+let message_type_jsont =
2929+ let of_string s = match Message_type.of_string s with
3030+ | Some t -> Ok t
3131+ | None -> Error (Format.sprintf "Invalid message type: %s" s)
3232+ in
3333+ Jsont.of_of_string ~kind:"Message_type.t" of_string ~enc:Message_type.to_string
3434+3535+(* Jsont codec for the message *)
3636+let jsont =
3737+ let kind = "Message" in
3838+ let doc = "A Zulip message to be sent" in
3939+ let make type_ to_ content topic queue_id local_id read_by_sender =
4040+ { type_; to_; content; topic; queue_id; local_id; read_by_sender }
4141+ in
4242+ Jsont.Object.map ~kind ~doc make
4343+ |> Jsont.Object.mem "type" message_type_jsont ~enc:type_
4444+ |> Jsont.Object.mem "to" (Jsont.list Jsont.string) ~enc:to_
4545+ |> Jsont.Object.mem "content" Jsont.string ~enc:content
4646+ |> Jsont.Object.opt_mem "topic" Jsont.string ~enc:topic
4747+ |> Jsont.Object.opt_mem "queue_id" Jsont.string ~enc:queue_id
4848+ |> Jsont.Object.opt_mem "local_id" Jsont.string ~enc:local_id
4949+ |> Jsont.Object.mem "read_by_sender" Jsont.bool ~enc:read_by_sender
5050+ |> Jsont.Object.finish
5151+5252+(* Encoding functions *)
5353+let to_json_string t =
5454+ Encode.to_json_string jsont t
5555+5656+let to_form_urlencoded t =
5757+ Encode.to_form_urlencoded jsont t
+18-9
stack/zulip/lib/zulip/lib/message.mli
···11type t
2233-val create :
44- type_:Message_type.t ->
55- to_:string list ->
66- content:string ->
77- ?topic:string ->
88- ?queue_id:string ->
99- ?local_id:string ->
1010- ?read_by_sender:bool ->
33+val create :
44+ type_:Message_type.t ->
55+ to_:string list ->
66+ content:string ->
77+ ?topic:string ->
88+ ?queue_id:string ->
99+ ?local_id:string ->
1010+ ?read_by_sender:bool ->
1111 unit -> t
12121313val type_ : t -> Message_type.t
···1717val queue_id : t -> string option
1818val local_id : t -> string option
1919val read_by_sender : t -> bool
2020-val to_json : t -> Zulip_types.json
2020+2121+(** Jsont codec for the message type *)
2222+val jsont : t Jsont.t
2323+2424+(** Encode to JSON string *)
2525+val to_json_string : t -> string
2626+2727+(** Encode to form-urlencoded string *)
2828+val to_form_urlencoded : t -> string
2929+2130val pp : Format.formatter -> t -> unit
+20-8
stack/zulip/lib/zulip/lib/message_response.ml
···66let id t = t.id
77let automatic_new_visibility_policy t = t.automatic_new_visibility_policy
8899+let pp fmt t = Format.fprintf fmt "MessageResponse{id=%d}" t.id
1010+1111+(* Jsont codec for message response *)
1212+let jsont =
1313+ let kind = "MessageResponse" in
1414+ let doc = "A Zulip message response" in
1515+ let make id automatic_new_visibility_policy =
1616+ { id; automatic_new_visibility_policy }
1717+ in
1818+ Jsont.Object.map ~kind ~doc make
1919+ |> Jsont.Object.mem "id" Jsont.int ~enc:id
2020+ |> Jsont.Object.opt_mem "automatic_new_visibility_policy" Jsont.string ~enc:automatic_new_visibility_policy
2121+ |> Jsont.Object.finish
2222+2323+(* Decode and encode functions using Encode module *)
924let of_json json =
1010- Jsonu.with_object "message_response" (fun fields ->
1111- match Jsonu.get_int fields "id" with
1212- | Error e -> Error e
1313- | Ok id ->
1414- let automatic_new_visibility_policy = Jsonu.get_string_opt fields "automatic_new_visibility_policy" in
1515- Ok { id; automatic_new_visibility_policy }
1616- ) json
2525+ match Encode.from_json jsont json with
2626+ | Ok v -> Ok v
2727+ | Error msg -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg ())
17281818-let pp fmt t = Format.fprintf fmt "MessageResponse{id=%d}" t.id2929+let to_json_string t =
3030+ Encode.to_json_string jsont t
+5
stack/zulip/lib/zulip/lib/message_response.mli
···2233val id : t -> int
44val automatic_new_visibility_policy : t -> string option
55+66+(** Jsont codec for message response *)
77+val jsont : t Jsont.t
88+59val of_json : Zulip_types.json -> (t, Zulip_types.zerror) result
1010+val to_json_string : t -> string
611val pp : Format.formatter -> t -> unit
+15-62
stack/zulip/lib/zulip/lib/messages.ml
···11let send client message =
22- let json = Message.to_json message in
33- let params = match json with
44- | `O fields ->
55- List.fold_left (fun acc (key, value) ->
66- let str_value = match value with
77- | `String s -> s
88- | `Bool true -> "true"
99- | `Bool false -> "false"
1010- | `A arr -> String.concat "," (List.map (function `String s -> s | _ -> "") arr)
1111- | _ -> ""
1212- in
1313- (key, str_value) :: acc
1414- ) [] fields
1515- | _ -> [] in
1616-1717- match Client.request client ~method_:`POST ~path:"/api/v1/messages" ~params () with
22+ (* Use form-urlencoded encoding for the message *)
33+ let body = Message.to_form_urlencoded message in
44+ let content_type = "application/x-www-form-urlencoded" in
55+66+ match Client.request client ~method_:`POST ~path:"/api/v1/messages" ~body ~content_type () with
187 | Ok response -> Message_response.of_json response
198 | Error err -> Error err
2092110let edit client ~message_id ?content ?topic () =
2222- let params =
1111+ let params =
2312 (("message_id", string_of_int message_id) ::
2413 (match content with Some c -> [("content", c)] | None -> []) @
2514 (match topic with Some t -> [("topic", t)] | None -> [])) in
2626-1515+2716 match Client.request client ~method_:`PATCH ~path:("/api/v1/messages/" ^ string_of_int message_id) ~params () with
2817 | Ok _ -> Ok ()
2918 | Error err -> Error err
···4837let add_reaction client ~message_id ~emoji_name =
4938 let params = [
5039 ("emoji_name", emoji_name);
5151- ("reaction_type", "unicode_emoji");
5240 ] in
5341 match Client.request client ~method_:`POST
5454- ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
5555- ~params () with
5656- | Ok _ -> Ok ()
4242+ ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions") ~params () with
4343+ | Ok _json -> Ok ()
5744 | Error err -> Error err
58455946let remove_reaction client ~message_id ~emoji_name =
6047 let params = [
6148 ("emoji_name", emoji_name);
6262- ("reaction_type", "unicode_emoji");
6349 ] in
6450 match Client.request client ~method_:`DELETE
6565- ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
6666- ~params () with
6767- | Ok _ -> Ok ()
5151+ ~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions") ~params () with
5252+ | Ok _json -> Ok ()
6853 | Error err -> Error err
69547070-let upload_file client ~filename =
7171- (* Read file contents *)
7272- let ic = open_in_bin filename in
7373- let len = in_channel_length ic in
7474- let content = really_input_string ic len in
7575- close_in ic;
7676-7777- (* Extract just the filename from the path *)
7878- let basename = Filename.basename filename in
7979-8080- (* Create multipart form data boundary *)
8181- let boundary = "----OCamlZulipBoundary" ^ string_of_float (Unix.gettimeofday ()) in
8282-8383- (* Build multipart body *)
8484- let body = Buffer.create (len + 1024) in
8585- Buffer.add_string body ("--" ^ boundary ^ "\r\n");
8686- Buffer.add_string body ("Content-Disposition: form-data; name=\"file\"; filename=\"" ^ basename ^ "\"\r\n");
8787- Buffer.add_string body "Content-Type: application/octet-stream\r\n";
8888- Buffer.add_string body "\r\n";
8989- Buffer.add_string body content;
9090- Buffer.add_string body ("\r\n--" ^ boundary ^ "--\r\n");
9191-9292- let body_str = Buffer.contents body in
9393- let content_type = "multipart/form-data; boundary=" ^ boundary in
9494-9595- match Client.request client ~method_:`POST ~path:"/api/v1/user_uploads"
9696- ~body:body_str ~content_type () with
9797- | Ok json ->
9898- (* Parse response to extract URI *)
9999- (match json with
100100- | `O fields ->
101101- (match Jsonu.get_string fields "uri" with
102102- | Ok uri -> Ok uri
103103- | Error e -> Error e)
104104- | _ -> Error (Zulip_types.create_error ~code:(Zulip_types.Other "upload_error") ~msg:"Failed to parse upload response" ()))
105105- | Error err -> Error err5555+let upload_file _client ~filename:_ =
5656+ (* TODO: Implement file upload using multipart/form-data *)
5757+ Error (Zulip_types.create_error ~code:(Other "not_implemented")
5858+ ~msg:"File upload not yet implemented" ())
+25-18
stack/zulip/lib/zulip/lib/user.ml
···1515let is_admin t = t.is_admin
1616let is_bot t = t.is_bot
17171818-let to_json t =
1919- `O [
2020- ("email", `String t.email);
2121- ("full_name", `String t.full_name);
2222- ("is_active", `Bool t.is_active);
2323- ("is_admin", `Bool t.is_admin);
2424- ("is_bot", `Bool t.is_bot);
2525- ]
1818+let pp fmt t = Format.fprintf fmt "User{email=%s, full_name=%s}" t.email t.full_name
1919+2020+(* Jsont codec for user *)
2121+let jsont =
2222+ let kind = "User" in
2323+ let doc = "A Zulip user" in
2424+ let make email full_name is_active is_admin is_bot =
2525+ { email; full_name; is_active; is_admin; is_bot }
2626+ in
2727+ Jsont.Object.map ~kind ~doc make
2828+ |> Jsont.Object.mem "email" Jsont.string ~enc:email
2929+ |> Jsont.Object.mem "full_name" Jsont.string ~enc:full_name
3030+ |> Jsont.Object.mem "is_active" Jsont.bool ~enc:is_active
3131+ |> Jsont.Object.mem "is_admin" Jsont.bool ~enc:is_admin
3232+ |> Jsont.Object.mem "is_bot" Jsont.bool ~enc:is_bot
3333+ |> Jsont.Object.finish
26343535+(* Decode and encode functions using Encode module *)
2736let of_json json =
2828- Jsonu.with_object "user" (fun fields ->
2929- match Jsonu.get_string fields "email", Jsonu.get_string fields "full_name" with
3030- | Ok email, Ok full_name ->
3131- let is_active = Jsonu.get_bool_default fields "is_active" true in
3232- let is_admin = Jsonu.get_bool_default fields "is_admin" false in
3333- let is_bot = Jsonu.get_bool_default fields "is_bot" false in
3434- Ok { email; full_name; is_active; is_admin; is_bot }
3535- | Error e, _ | _, Error e -> Error e
3636- ) json
3737+ match Encode.from_json jsont json with
3838+ | Ok v -> Ok v
3939+ | Error msg -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg ())
4040+4141+let to_json_string t =
4242+ Encode.to_json_string jsont t
37433838-let pp fmt t = Format.fprintf fmt "User{email=%s, full_name=%s}" t.email t.full_name4444+let to_form_urlencoded t =
4545+ Encode.to_form_urlencoded jsont t
+18-7
stack/zulip/lib/zulip/lib/user.mli
···11type t
2233-val create :
44- email:string ->
55- full_name:string ->
66- ?is_active:bool ->
77- ?is_admin:bool ->
88- ?is_bot:bool ->
33+val create :
44+ email:string ->
55+ full_name:string ->
66+ ?is_active:bool ->
77+ ?is_admin:bool ->
88+ ?is_bot:bool ->
99 unit -> t
10101111val email : t -> string
···1313val is_active : t -> bool
1414val is_admin : t -> bool
1515val is_bot : t -> bool
1616-val to_json : t -> Zulip_types.json
1616+1717+(** Jsont codec for the user type *)
1818+val jsont : t Jsont.t
1919+2020+(** Decode from Jsont.json *)
1721val of_json : Zulip_types.json -> (t, Zulip_types.zerror) result
2222+2323+(** Encode to JSON string *)
2424+val to_json_string : t -> string
2525+2626+(** Encode to form-urlencoded string *)
2727+val to_form_urlencoded : t -> string
2828+1829val pp : Format.formatter -> t -> unit
+32-28
stack/zulip/lib/zulip/lib/users.ml
···11-let list client =
11+let list client =
22+ (* Define response codec *)
33+ let response_codec =
44+ Jsont.Object.(
55+ map ~kind:"UsersResponse" (fun members -> members)
66+ |> mem "members" (Jsont.list User.jsont) ~enc:(fun x -> x)
77+ |> finish
88+ )
99+ in
1010+211 match Client.request client ~method_:`GET ~path:"/api/v1/users" () with
33- | Ok json ->
44- (match json with
55- | `O fields ->
66- (match List.assoc_opt "members" fields with
77- | Some (`A user_list) ->
88- let users = List.fold_left (fun acc user_json ->
99- match User.of_json user_json with
1010- | Ok user -> user :: acc
1111- | Error _ -> acc
1212- ) [] user_list in
1313- Ok (List.rev users)
1414- | _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Invalid users response format" ()))
1515- | _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Users response must be an object" ()))
1212+ | Ok json ->
1313+ (match Encode.from_json response_codec json with
1414+ | Ok users -> Ok users
1515+ | Error msg -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg ()))
1616 | Error err -> Error err
17171818let get client ~email =
···2323 | Error err -> Error err)
2424 | Error err -> Error err
25252626-let create_user client ~email ~full_name =
2727- let body_json = `O [
2828- ("email", `String email);
2929- ("full_name", `String full_name);
3030- ] in
3131- let body = match body_json with
3232- | `O fields ->
3333- String.concat "&" (List.map (fun (k, v) ->
3434- match v with
3535- | `String s -> k ^ "=" ^ s
3636- | _ -> ""
3737- ) fields)
3838- | _ -> "" in
3939- match Client.request client ~method_:`POST ~path:"/api/v1/users" ~body () with
2626+(* Request type for create_user *)
2727+module Create_user_request = struct
2828+ type t = { email : string; full_name : string }
2929+3030+ let codec =
3131+ Jsont.Object.(
3232+ map ~kind:"CreateUserRequest" (fun email full_name -> { email; full_name })
3333+ |> mem "email" Jsont.string ~enc:(fun r -> r.email)
3434+ |> mem "full_name" Jsont.string ~enc:(fun r -> r.full_name)
3535+ |> finish
3636+ )
3737+end
3838+3939+let create_user client ~email ~full_name =
4040+ let req = Create_user_request.{ email; full_name } in
4141+ let body = Encode.to_form_urlencoded Create_user_request.codec req in
4242+ let content_type = "application/x-www-form-urlencoded" in
4343+ match Client.request client ~method_:`POST ~path:"/api/v1/users" ~body ~content_type () with
4044 | Ok _json -> Ok ()
4145 | Error err -> Error err
4246
···11(** Core types for Zulip API *)
2233(** JSON type used throughout the API *)
44-type json = [`Null | `Bool of bool | `Float of float | `String of string | `A of json list | `O of (string * json) list]
44+type json = Jsont.json
5566(** Error codes returned by Zulip API *)
77type error_code =
···4545let pp_error fmt t = Format.fprintf fmt "Error(%s): %s"
4646 (error_code_to_string t.code) t.message
47474848+(* Jsont codec for error_code *)
4949+let error_code_jsont =
5050+ let of_string s = Ok (error_code_of_string s) in
5151+ Jsont.of_of_string ~kind:"ErrorCode" of_string ~enc:error_code_to_string
5252+5353+(* Jsont codec for zerror *)
5454+let zerror_jsont =
5555+ let kind = "ZulipError" in
5656+ let make code msg =
5757+ (* Extra fields handled by keep_unknown - we'll extract them separately *)
5858+ { code = error_code_of_string code; message = msg; extra = [] }
5959+ in
6060+ let code t = error_code_to_string t.code in
6161+ let msg t = t.message in
6262+ Jsont.Object.(
6363+ map ~kind make
6464+ |> mem "code" Jsont.string ~enc:code
6565+ |> mem "msg" Jsont.string ~enc:msg
6666+ |> finish
6767+ )
6868+4869let error_of_json json =
4949- match json with
5050- | `O fields ->
5151- (try
5252- let code_str = match List.assoc "code" fields with
5353- | `String s -> s
5454- | _ -> "OTHER" in
5555- let msg = match List.assoc "msg" fields with
5656- | `String s -> s
5757- | _ -> "Unknown error" in
5858- let code = error_code_of_string code_str in
5959- let extra = List.filter (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result") fields in
6060- Some (create_error ~code ~msg ~extra ())
6161- with Not_found -> None)
6262- | _ -> None7070+ match Encode.from_json zerror_jsont json with
7171+ | Ok err ->
7272+ (* Extract extra fields by getting all fields except code, msg, result *)
7373+ (match json with
7474+ | Jsont.Object (fields, _) ->
7575+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
7676+ let extra = List.filter (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result") assoc in
7777+ Some { err with extra }
7878+ | _ -> Some err)
7979+ | Error _ -> None
+6-1
stack/zulip/lib/zulip/lib/zulip_types.mli
···11(** Core types for Zulip API *)
2233(** JSON type used throughout the API *)
44-type json = [`Null | `Bool of bool | `Float of float | `String of string | `A of json list | `O of (string * json) list]
44+type json = Jsont.json
5566(** Error codes returned by Zulip API *)
77type error_code =
···2727val error_message : zerror -> string
2828val error_extra : zerror -> (string * json) list
2929val pp_error : Format.formatter -> zerror -> unit
3030+3131+(** Jsont codecs *)
3232+val error_code_jsont : error_code Jsont.t
3333+val zerror_jsont : zerror Jsont.t
3434+3035val error_of_json : json -> zerror option
+6-5
stack/zulip/lib/zulip_bot/lib/bot_runner.ml
···4545 (* Extract the actual message from the event *)
4646 let message_json, flags =
4747 match event_data with
4848- | `O fields ->
4949- let msg = match List.assoc_opt "message" fields with
4848+ | Jsont.Object (fields, _) ->
4949+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
5050+ let msg = match List.assoc_opt "message" assoc with
5051 | Some m -> m
5152 | None -> event_data (* Fallback if structure is different *)
5253 in
5353- let flgs = match List.assoc_opt "flags" fields with
5454- | Some (`A f) -> f
5454+ let flgs = match List.assoc_opt "flags" assoc with
5555+ | Some (Jsont.Array (f, _)) -> f
5556 | _ -> []
5657 in
5758 (msg, flgs)
···73747475 (* Check if mentioned *)
7576 let is_mentioned =
7676- List.exists (function `String "mentioned" -> true | _ -> false) flags ||
7777+ List.exists (function Jsont.String ("mentioned", _) -> true | _ -> false) flags ||
7778 Message.is_mentioned message ~user_email:bot_email in
78797980 (* Check if it's a private message *)
+81-29
stack/zulip/lib/zulip_bot/lib/bot_storage.ml
···99 mutable dirty_keys : string list;
1010}
11111212+(** {1 JSON Codecs for Bot Storage} *)
1313+1414+(* Storage response type - {"storage": {...}} *)
1515+type storage_response = {
1616+ storage : (string * string) list;
1717+ unknown : Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
1818+}
1919+2020+(* Custom codec for storage_response that handles the dictionary *)
2121+let storage_response_jsont : storage_response Jsont.t =
2222+ let of_string s =
2323+ match Jsont_bytesrw.decode_string' Jsont.json s with
2424+ | Error _ -> Error "Failed to decode JSON"
2525+ | Ok json ->
2626+ match json with
2727+ | Jsont.Object (fields, _) ->
2828+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
2929+ (match List.assoc_opt "storage" assoc with
3030+ | Some (Jsont.Object (storage_fields, _)) ->
3131+ let storage = List.filter_map (fun ((k, _), v) ->
3232+ match v with
3333+ | Jsont.String (s, _) -> Some (k, s)
3434+ | _ -> None
3535+ ) storage_fields in
3636+ (* Keep unknown fields *)
3737+ let unknown_fields = List.filter (fun (k, _) -> k <> "storage") assoc in
3838+ let unknown_mems = List.map (fun (k, v) -> ((k, Jsont.Meta.none), v)) unknown_fields in
3939+ let unknown = Jsont.Object (unknown_mems, Jsont.Meta.none) in
4040+ Ok { storage; unknown }
4141+ | Some _ -> Error "Expected 'storage' field to be an object"
4242+ | None -> Ok { storage = []; unknown = Jsont.Object ([], Jsont.Meta.none) })
4343+ | _ -> Error "Expected JSON object for storage response"
4444+ in
4545+ let to_string { storage; unknown } =
4646+ (* Create storage object *)
4747+ let storage_fields = List.map (fun (k, v) ->
4848+ ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))
4949+ ) storage in
5050+ let storage_obj = Jsont.Object (storage_fields, Jsont.Meta.none) in
5151+5252+ (* Merge with unknown fields *)
5353+ let storage_mem = (("storage", Jsont.Meta.none), storage_obj) in
5454+ let unknown_mems = match unknown with
5555+ | Jsont.Object (fields, _) -> fields
5656+ | _ -> []
5757+ in
5858+ let json = Jsont.Object (storage_mem :: unknown_mems, Jsont.Meta.none) in
5959+ match Jsont_bytesrw.encode_string' Jsont.json json with
6060+ | Ok s -> s
6161+ | Error e -> failwith ("Failed to encode storage response: " ^ Jsont.Error.to_string e)
6262+ in
6363+ Jsont.of_of_string ~kind:"StorageResponse" of_string ~enc:to_string
6464+1265let create client ~bot_email =
1366 Log.info (fun m -> m "Creating bot storage for %s" bot_email);
1467 let cache = Hashtbl.create 16 in
···1972 ~path:"/api/v1/bot_storage"
2073 () with
2174 | Ok json ->
2222- (match Zulip.Jsonu.get_object_opt (match json with `O f -> f | _ -> []) "storage" with
2323- | Some storage_fields ->
7575+ (match Zulip.Encode.from_json storage_response_jsont json with
7676+ | Ok response ->
2477 List.iter (fun (k, v) ->
2525- match Zulip.Jsonu.to_string_safe v with
2626- | Some value ->
2727- Log.debug (fun m -> m "Loaded key from server: %s" k);
2828- Hashtbl.add cache k value
2929- | None -> ()
3030- ) storage_fields
3131- | None -> ())
7878+ Log.debug (fun m -> m "Loaded key from server: %s" k);
7979+ Hashtbl.add cache k v
8080+ ) response.storage
8181+ | Error msg ->
8282+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg))
3283 | Error e ->
3384 Log.warn (fun m -> m "Failed to load existing storage: %s" (Zulip.error_message e)));
3485···4394let encode_storage_update keys_values =
4495 (* Build the storage object as JSON - the API expects storage={"key": "value"} *)
4596 let storage_obj =
4646- List.map (fun (k, v) -> (k, `String v)) keys_values
9797+ List.map (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))) keys_values
4798 in
4848- let json_obj = `O storage_obj in
9999+ let json_obj = Jsont.Object (storage_obj, Jsont.Meta.none) in
491005050- (* Convert to JSON string using Ezjsonm *)
5151- let json_str = Ezjsonm.to_string json_obj in
101101+ (* Convert to JSON string using Jsont_bytesrw *)
102102+ let json_str = Jsont_bytesrw.encode_string' Jsont.json json_obj |> Result.get_ok in
5210353104 (* Return as form-encoded body: storage=<url-encoded-json> *)
54105 "storage=" ^ Uri.pct_encode json_str
···68119 ~path:"/api/v1/bot_storage"
69120 ~params () with
70121 | Ok json ->
7171- (match Zulip.Jsonu.get_object_opt (match json with `O f -> f | _ -> []) "storage" with
7272- | Some storage_fields ->
7373- (match Zulip.Jsonu.get_string_opt storage_fields key with
122122+ (match Zulip.Encode.from_json storage_response_jsont json with
123123+ | Ok response ->
124124+ (match List.assoc_opt key response.storage with
74125 | Some value ->
75126 (* Cache the value *)
76127 Log.debug (fun m -> m "Retrieved key from API: %s" key);
···79130 | None ->
80131 Log.debug (fun m -> m "Key not found in API: %s" key);
81132 None)
8282- | None -> None)
133133+ | Error msg ->
134134+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
135135+ None)
83136 | Error e ->
84137 Log.warn (fun m -> m "Error fetching key %s: %s" key (Zulip.error_message e));
85138 None
···140193 ~path:"/api/v1/bot_storage"
141194 () with
142195 | Ok json ->
143143- (match json with
144144- | `O fields ->
145145- (match List.assoc_opt "storage" fields with
146146- | Some (`O storage_fields) ->
147147- let api_keys = List.map fst storage_fields in
148148- (* Merge with cache keys *)
149149- let cache_keys = Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] in
150150- let all_keys = List.sort_uniq String.compare (api_keys @ cache_keys) in
151151- Ok all_keys
152152- | _ -> Ok [])
153153- | _ -> Ok [])
196196+ (match Zulip.Encode.from_json storage_response_jsont json with
197197+ | Ok response ->
198198+ let api_keys = List.map fst response.storage in
199199+ (* Merge with cache keys *)
200200+ let cache_keys = Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] in
201201+ let all_keys = List.sort_uniq String.compare (api_keys @ cache_keys) in
202202+ Ok all_keys
203203+ | Error msg ->
204204+ Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
205205+ Ok [])
154206 | Error e -> Error e
155207156208(* Flush all dirty keys to API *)
···182234 | Error e ->
183235 Log.err (fun m -> m "Failed to flush storage: %s" (Zulip.error_message e));
184236 Error e
185185- end237237+ end
···11-(* Use Jsonm exclusively via Zulip.Jsonu utilities *)
11+(* Message parsing using Jsont codecs *)
2233let logs_src = Logs.Src.create "zulip_bot.message"
44module Log = (val Logs.src_log logs_src : Logs.LOG)
···1010 email: string;
1111 full_name: string;
1212 short_name: string option;
1313+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
1314 }
14151516 let user_id t = t.user_id
···1718 let full_name t = t.full_name
1819 let short_name t = t.short_name
19202121+ (* Jsont codec for User - handles both user_id and id fields *)
2222+ let jsont : t Jsont.t =
2323+ let of_string s =
2424+ match Jsont_bytesrw.decode_string' Jsont.json s with
2525+ | Error _ -> Error "Failed to decode JSON"
2626+ | Ok json ->
2727+ match json with
2828+ | Jsont.Object (fields, _) ->
2929+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
3030+ let user_id =
3131+ match List.assoc_opt "user_id" assoc with
3232+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
3333+ | _ ->
3434+ match List.assoc_opt "id" assoc with
3535+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
3636+ | _ -> None
3737+ in
3838+ let email =
3939+ match List.assoc_opt "email" assoc with
4040+ | Some (Jsont.String (s, _)) -> Some s
4141+ | _ -> None
4242+ in
4343+ let full_name =
4444+ match List.assoc_opt "full_name" assoc with
4545+ | Some (Jsont.String (s, _)) -> Some s
4646+ | _ -> None
4747+ in
4848+ let short_name =
4949+ match List.assoc_opt "short_name" assoc with
5050+ | Some (Jsont.String (s, _)) -> Some s
5151+ | _ -> None
5252+ in
5353+ (match (user_id, email, full_name) with
5454+ | (Some user_id, Some email, Some full_name) ->
5555+ (* Keep unknown fields *)
5656+ let unknown_fields = List.filter (fun (k, _) ->
5757+ k <> "user_id" && k <> "id" && k <> "email" && k <> "full_name" && k <> "short_name"
5858+ ) assoc in
5959+ let unknown_mems = List.map (fun (k, v) -> ((k, Jsont.Meta.none), v)) unknown_fields in
6060+ let unknown = Jsont.Object (unknown_mems, Jsont.Meta.none) in
6161+ Ok { user_id; email; full_name; short_name; unknown }
6262+ | _ -> Error "Missing required user fields")
6363+ | _ -> Error "Expected JSON object for user"
6464+ in
6565+ let to_string { user_id; email; full_name; short_name; unknown } =
6666+ let fields = [
6767+ (("user_id", Jsont.Meta.none), Jsont.Number (float_of_int user_id, Jsont.Meta.none));
6868+ (("email", Jsont.Meta.none), Jsont.String (email, Jsont.Meta.none));
6969+ (("full_name", Jsont.Meta.none), Jsont.String (full_name, Jsont.Meta.none));
7070+ ] in
7171+ let fields = match short_name with
7272+ | Some sn -> (("short_name", Jsont.Meta.none), Jsont.String (sn, Jsont.Meta.none)) :: fields
7373+ | None -> fields
7474+ in
7575+ let unknown_mems = match unknown with
7676+ | Jsont.Object (mems, _) -> mems
7777+ | _ -> []
7878+ in
7979+ let json = Jsont.Object (fields @ unknown_mems, Jsont.Meta.none) in
8080+ match Jsont_bytesrw.encode_string' Jsont.json json with
8181+ | Ok s -> s
8282+ | Error e -> failwith ("Failed to encode user: " ^ Jsont.Error.to_string e)
8383+ in
8484+ Jsont.of_of_string ~kind:"User" of_string ~enc:to_string
8585+2086 let of_json (json : Zulip.json) : (t, Zulip.zerror) result =
2121- let open Zulip.Jsonu_syntax in
2222- (Zulip.Jsonu.with_object "user" @@ fun fields ->
2323- let* user_id = (field fields "user_id" int) <|> (field fields "id" int) in
2424- let* email = field fields "email" string in
2525- let* full_name = field fields "full_name" string in
2626- let* short_name = field_opt fields "short_name" string in
2727- Ok { user_id; email; full_name; short_name }) json
8787+ match Zulip.Encode.from_json jsont json with
8888+ | Ok v -> Ok v
8989+ | Error msg -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg ())
2890end
29913092(** Reaction representation *)
···3496 emoji_code: string;
3597 reaction_type: string;
3698 user_id: int;
9999+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
37100 }
3810139102 let emoji_name t = t.emoji_name
···41104 let reaction_type t = t.reaction_type
42105 let user_id t = t.user_id
43106107107+ (* Jsont codec for Reaction - handles user_id in different locations *)
108108+ let jsont : t Jsont.t =
109109+ let of_string s =
110110+ match Jsont_bytesrw.decode_string' Jsont.json s with
111111+ | Error _ -> Error "Failed to decode JSON"
112112+ | Ok json ->
113113+ match json with
114114+ | Jsont.Object (fields, _) ->
115115+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
116116+ let emoji_name =
117117+ match List.assoc_opt "emoji_name" assoc with
118118+ | Some (Jsont.String (s, _)) -> Some s
119119+ | _ -> None
120120+ in
121121+ let emoji_code =
122122+ match List.assoc_opt "emoji_code" assoc with
123123+ | Some (Jsont.String (s, _)) -> Some s
124124+ | _ -> None
125125+ in
126126+ let reaction_type =
127127+ match List.assoc_opt "reaction_type" assoc with
128128+ | Some (Jsont.String (s, _)) -> Some s
129129+ | _ -> None
130130+ in
131131+ (* user_id can be either directly in the object or nested in a "user" field *)
132132+ let user_id =
133133+ match List.assoc_opt "user_id" assoc with
134134+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
135135+ | _ ->
136136+ match List.assoc_opt "user" assoc with
137137+ | Some (Jsont.Object (user_fields, _)) ->
138138+ let user_assoc = List.map (fun ((k, _), v) -> (k, v)) user_fields in
139139+ (match List.assoc_opt "user_id" user_assoc with
140140+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
141141+ | _ -> None)
142142+ | _ -> None
143143+ in
144144+ (match (emoji_name, emoji_code, reaction_type, user_id) with
145145+ | (Some emoji_name, Some emoji_code, Some reaction_type, Some user_id) ->
146146+ (* Keep unknown fields *)
147147+ let unknown_fields = List.filter (fun (k, _) ->
148148+ k <> "emoji_name" && k <> "emoji_code" && k <> "reaction_type" && k <> "user_id" && k <> "user"
149149+ ) assoc in
150150+ let unknown_mems = List.map (fun (k, v) -> ((k, Jsont.Meta.none), v)) unknown_fields in
151151+ let unknown = Jsont.Object (unknown_mems, Jsont.Meta.none) in
152152+ Ok { emoji_name; emoji_code; reaction_type; user_id; unknown }
153153+ | _ -> Error "Missing required reaction fields")
154154+ | _ -> Error "Expected JSON object for reaction"
155155+ in
156156+ let to_string { emoji_name; emoji_code; reaction_type; user_id; unknown } =
157157+ let fields = [
158158+ (("emoji_name", Jsont.Meta.none), Jsont.String (emoji_name, Jsont.Meta.none));
159159+ (("emoji_code", Jsont.Meta.none), Jsont.String (emoji_code, Jsont.Meta.none));
160160+ (("reaction_type", Jsont.Meta.none), Jsont.String (reaction_type, Jsont.Meta.none));
161161+ (("user_id", Jsont.Meta.none), Jsont.Number (float_of_int user_id, Jsont.Meta.none));
162162+ ] in
163163+ let unknown_mems = match unknown with
164164+ | Jsont.Object (mems, _) -> mems
165165+ | _ -> []
166166+ in
167167+ let json = Jsont.Object (fields @ unknown_mems, Jsont.Meta.none) in
168168+ match Jsont_bytesrw.encode_string' Jsont.json json with
169169+ | Ok s -> s
170170+ | Error e -> failwith ("Failed to encode reaction: " ^ Jsont.Error.to_string e)
171171+ in
172172+ Jsont.of_of_string ~kind:"Reaction" of_string ~enc:to_string
173173+44174 let of_json (json : Zulip.json) : (t, Zulip.zerror) result =
4545- let open Zulip.Jsonu_syntax in
4646- (Zulip.Jsonu.with_object "reaction" @@ fun fields ->
4747- let* emoji_name = field fields "emoji_name" string in
4848- let* emoji_code = field fields "emoji_code" string in
4949- let* reaction_type = field fields "reaction_type" string in
5050- let* user_id =
5151- (field fields "user_id" int) <|>
5252- (match field fields "user" object_ with
5353- | Ok user_obj -> field user_obj "user_id" int
5454- | Error _ -> fail "user_id not found") in
5555- Ok { emoji_name; emoji_code; reaction_type; user_id }) json
175175+ match Zulip.Encode.from_json jsont json with
176176+ | Ok v -> Ok v
177177+ | Error msg -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg ())
56178end
5717958180let parse_reaction_json json = Reaction.of_json json
···9621897219(** Helper function to parse common fields *)
98220let parse_common json =
9999- Zulip.Jsonu.parse_with_error "common fields" @@ fun () ->
100100- (Zulip.Jsonu.with_object "message" @@ fun fields ->
101101- let open Zulip.Jsonu_syntax in
102102- let* id = field fields "id" int in
103103- let* sender_id = field fields "sender_id" int in
104104- let* sender_email = field fields "sender_email" string in
105105- let* sender_full_name = field fields "sender_full_name" string in
106106- let sender_short_name = field_opt fields "sender_short_name" string |? None in
107107- let timestamp = field_or fields "timestamp" float 0.0 |? 0.0 in
108108- let content = field_or fields "content" string "" |? "" in
109109- let content_type = field_or fields "content_type" string "text/html" |? "text/html" in
221221+ match json with
222222+ | Jsont.Object (fields, _) ->
223223+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
224224+ let get_int key =
225225+ match List.assoc_opt key assoc with
226226+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
227227+ | _ -> None
228228+ in
229229+ let get_string key =
230230+ match List.assoc_opt key assoc with
231231+ | Some (Jsont.String (s, _)) -> Some s
232232+ | _ -> None
233233+ in
234234+ let get_float key default =
235235+ match List.assoc_opt key assoc with
236236+ | Some (Jsont.Number (f, _)) -> f
237237+ | _ -> default
238238+ in
239239+ let get_bool key default =
240240+ match List.assoc_opt key assoc with
241241+ | Some (Jsont.Bool (b, _)) -> b
242242+ | _ -> default
243243+ in
244244+ let get_array key =
245245+ match List.assoc_opt key assoc with
246246+ | Some (Jsont.Array (arr, _)) -> Some arr
247247+ | _ -> None
248248+ in
110249111111- let reactions =
112112- match Zulip.Jsonu.get_array_opt fields "reactions" with
113113- | Some reactions_json ->
114114- List.filter_map (fun r ->
115115- match parse_reaction_json r with
116116- | Ok reaction -> Some reaction
117117- | Error err ->
118118- Log.warn (fun m -> m "Failed to parse reaction: %s" (Zulip.error_message err));
119119- None
120120- ) reactions_json
121121- | None -> []
122122- in
250250+ (match (get_int "id", get_int "sender_id", get_string "sender_email", get_string "sender_full_name") with
251251+ | (Some id, Some sender_id, Some sender_email, Some sender_full_name) ->
252252+ let sender_short_name = get_string "sender_short_name" in
253253+ let timestamp = get_float "timestamp" 0.0 in
254254+ let content = get_string "content" |> Option.value ~default:"" in
255255+ let content_type = get_string "content_type" |> Option.value ~default:"text/html" in
123256124124- let submessages = Zulip.Jsonu.get_array_opt fields "submessages" |> Option.value ~default:[] in
257257+ let reactions =
258258+ match get_array "reactions" with
259259+ | Some reactions_json ->
260260+ List.filter_map (fun r ->
261261+ match parse_reaction_json r with
262262+ | Ok reaction -> Some reaction
263263+ | Error err ->
264264+ Log.warn (fun m -> m "Failed to parse reaction: %s" (Zulip.error_message err));
265265+ None
266266+ ) reactions_json
267267+ | None -> []
268268+ in
125269126126- let flags =
127127- match Zulip.Jsonu.get_array_opt fields "flags" with
128128- | Some flags_json -> List.filter_map Zulip.Jsonu.to_string_safe flags_json
129129- | None -> []
130130- in
270270+ let submessages = get_array "submessages" |> Option.value ~default:[] in
131271132132- let is_me_message = field_or fields "is_me_message" bool false |? false in
133133- let client = field_or fields "client" string "" |? "" in
134134- let gravatar_hash = field_or fields "gravatar_hash" string "" |? "" in
135135- let avatar_url = field_opt fields "avatar_url" string |? None in
272272+ let flags =
273273+ match get_array "flags" with
274274+ | Some flags_json ->
275275+ List.filter_map (fun f ->
276276+ match f with
277277+ | Jsont.String (s, _) -> Some s
278278+ | _ -> None
279279+ ) flags_json
280280+ | None -> []
281281+ in
136282137137- Ok {
138138- id; sender_id; sender_email; sender_full_name; sender_short_name;
139139- timestamp; content; content_type; reactions; submessages;
140140- flags; is_me_message; client; gravatar_hash; avatar_url
141141- }) json
283283+ let is_me_message = get_bool "is_me_message" false in
284284+ let client = get_string "client" |> Option.value ~default:"" in
285285+ let gravatar_hash = get_string "gravatar_hash" |> Option.value ~default:"" in
286286+ let avatar_url = get_string "avatar_url" in
287287+288288+ Ok {
289289+ id; sender_id; sender_email; sender_full_name; sender_short_name;
290290+ timestamp; content; content_type; reactions; submessages;
291291+ flags; is_me_message; client; gravatar_hash; avatar_url
292292+ }
293293+ | _ -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg:"Missing required message fields" ()))
294294+ | _ -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg:"Expected JSON object for message" ())
142295143296(** JSON parsing *)
144297let of_json json =
145145- Log.debug (fun m -> m "Parsing message JSON: %s" (Zulip.Jsonu.to_string_pretty json));
298298+ (* Helper to pretty print JSON without using jsonu *)
299299+ let json_str =
300300+ match Jsont_bytesrw.encode_string' Jsont.json json with
301301+ | Ok s -> s
302302+ | Error _ -> "<error encoding json>"
303303+ in
304304+ Log.debug (fun m -> m "Parsing message JSON: %s" json_str);
146305147147- let open Zulip.Jsonu_syntax in
148306 match parse_common json with
149307 | Error err -> Error (Zulip.error_message err)
150308 | Ok common ->
151151- (Zulip.Jsonu.parse_with_error "message type" @@ fun () ->
152152- (Zulip.Jsonu.with_object "message" @@ fun fields ->
153153- match Zulip.Jsonu.get_string fields "type" with
154154- | Ok "private" ->
155155- let* recipient_json = field fields "display_recipient" (array (fun x -> Ok x)) in
156156- let users = List.filter_map (fun u ->
157157- match parse_user_json u with
158158- | Ok user -> Some user
159159- | Error err ->
160160- Log.warn (fun m -> m "Failed to parse user in display_recipient: %s" (Zulip.error_message err));
161161- None
162162- ) recipient_json in
309309+ match json with
310310+ | Jsont.Object (fields, _) ->
311311+ let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
312312+ let msg_type =
313313+ match List.assoc_opt "type" assoc with
314314+ | Some (Jsont.String (s, _)) -> Some s
315315+ | _ -> None
316316+ in
317317+ (match msg_type with
318318+ | Some "private" ->
319319+ (match List.assoc_opt "display_recipient" assoc with
320320+ | Some (Jsont.Array (recipient_json, _)) ->
321321+ let users = List.filter_map (fun u ->
322322+ match parse_user_json u with
323323+ | Ok user -> Some user
324324+ | Error err ->
325325+ Log.warn (fun m -> m "Failed to parse user in display_recipient: %s" (Zulip.error_message err));
326326+ None
327327+ ) recipient_json in
163328164164- if List.length users = 0 && List.length recipient_json > 0 then
165165- fail "Failed to parse any users in display_recipient"
166166- else
167167- Ok (Private { common; display_recipient = users })
329329+ if List.length users = 0 && List.length recipient_json > 0 then
330330+ Error "Failed to parse any users in display_recipient"
331331+ else
332332+ Ok (Private { common; display_recipient = users })
333333+ | _ ->
334334+ Log.warn (fun m -> m "display_recipient is not an array for private message");
335335+ Ok (Unknown { common; raw_json = json }))
168336169169- | Ok "stream" ->
170170- let* display_recipient = field fields "display_recipient" string in
171171- let* stream_id = field fields "stream_id" int in
172172- let* subject = field fields "subject" string in
173173- Ok (Stream { common; display_recipient; stream_id; subject })
337337+ | Some "stream" ->
338338+ let display_recipient =
339339+ match List.assoc_opt "display_recipient" assoc with
340340+ | Some (Jsont.String (s, _)) -> Some s
341341+ | _ -> None
342342+ in
343343+ let stream_id =
344344+ match List.assoc_opt "stream_id" assoc with
345345+ | Some (Jsont.Number (f, _)) -> Some (int_of_float f)
346346+ | _ -> None
347347+ in
348348+ let subject =
349349+ match List.assoc_opt "subject" assoc with
350350+ | Some (Jsont.String (s, _)) -> Some s
351351+ | _ -> None
352352+ in
353353+ (match (display_recipient, stream_id, subject) with
354354+ | (Some display_recipient, Some stream_id, Some subject) ->
355355+ Ok (Stream { common; display_recipient; stream_id; subject })
356356+ | _ ->
357357+ Log.warn (fun m -> m "Missing required fields for stream message");
358358+ Ok (Unknown { common; raw_json = json }))
174359175175- | Ok unknown_type ->
176176- Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
177177- Ok (Unknown { common; raw_json = json })
360360+ | Some unknown_type ->
361361+ Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
362362+ Ok (Unknown { common; raw_json = json })
178363179179- | Error _ ->
180180- Log.warn (fun m -> m "No message type field found");
181181- Ok (Unknown { common; raw_json = json })
182182- ) json) |> Result.map_error Zulip.error_message
364364+ | None ->
365365+ Log.warn (fun m -> m "No message type field found");
366366+ Ok (Unknown { common; raw_json = json }))
367367+ | _ -> Error "Expected JSON object for message"
183368184369(** Accessor functions *)
185370let get_common = function
···363548(** Pretty print JSON for debugging *)
364549let pp_json_debug ppf json =
365550 let open Fmt in
366366- let json_str = Zulip.Jsonu.to_string_pretty json in
551551+ let json_str =
552552+ match Jsont_bytesrw.encode_string' Jsont.json json with
553553+ | Ok s -> s
554554+ | Error _ -> "<error encoding json>"
555555+ in
367556 pf ppf "@[<v>%a@.%a@]"
368557 (styled `Bold (styled (`Fg `Blue) string)) "Raw JSON:"
369558 (styled (`Fg `Black) string) json_str
+10
stack/zulip/lib/zulip_bot/lib/message.mli
···77 email: string;
88 full_name: string;
99 short_name: string option;
1010+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
1011 }
11121213 val user_id : t -> int
1314 val email : t -> string
1415 val full_name : t -> string
1516 val short_name : t -> string option
1717+1818+ (** Jsont codec for User *)
1919+ val jsont : t Jsont.t
2020+1621 val of_json : Zulip.json -> (t, Zulip.zerror) result
1722end
1823···2328 emoji_code: string;
2429 reaction_type: string;
2530 user_id: int;
3131+ unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
2632 }
27332834 val emoji_name : t -> string
2935 val emoji_code : t -> string
3036 val reaction_type : t -> string
3137 val user_id : t -> int
3838+3939+ (** Jsont codec for Reaction *)
4040+ val jsont : t Jsont.t
4141+3242 val of_json : Zulip.json -> (t, Zulip.zerror) result
3343end
3444
+30
stack/zulip/zulip.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "OCaml bindings for the Zulip REST API"
44+description:
55+ "High-quality OCaml bindings to the Zulip REST API using EIO for async operations"
66+depends: [
77+ "ocaml"
88+ "dune" {>= "3.0"}
99+ "eio"
1010+ "requests"
1111+ "uri"
1212+ "base64"
1313+ "alcotest" {with-test}
1414+ "eio_main" {with-test}
1515+ "odoc" {with-doc}
1616+]
1717+build: [
1818+ ["dune" "subst"] {dev}
1919+ [
2020+ "dune"
2121+ "build"
2222+ "-p"
2323+ name
2424+ "-j"
2525+ jobs
2626+ "@install"
2727+ "@runtest" {with-test}
2828+ "@doc" {with-doc}
2929+ ]
3030+]
+26
stack/zulip/zulip_bot.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "OCaml bot framework for Zulip"
44+description: "Interactive bot framework built on the OCaml Zulip library"
55+depends: [
66+ "ocaml"
77+ "dune" {>= "3.0"}
88+ "zulip"
99+ "eio"
1010+ "alcotest" {with-test}
1111+ "odoc" {with-doc}
1212+]
1313+build: [
1414+ ["dune" "subst"] {dev}
1515+ [
1616+ "dune"
1717+ "build"
1818+ "-p"
1919+ name
2020+ "-j"
2121+ jobs
2222+ "@install"
2323+ "@runtest" {with-test}
2424+ "@doc" {with-doc}
2525+ ]
2626+]
+29
stack/zulip/zulip_botserver.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "OCaml bot server for running multiple Zulip bots"
44+description:
55+ "HTTP server for running multiple Zulip bots with webhook support"
66+depends: [
77+ "ocaml"
88+ "dune" {>= "3.0"}
99+ "zulip"
1010+ "zulip_bot"
1111+ "eio"
1212+ "requests"
1313+ "alcotest" {with-test}
1414+ "odoc" {with-doc}
1515+]
1616+build: [
1717+ ["dune" "subst"] {dev}
1818+ [
1919+ "dune"
2020+ "build"
2121+ "-p"
2222+ name
2323+ "-j"
2424+ jobs
2525+ "@install"
2626+ "@runtest" {with-test}
2727+ "@doc" {with-doc}
2828+ ]
2929+]