OCaml library for JSONfeed parsing and creation

Compare changes

Choose any two refs to compare.

+1
.ocamlformat
···
···
+
version=0.27.0
+1
.tangled/workflows/build.yml
···
- bzip2
- gcc
- ocaml
steps:
- name: opam
···
- bzip2
- gcc
- ocaml
+
- pkg-config
steps:
- name: opam
+11
CHANGES.md
···
···
+
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)
+
-------------------
+
+
- Initial public release (@avsm)
+
+11 -1
README.md
···
- **feed_example.ml** - Creating and serializing feeds (blog and podcast)
- **feed_parser.ml** - Parsing and analyzing feeds from files
- **feed_validator.ml** - Validating feeds and demonstrating various feed types
Run examples:
```bash
-
opam exec -- dune exec -- ./example/feed_parser.exe
opam exec -- dune exec -- ./example/feed_example.exe
```
## API Documentation
···
- **feed_example.ml** - Creating and serializing feeds (blog and podcast)
- **feed_parser.ml** - Parsing and analyzing feeds from files
- **feed_validator.ml** - Validating feeds and demonstrating various feed types
+
- **feed_echo.ml** - Round-trip parsing: reads a feed from stdin and outputs to stdout
Run examples:
```bash
+
# Create and display sample feeds
opam exec -- dune exec -- ./example/feed_example.exe
+
+
# Parse and analyze a feed file
+
opam exec -- dune exec -- ./example/feed_parser.exe path/to/feed.json
+
+
# Validate feeds
+
opam exec -- dune exec -- ./example/feed_validator.exe
+
+
# Test round-trip parsing
+
cat feed.json | opam exec -- dune exec -- ./example/feed_echo.exe
```
## API Documentation
+3 -3
dune-project
···
and serialization using Jsonm and Ptime.")
(depends
(ocaml (>= 5.2.0))
-
(jsonm (>= 1.0.0))
(ptime (>= 1.2.0))
-
(fmt (>= 0.11.0))
(odoc :with-doc)
-
(alcotest (and :with-test (>= 1.9.0)))))
···
and serialization using Jsonm and Ptime.")
(depends
(ocaml (>= 5.2.0))
+
(jsont (>= 0.2.0))
(ptime (>= 1.2.0))
+
bytesrw
(odoc :with-doc)
+
(alcotest (and :with-test (>= 1.5.0)))))
+4 -3
jsonfeed.opam
···
depends: [
"dune" {>= "3.18"}
"ocaml" {>= "5.2.0"}
-
"jsonm" {>= "1.0.0"}
"ptime" {>= "1.2.0"}
-
"fmt" {>= "0.11.0"}
"odoc" {with-doc}
-
"alcotest" {with-test & >= "1.9.0"}
]
build: [
["dune" "subst"] {dev}
···
]
]
x-maintenance-intent: ["(latest)"]
···
depends: [
"dune" {>= "3.18"}
"ocaml" {>= "5.2.0"}
+
"jsont" {>= "0.2.0"}
"ptime" {>= "1.2.0"}
+
"bytesrw"
"odoc" {with-doc}
+
"alcotest" {with-test & >= "1.5.0"}
]
build: [
["dune" "subst"] {dev}
···
]
]
x-maintenance-intent: ["(latest)"]
+
dev-repo: "git+https://tangled.sh/@anil.recoil.org/ocaml-jsonfeed"
+1
jsonfeed.opam.template
···
···
+
dev-repo: "git+https://tangled.sh/@anil.recoil.org/ocaml-jsonfeed"
+12 -37
lib/attachment.ml
···
---------------------------------------------------------------------------*)
module Unknown = struct
-
type t = (string * Jsont.json) list
-
let empty = []
-
let is_empty = function [] -> true | _ -> false
end
type t = {
···
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 ")"
let jsont =
let kind = "Attachment" in
let doc = "An attachment object" in
-
let unknown_mems :
-
(Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
-
let open Jsont.Object.Mems in
-
let dec_empty () = [] in
-
let dec_add _meta (name : string) value acc =
-
((name, Jsont.Meta.none), value) :: acc
-
in
-
let dec_finish _meta mems =
-
List.rev_map (fun ((name, _meta), value) -> (name, value)) mems
-
in
-
let enc =
-
{
-
enc =
-
(fun (type acc)
-
(f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc)
-
unknown
-
(acc : acc)
-
->
-
List.fold_left
-
(fun acc (name, value) -> f Jsont.Meta.none name value acc)
-
acc unknown);
-
}
-
in
-
map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
-
in
let create_obj url mime_type title size_in_bytes duration_in_seconds unknown =
create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ~unknown
()
···
|> Jsont.Object.opt_mem "size_in_bytes" Jsont.int64 ~enc:size_in_bytes
|> Jsont.Object.opt_mem "duration_in_seconds" Jsont.int
~enc:duration_in_seconds
-
|> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
|> Jsont.Object.finish
···
---------------------------------------------------------------------------*)
module Unknown = struct
+
type t = Jsont.json
+
let empty = Jsont.Object ([], Jsont.Meta.none)
+
let is_empty = function Jsont.Object ([], _) -> true | _ -> false
end
type t = {
···
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 ")"
let jsont =
let kind = "Attachment" in
let doc = "An attachment object" in
let create_obj url mime_type title size_in_bytes duration_in_seconds unknown =
create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ~unknown
()
···
|> Jsont.Object.opt_mem "size_in_bytes" Jsont.int64 ~enc:size_in_bytes
|> Jsont.Object.opt_mem "duration_in_seconds" Jsont.int
~enc:duration_in_seconds
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
|> Jsont.Object.finish
+4 -4
lib/attachment.mli
···
(*---------------------------------------------------------------------------
-
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
···
(** {1 Unknown Fields} *)
module Unknown : sig
-
type t = (string * Jsont.json) list
-
(** Unknown/unrecognized JSON object members. Useful for preserving fields
-
from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
···
(** {1 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. *)
+4 -30
lib/author.ml
···
---------------------------------------------------------------------------*)
module Unknown = struct
-
type t = (string * Jsont.json) list
-
let empty = []
-
let is_empty = function [] -> true | _ -> false
end
type t = {
···
let jsont =
let kind = "Author" in
let doc = "An author object with at least one field set" in
-
(* Custom mems map for Unknown.t that strips metadata from names *)
-
let unknown_mems :
-
(Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
-
let open Jsont.Object.Mems in
-
let dec_empty () = [] in
-
let dec_add _meta (name : string) value acc =
-
((name, Jsont.Meta.none), value) :: acc
-
in
-
let dec_finish _meta mems =
-
List.rev_map (fun ((name, _meta), value) -> (name, value)) mems
-
in
-
let enc =
-
{
-
enc =
-
(fun (type acc)
-
(f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc)
-
unknown
-
(acc : acc)
-
->
-
List.fold_left
-
(fun acc (name, value) -> f Jsont.Meta.none name value acc)
-
acc unknown);
-
}
-
in
-
map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
-
in
(* Constructor that matches the jsont object map pattern *)
let create_obj name url avatar unknown =
create ?name ?url ?avatar ~unknown ()
···
|> Jsont.Object.opt_mem "name" Jsont.string ~enc:name
|> Jsont.Object.opt_mem "url" Jsont.string ~enc:url
|> Jsont.Object.opt_mem "avatar" Jsont.string ~enc:avatar
-
|> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
|> Jsont.Object.finish
···
---------------------------------------------------------------------------*)
module Unknown = struct
+
type t = Jsont.json
+
let empty = Jsont.Object ([], Jsont.Meta.none)
+
let is_empty = function Jsont.Object ([], _) -> true | _ -> false
end
type t = {
···
let jsont =
let kind = "Author" in
let doc = "An author object with at least one field set" in
(* Constructor that matches the jsont object map pattern *)
let create_obj name url avatar unknown =
create ?name ?url ?avatar ~unknown ()
···
|> Jsont.Object.opt_mem "name" Jsont.string ~enc:name
|> Jsont.Object.opt_mem "url" Jsont.string ~enc:url
|> Jsont.Object.opt_mem "avatar" Jsont.string ~enc:avatar
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
|> Jsont.Object.finish
+4 -4
lib/author.mli
···
(*---------------------------------------------------------------------------
-
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
···
(** {1 Unknown Fields} *)
module Unknown : sig
-
type t = (string * Jsont.json) list
-
(** Unknown/unrecognized JSON object members. Useful for preserving fields
-
from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
···
(** {1 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
+5
lib/cito.mli
···
(** Citation Typing Ontology (CiTO) intent annotations.
CiTO provides a structured vocabulary for describing the nature of
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
(** Citation Typing Ontology (CiTO) intent annotations.
CiTO provides a structured vocabulary for describing the nature of
+4 -29
lib/hub.ml
···
---------------------------------------------------------------------------*)
module Unknown = struct
-
type t = (string * Jsont.json) list
-
let empty = []
-
let is_empty = function [] -> true | _ -> false
end
type t = { type_ : string; url : string; unknown : Unknown.t }
···
let jsont =
let kind = "Hub" in
let doc = "A hub endpoint" in
-
let unknown_mems :
-
(Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
-
let open Jsont.Object.Mems in
-
let dec_empty () = [] in
-
let dec_add _meta (name : string) value acc =
-
((name, Jsont.Meta.none), value) :: acc
-
in
-
let dec_finish _meta mems =
-
List.rev_map (fun ((name, _meta), value) -> (name, value)) mems
-
in
-
let enc =
-
{
-
enc =
-
(fun (type acc)
-
(f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc)
-
unknown
-
(acc : acc)
-
->
-
List.fold_left
-
(fun acc (name, value) -> f Jsont.Meta.none name value acc)
-
acc unknown);
-
}
-
in
-
map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
-
in
let create_obj type_ url unknown = create ~type_ ~url ~unknown () in
Jsont.Object.map ~kind ~doc create_obj
|> Jsont.Object.mem "type" Jsont.string ~enc:type_
|> Jsont.Object.mem "url" Jsont.string ~enc:url
-
|> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
|> Jsont.Object.finish
···
---------------------------------------------------------------------------*)
module Unknown = struct
+
type t = Jsont.json
+
let empty = Jsont.Object ([], Jsont.Meta.none)
+
let is_empty = function Jsont.Object ([], _) -> true | _ -> false
end
type t = { type_ : string; url : string; unknown : Unknown.t }
···
let jsont =
let kind = "Hub" in
let doc = "A hub endpoint" in
let create_obj type_ url unknown = create ~type_ ~url ~unknown () in
Jsont.Object.map ~kind ~doc create_obj
|> Jsont.Object.mem "type" Jsont.string ~enc:type_
|> Jsont.Object.mem "url" Jsont.string ~enc:url
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
|> Jsont.Object.finish
+3 -3
lib/hub.mli
···
(** {1 Unknown Fields} *)
module Unknown : sig
-
type t = (string * Jsont.json) list
-
(** Unknown/unrecognized JSON object members. Useful for preserving fields
-
from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
(** {1 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. *)
+22 -75
lib/item.ml
···
---------------------------------------------------------------------------*)
module Unknown = struct
-
type t = (string * Jsont.json) list
-
let empty = []
-
let is_empty = function [] -> true | _ -> false
end
type content = [ `Html of string | `Text of string | `Both of string * string ]
···
| `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
-
-
let unknown_mems :
-
(Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
-
let open Jsont.Object.Mems in
-
let dec_empty () = [] in
-
let dec_add _meta (name : string) value acc =
-
((name, Jsont.Meta.none), value) :: acc
-
in
-
let dec_finish _meta mems =
-
List.rev_map (fun ((name, _meta), value) -> (name, value)) mems
-
in
-
let enc =
-
{
-
enc =
-
(fun (type acc)
-
(f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc)
-
unknown
-
(acc : acc)
-
->
-
List.fold_left
-
(fun acc (name, value) -> f Jsont.Meta.none name value acc)
-
acc unknown);
-
}
-
in
-
map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
-
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 unknown_mems ~enc:enc_unknown
|> Jsont.Object.finish
···
---------------------------------------------------------------------------*)
module Unknown = struct
+
type t = Jsont.json
+
let empty = Jsont.Object ([], Jsont.Meta.none)
+
let is_empty = function Jsont.Object ([], _) -> true | _ -> false
end
type content = [ `Html of string | `Text of string | `Both of string * string ]
···
| `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
+4 -4
lib/item.mli
···
(*---------------------------------------------------------------------------
-
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
···
(** {1 Unknown Fields} *)
module Unknown : sig
-
type t = (string * Jsont.json) list
-
(** Unknown/unrecognized JSON object members. Useful for preserving fields
-
from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
···
(** {1 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. *)
+16 -47
lib/jsonfeed.ml
···
module Item = Item
module Unknown = struct
-
type t = (string * Jsont.json) list
-
let empty = []
-
let is_empty = function [] -> true | _ -> false
end
type t = {
···
let jsont =
let kind = "JSON Feed" in
let doc = "A JSON Feed document" in
-
let unknown_mems :
-
(Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
-
let open Jsont.Object.Mems in
-
let dec_empty () = [] in
-
let dec_add _meta (name : string) value acc =
-
((name, Jsont.Meta.none), value) :: acc
-
in
-
let dec_finish _meta mems =
-
List.rev_map (fun ((name, _meta), value) -> (name, value)) mems
-
in
-
let enc =
-
{
-
enc =
-
(fun (type acc)
-
(f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc)
-
unknown
-
(acc : acc)
-
->
-
List.fold_left
-
(fun acc (name, value) -> f Jsont.Meta.none name value acc)
-
acc unknown);
-
}
-
in
-
map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
-
in
(* Helper constructor that sets version automatically *)
let make_from_json _version title home_page_url feed_url description
···
|> Jsont.Object.opt_mem "expired" Jsont.bool ~enc:expired
|> Jsont.Object.opt_mem "hubs" (Jsont.list Hub.jsont) ~enc:hubs
|> Jsont.Object.mem "items" (Jsont.list Item.jsont) ~enc:items
-
|> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
|> Jsont.Object.finish
(* Encoding and Decoding *)
···
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)
···
module Item = Item
module Unknown = struct
+
type t = Jsont.json
+
let empty = Jsont.Object ([], Jsont.Meta.none)
+
let is_empty = function Jsont.Object ([], _) -> true | _ -> false
end
type t = {
···
let jsont =
let kind = "JSON Feed" in
let doc = "A JSON Feed document" in
(* Helper constructor that sets version automatically *)
let make_from_json _version title home_page_url feed_url description
···
|> Jsont.Object.opt_mem "expired" Jsont.bool ~enc:expired
|> Jsont.Object.opt_mem "hubs" (Jsont.list Hub.jsont) ~enc:hubs
|> Jsont.Object.mem "items" (Jsont.list Item.jsont) ~enc:items
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
|> Jsont.Object.finish
(* Encoding and Decoding *)
···
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)
+130 -8
lib/jsonfeed.mli
···
(*---------------------------------------------------------------------------
-
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
-
(** JSON Feed format parser and serializer using Jsont and Bytesrw.
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
type t
(** The type representing a complete JSON Feed. *)
-
(** {1 Jsont Type} *)
-
val jsont : t Jsont.t
-
(** Declarative JSON type for JSON feeds.
Maps the complete JSON Feed 1.1 specification including all required and
optional fields. *)
module Unknown : sig
-
type t = (string * Jsont.json) list
-
(** Unknown/unrecognized JSON object members. Useful for preserving fields
-
from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
val is_empty : t -> bool
(** [is_empty u] returns [true] if there are no unknown fields. *)
end
val create :
title:string ->
···
?unknown:Unknown.t ->
unit ->
t
val version : t -> string
val title : t -> string
val home_page_url : t -> string option
val feed_url : t -> string option
val description : t -> string option
val user_comment : t -> string option
val next_url : t -> string option
val icon : t -> string option
val favicon : t -> string option
val authors : t -> Author.t list option
val language : t -> string option
val expired : t -> bool option
val hubs : t -> Hub.t list option
val items : t -> Item.t list
val unknown : t -> Unknown.t
(** {1 Encoding and Decoding} *)
···
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
SPDX-License-Identifier: ISC
---------------------------------------------------------------------------*)
+
(** JSON Feed format parser and serializer.
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
type t
(** The type representing a complete JSON Feed. *)
val jsont : t Jsont.t
+
(** Declarative type that describes the structure of JSON Feeds.
Maps the complete JSON Feed 1.1 specification including all required and
optional 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. *)
···
val is_empty : t -> bool
(** [is_empty u] returns [true] if there are no unknown fields. *)
end
+
+
(** {1 Construction} *)
val create :
title:string ->
···
?unknown:Unknown.t ->
unit ->
t
+
(** [create ~title ~items ()] creates a new JSON Feed.
+
+
@param title
+
The name of the feed. Required field that should be plain text, not HTML.
+
@param home_page_url
+
The URL of the resource that the feed describes. This resource may or may
+
not actually be a "home" page, but it should be an HTML page. If a feed is
+
for a podcast, for instance, the home_page_url would be the URL for the
+
podcast's website.
+
@param feed_url
+
The URL of the feed itself. This is the URL that was requested to get this
+
JSON Feed response. Helps feed readers to determine when they're being
+
redirected. Strongly recommended for feeds.
+
@param description
+
A plain text description of the feed, for human consumption. May contain
+
some formatting (like newlines).
+
@param user_comment
+
A description of the purpose of the feed, for a person looking at the raw
+
JSON. This is for the publisher's use only, not intended to be displayed
+
to the user.
+
@param next_url
+
The URL of a feed that provides the next n items, where n is determined by
+
the publisher. Used for pagination. A feed reader may continue to request
+
the URLs in next_url until it reaches a feed without a next_url.
+
@param icon
+
The URL of an image for the feed suitable to be used in a timeline, much
+
the way an avatar might be used. Should be square and relatively large -
+
such as 512 x 512 pixels - and may be cropped to a circle or rounded
+
corners. Should not be transparent.
+
@param favicon
+
The URL of an image for the feed suitable to be used in a source list.
+
Should be square and relatively small - such as 64 x 64 pixels. Should not
+
be transparent.
+
@param authors
+
Specifies one or more feed authors. The author object has several members
+
(name, url, avatar) which are all optional, but at least one must be
+
present for the object to be valid.
+
@param language
+
The primary language for the feed in RFC 5646 format. The value can be a
+
language tag such as "en" or "en-US", or a language-region combination.
+
@param expired
+
Whether or not the feed is finished - that is, whether or not it will ever
+
update again. A feed for a temporary event, like an instance of a
+
conference, may expire. If the value is [true], feed readers may stop
+
checking for updates.
+
@param hubs
+
Endpoints that can be used to subscribe to real-time notifications from
+
the publisher of this feed. Each hub object has a type (such as "rssCloud"
+
or "WebSub") and url.
+
@param items
+
The items in the feed. Required field, though it may be an empty array.
+
@param unknown
+
Unknown JSON object members preserved from parsing. Useful for custom
+
extensions. *)
+
+
(** {1 Accessors} *)
val version : t -> string
+
(** [version feed] returns the URL of the version of the format the feed uses.
+
This will always be "https://jsonfeed.org/version/1.1" for feeds created
+
with this library. This is a required field in the JSON Feed spec. *)
+
val title : t -> string
+
(** [title feed] returns the name of the feed. This is plain text and should not
+
contain HTML. This is a required field. *)
+
val home_page_url : t -> string option
+
(** [home_page_url feed] returns the URL of the resource that the feed
+
describes. This resource may or may not actually be a "home" page, but it
+
should be an HTML page. For instance, if a feed is for a podcast, the
+
home_page_url would be the URL for the podcast's website. *)
+
val feed_url : t -> string option
+
(** [feed_url feed] returns the URL of the feed itself. This should be the URL
+
that was requested to get this JSON Feed response. It helps feed readers
+
determine when they're being redirected. This is strongly recommended for
+
feeds. *)
+
val description : t -> string option
+
(** [description feed] returns a plain text description of the feed, for human
+
consumption. This field may contain some formatting such as newlines. *)
+
val user_comment : t -> string option
+
(** [user_comment feed] returns a description of the purpose of the feed, for a
+
person looking at the raw JSON. This is for the publisher's use only and is
+
not intended to be displayed to end users. *)
+
val next_url : t -> string option
+
(** [next_url feed] returns the URL of a feed that provides the next n items,
+
where n is determined by the publisher. This is used for pagination. A feed
+
reader may continue to request the URLs in next_url until it reaches a feed
+
without a next_url. *)
+
val icon : t -> string option
+
(** [icon feed] returns the URL of an image for the feed suitable to be used in
+
a timeline, much the way an avatar might be used. It should be square and
+
relatively large (such as 512 x 512 pixels) and may be cropped to a circle
+
or rounded corners by feed readers. It should not be transparent. *)
+
val favicon : t -> string option
+
(** [favicon feed] returns the URL of an image for the feed suitable to be used
+
in a source list. It should be square and relatively small (such as 64 x 64
+
pixels) and should not be transparent. *)
+
val authors : t -> Author.t list option
+
(** [authors feed] returns the feed authors. Each author object has several
+
members (name, url, avatar) which are all optional, but at least one must be
+
present for the object to be valid. If a feed has multiple authors, they
+
should all be listed here. *)
+
val language : t -> string option
+
(** [language feed] returns the primary language for the feed in RFC 5646
+
format. The value can be a language tag such as "en" or "en-US", or a
+
language-region combination. This field helps feed readers present the feed
+
in the appropriate language. *)
+
val expired : t -> bool option
+
(** [expired feed] returns whether the feed is finished - that is, whether it
+
will ever update again. A feed for a temporary event, like an instance of a
+
conference, may expire. If the value is [Some true], feed readers may stop
+
checking for updates. *)
+
val hubs : t -> Hub.t list option
+
(** [hubs feed] returns endpoints that can be used to subscribe to real-time
+
notifications from the publisher of this feed. Each hub object has a type
+
(such as "rssCloud" or "WebSub") and a url. Feed readers can use these to
+
get immediate updates when new items are published. *)
+
val items : t -> Item.t list
+
(** [items feed] returns the array of items in the feed. This is a required
+
field, though it may be an empty list. Items represent the individual
+
entries in the feed - blog posts, podcast episodes, microblog posts, etc. *)
+
val unknown : t -> Unknown.t
+
(** [unknown feed] returns any unknown JSON object members that were preserved
+
during parsing. This is useful for custom extensions or fields from future
+
versions of the spec. *)
(** {1 Encoding and Decoding} *)
+5 -30
lib/reference.ml
···
---------------------------------------------------------------------------*)
module Unknown = struct
-
type t = (string * Jsont.json) list
-
let empty = []
-
let is_empty = function [] -> true | _ -> false
end
type t = {
···
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 doc = "A reference to a cited source" in
-
let unknown_mems :
-
(Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
-
let open Jsont.Object.Mems in
-
let dec_empty () = [] in
-
let dec_add _meta (name : string) value acc =
-
((name, Jsont.Meta.none), value) :: acc
-
in
-
let dec_finish _meta mems =
-
List.rev_map (fun ((name, _meta), value) -> (name, value)) mems
-
in
-
let enc =
-
{
-
enc =
-
(fun (type acc)
-
(f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc)
-
unknown
-
(acc : acc)
-
->
-
List.fold_left
-
(fun acc (name, value) -> f Jsont.Meta.none name value acc)
-
acc unknown);
-
}
-
in
-
map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
-
in
let create_obj url doi cito unknown = create ~url ?doi ?cito ~unknown () in
Jsont.Object.map ~kind ~doc create_obj
|> Jsont.Object.mem "url" Jsont.string ~enc:url
|> Jsont.Object.opt_mem "doi" Jsont.string ~enc:doi
|> Jsont.Object.opt_mem "cito" (Jsont.list Cito.jsont) ~enc:cito
-
|> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
|> Jsont.Object.finish
···
---------------------------------------------------------------------------*)
module Unknown = struct
+
type t = Jsont.json
+
let empty = Jsont.Object ([], Jsont.Meta.none)
+
let is_empty = function Jsont.Object ([], _) -> true | _ -> false
end
type t = {
···
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
let doc = "A reference to a cited source" in
let create_obj url doi cito unknown = create ~url ?doi ?cito ~unknown () in
Jsont.Object.map ~kind ~doc create_obj
|> Jsont.Object.mem "url" Jsont.string ~enc:url
|> Jsont.Object.opt_mem "doi" Jsont.string ~enc:doi
|> Jsont.Object.opt_mem "cito" (Jsont.list Cito.jsont) ~enc:cito
+
|> Jsont.Object.keep_unknown Jsont.json_mems ~enc:unknown
|> Jsont.Object.finish
+3 -3
lib/reference.mli
···
(** {1 Unknown Fields} *)
module Unknown : sig
-
type t = (string * Jsont.json) list
-
(** Unknown/unrecognized JSON object members. Useful for preserving fields
-
from custom extensions or future spec versions. *)
val empty : t
(** [empty] is the empty list of unknown fields. *)
···
(** {1 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)
+182
test/test_jsonfeed.ml
···
test_feed_parse_invalid_missing_content );
]
(* Main test suite *)
let () =
···
("Hub", hub_tests);
("Item", item_tests);
("Jsonfeed", jsonfeed_tests);
]
···
test_feed_parse_invalid_missing_content );
]
+
(* Unknown fields preservation tests *)
+
+
let test_author_unknown_roundtrip () =
+
let json =
+
{|{
+
"name": "Test Author",
+
"custom_field": "custom value",
+
"another_extension": 42
+
}|}
+
in
+
match Jsont_bytesrw.decode_string' Author.jsont json with
+
| 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))
+
| Ok json2 -> (
+
match Jsont_bytesrw.decode_string' Author.jsont json2 with
+
| Error e ->
+
Alcotest.fail
+
(Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
+
| Ok author2 ->
+
(* Verify unknown fields survive roundtrip *)
+
let unknown2 = Author.unknown author2 in
+
Alcotest.(check bool)
+
"unknown fields preserved" false
+
(Jsonfeed.Unknown.is_empty unknown2)))
+
+
let test_item_unknown_roundtrip () =
+
let json =
+
{|{
+
"id": "https://example.com/1",
+
"content_html": "<p>Test</p>",
+
"custom_metadata": "some custom data",
+
"x_custom_number": 123.45
+
}|}
+
in
+
match Jsont_bytesrw.decode_string' Item.jsont json with
+
| 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))
+
| Ok json2 -> (
+
match Jsont_bytesrw.decode_string' Item.jsont json2 with
+
| Error e ->
+
Alcotest.fail
+
(Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
+
| Ok item2 ->
+
let unknown2 = Item.unknown item2 in
+
Alcotest.(check bool)
+
"unknown fields preserved" false
+
(Jsonfeed.Unknown.is_empty unknown2)))
+
+
let test_feed_unknown_roundtrip () =
+
let json =
+
{|{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Test Feed",
+
"items": [],
+
"custom_extension": "custom value",
+
"x_another_field": {"nested": "data"}
+
}|}
+
in
+
match Jsonfeed.of_string json with
+
| 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))
+
| Ok json2 -> (
+
match Jsonfeed.of_string json2 with
+
| Error e ->
+
Alcotest.fail
+
(Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
+
| Ok feed2 ->
+
let unknown2 = Jsonfeed.unknown feed2 in
+
Alcotest.(check bool)
+
"unknown fields preserved" false
+
(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))
+
| Ok json2 -> (
+
match Jsont_bytesrw.decode_string' Hub.jsont json2 with
+
| Error e ->
+
Alcotest.fail
+
(Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
+
| Ok hub2 ->
+
let unknown2 = Hub.unknown hub2 in
+
Alcotest.(check bool)
+
"unknown fields preserved" false
+
(Jsonfeed.Unknown.is_empty unknown2)))
+
+
let test_attachment_unknown_roundtrip () =
+
let json =
+
{|{
+
"url": "https://example.com/file.mp3",
+
"mime_type": "audio/mpeg",
+
"x_custom": "value"
+
}|}
+
in
+
match Jsont_bytesrw.decode_string' Attachment.jsont json with
+
| 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))
+
| Ok json2 -> (
+
match Jsont_bytesrw.decode_string' Attachment.jsont json2 with
+
| Error e ->
+
Alcotest.fail
+
(Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
+
| Ok att2 ->
+
let unknown2 = Attachment.unknown att2 in
+
Alcotest.(check bool)
+
"unknown fields preserved" false
+
(Jsonfeed.Unknown.is_empty unknown2)))
+
+
let unknown_fields_tests =
+
[
+
("author unknown roundtrip", `Quick, test_author_unknown_roundtrip);
+
("item unknown roundtrip", `Quick, test_item_unknown_roundtrip);
+
("feed unknown roundtrip", `Quick, test_feed_unknown_roundtrip);
+
("hub unknown roundtrip", `Quick, test_hub_unknown_roundtrip);
+
("attachment unknown roundtrip", `Quick, test_attachment_unknown_roundtrip);
+
]
+
(* Main test suite *)
let () =
···
("Hub", hub_tests);
("Item", item_tests);
("Jsonfeed", jsonfeed_tests);
+
("Unknown Fields", unknown_fields_tests);
]