python_tornado_server_generator
Generates Tornado server routing for an OpenAPI spec: one dataclass
per object schema (from_wire/to_wire/validate included - Python has no compiler to reject a
wrong-shaped value the way a statically-typed target does), and per OpenAPI tag a handler interface
you implement plus one tornado.web.RequestHandler subclass per path.
Incoming requests are parsed and validated - path/query/header/cookie parameters and, for bodies whose
schema has constraints, the deserialized body - before the handler is called, so handler
implementations only ever see clean, typed, already-valid Python values. All constraint-checking
logic lives once in the generated runtime.py and is called from every operation/model instead of
being duplicated per handler.
Tornado brings its own Application/HTTPServer/IOLoop, so unlike a WSGI-toolkit target
(Werkzeug, and by extension Flask) the generated code needs no separate WSGI server - it's a
complete, self-sufficient server stack on its own.
Usage
openapi-yagen g -o out -g python_tornado_server_generator openapi.yaml -v packageName=petstore_api
| Variable | Required | Description |
|---|---|---|
packageName | yes | Python package name for the generated code (e.g. petstore_api) |
strict | no (default true) | true: an unsupported schema/operation aborts generation with an error. false: skip it with a printed warning and generate everything else - useful for large real-world specs (see "Known limitations" below). |
generate | no (default all) | all: models.py plus the apis/ routes/handlers. models: only models.py. api: everything except models.py (apis/, runtime.py). |
publishOpenApiSpec | no (default false) | true: generates <packageName>/openapi.json (the effective OpenAPI document - the one actually used to generate this code, after $ref resolution, --tags filtering, and conversion to this generator's OpenAPI 3.2) plus a build_openapi_spec_routes() function serving it as JSON at openApiSpecPath. Ignored when generate is models. |
openApiSpecPath | no (default /openapi.json) | Only used when publishOpenApiSpec is true: the route path the spec is served at. |
Output layout
<packageName>/
__init__.py
models.py every schema's dataclass/type alias, in one module
apis/
__init__.py
<tag>.py handler interface + RequestHandler subclasses + build_<tag>_routes()
runtime.py shared parameter-extraction/constraint-checking helpers
openapi.json only if publishOpenApiSpec=true - the effective OpenAPI document
openapi_spec.py only if publishOpenApiSpec=true - build_openapi_spec_routes()
Written under a real <packageName>-named directory - Python requires a file's location to match
its import path, so -o should point at the parent directory the package gets created inside.
Every schema lives in a single models.py, not one file per schema. This is a deliberate choice:
splitting models across files would mean either hand-tracking which model needs which sibling
import (real circular-import risk for models that reference each other) or a blanket wildcard
re-export - models.py sidesteps that complexity entirely while staying just as easy to read for a
spec of this size.
Integrating the generated code
Implement the generated handler interface with your business logic, then build a tornado.web. Application from build_<tag>_routes():
from petstore_api.apis.pets import PetsHandler, build_pets_routes
from petstore_api.models import NewPet, Pet
from tornado.ioloop import IOLoop
from tornado.web import Application
class PetsService(PetsHandler):
def list_pets(self, *, limit: Optional[int]) -> List[Pet]:
...
def create_pet(self, *, body: NewPet) -> Pet:
...
def show_pet_by_id(self, *, pet_id: str) -> Pet:
...
application = Application(build_pets_routes(PetsService()))
application.listen(8080)
IOLoop.current().start()
build_<tag>_routes()'s URL patterns come straight from the spec's own paths: keys (no prefix
applied) - you control the mount path yourself, e.g. by prefixing the pattern strings it returns
before passing them to Application, or nesting them under your own Application's
default_host/handler list, if the spec's own servers/basePath implies one.
A validation failure (a missing required parameter, a constraint violation, a malformed body) is
raised as the generator's own runtime.ValidationError, immediately caught by the generated
RequestHandler method and re-raised as tornado.web.HTTPError(422, reason=str(exc)) - Tornado's
own idiomatic way to signal an HTTP error. HTTPError's default write_error sends a small HTML
error page; pass -v handlerBaseClass=your_app.support.BaseHandler (a dotted
module.path.ClassName) to have every generated RequestHandler subclass your own base instead of
tornado.web.RequestHandler directly, and override write_error(status_code, **kwargs) there to
control the actual JSON error body your application wants to send (and/or prepare()/on_finish()
for request-ID correlation, logging, auth, ...) - the generator itself doesn't hardcode any of this:
# your_app/support.py
import json
from tornado.web import HTTPError, RequestHandler
class BaseHandler(RequestHandler):
def write_error(self, status_code: int, **kwargs: object) -> None:
exc_info = kwargs.get("exc_info")
reason = exc_info[1].reason if exc_info and isinstance(exc_info[1], HTTPError) else self._reason
self.set_header("Content-Type", "application/json")
self.finish(json.dumps({"error": reason, "uid": "..."}))
openapi-yagen g -o out -g python_tornado_server_generator openapi.yaml \
-v packageName=petstore_api -v handlerBaseClass=your_app.support.BaseHandler
Publishing the OpenAPI spec
Set -v publishOpenApiSpec=true to have the generated server publish its own spec:
openapi-yagen g -o out -g python_tornado_server_generator openapi.yaml \
-v packageName=petstore_api -v publishOpenApiSpec=true
This writes <packageName>/openapi.json - the effective document (after $ref resolution,
--tags filtering, and conversion to this generator's OpenAPI 3.2) - and openapi_spec.py, which
reads it via importlib.resources (so it works whether <packageName> ends up on disk or inside a
zipped/frozen package) and exposes build_openapi_spec_routes(). Combine it with your other routes:
application = Application(build_pets_routes(PetsService()) + build_openapi_spec_routes())
Model types
An enum schema generates a plain class Name(str, Enum) (or (int, Enum)) with from_wire/
to_wire staticmethods and a no-op validate() - from_wire wraps the constructor (Name(value)
already raises ValueError on an unrecognized value, re-raised as ValidationError).
A discriminated oneOf/anyOf (discriminator.propertyName set, every variant a $ref) and an
undiscriminated one both generate a plain class acting as a from_wire/to_wire dispatch
namespace - no instance of that class is ever constructed; from_wire returns an instance of
whichever variant class matched (by discriminator value, or by the JSON value's shape for an
undiscriminated union), and to_wire dispatches back out the same way:
shape = Shape.from_wire(payload) # returns a Circle or Square instance directly
if isinstance(shape, Circle):
...
Shape.to_wire(shape)
Generation fails if an undiscriminated union's variants can't be unambiguously told apart from the raw JSON alone (e.g. two object variants with no property that distinguishes them).
A type: string property/parameter with format: date/date-time gets a real datetime.date/
datetime.datetime type instead of a plain str - from_wire/query-and-path-parameter parsing
routes through datetime.date.fromisoformat/datetime.datetime.fromisoformat (normalizing a
trailing Z UTC designator first, for correctness on any supported Python version, not just
3.11+, which is the first to accept Z natively), and to_wire calls .isoformat() back - the
parse doubles as the validation, so a malformed date/date-time value is rejected at the point it's
first read, not deferred to a separate check. format: uuid stays a plain str (no
dependency-free stdlib UUID type this generator reaches for) - a struct property or parameter with
it is shape-checked by validate()/parameter parsing: the canonical 8-4-4-4-12 hyphenated hex
form.
An optional property's default schema keyword becomes the real literal default for both entry
points into a model: the dataclass field's own default (NewWidget(name="Gizmo", owner="carol")
gets priority=1 without you passing it) and from_wire's absent-key handling
(NewWidget.from_wire({"name": "Gizmo", "owner": "carol"}) too). An explicit JSON null still
wins over the default - NewWidget.from_wire({..., "priority": None}).priority is None, not 1
- Python's
key in dis what makes this "absent vs. explicit null" distinction cheap to generate directly, unlike a language needing a custom (de)serializer for it. Adefaulton a required property, or one whose value doesn't map to a recognized literal shape (an object/array default, or aformat: date/date-timedefault), is ignored - the property keeps its ordinary required/None-default handling.
Authentication
http/bearer, apiKey, oauth2, and openIdConnect security schemes are supported - the
latter two treated identically to http/bearer (RFC 6750: an OAuth2/OIDC access token travels as
Authorization: Bearer <token> regardless of how it was obtained), with no scope/claim validation
of the token itself (just presence, same as every other scheme).
A security requirement naming a single scheme, or several required together (AND -
security: [{a: [], b: []}]), gives the handler method one extra keyword-only, required str
parameter per scheme, already extracted and validated before the handler runs:
def delete_pet(self, *, pet_id: str, bearer_auth_token: str) -> None: ...
A security with two or more OR-alternatives (security: [{a: []}, {b: []}], optionally with an
AND-combination inside one or more alternatives) instead gives the handler one Optional[str]
parameter per unique scheme name across every alternative, all extracted the same way but never
individually required - the generated method body tries each alternative in the spec's own
declared order, first one whose every scheme is non-None wins:
def favorite_widget(self, *, widget_id: int, oauth_2_auth_token: Optional[str], api_key_auth_key: Optional[str]) -> None: ...
Either way, a missing credential (no scheme populated for a single-scheme/AND operation, or no
alternative fully satisfied for an OR one) raises the generator's own
runtime.MissingAuthenticationError - deliberately not a ValidationError (or a subclass of it) -
immediately caught by the generated RequestHandler method and re-raised as
tornado.web.HTTPError(401, reason=str(exc)), alongside the existing ValidationError -> 422
mapping.
Request/response body content types
Request/response body content types are picked from whichever the spec declares in priority order
application/json > multipart/form-data/application/x-www-form-urlencoded > (a single
remaining media type, received/sent as str for any text/* media type, or raw bytes
otherwise):
-
application/json(default): the body isjson.loads-parsed, converted via the schema's own generatedfrom_wire, and validated (.validate()for an object schema, or the same recursive constraint walk for a Map/array/primitive schema) before the handler is called - the handler method'sbody:parameter is the generated type. -
multipart/form-data/application/x-www-form-urlencoded: Tornado parses either into the sameself.get_body_argument()/self.get_body_arguments()API, so one generated code path covers both. The schema must betype: objectwith only primitive/enum-typed properties, or arrays of either (self.get_body_arguments, the plural sibling ofself.get_body_argument- one repeated key per element, OpenAPI's defaultstyle: form, explode: true); a nested object property, or an array of non-scalar items, is a generator error - each field is extracted individually, then the constructed object's ownvalidate()is called once for required-ness and every declared constraint, same as the JSON encoding's whole-body validation. Atype: string, format: binaryproperty (a file field) maps to a realbytes, and - multipart only - is read from Tornado's ownself.request.files(a real uploaded file part, not a plain form field) rather thanself.get_body_argument; the same property in a urlencoded body is a generator error (a urlencoded body has no file-part concept to read one from). -
a single
text/*media type (text/plain,text/csv,text/html, ...): the handler method'sbody:/return type is a plainstr-self.request.body.decode("utf-8")/self.write(result), no JSON involved. -
a single other media type (
application/octet-stream,application/zip,image/png, ...): the handler method'sbody:is rawbytes(self.request.body, no decoding). Before the handler runs, the request'sContent-Typeis checked against the declared media type (which may be a wildcard likeapplication/*) - a mismatch is answered with 415; a body sent without anyContent-Typeis treated asapplication/octet-stream(RFC 9110). An empty body isNonefor an optional requestBody and a 422 for a required one. The return type isUnion[bytes, runtime.BinaryContent]- return plainbytes, or aBinaryContentto add metadata:from your_app.generated.runtime import BinaryContentdef download_backup(self, *, backup_id: str) -> Union[bytes, BinaryContent]:data = self.storage.read(backup_id)# Content-Disposition: attachment; filename="backup.tar"return BinaryContent(data=data, name="backup.tar")BinaryContent.content_typeoverrides the declared media type (a declared wildcard such asapplication/*is otherwise sent asapplication/octet-stream).A request/response body declaring two or more media types outside the ones above is ambiguous (which one would the generated handler actually expect?) and is a generator error, same as any other unsupported content-type - see "Known limitations".
Multiple success responses
An operation declaring two or more 2xx responses (e.g. 200 for a synchronous result and 202
for an accepted background job) returns one of a set of generated dataclasses - one per status
code, plus a Union alias of them in the tag's api module - and the handler picks the status by
which one it returns:
# Generated (your_app/generated/apis/backups.py):
@dataclass
class CreateBackup200Response:
body: Union[bytes, runtime.BinaryContent] # 200, application/zip
@dataclass
class CreateBackup202Response:
body: Operation # 202, application/json
@dataclass
class CreateBackup204Response:
pass # a response without content
CreateBackupResponse = Union[CreateBackup200Response, CreateBackup202Response, CreateBackup204Response]
# Implementation:
def create_backup(self, *, sync: Optional[bool]) -> CreateBackupResponse:
if sync:
return CreateBackup200Response(body=BinaryContent(data=self.build_now(), name="backup.zip"))
return CreateBackup202Response(body=self.start_in_background())
Each variant's body follows the same media type rules as a single response. Returning anything
else is answered with 500. An operation with a single success response keeps the plain -> T
return type.
Path/query/header/cookie parameters
Must resolve to a primitive scalar type (string/integer/number/boolean), an enum, or (query
parameters only) an array of one of those, serialized as repeated ?name=a&name=b keys (OpenAPI's
default style: form, explode: true) - path/header/cookie parameters stay scalar-only. An object,
nested array, or oneOf/anyOf in one of these positions is a generator error. A cookie parameter
is read via self.get_cookie(name) - not to be confused with a cookie-located apiKey security
scheme (in: cookie on the scheme itself, see "Authentication" below), which is a separate,
already-correct code path.
Known limitations (v1)
-
Raw request and response bodies are held in memory whole (Tornado buffers a request body before the handler runs) - there's no streaming, and no
Range/206 Partial Contentsupport for raw responses. -
Only explicitly numbered
2xxresponses become separate response variants; a range (2XX) ordefaultresponse is used only when no numbered2xxresponse exists. -
Request and response bodies support
application/json,multipart/form-data,application/ x-www-form-urlencoded, a singletext/*media type (asstr), and a single other media type (as rawbytes) - see "Request/response body content types" above. A requestBody/response declaring two or more media types outside those fixed ones is a generator error (aborts generation under defaultstrict=true; skips just that operation with a printed warning under-v strict=false). -
securityschemes are limited tohttp/bearer,apiKey,oauth2, andopenIdConnect-mutualTLS/HTTP Basic is a generator error - see "Authentication" above. -
Body/model validation covers
minLength/maxLength/pattern/minimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf/minItems/maxItems/uniqueItems/minProperties/maxProperties/const/format: uuid(a shape check) andformat: date/date-time(typed as realdatetime.date/datetime.datetime, parse-as-validation - see "Model types" above), and recurses into nested object properties (via their own generatedvalidate()), array elements, and Map (additionalProperties) values - but only for a property/parameter whose own declared type is directly one of those shapes, not through a$refto a Map/array/primitive-kind schema (a$refto an object, enum, or union schema is always deep-validated, since that's the common case a generatedvalidate()method exists for in the first place). -
Generated files are not run through a formatter.
Try it
From the generators/ directory, with openapi-yagen on PATH:
cd generators && openapi-yagen g -o out/python-tornado-server -g python_tornado_server_generator/src \
-c python_tornado_server_generator/test/resources/kitchensink.yaml -v packageName=petstore_api
For a real generate-then-run check exercising every operation, positive and negative, see
test/ -
this generator's own self-contained test suite (see also
../README.md for the collection-wide convention): pip install -r test/requirements.txt && OPENAPI_YAGEN=/path/to/openapi-yagen pytest test/, which regenerates from
test/resources/kitchensink.yaml, boots the result behind tornado.testing.AsyncHTTPTestCase
against fake handler implementations, and asserts on every generated operation's positive and
negative (validation, wrong-type parameter, ...) behavior - no real network or socket beyond
Tornado's own in-process test client.