Data Navigation¶
JSON Path / Schema¶
Navigate JSON structures with JSONPath and validate them against JSON Schema.
at_json_path¶
Navigate to a JSONPath and keep asserting on the extracted value:
data = {"users": [{"name": "Alice"}, {"name": "Bob"}], "meta": {"total": 2}}
assert_that(data).at_json_path("$.meta.total").is_equal_to(2)
assert_that(data).at_json_path("$.users[0].name").is_equal_to("Alice")
assert_that(data).at_json_path("$.users[*].name").is_equal_to(["Alice", "Bob"])
Raises ValueError if the path does not exist.
has_json_path / does_not_have_json_path¶
Assert that a path is present or absent:
assert_that(data).has_json_path("$.meta.total")
assert_that(data).does_not_have_json_path("$.error")
matches_json_schema¶
Validate against a JSON Schema dict, or load it from a file:
schema = {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
"required": ["name"],
}
assert_that({"name": "Alice", "age": 30}).matches_json_schema(schema)
assert_that(data).matches_json_schema_from_file("schemas/user.json")
JSON assertions chain and work with soft assertions:
with soft_assertions():
assert_that(response).has_json_path("$.data")
assert_that(response).at_json_path("$.data.id").satisfies(match.is_positive())
A path lands on whatever the document holds there, so what comes back offers the assertions every value has. To reach further, either use a matcher as above, or state the type you expect and continue in it.
The predicate below is a TypeIs, so the type checker takes its word. satisfies() runs it and reads
the verdict. Neither can confirm that the claim matches the check inside.
from typing_extensions import TypeIs
def is_int(value: object) -> TypeIs[int]:
return isinstance(value, int)
payload = {"data": {"id": 7}}
assert_that(payload).at_json_path("$.data.id").satisfies(is_int).is_between(1, 99)
conforms_to_openapi¶
Validate a response body against an OpenAPI operation's response schema - the one contract check none of the other assertion libraries here offer.
Three spec dialects are read:
- OpenAPI 3.1 and 3.2, as written: both carry JSON Schema 2020-12
- OpenAPI 3.0, including its
nullablekeyword - Swagger 2.0, where the schema sits directly on the response, including its
x-nullableextension
A spec declaring any other openapi version is refused. Read as one of these it would validate against
the wrong dialect, which passes every keyword that dialect cannot spell.
The same holds inside a spec. OpenAPI 3.0 and Swagger 2.0 carry an older JSON Schema, which passes over a
keyword of a later one, or reads it another way: a 3.0 schema with const: a used to take b. A schema
the response reaches that holds such a keyword is refused with a ValueError naming the schema and the
keyword: const,
contains, propertyNames, if, dependentRequired, dependentSchemas, unevaluatedProperties,
unevaluatedItems, prefixItems, $dynamicRef, exclusiveMinimum or exclusiveMaximum written as
a number, and $recursiveRef. The keyword is refused wherever it stands, whatever it would forbid
there. Spell the constraint as 3.0 does, or move the spec to OpenAPI 3.1 as a whole: with the version
line alone changed, nullable and a boolean exclusiveMinimum are what is not read. A $schema inside
a 3.0 or 2.0 document changes nothing: the document is read as one dialect throughout.
Inside any of them, $ref, oneOf / allOf / anyOf and enum validate with full JSON-Schema
semantics.
A multipleOf divides the numbers as they are written: 19.99 is a multiple of 0.01. As two floats
divided it is not, the quotient being 1998.9999999999998, and jsonschema fails it there. A float is
read as the decimal it prints as, so 0.30000000000000004 is no multiple of 0.1. Digits a float
does not keep are gone before the check sees the number: 1.0000000000000001 is 1.0 once parsed.
Where they matter, parse with json.loads(text, parse_float=decimal.Decimal), and a Decimal is
divided exactly. A multipleOf that is no number above zero is refused with a ValueError.
Two keywords of OpenAPI 3.0 are read as 3.0.3 words them, since the value is a response:
nullable: trueaddsnullto thetypebeside it and leaves every other keyword as written, so anenumbeside them letsnullthrough only where it lists it. With notypebeside it the keyword does nothing, with the one exception below.- A property marked
writeOnly: trueis not asked for byrequired, which for such a property "will take effect on the request only". One that did arrive is validated like any other. The mark is read through a$ref, and what stands beside a$refis ignored, as 3.0 has it.
Both are read on the schemas the response reaches and never on a value: an enum member or an example
that looks like a schema is left as it is.
One reading is wider than the text on purpose. nullable: true on a schema that is a $ref, an allOf,
an anyOf or a oneOf, with no type of its own, still allows null: that is how a nullable reference
is usually written, and a spec written so would fail on every null otherwise. What else stands beside
an allOf, anyOf or oneOf is still asked of null: an enum there lets it through only where it
lists it, as beside a type.
Pass strict_nullable=True to leave that reading out and hold nullable to the text. With no type
beside it the keyword then does nothing, and null is asked of what else the schema holds, as it
would be with no mark there: a $ref to a string refuses it and one to a nullable string takes it, an
anyOf takes it where a branch does, a oneOf where exactly one does, and a schema that holds
nothing else takes it as it takes any value. x-nullable and OpenAPI 3.1 are read the same either
way.
Three limits:
requiredin one branch of anallOfand thewriteOnlyproperty in another are not brought together: the mark is looked for in thepropertiesof the schema that holds therequired.- Swagger 2.0's
x-nullableis an extension with no text of its own. It allowsnullpast anenumand on any schema with notype, as it did. - In 3.1 and 3.2
writeOnlyis an annotation, andrequiredasks for the property.
A format is checked where there is a check for it:
format |
Checked |
|---|---|
date, ipv4, ipv6, uuid, regex |
always, by jsonschema |
email |
always, as the mailbox of RFC 5321: atoms joined by dots or a quoted string, an @, then a host name or an address in brackets. a@b.example, "a b"@c.example and a@[IPv6:::1] pass. @, a b@c.example and a..b@c.example fail, and so does an address with a letter past ASCII, which is for idn-email. The domain has the lengths of a host name. The local part has none in the grammar, and the 64 characters SMTP allows it are not held |
time |
always, as the full-time of RFC 3339, which needs its offset: 10:00:00Z and 10:00:00+02:00 pass, 10:00:00 fails. A second of 60 is taken in the last minute of a UTC day |
date-time |
always, by the grammar of RFC 3339: "yesterday" and "2026-02-30T10:00:00Z" fail. A second of 60 is taken in the last minute of a UTC month, where a leap second can fall. The check is the same one whether or not the package that gives jsonschema its own is installed |
int32, int64 |
always, as the signed range: 2**40 is no int32 |
uri |
always, by the grammar of RFC 3986: a scheme is required, so /orders/7 is no uri (it is a uri-reference), and neither is a text with a space or a non-ASCII letter in it |
hostname |
always, as the syntax of RFC 1123: labels of ASCII letters, digits and hyphens, 63 characters each and 253 in all, with no dot after the last. Under OpenAPI 3.1, whose JSON Schema takes punycode into the format, a label that opens xn-- has to be a valid A-label of IDNA 2008 as well: xn--nxasmq6b passes, xn--X fails. The idna package of the json extra judges it by the Unicode tables of the release installed, so a newer release takes code points assigned since. Under 3.0 and Swagger 2.0 the format is the syntax alone |
duration |
always, by the grammar RFC 3339 gives for it: P1DT12H and P2W pass, with the letters in either case. It is narrower than ISO 8601: PT0.5S, P1Y2D and -P1D fail. A fraction of a second, which Java and .NET print, is none: leave the format out for a field that carries one |
uri-reference, iri, idn-email, json-pointer, uri-template and the others jsonschema checks beside a package of their own |
where that package is installed, and not where it is missing. jsonschema[format-nongpl] brings them all |
Pass the parsed spec plus the operation's path and method. Loading the YAML or JSON is your job:
schema = {
"type": "object",
"required": ["id", "total"],
"properties": {
"id": {"type": "integer"},
"total": {"type": "number"},
"email": {"type": "string", "format": "email", "nullable": True},
},
}
spec = {
"openapi": "3.0.3",
"paths": {
"/orders/{id}": {
"get": {
"responses": {
"200": {"content": {"application/json": {"schema": schema}}}
}
}
}
},
}
body = {"id": 42, "total": 19.99, "email": None}
assert_that(body).conforms_to_openapi(spec, "/orders/{id}", "get")
path is the operation's key in the spec, not the request URL. A Swagger 2.0 basePath (or an OpenAPI 3
server prefix) is not part of it, so an endpoint served at /api/v1/version is looked up as /version.
status defaults to 200, then 201, then the range 2XX, then default. Pass status= to pick
another. A status is looked up as the spec declares it: its own code first, then its range (404 under
4XX), then default. A status none of the three covers is refused with a ValueError. Swagger 2.0
has no ranges.
When the body does not conform, the message names the operation and counts the failures. Every one is reported with its JSON path and the constraint it broke, not just the first:
diff (openapi):
$:
- {'id': 'x7', 'email': 'not-an-email'}
+ 'all required properties present'
$.email:
- 'not-an-email'
+ 'email format'
$.id:
- 'x7'
+ 'type integer'
Needs the JSON extra: pip install assertpy2[json].
Regex Group Extraction¶
extracting_group()¶
Search the value for a pattern and continue asserting on the captured group:
log = "2024-01-15 ERROR status=500 path=/api/users"
# by index
assert_that(log).extracting_group(r"status=(\d+)", 1).is_equal_to("500")
# by name
assert_that(log).extracting_group(r"(?P<level>\w+) status", "level").is_equal_to("ERROR")
# group 0 = whole match
assert_that("abc123").extracting_group(r"\d+").is_equal_to("123")
# chains
assert_that("count=42").extracting_group(r"count=(\d+)", 1).is_digit().is_length(2)
matches_with_groups()¶
Return all groups as a tuple, or a dict for named groups: