OCaml library for JSONfeed parsing and creation

Compare changes

Choose any two refs to compare.

+3 -2
CHANGES.md
···
-
v1.1.0 (dev)
-
------------
- Simplify round trip processing of unknown messages using Jsont combinators (@avsm).
v1.0.0 (2025-11-12)
-------------------
···
+
v1.1.0 (2025-11-12)
+
-------------------
- Simplify round trip processing of unknown messages using Jsont combinators (@avsm).
+
- Relax alcotest version constraints (@avsm).
v1.0.0 (2025-11-12)
-------------------
+1 -1
dune-project
···
(ptime (>= 1.2.0))
bytesrw
(odoc :with-doc)
-
(alcotest (and :with-test (>= 1.8.0)))))
···
(ptime (>= 1.2.0))
bytesrw
(odoc :with-doc)
+
(alcotest (and :with-test (>= 1.5.0)))))
+1 -1
jsonfeed.opam
···
"ptime" {>= "1.2.0"}
"bytesrw"
"odoc" {with-doc}
-
"alcotest" {with-test & >= "1.8.0"}
]
build: [
["dune" "subst"] {dev}
···
"ptime" {>= "1.2.0"}
"bytesrw"
"odoc" {with-doc}
+
"alcotest" {with-test & >= "1.5.0"}
]
build: [
["dune" "subst"] {dev}
+8 -8
lib/attachment.ml
···
Format.fprintf ppf "%s (%s" filename t.mime_type;
-
(match t.size_in_bytes with
-
| Some size ->
let mb = Int64.to_float size /. (1024. *. 1024.) in
-
Format.fprintf ppf ", %.1f MB" mb
-
| None -> ());
-
(match t.duration_in_seconds with
-
| Some duration ->
let mins = duration / 60 in
let secs = duration mod 60 in
-
Format.fprintf ppf ", %dm%ds" mins secs
-
| None -> ());
Format.fprintf ppf ")"
···
Format.fprintf ppf "%s (%s" filename t.mime_type;
+
Option.iter
+
(fun size ->
let mb = Int64.to_float size /. (1024. *. 1024.) in
+
Format.fprintf ppf ", %.1f MB" mb)
+
t.size_in_bytes;
+
Option.iter
+
(fun duration ->
let mins = duration / 60 in
let secs = duration mod 60 in
+
Format.fprintf ppf ", %dm%ds" mins secs)
+
t.duration_in_seconds;
Format.fprintf ppf ")"
+2 -2
lib/attachment.mli
···
module Unknown : sig
type t = Jsont.json
-
(** Unknown/unrecognized JSON object members as a generic JSON object.
-
Useful for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
module Unknown : sig
type t = Jsont.json
+
(** Unknown/unrecognized JSON object members as a generic JSON object. Useful
+
for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
+2 -2
lib/author.mli
···
module Unknown : sig
type t = Jsont.json
-
(** Unknown/unrecognized JSON object members as a generic JSON object.
-
Useful for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
module Unknown : sig
type t = Jsont.json
+
(** Unknown/unrecognized JSON object members as a generic JSON object. Useful
+
for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
+1 -3
lib/cito.ml
···
let jsont =
let kind = "CiTO intent" in
let doc = "A Citation Typing Ontology intent annotation" in
-
let dec = of_string in
-
let enc = to_string in
-
Jsont.map ~kind ~doc ~dec ~enc Jsont.string
···
let jsont =
let kind = "CiTO intent" in
let doc = "A Citation Typing Ontology intent annotation" in
+
Jsont.map ~kind ~doc ~dec:of_string ~enc:to_string Jsont.string
+2 -2
lib/hub.mli
···
module Unknown : sig
type t = Jsont.json
-
(** Unknown/unrecognized JSON object members as a generic JSON object.
-
Useful for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
module Unknown : sig
type t = Jsont.json
+
(** Unknown/unrecognized JSON object members as a generic JSON object. Useful
+
for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
+19 -46
lib/item.ml
···
| `Both (_, text) -> Some text
let equal a b = a.id = b.id
-
-
let compare a b =
-
match (a.date_published, b.date_published) with
-
| None, None -> 0
-
| None, Some _ -> -1
-
| Some _, None -> 1
-
| Some da, Some db -> Ptime.compare da db
let pp ppf t =
match (t.date_published, t.title) with
···
| None, None -> Format.fprintf ppf "%s" t.id
let pp_summary ppf t =
-
match t.title with
-
| Some title -> Format.fprintf ppf "%s" title
-
| None -> Format.fprintf ppf "%s" t.id
(* Jsont type *)
···
}
in
-
(* Encoders to extract fields from item *)
-
let enc_id t = t.id in
-
let enc_content_html t = content_html t in
-
let enc_content_text t = content_text t in
-
let enc_url t = t.url in
-
let enc_external_url t = t.external_url in
-
let enc_title t = t.title in
-
let enc_summary t = t.summary in
-
let enc_image t = t.image in
-
let enc_banner_image t = t.banner_image in
-
let enc_date_published t = t.date_published in
-
let enc_date_modified t = t.date_modified in
-
let enc_authors t = t.authors in
-
let enc_tags t = t.tags in
-
let enc_language t = t.language in
-
let enc_attachments t = t.attachments in
-
let enc_references t = t.references in
-
let enc_unknown t = t.unknown in
-
Jsont.Object.map ~kind ~doc make_from_json
-
|> Jsont.Object.mem "id" Jsont.string ~enc:enc_id
-
|> Jsont.Object.opt_mem "content_html" Jsont.string ~enc:enc_content_html
-
|> Jsont.Object.opt_mem "content_text" Jsont.string ~enc:enc_content_text
-
|> Jsont.Object.opt_mem "url" Jsont.string ~enc:enc_url
-
|> Jsont.Object.opt_mem "external_url" Jsont.string ~enc:enc_external_url
-
|> Jsont.Object.opt_mem "title" Jsont.string ~enc:enc_title
-
|> Jsont.Object.opt_mem "summary" Jsont.string ~enc:enc_summary
-
|> Jsont.Object.opt_mem "image" Jsont.string ~enc:enc_image
-
|> Jsont.Object.opt_mem "banner_image" Jsont.string ~enc:enc_banner_image
-
|> Jsont.Object.opt_mem "date_published" Rfc3339.jsont ~enc:enc_date_published
-
|> Jsont.Object.opt_mem "date_modified" Rfc3339.jsont ~enc:enc_date_modified
-
|> Jsont.Object.opt_mem "authors" (Jsont.list Author.jsont) ~enc:enc_authors
-
|> Jsont.Object.opt_mem "tags" (Jsont.list Jsont.string) ~enc:enc_tags
-
|> Jsont.Object.opt_mem "language" Jsont.string ~enc:enc_language
|> Jsont.Object.opt_mem "attachments"
(Jsont.list Attachment.jsont)
-
~enc:enc_attachments
|> Jsont.Object.opt_mem "_references"
(Jsont.list Reference.jsont)
-
~enc:enc_references
|> Jsont.Object.opt_mem "_extensions" Jsont.json_object ~enc:(fun _t -> None)
-
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:enc_unknown
|> Jsont.Object.finish
···
| `Both (_, text) -> Some text
let equal a b = a.id = b.id
+
let compare a b = Option.compare Ptime.compare a.date_published b.date_published
let pp ppf t =
match (t.date_published, t.title) with
···
| None, None -> Format.fprintf ppf "%s" t.id
let pp_summary ppf t =
+
Format.fprintf ppf "%s" (Option.value ~default:t.id t.title)
(* Jsont type *)
···
}
in
Jsont.Object.map ~kind ~doc make_from_json
+
|> Jsont.Object.mem "id" Jsont.string ~enc:id
+
|> Jsont.Object.opt_mem "content_html" Jsont.string ~enc:content_html
+
|> Jsont.Object.opt_mem "content_text" Jsont.string ~enc:content_text
+
|> Jsont.Object.opt_mem "url" Jsont.string ~enc:url
+
|> Jsont.Object.opt_mem "external_url" Jsont.string ~enc:external_url
+
|> Jsont.Object.opt_mem "title" Jsont.string ~enc:title
+
|> Jsont.Object.opt_mem "summary" Jsont.string ~enc:summary
+
|> Jsont.Object.opt_mem "image" Jsont.string ~enc:image
+
|> Jsont.Object.opt_mem "banner_image" Jsont.string ~enc:banner_image
+
|> Jsont.Object.opt_mem "date_published" Rfc3339.jsont ~enc:date_published
+
|> Jsont.Object.opt_mem "date_modified" Rfc3339.jsont ~enc:date_modified
+
|> Jsont.Object.opt_mem "authors" (Jsont.list Author.jsont) ~enc:authors
+
|> Jsont.Object.opt_mem "tags" (Jsont.list Jsont.string) ~enc:tags
+
|> Jsont.Object.opt_mem "language" Jsont.string ~enc:language
|> Jsont.Object.opt_mem "attachments"
(Jsont.list Attachment.jsont)
+
~enc:attachments
|> Jsont.Object.opt_mem "_references"
(Jsont.list Reference.jsont)
+
~enc:references
|> Jsont.Object.opt_mem "_extensions" Jsont.json_object ~enc:(fun _t -> None)
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
|> Jsont.Object.finish
+2 -2
lib/item.mli
···
module Unknown : sig
type t = Jsont.json
-
(** Unknown/unrecognized JSON object members as a generic JSON object.
-
Useful for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
module Unknown : sig
type t = Jsont.json
+
(** Unknown/unrecognized JSON object members as a generic JSON object. Useful
+
for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
+12 -18
lib/jsonfeed.ml
···
add_error "items must have unique IDs";
(* Validate authors *)
-
(match feed.authors with
-
| Some authors ->
-
List.iteri
-
(fun i author ->
-
if not (Author.is_valid author) then
-
add_error
-
(Printf.sprintf
-
"feed author %d is invalid (needs at least one field)" i))
-
authors
-
| None -> ());
(* Validate items *)
List.iteri
···
add_error (Printf.sprintf "item %d has empty ID" i);
(* Validate item authors *)
-
match Item.authors item with
-
| Some authors ->
-
List.iteri
-
(fun j author ->
-
if not (Author.is_valid author) then
-
add_error (Printf.sprintf "item %d author %d is invalid" i j))
-
authors
-
| None -> ())
feed.items;
match !errors with [] -> Ok () | errs -> Error (List.rev errs)
···
add_error "items must have unique IDs";
(* Validate authors *)
+
Option.iter
+
(List.iteri (fun i author ->
+
if not (Author.is_valid author) then
+
add_error
+
(Printf.sprintf
+
"feed author %d is invalid (needs at least one field)" i)))
+
feed.authors;
(* Validate items *)
List.iteri
···
add_error (Printf.sprintf "item %d has empty ID" i);
(* Validate item authors *)
+
Option.iter
+
(List.iteri (fun j author ->
+
if not (Author.is_valid author) then
+
add_error (Printf.sprintf "item %d author %d is invalid" i j)))
+
(Item.authors item))
feed.items;
match !errors with [] -> Ok () | errs -> Error (List.rev errs)
+2 -1
lib/jsonfeed.mli
···
module Unknown : sig
type t = Jsont.json
(** Unknown or unrecognized JSON object members as a generic JSON object.
-
Useful for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
module Unknown : sig
type t = Jsont.json
(** Unknown or unrecognized JSON object members as a generic JSON object.
+
Useful for preserving fields from custom extensions or future spec
+
versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
+1 -1
lib/reference.ml
···
let pp ppf t =
let open Format in
fprintf ppf "%s" t.url;
-
match t.doi with Some d -> fprintf ppf " [DOI: %s]" d | None -> ()
let jsont =
let kind = "Reference" in
···
let pp ppf t =
let open Format in
fprintf ppf "%s" t.url;
+
Option.iter (fprintf ppf " [DOI: %s]") t.doi
let jsont =
let kind = "Reference" in
+2 -2
lib/reference.mli
···
module Unknown : sig
type t = Jsont.json
-
(** Unknown/unrecognized JSON object members as a generic JSON object.
-
Useful for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
module Unknown : sig
type t = Jsont.json
+
(** Unknown/unrecognized JSON object members as a generic JSON object. Useful
+
for preserving fields from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
+1 -1
lib/rfc3339.ml
···
---------------------------------------------------------------------------*)
let parse s =
-
match Ptime.of_rfc3339 s with Ok (t, _, _) -> Some t | Error _ -> None
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 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)
+14 -12
test/test_jsonfeed.ml
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
-
| Ok author ->
(* Check that unknown fields are preserved *)
let unknown = Author.unknown author in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
(* Encode and decode again *)
-
(match Jsont_bytesrw.encode_string' Author.jsont author with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
-
| Ok item ->
(* Check that unknown fields are preserved *)
let unknown = Item.unknown item in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
(* Encode and decode again *)
-
(match Jsont_bytesrw.encode_string' Item.jsont item with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
-
| Ok feed ->
(* Check that unknown fields are preserved *)
let unknown = Jsonfeed.unknown feed in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
(* Encode and decode again *)
-
(match Jsonfeed.to_string feed with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
(Jsonfeed.Unknown.is_empty unknown2)))
let test_hub_unknown_roundtrip () =
-
let json = {|{
"type": "WebSub",
"url": "https://example.com/hub",
"custom_field": "test"
-
}|} in
match Jsont_bytesrw.decode_string' Hub.jsont json with
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
-
| Ok hub ->
let unknown = Hub.unknown hub in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
-
(match Jsont_bytesrw.encode_string' Hub.jsont hub with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
-
| Ok att ->
let unknown = Attachment.unknown att in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
-
(match Jsont_bytesrw.encode_string' Attachment.jsont att with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
+
| Ok author -> (
(* Check that unknown fields are preserved *)
let unknown = Author.unknown author in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
(* Encode and decode again *)
+
match Jsont_bytesrw.encode_string' Author.jsont author with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
+
| Ok item -> (
(* Check that unknown fields are preserved *)
let unknown = Item.unknown item in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
(* Encode and decode again *)
+
match Jsont_bytesrw.encode_string' Item.jsont item with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
+
| Ok feed -> (
(* Check that unknown fields are preserved *)
let unknown = Jsonfeed.unknown feed in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
(* Encode and decode again *)
+
match Jsonfeed.to_string feed with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
(Jsonfeed.Unknown.is_empty unknown2)))
let test_hub_unknown_roundtrip () =
+
let json =
+
{|{
"type": "WebSub",
"url": "https://example.com/hub",
"custom_field": "test"
+
}|}
+
in
match Jsont_bytesrw.decode_string' Hub.jsont json with
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
+
| Ok hub -> (
let unknown = Hub.unknown hub in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
+
match Jsont_bytesrw.encode_string' Hub.jsont hub with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
···
| Error e ->
Alcotest.fail
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
+
| Ok att -> (
let unknown = Attachment.unknown att in
Alcotest.(check bool)
"has unknown fields" false
(Jsonfeed.Unknown.is_empty unknown);
+
match Jsont_bytesrw.encode_string' Attachment.jsont att with
| Error e ->
Alcotest.fail
(Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))