serverless-openapi-generator produces an OpenAPI v3 description of an HTTP API from a Python project. It supports two independent routes to a specification: reading a serverless.yml file and its documentation blocks, or reading the Python source directly and inferring endpoints from handler modules, companion docs.py files, and Pydantic models.
The tool is a standalone application. It does not run as a Serverless Framework plugin and does not require Node.js, except for the optional HTML rendering step.
Python 3.11 or later. Because both workflows import your Pydantic models to derive JSON schemas, the interpreter that runs openapi-gen must have your project's dependencies installed. Running the tool in an environment where your models cannot be imported yields a specification with missing or empty component schemas rather than an error.
The package is not published on PyPI. Install it from the repository:
pip install git+https://github.com/tkfoss/python-serverless-openapi-documentation.gitTo build and install from a checkout:
pip install build
python -m build
pip install dist/serverless_openapi_generator-1.0.0-py3-none-any.whlTo run without installing, invoke it as a uv tool:
uvx --from git+https://github.com/tkfoss/python-serverless-openapi-documentation.git \
openapi-gen generate-spec openapi.json --serverless-yml-path path/to/serverless.ymlSee the uv documentation for details on tool execution.
Installation provides two entry points: openapi-gen, the generator, and openapi-validate, a thin wrapper around openapi-spec-validator's command line interface.
The joined workflow (generate, recommended) reads both sources and joins them on
the one fact they share: the handler's dotted path. Neither source can describe an API
alone —
serverless.ymlknows which operations exist (path, method) and, crucially, which functions are HTTP at all rather than EventBridge or SNS. It knows nothing about payload shapes.- The code knows what each operation accepts and returns, with validation constraints. It knows nothing reliable about the URL it is mounted at.
Payload models are read off the @event_parser(model=...) decorator on each handler,
then narrowed: a field left at the raw Lambda event shape (body: str | None) documents
nothing, while a field narrowed to Json[TokenRequestBody] | TokenRequestBody names the
real payload. Nothing needs to be restated in YAML. See
Joined resolution rules.
openapi-gen generate openapi.json \
--serverless-yml-path ./serverless.yml --project-dir . --validateThe serverless workflow treats serverless.yml as the source of truth. Endpoints come from function http/httpApi events, and documentation comes from custom.documentation and the per-event documentation blocks. Use it when the project already deploys through the Serverless Framework, or when you need precise control over the output: this is the only path where servers, tags, security schemes, and response models are stated explicitly rather than inferred.
The code-only workflow reads the source tree and never touches serverless.yml. It discovers handlers by file glob, derives paths and methods by convention, and reads optional docs.py modules for summaries, tags, and response models. Use it for projects that do not deploy through the Serverless Framework, and expect to review the output — much of it is inferred, and the inference rules are documented under Discovery conventions.
A third, intermediate route exists: generate-schemas and generate-serverless synthesize a serverless.yml from Pydantic models, after which the serverless workflow proceeds normally. This is useful when you want a generated starting point that you then edit by hand.
Generates an OpenAPI specification in one step by joining serverless.yml with the
Python source behind it. This is the recommended entry point.
openapi-gen generate openapi.json --serverless-yml-path serverless.yml --project-dir . --validate| Argument | Description |
|---|---|
output_file_path |
Positional, required. Path of the OpenAPI JSON file to write. |
--serverless-yml-path |
Required. Path to the serverless.yml file. Only its functions block is read, so CloudFormation-heavy resources sections cannot break generation. |
--project-dir |
Project root used for imports. Defaults to the serverless.yml directory. |
--title, --version, --description |
Override the values read from pyproject.toml. |
--openApiVersion |
OpenAPI version string emitted. Default 3.0.3, which triggers a downgrade pass from JSON Schema 2020-12. Pass 3.1.0 to skip it. |
--validate |
Validate the result after writing. Exits non-zero on failure. |
The handler modules are imported to read their Pydantic models, so run this in an
environment where the project's own dependencies are installed — uv run --with <this tool>
rather than uvx --from <this tool>.
Same resolution, rendered as reStructuredText for Sphinx. The output is intended to be
.. include::-ed into a hand-written page so that narrative prose and generated facts
live side by side.
openapi-gen generate-rst docs/_generated/endpoints.rst \
--serverless-yml-path serverless.yml --project-dir .Takes the same arguments as generate, minus --openApiVersion and --validate.
Generates an OpenAPI specification from an existing serverless.yml.
openapi-gen generate-spec openapi.json --serverless-yml-path serverless.yml| Argument | Description |
|---|---|
output_file_path |
Positional, required. Path of the OpenAPI JSON file to write. |
--serverless-yml-path |
Required. Path to the serverless.yml file. |
--openApiVersion |
OpenAPI version string emitted in the document. Default 3.0.3. |
--validate |
Validate the result after writing it. Errors are reported but do not change the exit status; the file is written either way. |
Generates an OpenAPI specification directly from Python source, with no serverless.yml involved.
openapi-gen generate-spec-python openapi.json --python-source ./src| Argument | Description |
|---|---|
output_file_path |
Positional, required. Path of the OpenAPI JSON file to write. |
--python-source |
Required. Root directory of the Python source tree to scan. |
--endpoint-pattern |
Glob for handler modules, relative to the source root. Default **/handler.py. |
--docs-pattern |
Glob for documentation modules. Default **/docs.py. |
--title, --version, --description |
Override the info block. Each falls back to pyproject.toml, then to Generated API, 1.0.0, and an empty string. |
--openApiVersion |
OpenAPI version string emitted in the document. Default 3.0.3. |
--validate |
Validate the result after writing it. |
Project metadata is read from either [project] or [tool.poetry] in the nearest pyproject.toml, searched from the source directory upward through at most four levels. The name is title-cased and underscores become spaces, so my_service becomes My Service.
Writes one JSON schema file per discovered Pydantic model. Discovery is by filename: only modules named dtos.py, at any depth below the source directory, are searched for models. Models defined elsewhere are not picked up, and neither the filename nor the search is configurable.
openapi-gen generate-schemas --pydantic-source ./src --output-dir ./openapi_models| Argument | Description |
|---|---|
--pydantic-source |
Required. Directory containing the Pydantic models. |
--output-dir |
Required. Directory to write the JSON schemas into. |
Importing models is done defensively, since DTO modules frequently import each other. The parent of the source directory is placed on sys.path, and modules that raise ImportError are deferred and retried over three passes, so ordering between interdependent files does not matter. Unresolved forward references are satisfied with generated placeholder classes. Types with no JSON Schema representation — AWS Lambda Powertools objects among them — are handled by a custom schema generator, which falls back to Pydantic's default and then skips the model with a warning. Models with no fields of their own are treated as abstract and skipped. A run that emits warnings still produces schemas for everything that succeeded.
Assembles a serverless.yml from previously generated schemas plus project metadata. The file is always written to <project-dir>/serverless.yml; there is no output path argument.
openapi-gen generate-serverless --schema-dir ./openapi_models --project-dir .| Argument | Description |
|---|---|
--schema-dir |
Required. Directory holding the JSON schemas. Every *.json file in it is read, and the file stem becomes the model name. |
--project-dir |
Project root, used to locate pyproject.toml and as the output directory. Defaults to the parent of --schema-dir. |
Renders an existing specification to a single HTML page.
openapi-gen generate-html openapi.json openapi.htmlThis shells out to redoc-cli bundle, which is an npm package and is not installed with this tool. Install it separately (npm install -g redoc-cli) and make sure it is on PATH; otherwise the command reports that the executable was not found and exits without writing anything.
Validates a specification file on its own:
openapi-validate openapi.jsonThe --validate flag on the generate commands performs the same check inline. Validation prefers openapi-spec-validator; if it is unavailable, it falls back to structural checks covering required top-level fields, the info object, path items and their operations, and the shape of components.schemas. Only the first five errors are printed, followed by a count of the remainder.
Used by generate and generate-rst. Unlike the code-only workflow below, paths and
methods are never guessed — they come from serverless.yml.
Which operations exist. Every function with an http or httpApi event, one
operation per event. Functions triggered by anything else (eventBridge, sns, ...) are
skipped, which is a distinction the source code does not record anywhere.
Request payloads. The handler file is parsed with ast to find
@event_parser(model=X); X is then imported and its fields inspected. A field counts
only if it was narrowed relative to a base class — this keeps inherited Lambda-event
plumbing such as requestContext out of the output — and if its annotation resolves to a
Pydantic model, unwrapping Json[Model] | Model and Model | None. Field names map to
locations as body → request body, queryStringParameters → query, pathParameters →
path, headers → header; any other narrowed field containing body in its name is
treated as a body. Query, path and header models are flattened into individual OpenAPI
parameters rather than referenced as a whole.
Handlers that take the bare envelope but parse their own query string are also covered:
models named *Query* that the endpoint package constructs are picked up as query
parameters.
docs.py — the authoritative description. A docs.py beside the handler describes
its endpoint. Every name is optional:
| Name | Meaning |
|---|---|
summary |
One line. Wins over the serverless description. |
description |
Full prose. Markdown is fine. |
name |
The operation's tag. |
request_model |
The body. A union becomes oneOf; wrap it as Annotated[A | B, Field(discriminator="kind")] to add a discriminator. |
request_query / request_headers / request_path |
Models flattened into parameters. |
request_content_type |
Defaults to application/json. |
request_body_required |
Defaults to True. |
status_code, response_model, response_description, response_headers |
The success response. status_code alone is enough for a redirect or an empty body. |
responses (alias error_responses) |
{code: {"model": M, "description": ..., "headers": {...}}}. model may be omitted for a bodyless status, and the dict may declare a second success code. |
security |
e.g. [{"bearerAuth": []}]. |
A union in request_model, or in a responses entry's model, may be tagged:
responses = {
202: {
"model": Annotated[
PasswordChallenge | SsoChallenge, Field(discriminator="next_challenge")
],
"description": "Another challenge is required.",
},
}which emits discriminator: {propertyName, mapping} alongside the oneOf, so a reader
selects the member from the payload instead of trial-matching each one. OpenAPI requires
the property on every member and requires it to be required; a union that does not
satisfy that is documented as a plain oneOf and the reason is reported.
A declaration here replaces whatever the envelope implied for the same location. A package
serving two operations on one path splits them into docs_get.py / docs_post.py, or
docs_<serverlessFunctionName>.py.
Responses, in order of authority:
- The
docs.pyabove. - Models sharing the request's name prefix (
AuthorizationChallengeRequest→AuthorizationChallengeResponseBody). This is what separates a package's redirectingGETfrom its JSONPOST. - Every response-shaped model the package actually constructs or returns, emitted
together as a
oneOf, since an endpoint with several response shapes genuinely returns one of them.
Only the first is trustworthy. The other two are naming heuristics: they always guess
status 200 and can only see models named recognisably, so an endpoint whose real success
code is 202, or whose error codes are chosen by middleware, needs a docs.py.
A *ResponseHeaders model carrying a field aliased Location marks the operation as a
redirect: 302 with a Location header and no body.
Request bodies are only emitted for methods that can carry one. A model resolved to
the body location on a GET, HEAD, DELETE or OPTIONS is dropped.
Response schemas use serialization mode, request schemas validation mode. A
@computed_field is always on the wire but never in the validation schema, so generating
both from one mode silently drops it. A model used on both sides is emitted twice, which
Pydantic names -Input / -Output when the two differ.
OPTIONS operations are emitted as CORS preflight responses and are never introspected.
Project-level configuration goes in a [tool.openapi-gen] table in pyproject.toml,
for the facts that are true of the API rather than of one endpoint:
[tool.openapi-gen]
servers = [{ url = "https://api.example.com", description = "Production" }]
default_security = []
[tool.openapi-gen.security_schemes.bearerAuth]
type = "http"
scheme = "bearer"
bearerFormat = "JWT"
[tool.openapi-gen.tags]
Token = "Token issuance."
# Headers the framework adds to every response, and to the preflight response.
[tool.openapi-gen.global_response_headers."X-Request-Id"]
schema = { type = "string" }
[tool.openapi-gen.cors_preflight_headers."Access-Control-Allow-Origin"]
schema = { type = "string" }cors_preflight_headers entries are merged over the built-in
Access-Control-Allow-Origin / -Methods / -Headers defaults, so the example above
re-describes one header and leaves the other two as they are. global_response_headers
is applied on top of the result, so a header named in both tables takes its description
from the global one.
--server URL=DESCRIPTION (repeatable) overrides the configured servers.
Import failures are fatal. Reading payload models means importing every handler, and a
handler that bootstraps itself at import time will fail without its environment. Rather
than emit a structurally valid document stripped of every schema, generation stops and
lists what failed. Pass --allow-import-errors to publish anyway.
Schema dialect. Pydantic emits JSON Schema 2020-12. Targeting OpenAPI 3.0.x runs a
downgrade pass converting anyOf: [X, {"type": "null"}] to X plus nullable: true,
const to a single-value enum, boolean exclusive bounds to their numeric form,
examples to example, and a $ref that has sibling keywords into allOf: [{$ref}]
plus those keywords — OpenAPI 3.0 ignores every sibling of a $ref, so a defaulted or
described enum field would otherwise lose both silently. Target 3.1.0 to keep 2020-12
as-is.
The code-only workflow relies on layout and naming rather than configuration. Understanding these rules is the difference between a usable specification and one you have to correct by hand.
Endpoints. Files matching --endpoint-pattern are parsed with ast; no application code is executed at this stage. Within each file, a top-level function is treated as an endpoint if its name contains handler, lambda_handler, or main.
Paths. For a file named handler.py, the path is the parent directory name: http_endpoints/register/handler.py yields /register. For any other filename, the path is derived from the function name with _handler and lambda_ stripped.
Methods. The HTTP method is inferred from a cascade, first match winning. Endpoints whose directory name matches an OAuth 2.0 or OIDC endpoint are mapped by specification: authorize, userinfo, jwks, and the discovery endpoints become GET; token, revoke, introspect, login, logout, register, and password_reset become POST. Otherwise verbs in the function name are matched (get, list, fetch, and retrieve to GET; create, register, and submit to POST; update and replace to PUT; delete and remove to DELETE; patch and modify to PATCH). Failing that, decorator names are checked for method hints, then a parameter annotated with a name containing Request or Body implies POST. Read-only-sounding names such as info, status, and health fall back to GET. Everything else defaults to POST.
Documentation. A docs.py file beside a handler documents the endpoint named by its parent directory. Unlike handler files, these modules are imported, so their imports must resolve at generation time. The module must define a dictionary named docs_input; a docs.py without one is skipped with a warning.
from .models import RegisterRequest, RegisterResponse, ErrorResponse
docs_input = {
"summary": "Register a client",
"description": "Registers a new OAuth 2.0 client and returns its credentials.",
"tags": ["oauth"],
"request_model": RegisterRequest,
"response_model": RegisterResponse,
"status_code": 201,
"response_description": "Client registered",
"responses": {
400: {"description": "Malformed request", "model": ErrorResponse},
409: {"description": "Client already exists"},
},
}Model entries accept a class or a string; classes are reduced to their __name__ and matched against the generated component schemas. error_responses is accepted as an alias for responses and the two are merged. When response_model is set and status_code is not already described, a success response is added for it, defaulting to status 200. Values from docs_input take precedence over the handler's docstring, whose first line is otherwise used as the summary and whose remaining lines become the description. Every operation additionally receives a 500 response referencing ErrorResponse unless one is already declared.
Schemas. Component schemas come from the same dtos.py scan that generate-schemas performs, run against a temporary directory. Every model that generates successfully is emitted into components.schemas, not only those a docs.py references. Models that fail are dropped, and any endpoint referring to a dropped model has that reference removed so the document stays internally consistent. An ErrorResponse schema with a single message property is added if the project does not define one.
Generated defaults. The code-only workflow writes a fixed servers list (https://api.example.com and https://staging-api.example.com) and always declares bearerAuth and oauth2 security schemes, whether or not the API uses them. Endpoints classified as protected receive security: [{"bearerAuth": []}]. Treat these as placeholders to edit, or use the serverless workflow, where all three are stated explicitly.
The serverless workflow reads two locations: custom.documentation for document-level metadata, and a documentation block inside each function's http or httpApi event for operation-level metadata.
custom:
documentation:
version: "1.0.0"
title: "My API"
description: "This is my API"
termsOfService: https://example.com/terms
contact:
name: API Support
email: support@example.com
license:
name: MIT
url: https://opensource.org/licenses/MIT
externalDocumentation:
url: https://example.com/docs
description: Full documentation
servers:
- url: https://example.com:{port}/
description: The server
variables:
port:
enum: ['4000', '3000']
default: '3000'
description: The port the server operates on
tags:
- name: tag1
description: this is a tag
securitySchemes:
my_api_key:
type: apiKey
name: api_key
in: header
security:
- my_api_key: []
models:
- name: "ErrorResponse"
description: "This is an error"
contentType: "application/json"
schema: ${file(models/ErrorResponse.json)}The schema of the documentation blocks matches the serverless-openapi-documenter plugin this tool was ported from; its README remains a valid reference for the field-level details.
Several behaviors of this workflow are worth knowing. Schema references are resolved wherever they appear, covering nested definitions, internal #/definitions/... pointers, ${file(...)} includes, and URLs, and custom x- specification extensions are preserved in most sections of the document. An operation that declares no tags is tagged from its handler path using the second-to-last dot-separated segment, so a handler at src.api.users.handler is tagged users.
Two event-level settings affect the output directly. An event marked private: true receives an x-api-key API key security scheme, registered in components.securitySchemes on first use and attached to the operation, and a request.schemas configuration on an event produces a requestBody. OWASP security headers are opt-in per response rather than automatic: set an owasp key in the response documentation to true for the full set of supported headers, or to a mapping to enable them individually.
Generating a validated specification from a Pydantic project by way of a generated serverless.yml:
# 1. Emit JSON schemas from the Pydantic models
openapi-gen generate-schemas --pydantic-source ./src --output-dir ./openapi_models
# 2. Assemble a serverless.yml from those schemas and pyproject.toml
openapi-gen generate-serverless --schema-dir ./openapi_models --project-dir .
# 3. Generate and validate the specification
openapi-gen generate-spec openapi.json --serverless-yml-path serverless.yml --validateThe same project without the intermediate steps:
openapi-gen generate-spec-python openapi.json --python-source ./src --validateThe project uses uv for dependency management, pytest for tests, and ruff for linting.
uv sync --dev
uv run ruff check .
uv run pytestThe CI workflow runs the same three steps against Python 3.11, 3.12, and 3.13.
Note that test/test_python_generator.py imports through the src. package path, so the repository root must be on PYTHONPATH for collection to succeed — run PYTHONPATH=. uv run pytest until a conftest.py or a pythonpath entry in pyproject.toml makes that unnecessary. One test in that file shells out to redoc-cli and fails when it is not installed.
Architectural decisions are recorded under adr/.
MIT. See LICENSE.