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