Skip to content

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.

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}

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.

The split reaches further than the keyword. A leading EnsureList offers the forms it wraps beside the list, and its null form follows the version: a branch on 3.1, the flag on 3.0. Where the scalar form would have to say “not null”, which 3.0 has no way to spell, the whole rendering widens rather than writing a branch that admits one value too many, which is the direction the contract allows, where leaving it out would reject input the schema accepts. So 3.0 gets the branch with the array exclusion it can spell and without the null one it cannot, a shade wider than the schema, and strict=True refuses it on the grounds that it is not exact. A nullable item with a named type needs neither: the lone value is never the null half, so dropping the flag says the rest exactly.

Beside other branches on 3.0 the flag becomes a branch of its own, and the only shape 3.0 has for one is a nullable object, which lets every object through as well. The document is wider than the schema from there on, so strict=True reports it. Alone on the array the flag is exact, an array or null and nothing else, and strict has nothing to say.

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.

The same object-level constraints come from the key-presence validators, which sit beside the mapping rather than on one of its keys. AtLeastOne renders as anyOf over the per-key required, ExactlyOne as oneOf, AtMostOne as the not over the pairs, and AllOrNone splits on version exactly as Inclusive does:

from probatio import AllOrNone, AtLeastOne, All, Optional, Schema, to_openapi
base = Schema({Optional("a"): str, Optional("b"): str})
to_openapi(Schema(All(AtLeastOne("a", "b"), base)))["anyOf"]
# [{'required': ['a']}, {'required': ['b']}]
all_or_none = to_openapi(
Schema(All(AllOrNone("a", "b"), base)), openapi_version="3.1.0"
)
all_or_none["dependentRequired"]
# {'a': ['b'], 'b': ['a']}

The rules that make one key depend on another render too. RequiredWith is dependentRequired where that exists, and the rest are written as an implication, anyOf: [{not: trigger}, {required: ...}], rather than if/then: OpenAPI 3.0 has the first keyword and not the second, so one shape serves both versions.

from probatio import All, Optional, RequiredWithout, Schema, to_openapi
base = Schema({Optional("cert"): str, Optional("cert_path"): str})
to_openapi(Schema(All(RequiredWithout("cert", "cert_path"), base)))["anyOf"]
# [{'required': ['cert']}, {'required': ['cert_path']}]

Two things leave a rule out. A key that is not a plain string has no spelling in either document. So does a RequiredIf whose compared value JSON cannot hold, or holds differently: (1, 2) would render as [1, 2], which the tuple never equals, so emitting it would make the document fire where Probatio does not. Either way the rule widens, which strict=True reports.

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.

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.

A version-specific drop is a loss like any other. OpenAPI 3.0 has neither contains nor prefixItems and misreads both, so a Contains renders as a plain array there and an ExactSequence keeps only the length it pins. The document accepts what the schema rejects either way, so strict=True reports it rather than handing back the looser array without a word. An empty ExactSequence is the exception: it has no position to lose, and the length keywords say all of it.

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.

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_openapi treats its input as untrusted: a catastrophic pattern or a pathologically deep document is refused with SchemaError, not a hang or a crash.