My agentic slop goes here. Not intended for anyone else!

jsont

+1
stack/.gitignore
···
+
_build
+2 -1
stack/cacheio/cacheio.opam
···
"dune" {>= "3.16"}
"eio"
"cmdliner" {>= "2.0.0"}
-
"yojson"
+
"jsont"
+
"bytesrw"
"ptime"
"logs"
"fmt"
+2 -1
stack/cacheio/dune-project
···
dune
eio
(cmdliner (>= 2.0.0))
-
yojson
+
jsont
+
bytesrw
ptime
logs
fmt
+1 -1
stack/cacheio/lib/dune
···
(public_name cacheio)
(name cacheio)
(modules cacheio flags entry stats range chunk)
-
(libraries eio eio_main digestif yojson ptime ptime.clock.os logs fmt xdge cstruct))
+
(libraries eio eio_main digestif jsont jsont.bytesrw ptime ptime.clock.os logs fmt xdge cstruct))
(library
(public_name cacheio.cmd)
+23 -1
stack/cacheio/lib/entry.ml
···
(match t.ttl with
| None -> "never"
| Some exp -> Printf.sprintf "%.1f" exp)
-
Flags.pp t.flags
+
Flags.pp t.flags
+
+
(* Jsont support *)
+
+
(* Helper codec for int64 *)
+
let int64_jsont =
+
let kind = "Int64" in
+
let doc = "64-bit integer as number" in
+
let dec n = Int64.of_float n in
+
let enc i = Int64.to_float i in
+
Jsont.map ~kind ~doc ~dec ~enc Jsont.number
+
+
let jsont =
+
let kind = "Entry" in
+
let doc = "A cache entry" in
+
let make key size mtime ttl flags = { key; size; mtime; ttl; flags } in
+
Jsont.Object.map ~kind ~doc make
+
|> Jsont.Object.mem "key" Jsont.string ~enc:key
+
|> Jsont.Object.mem "size" int64_jsont ~enc:size
+
|> Jsont.Object.mem "mtime" Jsont.number ~enc:mtime
+
|> Jsont.Object.opt_mem "ttl" Jsont.number ~enc:ttl
+
|> Jsont.Object.mem "flags" Flags.jsont ~enc:flags
+
|> Jsont.Object.finish
+6 -1
stack/cacheio/lib/entry.mli
···
(** {1 Pretty Printing} *)
(** Pretty printer for entries *)
-
val pp : Format.formatter -> t -> unit
+
val pp : Format.formatter -> t -> unit
+
+
(** {1 JSON Support} *)
+
+
(** Jsont codec for cache entries *)
+
val jsont : t Jsont.t
+31 -1
stack/cacheio/lib/flags.ml
···
| `Pinned -> "P"
| `Stale -> "S"
| `Temporary -> "T"
-
| `Chunk -> "C") flags))
+
| `Chunk -> "C") flags))
+
+
(* Jsont support *)
+
+
(* JSON codec for individual flags - using string representation *)
+
let flag_jsont =
+
let kind = "Flag" in
+
let doc = "A cache entry flag" in
+
let dec s =
+
match s with
+
| "pinned" -> `Pinned
+
| "stale" -> `Stale
+
| "temporary" -> `Temporary
+
| "chunk" -> `Chunk
+
| _ -> Jsont.Error.msg Jsont.Meta.none "Invalid flag value"
+
in
+
let enc = function
+
| `Pinned -> "pinned"
+
| `Stale -> "stale"
+
| `Temporary -> "temporary"
+
| `Chunk -> "chunk"
+
in
+
Jsont.map ~kind ~doc ~dec ~enc Jsont.string
+
+
(* JSON codec for flag set *)
+
let jsont =
+
let kind = "Flags" in
+
let doc = "A set of cache entry flags" in
+
let dec lst = of_list lst in
+
let enc t = to_list t in
+
Jsont.map ~kind ~doc ~dec ~enc (Jsont.list flag_jsont)
+6 -1
stack/cacheio/lib/flags.mli
···
val pp_flag : Format.formatter -> flag -> unit
(** Pretty printer for flag sets *)
-
val pp : Format.formatter -> t -> unit
+
val pp : Format.formatter -> t -> unit
+
+
(** {1 JSON Support} *)
+
+
(** Jsont codec for flags *)
+
val jsont : t Jsont.t
+26 -1
stack/cacheio/lib/stats.ml
···
t.expired_count
t.pinned_count
t.stale_count
-
t.temporary_count
+
t.temporary_count
+
+
(* Jsont support *)
+
+
(* Helper codec for int64 *)
+
let int64_jsont =
+
let kind = "Int64" in
+
let doc = "64-bit integer as number" in
+
let dec n = Int64.of_float n in
+
let enc i = Int64.to_float i in
+
Jsont.map ~kind ~doc ~dec ~enc Jsont.number
+
+
let jsont =
+
let kind = "Stats" in
+
let doc = "Cache statistics" in
+
let make total_size entry_count expired_count pinned_count stale_count temporary_count =
+
{ total_size; entry_count; expired_count; pinned_count; stale_count; temporary_count }
+
in
+
Jsont.Object.map ~kind ~doc make
+
|> Jsont.Object.mem "total_size" int64_jsont ~enc:total_size
+
|> Jsont.Object.mem "entry_count" Jsont.int ~enc:entry_count
+
|> Jsont.Object.mem "expired_count" Jsont.int ~enc:expired_count
+
|> Jsont.Object.mem "pinned_count" Jsont.int ~enc:pinned_count
+
|> Jsont.Object.mem "stale_count" Jsont.int ~enc:stale_count
+
|> Jsont.Object.mem "temporary_count" Jsont.int ~enc:temporary_count
+
|> Jsont.Object.finish
+6 -1
stack/cacheio/lib/stats.mli
···
(** {1 Pretty Printing} *)
(** Pretty printer for statistics *)
-
val pp : Format.formatter -> t -> unit
+
val pp : Format.formatter -> t -> unit
+
+
(** {1 JSON Support} *)
+
+
(** Jsont codec for cache statistics *)
+
val jsont : t Jsont.t
+1 -1
stack/immich/dune
···
(library
(name immich)
(public_name immich)
-
(libraries eio eio.core requests requests_json_api ezjsonm fmt ptime uri))
+
(libraries eio eio.core requests requests_json_api jsont jsont.bytesrw fmt ptime uri))
+2 -1
stack/immich/dune-project
···
eio
(eio_main (>= 1.0))
requests
-
ezjsonm
+
jsont
+
bytesrw
fmt
ptime
uri))
+44 -36
stack/immich/immich.ml
···
birth_date: string option;
thumbnail_path: string;
is_hidden: bool;
-
}
-
-
type people_response = {
-
total: int;
-
visible: int;
-
people: person list;
+
unknown: Jsont.json;
}
(** {1 Client Creation} *)
···
let requests_session = Requests.set_default_header requests_session "x-api-key" api_key in
{ base_url; api_key; requests_session }
-
(** {1 JSON Parsing} *)
+
(** {1 JSON Codecs} *)
-
(* Parse a single person from JSON *)
-
let parse_person json =
-
let open Ezjsonm in
-
let id = find json ["id"] |> get_string in
-
let name = find json ["name"] |> get_string in
-
let birth_date =
-
try Some (find json ["birthDate"] |> get_string)
-
with _ -> None
-
in
-
let thumbnail_path = find json ["thumbnailPath"] |> get_string in
-
let is_hidden =
-
try find json ["isHidden"] |> get_bool
-
with _ -> false
+
(* Jsont codec for person *)
+
let person_jsont =
+
let make id name birth_date thumbnail_path is_hidden unknown =
+
{ id; name; birth_date; thumbnail_path; is_hidden; unknown }
in
-
{ id; name; birth_date; thumbnail_path; is_hidden }
+
let id p = p.id in
+
let name p = p.name in
+
let birth_date p = p.birth_date in
+
let thumbnail_path p = p.thumbnail_path in
+
let is_hidden p = p.is_hidden in
+
let unknown p = p.unknown in
+
Jsont.Object.map ~kind:"Person" make
+
|> Jsont.Object.mem "id" Jsont.string ~enc:id
+
|> Jsont.Object.mem "name" Jsont.string ~enc:name
+
|> Jsont.Object.opt_mem "birthDate" Jsont.string ~enc:birth_date
+
|> Jsont.Object.mem "thumbnailPath" Jsont.string ~enc:thumbnail_path
+
|> Jsont.Object.mem "isHidden" Jsont.bool ~enc:is_hidden
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
+
|> Jsont.Object.finish
-
(* Parse people response from JSON *)
-
let parse_people_response json =
-
let open Ezjsonm in
-
let total = find json ["total"] |> get_int in
-
let visible = find json ["visible"] |> get_int in
-
let people_json = find json ["people"] in
-
let people = get_list parse_person people_json in
-
{ total; visible; people }
+
type people_response = {
+
total: int;
+
visible: int;
+
people: person list;
+
unknown: Jsont.json;
+
}
-
(* Parse a list of people from search results *)
-
let parse_person_list json =
-
let open Ezjsonm in
-
get_list parse_person json
+
(* Jsont codec for people_response *)
+
let people_response_jsont =
+
let make total visible people unknown =
+
{ total; visible; people; unknown }
+
in
+
let total r = r.total in
+
let visible r = r.visible in
+
let people r = r.people in
+
let unknown r = r.unknown in
+
Jsont.Object.map ~kind:"PeopleResponse" make
+
|> Jsont.Object.mem "total" Jsont.int ~enc:total
+
|> Jsont.Object.mem "visible" Jsont.int ~enc:visible
+
|> Jsont.Object.mem "people" (Jsont.list person_jsont) ~enc:people
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
+
|> Jsont.Object.finish
(** {1 API Functions} *)
let fetch_people { base_url; requests_session; _ } =
let open Requests_json_api in
let url = base_url / "api/people" in
-
get_json_exn requests_session url parse_people_response
+
get_json_exn requests_session url people_response_jsont
let fetch_person { base_url; requests_session; _ } ~person_id =
let open Requests_json_api in
let url = base_url / "api/people" / person_id in
-
get_json_exn requests_session url parse_person
+
get_json_exn requests_session url person_jsont
let download_thumbnail { base_url; requests_session; _ } ~fs ~person_id ~output_path =
try
···
let open Requests_json_api in
let encoded_name = Uri.pct_encode name in
let url = sprintf "%s/api/search/person?name=%s" base_url encoded_name in
-
get_json_exn requests_session url parse_person_list
+
get_json_exn requests_session url (Jsont.list person_jsont)
+2
stack/immich/immich.mli
···
birth_date: string option;
thumbnail_path: string;
is_hidden: bool;
+
unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
}
(** Type for the people API response *)
···
total: int;
visible: int;
people: person list;
+
unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
}
(** {1 Client Creation} *)
+2 -1
stack/immich/immich.opam
···
"eio"
"eio_main" {>= "1.0"}
"requests"
-
"ezjsonm"
+
"jsont"
+
"bytesrw"
"fmt"
"ptime"
"uri"
+42 -37
stack/karakeep/bin/karakeep_cli.ml
···
let bookmarks =
List.filter (fun (b : Karakeep.bookmark) ->
(match archived with
-
| Some true -> b.archived
-
| Some false -> not b.archived
+
| Some true -> Karakeep.bookmark_archived b
+
| Some false -> not (Karakeep.bookmark_archived b)
| None -> true) &&
(match favourited with
-
| Some true -> b.favourited
-
| Some false -> not b.favourited
+
| Some true -> Karakeep.bookmark_favourited b
+
| Some false -> not (Karakeep.bookmark_favourited b)
| None -> true)
) bookmarks
in
···
Printf.printf "Found %d bookmarks\n\n" (List.length bookmarks);
List.iteri (fun i (b : Karakeep.bookmark) ->
-
Printf.printf "%d. %s\n" (i + 1) b.url;
-
(match b.title with
+
Printf.printf "%d. %s\n" (i + 1) (Karakeep.bookmark_url b);
+
(match Karakeep.bookmark_title b with
| Some title -> Printf.printf " Title: %s\n" title
| None -> ());
-
Printf.printf " ID: %s\n" b.id;
-
Printf.printf " Created: %s\n" (Ptime.to_rfc3339 b.created_at);
-
if b.tags <> [] then
-
Printf.printf " Tags: %s\n" (String.concat ", " b.tags);
-
if b.archived then Printf.printf " [ARCHIVED]\n";
-
if b.favourited then Printf.printf " [FAVOURITED]\n";
-
(match b.summary with
+
Printf.printf " ID: %s\n" (Karakeep.bookmark_id b);
+
Printf.printf " Created: %s\n" (Ptime.to_rfc3339 (Karakeep.bookmark_created_at b));
+
let tags = Karakeep.bookmark_tags b in
+
if tags <> [] then
+
Printf.printf " Tags: %s\n" (String.concat ", " tags);
+
if Karakeep.bookmark_archived b then Printf.printf " [ARCHIVED]\n";
+
if Karakeep.bookmark_favourited b then Printf.printf " [FAVOURITED]\n";
+
(match Karakeep.bookmark_summary b with
| Some s when s <> "" ->
let summary = if String.length s > 100 then String.sub s 0 100 ^ "..." else s in
Printf.printf " Summary: %s\n" summary
···
let client = Karakeep.create ~sw ~env ~api_key ~base_url in
let bookmark = Karakeep.fetch_bookmark_details client bookmark_id in
-
Printf.printf "Bookmark: %s\n" bookmark.url;
-
Printf.printf "ID: %s\n" bookmark.id;
-
(match bookmark.title with
+
Printf.printf "Bookmark: %s\n" (Karakeep.bookmark_url bookmark);
+
Printf.printf "ID: %s\n" (Karakeep.bookmark_id bookmark);
+
(match Karakeep.bookmark_title bookmark with
| Some title -> Printf.printf "Title: %s\n" title
| None -> ());
-
(match bookmark.note with
+
(match Karakeep.bookmark_note bookmark with
| Some note -> Printf.printf "Note: %s\n" note
| None -> ());
-
Printf.printf "Created: %s\n" (Ptime.to_rfc3339 bookmark.created_at);
-
(match bookmark.updated_at with
+
Printf.printf "Created: %s\n" (Ptime.to_rfc3339 (Karakeep.bookmark_created_at bookmark));
+
(match Karakeep.bookmark_updated_at bookmark with
| Some t -> Printf.printf "Updated: %s\n" (Ptime.to_rfc3339 t)
| None -> ());
-
if bookmark.tags <> [] then
-
Printf.printf "Tags: %s\n" (String.concat ", " bookmark.tags);
+
let tags = Karakeep.bookmark_tags bookmark in
+
if tags <> [] then
+
Printf.printf "Tags: %s\n" (String.concat ", " tags);
-
if bookmark.archived then Printf.printf "Status: ARCHIVED\n";
-
if bookmark.favourited then Printf.printf "Status: FAVOURITED\n";
+
if Karakeep.bookmark_archived bookmark then Printf.printf "Status: ARCHIVED\n";
+
if Karakeep.bookmark_favourited bookmark then Printf.printf "Status: FAVOURITED\n";
-
(match bookmark.summary with
+
(match Karakeep.bookmark_summary bookmark with
| Some s when s <> "" -> Printf.printf "\nSummary:\n%s\n" s
| _ -> ());
-
if bookmark.content <> [] then begin
+
let content = Karakeep.bookmark_content bookmark in
+
if content <> [] then begin
Printf.printf "\nContent metadata:\n";
List.iter (fun (k, v) ->
if v <> "null" && v <> "" then
Printf.printf " %s: %s\n" k v
-
) bookmark.content
+
) content
end;
-
if bookmark.assets <> [] then begin
+
let assets = Karakeep.bookmark_assets bookmark in
+
if assets <> [] then begin
Printf.printf "\nAssets:\n";
List.iter (fun (id, asset_type) ->
Printf.printf " %s (%s)\n" id asset_type;
Printf.printf " URL: %s\n" (Karakeep.get_asset_url client id)
-
) bookmark.assets
+
) assets
end;
0
···
in
Printf.printf "✓ Bookmark created successfully!\n";
-
Printf.printf "ID: %s\n" bookmark.id;
-
Printf.printf "URL: %s\n" bookmark.url;
-
(match bookmark.title with
+
Printf.printf "ID: %s\n" (Karakeep.bookmark_id bookmark);
+
Printf.printf "URL: %s\n" (Karakeep.bookmark_url bookmark);
+
(match Karakeep.bookmark_title bookmark with
| Some t -> Printf.printf "Title: %s\n" t
| None -> ());
-
if bookmark.tags <> [] then
-
Printf.printf "Tags: %s\n" (String.concat ", " bookmark.tags);
+
let tags = Karakeep.bookmark_tags bookmark in
+
if tags <> [] then
+
Printf.printf "Tags: %s\n" (String.concat ", " tags);
0
with exn ->
Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
···
(if List.length bookmarks = 1 then "" else "s");
List.iteri (fun i (b : Karakeep.bookmark) ->
-
Printf.printf "%d. %s\n" (i + 1) b.url;
-
(match b.title with
+
Printf.printf "%d. %s\n" (i + 1) (Karakeep.bookmark_url b);
+
(match Karakeep.bookmark_title b with
| Some title -> Printf.printf " Title: %s\n" title
| None -> ());
-
Printf.printf " ID: %s\n" b.id;
-
Printf.printf " Tags: %s\n" (String.concat ", " b.tags);
+
Printf.printf " ID: %s\n" (Karakeep.bookmark_id b);
+
Printf.printf " Tags: %s\n" (String.concat ", " (Karakeep.bookmark_tags b));
Printf.printf "\n"
) bookmarks;
0
+1 -1
stack/karakeep/dune
···
(library
(name karakeep)
(public_name karakeep)
-
(libraries bushel eio eio.core requests requests_json_api ezjsonm fmt ptime uri logs logs.fmt))
+
(libraries eio eio.core requests requests_json_api jsont jsont.bytesrw fmt ptime uri logs logs.fmt))
-1
stack/karakeep/dune-project
···
eio
(eio_main (>= 1.0))
requests
-
ezjsonm
fmt
ptime
uri
+313 -271
stack/karakeep/karakeep.ml
···
(** Karakeep API client implementation (Eio version) *)
-
module J = Ezjsonm
-
let src = Logs.Src.create "karakeepe" ~doc:"Karakeep API client"
module Log = (val Logs.src_log src : Logs.LOG)
+
(** RFC 3339 timestamp support for JSON *)
+
module Rfc3339 = struct
+
let parse s =
+
Ptime.of_rfc3339 s |> Result.to_option |> Option.map (fun (t, _, _) -> t)
+
+
let format t = Ptime.to_rfc3339 ~frac_s:6 ~tz_offset_s:0 t
+
let _pp ppf t = Format.pp_print_string ppf (format t)
+
+
let jsont =
+
let kind = "RFC 3339 timestamp" in
+
let dec meta s =
+
match parse s with
+
| Some t -> t
+
| None ->
+
Jsont.Error.msgf meta "invalid RFC 3339 timestamp: %S" s
+
in
+
let enc = Jsont.Base.enc format in
+
Jsont.Base.string (Jsont.Base.map ~kind ~dec ~enc ())
+
end
+
+
(** Unknown JSON fields - used when keeping unknown members *)
+
let json_mems_empty = Jsont.Object ([], Jsont.Meta.none)
+
(** Type representing a Karakeep client session *)
type 'net t_internal = {
api_key: string;
···
let http_client = Requests.create ~sw env in
{ api_key; base_url; http_client }
-
(** Type representing a Karakeep bookmark *)
-
type bookmark = {
-
id: string;
-
title: string option;
-
url: string;
-
note: string option;
-
created_at: Ptime.t;
-
updated_at: Ptime.t option;
-
favourited: bool;
-
archived: bool;
-
tags: string list;
-
tagging_status: string option;
-
summary: string option;
-
content: (string * string) list;
-
assets: (string * string) list;
-
}
+
(** Tag type for bookmark tags *)
+
module Tag = struct
+
type t = {
+
name: string;
+
unknown: Jsont.json;
+
}
-
(** Type for Karakeep API response containing bookmarks *)
-
type bookmark_response = {
-
total: int;
-
data: bookmark list;
-
next_cursor: string option;
-
}
+
let make name unknown = { name; unknown }
+
let name t = t.name
+
let unknown t = t.unknown
-
(** Parse a date string to Ptime.t, defaulting to epoch if invalid *)
-
let parse_date str =
-
match Ptime.of_rfc3339 str with
-
| Ok (date, _, _) -> date
-
| Error _ ->
-
Fmt.epr "Warning: could not parse date '%s'\n" str;
-
(* Default to epoch time *)
-
let span_opt = Ptime.Span.of_d_ps (0, 0L) in
-
match span_opt with
-
| None -> failwith "Internal error: couldn't create epoch time span"
-
| Some span ->
-
match Ptime.of_span span with
-
| Some t -> t
-
| None -> failwith "Internal error: couldn't create epoch time"
+
let jsont =
+
let kind = "Tag" in
+
Jsont.Object.map ~kind make
+
|> Jsont.Object.mem "name" Jsont.string ~enc:name
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
+
|> Jsont.Object.finish
+
end
-
(** Extract a string field from JSON, returns None if not present or not a string *)
-
let get_string_opt json path =
-
try Some (J.find json path |> J.get_string)
-
with _ -> None
+
(** Content field pair (key-value from content object) *)
+
module ContentField = struct
+
type _t = string * string
-
(** Extract a string list field from JSON, returns empty list if not present *)
-
let get_string_list json path =
-
try
-
let items_json = J.find json path in
-
J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json
-
with _ -> []
+
let _key (k, _) = k
+
let _value (_, v) = v
-
(** Extract a boolean field from JSON, with default value *)
-
let get_bool_def json path default =
-
try J.find json path |> J.get_bool
-
with _ -> default
+
(* Helper to convert Jsont.json to string *)
+
let json_to_string = function
+
| Jsont.String (s, _) -> s
+
| Jsont.Bool (b, _) -> string_of_bool b
+
| Jsont.Number (n, _) -> string_of_float n
+
| Jsont.Null _ -> "null"
+
| _ -> "complex_value"
-
(** Parse a single bookmark from Karakeep JSON *)
-
let parse_bookmark json =
-
let id =
-
try J.find json ["id"] |> J.get_string
-
with e ->
-
Log.err (fun m -> m "Error parsing bookmark ID: %s@.JSON: %s"
-
(Printexc.to_string e) (J.value_to_string json));
-
failwith "Unable to parse bookmark ID"
-
in
+
(* Decode from JSON object members *)
+
let of_json_mems mems =
+
List.map (fun ((k, _meta), v) -> (k, json_to_string v)) mems
-
let title =
-
try Some (J.find json ["title"] |> J.get_string)
-
with _ -> None
-
in
+
(* Encode to JSON object members *)
+
let to_json_mems fields =
+
List.map (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))) fields
+
end
-
let url =
-
try J.find json ["url"] |> J.get_string
-
with _ -> try
-
J.find json ["content"; "url"] |> J.get_string
-
with _ -> try
-
J.find json ["content"; "sourceUrl"] |> J.get_string
-
with _ ->
-
match J.find_opt json ["content"; "type"] with
-
| Some (`String "asset") ->
-
(try J.find json ["content"; "sourceUrl"] |> J.get_string
-
with _ ->
-
(match J.find_opt json ["id"] with
-
| Some (`String id) -> "karakeep-asset://" ^ id
-
| _ -> failwith "No URL or asset ID found in bookmark"))
-
| _ ->
-
Log.err (fun m -> m "No URL found in bookmark@.JSON structure: %s"
-
(J.value_to_string json));
-
failwith "No URL found in bookmark"
-
in
+
(** Asset type *)
+
module Asset = struct
+
type t = {
+
id: string;
+
asset_type: string;
+
unknown: Jsont.json;
+
}
-
let note = get_string_opt json ["note"] in
+
let make id asset_type unknown = { id; asset_type; unknown }
+
let id t = t.id
+
let asset_type t = t.asset_type
+
let unknown t = t.unknown
-
let created_at =
-
try J.find json ["createdAt"] |> J.get_string |> parse_date
-
with _ ->
-
try J.find json ["created_at"] |> J.get_string |> parse_date
-
with _ -> failwith "No creation date found"
-
in
+
let jsont =
+
let kind = "Asset" in
+
Jsont.Object.map ~kind make
+
|> Jsont.Object.mem "id" Jsont.string ~enc:id
+
|> Jsont.Object.mem "assetType" Jsont.string ~enc:asset_type
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
+
|> Jsont.Object.finish
+
end
-
let updated_at =
-
try Some (J.find json ["updatedAt"] |> J.get_string |> parse_date)
-
with _ ->
-
try Some (J.find json ["modifiedAt"] |> J.get_string |> parse_date)
-
with _ -> None
-
in
+
(** Karakeep bookmark *)
+
module Bookmark = struct
+
type t = {
+
id: string;
+
title: string option;
+
url: string;
+
note: string option;
+
created_at: Ptime.t;
+
updated_at: Ptime.t option;
+
favourited: bool;
+
archived: bool;
+
tags: string list;
+
tagging_status: string option;
+
summary: string option;
+
content: (string * string) list;
+
assets: (string * string) list;
+
}
-
let favourited = get_bool_def json ["favourited"] false in
-
let archived = get_bool_def json ["archived"] false in
-
let tags = get_string_list json ["tags"] in
-
let tagging_status = get_string_opt json ["taggingStatus"] in
-
let summary = get_string_opt json ["summary"] in
+
let id t = t.id
+
let title t = t.title
+
let url t = t.url
+
let note t = t.note
+
let created_at t = t.created_at
+
let updated_at t = t.updated_at
+
let favourited t = t.favourited
+
let archived t = t.archived
+
let tags t = t.tags
+
let tagging_status t = t.tagging_status
+
let summary t = t.summary
+
let content t = t.content
+
let assets t = t.assets
-
let content =
-
try
-
let content_json = J.find json ["content"] in
-
let rec extract_fields acc = function
-
| [] -> acc
-
| (k, v) :: rest ->
-
let value = match v with
-
| `String s -> s
-
| `Bool b -> string_of_bool b
-
| `Float f -> string_of_float f
-
| `Null -> "null"
-
| _ -> "complex_value"
-
in
-
extract_fields ((k, value) :: acc) rest
+
let jsont =
+
let kind = "Bookmark" in
+
+
(* Constructor for decoding *)
+
let make id title url note created_at updated_at favourited archived
+
tag_objs tagging_status summary content_obj assets_objs _unknown =
+
+
(* Extract tag names from tag objects *)
+
let tags = match tag_objs with
+
| Some tags -> List.map Tag.name tags
+
| None -> []
+
in
+
+
(* Extract content fields from JSON object *)
+
let content = match content_obj with
+
| Some (Jsont.Object (mems, _)) -> ContentField.of_json_mems mems
+
| _ -> []
in
-
match content_json with
-
| `O fields -> extract_fields [] fields
-
| _ -> []
-
with _ -> []
-
in
-
let assets =
-
try
-
let assets_json = J.find json ["assets"] in
-
J.get_list (fun asset_json ->
-
let id = J.find asset_json ["id"] |> J.get_string in
-
let asset_type =
-
try J.find asset_json ["assetType"] |> J.get_string
-
with _ -> "unknown"
-
in
-
(id, asset_type)
-
) assets_json
-
with _ -> []
-
in
+
(* Extract asset tuples *)
+
let assets = match assets_objs with
+
| Some asset_list -> List.map (fun a -> (Asset.id a, Asset.asset_type a)) asset_list
+
| None -> []
+
in
-
{ id; title; url; note; created_at; updated_at; favourited; archived; tags;
-
tagging_status; summary; content; assets }
+
(* Handle URL extraction from content if main URL is missing *)
+
let url = match url with
+
| Some u -> u
+
| None ->
+
(* Try to find URL in content *)
+
(match List.assoc_opt "url" content with
+
| Some u -> u
+
| None ->
+
(match List.assoc_opt "sourceUrl" content with
+
| Some u -> u
+
| None ->
+
(* Check if it's an asset type *)
+
(match List.assoc_opt "type" content with
+
| Some "asset" ->
+
(match List.assoc_opt "sourceUrl" content with
+
| Some u -> u
+
| None -> "karakeep-asset://" ^ id)
+
| _ -> "unknown://no-url")))
+
in
+
+
{
+
id;
+
title;
+
url;
+
note;
+
created_at;
+
updated_at;
+
favourited = Option.value ~default:false favourited;
+
archived = Option.value ~default:false archived;
+
tags;
+
tagging_status;
+
summary;
+
content;
+
assets;
+
}
+
in
+
+
Jsont.Object.map ~kind make
+
|> Jsont.Object.mem "id" Jsont.string ~enc:id
+
|> Jsont.Object.opt_mem "title" Jsont.string ~enc:title
+
|> Jsont.Object.opt_mem "url" Jsont.string ~enc:(fun t -> Some t.url)
+
|> Jsont.Object.opt_mem "note" Jsont.string ~enc:note
+
|> Jsont.Object.mem "createdAt" Rfc3339.jsont ~enc:created_at
+
|> Jsont.Object.opt_mem "updatedAt" Rfc3339.jsont ~enc:updated_at
+
|> Jsont.Object.opt_mem "favourited" Jsont.bool ~enc:(fun t -> Some t.favourited)
+
|> Jsont.Object.opt_mem "archived" Jsont.bool ~enc:(fun t -> Some t.archived)
+
|> Jsont.Object.opt_mem "tags" (Jsont.list Tag.jsont)
+
~enc:(fun t -> if t.tags = [] then None else
+
Some (List.map (fun name -> Tag.make name json_mems_empty) t.tags))
+
|> Jsont.Object.opt_mem "taggingStatus" Jsont.string ~enc:tagging_status
+
|> Jsont.Object.opt_mem "summary" Jsont.string ~enc:summary
+
|> Jsont.Object.opt_mem "content" Jsont.json
+
~enc:(fun t -> if t.content = [] then None else
+
Some (Jsont.Object (ContentField.to_json_mems t.content, Jsont.Meta.none)))
+
|> Jsont.Object.opt_mem "assets" (Jsont.list Asset.jsont)
+
~enc:(fun t -> if t.assets = [] then None else
+
Some (List.map (fun (id, asset_type) ->
+
Asset.make id asset_type json_mems_empty) t.assets))
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
+
|> Jsont.Object.finish
+
end
+
+
(* Compatibility type aliases and accessors *)
+
type bookmark = Bookmark.t
+
let bookmark_id = Bookmark.id
+
let bookmark_title = Bookmark.title
+
let bookmark_url = Bookmark.url
+
let bookmark_note = Bookmark.note
+
let bookmark_created_at = Bookmark.created_at
+
let bookmark_updated_at = Bookmark.updated_at
+
let bookmark_favourited = Bookmark.favourited
+
let bookmark_archived = Bookmark.archived
+
let bookmark_tags = Bookmark.tags
+
let bookmark_tagging_status = Bookmark.tagging_status
+
let bookmark_summary = Bookmark.summary
+
let bookmark_content = Bookmark.content
+
let bookmark_assets = Bookmark.assets
+
+
(** Karakeep API response containing bookmarks *)
+
module BookmarkResponse = struct
+
type t = {
+
total: int;
+
data: bookmark list;
+
next_cursor: string option;
+
}
+
+
let make total data next_cursor = { total; data; next_cursor }
+
let total t = t.total
+
let data t = t.data
+
let next_cursor t = t.next_cursor
+
+
(* Format 1: {total, data, nextCursor} *)
+
let format1_jsont =
+
let kind = "BookmarkResponse" in
+
let make total data next_cursor _unknown =
+
{ total; data; next_cursor }
+
in
+
Jsont.Object.map ~kind make
+
|> Jsont.Object.mem "total" Jsont.int ~enc:total
+
|> Jsont.Object.mem "data" (Jsont.list Bookmark.jsont) ~enc:data
+
|> Jsont.Object.opt_mem "nextCursor" Jsont.string ~enc:next_cursor
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
+
|> Jsont.Object.finish
+
+
(* Format 2: {bookmarks, nextCursor} *)
+
let format2_jsont =
+
let kind = "BookmarkResponse" in
+
let make data next_cursor _unknown =
+
{ total = List.length data; data; next_cursor }
+
in
+
Jsont.Object.map ~kind make
+
|> Jsont.Object.mem "bookmarks" (Jsont.list Bookmark.jsont) ~enc:data
+
|> Jsont.Object.opt_mem "nextCursor" Jsont.string ~enc:next_cursor
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:(fun _ -> json_mems_empty)
+
|> Jsont.Object.finish
+
end
+
+
(* Compatibility type aliases and accessors *)
+
type bookmark_response = BookmarkResponse.t
+
let response_total = BookmarkResponse.total
+
let response_data = BookmarkResponse.data
+
let response_next_cursor = BookmarkResponse.next_cursor
(** Parse a Karakeep bookmark response - handles multiple API response formats *)
-
let parse_bookmark_response json =
-
Log.debug (fun m -> m "Parsing API response: %s" (J.value_to_string json));
+
let parse_bookmark_response json_str =
+
Log.debug (fun m -> m "Parsing API response (%d bytes)" (String.length json_str));
(* Try format 1: {total: int, data: [...], nextCursor?: string} *)
let try_format1 () =
Log.debug (fun m -> m "Trying format 1: {total, data, nextCursor}");
-
let total = J.find json ["total"] |> J.get_int in
-
let bookmarks_json = J.find json ["data"] in
-
let data = J.get_list parse_bookmark bookmarks_json in
-
let next_cursor =
-
try Some (J.find json ["nextCursor"] |> J.get_string)
-
with _ -> None
-
in
-
Log.debug (fun m -> m "Successfully parsed format 1: %d bookmarks" (List.length data));
-
{ total; data; next_cursor }
+
match Jsont_bytesrw.decode_string' BookmarkResponse.format1_jsont json_str with
+
| Ok response ->
+
Log.debug (fun m -> m "Successfully parsed format 1: %d bookmarks" (List.length response.data));
+
response
+
| Error e ->
+
Log.debug (fun m -> m "Format 1 failed: %s" (Jsont.Error.to_string e));
+
raise Not_found
in
-
(* Try format 2: {bookmarks: [...], nextCursor?: string} - no total field *)
+
(* Try format 2: {bookmarks: [...], nextCursor?: string} *)
let try_format2 () =
Log.debug (fun m -> m "Trying format 2: {bookmarks, nextCursor}");
-
let bookmarks_json = J.find json ["bookmarks"] in
-
let data = J.get_list parse_bookmark bookmarks_json in
-
let next_cursor =
-
try Some (J.find json ["nextCursor"] |> J.get_string)
-
with _ -> None
-
in
-
(* Calculate total from data length when total field is missing *)
-
let total = List.length data in
-
Log.debug (fun m -> m "Successfully parsed format 2: %d bookmarks" total);
-
{ total; data; next_cursor }
-
in
-
-
(* Try format 3: API error response {error: string, message?: string} *)
-
let try_error_format () =
-
Log.debug (fun m -> m "Checking for API error response");
-
let error = J.find json ["error"] |> J.get_string in
-
let message =
-
try J.find json ["message"] |> J.get_string
-
with _ -> "Unknown error"
-
in
-
Log.err (fun m -> m "API returned error: %s - %s" error message);
-
{ total = 0; data = []; next_cursor = None }
+
match Jsont_bytesrw.decode_string' BookmarkResponse.format2_jsont json_str with
+
| Ok response ->
+
Log.debug (fun m -> m "Successfully parsed format 2: %d bookmarks" (List.length response.data));
+
response
+
| Error e ->
+
Log.debug (fun m -> m "Format 2 failed: %s" (Jsont.Error.to_string e));
+
raise Not_found
in
-
(* Try format 4: Plain array at root level *)
+
(* Try format 3: Plain array at root level *)
let try_array_format () =
-
Log.debug (fun m -> m "Trying format 4: array at root");
-
match json with
-
| `A _ ->
-
let data = J.get_list parse_bookmark json in
+
Log.debug (fun m -> m "Trying format 3: array at root");
+
let array_jsont = Jsont.list Bookmark.jsont in
+
match Jsont_bytesrw.decode_string' array_jsont json_str with
+
| Ok data ->
Log.debug (fun m -> m "Successfully parsed array format: %d bookmarks" (List.length data));
-
{ total = List.length data; data; next_cursor = None }
-
| _ -> raise Not_found
+
BookmarkResponse.make (List.length data) data None
+
| Error e ->
+
Log.debug (fun m -> m "Array format failed: %s" (Jsont.Error.to_string e));
+
raise Not_found
in
(* Try each format in order *)
try try_format1 ()
-
with _ -> (
+
with Not_found -> (
try try_format2 ()
-
with _ -> (
-
try try_error_format ()
-
with _ -> (
-
try try_array_format ()
-
with _ ->
-
Log.err (fun m -> m "Failed to parse response in any known format");
-
Log.debug (fun m -> m "JSON keys: %s"
-
(match json with
-
| `O fields -> String.concat ", " (List.map fst fields)
-
| _ -> "not an object"));
-
{ total = 0; data = []; next_cursor = None }
-
)
+
with Not_found -> (
+
try try_array_format ()
+
with Not_found ->
+
Log.err (fun m -> m "Failed to parse response in any known format");
+
Log.debug (fun m -> m "Response preview: %s"
+
(if String.length json_str > 200 then String.sub json_str 0 200 ^ "..." else json_str));
+
BookmarkResponse.make 0 [] None
)
)
···
match Requests_json_api.check_ok response with
| Ok body_str ->
Log.debug (fun m -> m "Received %d bytes of response data" (String.length body_str));
-
(try
-
let json = J.from_string body_str in
-
parse_bookmark_response json
-
with e ->
-
Log.err (fun m -> m "JSON parsing error: %s" (Printexc.to_string e));
-
Log.debug (fun m -> m "Response body (first 200 chars): %s"
-
(if String.length body_str > 200 then String.sub body_str 0 200 ^ "..." else body_str));
-
raise e)
+
parse_bookmark_response body_str
| Error (status_code, _) ->
Log.err (fun m -> m "HTTP error %d" status_code);
failwith (Fmt.str "HTTP error: %d" status_code)
···
| _ -> all_bookmarks
in
-
(* Determine if more pages are available:
-
- If next_cursor is present, there are definitely more pages
-
- If no next_cursor and we got fewer items than page_size, we're done
-
- If no next_cursor and total is reliable (> current count), there may be more *)
+
(* Determine if more pages are available *)
let more_available =
match response.next_cursor with
| Some _ ->
···
let current_count = List.length all_bookmarks in
let got_full_page = List.length response.data = page_size in
let total_indicates_more = response.total > current_count in
-
(* If we got a full page and total indicates more, continue *)
let has_more = got_full_page && total_indicates_more in
if has_more then
Log.debug (fun m -> m "More pages likely available (%d fetched < %d total)"
···
let response = Requests.get client.http_client ~headers url in
match check_ok response with
| Ok body_str ->
-
let json = J.from_string body_str in
-
parse_bookmark json
+
(match Jsont_bytesrw.decode_string' Bookmark.jsont body_str with
+
| Ok bookmark -> bookmark
+
| Error e ->
+
failwith (Fmt.str "Failed to parse bookmark: %s" (Jsont.Error.to_string e)))
| Error (status_code, _) ->
failwith (Fmt.str "HTTP error: %d" status_code)
···
(** Create a new bookmark in Karakeep with optional tags *)
let create_bookmark client ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) () =
+
let meta = Jsont.Meta.none in
let body_obj = [
-
("type", `String "link");
-
("url", `String url);
-
("favourited", `Bool favourited);
-
("archived", `Bool archived);
+
(("type", meta), Jsont.String ("link", meta));
+
(("url", meta), Jsont.String (url, meta));
+
(("favourited", meta), Jsont.Bool (favourited, meta));
+
(("archived", meta), Jsont.Bool (archived, meta));
] in
let body_obj = match title with
-
| Some title_str -> ("title", `String title_str) :: body_obj
+
| Some title_str -> (("title", meta), Jsont.String (title_str, meta)) :: body_obj
| None -> body_obj
in
let body_obj = match note with
-
| Some note_str -> ("note", `String note_str) :: body_obj
+
| Some note_str -> (("note", meta), Jsont.String (note_str, meta)) :: body_obj
| None -> body_obj
in
-
let body_json = `O body_obj in
-
let body_str = J.to_string body_json in
+
let body_json = Jsont.Object (body_obj, meta) in
+
let body_str = match Jsont_bytesrw.encode_string' Jsont.json body_json with
+
| Ok s -> s
+
| Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
+
in
let headers = Requests.Headers.empty
|> Requests.Headers.set "Authorization" ("Bearer " ^ client.api_key)
···
let status_code = Requests.Response.status_code response in
if status_code = 201 || status_code = 200 then begin
let body_str = read_body response in
-
let json = J.from_string body_str in
-
let bookmark = parse_bookmark json in
+
let bookmark = match Jsont_bytesrw.decode_string' Bookmark.jsont body_str with
+
| Ok b -> b
+
| Error e -> failwith (Fmt.str "Failed to parse created bookmark: %s" (Jsont.Error.to_string e))
+
in
match tags with
| Some tag_list when tag_list <> [] ->
let tag_objects = List.map (fun tag_name ->
-
`O [("tagName", `String tag_name)]
+
Jsont.Object ([(("tagName", meta), Jsont.String (tag_name, meta))], meta)
) tag_list in
-
let tags_body = `O [("tags", `A tag_objects)] in
-
let tags_body_str = J.to_string tags_body in
+
let tags_body = Jsont.Object ([(("tags", meta), Jsont.Array (tag_objects, meta))], meta) in
+
let tags_body_str = match Jsont_bytesrw.encode_string' Jsont.json tags_body with
+
| Ok s -> s
+
| Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
+
in
let tags_url = client.base_url / "api/v1/bookmarks" / bookmark.id / "tags" in
let tags_body = Requests.Body.of_string Requests.Mime.json tags_body_str in
···
let error_body = read_body response in
failwith (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body)
end
-
-
(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure *)
-
let to_bushel_link ?base_url bookmark =
-
let description =
-
match bookmark.title with
-
| Some title when title <> "" -> title
-
| _ ->
-
let content_title = List.assoc_opt "title" bookmark.content in
-
match content_title with
-
| Some title when title <> "" && title <> "null" -> title
-
| _ -> bookmark.url
-
in
-
let date = Ptime.to_date bookmark.created_at in
-
-
let metadata =
-
(match bookmark.summary with Some s -> [("summary", s)] | None -> []) @
-
(List.filter_map (fun (id, asset_type) ->
-
match asset_type with
-
| "screenshot" | "bannerImage" -> Some (asset_type, id)
-
| _ -> None
-
) bookmark.assets) @
-
(List.filter_map (fun (k, v) ->
-
if k = "favicon" && v <> "" && v <> "null" then Some ("favicon", v) else None
-
) bookmark.content)
-
in
-
-
let karakeep =
-
match base_url with
-
| Some url ->
-
Some {
-
Bushel.Link.remote_url = url;
-
id = bookmark.id;
-
tags = bookmark.tags;
-
metadata = metadata;
-
}
-
| None -> None
-
in
-
-
let bushel_slugs =
-
List.filter_map (fun tag ->
-
if String.starts_with ~prefix:"bushel:" tag then
-
Some (String.sub tag 7 (String.length tag - 7))
-
else
-
None
-
) bookmark.tags
-
in
-
-
let bushel =
-
if bushel_slugs = [] then None
-
else Some { Bushel.Link.slugs = bushel_slugs; tags = [] }
-
in
-
-
{ Bushel.Link.url = bookmark.url; date; description; karakeep; bushel }
+23 -28
stack/karakeep/karakeep.mli
···
t
(** Type representing a Karakeep bookmark *)
-
type bookmark = {
-
id: string;
-
title: string option;
-
url: string;
-
note: string option;
-
created_at: Ptime.t;
-
updated_at: Ptime.t option;
-
favourited: bool;
-
archived: bool;
-
tags: string list;
-
tagging_status: string option;
-
summary: string option;
-
content: (string * string) list;
-
assets: (string * string) list;
-
}
+
type bookmark
+
+
(** Bookmark accessors *)
+
val bookmark_id : bookmark -> string
+
val bookmark_title : bookmark -> string option
+
val bookmark_url : bookmark -> string
+
val bookmark_note : bookmark -> string option
+
val bookmark_created_at : bookmark -> Ptime.t
+
val bookmark_updated_at : bookmark -> Ptime.t option
+
val bookmark_favourited : bookmark -> bool
+
val bookmark_archived : bookmark -> bool
+
val bookmark_tags : bookmark -> string list
+
val bookmark_tagging_status : bookmark -> string option
+
val bookmark_summary : bookmark -> string option
+
val bookmark_content : bookmark -> (string * string) list
+
val bookmark_assets : bookmark -> (string * string) list
(** Type for Karakeep API response containing bookmarks *)
-
type bookmark_response = {
-
total: int;
-
data: bookmark list;
-
next_cursor: string option;
-
}
+
type bookmark_response
-
(** Parse a single bookmark from Karakeep JSON *)
-
val parse_bookmark : Ezjsonm.value -> bookmark
+
(** Bookmark response accessors *)
+
val response_total : bookmark_response -> int
+
val response_data : bookmark_response -> bookmark list
+
val response_next_cursor : bookmark_response -> string option
-
(** Parse a Karakeep bookmark response *)
-
val parse_bookmark_response : Ezjsonm.value -> bookmark_response
+
(** Parse a Karakeep bookmark response from a JSON string *)
+
val parse_bookmark_response : string -> bookmark_response
(** Fetch bookmarks from a Karakeep instance with pagination support
@param client Karakeep client instance
···
t ->
string ->
bookmark
-
-
(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure
-
@param base_url Optional base URL of the Karakeep instance (for karakeep_id) *)
-
val to_bushel_link : ?base_url:string -> bookmark -> Bushel.Link.t
(** Fetch an asset from the Karakeep server as a binary string
@param client Karakeep client instance
-1
stack/karakeep/karakeep.opam
···
"eio"
"eio_main" {>= "1.0"}
"requests"
-
"ezjsonm"
"fmt"
"ptime"
"uri"
+8 -4
stack/peertubee/bin/peertubee_cli.ml
···
Printf.printf " Description: %s\n" desc_short
| None -> ());
Printf.printf " Published: %s\n" (Ptime.to_rfc3339 v.published_at);
-
if v.tags <> [] then
-
Printf.printf " Tags: %s\n" (String.concat ", " v.tags);
+
(match v.tags with
+
| Some tags when tags <> [] ->
+
Printf.printf " Tags: %s\n" (String.concat ", " tags)
+
| _ -> ());
Printf.printf "\n"
) videos;
0
···
(match video.originally_published_at with
| Some t -> Printf.printf "Originally published: %s\n" (Ptime.to_rfc3339 t)
| None -> ());
-
if video.tags <> [] then
-
Printf.printf "Tags: %s\n" (String.concat ", " video.tags);
+
(match video.tags with
+
| Some tags when tags <> [] ->
+
Printf.printf "Tags: %s\n" (String.concat ", " tags)
+
| _ -> ());
(match Peertubee.thumbnail_url client video with
| Some url -> Printf.printf "Thumbnail: %s\n" url
| None -> ());
+1 -1
stack/peertubee/dune
···
(library
(name peertubee)
(public_name peertubee)
-
(libraries ezjsonm eio eio.core requests requests_json_api ptime fmt))
+
(libraries jsont jsont.bytesrw eio eio.core requests requests_json_api ptime fmt))
-1
stack/peertubee/dune-project
···
eio
(eio_main (>= 1.0))
requests
-
ezjsonm
fmt
ptime))
+85 -55
stack/peertubee/peertubee.ml
···
(** PeerTube API client implementation (Eio version) *)
-
module J = Ezjsonm
-
(** Type representing a PeerTube client *)
type 'net t_internal = {
base_url: string;
···
published_at: Ptime.t;
originally_published_at: Ptime.t option;
thumbnail_path: string option;
-
tags: string list;
+
tags: string list option;
+
unknown: Jsont.json;
}
(** Type for PeerTube API response containing videos *)
type video_response = {
total: int;
data: video list;
+
unknown: Jsont.json;
}
-
(** Parse a date string to Ptime.t, defaulting to epoch if invalid *)
-
let parse_date str =
-
match Ptime.of_rfc3339 str with
-
| Ok (date, _, _) -> date
-
| Error _ ->
-
Fmt.epr "Warning: could not parse date '%s'\n" str;
-
(* Default to epoch time *)
-
let span_opt = Ptime.Span.of_d_ps (0, 0L) in
-
match span_opt with
-
| None -> failwith "Internal error: couldn't create epoch time span"
-
| Some span ->
-
match Ptime.of_span span with
-
| Some t -> t
-
| None -> failwith "Internal error: couldn't create epoch time"
+
(** Accessor functions for video *)
+
let video_id (v : video) = v.id
+
let video_uuid (v : video) = v.uuid
+
let video_name (v : video) = v.name
+
let video_description (v : video) = v.description
+
let video_url (v : video) = v.url
+
let video_embed_path (v : video) = v.embed_path
+
let video_published_at (v : video) = v.published_at
+
let video_originally_published_at (v : video) = v.originally_published_at
+
let video_thumbnail_path (v : video) = v.thumbnail_path
+
let video_tags (v : video) = v.tags
+
let video_unknown (v : video) = v.unknown
-
(** Extract a string field from JSON, returns None if not present or not a string *)
-
let get_string_opt json path =
-
try Some (J.find json path |> J.get_string)
-
with _ -> None
+
(** Accessor functions for video_response *)
+
let video_response_total (vr : video_response) = vr.total
+
let video_response_data (vr : video_response) = vr.data
+
let video_response_unknown (vr : video_response) = vr.unknown
-
(** Extract a string list field from JSON, returns empty list if not present *)
-
let get_string_list json path =
-
try
-
let tags_json = J.find json path in
-
J.get_list J.get_string tags_json
-
with _ -> []
+
(** RFC3339 timestamp codec *)
+
module Rfc3339 = struct
+
let parse s =
+
Ptime.of_rfc3339 s |> Result.to_option |> Option.map (fun (t, _, _) -> t)
-
(** Parse a single video from PeerTube JSON *)
-
let parse_video json =
-
let id = J.find json ["id"] |> J.get_int in
-
let uuid = J.find json ["uuid"] |> J.get_string in
-
let name = J.find json ["name"] |> J.get_string in
-
let description = get_string_opt json ["description"] in
-
let url = J.find json ["url"] |> J.get_string in
-
let embed_path = J.find json ["embedPath"] |> J.get_string in
+
let format t = Ptime.to_rfc3339 ~frac_s:6 ~tz_offset_s:0 t
+
let pp ppf t = Format.pp_print_string ppf (format t)
-
(* Parse dates *)
-
let published_at =
-
J.find json ["publishedAt"] |> J.get_string |> parse_date
+
let jsont =
+
let kind = "RFC 3339 timestamp" in
+
let doc = "An RFC 3339 date-time string" in
+
let dec s =
+
match parse s with
+
| Some t -> t
+
| None ->
+
Jsont.Error.msgf Jsont.Meta.none "%s: invalid RFC 3339 timestamp: %S"
+
kind s
+
in
+
Jsont.map ~kind ~doc ~dec ~enc:format Jsont.string
+
end
+
+
(** Jsont codec for video *)
+
let video_jsont : video Jsont.t =
+
let kind = "PeerTube Video" in
+
let doc = "A PeerTube video object" in
+
+
let make_video id uuid name description url embed_path published_at
+
originally_published_at thumbnail_path tags unknown : video =
+
{ id; uuid; name; description; url; embed_path; published_at;
+
originally_published_at; thumbnail_path; tags; unknown }
in
-
let originally_published_at =
-
match get_string_opt json ["originallyPublishedAt"] with
-
| Some date -> Some (parse_date date)
-
| None -> None
+
Jsont.Object.map ~kind ~doc make_video
+
|> Jsont.Object.mem "id" Jsont.int ~enc:video_id
+
|> Jsont.Object.mem "uuid" Jsont.string ~enc:video_uuid
+
|> Jsont.Object.mem "name" Jsont.string ~enc:video_name
+
|> Jsont.Object.opt_mem "description" Jsont.string ~enc:video_description
+
|> Jsont.Object.mem "url" Jsont.string ~enc:video_url
+
|> Jsont.Object.mem "embedPath" Jsont.string ~enc:video_embed_path
+
|> Jsont.Object.mem "publishedAt" Rfc3339.jsont ~enc:video_published_at
+
|> Jsont.Object.opt_mem "originallyPublishedAt" Rfc3339.jsont ~enc:video_originally_published_at
+
|> Jsont.Object.opt_mem "thumbnailPath" Jsont.string ~enc:video_thumbnail_path
+
|> Jsont.Object.opt_mem "tags" (Jsont.list Jsont.string) ~enc:video_tags
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:video_unknown
+
|> Jsont.Object.finish
+
+
(** Jsont codec for video_response *)
+
let video_response_jsont =
+
let kind = "PeerTube Video Response" in
+
let doc = "A PeerTube API response containing videos" in
+
+
let make_response total data unknown =
+
{ total; data; unknown }
in
-
let thumbnail_path = get_string_opt json ["thumbnailPath"] in
-
let tags = get_string_list json ["tags"] in
+
Jsont.Object.map ~kind ~doc make_response
+
|> Jsont.Object.mem "total" Jsont.int ~enc:video_response_total
+
|> Jsont.Object.mem "data" (Jsont.list video_jsont) ~enc:video_response_data
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:video_response_unknown
+
|> Jsont.Object.finish
-
{ id; uuid; name; description; url; embed_path;
-
published_at; originally_published_at;
-
thumbnail_path; tags }
+
(** Parse a single video from JSON string *)
+
let parse_video_string s =
+
match Jsont_bytesrw.decode_string' video_jsont s with
+
| Ok video -> video
+
| Error err -> failwith (Jsont.Error.to_string err)
-
(** Parse a PeerTube video response *)
-
let parse_video_response json =
-
let total = J.find json ["total"] |> J.get_int in
-
let videos_json = J.find json ["data"] in
-
let data = J.get_list parse_video videos_json in
-
{ total; data }
+
(** Parse a video response from JSON string *)
+
let parse_video_response_string s =
+
match Jsont_bytesrw.decode_string' video_response_jsont s with
+
| Ok response -> response
+
| Error err -> failwith (Jsont.Error.to_string err)
(** Fetch videos from a PeerTube instance channel with pagination support
@param count Number of videos to fetch per page
···
let open Requests_json_api in
let url = Printf.sprintf "%s/api/v1/video-channels/%s/videos?count=%d&start=%d"
client.base_url channel count start in
-
get_json_exn client.requests_session url parse_video_response
+
get_json_exn client.requests_session url video_response_jsont
(** Fetch all videos from a PeerTube instance channel using pagination
@param page_size Number of videos to fetch per page
···
let fetch_video_details client uuid =
let open Requests_json_api in
let url = client.base_url / "api/v1/videos" / uuid in
-
get_json_exn client.requests_session url parse_video
+
get_json_exn client.requests_session url video_jsont
(** Convert a PeerTube video to Bushel.Video.t compatible structure *)
let to_bushel_video video =
+33 -5
stack/peertubee/peertubee.mli
···
published_at: Ptime.t;
originally_published_at: Ptime.t option;
thumbnail_path: string option;
-
tags: string list;
+
tags: string list option;
+
unknown: Jsont.json;
}
(** Type for PeerTube API response containing videos *)
type video_response = {
total: int;
data: video list;
+
unknown: Jsont.json;
}
-
(** Parse a single video from PeerTube JSON *)
-
val parse_video : Ezjsonm.value -> video
+
(** Accessor functions for video *)
+
val video_id : video -> int
+
val video_uuid : video -> string
+
val video_name : video -> string
+
val video_description : video -> string option
+
val video_url : video -> string
+
val video_embed_path : video -> string
+
val video_published_at : video -> Ptime.t
+
val video_originally_published_at : video -> Ptime.t option
+
val video_thumbnail_path : video -> string option
+
val video_tags : video -> string list option
+
val video_unknown : video -> Jsont.json
-
(** Parse a PeerTube video response *)
-
val parse_video_response : Ezjsonm.value -> video_response
+
(** Accessor functions for video_response *)
+
val video_response_total : video_response -> int
+
val video_response_data : video_response -> video list
+
val video_response_unknown : video_response -> Jsont.json
+
+
(** RFC3339 timestamp handling *)
+
module Rfc3339 : sig
+
val parse : string -> Ptime.t option
+
val format : Ptime.t -> string
+
val pp : Format.formatter -> Ptime.t -> unit
+
val jsont : Ptime.t Jsont.t
+
end
+
+
(** Parse a single video from JSON string *)
+
val parse_video_string : string -> video
+
+
(** Parse a PeerTube video response from JSON string *)
+
val parse_video_response_string : string -> video_response
(** Fetch videos from a PeerTube instance channel with pagination support
@param client The PeerTube client
-1
stack/peertubee/peertubee.opam
···
"eio"
"eio_main" {>= "1.0"}
"requests"
-
"ezjsonm"
"fmt"
"ptime"
"odoc" {with-doc}
+1 -1
stack/requests/bin/dune
···
(executables
(public_names ocurl)
(names ocurl)
-
(libraries requests eio_main cmdliner logs logs.cli logs.fmt fmt.cli fmt.tty yojson))
+
(libraries requests eio_main cmdliner logs logs.cli logs.fmt fmt.cli fmt.tty jsont jsont.bytesrw))
+12 -3
stack/requests/bin/ocurl.ml
···
if String.length body_str > 0 &&
(body_str.[0] = '{' || body_str.[0] = '[') then
try
-
let json = Yojson.Safe.from_string body_str in
-
if not quiet then Fmt.pr "[%s]:@." url_str;
-
Fmt.pr "%a@." (Yojson.Safe.pretty_print ~std:true) json
+
match Jsont_bytesrw.decode_string' Jsont.json body_str with
+
| Ok json ->
+
(match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Jsont.json json with
+
| Ok pretty ->
+
if not quiet then Fmt.pr "[%s]:@." url_str;
+
print_string pretty
+
| Error _ ->
+
if not quiet then Fmt.pr "[%s]:@." url_str;
+
print_string body_str)
+
| Error _ ->
+
if not quiet then Fmt.pr "[%s]:@." url_str;
+
print_string body_str
with _ ->
if not quiet then Fmt.pr "[%s]:@." url_str;
print_string body_str
-1
stack/requests/dune-project
···
ca-certs
mirage-crypto-rng-eio
uri
-
yojson
digestif
base64
logs))
+28 -150
stack/requests/lib/body.ml
···
(Eio.Path.native_exn file) (Mime.to_string mime));
File { file; mime }
-
type json =
-
[ `Null | `Bool of bool | `Float of float | `String of string
-
| `A of json list | `O of (string * json) list ]
-
-
let json json_value =
-
(* Encode json value to a JSON string *)
-
let buffer = Buffer.create 1024 in
-
let encoder = Jsonm.encoder ~minify:true (`Buffer buffer) in
-
-
let enc e l =
-
match Jsonm.encode e (`Lexeme l) with
-
| `Ok -> ()
-
| `Partial -> failwith "Unexpected partial with buffer destination"
+
(* For simple JSON encoding, we just take a Jsont.json value and encode it *)
+
let json (json_value : Jsont.json) =
+
let content = match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value with
+
| Ok s -> s
+
| Error e ->
+
let msg = Jsont.Error.to_string e in
+
failwith (Printf.sprintf "Failed to encode JSON: %s" msg)
in
+
String { content; mime = Mime.json }
-
let rec encode_value v k e =
-
match v with
-
| `A vs -> encode_array vs k e
-
| `O ms -> encode_object ms k e
-
| `Null | `Bool _ | `Float _ | `String _ as v -> enc e v; k e
-
and encode_array vs k e =
-
enc e `As;
-
encode_array_values vs k e
-
and encode_array_values vs k e =
-
match vs with
-
| v :: vs' -> encode_value v (encode_array_values vs' k) e
-
| [] -> enc e `Ae; k e
-
and encode_object ms k e =
-
enc e `Os;
-
encode_object_members ms k e
-
and encode_object_members ms k e =
-
match ms with
-
| (n, v) :: ms' ->
-
enc e (`Name n);
-
encode_value v (encode_object_members ms' k) e
-
| [] -> enc e `Oe; k e
-
in
-
-
let finish e =
-
match Jsonm.encode e `End with
-
| `Ok -> ()
-
| `Partial -> failwith "Unexpected partial at end"
-
in
-
-
encode_value json_value finish encoder;
-
-
String { content = Buffer.contents buffer; mime = Mime.json }
-
+
(* JSON streaming using jsont - we encode the value to string and stream it *)
module Json_stream_source = struct
-
type encode_state =
-
| Ready (* Ready to encode new lexemes *)
-
| NeedAwait (* Need to send `Await after previous `Partial *)
-
| Finished (* All done *)
-
type t = {
-
encoder : Jsonm.encoder;
-
mutable buffer : bytes;
-
mutable buffer_offset : int;
-
mutable buffer_len : int;
-
mutable pending_lexemes : Jsonm.lexeme Queue.t;
-
mutable encode_state : encode_state;
-
mutable end_signaled : bool;
-
writer : (Jsonm.lexeme -> unit) -> unit;
+
mutable content : string;
+
mutable offset : int;
}
-
let rec single_read t dst =
-
if t.encode_state = Finished && t.buffer_offset >= t.buffer_len then
+
let single_read t dst =
+
if t.offset >= String.length t.content then
raise End_of_file
-
else if t.buffer_offset < t.buffer_len then begin
-
(* We have data in buffer to copy *)
-
let available = t.buffer_len - t.buffer_offset in
+
else begin
+
let available = String.length t.content - t.offset in
let to_copy = min (Cstruct.length dst) available in
-
Cstruct.blit_from_bytes t.buffer t.buffer_offset dst 0 to_copy;
-
t.buffer_offset <- t.buffer_offset + to_copy;
+
Cstruct.blit_from_string t.content t.offset dst 0 to_copy;
+
t.offset <- t.offset + to_copy;
to_copy
-
end else begin
-
(* Buffer empty, need to generate more data *)
-
t.buffer_offset <- 0;
-
t.buffer_len <- 0;
-
Jsonm.Manual.dst t.encoder t.buffer 0 (Bytes.length t.buffer);
-
-
let rec process_encoding () =
-
match t.encode_state with
-
| NeedAwait ->
-
(* Send `Await after previous `Partial *)
-
(match Jsonm.encode t.encoder `Await with
-
| `Ok ->
-
t.encode_state <- Ready;
-
process_encoding ()
-
| `Partial ->
-
t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
-
| Ready when not (Queue.is_empty t.pending_lexemes) ->
-
(* Encode next lexeme *)
-
let lexeme = Queue.take t.pending_lexemes in
-
(match Jsonm.encode t.encoder (`Lexeme lexeme) with
-
| `Ok ->
-
(* Successfully encoded, continue with next *)
-
process_encoding ()
-
| `Partial ->
-
(* Buffer full, need to flush and await
-
Note: The lexeme is partially encoded in the encoder's internal state,
-
we don't need to re-queue it. After `Await, the encoder continues. *)
-
t.encode_state <- NeedAwait;
-
t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
-
| Ready when Queue.is_empty t.pending_lexemes && not t.end_signaled ->
-
(* All lexemes done, signal end *)
-
t.end_signaled <- true;
-
(match Jsonm.encode t.encoder `End with
-
| `Ok ->
-
t.encode_state <- Finished;
-
t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder
-
| `Partial ->
-
t.encode_state <- NeedAwait;
-
t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
-
| Ready when t.end_signaled ->
-
(* Continue trying to finish *)
-
(match Jsonm.encode t.encoder `End with
-
| `Ok ->
-
t.encode_state <- Finished;
-
t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder
-
| `Partial ->
-
t.encode_state <- NeedAwait;
-
t.buffer_len <- Bytes.length t.buffer - Jsonm.Manual.dst_rem t.encoder)
-
| Finished ->
-
(* All done *)
-
()
-
| _ -> ()
-
in
-
process_encoding ();
-
-
if t.buffer_len > 0 then
-
single_read t dst
-
else if t.encode_state = Finished then
-
raise End_of_file
-
else
-
(* This shouldn't happen - we should always produce some data or be finished *)
-
raise End_of_file
end
let read_methods = []
end
-
let json_stream_source_create writer =
-
let buffer_size = 4096 in
-
let buffer = Bytes.create buffer_size in
-
let encoder = Jsonm.encoder ~minify:true (`Manual) in
-
let pending_lexemes = Queue.create () in
-
-
(* Call the writer to populate the queue *)
-
let encode_lexeme lexeme = Queue.add lexeme pending_lexemes in
-
writer encode_lexeme;
-
-
let t = {
-
Json_stream_source.encoder;
-
buffer;
-
buffer_offset = 0;
-
buffer_len = 0;
-
pending_lexemes;
-
encode_state = Ready;
-
end_signaled = false;
-
writer;
-
} in
+
let json_stream_source_create json_value =
+
(* Encode the entire JSON value to string with minified format *)
+
let content = match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json json_value with
+
| Ok s -> s
+
| Error e ->
+
let msg = Jsont.Error.to_string e in
+
failwith (Printf.sprintf "Failed to encode JSON stream: %s" msg)
+
in
+
let t = { Json_stream_source.content; offset = 0 } in
let ops = Eio.Flow.Pi.source (module Json_stream_source) in
Eio.Resource.T (t, ops)
-
let json_stream writer =
-
let source = json_stream_source_create writer in
+
let json_stream json_value =
+
let source = json_stream_source_create json_value in
Stream { source; mime = Mime.json; length = None }
let text content =
+14 -31
stack/requests/lib/body.mli
···
(** {1 Convenience Constructors} *)
-
type json =
-
[ `Null | `Bool of bool | `Float of float | `String of string
-
| `A of json list | `O of (string * json) list ]
-
(** JSON value representation, compatible with Jsonm's json type. *)
-
-
val json : json -> t
-
(** [json value] creates a JSON body from a json value.
+
val json : Jsont.json -> t
+
(** [json value] creates a JSON body from a Jsont.json value.
The value is encoded to a JSON string with Content-Type: application/json.
Example:
{[
-
let body = Body.json (`O [
-
("status", `String "success");
-
("count", `Float 42.);
-
("items", `A [`String "first"; `String "second"])
-
])
+
let body = Body.json (Jsont.Object ([
+
("status", Jsont.String "success");
+
("count", Jsont.Number 42.);
+
("items", Jsont.Array ([Jsont.String "first"; Jsont.String "second"], Jsont.Meta.none))
+
], Jsont.Meta.none))
]}
*)
-
val json_stream : ((Jsonm.lexeme -> unit) -> unit) -> t
-
(** [json_stream writer] creates a streaming JSON body using jsonm.
-
The [writer] function is called with a callback that accepts jsonm lexemes
-
to encode. The body will be streamed as the lexemes are produced.
+
val json_stream : Jsont.json -> t
+
(** [json_stream json_value] creates a streaming JSON body from a Jsont.json value.
+
The JSON value will be encoded to a minified JSON string and streamed.
Example:
{[
-
let body = Body.json_stream (fun encode ->
-
encode `Os; (* Start object *)
-
encode (`Name "users");
-
encode `As; (* Start array *)
-
List.iter (fun user ->
-
encode `Os;
-
encode (`Name "id");
-
encode (`Float (float_of_int user.id));
-
encode (`Name "name");
-
encode (`String user.name);
-
encode `Oe (* End object *)
-
) users;
-
encode `Ae; (* End array *)
-
encode `Oe (* End object *)
-
)
+
let large_data = Jsont.Object ([
+
("users", Jsont.Array ([...], Jsont.Meta.none))
+
], Jsont.Meta.none) in
+
let body = Body.json_stream large_data
]}
*)
+98 -30
stack/requests/lib/cache.ml
···
) parts
| None -> None
+
(* JSON codec for cache metadata *)
+
module Metadata = struct
+
type t = {
+
status_code : int;
+
headers : (string * string) list;
+
}
+
+
let make status_code headers = { status_code; headers }
+
let status_code t = t.status_code
+
let headers t = t.headers
+
+
let t_jsont =
+
let header_pair_jsont =
+
let dec x y = (x, y) in
+
let enc (x, y) i = if i = 0 then x else y in
+
Jsont.t2 ~dec ~enc Jsont.string
+
in
+
Jsont.Object.map ~kind:"CacheMetadata" make
+
|> Jsont.Object.mem "status_code" Jsont.int ~enc:status_code
+
|> Jsont.Object.mem "headers" (Jsont.list header_pair_jsont) ~enc:headers
+
|> Jsont.Object.finish
+
end
+
let serialize_metadata ~status ~headers =
let status_code = Cohttp.Code.code_of_status status in
let headers_assoc = Cohttp.Header.to_list headers in
-
let json = `Assoc [
-
("status_code", `Int status_code);
-
("headers", `Assoc (List.map (fun (k, v) -> (k, `String v)) headers_assoc));
-
] in
-
Yojson.Basic.to_string json
+
let metadata = Metadata.make status_code headers_assoc in
+
match Jsont_bytesrw.encode_string' Metadata.t_jsont metadata with
+
| Ok s -> s
+
| Error e -> failwith (Fmt.str "Failed to serialize metadata: %s" (Jsont.Error.to_string e))
let deserialize_metadata json_str =
try
-
let open Yojson.Basic.Util in
-
let json = Yojson.Basic.from_string json_str in
-
let status_code = json |> member "status_code" |> to_int in
-
let status = Cohttp.Code.status_of_code status_code in
-
let headers_json = json |> member "headers" |> to_assoc in
-
let headers = headers_json
-
|> List.map (fun (k, v) -> (k, to_string v))
-
|> Cohttp.Header.of_list in
-
Some (status, headers)
+
match Jsont_bytesrw.decode_string' Metadata.t_jsont json_str with
+
| Ok metadata ->
+
let status = Cohttp.Code.status_of_code (Metadata.status_code metadata) in
+
let headers = Cohttp.Header.of_list (Metadata.headers metadata) in
+
Some (status, headers)
+
| Error _ -> None
with _ -> None
let get t ~method_ ~url ~headers =
···
| None -> ());
Hashtbl.clear t.memory_cache
+
module Stats = struct
+
type cacheio_stats = {
+
total_entries : int;
+
total_bytes : int;
+
expired_entries : int;
+
pinned_entries : int;
+
temporary_entries : int;
+
}
+
+
type t = {
+
memory_cache_entries : int;
+
cache_backend : string;
+
enabled : bool;
+
cache_get_requests : bool;
+
cache_range_requests : bool;
+
cacheio_stats : cacheio_stats option;
+
}
+
+
let make_cacheio_stats total_entries total_bytes expired_entries pinned_entries temporary_entries =
+
{ total_entries; total_bytes; expired_entries; pinned_entries; temporary_entries }
+
+
let make memory_cache_entries cache_backend enabled cache_get_requests cache_range_requests cacheio_stats =
+
{ memory_cache_entries; cache_backend; enabled; cache_get_requests; cache_range_requests; cacheio_stats }
+
+
let cacheio_stats_jsont =
+
Jsont.Object.map ~kind:"CacheioStats" make_cacheio_stats
+
|> Jsont.Object.mem "total_entries" Jsont.int ~enc:(fun t -> t.total_entries)
+
|> Jsont.Object.mem "total_bytes" Jsont.int ~enc:(fun t -> t.total_bytes)
+
|> Jsont.Object.mem "expired_entries" Jsont.int ~enc:(fun t -> t.expired_entries)
+
|> Jsont.Object.mem "pinned_entries" Jsont.int ~enc:(fun t -> t.pinned_entries)
+
|> Jsont.Object.mem "temporary_entries" Jsont.int ~enc:(fun t -> t.temporary_entries)
+
|> Jsont.Object.finish
+
+
let t_jsont =
+
Jsont.Object.map ~kind:"CacheStats" make
+
|> Jsont.Object.mem "memory_cache_entries" Jsont.int ~enc:(fun t -> t.memory_cache_entries)
+
|> Jsont.Object.mem "cache_backend" Jsont.string ~enc:(fun t -> t.cache_backend)
+
|> Jsont.Object.mem "enabled" Jsont.bool ~enc:(fun t -> t.enabled)
+
|> Jsont.Object.mem "cache_get_requests" Jsont.bool ~enc:(fun t -> t.cache_get_requests)
+
|> Jsont.Object.mem "cache_range_requests" Jsont.bool ~enc:(fun t -> t.cache_range_requests)
+
|> Jsont.Object.opt_mem "cacheio_stats" cacheio_stats_jsont ~enc:(fun t -> t.cacheio_stats)
+
|> Jsont.Object.finish
+
+
let to_string t =
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent t_jsont t with
+
| Ok s -> s
+
| Error e ->
+
let msg = Jsont.Error.to_string e in
+
failwith (Printf.sprintf "Failed to encode stats: %s" msg)
+
end
+
let stats t =
let cacheio_stats =
match t.cacheio with
| Some cache ->
let stats = Cacheio.stats cache in
-
`Assoc [
-
("total_entries", `Int (Cacheio.Stats.entry_count stats));
-
("total_bytes", `Int (Int64.to_int (Cacheio.Stats.total_size stats)));
-
("expired_entries", `Int (Cacheio.Stats.expired_count stats));
-
("pinned_entries", `Int (Cacheio.Stats.pinned_count stats));
-
("temporary_entries", `Int (Cacheio.Stats.temporary_count stats));
-
]
-
| None -> `Assoc []
+
Some (Stats.make_cacheio_stats
+
(Cacheio.Stats.entry_count stats)
+
(Int64.to_int (Cacheio.Stats.total_size stats))
+
(Cacheio.Stats.expired_count stats)
+
(Cacheio.Stats.pinned_count stats)
+
(Cacheio.Stats.temporary_count stats))
+
| None -> None
in
-
`Assoc [
-
("memory_cache_entries", `Int (Hashtbl.length t.memory_cache));
-
("cache_backend", `String (if Option.is_some t.cacheio then "cacheio" else "memory"));
-
("enabled", `Bool t.enabled);
-
("cache_get_requests", `Bool t.cache_get_requests);
-
("cache_range_requests", `Bool t.cache_range_requests);
-
("cacheio_stats", cacheio_stats);
-
]
+
Stats.make
+
(Hashtbl.length t.memory_cache)
+
(if Option.is_some t.cacheio then "cacheio" else "memory")
+
t.enabled
+
t.cache_get_requests
+
t.cache_range_requests
+
cacheio_stats
+2 -3
stack/requests/lib/dune
···
cohttp
cohttp-eio
uri
-
jsonm
-
yojson
-
ezjsonm
+
jsont
+
jsont.bytesrw
base64
cacheio
cookeio
-1
stack/requests/requests.opam
···
"ca-certs"
"mirage-crypto-rng-eio"
"uri"
-
"yojson"
"digestif"
"base64"
"logs"
-18
stack/requests/test/dune
···
-
(test
-
(name test_requests)
-
(libraries
-
requests
-
alcotest
-
eio
-
eio_main
-
cohttp
-
cohttp-eio
-
uri
-
yojson
-
logs
-
str)
-
(deps
-
(package requests)))(executable
-
(name test_connection_pool)
-
(modules test_connection_pool)
-
(libraries requests eio_main logs logs.fmt conpool))
-52
stack/requests/test/test_connection_pool.ml
···
-
(** Test stateless One API - each request opens a fresh connection *)
-
-
open Eio.Std
-
-
let test_one_stateless () =
-
(* Initialize RNG for TLS *)
-
Mirage_crypto_rng_unix.use_default ();
-
-
Eio_main.run @@ fun env ->
-
Switch.run @@ fun sw ->
-
-
(* Configure logging to see One request activity *)
-
Logs.set_reporter (Logs_fmt.reporter ());
-
Logs.set_level (Some Logs.Info);
-
Logs.Src.set_level Requests.One.src (Some Logs.Info);
-
-
traceln "=== Testing One Stateless API ===\n";
-
traceln "The One API creates fresh connections for each request (no pooling)\n";
-
-
(* Make multiple requests to the same host using stateless One API *)
-
let start_time = Unix.gettimeofday () in
-
-
for i = 1 to 10 do
-
traceln "Request %d:" i;
-
let response = Requests.One.get ~sw
-
~clock:env#clock ~net:env#net
-
"http://example.com"
-
in
-
-
traceln " Status: %d" (Requests.Response.status_code response);
-
traceln " Content-Length: %s"
-
(match Requests.Response.content_length response with
-
| Some len -> Int64.to_string len
-
| None -> "unknown");
-
-
(* Connection is fresh for each request - no pooling *)
-
traceln ""
-
done;
-
-
let elapsed = Unix.gettimeofday () -. start_time in
-
traceln "All 10 requests completed in %.2f seconds" elapsed;
-
traceln "Average: %.2f seconds per request" (elapsed /. 10.0);
-
-
traceln "\n=== Test completed successfully ==="
-
-
let () =
-
try
-
test_one_stateless ()
-
with e ->
-
traceln "Test failed with exception: %s" (Printexc.to_string e);
-
Printexc.print_backtrace stdout;
-
exit 1
-899
stack/requests/test/test_requests.ml
···
-
open Eio_main
-
-
let port = ref 8088
-
-
let get_free_port () =
-
let p = !port in
-
incr port;
-
p
-
-
let string_contains s sub =
-
try
-
let _ = Str.search_forward (Str.regexp_string sub) s 0 in
-
true
-
with Not_found -> false
-
-
module Test_server = struct
-
open Cohttp_eio
-
-
let make_server ~port handler env =
-
let server_socket =
-
Eio.Net.listen env#net ~sw:env#sw ~backlog:128 ~reuse_addr:true
-
(`Tcp (Eio.Net.Ipaddr.V4.loopback, port))
-
in
-
let callback _conn req body =
-
let (resp, body_content) = handler ~request:req ~body in
-
Server.respond_string () ~status:(Http.Response.status resp)
-
~headers:(Http.Response.headers resp)
-
~body:body_content
-
in
-
let server = Server.make ~callback () in
-
Server.run server_socket server ~on_error:(fun exn ->
-
Logs.err (fun m -> m "Server error: %s" (Printexc.to_string exn))
-
)
-
-
let echo_handler ~request ~body =
-
let uri = Http.Request.resource request in
-
let meth = Http.Request.meth request in
-
let headers = Http.Request.headers request in
-
let body_str = Eio.Flow.read_all body in
-
-
let response_body =
-
`Assoc [
-
"method", `String (Cohttp.Code.string_of_method meth);
-
"uri", `String uri;
-
"headers", `Assoc (
-
Cohttp.Header.to_lines headers
-
|> List.map (fun line ->
-
match String.split_on_char ':' line with
-
| [k; v] -> (String.trim k, `String (String.trim v))
-
| _ -> ("", `String line)
-
)
-
);
-
"body", `String body_str;
-
]
-
|> Yojson.Basic.to_string
-
in
-
-
let resp = Http.Response.make ~status:`OK () in
-
let resp_headers = Cohttp.Header.add_unless_exists
-
(Http.Response.headers resp) "content-type" "application/json"
-
in
-
({ resp with headers = resp_headers }, response_body)
-
-
let status_handler status_code ~request:_ ~body:_ =
-
let status = Cohttp.Code.status_of_code status_code in
-
let resp = Http.Response.make ~status () in
-
(resp, "")
-
-
let redirect_handler target_path ~request:_ ~body:_ =
-
let resp = Http.Response.make ~status:`Moved_permanently () in
-
let headers = Cohttp.Header.add
-
(Http.Response.headers resp) "location" target_path
-
in
-
({ resp with headers }, "")
-
-
let cookie_handler ~request ~body:_ =
-
let headers = Http.Request.headers request in
-
let cookies =
-
match Cohttp.Header.get headers "cookie" with
-
| Some cookie_str -> cookie_str
-
| None -> "no cookies"
-
in
-
-
let resp = Http.Response.make ~status:`OK () in
-
let resp_headers =
-
Http.Response.headers resp
-
|> (fun h -> Cohttp.Header.add h "set-cookie" "test_cookie=test_value; Path=/")
-
|> (fun h -> Cohttp.Header.add h "set-cookie" "session=abc123; Path=/; HttpOnly")
-
in
-
({ resp with headers = resp_headers },
-
cookies)
-
-
let auth_handler ~request ~body:_ =
-
let headers = Http.Request.headers request in
-
let auth_result =
-
match Cohttp.Header.get headers "authorization" with
-
| Some auth ->
-
if String.starts_with ~prefix:"Bearer " auth then
-
let token = String.sub auth 7 (String.length auth - 7) in
-
if token = "valid_token" then "authorized"
-
else "invalid token"
-
else if String.starts_with ~prefix:"Basic " auth then
-
"basic auth received"
-
else "unknown auth"
-
| None -> "no auth"
-
in
-
-
let status =
-
if auth_result = "authorized" || auth_result = "basic auth received"
-
then `OK
-
else `Unauthorized
-
in
-
let resp = Http.Response.make ~status () in
-
(resp, auth_result)
-
-
let json_handler ~request:_ ~body =
-
let body_str = Eio.Flow.read_all body in
-
let json =
-
try
-
let parsed = Yojson.Basic.from_string body_str in
-
`Assoc [
-
"received", parsed;
-
"echo", `Bool true;
-
]
-
with _ ->
-
`Assoc [
-
"error", `String "invalid json";
-
"received", `String body_str;
-
]
-
in
-
-
let resp = Http.Response.make ~status:`OK () in
-
let resp_headers = Cohttp.Header.add_unless_exists
-
(Http.Response.headers resp) "content-type" "application/json"
-
in
-
({ resp with headers = resp_headers },
-
Yojson.Basic.to_string json)
-
-
let timeout_handler clock delay ~request:_ ~body:_ =
-
Eio.Time.sleep clock delay;
-
let resp = Http.Response.make ~status:`OK () in
-
(resp,"delayed response")
-
-
let chunked_handler _clock chunks ~request:_ ~body:_ =
-
let resp = Http.Response.make ~status:`OK () in
-
let body_str = String.concat "" chunks in
-
(resp,body_str)
-
-
let large_response_handler size ~request:_ ~body:_ =
-
let data = String.make size 'X' in
-
let resp = Http.Response.make ~status:`OK () in
-
(resp,data)
-
-
let multipart_handler ~request ~body =
-
let headers = Http.Request.headers request in
-
let content_type = Cohttp.Header.get headers "content-type" in
-
let body_str = Eio.Flow.read_all body in
-
-
let result =
-
match content_type with
-
| Some ct when String.starts_with ~prefix:"multipart/form-data" ct ->
-
Printf.sprintf "Multipart received: %d bytes" (String.length body_str)
-
| _ -> "Not multipart"
-
in
-
-
let resp = Http.Response.make ~status:`OK () in
-
(resp,result)
-
-
let router clock ~request ~body =
-
let uri = Http.Request.resource request in
-
match uri with
-
| "/" | "/echo" -> echo_handler ~request ~body
-
| "/status/200" -> status_handler 200 ~request ~body
-
| "/status/404" -> status_handler 404 ~request ~body
-
| "/status/500" -> status_handler 500 ~request ~body
-
| "/redirect" -> redirect_handler "/redirected" ~request ~body
-
| "/redirected" ->
-
let resp = Http.Response.make ~status:`OK () in
-
(resp,"redirect successful")
-
| "/cookies" -> cookie_handler ~request ~body
-
| "/auth" -> auth_handler ~request ~body
-
| "/json" -> json_handler ~request ~body
-
| "/timeout" -> timeout_handler clock 2.0 ~request ~body
-
| "/chunked" ->
-
chunked_handler clock ["chunk1"; "chunk2"; "chunk3"] ~request ~body
-
| "/large" -> large_response_handler 10000 ~request ~body
-
| "/multipart" -> multipart_handler ~request ~body
-
| _ -> status_handler 404 ~request ~body
-
-
let start_server ~port env =
-
Eio.Fiber.fork ~sw:env#sw (fun () ->
-
make_server ~port (router env#clock) env
-
);
-
Eio.Time.sleep env#clock 0.1
-
end
-
-
let test_get_request () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let response = Requests.get req (base_url ^ "/echo") in
-
-
Alcotest.(check int) "GET status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let method_str =
-
json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
-
in
-
-
Alcotest.(check string) "GET method" "GET" method_str
-
-
let test_post_request () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let body = Requests.Body.text "test post data" in
-
let response = Requests.post req ~body (base_url ^ "/echo") in
-
-
Alcotest.(check int) "POST status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let received_body =
-
json |> Yojson.Basic.Util.member "body" |> Yojson.Basic.Util.to_string
-
in
-
-
Alcotest.(check string) "POST body" "test post data" received_body
-
-
let test_put_request () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let body = Requests.Body.text "put data" in
-
let response = Requests.put req ~body (base_url ^ "/echo") in
-
-
Alcotest.(check int) "PUT status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let method_str =
-
json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
-
in
-
-
Alcotest.(check string) "PUT method" "PUT" method_str
-
-
let test_delete_request () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let response = Requests.delete req (base_url ^ "/echo") in
-
-
Alcotest.(check int) "DELETE status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let method_str =
-
json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
-
in
-
-
Alcotest.(check string) "DELETE method" "DELETE" method_str
-
-
let test_patch_request () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let body = Requests.Body.of_string Requests.Mime.json {|{"patch": "data"}|} in
-
let response = Requests.patch req ~body (base_url ^ "/echo") in
-
-
Alcotest.(check int) "PATCH status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let method_str =
-
json |> Yojson.Basic.Util.member "method" |> Yojson.Basic.Util.to_string
-
in
-
-
Alcotest.(check string) "PATCH method" "PATCH" method_str
-
-
let test_head_request () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let response = Requests.head req (base_url ^ "/echo") in
-
-
Alcotest.(check int) "HEAD status" 200 (Requests.Response.status_code response)
-
-
let test_options_request () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let response = Requests.options req (base_url ^ "/echo") in
-
-
Alcotest.(check int) "OPTIONS status" 200 (Requests.Response.status_code response)
-
-
let test_custom_headers () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let headers =
-
Requests.Headers.empty
-
|> Requests.Headers.set "X-Custom-Header" "custom-value"
-
|> Requests.Headers.set "User-Agent" "test-agent"
-
in
-
let response = Requests.get req ~headers (base_url ^ "/echo") in
-
-
Alcotest.(check int) "Headers status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let headers_obj = json |> Yojson.Basic.Util.member "headers" in
-
-
let custom_header =
-
headers_obj
-
|> Yojson.Basic.Util.member "x-custom-header"
-
|> Yojson.Basic.Util.to_string_option
-
|> Option.value ~default:""
-
in
-
-
Alcotest.(check string) "Custom header" "custom-value" custom_header
-
-
let test_query_params () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let params = [("key1", "value1"); ("key2", "value2")] in
-
let response = Requests.get req ~params (base_url ^ "/echo") in
-
-
Alcotest.(check int) "Query params status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let uri = json |> Yojson.Basic.Util.member "uri" |> Yojson.Basic.Util.to_string in
-
-
Alcotest.(check bool) "Query params present" true
-
(string_contains uri "key1=value1" && string_contains uri "key2=value2")
-
-
let test_json_body () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let json_data = {|{"name": "test", "value": 42}|} in
-
let body = Requests.Body.of_string Requests.Mime.json json_data in
-
let response = Requests.post req ~body (base_url ^ "/json") in
-
-
Alcotest.(check int) "JSON body status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let received = json |> Yojson.Basic.Util.member "received" in
-
let name = received |> Yojson.Basic.Util.member "name" |> Yojson.Basic.Util.to_string in
-
-
Alcotest.(check string) "JSON field" "test" name
-
-
let test_form_data () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let form_data = [("field1", "value1"); ("field2", "value2")] in
-
let body = Requests.Body.form form_data in
-
let response = Requests.post req ~body (base_url ^ "/echo") in
-
-
Alcotest.(check int) "Form data status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let received_body =
-
json |> Yojson.Basic.Util.member "body" |> Yojson.Basic.Util.to_string
-
in
-
-
Alcotest.(check bool) "Form data encoded" true
-
(string_contains received_body "field1=value1" &&
-
string_contains received_body "field2=value2")
-
-
let test_status_codes () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
-
let resp_200 = Requests.get req (base_url ^ "/status/200") in
-
Alcotest.(check int) "Status 200" 200 (Requests.Response.status_code resp_200);
-
-
let resp_404 = Requests.get req (base_url ^ "/status/404") in
-
Alcotest.(check int) "Status 404" 404 (Requests.Response.status_code resp_404);
-
-
let resp_500 = Requests.get req (base_url ^ "/status/500") in
-
Alcotest.(check int) "Status 500" 500 (Requests.Response.status_code resp_500)
-
-
let test_redirects () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw ~follow_redirects:true env in
-
let response = Requests.get req (base_url ^ "/redirect") in
-
-
Alcotest.(check int) "Redirect followed" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
Alcotest.(check string) "Redirect result" "redirect successful" body_str
-
-
let test_no_redirect () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let response = Requests.request req ~follow_redirects:false ~method_:`GET (base_url ^ "/redirect") in
-
-
Alcotest.(check int) "Redirect not followed" 301
-
(Requests.Response.status_code response)
-
-
let test_cookies () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
-
let _first_response = Requests.get req (base_url ^ "/cookies") in
-
-
let second_response = Requests.get req (base_url ^ "/cookies") in
-
let body_str = Requests.Response.body second_response |> Eio.Flow.read_all in
-
-
Alcotest.(check bool) "Cookies sent back" true
-
(string_contains body_str "test_cookie=test_value")
-
-
let test_bearer_auth () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let auth = Requests.Auth.bearer ~token:"valid_token" in
-
let response = Requests.get req ~auth (base_url ^ "/auth") in
-
-
Alcotest.(check int) "Bearer auth status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
Alcotest.(check string) "Bearer auth result" "authorized" body_str
-
-
let test_basic_auth () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let auth = Requests.Auth.basic ~username:"user" ~password:"pass" in
-
let response = Requests.get req ~auth (base_url ^ "/auth") in
-
-
Alcotest.(check int) "Basic auth status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
Alcotest.(check string) "Basic auth result" "basic auth received" body_str
-
-
let test_timeout () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let timeout = Requests.Timeout.create ~total:0.5 () in
-
-
let exception_raised =
-
try
-
let _ = Requests.get req ~timeout (base_url ^ "/timeout") in
-
false
-
with _ -> true
-
in
-
-
Alcotest.(check bool) "Timeout triggered" true exception_raised
-
-
let test_concurrent_requests () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
-
let r1 = ref None in
-
let r2 = ref None in
-
let r3 = ref None in
-
-
Eio.Fiber.all [
-
(fun () -> r1 := Some (Requests.get req (base_url ^ "/status/200")));
-
(fun () -> r2 := Some (Requests.get req (base_url ^ "/status/404")));
-
(fun () -> r3 := Some (Requests.get req (base_url ^ "/status/500")));
-
];
-
-
let r1 = Option.get !r1 in
-
let r2 = Option.get !r2 in
-
let r3 = Option.get !r3 in
-
-
Alcotest.(check int) "Concurrent 1" 200 (Requests.Response.status_code r1);
-
Alcotest.(check int) "Concurrent 2" 404 (Requests.Response.status_code r2);
-
Alcotest.(check int) "Concurrent 3" 500 (Requests.Response.status_code r3)
-
-
let test_large_response () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let response = Requests.get req (base_url ^ "/large") in
-
-
Alcotest.(check int) "Large response status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
Alcotest.(check int) "Large response size" 10000 (String.length body_str)
-
-
let test_one_module () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let response = Requests.One.get ~sw
-
~clock:env#clock ~net:env#net
-
(base_url ^ "/echo")
-
in
-
-
Alcotest.(check int) "One module status" 200 (Requests.Response.status_code response)
-
-
let test_multipart () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let parts = [
-
{ Requests.Body.name = "field1";
-
filename = None;
-
content_type = Requests.Mime.text;
-
content = `String "value1" };
-
{ Requests.Body.name = "field2";
-
filename = Some "test.txt";
-
content_type = Requests.Mime.text;
-
content = `String "file content" };
-
] in
-
let body = Requests.Body.multipart parts in
-
let response = Requests.post req ~body (base_url ^ "/multipart") in
-
-
Alcotest.(check int) "Multipart status" 200 (Requests.Response.status_code response);
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
Alcotest.(check bool) "Multipart recognized" true
-
(String.starts_with ~prefix:"Multipart received:" body_str)
-
-
let test_response_headers () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
let response = Requests.get req (base_url ^ "/json") in
-
-
let content_type =
-
Requests.Response.headers response
-
|> Requests.Headers.get "content-type"
-
in
-
-
Alcotest.(check (option string)) "Response content-type"
-
(Some "application/json") content_type
-
-
let test_default_headers () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let default_headers =
-
Requests.Headers.empty
-
|> Requests.Headers.set "X-Default" "default-value"
-
in
-
let req = Requests.create ~sw ~default_headers env in
-
let response = Requests.get req (base_url ^ "/echo") in
-
-
let body_str = Requests.Response.body response |> Eio.Flow.read_all in
-
let json = Yojson.Basic.from_string body_str in
-
let headers_obj = json |> Yojson.Basic.Util.member "headers" in
-
-
let default_header =
-
headers_obj
-
|> Yojson.Basic.Util.member "x-default"
-
|> Yojson.Basic.Util.to_string_option
-
|> Option.value ~default:""
-
in
-
-
Alcotest.(check string) "Default header present" "default-value" default_header
-
-
let test_session_persistence () =
-
run @@ fun env ->
-
Eio.Switch.run @@ fun sw ->
-
let port = get_free_port () in
-
let base_url = Printf.sprintf "http://127.0.0.1:%d" port in
-
-
let test_env = object
-
method clock = env#clock
-
method net = env#net
-
method sw = sw
-
end in
-
Test_server.start_server ~port test_env;
-
-
let req = Requests.create ~sw env in
-
-
let req = Requests.set_default_header req "X-Session" "session-123" in
-
-
let auth = Requests.Auth.bearer ~token:"test_token" in
-
let req = Requests.set_auth req auth in
-
-
let response1 = Requests.get req (base_url ^ "/echo") in
-
let body_str1 = Requests.Response.body response1 |> Eio.Flow.read_all in
-
let json1 = Yojson.Basic.from_string body_str1 in
-
let headers1 = json1 |> Yojson.Basic.Util.member "headers" in
-
-
let session_header =
-
headers1
-
|> Yojson.Basic.Util.member "x-session"
-
|> Yojson.Basic.Util.to_string_option
-
|> Option.value ~default:""
-
in
-
-
Alcotest.(check string) "Session header persisted" "session-123" session_header;
-
-
let req = Requests.remove_default_header req "X-Session" in
-
-
let response2 = Requests.get req (base_url ^ "/echo") in
-
let body_str2 = Requests.Response.body response2 |> Eio.Flow.read_all in
-
let json2 = Yojson.Basic.from_string body_str2 in
-
let headers2 = json2 |> Yojson.Basic.Util.member "headers" in
-
-
let session_header2 =
-
headers2
-
|> Yojson.Basic.Util.member "x-session"
-
|> Yojson.Basic.Util.to_string_option
-
in
-
-
Alcotest.(check (option string)) "Session header removed" None session_header2
-
-
let () =
-
Logs.set_reporter (Logs.format_reporter ());
-
Logs.set_level (Some Logs.Warning);
-
-
let open Alcotest in
-
run "Requests Tests" [
-
"HTTP Methods", [
-
test_case "GET request" `Quick test_get_request;
-
test_case "POST request" `Quick test_post_request;
-
test_case "PUT request" `Quick test_put_request;
-
test_case "DELETE request" `Quick test_delete_request;
-
test_case "PATCH request" `Quick test_patch_request;
-
test_case "HEAD request" `Quick test_head_request;
-
test_case "OPTIONS request" `Quick test_options_request;
-
];
-
"Request Features", [
-
test_case "Custom headers" `Quick test_custom_headers;
-
test_case "Query parameters" `Quick test_query_params;
-
test_case "JSON body" `Quick test_json_body;
-
test_case "Form data" `Quick test_form_data;
-
test_case "Multipart upload" `Quick test_multipart;
-
test_case "Default headers" `Quick test_default_headers;
-
];
-
"Response Handling", [
-
test_case "Status codes" `Quick test_status_codes;
-
test_case "Response headers" `Quick test_response_headers;
-
test_case "Large response" `Quick test_large_response;
-
];
-
"Redirects", [
-
test_case "Follow redirects" `Quick test_redirects;
-
test_case "No follow redirects" `Quick test_no_redirect;
-
];
-
"Authentication", [
-
test_case "Bearer auth" `Quick test_bearer_auth;
-
test_case "Basic auth" `Quick test_basic_auth;
-
];
-
"Session Features", [
-
test_case "Cookies" `Quick test_cookies;
-
test_case "Session persistence" `Quick test_session_persistence;
-
];
-
"Advanced", [
-
test_case "Timeout handling" `Quick test_timeout;
-
test_case "Concurrent requests" `Quick test_concurrent_requests;
-
test_case "One module" `Quick test_one_module;
-
];
-
]
+2 -1
stack/requests_json_api/dune-project
···
dune
requests
eio
-
ezjsonm))
+
jsont
+
bytesrw))
+1 -1
stack/requests_json_api/lib/dune
···
(library
(public_name requests_json_api)
(name requests_json_api)
-
(libraries requests eio ezjsonm))
+
(libraries requests eio jsont jsont.bytesrw))
+83 -15
stack/requests_json_api/lib/requests_json_api.ml
···
(** {1 JSON Helpers} *)
-
let parse_json parser body_str =
-
Ezjsonm.from_string body_str |> parser
+
let parse_json decoder body_str =
+
match Jsont_bytesrw.decode_string' decoder body_str with
+
| Ok v -> v
+
| Error e -> failwith (Fmt.str "JSON parse error: %s" (Jsont.Error.to_string e))
-
let parse_json_result parser body_str =
-
try Ok (parse_json parser body_str)
-
with exn -> Error (Printexc.to_string exn)
+
let parse_json_result decoder body_str =
+
match Jsont_bytesrw.decode_string' decoder body_str with
+
| Ok v -> Ok v
+
| Error e -> Error (Jsont.Error.to_string e)
-
let get_json_exn session url parser =
+
let get_json_exn session url decoder =
let response = Requests.get session url in
let status = Requests.Response.status_code response in
if status < 200 || status >= 300 then
failwith (Printf.sprintf "HTTP %d" status);
-
read_body response |> parse_json parser
+
read_body response |> parse_json decoder
-
let get_json session url parser =
+
let get_json session url decoder =
match get_result session url with
| Ok body ->
-
(match parse_json_result parser body with
+
(match parse_json_result decoder body with
| Ok result -> Ok result
| Error msg -> Error (`Json_error msg))
| Error (status, body) -> Error (`Http (status, body))
-
let post_json session url json_value =
-
let body_str = Ezjsonm.value_to_string json_value in
+
let post_json session url jsont_codec value =
+
let body_str = match Jsont_bytesrw.encode_string' jsont_codec value with
+
| Ok s -> s
+
| Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
+
in
let body = Requests.Body.of_string Requests.Mime.json body_str in
Requests.post session url ~body
-
let post_json_exn session url json_value =
-
let response = post_json session url json_value in
+
let post_json_exn session url jsont_codec value =
+
let response = post_json session url jsont_codec value in
let status = Requests.Response.status_code response in
if status < 200 || status >= 300 then
failwith (Printf.sprintf "HTTP %d" status);
read_body response
-
let post_json_result session url json_value =
+
let post_json_result session url jsont_codec value =
try
-
let response = post_json session url json_value in
+
let response = post_json session url jsont_codec value in
check_2xx response
with exn ->
Error (0, Printexc.to_string exn)
+
+
let post_json_decode_exn session url ~req req_value ~resp =
+
let response = post_json session url req req_value in
+
let status = Requests.Response.status_code response in
+
if status < 200 || status >= 300 then
+
failwith (Printf.sprintf "HTTP %d" status);
+
read_body response |> parse_json resp
+
+
let post_json_decode session url ~req req_value ~resp =
+
try
+
let response = post_json session url req req_value in
+
match check_2xx response with
+
| Ok body ->
+
(match parse_json_result resp body with
+
| Ok result -> Ok result
+
| Error msg -> Error (`Json_error msg))
+
| Error (status, body) -> Error (`Http (status, body))
+
with exn ->
+
Error (`Http (0, Printexc.to_string exn))
+
+
let put_json_exn session url jsont_codec value =
+
let body_str = match Jsont_bytesrw.encode_string' jsont_codec value with
+
| Ok s -> s
+
| Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
+
in
+
let body = Requests.Body.of_string Requests.Mime.json body_str in
+
let response = Requests.put session url ~body in
+
let status = Requests.Response.status_code response in
+
if status < 200 || status >= 300 then
+
failwith (Printf.sprintf "HTTP %d" status);
+
read_body response
+
+
let put_json_decode_exn session url ~req req_value ~resp =
+
let body_str = match Jsont_bytesrw.encode_string' req req_value with
+
| Ok s -> s
+
| Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
+
in
+
let body = Requests.Body.of_string Requests.Mime.json body_str in
+
let response = Requests.put session url ~body in
+
let status = Requests.Response.status_code response in
+
if status < 200 || status >= 300 then
+
failwith (Printf.sprintf "HTTP %d" status);
+
read_body response |> parse_json resp
+
+
let patch_json_exn session url jsont_codec value =
+
let body_str = match Jsont_bytesrw.encode_string' jsont_codec value with
+
| Ok s -> s
+
| Error e -> failwith (Fmt.str "JSON encoding error: %s" (Jsont.Error.to_string e))
+
in
+
let body = Requests.Body.of_string Requests.Mime.json body_str in
+
let response = Requests.patch session url ~body in
+
let status = Requests.Response.status_code response in
+
if status < 200 || status >= 300 then
+
failwith (Printf.sprintf "HTTP %d" status);
+
read_body response
+
+
let delete_json_exn session url =
+
let response = Requests.delete session url in
+
let status = Requests.Response.status_code response in
+
if status < 200 || status >= 300 then
+
failwith (Printf.sprintf "HTTP %d" status);
+
read_body response
(** {1 URL Helpers} *)
+50 -12
stack/requests_json_api/lib/requests_json_api.mli
···
{[
open Requests_json_api
+
(* Define a Jsont codec for your type *)
+
type user = { id : int; name : string }
+
+
let user_jsont =
+
Jsont.Object.map (fun id name -> { id; name })
+
|> Jsont.Object.mem "id" Jsont.int ~enc:(fun u -> u.id)
+
|> Jsont.Object.mem "name" Jsont.string ~enc:(fun u -> u.name)
+
|> Jsont.Object.finish
+
+
let users_jsont = Jsont.list user_jsont
+
let fetch_users session =
-
get_json_exn session (base_url / "users") parse_users
+
get_json_exn session (base_url / "users") users_jsont
]}
*)
(** {1 JSON Request Helpers} *)
-
val get_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> (Ezjsonm.value -> 'a) -> 'a
-
(** [get_json_exn session url parser] makes a GET request, checks status is 2xx,
-
reads and parses JSON body, then applies the parser function.
+
val get_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a
+
(** [get_json_exn session url decoder] makes a GET request, checks status is 2xx,
+
reads and parses JSON body using the provided Jsont decoder.
Raises [Failure] on any error (HTTP, network, or JSON parse). *)
-
val get_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> (Ezjsonm.value -> 'a) ->
+
val get_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t ->
('a, [> `Http of int * string | `Json_error of string]) result
(** Like [get_json_exn] but returns [Result] instead of raising exceptions.
Returns [Ok parsed_value] on success, or [Error] with details on failure. *)
-
val post_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> Ezjsonm.value -> Requests.Response.t
-
(** [post_json session url json_value] creates a JSON request body and POSTs it to the URL.
+
val post_json : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> Requests.Response.t
+
(** [post_json session url codec value] encodes [value] using the Jsont codec and POSTs it to the URL.
Returns the raw response for custom handling. *)
-
val post_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> Ezjsonm.value -> string
+
val post_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> string
(** Like [post_json] but checks status is 2xx and returns the response body as a string.
Raises [Failure] on non-2xx status. *)
-
val post_json_result : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> Ezjsonm.value ->
+
val post_json_result : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a ->
(string, int * string) result
(** Like [post_json_exn] but returns [Result] instead of raising.
[Ok body] on 2xx status, [Error (status, body)] otherwise. *)
+
val post_json_decode_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string ->
+
req:'a Jsont.t -> 'a -> resp:'b Jsont.t -> 'b
+
(** [post_json_decode_exn session url ~req req_value ~resp] encodes [req_value] using the [req] codec,
+
POSTs it to the URL, checks status is 2xx, and decodes the response using the [resp] codec.
+
Raises [Failure] on any error. *)
+
+
val post_json_decode : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string ->
+
req:'a Jsont.t -> 'a -> resp:'b Jsont.t ->
+
('b, [> `Http of int * string | `Json_error of string]) result
+
(** Like [post_json_decode_exn] but returns [Result] instead of raising. *)
+
+
val put_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> string
+
(** [put_json_exn session url codec value] encodes [value] and PUTs it to the URL.
+
Returns response body. Raises [Failure] on non-2xx status. *)
+
+
val put_json_decode_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string ->
+
req:'a Jsont.t -> 'a -> resp:'b Jsont.t -> 'b
+
(** Like [post_json_decode_exn] but uses PUT method. *)
+
+
val patch_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> 'a Jsont.t -> 'a -> string
+
(** [patch_json_exn session url codec value] encodes [value] and PATCHes it to the URL.
+
Returns response body. Raises [Failure] on non-2xx status. *)
+
+
val delete_json_exn : (_ Eio.Time.clock, _ Eio.Net.t) Requests.t -> string -> string
+
(** [delete_json_exn session url] makes a DELETE request.
+
Returns response body. Raises [Failure] on non-2xx status. *)
+
(** {1 JSON Parsing Helpers} *)
-
val parse_json : (Ezjsonm.value -> 'a) -> string -> 'a
-
(** [parse_json parser body_str] parses a JSON string and applies the parser function.
+
val parse_json : 'a Jsont.t -> string -> 'a
+
(** [parse_json decoder body_str] parses a JSON string using the provided Jsont decoder.
Raises exception on parse error. *)
-
val parse_json_result : (Ezjsonm.value -> 'a) -> string -> ('a, string) result
+
val parse_json_result : 'a Jsont.t -> string -> ('a, string) result
(** Like [parse_json] but returns [Result] on parse error instead of raising. *)
(** {1 Low-Level Helpers} *)
+2 -1
stack/requests_json_api/requests_json_api.opam
···
"dune" {>= "3.0"}
"requests"
"eio"
-
"ezjsonm"
+
"jsont"
+
"bytesrw"
"odoc" {with-doc}
]
build: [
+1 -1
stack/river/bin/dune
···
(executable
(public_name river-cli)
(name river_cli)
-
(libraries river cmdliner yojson fmt fmt.tty fmt.cli eio eio_main eiocmd unix ptime syndic xdge))
+
(libraries river cmdliner jsont jsont.bytesrw fmt fmt.tty fmt.cli eio eio_main eiocmd unix ptime syndic xdge))
+36 -38
stack/river/bin/river_cli.ml
···
with Eio.Io (Eio.Fs.E (Already_exists _), _) -> ()
) dirs
-
let user_of_json json =
-
let open Yojson.Safe.Util in
-
try
-
let feeds_json = json |> member "feeds" |> to_list in
-
let feeds = List.map (fun feed ->
-
{ River.name = feed |> member "name" |> to_string;
-
url = feed |> member "url" |> to_string }
-
) feeds_json in
-
Some {
-
username = json |> member "username" |> to_string;
-
fullname = json |> member "fullname" |> to_string;
-
email = json |> member "email" |> to_string_option;
-
feeds;
-
last_synced = json |> member "last_synced" |> to_string_option;
-
}
-
with _ -> None
+
(* JSON codecs for user data *)
+
+
(* Codec for River.source (feed) *)
+
let source_jsont =
+
let make name url = { River.name; url } in
+
Jsont.Object.map ~kind:"Source" make
+
|> Jsont.Object.mem "name" Jsont.string ~enc:(fun s -> s.River.name)
+
|> Jsont.Object.mem "url" Jsont.string ~enc:(fun s -> s.River.url)
+
|> Jsont.Object.finish
+
+
(* Codec for user *)
+
let user_jsont =
+
let make username fullname email feeds last_synced =
+
{ username; fullname; email; feeds; last_synced }
+
in
+
Jsont.Object.map ~kind:"User" make
+
|> Jsont.Object.mem "username" Jsont.string ~enc:(fun u -> u.username)
+
|> Jsont.Object.mem "fullname" Jsont.string ~enc:(fun u -> u.fullname)
+
|> Jsont.Object.opt_mem "email" Jsont.string ~enc:(fun u -> u.email)
+
|> Jsont.Object.mem "feeds" (Jsont.list source_jsont) ~enc:(fun u -> u.feeds)
+
|> Jsont.Object.opt_mem "last_synced" Jsont.string ~enc:(fun u -> u.last_synced)
+
|> Jsont.Object.finish
+
+
let user_of_string s =
+
match Jsont_bytesrw.decode_string' user_jsont s with
+
| Ok user -> Some user
+
| Error err ->
+
Log.err (fun m -> m "Failed to parse user JSON: %s" (Jsont.Error.to_string err));
+
None
+
+
let user_to_string user =
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent user_jsont user with
+
| Ok s -> s
+
| Error err -> failwith ("Failed to encode user: " ^ Jsont.Error.to_string err)
let load_user state username =
let file = user_file state username in
try
let content = Eio.Path.load file in
-
let json = Yojson.Safe.from_string content in
-
user_of_json json
+
user_of_string content
with
| Eio.Io (Eio.Fs.E (Not_found _), _) -> None
| e ->
Log.err (fun m -> m "Error loading user %s: %s" username (Printexc.to_string e));
None
-
let user_to_json user =
-
let feeds_json = List.map (fun feed ->
-
`Assoc [
-
"name", `String feed.River.name;
-
"url", `String feed.River.url;
-
]
-
) user.feeds in
-
`Assoc [
-
"username", `String user.username;
-
"fullname", `String user.fullname;
-
"email", (match user.email with
-
| Some e -> `String e
-
| None -> `Null);
-
"feeds", `List feeds_json;
-
"last_synced", (match user.last_synced with
-
| Some s -> `String s
-
| None -> `Null);
-
]
-
let save_user state user =
let file = user_file state user.username in
-
let json = user_to_json user |> Yojson.Safe.to_string ~std:true in
+
let json = user_to_string user in
Eio.Path.save ~create:(`Or_truncate 0o644) file json
let list_users state =
+1 -2
stack/river/dune-project
···
lambdasoup
uri
(cmdliner (>= 2.0.0))
-
yojson
fmt
xdge
(jsonfeed (>= 1.1.0))
(jsont (>= 0.2.0))
-
bytesrw
+
(jsont.bytesrw (>= 0.2.0))
(odoc :with-doc)))
+1 -1
stack/river/lib/dune
···
(name river)
(public_name river)
(wrapped false)
-
(libraries eio eio_main requests requests_json_api logs str syndic lambdasoup uri ptime jsonfeed jsont bytesrw))
+
(libraries eio eio_main requests requests_json_api logs str syndic lambdasoup uri ptime jsonfeed jsont jsont.bytesrw cacheio xdge))
+264 -189
stack/river/lib/river_store.ml
···
(*
-
* Persistent storage for Atom feed entries using Cacheio
+
* Persistent storage for Atom feed entries using Cacheio and Jsonfeed
*)
let src = Logs.Src.create "river.store" ~doc:"River persistent storage"
···
(* Types *)
-
type stored_entry = {
-
atom_id : string;
-
title : string;
-
link : Uri.t option;
-
published : Ptime.t option;
-
updated : Ptime.t;
-
author_name : string;
-
author_email : string option;
-
content : string;
+
(* Storage metadata that extends Jsonfeed.Item via unknown fields *)
+
type storage_meta = {
feed_url : string;
feed_name : string;
feed_title : string;
stored_at : Ptime.t;
-
tags : string list;
-
summary : string option;
+
}
+
+
(* A stored entry is a Jsonfeed.Item.t with storage metadata in unknown fields *)
+
type stored_entry = {
+
item : Jsonfeed.Item.t;
+
meta : storage_meta;
}
+
+
(* Stored entry accessors *)
+
let entry_item entry = entry.item
+
let entry_feed_url entry = entry.meta.feed_url
+
let entry_feed_name entry = entry.meta.feed_name
+
let entry_feed_title entry = entry.meta.feed_title
+
let entry_stored_at entry = entry.meta.stored_at
type feed_info = {
url : string;
···
let feed_key = make_feed_key feed_url in
feed_key ^ "/meta.json"
-
(* JSON serialization *)
+
(* JSON serialization using Jsonfeed and Jsont *)
-
let entry_to_json entry =
-
`Assoc [
-
"atom_id", `String entry.atom_id;
-
"title", `String entry.title;
-
"link", (match entry.link with
-
| Some u -> `String (Uri.to_string u)
-
| None -> `Null);
-
"published", (match entry.published with
-
| Some t -> `String (Ptime.to_rfc3339 t)
-
| None -> `Null);
-
"updated", `String (Ptime.to_rfc3339 entry.updated);
-
"author_name", `String entry.author_name;
-
"author_email", (match entry.author_email with Some e -> `String e | None -> `Null);
-
"content", `String entry.content;
-
"feed_url", `String entry.feed_url;
-
"feed_name", `String entry.feed_name;
-
"feed_title", `String entry.feed_title;
-
"stored_at", `String (Ptime.to_rfc3339 entry.stored_at);
-
"tags", `List (List.map (fun t -> `String t) entry.tags);
-
"summary", (match entry.summary with Some s -> `String s | None -> `Null);
-
]
+
(* Storage metadata codec - stores feed info and storage timestamp *)
+
let storage_meta_jsont : storage_meta Jsont.t =
+
Jsont.Object.(
+
map ~kind:"StorageMeta" (fun feed_url feed_name feed_title stored_at : storage_meta ->
+
{ feed_url; feed_name; feed_title; stored_at })
+
|> mem "x_river_feed_url" Jsont.string ~enc:(fun m -> m.feed_url)
+
|> mem "x_river_feed_name" Jsont.string ~enc:(fun m -> m.feed_name)
+
|> mem "x_river_feed_title" Jsont.string ~enc:(fun m -> m.feed_title)
+
|> mem "x_river_stored_at" Jsonfeed.Rfc3339.jsont ~enc:(fun m -> m.stored_at)
+
|> finish
+
)
-
let entry_of_json json =
-
let open Yojson.Safe.Util in
-
let parse_time s =
-
match Ptime.of_rfc3339 s with
-
| Ok (t, _, _) -> t
-
| Error _ -> failwith ("Invalid timestamp: " ^ s)
+
(* Codec for feed_info *)
+
let feed_meta_jsont : feed_info Jsont.t =
+
Jsont.Object.(
+
map ~kind:"FeedInfo" (fun url name title last_updated entry_count : feed_info ->
+
{ url; name; title; last_updated; entry_count })
+
|> mem "url" Jsont.string ~enc:(fun (m : feed_info) -> m.url)
+
|> mem "name" Jsont.string ~enc:(fun m -> m.name)
+
|> mem "title" Jsont.string ~enc:(fun m -> m.title)
+
|> mem "last_updated" Jsonfeed.Rfc3339.jsont ~enc:(fun m -> m.last_updated)
+
|> mem "entry_count" Jsont.int ~enc:(fun m -> m.entry_count)
+
|> finish
+
)
+
+
(* Helper to create item with storage metadata in unknown fields *)
+
let merge_storage_meta item meta =
+
let meta_json = Jsont_bytesrw.encode_string' storage_meta_jsont meta
+
|> Result.get_ok in
+
let meta_unknown = Jsont_bytesrw.decode_string' Jsont.json meta_json
+
|> Result.get_ok in
+
Jsonfeed.Item.create
+
~id:(Jsonfeed.Item.id item)
+
~content:(Jsonfeed.Item.content item)
+
?url:(Jsonfeed.Item.url item)
+
?external_url:(Jsonfeed.Item.external_url item)
+
?title:(Jsonfeed.Item.title item)
+
?summary:(Jsonfeed.Item.summary item)
+
?image:(Jsonfeed.Item.image item)
+
?banner_image:(Jsonfeed.Item.banner_image item)
+
?date_published:(Jsonfeed.Item.date_published item)
+
?date_modified:(Jsonfeed.Item.date_modified item)
+
?authors:(Jsonfeed.Item.authors item)
+
?tags:(Jsonfeed.Item.tags item)
+
?language:(Jsonfeed.Item.language item)
+
?attachments:(Jsonfeed.Item.attachments item)
+
?references:(Jsonfeed.Item.references item)
+
~unknown:meta_unknown
+
()
+
+
(* Helper to extract storage metadata from item's unknown fields *)
+
let extract_storage_meta item =
+
let unknown = Jsonfeed.Item.unknown item in
+
let meta_str = Jsont_bytesrw.encode_string' Jsont.json unknown |> Result.get_ok in
+
match Jsont_bytesrw.decode_string' storage_meta_jsont meta_str with
+
| Ok meta -> meta
+
| Error e -> failwith ("Missing storage metadata: " ^ Jsont.Error.to_string e)
+
+
(* Stored entry codec - just wraps Jsonfeed.Item.jsont *)
+
let stored_entry_jsont : stored_entry Jsont.t =
+
let kind = "StoredEntry" in
+
let of_string s =
+
match Jsont_bytesrw.decode_string' Jsonfeed.Item.jsont s with
+
| Ok item -> Ok { item; meta = extract_storage_meta item }
+
| Error e -> Error (Jsont.Error.to_string e)
+
in
+
let enc entry =
+
let item_with_meta = merge_storage_meta entry.item entry.meta in
+
match Jsont_bytesrw.encode_string' Jsonfeed.Item.jsont item_with_meta with
+
| Ok s -> s
+
| Error e -> failwith ("Failed to encode: " ^ Jsont.Error.to_string e)
in
-
{
-
atom_id = json |> member "atom_id" |> to_string;
-
title = json |> member "title" |> to_string;
-
link = json |> member "link" |> to_string_option |> Option.map Uri.of_string;
-
published = json |> member "published" |> to_string_option |> Option.map parse_time;
-
updated = json |> member "updated" |> to_string |> parse_time;
-
author_name = json |> member "author_name" |> to_string;
-
author_email = json |> member "author_email" |> to_string_option;
-
content = json |> member "content" |> to_string;
-
feed_url = json |> member "feed_url" |> to_string;
-
feed_name = json |> member "feed_name" |> to_string;
-
feed_title = json |> member "feed_title" |> to_string;
-
stored_at = json |> member "stored_at" |> to_string |> parse_time;
-
tags = (try json |> member "tags" |> to_list |> List.map to_string with _ -> []);
-
summary = (try json |> member "summary" |> to_string_option with _ -> None);
-
}
+
Jsont.of_of_string ~kind of_string ~enc
+
+
(* Encode/decode functions *)
+
let entry_to_string entry =
+
match Jsont_bytesrw.encode_string' stored_entry_jsont entry with
+
| Ok s -> s
+
| Error err -> failwith ("Failed to encode entry: " ^ Jsont.Error.to_string err)
+
+
let entry_of_string s =
+
match Jsont_bytesrw.decode_string' stored_entry_jsont s with
+
| Ok entry -> entry
+
| Error err -> failwith ("Failed to parse entry: " ^ Jsont.Error.to_string err)
-
let feed_meta_to_json meta =
-
`Assoc [
-
"url", `String meta.url;
-
"name", `String meta.name;
-
"title", `String meta.title;
-
"last_updated", `String (Ptime.to_rfc3339 meta.last_updated);
-
]
+
let feed_meta_to_string meta =
+
match Jsont_bytesrw.encode_string' feed_meta_jsont meta with
+
| Ok s -> s
+
| Error err -> failwith ("Failed to encode feed metadata: " ^ Jsont.Error.to_string err)
-
let feed_meta_of_json json =
-
let open Yojson.Safe.Util in
-
let parse_time s =
-
match Ptime.of_rfc3339 s with
-
| Ok (t, _, _) -> t
-
| Error _ -> failwith ("Invalid timestamp: " ^ s)
-
in
-
{
-
url = json |> member "url" |> to_string;
-
name = json |> member "name" |> to_string;
-
title = json |> member "title" |> to_string;
-
last_updated = json |> member "last_updated" |> to_string |> parse_time;
-
entry_count = 0; (* Will be counted separately *)
-
}
+
let feed_meta_of_string s =
+
match Jsont_bytesrw.decode_string' feed_meta_jsont s with
+
| Ok meta -> meta
+
| Error err -> failwith ("Failed to parse feed metadata: " ^ Jsont.Error.to_string err)
(* Store creation *)
···
Log.info (fun m -> m "Created River store with XDG at %a" Eio.Path.pp base_dir);
{ cache; base_dir }
-
(* Convert Post.t to stored_entry *)
-
let entry_of_post ~feed_url ~feed_name ~feed_title (post : Post.t) =
-
let atom_id = post.id in (* Use the post's unique ID *)
-
let updated = match post.date with
-
| Some d -> d
-
| None -> Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get
+
(* Convert Post.t to Jsonfeed.Item.t *)
+
let item_of_post ~feed_url ~feed_name ~feed_title (post : Post.t) =
+
let content =
+
let html = Soup.to_string post.content in
+
`Html html
in
-
let published = post.date in
-
{
-
atom_id;
-
title = post.title;
-
link = post.link;
-
published;
-
updated;
-
author_name = post.author;
-
author_email = if post.email = "" then None else Some post.email;
-
content = Soup.to_string post.content;
+
let url = Option.map Uri.to_string post.link in
+
let authors =
+
if post.author = "" then None
+
else Some [Jsonfeed.Author.create ~name:post.author ()]
+
in
+
let tags = if post.tags = [] then None else Some post.tags in
+
let item = Jsonfeed.Item.create
+
~id:post.id
+
~content
+
?url
+
?title:(if post.title = "" then None else Some post.title)
+
?summary:post.summary
+
?date_published:post.date
+
?date_modified:post.date
+
?authors
+
?tags
+
()
+
in
+
let meta = {
feed_url;
feed_name;
feed_title;
stored_at = Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get;
-
tags = post.tags;
-
summary = post.summary;
-
}
+
} in
+
{ item; meta }
-
(* Convert Syndic.Atom.entry to stored_entry *)
-
let entry_of_atom ~feed_url ~feed_name ~feed_title (atom_entry : Syndic.Atom.entry) =
+
(* Convert Syndic.Atom.entry to Jsonfeed.Item.t *)
+
let item_of_atom ~feed_url ~feed_name ~feed_title (atom_entry : Syndic.Atom.entry) =
let atom_id = Uri.to_string atom_entry.id in
-
let updated = atom_entry.updated in
-
let published = match atom_entry.published with
+
let date_modified = atom_entry.updated in
+
let date_published = match atom_entry.published with
| Some p -> Some p
| None -> Some atom_entry.updated
in
-
(* Extract author info - Syndic doesn't expose person record fields,
-
so we'll use placeholders and reconstruct via Atom.author later *)
-
let content = match atom_entry.content with
-
| Some (Syndic.Atom.Text s) -> s
-
| Some (Syndic.Atom.Html (_, s)) -> s
+
(* Extract content *)
+
let content_html = match atom_entry.content with
+
| Some (Syndic.Atom.Text s) -> Some s
+
| Some (Syndic.Atom.Html (_, s)) -> Some s
| Some (Syndic.Atom.Xhtml (_, nodes)) ->
let ns_prefix _ = Some "" in
-
String.concat "" (List.map (Syndic.XML.to_string ~ns_prefix) nodes)
-
| Some (Syndic.Atom.Mime _) | Some (Syndic.Atom.Src _) | None ->
-
(match atom_entry.summary with
-
| Some (Syndic.Atom.Text s) -> s
-
| Some (Syndic.Atom.Html (_, s)) -> s
-
| Some (Syndic.Atom.Xhtml (_, nodes)) ->
-
let ns_prefix _ = Some "" in
-
String.concat "" (List.map (Syndic.XML.to_string ~ns_prefix) nodes)
-
| None -> "")
+
Some (String.concat "" (List.map (Syndic.XML.to_string ~ns_prefix) nodes))
+
| Some (Syndic.Atom.Mime _) | Some (Syndic.Atom.Src _) | None -> None
+
in
+
let content_text = match atom_entry.summary with
+
| Some s -> Some (Util.string_of_text_construct s)
+
| None -> None
+
in
+
let content = match content_html, content_text with
+
| Some h, Some t -> `Both (h, t)
+
| Some h, None -> `Html h
+
| None, Some t -> `Text t
+
| None, None -> `Text "" (* Fallback *)
in
-
let link = try
-
Some (List.find (fun l -> l.Syndic.Atom.rel = Syndic.Atom.Alternate) atom_entry.links).href
+
let url = try
+
Some (Uri.to_string (List.find (fun l -> l.Syndic.Atom.rel = Syndic.Atom.Alternate) atom_entry.links).href)
with Not_found ->
match atom_entry.links with
-
| l :: _ -> Some l.href
+
| l :: _ -> Some (Uri.to_string l.href)
| [] -> None
in
-
(* Extract tags from categories *)
-
let tags = List.map (fun cat -> cat.Syndic.Atom.term) atom_entry.categories in
-
(* Extract summary *)
+
let tags =
+
let cat_tags = List.map (fun cat -> cat.Syndic.Atom.term) atom_entry.categories in
+
if cat_tags = [] then None else Some cat_tags
+
in
let summary = match atom_entry.summary with
| Some s -> Some (Util.string_of_text_construct s)
| None -> None
in
-
{
-
atom_id;
-
title = Util.string_of_text_construct atom_entry.title;
-
link;
-
published;
-
updated;
-
author_name = feed_name; (* Use feed name as fallback *)
-
author_email = None;
-
content;
+
let item = Jsonfeed.Item.create
+
~id:atom_id
+
~content
+
?url
+
~title:(Util.string_of_text_construct atom_entry.title)
+
?summary
+
?date_published
+
~date_modified
+
?tags
+
()
+
in
+
let meta = {
feed_url;
feed_name;
feed_title;
stored_at = Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get;
-
tags;
-
summary;
-
}
+
} in
+
{ item; meta }
(* Feed metadata management *)
let update_feed_meta store ~feed_url ~feed_name ~feed_title ~sw:_ =
···
last_updated = Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get;
entry_count = 0;
} in
-
let json = feed_meta_to_json meta |> Yojson.Safe.to_string in
-
let source = Eio.Flow.string_source json in
+
let json_str = feed_meta_to_string meta in
+
let source = Eio.Flow.string_source json_str in
Cacheio.put store.cache ~key ~source ~ttl:None ();
Log.debug (fun m -> m "Updated feed metadata for %s" feed_url)
···
| Some source ->
try
let json_str = Eio.Buf_read.(parse_exn take_all) source ~max_size:Int.max_int in
-
let json = Yojson.Safe.from_string json_str in
-
Some (feed_meta_of_json json)
+
Some (feed_meta_of_string json_str)
with e ->
Log.err (fun m -> m "Failed to parse feed metadata: %s" (Printexc.to_string e));
None
···
(* Entry storage *)
let store_entry store ~feed_url ~feed_name ~feed_title ~post ~sw =
-
let entry = entry_of_post ~feed_url ~feed_name ~feed_title post in
-
let key = make_entry_key feed_url entry.atom_id in
-
let json = entry_to_json entry |> Yojson.Safe.to_string in
-
let source = Eio.Flow.string_source json in
+
let entry = item_of_post ~feed_url ~feed_name ~feed_title post in
+
let key = make_entry_key feed_url (Jsonfeed.Item.id entry.item) in
+
let json_str = entry_to_string entry in
+
let source = Eio.Flow.string_source json_str in
Cacheio.put store.cache ~key ~source ~ttl:None ();
-
Log.debug (fun m -> m "Stored entry %s for feed %s" entry.atom_id feed_url);
+
Log.debug (fun m -> m "Stored entry %s for feed %s" (Jsonfeed.Item.id entry.item) feed_url);
(* Update feed metadata *)
update_feed_meta store ~feed_url ~feed_name ~feed_title ~sw
···
let store_atom_entries store ~feed_url ~feed_name ~feed_title ~entries ~sw =
Log.info (fun m -> m "Storing %d Atom entries for feed %s" (List.length entries) feed_url);
List.iter (fun atom_entry ->
-
let entry = entry_of_atom ~feed_url ~feed_name ~feed_title atom_entry in
-
let key = make_entry_key feed_url entry.atom_id in
-
let json = entry_to_json entry |> Yojson.Safe.to_string in
-
let source = Eio.Flow.string_source json in
+
let entry = item_of_atom ~feed_url ~feed_name ~feed_title atom_entry in
+
let key = make_entry_key feed_url (Jsonfeed.Item.id entry.item) in
+
let json_str = entry_to_string entry in
+
let source = Eio.Flow.string_source json_str in
Cacheio.put store.cache ~key ~source ~ttl:None ();
-
Log.debug (fun m -> m "Stored Atom entry %s" entry.atom_id);
+
Log.debug (fun m -> m "Stored Atom entry %s" (Jsonfeed.Item.id entry.item));
) entries;
update_feed_meta store ~feed_url ~feed_name ~feed_title ~sw;
Log.info (fun m -> m "Stored %d Atom entries for feed %s" (List.length entries) feed_url)
···
| Some source ->
try
let json_str = Eio.Buf_read.(parse_exn take_all) source ~max_size:Int.max_int in
-
let json = Yojson.Safe.from_string json_str in
-
Some (entry_of_json json)
+
Some (entry_of_string json_str)
with e ->
Log.err (fun m -> m "Failed to parse entry: %s" (Printexc.to_string e));
None
···
| Some source ->
try
let json_str = Eio.Buf_read.(parse_exn take_all) source ~max_size:Int.max_int in
-
let json = Yojson.Safe.from_string json_str in
-
Some (entry_of_json json)
+
Some (entry_of_string json_str)
with e ->
Log.err (fun m -> m "Failed to parse entry from scan: %s" (Printexc.to_string e));
None
else None
) entries in
-
(* Sort by updated time, newest first *)
-
List.sort (fun a b -> Ptime.compare b.updated a.updated) feed_entries
+
(* Sort by date_modified, newest first *)
+
List.sort (fun a b ->
+
let time_a = Jsonfeed.Item.date_modified a.item |> Option.value ~default:a.meta.stored_at in
+
let time_b = Jsonfeed.Item.date_modified b.item |> Option.value ~default:b.meta.stored_at in
+
Ptime.compare time_b time_a
+
) feed_entries
let list_entries_filtered store ~feed_url ?since ?until ?limit ?(sort=`Updated) () =
let entries = list_entries store ~feed_url in
(* Filter by time *)
let entries = match since with
| None -> entries
-
| Some t -> List.filter (fun e -> Ptime.is_later e.updated ~than:t || Ptime.equal e.updated t) entries
+
| Some t -> List.filter (fun e ->
+
let time = Jsonfeed.Item.date_modified e.item |> Option.value ~default:e.meta.stored_at in
+
Ptime.is_later time ~than:t || Ptime.equal time t) entries
in
let entries = match until with
| None -> entries
-
| Some t -> List.filter (fun e -> Ptime.is_earlier e.updated ~than:t || Ptime.equal e.updated t) entries
+
| Some t -> List.filter (fun e ->
+
let time = Jsonfeed.Item.date_modified e.item |> Option.value ~default:e.meta.stored_at in
+
Ptime.is_earlier time ~than:t || Ptime.equal time t) entries
in
(* Sort *)
let entries = match sort with
| `Published -> List.sort (fun a b ->
-
match a.published, b.published with
-
| Some pa, Some pb -> Ptime.compare pb pa
+
let pa = Jsonfeed.Item.date_published a.item in
+
let pb = Jsonfeed.Item.date_published b.item in
+
match pa, pb with
+
| Some ta, Some tb -> Ptime.compare tb ta
| None, Some _ -> 1
| Some _, None -> -1
-
| None, None -> Ptime.compare b.updated a.updated
+
| None, None ->
+
let ta = Jsonfeed.Item.date_modified a.item |> Option.value ~default:a.meta.stored_at in
+
let tb = Jsonfeed.Item.date_modified b.item |> Option.value ~default:b.meta.stored_at in
+
Ptime.compare tb ta
+
) entries
+
| `Updated -> List.sort (fun a b ->
+
let ta = Jsonfeed.Item.date_modified a.item |> Option.value ~default:a.meta.stored_at in
+
let tb = Jsonfeed.Item.date_modified b.item |> Option.value ~default:b.meta.stored_at in
+
Ptime.compare tb ta
) entries
-
| `Updated -> List.sort (fun a b -> Ptime.compare b.updated a.updated) entries
-
| `Stored -> List.sort (fun a b -> Ptime.compare b.stored_at a.stored_at) entries
+
| `Stored -> List.sort (fun a b -> Ptime.compare b.meta.stored_at a.meta.stored_at) entries
in
(* Limit *)
match limit with
···
| Some source ->
try
let json_str = Eio.Buf_read.(parse_exn take_all) source ~max_size:Int.max_int in
-
let json = Yojson.Safe.from_string json_str in
-
Some (entry_of_json json)
+
Some (entry_of_string json_str)
with e ->
Log.err (fun m -> m "Failed to parse entry: %s" (Printexc.to_string e));
None
else None
) entries in
-
let sorted = List.sort (fun a b -> Ptime.compare b.updated a.updated) all_entries in
+
let sorted = List.sort (fun a b ->
+
let ta = Jsonfeed.Item.date_modified a.item |> Option.value ~default:a.meta.stored_at in
+
let tb = Jsonfeed.Item.date_modified b.item |> Option.value ~default:b.meta.stored_at in
+
Ptime.compare tb ta
+
) all_entries in
List.filteri (fun i _ -> i < limit) sorted
let find_entry_by_id store ~id =
···
| Some source ->
(try
let json_str = Eio.Buf_read.(parse_exn take_all) source ~max_size:Int.max_int in
-
let json = Yojson.Safe.from_string json_str in
-
let entry = entry_of_json json in
+
let entry = entry_of_string json_str in
(* Exact ID match only *)
-
if entry.atom_id = id then
+
if Jsonfeed.Item.id entry.item = id then
Some entry
else
None
···
else None
) entries in
(match matching_entry with
-
| Some e -> Log.debug (fun m -> m "Found entry: %s" e.title)
+
| Some e -> Log.debug (fun m -> m "Found entry: %s"
+
(Jsonfeed.Item.title e.item |> Option.value ~default:"(no title)"))
| None -> Log.debug (fun m -> m "No entry found with ID: %s" id));
matching_entry
···
let entries = list_entries store ~feed_url in
let to_delete = List.filteri (fun i _ -> i >= keep) entries in
List.iter (fun entry ->
-
delete_entry store ~feed_url ~atom_id:entry.atom_id
+
delete_entry store ~feed_url ~atom_id:(Jsonfeed.Item.id entry.item)
) to_delete;
let deleted = List.length to_delete in
Log.info (fun m -> m "Pruned %d entries from feed %s (kept %d)" deleted feed_url keep);
···
let prune_old_entries store ~feed_url ~older_than =
let entries = list_entries store ~feed_url in
let to_delete = List.filter (fun e ->
-
Ptime.is_earlier e.updated ~than:older_than
+
let time = Jsonfeed.Item.date_modified e.item |> Option.value ~default:e.meta.stored_at in
+
Ptime.is_earlier time ~than:older_than
) entries in
List.iter (fun entry ->
-
delete_entry store ~feed_url ~atom_id:entry.atom_id
+
delete_entry store ~feed_url ~atom_id:(Jsonfeed.Item.id entry.item)
) to_delete;
let deleted = List.length to_delete in
Log.info (fun m -> m "Pruned %d old entries from feed %s" deleted feed_url);
···
| Some source ->
try
let json_str = Eio.Buf_read.(parse_exn take_all) source ~max_size:Int.max_int in
-
let json = Yojson.Safe.from_string json_str in
-
Some (feed_meta_of_json json)
+
Some (feed_meta_of_string json_str)
with e ->
Log.err (fun m -> m "Failed to parse feed metadata: %s" (Printexc.to_string e));
None
···
| Some n -> list_entries_filtered store ~feed_url ~limit:n ()
in
let atom_entries = List.map (fun entry ->
-
let id = Uri.of_string entry.atom_id in
-
let entry_title : Syndic.Atom.text_construct = Syndic.Atom.Text entry.title in
-
let links = match entry.link with
-
| Some uri -> [Syndic.Atom.link ~rel:Syndic.Atom.Alternate uri]
+
let item = entry.item in
+
let id = Uri.of_string (Jsonfeed.Item.id item) in
+
let entry_title : Syndic.Atom.text_construct =
+
Syndic.Atom.Text (Jsonfeed.Item.title item |> Option.value ~default:"(no title)") in
+
let links = match Jsonfeed.Item.url item with
+
| Some url_str -> [Syndic.Atom.link ~rel:Syndic.Atom.Alternate (Uri.of_string url_str)]
| None -> []
in
-
let entry_content : Syndic.Atom.content = Syndic.Atom.Html (None, entry.content) in
-
let author = Syndic.Atom.author ?email:entry.author_email entry.author_name in
+
let content_str = match Jsonfeed.Item.content item with
+
| `Html h -> h
+
| `Text t -> t
+
| `Both (h, _) -> h
+
in
+
let entry_content : Syndic.Atom.content = Syndic.Atom.Html (None, content_str) in
+
let author_name = match Jsonfeed.Item.authors item with
+
| Some (a :: _) -> Jsonfeed.Author.name a |> Option.value ~default:entry.meta.feed_name
+
| _ -> entry.meta.feed_name
+
in
+
let author = Syndic.Atom.author author_name in
let authors = (author, []) in
-
Syndic.Atom.entry ~id ~title:entry_title ~updated:entry.updated ?published:entry.published
+
let updated = Jsonfeed.Item.date_modified item |> Option.value ~default:entry.meta.stored_at in
+
Syndic.Atom.entry ~id ~title:entry_title ~updated
+
?published:(Jsonfeed.Item.date_published item)
~links ~content:entry_content ~authors ()
) entries in
let feed_title : Syndic.Atom.text_construct = match title with
···
let feed_id = Uri.of_string ("urn:river:archive:" ^ (Digest.string feed_url |> Digest.to_hex)) in
let feed_updated = match entries with
| [] -> Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get
-
| e :: _ -> e.updated
+
| e :: _ -> Jsonfeed.Item.date_modified e.item |> Option.value ~default:e.meta.stored_at
in
{
Syndic.Atom.id = feed_id;
···
(* Pretty printing *)
let pp_entry fmt entry =
+
let item = entry.item in
Format.fprintf fmt "@[<v 2>Entry:@,";
-
Format.fprintf fmt "ID: %s@," entry.atom_id;
-
Format.fprintf fmt "Title: %s@," entry.title;
-
Format.fprintf fmt "Link: %s@," (match entry.link with Some u -> Uri.to_string u | None -> "none");
-
Format.fprintf fmt "Published: %s@," (match entry.published with
-
| Some t -> Ptime.to_rfc3339 t
-
| None -> "unknown");
-
Format.fprintf fmt "Updated: %s@," (Ptime.to_rfc3339 entry.updated);
-
Format.fprintf fmt "Feed: %s (%s)@," entry.feed_name entry.feed_url;
-
Format.fprintf fmt "Stored: %s@]" (Ptime.to_rfc3339 entry.stored_at)
+
Format.fprintf fmt "ID: %s@," (Jsonfeed.Item.id item);
+
Format.fprintf fmt "Title: %s@," (Jsonfeed.Item.title item |> Option.value ~default:"(no title)");
+
Format.fprintf fmt "URL: %s@," (Jsonfeed.Item.url item |> Option.value ~default:"(none)");
+
(match Jsonfeed.Item.date_published item with
+
| Some t -> Format.fprintf fmt "Published: %s@," (Ptime.to_rfc3339 t)
+
| None -> ());
+
(match Jsonfeed.Item.date_modified item with
+
| Some t -> Format.fprintf fmt "Modified: %s@," (Ptime.to_rfc3339 t)
+
| None -> ());
+
Format.fprintf fmt "Feed: %s (%s)@," entry.meta.feed_name entry.meta.feed_url;
+
Format.fprintf fmt "Stored: %s@]" (Ptime.to_rfc3339 entry.meta.stored_at)
let pp_feed_info fmt info =
Format.fprintf fmt "@[<v 2>Feed:@,";
···
List.iter (fun feed ->
Format.fprintf fmt " - %s: %d entries@," feed.name feed.entry_count
) feeds;
-
Format.fprintf fmt "@]"
+
Format.fprintf fmt "@]"
+13 -38
stack/river/lib/river_store.mli
···
(** Abstract type representing the store *)
type t
-
(** Stored entry with resolved URLs and metadata *)
-
type stored_entry = {
-
atom_id : string;
-
(** Unique Atom entry ID (used as key) *)
+
(** Stored entry - combines Jsonfeed.Item with storage metadata *)
+
type stored_entry
-
title : string;
-
(** Entry title *)
+
(** {2 Stored Entry Accessors} *)
-
link : Uri.t option;
-
(** Primary link (resolved against feed base URI) *)
+
val entry_item : stored_entry -> Jsonfeed.Item.t
+
(** Get the underlying Jsonfeed Item *)
-
published : Ptime.t option;
-
(** Publication date *)
+
val entry_feed_url : stored_entry -> string
+
(** Get the source feed URL *)
-
updated : Ptime.t;
-
(** Last update time *)
+
val entry_feed_name : stored_entry -> string
+
(** Get the source feed name *)
-
author_name : string;
-
(** Entry author name *)
+
val entry_feed_title : stored_entry -> string
+
(** Get the source feed title *)
-
author_email : string option;
-
(** Entry author email *)
-
-
content : string;
-
(** HTML content with resolved URLs *)
-
-
feed_url : string;
-
(** URL of the source feed *)
-
-
feed_name : string;
-
(** Name of the source feed *)
-
-
feed_title : string;
-
(** Title of the source feed *)
-
-
stored_at : Ptime.t;
-
(** When this entry was stored *)
-
-
tags : string list;
-
(** Tags associated with the entry *)
-
-
summary : string option;
-
(** Summary/excerpt of the entry *)
-
}
+
val entry_stored_at : stored_entry -> Ptime.t
+
(** Get the storage timestamp *)
(** Feed metadata *)
type feed_info = {
+1 -2
stack/river/river.opam
···
"lambdasoup"
"uri"
"cmdliner" {>= "2.0.0"}
-
"yojson"
"fmt"
"xdge"
"jsonfeed" {>= "1.1.0"}
"jsont" {>= "0.2.0"}
-
"bytesrw"
+
"jsont.bytesrw" {>= "0.2.0"}
"odoc" {with-doc}
]
build: [
+1 -1
stack/typesense-client/dune
···
(library
(public_name typesense-client)
(name typesense_client)
-
(libraries eio requests requests_json_api ezjsonm fmt uri ptime))
+
(libraries eio requests requests_json_api jsont jsont.bytesrw fmt uri ptime))
+2 -1
stack/typesense-client/dune-project
···
(ocaml (>= 4.14))
eio
requests
-
ezjsonm
+
jsont
+
jsont-bytesrw
fmt
uri
ptime))
+2 -1
stack/typesense-client/typesense-client.opam
···
"ocaml" {>= "4.14"}
"eio"
"requests"
-
"ezjsonm"
+
"jsont"
+
"jsont-bytesrw"
"fmt"
"uri"
"ptime"
+175 -94
stack/typesense-client/typesense_client.ml
···
Error (Connection_error (Printexc.to_string exn))
(** Search result types *)
+
type highlight = {
+
field: string;
+
snippets: string list;
+
}
+
type search_result = {
id: string;
title: string;
content: string;
score: float;
collection: string;
-
highlights: (string * string list) list;
-
document: Ezjsonm.value; (* Store raw document for flexible field access *)
+
highlights: highlight list;
+
document: Jsont.json; (* Store raw document for flexible field access *)
}
type search_response = {
···
query_time: float;
}
-
(** Parse search result from JSON *)
-
let parse_search_result collection json =
-
let open Ezjsonm in
-
let document = get_dict json |> List.assoc "document" in
-
let highlights = try get_dict json |> List.assoc "highlights" with _ -> `A [] in
-
let score = try get_dict json |> List.assoc "text_match" |> get_float with _ -> 0.0 in
+
(* Jsont codecs *)
-
let id = get_dict document |> List.assoc "id" |> get_string in
-
let title = try get_dict document |> List.assoc "title" |> get_string with _ -> "" in
-
let content = try
+
(** Helper to find a field by name in the fields list *)
+
let find_field field_name fields =
+
List.find_opt (fun ((name, _), _value) -> name = field_name) fields
+
+
module Highlight = struct
+
let make field snippets = { field; snippets }
+
let field h = h.field
+
let snippets h = h.snippets
+
+
let jsont =
+
Jsont.Object.map ~kind:"Highlight" make
+
|> Jsont.Object.mem "field" Jsont.string ~enc:field
+
|> Jsont.Object.mem "snippets" (Jsont.list Jsont.string) ~enc:snippets
+
|> Jsont.Object.finish
+
end
+
+
module Search_result = struct
+
(* Helper to extract content from document based on collection *)
+
let extract_content collection document =
+
let get_string_field field_name =
+
match document with
+
| Jsont.Object (fields, _) ->
+
(match find_field field_name fields with
+
| Some (_, Jsont.String (s, _)) -> s
+
| _ -> "")
+
| _ -> ""
+
in
match collection with
-
| "papers" -> get_dict document |> List.assoc "abstract" |> get_string
-
| "projects" -> get_dict document |> List.assoc "description" |> get_string
-
| "news" -> get_dict document |> List.assoc "content" |> get_string
-
| "videos" -> get_dict document |> List.assoc "description" |> get_string
-
| "notes" -> get_dict document |> List.assoc "content" |> get_string
-
| "ideas" -> get_dict document |> List.assoc "description" |> get_string
-
| "contacts" -> get_dict document |> List.assoc "name" |> get_string
+
| "papers" -> get_string_field "abstract"
+
| "projects" -> get_string_field "description"
+
| "news" -> get_string_field "content"
+
| "videos" -> get_string_field "description"
+
| "notes" -> get_string_field "content"
+
| "ideas" -> get_string_field "description"
+
| "contacts" -> get_string_field "name"
| _ -> ""
-
with _ -> "" in
+
+
let make collection document highlights text_match =
+
let get_string_field field_name =
+
match document with
+
| Jsont.Object (fields, _) ->
+
(match find_field field_name fields with
+
| Some (_, Jsont.String (s, _)) -> s
+
| _ -> "")
+
| _ -> ""
+
in
+
let id = get_string_field "id" in
+
let title = get_string_field "title" in
+
let content = extract_content collection document in
+
let score = Option.value text_match ~default:0.0 in
+
let highlights = Option.value highlights ~default:[] in
+
{ id; title; content; score; collection; highlights; document }
-
let parse_highlights highlights =
-
try
-
get_list (fun h ->
-
let field = get_dict h |> List.assoc "field" |> get_string in
-
let snippets = get_dict h |> List.assoc "snippets" |> get_list get_string in
-
(field, snippets)
-
) highlights
-
with _ -> []
-
in
+
let document r = r.document
+
let highlights r = if r.highlights = [] then None else Some r.highlights
+
let score r = if r.score = 0.0 then None else Some r.score
+
+
let jsont collection =
+
Jsont.Object.map ~kind:"SearchResult" (make collection)
+
|> Jsont.Object.mem "document" Jsont.json ~enc:document
+
|> Jsont.Object.opt_mem "highlights" (Jsont.list Highlight.jsont) ~enc:highlights
+
|> Jsont.Object.opt_mem "text_match" Jsont.number ~enc:score
+
|> Jsont.Object.finish
+
end
-
{ id; title; content; score; collection; highlights = parse_highlights highlights; document }
+
module Search_response = struct
+
let make hits found search_time_ms =
+
{ hits; total = found; query_time = search_time_ms }
+
+
let hits r = r.hits
+
let total r = r.total
+
let query_time r = r.query_time
-
(** Parse search response from JSON *)
-
let parse_search_response collection json =
-
let open Ezjsonm in
-
let hits = get_dict json |> List.assoc "hits" |> get_list (parse_search_result collection) in
-
let total = get_dict json |> List.assoc "found" |> get_int in
-
let query_time = get_dict json |> List.assoc "search_time_ms" |> get_float in
-
{ hits; total; query_time }
+
let jsont collection =
+
Jsont.Object.map ~kind:"SearchResponse" make
+
|> Jsont.Object.mem "hits" (Jsont.list (Search_result.jsont collection)) ~enc:hits
+
|> Jsont.Object.mem "found" Jsont.int ~enc:total
+
|> Jsont.Object.mem "search_time_ms" Jsont.number ~enc:query_time
+
|> Jsont.Object.finish
+
end
(** Search a single collection *)
let search_collection client collection_name query ?(limit=10) ?(offset=0) () =
···
match make_request client path with
| Ok response_str ->
-
(match Requests_json_api.parse_json_result (parse_search_response collection_name) response_str with
+
(match Jsont_bytesrw.decode_string' (Search_response.jsont collection_name) response_str with
| Ok search_response -> Ok search_response
-
| Error msg -> Error (Json_error msg))
+
| Error error -> Error (Json_error (Jsont.Error.to_string error)))
| Error err -> Error err
(** Helper function to drop n elements from list *)
···
results: search_response list;
}
-
(** Parse multisearch response from JSON *)
-
let parse_multisearch_response json =
-
let open Ezjsonm in
-
let results_json = get_dict json |> List.assoc "results" |> get_list (fun r -> r) in
-
let results = List.mapi (fun i result_json ->
-
let collection_name = match i with
-
| 0 -> "contacts"
-
| 1 -> "news"
-
| 2 -> "notes"
-
| 3 -> "papers"
-
| 4 -> "projects"
-
| 5 -> "ideas"
-
| 6 -> "videos"
-
| _ -> "unknown"
-
in
-
parse_search_response collection_name result_json
-
) results_json in
-
{ results }
+
(* Multisearch response decoder - needs special handling for collection names *)
+
let decode_multisearch_response collections json_str =
+
(* First decode as generic JSON *)
+
match Jsont_bytesrw.decode_string' Jsont.json json_str with
+
| Error e -> Error e
+
| Ok json ->
+
(* Extract the results array *)
+
match json with
+
| Jsont.Object (fields, _) ->
+
(match find_field "results" fields with
+
| Some (_, Jsont.Array (results_array, _)) ->
+
(* Decode each result with its corresponding collection name *)
+
let decode_result idx result_json =
+
let collection = List.nth collections idx in
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json result_json with
+
| Error e -> Error e
+
| Ok result_str ->
+
Jsont_bytesrw.decode_string' (Search_response.jsont collection) result_str
+
in
+
let rec decode_all idx acc = function
+
| [] -> Ok (List.rev acc)
+
| hd :: tl ->
+
match decode_result idx hd with
+
| Error e -> Error e
+
| Ok result -> decode_all (idx + 1) (result :: acc) tl
+
in
+
(match decode_all 0 [] results_array with
+
| Ok results -> Ok { results }
+
| Error e -> Error e)
+
| _ -> Error (Jsont.Error.msg Jsont.Meta.none "Missing or invalid results field"))
+
| _ -> Error (Jsont.Error.msg Jsont.Meta.none "Expected JSON object")
(** Perform multisearch across all collections *)
let multisearch client query ?(limit=10) () =
···
("videos", "title,description,channel,platform,tags");
] in
+
(* Build search request objects *)
let searches = List.map (fun collection ->
let query_by = List.assoc collection query_by_collection in
-
Ezjsonm.dict [
-
("collection", Ezjsonm.string collection);
-
("q", Ezjsonm.string query);
-
("query_by", Ezjsonm.string query_by);
-
("exclude_fields", Ezjsonm.string "embedding");
-
("per_page", Ezjsonm.int limit);
-
]
+
Jsont.Object ([
+
(("collection", Jsont.Meta.none), Jsont.String (collection, Jsont.Meta.none));
+
(("q", Jsont.Meta.none), Jsont.String (query, Jsont.Meta.none));
+
(("query_by", Jsont.Meta.none), Jsont.String (query_by, Jsont.Meta.none));
+
(("exclude_fields", Jsont.Meta.none), Jsont.String ("embedding", Jsont.Meta.none));
+
(("per_page", Jsont.Meta.none), Jsont.Number (float_of_int limit, Jsont.Meta.none));
+
], Jsont.Meta.none)
) collections in
-
let body = Ezjsonm.dict [("searches", Ezjsonm.list (fun x -> x) searches)] |> Ezjsonm.value_to_string in
+
let request_obj = Jsont.Object ([
+
(("searches", Jsont.Meta.none), Jsont.Array (searches, Jsont.Meta.none));
+
], Jsont.Meta.none) in
-
match make_request client ~meth:`POST ~body "/multi_search" with
-
| Ok response_str ->
-
(match Requests_json_api.parse_json_result parse_multisearch_response response_str with
-
| Ok multisearch_resp -> Ok multisearch_resp
-
| Error msg -> Error (Json_error msg))
-
| Error err -> Error err
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Minify Jsont.json request_obj with
+
| Error encode_error -> Error (Json_error (Jsont.Error.to_string encode_error))
+
| Ok body ->
+
match make_request client ~meth:`POST ~body "/multi_search" with
+
| Ok response_str ->
+
(match decode_multisearch_response collections response_str with
+
| Ok multisearch_resp -> Ok multisearch_resp
+
| Error error -> Error (Json_error (Jsont.Error.to_string error)))
+
| Error err -> Error err
(** Combine multisearch results into single result set *)
let combine_multisearch_results (multisearch_resp : multisearch_response) ?(limit=10) ?(offset=0) () =
···
(** List all collections *)
let list_collections client =
-
let parse_collections json =
-
Ezjsonm.get_list (fun c ->
-
let name = Ezjsonm.get_dict c |> List.assoc "name" |> Ezjsonm.get_string in
-
let num_docs = Ezjsonm.get_dict c |> List.assoc "num_documents" |> Ezjsonm.get_int in
-
(name, num_docs)
-
) json
-
in
+
let module Collection_info = struct
+
let make name num_documents = (name, num_documents)
+
let name ci = fst ci
+
let num_documents ci = snd ci
+
+
let jsont =
+
Jsont.Object.map ~kind:"CollectionInfo" make
+
|> Jsont.Object.mem "name" Jsont.string ~enc:name
+
|> Jsont.Object.mem "num_documents" Jsont.int ~enc:num_documents
+
|> Jsont.Object.finish
+
end in
+
match make_request client "/collections" with
| Ok response_str ->
-
(match Requests_json_api.parse_json_result parse_collections response_str with
+
(match Jsont_bytesrw.decode_string' (Jsont.list Collection_info.jsont) response_str with
| Ok collections -> Ok collections
-
| Error msg -> Error (Json_error msg))
+
| Error error -> Error (Json_error (Jsont.Error.to_string error)))
| Error err -> Error err
(** Pretty printer utilities *)
(** Extract field value from JSON document or return empty string if not found *)
-
let extract_field_string document field =
-
try
-
let open Ezjsonm in
-
get_dict document |> List.assoc field |> get_string
-
with _ -> ""
+
let extract_field_string document field_name =
+
match document with
+
| Jsont.Object (fields, _) ->
+
(match find_field field_name fields with
+
| Some (_, Jsont.String (s, _)) -> s
+
| _ -> "")
+
| _ -> ""
(** Extract field value from JSON document as string list or return empty list if not found *)
-
let extract_field_string_list document field =
-
try
-
let open Ezjsonm in
-
get_dict document |> List.assoc field |> get_list get_string
-
with _ -> []
+
let extract_field_string_list document field_name =
+
match document with
+
| Jsont.Object (fields, _) ->
+
(match find_field field_name fields with
+
| Some (_, Jsont.Array (items, _)) ->
+
List.filter_map (function
+
| Jsont.String (s, _) -> Some s
+
| _ -> None
+
) items
+
| _ -> [])
+
| _ -> []
(** Extract field value from JSON document as boolean or return false if not found *)
-
let extract_field_bool document field =
-
try
-
let open Ezjsonm in
-
get_dict document |> List.assoc field |> get_bool
-
with _ -> false
+
let extract_field_bool document field_name =
+
match document with
+
| Jsont.Object (fields, _) ->
+
(match find_field field_name fields with
+
| Some (_, Jsont.Bool (b, _)) -> b
+
| _ -> false)
+
| _ -> false
(** Format authors list for display *)
let format_authors authors =
+12 -5
stack/typesense-client/typesense_client.mli
···
val pp_error : Format.formatter -> error -> unit
(** Search result types *)
+
+
(** A highlight snippet from a search result *)
+
type highlight = {
+
field: string;
+
snippets: string list;
+
}
+
type search_result = {
id: string;
title: string;
content: string;
score: float;
collection: string;
-
highlights: (string * string list) list;
-
document: Ezjsonm.value; (* Store raw document for flexible field access *)
+
highlights: highlight list;
+
document: Jsont.json; (* Store raw document for flexible field access *)
}
type search_response = {
···
((string * int) list, error) result
(** Pretty printer utilities *)
-
val extract_field_string : Ezjsonm.value -> string -> string
-
val extract_field_string_list : Ezjsonm.value -> string -> string list
-
val extract_field_bool : Ezjsonm.value -> string -> bool
+
val extract_field_string : Jsont.json -> string -> string
+
val extract_field_string_list : Jsont.json -> string -> string list
+
val extract_field_bool : Jsont.json -> string -> bool
val format_authors : string list -> string
val format_date : string -> string
val format_tags : string list -> string
+1 -1
stack/zotero-translation/dune
···
(library
(name zotero_translation)
(public_name zotero-translation)
-
(libraries astring eio requests ezjsonm fpath uri))
+
(libraries astring eio requests jsont jsont.bytesrw fpath uri))
+2 -1
stack/zotero-translation/dune-project
···
uri
eio
requests
-
ezjsonm
+
jsont
+
(jsont-bytesrw (>= "0.4"))
yaml
astring))
+2 -1
stack/zotero-translation/zotero-translation.opam
···
"uri"
"eio"
"requests"
-
"ezjsonm"
+
"jsont"
+
"jsont-bytesrw" {>= "0.4"}
"yaml"
"astring"
"odoc" {with-doc}
+105 -58
stack/zotero-translation/zotero_translation.ml
···
(** Resolve a DOI from a Zotero translation server *)
-
module J = Ezjsonm
-
(* From the ZTS source code: https://github.com/zotero/translation-server/blob/master/src/formats.js
bibtex: "9cb70025-a888-4a29-a210-93ec52da40d4",
biblatex: "b6e39b57-8942-4d11-8259-342c46ce395f",
···
requests_session: ('clock, 'net) Requests.t;
}
-
let create ~sw ~env ?requests_session base_uri =
-
let requests_session = match requests_session with
-
| Some session -> session
-
| None -> Requests.create ~sw env
-
in
+
let create ~requests_session base_uri =
{ base_uri; requests_session }
-
-
let v _base_uri =
-
failwith "Zotero_translation.v is deprecated. Use Zotero_translation.create ~sw ~env base_uri instead"
let resolve_doi { base_uri; requests_session } doi =
let body_str = "https://doi.org/" ^ doi in
···
let body = Requests.Response.body response |> Eio.Flow.read_all in
if status = 200 then begin
try
-
let doi_json = J.from_string body in
-
Ok doi_json
+
match Jsont_bytesrw.decode_string' Jsont.json body with
+
| Ok doi_json -> Ok doi_json
+
| Error e -> Error (`Msg (Jsont.Error.to_string e))
with exn -> Error (`Msg (Printexc.to_string exn))
end else
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
···
let body = Requests.Response.body response |> Eio.Flow.read_all in
if status = 200 then begin
try
-
let url_json = J.from_string body in
-
Ok url_json
+
match Jsont_bytesrw.decode_string' Jsont.json body with
+
| Ok url_json -> Ok url_json
+
| Error e -> Error (`Msg (Jsont.Error.to_string e))
with exn -> Error (`Msg (Printexc.to_string exn))
end else
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
···
let body = Requests.Response.body response |> Eio.Flow.read_all in
if status = 200 then begin
try
-
let doi_json = J.from_string body in
-
Ok doi_json
+
match Jsont_bytesrw.decode_string' Jsont.json body with
+
| Ok doi_json -> Ok doi_json
+
| Error e -> Error (`Msg (Jsont.Error.to_string e))
with exn -> Error (`Msg (Printexc.to_string exn))
end else
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
let export { base_uri; requests_session } format api =
-
let body_str = J.to_string api in
-
let uri = Uri.with_query' (export_endp base_uri ) ["format", (format_to_string format)] in
-
let body = Requests.Body.of_string Requests.Mime.json body_str in
-
let response = Requests.post requests_session ~body (Uri.to_string uri) in
-
let status = Requests.Response.status_code response in
-
let body = Requests.Response.body response |> Eio.Flow.read_all in
-
if status = 200 then begin
-
try
-
match format with
-
| Bibtex -> Ok (Astring.String.trim body)
-
| _ -> Ok body
-
with exn -> Error (`Msg (Printexc.to_string exn))
-
end else
-
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
+
match Jsont_bytesrw.encode_string' Jsont.json api with
+
| Error e -> Error (`Msg (Jsont.Error.to_string e))
+
| Ok body_str ->
+
let uri = Uri.with_query' (export_endp base_uri ) ["format", (format_to_string format)] in
+
let body = Requests.Body.of_string Requests.Mime.json body_str in
+
let response = Requests.post requests_session ~body (Uri.to_string uri) in
+
let status = Requests.Response.status_code response in
+
let body = Requests.Response.body response |> Eio.Flow.read_all in
+
if status = 200 then begin
+
try
+
match format with
+
| Bibtex -> Ok (Astring.String.trim body)
+
| _ -> Ok body
+
with exn -> Error (`Msg (Printexc.to_string exn))
+
end else
+
Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
let unescape_hex s =
let buf = Buffer.create (String.length s) in
···
| Ok [bib] ->
let f = Bibtex.fields bib |> Bibtex.SM.bindings |> List.map (fun (k,v) -> k, (unescape_bibtex v)) in
let ty = match Bibtex.type' bib with "inbook" -> "book" | x -> x in
-
let v = List.fold_left (fun acc (k,v) -> (k,(`String v))::acc) ["bibtype",`String ty] f in
+
let v = List.fold_left (fun acc (k,v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))::acc)
+
[(("bibtype", Jsont.Meta.none), Jsont.String (ty, Jsont.Meta.none))] f in
v
| Ok _ -> failwith "one bib at a time plz"
let bib_of_doi zt doi =
prerr_endline ("Fetching " ^ doi);
-
let v = match resolve_doi zt doi with
-
| Ok r -> r
+
match resolve_doi zt doi with
| Error (`Msg _) ->
Printf.eprintf "%s failed on /web, trying to /search\n%!" doi;
-
match search_id zt doi with
+
begin match search_id zt doi with
| Error (`Msg e) -> failwith e
-
| Ok r -> r
-
in
-
match export zt Bibtex v with
-
| Error (`Msg e) -> failwith e
-
| Ok r ->
-
print_endline r;
-
r
+
| Ok v ->
+
match export zt Bibtex v with
+
| Error (`Msg e) -> failwith e
+
| Ok r ->
+
print_endline r;
+
r
+
end
+
| Ok v ->
+
match export zt Bibtex v with
+
| Error (`Msg e) -> failwith e
+
| Ok r ->
+
print_endline r;
+
r
+
+
(* Helper to get string from Jsont.json *)
+
let get_string = function
+
| Jsont.String (s, _) -> s
+
| _ -> failwith "Expected string in JSON"
+
+
(* Helper to get list from Jsont.json *)
+
let get_list f = function
+
| Jsont.Array (arr, _) -> List.map f arr
+
| _ -> failwith "Expected array in JSON"
+
+
(* Helper to find a field in Jsont.Object *)
+
let find_field name = function
+
| Jsont.Object (mems, _) ->
+
List.find_map (fun ((k, _), v) -> if k = name then Some v else None) mems
+
| _ -> None
+
+
(* Helper to get a required field as string *)
+
let get_field name json =
+
match find_field name json with
+
| Some v -> get_string v
+
| None -> failwith ("Missing field: " ^ name)
+
+
(* Helper to update a field in a Jsont.Object *)
+
let update_field name value json =
+
match json with
+
| Jsont.Object (mems, meta) ->
+
let mems' =
+
match value with
+
| None -> List.filter (fun ((k, _), _) -> k <> name) mems
+
| Some v ->
+
let without = List.filter (fun ((k, _), _) -> k <> name) mems in
+
((name, Jsont.Meta.none), v) :: without
+
in
+
Jsont.Object (mems', meta)
+
| _ -> json
let split_authors keys =
+
let json = Jsont.Object (keys, Jsont.Meta.none) in
+
let author_str = get_field "author" json in
let authors =
-
List.assoc "author" keys |> J.get_string |>
-
Astring.String.cuts ~empty:false ~sep:" and " |>
+
Astring.String.cuts ~empty:false ~sep:" and " author_str |>
List.map Bibtex.list_value |>
List.map (fun v -> List.rev v |> String.concat " ") |>
-
List.map (fun x -> `String x)
+
List.map (fun x -> Jsont.String (x, Jsont.Meta.none))
in
let keywords =
-
List.assoc_opt "keywords" keys |> function
+
match find_field "keywords" json with
| None -> []
| Some k ->
-
Astring.String.cuts ~empty:false ~sep:", " (J.get_string k) |>
-
List.map (fun x -> `String x)
+
Astring.String.cuts ~empty:false ~sep:", " (get_string k) |>
+
List.map (fun x -> Jsont.String (x, Jsont.Meta.none))
in
-
J.update (`O keys) ["author"] (Some (`A authors)) |> fun j ->
-
J.update j ["keywords"] (match keywords with [] -> None | _ -> Some (`A keywords))
+
let json' = update_field "author" (Some (Jsont.Array (authors, Jsont.Meta.none))) json in
+
let json'' = update_field "keywords"
+
(match keywords with [] -> None | _ -> Some (Jsont.Array (keywords, Jsont.Meta.none))) json' in
+
match json'' with
+
| Jsont.Object (mems, _) -> mems
+
| _ -> failwith "Expected object"
let add_bibtex ~slug y =
-
let (.%{}) = fun y k -> J.find y [k] in
+
let json = Jsont.Object (y, Jsont.Meta.none) in
+
let find_opt k = find_field k json in
let add_if_present k f m =
-
match J.find y [k] with
-
| v -> Bibtex.SM.add k (f v) m
-
| exception Not_found -> m in
-
let string k m = add_if_present k J.get_string m in
-
let authors m = add_if_present "author" (fun j -> J.get_list J.get_string j |> String.concat " and ") m in
+
match find_opt k with
+
| Some v -> Bibtex.SM.add k (f v) m
+
| None -> m
+
in
+
let string k m = add_if_present k get_string m in
+
let authors m = add_if_present "author" (fun j -> get_list get_string j |> String.concat " and ") m in
let cite_key = Astring.String.map (function '-' -> '_' |x -> x) slug in
let fields = Bibtex.SM.empty in
-
let type' = y.%{"bibtype"} |> J.get_string |> String.lowercase_ascii in
+
let type' = get_field "bibtype" json |> String.lowercase_ascii in
let fields = authors fields |> string "title" |> string "doi" |> string "month" |> string "year" |> string "url" in
let fields = match type' with
| "article" -> string "journal" fields |> string "volume" |> string "number" |> string "pages"
···
| "misc" -> string "howpublished" fields
| "techreport" -> string "institution" fields |> string "number" |> string "address"
| b -> prerr_endline ("unknown bibtype " ^ b); fields in
-
Bibtex.v ~type' ~cite_key ~fields () |> Fmt.str "%a" Bibtex.pp |>
-
fun bib -> J.update y ["bib"] (Some (`String bib))
+
let bib = Bibtex.v ~type' ~cite_key ~fields () |> Fmt.str "%a" Bibtex.pp in
+
match update_field "bib" (Some (Jsont.String (bib, Jsont.Meta.none))) json with
+
| Jsont.Object (mems, _) -> mems
+
| _ -> failwith "Expected object"
let json_of_doi zt ~slug doi =
let x = bib_of_doi zt doi in
+8 -16
stack/zotero-translation/zotero_translation.mli
···
val format_of_string: string -> format option
(** Create a Zotero Translation client.
-
@param requests_session Optional Requests session for connection pooling.
-
If not provided, a new session is created. *)
+
@param requests_session Shared Requests session for connection pooling.
+
@param base_uri Base URI of the Zotero translation server (e.g., "http://localhost:1969"). *)
val create :
-
sw:Eio.Switch.t ->
-
env:< clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t;
-
net: ([> [> `Generic ] Eio.Net.ty ] as 'net) Eio.Resource.t;
-
fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
-
?requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
+
requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
string -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t
-
(** Deprecated: use [create] instead *)
-
val v : string -> (_, _) t
-
[@@deprecated "Use create ~sw ~env base_uri instead"]
-
val resolve_doi: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
-
string -> (Ezjsonm.t, [>`Msg of string]) result
+
string -> (Jsont.json, [>`Msg of string]) result
val resolve_url: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
-
string -> (Ezjsonm.t, [>`Msg of string]) result
+
string -> (Jsont.json, [>`Msg of string]) result
val search_id: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
-
string -> (Ezjsonm.t, [>`Msg of string]) result
+
string -> (Jsont.json, [>`Msg of string]) result
val export: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
-
format -> Ezjsonm.t -> (string, [>`Msg of string]) result
+
format -> Jsont.json -> (string, [>`Msg of string]) result
val json_of_doi : ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
-
slug:string -> string -> Ezjsonm.value
+
slug:string -> string -> Jsont.object'
+217
stack/zulip/ARCHITECTURE.md
···
+
# Zulip Library Architecture
+
+
## Overview
+
+
The Zulip OCaml library follows a clean, layered architecture that separates protocol types, encoding concerns, and HTTP communication.
+
+
## Architecture Layers
+
+
```
+
┌─────────────────────────────────────┐
+
│ API Modules (Messages, Channels) │ ← High-level operations
+
├─────────────────────────────────────┤
+
│ Protocol Types (Message, Channel) │ ← Business logic types with Jsont codecs
+
├─────────────────────────────────────┤
+
│ Encode Module │ ← JSON/Form encoding utilities
+
├─────────────────────────────────────┤
+
│ Client Module │ ← HTTP request/response handling
+
├─────────────────────────────────────┤
+
│ Requests Library (EIO-based) │ ← Low-level HTTP
+
└─────────────────────────────────────┘
+
```
+
+
## Key Design Principles
+
+
### 1. **Protocol Types with Jsont Codecs**
+
+
Each Zulip API type (Message, Channel, User, etc.) has:
+
- A clean OCaml record type
+
- A `jsont` codec that defines bidirectional JSON conversion
+
- Accessor functions
+
- Pretty printer
+
+
Example from `channel.ml`:
+
```ocaml
+
type t = {
+
name : string;
+
description : string;
+
invite_only : bool;
+
history_public_to_subscribers : bool;
+
}
+
+
let jsont =
+
Jsont.Object.map ~kind:"Channel" make
+
|> Jsont.Object.mem "name" Jsont.string ~enc:name
+
|> Jsont.Object.mem "description" Jsont.string ~enc:description
+
|> Jsont.Object.mem "invite_only" Jsont.bool ~enc:invite_only
+
|> Jsont.Object.mem "history_public_to_subscribers" Jsont.bool ~enc:history_public_to_subscribers
+
|> Jsont.Object.finish
+
```
+
+
### 2. **Encode Module: Separation of Encoding Concerns**
+
+
The `Encode` module provides clean utilities for converting between OCaml types and wire formats:
+
+
```ocaml
+
(** Convert using a jsont codec *)
+
val to_json_string : 'a Jsont.t -> 'a -> string
+
val to_form_urlencoded : 'a Jsont.t -> 'a -> string
+
val from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result
+
```
+
+
This eliminates the need for:
+
- ❌ Manual JSON tree walking
+
- ❌ Round-trip encode→decode conversions
+
- ❌ Per-type encoding functions
+
+
### 3. **Request/Response Types with Codecs**
+
+
API operations define request/response types locally with their codecs:
+
+
```ocaml
+
(* In channels.ml *)
+
module Subscribe_request = struct
+
type t = { subscriptions : string list }
+
+
let codec =
+
Jsont.Object.(
+
map ~kind:"SubscribeRequest" (fun subscriptions -> { subscriptions })
+
|> mem "subscriptions" (Jsont.list Jsont.string) ~enc:(fun r -> r.subscriptions)
+
|> finish
+
)
+
end
+
+
let subscribe client ~channels =
+
let req = Subscribe_request.{ subscriptions = channels } in
+
let body = Encode.to_form_urlencoded Subscribe_request.codec req in
+
let content_type = "application/x-www-form-urlencoded" in
+
match Client.request client ~method_:`POST ~path:"/api/v1/users/me/subscriptions"
+
~body ~content_type () with
+
| Ok _json -> Ok ()
+
| Error err -> Error err
+
```
+
+
### 4. **Type-Safe Decoding**
+
+
Response parsing uses codecs directly instead of manual pattern matching:
+
+
```ocaml
+
(* OLD - manual JSON walking *)
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
(match List.assoc_opt "streams" assoc with
+
| Some (Jsont.Array (channel_list, _)) -> ...
+
+
(* NEW - type-safe codec *)
+
let response_codec =
+
Jsont.Object.(
+
map ~kind:"StreamsResponse" (fun streams -> streams)
+
|> mem "streams" (Jsont.list Channel.jsont) ~enc:(fun x -> x)
+
|> finish
+
)
+
in
+
match Encode.from_json response_codec json with
+
| Ok channels -> Ok channels
+
| Error msg -> Error (...)
+
```
+
+
## Benefits
+
+
### ✅ Type Safety
+
- Jsont codecs ensure correct JSON structure
+
- Compilation errors catch schema mismatches
+
- No runtime type confusion
+
+
### ✅ Maintainability
+
- Protocol changes only require updating codecs
+
- No manual JSON manipulation scattered through code
+
- Clear separation of concerns
+
+
### ✅ Reusability
+
- Codecs can be composed and reused
+
- Encode module works for any jsont-encoded type
+
- Request/response types are self-documenting
+
+
### ✅ Testability
+
- Easy to test encoding/decoding in isolation
+
- Mock responses can be type-checked
+
- Round-trip property testing possible
+
+
## Migration Pattern
+
+
When adding new API endpoints:
+
+
1. **Define the protocol type with codec**:
+
```ocaml
+
type my_request = { field1: string; field2: int }
+
+
let my_request_codec =
+
Jsont.Object.(
+
map ~kind:"MyRequest" (fun field1 field2 -> { field1; field2 })
+
|> mem "field1" Jsont.string ~enc:(fun r -> r.field1)
+
|> mem "field2" Jsont.int ~enc:(fun r -> r.field2)
+
|> finish
+
)
+
```
+
+
2. **Encode using Encode module**:
+
```ocaml
+
let body = Encode.to_form_urlencoded my_request_codec req in
+
(* or *)
+
let json = Encode.to_json_string my_request_codec req in
+
```
+
+
3. **Decode responses with codec**:
+
```ocaml
+
match Client.request client ~method_:`POST ~path:"/api/..." ~body () with
+
| Ok json ->
+
(match Encode.from_json response_codec json with
+
| Ok data -> Ok data
+
| Error msg -> Error ...)
+
```
+
+
## Comparison with Old Approach
+
+
### Old (Manual JSON Manipulation):
+
```ocaml
+
let send client message =
+
let json = Message.to_json message in (* Round-trip conversion *)
+
let params = match json with
+
| Jsont.Object (fields, _) -> (* Manual pattern matching *)
+
List.fold_left (fun acc ((key, _), value) ->
+
let str_value = match value with (* More pattern matching *)
+
| Jsont.String (s, _) -> s
+
| Jsont.Bool (true, _) -> "true"
+
| _ -> ""
+
in
+
(key, str_value) :: acc
+
) [] fields
+
| _ -> [] in
+
(* ... *)
+
```
+
+
### New (Codec-Based):
+
```ocaml
+
let send client message =
+
let body = Message.to_form_urlencoded message in (* Clean encoding *)
+
let content_type = "application/x-www-form-urlencoded" in
+
match Client.request client ~method_:`POST ~path:"/api/v1/messages"
+
~body ~content_type () with
+
| Ok response -> Message_response.of_json response
+
| Error err -> Error err
+
```
+
+
## Future Enhancements
+
+
- **Validation**: Add validation layers on top of codecs
+
- **Versioning**: Support multiple API versions with codec variants
+
- **Documentation**: Generate API docs from codec definitions
+
- **Testing**: Property-based testing with codec round-trips
+
- **Code Generation**: Consider generating codecs from OpenAPI specs
+
+
## References
+
+
- Jsont library: https://erratique.ch/software/jsont
+
- Zulip REST API: https://zulip.com/api/rest
+
- Original design doc: `CLAUDE.md`
-689
stack/zulip/CLAUDE.md
···
-
I would like to build high quality OCaml bindings to the Zulip REST API,
-
documented at https://zulip.com/api/rest. As another reference, the Python
-
`zulip` library from pip is well maintained.
-
-
My target is to use the OCaml EIO direct-style library, with an idiomatic as
-
possible API that implements it. For JSON parsing, using the jsonm library is
-
right. For HTTPS, use cohttp-eio with the tls-eio library. You have access to
-
an OCaml LSP via MCP which provides type hints and other language server
-
features after you complete a `dune build`.
-
-
# OCaml Zulip Library Design
-
-
Based on analysis of:
-
- Zulip REST API documentation: https://zulip.com/api/rest
-
- Python zulip library: https://github.com/zulip/python-zulip-api
-
- Zulip error handling: https://zulip.com/api/rest-error-handling
-
- Zulip send message API: https://zulip.com/api/send-message
-
-
## Overview
-
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.
-
-
## Module Structure
-
-
### Authentication (`Zulip.Auth`)
-
```ocaml
-
type t (* abstract *)
-
-
val create : server_url:string -> email:string -> api_key:string -> t
-
val from_zuliprc : ?path:string -> unit -> (t, Error.t) result
-
val server_url : t -> string
-
val email : t -> string
-
val to_basic_auth_header : t -> string
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Error Handling (`Zulip.Error`)
-
```ocaml
-
type code =
-
| Invalid_api_key
-
| Request_variable_missing
-
| Bad_request
-
| User_deactivated
-
| Realm_deactivated
-
| Rate_limit_hit
-
| Other of string
-
-
type t (* abstract *)
-
-
val create : code:code -> msg:string -> ?extra:(string * Jsonm.value) list -> unit -> t
-
val code : t -> code
-
val message : t -> string
-
val extra : t -> (string * Jsonm.value) list
-
val pp : Format.formatter -> t -> unit
-
val of_json : Jsonm.value -> t option
-
```
-
-
### Message Types (`Zulip.Message_type`)
-
```ocaml
-
type t = [ `Direct | `Channel ]
-
-
val to_string : t -> string
-
val of_string : string -> t option
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Message (`Zulip.Message`)
-
```ocaml
-
type t (* abstract *)
-
-
val create :
-
type_:Message_type.t ->
-
to_:string list ->
-
content:string ->
-
?topic:string ->
-
?queue_id:string ->
-
?local_id:string ->
-
?read_by_sender:bool ->
-
unit -> t
-
-
val type_ : t -> Message_type.t
-
val to_ : t -> string list
-
val content : t -> string
-
val topic : t -> string option
-
val queue_id : t -> string option
-
val local_id : t -> string option
-
val read_by_sender : t -> bool
-
val to_json : t -> Jsonm.value
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Message Response (`Zulip.Message_response`)
-
```ocaml
-
type t (* abstract *)
-
-
val id : t -> int
-
val automatic_new_visibility_policy : t -> string option
-
val of_json : Jsonm.value -> (t, Error.t) result
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Client (`Zulip.Client`)
-
```ocaml
-
type t (* abstract *)
-
-
val create : #Eio.Env.t -> Auth.t -> t
-
val with_client : #Eio.Env.t -> Auth.t -> (t -> 'a) -> 'a
-
-
val request :
-
t ->
-
method_:[`GET | `POST | `PUT | `DELETE | `PATCH] ->
-
path:string ->
-
?params:(string * string) list ->
-
?body:string ->
-
unit ->
-
(Jsonm.value, Error.t) result
-
```
-
-
### Messages (`Zulip.Messages`)
-
```ocaml
-
val send : Client.t -> Message.t -> (Message_response.t, Error.t) result
-
val edit : Client.t -> message_id:int -> ?content:string -> ?topic:string -> unit -> (unit, Error.t) result
-
val delete : Client.t -> message_id:int -> (unit, Error.t) result
-
val get : Client.t -> message_id:int -> (Jsonm.value, Error.t) result
-
val get_messages :
-
Client.t ->
-
?anchor:string ->
-
?num_before:int ->
-
?num_after:int ->
-
?narrow:string list ->
-
unit ->
-
(Jsonm.value, Error.t) result
-
```
-
-
### Channel (`Zulip.Channel`)
-
```ocaml
-
type t (* abstract *)
-
-
val create :
-
name:string ->
-
description:string ->
-
?invite_only:bool ->
-
?history_public_to_subscribers:bool ->
-
unit -> t
-
-
val name : t -> string
-
val description : t -> string
-
val invite_only : t -> bool
-
val history_public_to_subscribers : t -> bool
-
val to_json : t -> Jsonm.value
-
val of_json : Jsonm.value -> (t, Error.t) result
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Channels (`Zulip.Channels`)
-
```ocaml
-
val create_channel : Client.t -> Channel.t -> (unit, Error.t) result
-
val delete : Client.t -> name:string -> (unit, Error.t) result
-
val list : Client.t -> (Channel.t list, Error.t) result
-
val subscribe : Client.t -> channels:string list -> (unit, Error.t) result
-
val unsubscribe : Client.t -> channels:string list -> (unit, Error.t) result
-
```
-
-
### User (`Zulip.User`)
-
```ocaml
-
type t (* abstract *)
-
-
val create :
-
email:string ->
-
full_name:string ->
-
?is_active:bool ->
-
?is_admin:bool ->
-
?is_bot:bool ->
-
unit -> t
-
-
val email : t -> string
-
val full_name : t -> string
-
val is_active : t -> bool
-
val is_admin : t -> bool
-
val is_bot : t -> bool
-
val to_json : t -> Jsonm.value
-
val of_json : Jsonm.value -> (t, Error.t) result
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Users (`Zulip.Users`)
-
```ocaml
-
val list : Client.t -> (User.t list, Error.t) result
-
val get : Client.t -> email:string -> (User.t, Error.t) result
-
val create_user : Client.t -> email:string -> full_name:string -> (unit, Error.t) result
-
val deactivate : Client.t -> email:string -> (unit, Error.t) result
-
```
-
-
### Event Type (`Zulip.Event_type`)
-
```ocaml
-
type t =
-
| Message
-
| Subscription
-
| User_activity
-
| Other of string
-
-
val to_string : t -> string
-
val of_string : string -> t
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Event (`Zulip.Event`)
-
```ocaml
-
type t (* abstract *)
-
-
val id : t -> int
-
val type_ : t -> Event_type.t
-
val data : t -> Jsonm.value
-
val of_json : Jsonm.value -> (t, Error.t) result
-
val pp : Format.formatter -> t -> unit
-
```
-
-
### Event Queue (`Zulip.Event_queue`)
-
```ocaml
-
type t (* abstract *)
-
-
val register :
-
Client.t ->
-
?event_types:Event_type.t list ->
-
unit ->
-
(t, Error.t) result
-
-
val id : t -> string
-
val get_events : t -> Client.t -> ?last_event_id:int -> unit -> (Event.t list, Error.t) result
-
val delete : t -> Client.t -> (unit, Error.t) result
-
val pp : Format.formatter -> t -> unit
-
```
-
-
## EIO Bot Framework Extension
-
-
Based on analysis of the Python bot framework at:
-
- https://github.com/zulip/python-zulip-api/blob/main/zulip_bots/zulip_bots/lib.py
-
- https://github.com/zulip/python-zulip-api/blob/main/zulip_botserver/zulip_botserver/server.py
-
-
### Bot Handler (`Zulip.Bot`)
-
```ocaml
-
module Storage : sig
-
type t (* abstract *)
-
-
val create : Client.t -> t
-
val get : t -> key:string -> string option
-
val put : t -> key:string -> value:string -> unit
-
val contains : t -> key:string -> bool
-
end
-
-
module Identity : sig
-
type t (* abstract *)
-
-
val full_name : t -> string
-
val email : t -> string
-
val mention_name : t -> string
-
end
-
-
type handler = {
-
handle_message :
-
client:Client.t ->
-
message:Jsonm.value ->
-
response:(Message.t -> unit) ->
-
unit;
-
-
usage : unit -> string;
-
description : unit -> string;
-
}
-
-
type t (* abstract *)
-
-
val create :
-
Client.t ->
-
handler:handler ->
-
?storage:Storage.t ->
-
unit -> t
-
-
val identity : t -> Identity.t
-
val storage : t -> Storage.t
-
val handle_message : t -> Jsonm.value -> unit
-
val send_reply : t -> original_message:Jsonm.value -> content:string -> unit
-
val send_message : t -> Message.t -> unit
-
```
-
-
### Bot Server (`Zulip.Bot_server`)
-
```ocaml
-
module Config : sig
-
type bot_config = {
-
email : string;
-
api_key : string;
-
token : string; (* webhook token *)
-
server_url : string;
-
module_name : string;
-
}
-
-
type t (* abstract *)
-
-
val create : bot_configs:bot_config list -> ?host:string -> ?port:int -> unit -> t
-
val from_file : string -> (t, Error.t) result
-
val from_env : string -> (t, Error.t) result
-
val host : t -> string
-
val port : t -> int
-
val bot_configs : t -> bot_config list
-
end
-
-
type t (* abstract *)
-
-
val create : #Eio.Env.t -> Config.t -> (t, Error.t) result
-
-
val run : t -> unit
-
(* Starts the server using EIO structured concurrency *)
-
-
val with_server : #Eio.Env.t -> Config.t -> (t -> 'a) -> ('a, Error.t) result
-
(* Resource-safe server management *)
-
```
-
-
### Bot Registry (`Zulip.Bot_registry`)
-
```ocaml
-
type bot_module = {
-
name : string;
-
handler : Bot.handler;
-
create_instance : Client.t -> Bot.t;
-
}
-
-
type t (* abstract *)
-
-
val create : unit -> t
-
val register : t -> bot_module -> unit
-
val get_handler : t -> email:string -> Bot.t option
-
val list_bots : t -> string list
-
-
(* Dynamic module loading *)
-
val load_from_file : string -> (bot_module, Error.t) result
-
val load_from_directory : string -> (bot_module list, Error.t) result
-
```
-
-
### Webhook Handler (`Zulip.Webhook`)
-
```ocaml
-
type webhook_event = {
-
bot_email : string;
-
token : string;
-
message : Jsonm.value;
-
trigger : [`Direct_message | `Mention];
-
}
-
-
type response = {
-
content : string option;
-
message_type : Message_type.t option;
-
to_ : string list option;
-
topic : string option;
-
}
-
-
val parse_webhook : string -> (webhook_event, Error.t) result
-
val handle_webhook : Bot_registry.t -> webhook_event -> (response option, Error.t) result
-
```
-
-
### Structured Concurrency Design
-
-
The EIO-based server uses structured concurrency to manage multiple bots safely:
-
-
```ocaml
-
(* Example server implementation using EIO *)
-
let run_server env config =
-
let registry = Bot_registry.create () in
-
-
(* Load and register all configured bots concurrently *)
-
Eio.Switch.run @@ fun sw ->
-
-
(* Start each bot in its own fiber *)
-
List.iter (fun bot_config ->
-
Eio.Fiber.fork ~sw (fun () ->
-
let auth = Auth.create
-
~server_url:bot_config.server_url
-
~email:bot_config.email
-
~api_key:bot_config.api_key in
-
-
Client.with_client env auth @@ fun client ->
-
-
(* Load bot module *)
-
match Bot_registry.load_from_file bot_config.module_name with
-
| Ok bot_module ->
-
let bot = bot_module.create_instance client in
-
Bot_registry.register registry bot_module;
-
-
(* Keep bot alive and handle events *)
-
Event_loop.run client bot
-
| Error e ->
-
Printf.eprintf "Failed to load bot %s: %s\n"
-
bot_config.email (Error.message e)
-
)
-
) (Config.bot_configs config);
-
-
(* Start HTTP server for webhooks *)
-
let server_addr = `Tcp (Eio.Net.Ipaddr.V4.loopback, Config.port config) in
-
Eio.Net.run_server env#net server_addr ~on_error:raise @@ fun flow _addr ->
-
-
(* Handle each webhook request concurrently *)
-
Eio.Switch.run @@ fun req_sw ->
-
Eio.Fiber.fork ~sw:req_sw (fun () ->
-
handle_http_request registry flow
-
)
-
```
-
-
### Event Loop (`Zulip.Event_loop`)
-
```ocaml
-
type t (* abstract *)
-
-
val create : Client.t -> Bot.t -> t
-
-
val run : Client.t -> Bot.t -> unit
-
(* Runs the event loop using real-time events API *)
-
-
val run_webhook_mode : Client.t -> Bot.t -> unit
-
(* Runs in webhook mode, waiting for HTTP callbacks *)
-
-
(* For advanced use cases *)
-
val with_event_loop :
-
Client.t ->
-
Bot.t ->
-
(Event_queue.t -> unit) ->
-
unit
-
```
-
-
## Key EIO Advantages
-
-
1. **Structured Concurrency**: Each bot runs in its own fiber with proper cleanup
-
2. **Resource Safety**: Automatic cleanup of connections, event queues, and HTTP servers
-
3. **Backpressure**: Natural flow control through EIO's cooperative scheduling
-
4. **Error Isolation**: Bot failures don't crash the entire server
-
5. **Graceful Shutdown**: Structured teardown of all resources
-
-
## Design Principles
-
-
1. **Abstract Types**: Each major concept has its own module with abstract `type t`
-
2. **Constructors**: Clear `create` functions with optional parameters
-
3. **Accessors**: All fields accessible via dedicated functions
-
4. **Pretty Printing**: Every type has a `pp` function for debugging
-
5. **JSON Conversion**: Bidirectional JSON conversion where appropriate
-
6. **Error Handling**: Consistent `(_, Error.t) result` return types
-
-
## Authentication Strategy
-
-
- Support zuliprc files and direct credential passing
-
- Abstract `Auth.t` prevents credential leakage
-
- HTTP Basic Auth with proper encoding
-
-
## EIO Integration
-
-
- All operations use EIO's direct-style async
-
- Resource-safe client management with `with_client`
-
- Proper cleanup of connections and event queues
-
-
## Example Usage
-
-
### Simple Message Sending
-
```ocaml
-
let () =
-
Eio_main.run @@ fun env ->
-
let auth = Zulip.Auth.create
-
~server_url:"https://example.zulipchat.com"
-
~email:"bot@example.com"
-
~api_key:"your-api-key" in
-
-
Zulip.Client.with_client env auth @@ fun client ->
-
-
let message = Zulip.Message.create
-
~type_:`Channel
-
~to_:["general"]
-
~content:"Hello from OCaml!"
-
~topic:"Bots"
-
() in
-
-
match Zulip.Messages.send client message with
-
| Ok response ->
-
Printf.printf "Message sent with ID: %d\n"
-
(Zulip.Message_response.id response)
-
| Error error ->
-
Printf.printf "Error: %s\n"
-
(Zulip.Error.message error)
-
```
-
-
### Simple Bot
-
```ocaml
-
let echo_handler = Zulip.Bot.{
-
handle_message = (fun ~client ~message ~response ->
-
let content = extract_content message in
-
let echo_msg = Message.create
-
~type_:`Direct
-
~to_:[sender_email message]
-
~content:("Echo: " ^ content) () in
-
response echo_msg
-
);
-
usage = (fun () -> "Echo bot - repeats your message");
-
description = (fun () -> "A simple echo bot");
-
}
-
-
let () =
-
Eio_main.run @@ fun env ->
-
let auth = Auth.from_zuliprc () |> Result.get_ok in
-
-
Client.with_client env auth @@ fun client ->
-
let bot = Bot.create client ~handler:echo_handler () in
-
Event_loop.run client bot
-
```
-
-
### Multi-Bot Server
-
```ocaml
-
let () =
-
Eio_main.run @@ fun env ->
-
let config = Bot_server.Config.from_file "bots.conf" |> Result.get_ok in
-
-
Bot_server.with_server env config @@ fun server ->
-
Bot_server.run server
-
```
-
-
## Package Dependencies
-
-
- `eio` - Effects-based I/O
-
- `cohttp-eio` - HTTP client with EIO support
-
- `tls-eio` - TLS support for HTTPS
-
- `jsonm` - Streaming JSON codec
-
- `uri` - URI parsing and manipulation
-
- `base64` - Base64 encoding for authentication
-
-
# Architecture Analysis: zulip_bot vs zulip_botserver
-
-
## Library Separation
-
-
### `zulip_bot` - Individual Bot Framework
-
**Purpose**: Library for building and running a single bot instance
-
-
**Key Components**:
-
- `Bot_handler` - Interface for bot logic with EIO environment access
-
- `Bot_runner` - Manages lifecycle of one bot (real-time events or webhook mode)
-
- `Bot_config` - Configuration for a single bot
-
- `Bot_storage` - Simple in-memory storage for bot state
-
-
**Usage Pattern**:
-
```ocaml
-
(* Run a single bot directly *)
-
let my_bot = Bot_handler.create (module My_echo_bot) ~config ~storage ~identity in
-
let runner = Bot_runner.create ~client ~handler:my_bot in
-
Bot_runner.run_realtime runner (* Bot connects to Zulip events API directly *)
-
```
-
-
### `zulip_botserver` - Multi-Bot Server Infrastructure
-
**Purpose**: HTTP server that manages multiple bots via webhooks
-
-
**Key Components**:
-
- `Bot_server` - HTTP server receiving webhook events from Zulip
-
- `Bot_registry` - Manages multiple bot instances
-
- `Server_config` - Configuration for multiple bots + server settings
-
- `Webhook_handler` - Parses incoming webhook requests and routes to appropriate bots
-
-
**Usage Pattern**:
-
```ocaml
-
(* Run a server hosting multiple bots *)
-
let registry = Bot_registry.create () in
-
Bot_registry.register registry echo_bot_module;
-
Bot_registry.register registry weather_bot_module;
-
-
let server = Bot_server.create ~env ~config ~registry in
-
Bot_server.run server (* HTTP server waits for webhook calls *)
-
```
-
-
## EIO Environment Requirements
-
-
### Why Bot Handlers Need Direct EIO Access
-
-
Bot handlers require direct access to the EIO environment for legitimate I/O operations beyond HTTP requests to Zulip:
-
-
1. **Network Operations**: Custom HTTP requests, API calls to external services
-
2. **File System Operations**: Reading configuration files, CSV dictionaries, logs
-
3. **Resource Management**: Proper cleanup via structured concurrency
-
-
### Example: URL Checker Bot
-
```ocaml
-
module Url_checker_bot : Zulip_bot.Bot_handler.Bot_handler = struct
-
let handle_message ~config ~storage ~identity ~message ~env =
-
match parse_command message with
-
| "!check", url ->
-
(* Direct EIO network access needed *)
-
Eio.Switch.run @@ fun sw ->
-
let client = Cohttp_eio.Client.make ~sw env#net in
-
let response = Cohttp_eio.Client.head ~sw client (Uri.of_string url) in
-
let status = Cohttp.Code.code_of_status response.status in
-
Ok (Response.reply ~content:(format_status_message url status))
-
| _ -> Ok Response.none
-
end
-
```
-
-
### Example: CSV Dictionary Bot
-
```ocaml
-
module Csv_dict_bot : Zulip_bot.Bot_handler.Bot_handler = struct
-
let handle_message ~config ~storage ~identity ~message ~env =
-
match parse_command message with
-
| "!lookup", term ->
-
(* Direct EIO file system access needed *)
-
let csv_path = Bot_config.get_required config ~key:"csv_file" in
-
let content = Eio.Path.load env#fs (Eio.Path.parse csv_path) in
-
let matches = search_csv_content content term in
-
Ok (Response.reply ~content:(format_matches matches))
-
| _ -> Ok Response.none
-
end
-
```
-
-
## Refined Bot Handler Interface
-
-
Based on analysis, the current EIO environment plumbing is **essential** and should be cleaned up:
-
-
```ocaml
-
(** Clean bot handler interface with direct EIO access *)
-
module type Bot_handler = sig
-
val initialize : Bot_config.t -> (unit, Zulip.Error.t) result
-
val usage : unit -> string
-
val description : unit -> string
-
-
(** Handle message with full EIO environment access *)
-
val handle_message :
-
config:Bot_config.t ->
-
storage:Bot_storage.t ->
-
identity:Identity.t ->
-
message:Message_context.t ->
-
env:#Eio.Env.t -> (* Essential for custom I/O *)
-
(Response.t, Zulip.Error.t) result
-
end
-
-
type t
-
-
(** Single creation interface *)
-
val create :
-
(module Bot_handler) ->
-
config:Bot_config.t ->
-
storage:Bot_storage.t ->
-
identity:Identity.t ->
-
t
-
-
(** Single message handler requiring EIO environment *)
-
val handle_message : t -> #Eio.Env.t -> Message_context.t -> (Response.t, Zulip.Error.t) result
-
```
-
-
## Storage Strategy
-
-
Bot storage can be simplified to in-memory key-value storage since it's server-side:
-
-
```ocaml
-
(* In zulip_bot - storage per bot instance *)
-
module Bot_storage = struct
-
type t = (string, string) Hashtbl.t (* Simple in-memory key-value *)
-
-
let create () = Hashtbl.create 16
-
let get t ~key = Hashtbl.find_opt t key
-
let put t ~key ~value = Hashtbl.replace t key value
-
let contains t ~key = Hashtbl.mem t key
-
end
-
-
(* In zulip_botserver - storage shared across bots *)
-
module Server_storage = struct
-
type t = (string * string, string) Hashtbl.t (* (bot_email, key) -> value *)
-
-
let create () = Hashtbl.create 64
-
let get t ~bot_email ~key = Hashtbl.find_opt t (bot_email, key)
-
let put t ~bot_email ~key ~value = Hashtbl.replace t (bot_email, key) value
-
end
-
```
-
-
## Interface Cleanup Recommendations
-
-
1. **Remove** the problematic `handle_message` function with mock environment
-
2. **Keep** `handle_message_with_env` but rename to `handle_message`
-
3. **Use** `#Eio.Env.t` constraint for clean typing
-
4. **Document** that bot handlers have full EIO access for custom I/O operations
-
-
This design maintains flexibility for real-world bot functionality while providing clean, type-safe interfaces.
-
-
## Sources and References
-
-
This design is based on comprehensive analysis of:
-
-
1. **Zulip REST API Documentation**:
-
- Main API: https://zulip.com/api/rest
-
- Error Handling: https://zulip.com/api/rest-error-handling
-
- Send Message: https://zulip.com/api/send-message
-
-
2. **Python Zulip Library**:
-
- Main repository: https://github.com/zulip/python-zulip-api
-
- Bot framework: https://github.com/zulip/python-zulip-api/blob/main/zulip_bots/zulip_bots/lib.py
-
- Bot server: https://github.com/zulip/python-zulip-api/blob/main/zulip_botserver/zulip_botserver/server.py
-
-
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.
+2 -2
stack/zulip/dune-project
···
(name ocaml-zulip)
+
(generate_opam_files true)
+
(package
(name zulip)
(synopsis "OCaml bindings for the Zulip REST API")
···
dune
eio
requests
-
ezjsonm
uri
base64
(alcotest :with-test)
···
dune
zulip
eio
-
ezjsonm
(alcotest :with-test)))
(package
+2 -5
stack/zulip/examples/example.ml
···
(match Message.topic message with Some t -> t | None -> "None");
(* Test JSON serialization *)
-
let json = Message.to_json message in
-
Printf.printf "\nMessage JSON: %s\n"
-
(match json with
-
| `O _ -> "JSON object (serialized correctly)"
-
| _ -> "Invalid JSON");
+
let json_str = Message.to_json_string message in
+
Printf.printf "\nMessage JSON: %s\n" json_str;
(* Create client *)
let client = Client.create ~sw env auth in
+4 -3
stack/zulip/examples/test_client.ml
···
| Ok json ->
Printf.printf "Fetched messages successfully!\n";
(match json with
-
| `O fields ->
-
(match List.assoc_opt "messages" fields with
-
| Some (`A messages) ->
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
(match List.assoc_opt "messages" assoc with
+
| Some (Jsont.Array (messages, _)) ->
Printf.printf "Got %d messages\n" (List.length messages)
| _ -> Printf.printf "No messages field found\n")
| _ -> Printf.printf "Unexpected JSON format\n")
+25 -33
stack/zulip/lib/zulip/lib/channel.ml
···
let invite_only t = t.invite_only
let history_public_to_subscribers t = t.history_public_to_subscribers
-
let to_json t =
-
`O [
-
("name", `String t.name);
-
("description", `String t.description);
-
("invite_only", `Bool t.invite_only);
-
("history_public_to_subscribers", `Bool t.history_public_to_subscribers);
-
]
+
let pp fmt t = Format.fprintf fmt "Channel{name=%s, description=%s}" t.name t.description
-
let of_json json =
-
try
-
match json with
-
| `O fields ->
-
let get_string key =
-
match List.assoc key fields with
-
| `String s -> s
-
| _ -> failwith ("Expected string for " ^ key) in
-
let get_bool key default =
-
match List.assoc_opt key fields with
-
| Some (`Bool b) -> b
-
| None -> default
-
| _ -> failwith ("Expected bool for " ^ key) in
-
-
let name = get_string "name" in
-
let description = get_string "description" in
-
let invite_only = get_bool "invite_only" false in
-
let history_public_to_subscribers = get_bool "history_public_to_subscribers" true in
-
-
Ok { name; description; invite_only; history_public_to_subscribers }
-
| _ ->
-
Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:"Channel JSON must be an object" ())
-
with
-
| exn ->
-
Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:("Channel JSON parsing failed: " ^ Printexc.to_string exn) ())
+
(* Jsont codec for channel *)
+
let jsont =
+
let kind = "Channel" in
+
let doc = "A Zulip channel (stream)" in
+
let make name description invite_only history_public_to_subscribers =
+
{ name; description; invite_only; history_public_to_subscribers }
+
in
+
Jsont.Object.map ~kind ~doc make
+
|> Jsont.Object.mem "name" Jsont.string ~enc:name
+
|> Jsont.Object.mem "description" Jsont.string ~enc:description
+
|> Jsont.Object.mem "invite_only" Jsont.bool ~enc:invite_only
+
|> Jsont.Object.mem "history_public_to_subscribers" Jsont.bool ~enc:history_public_to_subscribers
+
|> Jsont.Object.finish
+
+
(* Decode and encode functions using Encode module *)
+
let of_json json =
+
match Encode.from_json jsont json with
+
| Ok v -> Ok v
+
| Error msg -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg ())
+
+
let to_json_string t =
+
Encode.to_json_string jsont t
-
let pp fmt t = Format.fprintf fmt "Channel{name=%s, description=%s}" t.name t.description
+
let to_form_urlencoded t =
+
Encode.to_form_urlencoded jsont t
+17 -6
stack/zulip/lib/zulip/lib/channel.mli
···
type t
-
val create :
-
name:string ->
-
description:string ->
-
?invite_only:bool ->
-
?history_public_to_subscribers:bool ->
+
val create :
+
name:string ->
+
description:string ->
+
?invite_only:bool ->
+
?history_public_to_subscribers:bool ->
unit -> t
val name : t -> string
val description : t -> string
val invite_only : t -> bool
val history_public_to_subscribers : t -> bool
-
val to_json : t -> Zulip_types.json
+
+
(** Jsont codec for the channel type *)
+
val jsont : t Jsont.t
+
+
(** Decode from Jsont.json *)
val of_json : Zulip_types.json -> (t, Zulip_types.zerror) result
+
+
(** Encode to JSON string *)
+
val to_json_string : t -> string
+
+
(** Encode to form-urlencoded string *)
+
val to_form_urlencoded : t -> string
+
val pp : Format.formatter -> t -> unit
+53 -43
stack/zulip/lib/zulip/lib/channels.ml
···
-
let create_channel client channel =
-
let body = match Channel.to_json channel with
-
| `O fields ->
-
String.concat "&" (List.map (fun (k, v) ->
-
match v with
-
| `String s -> k ^ "=" ^ Uri.pct_encode s
-
| `Bool b -> k ^ "=" ^ string_of_bool b
-
| _ -> ""
-
) fields)
-
| _ -> "" in
-
match Client.request client ~method_:`POST ~path:"/api/v1/streams" ~body () with
+
let create_channel client channel =
+
let body = Channel.to_form_urlencoded channel in
+
let content_type = "application/x-www-form-urlencoded" in
+
match Client.request client ~method_:`POST ~path:"/api/v1/streams" ~body ~content_type () with
| Ok _json -> Ok ()
| Error err -> Error err
-
let delete client ~name =
+
let delete client ~name =
let encoded_name = Uri.pct_encode name in
match Client.request client ~method_:`DELETE ~path:("/api/v1/streams/" ^ encoded_name) () with
| Ok _json -> Ok ()
| Error err -> Error err
-
let list client =
+
let list client =
+
(* Define response codec *)
+
let response_codec =
+
Jsont.Object.(
+
map ~kind:"StreamsResponse" (fun streams -> streams)
+
|> mem "streams" (Jsont.list Channel.jsont) ~enc:(fun x -> x)
+
|> finish
+
)
+
in
+
match Client.request client ~method_:`GET ~path:"/api/v1/streams" () with
-
| Ok json ->
-
(match json with
-
| `O fields ->
-
(match List.assoc_opt "streams" fields with
-
| Some (`A channel_list) ->
-
let channels = List.fold_left (fun acc channel_json ->
-
match Channel.of_json channel_json with
-
| Ok channel -> channel :: acc
-
| Error _ -> acc
-
) [] channel_list in
-
Ok (List.rev channels)
-
| _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Invalid streams response format" ()))
-
| _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Streams response must be an object" ()))
+
| Ok json ->
+
(match Encode.from_json response_codec json with
+
| Ok channels -> Ok channels
+
| Error msg -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg ()))
| Error err -> Error err
-
let subscribe client ~channels =
-
let channels_json = `A (List.map (fun name -> `String name) channels) in
-
let body = "subscriptions=" ^ (match channels_json with
-
| `A items -> "[" ^ String.concat "," (List.map (function
-
| `String s -> "\"" ^ s ^ "\""
-
| _ -> "") items) ^ "]"
-
| _ -> "[]") in
-
match Client.request client ~method_:`POST ~path:"/api/v1/users/me/subscriptions" ~body () with
+
(* Request types with jsont codecs *)
+
module Subscribe_request = struct
+
type t = { subscriptions : string list }
+
+
let codec =
+
Jsont.Object.(
+
map ~kind:"SubscribeRequest" (fun subscriptions -> { subscriptions })
+
|> mem "subscriptions" (Jsont.list Jsont.string) ~enc:(fun r -> r.subscriptions)
+
|> finish
+
)
+
end
+
+
module Unsubscribe_request = struct
+
type t = { delete : string list }
+
+
let codec =
+
Jsont.Object.(
+
map ~kind:"UnsubscribeRequest" (fun delete -> { delete })
+
|> mem "delete" (Jsont.list Jsont.string) ~enc:(fun r -> r.delete)
+
|> finish
+
)
+
end
+
+
let subscribe client ~channels =
+
let req = Subscribe_request.{ subscriptions = channels } in
+
let body = Encode.to_form_urlencoded Subscribe_request.codec req in
+
let content_type = "application/x-www-form-urlencoded" in
+
match Client.request client ~method_:`POST ~path:"/api/v1/users/me/subscriptions" ~body ~content_type () with
| Ok _json -> Ok ()
| Error err -> Error err
-
let unsubscribe client ~channels =
-
let channels_json = `A (List.map (fun name -> `String name) channels) in
-
let body = "delete=" ^ (match channels_json with
-
| `A items -> "[" ^ String.concat "," (List.map (function
-
| `String s -> "\"" ^ s ^ "\""
-
| _ -> "") items) ^ "]"
-
| _ -> "[]") in
-
match Client.request client ~method_:`DELETE ~path:"/api/v1/users/me/subscriptions" ~body () with
+
let unsubscribe client ~channels =
+
let req = Unsubscribe_request.{ delete = channels } in
+
let body = Encode.to_form_urlencoded Unsubscribe_request.codec req in
+
let content_type = "application/x-www-form-urlencoded" in
+
match Client.request client ~method_:`DELETE ~path:"/api/v1/users/me/subscriptions" ~body ~content_type () with
| Ok _json -> Ok ()
-
| Error err -> Error err
+
| Error err -> Error err
+16 -14
stack/zulip/lib/zulip/lib/client.ml
···
Buffer.contents buf
in
-
(* Parse JSON response using Ezjsonm *)
+
(* Parse JSON response using Jsont_bytesrw *)
let json =
-
try
-
Ezjsonm.from_string body_str
-
with Ezjsonm.Parse_error (_, msg) ->
-
Log.err (fun m -> m "JSON parse error: %s" msg);
-
failwith ("JSON parse error: " ^ msg)
+
match Jsont_bytesrw.decode_string' Jsont.json body_str with
+
| Ok j -> j
+
| Error e ->
+
let msg = Jsont.Error.to_string e in
+
Log.err (fun m -> m "JSON parse error: %s" msg);
+
failwith ("JSON parse error: " ^ msg)
in
(* Check for Zulip error response *)
match json with
-
| `O fields ->
-
(match List.assoc_opt "result" fields with
-
| Some (`String "error") ->
-
let msg = match List.assoc_opt "msg" fields with
-
| Some (`String s) -> s
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
(match List.assoc_opt "result" assoc with
+
| Some (Jsont.String ("error", _)) ->
+
let msg = match List.assoc_opt "msg" assoc with
+
| Some (Jsont.String (s, _)) -> s
| _ -> "Unknown error"
in
-
let code = match List.assoc_opt "code" fields with
-
| Some (`String s) -> Zulip_types.error_code_of_string s
+
let code = match List.assoc_opt "code" assoc with
+
| Some (Jsont.String (s, _)) -> Zulip_types.error_code_of_string s
| _ -> Zulip_types.Other "unknown"
in
Log.warn (fun m -> m "API error: %s (code: %s)" msg
···
~msg:"Invalid JSON response" ()))
let pp fmt t =
-
Format.fprintf fmt "Client(server=%s)" (Auth.server_url t.auth)
+
Format.fprintf fmt "Client(server=%s)" (Auth.server_url t.auth)
+1 -1
stack/zulip/lib/zulip/lib/dune
···
(library
(public_name zulip)
(name zulip)
-
(libraries eio requests ezjsonm uri base64 logs))
+
(libraries eio requests jsont jsont.bytesrw uri base64 logs))
+56
stack/zulip/lib/zulip/lib/encode.ml
···
+
(** Encoding utilities for Zulip API requests *)
+
+
(** Convert a jsont-encoded value to JSON string *)
+
let to_json_string : 'a Jsont.t -> 'a -> string = fun codec value ->
+
match Jsont_bytesrw.encode_string' codec value with
+
| Ok s -> s
+
| Error e -> failwith ("JSON encoding error: " ^ Jsont.Error.to_string e)
+
+
(** Convert a jsont-encoded value to form-urlencoded string *)
+
let to_form_urlencoded : 'a Jsont.t -> 'a -> string = fun codec value ->
+
(* First encode to JSON, then extract fields *)
+
let json_str = to_json_string codec value in
+
match Jsont_bytesrw.decode_string' Jsont.json json_str with
+
| Error e -> failwith ("JSON decode error: " ^ Jsont.Error.to_string e)
+
| Ok (Jsont.Object (fields, _)) ->
+
(* Convert object fields to form-urlencoded *)
+
let encode_value = function
+
| Jsont.String (s, _) -> Some (Uri.pct_encode ~component:`Query_value s)
+
| Jsont.Bool (b, _) -> Some (string_of_bool b)
+
| Jsont.Number (n, _) -> Some (string_of_float n)
+
| Jsont.Null _ -> None
+
| Jsont.Array (items, _) ->
+
(* For arrays, encode as JSON array string *)
+
let array_str = "[" ^ String.concat "," (List.filter_map (function
+
| Jsont.String (s, _) -> Some ("\"" ^ String.escaped s ^ "\"")
+
| Jsont.Number (n, _) -> Some (string_of_float n)
+
| Jsont.Bool (b, _) -> Some (string_of_bool b)
+
| _ -> None
+
) items) ^ "]" in
+
Some array_str
+
| Jsont.Object _ -> None (* Skip nested objects *)
+
in
+
+
let params = List.filter_map (fun ((key, _), value) ->
+
match encode_value value with
+
| Some encoded -> Some (key ^ "=" ^ encoded)
+
| None -> None
+
) fields in
+
+
String.concat "&" params
+
| Ok _ ->
+
failwith "Expected JSON object for form encoding"
+
+
(** Parse JSON string using a jsont codec *)
+
let from_json_string : 'a Jsont.t -> string -> ('a, string) result = fun codec json_str ->
+
match Jsont_bytesrw.decode_string' codec json_str with
+
| Ok v -> Ok v
+
| Error e -> Error (Jsont.Error.to_string e)
+
+
(** Parse a Jsont.json value using a codec *)
+
let from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result = fun codec json ->
+
let json_str = match Jsont_bytesrw.encode_string' Jsont.json json with
+
| Ok s -> s
+
| Error e -> failwith ("Failed to re-encode json: " ^ Jsont.Error.to_string e)
+
in
+
from_json_string codec json_str
+21
stack/zulip/lib/zulip/lib/encode.mli
···
+
(** Encoding utilities for Zulip API requests *)
+
+
(** Convert a value to JSON string using its jsont codec *)
+
val to_json_string : 'a Jsont.t -> 'a -> string
+
+
(** Convert a value to application/x-www-form-urlencoded string using its jsont codec
+
+
The codec should represent a JSON object. Fields will be converted to key=value pairs:
+
- Strings: URL-encoded
+
- Booleans: "true"/"false"
+
- Numbers: string representation
+
- Arrays: JSON array string "[...]"
+
- Null: omitted
+
- Nested objects: omitted *)
+
val to_form_urlencoded : 'a Jsont.t -> 'a -> string
+
+
(** Parse JSON string using a jsont codec *)
+
val from_json_string : 'a Jsont.t -> string -> ('a, string) result
+
+
(** Parse a Jsont.json value using a codec *)
+
val from_json : 'a Jsont.t -> Jsont.json -> ('a, string) result
+35 -24
stack/zulip/lib/zulip/lib/event.ml
···
let type_ t = t.type_
let data t = t.data
-
let of_json json =
+
let pp fmt t = Format.fprintf fmt "Event{id=%d, type=%a}" t.id Event_type.pp t.type_
+
+
(* Helper to extract fields from Jsont.json *)
+
let get_int_field json name =
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
(match List.assoc_opt name assoc with
+
| Some (Jsont.Number (n, _)) -> int_of_float n
+
| _ -> Jsont.Error.msg Jsont.Meta.none
+
(Format.sprintf "Field '%s' not found or not an int" name))
+
| _ -> Jsont.Error.msg Jsont.Meta.none "Expected JSON object"
+
+
let get_string_field json name =
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
(match List.assoc_opt name assoc with
+
| Some (Jsont.String (s, _)) -> s
+
| _ -> Jsont.Error.msg Jsont.Meta.none
+
(Format.sprintf "Field '%s' not found or not a string" name))
+
| _ -> Jsont.Error.msg Jsont.Meta.none "Expected JSON object"
+
+
(* Simple decoder that extracts id and type, keeping full JSON as data *)
+
let of_json_direct json =
try
-
match json with
-
| `O fields ->
-
let get_int key =
-
match List.assoc key fields with
-
| `Float f -> int_of_float f
-
| _ -> failwith ("Expected int for " ^ key) in
-
let get_string key =
-
match List.assoc key fields with
-
| `String s -> s
-
| _ -> failwith ("Expected string for " ^ key) in
-
let id = get_int "id" in
-
let type_str = get_string "type" in
-
let type_ = Event_type.of_string type_str in
-
(* The whole event is the data - store it all *)
-
let data = json in
+
let id = get_int_field json "id" in
+
let type_str = get_string_field json "type" in
+
let type_ = Event_type.of_string type_str in
+
Ok { id; type_; data = json }
+
with e ->
+
Error (Zulip_types.create_error ~code:(Other "json_parse_error")
+
~msg:("Event JSON parsing failed: " ^ Printexc.to_string e) ())
-
Ok { id; type_; data }
-
| _ ->
-
Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:"Event JSON must be an object" ())
-
with
-
| exn ->
-
Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:("Event JSON parsing failed: " ^ Printexc.to_string exn) ())
-
-
let pp fmt t = Format.fprintf fmt "Event{id=%d, type=%a}" t.id Event_type.pp t.type_
+
(* Decode function *)
+
let of_json json =
+
of_json_direct json
+79 -42
stack/zulip/lib/zulip/lib/event_queue.ml
···
id : string;
}
+
(* Request/response codecs *)
+
module Register_request = struct
+
type t = { event_types : string list option }
+
+
let codec =
+
Jsont.Object.(
+
map ~kind:"RegisterRequest" (fun event_types -> { event_types })
+
|> opt_mem "event_types" (Jsont.list Jsont.string) ~enc:(fun r -> r.event_types)
+
|> finish
+
)
+
end
+
+
module Register_response = struct
+
type t = { queue_id : string }
+
+
let codec =
+
Jsont.Object.(
+
map ~kind:"RegisterResponse" (fun queue_id -> { queue_id })
+
|> mem "queue_id" Jsont.string ~enc:(fun r -> r.queue_id)
+
|> finish
+
)
+
end
+
let register client ?event_types () =
-
let params = match event_types with
-
| None -> []
-
| Some types ->
-
let types_json = "[" ^
-
String.concat "," (List.map (fun t -> "\"" ^ Event_type.to_string t ^ "\"") types) ^
-
"]"
-
in
-
Log.debug (fun m -> m "Registering with event_types: %s" types_json);
-
[("event_types", types_json)]
-
in
-
match Client.request client ~method_:`POST ~path:"/api/v1/register" ~params () with
-
| Ok json ->
-
(match json with
-
| `O fields ->
-
(match List.assoc_opt "queue_id" fields with
-
| Some (`String queue_id) -> Ok { id = queue_id }
-
| _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Invalid register response: missing queue_id" ()))
-
| _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Register response must be an object" ()))
+
let event_types_str = Option.map (List.map Event_type.to_string) event_types in
+
let req = Register_request.{ event_types = event_types_str } in
+
let body = Encode.to_form_urlencoded Register_request.codec req in
+
let content_type = "application/x-www-form-urlencoded" in
+
+
(match event_types_str with
+
| Some types -> Log.debug (fun m -> m "Registering with event_types: %s" (String.concat "," types))
+
| None -> ());
+
+
match Client.request client ~method_:`POST ~path:"/api/v1/register" ~body ~content_type () with
+
| Ok json ->
+
(match Encode.from_json Register_response.codec json with
+
| Ok response -> Ok { id = response.queue_id }
+
| Error msg -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg ()))
| Error err -> Error err
let id t = t.id
-
let get_events t client ?last_event_id () =
-
let params = [("queue_id", t.id)] @
+
(* Events response codec - events field is optional (may not be present) *)
+
module Events_response = struct
+
type t = { events : Event.t list }
+
+
(* Custom codec that handles Event.t which has its own of_json *)
+
let codec =
+
let kind = "EventsResponse" in
+
let of_string s =
+
match Jsont_bytesrw.decode_string' Jsont.json s with
+
| Error e -> Error (Jsont.Error.to_string e)
+
| Ok (Jsont.Object (fields, _)) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
(match List.assoc_opt "events" assoc with
+
| Some (Jsont.Array (event_list, _)) ->
+
let events = List.fold_left (fun acc event_json ->
+
match Event.of_json event_json with
+
| Ok event -> event :: acc
+
| Error _ -> acc
+
) [] event_list in
+
Ok { events = List.rev events }
+
| None -> Ok { events = [] }
+
| _ -> Error "events field is not an array")
+
| Ok _ -> Error "Expected JSON object"
+
in
+
let enc _t =
+
(* Not used for responses, but required by codec *)
+
Fmt.str "{\"events\": []}"
+
in
+
Jsont.of_of_string ~kind of_string ~enc
+
end
+
+
let get_events t client ?last_event_id () =
+
let params = [("queue_id", t.id)] @
(match last_event_id with
| None -> []
| Some event_id -> [("last_event_id", string_of_int event_id)]) in
match Client.request client ~method_:`GET ~path:"/api/v1/events" ~params () with
-
| Ok json ->
-
(match json with
-
| `O fields ->
-
(match List.assoc_opt "events" fields with
-
| Some (`A event_list) ->
-
Log.debug (fun m -> m "Got %d raw events from API" (List.length event_list));
-
let events = List.fold_left (fun acc event_json ->
-
match Event.of_json event_json with
-
| Ok event -> event :: acc
-
| Error e ->
-
Log.warn (fun m -> m "Failed to parse event: %s" (Zulip_types.error_message e));
-
acc
-
) [] event_list in
-
Ok (List.rev events)
-
| Some _other ->
-
Log.warn (fun m -> m "Events field is not an array");
-
Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Invalid events response format" ())
-
| None ->
-
Log.debug (fun m -> m "No events field in response");
-
Ok [])
-
| _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Events response must be an object" ()))
+
| Ok json ->
+
(match Encode.from_json Events_response.codec json with
+
| Ok response ->
+
Log.debug (fun m -> m "Got %d events from API" (List.length response.events));
+
Ok response.events
+
| Error msg ->
+
Log.warn (fun m -> m "Failed to parse events response: %s" msg);
+
Error (Zulip_types.create_error ~code:(Other "api_error") ~msg ()))
| Error err -> Error err
-
let delete t client =
+
let delete t client =
let params = [("queue_id", t.id)] in
match Client.request client ~method_:`DELETE ~path:"/api/v1/events" ~params () with
| Ok _json -> Ok ()
-206
stack/zulip/lib/zulip/lib/jsonu.ml
···
-
(** JSON utility functions for Zulip API *)
-
-
type json = Zulip_types.json
-
-
(** {1 Field extraction utilities} *)
-
-
let get_string fields key =
-
match List.assoc_opt key fields with
-
| Some (`String s) -> Ok s
-
| Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a string" key) ())
-
| None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
-
-
let get_string_default fields key default =
-
match get_string fields key with
-
| Ok s -> s
-
| Error _ -> default
-
-
let get_string_opt fields key =
-
match List.assoc_opt key fields with
-
| Some (`String s) -> Some s
-
| _ -> None
-
-
let to_int_flex = function
-
| `Float f -> int_of_float f
-
| `String s -> (try int_of_string s with _ -> failwith "Invalid integer string")
-
| json -> failwith (Printf.sprintf "Expected int or float, got %s" (match json with
-
| `Null -> "null"
-
| `Bool _ -> "bool"
-
| `O _ -> "object"
-
| `A _ -> "array"
-
| _ -> "unknown"))
-
-
let get_int fields key =
-
match List.assoc_opt key fields with
-
| Some json ->
-
(try Ok (to_int_flex json) with
-
| Failure msg -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg ()))
-
| None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
-
-
let get_int_default fields key default =
-
match get_int fields key with
-
| Ok i -> i
-
| Error _ -> default
-
-
let get_int_opt fields key =
-
match List.assoc_opt key fields with
-
| Some json -> (try Some (to_int_flex json) with _ -> None)
-
| None -> None
-
-
let get_float fields key =
-
match List.assoc_opt key fields with
-
| Some (`Float f) -> Ok f
-
| Some (`String s) ->
-
(try Ok (float_of_string s) with
-
| _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a valid float" key) ()))
-
| Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a float" key) ())
-
| None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
-
-
let get_float_default fields key default =
-
match get_float fields key with
-
| Ok f -> f
-
| Error _ -> default
-
-
let get_bool fields key =
-
match List.assoc_opt key fields with
-
| Some (`Bool b) -> Ok b
-
| Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not a boolean" key) ())
-
| None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
-
-
let get_bool_default fields key default =
-
match get_bool fields key with
-
| Ok b -> b
-
| Error _ -> default
-
-
let get_bool_opt fields key =
-
match List.assoc_opt key fields with
-
| Some (`Bool b) -> Some b
-
| _ -> None
-
-
let get_object fields key =
-
match List.assoc_opt key fields with
-
| Some (`O obj) -> Ok obj
-
| Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not an object" key) ())
-
| None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
-
-
let get_object_opt fields key =
-
match List.assoc_opt key fields with
-
| Some (`O obj) -> Some obj
-
| _ -> None
-
-
let get_array fields key =
-
match List.assoc_opt key fields with
-
| Some (`A arr) -> Ok arr
-
| Some _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' is not an array" key) ())
-
| None -> Error (Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Field '%s' not found" key) ())
-
-
let get_array_opt fields key =
-
match List.assoc_opt key fields with
-
| Some (`A arr) -> Some arr
-
| _ -> None
-
-
(** {1 Type conversion utilities} *)
-
-
let to_int_safe = function
-
| `Float f -> Some (int_of_float f)
-
| `String s -> (try Some (int_of_string s) with _ -> None)
-
| _ -> None
-
-
let to_string_safe = function
-
| `String s -> Some s
-
| _ -> None
-
-
let to_bool_safe = function
-
| `Bool b -> Some b
-
| _ -> None
-
-
let to_float_safe = function
-
| `Float f -> Some f
-
| `String s -> (try Some (float_of_string s) with _ -> None)
-
| _ -> None
-
-
(** {1 Object parsing utilities} *)
-
-
let with_object context f = function
-
| `O fields -> f fields
-
| _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "%s: expected JSON object" context) ())
-
-
let with_array context f json =
-
match json with
-
| `A items ->
-
let rec process acc = function
-
| [] -> Ok (List.rev acc)
-
| item :: rest ->
-
match f item with
-
| Ok v -> process (v :: acc) rest
-
| Error e -> Error e
-
in
-
process [] items
-
| _ -> Error (Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "%s: expected JSON array" context) ())
-
-
(** {1 Construction utilities} *)
-
-
let optional_field key encoder = function
-
| Some value -> Some (key, encoder value)
-
| None -> None
-
-
let optional_fields fields =
-
List.filter_map (fun x -> x) fields
-
-
let string_array strings =
-
`A (List.map (fun s -> `String s) strings)
-
-
let int_array ints =
-
`A (List.map (fun i -> `Float (float_of_int i)) ints)
-
-
(** {1 Error handling} *)
-
-
let json_error msg =
-
Zulip_types.create_error ~code:(Other "json_error") ~msg ()
-
-
let field_missing_error field =
-
Zulip_types.create_error ~code:(Other "json_missing_field") ~msg:(Printf.sprintf "Required field '%s' not found" field) ()
-
-
let type_mismatch_error field expected =
-
Zulip_types.create_error ~code:(Other "json_type_error") ~msg:(Printf.sprintf "Field '%s' type mismatch: expected %s" field expected) ()
-
-
let parse_with_error context f =
-
try f ()
-
with
-
| Failure msg -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:(Printf.sprintf "%s: %s" context msg) ())
-
| exn -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg:(Printf.sprintf "%s: %s" context (Printexc.to_string exn)) ())
-
-
-
(** {1 Debugging utilities} *)
-
-
let to_string_pretty json =
-
let rec aux indent = function
-
| `Null -> "null"
-
| `Bool b -> string_of_bool b
-
| `Float f ->
-
if float_of_int (int_of_float f) = f then
-
string_of_int (int_of_float f)
-
else
-
string_of_float f
-
| `String s -> Printf.sprintf "%S" s
-
| `A [] -> "[]"
-
| `A lst ->
-
let items = List.map (aux (indent ^ " ")) lst in
-
Printf.sprintf "[\n%s%s\n%s]"
-
(indent ^ " ")
-
(String.concat (",\n" ^ indent ^ " ") items)
-
indent
-
| `O [] -> "{}"
-
| `O fields ->
-
let items = List.map (fun (k, v) ->
-
Printf.sprintf "%S: %s" k (aux (indent ^ " ") v)
-
) fields in
-
Printf.sprintf "{\n%s%s\n%s}"
-
(indent ^ " ")
-
(String.concat (",\n" ^ indent ^ " ") items)
-
indent
-
in
-
aux "" json
-
-
let pp fmt json =
-
Format.pp_print_string fmt (to_string_pretty json)
-117
stack/zulip/lib/zulip/lib/jsonu.mli
···
-
(** JSON utility functions for Zulip API
-
-
This module provides common utilities for working with JSON in the Zulip API,
-
reducing boilerplate and providing consistent error handling. *)
-
-
(** {1 Type definitions} *)
-
-
type json = Zulip_types.json
-
-
(** {1 Field extraction utilities} *)
-
-
(** Extract a string field from a JSON object *)
-
val get_string : (string * json) list -> string -> (string, Zulip_types.zerror) result
-
-
(** Extract a string field with a default value *)
-
val get_string_default : (string * json) list -> string -> string -> string
-
-
(** Extract an optional string field *)
-
val get_string_opt : (string * json) list -> string -> string option
-
-
(** Extract an integer field (handles both int and float representations) *)
-
val get_int : (string * json) list -> string -> (int, Zulip_types.zerror) result
-
-
(** Extract an integer field with a default value *)
-
val get_int_default : (string * json) list -> string -> int -> int
-
-
(** Extract an optional integer field *)
-
val get_int_opt : (string * json) list -> string -> int option
-
-
(** Extract a float field *)
-
val get_float : (string * json) list -> string -> (float, Zulip_types.zerror) result
-
-
(** Extract a float field with a default value *)
-
val get_float_default : (string * json) list -> string -> float -> float
-
-
(** Extract a boolean field *)
-
val get_bool : (string * json) list -> string -> (bool, Zulip_types.zerror) result
-
-
(** Extract a boolean field with a default value *)
-
val get_bool_default : (string * json) list -> string -> bool -> bool
-
-
(** Extract an optional boolean field *)
-
val get_bool_opt : (string * json) list -> string -> bool option
-
-
(** Extract a JSON object field *)
-
val get_object : (string * json) list -> string -> ((string * json) list, Zulip_types.zerror) result
-
-
(** Extract an optional JSON object field *)
-
val get_object_opt : (string * json) list -> string -> (string * json) list option
-
-
(** Extract a JSON array field *)
-
val get_array : (string * json) list -> string -> (json list, Zulip_types.zerror) result
-
-
(** Extract an optional JSON array field *)
-
val get_array_opt : (string * json) list -> string -> json list option
-
-
(** {1 Type conversion utilities} *)
-
-
(** Convert JSON to int, handling both int and float representations *)
-
val to_int_flex : json -> int
-
-
(** Safely convert JSON to int *)
-
val to_int_safe : json -> int option
-
-
(** Convert JSON to string *)
-
val to_string_safe : json -> string option
-
-
(** Convert JSON to bool *)
-
val to_bool_safe : json -> bool option
-
-
(** Convert JSON to float *)
-
val to_float_safe : json -> float option
-
-
(** {1 Object parsing utilities} *)
-
-
(** Parse a JSON value as an object, applying a function to its fields *)
-
val with_object : string -> ((string * json) list -> ('a, Zulip_types.zerror) result) -> json -> ('a, Zulip_types.zerror) result
-
-
(** Parse a JSON value as an array, applying a function to each element *)
-
val with_array : string -> (json -> ('a, Zulip_types.zerror) result) -> json -> ('a list, Zulip_types.zerror) result
-
-
(** {1 Construction utilities} *)
-
-
(** Create an optional field for JSON object construction *)
-
val optional_field : string -> ('a -> json) -> 'a option -> (string * json) option
-
-
(** Create a list of optional fields, filtering out None values *)
-
val optional_fields : (string * json) option list -> (string * json) list
-
-
(** Convert a string list to a JSON array *)
-
val string_array : string list -> json
-
-
(** Convert an int list to a JSON array *)
-
val int_array : int list -> json
-
-
(** {1 Error handling} *)
-
-
(** Create a JSON parsing error *)
-
val json_error : string -> Zulip_types.zerror
-
-
(** Create a field missing error *)
-
val field_missing_error : string -> Zulip_types.zerror
-
-
(** Create a type mismatch error *)
-
val type_mismatch_error : string -> string -> Zulip_types.zerror
-
-
(** Wrap a parsing function with exception handling *)
-
val parse_with_error : string -> (unit -> ('a, Zulip_types.zerror) result) -> ('a, Zulip_types.zerror) result
-
-
-
(** {1 Debugging utilities} *)
-
-
(** Convert JSON to a pretty-printed string *)
-
val to_string_pretty : json -> string
-
-
(** Print JSON value for debugging *)
-
val pp : Format.formatter -> json -> unit
-144
stack/zulip/lib/zulip/lib/jsonu_syntax.ml
···
-
(** Syntax module for monadic and applicative JSON parsing *)
-
-
type json = Zulip_types.json
-
type 'a parser = json -> ('a, Zulip_types.zerror) result
-
-
(** Monadic bind operator for sequential parsing with error handling *)
-
let ( let* ) = Result.bind
-
-
(** Map operator for transforming successful results *)
-
let ( let+ ) x f = Result.map f x
-
-
(** Applicative parallel composition *)
-
let ( and+ ) x y =
-
match x, y with
-
| Ok x, Ok y -> Ok (x, y)
-
| Error e, _ | _, Error e -> Error e
-
-
(** Applicative parallel composition for 3 values *)
-
let ( and++ ) xy z =
-
match xy, z with
-
| Ok (x, y), Ok z -> Ok (x, y, z)
-
| Error e, _ | _, Error e -> Error e
-
-
(** Applicative parallel composition for 4 values *)
-
let ( and+++ ) xyz w =
-
match xyz, w with
-
| Ok (x, y, z), Ok w -> Ok (x, y, z, w)
-
| Error e, _ | _, Error e -> Error e
-
-
(** Applicative parallel composition for 5 values *)
-
let ( and++++ ) xyzw v =
-
match xyzw, v with
-
| Ok (x, y, z, w), Ok v -> Ok (x, y, z, w, v)
-
| Error e, _ | _, Error e -> Error e
-
-
(** Alternative operator - try first, if fails try second *)
-
let ( <|> ) x y =
-
match x with
-
| Ok _ -> x
-
| Error _ -> y
-
-
(** Provide a default value if parsing fails *)
-
let ( |? ) x default =
-
match x with
-
| Ok v -> v
-
| Error _ -> default
-
-
(** Convert option to result with error message *)
-
let required name = function
-
| Some v -> Ok v
-
| None -> Error (Zulip_types.create_error ~code:(Other "missing_field") ~msg:(Printf.sprintf "Required field '%s' not found" name) ())
-
-
(** Convert option to result with default *)
-
let default v = function
-
| Some x -> x
-
| None -> v
-
-
(** Lift a pure value into parser context *)
-
let pure x = Ok x
-
-
(** Fail with an error message *)
-
let fail msg = Error (Zulip_types.create_error ~code:(Other "parse_error") ~msg ())
-
-
(** Map over a list with error handling *)
-
let traverse f lst =
-
let rec go acc = function
-
| [] -> Ok (List.rev acc)
-
| x :: xs ->
-
let* v = f x in
-
go (v :: acc) xs
-
in
-
go [] lst
-
-
(** Filter and map over a list, dropping errors *)
-
let filter_map f lst =
-
List.filter_map (fun x ->
-
match f x with
-
| Ok v -> Some v
-
| Error _ -> None
-
) lst
-
-
(** Parse a field with a custom parser *)
-
let field fields key parser =
-
match List.assoc_opt key fields with
-
| Some json -> parser json
-
| None -> Error (Jsonu.field_missing_error key)
-
-
(** Parse an optional field with a custom parser *)
-
let field_opt fields key parser =
-
match List.assoc_opt key fields with
-
| Some json ->
-
(match parser json with
-
| Ok v -> Ok (Some v)
-
| Error _ -> Ok None)
-
| None -> Ok None
-
-
(** Parse a field with a default value if missing or fails *)
-
let field_or fields key parser default =
-
match List.assoc_opt key fields with
-
| Some json ->
-
(match parser json with
-
| Ok v -> Ok v
-
| Error _ -> Ok default)
-
| None -> Ok default
-
-
(** Common parsers *)
-
let string = function
-
| `String s -> Ok s
-
| _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected string" ())
-
-
let int = function
-
| `Float f -> Ok (int_of_float f)
-
| `String s ->
-
(try Ok (int_of_string s)
-
with _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected integer" ()))
-
| _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected integer" ())
-
-
let float = function
-
| `Float f -> Ok f
-
| `String s ->
-
(try Ok (float_of_string s)
-
with _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected float" ()))
-
| _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected float" ())
-
-
let bool = function
-
| `Bool b -> Ok b
-
| _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected boolean" ())
-
-
let array parser = function
-
| `A items -> traverse parser items
-
| _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected array" ())
-
-
let object_ = function
-
| `O fields -> Ok fields
-
| _ -> Error (Zulip_types.create_error ~code:(Other "type_error") ~msg:"Expected object" ())
-
-
(** Run a parser on JSON *)
-
let parse parser json = parser json
-
-
(** Run a parser with error context *)
-
let with_context ctx parser json =
-
match parser json with
-
| Ok v -> Ok v
-
| 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
···
-
(** Syntax module for monadic and applicative JSON parsing
-
-
This module provides binding operators and combinators to make JSON parsing
-
more ergonomic and composable. It enables code like:
-
-
{[
-
let parse_user json =
-
with_object "user" @@ fun fields ->
-
let+ user_id = field fields "user_id" int
-
and+ email = field fields "email" string
-
and+ full_name = field fields "full_name" string in
-
{ user_id; email; full_name }
-
]}
-
*)
-
-
type json = Zulip_types.json
-
type 'a parser = json -> ('a, Zulip_types.zerror) result
-
-
(** {1 Binding Operators} *)
-
-
(** Monadic bind operator for sequential parsing with error handling *)
-
val ( let* ) : ('a, 'e) result -> ('a -> ('b, 'e) result) -> ('b, 'e) result
-
-
(** Map operator for transforming successful results *)
-
val ( let+ ) : ('a, 'e) result -> ('a -> 'b) -> ('b, 'e) result
-
-
(** Applicative parallel composition for independent field extraction *)
-
val ( and+ ) : ('a, 'e) result -> ('b, 'e) result -> ('a * 'b, 'e) result
-
val ( and++ ) : ('a * 'b, 'e) result -> ('c, 'e) result -> ('a * 'b * 'c, 'e) result
-
val ( and+++ ) : ('a * 'b * 'c, 'e) result -> ('d, 'e) result -> ('a * 'b * 'c * 'd, 'e) result
-
val ( and++++ ) : ('a * 'b * 'c * 'd, 'e) result -> ('f, 'e) result -> ('a * 'b * 'c * 'd * 'f, 'e) result
-
-
(** {1 Alternative and Default Operators} *)
-
-
(** Alternative operator - try first parser, if fails try second *)
-
val ( <|> ) : ('a, 'e) result -> ('a, 'e) result -> ('a, 'e) result
-
-
(** Provide a default value if parsing fails *)
-
val ( |? ) : ('a, 'e) result -> 'a -> 'a
-
-
(** {1 Field Extraction} *)
-
-
(** Parse a required field with a custom parser *)
-
val field : (string * json) list -> string -> 'a parser -> ('a, Zulip_types.zerror) result
-
-
(** Parse an optional field with a custom parser *)
-
val field_opt : (string * json) list -> string -> 'a parser -> ('a option, Zulip_types.zerror) result
-
-
(** Parse a field with a default value if missing or fails *)
-
val field_or : (string * json) list -> string -> 'a parser -> 'a -> ('a, Zulip_types.zerror) result
-
-
(** {1 Basic Parsers} *)
-
-
(** Parse a JSON string *)
-
val string : string parser
-
-
(** Parse a JSON number as integer (handles both int and float) *)
-
val int : int parser
-
-
(** Parse a JSON number as float *)
-
val float : float parser
-
-
(** Parse a JSON boolean *)
-
val bool : bool parser
-
-
(** Parse a JSON array with a parser for elements *)
-
val array : 'a parser -> 'a list parser
-
-
(** Parse a JSON object to get its fields *)
-
val object_ : json -> ((string * json) list, Zulip_types.zerror) result
-
-
(** {1 Utility Functions} *)
-
-
(** Convert option to result with error message *)
-
val required : string -> 'a option -> ('a, Zulip_types.zerror) result
-
-
(** Get value from option with default *)
-
val default : 'a -> 'a option -> 'a
-
-
(** Lift a pure value into parser context *)
-
val pure : 'a -> ('a, 'e) result
-
-
(** Fail with an error message *)
-
val fail : string -> ('a, Zulip_types.zerror) result
-
-
(** Map over a list with error handling *)
-
val traverse : ('a -> ('b, Zulip_types.zerror) result) -> 'a list -> ('b list, Zulip_types.zerror) result
-
-
(** Filter and map over a list, dropping errors *)
-
val filter_map : ('a -> ('b, Zulip_types.zerror) result) -> 'a list -> 'b list
-
-
(** Run a parser on JSON *)
-
val parse : 'a parser -> json -> ('a, Zulip_types.zerror) result
-
-
(** Run a parser with error context *)
-
val with_context : string -> 'a parser -> 'a parser
+35 -21
stack/zulip/lib/zulip/lib/message.ml
···
let local_id t = t.local_id
let read_by_sender t = t.read_by_sender
-
let to_json t =
-
let base_fields = [
-
("type", `String (Message_type.to_string t.type_));
-
("to", `A (List.map (fun s -> `String s) t.to_));
-
("content", `String t.content);
-
("read_by_sender", `Bool t.read_by_sender);
-
] in
-
let with_topic = match t.topic with
-
| Some topic -> ("topic", `String topic) :: base_fields
-
| None -> base_fields in
-
let with_queue_id = match t.queue_id with
-
| Some qid -> ("queue_id", `String qid) :: with_topic
-
| None -> with_topic in
-
let with_local_id = match t.local_id with
-
| Some lid -> ("local_id", `String lid) :: with_queue_id
-
| None -> with_queue_id in
-
`O with_local_id
+
let pp fmt t = Format.fprintf fmt "Message{type=%a, to=%s, content=%s}"
+
Message_type.pp t.type_
+
(String.concat "," t.to_)
+
t.content
-
let pp fmt t = Format.fprintf fmt "Message{type=%a, to=%s, content=%s}"
-
Message_type.pp t.type_
-
(String.concat "," t.to_)
-
t.content
+
(* Jsont codec for Message_type.t *)
+
let message_type_jsont =
+
let of_string s = match Message_type.of_string s with
+
| Some t -> Ok t
+
| None -> Error (Format.sprintf "Invalid message type: %s" s)
+
in
+
Jsont.of_of_string ~kind:"Message_type.t" of_string ~enc:Message_type.to_string
+
+
(* Jsont codec for the message *)
+
let jsont =
+
let kind = "Message" in
+
let doc = "A Zulip message to be sent" in
+
let make type_ to_ content topic queue_id local_id read_by_sender =
+
{ type_; to_; content; topic; queue_id; local_id; read_by_sender }
+
in
+
Jsont.Object.map ~kind ~doc make
+
|> Jsont.Object.mem "type" message_type_jsont ~enc:type_
+
|> Jsont.Object.mem "to" (Jsont.list Jsont.string) ~enc:to_
+
|> Jsont.Object.mem "content" Jsont.string ~enc:content
+
|> Jsont.Object.opt_mem "topic" Jsont.string ~enc:topic
+
|> Jsont.Object.opt_mem "queue_id" Jsont.string ~enc:queue_id
+
|> Jsont.Object.opt_mem "local_id" Jsont.string ~enc:local_id
+
|> Jsont.Object.mem "read_by_sender" Jsont.bool ~enc:read_by_sender
+
|> Jsont.Object.finish
+
+
(* Encoding functions *)
+
let to_json_string t =
+
Encode.to_json_string jsont t
+
+
let to_form_urlencoded t =
+
Encode.to_form_urlencoded jsont t
+18 -9
stack/zulip/lib/zulip/lib/message.mli
···
type t
-
val create :
-
type_:Message_type.t ->
-
to_:string list ->
-
content:string ->
-
?topic:string ->
-
?queue_id:string ->
-
?local_id:string ->
-
?read_by_sender:bool ->
+
val create :
+
type_:Message_type.t ->
+
to_:string list ->
+
content:string ->
+
?topic:string ->
+
?queue_id:string ->
+
?local_id:string ->
+
?read_by_sender:bool ->
unit -> t
val type_ : t -> Message_type.t
···
val queue_id : t -> string option
val local_id : t -> string option
val read_by_sender : t -> bool
-
val to_json : t -> Zulip_types.json
+
+
(** Jsont codec for the message type *)
+
val jsont : t Jsont.t
+
+
(** Encode to JSON string *)
+
val to_json_string : t -> string
+
+
(** Encode to form-urlencoded string *)
+
val to_form_urlencoded : t -> string
+
val pp : Format.formatter -> t -> unit
+20 -8
stack/zulip/lib/zulip/lib/message_response.ml
···
let id t = t.id
let automatic_new_visibility_policy t = t.automatic_new_visibility_policy
+
let pp fmt t = Format.fprintf fmt "MessageResponse{id=%d}" t.id
+
+
(* Jsont codec for message response *)
+
let jsont =
+
let kind = "MessageResponse" in
+
let doc = "A Zulip message response" in
+
let make id automatic_new_visibility_policy =
+
{ id; automatic_new_visibility_policy }
+
in
+
Jsont.Object.map ~kind ~doc make
+
|> Jsont.Object.mem "id" Jsont.int ~enc:id
+
|> Jsont.Object.opt_mem "automatic_new_visibility_policy" Jsont.string ~enc:automatic_new_visibility_policy
+
|> Jsont.Object.finish
+
+
(* Decode and encode functions using Encode module *)
let of_json json =
-
Jsonu.with_object "message_response" (fun fields ->
-
match Jsonu.get_int fields "id" with
-
| Error e -> Error e
-
| Ok id ->
-
let automatic_new_visibility_policy = Jsonu.get_string_opt fields "automatic_new_visibility_policy" in
-
Ok { id; automatic_new_visibility_policy }
-
) json
+
match Encode.from_json jsont json with
+
| Ok v -> Ok v
+
| Error msg -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg ())
-
let pp fmt t = Format.fprintf fmt "MessageResponse{id=%d}" t.id
+
let to_json_string t =
+
Encode.to_json_string jsont t
+5
stack/zulip/lib/zulip/lib/message_response.mli
···
val id : t -> int
val automatic_new_visibility_policy : t -> string option
+
+
(** Jsont codec for message response *)
+
val jsont : t Jsont.t
+
val of_json : Zulip_types.json -> (t, Zulip_types.zerror) result
+
val to_json_string : t -> string
val pp : Format.formatter -> t -> unit
+15 -62
stack/zulip/lib/zulip/lib/messages.ml
···
let send client message =
-
let json = Message.to_json message in
-
let params = match json with
-
| `O fields ->
-
List.fold_left (fun acc (key, value) ->
-
let str_value = match value with
-
| `String s -> s
-
| `Bool true -> "true"
-
| `Bool false -> "false"
-
| `A arr -> String.concat "," (List.map (function `String s -> s | _ -> "") arr)
-
| _ -> ""
-
in
-
(key, str_value) :: acc
-
) [] fields
-
| _ -> [] in
-
-
match Client.request client ~method_:`POST ~path:"/api/v1/messages" ~params () with
+
(* Use form-urlencoded encoding for the message *)
+
let body = Message.to_form_urlencoded message in
+
let content_type = "application/x-www-form-urlencoded" in
+
+
match Client.request client ~method_:`POST ~path:"/api/v1/messages" ~body ~content_type () with
| Ok response -> Message_response.of_json response
| Error err -> Error err
let edit client ~message_id ?content ?topic () =
-
let params =
+
let params =
(("message_id", string_of_int message_id) ::
(match content with Some c -> [("content", c)] | None -> []) @
(match topic with Some t -> [("topic", t)] | None -> [])) in
-
+
match Client.request client ~method_:`PATCH ~path:("/api/v1/messages/" ^ string_of_int message_id) ~params () with
| Ok _ -> Ok ()
| Error err -> Error err
···
let add_reaction client ~message_id ~emoji_name =
let params = [
("emoji_name", emoji_name);
-
("reaction_type", "unicode_emoji");
] in
match Client.request client ~method_:`POST
-
~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
-
~params () with
-
| Ok _ -> Ok ()
+
~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions") ~params () with
+
| Ok _json -> Ok ()
| Error err -> Error err
let remove_reaction client ~message_id ~emoji_name =
let params = [
("emoji_name", emoji_name);
-
("reaction_type", "unicode_emoji");
] in
match Client.request client ~method_:`DELETE
-
~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions")
-
~params () with
-
| Ok _ -> Ok ()
+
~path:("/api/v1/messages/" ^ string_of_int message_id ^ "/reactions") ~params () with
+
| Ok _json -> Ok ()
| Error err -> Error err
-
let upload_file client ~filename =
-
(* Read file contents *)
-
let ic = open_in_bin filename in
-
let len = in_channel_length ic in
-
let content = really_input_string ic len in
-
close_in ic;
-
-
(* Extract just the filename from the path *)
-
let basename = Filename.basename filename in
-
-
(* Create multipart form data boundary *)
-
let boundary = "----OCamlZulipBoundary" ^ string_of_float (Unix.gettimeofday ()) in
-
-
(* Build multipart body *)
-
let body = Buffer.create (len + 1024) in
-
Buffer.add_string body ("--" ^ boundary ^ "\r\n");
-
Buffer.add_string body ("Content-Disposition: form-data; name=\"file\"; filename=\"" ^ basename ^ "\"\r\n");
-
Buffer.add_string body "Content-Type: application/octet-stream\r\n";
-
Buffer.add_string body "\r\n";
-
Buffer.add_string body content;
-
Buffer.add_string body ("\r\n--" ^ boundary ^ "--\r\n");
-
-
let body_str = Buffer.contents body in
-
let content_type = "multipart/form-data; boundary=" ^ boundary in
-
-
match Client.request client ~method_:`POST ~path:"/api/v1/user_uploads"
-
~body:body_str ~content_type () with
-
| Ok json ->
-
(* Parse response to extract URI *)
-
(match json with
-
| `O fields ->
-
(match Jsonu.get_string fields "uri" with
-
| Ok uri -> Ok uri
-
| Error e -> Error e)
-
| _ -> Error (Zulip_types.create_error ~code:(Zulip_types.Other "upload_error") ~msg:"Failed to parse upload response" ()))
-
| Error err -> Error err
+
let upload_file _client ~filename:_ =
+
(* TODO: Implement file upload using multipart/form-data *)
+
Error (Zulip_types.create_error ~code:(Other "not_implemented")
+
~msg:"File upload not yet implemented" ())
+25 -18
stack/zulip/lib/zulip/lib/user.ml
···
let is_admin t = t.is_admin
let is_bot t = t.is_bot
-
let to_json t =
-
`O [
-
("email", `String t.email);
-
("full_name", `String t.full_name);
-
("is_active", `Bool t.is_active);
-
("is_admin", `Bool t.is_admin);
-
("is_bot", `Bool t.is_bot);
-
]
+
let pp fmt t = Format.fprintf fmt "User{email=%s, full_name=%s}" t.email t.full_name
+
+
(* Jsont codec for user *)
+
let jsont =
+
let kind = "User" in
+
let doc = "A Zulip user" in
+
let make email full_name is_active is_admin is_bot =
+
{ email; full_name; is_active; is_admin; is_bot }
+
in
+
Jsont.Object.map ~kind ~doc make
+
|> Jsont.Object.mem "email" Jsont.string ~enc:email
+
|> Jsont.Object.mem "full_name" Jsont.string ~enc:full_name
+
|> Jsont.Object.mem "is_active" Jsont.bool ~enc:is_active
+
|> Jsont.Object.mem "is_admin" Jsont.bool ~enc:is_admin
+
|> Jsont.Object.mem "is_bot" Jsont.bool ~enc:is_bot
+
|> Jsont.Object.finish
+
(* Decode and encode functions using Encode module *)
let of_json json =
-
Jsonu.with_object "user" (fun fields ->
-
match Jsonu.get_string fields "email", Jsonu.get_string fields "full_name" with
-
| Ok email, Ok full_name ->
-
let is_active = Jsonu.get_bool_default fields "is_active" true in
-
let is_admin = Jsonu.get_bool_default fields "is_admin" false in
-
let is_bot = Jsonu.get_bool_default fields "is_bot" false in
-
Ok { email; full_name; is_active; is_admin; is_bot }
-
| Error e, _ | _, Error e -> Error e
-
) json
+
match Encode.from_json jsont json with
+
| Ok v -> Ok v
+
| Error msg -> Error (Zulip_types.create_error ~code:(Other "json_parse_error") ~msg ())
+
+
let to_json_string t =
+
Encode.to_json_string jsont t
-
let pp fmt t = Format.fprintf fmt "User{email=%s, full_name=%s}" t.email t.full_name
+
let to_form_urlencoded t =
+
Encode.to_form_urlencoded jsont t
+18 -7
stack/zulip/lib/zulip/lib/user.mli
···
type t
-
val create :
-
email:string ->
-
full_name:string ->
-
?is_active:bool ->
-
?is_admin:bool ->
-
?is_bot:bool ->
+
val create :
+
email:string ->
+
full_name:string ->
+
?is_active:bool ->
+
?is_admin:bool ->
+
?is_bot:bool ->
unit -> t
val email : t -> string
···
val is_active : t -> bool
val is_admin : t -> bool
val is_bot : t -> bool
-
val to_json : t -> Zulip_types.json
+
+
(** Jsont codec for the user type *)
+
val jsont : t Jsont.t
+
+
(** Decode from Jsont.json *)
val of_json : Zulip_types.json -> (t, Zulip_types.zerror) result
+
+
(** Encode to JSON string *)
+
val to_json_string : t -> string
+
+
(** Encode to form-urlencoded string *)
+
val to_form_urlencoded : t -> string
+
val pp : Format.formatter -> t -> unit
+32 -28
stack/zulip/lib/zulip/lib/users.ml
···
-
let list client =
+
let list client =
+
(* Define response codec *)
+
let response_codec =
+
Jsont.Object.(
+
map ~kind:"UsersResponse" (fun members -> members)
+
|> mem "members" (Jsont.list User.jsont) ~enc:(fun x -> x)
+
|> finish
+
)
+
in
+
match Client.request client ~method_:`GET ~path:"/api/v1/users" () with
-
| Ok json ->
-
(match json with
-
| `O fields ->
-
(match List.assoc_opt "members" fields with
-
| Some (`A user_list) ->
-
let users = List.fold_left (fun acc user_json ->
-
match User.of_json user_json with
-
| Ok user -> user :: acc
-
| Error _ -> acc
-
) [] user_list in
-
Ok (List.rev users)
-
| _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Invalid users response format" ()))
-
| _ -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg:"Users response must be an object" ()))
+
| Ok json ->
+
(match Encode.from_json response_codec json with
+
| Ok users -> Ok users
+
| Error msg -> Error (Zulip_types.create_error ~code:(Other "api_error") ~msg ()))
| Error err -> Error err
let get client ~email =
···
| Error err -> Error err)
| Error err -> Error err
-
let create_user client ~email ~full_name =
-
let body_json = `O [
-
("email", `String email);
-
("full_name", `String full_name);
-
] in
-
let body = match body_json with
-
| `O fields ->
-
String.concat "&" (List.map (fun (k, v) ->
-
match v with
-
| `String s -> k ^ "=" ^ s
-
| _ -> ""
-
) fields)
-
| _ -> "" in
-
match Client.request client ~method_:`POST ~path:"/api/v1/users" ~body () with
+
(* Request type for create_user *)
+
module Create_user_request = struct
+
type t = { email : string; full_name : string }
+
+
let codec =
+
Jsont.Object.(
+
map ~kind:"CreateUserRequest" (fun email full_name -> { email; full_name })
+
|> mem "email" Jsont.string ~enc:(fun r -> r.email)
+
|> mem "full_name" Jsont.string ~enc:(fun r -> r.full_name)
+
|> finish
+
)
+
end
+
+
let create_user client ~email ~full_name =
+
let req = Create_user_request.{ email; full_name } in
+
let body = Encode.to_form_urlencoded Create_user_request.codec req in
+
let content_type = "application/x-www-form-urlencoded" in
+
match Client.request client ~method_:`POST ~path:"/api/v1/users" ~body ~content_type () with
| Ok _json -> Ok ()
| Error err -> Error err
+1 -5
stack/zulip/lib/zulip/lib/zulip.ml
···
module Event = Event
module Event_type = Event_type
module Event_queue = Event_queue
-
-
(** JSON utilities with short alias *)
-
module J = Jsonu
-
module Jsonu_syntax = Jsonu_syntax
-
module Jsonu = Jsonu
+
module Encode = Encode
+2 -10
stack/zulip/lib/zulip/lib/zulip.mli
···
module Event_type = Event_type
module Event_queue = Event_queue
-
(** {1 JSON Utilities} *)
-
-
(** JSON utility functions (abbreviated as J for convenience) *)
-
module J = Jsonu
-
-
(** JSON parsing syntax extensions *)
-
module Jsonu_syntax = Jsonu_syntax
-
-
(** Full JSON utilities module *)
-
module Jsonu = Jsonu
+
(** JSON encoding/decoding utilities *)
+
module Encode = Encode
+32 -15
stack/zulip/lib/zulip/lib/zulip_types.ml
···
(** Core types for Zulip API *)
(** JSON type used throughout the API *)
-
type json = [`Null | `Bool of bool | `Float of float | `String of string | `A of json list | `O of (string * json) list]
+
type json = Jsont.json
(** Error codes returned by Zulip API *)
type error_code =
···
let pp_error fmt t = Format.fprintf fmt "Error(%s): %s"
(error_code_to_string t.code) t.message
+
(* Jsont codec for error_code *)
+
let error_code_jsont =
+
let of_string s = Ok (error_code_of_string s) in
+
Jsont.of_of_string ~kind:"ErrorCode" of_string ~enc:error_code_to_string
+
+
(* Jsont codec for zerror *)
+
let zerror_jsont =
+
let kind = "ZulipError" in
+
let make code msg =
+
(* Extra fields handled by keep_unknown - we'll extract them separately *)
+
{ code = error_code_of_string code; message = msg; extra = [] }
+
in
+
let code t = error_code_to_string t.code in
+
let msg t = t.message in
+
Jsont.Object.(
+
map ~kind make
+
|> mem "code" Jsont.string ~enc:code
+
|> mem "msg" Jsont.string ~enc:msg
+
|> finish
+
)
+
let error_of_json json =
-
match json with
-
| `O fields ->
-
(try
-
let code_str = match List.assoc "code" fields with
-
| `String s -> s
-
| _ -> "OTHER" in
-
let msg = match List.assoc "msg" fields with
-
| `String s -> s
-
| _ -> "Unknown error" in
-
let code = error_code_of_string code_str in
-
let extra = List.filter (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result") fields in
-
Some (create_error ~code ~msg ~extra ())
-
with Not_found -> None)
-
| _ -> None
+
match Encode.from_json zerror_jsont json with
+
| Ok err ->
+
(* Extract extra fields by getting all fields except code, msg, result *)
+
(match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
let extra = List.filter (fun (k, _) -> k <> "code" && k <> "msg" && k <> "result") assoc in
+
Some { err with extra }
+
| _ -> Some err)
+
| Error _ -> None
+6 -1
stack/zulip/lib/zulip/lib/zulip_types.mli
···
(** Core types for Zulip API *)
(** JSON type used throughout the API *)
-
type json = [`Null | `Bool of bool | `Float of float | `String of string | `A of json list | `O of (string * json) list]
+
type json = Jsont.json
(** Error codes returned by Zulip API *)
type error_code =
···
val error_message : zerror -> string
val error_extra : zerror -> (string * json) list
val pp_error : Format.formatter -> zerror -> unit
+
+
(** Jsont codecs *)
+
val error_code_jsont : error_code Jsont.t
+
val zerror_jsont : zerror Jsont.t
+
val error_of_json : json -> zerror option
+6 -5
stack/zulip/lib/zulip_bot/lib/bot_runner.ml
···
(* Extract the actual message from the event *)
let message_json, flags =
match event_data with
-
| `O fields ->
-
let msg = match List.assoc_opt "message" fields with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
let msg = match List.assoc_opt "message" assoc with
| Some m -> m
| None -> event_data (* Fallback if structure is different *)
in
-
let flgs = match List.assoc_opt "flags" fields with
-
| Some (`A f) -> f
+
let flgs = match List.assoc_opt "flags" assoc with
+
| Some (Jsont.Array (f, _)) -> f
| _ -> []
in
(msg, flgs)
···
(* Check if mentioned *)
let is_mentioned =
-
List.exists (function `String "mentioned" -> true | _ -> false) flags ||
+
List.exists (function Jsont.String ("mentioned", _) -> true | _ -> false) flags ||
Message.is_mentioned message ~user_email:bot_email in
(* Check if it's a private message *)
+81 -29
stack/zulip/lib/zulip_bot/lib/bot_storage.ml
···
mutable dirty_keys : string list;
}
+
(** {1 JSON Codecs for Bot Storage} *)
+
+
(* Storage response type - {"storage": {...}} *)
+
type storage_response = {
+
storage : (string * string) list;
+
unknown : Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
+
}
+
+
(* Custom codec for storage_response that handles the dictionary *)
+
let storage_response_jsont : storage_response Jsont.t =
+
let of_string s =
+
match Jsont_bytesrw.decode_string' Jsont.json s with
+
| Error _ -> Error "Failed to decode JSON"
+
| Ok json ->
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
(match List.assoc_opt "storage" assoc with
+
| Some (Jsont.Object (storage_fields, _)) ->
+
let storage = List.filter_map (fun ((k, _), v) ->
+
match v with
+
| Jsont.String (s, _) -> Some (k, s)
+
| _ -> None
+
) storage_fields in
+
(* Keep unknown fields *)
+
let unknown_fields = List.filter (fun (k, _) -> k <> "storage") assoc in
+
let unknown_mems = List.map (fun (k, v) -> ((k, Jsont.Meta.none), v)) unknown_fields in
+
let unknown = Jsont.Object (unknown_mems, Jsont.Meta.none) in
+
Ok { storage; unknown }
+
| Some _ -> Error "Expected 'storage' field to be an object"
+
| None -> Ok { storage = []; unknown = Jsont.Object ([], Jsont.Meta.none) })
+
| _ -> Error "Expected JSON object for storage response"
+
in
+
let to_string { storage; unknown } =
+
(* Create storage object *)
+
let storage_fields = List.map (fun (k, v) ->
+
((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))
+
) storage in
+
let storage_obj = Jsont.Object (storage_fields, Jsont.Meta.none) in
+
+
(* Merge with unknown fields *)
+
let storage_mem = (("storage", Jsont.Meta.none), storage_obj) in
+
let unknown_mems = match unknown with
+
| Jsont.Object (fields, _) -> fields
+
| _ -> []
+
in
+
let json = Jsont.Object (storage_mem :: unknown_mems, Jsont.Meta.none) in
+
match Jsont_bytesrw.encode_string' Jsont.json json with
+
| Ok s -> s
+
| Error e -> failwith ("Failed to encode storage response: " ^ Jsont.Error.to_string e)
+
in
+
Jsont.of_of_string ~kind:"StorageResponse" of_string ~enc:to_string
+
let create client ~bot_email =
Log.info (fun m -> m "Creating bot storage for %s" bot_email);
let cache = Hashtbl.create 16 in
···
~path:"/api/v1/bot_storage"
() with
| Ok json ->
-
(match Zulip.Jsonu.get_object_opt (match json with `O f -> f | _ -> []) "storage" with
-
| Some storage_fields ->
+
(match Zulip.Encode.from_json storage_response_jsont json with
+
| Ok response ->
List.iter (fun (k, v) ->
-
match Zulip.Jsonu.to_string_safe v with
-
| Some value ->
-
Log.debug (fun m -> m "Loaded key from server: %s" k);
-
Hashtbl.add cache k value
-
| None -> ()
-
) storage_fields
-
| None -> ())
+
Log.debug (fun m -> m "Loaded key from server: %s" k);
+
Hashtbl.add cache k v
+
) response.storage
+
| Error msg ->
+
Log.warn (fun m -> m "Failed to parse storage response: %s" msg))
| Error e ->
Log.warn (fun m -> m "Failed to load existing storage: %s" (Zulip.error_message e)));
···
let encode_storage_update keys_values =
(* Build the storage object as JSON - the API expects storage={"key": "value"} *)
let storage_obj =
-
List.map (fun (k, v) -> (k, `String v)) keys_values
+
List.map (fun (k, v) -> ((k, Jsont.Meta.none), Jsont.String (v, Jsont.Meta.none))) keys_values
in
-
let json_obj = `O storage_obj in
+
let json_obj = Jsont.Object (storage_obj, Jsont.Meta.none) in
-
(* Convert to JSON string using Ezjsonm *)
-
let json_str = Ezjsonm.to_string json_obj in
+
(* Convert to JSON string using Jsont_bytesrw *)
+
let json_str = Jsont_bytesrw.encode_string' Jsont.json json_obj |> Result.get_ok in
(* Return as form-encoded body: storage=<url-encoded-json> *)
"storage=" ^ Uri.pct_encode json_str
···
~path:"/api/v1/bot_storage"
~params () with
| Ok json ->
-
(match Zulip.Jsonu.get_object_opt (match json with `O f -> f | _ -> []) "storage" with
-
| Some storage_fields ->
-
(match Zulip.Jsonu.get_string_opt storage_fields key with
+
(match Zulip.Encode.from_json storage_response_jsont json with
+
| Ok response ->
+
(match List.assoc_opt key response.storage with
| Some value ->
(* Cache the value *)
Log.debug (fun m -> m "Retrieved key from API: %s" key);
···
| None ->
Log.debug (fun m -> m "Key not found in API: %s" key);
None)
-
| None -> None)
+
| Error msg ->
+
Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
+
None)
| Error e ->
Log.warn (fun m -> m "Error fetching key %s: %s" key (Zulip.error_message e));
None
···
~path:"/api/v1/bot_storage"
() with
| Ok json ->
-
(match json with
-
| `O fields ->
-
(match List.assoc_opt "storage" fields with
-
| Some (`O storage_fields) ->
-
let api_keys = List.map fst storage_fields in
-
(* Merge with cache keys *)
-
let cache_keys = Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] in
-
let all_keys = List.sort_uniq String.compare (api_keys @ cache_keys) in
-
Ok all_keys
-
| _ -> Ok [])
-
| _ -> Ok [])
+
(match Zulip.Encode.from_json storage_response_jsont json with
+
| Ok response ->
+
let api_keys = List.map fst response.storage in
+
(* Merge with cache keys *)
+
let cache_keys = Hashtbl.fold (fun k _ acc -> k :: acc) t.cache [] in
+
let all_keys = List.sort_uniq String.compare (api_keys @ cache_keys) in
+
Ok all_keys
+
| Error msg ->
+
Log.warn (fun m -> m "Failed to parse storage response: %s" msg);
+
Ok [])
| Error e -> Error e
(* Flush all dirty keys to API *)
···
| Error e ->
Log.err (fun m -> m "Failed to flush storage: %s" (Zulip.error_message e));
Error e
-
end
+
end
+1 -1
stack/zulip/lib/zulip_bot/lib/dune
···
(public_name zulip_bot)
(name zulip_bot)
(wrapped true)
-
(libraries zulip unix eio ezjsonm logs mirage-crypto-rng fmt)
+
(libraries zulip unix eio jsont jsont.bytesrw logs mirage-crypto-rng fmt)
(flags (:standard -warn-error -3)))
+277 -88
stack/zulip/lib/zulip_bot/lib/message.ml
···
-
(* Use Jsonm exclusively via Zulip.Jsonu utilities *)
+
(* Message parsing using Jsont codecs *)
let logs_src = Logs.Src.create "zulip_bot.message"
module Log = (val Logs.src_log logs_src : Logs.LOG)
···
email: string;
full_name: string;
short_name: string option;
+
unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
}
let user_id t = t.user_id
···
let full_name t = t.full_name
let short_name t = t.short_name
+
(* Jsont codec for User - handles both user_id and id fields *)
+
let jsont : t Jsont.t =
+
let of_string s =
+
match Jsont_bytesrw.decode_string' Jsont.json s with
+
| Error _ -> Error "Failed to decode JSON"
+
| Ok json ->
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
let user_id =
+
match List.assoc_opt "user_id" assoc with
+
| Some (Jsont.Number (f, _)) -> Some (int_of_float f)
+
| _ ->
+
match List.assoc_opt "id" assoc with
+
| Some (Jsont.Number (f, _)) -> Some (int_of_float f)
+
| _ -> None
+
in
+
let email =
+
match List.assoc_opt "email" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
let full_name =
+
match List.assoc_opt "full_name" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
let short_name =
+
match List.assoc_opt "short_name" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
(match (user_id, email, full_name) with
+
| (Some user_id, Some email, Some full_name) ->
+
(* Keep unknown fields *)
+
let unknown_fields = List.filter (fun (k, _) ->
+
k <> "user_id" && k <> "id" && k <> "email" && k <> "full_name" && k <> "short_name"
+
) assoc in
+
let unknown_mems = List.map (fun (k, v) -> ((k, Jsont.Meta.none), v)) unknown_fields in
+
let unknown = Jsont.Object (unknown_mems, Jsont.Meta.none) in
+
Ok { user_id; email; full_name; short_name; unknown }
+
| _ -> Error "Missing required user fields")
+
| _ -> Error "Expected JSON object for user"
+
in
+
let to_string { user_id; email; full_name; short_name; unknown } =
+
let fields = [
+
(("user_id", Jsont.Meta.none), Jsont.Number (float_of_int user_id, Jsont.Meta.none));
+
(("email", Jsont.Meta.none), Jsont.String (email, Jsont.Meta.none));
+
(("full_name", Jsont.Meta.none), Jsont.String (full_name, Jsont.Meta.none));
+
] in
+
let fields = match short_name with
+
| Some sn -> (("short_name", Jsont.Meta.none), Jsont.String (sn, Jsont.Meta.none)) :: fields
+
| None -> fields
+
in
+
let unknown_mems = match unknown with
+
| Jsont.Object (mems, _) -> mems
+
| _ -> []
+
in
+
let json = Jsont.Object (fields @ unknown_mems, Jsont.Meta.none) in
+
match Jsont_bytesrw.encode_string' Jsont.json json with
+
| Ok s -> s
+
| Error e -> failwith ("Failed to encode user: " ^ Jsont.Error.to_string e)
+
in
+
Jsont.of_of_string ~kind:"User" of_string ~enc:to_string
+
let of_json (json : Zulip.json) : (t, Zulip.zerror) result =
-
let open Zulip.Jsonu_syntax in
-
(Zulip.Jsonu.with_object "user" @@ fun fields ->
-
let* user_id = (field fields "user_id" int) <|> (field fields "id" int) in
-
let* email = field fields "email" string in
-
let* full_name = field fields "full_name" string in
-
let* short_name = field_opt fields "short_name" string in
-
Ok { user_id; email; full_name; short_name }) json
+
match Zulip.Encode.from_json jsont json with
+
| Ok v -> Ok v
+
| Error msg -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg ())
end
(** Reaction representation *)
···
emoji_code: string;
reaction_type: string;
user_id: int;
+
unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
}
let emoji_name t = t.emoji_name
···
let reaction_type t = t.reaction_type
let user_id t = t.user_id
+
(* Jsont codec for Reaction - handles user_id in different locations *)
+
let jsont : t Jsont.t =
+
let of_string s =
+
match Jsont_bytesrw.decode_string' Jsont.json s with
+
| Error _ -> Error "Failed to decode JSON"
+
| Ok json ->
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
let emoji_name =
+
match List.assoc_opt "emoji_name" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
let emoji_code =
+
match List.assoc_opt "emoji_code" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
let reaction_type =
+
match List.assoc_opt "reaction_type" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
(* user_id can be either directly in the object or nested in a "user" field *)
+
let user_id =
+
match List.assoc_opt "user_id" assoc with
+
| Some (Jsont.Number (f, _)) -> Some (int_of_float f)
+
| _ ->
+
match List.assoc_opt "user" assoc with
+
| Some (Jsont.Object (user_fields, _)) ->
+
let user_assoc = List.map (fun ((k, _), v) -> (k, v)) user_fields in
+
(match List.assoc_opt "user_id" user_assoc with
+
| Some (Jsont.Number (f, _)) -> Some (int_of_float f)
+
| _ -> None)
+
| _ -> None
+
in
+
(match (emoji_name, emoji_code, reaction_type, user_id) with
+
| (Some emoji_name, Some emoji_code, Some reaction_type, Some user_id) ->
+
(* Keep unknown fields *)
+
let unknown_fields = List.filter (fun (k, _) ->
+
k <> "emoji_name" && k <> "emoji_code" && k <> "reaction_type" && k <> "user_id" && k <> "user"
+
) assoc in
+
let unknown_mems = List.map (fun (k, v) -> ((k, Jsont.Meta.none), v)) unknown_fields in
+
let unknown = Jsont.Object (unknown_mems, Jsont.Meta.none) in
+
Ok { emoji_name; emoji_code; reaction_type; user_id; unknown }
+
| _ -> Error "Missing required reaction fields")
+
| _ -> Error "Expected JSON object for reaction"
+
in
+
let to_string { emoji_name; emoji_code; reaction_type; user_id; unknown } =
+
let fields = [
+
(("emoji_name", Jsont.Meta.none), Jsont.String (emoji_name, Jsont.Meta.none));
+
(("emoji_code", Jsont.Meta.none), Jsont.String (emoji_code, Jsont.Meta.none));
+
(("reaction_type", Jsont.Meta.none), Jsont.String (reaction_type, Jsont.Meta.none));
+
(("user_id", Jsont.Meta.none), Jsont.Number (float_of_int user_id, Jsont.Meta.none));
+
] in
+
let unknown_mems = match unknown with
+
| Jsont.Object (mems, _) -> mems
+
| _ -> []
+
in
+
let json = Jsont.Object (fields @ unknown_mems, Jsont.Meta.none) in
+
match Jsont_bytesrw.encode_string' Jsont.json json with
+
| Ok s -> s
+
| Error e -> failwith ("Failed to encode reaction: " ^ Jsont.Error.to_string e)
+
in
+
Jsont.of_of_string ~kind:"Reaction" of_string ~enc:to_string
+
let of_json (json : Zulip.json) : (t, Zulip.zerror) result =
-
let open Zulip.Jsonu_syntax in
-
(Zulip.Jsonu.with_object "reaction" @@ fun fields ->
-
let* emoji_name = field fields "emoji_name" string in
-
let* emoji_code = field fields "emoji_code" string in
-
let* reaction_type = field fields "reaction_type" string in
-
let* user_id =
-
(field fields "user_id" int) <|>
-
(match field fields "user" object_ with
-
| Ok user_obj -> field user_obj "user_id" int
-
| Error _ -> fail "user_id not found") in
-
Ok { emoji_name; emoji_code; reaction_type; user_id }) json
+
match Zulip.Encode.from_json jsont json with
+
| Ok v -> Ok v
+
| Error msg -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg ())
end
let parse_reaction_json json = Reaction.of_json json
···
(** Helper function to parse common fields *)
let parse_common json =
-
Zulip.Jsonu.parse_with_error "common fields" @@ fun () ->
-
(Zulip.Jsonu.with_object "message" @@ fun fields ->
-
let open Zulip.Jsonu_syntax in
-
let* id = field fields "id" int in
-
let* sender_id = field fields "sender_id" int in
-
let* sender_email = field fields "sender_email" string in
-
let* sender_full_name = field fields "sender_full_name" string in
-
let sender_short_name = field_opt fields "sender_short_name" string |? None in
-
let timestamp = field_or fields "timestamp" float 0.0 |? 0.0 in
-
let content = field_or fields "content" string "" |? "" in
-
let content_type = field_or fields "content_type" string "text/html" |? "text/html" in
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
let get_int key =
+
match List.assoc_opt key assoc with
+
| Some (Jsont.Number (f, _)) -> Some (int_of_float f)
+
| _ -> None
+
in
+
let get_string key =
+
match List.assoc_opt key assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
let get_float key default =
+
match List.assoc_opt key assoc with
+
| Some (Jsont.Number (f, _)) -> f
+
| _ -> default
+
in
+
let get_bool key default =
+
match List.assoc_opt key assoc with
+
| Some (Jsont.Bool (b, _)) -> b
+
| _ -> default
+
in
+
let get_array key =
+
match List.assoc_opt key assoc with
+
| Some (Jsont.Array (arr, _)) -> Some arr
+
| _ -> None
+
in
-
let reactions =
-
match Zulip.Jsonu.get_array_opt fields "reactions" with
-
| Some reactions_json ->
-
List.filter_map (fun r ->
-
match parse_reaction_json r with
-
| Ok reaction -> Some reaction
-
| Error err ->
-
Log.warn (fun m -> m "Failed to parse reaction: %s" (Zulip.error_message err));
-
None
-
) reactions_json
-
| None -> []
-
in
+
(match (get_int "id", get_int "sender_id", get_string "sender_email", get_string "sender_full_name") with
+
| (Some id, Some sender_id, Some sender_email, Some sender_full_name) ->
+
let sender_short_name = get_string "sender_short_name" in
+
let timestamp = get_float "timestamp" 0.0 in
+
let content = get_string "content" |> Option.value ~default:"" in
+
let content_type = get_string "content_type" |> Option.value ~default:"text/html" in
-
let submessages = Zulip.Jsonu.get_array_opt fields "submessages" |> Option.value ~default:[] in
+
let reactions =
+
match get_array "reactions" with
+
| Some reactions_json ->
+
List.filter_map (fun r ->
+
match parse_reaction_json r with
+
| Ok reaction -> Some reaction
+
| Error err ->
+
Log.warn (fun m -> m "Failed to parse reaction: %s" (Zulip.error_message err));
+
None
+
) reactions_json
+
| None -> []
+
in
-
let flags =
-
match Zulip.Jsonu.get_array_opt fields "flags" with
-
| Some flags_json -> List.filter_map Zulip.Jsonu.to_string_safe flags_json
-
| None -> []
-
in
+
let submessages = get_array "submessages" |> Option.value ~default:[] in
-
let is_me_message = field_or fields "is_me_message" bool false |? false in
-
let client = field_or fields "client" string "" |? "" in
-
let gravatar_hash = field_or fields "gravatar_hash" string "" |? "" in
-
let avatar_url = field_opt fields "avatar_url" string |? None in
+
let flags =
+
match get_array "flags" with
+
| Some flags_json ->
+
List.filter_map (fun f ->
+
match f with
+
| Jsont.String (s, _) -> Some s
+
| _ -> None
+
) flags_json
+
| None -> []
+
in
-
Ok {
-
id; sender_id; sender_email; sender_full_name; sender_short_name;
-
timestamp; content; content_type; reactions; submessages;
-
flags; is_me_message; client; gravatar_hash; avatar_url
-
}) json
+
let is_me_message = get_bool "is_me_message" false in
+
let client = get_string "client" |> Option.value ~default:"" in
+
let gravatar_hash = get_string "gravatar_hash" |> Option.value ~default:"" in
+
let avatar_url = get_string "avatar_url" in
+
+
Ok {
+
id; sender_id; sender_email; sender_full_name; sender_short_name;
+
timestamp; content; content_type; reactions; submessages;
+
flags; is_me_message; client; gravatar_hash; avatar_url
+
}
+
| _ -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg:"Missing required message fields" ()))
+
| _ -> Error (Zulip.create_error ~code:(Other "json_parse_error") ~msg:"Expected JSON object for message" ())
(** JSON parsing *)
let of_json json =
-
Log.debug (fun m -> m "Parsing message JSON: %s" (Zulip.Jsonu.to_string_pretty json));
+
(* Helper to pretty print JSON without using jsonu *)
+
let json_str =
+
match Jsont_bytesrw.encode_string' Jsont.json json with
+
| Ok s -> s
+
| Error _ -> "<error encoding json>"
+
in
+
Log.debug (fun m -> m "Parsing message JSON: %s" json_str);
-
let open Zulip.Jsonu_syntax in
match parse_common json with
| Error err -> Error (Zulip.error_message err)
| Ok common ->
-
(Zulip.Jsonu.parse_with_error "message type" @@ fun () ->
-
(Zulip.Jsonu.with_object "message" @@ fun fields ->
-
match Zulip.Jsonu.get_string fields "type" with
-
| Ok "private" ->
-
let* recipient_json = field fields "display_recipient" (array (fun x -> Ok x)) in
-
let users = List.filter_map (fun u ->
-
match parse_user_json u with
-
| Ok user -> Some user
-
| Error err ->
-
Log.warn (fun m -> m "Failed to parse user in display_recipient: %s" (Zulip.error_message err));
-
None
-
) recipient_json in
+
match json with
+
| Jsont.Object (fields, _) ->
+
let assoc = List.map (fun ((k, _), v) -> (k, v)) fields in
+
let msg_type =
+
match List.assoc_opt "type" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
(match msg_type with
+
| Some "private" ->
+
(match List.assoc_opt "display_recipient" assoc with
+
| Some (Jsont.Array (recipient_json, _)) ->
+
let users = List.filter_map (fun u ->
+
match parse_user_json u with
+
| Ok user -> Some user
+
| Error err ->
+
Log.warn (fun m -> m "Failed to parse user in display_recipient: %s" (Zulip.error_message err));
+
None
+
) recipient_json in
-
if List.length users = 0 && List.length recipient_json > 0 then
-
fail "Failed to parse any users in display_recipient"
-
else
-
Ok (Private { common; display_recipient = users })
+
if List.length users = 0 && List.length recipient_json > 0 then
+
Error "Failed to parse any users in display_recipient"
+
else
+
Ok (Private { common; display_recipient = users })
+
| _ ->
+
Log.warn (fun m -> m "display_recipient is not an array for private message");
+
Ok (Unknown { common; raw_json = json }))
-
| Ok "stream" ->
-
let* display_recipient = field fields "display_recipient" string in
-
let* stream_id = field fields "stream_id" int in
-
let* subject = field fields "subject" string in
-
Ok (Stream { common; display_recipient; stream_id; subject })
+
| Some "stream" ->
+
let display_recipient =
+
match List.assoc_opt "display_recipient" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
let stream_id =
+
match List.assoc_opt "stream_id" assoc with
+
| Some (Jsont.Number (f, _)) -> Some (int_of_float f)
+
| _ -> None
+
in
+
let subject =
+
match List.assoc_opt "subject" assoc with
+
| Some (Jsont.String (s, _)) -> Some s
+
| _ -> None
+
in
+
(match (display_recipient, stream_id, subject) with
+
| (Some display_recipient, Some stream_id, Some subject) ->
+
Ok (Stream { common; display_recipient; stream_id; subject })
+
| _ ->
+
Log.warn (fun m -> m "Missing required fields for stream message");
+
Ok (Unknown { common; raw_json = json }))
-
| Ok unknown_type ->
-
Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
-
Ok (Unknown { common; raw_json = json })
+
| Some unknown_type ->
+
Log.warn (fun m -> m "Unknown message type: %s" unknown_type);
+
Ok (Unknown { common; raw_json = json })
-
| Error _ ->
-
Log.warn (fun m -> m "No message type field found");
-
Ok (Unknown { common; raw_json = json })
-
) json) |> Result.map_error Zulip.error_message
+
| None ->
+
Log.warn (fun m -> m "No message type field found");
+
Ok (Unknown { common; raw_json = json }))
+
| _ -> Error "Expected JSON object for message"
(** Accessor functions *)
let get_common = function
···
(** Pretty print JSON for debugging *)
let pp_json_debug ppf json =
let open Fmt in
-
let json_str = Zulip.Jsonu.to_string_pretty json in
+
let json_str =
+
match Jsont_bytesrw.encode_string' Jsont.json json with
+
| Ok s -> s
+
| Error _ -> "<error encoding json>"
+
in
pf ppf "@[<v>%a@.%a@]"
(styled `Bold (styled (`Fg `Blue) string)) "Raw JSON:"
(styled (`Fg `Black) string) json_str
+10
stack/zulip/lib/zulip_bot/lib/message.mli
···
email: string;
full_name: string;
short_name: string option;
+
unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
}
val user_id : t -> int
val email : t -> string
val full_name : t -> string
val short_name : t -> string option
+
+
(** Jsont codec for User *)
+
val jsont : t Jsont.t
+
val of_json : Zulip.json -> (t, Zulip.zerror) result
end
···
emoji_code: string;
reaction_type: string;
user_id: int;
+
unknown: Jsont.json; (** Unknown/extra JSON fields preserved during parsing *)
}
val emoji_name : t -> string
val emoji_code : t -> string
val reaction_type : t -> string
val user_id : t -> int
+
+
(** Jsont codec for Reaction *)
+
val jsont : t Jsont.t
+
val of_json : Zulip.json -> (t, Zulip.zerror) result
end
+30
stack/zulip/zulip.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
synopsis: "OCaml bindings for the Zulip REST API"
+
description:
+
"High-quality OCaml bindings to the Zulip REST API using EIO for async operations"
+
depends: [
+
"ocaml"
+
"dune" {>= "3.0"}
+
"eio"
+
"requests"
+
"uri"
+
"base64"
+
"alcotest" {with-test}
+
"eio_main" {with-test}
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+26
stack/zulip/zulip_bot.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
synopsis: "OCaml bot framework for Zulip"
+
description: "Interactive bot framework built on the OCaml Zulip library"
+
depends: [
+
"ocaml"
+
"dune" {>= "3.0"}
+
"zulip"
+
"eio"
+
"alcotest" {with-test}
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]
+29
stack/zulip/zulip_botserver.opam
···
+
# This file is generated by dune, edit dune-project instead
+
opam-version: "2.0"
+
synopsis: "OCaml bot server for running multiple Zulip bots"
+
description:
+
"HTTP server for running multiple Zulip bots with webhook support"
+
depends: [
+
"ocaml"
+
"dune" {>= "3.0"}
+
"zulip"
+
"zulip_bot"
+
"eio"
+
"requests"
+
"alcotest" {with-test}
+
"odoc" {with-doc}
+
]
+
build: [
+
["dune" "subst"] {dev}
+
[
+
"dune"
+
"build"
+
"-p"
+
name
+
"-j"
+
jobs
+
"@install"
+
"@runtest" {with-test}
+
"@doc" {with-doc}
+
]
+
]