├── openapify ├── py.typed ├── core │ ├── __init__.py │ ├── openapi │ │ ├── __init__.py │ │ └── models.py │ ├── utils.py │ ├── base_plugins.py │ ├── const.py │ ├── models.py │ ├── document.py │ └── builder.py ├── ext │ ├── __init__.py │ └── web │ │ ├── __init__.py │ │ └── aiohttp.py ├── __init__.py ├── plugin.py └── decorators.py ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── requirements-dev.txt ├── pyproject.toml ├── .editorconfig ├── .gitignore ├── setup.py ├── LICENSE └── README.md /openapify/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /openapify/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /openapify/ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /openapify/ext/web/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /openapify/core/openapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://coindrop.to/tikhonov_a'] 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # tests 2 | mypy>=1.0 3 | flake8>=3.8.4 4 | isort>=5.6.4 5 | black>=23.3.0 6 | codespell>=2.2.2 7 | -------------------------------------------------------------------------------- /openapify/core/utils.py: -------------------------------------------------------------------------------- 1 | from mashumaro.core.meta.helpers import get_type_origin 2 | 3 | from openapify.core.models import TypeAnnotation 4 | 5 | 6 | def get_value_type(value_type: TypeAnnotation) -> TypeAnnotation: 7 | super_type = getattr(value_type, "__supertype__", None) 8 | if super_type is not None: 9 | return get_value_type(super_type) 10 | origin_type = get_type_origin(value_type) 11 | if origin_type is not value_type: 12 | return get_value_type(origin_type) 13 | return value_type 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | * openapify version: 11 | * Python version: 12 | * Operating System: 13 | 14 | ### Description 15 | 16 | Describe what you were trying to get done. 17 | Tell us what happened, what went wrong, and what you expected to happen. 18 | 19 | ### What I Did 20 | 21 | ``` 22 | Paste the code, command(s) you ran and the output. 23 | If there was a crash, please include the traceback here. 24 | ``` 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | ignore_missing_imports = true 3 | disallow_untyped_defs = true 4 | disallow_incomplete_defs = true 5 | 6 | [[tool.mypy.overrides]] 7 | module = [ 8 | 'openapify.core.openapi.models', 9 | ] 10 | disable_error_code = 'override' 11 | 12 | [flake8] 13 | max-line-length = 79 14 | 15 | [tool.isort] 16 | profile = 'black' 17 | line_length = 79 18 | multi_line_output = 3 19 | include_trailing_comma = true 20 | ensure_newline_before_comments = true 21 | 22 | [tool.black] 23 | line-length = 79 24 | target-version = ['py39', 'py310', 'py311', 'py312'] 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 4 12 | trim_trailing_whitespace = true 13 | charset = utf-8 14 | max_line_length=79 15 | 16 | [Makefile] 17 | indent_style = tab 18 | 19 | [*.{yml,yaml,feature,json,toml}] 20 | indent_size = 2 21 | 22 | [*.{tsv,csv}] 23 | trim_trailing_whitespace = false 24 | 25 | [*.rst] 26 | max_line_length = 80 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context about the feature request here. 21 | -------------------------------------------------------------------------------- /openapify/__init__.py: -------------------------------------------------------------------------------- 1 | from .core.builder import build_spec 2 | from .core.document import OpenAPIDocument 3 | from .core.models import Body, Header, PathParam, QueryParam 4 | from .core.openapi.models import Example 5 | from .decorators import ( 6 | operation_docs, 7 | request_schema, 8 | response_schema, 9 | security_requirements, 10 | ) 11 | from .plugin import BasePlugin 12 | 13 | __all__ = [ 14 | "build_spec", 15 | "operation_docs", 16 | "request_schema", 17 | "response_schema", 18 | "security_requirements", 19 | "OpenAPIDocument", 20 | "Body", 21 | "Header", 22 | "QueryParam", 23 | "PathParam", 24 | "Example", 25 | "BasePlugin", 26 | ] 27 | -------------------------------------------------------------------------------- /openapify/plugin.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, Union 2 | 3 | from apispec import APISpec 4 | 5 | from openapify.core.models import Body, Cookie, Header, PathParam, QueryParam 6 | 7 | 8 | class BasePlugin: 9 | spec: APISpec 10 | 11 | def init_spec(self, spec: APISpec) -> None: 12 | self.spec = spec 13 | 14 | def schema_helper( 15 | self, 16 | obj: Union[Body, Cookie, Header, QueryParam, PathParam], 17 | name: Optional[str] = None, 18 | ) -> Optional[Dict[str, Any]]: 19 | raise NotImplementedError 20 | 21 | def media_type_helper( 22 | self, body: Body, schema: Dict[str, Any] 23 | ) -> Optional[str]: 24 | raise NotImplementedError 25 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test-code-style: 13 | name: Code style tests 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | pip install --upgrade pip 27 | pip install . 28 | pip install -r requirements-dev.txt 29 | - name: Run flake8 30 | run: flake8 openapify --ignore=E203,W503,E704 31 | - name: Run mypy 32 | run: mypy openapify 33 | - name: Run black 34 | run: black --check . 35 | - name: Run codespell 36 | run: codespell openapify tests README.md 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # PyCharm 60 | .idea 61 | 62 | # pyenv 63 | .python-version 64 | 65 | # OSX 66 | .DS_Store 67 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import find_packages, setup 4 | 5 | setup( 6 | name="openapify", 7 | version="0.7", 8 | description=( 9 | "Framework agnostic OpenAPI Specification generation for code lovers" 10 | ), 11 | long_description=open("README.md", encoding="utf8").read(), 12 | long_description_content_type="text/markdown", 13 | platforms="all", 14 | classifiers=[ 15 | "License :: OSI Approved :: Apache Software License", 16 | "Intended Audience :: Developers", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Development Status :: 3 - Alpha", 24 | ], 25 | license="Apache License, Version 2.0", 26 | author="Alexander Tikhonov", 27 | author_email="random.gauss@gmail.com", 28 | url="https://github.com/Fatal1ty/openapify", 29 | packages=find_packages(include=("openapify", "openapify.*")), 30 | package_data={"openapify": ["py.typed"]}, 31 | python_requires=">=3.8", 32 | install_requires=[ 33 | "apispec", 34 | "mashumaro>=3.15", 35 | ], 36 | extras_require={ 37 | "aiohttp": ["aiohttp"], 38 | }, 39 | zip_safe=False, 40 | ) 41 | -------------------------------------------------------------------------------- /openapify/core/base_plugins.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from typing import Any, Dict, Optional, Union 3 | 4 | from mashumaro.jsonschema import OPEN_API_3_1, JSONSchemaBuilder 5 | from mashumaro.jsonschema.plugins import BasePlugin as BaseJSONSchemaPlugin 6 | 7 | from openapify.core.models import Body, Cookie, Header, PathParam, QueryParam 8 | from openapify.core.utils import get_value_type 9 | from openapify.plugin import BasePlugin 10 | 11 | 12 | class BodyBinaryPlugin(BasePlugin): 13 | def schema_helper( 14 | self, 15 | obj: Union[Body, Cookie, Header, QueryParam, PathParam], 16 | name: Optional[str] = None, 17 | ) -> Optional[Dict[str, Any]]: 18 | try: 19 | if isinstance(obj, Body): 20 | if get_value_type(obj.value_type) in (bytes, bytearray): 21 | return {} 22 | 23 | return None 24 | except TypeError: 25 | return None 26 | 27 | 28 | class GuessMediaTypePlugin(BasePlugin): 29 | def media_type_helper( 30 | self, body: Body, schema: Dict[str, Any] 31 | ) -> Optional[str]: 32 | if not schema and get_value_type(body.value_type) in ( 33 | bytes, 34 | bytearray, 35 | ): 36 | return "application/octet-stream" 37 | else: 38 | return "application/json" 39 | 40 | 41 | class BaseSchemaPlugin(BasePlugin): 42 | def __init__(self, plugins: Sequence[BaseJSONSchemaPlugin] = ()): 43 | self.json_schema_plugins = plugins 44 | 45 | def schema_helper( 46 | self, 47 | obj: Union[Body, Cookie, Header, QueryParam, PathParam], 48 | name: Optional[str] = None, 49 | ) -> Optional[Dict[str, Any]]: 50 | builder = JSONSchemaBuilder( 51 | dialect=OPEN_API_3_1, 52 | ref_prefix="#/components/schemas", 53 | plugins=self.json_schema_plugins, 54 | ) 55 | try: 56 | json_schema = builder.build(obj.value_type) 57 | except Exception: 58 | return None 59 | schemas = self.spec.components.schemas 60 | for name, schema in builder.context.definitions.items(): 61 | schemas[name] = schema.to_dict() 62 | if isinstance(obj, QueryParam) and obj.default is not None: 63 | json_schema.default = obj.default 64 | return json_schema.to_dict() 65 | -------------------------------------------------------------------------------- /openapify/core/const.py: -------------------------------------------------------------------------------- 1 | DEFAULT_SPEC_TITLE = "API" 2 | DEFAULT_SPEC_VERSION = "1.0.0" 3 | DEFAULT_OPENAPI_VERSION = "3.1.0" 4 | 5 | RESPONSE_DESCRIPTIONS = { 6 | "1XX": "Information Response", 7 | "100": "Continue", 8 | "101": "Switching Protocols", 9 | "102": "Processing", 10 | "103": "Early Hints", 11 | "2XX": "Successful Response", 12 | "200": "OK", 13 | "201": "Created", 14 | "202": "Accepted", 15 | "203": "Non-Authoritative Information", 16 | "204": "No Content", 17 | "205": "Reset Content", 18 | "206": "Partial Content", 19 | "207": "Multi-Status", 20 | "208": "Already Reported", 21 | "226": "IM Used", 22 | "3XX": "Redirection", 23 | "300": "Multiple Choices", 24 | "301": "Moved Permanently", 25 | "302": "Found", 26 | "303": "See Other", 27 | "304": "Not Modified", 28 | "307": "Temporary Redirect", 29 | "308": "Permanent Redirect", 30 | "4XX": "Client Error", 31 | "400": "Bad Request", 32 | "401": "Unauthorized", 33 | "402": "Payment Required", 34 | "403": "Forbidden", 35 | "404": "Not Found", 36 | "405": "Method Not Allowed", 37 | "406": "Not Acceptable", 38 | "407": "Proxy Authentication Required", 39 | "408": "Request Timeout", 40 | "409": "Conflict", 41 | "410": "Gone", 42 | "411": "Length Required", 43 | "412": "Precondition Failed", 44 | "413": "Payload Too Large", 45 | "414": "URI Too Long", 46 | "415": "Unsupported Media Type", 47 | "416": "Range Not Satisfiable", 48 | "417": "Expectation Failed", 49 | "418": "I'm a teapot", 50 | "421": "Misdirected Request", 51 | "422": "Unprocessable Content", 52 | "423": "Locked", 53 | "424": "Failed Dependency", 54 | "425": "Too Early", 55 | "426": "Upgrade Required", 56 | "428": "Precondition Required", 57 | "429": "Too Many Requests", 58 | "431": "Request Header Fields Too Large", 59 | "451": "Unavailable For Legal Reasons", 60 | "5XX": "Server Error", 61 | "500": "Internal Server Error", 62 | "501": "Not Implemented", 63 | "502": "Bad Gateway", 64 | "503": "Service Unavailable", 65 | "504": "Gateway Timeout", 66 | "505": "HTTP Version Not Supported", 67 | "506": "Variant Also Negotiates", 68 | "507": "Insufficient Storage", 69 | "508": "Loop Detected", 70 | "510": "Not Extended", 71 | "511": "Network Authentication Required", 72 | } 73 | -------------------------------------------------------------------------------- /openapify/core/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Dict, List, Mapping, Optional, Type, Union 3 | 4 | from typing_extensions import TypeAlias 5 | 6 | from openapify.core.openapi.models import ( 7 | Example, 8 | ParameterStyle, 9 | SecurityScheme, 10 | ) 11 | 12 | SecurityRequirement: TypeAlias = Mapping[str, "SecurityScheme"] 13 | 14 | # https://github.com/python/mypy/issues/9773 15 | TypeAnnotation: TypeAlias = Any 16 | 17 | 18 | @dataclass 19 | class PathParam: 20 | value_type: TypeAnnotation = str 21 | description: Optional[str] = None 22 | example: Optional[Any] = None 23 | examples: Optional[Mapping[str, Union[Example, Any]]] = None 24 | 25 | 26 | @dataclass 27 | class RouteDef: 28 | path: str 29 | method: str 30 | handler: Any 31 | summary: Optional[str] = None 32 | description: Optional[str] = None 33 | path_params: Optional[Dict[str, PathParam]] = None 34 | tags: Optional[List[str]] = None 35 | 36 | 37 | @dataclass 38 | class Body: 39 | value_type: TypeAnnotation 40 | media_type: Optional[str] = None 41 | required: Optional[bool] = None 42 | description: Optional[str] = None 43 | example: Optional[Any] = None 44 | examples: Optional[Mapping[str, Union[Example, Any]]] = None 45 | 46 | 47 | @dataclass 48 | class Header: 49 | description: Optional[str] = None 50 | required: Optional[bool] = None 51 | value_type: TypeAnnotation = str 52 | deprecated: Optional[bool] = None 53 | allowEmptyValue: Optional[bool] = None 54 | example: Optional[Any] = None 55 | examples: Optional[Mapping[str, Union[Example, Any]]] = None 56 | 57 | 58 | @dataclass 59 | class Cookie: 60 | description: Optional[str] = None 61 | required: Optional[bool] = None 62 | value_type: Type = str 63 | deprecated: Optional[bool] = None 64 | allowEmptyValue: Optional[bool] = None 65 | example: Optional[Any] = None 66 | examples: Optional[Mapping[str, Union[Example, Any]]] = None 67 | 68 | 69 | @dataclass 70 | class QueryParam: 71 | value_type: TypeAnnotation = str 72 | default: Optional[Any] = None 73 | required: Optional[bool] = None 74 | description: Optional[str] = None 75 | deprecated: Optional[bool] = None 76 | allowEmptyValue: Optional[bool] = None 77 | style: Optional[ParameterStyle] = None 78 | explode: Optional[bool] = None 79 | example: Optional[Any] = None 80 | examples: Optional[Mapping[str, Union[Example, Any]]] = None 81 | -------------------------------------------------------------------------------- /openapify/core/document.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Mapping, Optional, Sequence, Union 2 | 3 | from apispec import APISpec, BasePlugin 4 | 5 | from openapify.core.const import ( 6 | DEFAULT_OPENAPI_VERSION, 7 | DEFAULT_SPEC_TITLE, 8 | DEFAULT_SPEC_VERSION, 9 | ) 10 | from openapify.core.openapi.models import SecurityScheme, Server 11 | 12 | 13 | def merge_dicts(original: Dict, update: Dict) -> Dict: 14 | for key, value in update.items(): 15 | if key not in original: 16 | original[key] = value 17 | elif isinstance(value, dict): 18 | merge_dicts(original[key], value) 19 | return original 20 | 21 | 22 | class OpenAPIDocument(APISpec): 23 | def __init__( 24 | self, 25 | title: str = DEFAULT_SPEC_TITLE, 26 | version: str = DEFAULT_SPEC_VERSION, 27 | openapi_version: str = DEFAULT_OPENAPI_VERSION, 28 | plugins: Sequence[BasePlugin] = (), 29 | servers: Optional[List[Union[str, Server]]] = None, 30 | security_schemes: Optional[Mapping[str, SecurityScheme]] = None, 31 | **options: Any, 32 | ) -> None: 33 | kwargs = {} 34 | _servers = [] 35 | for server in servers or (): 36 | if isinstance(server, str): 37 | _servers.append({"url": server}) 38 | else: 39 | _servers.append(server.to_dict()) 40 | if _servers: 41 | kwargs["servers"] = _servers 42 | super().__init__( 43 | title=title, 44 | version=version, 45 | openapi_version=openapi_version, 46 | plugins=plugins, 47 | **kwargs, 48 | **options, 49 | ) 50 | if security_schemes: 51 | for name, scheme in security_schemes.items(): 52 | self.components.security_scheme(name, scheme.to_dict()) 53 | 54 | def to_dict(self) -> Dict[str, Any]: 55 | ret: Dict[str, Any] = { 56 | "openapi": str(self.openapi_version), 57 | "info": {"title": self.title, "version": self.version}, 58 | } 59 | servers = self.options.pop("servers", None) 60 | if servers: 61 | ret["servers"] = servers 62 | ret["paths"] = self._paths 63 | components_dict = self.components.to_dict() 64 | if components_dict: 65 | ret["components"] = components_dict 66 | if self._tags: 67 | ret["tags"] = self._tags 68 | ret = merge_dicts(ret, self.options) 69 | return ret 70 | -------------------------------------------------------------------------------- /openapify/ext/web/aiohttp.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import ( 3 | Any, 4 | Callable, 5 | Dict, 6 | Iterable, 7 | List, 8 | Mapping, 9 | Optional, 10 | Protocol, 11 | Sequence, 12 | Tuple, 13 | Type, 14 | Union, 15 | overload, 16 | ) 17 | 18 | from aiohttp import hdrs 19 | from aiohttp.abc import AbstractView 20 | from aiohttp.typedefs import Handler 21 | from aiohttp.web_app import Application 22 | from apispec import APISpec 23 | from mashumaro.jsonschema.annotations import Pattern 24 | from typing_extensions import Annotated 25 | 26 | from openapify.core.builder import build_spec as core_build_spec 27 | from openapify.core.const import ( 28 | DEFAULT_OPENAPI_VERSION, 29 | DEFAULT_SPEC_TITLE, 30 | DEFAULT_SPEC_VERSION, 31 | ) 32 | from openapify.core.models import PathParam, RouteDef 33 | from openapify.core.openapi.models import SecurityScheme, Server 34 | from openapify.plugin import BasePlugin 35 | 36 | PARAMETER_TEMPLATE = re.compile(r"{([^:{}]+)(?::(.+))?}") 37 | 38 | 39 | class AioHttpRouteDef(Protocol): 40 | method: str 41 | path: str 42 | handler: Union[Type[AbstractView], Handler] 43 | 44 | 45 | def _aiohttp_app_to_route_defs(app: Application) -> Iterable[RouteDef]: 46 | for route in app.router.routes(): 47 | yield RouteDef( 48 | path=route.resource.canonical, # type: ignore 49 | method=route.method, 50 | handler=route.handler, 51 | ) 52 | 53 | 54 | def _aiohttp_route_defs_to_route_defs( 55 | route_defs: Iterable[AioHttpRouteDef], 56 | ) -> Iterable[RouteDef]: 57 | for route in route_defs: 58 | if route.method == hdrs.METH_ANY: 59 | for method in map(str.lower, hdrs.METH_ALL): 60 | handler = getattr(route.handler, method, None) 61 | if handler: 62 | yield RouteDef(route.path, method, handler) 63 | else: 64 | yield RouteDef(route.path, route.method, route.handler) 65 | 66 | 67 | def _pull_out_path_parameters( 68 | route: RouteDef, 69 | ) -> Tuple[str, Dict[str, PathParam]]: 70 | parameters = {} 71 | 72 | def _sub(match: re.Match) -> str: 73 | name = match.group(1) 74 | regex = match.group(2) 75 | if regex: 76 | instance_type = Annotated[str, Pattern(regex)] 77 | else: 78 | instance_type = str # type: ignore[misc] 79 | parameters[name] = PathParam(value_type=instance_type) 80 | return f"{{{name}}}" 81 | 82 | return re.sub(PARAMETER_TEMPLATE, _sub, route.path), parameters 83 | 84 | 85 | def _complete_routes(routes: Iterable[RouteDef]) -> Iterable[RouteDef]: 86 | for route in routes: 87 | route.path, parameters = _pull_out_path_parameters(route) 88 | if parameters: 89 | route.path_params = parameters 90 | yield route 91 | 92 | 93 | @overload 94 | def build_spec( 95 | app: Application, spec: Optional[APISpec] = None 96 | ) -> APISpec: ... 97 | 98 | 99 | @overload 100 | def build_spec( 101 | routes: Iterable[AioHttpRouteDef], spec: Optional[APISpec] = None 102 | ) -> APISpec: ... 103 | 104 | 105 | @overload 106 | def build_spec( 107 | app: Application, 108 | *, 109 | title: str = DEFAULT_SPEC_TITLE, 110 | version: str = DEFAULT_SPEC_VERSION, 111 | openapi_version: str = DEFAULT_OPENAPI_VERSION, 112 | plugins: Sequence[BasePlugin] = (), 113 | route_postprocessor: Optional[ 114 | Callable[[RouteDef], Union[RouteDef, None]] 115 | ] = None, 116 | **options: Any, 117 | ) -> APISpec: ... 118 | 119 | 120 | @overload 121 | def build_spec( 122 | routes: Iterable[AioHttpRouteDef], 123 | *, 124 | title: str = DEFAULT_SPEC_TITLE, 125 | version: str = DEFAULT_SPEC_VERSION, 126 | openapi_version: str = DEFAULT_OPENAPI_VERSION, 127 | plugins: Sequence[BasePlugin] = (), 128 | route_postprocessor: Optional[ 129 | Callable[[RouteDef], Union[RouteDef, None]] 130 | ] = None, 131 | **options: Any, 132 | ) -> APISpec: ... 133 | 134 | 135 | def build_spec( # type: ignore[misc] 136 | app_or_routes: Union[Application, Iterable[AioHttpRouteDef]], 137 | spec: Optional[APISpec] = None, 138 | *, 139 | title: str = DEFAULT_SPEC_TITLE, 140 | version: str = DEFAULT_SPEC_VERSION, 141 | openapi_version: str = DEFAULT_OPENAPI_VERSION, 142 | plugins: Sequence[BasePlugin] = (), 143 | servers: Optional[List[Union[str, Server]]] = None, 144 | security_schemes: Optional[Mapping[str, SecurityScheme]] = None, 145 | route_postprocessor: Optional[ 146 | Callable[[RouteDef], Union[RouteDef, None]] 147 | ] = None, 148 | **options: Any, 149 | ) -> APISpec: 150 | if isinstance(app_or_routes, Application): 151 | routes = _aiohttp_app_to_route_defs(app_or_routes) 152 | else: 153 | routes = _aiohttp_route_defs_to_route_defs(app_or_routes) 154 | routes = _complete_routes(routes) 155 | if route_postprocessor: 156 | routes = filter(None, map(route_postprocessor, routes)) 157 | return core_build_spec( 158 | routes=routes, 159 | spec=spec, 160 | title=title, 161 | version=version, 162 | openapi_version=openapi_version, 163 | plugins=plugins, 164 | servers=servers, 165 | security_schemes=security_schemes, 166 | **options, 167 | ) 168 | 169 | 170 | __all__ = ["build_spec"] 171 | -------------------------------------------------------------------------------- /openapify/core/openapi/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Any, Dict, List, Mapping, Optional, Union 4 | 5 | from mashumaro import DataClassDictMixin 6 | from mashumaro.config import BaseConfig 7 | from typing_extensions import Literal, TypeAlias 8 | 9 | HttpCode: TypeAlias = Union[str, int] 10 | Schema: TypeAlias = Mapping[str, Any] 11 | 12 | 13 | @dataclass 14 | class Object(DataClassDictMixin): 15 | class Config(BaseConfig): 16 | omit_none = True 17 | 18 | 19 | @dataclass 20 | class ServerVariable(Object): 21 | default: str 22 | enum: Optional[List[str]] = None 23 | description: Optional[str] = None 24 | 25 | 26 | @dataclass 27 | class Server(Object): 28 | url: str 29 | description: Optional[str] = None 30 | variables: Optional[Mapping[str, ServerVariable]] = None 31 | 32 | 33 | @dataclass 34 | class Example(Object): 35 | value: Optional[Any] = None 36 | summary: Optional[str] = None 37 | description: Optional[str] = None 38 | 39 | 40 | @dataclass 41 | class Header(Object): 42 | schema: Schema 43 | description: Optional[str] = None 44 | required: Optional[bool] = None 45 | deprecated: Optional[bool] = None 46 | allowEmptyValue: Optional[bool] = None 47 | example: Optional[Any] = None 48 | examples: Optional[Mapping[str, Example]] = None 49 | 50 | 51 | @dataclass 52 | class MediaType(Object): 53 | schema: Optional[Schema] = None 54 | example: Optional[Any] = None 55 | examples: Optional[Mapping[str, Example]] = None 56 | encoding: Optional[str] = None 57 | 58 | 59 | @dataclass 60 | class RequestBody(Object): 61 | description: Optional[str] = None 62 | content: Optional[Dict[str, MediaType]] = None 63 | required: Optional[bool] = None 64 | 65 | 66 | @dataclass 67 | class Response(Object): 68 | description: Optional[str] = None 69 | headers: Optional[Mapping[str, Header]] = None 70 | content: Optional[Dict[str, MediaType]] = None 71 | 72 | 73 | @dataclass 74 | class Responses(Object): 75 | default: Optional[Response] = None 76 | codes: Optional[Dict[HttpCode, Response]] = None 77 | 78 | def __post_serialize__(self, d: Dict[Any, Any]) -> Dict[Any, Any]: 79 | codes = d.pop("codes", None) 80 | if codes: 81 | d.update(codes) 82 | return d 83 | 84 | 85 | class ParameterLocation(Enum): 86 | QUERY = "query" 87 | HEADER = "header" 88 | PATH = "path" 89 | COOKIE = "cookie" 90 | 91 | 92 | class ParameterStyle(Enum): 93 | SIMPLE = "simple" 94 | FORM = "form" 95 | 96 | 97 | @dataclass 98 | class Parameter(Object): 99 | name: str 100 | location: ParameterLocation 101 | description: Optional[str] = None 102 | required: Optional[bool] = None 103 | deprecated: Optional[bool] = None 104 | allowEmptyValue: Optional[bool] = None 105 | style: Optional[ParameterStyle] = None 106 | explode: Optional[bool] = None 107 | schema: Optional[Schema] = None 108 | example: Optional[Any] = None 109 | examples: Optional[Mapping[str, Example]] = None 110 | content: Optional[Mapping[str, MediaType]] = None 111 | 112 | class Config(Object.Config): 113 | serialize_by_alias = True 114 | aliases = {"location": "in"} 115 | 116 | 117 | @dataclass 118 | class ExternalDocumentation(Object): 119 | url: str 120 | description: Optional[str] = None 121 | 122 | 123 | class SecuritySchemeType(Enum): 124 | API_KEY = "apiKey" 125 | HTTP = "http" 126 | MUTUAL_TLS = "mutualTLS" 127 | OAUTH2 = "oauth2" 128 | OPEN_ID_CONNECT = "openIdConnect" 129 | 130 | 131 | class SecuritySchemeAPIKeyLocation(Enum): 132 | QUERY = "query" 133 | HEADER = "header" 134 | COOKIE = "cookie" 135 | 136 | 137 | @dataclass(unsafe_hash=True) 138 | class SecurityScheme(Object): 139 | type: SecuritySchemeType 140 | 141 | 142 | @dataclass 143 | class APIKeySecurityScheme(SecurityScheme): 144 | name: Optional[str] = None 145 | location: SecuritySchemeAPIKeyLocation = SecuritySchemeAPIKeyLocation.QUERY 146 | type: Literal[SecuritySchemeType.API_KEY] = SecuritySchemeType.API_KEY 147 | description: Optional[str] = None 148 | 149 | class Config(Object.Config): 150 | serialize_by_alias = True 151 | aliases = {"location": "in"} 152 | 153 | 154 | @dataclass 155 | class HTTPSecurityScheme(SecurityScheme): 156 | scheme: str = "basic" 157 | type: Literal[SecuritySchemeType.HTTP] = SecuritySchemeType.HTTP 158 | bearerFormat: Optional[str] = None 159 | description: Optional[str] = None 160 | 161 | 162 | @dataclass 163 | class OAuthFlows(Object): 164 | pass 165 | 166 | 167 | @dataclass 168 | class OAuth2SecurityScheme(SecurityScheme): 169 | flows: Optional[OAuthFlows] = None 170 | type: Literal[SecuritySchemeType.OAUTH2] = SecuritySchemeType.OAUTH2 171 | description: Optional[str] = None 172 | 173 | 174 | @dataclass 175 | class OpenIDConnectSecurityScheme(SecurityScheme): 176 | openIdConnectUrl: str = "" 177 | type: Literal[SecuritySchemeType.OPEN_ID_CONNECT] = ( 178 | SecuritySchemeType.OPEN_ID_CONNECT 179 | ) 180 | description: Optional[str] = None 181 | 182 | 183 | @dataclass 184 | class Operation(Object): 185 | tags: Optional[List[str]] = None 186 | summary: Optional[str] = None 187 | description: Optional[str] = None 188 | externalDocs: Optional[ExternalDocumentation] = None 189 | operationId: Optional[str] = None 190 | parameters: Optional[List[Parameter]] = None 191 | requestBody: Optional[RequestBody] = None 192 | responses: Optional[Responses] = None 193 | deprecated: Optional[bool] = None 194 | security: Optional[List[Mapping[str, List[str]]]] = None 195 | 196 | 197 | @dataclass 198 | class PathItem(Object): 199 | # TODO: Do we need this class? 200 | ref: Optional[str] = None 201 | summary: Optional[str] = None 202 | description: Optional[str] = None 203 | get: Optional[Operation] = None 204 | put: Optional[Operation] = None 205 | post: Optional[Operation] = None 206 | delete: Optional[Operation] = None 207 | options: Optional[Operation] = None 208 | head: Optional[Operation] = None 209 | patch: Optional[Operation] = None 210 | trace: Optional[Operation] = None 211 | parameters: Optional[List[Parameter]] = None 212 | -------------------------------------------------------------------------------- /openapify/decorators.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Callable, 4 | List, 5 | Mapping, 6 | Optional, 7 | Sequence, 8 | Tuple, 9 | TypeVar, 10 | Union, 11 | overload, 12 | ) 13 | 14 | from openapify.core.models import ( 15 | Body, 16 | Cookie, 17 | Header, 18 | PathParam, 19 | QueryParam, 20 | SecurityRequirement, 21 | TypeAnnotation, 22 | ) 23 | from openapify.core.openapi.models import Example, HttpCode 24 | 25 | __openapify__ = "__openapify__" 26 | 27 | 28 | Handler = TypeVar("Handler") 29 | 30 | 31 | @overload 32 | def request_schema( 33 | body: Optional[Body] = None, 34 | *, 35 | query_params: Optional[ 36 | Mapping[str, Union[TypeAnnotation, QueryParam]] 37 | ] = None, 38 | path_params: Optional[ 39 | Mapping[str, Union[TypeAnnotation, PathParam]] 40 | ] = None, 41 | headers: Optional[Mapping[str, Union[str, Header]]] = None, 42 | cookies: Optional[Mapping[str, Union[str, Cookie]]] = None, 43 | ) -> Callable[[Handler], Handler]: ... 44 | 45 | 46 | @overload 47 | def request_schema( 48 | body: Optional[TypeAnnotation] = None, 49 | *, 50 | media_type: Optional[str] = None, 51 | body_required: bool = False, 52 | body_description: Optional[str] = None, 53 | body_example: Optional[Any] = None, 54 | body_examples: Optional[Mapping[str, Union[Example, Any]]] = None, 55 | query_params: Optional[ 56 | Mapping[str, Union[TypeAnnotation, QueryParam]] 57 | ] = None, 58 | path_params: Optional[ 59 | Mapping[str, Union[TypeAnnotation, PathParam]] 60 | ] = None, 61 | headers: Optional[Mapping[str, Union[str, Header]]] = None, 62 | cookies: Optional[Mapping[str, Union[str, Cookie]]] = None, 63 | ) -> Callable[[Handler], Handler]: ... 64 | 65 | 66 | def request_schema( # type: ignore[misc] 67 | body: Optional[TypeAnnotation] = None, 68 | *, 69 | media_type: Optional[str] = None, 70 | body_required: bool = False, 71 | body_description: Optional[str] = None, 72 | body_example: Optional[Any] = None, 73 | body_examples: Optional[Mapping[str, Union[Example, Any]]] = None, 74 | query_params: Optional[ 75 | Mapping[str, Union[TypeAnnotation, QueryParam]] 76 | ] = None, 77 | path_params: Optional[ 78 | Mapping[str, Union[TypeAnnotation, PathParam]] 79 | ] = None, 80 | headers: Optional[Mapping[str, Union[str, Header]]] = None, 81 | cookies: Optional[Mapping[str, Union[str, Cookie]]] = None, 82 | ) -> Callable[[Handler], Handler]: 83 | def decorator(handler: Handler) -> Handler: 84 | meta = getattr(handler, __openapify__, []) 85 | if not meta: 86 | handler.__openapify__ = meta # type: ignore[attr-defined] 87 | meta.append( 88 | ( 89 | "request", 90 | { 91 | "body": body, 92 | "media_type": media_type, 93 | "body_required": body_required, 94 | "body_description": body_description, 95 | "body_example": body_example, 96 | "body_examples": body_examples, 97 | "query_params": query_params, 98 | "path_params": path_params, 99 | "headers": headers, 100 | "cookies": cookies, 101 | }, 102 | ), 103 | ) 104 | return handler 105 | 106 | return decorator 107 | 108 | 109 | def response_schema( 110 | body: Optional[TypeAnnotation] = None, 111 | http_code: HttpCode = 200, 112 | media_type: Optional[str] = None, 113 | description: Optional[str] = None, 114 | # TODO: Generate a required description depending on http_code 115 | # https://spec.openapis.org/oas/v3.1.0#response-object 116 | headers: Optional[Mapping[str, Union[str, Header]]] = None, 117 | example: Optional[Any] = None, 118 | examples: Optional[Mapping[str, Union[Example, Any]]] = None, 119 | ) -> Callable[[Handler], Handler]: 120 | def decorator(handler: Handler) -> Handler: 121 | meta = getattr(handler, __openapify__, []) 122 | if not meta: 123 | handler.__openapify__ = meta # type: ignore[attr-defined] 124 | meta.append( 125 | ( 126 | "response", 127 | { 128 | "body": body, 129 | "http_code": http_code, 130 | "media_type": media_type, 131 | "description": description, 132 | "headers": headers, 133 | "example": example, 134 | "examples": examples, 135 | }, 136 | ), 137 | ) 138 | return handler 139 | 140 | return decorator 141 | 142 | 143 | def operation_docs( 144 | summary: Optional[str] = None, 145 | description: Optional[str] = None, 146 | tags: Optional[Sequence[str]] = None, 147 | # parameters: Optional[Mapping[str, Union[str, Parameter]]] = None, 148 | operation_id: Optional[str] = None, 149 | external_docs: Optional[Union[str, Tuple[str, str]]] = None, 150 | deprecated: Optional[bool] = None, 151 | ) -> Callable[[Handler], Handler]: 152 | def decorator(handler: Handler) -> Handler: 153 | meta = getattr(handler, __openapify__, []) 154 | if not meta: 155 | handler.__openapify__ = meta # type: ignore[attr-defined] 156 | meta.append( 157 | ( 158 | "operation_docs", 159 | { 160 | "summary": summary, 161 | "description": description, 162 | "tags": tags, 163 | # "parameters": parameters, 164 | "operation_id": operation_id, 165 | "external_docs": external_docs, 166 | "deprecated": deprecated, 167 | }, 168 | ), 169 | ) 170 | return handler 171 | 172 | return decorator 173 | 174 | 175 | def security_requirements( 176 | requirements: Optional[ 177 | Union[SecurityRequirement, List[SecurityRequirement]] 178 | ] = None, 179 | ) -> Callable[[Handler], Handler]: 180 | def decorator(handler: Handler) -> Handler: 181 | meta = getattr(handler, __openapify__, []) 182 | if not meta: 183 | handler.__openapify__ = meta # type: ignore[attr-defined] 184 | meta.append(("security_requirements", {"requirements": requirements})) 185 | return handler 186 | 187 | return decorator 188 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2023 Alexander Tikhonov 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /openapify/core/builder.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Dict, 4 | Iterable, 5 | List, 6 | Mapping, 7 | Optional, 8 | Sequence, 9 | Tuple, 10 | Type, 11 | Union, 12 | overload, 13 | ) 14 | 15 | import apispec 16 | 17 | from openapify.core.base_plugins import ( 18 | BaseSchemaPlugin, 19 | BodyBinaryPlugin, 20 | GuessMediaTypePlugin, 21 | ) 22 | from openapify.core.const import ( 23 | DEFAULT_OPENAPI_VERSION, 24 | DEFAULT_SPEC_TITLE, 25 | DEFAULT_SPEC_VERSION, 26 | RESPONSE_DESCRIPTIONS, 27 | ) 28 | from openapify.core.document import OpenAPIDocument 29 | from openapify.core.models import ( 30 | Body, 31 | Cookie, 32 | Header, 33 | PathParam, 34 | QueryParam, 35 | RouteDef, 36 | SecurityRequirement, 37 | TypeAnnotation, 38 | ) 39 | from openapify.core.openapi import models as openapi 40 | from openapify.core.openapi.models import Parameter 41 | from openapify.plugin import BasePlugin 42 | 43 | BASE_PLUGINS = (BodyBinaryPlugin(), GuessMediaTypePlugin(), BaseSchemaPlugin()) 44 | 45 | 46 | METHOD_ORDER = [ 47 | "connect", 48 | "get", 49 | "post", 50 | "put", 51 | "patch", 52 | "delete", 53 | "head", 54 | "options", 55 | "trace", 56 | ] 57 | 58 | 59 | def default_response_description(http_code: str) -> str: 60 | if http_code.lower() == "default": 61 | return "Default Response" 62 | result = RESPONSE_DESCRIPTIONS.get(http_code) 63 | if result: 64 | return result 65 | else: 66 | return RESPONSE_DESCRIPTIONS[f"{http_code[0]}XX"] 67 | 68 | 69 | def _merge_parameters( 70 | old_parameters: Sequence[openapi.Parameter], new_parameters: Dict[str, str] 71 | ) -> Sequence[openapi.Parameter]: 72 | for parameter in old_parameters or []: 73 | parameter_description = new_parameters.get(parameter.name) 74 | if parameter_description: 75 | parameter.description = parameter_description 76 | return old_parameters 77 | 78 | 79 | class OpenAPISpecBuilder: 80 | def __init__( 81 | self, 82 | spec: Optional[apispec.APISpec] = None, 83 | title: str = DEFAULT_SPEC_TITLE, 84 | version: str = DEFAULT_SPEC_VERSION, 85 | openapi_version: str = DEFAULT_OPENAPI_VERSION, 86 | plugins: Sequence[BasePlugin] = (), 87 | servers: Optional[List[Union[str, openapi.Server]]] = None, 88 | security_schemes: Optional[ 89 | Mapping[str, openapi.SecurityScheme] 90 | ] = None, 91 | **options: Any, 92 | ): 93 | if spec is None: 94 | spec = OpenAPIDocument( 95 | title=title, 96 | version=version, 97 | openapi_version=openapi_version, 98 | servers=servers, 99 | security_schemes=security_schemes, 100 | **options, 101 | ) 102 | self.spec = spec 103 | self.plugins: Sequence[BasePlugin] = (*plugins, *BASE_PLUGINS) 104 | for plugin in self.plugins: 105 | plugin.init_spec(spec) 106 | 107 | def feed_routes(self, routes: Iterable[RouteDef]) -> None: 108 | for route in sorted( 109 | routes, 110 | key=lambda r: (r.path, METHOD_ORDER.index(r.method.lower())), 111 | ): 112 | self._process_route(route) 113 | 114 | def _process_route(self, route: RouteDef) -> None: 115 | method = route.method.lower() 116 | meta = getattr(route.handler, "__openapify__", []) 117 | responses: Optional[openapi.Responses] = None 118 | summary = route.summary 119 | description = route.description 120 | tags = route.tags.copy() if route.tags else [] 121 | deprecated = None 122 | operation_id = None 123 | external_docs = None 124 | security = None 125 | parameters: list[Parameter] = [] 126 | route_path_params = { 127 | param.name: param 128 | for param in self._build_path_params(route.path_params or {}) 129 | } 130 | request_body: Optional[openapi.RequestBody] = None 131 | for args_type, args in meta: 132 | if args_type == "request": 133 | body = args.get("body") 134 | if isinstance(body, Body): 135 | body_value_type = body.value_type 136 | media_type = body.media_type 137 | body_required = body.required 138 | body_description = body.description 139 | body_example = body.example 140 | body_examples = body.examples 141 | else: 142 | body_value_type = body 143 | media_type = args.get("media_type") 144 | body_required = args.get("body_required") 145 | body_description = args.get("body_description") 146 | body_example = args.get("body_example") 147 | body_examples = args.get("body_examples") 148 | if body is not None or media_type is not None: 149 | request_body = self._update_request_body( 150 | request_body=request_body, 151 | value_type=body_value_type, 152 | media_type=media_type, 153 | required=body_required, 154 | description=body_description, 155 | example=body_example, 156 | examples=body_examples, 157 | ) 158 | query_params = args.get("query_params") 159 | if query_params: 160 | parameters.extend(self._build_query_params(query_params)) 161 | path_params = args.get("path_params") 162 | if path_params: 163 | for new_param in self._build_path_params(path_params): 164 | route_path_params.pop(new_param.name, None) 165 | parameters.append(new_param) 166 | headers = args.get("headers") 167 | if headers: 168 | parameters.extend(self._build_request_headers(headers)) 169 | cookies = args.get("cookies") 170 | if cookies: 171 | parameters.extend(self._build_cookies(cookies)) 172 | 173 | elif args_type == "response": 174 | responses = self._update_responses(responses=responses, **args) 175 | elif args_type == "operation_docs": 176 | args = args.copy() 177 | summary = args.get("summary") 178 | description = args.get("description") 179 | tags.extend(args.get("tags") or []) 180 | # _merge_parameters(parameters, args.get("parameters") or {}) 181 | operation_id = args.get("operation_id") 182 | external_docs = self._build_external_docs( 183 | args.get("external_docs") 184 | ) 185 | deprecated = args.pop("deprecated") 186 | elif args_type == "security_requirements": 187 | security = self._build_security_requirements( 188 | args.get("requirements") 189 | ) 190 | parameters.extend(route_path_params.values()) 191 | self.spec.path( 192 | route.path, 193 | operations={ 194 | method: openapi.Operation( 195 | summary=summary, 196 | description=description, 197 | requestBody=request_body, 198 | responses=responses, 199 | deprecated=deprecated, 200 | tags=tags or None, 201 | parameters=parameters or None, 202 | externalDocs=external_docs, 203 | operationId=operation_id, 204 | security=security, 205 | ).to_dict() 206 | }, 207 | # https://github.com/swagger-api/swagger-ui/issues/5653 208 | # summary=summary, 209 | # description=description, 210 | # parameters=[param.to_dict() for param in route.parameters or []], 211 | ) 212 | 213 | def _build_query_params( 214 | self, query_params: Dict[str, Union[Type, QueryParam]] 215 | ) -> Sequence[openapi.Parameter]: 216 | result = [] 217 | for name, param in query_params.items(): 218 | if not isinstance(param, QueryParam): 219 | param = QueryParam(param) 220 | parameter_schema = self.__build_object_schema_with_plugins( 221 | param, name 222 | ) 223 | if parameter_schema is None: 224 | parameter_schema = {} 225 | result.append( 226 | openapi.Parameter( 227 | name=name, 228 | location=openapi.ParameterLocation.QUERY, 229 | description=param.description, 230 | required=param.required, 231 | deprecated=param.deprecated, 232 | allowEmptyValue=param.allowEmptyValue, 233 | schema=parameter_schema, 234 | style=param.style, 235 | explode=param.explode, 236 | example=param.example, 237 | examples=self._build_examples(param.examples), 238 | ) 239 | ) 240 | return result 241 | 242 | def _build_path_params( 243 | self, path_params: Mapping[str, Union[Type, PathParam]] 244 | ) -> Sequence[openapi.Parameter]: 245 | result = [] 246 | for name, param in path_params.items(): 247 | if not isinstance(param, PathParam): 248 | param = PathParam(param) 249 | parameter_schema = self.__build_object_schema_with_plugins( 250 | param, name 251 | ) 252 | if parameter_schema is None: 253 | parameter_schema = {} 254 | result.append( 255 | openapi.Parameter( 256 | name=name, 257 | location=openapi.ParameterLocation.PATH, 258 | description=param.description, 259 | required=True, 260 | schema=parameter_schema, 261 | example=param.example, 262 | examples=self._build_examples(param.examples), 263 | ) 264 | ) 265 | return result 266 | 267 | def _build_request_headers( 268 | self, headers: Dict[str, Union[str, Header]] 269 | ) -> Sequence[openapi.Parameter]: 270 | result = [] 271 | for name, header in headers.items(): 272 | if not isinstance(header, Header): 273 | header = Header(description=header) 274 | parameter_schema = self.__build_object_schema_with_plugins( 275 | header, name 276 | ) 277 | if parameter_schema is None: 278 | parameter_schema = {} 279 | result.append( 280 | openapi.Parameter( 281 | name=name, 282 | location=openapi.ParameterLocation.HEADER, 283 | description=header.description, 284 | required=header.required, 285 | deprecated=header.deprecated, 286 | allowEmptyValue=header.allowEmptyValue, 287 | schema=parameter_schema, 288 | example=header.example, 289 | examples=self._build_examples(header.examples), 290 | ) 291 | ) 292 | return result 293 | 294 | def _build_response_headers( 295 | self, headers: Dict[str, Union[str, Header]] 296 | ) -> Mapping[str, openapi.Header]: 297 | result = {} 298 | for name, header in headers.items(): 299 | if not isinstance(header, Header): 300 | header = Header(description=header) 301 | header_schema = self.__build_object_schema_with_plugins( 302 | header, name 303 | ) 304 | if header_schema is None: 305 | header_schema = {} 306 | result[name] = openapi.Header( 307 | schema=header_schema, 308 | description=header.description, 309 | required=header.required, 310 | deprecated=header.deprecated, 311 | allowEmptyValue=header.allowEmptyValue, 312 | example=header.example, 313 | examples=self._build_examples(header.examples), 314 | ) 315 | return result 316 | 317 | def _build_cookies( 318 | self, cookies: Dict[str, Union[str, Cookie]] 319 | ) -> Sequence[openapi.Parameter]: 320 | result = [] 321 | for name, cookie in cookies.items(): 322 | if not isinstance(cookie, Cookie): 323 | cookie = Cookie(cookie) 324 | parameter_schema = self.__build_object_schema_with_plugins( 325 | cookie, name 326 | ) 327 | if parameter_schema is None: 328 | parameter_schema = {} 329 | result.append( 330 | openapi.Parameter( 331 | name=name, 332 | location=openapi.ParameterLocation.QUERY, 333 | description=cookie.description, 334 | required=cookie.required, 335 | deprecated=cookie.deprecated, 336 | allowEmptyValue=cookie.allowEmptyValue, 337 | schema=parameter_schema, 338 | example=cookie.example, 339 | examples=self._build_examples(cookie.examples), 340 | ) 341 | ) 342 | return result 343 | 344 | def _update_request_body( 345 | self, 346 | request_body: Optional[openapi.RequestBody], 347 | value_type: TypeAnnotation, 348 | media_type: Optional[str] = None, 349 | required: Optional[bool] = None, 350 | description: Optional[str] = None, 351 | example: Optional[Any] = None, 352 | examples: Optional[Mapping[str, Union[openapi.Example, Any]]] = None, 353 | ) -> openapi.RequestBody: 354 | if request_body is None: 355 | request_body = openapi.RequestBody() 356 | if description: 357 | request_body.description = description 358 | if required is not None: 359 | request_body.required = required 360 | body_schema: Optional[Dict[str, Any]] = None 361 | if value_type is not None: 362 | body = Body( 363 | value_type=value_type, 364 | media_type=media_type, 365 | required=required, 366 | description=description, 367 | example=example, 368 | examples=examples, 369 | ) 370 | body_schema = self.__build_object_schema_with_plugins(body) 371 | if body_schema is None: 372 | body_schema = {} 373 | if media_type is None: 374 | media_type = self._determine_body_media_type(body, body_schema) 375 | elif media_type is not None: 376 | body_schema = {} 377 | if body_schema is not None and media_type is not None: 378 | if request_body.content is None: 379 | request_body.content = {} 380 | request_body.content[media_type] = openapi.MediaType( 381 | schema=body_schema, 382 | example=example, 383 | examples=self._build_examples(examples), 384 | ) 385 | return request_body 386 | 387 | def _update_responses( 388 | self, 389 | responses: Optional[openapi.Responses], 390 | http_code: openapi.HttpCode, 391 | body: Optional[Type] = None, 392 | media_type: Optional[str] = None, 393 | description: Optional[str] = None, 394 | headers: Optional[Dict[str, Union[str, Header]]] = None, 395 | example: Optional[Any] = None, 396 | examples: Optional[Dict[str, Union[openapi.Example, Any]]] = None, 397 | ) -> openapi.Responses: 398 | http_code = str(http_code) 399 | if responses is None: 400 | responses = openapi.Responses() 401 | if responses.codes is None: 402 | responses.codes = {} 403 | response = responses.codes.get(http_code) 404 | if not response: 405 | response = openapi.Response() 406 | responses.codes[http_code] = response 407 | if headers: 408 | response.headers = self._build_response_headers(headers) 409 | if description: 410 | response.description = description 411 | elif not response.description: 412 | response.description = default_response_description(http_code) 413 | body_schema: Optional[Dict[str, Any]] = None 414 | if body is not None: 415 | body_obj = Body( 416 | value_type=body, 417 | media_type=media_type, 418 | required=True, 419 | description=description, 420 | example=example, 421 | examples=examples, 422 | ) 423 | body_schema = self.__build_object_schema_with_plugins(body_obj) 424 | if body_schema is None: 425 | body_schema = {} 426 | if media_type is None: 427 | media_type = self._determine_body_media_type( 428 | body_obj, body_schema 429 | ) 430 | elif media_type is not None: 431 | body_schema = {} 432 | if body_schema is not None and media_type is not None: 433 | if response.content is None: 434 | response.content = {} 435 | response.content[media_type] = openapi.MediaType( 436 | schema=body_schema, 437 | example=example, 438 | examples=self._build_examples(examples), 439 | ) 440 | return responses 441 | 442 | @staticmethod 443 | def _build_external_docs( 444 | data: Union[str, Tuple[str, str]], 445 | ) -> Optional[openapi.ExternalDocumentation]: 446 | if not data: 447 | return None 448 | elif isinstance(data, tuple): 449 | return openapi.ExternalDocumentation(*data) 450 | else: 451 | return openapi.ExternalDocumentation(data) 452 | 453 | def _build_security_requirements( 454 | self, 455 | security: Optional[ 456 | Union[SecurityRequirement, List[SecurityRequirement]] 457 | ] = None, 458 | ) -> Optional[List[Mapping[str, List[str]]]]: 459 | if security is None: 460 | return None 461 | result: List[Mapping[str, List[str]]] = [] 462 | if isinstance(security, dict): 463 | security = [security] 464 | for requirement in security: 465 | for name, scheme in requirement.items(): # type: ignore 466 | if name not in self.spec.components.security_schemes: 467 | self.spec.components.security_scheme( 468 | name, scheme.to_dict() 469 | ) 470 | # TODO: include list of scopes for oauth2 or openIdConnect 471 | result.append({name: []}) 472 | return result 473 | 474 | @staticmethod 475 | def _build_examples( 476 | examples: Optional[Mapping[str, Union[openapi.Example, Any]]] = None, 477 | ) -> Optional[Mapping[str, openapi.Example]]: 478 | if examples is None: 479 | return None 480 | result = {} 481 | for key, value in examples.items(): 482 | if not isinstance(value, openapi.Example): 483 | result[key] = openapi.Example(value) 484 | else: 485 | result[key] = value 486 | return result 487 | 488 | def __build_object_schema_with_plugins( 489 | self, 490 | obj: Union[Body, Cookie, Header, QueryParam, PathParam], 491 | name: Optional[str] = None, 492 | ) -> Optional[Dict[str, Any]]: 493 | return build_object_schema_with_plugins(obj, self.plugins, name) 494 | 495 | def _determine_body_media_type( 496 | self, body: Body, schema: Dict[str, Any] 497 | ) -> Optional[str]: 498 | for plugin in self.plugins: 499 | try: 500 | media_type = plugin.media_type_helper(body, schema) 501 | if media_type is not None: 502 | return media_type 503 | except NotImplementedError: 504 | continue 505 | return None 506 | 507 | 508 | def build_object_schema_with_plugins( 509 | obj: Union[Body, Cookie, Header, QueryParam, PathParam], 510 | plugins: Sequence[BasePlugin], 511 | name: Optional[str] = None, 512 | ) -> Optional[Dict[str, Any]]: 513 | for plugin in plugins: 514 | try: 515 | schema = plugin.schema_helper(obj, name) 516 | if schema is not None: 517 | return schema 518 | except NotImplementedError: 519 | continue 520 | return None 521 | 522 | 523 | @overload 524 | def build_spec( 525 | routes: Iterable[RouteDef], spec: apispec.APISpec 526 | ) -> apispec.APISpec: ... 527 | 528 | 529 | @overload 530 | def build_spec( 531 | routes: Iterable[RouteDef], 532 | *, 533 | title: str = DEFAULT_SPEC_TITLE, 534 | version: str = DEFAULT_SPEC_VERSION, 535 | openapi_version: str = DEFAULT_OPENAPI_VERSION, 536 | plugins: Sequence[BasePlugin] = (), 537 | servers: Optional[List[Union[str, openapi.Server]]] = None, 538 | security_schemes: Optional[Mapping[str, openapi.SecurityScheme]] = None, 539 | **options: Any, 540 | ) -> apispec.APISpec: ... 541 | 542 | 543 | def build_spec( 544 | routes: Iterable[RouteDef], 545 | spec: Optional[apispec.APISpec] = None, 546 | *, 547 | title: str = DEFAULT_SPEC_TITLE, 548 | version: str = DEFAULT_SPEC_VERSION, 549 | openapi_version: str = DEFAULT_OPENAPI_VERSION, 550 | plugins: Sequence[BasePlugin] = (), 551 | servers: Optional[List[Union[str, openapi.Server]]] = None, 552 | security_schemes: Optional[Mapping[str, openapi.SecurityScheme]] = None, 553 | **options: Any, 554 | ) -> apispec.APISpec: 555 | builder = OpenAPISpecBuilder( 556 | spec=spec, 557 | title=title, 558 | version=version, 559 | openapi_version=openapi_version, 560 | plugins=plugins, 561 | servers=servers, 562 | security_schemes=security_schemes, 563 | **options, 564 | ) 565 | builder.feed_routes(routes) 566 | return builder.spec 567 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # openapify 2 | 3 | ###### Framework agnostic OpenAPI Specification generation for code lovers 4 | 5 | [![Build Status](https://github.com/Fatal1ty/openapify/workflows/tests/badge.svg)](https://github.com/Fatal1ty/openapify/actions) 6 | [![Latest Version](https://img.shields.io/pypi/v/openapify.svg)](https://pypi.python.org/pypi/openapify) 7 | [![Python Version](https://img.shields.io/pypi/pyversions/openapify.svg)](https://pypi.python.org/pypi/openapify) 8 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 9 | 10 | --- 11 | 12 | This library is designed for code-first people who don't want to bother diving 13 | into the details 14 | of [OpenAPI Specification](https://spec.openapis.org/oas/v3.1.0), but who 15 | instead want to use advantages of Python typing system, IDE code-completion and 16 | static type checkers to continuously build the API documentation and keep it 17 | always up to date. 18 | 19 | Openapify is based on the idea of applying decorators on route handlers. Any 20 | web-framework has a routing system that let us link a route to a handler 21 | (a high-level function or a class method). By using decorators, we can add 22 | information about requests, responses and other details that will then be used 23 | to create an entire OpenAPI document. 24 | 25 | > [!WARNING]\ 26 | > This library is currently in pre-release stage and may have backward 27 | > incompatible changes prior to version 1.0. Please use caution when using this 28 | > library in production environments and be sure to thoroughly test any updates 29 | > before upgrading to a new version. 30 | 31 | Table of contents 32 | -------------------------------------------------------------------------------- 33 | 34 | * [Installation](#installation) 35 | * [Quickstart](#quickstart) 36 | * [Building the OpenAPI Document](#building-the-openapi-document) 37 | * [Integration with web-frameworks](#integration-with-web-frameworks) 38 | * [aiohttp](#aiohttp) 39 | * [Writing your own integration](#writing-your-own-integration) 40 | * [Decorators](#decorators) 41 | * [Generic operation info](#generic-operation-info) 42 | * [Request](#request) 43 | * [Response](#response) 44 | * [Security requirements](#security-requirements) 45 | * [Plugins](#plugins) 46 | * [`schema_helper`](#schema_helper) 47 | * [`media_type_helper`](#media_type_helper) 48 | 49 | Installation 50 | -------------------------------------------------------------------------------- 51 | 52 | Use pip to install: 53 | 54 | ```shell 55 | $ pip install openapify 56 | ``` 57 | 58 | Quickstart 59 | -------------------------------------------------------------------------------- 60 | 61 | > [!NOTE]\ 62 | > In the following example, we will intentionally demonstrate the process of 63 | > creating an OpenAPI document without being tied to a specific web-framework. 64 | > However, this process may be easier on a supported web-framework. 65 | > See [Integration with web-frameworks](#integration-with-web-frameworks) for 66 | > more info. 67 | 68 | Let's see how to build an OpenAPI document with openapify. Suppose we are 69 | writing an app for a bookstore that return a list of new books. Here we have a 70 | dataclass model `Book` that would be used as a response model in a real-life 71 | scenario. A function `get_new_books` is our handler. 72 | 73 | ```python 74 | from dataclasses import dataclass 75 | 76 | @dataclass 77 | class Book: 78 | title: str 79 | author: str 80 | year: int 81 | 82 | def get_new_books(...): 83 | ... 84 | ``` 85 | 86 | Now we want to say that our handler returns a json serialized list of books 87 | limited by the optional `count` parameter. We use `request_schema` 88 | and `response_schema` decorators accordingly: 89 | 90 | ```python 91 | from openapify import request_schema, response_schema 92 | 93 | @request_schema(query_params={"count": int}) 94 | @response_schema(list[Book]) 95 | def get_new_books(...): 96 | ... 97 | ``` 98 | 99 | And now we need to collect all the route definitions and pass them to the 100 | `build_spec` function. This function returns an object that has `to_yaml` 101 | method. 102 | 103 | ```python 104 | from openapify import build_spec 105 | from openapify.core.models import RouteDef 106 | 107 | routes = [RouteDef("/books", "get", get_new_books)] 108 | spec = build_spec(routes) 109 | print(spec.to_yaml()) 110 | ``` 111 | 112 | As a result, we will get the following OpenAPI document which can be rendered 113 | using tools such as Swagger UI: 114 | 115 | ```yaml 116 | openapi: 3.1.0 117 | info: 118 | title: API 119 | version: 1.0.0 120 | paths: 121 | /books: 122 | get: 123 | parameters: 124 | - name: count 125 | in: query 126 | schema: 127 | type: integer 128 | responses: 129 | '200': 130 | description: OK 131 | content: 132 | application/json: 133 | schema: 134 | type: array 135 | items: 136 | $ref: '#/components/schemas/Book' 137 | components: 138 | schemas: 139 | Book: 140 | type: object 141 | title: Book 142 | properties: 143 | title: 144 | type: string 145 | author: 146 | type: string 147 | year: 148 | type: integer 149 | additionalProperties: false 150 | required: 151 | - title 152 | - author 153 | - year 154 | ``` 155 | 156 | Building the OpenAPI Document 157 | -------------------------------------------------------------------------------- 158 | The final goal of this library is to build 159 | the [OpenAPI Document](https://spec.openapis.org/oas/v3.1.0#openapi-document) 160 | for your web-application. This document consists of common information about 161 | the application, such as a title and version, and specific information that 162 | outlines the functionalities of the API. 163 | 164 | Since openapify is now based 165 | on [apispec](https://github.com/marshmallow-code/apispec) library, the OpenAPI 166 | document is presented by `APISpec` class for the convenience of using the 167 | existing ecosystem of plugins. However, openapify has its own 168 | subclass `OpenAPIDocument` which makes it easier to add some common fields, 169 | such as an array 170 | of [Server](https://spec.openapis.org/oas/v3.1.0#server-object) objects or 171 | array of 172 | common [Security Scheme](https://spec.openapis.org/oas/v3.1.0#security-scheme-object) 173 | objects. 174 | 175 | To build the document, there is `build_spec` function. The very basic document 176 | can be created by calling it with an empty list of route definitions, leaving 177 | all the parameters with their default values. 178 | ```python 179 | from openapify import build_spec 180 | 181 | print(build_spec([]).to_yaml()) 182 | ``` 183 | 184 | As a result, we will get the following document: 185 | 186 | ```yaml 187 | openapi: 3.1.0 188 | info: 189 | title: API 190 | version: 1.0.0 191 | paths: {} 192 | ``` 193 | 194 | We can change the common document attributes either by passing them 195 | to `build_spec`: 196 | 197 | ```python 198 | from openapify import build_spec 199 | from openapify.core.openapi.models import HTTPSecurityScheme 200 | 201 | build_spec( 202 | routes=[], 203 | title="My Bookstore API", 204 | version="1.1.0", 205 | openapi_version="3.1.0", 206 | servers=["http://127.0.0.1"], 207 | security_schemes={"basic_auth": HTTPSecurityScheme()} 208 | ) 209 | ``` 210 | 211 | or using a prepared `OpenAPIDocument` object: 212 | 213 | ```python 214 | from openapify import OpenAPIDocument, build_spec 215 | from openapify.core.openapi.models import HTTPSecurityScheme 216 | 217 | spec = OpenAPIDocument( 218 | title="My Bookstore API", 219 | version="1.1.0", 220 | openapi_version="3.1.0", 221 | servers=["http://127.0.0.1"], 222 | security_schemes={"basic_auth": HTTPSecurityScheme()}, 223 | ) 224 | build_spec([], spec) 225 | ``` 226 | 227 | To add meaning to our document, we can 228 | add [Path](https://spec.openapis.org/oas/v3.1.0#paths-object), 229 | [Component](https://spec.openapis.org/oas/v3.1.0#components-object) 230 | and other OpenAPI objects by applying [decorators](#decorators) on our route 231 | handlers and constructing route definitions that will be passed to the builder. 232 | A single complete route definition presented by `RouteDef` class can look like 233 | this: 234 | 235 | ```python 236 | from openapify.core.models import RouteDef 237 | from openapify.core.openapi.models import Parameter, ParameterLocation 238 | 239 | def get_book_by_id_handler(...): 240 | ... 241 | 242 | RouteDef( 243 | path="/book/{id}", 244 | method="get", 245 | handler=get_book_by_id_handler, 246 | summary="Getting the book", 247 | description="Getting the book by id", 248 | parameters=[ 249 | Parameter( 250 | name="id", 251 | location=ParameterLocation.PATH, 252 | required=True, 253 | schema={"type": "integer"}, 254 | ) 255 | ], 256 | tags=["book"], 257 | ) 258 | ``` 259 | 260 | As will be shown further, optional 261 | arguments `summary`, `description`, `parameters` and `tags` can be overridden 262 | or extended by `operation_docs` and `request_schema` decorators. 263 | 264 | The creating of these route definitions can be automated and adapted to a 265 | specific web-framework, and openapify has built-in support for a few of them. 266 | See [Integration with web-frameworks](#integration-with-web-frameworks) for 267 | details. 268 | 269 | Integration with web-frameworks 270 | -------------------------------------------------------------------------------- 271 | 272 | There is built-in support for a few web-frameworks, which makes creating the 273 | documentation even easier and more fun. Any other frameworks can be integrated 274 | with a little effort. If you are ready to take on this, you are very welcome to 275 | create a [pull request](https://github.com/Fatal1ty/openapify/pulls). 276 | 277 | ### aiohttp 278 | 279 | The documentation for [aiohttp](https://github.com/aio-libs/aiohttp) 280 | web-application can be built in three ways: 281 | 282 | * Using an already existing [`aiohttp.web.Application`](https://docs.aiohttp.org/en/stable/web_reference.html#application) object 283 | * Using a set of [`aiohttp.web.RouteDef`](https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.RouteDef) objects 284 | * Using a set of objects implementing [`AioHttpRouteDef`](https://github.com/Fatal1ty/openapify/blob/2bbf2e99c06b31fa2f1465f2ebc118884ce2bb95/openapify/ext/web/aiohttp.py#L43-L46) protocol 285 | 286 | All we need is to pass either an application, or a set of route defs to 287 | modified `build_spec` function. See the example: 288 | ```python 289 | from aiohttp import web 290 | from openapify import request_schema, response_schema 291 | from openapify.ext.web.aiohttp import build_spec 292 | 293 | routes = web.RouteTableDef() 294 | 295 | @response_schema(str, media_type="text/plain") 296 | @routes.post("/") 297 | async def hello(request): 298 | return web.Response(text="Hello, world") 299 | 300 | app = web.Application() 301 | app.add_routes(routes) 302 | 303 | print(build_spec(app).to_yaml()) 304 | ``` 305 | As a result, we will get the following document: 306 | 307 | ```yaml 308 | openapi: 3.1.0 309 | info: 310 | title: API 311 | version: 1.0.0 312 | paths: 313 | /: 314 | post: 315 | responses: 316 | '200': 317 | description: OK 318 | content: 319 | text/plain: 320 | schema: 321 | type: string 322 | ``` 323 | 324 | ### Writing your own integration 325 | 326 | 🚧 To be described 327 | 328 | Decorators 329 | -------------------------------------------------------------------------------- 330 | 331 | Openapify has several decorators that embed necessary specific information for 332 | later use when building the OpenAPI document. In general, decorators will 333 | define the information that will be included in 334 | the [Operation Object](https://spec.openapis.org/oas/v3.1.0#operation-object) 335 | which describes a single API operation on a path. We will look at what each 336 | decorator parameter is responsible for and how it is reflected in the final 337 | document. 338 | 339 | ### Generic operation info 340 | 341 | Decorator `operation_docs` adds generic information about the Operation object, 342 | which includes summary, description, tags, external documentation and 343 | deprecation marker. 344 | 345 | ```python 346 | from openapify import operation_docs 347 | ``` 348 | 349 | #### summary 350 | 351 | An optional, string summary, intended to apply to the operation. This affects 352 | the value of 353 | the [`summary`](https://spec.openapis.org/oas/v3.1.0#operation-object) field of 354 | the Operation object. 355 | 356 | | Possible types | Examples | 357 | |----------------|-----------------------| 358 | | `str` | `"Getting new books"` | 359 | 360 | #### description 361 | 362 | An optional, string description, intended to apply to the 363 | operation. [CommonMark syntax](https://spec.commonmark.org) MAY be used for 364 | rich text representation. This affects the value of 365 | the [`description`](https://spec.openapis.org/oas/v3.1.0#operation-object) 366 | field of the Operation object. 367 | 368 | | Possible types | Examples | 369 | |----------------|-----------------------------| 370 | | `str` | `"Returns a list of books"` | 371 | 372 | #### tags 373 | 374 | A list of tags for API documentation control. Tags can be used for logical 375 | grouping of operations by resources or any other qualifier. This affects the 376 | value of the [`tags`](https://spec.openapis.org/oas/v3.1.0#operation-object) 377 | field of the Operation object. 378 | 379 | | Possible types | Examples | 380 | |-----------------|------------| 381 | | `Sequence[str]` | `["book"]` | 382 | 383 | #### operation_id 384 | 385 | Unique string used to identify the operation. This affects the 386 | value of 387 | the [`operationId`](https://spec.openapis.org/oas/v3.1.0#operation-object) 388 | field of the Operation object. 389 | 390 | | Possible types | Examples | 391 | |----------------|------------| 392 | | `str` | `getBooks` | 393 | 394 | #### external_docs 395 | 396 | Additional external documentation for the operation. It can be a single url or 397 | (url, description) pair. This affects the value of 398 | the [`summary`](https://spec.openapis.org/oas/v3.1.0#operation-object) field of 399 | the Operation object. 400 | 401 | | Possible types | Examples | 402 | |-------------------|---------------------------------------------------------------------------| 403 | | `str` | `"https://example.org/docs/books"` | 404 | | `Tuple[str, str]` | `("https://example.org/docs/books", "External documentation for /books")` | 405 | 406 | #### deprecated 407 | 408 | Declares the operation to be deprecated. Consumers SHOULD refrain from usage 409 | of the declared operation. Default value is false. This affects the value of 410 | the [`deprecated`](https://spec.openapis.org/oas/v3.1.0#operation-object) field 411 | of the Operation object. 412 | 413 | | Possible types | Examples | 414 | |----------------|--------------------------------| 415 | | `bool` | True | 416 | 417 | ### Request 418 | 419 | Decorator `request_schema` adds information about the operation requests. 420 | Request can have a body, query parameters, headers and cookies. 421 | 422 | ```python 423 | from openapify import request_schema 424 | ``` 425 | 426 | #### body 427 | 428 | A request body can be described entirely by one `body` parameter of type `Body` 429 | or partially by separate `body_*` parameters (see below). 430 | 431 | In the first case it is `openapify.core.models.Body` object that has all the 432 | separate `body_*` parameters inside. This affects the value of 433 | the [`requestBody`](https://spec.openapis.org/oas/v3.1.0#operation-object) 434 | field of the Operation object. 435 | 436 | In the second case it is the request body Python data type for which the JSON 437 | Schema will be built. This affects the value of 438 | the [`requestBody`](https://spec.openapis.org/oas/v3.1.0#operation-object) 439 | field of the Operation object, or more precisely, 440 | the [`schema`](https://spec.openapis.org/oas/v3.1.0#media-type-object) field of 441 | Media Type object inside 442 | the value 443 | of [`content`](https://spec.openapis.org/oas/v3.1.0#request-body-object) field 444 | of Request Body object. 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 460 | 461 | 462 | 463 | 480 | 481 |
Possible typesExamples
Type 454 | 455 | ```python 456 | Book 457 | ``` 458 | 459 |
Body 464 | 465 | ```python 466 | Body( 467 | value_type=Book, 468 | media_type="application/json", 469 | required=True, 470 | description="A book", 471 | example={ 472 | "title": "Anna Karenina", 473 | "author": "Leo Tolstoy", 474 | "year": 1877, 475 | }, 476 | ) 477 | ``` 478 | 479 |
482 | 483 | #### media_type 484 | 485 | A media type 486 | or [media type range](https://www.rfc-editor.org/rfc/rfc7231#appendix-D) of the 487 | request body. This affects the value of 488 | the [`requestBody`](https://spec.openapis.org/oas/v3.1.0#operation-object) 489 | field of the Operation object, or more precisely, 490 | the key 491 | of [`content`](https://spec.openapis.org/oas/v3.1.0#request-body-object) field 492 | of Request Body object. 493 | 494 | The default value is `"application/json"`. 495 | 496 | | Possible types | Examples | 497 | |----------------|---------------------| 498 | | `str` | `"application/xml"` | 499 | 500 | #### body_required 501 | 502 | Determines if the request body is required in the request. Defaults to false. 503 | This affects the value of 504 | the [`requestBody`](https://spec.openapis.org/oas/v3.1.0#operation-object) 505 | field of the Operation object, or more precisely, 506 | the [`required`](https://spec.openapis.org/oas/v3.1.0#request-body-object) 507 | field of Request Body object. 508 | 509 | | Possible types | Examples | 510 | |----------------|----------| 511 | | `bool` | `True` | 512 | 513 | #### body_description 514 | 515 | A brief description of the request body. This could contain examples of 516 | use. [CommonMark syntax](https://spec.commonmark.org) MAY be used for rich text 517 | representation. This affects the value of 518 | the [`requestBody`](https://spec.openapis.org/oas/v3.1.0#operation-object) 519 | field of the Operation object, or more precisely, 520 | the [`description`](https://spec.openapis.org/oas/v3.1.0#request-body-object) 521 | field of Request Body object. 522 | 523 | | Possible types | Examples | 524 | |----------------|------------| 525 | | `str` | `"A book"` | 526 | 527 | #### body_example 528 | 529 | Example of the request body. The example object SHOULD be in the correct format 530 | as specified by the media type. This affects the value of 531 | the [`requestBody`](https://spec.openapis.org/oas/v3.1.0#operation-object) 532 | field of the Operation object, or more precisely, 533 | the [`example`](https://spec.openapis.org/oas/v3.1.0#media-type-object) field 534 | of 535 | Media Type object inside 536 | the value 537 | of [`content`](https://spec.openapis.org/oas/v3.1.0#request-body-object) field 538 | of Request Body object. 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 558 | 559 |
Possible typesExamples
Any 548 | 549 | ```python 550 | { 551 | "title": "Anna Karenina", 552 | "author": "Leo Tolstoy", 553 | "year": 1877, 554 | } 555 | ``` 556 | 557 |
560 | 561 | #### body_examples 562 | 563 | Examples of the request body. Each example object SHOULD match the media type 564 | and specified schema if present. This affects the value of 565 | the [`requestBody`](https://spec.openapis.org/oas/v3.1.0#operation-object) 566 | field of the Operation object, or more precisely, 567 | the [`examples`](https://spec.openapis.org/oas/v3.1.0#media-type-object) field 568 | of 569 | Media Type object inside 570 | the value 571 | of [`content`](https://spec.openapis.org/oas/v3.1.0#request-body-object) field 572 | of Request Body object. 573 | 574 | The values of this dictionary could be either examples themselves, 575 | or `openapify.core.openapi.models.Example` objects. In the latter case, 576 | extended information about examples, such as a summary and description, can be 577 | added to the [Example](https://spec.openapis.org/oas/v3.1.0#example-object) 578 | object. 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 600 | 601 | 602 | 603 | 619 | 620 |
Possible typesExamples
Mapping[str, Any] 588 | 589 | ```python 590 | { 591 | "Anna Karenina": { 592 | "title": "Anna Karenina", 593 | "author": "Leo Tolstoy", 594 | "year": 1877, 595 | } 596 | } 597 | ``` 598 | 599 |
Mapping[str, Example] 604 | 605 | ```python 606 | { 607 | "Anna Karenina": Example( 608 | value={ 609 | "title": "Anna Karenina", 610 | "author": "Leo Tolstoy", 611 | "year": 1877, 612 | }, 613 | summary="The book 'Anna Karenina'", 614 | ) 615 | } 616 | ``` 617 | 618 |
621 | 622 | #### query_params 623 | 624 | Dictionary of query parameters applicable for the operation, where the key is 625 | the parameter name and the value can be either a Python data type or 626 | a `QueryParam` object. 627 | 628 | In the first case it is the Python data type for the query parameter for which 629 | the JSON Schema will be built. This affects the value of 630 | the [`parameters`](https://spec.openapis.org/oas/v3.1.0#operation-object) 631 | field of the Operation object, or more precisely, 632 | the [`schema`](https://spec.openapis.org/oas/v3.1.0#parameter-object) field of 633 | Parameter object. 634 | 635 | In the second case it is `openapify.core.models.QueryParam` object that can 636 | have extended information about the parameter, such as a default value, 637 | deprecation marker, examples etc. 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 653 | 654 | 655 | 656 | 673 | 674 |
Possible typesExamples
Mapping[str, Type] 647 | 648 | ```python 649 | {"count": int} 650 | ``` 651 | 652 |
Mapping[str, QueryParam] 657 | 658 | ```python 659 | { 660 | "count": QueryParam( 661 | value_type=int, 662 | default=10, 663 | required=True, 664 | description="Limits the number of books returned", 665 | deprecated=False, 666 | allowEmptyValue=False, 667 | example=42, 668 | ) 669 | } 670 | ``` 671 | 672 |
675 | 676 | ### path_params 677 | 678 | Dictionary of path parameters applicable for the operation, where the key is 679 | the parameter name and the value can be either a Python data type or 680 | a `PathParam` object. 681 | 682 | In the first case it is the Python data type for the path parameter for which 683 | the JSON Schema will be built. This affects the value of 684 | the [`parameters`](https://spec.openapis.org/oas/v3.1.0#operation-object) 685 | field of the Operation object, or more precisely, 686 | the [`schema`](https://spec.openapis.org/oas/v3.1.0#parameter-object) field of 687 | Parameter object. 688 | 689 | In the second case it is `openapify.core.models.PathParam` object that can 690 | have extended information about the parameter, such as a description, examples. 691 | 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 706 | 707 | 708 | 709 | 722 | 723 |
Possible typesExamples
Mapping[str, Type] 700 | 701 | ```python 702 | {"id": UUID} 703 | ``` 704 | 705 |
Mapping[str, PathParam] 710 | 711 | ```python 712 | { 713 | "id": PathParam( 714 | value_type=UUID, 715 | description="ID of the book", 716 | example="eab9d66d-4317-464a-a995-510bd6e2148f", 717 | ) 718 | } 719 | ``` 720 | 721 |
724 | 725 | #### headers 726 | 727 | Dictionary of request headers applicable for the operation, where the key is 728 | the header name and the value can be either a string or a `Header` object. 729 | 730 | In the first case it is the header description. This affects the value of 731 | the [`parameters`](https://spec.openapis.org/oas/v3.1.0#operation-object) 732 | field of the Operation object, or more precisely, 733 | the [`description`](https://spec.openapis.org/oas/v3.1.0#parameter-object) 734 | field of Parameter object. 735 | 736 | In the second case it is `openapify.core.models.Header` object that can have 737 | extended information about the header, such as a description, deprecation 738 | marker, examples etc. 739 | 740 | 741 | 742 | 743 | 744 | 745 | 746 | 747 | 754 | 755 | 756 | 757 | 773 | 774 |
Possible typesExamples
Mapping[str, str] 748 | 749 | ```python 750 | {"X-Requested-With": "Information about the creation of the request"} 751 | ``` 752 | 753 |
Mapping[str, Header] 758 | 759 | ```python 760 | { 761 | "X-Requested-With": Header( 762 | description="Information about the creation of the request", 763 | required=True, 764 | value_type=str, 765 | deprecated=False, 766 | allowEmptyValue=False, 767 | example="XMLHttpRequest", 768 | ) 769 | } 770 | ``` 771 | 772 |
775 | 776 | #### cookies 777 | 778 | Dictionary of request cookies applicable for the operation, where the key is 779 | the cookie name and the value can be either a string or a `Cookie` object. 780 | 781 | In the first case it is the cookie description. This affects the value of 782 | the [`parameters`](https://spec.openapis.org/oas/v3.1.0#operation-object) 783 | field of the Operation object, or more precisely, 784 | the [`description`](https://spec.openapis.org/oas/v3.1.0#parameter-object) 785 | field of Parameter object. 786 | 787 | In the second case it is `openapify.core.models.Cookie` object that can have 788 | extended information about the cookie, such as a description, deprecation 789 | marker, examples etc. 790 | 791 | 792 | 793 | 794 | 795 | 796 | 797 | 798 | 805 | 806 | 807 | 808 | 824 | 825 |
Possible typesExamples
Mapping[str, str] 799 | 800 | ```python 801 | {"__ga": "A randomly generated number as a client ID"} 802 | ``` 803 | 804 |
Mapping[str, Cookie] 809 | 810 | ```python 811 | { 812 | "__ga": Cookie( 813 | description="A randomly generated number as a client ID", 814 | required=True, 815 | value_type=str, 816 | deprecated=False, 817 | allowEmptyValue=False, 818 | example="1.2.345678901.2345678901", 819 | ) 820 | } 821 | ``` 822 | 823 |
826 | 827 | ### Response 828 | 829 | Decorator `response_schema` describes a single response from the API Operation. 830 | Response can have an HTTP code, body and headers. If the Operation supports 831 | more than one response, then the decorator must be applied multiple times to 832 | cover each of them. 833 | 834 | ```python 835 | from openapify import response_schema 836 | ``` 837 | 838 | #### body 839 | 840 | A Python data type for the response body for which 841 | the JSON Schema will be built. This affects the value of 842 | the [`responses`](https://spec.openapis.org/oas/v3.1.0#operation-object) 843 | field of the Operation object, or more precisely, 844 | the [`schema`](https://spec.openapis.org/oas/v3.1.0#media-type-object) field of 845 | Media Type object inside the value 846 | of [`content`](https://spec.openapis.org/oas/v3.1.0#response-object) field 847 | of Response object. 848 | 849 | | Possible types | Examples | 850 | |----------------|----------| 851 | | `Type` | `Book` | 852 | 853 | #### http_code 854 | 855 | An HTTP code of the response. This affects the value of 856 | the [`responses`](https://spec.openapis.org/oas/v3.1.0#operation-object) 857 | field of the Operation object, or more precisely, the patterned key in 858 | the [Responses](https://spec.openapis.org/oas/v3.1.0#responses-object) object. 859 | 860 | | Possible types | Examples | 861 | |----------------|----------| 862 | | `str` | `"200"` | 863 | | `int` | `400` | 864 | 865 | #### media_type 866 | 867 | A media type 868 | or [media type range](https://www.rfc-editor.org/rfc/rfc7231#appendix-D) of the 869 | response body. This affects the value of 870 | the [`responses`](https://spec.openapis.org/oas/v3.1.0#operation-object) 871 | field of the Operation object, or more precisely, the key 872 | of [`content`](https://spec.openapis.org/oas/v3.1.0#response-object) field of 873 | Response object. 874 | 875 | The default value is `"application/json"`. 876 | 877 | | Possible types | Examples | 878 | |----------------|---------------------| 879 | | `str` | `"application/xml"` | 880 | 881 | #### 882 | 883 | #### description 884 | 885 | A description of the response. [CommonMark syntax](https://spec.commonmark.org) 886 | MAY be used for rich text representation. This affects the value of 887 | the [`responses`](https://spec.openapis.org/oas/v3.1.0#operation-object) 888 | field of the Operation object, or more precisely, 889 | the [`description`](https://spec.openapis.org/oas/v3.1.0#response-object) field 890 | of Response object. 891 | 892 | 893 | | Possible types | Examples | 894 | |----------------|-------------------------| 895 | | `str` | `"Invalid ID Supplied"` | 896 | 897 | #### headers 898 | 899 | Dictionary of response headers applicable for the operation, where the key is 900 | the header name and the value can be either a string or a `Header` object. 901 | 902 | In the first case it is the header description. This affects the value of 903 | the [`responses`](https://spec.openapis.org/oas/v3.1.0#operation-object) 904 | field of the Operation object, or more precisely, 905 | the [`description`](https://spec.openapis.org/oas/v3.1.0#header-object) 906 | field of Header object. 907 | 908 | In the second case it is `openapify.core.models.Header` object that can have 909 | extended information about the header, such as a description, deprecation 910 | marker, examples etc. 911 | 912 | 913 | 914 | 915 | 916 | 917 | 918 | 919 | 926 | 927 | 928 | 929 | 941 | 942 |
Possible typesExamples
Mapping[str, str] 920 | 921 | ```python 922 | {"Content-Location": "An alternate location for the returned data"} 923 | ``` 924 | 925 |
Mapping[str, Header] 930 | 931 | ```python 932 | { 933 | "Content-Location": Header( 934 | description="An alternate location for the returned data", 935 | example="/index.htm", 936 | ) 937 | } 938 | ``` 939 | 940 |
943 | 944 | #### example 945 | 946 | Example of the response body. The example object SHOULD be in the correct format 947 | as specified by the media type. This affects the value of 948 | the [`responses`](https://spec.openapis.org/oas/v3.1.0#operation-object) 949 | field of the Operation object, or more precisely, 950 | the [`example`](https://spec.openapis.org/oas/v3.1.0#media-type-object) field 951 | of Media Type object inside the value 952 | of [`content`](https://spec.openapis.org/oas/v3.1.0#response-object) field of 953 | Response object. 954 | 955 | 956 | 957 | 958 | 959 | 960 | 961 | 962 | 973 | 974 |
Possible typesExamples
Any 963 | 964 | ```python 965 | { 966 | "title": "Anna Karenina", 967 | "author": "Leo Tolstoy", 968 | "year": 1877, 969 | } 970 | ``` 971 | 972 |
975 | 976 | #### examples 977 | 978 | Examples of the response body. Each example object SHOULD match the media type 979 | and specified schema if present. This affects the value of 980 | the [`responses`](https://spec.openapis.org/oas/v3.1.0#operation-object) 981 | field of the Operation object, or more precisely, 982 | the [`examples`](https://spec.openapis.org/oas/v3.1.0#media-type-object) field 983 | of Media Type object inside the value 984 | of [`content`](https://spec.openapis.org/oas/v3.1.0#response-object) field of 985 | Response object. 986 | 987 | The values of this dictionary could be either examples themselves, 988 | or `openapify.core.openapi.models.Example` objects. In the latter case, 989 | extended information about examples, such as a summary and description, can be 990 | added to the [Example](https://spec.openapis.org/oas/v3.1.0#example-object) 991 | object. 992 | 993 | 994 | 995 | 996 | 997 | 998 | 999 | 1000 | 1013 | 1014 | 1015 | 1016 | 1032 | 1033 |
Possible typesExamples
Mapping[str, Any] 1001 | 1002 | ```python 1003 | { 1004 | "Anna Karenina": { 1005 | "title": "Anna Karenina", 1006 | "author": "Leo Tolstoy", 1007 | "year": 1877, 1008 | } 1009 | } 1010 | ``` 1011 | 1012 |
Mapping[str, Example] 1017 | 1018 | ```python 1019 | { 1020 | "Anna Karenina": Example( 1021 | value={ 1022 | "title": "Anna Karenina", 1023 | "author": "Leo Tolstoy", 1024 | "year": 1877, 1025 | }, 1026 | summary="The book 'Anna Karenina'", 1027 | ) 1028 | } 1029 | ``` 1030 | 1031 |
1034 | 1035 | ### Security requirements 1036 | 1037 | Decorator `security_requirements` 1038 | declares [security mechanisms](https://spec.openapis.org/oas/v3.1.0#securityRequirementObject) 1039 | that can be used for the operation. 1040 | 1041 | ```python 1042 | from openapify import security_requirements 1043 | ``` 1044 | 1045 | This decorator takes one or more `SecurityRequirement` mappings, where the key 1046 | is the requirement name and the value is `SecurityScheme` object. There are 1047 | classes for 1048 | each [security scheme](https://spec.openapis.org/oas/v3.1.0#security-scheme-object) 1049 | which can be imported as follows: 1050 | 1051 | ```python 1052 | from openapify.core.openapi.models import ( 1053 | APIKeySecurityScheme, 1054 | HTTPSecurityScheme, 1055 | OAuth2SecurityScheme, 1056 | OpenIDConnectSecurityScheme, 1057 | ) 1058 | ``` 1059 | 1060 | For example, to add authorization by token, you can write something like this: 1061 | 1062 | ```python 1063 | from openapify import security_requirements 1064 | from openapify.core.openapi.models import ( 1065 | APIKeySecurityScheme, 1066 | SecuritySchemeAPIKeyLocation, 1067 | ) 1068 | 1069 | XAuthTokenSecurityRequirement = { 1070 | "x-auth-token": APIKeySecurityScheme( 1071 | name="X-Auh-Token", 1072 | location=SecuritySchemeAPIKeyLocation.HEADER, 1073 | ) 1074 | } 1075 | 1076 | @security_requirements(XAuthTokenSecurityRequirement) 1077 | def secure_operation(): 1078 | ... 1079 | ``` 1080 | 1081 | And the generated specification document will look like this: 1082 | 1083 | ```yaml 1084 | openapi: 3.1.0 1085 | info: 1086 | title: API 1087 | version: 1.0.0 1088 | paths: 1089 | /secure_path: 1090 | get: 1091 | security: 1092 | - x-auth-token: [] 1093 | components: 1094 | securitySchemes: 1095 | x-auth-token: 1096 | type: apiKey 1097 | name: X-Auh-Token 1098 | in: header 1099 | ``` 1100 | 1101 | Plugins 1102 | -------------------------------------------------------------------------------- 1103 | 1104 | Some aspects of creating an OpenAPI document can be changed using plugins. 1105 | There is `openapify.plugins.BasePlugin` base class, which has all the methods 1106 | available for definition. If you want to write a plugin that, for example, will 1107 | only generate schema for request parameters, then it will be enough for you to 1108 | define only one appropriate method, and leave the rest non-implemented. 1109 | Plugin system works by going through all registered plugins and calling 1110 | the appropriate method. If such a method raises `NotImplementedError` or 1111 | returns `None`, it is assumed that this plugin doesn't provide the necessary 1112 | functionality. Iteration stops at the first plugin that returned something 1113 | other than `None`. 1114 | 1115 | Plugins are registered via the `plugins` argument of the `build_spec` function: 1116 | 1117 | ```python 1118 | from openapify import BasePlugin, build_spec 1119 | 1120 | 1121 | class MyPlugin1(BasePlugin): 1122 | def schema_helper(...): 1123 | # return something meaningful here, see the following chapters 1124 | ... 1125 | 1126 | 1127 | build_spec(..., plugins=[MyPlugin1()]) 1128 | ``` 1129 | 1130 | ### `schema_helper` 1131 | 1132 | OpenAPI [Schema](https://spec.openapis.org/oas/v3.1.0#schemaObject) object 1133 | is built from python types stored in the `value_type` attribute of the 1134 | following openapify dataclasses defined in `openapify.core.models`: 1135 | * `Body` 1136 | * `Cookie` 1137 | * `Header` 1138 | * `QueryParam` 1139 | 1140 | Out of the box, the schema is generated by using 1141 | [`mashumaro`](https://github.com/Fatal1ty/mashumaro) library (see the note 1142 | below), but support for third-party entity schema generators can be achieved 1143 | through `schema_helper` method. For example, here's what a plugin for pydantic 1144 | models might look like: 1145 | 1146 | ```python 1147 | from typing import Any 1148 | 1149 | from openapify import BasePlugin 1150 | from openapify.core.models import Body, Cookie, Header, QueryParam 1151 | from pydantic import BaseModel 1152 | 1153 | 1154 | class PydanticSchemaPlugin(BasePlugin): 1155 | def schema_helper( 1156 | self, 1157 | obj: Body | Cookie | Header | QueryParam, 1158 | name: str | None = None, 1159 | ) -> dict[str, Any] | None: 1160 | if issubclass(obj.value_type, BaseModel): 1161 | schema = obj.value_type.model_json_schema( 1162 | ref_template="#/components/schemas/{model}" 1163 | ) 1164 | self.spec.components.schemas.update(schema.pop("$defs", {})) 1165 | return schema 1166 | ``` 1167 | 1168 | > [!NOTE]\ 1169 | > The [`BaseSchemaPlugin`](https://github.com/Fatal1ty/openapify/blob/master/openapify/core/base_plugins.py#L41-L64), 1170 | > which is enabled by default and has the lowest priority, is responsible for 1171 | > generating the schema. This plugin utilizes the mashumaro library for schema 1172 | > generation, which in turn incorporates its own [plugin system](https://github.com/Fatal1ty/mashumaro?tab=readme-ov-file#json-schema-plugins), 1173 | > enabling customization of JSON Schema generation and support for additional 1174 | > data types. For more nuanced modifications, particularly within nested data 1175 | > models, you can employ `BaseSchemaPlugin` with a specified list of mashumaro JSON 1176 | > Schema plugins. This approach allows for finer control over schema generation 1177 | > when needed: 1178 | > ```python 1179 | > from mashumaro.jsonschema.plugins import DocstringDescriptionPlugin 1180 | > 1181 | > from openapify import build_spec 1182 | > from openapify.core.base_plugins import BaseSchemaPlugin 1183 | > 1184 | > spec = build_spec( 1185 | > routes=[...], 1186 | > plugins=[ 1187 | > BaseSchemaPlugin( 1188 | > plugins=[ 1189 | > DocstringDescriptionPlugin(), 1190 | > ] 1191 | > ), 1192 | > ], 1193 | > ) 1194 | > ``` 1195 | 1196 | ### media_type_helper 1197 | 1198 | A media type is used in OpenAPI Request 1199 | [Body](https://spec.openapis.org/oas/v3.1.0#request-body-object) and 1200 | [Response](https://spec.openapis.org/oas/v3.1.0#response-object) objects. 1201 | By default, `application/octet-stream` is applied for `bytes` or `bytearray` 1202 | types, and `application/json` is applied otherwise. You can support more media 1203 | types or override existing ones with `media_type_helper` method. 1204 | 1205 | Let's imagine that you have an API route that returns PNG images as the body. 1206 | You can have a separate model class representing images, but the more common 1207 | case is to use `typing.Annotated` wrapper for bytes. Here's what a plugin for 1208 | `image/png` media type might look like: 1209 | 1210 | ```python 1211 | from typing import Annotated, Any, Dict, Optional 1212 | 1213 | from openapify import BasePlugin, build_spec, response_schema 1214 | from openapify.core.models import Body, RouteDef 1215 | 1216 | ImagePNG = Annotated[bytes, "PNG"] 1217 | 1218 | 1219 | class ImagePNGPlugin(BasePlugin): 1220 | def media_type_helper( 1221 | self, body: Body, schema: Dict[str, Any] 1222 | ) -> Optional[str]: 1223 | if body.value_type is ImagePNG: 1224 | return "image/png" 1225 | 1226 | 1227 | @response_schema(body=ImagePNG) 1228 | def foo(): 1229 | ... 1230 | 1231 | 1232 | routes = [RouteDef("/foo", "get", foo)] 1233 | spec = build_spec(routes, plugins=[ImagePNGPlugin()]) 1234 | print(spec.to_yaml()) 1235 | ``` 1236 | 1237 | The resulting document will contain `image/png` content in the response: 1238 | ```yaml 1239 | openapi: 3.1.0 1240 | info: 1241 | title: API 1242 | version: 1.0.0 1243 | paths: 1244 | /foo: 1245 | get: 1246 | responses: 1247 | '200': 1248 | description: OK 1249 | content: 1250 | image/png: 1251 | schema: {} 1252 | ``` 1253 | --------------------------------------------------------------------------------