From 631e667fe5cfeb9d39bb346b3f5d14bf1c592ff6 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Thu, 9 Jul 2026 20:39:59 +0200 Subject: [PATCH] Document a duplicate-object-key rejection recipe (#5259) * :memo: Document a duplicate-object-key rejection recipe RFC 8259 leaves handling of duplicate object keys to the implementation; this library silently keeps only the last value for a repeated key. Discussion #5085 asked for an opt-in rejection mode. Decision: don't change library behavior, but document the existing parser-callback workaround instead. Adds a "Recipe: rejecting duplicate object keys" section to parser_callbacks.md, adapted from a community-contributed workaround. Fixed an off-by-one bug in the original snippet: object_start reports the depth of the object's parent, while key events inside that object report depth+1, so indexing the per-depth key set with the same depth in both places caused an out-of-bounds access on nested objects. Verified the published snippet compiles and behaves correctly for flat duplicates, nested duplicates, sibling objects sharing key names, and arrays of objects. Signed-off-by: Niels Lohmann * Cross-link the duplicate-key recipe with the existing object_t behavior docs object_t.md and features/types/index.md already document that duplicate object keys resolve to an unspecified value (RFC 8259 leaves this to the implementation). The new recipe's intro overstated this as a guaranteed "last value wins" rule, which isn't true in general -- parsing text keeps the last value, but constructing from an initializer list keeps the first. Reworded the recipe to point at object_t's "unspecified" behavior instead of asserting a specific rule, and added cross-links from both existing pages to the new recipe. Signed-off-by: Niels Lohmann * Turn the duplicate-key recipe into a standalone, compiled example Replace the inline code fence in the "rejecting duplicate object keys" recipe with a proper docs/mkdocs/docs/examples/*.cpp + .output pair, included via --8<-- like every other example on the site. The .output file was generated by running it through the project's actual example build (docs/Makefile: single_include, -std=c++11, -DJSON_USE_GLOBAL_UDLS=0) and cross-checked with `make check_output`, and the source passes the pinned astyle 3.4.13 formatting unchanged. Signed-off-by: Niels Lohmann --------- Signed-off-by: Niels Lohmann --- docs/mkdocs/docs/api/basic_json/object_t.md | 3 +- .../docs/examples/reject_duplicate_keys.cpp | 61 +++++++++++++++++++ .../examples/reject_duplicate_keys.output | 2 + .../docs/features/parsing/parser_callbacks.md | 31 ++++++++++ docs/mkdocs/docs/features/types/index.md | 2 +- 5 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 docs/mkdocs/docs/examples/reject_duplicate_keys.cpp create mode 100644 docs/mkdocs/docs/examples/reject_duplicate_keys.output diff --git a/docs/mkdocs/docs/api/basic_json/object_t.md b/docs/mkdocs/docs/api/basic_json/object_t.md index cf0ffc87b..de41b86e4 100644 --- a/docs/mkdocs/docs/api/basic_json/object_t.md +++ b/docs/mkdocs/docs/api/basic_json/object_t.md @@ -63,7 +63,8 @@ behavior: object will agree on the name-value mappings. - When the names within an object are not unique, it is unspecified which one of the values for a given key will be chosen. For instance, `#!json {"key": 2, "key": 1}` could be equal to either `#!json {"key": 1}` or - `#!json {"key": 2}`. + `#!json {"key": 2}`. To reject duplicate keys instead of silently resolving them one way or another, see + [this parsing recipe](../../features/parsing/parser_callbacks.md#recipe-rejecting-duplicate-object-keys). - Internally, name/value pairs are stored in lexicographical order of the names. Objects will also be serialized (see [`dump`](dump.md)) in this order. For instance, `#!json {"b": 1, "a": 2}` and `#!json {"a": 2, "b": 1}` will be stored and serialized as `#!json {"a": 2, "b": 1}`. diff --git a/docs/mkdocs/docs/examples/reject_duplicate_keys.cpp b/docs/mkdocs/docs/examples/reject_duplicate_keys.cpp new file mode 100644 index 000000000..0e5e78925 --- /dev/null +++ b/docs/mkdocs/docs/examples/reject_duplicate_keys.cpp @@ -0,0 +1,61 @@ +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +json parse_strict(const std::string& input) +{ + // one key set per nesting depth, reused across sibling objects + std::vector> keys; + + auto reject_duplicate_keys = [&](int depth, json::parse_event_t event, json & parsed) + { + if (event == json::parse_event_t::object_start) + { + // keys of this object are reported at depth+1 (see the event table above) + const auto child_depth = static_cast(depth) + 1; + if (keys.size() <= child_depth) + { + keys.resize(child_depth + 1); + } + keys[child_depth].clear(); + return true; + } + + if (event == json::parse_event_t::key) + { + auto& seen = keys[static_cast(depth)]; + const auto& key = parsed.get_ref(); + if (!seen.insert(key).second) + { + throw std::runtime_error("duplicate JSON object key: " + key); + } + return true; + } + + return true; + }; + + return json::parse(input, reject_duplicate_keys); +} + +int main() +{ + // parsing succeeds when all keys are unique + json j = parse_strict(R"({"one": 1, "two": 2})"); + std::cout << j << '\n'; + + // parsing throws when a key is repeated + try + { + parse_strict(R"({"one": 1, "one": 2})"); + } + catch (const std::exception& e) + { + std::cout << e.what() << '\n'; + } +} diff --git a/docs/mkdocs/docs/examples/reject_duplicate_keys.output b/docs/mkdocs/docs/examples/reject_duplicate_keys.output new file mode 100644 index 000000000..2366b4a43 --- /dev/null +++ b/docs/mkdocs/docs/examples/reject_duplicate_keys.output @@ -0,0 +1,2 @@ +{"one":1,"two":2} +duplicate JSON object key: one diff --git a/docs/mkdocs/docs/features/parsing/parser_callbacks.md b/docs/mkdocs/docs/features/parsing/parser_callbacks.md index ef076d126..d398be325 100644 --- a/docs/mkdocs/docs/features/parsing/parser_callbacks.md +++ b/docs/mkdocs/docs/features/parsing/parser_callbacks.md @@ -81,3 +81,34 @@ was called: ```json --8<-- "examples/parse__string__parser_callback_t.output" ``` + +## Recipe: rejecting duplicate object keys + +The JSON specification leaves the handling of objects with repeated keys up to the implementation. As described in +[`object_t`](../../api/basic_json/object_t.md#behavior), it is unspecified which value for a repeated key ends up in +the resulting `#!c json` value -- once parsing has produced that value, the duplicate is already gone, because object +storage maps each key to a single value. If duplicate keys should instead be treated as an error, a parser callback +can detect them while the object is still being read, before that ambiguity ever applies. + +??? example + + ```cpp + --8<-- "examples/reject_duplicate_keys.cpp" + ``` + + Output: + + ```json + --8<-- "examples/reject_duplicate_keys.output" + ``` + +This approach has two limitations: + +- The depth-indexed bookkeeping must account for the fact that `object_start` reports the depth of the *parent* of + the object, while the `key` events inside that object are reported one depth deeper (see the event table above); + it is easy to get this off by one for nested objects. +- The thrown exception cannot carry a `parse_error`-style byte offset, because position tracking only exists inside + the parser and lexer, not at the callback layer. + +For strict validation with precise error positions, implementing a [SAX interface](sax_interface.md) instead gives +access to the parser's position information directly. diff --git a/docs/mkdocs/docs/features/types/index.md b/docs/mkdocs/docs/features/types/index.md index 78e332936..f24da12a6 100644 --- a/docs/mkdocs/docs/features/types/index.md +++ b/docs/mkdocs/docs/features/types/index.md @@ -131,7 +131,7 @@ std::map< The choice of `object_t` influences the behavior of the JSON class. With the default type, objects have the following behavior: - When all names are unique, objects will be interoperable in the sense that all software implementations receiving that object will agree on the name-value mappings. -- When the names within an object are not unique, it is unspecified which one of the values for a given key will be chosen. For instance, `#!json {"key": 2, "key": 1}` could be equal to either `#!json {"key": 1}` or `#!json {"key": 2}`. +- When the names within an object are not unique, it is unspecified which one of the values for a given key will be chosen. For instance, `#!json {"key": 2, "key": 1}` could be equal to either `#!json {"key": 1}` or `#!json {"key": 2}`. To reject duplicate keys instead of silently resolving them one way or another, see [this parsing recipe](../parsing/parser_callbacks.md#recipe-rejecting-duplicate-object-keys). - Internally, name/value pairs are stored in lexicographical order of the names. Objects will also be serialized (see `dump`) in this order. For instance, both `#!json {"b": 1, "a": 2}` and `#!json {"a": 2, "b": 1}` will be stored and serialized as `#!json {"a": 2, "b": 1}`. - When comparing objects, the order of the name/value pairs is irrelevant. This makes objects interoperable in the sense that they will not be affected by these differences. For instance, `#!json {"b": 1, "a": 2}` and `#!json {"a": 2, "b": 1}` will be treated as equal.