Batu Lab NotesPractical developer guides

Validate an integer JSON field without accepting float

By Batu · English technical notes

Also published in our primary archive.

Validate an integer JSON field without accepting float

To validate an integer JSON field without accepting a float, decode it and require type(count) is int. A numeric check such as count >= 0 is too broad: JSON 3.0 decodes to float, while JSON true decodes to True; both comparisons succeed in this experiment.

The example first performs the ordinary decode so its exact resulting type is visible. It then uses object_pairs_hook to retain each JSON object as a distinguishable ordered-pairs value. That matters because the hook runs for every JSON object, including nested ones. Before inspecting members, the code requires the returned root value to be ObjectPairs; a root array remains an ordinary list and is rejected. It then validates only the returned root pairs and recursively materializes nested pair collections as dictionaries. Consequently, {"count":3,"meta":{}} is accepted: the empty nested meta object is not required to have a count member.

The root contract is deliberately narrow: exactly one root count member, whose exact type is int. The outer duplicate-count case is rejected before ordinary dictionary conversion could retain only its last value. The root-array case is rejected before any pair-shaped list can be mistaken for object members. This example does not reject duplicate keys other than count at the root, reject duplicate keys inside nested objects, enforce bounds, require a particular set of other root fields, or impose an input-size limit; add those rules when the data contract needs them.

Python’s JSON conversion table shows that JSON integer numbers decode as int, real numbers as float, and true as the value True; in Python, that value has type bool. The json documentation also states that object_pairs_hook receives every JSON object as ordered pairs in json — JSON encoder and decoder. Python’s numeric-type documentation notes that bool is a subtype of int, which is why an exact-type check is intentional here in Numeric Types — int, float, complex. object_pairs_hook requires Python 3.1 or later. AI assistance disclosure: this article was drafted with AI assistance using synthetic in-memory JSON text.

import json


class CountContractError(ValueError):
    pass


class ObjectPairs(list):
    pass


def keep_pairs(pairs):
    return ObjectPairs(pairs)


def materialize(value):
    if isinstance(value, ObjectPairs):
        return {name: materialize(item) for name, item in value}
    if isinstance(value, list):
        return [materialize(item) for item in value]
    return value


def validate_root_count(text):
    root = json.loads(text, object_pairs_hook=keep_pairs)
    if not isinstance(root, ObjectPairs):
        raise CountContractError("root must be an object")
    count_members = [value for name, value in root if name == "count"]
    if len(count_members) != 1:
        raise CountContractError("count must appear exactly once")

    count = count_members[0]
    if type(count) is not int:
        raise CountContractError("count must be an integer")
    return materialize(root)


def examine(text):
    default_root = json.loads(text)
    if isinstance(default_root, dict):
        default_count = default_root["count"]
        decoded = type(default_count).__name__
        numeric = str(default_count >= 0)
    else:
        decoded = type(default_root).__name__
        numeric = "not-run"
    try:
        validated = validate_root_count(text)
    except CountContractError as error:
        outcome = "rejected=" + str(error)
    else:
        outcome = "accepted=" + str(validated["count"])
    return (
        text
        + " -> decoded="
        + decoded
        + "; nonnegative="
        + numeric
        + "; "
        + outcome
    )


cases = [
    '{"count":3}',
    '{"count":3.0}',
    '{"count":true}',
    '{"count":3,"meta":{}}',
    '{"count":3,"count":3.0}',
    '[["count",3]]',
]
results = [examine(text) for text in cases]

assert results == [
    '{"count":3} -> decoded=int; nonnegative=True; accepted=3',
    '{"count":3.0} -> decoded=float; nonnegative=True; rejected=count must be an integer',
    '{"count":true} -> decoded=bool; nonnegative=True; rejected=count must be an integer',
    '{"count":3,"meta":{}} -> decoded=int; nonnegative=True; accepted=3',
    '{"count":3,"count":3.0} -> decoded=float; nonnegative=True; rejected=count must appear exactly once',
    '[["count",3]] -> decoded=list; nonnegative=not-run; rejected=root must be an object',
]

for result in results:
    print(result)
{"count":3} -> decoded=int; nonnegative=True; accepted=3
{"count":3.0} -> decoded=float; nonnegative=True; rejected=count must be an integer
{"count":true} -> decoded=bool; nonnegative=True; rejected=count must be an integer
{"count":3,"meta":{}} -> decoded=int; nonnegative=True; accepted=3
{"count":3,"count":3.0} -> decoded=float; nonnegative=True; rejected=count must appear exactly once
[["count",3]] -> decoded=list; nonnegative=not-run; rejected=root must be an object