OCaml library for JSONfeed parsing and creation

Add test cases with mix of valid broken and valid jsonfeeds

+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": []
+
}
+9
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)))
+116
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]