├── apispec_serpyco ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── utils.py │ ├── test_schema_name_resolver.py │ └── test_ext_serpyco.py ├── README.md ├── setup.py ├── LICENSE └── apispec_serpyco │ ├── utils.py │ ├── openapi.py │ └── __init__.py ├── apispec_marshmallow_advanced ├── tests │ ├── __init__.py │ ├── test_schema_class_resolving.py │ ├── utils.py │ └── conftest.py ├── apispec_marshmallow_advanced │ ├── openapi.py │ ├── __init__.py │ └── common.py ├── README.md ├── setup.py └── LICENSE ├── README.md ├── setup.cfg ├── LICENSE ├── .travis.yml └── .gitignore /apispec_serpyco/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apispec_plugins 2 | 3 | [![Build Status](https://travis-ci.org/algoo/apispec_plugins.svg?branch=master)](https://travis-ci.org/algoo/apispec_plugins) 4 | 5 | Apispec plugins repository of Algoo 6 | 7 | * [Marshmallow advanced](apispec_marshmallow_advanced/README.md) 8 | * [serpyco](apispec_serpyco/README.md) 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length = 80 3 | force_single_line = true 4 | force_sort_within_sections = true 5 | combine_as_imports = true 6 | multi_line_output = 3 7 | include_trailing_comma = false 8 | force_grid_wrap = 0 9 | use_parentheses = true 10 | skip = .eggs,.venv 11 | known_third_party = apispec,marshmallow,serpyco 12 | known_first_party = tests 13 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/apispec_marshmallow_advanced/openapi.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from apispec.ext.marshmallow import OpenAPIConverter 3 | 4 | from apispec_marshmallow_advanced.common import schema_class_resolver 5 | 6 | 7 | class HapicOpenAPIConverter(OpenAPIConverter): 8 | def resolve_schema_class(self, schema): 9 | """See parent method""" 10 | return schema_class_resolver(self.spec, schema) 11 | -------------------------------------------------------------------------------- /apispec_serpyco/README.md: -------------------------------------------------------------------------------- 1 | apispec SerpycoPlugin 2 | --------------------- 3 | 4 | `SerpycoPlugin` is [Serpyco](https://gitlab.com/sgrignard/serpyco) apispec integration plugin. 5 | Code of this repository strongly inspired and based on apispec source code [here](https://github.com/marshmallow-code/apispec/tree/b4bf604b6847b87616b84aed417154a58a97a8de) 6 | 7 | Install 8 | ------- 9 | 10 | pip install apispec_serpyco 11 | 12 | Tests 13 | ----- 14 | 15 | To execute tests, be sure to install test tools 16 | 17 | pip install -e ".[test]" 18 | 19 | To execute tests, run 20 | 21 | pytest tests 22 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/README.md: -------------------------------------------------------------------------------- 1 | apispec MarshmallowAdvancedPlugin 2 | --------------------- 3 | 4 | `MarshmallowAdvancedPlugin` a simple overloading of 5 | original apispec marshmallow plugin : 6 | 7 | - manage `only` and `exclude` schema parameters and auto-generate associated schemas. 8 | - works with auto-referencing mechanism 9 | 10 | Install 11 | ------- 12 | 13 | pip install apispec_marshmallow_advanced 14 | 15 | Tests 16 | ----- 17 | 18 | To execute tests, be sure to install test tools 19 | 20 | pip install -e ".[test]" 21 | 22 | To execute tests, run 23 | 24 | pytest tests 25 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/tests/test_schema_class_resolving.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import marshmallow 3 | 4 | from tests.utils import get_definitions 5 | 6 | 7 | class Person(marshmallow.Schema): 8 | first_name = marshmallow.fields.String() 9 | last_name = marshmallow.fields.String() 10 | phone_number = marshmallow.fields.String() 11 | 12 | 13 | class Assembly(marshmallow.Schema): 14 | president = marshmallow.fields.Nested(Person) 15 | deputies = marshmallow.fields.Nested(Person, many=True, exclude=("phone_number",)) 16 | 17 | 18 | class TestSchemaClassResolving(object): 19 | def test_unit__reference_with_exclude__ok__nominal_case(self, spec): 20 | spec.components.schema("assembly", schema=Assembly) 21 | definitions = get_definitions(spec) 22 | 23 | pass 24 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/apispec_marshmallow_advanced/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from apispec.ext.marshmallow import MarshmallowPlugin 3 | 4 | from apispec_marshmallow_advanced.common import generate_schema_name 5 | from apispec_marshmallow_advanced.openapi import HapicOpenAPIConverter 6 | 7 | 8 | class MarshmallowAdvancedPlugin(MarshmallowPlugin): 9 | def __init__(self, schema_name_resolver=None): 10 | schema_name_resolver = schema_name_resolver or generate_schema_name 11 | super().__init__(schema_name_resolver) 12 | 13 | def init_spec(self, spec): 14 | super().init_spec(spec) 15 | self.openapi = HapicOpenAPIConverter( 16 | openapi_version=spec.openapi_version, 17 | spec=self.spec, 18 | schema_name_resolver=self.schema_name_resolver, 19 | ) 20 | -------------------------------------------------------------------------------- /apispec_serpyco/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import namedtuple 3 | 4 | from apispec import APISpec 5 | import pytest 6 | 7 | from apispec_serpyco import SerpycoPlugin 8 | 9 | 10 | def make_spec(openapi_version): 11 | s_plugin = SerpycoPlugin() 12 | spec = APISpec( 13 | title="Validation", 14 | version="0.1", 15 | openapi_version=openapi_version, 16 | plugins=(s_plugin,), 17 | ) 18 | return namedtuple("Spec", ("spec", "serpyco_plugin", "openapi"))( 19 | spec, s_plugin, s_plugin.openapi 20 | ) 21 | 22 | 23 | @pytest.fixture(params=("2.0", "3.0.0")) 24 | def spec_fixture(request): 25 | return make_spec(request.param) 26 | 27 | 28 | @pytest.fixture(params=("2.0", "3.0.0")) 29 | def spec(request): 30 | return make_spec(request.param).spec 31 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/tests/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | 4 | def get_definitions(spec): 5 | if spec.openapi_version.major < 3: 6 | return spec.to_dict()["definitions"] 7 | return spec.to_dict()["components"]["schemas"] 8 | 9 | 10 | def get_parameters(spec): 11 | if spec.openapi_version.major < 3: 12 | return spec.to_dict()["parameters"] 13 | return spec.to_dict()["components"]["parameters"] 14 | 15 | 16 | def get_responses(spec): 17 | if spec.openapi_version.major < 3: 18 | return spec.to_dict()["responses"] 19 | return spec.to_dict()["components"]["responses"] 20 | 21 | 22 | def get_paths(spec): 23 | return spec.to_dict()["paths"] 24 | 25 | 26 | def ref_path(spec): 27 | if spec.openapi_version.version[0] < 3: 28 | return "#/definitions/" 29 | return "#/components/schemas/" 30 | -------------------------------------------------------------------------------- /apispec_serpyco/tests/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | 4 | def get_definitions(spec): 5 | if spec.openapi_version.major < 3: 6 | return spec.to_dict().get("definitions", {}) 7 | return spec.to_dict().get("components", {}).get("schemas", {}) 8 | 9 | 10 | def get_parameters(spec): 11 | if spec.openapi_version.major < 3: 12 | return spec.to_dict().get("parameters", {}) 13 | return spec.to_dict().get("components", {}).get("parameters", {}) 14 | 15 | 16 | def get_responses(spec): 17 | if spec.openapi_version.major < 3: 18 | return spec.to_dict().get("responses", {}) 19 | return spec.to_dict().get("components", {}).get("responses", {}) 20 | 21 | 22 | def get_paths(spec): 23 | return spec.to_dict()["paths"] 24 | 25 | 26 | def ref_path(spec): 27 | if spec.openapi_version.version[0] < 3: 28 | return "#/definitions/" 29 | return "#/components/schemas/" 30 | -------------------------------------------------------------------------------- /apispec_serpyco/setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from setuptools import setup 3 | 4 | setup( 5 | name="apispec_serpyco", 6 | version="0.21", 7 | author="Algoo", 8 | author_email="contact@algoo.fr", 9 | description="Serpyco plugin for Apispec", 10 | license="MIT", 11 | keywords="apispec openapi serpyco api", 12 | url="https://github.com/algoo/apispec_plugins", 13 | packages=["apispec_serpyco"], 14 | long_description="https://github.com/algoo/apispec_plugins/tree/master/apispec_serpyco", 15 | classifiers=[ 16 | "Development Status :: 2 - Pre-Alpha", 17 | "Programming Language :: Python :: 3.6", 18 | "Programming Language :: Python :: 3.7", 19 | "License :: OSI Approved :: MIT License", 20 | ], 21 | install_requires=["apispec>=1.1.0,<3", "serpyco>=0.18.0", "typing-inspect"], 22 | extras_require={"test": ["pytest"]}, 23 | data_files = [("", ["LICENSE"])], 24 | ) 25 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from collections import namedtuple 3 | 4 | from apispec import APISpec 5 | import pytest 6 | 7 | from apispec_marshmallow_advanced import MarshmallowAdvancedPlugin 8 | from apispec_marshmallow_advanced.common import generate_schema_name 9 | 10 | 11 | def make_spec(openapi_version): 12 | m_plugin = MarshmallowAdvancedPlugin(schema_name_resolver=generate_schema_name) 13 | spec = APISpec( 14 | title="Validation", 15 | version="0.1", 16 | openapi_version=openapi_version, 17 | plugins=(m_plugin,), 18 | ) 19 | return namedtuple("Spec", ("spec", "marshmallow_plugin", "openapi"))( 20 | spec, m_plugin, m_plugin.openapi 21 | ) 22 | 23 | 24 | @pytest.fixture(params=("2.0", "3.0.0")) 25 | def spec_fixture(request): 26 | return make_spec(request.param) 27 | 28 | 29 | @pytest.fixture(params=("2.0", "3.0.0")) 30 | def spec(request): 31 | return make_spec(request.param).spec 32 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from setuptools import setup 3 | 4 | setup( 5 | name="apispec_marshmallow_advanced", 6 | version="0.4", 7 | author="Algoo", 8 | author_email="contact@algoo.fr", 9 | description="Marshmallow apispec plugin for hapic", 10 | license="MIT", 11 | keywords="apispec openapi marshmallow api", 12 | url="https://github.com/algoo/apispec_plugins", 13 | packages=["apispec_marshmallow_advanced"], 14 | long_description="https://github.com/algoo/apispec_plugins/tree/master/apispec_marshmallow_advanced", 15 | classifiers=[ 16 | "Development Status :: 4 - Beta", 17 | "Programming Language :: Python :: 3.4", 18 | "Programming Language :: Python :: 3.5", 19 | "Programming Language :: Python :: 3.6", 20 | "Programming Language :: Python :: 3.7", 21 | "License :: OSI Approved :: MIT License", 22 | ], 23 | install_requires=["apispec>=1.1.0,<3", "marshmallow"], 24 | extras_require={"test": ["pytest"]}, 25 | data_files = [("", ["LICENSE"])], 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 algoo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apispec_serpyco/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 algoo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 algoo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | 4 | sudo: false 5 | 6 | jobs: 7 | include: 8 | - stage: Tests 9 | name: apispec_serpyco (Python3.6) 10 | python: "3.6" 11 | install: 12 | - cd $TRAVIS_BUILD_DIR/apispec_serpyco 13 | - python setup.py develop 14 | - pip install -e ".[test]" 15 | script: pytest $TRAVIS_BUILD_DIR/apispec_serpyco/tests 16 | 17 | - stage: Tests 18 | python: "3.7" 19 | name: apispec_serpyco (Python3.7) 20 | install: 21 | - cd $TRAVIS_BUILD_DIR/apispec_serpyco 22 | - python setup.py develop 23 | - pip install -e ".[test]" 24 | script: pytest $TRAVIS_BUILD_DIR/apispec_serpyco/tests 25 | 26 | - stage: Tests 27 | python: "3.6" 28 | name: apispec_marshmallow_advanced (Python3.6) 29 | install: 30 | - cd $TRAVIS_BUILD_DIR/apispec_marshmallow_advanced 31 | - python setup.py develop 32 | - pip install -e ".[test]" 33 | script: pytest $TRAVIS_BUILD_DIR/apispec_marshmallow_advanced/tests 34 | 35 | - stage: Tests 36 | python: "3.7" 37 | name: apispec_marshmallow_advanced (Python3.7) 38 | install: 39 | - cd $TRAVIS_BUILD_DIR/apispec_marshmallow_advanced 40 | - python setup.py develop 41 | - pip install -e ".[test]" 42 | script: pytest $TRAVIS_BUILD_DIR/apispec_marshmallow_advanced/tests 43 | -------------------------------------------------------------------------------- /apispec_serpyco/tests/test_schema_name_resolver.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import dataclasses 3 | 4 | import typing 5 | 6 | from apispec_serpyco.utils import schema_name_resolver 7 | 8 | 9 | def test_schema_name_resolver__nominal_case(): 10 | @dataclasses.dataclass 11 | class Foo: 12 | pass 13 | 14 | assert "Foo" == schema_name_resolver(Foo) 15 | 16 | 17 | def test_schema_name_resolver__exclude(): 18 | @dataclasses.dataclass 19 | class Foo: 20 | bar: str 21 | 22 | assert "Foo_exclude_bar" == schema_name_resolver(Foo, exclude=["bar"]) 23 | 24 | 25 | def test_schema_name_resolver__include(): 26 | @dataclasses.dataclass 27 | class Foo: 28 | bar: str 29 | baz: str 30 | 31 | assert "Foo_exclude_bar" == schema_name_resolver(Foo, only=["baz"]) 32 | 33 | 34 | def test_schema_name_resolver__generic_type(): 35 | @dataclasses.dataclass 36 | class Foo: 37 | bar: str 38 | 39 | T = typing.TypeVar("T") 40 | 41 | @dataclasses.dataclass 42 | class Bar(typing.Generic[T]): 43 | items: typing.List[T] 44 | 45 | assert "Bar_Foo" == schema_name_resolver(Bar[Foo]) 46 | 47 | 48 | def test_schema_name_resolver__args_generic_type(): 49 | @dataclasses.dataclass 50 | class Foo: 51 | bar: str 52 | 53 | T = typing.TypeVar("T") 54 | 55 | @dataclasses.dataclass 56 | class Bar(typing.Generic[T]): 57 | items: typing.List[T] 58 | 59 | assert "Bar_Foo_int" == schema_name_resolver(Bar[Foo], [int]) 60 | -------------------------------------------------------------------------------- /apispec_serpyco/apispec_serpyco/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import dataclasses 3 | import typing 4 | 5 | import typing_inspect 6 | 7 | 8 | def extract_name_of_dataclass(dataclass_: type) -> typing.Tuple[type, str]: 9 | try: 10 | dataclass_name = dataclass_.__name__ 11 | except AttributeError: 12 | dataclass_name = dataclass_.__origin__.__name__ 13 | 14 | return dataclass_name 15 | 16 | 17 | def schema_name_resolver( 18 | dataclass_: type, 19 | arguments: typing.Optional[tuple] = None, 20 | only: typing.Optional[typing.List[str]] = None, 21 | exclude: typing.Optional[typing.List[str]] = None, 22 | ) -> str: 23 | if typing_inspect.is_generic_type(dataclass_): 24 | dataclass_name = extract_name_of_dataclass(dataclass_) 25 | dataclass_name += "_" + "_".join( 26 | [arg.__name__ for arg in typing_inspect.get_args(dataclass_)] 27 | + ([a.__name__ for a in arguments] if arguments is not None else []) 28 | ) 29 | else: 30 | dataclass_name = extract_name_of_dataclass(dataclass_) 31 | 32 | dataclass_origin = dataclass_ 33 | try: 34 | dataclass_origin = dataclass_.__origin__ 35 | except AttributeError: 36 | pass 37 | 38 | only = only or [] 39 | exclude = exclude or [] 40 | excluded_field_names = exclude 41 | dataclass_field_names = [f.name for f in dataclasses.fields(dataclass_origin)] 42 | 43 | if only: 44 | for dataclass_field_name in dataclass_field_names: 45 | if dataclass_field_name not in only: 46 | excluded_field_names.append(dataclass_field_name) 47 | 48 | if not excluded_field_names: 49 | return dataclass_name 50 | 51 | return "{}_exclude_{}".format(dataclass_name, "_".join(excluded_field_names)) 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | *~ 107 | .idea 108 | venv* 109 | -------------------------------------------------------------------------------- /apispec_marshmallow_advanced/apispec_marshmallow_advanced/common.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import marshmallow 3 | 4 | 5 | def get_excluded_params(schema): 6 | """ 7 | Get all params excluded in this schema, 8 | if "only" is provided in schema instance, 9 | consider all not included params as excluded. 10 | :param schema: instance or cls schema 11 | :return: set of excluded params 12 | """ 13 | if isinstance(schema, type): 14 | return set() 15 | 16 | exclude = set() 17 | only = set() 18 | if getattr(schema, "exclude", ()): 19 | exclude = set(getattr(schema, "exclude", ())) 20 | if getattr(schema, "only", ()): 21 | only = set(getattr(schema, "only", ())) 22 | if only: 23 | for field in schema._declared_fields: 24 | if field not in only: 25 | exclude.add(str(field)) 26 | return exclude 27 | 28 | 29 | def generate_id(schema, exclude=()): 30 | """ 31 | Generate id in order to distinct 2 schemas, instance 32 | or cls. 33 | :param schema: base_schema 34 | :param exclude: excluded fields 35 | :return: str id related to schema and exclude params 36 | """ 37 | schema_id = "" 38 | if isinstance(schema, type): 39 | schema_id += schema.__name__ 40 | else: 41 | schema_id += type(schema).__name__ 42 | 43 | # fields 44 | fields = [field for field in schema._declared_fields.keys() if field not in exclude] 45 | fields = sorted(fields) 46 | schema_id += "(" 47 | for field in fields: 48 | schema_id += "_" + str(field) 49 | schema_id += ")" 50 | 51 | return schema_id 52 | 53 | 54 | def schema_class_resolver(marshmallow_plugin, schema): 55 | """ 56 | Return best candidate class for a schema instance or cls. 57 | :param spec: Apispec instance 58 | :param schema: schema instance or cls 59 | :return: best schema cls 60 | """ 61 | if isinstance(schema, type): 62 | return schema 63 | 64 | # Get instance params 65 | exclude = get_excluded_params(schema) 66 | 67 | cls_schema = type(schema) 68 | 69 | # generate id 70 | schema_id = generate_id(schema, exclude) 71 | 72 | # same as class schema ? 73 | if generate_id(cls_schema) == schema_id: 74 | return cls_schema 75 | 76 | # FIXME BS 2018-11-22: Must be in real code 77 | if not hasattr(marshmallow_plugin, "auto_generated_schemas"): 78 | marshmallow_plugin.auto_generated_schemas = {} 79 | 80 | # already generated similar schema ? 81 | if schema_id in marshmallow_plugin.auto_generated_schemas: 82 | return marshmallow_plugin.auto_generated_schemas[schema_id] 83 | 84 | # no similar schema found, create new one 85 | class NewSchema(cls_schema): 86 | pass 87 | 88 | NewSchema.opts.exclude = exclude 89 | NewSchema.__name__ = cls_schema.__name__ 90 | NewSchema._schema_name = "{}_{}".format(cls_schema.__name__, id(NewSchema)) 91 | marshmallow_plugin.auto_generated_schemas[schema_id] = NewSchema 92 | return NewSchema 93 | 94 | 95 | def generate_schema_name(schema: marshmallow.Schema): 96 | """ 97 | Return best candidate name for one schema cls or instance. 98 | :param schema: instance or cls schema 99 | :return: best schema name 100 | """ 101 | if not isinstance(schema, type): 102 | schema = type(schema) 103 | 104 | if getattr(schema, "_schema_name", None): 105 | if schema.opts.exclude: 106 | schema_name = "{}_without".format(schema.__name__) 107 | for elem in sorted(schema.opts.exclude): 108 | schema_name = "{}_{}".format(schema_name, elem) 109 | else: 110 | schema_name = schema._schema_name 111 | else: 112 | schema_name = schema.__name__ 113 | 114 | return schema_name 115 | -------------------------------------------------------------------------------- /apispec_serpyco/apispec_serpyco/openapi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import typing 3 | 4 | from apispec.utils import OpenAPIVersion 5 | import serpyco 6 | from serpyco.schema import default_get_definition_name 7 | 8 | import dataclasses 9 | 10 | __location_map__ = { 11 | "query": "query", 12 | "querystring": "query", 13 | "json": "body", 14 | "headers": "header", 15 | "cookies": "cookie", 16 | "form": "formData", 17 | "files": "formData", 18 | } 19 | 20 | 21 | class OpenAPIConverter(object): 22 | """Converter generating OpenAPI specification from serpyco schemas and fields 23 | 24 | :param str|OpenAPIVersion openapi_version: The OpenAPI version to use. 25 | Should be in the form '2.x' or '3.x.x' to comply with the OpenAPI standard. 26 | """ 27 | 28 | def __init__( 29 | self, openapi_version, schema_name_resolver=default_get_definition_name 30 | ): 31 | self.openapi_version = OpenAPIVersion(openapi_version) 32 | # Schema references 33 | self.refs = {} 34 | self._schema_name_resolver = schema_name_resolver 35 | 36 | def get_ref_path(self): 37 | """Return the path for references based on the openapi version""" 38 | ref_paths = {2: "definitions", 3: "components/schemas"} 39 | return ref_paths[self.openapi_version.major] 40 | 41 | def schema2jsonschema(self, schema, **kwargs): 42 | return self.fields2jsonschema(dataclasses.fields(schema), schema, **kwargs) 43 | 44 | def resolve_schema_dict(self, schema): 45 | if isinstance(schema, dict): 46 | if schema.get("type") == "array" and "items" in schema: 47 | schema["items"] = self.resolve_schema_dict(schema["items"]) 48 | if schema.get("type") == "object" and "properties" in schema: 49 | schema["properties"] = { 50 | k: self.resolve_schema_dict(v) 51 | for k, v in schema["properties"].items() 52 | } 53 | return schema 54 | 55 | if schema in self.refs: 56 | ref_path = self.get_ref_path() 57 | ref_schema = {"$ref": "#/{0}/{1}".format(ref_path, self.refs[schema])} 58 | return ref_schema 59 | 60 | return self.schema2jsonschema(schema) 61 | 62 | def fields2jsonschema(self, fields, schema=None): 63 | """Convert dataclass field into json_schema""" 64 | field_names = [field.name for field in fields] 65 | serializer = serpyco.SchemaBuilder( 66 | dataclass=schema, 67 | only=field_names, 68 | get_definition_name=self._schema_name_resolver, 69 | ) 70 | 71 | return serializer.json_schema() 72 | 73 | def schema2parameters(self, schema, **kwargs): 74 | """Return an array of OpenAPI parameters given a given dataclass. 75 | If `default_in` is "body", then return an array 76 | of a single parameter; else return an array of a parameter for each included field in 77 | the dataclass. 78 | 79 | https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#parameterObject 80 | """ 81 | return self.fields2parameters(dataclasses.fields(schema), schema, **kwargs) 82 | 83 | def fields2parameters( 84 | self, 85 | fields, 86 | schema, 87 | default_in="body", 88 | name="body", 89 | required=False, 90 | description=None, 91 | **kwargs 92 | ): 93 | """Convert dataclass fields into OpenAPI parameters""" 94 | openapi_default_in = __location_map__.get(default_in, default_in) 95 | if self.openapi_version.major < 3 and openapi_default_in == "body": 96 | prop = self.resolve_schema_dict(schema) 97 | 98 | param = { 99 | "in": openapi_default_in, 100 | "required": required, 101 | "name": name, 102 | "schema": prop, 103 | } 104 | 105 | if description: 106 | param["description"] = description 107 | 108 | return [param] 109 | 110 | parameters = [] 111 | body_param = None 112 | for field in fields: 113 | 114 | field_name = field.name 115 | field_obj = field 116 | 117 | param = self.field2parameter( 118 | schema, field_obj, name=field_name, default_in=default_in 119 | ) 120 | if ( 121 | self.openapi_version.major < 3 122 | and param["in"] == "body" 123 | and body_param is not None 124 | ): 125 | body_param["schema"]["properties"].update(param["schema"]["properties"]) 126 | required_fields = param["schema"].get("required", []) 127 | if required_fields: 128 | body_param["schema"].setdefault("required", []).extend( 129 | required_fields 130 | ) 131 | else: 132 | if self.openapi_version.major < 3 and param["in"] == "body": 133 | body_param = param 134 | parameters.append(param) 135 | return parameters 136 | 137 | def field2parameter(self, schema, field, name="body", default_in="body"): 138 | """Return an OpenAPI parameter as a `dict`, given a dataclass field. 139 | 140 | https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#parameterObject 141 | """ 142 | assert schema 143 | 144 | serializer = serpyco.SchemaBuilder( 145 | schema, only=[field.name], get_definition_name=self._schema_name_resolver 146 | ) 147 | field_json_schema = serializer.json_schema()["properties"][field.name] 148 | 149 | return self.property2parameter( 150 | field_json_schema, 151 | name=name, 152 | required=isinstance(field.default, dataclasses._MISSING_TYPE), 153 | multiple=isinstance(field.type, typing.Sequence), 154 | default_in=default_in, 155 | ) 156 | 157 | def property2parameter( 158 | self, 159 | prop, 160 | name="body", 161 | required=False, 162 | multiple=False, 163 | location=None, 164 | default_in="body", 165 | ): 166 | """Return the Parameter Object definition for a JSON Schema property. 167 | 168 | https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#parameterObject 169 | 170 | :param dict prop: JSON Schema property 171 | :param str name: Field name 172 | :param bool required: Parameter is required 173 | :param bool multiple: Parameter is repeated 174 | :param str location: Location to look for ``name`` 175 | :param str default_in: Default location to look for ``name`` 176 | :raise: TranslationError if arg object cannot be translated to a Parameter Object schema. 177 | :rtype: dict, a Parameter Object 178 | """ 179 | openapi_default_in = __location_map__.get(default_in, default_in) 180 | openapi_location = __location_map__.get(location, openapi_default_in) 181 | ret = {"in": openapi_location, "name": name} 182 | 183 | if openapi_location == "body": 184 | ret["required"] = False 185 | ret["name"] = "body" 186 | ret["schema"] = { 187 | "type": "object", 188 | "properties": {name: prop} if name else {}, 189 | } 190 | if name and required: 191 | ret["schema"]["required"] = [name] 192 | else: 193 | ret["required"] = required 194 | if self.openapi_version.major < 3: 195 | if multiple: 196 | ret["collectionFormat"] = "multi" 197 | ret.update(prop) 198 | else: 199 | if multiple: 200 | ret["explode"] = True 201 | ret["style"] = "form" 202 | if prop.get("description", None): 203 | ret["description"] = prop.pop("description") 204 | ret["schema"] = prop 205 | return ret 206 | -------------------------------------------------------------------------------- /apispec_serpyco/apispec_serpyco/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Serpyco plugin for apispec. Allows passing a python dataclass 3 | to `APISpec.definition ` 4 | and `APISpec.path ` (for responses). Note serpyco field type is supported. 5 | """ 6 | import serpyco 7 | from apispec import BasePlugin 8 | from serpyco.schema import default_get_definition_name 9 | 10 | from apispec_serpyco.openapi import OpenAPIConverter 11 | 12 | 13 | def extract_definitions_from_json_schema(definition): 14 | definitions = {} 15 | 16 | for name, definition_ in definition.get("definitions", {}).items(): 17 | 18 | # TODO BS: Bypass a serpyco bug 19 | if definition_ is None: 20 | continue 21 | 22 | definitions[name] = definition_ 23 | if definition_.get("definitions"): 24 | definitions.update(extract_definitions_from_json_schema(definition_)) 25 | 26 | return definitions 27 | 28 | 29 | def replace_refs_for_openapi3(data): 30 | for key, value in data.items(): 31 | if isinstance(value, dict): 32 | replace_refs_for_openapi3(value) 33 | elif key == "$ref" and value.startswith("#/definitions"): 34 | data[key] = value.replace("#/definitions", "#/components/schemas") 35 | 36 | 37 | def is_type_or_null_property(property_): 38 | """ 39 | Serpyco use "anyOf" (null, or defined type) key to define optional properties. 40 | Example: 41 | ``` json 42 | [...] 43 | "properties":{ 44 | "id":{ 45 | "type":"integer" 46 | }, 47 | "name":{ 48 | "anyOf":[ 49 | { 50 | "type":"string" 51 | }, 52 | { 53 | "type":"null" 54 | } 55 | ] 56 | [...] 57 | ``` 58 | 59 | This function return True if given property it is. 60 | 61 | :param property_: property to inspect 62 | :return: True if given property is optional property 63 | """ 64 | # These expression of property is an not required property 65 | if "anyOf" in property_ and 2 == len(property_["anyOf"]): 66 | for optional_property in property_["anyOf"]: 67 | if optional_property.get("type") == "null": 68 | return True 69 | return False 70 | 71 | 72 | def extract_type_for_type_or_null_property(property_): 73 | """ 74 | Serpyco use "anyOf" (null, or defined type) key to define optional properties. 75 | Example: 76 | ``` json 77 | [...] 78 | "properties":{ 79 | "id":{ 80 | "type":"integer" 81 | }, 82 | "name":{ 83 | "anyOf":[ 84 | { 85 | "type":"string" 86 | }, 87 | { 88 | "type":"null" 89 | } 90 | ] 91 | [...] 92 | ``` 93 | 94 | This function return real property definition. 95 | 96 | :param property_:property where extract 97 | :return: real property definition of given property 98 | """ 99 | if "anyOf" in property_ and 2 == len(property_["anyOf"]): 100 | for optional_property in property_["anyOf"]: 101 | if optional_property.get("type") != "null": 102 | return optional_property 103 | 104 | raise TypeError("Can't extract type because this is not a type_or_null_property") 105 | 106 | 107 | def manage_optional_properties(schema): 108 | """ 109 | Serpyco use "anyOf" (null, or defined type) key to define optional properties. 110 | Example: 111 | ``` json 112 | [...] 113 | "properties":{ 114 | "id":{ 115 | "type":"integer" 116 | }, 117 | "name":{ 118 | "anyOf":[ 119 | { 120 | "type":"string" 121 | }, 122 | { 123 | "type":"null" 124 | } 125 | ] 126 | [...] 127 | ``` 128 | 129 | This function replace properties definition by real definition (by removing "anyOf") and 130 | fill "required" property if any required property. 131 | :param schema: schema dict to update 132 | :return: Nothing. Schema is updated by reference. 133 | """ 134 | for property_name, property_ in dict(schema.get("properties", {}).items()).items(): 135 | if is_type_or_null_property(property_): 136 | real_property = extract_type_for_type_or_null_property(property_) 137 | # In OpenAPI, required properties are added in "required" key (see below) 138 | schema["properties"][property_name] = real_property 139 | 140 | 141 | def replace_auto_refs(schema_name, data, openapi_version): 142 | for key, value in data.items(): 143 | if isinstance(value, dict): 144 | replace_auto_refs(schema_name, value, openapi_version) 145 | elif key == "$ref" and value == "#": 146 | if openapi_version.major < 3: 147 | data[key] = "#/definitions/{}".format(schema_name) 148 | else: 149 | data[key] = "#/components/schemas/{}".format(schema_name) 150 | 151 | 152 | class SerpycoPlugin(BasePlugin): 153 | """APISpec plugin handling python dataclass (with serpyco typing support)""" 154 | 155 | def __init__(self, schema_name_resolver=default_get_definition_name): 156 | super(SerpycoPlugin, self).__init__() 157 | self.spec = None 158 | # self.schema_name_resolver = schema_name_resolver 159 | self.openapi_version = None 160 | self.openapi = None 161 | self.schema_name_resolver = schema_name_resolver 162 | 163 | def init_spec(self, spec): 164 | """Initialize plugin with APISpec object 165 | 166 | :param APISpec spec: APISpec object this plugin instance is attached to 167 | """ 168 | super(SerpycoPlugin, self).init_spec(spec) 169 | self.spec = spec 170 | self.openapi_version = spec.openapi_version 171 | self.openapi = OpenAPIConverter( 172 | openapi_version=spec.openapi_version, 173 | schema_name_resolver=self.schema_name_resolver, 174 | ) 175 | 176 | def schema_helper(self, name, component=None, schema=None, **kwargs): 177 | """Definition helper that allows using a dataclass to provide 178 | OpenAPI metadata. 179 | 180 | :param type|type schema a dataclass class 181 | """ 182 | with_definition = kwargs.get("with_definition") 183 | 184 | if schema is None and not with_definition: 185 | return None 186 | 187 | if with_definition: 188 | return with_definition 189 | 190 | # Store registered refs, keyed by Schema class 191 | self.openapi.refs[schema] = name 192 | 193 | builder = serpyco.SchemaBuilder( 194 | schema, 195 | get_definition_name=self.schema_name_resolver, 196 | **kwargs.get("serpyco_builder_args", {}), 197 | ) 198 | json_schema = builder.json_schema() 199 | 200 | if self.openapi_version.major > 2: 201 | replace_refs_for_openapi3(json_schema["properties"]) 202 | # To be OpenAPI compliant, we must manage ourself required properties 203 | manage_optional_properties(json_schema) 204 | # Replace auto reference to absolute reference 205 | replace_auto_refs(name, json_schema["properties"], self.openapi_version) 206 | 207 | # If definitions in json_schema, add them 208 | if json_schema.get("definitions"): 209 | flat_definitions = extract_definitions_from_json_schema(json_schema) 210 | for name, definition in flat_definitions.items(): 211 | 212 | # Test if schema not already in schema lists 213 | # FIXME BS 2019-01-31: We must take a look into _schemas attribute to prevent 214 | # apispec.exceptions.DuplicateComponentNameError raise. See #14. 215 | if name not in self.spec.components._schemas: 216 | # To be OpenAPI compliant, we must manage ourself required properties 217 | manage_optional_properties(definition) 218 | self.spec.components.schema(name, with_definition=definition) 219 | 220 | # Clean json_schema (to be OpenAPI compatible) 221 | json_schema.pop("definitions", None) 222 | json_schema.pop("$schema", None) 223 | 224 | return json_schema 225 | 226 | def parameter_helper(self, component=None, **kwargs): 227 | """Parameter component helper that allows using a dataclass 228 | in parameter definition. 229 | 230 | :param type|type schema: A dataclass. 231 | """ 232 | # In OpenAPIv3, this only works when using the complex form using "content" 233 | self.resolve_schema(kwargs) 234 | return kwargs 235 | 236 | def response_helper(self, component=None, **kwargs): 237 | """Response component helper that allows using a dataclass in response definition. 238 | 239 | :param type|Schema schema: A marshmallow Schema class or instance. 240 | """ 241 | self.resolve_schema(kwargs) 242 | return kwargs 243 | 244 | def operation_helper(self, path=None, operations=None, **kwargs): 245 | """May mutate operations. 246 | 247 | :param str path: Path to the resource 248 | :param dict operations: A `dict` mapping HTTP methods to operation object. 249 | """ 250 | for operation in operations.values(): 251 | if not isinstance(operation, dict): 252 | continue 253 | if "parameters" in operation: 254 | operation["parameters"] = self.resolve_parameters( 255 | operation["parameters"] 256 | ) 257 | if self.openapi_version.major >= 3: 258 | if "requestBody" in operation: 259 | self.resolve_schema_in_request_body(operation["requestBody"]) 260 | for response in operation.get("responses", {}).values(): 261 | self.resolve_schema(response) 262 | 263 | def resolve_schema_in_request_body(self, request_body): 264 | """Function to resolve a schema in a requestBody object - modifies then 265 | response dict to convert dataclass into dict 266 | """ 267 | content = request_body["content"] 268 | for content_type in content: 269 | schema = content[content_type]["schema"] 270 | content[content_type]["schema"] = self.openapi.resolve_schema_dict(schema) 271 | 272 | def resolve_schema(self, data): 273 | """Function to resolve a schema in a parameter or response - modifies the 274 | corresponding dict to convert dataclass class into dict 275 | 276 | :param APISpec spec: `APISpec` containing refs. 277 | :param dict data: the parameter or response dictionary that may contain a dataclass 278 | :param bool dump: Introspect dump logic. 279 | :param bool load: Introspect load logic. 280 | """ 281 | if self.openapi_version.major < 3: 282 | if "schema" in data: 283 | data["schema"] = self.openapi.resolve_schema_dict(data["schema"]) 284 | else: 285 | if "content" in data: 286 | for content_type in data["content"]: 287 | schema = data["content"][content_type]["schema"] 288 | data["content"][content_type][ 289 | "schema" 290 | ] = self.openapi.resolve_schema_dict(schema) 291 | 292 | def resolve_parameters(self, parameters): 293 | resolved = [] 294 | for parameter in parameters: 295 | if not isinstance(parameter.get("schema", {}), dict): 296 | schema = parameter["schema"] 297 | if "in" in parameter: 298 | del parameter["schema"] 299 | resolved += self.openapi.schema2parameters( 300 | schema, default_in=parameter.pop("in"), **parameter 301 | ) 302 | continue 303 | self.resolve_schema(parameter) 304 | resolved.append(parameter) 305 | return resolved 306 | -------------------------------------------------------------------------------- /apispec_serpyco/tests/test_ext_serpyco.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import typing 4 | 5 | from apispec import APISpec 6 | import pytest 7 | import serpyco 8 | from serpyco import nested_field 9 | from serpyco import string_field 10 | 11 | from apispec_serpyco import SerpycoPlugin 12 | from apispec_serpyco.utils import schema_name_resolver 13 | import dataclasses 14 | from dataclasses import dataclass 15 | from tests.utils import get_definitions 16 | from tests.utils import get_parameters 17 | from tests.utils import get_paths 18 | from tests.utils import get_responses 19 | from tests.utils import ref_path 20 | 21 | 22 | @dataclass 23 | class PetSchema(object): 24 | id: int = string_field(description="Pet id") 25 | name: str = string_field(description="Pet name") 26 | password: str = string_field(description="Pet auth password") 27 | 28 | 29 | @dataclass 30 | class SampleSchema(object): 31 | count: int 32 | runs: typing.List["RunSchema"] = nested_field(exclude=["sample"]) 33 | 34 | 35 | @dataclass 36 | class RunSchema(object): 37 | sample: typing.List[SampleSchema] = nested_field(exclude=["runs"]) 38 | 39 | 40 | @dataclass 41 | class AnalysisSchema(object): 42 | sample: SampleSchema 43 | 44 | 45 | @dataclass 46 | class AnalysisWithListSchema(object): 47 | samples: typing.List[SampleSchema] 48 | 49 | 50 | @dataclass 51 | class SelfReferencingSchema(object): 52 | id: int 53 | single: "SelfReferencingSchema" 54 | many: typing.List["SelfReferencingSchema"] 55 | 56 | 57 | @dataclass 58 | class DefaultValuesSchema(object): 59 | number_auto_default: int = dataclasses.field(default=12) 60 | string_callable_default: str = dataclasses.field( 61 | default_factory=lambda: "Callable value" 62 | ) 63 | numbers: typing.List[int] = dataclasses.field(default_factory=lambda: []) 64 | 65 | 66 | class TestDefinitionHelper: 67 | @pytest.mark.parametrize("schema", [PetSchema]) 68 | def test_can_use_schema_as_definition(self, spec, schema): 69 | spec.components.schema("Pet", schema=schema) 70 | definitions = get_definitions(spec) 71 | props = definitions["Pet"]["properties"] 72 | 73 | assert props["id"]["type"] == "integer" 74 | assert props["name"]["type"] == "string" 75 | 76 | def test_schema_helper_without_schema(self, spec): 77 | spec.components.schema("Pet", {"properties": {"key": {"type": "integer"}}}) 78 | definitions = get_definitions(spec) 79 | assert definitions["Pet"]["properties"] == {"key": {"type": "integer"}} 80 | 81 | @pytest.mark.parametrize("schema", [AnalysisSchema]) 82 | def test_resolve_schema_dict_auto_reference(self, schema): 83 | def resolver(schema): 84 | return schema.__name__ 85 | 86 | spec = APISpec( 87 | title="Test auto-reference", 88 | version="0.1", 89 | openapi_version="2.0", 90 | plugins=(SerpycoPlugin(schema_name_resolver=schema_name_resolver),), 91 | ) 92 | assert {} == get_definitions(spec) 93 | 94 | spec.components.schema("analysis", schema=schema) 95 | spec.path( 96 | "/test", 97 | operations={ 98 | "get": { 99 | "responses": {"200": {"schema": {"$ref": "#/definitions/analysis"}}} 100 | } 101 | }, 102 | ) 103 | definitions = get_definitions(spec) 104 | assert 3 == len(definitions) 105 | 106 | assert "analysis" in definitions 107 | assert "SampleSchema" in definitions 108 | # TODO : Check with Serpyco about nested_field excludes 109 | # assert "RunSchema_exclude_sample" in definitions 110 | 111 | @pytest.mark.parametrize("schema", [AnalysisWithListSchema]) 112 | def test_resolve_schema_dict_auto_reference_in_list(self, schema): 113 | def resolver(schema): 114 | return schema.__name__ 115 | 116 | spec = APISpec( 117 | title="Test auto-reference", 118 | version="0.1", 119 | openapi_version="2.0", 120 | plugins=(SerpycoPlugin(),), 121 | ) 122 | assert {} == get_definitions(spec) 123 | 124 | spec.components.schema("analysis", schema=schema) 125 | spec.path( 126 | "/test", 127 | operations={ 128 | "get": { 129 | "responses": {"200": {"schema": {"$ref": "#/definitions/analysis"}}} 130 | } 131 | }, 132 | ) 133 | definitions = get_definitions(spec) 134 | assert 3 == len(definitions) 135 | 136 | assert "analysis" in definitions 137 | assert "tests.test_ext_serpyco.SampleSchema" in definitions 138 | assert "tests.test_ext_serpyco.RunSchema_exclude_sample" in definitions 139 | 140 | 141 | class TestComponentParameterHelper(object): 142 | @pytest.mark.parametrize("schema", [PetSchema]) 143 | def test_can_use_schema_in_parameter(self, spec, schema): 144 | if spec.openapi_version.major < 3: 145 | kwargs = {"schema": schema} 146 | else: 147 | kwargs = {"content": {"application/json": {"schema": schema}}} 148 | spec.components.parameter("Pet", "body", **kwargs) 149 | parameter = get_parameters(spec)["Pet"] 150 | assert parameter["in"] == "body" 151 | if spec.openapi_version.major < 3: 152 | schema = parameter["schema"]["properties"] 153 | else: 154 | schema = parameter["content"]["application/json"]["schema"]["properties"] 155 | 156 | assert schema["name"]["type"] == "string" 157 | assert schema["password"]["type"] == "string" 158 | 159 | 160 | class TestComponentResponseHelper: 161 | @pytest.mark.parametrize("schema", [PetSchema]) 162 | def test_can_use_schema_in_response(self, spec, schema): 163 | if spec.openapi_version.major < 3: 164 | kwargs = {"schema": schema} 165 | else: 166 | kwargs = {"content": {"application/json": {"schema": schema}}} 167 | spec.components.response("GetPetOk", **kwargs) 168 | response = get_responses(spec)["GetPetOk"] 169 | if spec.openapi_version.major < 3: 170 | schema = response["schema"]["properties"] 171 | else: 172 | schema = response["content"]["application/json"]["schema"]["properties"] 173 | 174 | assert schema["id"]["type"] == "integer" 175 | assert schema["name"]["type"] == "string" 176 | 177 | 178 | class TestCustomField: 179 | def test_can_use_custom_field_decorator(self, spec_fixture): 180 | @dataclass 181 | class CustomPetASchema(PetSchema): 182 | email: str = serpyco.string_field( 183 | format_=serpyco.StringFormat.EMAIL, 184 | pattern="^[A-Z]", 185 | min_length=3, 186 | max_length=24, 187 | ) 188 | 189 | @dataclass 190 | class CustomPetBSchema(PetSchema): 191 | age: int = serpyco.number_field(minimum=1, maximum=120) 192 | 193 | @dataclass 194 | class WithStringField(object): 195 | """String field test class""" 196 | 197 | foo: str = serpyco.string_field( 198 | format_=serpyco.StringFormat.EMAIL, 199 | pattern="^[A-Z]", 200 | min_length=3, 201 | max_length=24, 202 | ) 203 | 204 | serializer = serpyco.Serializer(WithStringField) 205 | serializer.json_schema() 206 | 207 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 208 | spec_fixture.spec.components.schema("CustomPetA", schema=CustomPetASchema) 209 | spec_fixture.spec.components.schema("CustomPetB", schema=CustomPetBSchema) 210 | 211 | props_0 = get_definitions(spec_fixture.spec)["Pet"]["properties"] 212 | props_a = get_definitions(spec_fixture.spec)["CustomPetA"]["properties"] 213 | props_b = get_definitions(spec_fixture.spec)["CustomPetB"]["properties"] 214 | 215 | assert props_0["name"]["type"] == "string" 216 | assert "format" not in props_0["name"] 217 | 218 | assert props_a["email"]["type"] == "string" 219 | assert json.dumps(props_a["email"]["format"]) == '"email"' 220 | assert props_a["email"]["pattern"] == "^[A-Z]" 221 | assert props_a["email"]["maxLength"] == 24 222 | assert props_a["email"]["minLength"] == 3 223 | 224 | assert props_b["age"]["type"] == "integer" 225 | assert props_b["age"]["minimum"] == 1 226 | assert props_b["age"]["maximum"] == 120 227 | 228 | 229 | class TestOperationHelper: 230 | @staticmethod 231 | def ref_path(spec): 232 | if spec.openapi_version.version[0] < 3: 233 | return "#/definitions/" 234 | return "#/components/schemas/" 235 | 236 | @pytest.mark.parametrize("pet_schema", (PetSchema,)) 237 | @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True) 238 | def test_schema_v2(self, spec_fixture, pet_schema): 239 | spec_fixture.spec.path( 240 | path="/pet", 241 | operations={ 242 | "get": { 243 | "responses": { 244 | "200": { 245 | "schema": pet_schema, 246 | "description": "successful operation", 247 | } 248 | } 249 | } 250 | }, 251 | ) 252 | get = get_paths(spec_fixture.spec)["/pet"]["get"] 253 | assert get["responses"]["200"][ 254 | "schema" 255 | ] == spec_fixture.openapi.schema2jsonschema(PetSchema) 256 | assert get["responses"]["200"]["description"] == "successful operation" 257 | 258 | @pytest.mark.parametrize("pet_schema", (PetSchema,)) 259 | @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True) 260 | def test_schema_v3(self, spec_fixture, pet_schema): 261 | spec_fixture.spec.path( 262 | path="/pet", 263 | operations={ 264 | "get": { 265 | "responses": { 266 | "200": { 267 | "content": {"application/json": {"schema": pet_schema}}, 268 | "description": "successful operation", 269 | } 270 | } 271 | } 272 | }, 273 | ) 274 | get = get_paths(spec_fixture.spec)["/pet"]["get"] 275 | resolved_schema = get["responses"]["200"]["content"]["application/json"][ 276 | "schema" 277 | ] 278 | assert resolved_schema == spec_fixture.openapi.schema2jsonschema(PetSchema) 279 | assert get["responses"]["200"]["description"] == "successful operation" 280 | 281 | @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True) 282 | def test_schema_expand_parameters_v2(self, spec_fixture): 283 | spec_fixture.spec.path( 284 | path="/pet", 285 | operations={ 286 | "get": {"parameters": [{"in": "query", "schema": PetSchema}]}, 287 | "post": { 288 | "parameters": [ 289 | { 290 | "in": "body", 291 | "description": "a pet schema", 292 | "required": True, 293 | "name": "pet", 294 | "schema": PetSchema, 295 | } 296 | ] 297 | }, 298 | }, 299 | ) 300 | p = get_paths(spec_fixture.spec)["/pet"] 301 | get = p["get"] 302 | assert get["parameters"] == spec_fixture.openapi.schema2parameters( 303 | PetSchema, default_in="query" 304 | ) 305 | post = p["post"] 306 | assert post["parameters"] == spec_fixture.openapi.schema2parameters( 307 | PetSchema, 308 | default_in="body", 309 | required=True, 310 | name="pet", 311 | description="a pet schema", 312 | ) 313 | 314 | @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True) 315 | def test_schema_expand_parameters_v3(self, spec_fixture): 316 | spec_fixture.spec.path( 317 | path="/pet", 318 | operations={ 319 | "get": {"parameters": [{"in": "query", "schema": PetSchema}]}, 320 | "post": { 321 | "requestBody": { 322 | "description": "a pet schema", 323 | "required": True, 324 | "content": {"application/json": {"schema": PetSchema}}, 325 | } 326 | }, 327 | }, 328 | ) 329 | p = get_paths(spec_fixture.spec)["/pet"] 330 | get = p["get"] 331 | assert get["parameters"] == spec_fixture.openapi.schema2parameters( 332 | PetSchema, default_in="query" 333 | ) 334 | 335 | post = p["post"] 336 | post_schema = spec_fixture.openapi.resolve_schema_dict(PetSchema) 337 | assert ( 338 | post["requestBody"]["content"]["application/json"]["schema"] == post_schema 339 | ) 340 | assert post["requestBody"]["description"] == "a pet schema" 341 | assert post["requestBody"]["required"] 342 | 343 | @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True) 344 | def test_schema_uses_ref_if_available_v2(self, spec_fixture): 345 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 346 | spec_fixture.spec.path( 347 | path="/pet", 348 | operations={"get": {"responses": {"200": {"schema": PetSchema}}}}, 349 | ) 350 | get = get_paths(spec_fixture.spec)["/pet"]["get"] 351 | assert ( 352 | get["responses"]["200"]["schema"]["$ref"] 353 | == self.ref_path(spec_fixture.spec) + "Pet" 354 | ) 355 | 356 | @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True) 357 | def test_schema_uses_ref_if_available_v3(self, spec_fixture): 358 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 359 | spec_fixture.spec.path( 360 | path="/pet", 361 | operations={ 362 | "get": { 363 | "responses": { 364 | "200": {"content": {"application/json": {"schema": PetSchema}}} 365 | } 366 | } 367 | }, 368 | ) 369 | get = get_paths(spec_fixture.spec)["/pet"]["get"] 370 | assert ( 371 | get["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] 372 | == self.ref_path(spec_fixture.spec) + "Pet" 373 | ) 374 | 375 | @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True) 376 | def test_schema_uses_ref_in_parameters_and_request_body_if_available_v2( 377 | self, spec_fixture 378 | ): 379 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 380 | spec_fixture.spec.path( 381 | path="/pet", 382 | operations={ 383 | "get": {"parameters": [{"in": "query", "schema": PetSchema}]}, 384 | "post": {"parameters": [{"in": "body", "schema": PetSchema}]}, 385 | }, 386 | ) 387 | p = get_paths(spec_fixture.spec)["/pet"] 388 | assert "schema" not in p["get"]["parameters"][0] 389 | post = p["post"] 390 | assert len(post["parameters"]) == 1 391 | assert ( 392 | post["parameters"][0]["schema"]["$ref"] 393 | == self.ref_path(spec_fixture.spec) + "Pet" 394 | ) 395 | 396 | @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True) 397 | def test_schema_uses_ref_in_parameters_and_request_body_if_available_v3( 398 | self, spec_fixture 399 | ): 400 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 401 | spec_fixture.spec.path( 402 | path="/pet", 403 | operations={ 404 | "get": {"parameters": [{"in": "query", "schema": PetSchema}]}, 405 | "post": { 406 | "requestBody": { 407 | "content": {"application/json": {"schema": PetSchema}} 408 | } 409 | }, 410 | }, 411 | ) 412 | p = get_paths(spec_fixture.spec)["/pet"] 413 | assert "schema" in p["get"]["parameters"][0] 414 | post = p["post"] 415 | schema_ref = post["requestBody"]["content"]["application/json"]["schema"] 416 | assert schema_ref == {"$ref": self.ref_path(spec_fixture.spec) + "Pet"} 417 | 418 | @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True) 419 | def test_schema_array_uses_ref_if_available_v2(self, spec_fixture): 420 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 421 | spec_fixture.spec.path( 422 | path="/pet", 423 | operations={ 424 | "get": { 425 | "parameters": [ 426 | { 427 | "in": "body", 428 | "name": "body", 429 | "schema": {"type": "array", "items": PetSchema}, 430 | } 431 | ], 432 | "responses": { 433 | "200": {"schema": {"type": "array", "items": PetSchema}} 434 | }, 435 | } 436 | }, 437 | ) 438 | get = get_paths(spec_fixture.spec)["/pet"]["get"] 439 | assert len(get["parameters"]) == 1 440 | resolved_schema = { 441 | "type": "array", 442 | "items": {"$ref": self.ref_path(spec_fixture.spec) + "Pet"}, 443 | } 444 | assert get["parameters"][0]["schema"] == resolved_schema 445 | assert get["responses"]["200"]["schema"] == resolved_schema 446 | 447 | @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True) 448 | def test_schema_array_uses_ref_if_available_v3(self, spec_fixture): 449 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 450 | spec_fixture.spec.path( 451 | path="/pet", 452 | operations={ 453 | "get": { 454 | "parameters": [ 455 | { 456 | "in": "body", 457 | "name": " body", 458 | "content": { 459 | "application/json": { 460 | "schema": {"type": "array", "items": PetSchema} 461 | } 462 | }, 463 | } 464 | ], 465 | "responses": { 466 | "200": { 467 | "content": { 468 | "application/json": { 469 | "schema": {"type": "array", "items": PetSchema} 470 | } 471 | } 472 | } 473 | }, 474 | } 475 | }, 476 | ) 477 | p = get_paths(spec_fixture.spec)["/pet"] 478 | assert "get" in p 479 | op = p["get"] 480 | resolved_schema = { 481 | "type": "array", 482 | "items": {"$ref": self.ref_path(spec_fixture.spec) + "Pet"}, 483 | } 484 | request_schema = op["parameters"][0]["content"]["application/json"]["schema"] 485 | assert request_schema == resolved_schema 486 | response_schema = op["responses"]["200"]["content"]["application/json"][ 487 | "schema" 488 | ] 489 | assert response_schema == resolved_schema 490 | 491 | @pytest.mark.parametrize("spec_fixture", ("2.0",), indirect=True) 492 | def test_schema_partially_v2(self, spec_fixture): 493 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 494 | spec_fixture.spec.path( 495 | path="/parents", 496 | operations={ 497 | "get": { 498 | "responses": { 499 | "200": { 500 | "schema": { 501 | "type": "object", 502 | "properties": { 503 | "mother": PetSchema, 504 | "father": PetSchema, 505 | }, 506 | } 507 | } 508 | } 509 | } 510 | }, 511 | ) 512 | get = get_paths(spec_fixture.spec)["/parents"]["get"] 513 | assert get["responses"]["200"]["schema"] == { 514 | "type": "object", 515 | "properties": { 516 | "mother": {"$ref": self.ref_path(spec_fixture.spec) + "Pet"}, 517 | "father": {"$ref": self.ref_path(spec_fixture.spec) + "Pet"}, 518 | }, 519 | } 520 | 521 | @pytest.mark.parametrize("spec_fixture", ("3.0.0",), indirect=True) 522 | def test_schema_partially_v3(self, spec_fixture): 523 | spec_fixture.spec.components.schema("Pet", schema=PetSchema) 524 | spec_fixture.spec.path( 525 | path="/parents", 526 | operations={ 527 | "get": { 528 | "responses": { 529 | "200": { 530 | "content": { 531 | "application/json": { 532 | "schema": { 533 | "type": "object", 534 | "properties": { 535 | "mother": PetSchema, 536 | "father": PetSchema, 537 | }, 538 | } 539 | } 540 | } 541 | } 542 | } 543 | } 544 | }, 545 | ) 546 | get = get_paths(spec_fixture.spec)["/parents"]["get"] 547 | assert get["responses"]["200"]["content"]["application/json"]["schema"] == { 548 | "type": "object", 549 | "properties": { 550 | "mother": {"$ref": self.ref_path(spec_fixture.spec) + "Pet"}, 551 | "father": {"$ref": self.ref_path(spec_fixture.spec) + "Pet"}, 552 | }, 553 | } 554 | 555 | 556 | class TestCircularReference: 557 | def test_circular_referencing_schemas(self, spec): 558 | spec.components.schema("Analysis", schema=AnalysisSchema) 559 | spec.components.schema("Sample", schema=SampleSchema) 560 | spec.components.schema("Run", schema=RunSchema) 561 | definitions = get_definitions(spec) 562 | ref = definitions["Analysis"]["properties"]["sample"]["$ref"] 563 | assert ref == ref_path(spec) + "tests.test_ext_serpyco.SampleSchema" 564 | 565 | 566 | class TestSelfReference: 567 | def test_self_referencing_field_single(self, spec): 568 | spec.components.schema("SelfReference", schema=SelfReferencingSchema) 569 | definitions = get_definitions(spec) 570 | ref = definitions["SelfReference"]["properties"]["single"]["$ref"] 571 | assert ref == ref_path(spec) + "SelfReference" 572 | 573 | def test_self_referencing_field_many(self, spec): 574 | spec.components.schema("SelfReference", schema=SelfReferencingSchema) 575 | definitions = get_definitions(spec) 576 | result = definitions["SelfReference"]["properties"]["many"] 577 | assert result == { 578 | "type": "array", 579 | "items": {"$ref": ref_path(spec) + "SelfReference"}, 580 | } 581 | 582 | 583 | class TestSchemaWithDefaultValues: 584 | def test_schema_with_default_values(self, spec): 585 | spec.components.schema("DefaultValuesSchema", schema=DefaultValuesSchema) 586 | definitions = get_definitions(spec) 587 | props = definitions["DefaultValuesSchema"]["properties"] 588 | assert props["number_auto_default"]["default"] == 12 589 | # FIXME BS 2019-10-21: restore these 2 lines when 590 | # https://gitlab.com/sgrignard/serpyco/issues/32 resolved 591 | # assert props["string_callable_default"]["default"] == "Callable value" 592 | # assert props["numbers"]["default"] == [] 593 | 594 | 595 | class TestSchemaWithOptional: 596 | def test_schema_with_optional_string(self, spec): 597 | @dataclasses.dataclass 598 | class MySchema: 599 | id: int 600 | name: typing.Optional[str] = None 601 | 602 | spec.components.schema("MySchema", schema=MySchema) 603 | definitions = get_definitions(spec) 604 | props = definitions["MySchema"]["properties"] 605 | 606 | assert "required" in definitions["MySchema"] 607 | assert ["id"] == definitions["MySchema"]["required"] 608 | assert {"id": {"type": "integer"}, "name": {"type": "string"}} == props 609 | 610 | def test_schema_with_optional_string_in_related_schema(self, spec): 611 | @dataclasses.dataclass 612 | class MyChildSchema: 613 | id: int 614 | name: typing.Optional[str] = None 615 | 616 | @dataclasses.dataclass 617 | class MyParentSchema: 618 | id: int 619 | child: MyChildSchema 620 | 621 | spec.components.schema("MyParentSchema", schema=MyParentSchema) 622 | definitions = get_definitions(spec) 623 | props = definitions["tests.test_ext_serpyco.MyChildSchema"]["properties"] 624 | definition = definitions["tests.test_ext_serpyco.MyChildSchema"] 625 | assert "required" in definition 626 | assert ["id"] == definition["required"] 627 | assert {"id": {"type": "integer"}, "name": {"type": "string"}} == props 628 | 629 | --------------------------------------------------------------------------------