Skip to content

Latest commit

 

History

523 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OpenAPI Generator for Serverless Configurations

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.

Requirements

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.

Installation

The package is not published on PyPI. Install it from the repository:

pip install git+https://github.com/tkfoss/python-serverless-openapi-documentation.git

To build and install from a checkout:

pip install build
python -m build
pip install dist/serverless_openapi_generator-1.0.0-py3-none-any.whl

To 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.yml

See 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.

Choosing a workflow

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.yml knows 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 . --validate

The 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.

Commands

generate

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>.

generate-rst

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.

generate-spec

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.

generate-spec-python

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.

generate-schemas

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.

generate-serverless

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.

generate-html

Renders an existing specification to a single HTML page.

openapi-gen generate-html openapi.json openapi.html

This 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.

openapi-validate

Validates a specification file on its own:

openapi-validate openapi.json

The --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.

Joined resolution rules

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:

  1. The docs.py above.
  2. Models sharing the request's name prefix (AuthorizationChallengeRequest → AuthorizationChallengeResponseBody). This is what separates a package's redirecting GET from its JSON POST.
  3. 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.

Discovery conventions

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.

Configuring serverless.yml

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.

Complete example

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 --validate

The same project without the intermediate steps:

openapi-gen generate-spec-python openapi.json --python-source ./src --validate

Development

The project uses uv for dependency management, pytest for tests, and ruff for linting.

uv sync --dev
uv run ruff check .
uv run pytest

The 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/.

License

MIT. See LICENSE.

About

Generate OpenAPI spec from serverless.yml and/or Pydantic models

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages