├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── MANIFEST.in
├── README.md
├── mypy.ini
├── pyopenapi
├── __init__.py
├── __main__.py
├── generator.py
├── metadata.py
├── operations.py
├── options.py
├── proxy.py
├── py.typed
├── specification.py
├── template.html
└── utility.py
├── pyproject.toml
├── requirements.txt
├── setup.cfg
├── setup.py
└── tests
├── __init__.py
├── endpoint.md
├── endpoint.py
├── test_openapi.py
└── test_proxy.py
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: pyopenapi build, test and deploy
3 |
4 | on:
5 | workflow_dispatch:
6 | inputs:
7 | publish-to-github:
8 | type: boolean
9 | description: 'Publish to GitHub'
10 | required: true
11 | default: false
12 | publish-to-pypi:
13 | type: boolean
14 | description: 'Publish to PyPI'
15 | required: true
16 | default: false
17 |
18 | jobs:
19 | build:
20 | name: Build distribution
21 |
22 | runs-on: ubuntu-latest
23 | steps:
24 | - name: Fetch source code
25 | uses: actions/checkout@v4
26 | - name: Set up Python
27 | uses: actions/setup-python@v5
28 | with:
29 | python-version: "3.9"
30 | - name: Set up build dependencies
31 | run: |
32 | python -m pip install --upgrade pip
33 | python -m pip --disable-pip-version-check install -r requirements.txt
34 |
35 | # Generate and publish website content
36 | - name: Generate website content
37 | run: |
38 | python -m unittest discover -s tests
39 | - name: Save website content as artifact
40 | uses: actions/upload-pages-artifact@v3
41 | with:
42 | name: github-pages
43 | path: website/
44 |
45 | # Generate and publish PyPI package
46 | - name: Build PyPI package
47 | run: |
48 | python -m build --sdist --wheel
49 | - name: Save PyPI package as artifact
50 | uses: actions/upload-artifact@v4
51 | with:
52 | name: pyopenapi-dist
53 | path: dist/**
54 | if-no-files-found: error
55 | compression-level: 0
56 |
57 | test:
58 | name: Run unit tests
59 |
60 | strategy:
61 | matrix:
62 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
63 |
64 | runs-on: ubuntu-latest
65 | steps:
66 | - name: Fetch source code
67 | uses: actions/checkout@v4
68 | - name: Set up Python
69 | uses: actions/setup-python@v5
70 | with:
71 | python-version: ${{ matrix.python-version }}
72 | - name: Set up build dependencies
73 | run: |
74 | python -m pip install --upgrade pip
75 | python -m pip --disable-pip-version-check install -r requirements.txt
76 | - name: Run unit tests
77 | run: |
78 | python -m unittest discover -s tests
79 |
80 | github-deploy:
81 | name: GitHub Pages deploy
82 |
83 | # Add a dependency to the build job
84 | needs: build
85 |
86 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment
87 | permissions:
88 | pages: write # to deploy to Pages
89 | id-token: write # to verify the deployment originates from an appropriate source
90 |
91 | # Deploy to the github-pages environment
92 | environment:
93 | name: github-pages
94 | url: ${{ steps.deployment.outputs.page_url }}
95 |
96 | # Specify runner and deployment step
97 | runs-on: ubuntu-latest
98 | steps:
99 | - name: Deploy to GitHub Pages
100 | id: deployment
101 | uses: actions/deploy-pages@v4
102 | with:
103 | artifact_name: github-pages
104 |
105 | github-release:
106 | name: GitHub Release signed with Sigstore
107 |
108 | if: ${{ inputs.publish-to-github }}
109 | needs: build
110 |
111 | permissions:
112 | contents: write # IMPORTANT: mandatory for making GitHub Releases
113 | id-token: write # IMPORTANT: mandatory for Sigstore
114 |
115 | runs-on: ubuntu-latest
116 | steps:
117 | - name: Download the distribution
118 | uses: actions/download-artifact@v4
119 | with:
120 | name: pyopenapi-dist
121 | path: dist/
122 | - name: Sign the distribution with Sigstore
123 | uses: sigstore/gh-action-sigstore-python@v3.0.0
124 | with:
125 | inputs: |
126 | ./dist/*.tar.gz
127 | ./dist/*.whl
128 | - name: Upload artifact signatures to GitHub Release
129 | env:
130 | GITHUB_TOKEN: ${{ github.token }}
131 | # Upload to GitHub Release using the `gh` CLI.
132 | # `dist/` contains the built packages, and the
133 | # sigstore-produced signatures and certificates.
134 | run: >-
135 | gh release create
136 | `ls -1 dist/*.tar.gz | grep -Eo '[0-9]+[.][0-9]+[.][0-9]+'` dist/**
137 | --repo '${{ github.repository }}' --notes ''
138 |
139 | pypi-publish:
140 | name: Publish release to PyPI
141 |
142 | if: ${{ inputs.publish-to-pypi }}
143 | needs: build
144 |
145 | runs-on: ubuntu-latest
146 | steps:
147 | - name: Download the distribution
148 | uses: actions/download-artifact@v4
149 | with:
150 | name: pyopenapi-dist
151 | path: dist/
152 | - name: Publish package distribution to PyPI
153 | uses: pypa/gh-action-pypi-publish@release/v1
154 | with:
155 | password: ${{ secrets.PYPI_ID_TOKEN }}
156 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.env
2 | /build
3 | /dist
4 | /website
5 | /*.egg-info
6 | __pycache__
7 | *.pyc
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "terminal.integrated.env.osx": {
3 | "PYTHONPATH": "${workspaceFolder}"
4 | },
5 | "terminal.integrated.env.windows": {
6 | "PYTHONPATH": "${workspaceFolder}"
7 | },
8 | "files.insertFinalNewline": true,
9 | "[python]": {
10 | "editor.codeActionsOnSave": {
11 | "source.fixAll": "explicit",
12 | "source.organizeImports": "explicit"
13 | },
14 | "editor.formatOnSave": true,
15 | "editor.defaultFormatter": "charliermarsh.ruff"
16 | },
17 | "cSpell.words": [
18 | "pyopenapi",
19 | "pypi",
20 | "Sigstore",
21 | "webmethod"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021-2025 Levente Hunyadi
4 | Copyright (c) 2021-2022 Instructure Inc.
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
23 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include tests *.py
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Generate an OpenAPI specification from a Python class
2 |
3 | *PyOpenAPI* produces an OpenAPI specification in JSON, YAML or HTML format with endpoint definitions extracted from member functions of a strongly-typed Python class.
4 |
5 | ## Features
6 |
7 | * supports standard and asynchronous functions (`async def`)
8 | * maps function name prefixes such as `get_` or `create_` to HTTP GET, POST, PUT, DELETE, PATCH
9 | * handles both simple and composite types (`int`, `str`, `Enum`, `@dataclass`)
10 | * handles generic types (`list[T]`, `dict[K, V]`, `Optional[T]`, `Union[T1, T2, T3]`)
11 | * maps Python positional-only and keyword-only arguments (of simple types) to path and query parameters, respectively
12 | * maps composite types to HTTP request body
13 | * supports user-defined routes, request and response samples with decorator `@webmethod`
14 | * extracts description text from class and function doc-strings (`__doc__`)
15 | * recognizes parameter description text given in reStructuredText doc-string format (`:param name: ...`)
16 | * converts exceptions declared in doc-strings into HTTP 4xx and 5xx responses (e.g. `:raises TypeError:`)
17 | * recursively converts composite types into JSON schemas
18 | * groups frequently used composite types into a separate section and re-uses them with `$ref`
19 | * displays generated OpenAPI specification in HTML with [ReDoc](https://github.com/Redocly/redoc)
20 |
21 | ## Live examples
22 |
23 | * [Endpoint definition in Python](https://hunyadi.github.io/pyopenapi/examples/python/index.html)
24 | * [Generated OpenAPI specification in JSON](https://hunyadi.github.io/pyopenapi/examples/json/index.html)
25 | * [Generated OpenAPI specification in YAML](https://hunyadi.github.io/pyopenapi/examples/yaml/index.html)
26 | * [Generated OpenAPI specification in HTML with ReDoc](https://hunyadi.github.io/pyopenapi/examples/index.html)
27 |
28 | ## User guide
29 |
30 | ### The specification object
31 |
32 | In order to generate an [OpenAPI specification document](https://spec.openapis.org/oas/latest.html), you should first construct a `Specification` object, which encapsulates the formal definition:
33 |
34 | ```python
35 | specification = Specification(
36 | MyEndpoint,
37 | Options(
38 | server=Server(url="http://example.com/api"),
39 | info=Info(
40 | title="Example specification",
41 | version="1.0",
42 | description=description,
43 | ),
44 | default_security_scheme=SecuritySchemeHTTP(
45 | "Authenticates a request by verifying a JWT (JSON Web Token) passed in the `Authorization` HTTP header.",
46 | "bearer",
47 | "JWT",
48 | ),
49 | extra_types=[ExampleType, UnreferencedType],
50 | error_wrapper=True,
51 | ),
52 | )
53 | ```
54 |
55 | The first argument to `Specification` is a Python class (`type`) whose methods will be inspected and converted into OpenAPI endpoint operations. The second argument is additional options that fine-tune how the specification is generated.
56 |
57 | ### Defining endpoint operations
58 |
59 | Let's take a look at the definition of a simple endpoint called `JobManagement`:
60 |
61 | ```python
62 | class JobManagement:
63 | def create_job(self, items: list[URL]) -> uuid.UUID:
64 | ...
65 |
66 | def get_job(self, job_id: uuid.UUID, /, format: Format) -> Job:
67 | ...
68 |
69 | def remove_job(self, job_id: uuid.UUID, /) -> None:
70 | ...
71 |
72 | def update_job(self, job_id: uuid.UUID, /, job: Job) -> None:
73 | ...
74 | ```
75 |
76 | The name of each method begins with a prefix such as `create`, `get`, `remove` or `update`, each of which maps to an HTTP verb, e.g. `POST`, `GET`, `DELETE` or `PATCH`. The rest of the function name serves as an identifier, e.g. `job`. The `self` argument to the function is ignored. Other arguments indicate what path and query [parameter objects](https://spec.openapis.org/oas/latest.html#parameter-object), and what [HTTP request body](https://spec.openapis.org/oas/latest.html#request-body-object) the operation accepts.
77 |
78 | ### Function signatures for operations
79 |
80 | Function signatures for operations must have full type annotation, including parameter types and return type.
81 |
82 | Python [positional-only arguments](https://peps.python.org/pep-0570/) map to path parameters. Python positional-or-keyword arguments map to query parameters if they are of a simple type (e.g. `int` or `str`). If a composite type (e.g. a class, a list or a union) occurs in the Python parameter list, it is treated as the definition of the HTTP request body. Only one composite type may appear in the parameter list. The return type of the function is treated as the HTTP response body. If the function returns `None`, it corresponds to an HTTP response with no payload (i.e. a `Content-Length` of 0).
83 |
84 | The JSON schema for the HTTP request and response body is generated with the library [json_strong_typing](https://github.com/hunyadi/strong_typing), and is automatically embedded in the OpenAPI specification document.
85 |
86 | ### User-defined operation path
87 |
88 | By default, the library constructs the operation path from the Python function name and positional-only parameters. However, it is possible to supply a custom path (route) using the `@webmethod` decorator:
89 |
90 | ```python
91 | @webmethod(
92 | route="/person/name/{family}/{given}",
93 | )
94 | def get_person_by_name(self, family: str, given: str, /) -> Person:
95 | ...
96 | ```
97 |
98 | The custom path must have placeholders for all positional-only parameters in the function signature, and vice versa.
99 |
100 | ### Documenting operations
101 |
102 | Use Python ReST (ReStructured Text) doc-strings to attach documentation to operations:
103 |
104 | ```python
105 | def get_job(self, job_id: uuid.UUID, /, format: Format) -> Job:
106 | """
107 | Query status information about a job.
108 |
109 | :param job_id: Unique identifier for the job to query.
110 | :returns: Status information about the job.
111 | :raises NotFoundError: The job does not exist.
112 | :raises ValidationError: The input is malformed.
113 | """
114 | ...
115 | ```
116 |
117 | Fields such as `param` and `returns` help document path and query parameters, HTTP request and response body. The field `raises` helps document error responses by identifying the exact return type when an error occurs. The Python type for `returns` and `raises` is translated to a JSON schema and embedded in the OpenAPI specification document.
118 |
119 | ### Request and response examples
120 |
121 | OpenAPI supports specifying [examples](https://spec.openapis.org/oas/latest.html#example-object) for the HTTP request and response body of endpoint operations. This is supported via the `@webmethod` decorator:
122 |
123 | ```python
124 | @webmethod(
125 | route="/member/name/{family}/{given}",
126 | response_examples=[
127 | Student("Szörnyeteg", "Lajos"),
128 | Student("Ló", "Szerafin"),
129 | Student("Bruckner", "Szigfrid"),
130 | Student("Nagy", "Zoárd"),
131 | Teacher("Mikka", "Makka", "Négyszögletű Kerek Erdő"),
132 | Teacher("Vacska", "Mati", "Négyszögletű Kerek Erdő"),
133 | ],
134 | )
135 | def get_member_by_name(self, family: str, given: str, /) -> Union[Student, Teacher]:
136 | ...
137 | ```
138 |
139 | A response example may be an exception or error class (a type that derives from `Exception`). These are usually shown under an HTTP status code of 4xx or 5xx.
140 |
141 | The Python objects in `request_examples` and `response_examples` are translated to JSON with the library [json_strong_typing](https://github.com/hunyadi/strong_typing).
142 |
143 | ### Mapping function name prefixes to HTTP verbs
144 |
145 | The following table identifies which function name prefixes map to which HTTP verbs:
146 |
147 | | Prefix | HTTP verb |
148 | | ------ | ----------- |
149 | | create | POST |
150 | | delete | REMOVE |
151 | | do | GET or POST |
152 | | get | GET |
153 | | post | POST |
154 | | put | POST |
155 | | remove | REMOVE |
156 | | set | PUT |
157 | | update | PATCH |
158 |
159 | If the function signature conflicts with the HTTP verb (e.g. a function name starts with `get` but has a composite type in the parameter list, which maps to a non-empty HTTP request body), the HTTP verb is automatically adjusted.
160 |
161 | ### Associating HTTP status codes with response types
162 |
163 | By default, the library associates success responses with HTTP status code 200, and error responses with HTTP status code 500. However, it is possible to associate any Python type with any HTTP status code:
164 |
165 | ```python
166 | specification = Specification(
167 | MyEndpoint,
168 | Options(
169 | server=Server(url="http://example.com/api"),
170 | info=Info(
171 | title="Example specification",
172 | version="1.0",
173 | description=description,
174 | ),
175 | success_responses={
176 | Student: HTTPStatus.CREATED,
177 | Teacher: HTTPStatus.ACCEPTED,
178 | },
179 | error_responses={
180 | AuthenticationError: HTTPStatus.UNAUTHORIZED,
181 | BadRequestError: 400,
182 | InternalServerError: 500,
183 | NotFoundError: HTTPStatus.NOT_FOUND,
184 | ValidationError: "400",
185 | },
186 | error_wrapper=True,
187 | ),
188 | )
189 | ```
190 |
191 | The arguments `success_responses` and `error_responses` take a dictionary that maps types to status codes. Status codes may be integers (e.g. `400`), strings (e.g. `"400"` or `"4xx"`) or [HTTPStatus](https://docs.python.org/3/library/http.html#http.HTTPStatus) enumeration values. The string representation of the status code must be valid as per the OpenAPI specification.
192 |
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | check_untyped_defs = True
3 | disallow_untyped_calls = True
4 | disallow_untyped_decorators = True
5 | disallow_untyped_defs = True
6 | ignore_missing_imports = True
7 | show_column_numbers = True
8 | warn_redundant_casts = True
9 | warn_unused_ignores = True
10 |
--------------------------------------------------------------------------------
/pyopenapi/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Optional, TypeVar
2 |
3 | from .metadata import WebMethod
4 | from .options import * # noqa: F403
5 | from .utility import Specification as Specification
6 |
7 | __version__ = "0.1.10"
8 |
9 | T = TypeVar("T")
10 |
11 |
12 | def webmethod(
13 | route: Optional[str] = None,
14 | public: Optional[bool] = False,
15 | request_example: Optional[Any] = None,
16 | response_example: Optional[Any] = None,
17 | request_examples: Optional[list[Any]] = None,
18 | response_examples: Optional[list[Any]] = None,
19 | ) -> Callable[[T], T]:
20 | """
21 | Decorator that supplies additional metadata to an endpoint operation function.
22 |
23 | :param route: The URL path pattern associated with this operation which path parameters are substituted into.
24 | :param public: True if the operation can be invoked without prior authentication.
25 | :param request_example: A sample request that the operation might take.
26 | :param response_example: A sample response that the operation might produce.
27 | :param request_examples: Sample requests that the operation might take. Pass a list of objects, not JSON.
28 | :param response_examples: Sample responses that the operation might produce. Pass a list of objects, not JSON.
29 | """
30 |
31 | if request_example is not None and request_examples is not None:
32 | raise ValueError("arguments `request_example` and `request_examples` are exclusive")
33 | if response_example is not None and response_examples is not None:
34 | raise ValueError("arguments `response_example` and `response_examples` are exclusive")
35 |
36 | if request_example:
37 | request_examples = [request_example]
38 | if response_example:
39 | response_examples = [response_example]
40 |
41 | def wrap(cls: T) -> T:
42 | setattr(
43 | cls,
44 | "__webmethod__",
45 | WebMethod(
46 | route=route,
47 | public=public or False,
48 | request_examples=request_examples,
49 | response_examples=response_examples,
50 | ),
51 | )
52 | return cls
53 |
54 | return wrap
55 |
--------------------------------------------------------------------------------
/pyopenapi/__main__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hunyadi/pyopenapi/83dda028f8b18336be3c9cf20e186499753a47f3/pyopenapi/__main__.py
--------------------------------------------------------------------------------
/pyopenapi/generator.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | import hashlib
3 | import ipaddress
4 | import typing
5 | from dataclasses import dataclass
6 | from http import HTTPStatus
7 | from typing import Any, Callable, Optional, Union
8 |
9 | from strong_typing.core import JsonType
10 | from strong_typing.docstring import Docstring, parse_type
11 | from strong_typing.inspection import is_generic_list, is_type_optional, is_type_union, unwrap_generic_list, unwrap_optional_type, unwrap_union_types
12 | from strong_typing.name import python_type_to_name
13 | from strong_typing.schema import JsonSchemaGenerator, Schema, SchemaOptions, get_schema_identifier, register_schema
14 | from strong_typing.serialization import json_dump_string, object_to_json
15 |
16 | from .operations import EndpointOperation, HTTPMethod, get_endpoint_events, get_endpoint_operations
17 | from .options import HTTPStatusCode, Options
18 | from .specification import (
19 | Components,
20 | Document,
21 | Example,
22 | ExampleRef,
23 | MediaType,
24 | Operation,
25 | Parameter,
26 | ParameterLocation,
27 | PathItem,
28 | RequestBody,
29 | Response,
30 | ResponseRef,
31 | SchemaOrRef,
32 | SchemaRef,
33 | Tag,
34 | TagGroup,
35 | )
36 |
37 | register_schema(
38 | ipaddress.IPv4Address,
39 | schema={
40 | "type": "string",
41 | "format": "ipv4",
42 | "title": "IPv4 address",
43 | "description": "IPv4 address, according to dotted-quad ABNF syntax as defined in RFC 2673, section 3.2.",
44 | },
45 | examples=["192.0.2.0", "198.51.100.1", "203.0.113.255"],
46 | )
47 |
48 | register_schema(
49 | ipaddress.IPv6Address,
50 | schema={
51 | "type": "string",
52 | "format": "ipv6",
53 | "title": "IPv6 address",
54 | "description": "IPv6 address, as defined in RFC 2373, section 2.2.",
55 | },
56 | examples=[
57 | "FEDC:BA98:7654:3210:FEDC:BA98:7654:3210",
58 | "1080:0:0:0:8:800:200C:417A",
59 | "1080::8:800:200C:417A",
60 | "FF01::101",
61 | "::1",
62 | ],
63 | )
64 |
65 |
66 | def http_status_to_string(status_code: HTTPStatusCode) -> str:
67 | "Converts an HTTP status code to a string."
68 |
69 | if isinstance(status_code, HTTPStatus):
70 | return str(status_code.value)
71 | elif isinstance(status_code, int):
72 | return str(status_code)
73 | elif isinstance(status_code, str):
74 | return status_code
75 | else:
76 | raise TypeError("expected: HTTP status code")
77 |
78 |
79 | class SchemaBuilder:
80 | schema_generator: JsonSchemaGenerator
81 | schemas: dict[str, Schema]
82 |
83 | def __init__(self, schema_generator: JsonSchemaGenerator) -> None:
84 | self.schema_generator = schema_generator
85 | self.schemas = {}
86 |
87 | def classdef_to_schema(self, typ: type) -> Schema:
88 | """
89 | Converts a type to a JSON schema.
90 | For nested types found in the type hierarchy, adds the type to the schema registry in the OpenAPI specification section `components`.
91 | """
92 |
93 | type_schema, type_definitions = self.schema_generator.classdef_to_schema(typ)
94 |
95 | # append schema to list of known schemas, to be used in OpenAPI's Components Object section
96 | for ref, schema in type_definitions.items():
97 | self._add_ref(ref, schema)
98 |
99 | return type_schema
100 |
101 | def classdef_to_named_schema(self, name: str, typ: type) -> Schema:
102 | schema = self.classdef_to_schema(typ)
103 | self._add_ref(name, schema)
104 | return schema
105 |
106 | def classdef_to_ref(self, typ: type) -> SchemaOrRef:
107 | """
108 | Converts a type to a JSON schema, and if possible, returns a schema reference.
109 | For composite types (such as classes), adds the type to the schema registry in the OpenAPI specification section `components`.
110 | """
111 |
112 | type_schema = self.classdef_to_schema(typ)
113 | if typ is str or typ is int or typ is float:
114 | # represent simple types as themselves
115 | return type_schema
116 |
117 | type_name = get_schema_identifier(typ)
118 | if type_name is not None:
119 | return self._build_ref(type_name, type_schema)
120 |
121 | try:
122 | type_name = python_type_to_name(typ)
123 | return self._build_ref(type_name, type_schema)
124 | except TypeError:
125 | pass
126 |
127 | return type_schema
128 |
129 | def _build_ref(self, type_name: str, type_schema: Schema) -> SchemaRef:
130 | self._add_ref(type_name, type_schema)
131 | return SchemaRef(type_name)
132 |
133 | def _add_ref(self, type_name: str, type_schema: Schema) -> None:
134 | if type_name not in self.schemas:
135 | self.schemas[type_name] = type_schema
136 |
137 |
138 | class ContentBuilder:
139 | schema_builder: SchemaBuilder
140 | schema_transformer: Optional[Callable[[SchemaOrRef], SchemaOrRef]]
141 | sample_transformer: Optional[Callable[[JsonType], JsonType]]
142 |
143 | def __init__(
144 | self,
145 | schema_builder: SchemaBuilder,
146 | schema_transformer: Optional[Callable[[SchemaOrRef], SchemaOrRef]] = None,
147 | sample_transformer: Optional[Callable[[JsonType], JsonType]] = None,
148 | ) -> None:
149 | self.schema_builder = schema_builder
150 | self.schema_transformer = schema_transformer
151 | self.sample_transformer = sample_transformer
152 |
153 | def build_content(self, payload_type: type, examples: Optional[list[Any]] = None) -> dict[str, MediaType]:
154 | "Creates the content subtree for a request or response."
155 |
156 | if is_generic_list(payload_type):
157 | media_type = "application/jsonl"
158 | item_type = unwrap_generic_list(payload_type)
159 | else:
160 | media_type = "application/json"
161 | item_type = payload_type
162 |
163 | return {media_type: self.build_media_type(item_type, examples)}
164 |
165 | def build_media_type(self, item_type: type, examples: Optional[list[Any]] = None) -> MediaType:
166 | schema = self.schema_builder.classdef_to_ref(item_type)
167 | if self.schema_transformer:
168 | schema_transformer: Callable[[SchemaOrRef], SchemaOrRef] = self.schema_transformer
169 | schema = schema_transformer(schema)
170 |
171 | if not examples:
172 | return MediaType(schema=schema)
173 |
174 | if len(examples) == 1:
175 | return MediaType(schema=schema, example=self._build_example(examples[0]))
176 |
177 | return MediaType(
178 | schema=schema,
179 | examples=self._build_examples(examples),
180 | )
181 |
182 | def _build_examples(self, examples: list[Any]) -> dict[str, Union[Example, ExampleRef]]:
183 | "Creates a set of several examples for a media type."
184 |
185 | builder = ExampleBuilder(self.sample_transformer)
186 |
187 | results: dict[str, Union[Example, ExampleRef]] = {}
188 | for example in examples:
189 | name, value = builder.get_named(example)
190 | results[name] = Example(value=value)
191 |
192 | return results
193 |
194 | def _build_example(self, example: Any) -> Any:
195 | "Creates a single example for a media type."
196 |
197 | builder = ExampleBuilder(self.sample_transformer)
198 | return builder.get_anonymous(example)
199 |
200 |
201 | class ExampleBuilder:
202 | sample_transformer: Callable[[JsonType], JsonType]
203 |
204 | def __init__(
205 | self,
206 | sample_transformer: Optional[Callable[[JsonType], JsonType]] = None,
207 | ) -> None:
208 | if sample_transformer:
209 | self.sample_transformer = sample_transformer
210 | else:
211 | self.sample_transformer = lambda sample: sample # noqa: E731
212 |
213 | def _get_value(self, example: Any) -> JsonType:
214 | return self.sample_transformer(object_to_json(example))
215 |
216 | def get_anonymous(self, example: Any) -> JsonType:
217 | return self._get_value(example)
218 |
219 | def get_named(self, example: Any) -> tuple[str, JsonType]:
220 | value = self._get_value(example)
221 |
222 | name: Optional[str] = None
223 |
224 | if type(example).__str__ is not object.__str__:
225 | friendly_name = str(example)
226 | if friendly_name.isprintable():
227 | name = friendly_name
228 |
229 | if name is None:
230 | hash_string = hashlib.md5(json_dump_string(value).encode("utf-8")).digest().hex()
231 | name = f"ex-{hash_string}"
232 |
233 | return name, value
234 |
235 |
236 | @dataclass
237 | class ResponseOptions:
238 | """
239 | Configuration options for building a response for an operation.
240 |
241 | :param type_descriptions: Maps each response type to a textual description (if available).
242 | :param examples: A list of response examples.
243 | :param status_catalog: Maps each response type to an HTTP status code.
244 | :param default_status_code: HTTP status code assigned to responses that have no mapping.
245 | """
246 |
247 | type_descriptions: dict[type, str]
248 | examples: Optional[list[Any]]
249 | status_catalog: dict[type, HTTPStatusCode]
250 | default_status_code: HTTPStatusCode
251 |
252 |
253 | @dataclass
254 | class StatusResponse:
255 | status_code: str
256 | types: list[type] = dataclasses.field(default_factory=list)
257 | examples: list[Any] = dataclasses.field(default_factory=list)
258 |
259 |
260 | class ResponseBuilder:
261 | content_builder: ContentBuilder
262 |
263 | def __init__(self, content_builder: ContentBuilder) -> None:
264 | self.content_builder = content_builder
265 |
266 | def _get_status_responses(self, options: ResponseOptions) -> dict[str, StatusResponse]:
267 | status_responses: dict[str, StatusResponse] = {}
268 |
269 | for response_type in options.type_descriptions.keys():
270 | status_code = http_status_to_string(options.status_catalog.get(response_type, options.default_status_code))
271 |
272 | # look up response for status code
273 | if status_code not in status_responses:
274 | status_responses[status_code] = StatusResponse(status_code)
275 | status_response = status_responses[status_code]
276 |
277 | # append response types that are assigned the given status code
278 | status_response.types.append(response_type)
279 |
280 | # append examples that have the matching response type
281 | if options.examples:
282 | status_response.examples.extend(example for example in options.examples if isinstance(example, response_type))
283 |
284 | return dict(sorted(status_responses.items()))
285 |
286 | def build_response(self, options: ResponseOptions) -> dict[str, Union[Response, ResponseRef]]:
287 | """
288 | Groups responses that have the same status code.
289 | """
290 |
291 | responses: dict[str, Union[Response, ResponseRef]] = {}
292 | status_responses = self._get_status_responses(options)
293 | for status_code, status_response in status_responses.items():
294 | response_types = tuple(status_response.types)
295 | if len(response_types) > 1:
296 | composite_response_type: type = Union[response_types] # type: ignore
297 | else:
298 | (response_type,) = response_types
299 | composite_response_type = response_type
300 |
301 | description = " **OR** ".join(
302 | filter(
303 | None,
304 | (options.type_descriptions[response_type] for response_type in response_types),
305 | )
306 | )
307 |
308 | responses[status_code] = self._build_response(
309 | response_type=composite_response_type,
310 | description=description,
311 | examples=status_response.examples or None,
312 | )
313 |
314 | return responses
315 |
316 | def _build_response(
317 | self,
318 | response_type: type,
319 | description: str,
320 | examples: Optional[list[Any]] = None,
321 | ) -> Response:
322 | "Creates a response subtree."
323 |
324 | if response_type is not None:
325 | return Response(
326 | description=description,
327 | content=self.content_builder.build_content(response_type, examples),
328 | )
329 | else:
330 | return Response(description=description)
331 |
332 |
333 | def schema_error_wrapper(schema: SchemaOrRef) -> Schema:
334 | "Wraps an error output schema into a top-level error schema."
335 |
336 | return {
337 | "type": "object",
338 | "properties": {
339 | "error": schema, # type: ignore
340 | },
341 | "additionalProperties": False,
342 | "required": [
343 | "error",
344 | ],
345 | }
346 |
347 |
348 | def sample_error_wrapper(error: JsonType) -> JsonType:
349 | "Wraps an error output sample into a top-level error sample."
350 |
351 | return {"error": error}
352 |
353 |
354 | class Generator:
355 | endpoint: type
356 | options: Options
357 | schema_builder: SchemaBuilder
358 | responses: dict[str, Response]
359 |
360 | def __init__(self, endpoint: type, options: Options) -> None:
361 | self.endpoint = endpoint
362 | self.options = options
363 | schema_generator = JsonSchemaGenerator(
364 | SchemaOptions(
365 | definitions_path="#/components/schemas/",
366 | use_examples=self.options.use_examples,
367 | property_description_fun=options.property_description_fun,
368 | )
369 | )
370 | self.schema_builder = SchemaBuilder(schema_generator)
371 | self.responses = {}
372 |
373 | def _build_type_tag(self, ref: str, schema: Schema) -> Tag:
374 | definition = f''
375 | title = typing.cast(str, schema.get("title"))
376 | description = typing.cast(str, schema.get("description"))
377 | return Tag(
378 | name=ref,
379 | description="\n\n".join(s for s in (title, description, definition) if s is not None),
380 | )
381 |
382 | def _build_extra_tag_groups(self, extra_types: dict[str, list[type]]) -> dict[str, list[Tag]]:
383 | """
384 | Creates a dictionary of tag group captions as keys, and tag lists as values.
385 |
386 | :param extra_types: A dictionary of type categories and list of types in that category.
387 | """
388 |
389 | extra_tags: dict[str, list[Tag]] = {}
390 |
391 | for category_name, category_items in extra_types.items():
392 | tag_list: list[Tag] = []
393 |
394 | for extra_type in category_items:
395 | name = python_type_to_name(extra_type)
396 | schema = self.schema_builder.classdef_to_named_schema(name, extra_type)
397 | tag_list.append(self._build_type_tag(name, schema))
398 |
399 | if tag_list:
400 | extra_tags[category_name] = tag_list
401 |
402 | return extra_tags
403 |
404 | def _build_operation(self, op: EndpointOperation) -> Operation:
405 | doc_string = parse_type(op.func_ref)
406 | doc_params = dict((param.name, param.description) for param in doc_string.params.values())
407 |
408 | # parameters passed in URL component path
409 | path_parameters = [
410 | Parameter(
411 | name=param_name,
412 | in_=ParameterLocation.Path,
413 | description=doc_params.get(param_name),
414 | required=True,
415 | schema=self.schema_builder.classdef_to_ref(param_type),
416 | )
417 | for param_name, param_type in op.path_params
418 | ]
419 |
420 | # parameters passed in URL component query string
421 | query_parameters = []
422 | for param_name, param_type in op.query_params:
423 | if is_type_optional(param_type):
424 | inner_type: type = unwrap_optional_type(param_type)
425 | required = False
426 | else:
427 | inner_type = param_type
428 | required = True
429 |
430 | query_parameter = Parameter(
431 | name=param_name,
432 | in_=ParameterLocation.Query,
433 | description=doc_params.get(param_name),
434 | required=required,
435 | schema=self.schema_builder.classdef_to_ref(inner_type),
436 | )
437 | query_parameters.append(query_parameter)
438 |
439 | # parameters passed anywhere
440 | parameters = path_parameters + query_parameters
441 |
442 | # data passed in payload
443 | if op.request_param:
444 | builder = ContentBuilder(self.schema_builder)
445 | request_name, request_type = op.request_param
446 | requestBody = RequestBody(
447 | content={"application/json": builder.build_media_type(request_type, op.request_examples)},
448 | description=doc_params.get(request_name),
449 | required=True,
450 | )
451 | else:
452 | requestBody = None
453 |
454 | # success response types
455 | if doc_string.returns is None and is_type_union(op.response_type):
456 | # split union of return types into a list of response types
457 | success_type_docstring: dict[type, Docstring] = {typing.cast(type, item): parse_type(item) for item in unwrap_union_types(op.response_type)}
458 | success_type_descriptions = {
459 | item: doc_string.short_description for item, doc_string in success_type_docstring.items() if doc_string.short_description
460 | }
461 | else:
462 | # use return type as a single response type
463 | success_type_descriptions = {op.response_type: (doc_string.returns.description if doc_string.returns else "OK")}
464 |
465 | response_examples = op.response_examples or []
466 | success_examples = [example for example in response_examples if not isinstance(example, Exception)]
467 |
468 | content_builder = ContentBuilder(self.schema_builder)
469 | response_builder = ResponseBuilder(content_builder)
470 | response_options = ResponseOptions(
471 | success_type_descriptions,
472 | success_examples if self.options.use_examples else None,
473 | self.options.success_responses,
474 | "200",
475 | )
476 | responses = response_builder.build_response(response_options)
477 |
478 | # failure response types
479 | if doc_string.raises:
480 | exception_types: dict[type, str] = {item.raise_type: item.description for item in doc_string.raises.values()}
481 | exception_examples = [example for example in response_examples if isinstance(example, Exception)]
482 |
483 | if self.options.error_wrapper:
484 | schema_transformer = schema_error_wrapper
485 | sample_transformer = sample_error_wrapper
486 | else:
487 | schema_transformer = None
488 | sample_transformer = None
489 |
490 | content_builder = ContentBuilder(
491 | self.schema_builder,
492 | schema_transformer=schema_transformer,
493 | sample_transformer=sample_transformer,
494 | )
495 | response_builder = ResponseBuilder(content_builder)
496 | response_options = ResponseOptions(
497 | exception_types,
498 | exception_examples if self.options.use_examples else None,
499 | self.options.error_responses,
500 | "500",
501 | )
502 | responses.update(response_builder.build_response(response_options))
503 |
504 | if op.event_type is not None:
505 | builder = ContentBuilder(self.schema_builder)
506 | callbacks = {
507 | f"{op.func_name}_callback": {
508 | "{$request.query.callback}": PathItem(
509 | post=Operation(
510 | requestBody=RequestBody(content=builder.build_content(op.event_type)),
511 | responses={"200": Response(description="OK")},
512 | )
513 | )
514 | }
515 | }
516 |
517 | else:
518 | callbacks = None
519 |
520 | return Operation(
521 | tags=[op.defining_class.__name__],
522 | summary=doc_string.short_description,
523 | description=doc_string.long_description,
524 | parameters=parameters,
525 | requestBody=requestBody,
526 | responses=responses,
527 | callbacks=callbacks,
528 | security=[] if op.public else None,
529 | )
530 |
531 | def generate(self) -> Document:
532 | paths: dict[str, PathItem] = {}
533 | endpoint_classes: set[type] = set()
534 | for op in get_endpoint_operations(self.endpoint, use_examples=self.options.use_examples):
535 | endpoint_classes.add(op.defining_class)
536 |
537 | operation = self._build_operation(op)
538 |
539 | if op.http_method is HTTPMethod.GET:
540 | pathItem = PathItem(get=operation)
541 | elif op.http_method is HTTPMethod.PUT:
542 | pathItem = PathItem(put=operation)
543 | elif op.http_method is HTTPMethod.POST:
544 | pathItem = PathItem(post=operation)
545 | elif op.http_method is HTTPMethod.DELETE:
546 | pathItem = PathItem(delete=operation)
547 | elif op.http_method is HTTPMethod.PATCH:
548 | pathItem = PathItem(patch=operation)
549 | else:
550 | raise NotImplementedError(f"unknown HTTP method: {op.http_method}")
551 |
552 | route = op.get_route()
553 | if route in paths:
554 | paths[route].update(pathItem)
555 | else:
556 | paths[route] = pathItem
557 |
558 | operation_tags: list[Tag] = []
559 | for cls in endpoint_classes:
560 | doc_string = parse_type(cls)
561 | operation_tags.append(
562 | Tag(
563 | name=cls.__name__,
564 | description=doc_string.long_description,
565 | displayName=doc_string.short_description,
566 | )
567 | )
568 |
569 | # types that are produced/consumed by operations
570 | type_tags = [self._build_type_tag(ref, schema) for ref, schema in self.schema_builder.schemas.items()]
571 |
572 | # types that are emitted by events
573 | event_tags: list[Tag] = []
574 | events = get_endpoint_events(self.endpoint)
575 | for ref, event_type in events.items():
576 | event_schema = self.schema_builder.classdef_to_named_schema(ref, event_type)
577 | event_tags.append(self._build_type_tag(ref, event_schema))
578 |
579 | # types that are explicitly declared
580 | extra_tag_groups: dict[str, list[Tag]] = {}
581 | if self.options.extra_types is not None:
582 | if isinstance(self.options.extra_types, list):
583 | extra_tag_groups = self._build_extra_tag_groups({"AdditionalTypes": self.options.extra_types})
584 | elif isinstance(self.options.extra_types, dict):
585 | extra_tag_groups = self._build_extra_tag_groups(self.options.extra_types)
586 | else:
587 | raise TypeError(f"type mismatch for collection of extra types: {type(self.options.extra_types)}")
588 |
589 | # list all operations and types
590 | tags: list[Tag] = []
591 | tags.extend(operation_tags)
592 | tags.extend(type_tags)
593 | tags.extend(event_tags)
594 | for extra_tag_group in extra_tag_groups.values():
595 | tags.extend(extra_tag_group)
596 |
597 | tag_groups = []
598 | if operation_tags:
599 | tag_groups.append(
600 | TagGroup(
601 | name=self.options.map("Operations"),
602 | tags=sorted(tag.name for tag in operation_tags),
603 | )
604 | )
605 | if type_tags:
606 | tag_groups.append(
607 | TagGroup(
608 | name=self.options.map("Types"),
609 | tags=sorted(tag.name for tag in type_tags),
610 | )
611 | )
612 | if event_tags:
613 | tag_groups.append(
614 | TagGroup(
615 | name=self.options.map("Events"),
616 | tags=sorted(tag.name for tag in event_tags),
617 | )
618 | )
619 | for caption, extra_tag_group in extra_tag_groups.items():
620 | tag_groups.append(
621 | TagGroup(
622 | name=self.options.map(caption),
623 | tags=sorted(tag.name for tag in extra_tag_group),
624 | )
625 | )
626 |
627 | if self.options.default_security_scheme:
628 | securitySchemes = {"Default": self.options.default_security_scheme}
629 | else:
630 | securitySchemes = None
631 |
632 | return Document(
633 | openapi=".".join(str(item) for item in self.options.version),
634 | info=self.options.info,
635 | jsonSchemaDialect=("https://json-schema.org/draft/2020-12/schema" if self.options.version >= (3, 1, 0) else None),
636 | servers=[self.options.server],
637 | paths=paths,
638 | components=Components(
639 | schemas=self.schema_builder.schemas,
640 | responses=self.responses,
641 | securitySchemes=securitySchemes,
642 | ),
643 | security=[{"Default": []}],
644 | tags=tags,
645 | tagGroups=tag_groups,
646 | )
647 |
--------------------------------------------------------------------------------
/pyopenapi/metadata.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | from typing import Any, Optional
3 |
4 |
5 | @dataclass
6 | class WebMethod:
7 | """
8 | Additional metadata tied to an endpoint operation function.
9 |
10 | :param route: The URL path pattern associated with this operation which path parameters are substituted into.
11 | :param public: True if the operation can be invoked without prior authentication.
12 | :param request_examples: Sample requests that the operation might take. Pass a list of objects, not JSON.
13 | :param response_examples: Sample responses that the operation might produce. Pass a list of objects, not JSON.
14 | """
15 |
16 | route: Optional[str] = None
17 | public: bool = False
18 | request_examples: Optional[list[Any]] = None
19 | response_examples: Optional[list[Any]] = None
20 |
--------------------------------------------------------------------------------
/pyopenapi/operations.py:
--------------------------------------------------------------------------------
1 | import collections.abc
2 | import enum
3 | import inspect
4 | import typing
5 | import uuid
6 | from dataclasses import dataclass
7 | from typing import Any, Callable, Iterable, Iterator, Optional, Union
8 |
9 | from strong_typing.inspection import get_signature, is_type_enum, is_type_optional, unwrap_optional_type
10 |
11 | from .metadata import WebMethod
12 |
13 |
14 | def split_prefix(s: str, sep: str, prefix: Union[str, Iterable[str]]) -> tuple[Optional[str], str]:
15 | """
16 | Recognizes a prefix at the beginning of a string.
17 |
18 | :param s: The string to check.
19 | :param sep: A separator between (one of) the prefix(es) and the rest of the string.
20 | :param prefix: A string or a set of strings to identify as a prefix.
21 | :return: A tuple of the recognized prefix (if any) and the rest of the string excluding the separator (or the entire string).
22 | """
23 |
24 | if isinstance(prefix, str):
25 | if s.startswith(prefix + sep):
26 | return prefix, s[len(prefix) + len(sep) :]
27 | else:
28 | return None, s
29 |
30 | for p in prefix:
31 | if s.startswith(p + sep):
32 | return p, s[len(p) + len(sep) :]
33 |
34 | return None, s
35 |
36 |
37 | def _get_annotation_type(annotation: Union[type, str], callable: Callable) -> type:
38 | "Maps a stringized reference to a type, as if using `from __future__ import annotations`."
39 |
40 | if isinstance(annotation, str):
41 | return eval(annotation, callable.__globals__)
42 | else:
43 | return annotation
44 |
45 |
46 | @enum.unique
47 | class HTTPMethod(enum.Enum):
48 | "HTTP method used to invoke an endpoint operation."
49 |
50 | GET = "GET"
51 | POST = "POST"
52 | PUT = "PUT"
53 | DELETE = "DELETE"
54 | PATCH = "PATCH"
55 |
56 |
57 | OperationParameter = tuple[str, type]
58 |
59 |
60 | class ValidationError(TypeError):
61 | pass
62 |
63 |
64 | @dataclass
65 | class EndpointOperation:
66 | """
67 | Type information and metadata associated with an endpoint operation.
68 |
69 | "param defining_class: The most specific class that defines the endpoint operation.
70 | :param name: The short name of the endpoint operation.
71 | :param func_name: The name of the function to invoke when the operation is triggered.
72 | :param func_ref: The callable to invoke when the operation is triggered.
73 | :param route: A custom route string assigned to the operation.
74 | :param path_params: Parameters of the operation signature that are passed in the path component of the URL string.
75 | :param query_params: Parameters of the operation signature that are passed in the query string as `key=value` pairs.
76 | :param request_param: The parameter that corresponds to the data transmitted in the request body.
77 | :param event_type: The Python type of the data that is transmitted out-of-band (e.g. via websockets) while the operation is in progress.
78 | :param response_type: The Python type of the data that is transmitted in the response body.
79 | :param http_method: The HTTP method used to invoke the endpoint such as POST, GET or PUT.
80 | :param public: True if the operation can be invoked without prior authentication.
81 | :param request_examples: Sample requests that the operation might take.
82 | :param response_examples: Sample responses that the operation might produce.
83 | """
84 |
85 | defining_class: type
86 | name: str
87 | func_name: str
88 | func_ref: Callable[..., Any]
89 | route: Optional[str]
90 | path_params: list[OperationParameter]
91 | query_params: list[OperationParameter]
92 | request_param: Optional[OperationParameter]
93 | event_type: Optional[type]
94 | response_type: type
95 | http_method: HTTPMethod
96 | public: bool
97 | request_examples: Optional[list[Any]] = None
98 | response_examples: Optional[list[Any]] = None
99 |
100 | def get_route(self) -> str:
101 | if self.route is not None:
102 | return self.route
103 |
104 | route_parts = ["", self.name]
105 | for param_name, _ in self.path_params:
106 | route_parts.append("{" + param_name + "}")
107 | return "/".join(route_parts)
108 |
109 |
110 | class _FormatParameterExtractor:
111 | "A visitor to exract parameters in a format string."
112 |
113 | keys: list[str]
114 |
115 | def __init__(self) -> None:
116 | self.keys = []
117 |
118 | def __getitem__(self, key: str) -> None:
119 | self.keys.append(key)
120 | return None
121 |
122 |
123 | def _get_route_parameters(route: str) -> list[str]:
124 | extractor = _FormatParameterExtractor()
125 | route.format_map(extractor)
126 | return extractor.keys
127 |
128 |
129 | def _get_endpoint_functions(endpoint: type, prefixes: list[str]) -> Iterator[tuple[str, str, str, Callable]]:
130 | if not inspect.isclass(endpoint):
131 | raise ValidationError(f"object is not a class type: {endpoint}")
132 |
133 | functions = inspect.getmembers(endpoint, inspect.isfunction)
134 | for func_name, func_ref in functions:
135 | prefix, operation_name = split_prefix(func_name, "_", prefixes)
136 | if not prefix:
137 | continue
138 |
139 | yield prefix, operation_name, func_name, func_ref
140 |
141 |
142 | def _get_defining_class(member_fn: str, derived_cls: type) -> type:
143 | "Find the class in which a member function is first defined in a class inheritance hierarchy."
144 |
145 | # iterate in reverse member resolution order to find most specific class first
146 | for cls in reversed(inspect.getmro(derived_cls)):
147 | for name, _ in inspect.getmembers(cls, inspect.isfunction):
148 | if name == member_fn:
149 | return cls
150 |
151 | raise ValidationError(f"cannot find defining class for {member_fn} in {derived_cls}")
152 |
153 |
154 | def get_endpoint_operations(endpoint: type, use_examples: bool = True) -> list[EndpointOperation]:
155 | """
156 | Extracts a list of member functions in a class eligible for HTTP interface binding.
157 |
158 | These member functions are expected to have a signature like
159 | ```
160 | async def get_object(self, uuid: str, version: int) -> Object:
161 | ...
162 | ```
163 | where the prefix `get_` translates to an HTTP GET, `object` corresponds to the name of the endpoint operation,
164 | `uuid` and `version` are mapped to route path elements in "/object/{uuid}/{version}", and `Object` becomes
165 | the response payload type, transmitted as an object serialized to JSON.
166 |
167 | If the member function has a composite class type in the argument list, it becomes the request payload type,
168 | and the caller is expected to provide the data as serialized JSON in an HTTP POST request.
169 |
170 | :param endpoint: A class with member functions that can be mapped to an HTTP endpoint.
171 | :param use_examples: Whether to return examples associated with member functions.
172 | """
173 |
174 | result = []
175 |
176 | for prefix, operation_name, func_name, func_ref in _get_endpoint_functions(
177 | endpoint,
178 | [
179 | "create",
180 | "delete",
181 | "do",
182 | "get",
183 | "post",
184 | "put",
185 | "remove",
186 | "set",
187 | "update",
188 | ],
189 | ):
190 | # extract routing information from function metadata
191 | webmethod: Optional[WebMethod] = getattr(func_ref, "__webmethod__", None)
192 | if webmethod is not None:
193 | route = webmethod.route
194 | route_params = _get_route_parameters(route) if route is not None else None
195 | public = webmethod.public
196 | request_examples = webmethod.request_examples
197 | response_examples = webmethod.response_examples
198 | else:
199 | route = None
200 | route_params = None
201 | public = False
202 | request_examples = None
203 | response_examples = None
204 |
205 | # inspect function signature for path and query parameters, and request/response payload type
206 | signature = get_signature(func_ref)
207 |
208 | path_params = []
209 | query_params = []
210 | request_param = None
211 |
212 | for param_name, parameter in signature.parameters.items():
213 | param_type = _get_annotation_type(parameter.annotation, func_ref)
214 |
215 | # omit "self" for instance methods
216 | if param_name == "self" and param_type is inspect.Parameter.empty:
217 | continue
218 |
219 | # check if all parameters have explicit type
220 | if parameter.annotation is inspect.Parameter.empty:
221 | raise ValidationError(f"parameter '{param_name}' in function '{func_name}' has no type annotation")
222 |
223 | if is_type_optional(param_type):
224 | inner_type: type = unwrap_optional_type(param_type)
225 | else:
226 | inner_type = param_type
227 |
228 | if inner_type is bool or inner_type is int or inner_type is float or inner_type is str or inner_type is uuid.UUID or is_type_enum(inner_type):
229 | if parameter.kind == inspect.Parameter.POSITIONAL_ONLY:
230 | if route_params is not None and param_name not in route_params:
231 | raise ValidationError(f"positional parameter '{param_name}' absent from user-defined route '{route}' for function '{func_name}'")
232 |
233 | # simple type maps to route path element, e.g. /study/{uuid}/{version}
234 | path_params.append((param_name, param_type))
235 | else:
236 | if route_params is not None and param_name in route_params:
237 | raise ValidationError(f"query parameter '{param_name}' found in user-defined route '{route}' for function '{func_name}'")
238 |
239 | # simple type maps to key=value pair in query string
240 | query_params.append((param_name, param_type))
241 | else:
242 | if route_params is not None and param_name in route_params:
243 | raise ValidationError(
244 | f"user-defined route '{route}' for function '{func_name}' has parameter '{param_name}' of composite type: {param_type}"
245 | )
246 |
247 | if request_param is not None:
248 | param = (param_name, param_type)
249 | raise ValidationError(
250 | f"only a single composite type is permitted in a signature but multiple composite types found in function '{func_name}': {request_param} and {param}"
251 | )
252 |
253 | # composite types are read from body
254 | request_param = (param_name, param_type)
255 |
256 | # check if function has explicit return type
257 | if signature.return_annotation is inspect.Signature.empty:
258 | raise ValidationError(f"function '{func_name}' has no return type annotation")
259 |
260 | return_type = _get_annotation_type(signature.return_annotation, func_ref)
261 |
262 | # operations that produce events are labeled as Generator[YieldType, SendType, ReturnType]
263 | # where YieldType is the event type, SendType is None, and ReturnType is the immediate response type to the request
264 | if typing.get_origin(return_type) is collections.abc.Generator:
265 | event_type, send_type, response_type = typing.get_args(return_type)
266 | if send_type is not type(None):
267 | raise ValidationError(
268 | f"function '{func_name}' has a return type Generator[Y,S,R] and therefore looks like an event but has an explicit send type"
269 | )
270 | else:
271 | event_type = None
272 | response_type = return_type
273 |
274 | # set HTTP request method based on type of request and presence of payload
275 | if request_param is None:
276 | if prefix in ["delete", "remove"]:
277 | http_method = HTTPMethod.DELETE
278 | else:
279 | http_method = HTTPMethod.GET
280 | else:
281 | if prefix == "set":
282 | http_method = HTTPMethod.PUT
283 | elif prefix == "update":
284 | http_method = HTTPMethod.PATCH
285 | else:
286 | http_method = HTTPMethod.POST
287 |
288 | result.append(
289 | EndpointOperation(
290 | defining_class=_get_defining_class(func_name, endpoint),
291 | name=operation_name,
292 | func_name=func_name,
293 | func_ref=func_ref,
294 | route=route,
295 | path_params=path_params,
296 | query_params=query_params,
297 | request_param=request_param,
298 | event_type=event_type,
299 | response_type=response_type,
300 | http_method=http_method,
301 | public=public,
302 | request_examples=request_examples if use_examples else None,
303 | response_examples=response_examples if use_examples else None,
304 | )
305 | )
306 |
307 | if not result:
308 | raise ValidationError(f"no eligible endpoint operations in type {endpoint}")
309 |
310 | return result
311 |
312 |
313 | def get_endpoint_events(endpoint: type) -> dict[str, type]:
314 | results = {}
315 |
316 | for decl in typing.get_type_hints(endpoint).values():
317 | # check if signature is Callable[...]
318 | origin = typing.get_origin(decl)
319 | if origin is None or not issubclass(origin, Callable): # type: ignore
320 | continue
321 |
322 | # check if signature is Callable[[...], Any]
323 | args = typing.get_args(decl)
324 | if len(args) != 2:
325 | continue
326 | params_type, return_type = args
327 | if not isinstance(params_type, list):
328 | continue
329 |
330 | # check if signature is Callable[[...], None]
331 | if not issubclass(return_type, type(None)):
332 | continue
333 |
334 | # check if signature is Callable[[EventType], None]
335 | if len(params_type) != 1:
336 | continue
337 |
338 | param_type = params_type[0]
339 | results[param_type.__name__] = param_type
340 |
341 | return results
342 |
--------------------------------------------------------------------------------
/pyopenapi/options.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | from dataclasses import dataclass
3 | from http import HTTPStatus
4 | from typing import Callable, ClassVar, Optional, Union
5 |
6 | from .specification import Info, SecurityScheme
7 | from .specification import SecuritySchemeAPI as SecuritySchemeAPI
8 | from .specification import SecuritySchemeHTTP as SecuritySchemeHTTP
9 | from .specification import SecuritySchemeOpenIDConnect as SecuritySchemeOpenIDConnect
10 | from .specification import Server
11 |
12 | HTTPStatusCode = Union[HTTPStatus, int, str]
13 |
14 |
15 | @dataclass
16 | class Options:
17 | """
18 | :param server: Base URL for the API endpoint.
19 | :param info: Meta-information for the endpoint specification.
20 | :param version: OpenAPI specification version as a tuple of major, minor, revision.
21 | :param default_security_scheme: Security scheme to apply to endpoints, unless overridden on a per-endpoint basis.
22 | :param extra_types: Extra types in addition to those found in operation signatures. Use a dictionary to group related types.
23 | :param use_examples: Whether to emit examples for operations.
24 | :param success_responses: Associates operation response types with HTTP status codes.
25 | :param error_responses: Associates error response types with HTTP status codes.
26 | :param error_wrapper: True if errors are encapsulated in an error object wrapper.
27 | :param property_description_fun: Custom transformation function to apply to class property documentation strings.
28 | :param captions: User-defined captions for sections such as "Operations" or "Types", and (if applicable) groups of extra types.
29 | """
30 |
31 | server: Server
32 | info: Info
33 | version: tuple[int, int, int] = (3, 1, 0)
34 | default_security_scheme: Optional[SecurityScheme] = None
35 | extra_types: Union[list[type], dict[str, list[type]], None] = None
36 | use_examples: bool = True
37 | success_responses: dict[type, HTTPStatusCode] = dataclasses.field(default_factory=dict)
38 | error_responses: dict[type, HTTPStatusCode] = dataclasses.field(default_factory=dict)
39 | error_wrapper: bool = False
40 | property_description_fun: Optional[Callable[[type, str, str], str]] = None
41 | captions: Optional[dict[str, str]] = None
42 |
43 | default_captions: ClassVar[dict[str, str]] = {
44 | "Operations": "Operations",
45 | "Types": "Types",
46 | "Events": "Events",
47 | "AdditionalTypes": "Additional types",
48 | }
49 |
50 | def map(self, id: str) -> str:
51 | "Maps a language-neutral placeholder string to language-dependent text."
52 |
53 | if self.captions is not None:
54 | caption = self.captions.get(id)
55 | if caption is not None:
56 | return caption
57 |
58 | caption = self.__class__.default_captions.get(id)
59 | if caption is not None:
60 | return caption
61 |
62 | raise KeyError(f"no caption found for ID: {id}")
63 |
--------------------------------------------------------------------------------
/pyopenapi/proxy.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Any, Callable, Optional, TypeVar
3 |
4 | import aiohttp
5 | from strong_typing.serialization import json_to_object, object_to_json
6 |
7 | from .operations import EndpointOperation, HTTPMethod, get_endpoint_operations, get_signature
8 |
9 |
10 | async def make_request(
11 | http_method: HTTPMethod,
12 | server: str,
13 | path: str,
14 | query: dict[str, str],
15 | data: Optional[str],
16 | ) -> tuple[int, str]:
17 | "Makes an asynchronous HTTP request and returns the response."
18 |
19 | headers = {"Accept": "application/json"}
20 | if data:
21 | headers["Content-Type"] = "application/json"
22 |
23 | async with aiohttp.ClientSession(server) as session:
24 | if http_method is HTTPMethod.GET:
25 | fn = session.get
26 | elif http_method is HTTPMethod.POST:
27 | fn = session.post
28 | elif http_method is HTTPMethod.PUT:
29 | fn = session.put
30 | elif http_method is HTTPMethod.DELETE:
31 | fn = session.delete
32 | elif http_method is HTTPMethod.PATCH:
33 | fn = session.patch
34 | else:
35 | raise NotImplementedError(f"unknown HTTP method: {http_method}")
36 |
37 | async with fn(path, headers=headers, params=query, data=data) as resp:
38 | body = await resp.text()
39 | return resp.status, body
40 |
41 |
42 | class ProxyInvokeError(RuntimeError):
43 | pass
44 |
45 |
46 | class EndpointProxy:
47 | "The HTTP REST proxy class for an endpoint."
48 |
49 | base_url: str
50 |
51 | def __init__(self, base_url: str) -> None:
52 | self.base_url = base_url
53 |
54 |
55 | class OperationProxy:
56 | """
57 | The HTTP REST proxy class for an endpoint operation.
58 |
59 | Extracts operation parameters from the Python API signature such as route, path and query parameters and request
60 | payload, builds an HTTP request, and processes the HTTP response.
61 | """
62 |
63 | def __init__(self, op: EndpointOperation) -> None:
64 | self.op = op
65 | self.sig = get_signature(op.func_ref)
66 |
67 | async def __call__(self, endpoint_proxy: EndpointProxy, *args: Any, **kwargs: Any) -> Any:
68 | "Invokes an API operation via HTTP REST."
69 |
70 | ba = self.sig.bind(self, *args, **kwargs)
71 |
72 | # substitute parameters in URL path
73 | route = self.op.get_route()
74 | path = route.format_map({name: ba.arguments[name] for name, _type in self.op.path_params})
75 |
76 | # gather URL query parameters
77 | query = {name: str(ba.arguments[name]) for name, _type in self.op.query_params}
78 |
79 | # assemble request body
80 | if self.op.request_param:
81 | name, _type = self.op.request_param
82 | value = ba.arguments[name]
83 | data = json.dumps(
84 | object_to_json(value),
85 | check_circular=False,
86 | indent=None,
87 | separators=(",", ":"),
88 | )
89 | else:
90 | data = None
91 |
92 | # make HTTP request
93 | status, response = await make_request(self.op.http_method, endpoint_proxy.base_url, path, query, data)
94 |
95 | # process HTTP response
96 | if response:
97 | try:
98 | s = json.loads(response)
99 | except json.JSONDecodeError:
100 | raise ProxyInvokeError(f"response body is not well-formed JSON:\n{response}")
101 |
102 | return json_to_object(self.op.response_type, s)
103 | else:
104 | return None
105 |
106 |
107 | def _get_operation_proxy(op: EndpointOperation) -> Callable[..., Any]:
108 | "Wraps an operation into a function that calls the corresponding HTTP REST API operation."
109 |
110 | operation_proxy = OperationProxy(op)
111 |
112 | async def _operation_proxy_fn(self: EndpointProxy, *args: Any, **kwargs: Any) -> Any:
113 | return await operation_proxy(self, *args, **kwargs)
114 |
115 | return _operation_proxy_fn
116 |
117 |
118 | T = TypeVar("T")
119 |
120 |
121 | def make_proxy_class(api: type[T]) -> type[T]:
122 | """
123 | Creates a proxy class for calling an HTTP REST API.
124 |
125 | :param api: The endpoint (as a Python class) that defines operations.
126 | """
127 |
128 | ops = get_endpoint_operations(api)
129 | properties = {op.func_name: _get_operation_proxy(op) for op in ops}
130 | proxy = type(f"{api.__name__}Proxy", (api, EndpointProxy), properties)
131 | return proxy
132 |
--------------------------------------------------------------------------------
/pyopenapi/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hunyadi/pyopenapi/83dda028f8b18336be3c9cf20e186499753a47f3/pyopenapi/py.typed
--------------------------------------------------------------------------------
/pyopenapi/specification.py:
--------------------------------------------------------------------------------
1 | import dataclasses
2 | import enum
3 | from dataclasses import dataclass
4 | from typing import Any, ClassVar, Optional, Union
5 |
6 | from strong_typing.schema import JsonType as JsonType
7 | from strong_typing.schema import Schema, StrictJsonType
8 |
9 | URL = str
10 |
11 |
12 | @dataclass
13 | class Ref:
14 | ref_type: ClassVar[str]
15 | id: str
16 |
17 | def to_json(self) -> StrictJsonType:
18 | return {"$ref": f"#/components/{self.ref_type}/{self.id}"}
19 |
20 |
21 | @dataclass
22 | class SchemaRef(Ref):
23 | ref_type: ClassVar[str] = "schemas"
24 |
25 |
26 | SchemaOrRef = Union[Schema, SchemaRef]
27 |
28 |
29 | @dataclass
30 | class ResponseRef(Ref):
31 | ref_type: ClassVar[str] = "responses"
32 |
33 |
34 | @dataclass
35 | class ParameterRef(Ref):
36 | ref_type: ClassVar[str] = "parameters"
37 |
38 |
39 | @dataclass
40 | class ExampleRef(Ref):
41 | ref_type: ClassVar[str] = "examples"
42 |
43 |
44 | @dataclass
45 | class Contact:
46 | name: Optional[str] = None
47 | url: Optional[URL] = None
48 | email: Optional[str] = None
49 |
50 |
51 | @dataclass
52 | class License:
53 | name: str
54 | url: Optional[URL] = None
55 |
56 |
57 | @dataclass
58 | class Info:
59 | title: str
60 | version: str
61 | description: Optional[str] = None
62 | termsOfService: Optional[str] = None
63 | contact: Optional[Contact] = None
64 | license: Optional[License] = None
65 |
66 |
67 | @dataclass
68 | class MediaType:
69 | schema: Optional[SchemaOrRef] = None
70 | example: Optional[Any] = None
71 | examples: Optional[dict[str, Union["Example", ExampleRef]]] = None
72 |
73 |
74 | @dataclass
75 | class RequestBody:
76 | content: dict[str, MediaType]
77 | description: Optional[str] = None
78 | required: Optional[bool] = None
79 |
80 |
81 | @dataclass
82 | class Response:
83 | description: str
84 | content: Optional[dict[str, MediaType]] = None
85 |
86 |
87 | @enum.unique
88 | class ParameterLocation(enum.Enum):
89 | Query = "query"
90 | Header = "header"
91 | Path = "path"
92 | Cookie = "cookie"
93 |
94 |
95 | @dataclass
96 | class Parameter:
97 | name: str
98 | in_: ParameterLocation
99 | description: Optional[str] = None
100 | required: Optional[bool] = None
101 | schema: Optional[SchemaOrRef] = None
102 | example: Optional[Any] = None
103 |
104 |
105 | @dataclass
106 | class Operation:
107 | responses: dict[str, Union[Response, ResponseRef]]
108 | tags: Optional[list[str]] = None
109 | summary: Optional[str] = None
110 | description: Optional[str] = None
111 | operationId: Optional[str] = None
112 | parameters: Optional[list[Parameter]] = None
113 | requestBody: Optional[RequestBody] = None
114 | callbacks: Optional[dict[str, "Callback"]] = None
115 | security: Optional[list["SecurityRequirement"]] = None
116 |
117 |
118 | @dataclass
119 | class PathItem:
120 | summary: Optional[str] = None
121 | description: Optional[str] = None
122 | get: Optional[Operation] = None
123 | put: Optional[Operation] = None
124 | post: Optional[Operation] = None
125 | delete: Optional[Operation] = None
126 | options: Optional[Operation] = None
127 | head: Optional[Operation] = None
128 | patch: Optional[Operation] = None
129 | trace: Optional[Operation] = None
130 |
131 | def update(self, other: "PathItem") -> None:
132 | "Merges another instance of this class into this object."
133 |
134 | for field in dataclasses.fields(self.__class__):
135 | value = getattr(other, field.name)
136 | if value is not None:
137 | setattr(self, field.name, value)
138 |
139 |
140 | # maps run-time expressions such as "$request.body#/url" to path items
141 | Callback = dict[str, PathItem]
142 |
143 |
144 | @dataclass
145 | class Example:
146 | summary: Optional[str] = None
147 | description: Optional[str] = None
148 | value: Optional[Any] = None
149 | externalValue: Optional[URL] = None
150 |
151 |
152 | @dataclass
153 | class Server:
154 | url: URL
155 | description: Optional[str] = None
156 |
157 |
158 | @enum.unique
159 | class SecuritySchemeType(enum.Enum):
160 | ApiKey = "apiKey"
161 | HTTP = "http"
162 | OAuth2 = "oauth2"
163 | OpenIDConnect = "openIdConnect"
164 |
165 |
166 | @dataclass
167 | class SecurityScheme:
168 | type: SecuritySchemeType
169 | description: str
170 |
171 |
172 | @dataclass(init=False)
173 | class SecuritySchemeAPI(SecurityScheme):
174 | name: str
175 | in_: ParameterLocation
176 |
177 | def __init__(self, description: str, name: str, in_: ParameterLocation) -> None:
178 | super().__init__(SecuritySchemeType.ApiKey, description)
179 | self.name = name
180 | self.in_ = in_
181 |
182 |
183 | @dataclass(init=False)
184 | class SecuritySchemeHTTP(SecurityScheme):
185 | scheme: str
186 | bearerFormat: Optional[str] = None
187 |
188 | def __init__(self, description: str, scheme: str, bearerFormat: Optional[str] = None) -> None:
189 | super().__init__(SecuritySchemeType.HTTP, description)
190 | self.scheme = scheme
191 | self.bearerFormat = bearerFormat
192 |
193 |
194 | @dataclass(init=False)
195 | class SecuritySchemeOpenIDConnect(SecurityScheme):
196 | openIdConnectUrl: str
197 |
198 | def __init__(self, description: str, openIdConnectUrl: str) -> None:
199 | super().__init__(SecuritySchemeType.OpenIDConnect, description)
200 | self.openIdConnectUrl = openIdConnectUrl
201 |
202 |
203 | @dataclass
204 | class Components:
205 | schemas: Optional[dict[str, Schema]] = None
206 | responses: Optional[dict[str, Response]] = None
207 | parameters: Optional[dict[str, Parameter]] = None
208 | examples: Optional[dict[str, Example]] = None
209 | requestBodies: Optional[dict[str, RequestBody]] = None
210 | securitySchemes: Optional[dict[str, SecurityScheme]] = None
211 | callbacks: Optional[dict[str, Callback]] = None
212 |
213 |
214 | SecurityScope = str
215 | SecurityRequirement = dict[str, list[SecurityScope]]
216 |
217 |
218 | @dataclass
219 | class Tag:
220 | name: str
221 | description: Optional[str] = None
222 | displayName: Optional[str] = None
223 |
224 |
225 | @dataclass
226 | class TagGroup:
227 | """
228 | A ReDoc extension to provide information about groups of tags.
229 |
230 | Exposed via the vendor-specific property "x-tagGroups" of the top-level object.
231 | """
232 |
233 | name: str
234 | tags: list[str]
235 |
236 |
237 | @dataclass
238 | class Document:
239 | """
240 | This class is a Python dataclass adaptation of the OpenAPI Specification.
241 |
242 | For details, see
243 | """
244 |
245 | openapi: str
246 | info: Info
247 | servers: list[Server]
248 | paths: dict[str, PathItem]
249 | jsonSchemaDialect: Optional[str] = None
250 | components: Optional[Components] = None
251 | security: Optional[list[SecurityRequirement]] = None
252 | tags: Optional[list[Tag]] = None
253 | tagGroups: Optional[list[TagGroup]] = None
254 |
--------------------------------------------------------------------------------
/pyopenapi/template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | OpenAPI specification
8 |
9 |
16 |
17 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/pyopenapi/utility.py:
--------------------------------------------------------------------------------
1 | import importlib.resources
2 | import json
3 | import typing
4 | from typing import TextIO
5 |
6 | from strong_typing.schema import StrictJsonType, object_to_json
7 |
8 | from .generator import Generator
9 | from .options import Options
10 | from .specification import Document
11 |
12 |
13 | class Specification:
14 | document: Document
15 |
16 | def __init__(self, endpoint: type, options: Options) -> None:
17 | generator = Generator(endpoint, options)
18 | self.document = generator.generate()
19 |
20 | def get_json(self) -> StrictJsonType:
21 | """
22 | Returns the OpenAPI specification as a Python data type (e.g. `dict` for an object, `list` for an array).
23 |
24 | The result can be serialized to a JSON string with `json.dump` or `json.dumps`.
25 | """
26 |
27 | json_doc = typing.cast(StrictJsonType, object_to_json(self.document))
28 |
29 | if isinstance(json_doc, dict):
30 | # rename vendor-specific properties
31 | tag_groups = json_doc.pop("tagGroups", None)
32 | if tag_groups:
33 | json_doc["x-tagGroups"] = tag_groups
34 | tags = json_doc.get("tags")
35 | if tags and isinstance(tags, list):
36 | for tag in tags:
37 | if not isinstance(tag, dict):
38 | continue
39 |
40 | display_name = tag.pop("displayName", None)
41 | if display_name:
42 | tag["x-displayName"] = display_name
43 |
44 | return json_doc
45 |
46 | def get_json_string(self, pretty_print: bool = False) -> str:
47 | """
48 | Returns the OpenAPI specification as a JSON string.
49 |
50 | :param pretty_print: Whether to use line indents to beautify the output.
51 | """
52 |
53 | json_doc = self.get_json()
54 | if pretty_print:
55 | return json.dumps(json_doc, check_circular=False, ensure_ascii=False, indent=4)
56 | else:
57 | return json.dumps(
58 | json_doc,
59 | check_circular=False,
60 | ensure_ascii=False,
61 | separators=(",", ":"),
62 | )
63 |
64 | def write_json(self, f: TextIO, pretty_print: bool = False) -> None:
65 | """
66 | Writes the OpenAPI specification to a file as a JSON string.
67 |
68 | :param pretty_print: Whether to use line indents to beautify the output.
69 | """
70 |
71 | json_doc = self.get_json()
72 | if pretty_print:
73 | json.dump(
74 | json_doc,
75 | f,
76 | check_circular=False,
77 | ensure_ascii=False,
78 | indent=4,
79 | )
80 | else:
81 | json.dump(
82 | json_doc,
83 | f,
84 | check_circular=False,
85 | ensure_ascii=False,
86 | separators=(",", ":"),
87 | )
88 |
89 | def write_html(self, f: TextIO, pretty_print: bool = False) -> None:
90 | """
91 | Creates a stand-alone HTML page for the OpenAPI specification with ReDoc.
92 |
93 | :param pretty_print: Whether to use line indents to beautify the JSON string in the HTML file.
94 | """
95 |
96 | with importlib.resources.files(__package__).joinpath("template.html").open(encoding="utf-8", errors="strict") as html_template_file:
97 | html_template = html_template_file.read()
98 |
99 | html = html_template.replace(
100 | "{ /* OPENAPI_SPECIFICATION */ }",
101 | self.get_json_string(pretty_print=pretty_print),
102 | )
103 |
104 | f.write(html)
105 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=42", "wheel"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [tool.ruff]
6 | line-length = 160
7 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # production dependencies
2 | aiohttp
3 | json_strong_typing
4 |
5 | # unit test dependencies
6 | Pygments
7 | PyYAML
8 | types-PyYAML
9 | types-Pygments
10 |
11 | # build dependencies
12 | build
13 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = python-openapi
3 | version = attr: pyopenapi.__version__
4 | description = Generate an OpenAPI specification from a Python class definition
5 | author = Levente Hunyadi
6 | author_email = hunyadi@gmail.com
7 | url = https://github.com/hunyadi/pyopenapi
8 | long_description = file: README.md
9 | long_description_content_type = text/markdown
10 | license = MIT
11 | classifiers =
12 | Development Status :: 5 - Production/Stable
13 | Intended Audience :: Developers
14 | License :: OSI Approved :: MIT License
15 | Operating System :: OS Independent
16 | Programming Language :: Python :: 3
17 | Programming Language :: Python :: 3.9
18 | Programming Language :: Python :: 3.10
19 | Programming Language :: Python :: 3.11
20 | Programming Language :: Python :: 3.12
21 | Programming Language :: Python :: 3.13
22 | Topic :: Software Development :: Code Generators
23 | Topic :: Software Development :: Libraries :: Python Modules
24 | Typing :: Typed
25 |
26 | [options]
27 | zip_safe = True
28 | include_package_data = True
29 | packages = find:
30 | python_requires = >=3.9
31 | install_requires =
32 | aiohttp >= 3.11
33 | json_strong_typing >= 0.3.8
34 |
35 | [options.packages.find]
36 | exclude =
37 | tests*
38 |
39 | [options.package_data]
40 | pyopenapi =
41 | *.html
42 | py.typed
43 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | if __name__ == "__main__":
4 | setup()
5 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hunyadi/pyopenapi/83dda028f8b18336be3c9cf20e186499753a47f3/tests/__init__.py
--------------------------------------------------------------------------------
/tests/endpoint.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | This is introductory text for the API. You can write a Markdown document, save it in a separate file and import the file as a global description by passing it as a parameter to the initializer of the `Specification` class.
4 |
5 | This specification has been generated with [python-openapi](https://github.com/hunyadi/pyopenapi).
6 |
--------------------------------------------------------------------------------
/tests/endpoint.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import enum
3 | import uuid
4 | from dataclasses import dataclass
5 | from typing import Callable, Generator, Optional, Protocol, Union
6 |
7 | from strong_typing.schema import json_schema_type
8 |
9 | from pyopenapi import webmethod
10 |
11 |
12 | @json_schema_type(schema={"type": "string", "format": "uri", "pattern": "^https?://"}) # type: ignore
13 | @dataclass
14 | class URL:
15 | """A Uniform Resource Locator (URL).
16 |
17 | :param url: The URL encapsulated in this object.
18 | """
19 |
20 | url: str
21 |
22 | def __str__(self) -> str:
23 | return self.url
24 |
25 |
26 | @json_schema_type
27 | @dataclass
28 | class OperationError(Exception):
29 | """
30 | Encapsulates an error from an endpoint operation.
31 |
32 | :param type: A machine-processable identifier for the error. Typically corresponds to the fully-qualified exception
33 | class (i.e. Python exception type).
34 | :param uuid: Unique identifier of the error. This identifier helps locate the exact source of the error (e.g. find
35 | the log entry in the server log stream). Make sure to include this identifier when contacting support.
36 | :param message: A human-readable description for the error for informational purposes. The exact format of the
37 | message is unspecified, and implementations should not rely on the presence of any specific information.
38 | """
39 |
40 | type: str
41 | uuid: uuid.UUID
42 | message: str
43 |
44 |
45 | @dataclass
46 | class AuthenticationError(OperationError):
47 | """
48 | Raised when the client fails to provide valid authentication credentials.
49 | """
50 |
51 |
52 | @dataclass
53 | class BadRequestError(OperationError):
54 | """
55 | The server cannot process the request due a client error.
56 |
57 | This might be due to malformed request syntax or invalid usage.
58 | """
59 |
60 |
61 | @dataclass
62 | class InternalServerError(OperationError):
63 | "The server encountered an unexpected error when processing the request."
64 |
65 |
66 | @dataclass
67 | class NotFoundError(OperationError):
68 | """
69 | Raised when an entity does not exist or has expired.
70 |
71 | :param id: The identifier of the entity not found, e.g. the UUID of a job.
72 | :param kind: The entity that is not found such as a namespace, object, person or job.
73 | """
74 |
75 | id: str
76 | kind: str
77 |
78 |
79 | @dataclass
80 | class Location:
81 | """
82 | Refers to a location in parsable text input (e.g. JSON, YAML or structured text).
83 |
84 | :param line: Line number (1-based).
85 | :param column: Column number w.r.t. the beginning of the line (1-based).
86 | :param character: Character number w.r.t. the beginning of the input (1-based).
87 | """
88 |
89 | line: int
90 | column: int
91 | character: int
92 |
93 |
94 | @dataclass
95 | class ValidationError(OperationError):
96 | """
97 | Raised when a JSON validation error occurs.
98 |
99 | :param location: Location of where invalid input was found.
100 | """
101 |
102 | location: Location
103 |
104 |
105 | class Status(enum.Enum):
106 | "Status of a job."
107 |
108 | Created = "created"
109 | Running = "running"
110 | Stopped = "stopped"
111 |
112 |
113 | class Format(enum.Enum):
114 | "Possible representation formats."
115 |
116 | HTML = "text/html"
117 | Plain = "text/plain"
118 | Markdown = "text/markdown"
119 |
120 |
121 | @json_schema_type
122 | @dataclass
123 | class Description:
124 | """
125 | A textual description as plain text or a well-known markup format.
126 |
127 | :param format: The representation format for the text.
128 | :param text: The text string.
129 | """
130 |
131 | format: Format
132 | text: str
133 |
134 |
135 | @json_schema_type
136 | @dataclass
137 | class Job:
138 | """
139 | A unit of execution.
140 |
141 | :param id: Job identifier.
142 | :param status: Current job status.
143 | :param started_at: The timestamp (in UTC) when the job was started.
144 | :param description: Additional information associated with the job.
145 | """
146 |
147 | id: uuid.UUID
148 | status: Status
149 | started_at: datetime.datetime
150 | description: Description
151 |
152 |
153 | @json_schema_type
154 | @dataclass
155 | class StatusResponse:
156 | """
157 | Triggered synchronously as the immediate response to an asynchronous operation.
158 |
159 | This response serves as an acknowledgment, and may be followed by several out-of-band events, transmitted e.g. over a websocket connection.
160 |
161 | :param id: Uniquely identifies the job which the response corresponds to.
162 | :param description: Textual description associated with the response.
163 | """
164 |
165 | id: uuid.UUID
166 | description: str
167 |
168 |
169 | @json_schema_type
170 | @dataclass
171 | class StatusEvent:
172 | """
173 | Triggered when an out-of-band event takes place.
174 |
175 | This message is typically transmitted in a separate channel, e.g. over a websocket connection.
176 |
177 | :param id: Uniquely identifies the job which the event corresponds to.
178 | :param status: The current status of the job.
179 | """
180 |
181 | id: uuid.UUID
182 | status: Status
183 |
184 |
185 | @dataclass
186 | class DataEvent:
187 | data: bytes
188 |
189 |
190 | @json_schema_type
191 | @dataclass
192 | class Person:
193 | """
194 | Represents a real person.
195 |
196 | :param family_name: The person's family name (typically last name).
197 | :param given_name: The person's given name (typically first name).
198 | """
199 |
200 | family_name: str
201 | given_name: str
202 |
203 | def __str__(self) -> str:
204 | return f"{self.given_name} {self.family_name}"
205 |
206 |
207 | @json_schema_type
208 | @dataclass
209 | class Student(Person):
210 | "A student at a university."
211 |
212 | birth_date: Optional[datetime.date] = None
213 |
214 |
215 | @json_schema_type
216 | @dataclass
217 | class Teacher(Person):
218 | "A lecturer at a university."
219 |
220 | subject: str
221 |
222 |
223 | #
224 | # Endpoint examples
225 | #
226 |
227 |
228 | class JobManagement(Protocol):
229 | """
230 | Job management.
231 |
232 | Operations to create, inspect, update and terminate jobs.
233 | """
234 |
235 | def create_job(self, items: list[URL]) -> uuid.UUID:
236 | """
237 | Creates a new job with the given data as input.
238 |
239 | :param items: A set of URLs to resources used to initiate the job.
240 | :returns: The unique identifier of the newly created job.
241 | :raises BadRequestError: URL points to an invalid location.
242 | :raises InternalServerError: Unexpected error while creating job.
243 | :raises ValidationError: The input is malformed.
244 | """
245 | ...
246 |
247 | @webmethod(
248 | response_examples=[
249 | NotFoundError(
250 | "NotFoundException",
251 | uuid.UUID("123e4567-e89b-12d3-a456-426614174000"),
252 | "Job does not exist.",
253 | "12345678-1234-5678-1234-567812345678",
254 | "job",
255 | )
256 | ],
257 | )
258 | def get_job(self, job_id: uuid.UUID, /, format: Format) -> Job:
259 | """
260 | Query status information about a job.
261 |
262 | :param job_id: Unique identifier for the job to query.
263 | :returns: Status information about the job.
264 | :raises NotFoundError: The job does not exist.
265 | :raises ValidationError: The input is malformed.
266 | """
267 | ...
268 |
269 | def remove_job(self, job_id: uuid.UUID, /) -> None:
270 | """
271 | Terminates a job.
272 |
273 | :param job_id: Unique identifier for the job to terminate.
274 | """
275 | ...
276 |
277 | def update_job(self, job_id: uuid.UUID, /, job: Job) -> None:
278 | """
279 | Updates information related to a job.
280 |
281 | May cause the job to be stopped and restarted.
282 |
283 | :param job_id: Unique identifier for the job to update.
284 | :param job: Data to update the job with.
285 | :raises ValidationError: The input is malformed.
286 | """
287 | ...
288 |
289 | def get_status(self, job_id: uuid.UUID, /) -> Generator[StatusEvent, None, StatusResponse]:
290 | """
291 | Provides asynchronous status information about a job.
292 |
293 | This operation is defined with the special return type of `Generator`. `Generator[Y,S,R]` has the yield type
294 | `Y`, the send type `S` of `None`, and the return type `R`. `R` is the response type immediately returned by
295 | a call to this operation. However, the client will receive out-of-band events of type `Y` over a different
296 | channel, e.g. a websocket connection or an HTTP callback.
297 | """
298 |
299 | # a list of out-of-band events triggered by the endpoint asynchronously
300 | data_event: Callable[[DataEvent], None]
301 |
302 |
303 | class PeopleCatalog(Protocol):
304 | """
305 | Operations related to people.
306 | """
307 |
308 | @webmethod(route="/person/id/{id}")
309 | def get_person_by_id(self, id: str, /) -> Person:
310 | """
311 | Find a person by their identifier.
312 |
313 | This operation has a custom route associated with it.
314 | """
315 | ...
316 |
317 | @webmethod(
318 | route="/person/name/{family}/{given}",
319 | response_example=Person("Hunyadi", "Levente"),
320 | )
321 | def get_person_by_name(self, family: str, given: str, /) -> Person:
322 | """
323 | Find a person by their name.
324 |
325 | This operation has a custom route associated with it.
326 | """
327 | ...
328 |
329 | @webmethod(
330 | route="/member/name/{family}/{given}",
331 | response_examples=[
332 | Student("Szörnyeteg", "Lajos"),
333 | Student("Ló", "Szerafin"),
334 | Student("Bruckner", "Szigfrid"),
335 | Student("Nagy", "Zoárd"),
336 | Teacher("Mikka", "Makka", "Négyszögletű Kerek Erdő"),
337 | Teacher("Vacska", "Mati", "Négyszögletű Kerek Erdő"),
338 | ],
339 | )
340 | def get_member_by_name(self, family: str, given: str, /) -> Union[Student, Teacher]:
341 | """
342 | Find a member by their name.
343 |
344 | This operation has multiple response payload types.
345 | """
346 | ...
347 |
348 |
349 | #
350 | # Authentication and authorization
351 | #
352 |
353 |
354 | @dataclass
355 | class Credentials:
356 | """
357 | Authentication credentials.
358 |
359 | :param client_id: An API key.
360 | :param client_secret: The secret that corresponds to the API key.
361 | """
362 |
363 | client_id: str
364 | client_secret: str
365 |
366 |
367 | @dataclass
368 | class TokenProperties:
369 | """
370 | Authentication/authorization token issued in response to an authentication request.
371 |
372 | :param access_token: A base64-encoded access token string with header, payload and signature parts.
373 | :param expires_at: Expiry of the access token. This field is informational, the timestamp is also embedded in the access token.
374 | """
375 |
376 | access_token: str
377 | expires_at: datetime.datetime
378 |
379 |
380 | class Endpoint(JobManagement, PeopleCatalog, Protocol):
381 | @webmethod(route="/auth", public=True)
382 | def do_authenticate(self, credentials: Credentials) -> TokenProperties:
383 | """
384 | Issues a JSON Web Token (JWT) to be passed to API calls.
385 |
386 | :raises AuthenticationError: Client lacks valid authentication credentials.
387 | :raises ValidationError: The input is malformed.
388 | """
389 | ...
390 |
--------------------------------------------------------------------------------
/tests/test_openapi.py:
--------------------------------------------------------------------------------
1 | import os
2 | import os.path
3 | import unittest
4 | from datetime import datetime
5 | from http import HTTPStatus
6 | from typing import TextIO
7 | from uuid import UUID
8 |
9 | from endpoint import AuthenticationError, BadRequestError, Endpoint, InternalServerError, NotFoundError, Student, Teacher, ValidationError
10 |
11 | from pyopenapi import Info, Options, Server, Specification
12 | from pyopenapi.specification import SecuritySchemeHTTP
13 |
14 | try:
15 | from pygments import highlight
16 | from pygments.formatter import Formatter
17 | from pygments.formatters import HtmlFormatter
18 | from pygments.lexers import get_lexer_by_name
19 |
20 | def save_with_highlight(f: TextIO, code: str, format: str) -> None:
21 | lexer = get_lexer_by_name(format)
22 | formatter: Formatter = HtmlFormatter()
23 | style = formatter.get_style_defs(".highlight")
24 | f.writelines(
25 | [
26 | "",
27 | "",
28 | "",
29 | '',
30 | '',
31 | f"",
32 | "",
33 | "",
34 | ]
35 | )
36 | highlight(code, lexer, formatter, outfile=f)
37 | f.writelines(["", ""])
38 |
39 | except ImportError:
40 |
41 | def save_with_highlight(f: TextIO, code: str, format: str) -> None:
42 | pass
43 |
44 |
45 | class ExampleType:
46 | """
47 | An example type with a few properties.
48 |
49 | :param uuid: Uniquely identifies this instance.
50 | :param count: A sample property of an integer type.
51 | :param value: A sample property of a string type.
52 | :param created_at: A timestamp. The date type is identified with OpenAPI's format string.
53 | :param updated_at: A timestamp.
54 | """
55 |
56 | uuid: UUID
57 | count: int
58 | value: str
59 | created_at: datetime
60 | updated_at: datetime
61 |
62 |
63 | class UnreferencedType:
64 | "A type not referenced from anywhere else but passed as an additional type to the initializer of the class `Specification`."
65 |
66 |
67 | class TestOpenAPI(unittest.TestCase):
68 | root: str
69 | specification: Specification
70 |
71 | def setUp(self) -> None:
72 | super().setUp()
73 |
74 | with open(os.path.join(os.path.dirname(__file__), "endpoint.md"), "r") as f:
75 | description = f.read()
76 |
77 | self.root = os.path.join(os.path.dirname(__file__), "..", "website", "examples")
78 | os.makedirs(self.root, exist_ok=True)
79 | self.specification = Specification(
80 | Endpoint,
81 | Options(
82 | server=Server(url="http://example.com/api"),
83 | info=Info(
84 | title="Example specification",
85 | version="1.0",
86 | description=description,
87 | ),
88 | default_security_scheme=SecuritySchemeHTTP(
89 | "Authenticates a request by verifying a JWT (JSON Web Token) passed in the `Authorization` HTTP header.",
90 | "bearer",
91 | "JWT",
92 | ),
93 | extra_types=[ExampleType, UnreferencedType],
94 | success_responses={
95 | Student: HTTPStatus.CREATED,
96 | Teacher: HTTPStatus.ACCEPTED,
97 | },
98 | error_responses={
99 | AuthenticationError: HTTPStatus.UNAUTHORIZED,
100 | BadRequestError: 400,
101 | InternalServerError: 500,
102 | NotFoundError: HTTPStatus.NOT_FOUND,
103 | ValidationError: 400,
104 | },
105 | error_wrapper=True,
106 | ),
107 | )
108 |
109 | def test_json(self) -> None:
110 | json_dir = os.path.join(self.root, "json")
111 | os.makedirs(json_dir, exist_ok=True)
112 |
113 | path = os.path.join(json_dir, "openapi.json")
114 | with open(path, "w", encoding="utf-8") as f:
115 | self.specification.write_json(f, pretty_print=True)
116 |
117 | code = self.specification.get_json_string(pretty_print=True)
118 | path = os.path.join(json_dir, "index.html")
119 | with open(path, "w", encoding="utf-8") as f:
120 | save_with_highlight(f, code, "json")
121 |
122 | def test_yaml(self) -> None:
123 | try:
124 | import yaml
125 |
126 | yaml_dir = os.path.join(self.root, "yaml")
127 | os.makedirs(yaml_dir, exist_ok=True)
128 |
129 | path = os.path.join(yaml_dir, "openapi.yaml")
130 | with open(path, "w", encoding="utf-8") as f:
131 | yaml.dump(self.specification.get_json(), f, allow_unicode=True)
132 |
133 | code = yaml.dump(self.specification.get_json(), allow_unicode=True)
134 | path = os.path.join(yaml_dir, "index.html")
135 | with open(path, "w", encoding="utf-8") as f:
136 | save_with_highlight(f, code, "yaml")
137 |
138 | except ImportError:
139 | self.skipTest("package PyYAML is required for `*.yaml` output")
140 |
141 | def test_html(self) -> None:
142 | path = os.path.join(self.root, "index.html")
143 | with open(path, "w", encoding="utf-8") as f:
144 | self.specification.write_html(f, pretty_print=False)
145 |
146 | def test_python(self) -> None:
147 | source = os.path.join(os.path.dirname(__file__), "endpoint.py")
148 | with open(source, "r", encoding="utf-8") as f:
149 | code = f.read()
150 |
151 | python_dir = os.path.join(self.root, "python")
152 | os.makedirs(python_dir, exist_ok=True)
153 | path = os.path.join(python_dir, "openapi.py")
154 | with open(path, "w", encoding="utf-8") as f:
155 | f.write(code)
156 |
157 | path = os.path.join(python_dir, "index.html")
158 | with open(path, "w", encoding="utf-8") as f:
159 | save_with_highlight(f, code, "python")
160 |
161 |
162 | if __name__ == "__main__":
163 | unittest.main()
164 |
--------------------------------------------------------------------------------
/tests/test_proxy.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from dataclasses import dataclass
3 | from typing import Optional, Protocol
4 |
5 | from strong_typing.schema import JsonType
6 |
7 | from pyopenapi import webmethod
8 | from pyopenapi.proxy import make_proxy_class
9 |
10 |
11 | @dataclass
12 | class Document:
13 | title: str
14 | text: str
15 |
16 |
17 | @dataclass
18 | class HTTPBinResponse:
19 | args: dict[str, str]
20 | headers: dict[str, str]
21 | origin: str
22 | url: str
23 |
24 |
25 | @dataclass
26 | class HTTPBinPostResponse(HTTPBinResponse):
27 | data: str
28 | json: JsonType
29 | files: dict[str, str]
30 | form: dict[str, str]
31 |
32 |
33 | class API(Protocol):
34 | @webmethod(route="/get")
35 | async def get_method(self, /, id: str) -> HTTPBinResponse: ...
36 |
37 | @webmethod(route="/put")
38 | async def set_method(self, /, id: str, doc: Document) -> HTTPBinPostResponse: ...
39 |
40 |
41 | class TestOpenAPI(unittest.IsolatedAsyncioTestCase):
42 | def assertDictSubset(self, subset: dict, superset: dict) -> None:
43 | self.assertLessEqual(subset.items(), superset.items())
44 |
45 | def assertResponse(
46 | self,
47 | response: HTTPBinResponse,
48 | params: dict[str, str],
49 | headers: Optional[dict[str, str]] = None,
50 | ) -> None:
51 | self.assertIsInstance(response, HTTPBinResponse)
52 | self.assertDictEqual(response.args, params)
53 | if headers:
54 | self.assertDictSubset(headers, response.headers)
55 |
56 | async def test_http(self) -> None:
57 | Proxy = make_proxy_class(API) # type: ignore
58 | proxy = Proxy("http://httpbin.org") # type: ignore
59 |
60 | response = await proxy.get_method("abc")
61 | self.assertResponse(response, params={"id": "abc"})
62 |
63 | response = await proxy.set_method("abc", Document("title", "text"))
64 | self.assertResponse(response, params={"id": "abc"}, headers={"Content-Type": "application/json"})
65 |
66 |
67 | if __name__ == "__main__":
68 | unittest.main()
69 |
--------------------------------------------------------------------------------