My agentic slop goes here. Not intended for anyone else!
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
jsontcodec that defines bidirectional JSON conversion - Accessor functions
- Pretty printer
Example from channel.ml:
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:
(** 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:
(* 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:
(* 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:
-
Define the protocol type with codec:
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 ) -
Encode using Encode module:
let body = Encode.to_form_urlencoded my_request_codec req in (* or *) let json = Encode.to_json_string my_request_codec req in -
Decode responses with codec:
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):#
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):#
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