OpenAPI
OpenAPI Schema objects are JSON Schema with a few of their own rules. to_openapi
and from_openapi are the OpenAPI pair, exported from the top level. from_openapi
is the JSON Schema decoder plus the OpenAPI extras, so its supported keywords,
round-trip caveats, and untrusted-input guards match JSON
Schema; read that page first. The to_openapi encoder is a
separate implementation (it targets OpenAPI 3.0 or 3.1 and takes a
custom_serializer), so its construct coverage differs from to_json_schema in
places. This page covers what OpenAPI adds.
Both directions
Section titled “Both directions”to_openapi(schema) renders a schema as an OpenAPI Schema object.
from_openapi(dict) is the inverse: it builds a Schema back. Use the pair to
publish request and response schemas in the spec of an OpenAPI-described
service, straight from the validators you already run, or to build a validator
from a spec you consume. For LLM tool calling and MCP, which take JSON Schema
rather than OpenAPI, see the LLM tool recipe.
from probatio import Schema, Required, Optional, to_openapi
schema = Schema({Required("name"): str, Optional("port", default=8080): int})to_openapi(schema)# {'type': 'object', 'properties': {'name': {'type': 'string'}, 'port': {'type': 'integer', 'default': 8080}}, 'required': ['name'], 'additionalProperties': False}A closed object emits additionalProperties: False, the same as to_json_schema.
An ALLOW_EXTRA object emits additionalProperties: True, and a REMOVE_EXTRA
object omits the keyword (it accepts extra keys but strips them, so the wire shape
is open). This is one of the places to_openapi diverges from voluptuous-openapi,
which omits the keyword on a closed object: to_openapi emits correct OpenAPI even
where the reference implementation does not.
A value that accepts anything (object, a callable without a type hint) renders as
the empty schema {}, in every position, the same as to_json_schema.
voluptuous-openapi renders object as a JSON object and gives any other untyped
property type: string; both reject values the validator accepts, so to_openapi
does neither. A bare bound (Range) is still typed number, as voluptuous-openapi
does. A mixed enum stays untyped, since a string stamp would reject the 1 in
In([1, "x"]). A bounded Length on its own renders one branch per sized type
(string, array, object) with the bounds on that type’s keyword, because
Length accepts all three and minLength alone constrains only strings. Beside a
sized type, as in All(str, Length(min=1)), the bounds land on that type’s keyword
directly; beside anything else the branches stay and the rest is intersected with
them. An unbounded Length() accepts every value and renders as {}.
Going the other way, an OpenAPI Schema object becomes a working validator.
nullable is read back too, so a nullable field accepts both None and a real
value:
from probatio import from_openapi
document = { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer", "nullable": True}, }, "required": ["name"],}schema = from_openapi(document)schema({"name": "Ada", "age": None}) # {'name': 'Ada', 'age': None}schema({"name": "Ada", "age": 37}) # {'name': 'Ada', 'age': 37}The nullable keyword
Section titled “The nullable keyword”nullable is the visible difference from JSON Schema. A value that also accepts
None renders as two branches in JSON Schema, but as a single nullable: True in
OpenAPI:
from probatio import Schema, Maybe, to_json_schema, to_openapi
schema = Schema({"nickname": Maybe(str)})to_json_schema(schema)["properties"]["nickname"]# {'anyOf': [{'type': 'null'}, {'type': 'string'}]}to_openapi(schema)["properties"]["nickname"]# {'type': 'string', 'nullable': True}to_openapi defaults to OpenAPI 3.0 (the nullable keyword above). Pass
openapi_version="3.1.0" for 3.1, which drops nullable and expresses
nullability the JSON Schema way instead, as an anyOf with a {"type": "null"}
branch. Those two spellings, "3.0" and "3.1.0", are the only ones accepted
(they match voluptuous-openapi’s OpenApiVersion values); anything else, "3.1"
included, raises ValueError rather than quietly rendering as one of them.
Group constraints across versions
Section titled “Group constraints across versions”The Inclusive (all-or-none) and Exclusive (at most one, or exactly one when
required) dict-group markers render as object-level constraints, and one of them
splits on version. Exclusive uses oneOf/not, which both OpenAPI versions
have. Inclusive maps to dependentRequired, which only OpenAPI 3.1 has:
from probatio import Schema, Inclusive, to_openapi
schema = Schema({Inclusive("lat", "coords"): float, Inclusive("lon", "coords"): float})to_openapi(schema, openapi_version="3.1.0")["dependentRequired"]# {'lat': ['lon'], 'lon': ['lat']}OpenAPI 3.0 has no dependentRequired (and silently ignores it), so there the
same all-or-none is spelled with the keywords 3.0 does have, an allOf entry that
accepts every member present or none present and rejects any partial combination.
The 3.1 dependentRequired decodes back to an Inclusive group through
from_openapi; the 3.0 form round-trips by behavior, not back to the marker.
A group member does not have to be a literal key. Inclusive(Any("hours", "minutes"), "d") is one member that either name satisfies, and the rule is the
usual all-or-none: that member and every other member of the group are either all
present or all absent. dependentRequired cannot express it, having no way to say
“if this name is present then at least one of those”, so on 3.1 a group holding
such a member renders under allOf instead, at the cost of not decoding back to
the marker.
A rule is only written for a key that certainly receives the names it lists.
Several things can take a name first: a literal key, an Alias under any of its
accepted names, another Any listing the same one, or a variable key such as
{str: ...}, which matches anything. Rather than model that precedence, the
codecs leave the rule out whenever a name is not provably theirs, so the document
stays a superset of what Probatio validates rather than contradicting it. The
properties are emitted either way, and Extra never competes, being the catch-all
for names nothing else matched. Under strict=True the dropped rule raises
instead, like any other widening.
Strict mode
Section titled “Strict mode”By default a construct with no OpenAPI form widens to an open schema ({}), so
the emitted document stays a superset of what Probatio validates. Pass
strict=True to raise SchemaError instead, so a lossy conversion is caught
rather than silently accepted, the same as to_json_schema:
from probatio import Schema, In, to_openapi
to_openapi(Schema(In([b"raw"])), strict=True)A faithfully open construct is not a loss, so it passes strict=True: object is
the empty schema, and a bare dict is an open object. Strict catches the
constructs that widen to an open schema, not a version-specific partial drop (an
OpenAPI 3.0 Contains still renders as a plain array).
Customizing the output
Section titled “Customizing the output”to_openapi takes a custom_serializer hook, the same one to_field_list uses, to
override how individual nodes render. It is documented with the field-list codec
in Field lists, since both codecs
share it.
Shared with JSON Schema
Section titled “Shared with JSON Schema”Everything else is the JSON Schema codec:
- The supported keywords and how each validator maps to them.
- The round trip is not lossless; treat it as “the validatable shape survives”.
from_openapitreats its input as untrusted: a catastrophicpatternor a pathologically deep document is refused withSchemaError, not a hang or a crash.