OCaml library for JSONfeed parsing and creation

Compare changes

Choose any two refs to compare.

+3
.gitignore
···
_build
blog-feed.json
···
_build
blog-feed.json
+
*.bak*
+
*.old
+
.*.swp
+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)
+
+12 -2
README.md
···
| Some title -> Printf.printf "- %s\n" title
| None -> ()
) (Jsonfeed.items feed)
-
| Error (`Msg err) ->
Printf.eprintf "Parse error: %s\n" err
(* Parse from file *)
···
- **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
···
| Some title -> Printf.printf "- %s\n" title
| None -> ()
) (Jsonfeed.items feed)
+
| Error err ->
Printf.eprintf "Parse error: %s\n" err
(* Parse from file *)
···
- **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)))))
+5
example/dune
···
(name feed_validator)
(modules feed_validator)
(libraries jsonfeed))
···
(name feed_validator)
(modules feed_validator)
(libraries jsonfeed))
+
+
(executable
+
(name feed_echo)
+
(modules feed_echo)
+
(libraries jsonfeed))
+33
example/feed_echo.ml
···
···
+
(** Example: JSON Feed Echo
+
+
Reads a JSON Feed from stdin, parses it, and outputs it to stdout. Useful
+
for testing round-trip parsing and identifying any changes during
+
serialization/deserialization.
+
+
Usage: feed_echo < feed.json cat feed.json | feed_echo > output.json diff
+
<(cat feed.json | feed_echo) feed.json
+
+
Exit codes: 0 - Success 1 - Parsing or encoding failed *)
+
+
let echo_feed () =
+
(* Create a bytesrw reader from stdin *)
+
let stdin = Bytesrw.Bytes.Reader.of_in_channel In_channel.stdin in
+
+
(* Parse the JSON feed *)
+
match Jsonfeed.decode ~locs:true stdin with
+
| Error err ->
+
Format.eprintf "Parsing failed:\n %s\n%!" (Jsont.Error.to_string err);
+
exit 1
+
| Ok feed -> (
+
(* Encode the feed back to stdout *)
+
match Jsonfeed.to_string ~minify:false feed with
+
| Error err ->
+
Format.eprintf "Encoding failed:\n %s\n%!"
+
(Jsont.Error.to_string err);
+
exit 1
+
| Ok json ->
+
print_string json;
+
print_newline ();
+
exit 0)
+
+
let () = echo_feed ()
+103 -105
example/feed_example.ml
···
(* Helper to write feed to output channel *)
let to_file filename feed =
-
let s = Jsonfeed.to_string feed in
-
Out_channel.with_open_gen
-
[Open_wronly; Open_creat; Open_trunc; Open_text]
-
0o644
-
filename
-
(fun oc -> Out_channel.output_string oc s)
let create_blog_feed () =
(* Create some authors *)
-
let jane = Author.create
-
~name:"Jane Doe"
-
~url:"https://example.com/authors/jane"
-
~avatar:"https://example.com/avatars/jane.png"
-
() in
-
let john = Author.create
-
~name:"John Smith"
-
~url:"https://example.com/authors/john"
-
() in
(* Create items with different content types *)
-
let item1 = Item.create
-
~id:"https://example.com/posts/1"
-
~url:"https://example.com/posts/1"
-
~title:"Introduction to OCaml"
-
~content:(`Both (
-
"<p>OCaml is a powerful functional programming language.</p>",
-
"OCaml is a powerful functional programming language."
-
))
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T10:00:00Z" |> Option.get)
-
~date_modified:(Jsonfeed.parse_rfc3339 "2024-11-01T15:30:00Z" |> Option.get)
-
~authors:[jane]
-
~tags:["ocaml"; "programming"; "functional"]
-
~summary:"A beginner's guide to OCaml programming"
-
() in
-
let item2 = Item.create
-
~id:"https://example.com/posts/2"
-
~url:"https://example.com/posts/2"
-
~title:"JSON Feed for Syndication"
-
~content:(`Html "<p>JSON Feed is a modern alternative to RSS and Atom.</p>")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-02T09:00:00Z" |> Option.get)
-
~authors:[jane; john]
-
~tags:["json"; "syndication"; "web"]
-
~image:"https://example.com/images/jsonfeed.png"
-
() in
(* Microblog-style item (text only, no title) *)
-
let item3 = Item.create
-
~id:"https://example.com/micro/42"
-
~content:(`Text "Just shipped a new feature! ๐Ÿš€")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-03T08:15:00Z" |> Option.get)
-
~tags:["microblog"]
-
() in
(* Create the complete feed *)
-
let feed = Jsonfeed.create
-
~title:"Example Blog"
-
~home_page_url:"https://example.com"
-
~feed_url:"https://example.com/feed.json"
-
~description:"A blog about programming, web development, and technology"
-
~icon:"https://example.com/icon-512.png"
-
~favicon:"https://example.com/favicon-64.png"
-
~authors:[jane; john]
-
~language:"en-US"
-
~items:[item1; item2; item3]
-
() in
feed
let create_podcast_feed () =
(* Create podcast author *)
-
let host = Author.create
-
~name:"Podcast Host"
-
~url:"https://podcast.example.com/host"
-
~avatar:"https://podcast.example.com/host-avatar.jpg"
-
() in
(* Create episode with audio attachment *)
-
let attachment = Attachment.create
-
~url:"https://podcast.example.com/episodes/ep1.mp3"
-
~mime_type:"audio/mpeg"
-
~title:"Episode 1: Introduction"
-
~size_in_bytes:15_728_640L
-
~duration_in_seconds:1800
-
() in
-
let episode = Item.create
-
~id:"https://podcast.example.com/episodes/1"
-
~url:"https://podcast.example.com/episodes/1"
-
~title:"Episode 1: Introduction"
-
~content:(`Html "<p>Welcome to our first episode!</p>")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T12:00:00Z" |> Option.get)
-
~attachments:[attachment]
-
~authors:[host]
-
~image:"https://podcast.example.com/episodes/ep1-cover.jpg"
-
() in
(* Create podcast feed with hub for real-time updates *)
-
let hub = Hub.create
-
~type_:"WebSub"
-
~url:"https://pubsubhubbub.appspot.com/"
-
() in
-
let feed = Jsonfeed.create
-
~title:"Example Podcast"
-
~home_page_url:"https://podcast.example.com"
-
~feed_url:"https://podcast.example.com/feed.json"
-
~description:"A podcast about interesting topics"
-
~icon:"https://podcast.example.com/icon.png"
-
~authors:[host]
-
~language:"en-US"
-
~hubs:[hub]
-
~items:[episode]
-
() in
feed
···
Format.printf "Created blog feed: %a\n\n" Jsonfeed.pp blog_feed;
(* Serialize to string *)
-
let json_string = Jsonfeed.to_string blog_feed in
-
Format.printf "JSON (first 200 chars): %s...\n\n"
-
(String.sub json_string 0 (min 200 (String.length json_string)));
(* Serialize to file *)
to_file "blog-feed.json" blog_feed;
···
(* Validate feeds *)
(match Jsonfeed.validate blog_feed with
-
| Ok () -> Format.printf "โœ“ Blog feed is valid\n"
-
| Error errors ->
-
Format.printf "โœ— Blog feed validation errors:\n";
-
List.iter (Format.printf " - %s\n") errors);
-
(match Jsonfeed.validate podcast_feed with
-
| Ok () -> Format.printf "โœ“ Podcast feed is valid\n"
-
| Error errors ->
-
Format.printf "โœ— Podcast feed validation errors:\n";
-
List.iter (Format.printf " - %s\n") errors)
let () = main ()
···
(* Helper to write feed to output channel *)
let to_file filename feed =
+
match Jsonfeed.to_string feed with
+
| Ok s ->
+
Out_channel.with_open_gen
+
[ Open_wronly; Open_creat; Open_trunc; Open_text ] 0o644 filename
+
(fun oc -> Out_channel.output_string oc s)
+
| Error e ->
+
Printf.eprintf "Error encoding feed: %s\n" (Jsont.Error.to_string e);
+
exit 1
let create_blog_feed () =
(* Create some authors *)
+
let jane =
+
Author.create ~name:"Jane Doe" ~url:"https://example.com/authors/jane"
+
~avatar:"https://example.com/avatars/jane.png" ()
+
in
+
let john =
+
Author.create ~name:"John Smith" ~url:"https://example.com/authors/john" ()
+
in
(* Create items with different content types *)
+
let item1 =
+
Item.create ~id:"https://example.com/posts/1"
+
~url:"https://example.com/posts/1" ~title:"Introduction to OCaml"
+
~content:
+
(`Both
+
( "<p>OCaml is a powerful functional programming language.</p>",
+
"OCaml is a powerful functional programming language." ))
+
~date_published:
+
(Jsonfeed.Rfc3339.parse "2024-11-01T10:00:00Z" |> Option.get)
+
~date_modified:
+
(Jsonfeed.Rfc3339.parse "2024-11-01T15:30:00Z" |> Option.get)
+
~authors:[ jane ]
+
~tags:[ "ocaml"; "programming"; "functional" ]
+
~summary:"A beginner's guide to OCaml programming" ()
+
in
+
let item2 =
+
Item.create ~id:"https://example.com/posts/2"
+
~url:"https://example.com/posts/2" ~title:"JSON Feed for Syndication"
+
~content:
+
(`Html "<p>JSON Feed is a modern alternative to RSS and Atom.</p>")
+
~date_published:
+
(Jsonfeed.Rfc3339.parse "2024-11-02T09:00:00Z" |> Option.get)
+
~authors:[ jane; john ]
+
~tags:[ "json"; "syndication"; "web" ]
+
~image:"https://example.com/images/jsonfeed.png" ()
+
in
(* Microblog-style item (text only, no title) *)
+
let item3 =
+
Item.create ~id:"https://example.com/micro/42"
+
~content:(`Text "Just shipped a new feature! ๐Ÿš€")
+
~date_published:
+
(Jsonfeed.Rfc3339.parse "2024-11-03T08:15:00Z" |> Option.get)
+
~tags:[ "microblog" ] ()
+
in
(* Create the complete feed *)
+
let feed =
+
Jsonfeed.create ~title:"Example Blog" ~home_page_url:"https://example.com"
+
~feed_url:"https://example.com/feed.json"
+
~description:"A blog about programming, web development, and technology"
+
~icon:"https://example.com/icon-512.png"
+
~favicon:"https://example.com/favicon-64.png" ~authors:[ jane; john ]
+
~language:"en-US" ~items:[ item1; item2; item3 ] ()
+
in
feed
let create_podcast_feed () =
(* Create podcast author *)
+
let host =
+
Author.create ~name:"Podcast Host" ~url:"https://podcast.example.com/host"
+
~avatar:"https://podcast.example.com/host-avatar.jpg" ()
+
in
(* Create episode with audio attachment *)
+
let attachment =
+
Attachment.create ~url:"https://podcast.example.com/episodes/ep1.mp3"
+
~mime_type:"audio/mpeg" ~title:"Episode 1: Introduction"
+
~size_in_bytes:15_728_640L ~duration_in_seconds:1800 ()
+
in
+
let episode =
+
Item.create ~id:"https://podcast.example.com/episodes/1"
+
~url:"https://podcast.example.com/episodes/1"
+
~title:"Episode 1: Introduction"
+
~content:(`Html "<p>Welcome to our first episode!</p>")
+
~date_published:
+
(Jsonfeed.Rfc3339.parse "2024-11-01T12:00:00Z" |> Option.get)
+
~attachments:[ attachment ] ~authors:[ host ]
+
~image:"https://podcast.example.com/episodes/ep1-cover.jpg" ()
+
in
(* Create podcast feed with hub for real-time updates *)
+
let hub =
+
Hub.create ~type_:"WebSub" ~url:"https://pubsubhubbub.appspot.com/" ()
+
in
+
let feed =
+
Jsonfeed.create ~title:"Example Podcast"
+
~home_page_url:"https://podcast.example.com"
+
~feed_url:"https://podcast.example.com/feed.json"
+
~description:"A podcast about interesting topics"
+
~icon:"https://podcast.example.com/icon.png" ~authors:[ host ]
+
~language:"en-US" ~hubs:[ hub ] ~items:[ episode ] ()
+
in
feed
···
Format.printf "Created blog feed: %a\n\n" Jsonfeed.pp blog_feed;
(* Serialize to string *)
+
(match Jsonfeed.to_string blog_feed with
+
| Ok json_string ->
+
Format.printf "JSON (first 200 chars): %s...\n\n"
+
(String.sub json_string 0 (min 200 (String.length json_string)))
+
| Error e ->
+
Printf.eprintf "Error serializing to string: %s\n"
+
(Jsont.Error.to_string e);
+
exit 1);
(* Serialize to file *)
to_file "blog-feed.json" blog_feed;
···
(* Validate feeds *)
(match Jsonfeed.validate blog_feed with
+
| Ok () -> Format.printf "โœ“ Blog feed is valid\n"
+
| Error errors ->
+
Format.printf "โœ— Blog feed validation errors:\n";
+
List.iter (Format.printf " - %s\n") errors);
+
match Jsonfeed.validate podcast_feed with
+
| Ok () -> Format.printf "โœ“ Podcast feed is valid\n"
+
| Error errors ->
+
Format.printf "โœ— Podcast feed validation errors:\n";
+
List.iter (Format.printf " - %s\n") errors
let () = main ()
+90 -86
example/feed_parser.ml
···
Format.printf " Version: %s\n" (Jsonfeed.version feed);
(match Jsonfeed.home_page_url feed with
-
| Some url -> Format.printf " Home Page: %s\n" url
-
| None -> ());
(match Jsonfeed.feed_url feed with
-
| Some url -> Format.printf " Feed URL: %s\n" url
-
| None -> ());
(match Jsonfeed.description feed with
-
| Some desc -> Format.printf " Description: %s\n" desc
-
| None -> ());
(match Jsonfeed.language feed with
-
| Some lang -> Format.printf " Language: %s\n" lang
-
| None -> ());
(match Jsonfeed.authors feed with
-
| Some authors ->
-
Format.printf " Authors:\n";
-
List.iter (fun author ->
-
match Author.name author with
-
| Some name -> Format.printf " - %s" name;
-
(match Author.url author with
| Some url -> Format.printf " (%s)" url
| None -> ());
-
Format.printf "\n"
-
| None -> ()
-
) authors
-
| None -> ());
Format.printf " Items: %d\n\n" (List.length (Jsonfeed.items feed))
···
Format.printf "Item: %s\n" (Item.id item);
(match Item.title item with
-
| Some title -> Format.printf " Title: %s\n" title
-
| None -> Format.printf " (No title - microblog entry)\n");
(match Item.url item with
-
| Some url -> Format.printf " URL: %s\n" url
-
| None -> ());
(* Print content info *)
(match Item.content item with
-
| `Html html ->
-
Format.printf " Content: HTML only (%d chars)\n"
-
(String.length html)
-
| `Text text ->
-
Format.printf " Content: Text only (%d chars)\n"
-
(String.length text)
-
| `Both (html, text) ->
-
Format.printf " Content: Both HTML (%d chars) and Text (%d chars)\n"
-
(String.length html) (String.length text));
(* Print dates *)
(match Item.date_published item with
-
| Some date ->
-
Format.printf " Published: %s\n"
-
(Jsonfeed.format_rfc3339 date)
-
| None -> ());
(match Item.date_modified item with
-
| Some date ->
-
Format.printf " Modified: %s\n"
-
(Jsonfeed.format_rfc3339 date)
-
| None -> ());
(* Print tags *)
(match Item.tags item with
-
| Some tags when tags <> [] ->
-
Format.printf " Tags: %s\n" (String.concat ", " tags)
-
| _ -> ());
(* Print attachments *)
(match Item.attachments item with
-
| Some attachments when attachments <> [] ->
-
Format.printf " Attachments:\n";
-
List.iter (fun att ->
-
Format.printf " - %s (%s)\n"
-
(Attachment.url att)
-
(Attachment.mime_type att);
-
(match Attachment.size_in_bytes att with
| Some size ->
let mb = Int64.to_float size /. (1024. *. 1024.) in
Format.printf " Size: %.2f MB\n" mb
| None -> ());
-
(match Attachment.duration_in_seconds att with
| Some duration ->
let mins = duration / 60 in
let secs = duration mod 60 in
Format.printf " Duration: %dm%ds\n" mins secs
| None -> ())
-
) attachments
-
| _ -> ());
Format.printf "\n"
···
let text_only = ref 0 in
let both = ref 0 in
-
List.iter (fun item ->
-
match Item.content item with
-
| `Html _ -> incr html_only
-
| `Text _ -> incr text_only
-
| `Both _ -> incr both
-
) items;
Format.printf "Content Types:\n";
Format.printf " HTML only: %d\n" !html_only;
···
Format.printf " Both: %d\n\n" !both;
(* Find items with attachments *)
-
let with_attachments = List.filter (fun item ->
-
match Item.attachments item with
-
| Some att when att <> [] -> true
-
| _ -> false
-
) items in
Format.printf "Items with attachments: %d\n\n" (List.length with_attachments);
(* Collect all unique tags *)
-
let all_tags = List.fold_left (fun acc item ->
-
match Item.tags item with
-
| Some tags -> acc @ tags
-
| None -> acc
-
) [] items in
let unique_tags = List.sort_uniq String.compare all_tags in
-
if unique_tags <> [] then (
Format.printf "All tags used: %s\n\n" (String.concat ", " unique_tags)
-
)
let main () =
(* Parse from example_feed.json file *)
Format.printf "=== Parsing JSON Feed from example_feed.json ===\n\n";
-
(try
match of_file "example/example_feed.json" with
-
| Ok feed ->
print_feed_info feed;
Format.printf "=== Items ===\n\n";
···
(* Demonstrate round-trip parsing *)
Format.printf "\n=== Round-trip Test ===\n\n";
-
let json = Jsonfeed.to_string feed in
-
(match Jsonfeed.of_string json with
-
| Ok feed2 ->
-
if Jsonfeed.equal feed feed2 then
-
Format.printf "โœ“ Round-trip successful: feeds are equal\n"
-
else
-
Format.printf "โœ— Round-trip failed: feeds differ\n"
-
| Error (`Msg err) ->
-
Format.eprintf "โœ— Round-trip failed: %s\n" err)
-
| Error (`Msg err) ->
-
Format.eprintf "Error parsing feed: %s\n" err
-
with
-
| Sys_error msg ->
-
Format.eprintf "Error reading file: %s\n" msg)
let () = main ()
···
Format.printf " Version: %s\n" (Jsonfeed.version feed);
(match Jsonfeed.home_page_url feed with
+
| Some url -> Format.printf " Home Page: %s\n" url
+
| None -> ());
(match Jsonfeed.feed_url feed with
+
| Some url -> Format.printf " Feed URL: %s\n" url
+
| None -> ());
(match Jsonfeed.description feed with
+
| Some desc -> Format.printf " Description: %s\n" desc
+
| None -> ());
(match Jsonfeed.language feed with
+
| Some lang -> Format.printf " Language: %s\n" lang
+
| None -> ());
(match Jsonfeed.authors feed with
+
| Some authors ->
+
Format.printf " Authors:\n";
+
List.iter
+
(fun author ->
+
match Author.name author with
+
| Some name ->
+
Format.printf " - %s" name;
+
(match Author.url author with
| Some url -> Format.printf " (%s)" url
| None -> ());
+
Format.printf "\n"
+
| None -> ())
+
authors
+
| None -> ());
Format.printf " Items: %d\n\n" (List.length (Jsonfeed.items feed))
···
Format.printf "Item: %s\n" (Item.id item);
(match Item.title item with
+
| Some title -> Format.printf " Title: %s\n" title
+
| None -> Format.printf " (No title - microblog entry)\n");
(match Item.url item with
+
| Some url -> Format.printf " URL: %s\n" url
+
| None -> ());
(* Print content info *)
(match Item.content item with
+
| `Html html ->
+
Format.printf " Content: HTML only (%d chars)\n" (String.length html)
+
| `Text text ->
+
Format.printf " Content: Text only (%d chars)\n" (String.length text)
+
| `Both (html, text) ->
+
Format.printf " Content: Both HTML (%d chars) and Text (%d chars)\n"
+
(String.length html) (String.length text));
(* Print dates *)
(match Item.date_published item with
+
| Some date ->
+
Format.printf " Published: %s\n" (Jsonfeed.Rfc3339.format date)
+
| None -> ());
(match Item.date_modified item with
+
| Some date -> Format.printf " Modified: %s\n" (Jsonfeed.Rfc3339.format date)
+
| None -> ());
(* Print tags *)
(match Item.tags item with
+
| Some tags when tags <> [] ->
+
Format.printf " Tags: %s\n" (String.concat ", " tags)
+
| _ -> ());
(* Print attachments *)
(match Item.attachments item with
+
| Some attachments when attachments <> [] ->
+
Format.printf " Attachments:\n";
+
List.iter
+
(fun att ->
+
Format.printf " - %s (%s)\n" (Attachment.url att)
+
(Attachment.mime_type att);
+
(match Attachment.size_in_bytes att with
| Some size ->
let mb = Int64.to_float size /. (1024. *. 1024.) in
Format.printf " Size: %.2f MB\n" mb
| None -> ());
+
match Attachment.duration_in_seconds att with
| Some duration ->
let mins = duration / 60 in
let secs = duration mod 60 in
Format.printf " Duration: %dm%ds\n" mins secs
| None -> ())
+
attachments
+
| _ -> ());
Format.printf "\n"
···
let text_only = ref 0 in
let both = ref 0 in
+
List.iter
+
(fun item ->
+
match Item.content item with
+
| `Html _ -> incr html_only
+
| `Text _ -> incr text_only
+
| `Both _ -> incr both)
+
items;
Format.printf "Content Types:\n";
Format.printf " HTML only: %d\n" !html_only;
···
Format.printf " Both: %d\n\n" !both;
(* Find items with attachments *)
+
let with_attachments =
+
List.filter
+
(fun item ->
+
match Item.attachments item with
+
| Some att when att <> [] -> true
+
| _ -> false)
+
items
+
in
Format.printf "Items with attachments: %d\n\n" (List.length with_attachments);
(* Collect all unique tags *)
+
let all_tags =
+
List.fold_left
+
(fun acc item ->
+
match Item.tags item with Some tags -> acc @ tags | None -> acc)
+
[] items
+
in
let unique_tags = List.sort_uniq String.compare all_tags in
+
if unique_tags <> [] then
Format.printf "All tags used: %s\n\n" (String.concat ", " unique_tags)
let main () =
(* Parse from example_feed.json file *)
Format.printf "=== Parsing JSON Feed from example_feed.json ===\n\n";
+
try
match of_file "example/example_feed.json" with
+
| Ok feed -> (
print_feed_info feed;
Format.printf "=== Items ===\n\n";
···
(* Demonstrate round-trip parsing *)
Format.printf "\n=== Round-trip Test ===\n\n";
+
match Jsonfeed.to_string feed with
+
| Error e ->
+
Printf.eprintf "Error serializing feed: %s\n"
+
(Jsont.Error.to_string e);
+
exit 1
+
| Ok json -> (
+
match Jsonfeed.of_string json with
+
| Ok feed2 ->
+
if Jsonfeed.equal feed feed2 then
+
Format.printf "โœ“ Round-trip successful: feeds are equal\n"
+
else Format.printf "โœ— Round-trip failed: feeds differ\n"
+
| Error err ->
+
Format.eprintf "โœ— Round-trip failed: %s\n"
+
(Jsont.Error.to_string err)))
+
| Error err ->
+
Format.eprintf "Error parsing feed: %s\n" (Jsont.Error.to_string err)
+
with Sys_error msg -> Format.eprintf "Error reading file: %s\n" msg
let () = main ()
+28 -298
example/feed_validator.ml
···
-
(** Example: Validating JSON Feeds
-
-
This demonstrates:
-
- Validating feed structure
-
- Testing various edge cases
-
- Handling invalid feeds
-
- Best practices for feed construction *)
-
-
open Jsonfeed
-
-
let test_valid_minimal_feed () =
-
Format.printf "=== Test: Minimal Valid Feed ===\n";
-
-
let feed = Jsonfeed.create
-
~title:"Minimal Feed"
-
~items:[]
-
() in
-
-
match Jsonfeed.validate feed with
-
| Ok () -> Format.printf "โœ“ Minimal feed is valid\n\n"
-
| Error errors ->
-
Format.printf "โœ— Minimal feed validation failed:\n";
-
List.iter (Format.printf " - %s\n") errors;
-
Format.printf "\n"
-
-
let test_valid_complete_feed () =
-
Format.printf "=== Test: Complete Valid Feed ===\n";
-
-
let author = Author.create
-
~name:"Test Author"
-
~url:"https://example.com/author"
-
~avatar:"https://example.com/avatar.png"
-
() in
-
-
let attachment = Attachment.create
-
~url:"https://example.com/file.mp3"
-
~mime_type:"audio/mpeg"
-
~title:"Audio File"
-
~size_in_bytes:1024L
-
~duration_in_seconds:60
-
() in
-
-
let item = Item.create
-
~id:"https://example.com/items/1"
-
~url:"https://example.com/items/1"
-
~title:"Test Item"
-
~content:(`Both ("<p>HTML content</p>", "Text content"))
-
~summary:"A test item"
-
~image:"https://example.com/image.jpg"
-
~banner_image:"https://example.com/banner.jpg"
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T10:00:00Z" |> Option.get)
-
~date_modified:(Jsonfeed.parse_rfc3339 "2024-11-01T15:00:00Z" |> Option.get)
-
~authors:[author]
-
~tags:["test"; "example"]
-
~language:"en"
-
~attachments:[attachment]
-
() in
-
-
let hub = Hub.create
-
~type_:"WebSub"
-
~url:"https://pubsubhubbub.appspot.com/"
-
() in
-
-
let feed = Jsonfeed.create
-
~title:"Complete Feed"
-
~home_page_url:"https://example.com"
-
~feed_url:"https://example.com/feed.json"
-
~description:"A complete test feed"
-
~user_comment:"This is a test feed"
-
~icon:"https://example.com/icon.png"
-
~favicon:"https://example.com/favicon.ico"
-
~authors:[author]
-
~language:"en-US"
-
~hubs:[hub]
-
~items:[item]
-
() in
-
-
match Jsonfeed.validate feed with
-
| Ok () -> Format.printf "โœ“ Complete feed is valid\n\n"
-
| Error errors ->
-
Format.printf "โœ— Complete feed validation failed:\n";
-
List.iter (Format.printf " - %s\n") errors;
-
Format.printf "\n"
-
-
let test_feed_with_multiple_items () =
-
Format.printf "=== Test: Feed with Multiple Items ===\n";
-
-
let items = List.init 10 (fun i ->
-
Item.create
-
~id:(Printf.sprintf "https://example.com/items/%d" i)
-
~content:(`Text (Printf.sprintf "Item %d content" i))
-
~title:(Printf.sprintf "Item %d" i)
-
~date_published:(Jsonfeed.parse_rfc3339
-
(Printf.sprintf "2024-11-%02dT10:00:00Z" (i + 1)) |> Option.get)
-
()
-
) in
-
-
let feed = Jsonfeed.create
-
~title:"Multi-item Feed"
-
~items
-
() in
-
-
match Jsonfeed.validate feed with
-
| Ok () ->
-
Format.printf "โœ“ Feed with %d items is valid\n\n" (List.length items)
-
| Error errors ->
-
Format.printf "โœ— Multi-item feed validation failed:\n";
-
List.iter (Format.printf " - %s\n") errors;
-
Format.printf "\n"
-
-
let test_podcast_feed () =
-
Format.printf "=== Test: Podcast Feed ===\n";
-
-
let host = Author.create
-
~name:"Podcast Host"
-
~url:"https://podcast.example.com/host"
-
() in
-
-
let episode1 = Attachment.create
-
~url:"https://podcast.example.com/ep1.mp3"
-
~mime_type:"audio/mpeg"
-
~title:"Episode 1"
-
~size_in_bytes:20_971_520L (* 20 MB *)
-
~duration_in_seconds:1800 (* 30 minutes *)
-
() in
-
-
(* Alternate format of the same episode *)
-
let episode1_aac = Attachment.create
-
~url:"https://podcast.example.com/ep1.aac"
-
~mime_type:"audio/aac"
-
~title:"Episode 1"
-
~size_in_bytes:16_777_216L
-
~duration_in_seconds:1800
-
() in
-
-
let item = Item.create
-
~id:"https://podcast.example.com/episodes/1"
-
~url:"https://podcast.example.com/episodes/1"
-
~title:"Episode 1: Introduction"
-
~content:(`Html "<p>Welcome to the first episode!</p>")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T12:00:00Z" |> Option.get)
-
~authors:[host]
-
~attachments:[episode1; episode1_aac]
-
~image:"https://podcast.example.com/ep1-cover.jpg"
-
() in
-
-
let feed = Jsonfeed.create
-
~title:"Example Podcast"
-
~home_page_url:"https://podcast.example.com"
-
~feed_url:"https://podcast.example.com/feed.json"
-
~authors:[host]
-
~items:[item]
-
() in
-
-
match Jsonfeed.validate feed with
-
| Ok () -> Format.printf "โœ“ Podcast feed is valid\n\n"
-
| Error errors ->
-
Format.printf "โœ— Podcast feed validation failed:\n";
-
List.iter (Format.printf " - %s\n") errors;
-
Format.printf "\n"
-
-
let test_microblog_feed () =
-
Format.printf "=== Test: Microblog Feed (no titles) ===\n";
-
let author = Author.create
-
~name:"Microblogger"
-
~url:"https://micro.example.com"
-
() in
-
let items = [
-
Item.create
-
~id:"https://micro.example.com/1"
-
~content:(`Text "Just posted a new photo!")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T08:00:00Z" |> Option.get)
-
();
-
Item.create
-
~id:"https://micro.example.com/2"
-
~content:(`Text "Having a great day! โ˜€๏ธ")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T12:30:00Z" |> Option.get)
-
();
-
Item.create
-
~id:"https://micro.example.com/3"
-
~content:(`Html "<p>Check out this <a href=\"#\">link</a></p>")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T16:45:00Z" |> Option.get)
-
()
-
] in
-
-
let feed = Jsonfeed.create
-
~title:"Microblog"
-
~home_page_url:"https://micro.example.com"
-
~authors:[author]
-
~items
-
() in
-
-
match Jsonfeed.validate feed with
-
| Ok () ->
-
Format.printf "โœ“ Microblog feed with %d items is valid\n\n"
-
(List.length items)
-
| Error errors ->
-
Format.printf "โœ— Microblog feed validation failed:\n";
-
List.iter (Format.printf " - %s\n") errors;
-
Format.printf "\n"
-
-
let test_expired_feed () =
-
Format.printf "=== Test: Expired Feed ===\n";
-
let feed = Jsonfeed.create
-
~title:"Archived Blog"
-
~home_page_url:"https://archive.example.com"
-
~description:"This blog is no longer updated"
-
~expired:true
-
~items:[]
-
() in
-
match Jsonfeed.validate feed with
-
| Ok () -> Format.printf "โœ“ Expired feed is valid\n\n"
-
| Error errors ->
-
Format.printf "โœ— Expired feed validation failed:\n";
-
List.iter (Format.printf " - %s\n") errors;
-
Format.printf "\n"
-
let test_paginated_feed () =
-
Format.printf "=== Test: Paginated Feed ===\n";
-
-
let items = List.init 25 (fun i ->
-
Item.create
-
~id:(Printf.sprintf "https://example.com/items/%d" i)
-
~content:(`Text (Printf.sprintf "Item %d" i))
-
()
-
) in
-
-
let feed = Jsonfeed.create
-
~title:"Large Feed"
-
~home_page_url:"https://example.com"
-
~feed_url:"https://example.com/feed.json?page=1"
-
~next_url:"https://example.com/feed.json?page=2"
-
~items
-
() in
-
-
match Jsonfeed.validate feed with
-
| Ok () ->
-
Format.printf "โœ“ Paginated feed is valid (page 1 with next_url)\n\n"
-
| Error errors ->
-
Format.printf "โœ— Paginated feed validation failed:\n";
-
List.iter (Format.printf " - %s\n") errors;
-
Format.printf "\n"
-
-
let test_invalid_feed_from_json () =
-
Format.printf "=== Test: Parsing Invalid JSON ===\n";
-
-
(* Missing required version field *)
-
let invalid_json1 = {|{
-
"title": "Test",
-
"items": []
-
}|} in
-
-
(match Jsonfeed.of_string invalid_json1 with
-
| Ok _ -> Format.printf "โœ— Should have failed (missing version)\n"
-
| Error (`Msg err) ->
-
Format.printf "โœ“ Correctly rejected invalid feed: %s\n" err);
-
-
(* Missing required title field *)
-
let invalid_json2 = {|{
-
"version": "https://jsonfeed.org/version/1.1",
-
"items": []
-
}|} in
-
-
(match Jsonfeed.of_string invalid_json2 with
-
| Ok _ -> Format.printf "โœ— Should have failed (missing title)\n"
-
| Error (`Msg err) ->
-
Format.printf "โœ“ Correctly rejected invalid feed: %s\n" err);
-
-
(* Item without id *)
-
let invalid_json3 = {|{
-
"version": "https://jsonfeed.org/version/1.1",
-
"title": "Test",
-
"items": [{
-
"content_text": "Hello"
-
}]
-
}|} in
-
-
(match Jsonfeed.of_string invalid_json3 with
-
| Ok _ -> Format.printf "โœ— Should have failed (item without id)\n"
-
| Error (`Msg err) ->
-
Format.printf "โœ“ Correctly rejected invalid feed: %s\n" err);
-
-
Format.printf "\n"
-
-
let main () =
-
Format.printf "\n=== JSON Feed Validation Tests ===\n\n";
-
-
test_valid_minimal_feed ();
-
test_valid_complete_feed ();
-
test_feed_with_multiple_items ();
-
test_podcast_feed ();
-
test_microblog_feed ();
-
test_expired_feed ();
-
test_paginated_feed ();
-
test_invalid_feed_from_json ();
-
-
Format.printf "=== All Tests Complete ===\n"
-
-
let () = main ()
···
+
(** Example: JSON Feed Validator
+
Reads a JSON Feed from stdin and validates it.
+
Usage: feed_validator < feed.json cat feed.json | feed_validator
+
Exit codes: 0 - Feed is valid 1 - Feed parsing failed 2 - Feed validation
+
failed *)
+
let validate_stdin () =
+
let stdin = Bytesrw.Bytes.Reader.of_in_channel In_channel.stdin in
+
match Jsonfeed.decode ~locs:true stdin with
+
| Error err ->
+
Format.eprintf "Parsing failed:\n %s\n%!" (Jsont.Error.to_string err);
+
exit 1
+
| Ok feed -> (
+
match Jsonfeed.validate feed with
+
| Ok () ->
+
Format.printf "Feed is valid\n%!";
+
Format.printf "\nFeed details:\n";
+
Format.printf " Title: %s\n" (Jsonfeed.title feed);
+
Format.printf " Version: %s\n" (Jsonfeed.version feed);
+
(match Jsonfeed.home_page_url feed with
+
| Some url -> Format.printf " Home page: %s\n" url
+
| None -> ());
+
Format.printf " Items: %d\n" (List.length (Jsonfeed.items feed));
+
exit 0
+
| Error errors ->
+
Format.eprintf "Validation failed:\n%!";
+
List.iter (fun err -> Format.eprintf " - %s\n%!" err) errors;
+
exit 2)
+
let () = validate_stdin ()
+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"
+47 -19
lib/attachment.ml
···
-
(** Attachments for JSON Feed items. *)
type t = {
url : string;
···
title : string option;
size_in_bytes : int64 option;
duration_in_seconds : int option;
}
-
let create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds () =
-
{ url; mime_type; title; size_in_bytes; duration_in_seconds }
let url t = t.url
let mime_type t = t.mime_type
let title t = t.title
let size_in_bytes t = t.size_in_bytes
let duration_in_seconds t = t.duration_in_seconds
let equal a b =
-
a.url = b.url &&
-
a.mime_type = b.mime_type &&
-
a.title = b.title &&
-
a.size_in_bytes = b.size_in_bytes &&
-
a.duration_in_seconds = b.duration_in_seconds
let pp ppf t =
(* Extract filename from URL *)
···
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 ")"
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
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 = {
url : string;
···
title : string option;
size_in_bytes : int64 option;
duration_in_seconds : int option;
+
unknown : Unknown.t;
}
+
let create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds
+
?(unknown = Unknown.empty) () =
+
{ url; mime_type; title; size_in_bytes; duration_in_seconds; unknown }
let url t = t.url
let mime_type t = t.mime_type
let title t = t.title
let size_in_bytes t = t.size_in_bytes
let duration_in_seconds t = t.duration_in_seconds
+
let unknown t = t.unknown
let equal a b =
+
a.url = b.url && a.mime_type = b.mime_type && a.title = b.title
+
&& a.size_in_bytes = b.size_in_bytes
+
&& a.duration_in_seconds = b.duration_in_seconds
let pp ppf t =
(* Extract filename from URL *)
···
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
+
()
+
in
+
Jsont.Object.map ~kind ~doc create_obj
+
|> Jsont.Object.mem "url" Jsont.string ~enc:url
+
|> Jsont.Object.mem "mime_type" Jsont.string ~enc:mime_type
+
|> Jsont.Object.opt_mem "title" Jsont.string ~enc:title
+
|> 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
+64 -43
lib/attachment.mli
···
(** Attachments for JSON Feed items.
-
An attachment represents an external resource related to a feed item,
-
such as audio files for podcasts, video files, or other downloadable content.
-
Attachments with identical titles indicate alternate formats of the same resource.
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
-
(** The type representing an attachment. *)
-
type t
-
(** {1 Construction} *)
-
(** [create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ()]
-
creates an attachment object.
-
@param url The location of the attachment (required)
-
@param mime_type The MIME type of the attachment, e.g. ["audio/mpeg"] (required)
-
@param title The name of the attachment; identical titles indicate alternate formats
-
of the same resource
-
@param size_in_bytes The size of the attachment file in bytes
-
@param duration_in_seconds The duration of the attachment in seconds (for audio/video)
-
{b Examples:}
-
{[
-
(* Simple attachment *)
-
let att = Attachment.create
-
~url:"https://example.com/episode.mp3"
-
~mime_type:"audio/mpeg" ()
-
(* Podcast episode with metadata *)
-
let att = Attachment.create
-
~url:"https://example.com/episode.mp3"
-
~mime_type:"audio/mpeg"
-
~title:"Episode 42"
-
~size_in_bytes:15_728_640L
-
~duration_in_seconds:1800 ()
-
(* Alternate format (same title indicates same content) *)
-
let att2 = Attachment.create
-
~url:"https://example.com/episode.aac"
-
~mime_type:"audio/aac"
-
~title:"Episode 42"
-
~size_in_bytes:12_582_912L
-
~duration_in_seconds:1800 ()
-
]} *)
val create :
url:string ->
mime_type:string ->
?title:string ->
?size_in_bytes:int64 ->
?duration_in_seconds:int ->
unit ->
t
(** {1 Accessors} *)
-
(** [url t] returns the attachment's URL. *)
val url : t -> string
(** [mime_type t] returns the attachment's MIME type. *)
-
val mime_type : t -> string
-
(** [title t] returns the attachment's title, if set. *)
val title : t -> string option
-
(** [size_in_bytes t] returns the attachment's size in bytes, if set. *)
val size_in_bytes : t -> int64 option
-
(** [duration_in_seconds t] returns the attachment's duration, if set. *)
val duration_in_seconds : t -> int option
(** {1 Comparison} *)
-
(** [equal a b] tests equality between two attachments. *)
val equal : t -> t -> bool
-
(** {1 Pretty Printing} *)
(** [pp ppf t] pretty prints an attachment to the formatter.
The output is human-readable and suitable for debugging.
{b Example output:}
{v episode.mp3 (audio/mpeg, 15.0 MB, 30m0s) v} *)
-
val pp : Format.formatter -> t -> unit
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
(** Attachments for JSON Feed items.
+
An attachment represents an external resource related to a feed item, such
+
as audio files for podcasts, video files, or other downloadable content.
+
Attachments with identical titles indicate alternate formats of the same
+
resource.
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
+
type t
(** The type representing an attachment. *)
+
(** {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. *)
+
val is_empty : t -> bool
+
(** [is_empty u] returns [true] if there are no unknown fields. *)
+
end
+
(** {1 Jsont Type} *)
+
+
val jsont : t Jsont.t
+
(** Declarative JSON type for attachments.
+
+
Maps JSON objects with "url" (required), "mime_type" (required), and
+
optional "title", "size_in_bytes", "duration_in_seconds" fields. *)
+
(** {1 Construction} *)
val create :
url:string ->
mime_type:string ->
?title:string ->
?size_in_bytes:int64 ->
?duration_in_seconds:int ->
+
?unknown:Unknown.t ->
unit ->
t
+
(** [create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ?unknown
+
()] creates an attachment object.
+
@param url The location of the attachment (required)
+
@param mime_type
+
The MIME type of the attachment, e.g. ["audio/mpeg"] (required)
+
@param title
+
The name of the attachment; identical titles indicate alternate formats of
+
the same resource
+
@param size_in_bytes The size of the attachment file in bytes
+
@param duration_in_seconds
+
The duration of the attachment in seconds (for audio/video)
+
@param unknown Unknown/custom fields for extensions (default: empty)
+
+
{b Examples:}
+
{[
+
(* Simple attachment *)
+
let att =
+
Attachment.create ~url:"https://example.com/episode.mp3"
+
~mime_type:"audio/mpeg" ()
+
+
(* Podcast episode with metadata *)
+
let att =
+
Attachment.create ~url:"https://example.com/episode.mp3"
+
~mime_type:"audio/mpeg" ~title:"Episode 42" ~size_in_bytes:15_728_640L
+
~duration_in_seconds:1800 ()
+
]} *)
(** {1 Accessors} *)
val url : t -> string
+
(** [url t] returns the attachment's URL. *)
+
val mime_type : t -> string
(** [mime_type t] returns the attachment's MIME type. *)
val title : t -> string option
+
(** [title t] returns the attachment's title, if set. *)
val size_in_bytes : t -> int64 option
+
(** [size_in_bytes t] returns the attachment's size in bytes, if set. *)
val duration_in_seconds : t -> int option
+
(** [duration_in_seconds t] returns the attachment's duration, if set. *)
+
val unknown : t -> Unknown.t
+
(** [unknown t] returns unrecognized fields from the JSON. *)
(** {1 Comparison} *)
val equal : t -> t -> bool
+
(** [equal a b] tests equality between two attachments. *)
(** {1 Pretty Printing} *)
+
val pp : Format.formatter -> t -> unit
(** [pp ppf t] pretty prints an attachment to the formatter.
The output is human-readable and suitable for debugging.
{b Example output:}
{v episode.mp3 (audio/mpeg, 15.0 MB, 30m0s) v} *)
+37 -13
lib/author.ml
···
-
(** Author information for JSON Feed items and feeds. *)
type t = {
name : string option;
url : string option;
avatar : string option;
}
-
let create ?name ?url ?avatar () =
if name = None && url = None && avatar = None then
-
invalid_arg "Author.create: at least one field (name, url, or avatar) must be provided";
-
{ name; url; avatar }
let name t = t.name
let url t = t.url
let avatar t = t.avatar
-
-
let is_valid t =
-
t.name <> None || t.url <> None || t.avatar <> None
-
-
let equal a b =
-
a.name = b.name && a.url = b.url && a.avatar = b.avatar
let pp ppf t =
-
match t.name, t.url with
| Some name, Some url -> Format.fprintf ppf "%s <%s>" name url
| Some name, None -> Format.fprintf ppf "%s" name
| None, Some url -> Format.fprintf ppf "<%s>" url
-
| None, None ->
match t.avatar with
| Some avatar -> Format.fprintf ppf "(avatar: %s)" avatar
-
| None -> Format.fprintf ppf "(empty author)"
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
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 = {
name : string option;
url : string option;
avatar : string option;
+
unknown : Unknown.t;
}
+
let create ?name ?url ?avatar ?(unknown = Unknown.empty) () =
if name = None && url = None && avatar = None then
+
invalid_arg
+
"Author.create: at least one field (name, url, or avatar) must be \
+
provided";
+
{ name; url; avatar; unknown }
let name t = t.name
let url t = t.url
let avatar t = t.avatar
+
let unknown t = t.unknown
+
let is_valid t = t.name <> None || t.url <> None || t.avatar <> None
+
let equal a b = a.name = b.name && a.url = b.url && a.avatar = b.avatar
let pp ppf t =
+
match (t.name, t.url) with
| Some name, Some url -> Format.fprintf ppf "%s <%s>" name url
| Some name, None -> Format.fprintf ppf "%s" name
| None, Some url -> Format.fprintf ppf "<%s>" url
+
| None, None -> (
match t.avatar with
| Some avatar -> Format.fprintf ppf "(avatar: %s)" avatar
+
| None -> Format.fprintf ppf "(empty author)")
+
+
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 ()
+
in
+
Jsont.Object.map ~kind ~doc create_obj
+
|> 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
+54 -22
lib/author.mli
···
(** Author information for JSON Feed items and feeds.
An author object provides information about the creator of a feed or item.
···
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
-
(** The type representing an author. *)
-
type t
(** {1 Construction} *)
-
(** [create ?name ?url ?avatar ()] creates an author object.
-
At least one of the optional parameters must be provided, otherwise
-
the function will raise [Invalid_argument].
@param name The author's name
@param url URL of the author's website or profile
-
@param avatar URL of the author's avatar image (should be square, 512x512 or larger)
{b Examples:}
{[
let author = Author.create ~name:"Jane Doe" ()
let author = Author.create ~name:"Jane Doe" ~url:"https://janedoe.com" ()
-
let author = Author.create
-
~name:"Jane Doe"
-
~url:"https://janedoe.com"
-
~avatar:"https://janedoe.com/avatar.png" ()
]} *)
-
val create : ?name:string -> ?url:string -> ?avatar:string -> unit -> t
-
(** {1 Accessors} *)
-
(** [name t] returns the author's name, if set. *)
val name : t -> string option
(** [url t] returns the author's URL, if set. *)
-
val url : t -> string option
-
(** [avatar t] returns the author's avatar URL, if set. *)
val avatar : t -> string option
(** {1 Predicates} *)
(** [is_valid t] checks if the author has at least one field set.
-
This should always return [true] for authors created via {!create},
-
but may be useful when parsing from external sources. *)
-
val is_valid : t -> bool
-
(** {1 Comparison} *)
(** [equal a b] tests equality between two authors. *)
-
val equal : t -> t -> bool
-
(** {1 Pretty Printing} *)
(** [pp ppf t] pretty prints an author to the formatter.
The output is human-readable and suitable for debugging.
{b Example output:}
{v Jane Doe <https://janedoe.com> v} *)
-
val pp : Format.formatter -> t -> unit
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
(** Author information for JSON Feed items and feeds.
An author object provides information about the creator of a feed or item.
···
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
+
type t
+
(** The type representing an author. *)
+
(** {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. *)
+
+
val is_empty : t -> bool
+
(** [is_empty u] returns [true] if there are no unknown fields. *)
+
end
+
+
(** {1 Jsont Type} *)
+
+
val jsont : t Jsont.t
+
(** Declarative JSON type for authors.
+
+
Maps JSON objects with optional "name", "url", and "avatar" fields. At least
+
one field must be present during decoding. *)
(** {1 Construction} *)
+
val create :
+
?name:string ->
+
?url:string ->
+
?avatar:string ->
+
?unknown:Unknown.t ->
+
unit ->
+
t
+
(** [create ?name ?url ?avatar ?unknown ()] creates an author.
+
At least one of the optional parameters must be provided, otherwise the
+
function will raise [Invalid_argument].
@param name The author's name
@param url URL of the author's website or profile
+
@param avatar
+
URL of the author's avatar image (should be square, 512x512 or larger)
+
@param unknown Unknown/custom fields for extensions (default: empty)
{b Examples:}
{[
let author = Author.create ~name:"Jane Doe" ()
let author = Author.create ~name:"Jane Doe" ~url:"https://janedoe.com" ()
+
+
let author =
+
Author.create ~name:"Jane Doe" ~url:"https://janedoe.com"
+
~avatar:"https://janedoe.com/avatar.png" ()
]} *)
(** {1 Accessors} *)
val name : t -> string option
+
(** [name t] returns the author's name, if set. *)
+
val url : t -> string option
(** [url t] returns the author's URL, if set. *)
val avatar : t -> string option
+
(** [avatar t] returns the author's avatar URL, if set. *)
+
val unknown : t -> Unknown.t
+
(** [unknown t] returns unrecognized fields from the JSON. *)
(** {1 Predicates} *)
+
val is_valid : t -> bool
(** [is_valid t] checks if the author has at least one field set.
+
This should always return [true] for authors created via {!create}, but may
+
be useful when parsing from external sources. *)
(** {1 Comparison} *)
+
val equal : t -> t -> bool
(** [equal a b] tests equality between two authors. *)
(** {1 Pretty Printing} *)
+
val pp : Format.formatter -> t -> unit
(** [pp ppf t] pretty prints an author to the formatter.
The output is human-readable and suitable for debugging.
{b Example output:}
{v Jane Doe <https://janedoe.com> v} *)
+9 -9
lib/cito.ml
···
-
type t = [
-
| `Cites
| `CitesAsAuthority
| `CitesAsDataSource
| `CitesAsEvidence
···
| `SharesPublicationVenueWith
| `SharesFundingAgencyWith
| `SharesAuthorInstitutionWith
-
| `Other of string
-
]
let to_string = function
| `Cites -> "cites"
···
| "sharesauthorinstitutionwith" -> `SharesAuthorInstitutionWith
| _ -> `Other s
-
let equal a b =
-
match a, b with
-
| `Other sa, `Other sb -> sa = sb
-
| _ -> a = b
-
let pp ppf t = Format.fprintf ppf "%s" (to_string t)
···
+
type t =
+
[ `Cites
| `CitesAsAuthority
| `CitesAsDataSource
| `CitesAsEvidence
···
| `SharesPublicationVenueWith
| `SharesFundingAgencyWith
| `SharesAuthorInstitutionWith
+
| `Other of string ]
let to_string = function
| `Cites -> "cites"
···
| "sharesauthorinstitutionwith" -> `SharesAuthorInstitutionWith
| _ -> `Other s
+
let equal a b = match (a, b) with `Other sa, `Other sb -> sa = sb | _ -> a = b
+
let pp ppf t = Format.fprintf ppf "%s" (to_string t)
+
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
+87 -75
lib/cito.mli
···
(** Citation Typing Ontology (CiTO) intent annotations.
-
CiTO provides a structured vocabulary for describing the nature of citations.
-
This module implements support for CiTO annotations as used in the references extension.
@see <https://purl.archive.org/spar/cito> Citation Typing Ontology
-
@see <https://sparontologies.github.io/cito/current/cito.html> CiTO Specification *)
-
-
-
(** CiTO citation intent annotation.
-
Represents the intent or nature of a citation using the Citation Typing Ontology.
-
Each variant corresponds to a specific CiTO property. The [`Other] variant allows
-
for custom or future CiTO terms not yet included in this library.
-
-
{b Categories:}
-
- Factual: Citing for data, methods, evidence, or information
-
- Critical: Agreement, disagreement, correction, or qualification
-
- Rhetorical: Style-based citations (parody, ridicule, etc.)
-
- Relational: Document relationships and compilations
-
- Support: Providing or obtaining backing and context
-
- Exploratory: Speculation and recommendations
-
- Quotation: Direct quotes and excerpts
-
- Dialogue: Replies and responses
-
- Sharing: Common attributes between works *)
-
type t = [
-
| `Cites (** The base citation property *)
-
-
(* Factual citation intents *)
-
| `CitesAsAuthority (** Cites as authoritative source *)
| `CitesAsDataSource (** Cites as origin of data *)
| `CitesAsEvidence (** Cites for factual evidence *)
| `CitesForInformation (** Cites as information source *)
| `UsesDataFrom (** Uses data from cited work *)
| `UsesMethodIn (** Uses methodology from cited work *)
| `UsesConclusionsFrom (** Applies conclusions from cited work *)
-
-
(* Agreement/disagreement *)
-
| `AgreesWith (** Concurs with cited statements *)
| `DisagreesWith (** Rejects cited statements *)
| `Confirms (** Validates facts in cited work *)
| `Refutes (** Disproves cited statements *)
| `Disputes (** Contests without definitive refutation *)
-
-
(* Critical engagement *)
-
| `Critiques (** Analyzes and finds fault *)
| `Qualifies (** Places conditions on statements *)
| `Corrects (** Fixes errors in cited work *)
| `Updates (** Advances understanding beyond cited work *)
| `Extends (** Builds upon cited facts *)
-
-
(* Rhetorical/stylistic *)
-
| `Parodies (** Imitates for comic effect *)
| `Plagiarizes (** Uses without acknowledgment *)
| `Derides (** Expresses contempt *)
| `Ridicules (** Mocks cited work *)
-
-
(* Document relationships *)
-
| `Describes (** Characterizes cited entity *)
| `Documents (** Records information about source *)
| `CitesAsSourceDocument (** Cites as foundational source *)
| `CitesAsMetadataDocument (** Cites containing metadata *)
| `Compiles (** Uses to create new work *)
| `Reviews (** Examines cited statements *)
| `Retracts (** Formally withdraws *)
-
-
(* Support/context *)
-
| `Supports (** Provides intellectual backing *)
| `GivesSupportTo (** Provides support to citing entity *)
| `ObtainsSupportFrom (** Obtains backing from cited work *)
| `GivesBackgroundTo (** Provides context *)
| `ObtainsBackgroundFrom (** Obtains context from cited work *)
-
-
(* Exploratory *)
-
| `SpeculatesOn (** Theorizes without firm evidence *)
| `CitesAsPotentialSolution (** Offers possible resolution *)
| `CitesAsRecommendedReading (** Suggests as further reading *)
| `CitesAsRelated (** Identifies as thematically connected *)
-
-
(* Quotation/excerpting *)
-
| `IncludesQuotationFrom (** Incorporates direct quotes *)
| `IncludesExcerptFrom (** Uses non-quoted passages *)
-
-
(* Dialogue *)
-
| `RepliesTo (** Responds to cited statements *)
| `HasReplyFrom (** Evokes response *)
-
-
(* Linking *)
-
| `LinksTo (** Provides URL hyperlink *)
-
-
(* Shared attribution *)
-
| `SharesAuthorWith (** Common authorship *)
| `SharesJournalWith (** Published in same journal *)
| `SharesPublicationVenueWith (** Published in same venue *)
| `SharesFundingAgencyWith (** Funded by same agency *)
| `SharesAuthorInstitutionWith (** Authors share affiliation *)
-
(* Extensibility *)
-
| `Other of string (** Custom or future CiTO term *)
-
]
(** {1 Conversion} *)
(** [of_string s] converts a CiTO term string to its variant representation.
Recognized CiTO terms are converted to their corresponding variants.
Unrecognized terms are wrapped in [`Other].
-
The comparison is case-insensitive for standard CiTO terms but preserves
-
the original case in [`Other] variants.
{b Examples:}
{[
-
of_string "cites" (* returns `Cites *)
-
of_string "usesMethodIn" (* returns `UsesMethodIn *)
-
of_string "citesAsRecommendedReading" (* returns `CitesAsRecommendedReading *)
-
of_string "customTerm" (* returns `Other "customTerm" *)
]} *)
-
val of_string : string -> t
-
(** [to_string t] converts a CiTO variant to its canonical string representation.
Standard CiTO terms use their official CiTO local names (camelCase).
[`Other] variants return the wrapped string unchanged.
{b Examples:}
{[
-
to_string `Cites (* returns "cites" *)
-
to_string `UsesMethodIn (* returns "usesMethodIn" *)
-
to_string (`Other "customTerm") (* returns "customTerm" *)
]} *)
-
val to_string : t -> string
-
(** {1 Comparison} *)
(** [equal a b] tests equality between two CiTO annotations.
-
Two annotations are equal if they represent the same CiTO term.
-
For [`Other] variants, string comparison is case-sensitive. *)
-
val equal : t -> t -> bool
(** {1 Pretty Printing} *)
(** [pp ppf t] pretty prints a CiTO annotation to the formatter.
{b Example output:}
{v citesAsRecommendedReading v} *)
-
val pp : Format.formatter -> t -> unit
···
+
(*---------------------------------------------------------------------------
+
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
+
citations. This module implements support for CiTO annotations as used in
+
the references extension.
@see <https://purl.archive.org/spar/cito> Citation Typing Ontology
+
@see <https://sparontologies.github.io/cito/current/cito.html>
+
CiTO Specification *)
+
type t =
+
[ `Cites (** The base citation property *)
+
| (* Factual citation intents *)
+
`CitesAsAuthority
+
(** Cites as authoritative source *)
| `CitesAsDataSource (** Cites as origin of data *)
| `CitesAsEvidence (** Cites for factual evidence *)
| `CitesForInformation (** Cites as information source *)
| `UsesDataFrom (** Uses data from cited work *)
| `UsesMethodIn (** Uses methodology from cited work *)
| `UsesConclusionsFrom (** Applies conclusions from cited work *)
+
| (* Agreement/disagreement *)
+
`AgreesWith
+
(** Concurs with cited statements *)
| `DisagreesWith (** Rejects cited statements *)
| `Confirms (** Validates facts in cited work *)
| `Refutes (** Disproves cited statements *)
| `Disputes (** Contests without definitive refutation *)
+
| (* Critical engagement *)
+
`Critiques
+
(** Analyzes and finds fault *)
| `Qualifies (** Places conditions on statements *)
| `Corrects (** Fixes errors in cited work *)
| `Updates (** Advances understanding beyond cited work *)
| `Extends (** Builds upon cited facts *)
+
| (* Rhetorical/stylistic *)
+
`Parodies
+
(** Imitates for comic effect *)
| `Plagiarizes (** Uses without acknowledgment *)
| `Derides (** Expresses contempt *)
| `Ridicules (** Mocks cited work *)
+
| (* Document relationships *)
+
`Describes
+
(** Characterizes cited entity *)
| `Documents (** Records information about source *)
| `CitesAsSourceDocument (** Cites as foundational source *)
| `CitesAsMetadataDocument (** Cites containing metadata *)
| `Compiles (** Uses to create new work *)
| `Reviews (** Examines cited statements *)
| `Retracts (** Formally withdraws *)
+
| (* Support/context *)
+
`Supports
+
(** Provides intellectual backing *)
| `GivesSupportTo (** Provides support to citing entity *)
| `ObtainsSupportFrom (** Obtains backing from cited work *)
| `GivesBackgroundTo (** Provides context *)
| `ObtainsBackgroundFrom (** Obtains context from cited work *)
+
| (* Exploratory *)
+
`SpeculatesOn
+
(** Theorizes without firm evidence *)
| `CitesAsPotentialSolution (** Offers possible resolution *)
| `CitesAsRecommendedReading (** Suggests as further reading *)
| `CitesAsRelated (** Identifies as thematically connected *)
+
| (* Quotation/excerpting *)
+
`IncludesQuotationFrom
+
(** Incorporates direct quotes *)
| `IncludesExcerptFrom (** Uses non-quoted passages *)
+
| (* Dialogue *)
+
`RepliesTo
+
(** Responds to cited statements *)
| `HasReplyFrom (** Evokes response *)
+
| (* Linking *)
+
`LinksTo
+
(** Provides URL hyperlink *)
+
| (* Shared attribution *)
+
`SharesAuthorWith
+
(** Common authorship *)
| `SharesJournalWith (** Published in same journal *)
| `SharesPublicationVenueWith (** Published in same venue *)
| `SharesFundingAgencyWith (** Funded by same agency *)
| `SharesAuthorInstitutionWith (** Authors share affiliation *)
+
| (* Extensibility *)
+
`Other of string
+
(** Custom or future CiTO term *) ]
+
(** CiTO citation intent annotation.
+
Represents the intent or nature of a citation using the Citation Typing
+
Ontology. Each variant corresponds to a specific CiTO property. The [`Other]
+
variant allows for custom or future CiTO terms not yet included in this
+
library.
+
{b Categories:}
+
- Factual: Citing for data, methods, evidence, or information
+
- Critical: Agreement, disagreement, correction, or qualification
+
- Rhetorical: Style-based citations (parody, ridicule, etc.)
+
- Relational: Document relationships and compilations
+
- Support: Providing or obtaining backing and context
+
- Exploratory: Speculation and recommendations
+
- Quotation: Direct quotes and excerpts
+
- Dialogue: Replies and responses
+
- Sharing: Common attributes between works *)
(** {1 Conversion} *)
+
val of_string : string -> t
(** [of_string s] converts a CiTO term string to its variant representation.
Recognized CiTO terms are converted to their corresponding variants.
Unrecognized terms are wrapped in [`Other].
+
The comparison is case-insensitive for standard CiTO terms but preserves the
+
original case in [`Other] variants.
{b Examples:}
{[
+
of_string "cites" (* returns `Cites *) of_string "usesMethodIn"
+
(* returns `UsesMethodIn *) of_string
+
"citesAsRecommendedReading" (* returns `CitesAsRecommendedReading *)
+
of_string "customTerm" (* returns `Other "customTerm" *)
]} *)
+
val to_string : t -> string
+
(** [to_string t] converts a CiTO variant to its canonical string
+
representation.
Standard CiTO terms use their official CiTO local names (camelCase).
[`Other] variants return the wrapped string unchanged.
{b Examples:}
{[
+
to_string `Cites (* returns "cites" *) to_string `UsesMethodIn
+
(* returns "usesMethodIn" *) to_string (`Other "customTerm")
+
(* returns "customTerm" *)
]} *)
(** {1 Comparison} *)
+
val equal : t -> t -> bool
(** [equal a b] tests equality between two CiTO annotations.
+
Two annotations are equal if they represent the same CiTO term. For [`Other]
+
variants, string comparison is case-sensitive. *)
+
(** {1 Jsont Type} *)
+
+
val jsont : t Jsont.t
+
(** Declarative JSON type for CiTO annotations.
+
+
Maps CiTO intent strings to the corresponding variants. Unknown intents are
+
mapped to [`Other s]. *)
(** {1 Pretty Printing} *)
+
val pp : Format.formatter -> t -> unit
(** [pp ppf t] pretty prints a CiTO annotation to the formatter.
{b Example output:}
{v citesAsRecommendedReading v} *)
+1 -1
lib/dune
···
(library
(name jsonfeed)
(public_name jsonfeed)
-
(libraries jsonm ptime fmt))
···
(library
(name jsonfeed)
(public_name jsonfeed)
+
(libraries jsont jsont.bytesrw bytesrw ptime))
+24 -12
lib/hub.ml
···
-
(** Hub endpoints for real-time notifications. *)
-
type t = {
-
type_ : string;
-
url : string;
-
}
-
let create ~type_ ~url () =
-
{ type_; url }
let type_ t = t.type_
let url t = t.url
-
let equal a b =
-
a.type_ = b.type_ && a.url = b.url
-
-
let pp ppf t =
-
Format.fprintf ppf "%s: %s" t.type_ t.url
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
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 create ~type_ ~url ?(unknown = Unknown.empty) () = { type_; url; unknown }
let type_ t = t.type_
let url t = t.url
+
let unknown t = t.unknown
+
let equal a b = a.type_ = b.type_ && a.url = b.url
+
let pp ppf t = Format.fprintf ppf "%s: %s" t.type_ t.url
+
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
+37 -13
lib/hub.mli
···
(** Hub endpoints for real-time notifications.
Hubs describe endpoints that can be used to subscribe to real-time
···
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
-
(** The type representing a hub endpoint. *)
-
type t
(** {1 Construction} *)
-
(** [create ~type_ ~url ()] creates a hub object.
@param type_ The type of hub protocol (e.g., ["rssCloud"], ["WebSub"])
@param url The URL endpoint for the hub
{b Example:}
{[
-
let hub = Hub.create
-
~type_:"WebSub"
-
~url:"https://pubsubhubbub.appspot.com/" ()
]} *)
-
val create : type_:string -> url:string -> unit -> t
-
(** {1 Accessors} *)
(** [type_ t] returns the hub's protocol type. *)
-
val type_ : t -> string
-
(** [url t] returns the hub's endpoint URL. *)
val url : t -> string
(** {1 Comparison} *)
-
(** [equal a b] tests equality between two hubs. *)
val equal : t -> t -> bool
-
(** {1 Pretty Printing} *)
(** [pp ppf t] pretty prints a hub to the formatter.
The output is human-readable and suitable for debugging.
{b Example output:}
{v WebSub: https://pubsubhubbub.appspot.com/ v} *)
-
val pp : Format.formatter -> t -> unit
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
(** Hub endpoints for real-time notifications.
Hubs describe endpoints that can be used to subscribe to real-time
···
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
+
type t
+
(** The type representing a hub endpoint. *)
+
(** {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. *)
+
+
val is_empty : t -> bool
+
(** [is_empty u] returns [true] if there are no unknown fields. *)
+
end
+
+
(** {1 Jsont Type} *)
+
+
val jsont : t Jsont.t
+
(** Declarative JSON type for hubs.
+
+
Maps JSON objects with "type" and "url" fields (both required). *)
(** {1 Construction} *)
+
val create : type_:string -> url:string -> ?unknown:Unknown.t -> unit -> t
+
(** [create ~type_ ~url ?unknown ()] creates a hub object.
@param type_ The type of hub protocol (e.g., ["rssCloud"], ["WebSub"])
@param url The URL endpoint for the hub
+
@param unknown Unknown/custom fields for extensions (default: empty)
{b Example:}
{[
+
let hub =
+
Hub.create ~type_:"WebSub" ~url:"https://pubsubhubbub.appspot.com/" ()
]} *)
(** {1 Accessors} *)
+
val type_ : t -> string
(** [type_ t] returns the hub's protocol type. *)
val url : t -> string
+
(** [url t] returns the hub's endpoint URL. *)
+
val unknown : t -> Unknown.t
+
(** [unknown t] returns unrecognized fields from the JSON. *)
(** {1 Comparison} *)
val equal : t -> t -> bool
+
(** [equal a b] tests equality between two hubs. *)
(** {1 Pretty Printing} *)
+
val pp : Format.formatter -> t -> unit
(** [pp ppf t] pretty prints a hub to the formatter.
The output is human-readable and suitable for debugging.
{b Example output:}
{v WebSub: https://pubsubhubbub.appspot.com/ v} *)
+90 -33
lib/item.ml
···
-
(** Feed items in a JSON Feed. *)
-
type content =
-
[ `Html of string
-
| `Text of string
-
| `Both of string * string
-
]
type t = {
id : string;
···
language : string option;
attachments : Attachment.t list option;
references : Reference.t list option;
}
let create ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
-
?date_published ?date_modified ?authors ?tags ?language ?attachments ?references () =
{
id;
content;
···
language;
attachments;
references;
}
let id t = t.id
···
let language t = t.language
let attachments t = t.attachments
let references t = t.references
let content_html t =
match t.content with
···
| `Text text -> Some text
| `Both (_, text) -> Some text
-
let equal a b =
-
(* Items are equal if they have the same ID *)
-
a.id = b.id
-
-
let compare a b =
-
(* Compare by publication date, with items without dates considered older *)
-
match a.date_published, b.date_published with
-
| None, None -> 0
-
| None, Some _ -> -1 (* Items without dates are "older" *)
-
| Some _, None -> 1
-
| Some da, Some db -> Ptime.compare da db
-
-
let pp_content ppf = function
-
| `Html html ->
-
Format.fprintf ppf "HTML (%d chars)" (String.length html)
-
| `Text text ->
-
Format.fprintf ppf "Text (%d chars)" (String.length text)
-
| `Both (html, text) ->
-
Format.fprintf ppf "Both (HTML: %d chars, Text: %d chars)"
-
(String.length html) (String.length text)
let pp ppf t =
-
match t.date_published, t.title with
| Some date, Some title ->
-
(* Use Ptime's date formatting *)
let (y, m, d), _ = Ptime.to_date_time date in
Format.fprintf ppf "[%04d-%02d-%02d] %s (%s)" y m d title t.id
| Some date, None ->
let (y, m, d), _ = Ptime.to_date_time date in
Format.fprintf ppf "[%04d-%02d-%02d] %s" y m d t.id
-
| None, Some title ->
-
Format.fprintf ppf "%s (%s)" title t.id
-
| None, None ->
-
Format.fprintf ppf "%s" t.id
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
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 ]
type t = {
id : string;
···
language : string option;
attachments : Attachment.t list option;
references : Reference.t list option;
+
unknown : Unknown.t;
}
let create ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
+
?date_published ?date_modified ?authors ?tags ?language ?attachments
+
?references ?(unknown = Unknown.empty) () =
{
id;
content;
···
language;
attachments;
references;
+
unknown;
}
let id t = t.id
···
let language t = t.language
let attachments t = t.attachments
let references t = t.references
+
let unknown t = t.unknown
let content_html t =
match t.content with
···
| `Text text -> Some text
| `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
| Some date, Some title ->
let (y, m, d), _ = Ptime.to_date_time date in
Format.fprintf ppf "[%04d-%02d-%02d] %s (%s)" y m d title t.id
| Some date, None ->
let (y, m, d), _ = Ptime.to_date_time date in
Format.fprintf ppf "[%04d-%02d-%02d] %s" y m d t.id
+
| None, Some title -> Format.fprintf ppf "%s (%s)" title t.id
+
| 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 *)
+
+
let jsont =
+
let kind = "Item" in
+
let doc = "A JSON Feed item" in
+
+
(* Helper to construct item from JSON fields *)
+
let make_from_json id content_html content_text url external_url title summary
+
image banner_image date_published date_modified authors tags language
+
attachments references _extensions unknown =
+
(* Determine content from content_html and content_text *)
+
let content =
+
match (content_html, content_text) with
+
| Some html, Some text -> `Both (html, text)
+
| Some html, None -> `Html html
+
| None, Some text -> `Text text
+
| None, None ->
+
Jsont.Error.msg Jsont.Meta.none
+
"Item must have at least one of content_html or content_text"
+
in
+
{
+
id;
+
content;
+
url;
+
external_url;
+
title;
+
summary;
+
image;
+
banner_image;
+
date_published;
+
date_modified;
+
authors;
+
tags;
+
language;
+
attachments;
+
references;
+
unknown;
+
}
+
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
+34 -140
lib/item.mli
···
(** Feed items in a JSON Feed.
-
An item represents a single entry in a feed, such as a blog post, podcast episode,
-
or microblog entry. Each item must have a unique identifier and content.
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
-
-
(** The type representing a feed item. *)
type t
(** Content representation for an item.
-
The JSON Feed specification requires that each item has at least one
-
form of content. This type enforces that requirement at compile time.
- [`Html s]: Item has HTML content only
- [`Text s]: Item has plain text content only
- [`Both (html, text)]: Item has both HTML and plain text versions *)
-
type content =
-
[ `Html of string
-
| `Text of string
-
| `Both of string * string
-
]
-
(** {1 Construction} *)
-
(** [create ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
-
?date_published ?date_modified ?authors ?tags ?language ?attachments ()]
-
creates a feed item.
-
@param id Unique identifier for the item (required). Should be a full URL if possible.
-
@param content The item's content in HTML and/or plain text (required)
-
@param url Permalink to the item
-
@param external_url URL of an external resource (useful for linkblogs)
-
@param title Plain text title of the item
-
@param summary Plain text summary/excerpt of the item
-
@param image URL of the main featured image for the item
-
@param banner_image URL of a banner image for the item
-
@param date_published Publication date/time (RFC 3339 format)
-
@param date_modified Last modification date/time (RFC 3339 format)
-
@param authors Item-specific authors (overrides feed-level authors)
-
@param tags Plain text tags/categories for the item
-
@param language Primary language of the item (RFC 5646 format, e.g. ["en-US"])
-
@param attachments Related resources like audio files or downloads
-
@param references References to cited sources (extension)
-
{b Examples:}
-
{[
-
(* Simple blog post *)
-
let item = Item.create
-
~id:"https://example.com/posts/42"
-
~content:(`Html "<p>Hello, world!</p>")
-
~title:"My First Post"
-
~url:"https://example.com/posts/42" ()
-
(* Microblog entry with plain text *)
-
let item = Item.create
-
~id:"https://example.com/micro/123"
-
~content:(`Text "Just posted a new photo!")
-
~date_published:(Ptime.of_float_s (Unix.time ()) |> Option.get) ()
-
(* Article with both HTML and plain text *)
-
let item = Item.create
-
~id:"https://example.com/article/99"
-
~content:(`Both ("<p>Rich content</p>", "Plain version"))
-
~title:"Article Title"
-
~tags:["ocaml"; "programming"] ()
-
(* Podcast episode with attachment *)
-
let attachment = Attachment.create
-
~url:"https://example.com/ep1.mp3"
-
~mime_type:"audio/mpeg"
-
~duration_in_seconds:1800 () in
-
let item = Item.create
-
~id:"https://example.com/podcast/1"
-
~content:(`Html "<p>Episode description</p>")
-
~title:"Episode 1"
-
~attachments:[attachment] ()
-
(* Article with references *)
-
let reference = Reference.create
-
~url:"https://doi.org/10.5281/zenodo.16755947"
-
~doi:"10.5281/zenodo.16755947"
-
~cito:[`CitesAsRecommendedReading; `UsesMethodIn] () in
-
let item = Item.create
-
~id:"https://doi.org/10.59350/krw9n-dv417"
-
~content:(`Html "<p>Research article content</p>")
-
~title:"One Million IUPAC names #4: a lot is happening"
-
~url:"https://chem-bla-ics.linkedchemistry.info/2025/08/09/one-million-iupac-names-4.html"
-
~references:[reference] ()
-
]} *)
val create :
id:string ->
content:content ->
···
?language:string ->
?attachments:Attachment.t list ->
?references:Reference.t list ->
unit ->
t
-
(** {1 Accessors} *)
-
(** [id t] returns the item's unique identifier. *)
val id : t -> string
-
-
(** [content t] returns the item's content. *)
val content : t -> content
-
-
(** [url t] returns the item's permalink URL, if set. *)
val url : t -> string option
-
-
(** [external_url t] returns the external resource URL, if set. *)
val external_url : t -> string option
-
-
(** [title t] returns the item's title, if set. *)
val title : t -> string option
-
-
(** [summary t] returns the item's summary, if set. *)
val summary : t -> string option
-
-
(** [image t] returns the item's featured image URL, if set. *)
val image : t -> string option
-
-
(** [banner_image t] returns the item's banner image URL, if set. *)
val banner_image : t -> string option
-
-
(** [date_published t] returns the item's publication date, if set. *)
val date_published : t -> Ptime.t option
-
-
(** [date_modified t] returns the item's last modification date, if set. *)
val date_modified : t -> Ptime.t option
-
-
(** [authors t] returns the item's authors, if set. *)
val authors : t -> Author.t list option
-
-
(** [tags t] returns the item's tags, if set. *)
val tags : t -> string list option
-
-
(** [language t] returns the item's language code, if set. *)
val language : t -> string option
-
-
(** [attachments t] returns the item's attachments, if set. *)
val attachments : t -> Attachment.t list option
-
-
(** [references t] returns the item's references, if set. *)
val references : t -> Reference.t list option
-
-
-
(** {1 Content Helpers} *)
-
-
(** [content_html t] extracts HTML content from the item.
-
-
Returns [Some html] if the item has HTML content (either [Html] or [Both]),
-
[None] otherwise. *)
-
val content_html : t -> string option
-
-
(** [content_text t] extracts plain text content from the item.
-
-
Returns [Some text] if the item has plain text content (either [Text] or [Both]),
-
[None] otherwise. *)
-
val content_text : t -> string option
-
(** {1 Comparison} *)
-
(** [equal a b] tests equality between two items.
-
-
Items are considered equal if they have the same ID. *)
val equal : t -> t -> bool
-
-
(** [compare a b] compares two items by their publication dates.
-
-
Items without publication dates are considered older than items with dates.
-
Useful for sorting items chronologically. *)
val compare : t -> t -> int
-
(** {1 Pretty Printing} *)
-
(** [pp ppf t] pretty prints an item to the formatter.
-
-
The output is human-readable and suitable for debugging.
-
-
{b Example output:}
-
{v [2024-11-03] My First Post (https://example.com/posts/42) v} *)
val pp : Format.formatter -> t -> unit
-
-
(** [pp_content ppf content] pretty prints content to the formatter.
-
-
{b Example output:}
-
{v HTML (123 chars) v}
-
{v Text (56 chars) v}
-
{v Both (HTML: 123 chars, Text: 56 chars) v} *)
-
val pp_content : Format.formatter -> content -> unit
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
(** Feed items in a JSON Feed.
+
An item represents a single entry in a feed, such as a blog post, podcast
+
episode, or microblog entry. Each item must have a unique identifier and
+
content.
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
type t
+
(** The type representing a feed item. *)
+
type content = [ `Html of string | `Text of string | `Both of string * string ]
(** Content representation for an item.
+
The JSON Feed specification requires that each item has at least one form of
+
content. This type enforces that requirement at compile time.
- [`Html s]: Item has HTML content only
- [`Text s]: Item has plain text content only
- [`Both (html, text)]: Item has both HTML and plain text versions *)
+
(** {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. *)
+
val is_empty : t -> bool
+
(** [is_empty u] returns [true] if there are no unknown fields. *)
+
end
+
(** {1 Jsont Type} *)
+
val jsont : t Jsont.t
+
(** Declarative JSON type for feed items.
+
Maps JSON objects with "id" (required), content fields, and various optional
+
metadata. The content must have at least one of "content_html" or
+
"content_text". *)
+
(** {1 Construction} *)
val create :
id:string ->
content:content ->
···
?language:string ->
?attachments:Attachment.t list ->
?references:Reference.t list ->
+
?unknown:Unknown.t ->
unit ->
t
(** {1 Accessors} *)
val id : t -> string
val content : t -> content
+
val content_html : t -> string option
+
val content_text : t -> string option
val url : t -> string option
val external_url : t -> string option
val title : t -> string option
val summary : t -> string option
val image : t -> string option
val banner_image : t -> string option
val date_published : t -> Ptime.t option
val date_modified : t -> Ptime.t option
val authors : t -> Author.t list option
val tags : t -> string list option
val language : t -> string option
val attachments : t -> Attachment.t list option
val references : t -> Reference.t list option
+
val unknown : t -> Unknown.t
(** {1 Comparison} *)
val equal : t -> t -> bool
val compare : t -> t -> int
(** {1 Pretty Printing} *)
val pp : Format.formatter -> t -> unit
+
val pp_summary : Format.formatter -> t -> unit
+99 -515
lib/jsonfeed.ml
···
-
(** JSON Feed format parser and serializer. *)
-
-
exception Invalid_feed of string
module Author = Author
module Attachment = Attachment
module Hub = Hub
-
module Item = Item
module Reference = Reference
-
module Cito = Cito
type t = {
version : string;
···
expired : bool option;
hubs : Hub.t list option;
items : Item.t list;
}
-
let create ~title ?home_page_url ?feed_url ?description ?user_comment
-
?next_url ?icon ?favicon ?authors ?language ?expired ?hubs ~items () =
{
version = "https://jsonfeed.org/version/1.1";
title;
···
expired;
hubs;
items;
}
let version t = t.version
···
let expired t = t.expired
let hubs t = t.hubs
let items t = t.items
-
-
(* RFC3339 date utilities *)
-
-
let parse_rfc3339 s =
-
match Ptime.of_rfc3339 s with
-
| Ok (t, _, _) -> Some t
-
| Error _ -> None
-
-
let format_rfc3339 t =
-
Ptime.to_rfc3339 t
-
-
(* JSON parsing and serialization *)
-
-
type error = [ `Msg of string ]
-
-
let error_msgf fmt = Format.kasprintf (fun s -> Error (`Msg s)) fmt
-
-
(* JSON parsing helpers *)
-
-
type json_value =
-
| Null
-
| Bool of bool
-
| Float of float
-
| String of string
-
| Array of json_value list
-
| Object of (string * json_value) list
-
-
let rec decode_value dec =
-
match Jsonm.decode dec with
-
| `Lexeme `Null -> Null
-
| `Lexeme (`Bool b) -> Bool b
-
| `Lexeme (`Float f) -> Float f
-
| `Lexeme (`String s) -> String s
-
| `Lexeme `Os -> decode_object dec []
-
| `Lexeme `As -> decode_array dec []
-
| `Lexeme _ -> Null
-
| `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
-
| `End | `Await -> Null
-
-
and decode_object dec acc =
-
match Jsonm.decode dec with
-
| `Lexeme `Oe -> Object (List.rev acc)
-
| `Lexeme (`Name n) ->
-
let v = decode_value dec in
-
decode_object dec ((n, v) :: acc)
-
| `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
-
| _ -> Object (List.rev acc)
-
-
and decode_array dec acc =
-
match Jsonm.decode dec with
-
| `Lexeme `Ae -> Array (List.rev acc)
-
| `Lexeme `Os ->
-
let v = decode_object dec [] in
-
decode_array dec (v :: acc)
-
| `Lexeme `As ->
-
let v = decode_array dec [] in
-
decode_array dec (v :: acc)
-
| `Lexeme `Null -> decode_array dec (Null :: acc)
-
| `Lexeme (`Bool b) -> decode_array dec (Bool b :: acc)
-
| `Lexeme (`Float f) -> decode_array dec (Float f :: acc)
-
| `Lexeme (`String s) -> decode_array dec (String s :: acc)
-
| `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
-
| _ -> Array (List.rev acc)
-
-
(* Helpers to extract values from JSON *)
-
-
let get_string = function String s -> Some s | _ -> None
-
let get_bool = function Bool b -> Some b | _ -> None
-
let _get_float = function Float f -> Some f | _ -> None
-
let get_int = function Float f -> Some (int_of_float f) | _ -> None
-
let get_int64 = function Float f -> Some (Int64.of_float f) | _ -> None
-
let get_array = function Array arr -> Some arr | _ -> None
-
let _get_object = function Object obj -> Some obj | _ -> None
-
-
let find_field name obj = List.assoc_opt name obj
-
-
let require_field name obj =
-
match find_field name obj with
-
| Some v -> v
-
| None -> raise (Invalid_feed (Printf.sprintf "Missing required field: %s" name))
-
-
let require_string name obj =
-
match require_field name obj |> get_string with
-
| Some s -> s
-
| None -> raise (Invalid_feed (Printf.sprintf "Field %s must be a string" name))
-
-
let optional_string name obj =
-
match find_field name obj with Some v -> get_string v | None -> None
-
-
let optional_bool name obj =
-
match find_field name obj with Some v -> get_bool v | None -> None
-
-
let optional_int name obj =
-
match find_field name obj with Some v -> get_int v | None -> None
-
-
let optional_int64 name obj =
-
match find_field name obj with Some v -> get_int64 v | None -> None
-
-
let optional_array name obj =
-
match find_field name obj with Some v -> get_array v | None -> None
-
-
(* Parse Author *)
-
-
let parse_author_obj obj =
-
let name = optional_string "name" obj in
-
let url = optional_string "url" obj in
-
let avatar = optional_string "avatar" obj in
-
if name = None && url = None && avatar = None then
-
raise (Invalid_feed "Author must have at least one field");
-
Author.create ?name ?url ?avatar ()
-
-
let parse_author = function
-
| Object obj -> parse_author_obj obj
-
| _ -> raise (Invalid_feed "Author must be an object")
-
-
(* Parse Attachment *)
-
-
let parse_attachment_obj obj =
-
let url = require_string "url" obj in
-
let mime_type = require_string "mime_type" obj in
-
let title = optional_string "title" obj in
-
let size_in_bytes = optional_int64 "size_in_bytes" obj in
-
let duration_in_seconds = optional_int "duration_in_seconds" obj in
-
Attachment.create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ()
-
-
let parse_attachment = function
-
| Object obj -> parse_attachment_obj obj
-
| _ -> raise (Invalid_feed "Attachment must be an object")
-
-
(* Parse Hub *)
-
-
let parse_hub_obj obj =
-
let type_ = require_string "type" obj in
-
let url = require_string "url" obj in
-
Hub.create ~type_ ~url ()
-
-
let parse_hub = function
-
| Object obj -> parse_hub_obj obj
-
| _ -> raise (Invalid_feed "Hub must be an object")
-
-
(* Parse Item *)
-
-
let parse_item_obj obj =
-
let id = require_string "id" obj in
-
-
(* Parse content - at least one required *)
-
let content_html = optional_string "content_html" obj in
-
let content_text = optional_string "content_text" obj in
-
let content = match content_html, content_text with
-
| Some html, Some text -> `Both (html, text)
-
| Some html, None -> `Html html
-
| None, Some text -> `Text text
-
| None, None ->
-
raise (Invalid_feed "Item must have content_html or content_text")
-
in
-
-
let url = optional_string "url" obj in
-
let external_url = optional_string "external_url" obj in
-
let title = optional_string "title" obj in
-
let summary = optional_string "summary" obj in
-
let image = optional_string "image" obj in
-
let banner_image = optional_string "banner_image" obj in
-
-
let date_published =
-
match optional_string "date_published" obj with
-
| Some s -> parse_rfc3339 s
-
| None -> None
-
in
-
-
let date_modified =
-
match optional_string "date_modified" obj with
-
| Some s -> parse_rfc3339 s
-
| None -> None
-
in
-
-
let authors =
-
match optional_array "authors" obj with
-
| Some arr ->
-
let parsed = List.map parse_author arr in
-
if parsed = [] then None else Some parsed
-
| None -> None
-
in
-
-
let tags =
-
match optional_array "tags" obj with
-
| Some arr ->
-
let parsed = List.filter_map get_string arr in
-
if parsed = [] then None else Some parsed
-
| None -> None
-
in
-
-
let language = optional_string "language" obj in
-
-
let attachments =
-
match optional_array "attachments" obj with
-
| Some arr ->
-
let parsed = List.map parse_attachment arr in
-
if parsed = [] then None else Some parsed
-
| None -> None
-
in
-
-
let parse_reference = function
-
| Object obj ->
-
let url = require_string "url" obj in
-
let doi = optional_string "doi" obj in
-
Reference.create ~url ?doi ()
-
| _ -> raise (Invalid_feed "Reference must be an object")
-
in
-
-
let references =
-
match optional_array "_references" obj with
-
| Some arr ->
-
let parsed = List.map parse_reference arr in
-
if parsed = [] then None else Some parsed
-
| None -> None
-
in
-
-
Item.create ~id ~content ?url ?external_url ?title ?summary ?image
-
?banner_image ?date_published ?date_modified ?authors ?tags ?language
-
?attachments ?references ()
-
-
let parse_item = function
-
| Object obj -> parse_item_obj obj
-
| _ -> raise (Invalid_feed "Item must be an object")
-
-
(* Parse Feed *)
-
-
let parse_feed_obj obj =
-
let version = require_string "version" obj in
-
let title = require_string "title" obj in
-
let home_page_url = optional_string "home_page_url" obj in
-
let feed_url = optional_string "feed_url" obj in
-
let description = optional_string "description" obj in
-
let user_comment = optional_string "user_comment" obj in
-
let next_url = optional_string "next_url" obj in
-
let icon = optional_string "icon" obj in
-
let favicon = optional_string "favicon" obj in
-
let language = optional_string "language" obj in
-
let expired = optional_bool "expired" obj in
-
-
let authors =
-
match optional_array "authors" obj with
-
| Some arr ->
-
let parsed = List.map parse_author arr in
-
if parsed = [] then None else Some parsed
-
| None -> None
-
in
-
-
let hubs =
-
match optional_array "hubs" obj with
-
| Some arr ->
-
let parsed = List.map parse_hub arr in
-
if parsed = [] then None else Some parsed
-
| None -> None
-
in
-
-
let items =
-
match optional_array "items" obj with
-
| Some arr -> List.map parse_item arr
-
| None -> []
-
in
-
-
{
-
version;
-
title;
-
home_page_url;
-
feed_url;
-
description;
-
user_comment;
-
next_url;
-
icon;
-
favicon;
-
authors;
-
language;
-
expired;
-
hubs;
-
items;
-
}
-
-
let of_jsonm dec =
-
try
-
let json = decode_value dec in
-
match json with
-
| Object obj -> Ok (parse_feed_obj obj)
-
| _ -> error_msgf "Feed must be a JSON object"
-
with
-
| Invalid_feed msg -> error_msgf "%s" msg
-
(* JSON serialization *)
-
let to_jsonm enc feed =
-
(* Simplified serialization using Jsonm *)
-
let enc_field name value_fn =
-
ignore (Jsonm.encode enc (`Lexeme (`Name name)));
-
value_fn ()
-
in
-
let enc_string s =
-
ignore (Jsonm.encode enc (`Lexeme (`String s)))
-
in
-
let enc_bool b =
-
ignore (Jsonm.encode enc (`Lexeme (`Bool b)))
-
in
-
let enc_opt enc_fn = function
-
| None -> ()
-
| Some v -> enc_fn v
in
-
let enc_list enc_fn lst =
-
ignore (Jsonm.encode enc (`Lexeme `As));
-
List.iter enc_fn lst;
-
ignore (Jsonm.encode enc (`Lexeme `Ae))
-
in
-
let enc_author author =
-
ignore (Jsonm.encode enc (`Lexeme `Os));
-
(match Author.name author with
-
| Some name -> enc_field "name" (fun () -> enc_string name)
-
| None -> ());
-
(match Author.url author with
-
| Some url -> enc_field "url" (fun () -> enc_string url)
-
| None -> ());
-
(match Author.avatar author with
-
| Some avatar -> enc_field "avatar" (fun () -> enc_string avatar)
-
| None -> ());
-
ignore (Jsonm.encode enc (`Lexeme `Oe))
-
in
-
let enc_attachment att =
-
ignore (Jsonm.encode enc (`Lexeme `Os));
-
enc_field "url" (fun () -> enc_string (Attachment.url att));
-
enc_field "mime_type" (fun () -> enc_string (Attachment.mime_type att));
-
enc_opt (fun title -> enc_field "title" (fun () -> enc_string title))
-
(Attachment.title att);
-
enc_opt (fun size ->
-
enc_field "size_in_bytes" (fun () ->
-
ignore (Jsonm.encode enc (`Lexeme (`Float (Int64.to_float size))))))
-
(Attachment.size_in_bytes att);
-
enc_opt (fun dur ->
-
enc_field "duration_in_seconds" (fun () ->
-
ignore (Jsonm.encode enc (`Lexeme (`Float (float_of_int dur))))))
-
(Attachment.duration_in_seconds att);
-
ignore (Jsonm.encode enc (`Lexeme `Oe))
-
in
-
let enc_reference ref =
-
ignore (Jsonm.encode enc (`Lexeme `Os));
-
enc_field "url" (fun () -> enc_string (Reference.url ref));
-
enc_opt (fun doi -> enc_field "doi" (fun () -> enc_string doi))
-
(Reference.doi ref);
-
enc_opt (fun cito_list ->
-
enc_field "cito" (fun () ->
-
enc_list (fun cito -> enc_string (Cito.to_string cito)) cito_list))
-
(Reference.cito ref);
-
ignore (Jsonm.encode enc (`Lexeme `Oe))
-
in
-
let enc_hub hub =
-
ignore (Jsonm.encode enc (`Lexeme `Os));
-
enc_field "type" (fun () -> enc_string (Hub.type_ hub));
-
enc_field "url" (fun () -> enc_string (Hub.url hub));
-
ignore (Jsonm.encode enc (`Lexeme `Oe))
-
in
-
let enc_item item =
-
ignore (Jsonm.encode enc (`Lexeme `Os));
-
enc_field "id" (fun () -> enc_string (Item.id item));
-
(* Encode content *)
-
(match Item.content item with
-
| `Html html ->
-
enc_field "content_html" (fun () -> enc_string html)
-
| `Text text ->
-
enc_field "content_text" (fun () -> enc_string text)
-
| `Both (html, text) ->
-
enc_field "content_html" (fun () -> enc_string html);
-
enc_field "content_text" (fun () -> enc_string text));
-
enc_opt (fun url -> enc_field "url" (fun () -> enc_string url))
-
(Item.url item);
-
enc_opt (fun url -> enc_field "external_url" (fun () -> enc_string url))
-
(Item.external_url item);
-
enc_opt (fun title -> enc_field "title" (fun () -> enc_string title))
-
(Item.title item);
-
enc_opt (fun summary -> enc_field "summary" (fun () -> enc_string summary))
-
(Item.summary item);
-
enc_opt (fun img -> enc_field "image" (fun () -> enc_string img))
-
(Item.image item);
-
enc_opt (fun img -> enc_field "banner_image" (fun () -> enc_string img))
-
(Item.banner_image item);
-
enc_opt (fun date -> enc_field "date_published" (fun () -> enc_string (format_rfc3339 date)))
-
(Item.date_published item);
-
enc_opt (fun date -> enc_field "date_modified" (fun () -> enc_string (format_rfc3339 date)))
-
(Item.date_modified item);
-
enc_opt (fun authors ->
-
enc_field "authors" (fun () -> enc_list enc_author authors))
-
(Item.authors item);
-
enc_opt (fun tags ->
-
enc_field "tags" (fun () -> enc_list enc_string tags))
-
(Item.tags item);
-
enc_opt (fun lang -> enc_field "language" (fun () -> enc_string lang))
-
(Item.language item);
-
enc_opt (fun atts ->
-
enc_field "attachments" (fun () -> enc_list enc_attachment atts))
-
(Item.attachments item);
-
enc_opt (fun refs ->
-
enc_field "_references" (fun () -> enc_list enc_reference refs))
-
(Item.references item);
-
-
ignore (Jsonm.encode enc (`Lexeme `Oe))
-
in
-
-
(* Encode the feed *)
-
ignore (Jsonm.encode enc (`Lexeme `Os));
-
enc_field "version" (fun () -> enc_string feed.version);
-
enc_field "title" (fun () -> enc_string feed.title);
-
enc_opt (fun url -> enc_field "home_page_url" (fun () -> enc_string url))
-
feed.home_page_url;
-
enc_opt (fun url -> enc_field "feed_url" (fun () -> enc_string url))
-
feed.feed_url;
-
enc_opt (fun desc -> enc_field "description" (fun () -> enc_string desc))
-
feed.description;
-
enc_opt (fun comment -> enc_field "user_comment" (fun () -> enc_string comment))
-
feed.user_comment;
-
enc_opt (fun url -> enc_field "next_url" (fun () -> enc_string url))
-
feed.next_url;
-
enc_opt (fun icon -> enc_field "icon" (fun () -> enc_string icon))
-
feed.icon;
-
enc_opt (fun favicon -> enc_field "favicon" (fun () -> enc_string favicon))
-
feed.favicon;
-
enc_opt (fun authors ->
-
enc_field "authors" (fun () -> enc_list enc_author authors))
-
feed.authors;
-
enc_opt (fun lang -> enc_field "language" (fun () -> enc_string lang))
-
feed.language;
-
enc_opt (fun expired -> enc_field "expired" (fun () -> enc_bool expired))
-
feed.expired;
-
enc_opt (fun hubs ->
-
enc_field "hubs" (fun () -> enc_list enc_hub hubs))
-
feed.hubs;
-
enc_field "items" (fun () -> enc_list enc_item feed.items);
-
ignore (Jsonm.encode enc (`Lexeme `Oe));
-
ignore (Jsonm.encode enc `End)
-
-
let of_string s =
-
let dec = Jsonm.decoder (`String s) in
-
of_jsonm dec
-
-
let to_string ?(minify=false) feed =
-
let buf = Buffer.create 1024 in
-
let enc = Jsonm.encoder ~minify (`Buffer buf) in
-
to_jsonm enc feed;
-
Buffer.contents buf
(* Validation *)
···
let add_error msg = errors := msg :: !errors in
(* Check required fields *)
-
if feed.title = "" then
-
add_error "title is required and cannot be empty";
(* Check items have unique IDs *)
let ids = List.map Item.id feed.items in
···
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 (fun i item ->
-
if Item.id item = "" then
-
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;
-
-
if !errors = [] then Ok ()
-
else Error (List.rev !errors)
-
-
(* Comparison *)
-
-
let equal a b =
-
a.version = b.version &&
-
a.title = b.title &&
-
a.home_page_url = b.home_page_url &&
-
a.feed_url = b.feed_url &&
-
a.description = b.description &&
-
a.user_comment = b.user_comment &&
-
a.next_url = b.next_url &&
-
a.icon = b.icon &&
-
a.favicon = b.favicon &&
-
a.language = b.language &&
-
a.expired = b.expired &&
-
(* Note: We're doing structural equality on items *)
-
List.length a.items = List.length b.items
-
-
(* Pretty printing *)
-
-
let pp_summary ppf feed =
-
Format.fprintf ppf "%s (%d items)" feed.title (List.length feed.items)
-
let pp ppf feed =
-
Format.fprintf ppf "Feed: %s" feed.title;
-
(match feed.home_page_url with
-
| Some url -> Format.fprintf ppf " (%s)" url
-
| None -> ());
-
Format.fprintf ppf "@\n";
-
-
Format.fprintf ppf " Items: %d@\n" (List.length feed.items);
-
-
(match feed.authors with
-
| Some authors when authors <> [] ->
-
Format.fprintf ppf " Authors: ";
-
List.iteri (fun i author ->
-
if i > 0 then Format.fprintf ppf ", ";
-
Format.fprintf ppf "%a" Author.pp author
-
) authors;
-
Format.fprintf ppf "@\n"
-
| _ -> ());
-
(match feed.language with
-
| Some lang -> Format.fprintf ppf " Language: %s@\n" lang
-
| None -> ())
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
module Rfc3339 = Rfc3339
+
module Cito = Cito
module Author = Author
module Attachment = Attachment
module Hub = Hub
module Reference = Reference
+
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 = {
version : string;
···
expired : bool option;
hubs : Hub.t list option;
items : Item.t list;
+
unknown : Unknown.t;
}
+
let create ~title ?home_page_url ?feed_url ?description ?user_comment ?next_url
+
?icon ?favicon ?authors ?language ?expired ?hubs ~items
+
?(unknown = Unknown.empty) () =
{
version = "https://jsonfeed.org/version/1.1";
title;
···
expired;
hubs;
items;
+
unknown;
}
let version t = t.version
···
let expired t = t.expired
let hubs t = t.hubs
let items t = t.items
+
let unknown t = t.unknown
+
let equal a b = a.title = b.title && a.items = b.items
+
let pp ppf t =
+
Format.fprintf ppf "Feed: %s (%d items)" t.title (List.length t.items)
+
let pp_summary ppf t =
+
Format.fprintf ppf "%s (%d items)" t.title (List.length t.items)
+
(* Jsont type *)
+
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
+
user_comment next_url icon favicon authors language expired hubs items
+
unknown =
+
{
+
version = "https://jsonfeed.org/version/1.1";
+
title;
+
home_page_url;
+
feed_url;
+
description;
+
user_comment;
+
next_url;
+
icon;
+
favicon;
+
authors;
+
language;
+
expired;
+
hubs;
+
items;
+
unknown;
+
}
in
+
Jsont.Object.map ~kind ~doc make_from_json
+
|> Jsont.Object.mem "version" Jsont.string ~enc:version
+
|> Jsont.Object.mem "title" Jsont.string ~enc:title
+
|> Jsont.Object.opt_mem "home_page_url" Jsont.string ~enc:home_page_url
+
|> Jsont.Object.opt_mem "feed_url" Jsont.string ~enc:feed_url
+
|> Jsont.Object.opt_mem "description" Jsont.string ~enc:description
+
|> Jsont.Object.opt_mem "user_comment" Jsont.string ~enc:user_comment
+
|> Jsont.Object.opt_mem "next_url" Jsont.string ~enc:next_url
+
|> Jsont.Object.opt_mem "icon" Jsont.string ~enc:icon
+
|> Jsont.Object.opt_mem "favicon" Jsont.string ~enc:favicon
+
|> Jsont.Object.opt_mem "authors" (Jsont.list Author.jsont) ~enc:authors
+
|> Jsont.Object.opt_mem "language" Jsont.string ~enc:language
+
|> 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 *)
+
let decode ?layout ?locs ?file r =
+
Jsont_bytesrw.decode' ?layout ?locs ?file jsont r
+
let decode_string ?layout ?locs ?file s =
+
Jsont_bytesrw.decode_string' ?layout ?locs ?file jsont s
+
let encode ?format ?number_format feed ~eod w =
+
Jsont_bytesrw.encode' ?format ?number_format jsont feed ~eod w
+
let encode_string ?format ?number_format feed =
+
Jsont_bytesrw.encode_string' ?format ?number_format jsont feed
+
let of_string s = decode_string s
+
let to_string ?(minify = false) feed =
+
let format = if minify then Jsont.Minify else Jsont.Indent in
+
encode_string ~format feed
(* Validation *)
···
let add_error msg = errors := msg :: !errors in
(* Check required fields *)
+
if feed.title = "" then add_error "title is required and cannot be empty";
(* Check items have unique IDs *)
let ids = List.map Item.id feed.items in
···
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
+
(fun i item ->
+
if Item.id item = "" then
+
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)
+175 -294
lib/jsonfeed.mli
···
-
(** JSON Feed format parser and serializer.
-
This library implements the JSON Feed specification version 1.1, providing
-
type-safe parsing and serialization of JSON Feed documents. JSON Feed is a
-
syndication format similar to RSS and Atom, but using JSON instead of XML.
-
-
{b Key Features:}
-
- Type-safe construction with compile-time validation
-
- Support for all JSON Feed 1.1 fields
-
- RFC 3339 date parsing with Ptime integration
-
- Streaming parsing and serialization with Jsonm
-
- Comprehensive documentation and examples
-
-
{b Quick Start:}
-
{[
-
(* Create a simple feed *)
-
let feed = Jsonfeed.create
-
~title:"My Blog"
-
~home_page_url:"https://example.com"
-
~feed_url:"https://example.com/feed.json"
-
~items:[
-
Item.create
-
~id:"https://example.com/post/1"
-
~content:(Item.Html "<p>Hello, world!</p>")
-
~title:"First Post"
-
()
-
]
-
()
-
-
(* Serialize to string *)
-
let json = Jsonfeed.to_string feed
-
-
(* Parse from string *)
-
match Jsonfeed.of_string json with
-
| Ok feed -> Printf.printf "Feed: %s\n" (Jsonfeed.title feed)
-
| Error (`Msg err) -> Printf.eprintf "Error: %s\n" err
-
]}
@see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
-
(** The type representing a complete JSON Feed. *)
-
type t
-
(** Exception raised when attempting to parse an invalid feed. *)
-
exception Invalid_feed of string
-
(** {1 Construction} *)
-
(** [create ~title ?home_page_url ?feed_url ?description ?user_comment ?next_url
-
?icon ?favicon ?authors ?language ?expired ?hubs ~items ()]
-
creates a JSON Feed.
-
@param title The name of the feed (required)
-
@param home_page_url The URL of the resource the feed describes
-
@param feed_url The URL of the feed itself (serves as unique identifier)
-
@param description Additional information about the feed
-
@param user_comment A description of the feed's purpose for humans reading the raw JSON
-
@param next_url URL of the next page of items (for pagination)
-
@param icon The feed's icon URL (should be square, 512x512 or larger)
-
@param favicon The feed's favicon URL (should be square, 64x64 or larger)
-
@param authors The feed's default authors (inherited by items without authors)
-
@param language The primary language of the feed (RFC 5646 format, e.g. ["en-US"])
-
@param expired Whether the feed will update again ([true] means no more updates)
-
@param hubs Endpoints for real-time notifications
-
@param items The list of feed items (required)
-
{b Examples:}
-
{[
-
(* Minimal feed *)
-
let feed = Jsonfeed.create
-
~title:"My Blog"
-
~items:[] ()
-
(* Full-featured blog feed *)
-
let feed = Jsonfeed.create
-
~title:"Example Blog"
-
~home_page_url:"https://example.com"
-
~feed_url:"https://example.com/feed.json"
-
~description:"A blog about OCaml and functional programming"
-
~icon:"https://example.com/icon.png"
-
~authors:[
-
Author.create
-
~name:"Jane Doe"
-
~url:"https://example.com/about"
-
()
-
]
-
~language:"en-US"
-
~items:[
-
Item.create
-
~id:"https://example.com/posts/1"
-
~content:(Item.Html "<p>First post</p>")
-
~title:"Hello World"
-
();
-
Item.create
-
~id:"https://example.com/posts/2"
-
~content:(Item.Html "<p>Second post</p>")
-
~title:"Another Post"
-
()
-
]
-
()
-
(* Podcast feed with hubs *)
-
let hub = Hub.create
-
~type_:"WebSub"
-
~url:"https://pubsubhubbub.appspot.com/"
-
() in
-
let feed = Jsonfeed.create
-
~title:"My Podcast"
-
~home_page_url:"https://podcast.example.com"
-
~feed_url:"https://podcast.example.com/feed.json"
-
~hubs:[hub]
-
~items:[
-
Item.create
-
~id:"https://podcast.example.com/episodes/1"
-
~content:(Item.Html "<p>Episode description</p>")
-
~title:"Episode 1"
-
~attachments:[
-
Attachment.create
-
~url:"https://podcast.example.com/ep1.mp3"
-
~mime_type:"audio/mpeg"
-
~duration_in_seconds:1800
-
()
-
]
-
()
-
]
-
()
-
]} *)
val create :
title:string ->
?home_page_url:string ->
···
?expired:bool ->
?hubs:Hub.t list ->
items:Item.t list ->
unit ->
t
(** {1 Accessors} *)
-
(** [version t] returns the JSON Feed version URL.
-
-
This is always ["https://jsonfeed.org/version/1.1"] for feeds created
-
by this library, but may differ when parsing external feeds. *)
val version : t -> string
-
(** [title t] returns the feed's title. *)
val title : t -> string
-
(** [home_page_url t] returns the feed's home page URL, if set. *)
val home_page_url : t -> string option
-
(** [feed_url t] returns the feed's URL, if set. *)
val feed_url : t -> string option
-
(** [description t] returns the feed's description, if set. *)
val description : t -> string option
-
(** [user_comment t] returns the feed's user comment, if set. *)
val user_comment : t -> string option
-
(** [next_url t] returns the URL for the next page of items, if set. *)
val next_url : t -> string option
-
(** [icon t] returns the feed's icon URL, if set. *)
val icon : t -> string option
-
(** [favicon t] returns the feed's favicon URL, if set. *)
val favicon : t -> string option
-
(** [authors t] returns the feed's default authors, if set. *)
val authors : t -> Author.t list option
-
(** [language t] returns the feed's primary language, if set. *)
val language : t -> string option
-
(** [expired t] returns whether the feed will update again. *)
val expired : t -> bool option
-
(** [hubs t] returns the feed's hub endpoints, if set. *)
val hubs : t -> Hub.t list option
-
(** [items t] returns the feed's items. *)
val items : t -> Item.t list
-
(** {1 Parsing and Serialization} *)
-
(** Error type for parsing operations. *)
-
type error = [ `Msg of string ]
-
-
(** [of_jsonm decoder] parses a JSON Feed from a Jsonm decoder.
-
-
This is the lowest-level parsing function, suitable for integration
-
with streaming JSON processing pipelines.
-
-
@param decoder A Jsonm decoder positioned at the start of a JSON Feed document
-
@return [Ok feed] on success, [Error (`Msg err)] on parse error
-
-
{b Example:}
-
{[
-
let decoder = Jsonm.decoder (`String json_string) in
-
match Jsonfeed.of_jsonm decoder with
-
| Ok feed -> (* process feed *)
-
| Error (`Msg err) -> (* handle error *)
-
]} *)
-
val of_jsonm : Jsonm.decoder -> (t, [> error]) result
-
-
(** [to_jsonm encoder feed] serializes a JSON Feed to a Jsonm encoder.
-
-
This is the lowest-level serialization function, suitable for integration
-
with streaming JSON generation pipelines.
-
-
@param encoder A Jsonm encoder
-
@param feed The feed to serialize
-
-
{b Example:}
-
{[
-
let buffer = Buffer.create 1024 in
-
let encoder = Jsonm.encoder (`Buffer buffer) in
-
Jsonfeed.to_jsonm encoder feed;
-
let json = Buffer.contents buffer
-
]} *)
-
val to_jsonm : Jsonm.encoder -> t -> unit
-
-
(** [of_string s] parses a JSON Feed from a string.
-
-
@param s A JSON string containing a JSON Feed document
-
@return [Ok feed] on success, [Error (`Msg err)] on parse error
-
-
{b Example:}
-
{[
-
let json = {|{
-
"version": "https://jsonfeed.org/version/1.1",
-
"title": "My Feed",
-
"items": []
-
}|} in
-
match Jsonfeed.of_string json with
-
| Ok feed -> Printf.printf "Parsed: %s\n" (Jsonfeed.title feed)
-
| Error (`Msg err) -> Printf.eprintf "Error: %s\n" err
-
]} *)
-
val of_string : string -> (t, [> error]) result
-
-
(** [to_string ?minify feed] serializes a JSON Feed to a string.
-
-
@param minify If [true], produces compact JSON without whitespace.
-
If [false] (default), produces indented, human-readable JSON.
-
@param feed The feed to serialize
-
@return A JSON string
-
-
{b Example:}
-
{[
-
let json = Jsonfeed.to_string feed
-
let compact = Jsonfeed.to_string ~minify:true feed
-
]} *)
-
val to_string : ?minify:bool -> t -> string
-
-
-
(** {1 Date Utilities} *)
-
-
(** [parse_rfc3339 s] parses an RFC 3339 date/time string.
-
-
This function parses timestamps in the format required by JSON Feed,
-
such as ["2024-11-03T10:30:00Z"] or ["2024-11-03T10:30:00-08:00"].
-
-
@param s An RFC 3339 formatted date/time string
-
@return [Some time] on success, [None] if the string is invalid
-
-
{b Examples:}
-
{[
-
parse_rfc3339 "2024-11-03T10:30:00Z"
-
(* returns Some time *)
-
parse_rfc3339 "2024-11-03T10:30:00-08:00"
-
(* returns Some time *)
-
parse_rfc3339 "invalid"
-
(* returns None *)
-
]} *)
-
val parse_rfc3339 : string -> Ptime.t option
-
(** [format_rfc3339 time] formats a timestamp as an RFC 3339 string.
-
The output uses UTC timezone (Z suffix) and includes fractional seconds
-
if the timestamp has sub-second precision.
-
@param time A Ptime timestamp
-
@return An RFC 3339 formatted string
-
{b Example:}
-
{[
-
let now = Ptime_clock.now () in
-
let s = format_rfc3339 now
-
(* returns "2024-11-03T10:30:45.123Z" or similar *)
-
]} *)
-
val format_rfc3339 : Ptime.t -> string
(** {1 Validation} *)
-
(** [validate feed] validates a JSON Feed.
-
-
Checks that:
-
- All required fields are present
-
- All items have unique IDs
-
- All items have valid content
-
- All URLs are well-formed (if possible)
-
- Authors have at least one field set
-
-
@param feed The feed to validate
-
@return [Ok ()] if valid, [Error errors] with a list of validation issues
-
-
{b Example:}
-
{[
-
match Jsonfeed.validate feed with
-
| Ok () -> (* feed is valid *)
-
| Error errors ->
-
List.iter (Printf.eprintf "Validation error: %s\n") errors
-
]} *)
val validate : t -> (unit, string list) result
-
(** {1 Comparison} *)
-
(** [equal a b] tests equality between two feeds.
-
-
Feeds are compared structurally, including all fields and items. *)
val equal : t -> t -> bool
-
(** {1 Pretty Printing} *)
-
(** [pp ppf feed] pretty prints a feed to the formatter.
-
-
The output is human-readable and suitable for debugging. It shows
-
the feed's metadata and a summary of items.
-
-
{b Example output:}
-
{v
-
Feed: My Blog (https://example.com)
-
Items: 2
-
Authors: Jane Doe
-
Language: en-US
-
v} *)
val pp : Format.formatter -> t -> unit
-
-
(** [pp_summary ppf feed] prints a brief summary of the feed.
-
-
Shows only the title and item count.
-
-
{b Example output:}
-
{v My Blog (2 items) v} *)
val pp_summary : Format.formatter -> t -> unit
-
(** {1 Feed Content} *)
-
-
(** Author information for feeds and items. *)
module Author = Author
-
-
(** Attachments for feed items (audio, video, downloads). *)
module Attachment = Attachment
-
-
(** Hub endpoints for real-time notifications. *)
module Hub = Hub
-
-
(** Feed items (posts, episodes, entries). *)
-
module Item = Item
-
-
(** References to cited sources in items (extension). *)
module Reference = Reference
-
-
(** Citation Typing Ontology annotations for references (extension). *)
-
module Cito = Cito
···
+
(*---------------------------------------------------------------------------
+
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 ->
?home_page_url:string ->
···
?expired:bool ->
?hubs:Hub.t list ->
items:Item.t list ->
+
?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} *)
+
val decode :
+
?layout:bool ->
+
?locs:bool ->
+
?file:string ->
+
Bytesrw.Bytes.Reader.t ->
+
(t, Jsont.Error.t) result
+
(** [decode r] decodes a JSON Feed from bytesrw reader [r].
+
@param layout Preserve whitespace for round-tripping (default: false)
+
@param locs Track locations for better error messages (default: false)
+
@param file Source file name for error reporting *)
+
val decode_string :
+
?layout:bool ->
+
?locs:bool ->
+
?file:string ->
+
string ->
+
(t, Jsont.Error.t) result
+
(** [decode_string s] decodes a JSON Feed from string [s]. *)
+
val encode :
+
?format:Jsont.format ->
+
?number_format:Jsont.number_format ->
+
t ->
+
eod:bool ->
+
Bytesrw.Bytes.Writer.t ->
+
(unit, Jsont.Error.t) result
+
(** [encode feed w] encodes [feed] to bytesrw writer [w].
+
@param format
+
Output formatting: [Jsont.Minify] or [Jsont.Indent] (default: Minify)
+
@param number_format Printf format for numbers (default: "%.16g")
+
@param eod Write end-of-data marker *)
+
val encode_string :
+
?format:Jsont.format ->
+
?number_format:Jsont.number_format ->
+
t ->
+
(string, Jsont.Error.t) result
+
(** [encode_string feed] encodes [feed] to a string. *)
+
val of_string : string -> (t, Jsont.Error.t) result
+
(** Alias for [decode_string] with default options. *)
+
val to_string : ?minify:bool -> t -> (string, Jsont.Error.t) result
+
(** [to_string feed] encodes [feed] to string.
+
@param minify Use compact format (true) or indented (false, default) *)
(** {1 Validation} *)
val validate : t -> (unit, string list) result
+
(** [validate feed] validates the feed structure. Checks for unique item IDs,
+
valid content, etc. *)
(** {1 Comparison} *)
val equal : t -> t -> bool
+
(** [equal a b] tests equality between two feeds. *)
(** {1 Pretty Printing} *)
val pp : Format.formatter -> t -> unit
val pp_summary : Format.formatter -> t -> unit
+
(** {1 Submodules} *)
+
module Rfc3339 = Rfc3339
+
module Cito = Cito
module Author = Author
module Attachment = Attachment
module Hub = Hub
module Reference = Reference
+
module Item = Item
+28 -5
lib/reference.ml
···
type t = {
url : string;
doi : string option;
cito : Cito.t list option;
}
-
let create ~url ?doi ?cito () = { url; doi; cito }
let url t = t.url
let doi t = t.doi
let cito t = t.cito
-
let equal a b = String.equal a.url b.url
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 -> ()
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
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 = {
url : string;
doi : string option;
cito : Cito.t list option;
+
unknown : Unknown.t;
}
+
let create ~url ?doi ?cito ?(unknown = Unknown.empty) () =
+
{ url; doi; cito; unknown }
let url t = t.url
let doi t = t.doi
let cito t = t.cito
+
let unknown t = t.unknown
let equal a b = String.equal a.url b.url
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
+62 -39
lib/reference.mli
···
(** References extension for JSON Feed items.
This implements the references extension that allows items to cite sources.
-
Each reference represents a cited resource with optional DOI and CiTO annotations.
-
@see <https://github.com/egonw/JSONFeed-extensions/blob/main/references.md> References Extension Specification
@see <https://purl.archive.org/spar/cito> Citation Typing Ontology *)
-
(** The type representing a reference to a cited source. *)
-
type t
(** {1 Construction} *)
-
(** [create ~url ?doi ?cito ()] creates a reference.
-
@param url Unique URL for the reference (required).
-
A URL based on a persistent unique identifier (like DOI) is recommended.
@param doi Digital Object Identifier for the reference
@param cito Citation Typing Ontology intent annotations
{b Examples:}
{[
(* Simple reference with just a URL *)
-
let ref1 = Reference.create
-
~url:"https://doi.org/10.5281/zenodo.16755947"
-
()
(* Reference with DOI *)
-
let ref2 = Reference.create
-
~url:"https://doi.org/10.5281/zenodo.16755947"
-
~doi:"10.5281/zenodo.16755947"
-
()
(* Reference with CiTO annotations *)
-
let ref3 = Reference.create
-
~url:"https://doi.org/10.5281/zenodo.16755947"
-
~doi:"10.5281/zenodo.16755947"
-
~cito:[`CitesAsRecommendedReading; `UsesMethodIn]
-
()
-
-
(* Reference with custom CiTO term *)
-
let ref4 = Reference.create
-
~url:"https://example.com/paper"
-
~cito:[`Other "customIntent"]
-
()
]} *)
-
val create :
-
url:string ->
-
?doi:string ->
-
?cito:Cito.t list ->
-
unit ->
-
t
-
(** {1 Accessors} *)
-
(** [url t] returns the reference's URL. *)
val url : t -> string
(** [doi t] returns the reference's DOI, if set. *)
-
val doi : t -> string option
-
(** [cito t] returns the reference's CiTO annotations, if set. *)
val cito : t -> Cito.t list option
(** {1 Comparison} *)
(** [equal a b] tests equality between two references.
References are considered equal if they have the same URL. *)
-
val equal : t -> t -> bool
-
(** {1 Pretty Printing} *)
(** [pp ppf t] pretty prints a reference to the formatter.
{b Example output:}
-
{v https://doi.org/10.5281/zenodo.16755947 [DOI: 10.5281/zenodo.16755947] v} *)
-
val pp : Format.formatter -> t -> unit
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
(** References extension for JSON Feed items.
This implements the references extension that allows items to cite sources.
+
Each reference represents a cited resource with optional DOI and CiTO
+
annotations.
+
@see <https://github.com/egonw/JSONFeed-extensions/blob/main/references.md>
+
References Extension Specification
@see <https://purl.archive.org/spar/cito> Citation Typing Ontology *)
+
type t
+
(** The type representing a reference to a cited source. *)
+
(** {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. *)
+
+
val is_empty : t -> bool
+
(** [is_empty u] returns [true] if there are no unknown fields. *)
+
end
+
(** {1 Jsont Type} *)
+
+
val jsont : t Jsont.t
+
(** Declarative JSON type for references.
+
+
Maps JSON objects with "url" (required) and optional "doi" and "cito"
+
fields. *)
(** {1 Construction} *)
+
val create :
+
url:string ->
+
?doi:string ->
+
?cito:Cito.t list ->
+
?unknown:Unknown.t ->
+
unit ->
+
t
+
(** [create ~url ?doi ?cito ?unknown ()] creates a reference.
+
@param url
+
Unique URL for the reference (required). A URL based on a persistent
+
unique identifier (like DOI) is recommended.
@param doi Digital Object Identifier for the reference
@param cito Citation Typing Ontology intent annotations
+
@param unknown Unknown/custom fields for extensions (default: empty)
{b Examples:}
{[
(* Simple reference with just a URL *)
+
let ref1 =
+
Reference.create ~url:"https://doi.org/10.5281/zenodo.16755947" ()
(* Reference with DOI *)
+
let ref2 =
+
Reference.create ~url:"https://doi.org/10.5281/zenodo.16755947"
+
~doi:"10.5281/zenodo.16755947" ()
(* Reference with CiTO annotations *)
+
let ref3 =
+
Reference.create ~url:"https://doi.org/10.5281/zenodo.16755947"
+
~doi:"10.5281/zenodo.16755947"
+
~cito:[ `CitesAsRecommendedReading; `UsesMethodIn ]
+
()
]} *)
(** {1 Accessors} *)
val url : t -> string
+
(** [url t] returns the reference's URL. *)
+
val doi : t -> string option
(** [doi t] returns the reference's DOI, if set. *)
val cito : t -> Cito.t list option
+
(** [cito t] returns the reference's CiTO annotations, if set. *)
+
val unknown : t -> Unknown.t
+
(** [unknown t] returns unrecognized fields from the JSON. *)
(** {1 Comparison} *)
+
val equal : t -> t -> bool
(** [equal a b] tests equality between two references.
References are considered equal if they have the same URL. *)
(** {1 Pretty Printing} *)
+
val pp : Format.formatter -> t -> unit
(** [pp ppf t] pretty prints a reference to the formatter.
{b Example output:}
+
{v https://doi.org/10.5281/zenodo.16755947 [DOI: 10.5281/zenodo.16755947] v}
+
*)
+23
lib/rfc3339.ml
···
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
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 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
+
let enc = format in
+
Jsont.map ~kind ~doc ~dec ~enc Jsont.string
+44
lib/rfc3339.mli
···
···
+
(*---------------------------------------------------------------------------
+
Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
+
SPDX-License-Identifier: ISC
+
---------------------------------------------------------------------------*)
+
+
(** RFC 3339 date/time handling for JSON Feed.
+
+
Provides parsing, formatting, and jsont combinators for RFC 3339 timestamps
+
as required by the JSON Feed specification.
+
+
@see <https://www.rfc-editor.org/rfc/rfc3339> RFC 3339 *)
+
+
val jsont : Ptime.t Jsont.t
+
(** [jsont] is a bidirectional JSON type for RFC 3339 timestamps.
+
+
On decode: accepts JSON strings in RFC 3339 format (e.g.,
+
"2024-11-03T10:30:00Z") On encode: produces UTC timestamps with 'Z' suffix
+
+
{b Example:}
+
{[
+
let time = Ptime.of_float_s (Unix.time ()) |> Option.get in
+
Jsont_bytesrw.encode_string Rfc3339.jsont time
+
]} *)
+
+
val parse : string -> Ptime.t option
+
(** [parse s] parses an RFC 3339 timestamp string.
+
+
Accepts various formats:
+
- "2024-11-03T10:30:00Z" (UTC)
+
- "2024-11-03T10:30:00-08:00" (with timezone offset)
+
- "2024-11-03T10:30:00.123Z" (with fractional seconds)
+
+
Returns [None] if the string is not valid RFC 3339. *)
+
+
val format : Ptime.t -> string
+
(** [format t] formats a timestamp as RFC 3339.
+
+
Always uses UTC timezone (Z suffix) and includes fractional seconds if the
+
timestamp has sub-second precision.
+
+
{b Example output:} ["2024-11-03T10:30:45.123Z"] *)
+
+
val pp : Format.formatter -> Ptime.t -> unit
+
(** [pp ppf t] pretty prints a timestamp in RFC 3339 format. *)
+30
test/data/complete_valid.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Complete Feed",
+
"home_page_url": "https://example.com",
+
"feed_url": "https://example.com/feed.json",
+
"description": "A complete test feed",
+
"user_comment": "Test comment",
+
"next_url": "https://example.com/feed2.json",
+
"icon": "https://example.com/icon.png",
+
"favicon": "https://example.com/favicon.ico",
+
"authors": [
+
{
+
"name": "Test Author",
+
"url": "https://example.com/author",
+
"avatar": "https://example.com/avatar.png"
+
}
+
],
+
"language": "en-US",
+
"expired": false,
+
"items": [
+
{
+
"id": "https://example.com/item1",
+
"content_html": "<p>Test content</p>",
+
"title": "Test Item",
+
"url": "https://example.com/item1.html",
+
"date_published": "2024-01-01T12:00:00Z",
+
"tags": ["test", "example"]
+
}
+
]
+
}
+5
test/data/extra_comma.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with trailing comma",
+
"items": [],
+
}
+8
test/data/invalid_author_type.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with invalid author",
+
"authors": [
+
"Just a string instead of object"
+
],
+
"items": []
+
}
+11
test/data/invalid_date_format.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with invalid date",
+
"items": [
+
{
+
"id": "https://example.com/item1",
+
"content_html": "<p>Test</p>",
+
"date_published": "not-a-valid-date"
+
}
+
]
+
}
+10
test/data/invalid_hub_type.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with invalid hub",
+
"hubs": [
+
{
+
"type": "WebSub"
+
}
+
],
+
"items": []
+
}
+16
test/data/invalid_nested_attachment.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with invalid attachment",
+
"items": [
+
{
+
"id": "https://example.com/item1",
+
"content_html": "<p>Test</p>",
+
"attachments": [
+
{
+
"url": "https://example.com/file.mp3",
+
"mime_type": 12345
+
}
+
]
+
}
+
]
+
}
+5
test/data/malformed_json.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1"
+
"title": "Missing comma between fields",
+
"items": []
+
}
+5
test/data/minimal_valid.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Minimal Feed",
+
"items": []
+
}
+10
test/data/missing_item_content.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with item missing content",
+
"items": [
+
{
+
"id": "https://example.com/nocontent",
+
"title": "Item without content"
+
}
+
]
+
}
+9
test/data/missing_item_id.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with item missing ID",
+
"items": [
+
{
+
"content_html": "<p>Item without id</p>"
+
}
+
]
+
}
+4
test/data/missing_items.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed without items"
+
}
+4
test/data/missing_title.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"items": []
+
}
+4
test/data/missing_version.json
···
···
+
{
+
"title": "Feed without version",
+
"items": []
+
}
+19
test/data/mixed_content.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Mixed Content Feed",
+
"items": [
+
{
+
"id": "https://example.com/html",
+
"content_html": "<p>HTML only</p>"
+
},
+
{
+
"id": "https://example.com/text",
+
"content_text": "Text only"
+
},
+
{
+
"id": "https://example.com/both",
+
"content_html": "<p>HTML version</p>",
+
"content_text": "Text version"
+
}
+
]
+
}
+9
test/data/with_extensions.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with Extensions",
+
"items": [],
+
"_custom_field": "custom value",
+
"_another_extension": {
+
"nested": "data"
+
}
+
}
+6
test/data/wrong_type_expired.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with wrong type for expired",
+
"expired": "yes",
+
"items": []
+
}
+7
test/data/wrong_type_items.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": "Feed with items as object",
+
"items": {
+
"item1": {}
+
}
+
}
+5
test/data/wrong_type_title.json
···
···
+
{
+
"version": "https://jsonfeed.org/version/1.1",
+
"title": true,
+
"items": []
+
}
+5
test/data/wrong_type_version.json
···
···
+
{
+
"version": 1.1,
+
"title": "Feed with numeric version",
+
"items": []
+
}
+10
test/dune
···
(name test_serialization)
(modules test_serialization)
(libraries jsonfeed))
···
(name test_serialization)
(modules test_serialization)
(libraries jsonfeed))
+
+
(executable
+
(name test_location_errors)
+
(modules test_location_errors)
+
(libraries jsonfeed))
+
+
(cram
+
(deps
+
test_location_errors.exe
+
(glob_files data/*.json)))
+401 -167
test/test_jsonfeed.ml
···
let test_author_create_with_url () =
let author = Author.create ~url:"https://example.com" () in
Alcotest.(check (option string)) "name" None (Author.name author);
-
Alcotest.(check (option string)) "url" (Some "https://example.com") (Author.url author);
Alcotest.(check bool) "is_valid" true (Author.is_valid author)
let test_author_create_with_all_fields () =
-
let author = Author.create
-
~name:"Jane Doe"
-
~url:"https://example.com"
-
~avatar:"https://example.com/avatar.png"
-
() in
Alcotest.(check (option string)) "name" (Some "Jane Doe") (Author.name author);
-
Alcotest.(check (option string)) "url" (Some "https://example.com") (Author.url author);
-
Alcotest.(check (option string)) "avatar" (Some "https://example.com/avatar.png") (Author.avatar author);
Alcotest.(check bool) "is_valid" true (Author.is_valid author)
let test_author_create_no_fields_fails () =
Alcotest.check_raises "no fields"
-
(Invalid_argument "Author.create: at least one field (name, url, or avatar) must be provided")
-
(fun () -> ignore (Author.create ()))
let test_author_equal () =
let a1 = Author.create ~name:"Jane Doe" () in
···
let test_author_pp () =
let author = Author.create ~name:"Jane Doe" ~url:"https://example.com" () in
let s = Format.asprintf "%a" Author.pp author in
-
Alcotest.(check string) "pp with name and url" "Jane Doe <https://example.com>" s
-
let author_tests = [
-
"create with name", `Quick, test_author_create_with_name;
-
"create with url", `Quick, test_author_create_with_url;
-
"create with all fields", `Quick, test_author_create_with_all_fields;
-
"create with no fields fails", `Quick, test_author_create_no_fields_fails;
-
"equal", `Quick, test_author_equal;
-
"pp", `Quick, test_author_pp;
-
]
(* Attachment tests *)
let test_attachment_create_minimal () =
-
let att = Attachment.create
-
~url:"https://example.com/file.mp3"
-
~mime_type:"audio/mpeg"
-
() in
-
Alcotest.(check string) "url" "https://example.com/file.mp3" (Attachment.url att);
Alcotest.(check string) "mime_type" "audio/mpeg" (Attachment.mime_type att);
Alcotest.(check (option string)) "title" None (Attachment.title att);
-
Alcotest.(check (option int64)) "size_in_bytes" None (Attachment.size_in_bytes att);
-
Alcotest.(check (option int)) "duration_in_seconds" None (Attachment.duration_in_seconds att)
let test_attachment_create_complete () =
-
let att = Attachment.create
-
~url:"https://example.com/episode.mp3"
-
~mime_type:"audio/mpeg"
-
~title:"Episode 1"
-
~size_in_bytes:15_728_640L
-
~duration_in_seconds:1800
-
() in
-
Alcotest.(check string) "url" "https://example.com/episode.mp3" (Attachment.url att);
Alcotest.(check string) "mime_type" "audio/mpeg" (Attachment.mime_type att);
-
Alcotest.(check (option string)) "title" (Some "Episode 1") (Attachment.title att);
-
Alcotest.(check (option int64)) "size_in_bytes" (Some 15_728_640L) (Attachment.size_in_bytes att);
-
Alcotest.(check (option int)) "duration_in_seconds" (Some 1800) (Attachment.duration_in_seconds att)
let test_attachment_equal () =
-
let a1 = Attachment.create
-
~url:"https://example.com/file.mp3"
-
~mime_type:"audio/mpeg"
-
() in
-
let a2 = Attachment.create
-
~url:"https://example.com/file.mp3"
-
~mime_type:"audio/mpeg"
-
() in
-
let a3 = Attachment.create
-
~url:"https://example.com/other.mp3"
-
~mime_type:"audio/mpeg"
-
() in
Alcotest.(check bool) "equal same" true (Attachment.equal a1 a2);
Alcotest.(check bool) "equal different" false (Attachment.equal a1 a3)
-
let attachment_tests = [
-
"create minimal", `Quick, test_attachment_create_minimal;
-
"create complete", `Quick, test_attachment_create_complete;
-
"equal", `Quick, test_attachment_equal;
-
]
(* Hub tests *)
···
Alcotest.(check bool) "equal same" true (Hub.equal h1 h2);
Alcotest.(check bool) "equal different" false (Hub.equal h1 h3)
-
let hub_tests = [
-
"create", `Quick, test_hub_create;
-
"equal", `Quick, test_hub_equal;
-
]
(* Item tests *)
let test_item_create_html () =
-
let item = Item.create
-
~id:"https://example.com/1"
-
~content:(`Html "<p>Hello</p>")
-
() in
Alcotest.(check string) "id" "https://example.com/1" (Item.id item);
-
Alcotest.(check (option string)) "content_html" (Some "<p>Hello</p>") (Item.content_html item);
Alcotest.(check (option string)) "content_text" None (Item.content_text item)
let test_item_create_text () =
-
let item = Item.create
-
~id:"https://example.com/2"
-
~content:(`Text "Hello world")
-
() in
Alcotest.(check string) "id" "https://example.com/2" (Item.id item);
Alcotest.(check (option string)) "content_html" None (Item.content_html item);
-
Alcotest.(check (option string)) "content_text" (Some "Hello world") (Item.content_text item)
let test_item_create_both () =
-
let item = Item.create
-
~id:"https://example.com/3"
-
~content:(`Both ("<p>Hello</p>", "Hello"))
-
() in
Alcotest.(check string) "id" "https://example.com/3" (Item.id item);
-
Alcotest.(check (option string)) "content_html" (Some "<p>Hello</p>") (Item.content_html item);
-
Alcotest.(check (option string)) "content_text" (Some "Hello") (Item.content_text item)
let test_item_with_metadata () =
-
let item = Item.create
-
~id:"https://example.com/4"
-
~content:(`Html "<p>Test</p>")
-
~title:"Test Post"
-
~url:"https://example.com/posts/4"
-
~tags:["test"; "example"]
-
() in
Alcotest.(check (option string)) "title" (Some "Test Post") (Item.title item);
-
Alcotest.(check (option string)) "url" (Some "https://example.com/posts/4") (Item.url item);
-
Alcotest.(check (option (list string))) "tags" (Some ["test"; "example"]) (Item.tags item)
let test_item_equal () =
let i1 = Item.create ~id:"https://example.com/1" ~content:(`Text "test") () in
-
let i2 = Item.create ~id:"https://example.com/1" ~content:(`Html "<p>test</p>") () in
let i3 = Item.create ~id:"https://example.com/2" ~content:(`Text "test") () in
Alcotest.(check bool) "equal same id" true (Item.equal i1 i2);
Alcotest.(check bool) "equal different id" false (Item.equal i1 i3)
-
let item_tests = [
-
"create with HTML content", `Quick, test_item_create_html;
-
"create with text content", `Quick, test_item_create_text;
-
"create with both contents", `Quick, test_item_create_both;
-
"create with metadata", `Quick, test_item_with_metadata;
-
"equal", `Quick, test_item_equal;
-
]
(* Jsonfeed tests *)
let test_feed_create_minimal () =
let feed = Jsonfeed.create ~title:"Test Feed" ~items:[] () in
Alcotest.(check string) "title" "Test Feed" (Jsonfeed.title feed);
-
Alcotest.(check string) "version" "https://jsonfeed.org/version/1.1" (Jsonfeed.version feed);
Alcotest.(check int) "items length" 0 (List.length (Jsonfeed.items feed))
let test_feed_create_with_items () =
-
let item = Item.create
-
~id:"https://example.com/1"
-
~content:(`Text "Hello")
-
() in
-
let feed = Jsonfeed.create
-
~title:"Test Feed"
-
~items:[item]
-
() in
Alcotest.(check int) "items length" 1 (List.length (Jsonfeed.items feed))
let test_feed_validate_valid () =
···
match Jsonfeed.validate feed with
| Ok () -> ()
| Error errors ->
-
Alcotest.fail (Printf.sprintf "Validation should succeed: %s"
-
(String.concat "; " errors))
let test_feed_validate_empty_title () =
let feed = Jsonfeed.create ~title:"" ~items:[] () in
match Jsonfeed.validate feed with
| Ok () -> Alcotest.fail "Should fail validation"
| Error errors ->
-
Alcotest.(check bool) "has error" true
(List.exists (fun s -> String.starts_with ~prefix:"title" s) errors)
let contains_substring s sub =
···
let test_feed_to_string () =
let feed = Jsonfeed.create ~title:"Test Feed" ~items:[] () in
-
let json = Jsonfeed.to_string feed in
-
Alcotest.(check bool) "contains version" true (contains_substring json "version");
-
Alcotest.(check bool) "contains title" true (contains_substring json "Test Feed")
let test_feed_parse_minimal () =
-
let json = {|{
"version": "https://jsonfeed.org/version/1.1",
"title": "Test Feed",
"items": []
-
}|} in
match Jsonfeed.of_string json with
| Ok feed ->
Alcotest.(check string) "title" "Test Feed" (Jsonfeed.title feed);
Alcotest.(check int) "items" 0 (List.length (Jsonfeed.items feed))
-
| Error (`Msg err) ->
-
Alcotest.fail (Printf.sprintf "Parse failed: %s" err)
let test_feed_parse_with_item () =
-
let json = {|{
"version": "https://jsonfeed.org/version/1.1",
"title": "Test Feed",
"items": [
···
"content_html": "<p>Hello</p>"
}
]
-
}|} in
match Jsonfeed.of_string json with
-
| Ok feed ->
let items = Jsonfeed.items feed in
Alcotest.(check int) "items count" 1 (List.length items);
-
(match items with
-
| [item] ->
-
Alcotest.(check string) "item id" "https://example.com/1" (Item.id item);
-
Alcotest.(check (option string)) "content_html" (Some "<p>Hello</p>") (Item.content_html item)
-
| _ -> Alcotest.fail "Expected 1 item")
-
| Error (`Msg err) ->
-
Alcotest.fail (Printf.sprintf "Parse failed: %s" err)
let test_feed_roundtrip () =
let author = Author.create ~name:"Test Author" () in
-
let item = Item.create
-
~id:"https://example.com/1"
-
~title:"Test Item"
-
~content:(`Html "<p>Hello, world!</p>")
-
~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T10:00:00Z" |> Option.get)
-
~tags:["test"; "example"]
-
() in
-
let feed1 = Jsonfeed.create
-
~title:"Test Feed"
-
~home_page_url:"https://example.com"
-
~authors:[author]
-
~items:[item]
-
() in
(* Serialize and parse *)
-
let json = Jsonfeed.to_string feed1 in
-
match Jsonfeed.of_string json with
-
| Ok feed2 ->
-
Alcotest.(check string) "title" (Jsonfeed.title feed1) (Jsonfeed.title feed2);
-
Alcotest.(check (option string)) "home_page_url"
-
(Jsonfeed.home_page_url feed1) (Jsonfeed.home_page_url feed2);
-
Alcotest.(check int) "items count"
-
(List.length (Jsonfeed.items feed1))
-
(List.length (Jsonfeed.items feed2))
-
| Error (`Msg err) ->
-
Alcotest.fail (Printf.sprintf "Round-trip failed: %s" err)
let test_feed_parse_invalid_missing_content () =
-
let json = {|{
"version": "https://jsonfeed.org/version/1.1",
"title": "Test",
"items": [
···
"id": "1"
}
]
-
}|} in
match Jsonfeed.of_string json with
| Ok _ -> Alcotest.fail "Should reject item without content"
-
| Error (`Msg err) ->
-
Alcotest.(check bool) "has error" true
-
(contains_substring err "content")
-
let jsonfeed_tests = [
-
"create minimal feed", `Quick, test_feed_create_minimal;
-
"create feed with items", `Quick, test_feed_create_with_items;
-
"validate valid feed", `Quick, test_feed_validate_valid;
-
"validate empty title", `Quick, test_feed_validate_empty_title;
-
"to_string", `Quick, test_feed_to_string;
-
"parse minimal feed", `Quick, test_feed_parse_minimal;
-
"parse feed with item", `Quick, test_feed_parse_with_item;
-
"round-trip", `Quick, test_feed_roundtrip;
-
"parse invalid missing content", `Quick, test_feed_parse_invalid_missing_content;
-
]
(* Main test suite *)
let () =
-
Alcotest.run "jsonfeed" [
-
"Author", author_tests;
-
"Attachment", attachment_tests;
-
"Hub", hub_tests;
-
"Item", item_tests;
-
"Jsonfeed", jsonfeed_tests;
-
]
···
let test_author_create_with_url () =
let author = Author.create ~url:"https://example.com" () in
Alcotest.(check (option string)) "name" None (Author.name author);
+
Alcotest.(check (option string))
+
"url" (Some "https://example.com") (Author.url author);
Alcotest.(check bool) "is_valid" true (Author.is_valid author)
let test_author_create_with_all_fields () =
+
let author =
+
Author.create ~name:"Jane Doe" ~url:"https://example.com"
+
~avatar:"https://example.com/avatar.png" ()
+
in
Alcotest.(check (option string)) "name" (Some "Jane Doe") (Author.name author);
+
Alcotest.(check (option string))
+
"url" (Some "https://example.com") (Author.url author);
+
Alcotest.(check (option string))
+
"avatar" (Some "https://example.com/avatar.png") (Author.avatar author);
Alcotest.(check bool) "is_valid" true (Author.is_valid author)
let test_author_create_no_fields_fails () =
Alcotest.check_raises "no fields"
+
(Invalid_argument
+
"Author.create: at least one field (name, url, or avatar) must be \
+
provided") (fun () -> ignore (Author.create ()))
let test_author_equal () =
let a1 = Author.create ~name:"Jane Doe" () in
···
let test_author_pp () =
let author = Author.create ~name:"Jane Doe" ~url:"https://example.com" () in
let s = Format.asprintf "%a" Author.pp author in
+
Alcotest.(check string)
+
"pp with name and url" "Jane Doe <https://example.com>" s
+
let author_tests =
+
[
+
("create with name", `Quick, test_author_create_with_name);
+
("create with url", `Quick, test_author_create_with_url);
+
("create with all fields", `Quick, test_author_create_with_all_fields);
+
("create with no fields fails", `Quick, test_author_create_no_fields_fails);
+
("equal", `Quick, test_author_equal);
+
("pp", `Quick, test_author_pp);
+
]
(* Attachment tests *)
let test_attachment_create_minimal () =
+
let att =
+
Attachment.create ~url:"https://example.com/file.mp3"
+
~mime_type:"audio/mpeg" ()
+
in
+
Alcotest.(check string)
+
"url" "https://example.com/file.mp3" (Attachment.url att);
Alcotest.(check string) "mime_type" "audio/mpeg" (Attachment.mime_type att);
Alcotest.(check (option string)) "title" None (Attachment.title att);
+
Alcotest.(check (option int64))
+
"size_in_bytes" None
+
(Attachment.size_in_bytes att);
+
Alcotest.(check (option int))
+
"duration_in_seconds" None
+
(Attachment.duration_in_seconds att)
let test_attachment_create_complete () =
+
let att =
+
Attachment.create ~url:"https://example.com/episode.mp3"
+
~mime_type:"audio/mpeg" ~title:"Episode 1" ~size_in_bytes:15_728_640L
+
~duration_in_seconds:1800 ()
+
in
+
Alcotest.(check string)
+
"url" "https://example.com/episode.mp3" (Attachment.url att);
Alcotest.(check string) "mime_type" "audio/mpeg" (Attachment.mime_type att);
+
Alcotest.(check (option string))
+
"title" (Some "Episode 1") (Attachment.title att);
+
Alcotest.(check (option int64))
+
"size_in_bytes" (Some 15_728_640L)
+
(Attachment.size_in_bytes att);
+
Alcotest.(check (option int))
+
"duration_in_seconds" (Some 1800)
+
(Attachment.duration_in_seconds att)
let test_attachment_equal () =
+
let a1 =
+
Attachment.create ~url:"https://example.com/file.mp3"
+
~mime_type:"audio/mpeg" ()
+
in
+
let a2 =
+
Attachment.create ~url:"https://example.com/file.mp3"
+
~mime_type:"audio/mpeg" ()
+
in
+
let a3 =
+
Attachment.create ~url:"https://example.com/other.mp3"
+
~mime_type:"audio/mpeg" ()
+
in
Alcotest.(check bool) "equal same" true (Attachment.equal a1 a2);
Alcotest.(check bool) "equal different" false (Attachment.equal a1 a3)
+
let attachment_tests =
+
[
+
("create minimal", `Quick, test_attachment_create_minimal);
+
("create complete", `Quick, test_attachment_create_complete);
+
("equal", `Quick, test_attachment_equal);
+
]
(* Hub tests *)
···
Alcotest.(check bool) "equal same" true (Hub.equal h1 h2);
Alcotest.(check bool) "equal different" false (Hub.equal h1 h3)
+
let hub_tests =
+
[ ("create", `Quick, test_hub_create); ("equal", `Quick, test_hub_equal) ]
(* Item tests *)
let test_item_create_html () =
+
let item =
+
Item.create ~id:"https://example.com/1" ~content:(`Html "<p>Hello</p>") ()
+
in
Alcotest.(check string) "id" "https://example.com/1" (Item.id item);
+
Alcotest.(check (option string))
+
"content_html" (Some "<p>Hello</p>") (Item.content_html item);
Alcotest.(check (option string)) "content_text" None (Item.content_text item)
let test_item_create_text () =
+
let item =
+
Item.create ~id:"https://example.com/2" ~content:(`Text "Hello world") ()
+
in
Alcotest.(check string) "id" "https://example.com/2" (Item.id item);
Alcotest.(check (option string)) "content_html" None (Item.content_html item);
+
Alcotest.(check (option string))
+
"content_text" (Some "Hello world") (Item.content_text item)
let test_item_create_both () =
+
let item =
+
Item.create ~id:"https://example.com/3"
+
~content:(`Both ("<p>Hello</p>", "Hello"))
+
()
+
in
Alcotest.(check string) "id" "https://example.com/3" (Item.id item);
+
Alcotest.(check (option string))
+
"content_html" (Some "<p>Hello</p>") (Item.content_html item);
+
Alcotest.(check (option string))
+
"content_text" (Some "Hello") (Item.content_text item)
let test_item_with_metadata () =
+
let item =
+
Item.create ~id:"https://example.com/4" ~content:(`Html "<p>Test</p>")
+
~title:"Test Post" ~url:"https://example.com/posts/4"
+
~tags:[ "test"; "example" ] ()
+
in
Alcotest.(check (option string)) "title" (Some "Test Post") (Item.title item);
+
Alcotest.(check (option string))
+
"url" (Some "https://example.com/posts/4") (Item.url item);
+
Alcotest.(check (option (list string)))
+
"tags"
+
(Some [ "test"; "example" ])
+
(Item.tags item)
let test_item_equal () =
let i1 = Item.create ~id:"https://example.com/1" ~content:(`Text "test") () in
+
let i2 =
+
Item.create ~id:"https://example.com/1" ~content:(`Html "<p>test</p>") ()
+
in
let i3 = Item.create ~id:"https://example.com/2" ~content:(`Text "test") () in
Alcotest.(check bool) "equal same id" true (Item.equal i1 i2);
Alcotest.(check bool) "equal different id" false (Item.equal i1 i3)
+
let item_tests =
+
[
+
("create with HTML content", `Quick, test_item_create_html);
+
("create with text content", `Quick, test_item_create_text);
+
("create with both contents", `Quick, test_item_create_both);
+
("create with metadata", `Quick, test_item_with_metadata);
+
("equal", `Quick, test_item_equal);
+
]
(* Jsonfeed tests *)
let test_feed_create_minimal () =
let feed = Jsonfeed.create ~title:"Test Feed" ~items:[] () in
Alcotest.(check string) "title" "Test Feed" (Jsonfeed.title feed);
+
Alcotest.(check string)
+
"version" "https://jsonfeed.org/version/1.1" (Jsonfeed.version feed);
Alcotest.(check int) "items length" 0 (List.length (Jsonfeed.items feed))
let test_feed_create_with_items () =
+
let item =
+
Item.create ~id:"https://example.com/1" ~content:(`Text "Hello") ()
+
in
+
let feed = Jsonfeed.create ~title:"Test Feed" ~items:[ item ] () in
Alcotest.(check int) "items length" 1 (List.length (Jsonfeed.items feed))
let test_feed_validate_valid () =
···
match Jsonfeed.validate feed with
| Ok () -> ()
| Error errors ->
+
Alcotest.fail
+
(Printf.sprintf "Validation should succeed: %s"
+
(String.concat "; " errors))
let test_feed_validate_empty_title () =
let feed = Jsonfeed.create ~title:"" ~items:[] () in
match Jsonfeed.validate feed with
| Ok () -> Alcotest.fail "Should fail validation"
| Error errors ->
+
Alcotest.(check bool)
+
"has error" true
(List.exists (fun s -> String.starts_with ~prefix:"title" s) errors)
let contains_substring s sub =
···
let test_feed_to_string () =
let feed = Jsonfeed.create ~title:"Test Feed" ~items:[] () in
+
match Jsonfeed.to_string feed with
+
| Ok json ->
+
Alcotest.(check bool)
+
"contains version" true
+
(contains_substring json "version");
+
Alcotest.(check bool)
+
"contains title" true
+
(contains_substring json "Test Feed")
+
| Error e ->
+
Alcotest.fail
+
(Printf.sprintf "Serialization failed: %s" (Jsont.Error.to_string e))
let test_feed_parse_minimal () =
+
let json =
+
{|{
"version": "https://jsonfeed.org/version/1.1",
"title": "Test Feed",
"items": []
+
}|}
+
in
match Jsonfeed.of_string json with
| Ok feed ->
Alcotest.(check string) "title" "Test Feed" (Jsonfeed.title feed);
Alcotest.(check int) "items" 0 (List.length (Jsonfeed.items feed))
+
| Error err ->
+
Alcotest.fail
+
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string err))
let test_feed_parse_with_item () =
+
let json =
+
{|{
"version": "https://jsonfeed.org/version/1.1",
"title": "Test Feed",
"items": [
···
"content_html": "<p>Hello</p>"
}
]
+
}|}
+
in
match Jsonfeed.of_string json with
+
| Ok feed -> (
let items = Jsonfeed.items feed in
Alcotest.(check int) "items count" 1 (List.length items);
+
match items with
+
| [ item ] ->
+
Alcotest.(check string)
+
"item id" "https://example.com/1" (Item.id item);
+
Alcotest.(check (option string))
+
"content_html" (Some "<p>Hello</p>") (Item.content_html item)
+
| _ -> Alcotest.fail "Expected 1 item")
+
| Error err ->
+
Alcotest.fail
+
(Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string err))
let test_feed_roundtrip () =
let author = Author.create ~name:"Test Author" () in
+
let item =
+
Item.create ~id:"https://example.com/1" ~title:"Test Item"
+
~content:(`Html "<p>Hello, world!</p>")
+
~date_published:
+
(Jsonfeed.Rfc3339.parse "2024-11-01T10:00:00Z" |> Option.get)
+
~tags:[ "test"; "example" ] ()
+
in
+
let feed1 =
+
Jsonfeed.create ~title:"Test Feed" ~home_page_url:"https://example.com"
+
~authors:[ author ] ~items:[ item ] ()
+
in
(* Serialize and parse *)
+
match Jsonfeed.to_string feed1 with
+
| Error e ->
+
Alcotest.fail
+
(Printf.sprintf "Serialization failed: %s" (Jsont.Error.to_string e))
+
| Ok json -> (
+
match Jsonfeed.of_string json with
+
| Ok feed2 ->
+
Alcotest.(check string)
+
"title" (Jsonfeed.title feed1) (Jsonfeed.title feed2);
+
Alcotest.(check (option string))
+
"home_page_url"
+
(Jsonfeed.home_page_url feed1)
+
(Jsonfeed.home_page_url feed2);
+
Alcotest.(check int)
+
"items count"
+
(List.length (Jsonfeed.items feed1))
+
(List.length (Jsonfeed.items feed2))
+
| Error err ->
+
Alcotest.fail
+
(Printf.sprintf "Round-trip parse failed: %s"
+
(Jsont.Error.to_string err)))
let test_feed_parse_invalid_missing_content () =
+
let json =
+
{|{
"version": "https://jsonfeed.org/version/1.1",
"title": "Test",
"items": [
···
"id": "1"
}
]
+
}|}
+
in
match Jsonfeed.of_string json with
| Ok _ -> Alcotest.fail "Should reject item without content"
+
| Error err ->
+
let err_str = Jsont.Error.to_string err in
+
Alcotest.(check bool)
+
"has error" true
+
(contains_substring err_str "content")
+
let jsonfeed_tests =
+
[
+
("create minimal feed", `Quick, test_feed_create_minimal);
+
("create feed with items", `Quick, test_feed_create_with_items);
+
("validate valid feed", `Quick, test_feed_validate_valid);
+
("validate empty title", `Quick, test_feed_validate_empty_title);
+
("to_string", `Quick, test_feed_to_string);
+
("parse minimal feed", `Quick, test_feed_parse_minimal);
+
("parse feed with item", `Quick, test_feed_parse_with_item);
+
("round-trip", `Quick, test_feed_roundtrip);
+
( "parse invalid missing content",
+
`Quick,
+
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 () =
+
Alcotest.run "jsonfeed"
+
[
+
("Author", author_tests);
+
("Attachment", attachment_tests);
+
("Hub", hub_tests);
+
("Item", item_tests);
+
("Jsonfeed", jsonfeed_tests);
+
("Unknown Fields", unknown_fields_tests);
+
]
+111
test/test_location_errors.ml
···
···
+
(** Test executable for verifying jsont location tracking
+
+
Usage: test_location_errors <file> [field]
+
+
Parses JSON feed files and outputs JSON with either:
+
- Success: {"status":"ok", "field":"<field>", "value":"<value>"}
+
- Error: {"status":"error", "message":"...", "location":{...}, "context":"..."}
+
*)
+
+
open Jsonfeed
+
+
(* Helper to format path context *)
+
let format_context (ctx : Jsont.Error.Context.t) =
+
if Jsont.Error.Context.is_empty ctx then "$"
+
else
+
let indices = ctx in
+
let rec format_path acc = function
+
| [] -> if acc = "" then "$" else "$" ^ acc
+
| ((_kinded_sort, _meta), idx) :: rest ->
+
let segment =
+
match idx with
+
| Jsont.Path.Mem (name, _meta) -> "." ^ name
+
| Jsont.Path.Nth (n, _meta) -> "[" ^ string_of_int n ^ "]"
+
in
+
format_path (acc ^ segment) rest
+
in
+
format_path "" indices
+
+
(* Extract field from successfully parsed feed *)
+
let extract_field field feed =
+
match field with
+
| "title" -> Jsonfeed.title feed
+
| "version" -> Jsonfeed.version feed
+
| "item_count" -> string_of_int (List.length (Jsonfeed.items feed))
+
| "first_item_id" -> (
+
match Jsonfeed.items feed with
+
| [] -> "(no items)"
+
| item :: _ -> Item.id item)
+
| _ -> "(unknown field)"
+
+
(* Escape JSON strings *)
+
let escape_json_string s =
+
let buf = Buffer.create (String.length s) in
+
String.iter
+
(function
+
| '"' -> Buffer.add_string buf "\\\""
+
| '\\' -> Buffer.add_string buf "\\\\"
+
| '\n' -> Buffer.add_string buf "\\n"
+
| '\r' -> Buffer.add_string buf "\\r"
+
| '\t' -> Buffer.add_string buf "\\t"
+
| c when c < ' ' -> Printf.bprintf buf "\\u%04x" (Char.code c)
+
| c -> Buffer.add_char buf c)
+
s;
+
Buffer.contents buf
+
+
(* Output success as JSON *)
+
let output_success field value =
+
Printf.printf {|{"status":"ok","field":"%s","value":"%s"}|}
+
(escape_json_string field) (escape_json_string value);
+
print_newline ()
+
+
(* Output error as JSON *)
+
let output_error (ctx, meta, kind) =
+
let message = Jsont.Error.kind_to_string kind in
+
let textloc = Jsont.Meta.textloc meta in
+
let file = Jsont.Textloc.file textloc in
+
let first_byte = Jsont.Textloc.first_byte textloc in
+
let last_byte = Jsont.Textloc.last_byte textloc in
+
let line_num, line_start_byte = Jsont.Textloc.first_line textloc in
+
let column = first_byte - line_start_byte + 1 in
+
let context = format_context ctx in
+
+
Printf.printf
+
{|{"status":"error","message":"%s","location":{"file":"%s","line":%d,"column":%d,"byte_start":%d,"byte_end":%d},"context":"%s"}|}
+
(escape_json_string message)
+
(escape_json_string file) line_num column first_byte last_byte
+
(escape_json_string context);
+
print_newline ()
+
+
let main () =
+
(* Disable ANSI styling in error messages for consistent output *)
+
Jsont.Error.disable_ansi_styler ();
+
+
if Array.length Sys.argv < 2 then (
+
Printf.eprintf "Usage: %s <file> [field]\n" Sys.argv.(0);
+
Printf.eprintf "Fields: title, version, item_count, first_item_id\n";
+
exit 1);
+
+
let file = Sys.argv.(1) in
+
let field = if Array.length Sys.argv > 2 then Sys.argv.(2) else "title" in
+
+
(* Read file *)
+
let content =
+
try In_channel.with_open_text file In_channel.input_all
+
with Sys_error msg ->
+
Printf.printf {|{"status":"error","message":"File error: %s"}|}
+
(escape_json_string msg);
+
print_newline ();
+
exit 1
+
in
+
+
(* Parse with location tracking *)
+
match Jsonfeed.decode_string ~locs:true ~file content with
+
| Ok feed ->
+
let value = extract_field field feed in
+
output_success field value
+
| Error err ->
+
output_error err;
+
exit 1
+
+
let () = main ()
+127
test/test_locations.t
···
···
+
Location tracking tests for JSON Feed parser
+
===========================================
+
+
This test suite verifies that jsont combinators correctly track location
+
information for both valid and invalid JSON feeds.
+
+
Valid Feeds
+
-----------
+
+
Test minimal valid feed:
+
$ ./test_location_errors.exe data/minimal_valid.json title
+
{"status":"ok","field":"title","value":"Minimal Feed"}
+
+
$ ./test_location_errors.exe data/minimal_valid.json version
+
{"status":"ok","field":"version","value":"https://jsonfeed.org/version/1.1"}
+
+
$ ./test_location_errors.exe data/minimal_valid.json item_count
+
{"status":"ok","field":"item_count","value":"0"}
+
+
Test complete feed with all fields:
+
$ ./test_location_errors.exe data/complete_valid.json title
+
{"status":"ok","field":"title","value":"Complete Feed"}
+
+
$ ./test_location_errors.exe data/complete_valid.json item_count
+
{"status":"ok","field":"item_count","value":"1"}
+
+
$ ./test_location_errors.exe data/complete_valid.json first_item_id
+
{"status":"ok","field":"first_item_id","value":"https://example.com/item1"}
+
+
Test mixed content types:
+
$ ./test_location_errors.exe data/mixed_content.json item_count
+
{"status":"ok","field":"item_count","value":"3"}
+
+
Test feed with extensions:
+
$ ./test_location_errors.exe data/with_extensions.json title
+
{"status":"ok","field":"title","value":"Feed with Extensions"}
+
+
+
Missing Required Fields
+
------------------------
+
+
Test missing title field:
+
$ ./test_location_errors.exe data/missing_title.json title
+
{"status":"error","message":"Missing member title in JSON Feed object","location":{"file":"data/missing_title.json","line":1,"column":1,"byte_start":0,"byte_end":65},"context":"$"}
+
[1]
+
+
Test missing version field:
+
$ ./test_location_errors.exe data/missing_version.json title
+
{"status":"error","message":"Missing member version in JSON Feed object","location":{"file":"data/missing_version.json","line":1,"column":1,"byte_start":0,"byte_end":51},"context":"$"}
+
[1]
+
+
Test missing items field:
+
$ ./test_location_errors.exe data/missing_items.json title
+
{"status":"error","message":"Missing member items in JSON Feed object","location":{"file":"data/missing_items.json","line":1,"column":1,"byte_start":0,"byte_end":83},"context":"$"}
+
[1]
+
+
Test missing item id:
+
$ ./test_location_errors.exe data/missing_item_id.json first_item_id
+
{"status":"error","message":"Missing member id in Item object","location":{"file":"data/missing_item_id.json","line":5,"column":5,"byte_start":108,"byte_end":161},"context":"$.items[0]"}
+
[1]
+
+
Test missing item content:
+
$ ./test_location_errors.exe data/missing_item_content.json first_item_id
+
{"status":"error","message":"Item must have at least one of content_html or content_text","location":{"file":"-","line":-1,"column":1,"byte_start":-1,"byte_end":-1},"context":"$.items[0]"}
+
[1]
+
+
+
Type Errors
+
-----------
+
+
Test wrong type for version (number instead of string):
+
$ ./test_location_errors.exe data/wrong_type_version.json title
+
{"status":"error","message":"Expected string but found number","location":{"file":"data/wrong_type_version.json","line":2,"column":14,"byte_start":15,"byte_end":15},"context":"$.version"}
+
[1]
+
+
Test wrong type for items (object instead of array):
+
$ ./test_location_errors.exe data/wrong_type_items.json item_count
+
{"status":"error","message":"Expected array<Item object> but found object","location":{"file":"data/wrong_type_items.json","line":4,"column":12,"byte_start":102,"byte_end":102},"context":"$.items"}
+
[1]
+
+
Test wrong type for title (boolean instead of string):
+
$ ./test_location_errors.exe data/wrong_type_title.json title
+
{"status":"error","message":"Expected string but found bool","location":{"file":"data/wrong_type_title.json","line":3,"column":12,"byte_start":62,"byte_end":62},"context":"$.title"}
+
[1]
+
+
Test wrong type for expired (string instead of boolean):
+
$ ./test_location_errors.exe data/wrong_type_expired.json title
+
{"status":"error","message":"Expected bool but found string","location":{"file":"data/wrong_type_expired.json","line":4,"column":14,"byte_start":111,"byte_end":111},"context":"$.expired"}
+
[1]
+
+
+
Nested Errors
+
-------------
+
+
Test invalid date format in item:
+
$ ./test_location_errors.exe data/invalid_date_format.json first_item_id
+
{"status":"error","message":"RFC 3339 timestamp: invalid RFC 3339 timestamp: \"not-a-valid-date\"","location":{"file":"-","line":-1,"column":1,"byte_start":-1,"byte_end":-1},"context":"$.items[0].date_published"}
+
[1]
+
+
Test invalid author type (string instead of object):
+
$ ./test_location_errors.exe data/invalid_author_type.json title
+
{"status":"error","message":"Expected Author object but found string","location":{"file":"data/invalid_author_type.json","line":5,"column":5,"byte_start":109,"byte_end":109},"context":"$.authors[0]"}
+
[1]
+
+
Test invalid attachment field type (deeply nested):
+
$ ./test_location_errors.exe data/invalid_nested_attachment.json first_item_id
+
{"status":"error","message":"Expected string but found number","location":{"file":"data/invalid_nested_attachment.json","line":11,"column":24,"byte_start":296,"byte_end":296},"context":"$.items[0].attachments[0].mime_type"}
+
[1]
+
+
Test missing required field in hub:
+
$ ./test_location_errors.exe data/invalid_hub_type.json title
+
{"status":"error","message":"Missing member url in Hub object","location":{"file":"data/invalid_hub_type.json","line":5,"column":5,"byte_start":103,"byte_end":132},"context":"$.hubs[0]"}
+
[1]
+
+
+
JSON Syntax Errors
+
------------------
+
+
Test trailing comma:
+
$ ./test_location_errors.exe data/extra_comma.json title
+
{"status":"error","message":"Expected object member but found }","location":{"file":"data/extra_comma.json","line":5,"column":1,"byte_start":105,"byte_end":105},"context":"$"}
+
[1]
+
+
Test malformed JSON (missing comma):
+
$ ./test_location_errors.exe data/malformed_json.json title
+
{"status":"error","message":"Expected , or } after object member but found: \"","location":{"file":"data/malformed_json.json","line":3,"column":3,"byte_start":52,"byte_end":52},"context":"$"}
+
[1]
+13 -12
test/test_serialization.ml
···
let () =
(* Create a simple feed *)
let author = Author.create ~name:"Test Author" () in
-
let item = Item.create
-
~id:"https://example.com/1"
-
~title:"Test Item"
-
~content:(`Html "<p>Hello, world!</p>")
-
() in
-
let feed = Jsonfeed.create
-
~title:"Test Feed"
-
~home_page_url:"https://example.com"
-
~authors:[author]
-
~items:[item]
-
() in
(* Serialize to JSON *)
-
let json = Jsonfeed.to_string feed in
(* Print it *)
Printf.printf "Generated JSON Feed:\n%s\n\n" json;
···
let () =
(* Create a simple feed *)
let author = Author.create ~name:"Test Author" () in
+
let item =
+
Item.create ~id:"https://example.com/1" ~title:"Test Item"
+
~content:(`Html "<p>Hello, world!</p>") ()
+
in
+
let feed =
+
Jsonfeed.create ~title:"Test Feed" ~home_page_url:"https://example.com"
+
~authors:[ author ] ~items:[ item ] ()
+
in
(* Serialize to JSON *)
+
let json =
+
match Jsonfeed.to_string feed with
+
| Ok s -> s
+
| Error e -> failwith (Jsont.Error.to_string e)
+
in
(* Print it *)
Printf.printf "Generated JSON Feed:\n%s\n\n" json;