├── lepo ├── apidef │ ├── __init__.py │ ├── operation │ │ ├── __init__.py │ │ ├── swagger.py │ │ ├── openapi.py │ │ └── base.py │ ├── parameter │ │ ├── __init__.py │ │ ├── base.py │ │ ├── utils.py │ │ ├── swagger.py │ │ └── openapi.py │ ├── version.py │ ├── path.py │ └── doc.py ├── __init__.py ├── decorators.py ├── api_info.py ├── validate.py ├── decoders.py ├── utils.py ├── path_view.py ├── excs.py ├── codegen.py ├── parameter_utils.py ├── handlers.py └── router.py ├── lepo_doc ├── __init__.py ├── views.py ├── urls.py ├── templates │ └── lepo_doc │ │ └── swagger-ui.html └── static │ └── lepo_doc │ └── swagger-ui │ └── swagger-ui.css ├── lepo_tests ├── __init__.py ├── tests │ ├── __init__.py │ ├── swagger2 │ │ ├── path-refs.yaml │ │ ├── header-underscore.yaml │ │ ├── schema-refs.yaml │ │ ├── parameter-test.yaml │ │ └── petstore-expanded.yaml │ ├── openapi3 │ │ ├── path-refs.yaml │ │ ├── header-underscore.yaml │ │ ├── schema-refs.yaml │ │ ├── parameter-test.yaml │ │ └── petstore-expanded.yaml │ ├── test_doc.py │ ├── test_codegen.py │ ├── test_exceptional_response.py │ ├── test_router_validator.py │ ├── test_body_type.py │ ├── test_processor.py │ ├── test_cascade.py │ ├── test_refs.py │ ├── utils.py │ ├── test_openapi3_complex.py │ ├── test_openapi3_label.py │ ├── test_datatypes.py │ ├── test_parameters.py │ └── test_pet_api.py ├── handlers │ ├── __init__.py │ ├── pets_bare.py │ └── pets_cb.py ├── models.py ├── wsgi.py ├── urls.py ├── schemata.py ├── settings.py └── utils.py ├── MANIFEST.in ├── requirements-docs.in ├── branding ├── small_bear.afdesign ├── sleepy_polar_bear.afdesign ├── sleepy_polar_bear_with_text.afdesign ├── sleepy_polar_bear.svg ├── small_bear.svg └── sleepy_polar_bear_with_text.svg ├── pyproject.toml ├── .coveragerc ├── requirements-test.in ├── docs ├── class-based-handlers.md ├── codegen.md ├── concepts.md ├── index.md ├── getting-started.md ├── features.md └── banner.svg ├── .gitignore ├── manage.py ├── mkdocs.yml ├── setup.cfg ├── .github └── workflows │ └── test.yml ├── LICENSE ├── requirements-docs.txt ├── requirements-test.txt └── README.md /lepo/apidef/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo_doc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo_tests/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo/apidef/operation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo/apidef/parameter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo_tests/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.4.0' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include lepo_doc *.html *.css *.js 2 | -------------------------------------------------------------------------------- /requirements-docs.in: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | pygments 4 | pymdown-extensions 5 | -------------------------------------------------------------------------------- /lepo/decorators.py: -------------------------------------------------------------------------------- 1 | def csrf_exempt(view): 2 | view.csrf_exempt = True 3 | return view 4 | -------------------------------------------------------------------------------- /branding/small_bear.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/lepo/HEAD/branding/small_bear.afdesign -------------------------------------------------------------------------------- /branding/sleepy_polar_bear.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/lepo/HEAD/branding/sleepy_polar_bear.afdesign -------------------------------------------------------------------------------- /branding/sleepy_polar_bear_with_text.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akx/lepo/HEAD/branding/sleepy_polar_bear_with_text.afdesign -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | #branch = True 3 | omit = 4 | *site-packages* 5 | lepo_tests/settings.py 6 | lepo_tests/wsgi.py 7 | manage.py 8 | setup.py 9 | -------------------------------------------------------------------------------- /lepo_tests/tests/swagger2/path-refs.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | paths: 3 | /a: 4 | get: 5 | operationId: foo 6 | /b: 7 | $ref: '#/paths/~1a' 8 | -------------------------------------------------------------------------------- /requirements-test.in: -------------------------------------------------------------------------------- 1 | autoflake 2 | autopep8 3 | flake8 4 | isort 5 | pip-tools 6 | pytest 7 | pytest-cov 8 | pytest-django 9 | pytest-xdist 10 | PyYAML 11 | -------------------------------------------------------------------------------- /lepo_tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Pet(models.Model): 5 | name = models.CharField(max_length=128) 6 | tag = models.CharField(max_length=128, blank=True) 7 | -------------------------------------------------------------------------------- /docs/class-based-handlers.md: -------------------------------------------------------------------------------- 1 | # Class based handlers 2 | 3 | Lepo provides a base class for class based views that validate their input and output against the schema. 4 | More documentation on this is TBD. 5 | -------------------------------------------------------------------------------- /lepo_tests/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lepo_tests.settings") 6 | application = get_wsgi_application() 7 | -------------------------------------------------------------------------------- /lepo_tests/urls.py: -------------------------------------------------------------------------------- 1 | # There is nothing here. Tests that use urls should declare which urls they want to use. 2 | # If you want to see how to use the Lepo URLs api, see `utils.py`. 3 | 4 | urlpatterns = [] # pragma: no cover 5 | -------------------------------------------------------------------------------- /lepo_tests/schemata.py: -------------------------------------------------------------------------------- 1 | from marshmallow import Schema, fields 2 | 3 | 4 | class PetSchema(Schema): 5 | id = fields.Integer(required=False) 6 | name = fields.Str(required=True) 7 | tag = fields.Str(required=False) 8 | -------------------------------------------------------------------------------- /docs/codegen.md: -------------------------------------------------------------------------------- 1 | # Code Generation 2 | 3 | You can use the `lepo.codegen` module to generate a handler stub file 4 | from an OpenAPI YAML/JSON file. 5 | 6 | ## Command line usage 7 | 8 | ``` 9 | python -m lepo.codegen swagger.yaml > handlers.py 10 | ``` -------------------------------------------------------------------------------- /lepo_tests/tests/swagger2/header-underscore.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | consumes: 3 | - application/json 4 | paths: 5 | /cat: 6 | post: 7 | operationId: hello 8 | parameters: 9 | - name: greeting_text 10 | in: header 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *,cover 2 | *.egg-info/ 3 | *.py[cod] 4 | *.sqlite3 5 | .*cache 6 | .coverage 7 | .coverage.* 8 | .hypothesis/ 9 | .idea 10 | .tox/ 11 | /site 12 | __pycache__/ 13 | build/ 14 | coverage.xml 15 | dist/ 16 | env/ 17 | htmlcov/ 18 | nosetests.xml 19 | var/ 20 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lepo_tests.settings") 7 | from django.core.management import execute_from_command_line 8 | execute_from_command_line(sys.argv) 9 | -------------------------------------------------------------------------------- /lepo_tests/tests/openapi3/path-refs.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | servers: [] 3 | paths: 4 | /a: 5 | get: 6 | operationId: foo 7 | responses: 8 | default: 9 | description: Default response 10 | /b: 11 | $ref: '#/paths/~1a' 12 | info: 13 | version: '' 14 | title: '' 15 | -------------------------------------------------------------------------------- /lepo/api_info.py: -------------------------------------------------------------------------------- 1 | class APIInfo: 2 | def __init__(self, operation, router=None): 3 | """ 4 | :type operation: lepo.operation.Operation 5 | :type router: lepo.router.Router 6 | """ 7 | self.api = operation.api 8 | self.path = operation.path 9 | self.operation = operation 10 | self.router = router 11 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_doc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from lepo_tests.utils import urlconf_map 4 | 5 | 6 | # TODO: test OpenAPI 3 too 7 | @pytest.mark.urls(urlconf_map[('pets_cb', 'swagger2')].__name__) 8 | def test_docs(client): 9 | assert '/api/swagger.json' in client.get('/api/docs/').content.decode('utf-8') 10 | assert client.get('/api/swagger.json').content.startswith(b'{') 11 | -------------------------------------------------------------------------------- /lepo_tests/tests/openapi3/header-underscore.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | servers: [] 3 | paths: 4 | /cat: 5 | post: 6 | operationId: hello 7 | parameters: 8 | - name: greeting_text 9 | in: header 10 | schema: 11 | type: string 12 | responses: 13 | default: 14 | description: Default response 15 | info: 16 | version: '' 17 | title: '' 18 | -------------------------------------------------------------------------------- /lepo_doc/views.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | from django.shortcuts import render 3 | from django.urls import reverse 4 | 5 | 6 | def get_swagger_json(request, router): 7 | return JsonResponse(router.api.doc) 8 | 9 | 10 | def render_docs(request, router, json_url_name): 11 | return render(request, 'lepo_doc/swagger-ui.html', { 12 | 'json_url': request.build_absolute_uri(reverse(json_url_name)), 13 | }) 14 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_codegen.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from lepo import codegen 4 | from lepo_tests.tests.utils import doc_versions 5 | 6 | 7 | @doc_versions 8 | def test_codegen(doc_version, capsys): 9 | path = os.path.realpath(os.path.join(os.path.dirname(__file__), doc_version, 'petstore-expanded.yaml')) 10 | codegen.cmdline([path]) 11 | out, err = capsys.readouterr() 12 | # Compile to test for syntax errors 13 | compile(out, f'{doc_version}-generated.py', 'exec') 14 | -------------------------------------------------------------------------------- /lepo_doc/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | 4 | def get_docs_urls(router, namespace, docs_url='docs/?'): 5 | from . import views 6 | json_url_name = f'lepo_doc_{id(router)}' 7 | return [ 8 | re_path(r'swagger\.json$', views.get_swagger_json, kwargs={'router': router}, name=json_url_name), 9 | re_path(f'{docs_url}$', views.render_docs, kwargs={ 10 | 'router': router, 11 | 'json_url_name': f'{namespace}:{json_url_name}', 12 | }), 13 | ] 14 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Lepo 2 | pages: 3 | - index.md 4 | - features.md 5 | - getting-started.md 6 | - class-based-handlers.md 7 | - concepts.md 8 | - codegen.md 9 | theme: material 10 | markdown_extensions: 11 | - admonition 12 | - codehilite 13 | - pymdownx.tasklist(custom_checkbox=true) 14 | - pymdownx.inlinehilite 15 | extra: 16 | palette: 17 | primary: 'deep purple' 18 | accent: 'indigo' 19 | repo_name: 'akx/lepo' 20 | repo_url: 'https://github.com/akx/lepo' 21 | copyright: 'Copyright © 2017 Lepo authors' 22 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_exceptional_response.py: -------------------------------------------------------------------------------- 1 | from django.http.response import HttpResponse 2 | 3 | from lepo.excs import ExceptionalResponse 4 | from lepo_tests.tests.utils import doc_versions, get_router 5 | 6 | 7 | def greet(request, greeting, greetee): 8 | raise ExceptionalResponse(HttpResponse('oh no', status=400)) 9 | 10 | 11 | @doc_versions 12 | def test_exceptional_response(rf, doc_version): 13 | router = get_router(f'{doc_version}/parameter-test.yaml') 14 | router.add_handlers({'greet': greet}) 15 | path_view = router.get_path_view_class('/greet').as_view() 16 | response = path_view(rf.get('/', {'greeting': 'hello', 'greetee': 'world'})) 17 | assert response.content == b'oh no' 18 | -------------------------------------------------------------------------------- /lepo/apidef/version.py: -------------------------------------------------------------------------------- 1 | SWAGGER_2 = 'swagger2' 2 | OPENAPI_3 = 'openapi3' 3 | 4 | 5 | def split_version(version): 6 | # Not perfectly Semver safe. 7 | return [int(bit) for bit in str(version).split('.')] 8 | 9 | 10 | def parse_version(doc_dict): 11 | if 'swagger' in doc_dict: 12 | version = split_version(doc_dict['swagger']) 13 | if version[0] != 2: # pragma: no cover 14 | raise ValueError('Only Swagger 2.x is supported') 15 | return SWAGGER_2 16 | elif 'openapi' in doc_dict: 17 | version = split_version(doc_dict['openapi']) 18 | if version[0] != 3: # pragma: no cover 19 | raise ValueError('Only OpenAPI 3.x is supported') 20 | return OPENAPI_3 21 | raise ValueError('API document is missing version specifier') 22 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_router_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from lepo.excs import InvalidParameterDefinition, RouterValidationError 4 | from lepo.validate import validate_router 5 | from lepo_tests.tests.utils import doc_versions, get_router 6 | 7 | 8 | @doc_versions 9 | def test_validator(doc_version): 10 | router = get_router(f'{doc_version}/schema-refs.yaml') 11 | with pytest.raises(RouterValidationError) as ei: 12 | validate_router(router) 13 | assert len(ei.value.errors) == 2 14 | 15 | 16 | @doc_versions 17 | def test_header_underscore(doc_version): 18 | router = get_router(f'{doc_version}/header-underscore.yaml') 19 | with pytest.raises(RouterValidationError) as ei: 20 | validate_router(router) 21 | errors = list(ei.value.flat_errors) 22 | assert any(isinstance(e[1], InvalidParameterDefinition) for e in errors) 23 | -------------------------------------------------------------------------------- /lepo/apidef/operation/swagger.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | 3 | from lepo.apidef.operation.base import Operation 4 | from lepo.apidef.parameter.swagger import Swagger2Parameter 5 | 6 | 7 | class Swagger2Operation(Operation): 8 | parameter_class = Swagger2Parameter 9 | 10 | @cached_property 11 | def consumes(self): 12 | value = self._get_overridable('consumes', []) 13 | if not isinstance(value, (list, tuple)): 14 | raise TypeError(f'`consumes` must be a list, got {value!r}') # pragma: no cover 15 | return value 16 | 17 | @cached_property 18 | def produces(self): 19 | value = self._get_overridable('produces', []) 20 | if not isinstance(value, (list, tuple)): 21 | raise TypeError(f'`produces` must be a list, got {value!r}') # pragma: no cover 22 | return value 23 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = lepo 3 | version = attr:lepo.__version__ 4 | url = https://github.com/akx/lepo 5 | author = Aarni Koskela 6 | author_email = akx@iki.fi 7 | maintainer = Aarni Koskela 8 | maintainer_email = akx@iki.fi 9 | license = MIT 10 | 11 | [options] 12 | install_requires = 13 | Django>=3.0 14 | iso8601 15 | jsonschema~=3.2 16 | marshmallow~=3.0 17 | packages = find: 18 | include_package_data = True 19 | 20 | [options.packages.find] 21 | exclude = 22 | lepo_tests 23 | lepo_tests.* 24 | 25 | [tool:pytest] 26 | DJANGO_SETTINGS_MODULE = lepo_tests.settings 27 | filterwarnings = 28 | error 29 | ignore:.*packaging.version.*:DeprecationWarning 30 | ignore:.*USE_L10N.*:PendingDeprecationWarning 31 | 32 | [flake8] 33 | max-line-length = 119 34 | max-complexity = 10 35 | 36 | [isort] 37 | profile = black 38 | multi_line_output = 3 39 | 40 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_body_type.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | from lepo.api_info import APIInfo 4 | from lepo.apidef.doc import APIDefinition 5 | from lepo.parameter_utils import read_parameters 6 | 7 | JSONIFY_DOC = """ 8 | swagger: "2.0" 9 | consumes: 10 | - text/plain 11 | produces: 12 | - application/json 13 | paths: 14 | /jsonify: 15 | post: 16 | parameters: 17 | - name: text 18 | in: body 19 | 20 | """ 21 | 22 | 23 | # TODO: add OpenAPI 3 version of this test 24 | def test_text_body_type(rf): 25 | apidoc = APIDefinition.from_data(yaml.safe_load(JSONIFY_DOC)) 26 | operation = apidoc.get_path('/jsonify').get_operation('post') 27 | request = rf.post('/jsonify', 'henlo worl', content_type='text/plain') 28 | request.api_info = APIInfo(operation=operation) 29 | params = read_parameters(request, {}) 30 | assert params['text'] == 'henlo worl' 31 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_processor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import AnonymousUser 3 | from django.core.exceptions import PermissionDenied 4 | 5 | from lepo.handlers import BaseHandler 6 | 7 | 8 | class EvalHandler(BaseHandler): 9 | view_processors = ['ensure_superuser'] # all views will pass through this 10 | 11 | def handle_eval(self): 12 | return eval(self.args['expression']) 13 | 14 | def ensure_superuser(self, purpose, **kwargs): 15 | if not self.request.user.is_superuser: 16 | raise PermissionDenied('oh no') 17 | 18 | 19 | def test_eval_handler(rf, admin_user): 20 | eval_fn = EvalHandler.get_view('handle_eval') 21 | request = rf.post('/') 22 | request.user = AnonymousUser() 23 | with pytest.raises(PermissionDenied): 24 | eval_fn(request, expression='6 + 6') 25 | request = rf.post('/') 26 | request.user = admin_user 27 | assert eval_fn(request, expression='6 + 6') == 12 28 | -------------------------------------------------------------------------------- /lepo/validate.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from lepo.excs import InvalidParameterDefinition, MissingHandler, RouterValidationError 4 | 5 | 6 | def validate_router(router): 7 | errors = defaultdict(list) 8 | for path in router.get_paths(): 9 | for operation in path.get_operations(): 10 | # Check the handler exists. 11 | try: 12 | router.get_handler(operation.id) 13 | except MissingHandler as e: 14 | errors[operation].append(e) 15 | 16 | for param in operation.parameters: 17 | if param.location == 'header' and '_' in param.name: # See https://github.com/akx/lepo/issues/23 18 | ipd = InvalidParameterDefinition( 19 | f'{param.name}: Header parameter names may not contain underscores (Django bug 25048)' 20 | ) 21 | errors[operation].append(ipd) 22 | 23 | if errors: 24 | raise RouterValidationError(errors) 25 | -------------------------------------------------------------------------------- /lepo/decoders.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.utils.encoding import force_str 4 | 5 | DEFAULT_ENCODING = 'utf-8' 6 | 7 | 8 | def decode_json(content, encoding=None, **kwargs): 9 | return json.loads(force_str(content, encoding=(encoding or DEFAULT_ENCODING))) 10 | 11 | 12 | def decode_plain_text(content, encoding=None, **kwargs): 13 | return force_str(content, encoding=(encoding or DEFAULT_ENCODING)) 14 | 15 | 16 | DECODERS = { 17 | 'application/json': decode_json, 18 | 'text/plain': decode_plain_text, 19 | } 20 | 21 | 22 | def get_decoder(content_type): 23 | """ 24 | Get a decoder function for the content type given, or None if there is none. 25 | 26 | :param content_type: Content type string 27 | :type content_type: str 28 | :return: function or None 29 | """ 30 | if content_type.endswith('+json'): # Process all +json vendor types like JSON 31 | content_type = 'application/json' 32 | 33 | if content_type in DECODERS: 34 | return DECODERS[content_type] 35 | 36 | return None 37 | -------------------------------------------------------------------------------- /lepo/utils.py: -------------------------------------------------------------------------------- 1 | from fnmatch import fnmatch 2 | 3 | from django.utils.text import camel_case_to_spaces 4 | 5 | 6 | def maybe_resolve(object, resolve): 7 | """ 8 | Call `resolve` on the `object`'s `$ref` value if it has one. 9 | 10 | :param object: An object. 11 | :param resolve: A resolving function. 12 | :return: An object, or some other object! :sparkles: 13 | """ 14 | if isinstance(object, dict) and object.get('$ref'): 15 | return resolve(object['$ref']) 16 | return object 17 | 18 | 19 | def snake_case(string): 20 | return camel_case_to_spaces(string).replace('-', '_').replace(' ', '_').replace('__', '_') 21 | 22 | 23 | def match_content_type(content_type, content_type_mapping): 24 | for map_content_type in content_type_mapping: 25 | if fnmatch(content_type, map_content_type): 26 | return map_content_type 27 | 28 | 29 | def get_content_type_specificity(content_type): 30 | major, minor = content_type.split('/', 1) 31 | return (100 if major == '*' else 0) + (10 if minor == '*' else 0) 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | Build: 11 | runs-on: '${{ matrix.os }}' 12 | strategy: 13 | matrix: 14 | include: 15 | - os: ubuntu-18.04 16 | python-version: '3.7' 17 | DJANGO: django~=3.0 18 | - os: ubuntu-20.04 19 | python-version: '3.10' 20 | DJANGO: django~=4.0 21 | steps: 22 | - name: 'Set up Python ${{ matrix.python-version }}' 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '${{ matrix.python-version }}' 26 | - uses: actions/checkout@v2 27 | - name: Cache multiple paths 28 | uses: actions/cache@v2 29 | with: 30 | path: $HOME/.cache/pip 31 | key: '${{ runner.os }}-${{ hashFiles(''TODO'') }}' 32 | - run: pip install -r requirements-test.txt 33 | - run: pip install $DJANGO -e . 34 | env: 35 | DJANGO: '${{ matrix.DJANGO }}' 36 | - run: flake8 lepo 37 | - run: py.test -vvv --cov . 38 | - uses: codecov/codecov-action@v2 39 | -------------------------------------------------------------------------------- /lepo_tests/handlers/pets_bare.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bare view implementation of the pet resource. 3 | """ 4 | 5 | from functools import reduce 6 | 7 | from django.db.models import Q 8 | 9 | from lepo_tests.models import Pet 10 | from lepo_tests.schemata import PetSchema 11 | 12 | 13 | def find_pets(request, limit=None, tags=()): 14 | pets = Pet.objects.all()[:limit] 15 | if tags: 16 | tags_q = reduce( 17 | lambda q, term: q | Q(tag=term), 18 | tags, 19 | Q() 20 | ) 21 | pets = pets.filter(tags_q) 22 | return PetSchema().dump(pets, many=True) 23 | 24 | 25 | def add_pet(request, pet): 26 | pet_data = PetSchema().load(pet) 27 | pet = Pet(**pet_data) 28 | pet.save() 29 | return PetSchema().dump(pet) 30 | 31 | 32 | def find_pet_by_id(request, id): 33 | return PetSchema().dump(Pet.objects.get(id=id)) 34 | 35 | 36 | def delete_pet(request, id): 37 | Pet.objects.filter(id=id).delete() 38 | 39 | 40 | def update_pet(request, id, pet): 41 | old_pet = Pet.objects.get(id=id) 42 | for key, value in PetSchema().load(pet).items(): 43 | setattr(old_pet, key, value) 44 | old_pet.save() 45 | return PetSchema().dump(old_pet) 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017 Aarni Koskela, Santtu Pajukanta 5 | Copyright (c) 2018, 2020 Aarni Koskela 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_cascade.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | from lepo.apidef.doc import APIDefinition 4 | 5 | CASCADE_DOC = """ 6 | swagger: "2.0" 7 | consumes: 8 | - application/json 9 | produces: 10 | - application/x-happiness 11 | paths: 12 | /cows: 13 | consumes: 14 | - application/x-grass 15 | produces: 16 | - application/x-moo 17 | post: 18 | operationId: tip 19 | delete: 20 | operationId: remoove 21 | produces: 22 | - application/x-no-more-moo 23 | /hello: 24 | get: 25 | operationId: greet 26 | """ 27 | 28 | 29 | def test_cascade(): 30 | router = APIDefinition.from_data(yaml.safe_load(CASCADE_DOC)) 31 | tip_operation = router.get_path('/cows').get_operation('post') 32 | assert tip_operation.consumes == ['application/x-grass'] 33 | assert tip_operation.produces == ['application/x-moo'] 34 | remooval_operation = router.get_path('/cows').get_operation('delete') 35 | assert remooval_operation.consumes == ['application/x-grass'] 36 | assert remooval_operation.produces == ['application/x-no-more-moo'] 37 | greet_operation = router.get_path('/hello').get_operation('get') 38 | assert greet_operation.consumes == ['application/json'] 39 | assert greet_operation.produces == ['application/x-happiness'] 40 | -------------------------------------------------------------------------------- /lepo_tests/handlers/pets_cb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Class-based implementation of the pet resource. 3 | """ 4 | 5 | from functools import reduce 6 | 7 | from django.db.models import Q 8 | 9 | from lepo.handlers import CRUDModelHandler 10 | from lepo_tests.models import Pet 11 | from lepo_tests.schemata import PetSchema 12 | 13 | 14 | class PetHandler(CRUDModelHandler): 15 | model = Pet 16 | queryset = Pet.objects.all() 17 | schema_class = PetSchema 18 | create_data_name = 'pet' 19 | update_data_name = 'pet' 20 | 21 | def process_object_list(self, purpose, object_list): 22 | if purpose == 'list': 23 | tags = self.args.get('tags') 24 | if tags: 25 | tags_q = reduce( 26 | lambda q, term: q | Q(tag=term), 27 | tags, 28 | Q() 29 | ) 30 | object_list = object_list.filter(tags_q) 31 | limit = self.args.get('limit') 32 | if limit is not None: 33 | object_list = object_list[:limit] 34 | return super().process_object_list(purpose, object_list) 35 | 36 | 37 | find_pets = PetHandler.get_view('handle_list') 38 | add_pet = PetHandler.get_view('handle_create') 39 | find_pet_by_id = PetHandler.get_view('handle_retrieve') 40 | delete_pet = PetHandler.get_view('handle_delete') 41 | update_pet = PetHandler.get_view('handle_update') 42 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_refs.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from lepo.api_info import APIInfo 6 | from lepo.parameter_utils import read_parameters 7 | from lepo_tests.tests.utils import doc_versions, get_router 8 | 9 | lil_bub = {'name': 'Lil Bub', 'petType': 'Cat', 'huntingSkill': 'lazy'} 10 | hachiko = {'name': 'Hachiko', 'petType': 'Dog', 'packSize': 83} 11 | 12 | 13 | @doc_versions 14 | def test_path_refs(doc_version): 15 | router = get_router(f'{doc_version}/path-refs.yaml') 16 | assert router.get_path('/b').mapping == router.get_path('/a').mapping 17 | 18 | 19 | @doc_versions 20 | def test_schema_refs(rf, doc_version): 21 | router = get_router(f'{doc_version}/schema-refs.yaml') 22 | request = rf.post('/cat', json.dumps(lil_bub), content_type='application/json') 23 | request.api_info = APIInfo(router.get_path('/cat').get_operation('post')) 24 | params = read_parameters(request, {}) 25 | assert params['cat'] == lil_bub 26 | 27 | 28 | @doc_versions 29 | @pytest.mark.parametrize('object', (lil_bub, hachiko)) 30 | def test_polymorphism(rf, doc_version, object): 31 | router = get_router(f'{doc_version}/schema-refs.yaml') 32 | request = rf.post('/pet', json.dumps(object), content_type='application/json') 33 | request.api_info = APIInfo(router.get_path('/pet').get_operation('post')) 34 | params = read_parameters(request, {}) 35 | assert params['pet'] == object 36 | -------------------------------------------------------------------------------- /lepo/apidef/operation/openapi.py: -------------------------------------------------------------------------------- 1 | from lepo.apidef.operation.base import Operation 2 | from lepo.apidef.parameter.openapi import OpenAPI3BodyParameter, OpenAPI3Parameter 3 | from lepo.utils import maybe_resolve 4 | 5 | 6 | class OpenAPI3Operation(Operation): 7 | parameter_class = OpenAPI3Parameter 8 | body_parameter_class = OpenAPI3BodyParameter 9 | 10 | def _get_body_parameter(self): 11 | for source in ( 12 | self.path.mapping.get('requestBody'), 13 | self.data.get('requestBody'), 14 | ): 15 | if source: 16 | source = maybe_resolve(source, self.api.resolve_reference) 17 | body_parameter = self.body_parameter_class(data=source, operation=self, api=self.api) 18 | # TODO: Document x-lepo-body-name 19 | body_parameter.name = self.data.get('x-lepo-body-name', body_parameter.name) 20 | return body_parameter 21 | 22 | def get_parameter_dict(self): 23 | parameter_dict = super().get_parameter_dict() 24 | for parameter in parameter_dict.values(): 25 | if parameter.in_body: # pragma: no cover 26 | raise ValueError('Regular parameter declared to be in body while parsing OpenAPI 3') 27 | body_parameter = self._get_body_parameter() 28 | if body_parameter: 29 | parameter_dict[body_parameter.name] = body_parameter 30 | return parameter_dict 31 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.9 3 | # To update, run: 4 | # 5 | # pip-compile requirements-docs.in 6 | # 7 | click==8.0.4 8 | # via mkdocs 9 | ghp-import==2.0.2 10 | # via mkdocs 11 | importlib-metadata==4.11.2 12 | # via 13 | # markdown 14 | # mkdocs 15 | jinja2==3.0.3 16 | # via 17 | # mkdocs 18 | # mkdocs-material 19 | markdown==3.3.6 20 | # via 21 | # mkdocs 22 | # mkdocs-material 23 | # pymdown-extensions 24 | markupsafe==2.1.0 25 | # via jinja2 26 | mergedeep==1.3.4 27 | # via mkdocs 28 | mkdocs==1.2.3 29 | # via 30 | # -r requirements-docs.in 31 | # mkdocs-material 32 | mkdocs-material==8.2.4 33 | # via -r requirements-docs.in 34 | mkdocs-material-extensions==1.0.3 35 | # via mkdocs-material 36 | packaging==21.3 37 | # via mkdocs 38 | pygments==2.11.2 39 | # via 40 | # -r requirements-docs.in 41 | # mkdocs-material 42 | pymdown-extensions==9.2 43 | # via 44 | # -r requirements-docs.in 45 | # mkdocs-material 46 | pyparsing==3.0.7 47 | # via packaging 48 | python-dateutil==2.8.2 49 | # via ghp-import 50 | pyyaml==6.0 51 | # via 52 | # mkdocs 53 | # pyyaml-env-tag 54 | pyyaml-env-tag==0.1 55 | # via mkdocs 56 | six==1.16.0 57 | # via python-dateutil 58 | watchdog==2.1.6 59 | # via mkdocs 60 | zipp==3.7.0 61 | # via importlib-metadata 62 | -------------------------------------------------------------------------------- /lepo/path_view.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse, JsonResponse 2 | from django.views import View 3 | 4 | from lepo.api_info import APIInfo 5 | from lepo.excs import ExceptionalResponse, InvalidOperation 6 | from lepo.parameter_utils import read_parameters 7 | from lepo.utils import snake_case 8 | 9 | 10 | class PathView(View): 11 | router = None # Filled in by subclasses 12 | path = None # Filled in by subclasses 13 | 14 | def dispatch(self, request, **kwargs): 15 | try: 16 | operation = self.path.get_operation(request.method) 17 | except InvalidOperation: 18 | return self.http_method_not_allowed(request, **kwargs) 19 | request.api_info = APIInfo( 20 | operation=operation, 21 | router=self.router, 22 | ) 23 | params = { 24 | snake_case(name): value 25 | for (name, value) 26 | in read_parameters(request, kwargs, capture_errors=True).items() 27 | } 28 | handler = request.api_info.router.get_handler(operation.id) 29 | try: 30 | response = handler(request, **params) 31 | except ExceptionalResponse as er: 32 | response = er.response 33 | 34 | return self.transform_response(response) 35 | 36 | def transform_response(self, response): 37 | if isinstance(response, HttpResponse): 38 | # TODO: validate against responses 39 | return response 40 | return JsonResponse(response, safe=False) # TODO: maybe less TIMTOWDI here? 41 | -------------------------------------------------------------------------------- /lepo/excs.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | 4 | class MissingParameter(ValueError): 5 | pass 6 | 7 | 8 | class InvalidOperation(ValueError): 9 | pass 10 | 11 | 12 | class MissingHandler(ValueError): 13 | pass 14 | 15 | 16 | class ErroneousParameters(Exception): 17 | def __init__(self, error_map, parameters): 18 | self.errors = error_map 19 | self.parameters = parameters 20 | 21 | 22 | class InvalidBodyFormat(ValueError): 23 | pass 24 | 25 | 26 | class InvalidBodyContent(ValueError): 27 | pass 28 | 29 | 30 | class InvalidComplexContent(ValueError): 31 | def __init__(self, message, error_map): 32 | super().__init__(message) 33 | self.errors = error_map 34 | 35 | 36 | class InvalidParameterDefinition(ImproperlyConfigured): 37 | pass 38 | 39 | 40 | class RouterValidationError(Exception): 41 | def __init__(self, error_map): 42 | self.errors = error_map 43 | self.description = '\n'.join(f'{operation.id}: {error}' for (operation, error) in self.flat_errors) 44 | super().__init__(f'Router validation failed:\n{self.description}') 45 | 46 | @property 47 | def flat_errors(self): 48 | for operation, errors in sorted(self.errors.items(), key=str): 49 | for error in errors: 50 | yield (operation, error) 51 | 52 | 53 | class ExceptionalResponse(Exception): 54 | """ 55 | Wraps a Response in an exception. 56 | 57 | These exceptions are caught in PathView. 58 | """ 59 | def __init__(self, response): 60 | self.response = response 61 | -------------------------------------------------------------------------------- /lepo/apidef/parameter/base.py: -------------------------------------------------------------------------------- 1 | from lepo.excs import InvalidParameterDefinition 2 | 3 | NO_VALUE = object() 4 | 5 | 6 | class BaseParameter: 7 | def __init__(self, data, api=None): 8 | self.data = data 9 | self.api = api 10 | 11 | def __repr__(self): 12 | return f'<{self.__class__.__name__} ({self.data!r})>' 13 | 14 | @property 15 | def location(self): 16 | return self.data['in'] 17 | 18 | @property 19 | def required(self): 20 | return bool(self.data.get('required')) 21 | 22 | @property 23 | def items(self): 24 | return self.data.get('items') 25 | 26 | def cast(self, api, value): # pragma: no cover 27 | raise NotImplementedError('Subclasses must implement cast') 28 | 29 | def get_value(self, request, view_kwargs): # pragma: no cover 30 | """ 31 | :type request: WSGIRequest 32 | :type view_kwargs: dict 33 | """ 34 | raise NotImplementedError('Subclasses must implement get_value') 35 | 36 | 37 | class BaseTopParameter(BaseParameter): 38 | """ 39 | Top-level Parameter, such as in an operation 40 | """ 41 | 42 | def __init__(self, data, api=None, operation=None): 43 | super().__init__(data, api=api) 44 | self.operation = operation 45 | 46 | @property 47 | def name(self): 48 | return self.data['name'] 49 | 50 | @property 51 | def in_body(self): 52 | return self.location in ('formData', 'body') 53 | 54 | def get_value(self, request, view_kwargs): 55 | raise InvalidParameterDefinition(f'unsupported `in` value {self.location!r} in {self!r}') # pragma: no cover 56 | -------------------------------------------------------------------------------- /lepo_tests/tests/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import pytest 5 | from django.test import RequestFactory 6 | 7 | from lepo.api_info import APIInfo 8 | from lepo.apidef.doc import OpenAPI3APIDefinition, Swagger2APIDefinition 9 | from lepo.router import Router 10 | 11 | TESTS_DIR = os.path.dirname(__file__) 12 | 13 | 14 | def get_router(fixture_name): 15 | return Router.from_file(os.path.join(TESTS_DIR, fixture_name)) 16 | 17 | 18 | def get_data_from_response(response, status=200): 19 | if status and response.status_code != status: 20 | raise ValueError(f'failed status check ({response.status_code} != expected {status})') # pragma: no cover 21 | return json.loads(response.content.decode('utf-8')) 22 | 23 | 24 | DOC_VERSIONS = ['swagger2', 'openapi3'] 25 | doc_versions = pytest.mark.parametrize('doc_version', DOC_VERSIONS) 26 | 27 | 28 | def cast_parameter_value(apidoc, parameter, value): 29 | if isinstance(parameter, dict): 30 | parameter_class = apidoc.operation_class.parameter_class 31 | parameter = parameter_class(parameter) 32 | return parameter.cast(apidoc, value) 33 | 34 | 35 | def get_apidoc_from_version(version, content={}): 36 | if version == 'swagger2': 37 | return Swagger2APIDefinition(content) 38 | elif version == 'openapi3': 39 | return OpenAPI3APIDefinition(content) 40 | else: # pragma: no cover 41 | raise NotImplementedError('...') 42 | 43 | 44 | def make_request_for_operation(operation, method='GET', query_string=''): 45 | request = RequestFactory().generic(method=method, path=operation.path.path, QUERY_STRING=query_string) 46 | request.api_info = APIInfo(operation) 47 | return request 48 | -------------------------------------------------------------------------------- /lepo/codegen.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | 4 | from django.utils.text import camel_case_to_spaces 5 | from six import StringIO 6 | 7 | from lepo.apidef.doc import APIDefinition 8 | from lepo.router import Router 9 | 10 | HANDLER_TEMPLATE = ''' 11 | def {func_name}(request, {parameters}): 12 | raise NotImplementedError('Handler {operation_id} not implemented') 13 | '''.strip() 14 | 15 | 16 | def generate_handler_stub(router, handler_template=HANDLER_TEMPLATE): 17 | output = StringIO() 18 | func_name_to_operation = {} 19 | for path in router.get_paths(): 20 | for operation in path.get_operations(): 21 | snake_operation_id = camel_case_to_spaces(operation.id).replace(' ', '_') 22 | func_name_to_operation[snake_operation_id] = operation 23 | for func_name, operation in sorted(func_name_to_operation.items()): 24 | parameter_names = [p.name for p in operation.parameters] 25 | handler = handler_template.format( 26 | func_name=func_name, 27 | operation_id=operation.id, 28 | parameters=', '.join(parameter_names), 29 | ) 30 | output.write(handler) 31 | output.write('\n\n\n') 32 | return output.getvalue() 33 | 34 | 35 | def cmdline(args=None): # pragma: no cover 36 | ap = argparse.ArgumentParser() 37 | ap.add_argument('input', default=None, nargs='?') 38 | args = ap.parse_args(args) 39 | if args.input: 40 | apidoc = APIDefinition.from_file(args.input) 41 | else: # pragma: no cover 42 | apidoc = APIDefinition.from_yaml(sys.stdin) 43 | print(generate_handler_stub(Router(apidoc))) 44 | 45 | 46 | if __name__ == '__main__': # pragma: no cover 47 | cmdline() 48 | -------------------------------------------------------------------------------- /lepo_tests/tests/swagger2/schema-refs.yaml: -------------------------------------------------------------------------------- 1 | # via https://github.com/OAI/OpenAPI-Specification/blob/ae9322eb2df1555acf3163e30cd84779d98afec5/versions/2.0.md#models-with-polymorphism-support 2 | swagger: "2.0" 3 | consumes: 4 | - application/json 5 | paths: 6 | /cat: 7 | post: 8 | operationId: emkitten 9 | parameters: 10 | - name: cat 11 | in: body 12 | schema: 13 | $ref: '#/definitions/Cat' 14 | /pet: 15 | post: 16 | operationId: pet a pet 17 | parameters: 18 | - name: pet 19 | in: body 20 | schema: 21 | $ref: '#/definitions/Pet' 22 | definitions: 23 | Pet: 24 | type: object 25 | discriminator: petType 26 | properties: 27 | name: 28 | type: string 29 | petType: 30 | type: string 31 | required: 32 | - name 33 | - petType 34 | Cat: 35 | description: A representation of a cat 36 | allOf: 37 | - $ref: '#/definitions/Pet' 38 | - type: object 39 | properties: 40 | huntingSkill: 41 | type: string 42 | description: The measured skill for hunting 43 | default: lazy 44 | enum: 45 | - clueless 46 | - lazy 47 | - adventurous 48 | - aggressive 49 | required: 50 | - huntingSkill 51 | Dog: 52 | description: A representation of a dog 53 | allOf: 54 | - $ref: '#/definitions/Pet' 55 | - type: object 56 | properties: 57 | packSize: 58 | type: integer 59 | format: int32 60 | description: the size of the pack the dog is from 61 | default: 0 62 | minimum: 0 63 | required: 64 | - packSize 65 | -------------------------------------------------------------------------------- /lepo/apidef/path.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from lepo.excs import InvalidOperation 4 | from lepo.path_view import PathView 5 | 6 | PATH_PLACEHOLDER_REGEX = r'\{(.+?)\}' 7 | 8 | # As defined in the documentation for Path Items: 9 | METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch'} 10 | 11 | 12 | class Path: 13 | def __init__(self, api, path, mapping): 14 | """ 15 | :type api: lepo.apidef.APIDefinition 16 | :type path: str 17 | :type mapping: dict 18 | """ 19 | self.api = api 20 | self.path = path 21 | self.mapping = mapping 22 | self.regex = self._build_regex() 23 | self.name = self._build_view_name() 24 | 25 | def get_view_class(self, router): 26 | return type(f'{self.name.title()}View', (PathView,), { 27 | 'path': self, 28 | 'router': router, 29 | }) 30 | 31 | def _build_view_name(self): 32 | path = re.sub(PATH_PLACEHOLDER_REGEX, r'\1', self.path) 33 | name = re.sub(r'[^a-z0-9]+', '-', path, flags=re.I).strip('-').lower() 34 | return name 35 | 36 | def _build_regex(self): 37 | return re.sub( 38 | PATH_PLACEHOLDER_REGEX, 39 | lambda m: f'(?P<{m.group(1)}>[^/]+?)', 40 | self.path, 41 | ).lstrip('/') + '$' 42 | 43 | def get_operation(self, method): 44 | operation_data = self.mapping.get(method.lower()) 45 | if not operation_data: 46 | raise InvalidOperation(f'Path {self.path} does not support method {method.upper()}') 47 | return self.api.operation_class(api=self.api, path=self, data=operation_data, method=method) 48 | 49 | def get_operations(self): 50 | for method in METHODS: 51 | if method in self.mapping: 52 | yield self.get_operation(method) 53 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.9 3 | # To update, run: 4 | # 5 | # pip-compile requirements-test.in 6 | # 7 | attrs==21.4.0 8 | # via pytest 9 | autoflake==1.4 10 | # via -r requirements-test.in 11 | autopep8==1.6.0 12 | # via -r requirements-test.in 13 | click==8.0.4 14 | # via pip-tools 15 | coverage[toml]==6.3.2 16 | # via pytest-cov 17 | execnet==1.9.0 18 | # via pytest-xdist 19 | flake8==4.0.1 20 | # via -r requirements-test.in 21 | iniconfig==1.1.1 22 | # via pytest 23 | isort==5.10.1 24 | # via -r requirements-test.in 25 | mccabe==0.6.1 26 | # via flake8 27 | packaging==21.3 28 | # via pytest 29 | pep517==0.12.0 30 | # via pip-tools 31 | pip-tools==6.5.1 32 | # via -r requirements-test.in 33 | pluggy==1.0.0 34 | # via pytest 35 | py==1.11.0 36 | # via 37 | # pytest 38 | # pytest-forked 39 | pycodestyle==2.8.0 40 | # via 41 | # autopep8 42 | # flake8 43 | pyflakes==2.4.0 44 | # via 45 | # autoflake 46 | # flake8 47 | pyparsing==3.0.7 48 | # via packaging 49 | pytest==7.0.1 50 | # via 51 | # -r requirements-test.in 52 | # pytest-cov 53 | # pytest-django 54 | # pytest-forked 55 | # pytest-xdist 56 | pytest-cov==3.0.0 57 | # via -r requirements-test.in 58 | pytest-django==4.5.2 59 | # via -r requirements-test.in 60 | pytest-forked==1.4.0 61 | # via pytest-xdist 62 | pytest-xdist==2.5.0 63 | # via -r requirements-test.in 64 | pyyaml==6.0 65 | # via -r requirements-test.in 66 | toml==0.10.2 67 | # via autopep8 68 | tomli==2.0.1 69 | # via 70 | # coverage 71 | # pep517 72 | # pytest 73 | wheel==0.37.1 74 | # via pip-tools 75 | 76 | # The following packages are considered to be unsafe in a requirements file: 77 | # pip 78 | # setuptools 79 | -------------------------------------------------------------------------------- /lepo_tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | SECRET_KEY = 'pet? pot? pat?' 5 | DEBUG = True 6 | ALLOWED_HOSTS = [] 7 | 8 | INSTALLED_APPS = [ 9 | 'django.contrib.admin', 10 | 'django.contrib.auth', 11 | 'django.contrib.contenttypes', 12 | 'django.contrib.sessions', 13 | 'django.contrib.messages', 14 | 'django.contrib.staticfiles', 15 | 'lepo_tests', 16 | 'lepo', 17 | 'lepo_doc', 18 | ] 19 | 20 | MIDDLEWARE = [ 21 | 'django.middleware.security.SecurityMiddleware', 22 | 'django.contrib.sessions.middleware.SessionMiddleware', 23 | 'django.middleware.common.CommonMiddleware', 24 | 'django.middleware.csrf.CsrfViewMiddleware', 25 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 26 | 'django.contrib.messages.middleware.MessageMiddleware', 27 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 28 | ] 29 | 30 | ROOT_URLCONF = 'lepo_tests.urls' 31 | 32 | TEMPLATES = [ 33 | { 34 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 35 | 'DIRS': [], 36 | 'APP_DIRS': True, 37 | 'OPTIONS': { 38 | 'context_processors': [ 39 | 'django.template.context_processors.debug', 40 | 'django.template.context_processors.request', 41 | 'django.contrib.auth.context_processors.auth', 42 | 'django.contrib.messages.context_processors.messages', 43 | ], 44 | }, 45 | }, 46 | ] 47 | 48 | WSGI_APPLICATION = 'lepo_tests.wsgi.application' 49 | 50 | DATABASES = { 51 | 'default': { 52 | 'ENGINE': 'django.db.backends.sqlite3', 53 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 54 | } 55 | } 56 | 57 | LANGUAGE_CODE = 'en-us' 58 | TIME_ZONE = 'UTC' 59 | USE_I18N = True 60 | USE_L10N = True 61 | USE_TZ = True 62 | STATIC_URL = '/static/' 63 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 64 | -------------------------------------------------------------------------------- /lepo_tests/tests/openapi3/schema-refs.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | servers: [] 3 | paths: 4 | /cat: 5 | post: 6 | operationId: emkitten 7 | x-lepo-body-name: cat 8 | requestBody: 9 | content: 10 | application/json: 11 | schema: 12 | $ref: '#/components/schemas/Cat' 13 | responses: 14 | default: 15 | description: Default response 16 | /pet: 17 | post: 18 | operationId: pet a pet 19 | x-lepo-body-name: pet 20 | requestBody: 21 | content: 22 | application/json: 23 | schema: 24 | $ref: '#/components/schemas/Pet' 25 | responses: 26 | default: 27 | description: Default response 28 | info: 29 | version: '' 30 | title: '' 31 | components: 32 | schemas: 33 | Pet: 34 | type: object 35 | discriminator: 36 | propertyName: petType 37 | properties: 38 | name: 39 | type: string 40 | petType: 41 | type: string 42 | required: 43 | - name 44 | - petType 45 | Cat: 46 | description: A representation of a cat 47 | allOf: 48 | - $ref: '#/components/schemas/Pet' 49 | - type: object 50 | properties: 51 | huntingSkill: 52 | type: string 53 | description: The measured skill for hunting 54 | default: lazy 55 | enum: 56 | - clueless 57 | - lazy 58 | - adventurous 59 | - aggressive 60 | required: 61 | - huntingSkill 62 | Dog: 63 | description: A representation of a dog 64 | allOf: 65 | - $ref: '#/components/schemas/Pet' 66 | - type: object 67 | properties: 68 | packSize: 69 | type: integer 70 | format: int32 71 | description: the size of the pack the dog is from 72 | default: 0 73 | minimum: 0 74 | required: 75 | - packSize 76 | -------------------------------------------------------------------------------- /lepo_tests/tests/swagger2/parameter-test.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | paths: 3 | /upload: 4 | post: 5 | parameters: 6 | - name: file 7 | type: file 8 | in: formData 9 | /greet: 10 | get: 11 | operationId: greet 12 | parameters: 13 | - name: greeting 14 | type: string 15 | in: query 16 | default: henlo 17 | - name: greetee 18 | type: string 19 | in: query 20 | required: true 21 | /multiple-tags: 22 | get: 23 | parameters: 24 | - name: tag 25 | type: array 26 | in: query 27 | items: 28 | type: string 29 | collectionFormat: multi 30 | /invalid-collection-format: 31 | get: 32 | parameters: 33 | - name: blep 34 | type: array 35 | in: query 36 | items: 37 | type: string 38 | collectionFormat: quux 39 | /add-numbers: 40 | get: 41 | parameters: 42 | - name: a 43 | type: integer 44 | in: query 45 | required: true 46 | - name: b 47 | type: integer 48 | in: query 49 | required: true 50 | /header-parameter: 51 | get: 52 | parameters: 53 | - name: token 54 | type: string 55 | in: header 56 | required: true 57 | /cascade-parameters: 58 | parameters: 59 | - name: a 60 | type: integer 61 | in: query 62 | required: true 63 | get: 64 | parameters: 65 | - name: b 66 | type: integer 67 | in: query 68 | required: true 69 | /cascade-parameter-override: 70 | parameters: 71 | - name: a 72 | type: integer 73 | in: query 74 | get: 75 | parameters: 76 | - name: a 77 | type: string 78 | in: query 79 | required: true 80 | /parameter-reference: 81 | get: 82 | parameters: 83 | - $ref: '#/parameters/age' 84 | /parameters-reference: 85 | get: 86 | parameters: 87 | $ref: '#/paths/~1parameter-reference/get/parameters' 88 | parameters: 89 | age: 90 | name: age 91 | in: query 92 | type: integer 93 | minimum: 0 94 | -------------------------------------------------------------------------------- /lepo_tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import types 4 | 5 | from django.contrib import admin 6 | from django.urls import include, re_path 7 | 8 | from lepo.decorators import csrf_exempt 9 | from lepo.router import Router 10 | from lepo.validate import validate_router 11 | from lepo_doc.urls import get_docs_urls 12 | from lepo_tests.tests.utils import DOC_VERSIONS 13 | 14 | 15 | def get_urlpatterns(handler_module, definition_file='swagger2/petstore-expanded.yaml'): 16 | # NB: This could just as well be your `urls.py` – it's here to make testing various handler 17 | # configurations easier. 18 | 19 | router = Router.from_file(os.path.join(os.path.dirname(__file__), 'tests', definition_file)) 20 | router.add_handlers(handler_module) 21 | validate_router(router) 22 | router_urls = router.get_urls( 23 | decorate=(csrf_exempt,), 24 | optional_trailing_slash=True, 25 | root_view_name='api_root', 26 | ) 27 | 28 | urlpatterns = [ 29 | re_path(r'^admin/', admin.site.urls), 30 | re_path(r'^api/', include((router_urls, 'api'), 'api')), 31 | re_path(r'^api/', include((get_docs_urls(router, 'api-docs'), 'api-docs'), 'api-docs')), 32 | ] 33 | return urlpatterns 34 | 35 | 36 | URLCONF_TEMPLATE = ''' 37 | from lepo_tests.handlers import %(module)s 38 | from lepo_tests.utils import get_urlpatterns 39 | urlpatterns = get_urlpatterns(%(module)s, %(file)r) 40 | ''' 41 | 42 | 43 | def generate_urlconf_module(handler_style, version): 44 | modname = f'lepo_tests.generated_urls_{handler_style}_{version}' 45 | mod = types.ModuleType(modname) 46 | code = URLCONF_TEMPLATE % { 47 | 'module': handler_style, 48 | 'file': f'{version}/petstore-expanded.yaml', 49 | } 50 | exec(code, mod.__dict__) 51 | sys.modules[modname] = mod 52 | return mod 53 | 54 | 55 | def generate_urlconf_modules(handler_styles, versions): 56 | urlconf_modules = {} 57 | for handler_style in handler_styles: 58 | for version in versions: 59 | mod = generate_urlconf_module(handler_style, version) 60 | urlconf_modules[(handler_style, version)] = mod 61 | return urlconf_modules 62 | 63 | 64 | urlconf_map = generate_urlconf_modules( 65 | handler_styles=('pets_cb', 'pets_bare'), 66 | versions=DOC_VERSIONS, 67 | ) 68 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_openapi3_complex.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from django.utils.http import urlencode 5 | from jsonschema import ValidationError 6 | 7 | from lepo.apidef.doc import APIDefinition 8 | from lepo.excs import ErroneousParameters, InvalidComplexContent 9 | from lepo.parameter_utils import read_parameters 10 | from lepo_tests.tests.utils import make_request_for_operation 11 | 12 | doc = APIDefinition.from_yaml(''' 13 | openapi: 3.0.0 14 | paths: 15 | /complex-parameter: 16 | get: 17 | parameters: 18 | - in: query 19 | name: coordinates 20 | required: true 21 | content: 22 | application/json: 23 | schema: 24 | type: object 25 | required: 26 | - lat 27 | - long 28 | properties: 29 | lat: 30 | type: number 31 | long: 32 | type: number 33 | ''') 34 | 35 | 36 | def test_complex_parameter(): 37 | coords_obj = {'lat': 8, 'long': 7} 38 | request = make_request_for_operation( 39 | doc.get_path('/complex-parameter').get_operation('get'), 40 | query_string=urlencode({ 41 | 'coordinates': json.dumps(coords_obj), 42 | }), 43 | ) 44 | params = read_parameters(request) 45 | assert params == {'coordinates': coords_obj} 46 | 47 | 48 | def test_complex_parameter_fails_validation(): 49 | request = make_request_for_operation( 50 | doc.get_path('/complex-parameter').get_operation('get'), 51 | query_string=urlencode({ 52 | 'coordinates': json.dumps({'lat': 8, 'long': 'hoi there'}), 53 | }), 54 | ) 55 | with pytest.raises(ErroneousParameters) as ei: 56 | read_parameters(request, capture_errors=True) 57 | assert isinstance(ei.value.errors['coordinates'], ValidationError) 58 | 59 | 60 | def test_malformed_complex_parameter(): 61 | request = make_request_for_operation( 62 | doc.get_path('/complex-parameter').get_operation('get'), 63 | query_string=urlencode({ 64 | 'coordinates': '{{{{{{{{{{{{{it\'s so cold and miserable', 65 | }), 66 | ) 67 | with pytest.raises(ErroneousParameters) as ei: 68 | read_parameters(request, capture_errors=True) 69 | assert isinstance(ei.value.errors['coordinates'], InvalidComplexContent) 70 | -------------------------------------------------------------------------------- /lepo/parameter_utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import iso8601 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.utils.encoding import force_bytes, force_str 6 | 7 | from lepo.apidef.parameter.base import NO_VALUE 8 | from lepo.excs import ErroneousParameters, MissingParameter 9 | 10 | 11 | def cast_primitive_value(type, format, value): 12 | if type == 'boolean': 13 | return (force_str(value).lower() in ('1', 'yes', 'true')) 14 | if type == 'integer' or format in ('integer', 'long'): 15 | return int(value) 16 | if type == 'number' or format in ('float', 'double'): 17 | return float(value) 18 | if format == 'byte': # base64 encoded characters 19 | return base64.b64decode(value) 20 | if format == 'binary': # any sequence of octets 21 | return force_bytes(value) 22 | if format == 'date': # ISO8601 date 23 | return iso8601.parse_date(value).date() 24 | if format == 'dateTime': # ISO8601 datetime 25 | return iso8601.parse_date(value) 26 | if type == 'string': 27 | return force_str(value) 28 | return value 29 | 30 | 31 | def read_parameters(request, view_kwargs=None, capture_errors=False): # noqa: C901 32 | """ 33 | :param request: HttpRequest with attached api_info 34 | :type request: HttpRequest 35 | :type view_kwargs: dict[str, object] 36 | :type capture_errors: bool 37 | :rtype: dict[str, object] 38 | """ 39 | if view_kwargs is None: 40 | view_kwargs = {} 41 | params = {} 42 | errors = {} 43 | for param in request.api_info.operation.parameters: 44 | try: 45 | value = param.get_value(request, view_kwargs) 46 | if value is NO_VALUE: 47 | if param.has_default: 48 | params[param.name] = param.default 49 | elif param.required: # Required but missing 50 | errors[param.name] = MissingParameter(f'parameter {param.name} is required but missing') 51 | continue # No value, or a default was added, or an error was added. 52 | params[param.name] = param.cast(request.api_info.api, value) 53 | except (NotImplementedError, ImproperlyConfigured): 54 | raise 55 | except Exception as e: 56 | if not capture_errors: 57 | raise 58 | errors[param.name] = e 59 | if errors: 60 | raise ErroneousParameters(errors, params) 61 | return params 62 | -------------------------------------------------------------------------------- /lepo/apidef/operation/base.py: -------------------------------------------------------------------------------- 1 | from collections.__init__ import OrderedDict 2 | 3 | from django.utils.functional import cached_property 4 | 5 | from lepo.utils import maybe_resolve 6 | 7 | 8 | class Operation: 9 | parameter_class = None # This should never be used 10 | 11 | def __init__(self, api, path, method, data): 12 | """ 13 | :type api: lepo.apidef.doc.APIDefinition 14 | :type path: lepo.apidef.path.Path 15 | :type method: str 16 | :type data: dict 17 | """ 18 | self.api = api 19 | self.path = path 20 | self.method = method 21 | self.data = data 22 | 23 | @property 24 | def id(self): 25 | return self.data['operationId'] 26 | 27 | @cached_property 28 | def parameters(self): 29 | """ 30 | Combined path-level and operation-level parameters. 31 | 32 | Any $refs are resolved here. 33 | 34 | Note that this implementation differs from the spec in that we only use 35 | the _name_ of a parameter to consider its uniqueness, not the name and location. 36 | 37 | This is because we end up passing parameters to the handler by name anyway, 38 | so any duplicate names, even if they had different locations, would be horribly mangled. 39 | 40 | :rtype: list[Parameter] 41 | """ 42 | return list(self.get_parameter_dict().values()) 43 | 44 | def get_parameter_dict(self): 45 | parameters = OrderedDict() 46 | for parameter in self._get_regular_parameters(): 47 | parameters[parameter.name] = parameter 48 | return parameters 49 | 50 | def _get_regular_parameters(self): 51 | for source in ( 52 | self.path.mapping.get('parameters', ()), 53 | self.data.get('parameters', {}), 54 | ): 55 | source = maybe_resolve(source, self.api.resolve_reference) 56 | for parameter in source: 57 | parameter_data = maybe_resolve(parameter, self.api.resolve_reference) 58 | parameter = self.parameter_class(data=parameter_data, operation=self, api=self.api) 59 | yield parameter 60 | 61 | def _get_overridable(self, key, default=None): 62 | # TODO: This probes a little too deeply into the specifics of these objects, I think... 63 | for obj in ( 64 | self.data, 65 | self.path.mapping, 66 | self.api.doc, 67 | ): 68 | if key in obj: 69 | return obj[key] 70 | return default 71 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | ## Router 4 | 5 | The `lepo.router.Router` class is the root class of the API. 6 | 7 | It encapsulates the OpenAPI definition document and generates 8 | the URL patterns that are to be mounted in a Django URLconf. 9 | 10 | ### View Decoration 11 | 12 | You can decorate the views that end up calling handlers when you instantiate the router. 13 | 14 | For instance, you will need to decorate the views to be CSRF exempt 15 | if you're using Django's default [CSRF middleware][csrf-middleware] 16 | and need to send POST (or PATCH, etc.) requests to your API. 17 | 18 | To do this, turn your 19 | 20 | ```python 21 | router.get_urls() 22 | ``` 23 | 24 | into 25 | 26 | ```python 27 | from lepo.decorators import csrf_exempt 28 | 29 | router.get_urls(decorate=(csrf_exempt,)) 30 | ``` 31 | 32 | (Do note that this _does_ indeed remove Django's CSRF protection from the API views.) 33 | 34 | ## Handler 35 | 36 | Handlers are the functions that do the actual API work. 37 | 38 | They are mapped to the OpenAPI definition by way of the `operationId` 39 | field available in Operation objects. 40 | 41 | Handler functions are superficially similar to plain Django view functions 42 | aside from a few significant differences: 43 | 44 | * `request` is the _only_ positional argument passed to a handler; 45 | the other arguments are mapped from the OpenAPI operation's parameters 46 | and passed in as keyword arguments (converted to `snake_case`). 47 | 48 | ### Exception Handling 49 | 50 | You can raise a `lepo.excs.ExceptionalResponse` (which wraps a Django `response`) 51 | anywhere within a handler invocation. These exceptions will be caught by the internal 52 | `PathView` class and the wrapped response used as the handler's response. 53 | 54 | This is a pragmatic way to refactor behavior common to multiple handlers, e.g. 55 | 56 | ```python 57 | def _get_some_object(request, id): 58 | if not request.user.is_authenticated(): 59 | raise ExceptionalResponse(JsonResponse({'error': 'not authenticated'}, status=401)) 60 | try: 61 | return Object.objects.get(pk=id) 62 | except ObjectDoesNotExist: 63 | raise ExceptionalResponse(JsonResponse({'error': 'no such object'}, status=404)) 64 | 65 | 66 | def get_object_detail(request, id): 67 | object = _get_some_object(request, id) 68 | return {'id': object.id} 69 | 70 | 71 | def delete_object(request, id): 72 | object = _get_some_object(request, id) 73 | object.delete() 74 | return {'id': object.id, 'deleted': True} 75 | ``` 76 | 77 | [csrf-middleware]: https://docs.djangoproject.com/en/1.11/ref/csrf/ 78 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_openapi3_label.py: -------------------------------------------------------------------------------- 1 | from lepo.apidef.doc import APIDefinition 2 | from lepo.parameter_utils import read_parameters 3 | from lepo_tests.tests.utils import make_request_for_operation 4 | 5 | doc = APIDefinition.from_yaml(''' 6 | openapi: 3.0.0 7 | servers: [] 8 | paths: 9 | /single/{thing}/data{format}: 10 | get: 11 | parameters: 12 | - name: format 13 | in: path 14 | style: label 15 | schema: 16 | type: string 17 | - name: thing 18 | in: path 19 | schema: 20 | type: string 21 | /array/{thing}/data{format}: 22 | get: 23 | parameters: 24 | - name: format 25 | in: path 26 | style: label 27 | explode: true 28 | schema: 29 | type: array 30 | items: 31 | type: string 32 | - name: thing 33 | in: path 34 | schema: 35 | type: string 36 | /object/{thing}/data{format}: 37 | get: 38 | parameters: 39 | - name: format 40 | in: path 41 | style: label 42 | explode: false 43 | schema: 44 | type: object 45 | items: 46 | type: string 47 | - name: thing 48 | in: path 49 | schema: 50 | type: string 51 | ''') 52 | 53 | 54 | def test_label_parameter(): 55 | request = make_request_for_operation(doc.get_path('/single/{thing}/data{format}').get_operation('get')) 56 | params = read_parameters(request, { 57 | 'thing': 'blorp', 58 | 'format': '.json', 59 | }) 60 | assert params == { 61 | 'thing': 'blorp', 62 | 'format': 'json', # Notice the missing dot 63 | } 64 | 65 | 66 | def test_label_array_parameter(): 67 | request = make_request_for_operation(doc.get_path('/array/{thing}/data{format}').get_operation('get')) 68 | params = read_parameters(request, { 69 | 'thing': 'blorp', 70 | 'format': '.json.yaml.xml.pdf', 71 | }) 72 | assert params == { 73 | 'thing': 'blorp', 74 | 'format': ['json', 'yaml', 'xml', 'pdf'], # An eldritch monstrosity 75 | } 76 | 77 | 78 | def test_label_object_parameter(): 79 | request = make_request_for_operation(doc.get_path('/object/{thing}/data{format}').get_operation('get')) 80 | params = read_parameters(request, { 81 | 'thing': 'blorp', 82 | 'format': '.some,body,once,told', 83 | }) 84 | assert params == { 85 | 'thing': 'blorp', 86 | 'format': {'some': 'body', 'once': 'told'}, 87 | } 88 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ![](banner.svg) 2 | 3 | # Welcome! 4 | 5 | Lepo is a *contract-first* API framework that enables you to design your API 6 | using the [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification) (formerly known as Swagger) 7 | and implement it in Python 3 and [Django](https://www.djangoproject.com/). 8 | 9 | What does it mean when we say *contract-first*? Contrast this to *code-first*: 10 | 11 | * **Code-first**: 12 | First write the implementation of your API endpoints. 13 | Interactive API documentation is generated from docstrings and other 14 | meta-data embedded in the implementation. 15 | The [Django REST Framework](http://www.django-rest-framework.org/) is a 16 | popular example of a framework that promotes code-first style. 17 | 18 | * **Contract-first** (or **API first**): 19 | Write the *contract* of your API first in machine-readable documentation describing 20 | the available endpoints and their input and output. 21 | API calls are mapped into view functions using meta-data embedded in this machine-readable documentation. 22 | Other examples of contract-first frameworks include [connexion](https://github.com/zalando/connexion) 23 | (using [Flask](https://github.com/pallets/flask)) and 24 | [Apigee 127](https://github.com/apigee-127/swagger-tools) (using Node.js and Express). 25 | 26 | ## Features 27 | 28 | * Automatic routing of requests to endpoints 29 | * Body and query parameter validation 30 | * Output validation 31 | * Embedded Swagger UI (for Swagger 2) 32 | * Support for both Swagger 2 and OpenAPI 3 33 | 34 | ## License 35 | 36 | The MIT License (MIT) 37 | 38 | Copyright (c) 2017 Aarni Koskela, Santtu Pajukanta 39 | Copyright (c) 2018, 2020 Aarni Koskela 40 | 41 | Permission is hereby granted, free of charge, to any person obtaining a copy 42 | of this software and associated documentation files (the "Software"), to deal 43 | in the Software without restriction, including without limitation the rights 44 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 45 | copies of the Software, and to permit persons to whom the Software is 46 | furnished to do so, subject to the following conditions: 47 | 48 | The above copyright notice and this permission notice shall be included in all 49 | copies or substantial portions of the Software. 50 | 51 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 52 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 53 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 54 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 55 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 56 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 57 | SOFTWARE. 58 | -------------------------------------------------------------------------------- /lepo/apidef/parameter/utils.py: -------------------------------------------------------------------------------- 1 | import jsonschema 2 | from django.core.files import File 3 | from django.utils.encoding import force_str 4 | from jsonschema import Draft4Validator 5 | 6 | from lepo.decoders import get_decoder 7 | from lepo.excs import InvalidBodyContent 8 | from lepo.utils import maybe_resolve 9 | 10 | 11 | def comma_split(value): 12 | return force_str(value).split(',') 13 | 14 | 15 | def dot_split(value): 16 | return force_str(value).split('.') 17 | 18 | 19 | def space_split(value): 20 | return force_str(value).split(' ') 21 | 22 | 23 | def tab_split(value): 24 | return force_str(value).split('\t') 25 | 26 | 27 | def pipe_split(value): 28 | return force_str(value).split('|') 29 | 30 | 31 | def read_body(request, parameter=None): 32 | if parameter: 33 | if parameter.type == 'binary': 34 | return request.body.read() 35 | try: 36 | if request.content_type == 'multipart/form-data': 37 | # TODO: this definitely doesn't handle multiple values for the same key correctly 38 | data = dict() 39 | data.update(request.POST.items()) 40 | data.update(request.FILES.items()) 41 | return data 42 | decoder = get_decoder(request.content_type) 43 | if decoder: 44 | return decoder(request.body, encoding=request.content_params.get('charset', 'UTF-8')) 45 | except Exception as exc: 46 | raise InvalidBodyContent(f'Unable to parse this body as {request.content_type}') from exc 47 | raise NotImplementedError(f'No idea how to parse content-type {request.content_type}') # pragma: no cover 48 | 49 | 50 | class LepoDraft4Validator(Draft4Validator): 51 | def iter_errors(self, instance, _schema=None): 52 | if isinstance(instance, File): 53 | # Skip validating File instances that come from POST requests... 54 | return 55 | yield from super().iter_errors(instance, _schema) 56 | 57 | 58 | def validate_schema(schema, api, value): 59 | schema = maybe_resolve(schema, resolve=api.resolve_reference) 60 | jsonschema.validate( 61 | value, 62 | schema, 63 | cls=LepoDraft4Validator, 64 | resolver=api.resolver, 65 | ) 66 | if 'discriminator' in schema: # Swagger/OpenAPI 3 Polymorphism support 67 | discriminator = schema['discriminator'] 68 | if isinstance(discriminator, dict): # OpenAPI 3 69 | type = value[discriminator['propertyName']] 70 | if 'mapping' in discriminator: 71 | actual_type = discriminator['mapping'][type] 72 | else: 73 | actual_type = f'#/components/schemas/{type}' 74 | else: 75 | type = value[discriminator] 76 | actual_type = f'#/definitions/{type}' 77 | schema = api.resolve_reference(actual_type) 78 | jsonschema.validate( 79 | value, 80 | schema, 81 | cls=LepoDraft4Validator, 82 | resolver=api.resolver, 83 | ) 84 | return value 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lepo – Contract-first REST APIs in Django 2 | 3 | [![Build Status](https://travis-ci.org/akx/lepo.svg?branch=master)](https://travis-ci.org/akx/lepo) [![Codecov](https://img.shields.io/codecov/c/github/akx/lepo.svg)]() 4 | 5 | Lepo is a *contract-first* API framework that enables you to design your API using the [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification) (formerly known as Swagger) and implement it in Python 3 and [Django](https://www.djangoproject.com/). 6 | 7 | What does it mean when we say *contract-first*? Contrast this to *code-first*: 8 | 9 | * **Code-first**: First write the implementation of your API endpoints. Interactive API documentation is generated from docstrings and other meta-data embedded in the implementation. The [Django REST Framework](http://www.django-rest-framework.org/) is a popular example of a framework that promotes code-first style. 10 | * **Contract-first** (or **API first**): Write the *contract* of your API first in machine-readable documentation describing the available endpoints and their input and output. API calls are mapped into view functions using meta-data embedded in this machine-readable documentation. Other examples of contract-first frameworks include [connexion](https://github.com/zalando/connexion) (using [Flask](https://github.com/pallets/flask)) and [Apigee 127](https://github.com/apigee-127/swagger-tools) (using Node.js and Express). 11 | 12 | ## Features 13 | 14 | * Automatic routing of requests to endpoints 15 | * Body and query parameter validation 16 | * Output validation (soon!) 17 | * Embedded Swagger UI 18 | 19 | ## Documentation 20 | 21 | Please see the [docs](./docs) folder for more extensive documentation. 22 | 23 | ## Why is it called *lepo*? 24 | 25 | *Lepo* is Finnish for *rest*. 26 | 27 | ## License 28 | 29 | The MIT License (MIT) 30 | 31 | Copyright (c) 2017 Aarni Koskela, Santtu Pajukanta 32 | Copyright (c) 2018, 2020 Aarni Koskela 33 | 34 | Permission is hereby granted, free of charge, to any person obtaining a copy 35 | of this software and associated documentation files (the "Software"), to deal 36 | in the Software without restriction, including without limitation the rights 37 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 38 | copies of the Software, and to permit persons to whom the Software is 39 | furnished to do so, subject to the following conditions: 40 | 41 | The above copyright notice and this permission notice shall be included in all 42 | copies or substantial portions of the Software. 43 | 44 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 45 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 46 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 47 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 48 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 49 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 50 | SOFTWARE. 51 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Writing your API contract 4 | 5 | Write the contract of the first version of your API in the [OpenAPI format](https://github.com/OAI/OpenAPI-Specification). You'll end up with a YAML file popularly called `swagger.yml`. 6 | 7 | ```yaml 8 | swagger: "2.0" 9 | info: 10 | version: 0.0.1 11 | title: Lepo Petstore 12 | description: A sample API that uses a petstore as an example to … 13 | host: localhost:8000 14 | basePath: /api 15 | schemes: 16 | - http 17 | consumes: 18 | - application/json 19 | produces: 20 | - application/json 21 | paths: 22 | /pets: 23 | get: 24 | description: | 25 | Returns all pets from the system that the user has access to 26 | operationId: findPets 27 | responses: 28 | 200: 29 | description: Great success. 30 | ``` 31 | 32 | Note the `operationId` field. It's converted from camel case to snake case (`findPets` becomes `find_pets`) and used to route an API call to the correct handler (or *view* in Django lingo). 33 | 34 | ## Installing Lepo 35 | 36 | Install Lepo into your environment (likely a [virtualenv]) using `pip` 37 | (or if you manage requirements using a requirements.txt file or similar, add it there). 38 | 39 | ```shell 40 | $ pip install lepo 41 | ``` 42 | 43 | Since we're using a YAML-formatted API declaration, we'll also need the [PyYAML] library. 44 | If you happen to be using JSON-formatted OpenAPI documents, you don't need this. 45 | 46 | ```shell 47 | $ pip install PyYAML 48 | ``` 49 | 50 | Add `lepo` to your `INSTALLED_APPS`. If you want to use the Swagger UI, also add `lepo_doc`. 51 | 52 | ```python 53 | INSTALLED_APPS = [ 54 | # … 55 | 'lepo', 56 | 'lepo_doc', 57 | ] 58 | ``` 59 | 60 | ## Wiring up Lepo 61 | 62 | Make a Django app, say `petstore`, add it to `INSTALLED_APPS`, and hook the `swagger.yml` up to your application in `urls.py`: 63 | 64 | ```python 65 | from pkg_resources import resource_filename 66 | 67 | from django.conf.urls import include, url 68 | from django.contrib import admin 69 | 70 | from lepo.router import Router 71 | from lepo.validate import validate_router 72 | from lepo_doc.urls import get_docs_urls 73 | 74 | from . import views 75 | 76 | 77 | router = Router.from_file(resource_filename(__name__, 'swagger.yml')) 78 | router.add_handlers(views) 79 | validate_router(router) 80 | 81 | urlpatterns = [ 82 | url(r'^admin/', admin.site.urls), 83 | url(r'^api/', include(router.get_urls(), 'api')), 84 | url(r'^api/', include(get_docs_urls(router, 'api-docs'), 'api-docs')), 85 | ] 86 | ``` 87 | 88 | Observe it is your responsibility to mount the API at the correct base path. Lepo does not read `basePath` from your `swagger.yml`. 89 | 90 | Finally, implement the operations in `petstore/views.py`: 91 | 92 | ```python 93 | from django.http import JSONResponse 94 | 95 | def find_pets(request): 96 | return JSONResponse({'pets': []}) 97 | ``` 98 | 99 | [virtualenv]: http://docs.python-guide.org/en/latest/dev/virtualenvs/ 100 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_datatypes.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from datetime import date, datetime 3 | 4 | import pytest 5 | from iso8601 import UTC 6 | 7 | from lepo.apidef.doc import Swagger2APIDefinition 8 | from lepo.parameter_utils import cast_primitive_value 9 | from lepo_tests.tests.utils import ( 10 | cast_parameter_value, 11 | doc_versions, 12 | get_apidoc_from_version, 13 | ) 14 | 15 | DATA_EXAMPLES = [ 16 | {'spec': {'type': 'integer'}, 'input': '5041211', 'output': 5041211}, 17 | {'spec': {'format': 'long'}, 'input': '88888888888888888888888', 'output': 88888888888888888888888}, 18 | {'spec': {'format': 'float'}, 'input': '-6.3', 'output': -6.3}, 19 | {'spec': {'format': 'double'}, 'input': '-6.7', 'output': -6.7}, 20 | {'spec': {'type': 'string'}, 'input': '', 'output': ''}, 21 | {'spec': {'type': 'string'}, 'input': 'hee', 'output': 'hee'}, 22 | {'spec': {'format': 'byte'}, 'input': 'c3VyZS4=', 'output': b'sure.'}, 23 | {'spec': {'format': 'binary'}, 'input': 'v', 'output': b'v'}, 24 | {'spec': {'type': 'boolean'}, 'input': 'true', 'output': True}, 25 | {'spec': {'type': 'boolean'}, 'input': 'false', 'output': False}, 26 | {'spec': {'format': 'date'}, 'input': '2016-03-06 15:33:33', 'output': date(2016, 3, 6)}, 27 | {'spec': {'format': 'date'}, 'input': '2016-03-06', 'output': date(2016, 3, 6)}, 28 | { 29 | 'spec': {'format': 'dateTime'}, 'input': '2016-03-06 15:33:33', 30 | 'output': datetime(2016, 3, 6, 15, 33, 33, tzinfo=UTC) 31 | }, 32 | {'spec': {'format': 'password'}, 'input': 'ffffffffffff', 'output': 'ffffffffffff'}, 33 | ] 34 | 35 | 36 | @pytest.mark.parametrize('case', DATA_EXAMPLES) 37 | def test_data(case): 38 | spec = case['spec'] 39 | type = spec.get('type') 40 | format = spec.get('format') 41 | parsed = cast_primitive_value(type, format, case['input']) 42 | assert parsed == case['output'] 43 | 44 | 45 | Case = namedtuple('Case', 'swagger2_schema openapi3_schema input expected') 46 | 47 | collection_format_cases = [ 48 | Case( 49 | {'type': 'array', 'collectionFormat': 'tsv', 'items': {'type': 'boolean'}}, 50 | None, # TSV does not exist in OpenAPI 3 51 | 'true\ttrue\tfalse', 52 | [True, True, False], 53 | ), 54 | Case( 55 | {'type': 'array', 'collectionFormat': 'ssv', 'items': {'type': 'string'}}, 56 | None, 57 | 'what it do', 58 | ['what', 'it', 'do'], 59 | ), 60 | Case( 61 | { 62 | 'type': 'array', 63 | 'collectionFormat': 'pipes', 64 | 'items': { 65 | 'type': 'array', 66 | 'items': { 67 | 'type': 'integer', 68 | }, 69 | } 70 | }, 71 | None, 72 | '1,2,3|4,5,6|7,8,9', 73 | [[1, 2, 3], [4, 5, 6], [7, 8, 9]], 74 | ), 75 | ] 76 | 77 | 78 | @doc_versions 79 | @pytest.mark.parametrize('case', collection_format_cases) 80 | def test_collection_formats(doc_version, case): 81 | schema = (case.swagger2_schema if doc_version == 'swagger2' else case.openapi3_schema) 82 | if schema is None: 83 | pytest.xfail(f'{case}: no schema for {doc_version}') 84 | apidoc = get_apidoc_from_version(doc_version) 85 | assert cast_parameter_value(apidoc, schema, case.input) == case.expected 86 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | Features 2 | ======== 3 | 4 | * Features marked with "Yes" are supported. 5 | * Features marked with "Planned" are planned. 6 | * Features marked with "No" are not supported, either not yet or ever. 7 | * Features marked with "Maybe" might be supported, but they haven't been tested. 8 | * Features marked with "-" do not apply to the given API version. 9 | 10 | The absence or presence of a feature here does not directly mean it will or won't be implemented, 11 | so this document also serves as a TODO list of sorts. 12 | 13 | Platform features 14 | ----------------- 15 | 16 | * [ ] DRY API error handling 17 | * [ ] DRY authentication and authorization 18 | * [ ] DRY pagination 19 | 20 | OpenAPI Features 21 | ---------------- 22 | 23 | This list was built by manually scanning down the OpenAPI specification, so omissions are entirely possible. 24 | 25 | | Feature | Swagger 2 | OpenAPI 3 | 26 | | ------- | --------- | --------- | 27 | | General: Path `$ref`s | Yes | Yes | 28 | | General: Path-level `consumes`/`produces` definitions | Yes | Yes | 29 | | General: Operation-level `consumes`/`produces` definitions | Yes | Yes | 30 | | General: References outside a single OpenAPI file ("Relative Files With Embedded Schema") | No | No | 31 | | Definitions: `$ref`s in definitions | Yes | | 32 | | Definitions: Model polymorphism (schema `discriminator` field) | Yes | Yes | 33 | | Definitions: Population of default values within models | No | No | 34 | | Parameters: Path-level `parameter` definitions | Yes | Maybe | 35 | | Parameters: Operation-level `parameter` definitions | Yes | Yes | 36 | | Parameters: in paths | Yes | Yes | 37 | | Parameters: in query string | Yes | Yes | 38 | | Parameters: in HTTP headers | Yes | Yes | 39 | | Parameters: in HTTP body | Yes | Yes | 40 | | Parameters: in HTTP cookies | - | Maybe | 41 | | Parameters: Primitive parameters in HTTP body | Yes | Yes | 42 | | Parameters: Parameters in HTTP form data | Yes | Yes | 43 | | Parameters: Body-type parameter schema validation | Yes | Yes | 44 | | Parameters: type/format validation | Yes | Yes | 45 | | Parameters: `allowEmptyValue` | No | No | 46 | | Parameters: CSV/SSV/TSV/Pipes collection formats | Yes | Yes | 47 | | Parameters: multi collection format | Yes | - | 48 | | Parameters: `label` style path components | - | Yes | 49 | | Parameters: `matrix` style path components | - | Unlikely | 50 | | Parameters: `deepObject` style | - | No | 51 | | Parameters: Complex parameters | - | Yes | 52 | | Parameters: list parameters becoming objects | - | Maybe | 53 | | Parameters: defaults | Yes | Yes | 54 | | Parameters: extended validation (`maximum`/...) | Yes | Yes | 55 | | Parameters: array `items` validation | Yes | Yes | 56 | | Parameters: Definitions Objects | Yes | Yes | 57 | | Parameters: `$ref`s | Yes | Yes | 58 | | Parameters: Replacement of entire `parameters` objects with `$ref`s | Yes | Maybe | 59 | | Operations: Operation response validation | No | No | 60 | | Operations: Operation response `$ref`s | No | No | 61 | | Operations: Operation response schema validation | No | No | 62 | | Operations: Operation response `file` schema type | No | No | 63 | | Operations: Responses Definitions Objects | No | No | 64 | | Operations: Headers validation | No | No | 65 | | Security: Operation security declarations | No | No | 66 | | Security: Security Definitions Objects | No | No | 67 | | UI: Swagger UI | Yes | Maybe | 68 | -------------------------------------------------------------------------------- /lepo/apidef/doc.py: -------------------------------------------------------------------------------- 1 | from jsonschema import RefResolver 2 | 3 | from lepo.apidef.operation.openapi import OpenAPI3Operation 4 | from lepo.apidef.operation.swagger import Swagger2Operation 5 | from lepo.apidef.path import Path 6 | from lepo.apidef.version import OPENAPI_3, SWAGGER_2, parse_version 7 | from lepo.utils import maybe_resolve 8 | 9 | 10 | class APIDefinition: 11 | version = None 12 | path_class = Path 13 | operation_class = None 14 | 15 | def __init__(self, doc): 16 | """ 17 | Instantiate a new Lepo router. 18 | 19 | :param doc: The OpenAPI definition document. 20 | :type doc: dict 21 | """ 22 | self.doc = doc 23 | self.resolver = RefResolver('', self.doc) 24 | 25 | def resolve_reference(self, ref): 26 | """ 27 | Resolve a JSON Pointer object reference to the object itself. 28 | 29 | :param ref: Reference string (`#/foo/bar`, for instance) 30 | :return: The object, if found 31 | :raises jsonschema.exceptions.RefResolutionError: if there is trouble resolving the reference 32 | """ 33 | url, resolved = self.resolver.resolve(ref) 34 | return resolved 35 | 36 | def get_path_mapping(self, path): 37 | return maybe_resolve(self.doc['paths'][path], self.resolve_reference) 38 | 39 | def get_path_names(self): 40 | yield from self.doc['paths'] 41 | 42 | def get_path(self, path): 43 | """ 44 | Construct a Path object from a path string. 45 | 46 | The Path string must be declared in the API. 47 | 48 | :type path: str 49 | :rtype: lepo.path.Path 50 | """ 51 | mapping = self.get_path_mapping(path) 52 | return self.path_class(api=self, path=path, mapping=mapping) 53 | 54 | def get_paths(self): 55 | """ 56 | Iterate over all Path objects declared by the API. 57 | 58 | :rtype: Iterable[lepo.path.Path] 59 | """ 60 | for path_name in self.get_path_names(): 61 | yield self.get_path(path_name) 62 | 63 | @classmethod 64 | def from_file(cls, filename): 65 | """ 66 | Construct an APIDefinition by parsing the given `filename`. 67 | 68 | If PyYAML is installed, YAML files are supported. 69 | JSON files are always supported. 70 | 71 | :param filename: The filename to read. 72 | :rtype: APIDefinition 73 | """ 74 | with open(filename) as infp: 75 | if filename.endswith('.yaml') or filename.endswith('.yml'): 76 | import yaml 77 | data = yaml.safe_load(infp) 78 | else: 79 | import json 80 | data = json.load(infp) 81 | return cls.from_data(data) 82 | 83 | @classmethod 84 | def from_data(cls, data): 85 | version = parse_version(data) 86 | if version == SWAGGER_2: 87 | return Swagger2APIDefinition(data) 88 | if version == OPENAPI_3: 89 | return OpenAPI3APIDefinition(data) 90 | raise NotImplementedError('We can never get here.') # pragma: no cover 91 | 92 | @classmethod 93 | def from_yaml(cls, yaml_string): 94 | from yaml import safe_load 95 | return cls.from_data(safe_load(yaml_string)) 96 | 97 | 98 | class Swagger2APIDefinition(APIDefinition): 99 | version = SWAGGER_2 100 | operation_class = Swagger2Operation 101 | 102 | 103 | class OpenAPI3APIDefinition(APIDefinition): 104 | version = OPENAPI_3 105 | operation_class = OpenAPI3Operation 106 | -------------------------------------------------------------------------------- /lepo_doc/templates/lepo_doc/swagger-ui.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | {{ title|default:"Swagger UI" }} 7 | 8 | 9 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 | 67 | 68 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /lepo_tests/tests/openapi3/parameter-test.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | servers: [] 3 | paths: 4 | /upload: 5 | post: 6 | requestBody: 7 | content: 8 | multipart/form-data: 9 | schema: 10 | properties: 11 | file: 12 | type: string 13 | format: binary 14 | responses: 15 | default: 16 | description: Default response 17 | /greet: 18 | get: 19 | operationId: greet 20 | parameters: 21 | - name: greeting 22 | in: query 23 | schema: 24 | type: string 25 | default: henlo 26 | - name: greetee 27 | in: query 28 | required: true 29 | schema: 30 | type: string 31 | responses: 32 | default: 33 | description: Default response 34 | /multiple-tags: 35 | get: 36 | parameters: 37 | - name: tag 38 | in: query 39 | explode: true 40 | schema: 41 | type: array 42 | items: 43 | type: string 44 | responses: 45 | default: 46 | description: Default response 47 | /invalid-collection-format: 48 | get: 49 | parameters: 50 | - name: blep 51 | in: query 52 | style: very nice 53 | schema: 54 | type: array 55 | items: 56 | type: string 57 | responses: 58 | default: 59 | description: Default response 60 | /add-numbers: 61 | get: 62 | parameters: 63 | - name: a 64 | in: query 65 | required: true 66 | schema: 67 | type: integer 68 | - name: b 69 | in: query 70 | required: true 71 | schema: 72 | type: integer 73 | responses: 74 | default: 75 | description: Default response 76 | /header-parameter: 77 | get: 78 | parameters: 79 | - name: token 80 | in: header 81 | required: true 82 | schema: 83 | type: string 84 | responses: 85 | default: 86 | description: Default response 87 | /cascade-parameters: 88 | parameters: 89 | - name: a 90 | in: query 91 | required: true 92 | schema: 93 | type: integer 94 | get: 95 | parameters: 96 | - name: b 97 | in: query 98 | required: true 99 | schema: 100 | type: integer 101 | responses: 102 | default: 103 | description: Default response 104 | /cascade-parameter-override: 105 | parameters: 106 | - name: a 107 | in: query 108 | schema: 109 | type: integer 110 | get: 111 | parameters: 112 | - name: a 113 | in: query 114 | required: true 115 | schema: 116 | type: string 117 | responses: 118 | default: 119 | description: Default response 120 | /parameter-reference: 121 | get: 122 | parameters: 123 | - $ref: '#/components/parameters/age' 124 | responses: 125 | default: 126 | description: Default response 127 | /parameters-reference: 128 | get: 129 | parameters: 130 | $ref: '#/paths/~1parameter-reference/get/parameters' 131 | responses: 132 | default: 133 | description: Default response 134 | info: 135 | version: '' 136 | title: '' 137 | components: 138 | parameters: 139 | age: 140 | name: age 141 | in: query 142 | schema: 143 | type: integer 144 | minimum: 0 145 | -------------------------------------------------------------------------------- /lepo_tests/tests/swagger2/petstore-expanded.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | description: A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification 6 | termsOfService: http://swagger.io/terms/ 7 | contact: 8 | name: Swagger API Team 9 | email: foo@example.com 10 | url: http://madskristensen.net 11 | license: 12 | name: MIT 13 | url: http://github.com/gruntjs/grunt/blob/master/LICENSE-MIT 14 | host: petstore.swagger.io 15 | basePath: /api 16 | schemes: 17 | - http 18 | consumes: 19 | - application/json 20 | produces: 21 | - application/json 22 | paths: 23 | /pets: 24 | get: 25 | description: | 26 | Returns all pets from the system that the user has access to 27 | operationId: findPets 28 | parameters: 29 | - name: tags 30 | in: query 31 | description: tags to filter by 32 | required: false 33 | type: array 34 | collectionFormat: csv 35 | items: 36 | type: string 37 | - name: limit 38 | in: query 39 | description: maximum number of results to return 40 | required: false 41 | type: integer 42 | format: int32 43 | responses: 44 | "200": 45 | description: pet response 46 | schema: 47 | type: array 48 | items: 49 | $ref: '#/definitions/Pet' 50 | default: 51 | description: unexpected error 52 | schema: 53 | $ref: '#/definitions/Error' 54 | post: 55 | description: Creates a new pet in the store. Duplicates are allowed 56 | operationId: addPet 57 | parameters: 58 | - name: pet 59 | in: body 60 | description: Pet to add to the store 61 | required: true 62 | schema: 63 | $ref: '#/definitions/NewPet' 64 | responses: 65 | "200": 66 | description: pet response 67 | schema: 68 | $ref: '#/definitions/Pet' 69 | default: 70 | description: unexpected error 71 | schema: 72 | $ref: '#/definitions/Error' 73 | /pets/{id}: 74 | get: 75 | description: Returns a user based on a single ID, if the user does not have access to the pet 76 | operationId: find pet by id 77 | parameters: 78 | - name: id 79 | in: path 80 | description: ID of pet to fetch 81 | required: true 82 | type: integer 83 | format: int64 84 | responses: 85 | "200": 86 | description: pet response 87 | schema: 88 | $ref: '#/definitions/Pet' 89 | default: 90 | description: unexpected error 91 | schema: 92 | $ref: '#/definitions/Error' 93 | delete: 94 | description: deletes a single pet based on the ID supplied 95 | operationId: deletePet 96 | parameters: 97 | - name: id 98 | in: path 99 | description: ID of pet to delete 100 | required: true 101 | type: integer 102 | format: int64 103 | responses: 104 | "204": 105 | description: pet deleted 106 | default: 107 | description: unexpected error 108 | schema: 109 | $ref: '#/definitions/Error' 110 | patch: 111 | description: updates a pet by ID 112 | operationId: updatePet 113 | parameters: 114 | - name: id 115 | in: path 116 | description: ID of pet to update 117 | required: true 118 | type: integer 119 | format: int64 120 | - name: pet 121 | in: body 122 | description: Pet data to update 123 | required: true 124 | schema: 125 | $ref: '#/definitions/NewPet' 126 | responses: 127 | "200": 128 | description: pet updated 129 | default: 130 | description: unexpected error 131 | schema: 132 | $ref: '#/definitions/Error' 133 | definitions: 134 | Pet: 135 | allOf: 136 | - $ref: '#/definitions/NewPet' 137 | - required: 138 | - id 139 | properties: 140 | id: 141 | type: integer 142 | format: int64 143 | 144 | NewPet: 145 | required: 146 | - name 147 | properties: 148 | name: 149 | type: string 150 | tag: 151 | type: string 152 | 153 | Error: 154 | required: 155 | - code 156 | - message 157 | properties: 158 | code: 159 | type: integer 160 | format: int32 161 | message: 162 | type: string 163 | -------------------------------------------------------------------------------- /lepo/apidef/parameter/swagger.py: -------------------------------------------------------------------------------- 1 | import jsonschema 2 | 3 | from lepo.apidef.parameter.base import NO_VALUE, BaseParameter, BaseTopParameter 4 | from lepo.apidef.parameter.utils import ( 5 | comma_split, 6 | pipe_split, 7 | read_body, 8 | space_split, 9 | tab_split, 10 | validate_schema, 11 | ) 12 | from lepo.excs import InvalidBodyFormat 13 | from lepo.parameter_utils import cast_primitive_value 14 | from lepo.utils import maybe_resolve 15 | 16 | COLLECTION_FORMAT_SPLITTERS = { 17 | 'csv': comma_split, 18 | 'ssv': space_split, 19 | 'tsv': tab_split, 20 | 'pipes': pipe_split, 21 | } 22 | 23 | OPENAPI_JSONSCHEMA_VALIDATION_KEYS = ( 24 | 'maximum', 'exclusiveMaximum', 25 | 'minimum', 'exclusiveMinimum', 26 | 'maxLength', 'minLength', 27 | 'pattern', 28 | 'maxItems', 'minItems', 29 | 'uniqueItems', 30 | 'enum', 'multipleOf', 31 | ) 32 | 33 | 34 | class Swagger2BaseParameter(BaseParameter): 35 | 36 | @property 37 | def type(self): 38 | return self.data.get('type') 39 | 40 | @property 41 | def format(self): 42 | return self.data.get('format') 43 | 44 | @property 45 | def schema(self): 46 | return self.data.get('schema') 47 | 48 | @property 49 | def has_default(self): 50 | return 'default' in self.data 51 | 52 | @property 53 | def default(self): 54 | return self.data.get('default') 55 | 56 | @property 57 | def collection_format(self): 58 | return self.data.get('collectionFormat') 59 | 60 | @property 61 | def validation_keys(self): 62 | return { 63 | key: self.data[key] 64 | for key in self.data 65 | if key in OPENAPI_JSONSCHEMA_VALIDATION_KEYS 66 | } 67 | 68 | def validate_primitive(self, value): 69 | jsonschema_validation_object = self.validation_keys 70 | if jsonschema_validation_object: 71 | jsonschema.validate(value, jsonschema_validation_object) 72 | return value 73 | 74 | def validate_schema(self, api, value): 75 | schema = maybe_resolve(self.schema, resolve=api.resolve_reference) 76 | return validate_schema(schema, api, value) 77 | 78 | def arrayfy_value(self, value): 79 | collection_format = self.collection_format or 'csv' 80 | splitter = COLLECTION_FORMAT_SPLITTERS.get(collection_format) 81 | if not splitter: 82 | raise NotImplementedError(f'unsupported collection format in {self!r}') 83 | value = splitter(value) 84 | return value 85 | 86 | def cast_array(self, api, value): 87 | if not isinstance(value, list): # could be a list already if collection format was multi 88 | value = self.arrayfy_value(value) 89 | items_param = Swagger2BaseParameter(self.items, api=api) 90 | return [items_param.cast(api, item) for item in value] 91 | 92 | def cast(self, api, value): 93 | if self.type == 'array': 94 | value = self.cast_array(api, value) 95 | 96 | if self.schema: 97 | return self.validate_schema(api, value) 98 | 99 | value = cast_primitive_value(self.type, self.format, value) 100 | value = self.validate_primitive(value) 101 | return value 102 | 103 | 104 | class Swagger2Parameter(Swagger2BaseParameter, BaseTopParameter): 105 | 106 | def get_value(self, request, view_kwargs): 107 | if self.location == 'path': 108 | return view_kwargs.get(self.name, NO_VALUE) 109 | 110 | if self.location == 'header': 111 | meta_key = f"HTTP_{self.name.upper().replace('-', '_')}" 112 | return request.META.get(meta_key, NO_VALUE) 113 | 114 | if self.location == 'body': 115 | return self.read_body(request) 116 | 117 | if self.location == 'formData' and self.type == 'file': 118 | return request.FILES.get(self.name, NO_VALUE) 119 | 120 | if self.location in ('query', 'formData'): 121 | source = (request.POST if self.location == 'formData' else request.GET) 122 | 123 | if self.name not in source: 124 | return NO_VALUE 125 | 126 | if self.type == 'array' and self.collection_format == 'multi': 127 | return source.getlist(self.name) 128 | else: 129 | return source[self.name] 130 | 131 | return super().get_value(request, view_kwargs) # pragma: no cover 132 | 133 | def read_body(self, request): 134 | consumes = request.api_info.operation.consumes 135 | if request.content_type not in consumes: 136 | raise InvalidBodyFormat(f'Content-type {request.content_type} is not supported ({consumes!r} are)') 137 | 138 | return read_body(request, None) 139 | -------------------------------------------------------------------------------- /lepo/handlers.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from functools import wraps 3 | 4 | from django.utils.module_loading import import_string 5 | 6 | 7 | class BaseHandler: 8 | def __init__(self, request, args): 9 | self.request = request 10 | self.args = args 11 | 12 | @classmethod 13 | def get_view(cls, method_name): 14 | """ 15 | Get a Django function view calling the given method (and pre/post-processors) 16 | 17 | :param method_name: The method on the class 18 | :return: View function 19 | """ 20 | method = getattr(cls, method_name) 21 | 22 | @wraps(method) 23 | def view(request, **kwargs): 24 | handler = cls(request, kwargs) 25 | handler.call_processors('view') 26 | response = None 27 | try: 28 | response = method(handler) 29 | return response 30 | finally: 31 | handler.call_processors('post_view', response=response) 32 | 33 | return view 34 | 35 | def get_processors(self, purpose): 36 | return getattr(self, f'{purpose}_processors', ()) 37 | 38 | def call_processors(self, purpose, **kwargs): 39 | for proc in self.get_processors(purpose): 40 | if isinstance(proc, str): 41 | proc = getattr(self, proc, None) or import_string(proc) 42 | kwargs['purpose'] = purpose 43 | proc(**kwargs) 44 | 45 | 46 | class BaseModelHandler(BaseHandler): 47 | model = None 48 | queryset = None 49 | schema_class = None 50 | 51 | id_data_name = 'id' 52 | id_field_name = 'pk' 53 | 54 | def get_schema(self, purpose, object=None): 55 | schema_class = getattr(self, f'{purpose}_schema_class', None) 56 | if schema_class is None: 57 | schema_class = self.schema_class 58 | kwargs = {} 59 | if purpose == 'update': 60 | kwargs['partial'] = True 61 | return schema_class(**kwargs) 62 | 63 | def get_queryset(self, purpose): 64 | queryset = getattr(self, f'{purpose}_queryset', None) 65 | if queryset is None: 66 | queryset = self.queryset 67 | return deepcopy(queryset) 68 | 69 | def process_object_list(self, purpose, object_list): 70 | return object_list 71 | 72 | def retrieve_object(self): 73 | queryset = self.get_queryset('retrieve') 74 | object = queryset.get(**{self.id_field_name: self.args[self.id_data_name]}) 75 | self.call_processors('retrieve_object', object=object) 76 | return object 77 | 78 | 79 | class ModelHandlerReadMixin(BaseModelHandler): 80 | def handle_list(self): 81 | self.call_processors('list') 82 | queryset = self.get_queryset('list') 83 | object_list = self.process_object_list('list', queryset) 84 | schema = self.get_schema('list') 85 | return schema.dump(object_list, many=True) 86 | 87 | def handle_retrieve(self): 88 | self.call_processors('retrieve') 89 | object = self.retrieve_object() 90 | schema = self.get_schema('retrieve') 91 | return schema.dump(object) 92 | 93 | 94 | class ModelHandlerCreateMixin(BaseModelHandler): 95 | create_data_name = 'data' 96 | 97 | def handle_create(self): 98 | self.call_processors('create') 99 | schema = self.get_schema('create') 100 | data = schema.load(self.args[self.create_data_name]) 101 | if not isinstance(data, self.model): 102 | data = self.model(**data) 103 | 104 | data.full_clean() 105 | data.save() 106 | 107 | schema = self.get_schema('post_create', object=data) 108 | return schema.dump(data) 109 | 110 | 111 | class ModelHandlerUpdateMixin(BaseModelHandler): 112 | update_data_name = 'data' 113 | 114 | def handle_update(self): 115 | self.call_processors('update') 116 | object = self.retrieve_object() 117 | schema = self.get_schema('update', object=object) 118 | data = schema.load(self.args[self.update_data_name]) 119 | for key, value in data.items(): 120 | setattr(object, key, value) 121 | object.full_clean() 122 | object.save() 123 | self.call_processors('post_update', object=object) 124 | schema = self.get_schema('post_update', object=object) 125 | return schema.dump(object) 126 | 127 | 128 | class ModelHandlerDeleteMixin(BaseModelHandler): 129 | def handle_delete(self): 130 | self.call_processors('delete') 131 | object = self.retrieve_object() 132 | object.delete() 133 | self.call_processors('post_delete', object=object) 134 | 135 | 136 | class CRUDModelHandler( 137 | ModelHandlerCreateMixin, 138 | ModelHandlerReadMixin, 139 | ModelHandlerUpdateMixin, 140 | ModelHandlerDeleteMixin, 141 | ): 142 | pass 143 | -------------------------------------------------------------------------------- /branding/sleepy_polar_bear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_parameters.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ImproperlyConfigured 3 | from django.core.files.base import ContentFile 4 | from django.core.files.uploadedfile import UploadedFile 5 | from jsonschema import ValidationError 6 | 7 | from lepo.api_info import APIInfo 8 | from lepo.apidef.doc import Swagger2APIDefinition 9 | from lepo.apidef.parameter.openapi import OpenAPI3BodyParameter 10 | from lepo.excs import ErroneousParameters, MissingParameter 11 | from lepo.parameter_utils import read_parameters 12 | from lepo_tests.tests.utils import DOC_VERSIONS, cast_parameter_value, get_router 13 | 14 | routers = pytest.mark.parametrize('router', [ 15 | get_router(f'{doc_version}/parameter-test.yaml') 16 | for doc_version 17 | in DOC_VERSIONS 18 | ], ids=DOC_VERSIONS) 19 | 20 | 21 | def test_parameter_validation(): 22 | with pytest.raises(ValidationError) as ei: 23 | cast_parameter_value( 24 | Swagger2APIDefinition({}), 25 | { 26 | 'type': 'array', 27 | 'collectionFormat': 'ssv', 28 | 'items': { 29 | 'type': 'string', 30 | 'maxLength': 3, 31 | }, 32 | }, 33 | 'what it do', 34 | ) 35 | assert "'what' is too long" in str(ei.value) 36 | 37 | 38 | @routers 39 | def test_files(rf, router): 40 | request = rf.post('/upload', { 41 | 'file': ContentFile(b'foo', name='foo.txt'), 42 | }) 43 | request.api_info = APIInfo(router.get_path('/upload').get_operation('post')) 44 | parameters = read_parameters(request) 45 | if OpenAPI3BodyParameter.name in parameters: # Peel into the body parameter 46 | parameters = parameters[OpenAPI3BodyParameter.name] 47 | assert isinstance(parameters['file'], UploadedFile) 48 | 49 | 50 | @routers 51 | def test_multi(rf, router): 52 | request = rf.get('/multiple-tags?tag=a&tag=b&tag=c') 53 | request.api_info = APIInfo(router.get_path('/multiple-tags').get_operation('get')) 54 | parameters = read_parameters(request) 55 | assert parameters['tag'] == ['a', 'b', 'c'] 56 | 57 | 58 | @routers 59 | def test_default(rf, router): 60 | request = rf.get('/greet?greetee=doggo') 61 | request.api_info = APIInfo(router.get_path('/greet').get_operation('get')) 62 | parameters = read_parameters(request) 63 | assert parameters == {'greeting': 'henlo', 'greetee': 'doggo'} 64 | 65 | 66 | @routers 67 | def test_required(rf, router): 68 | request = rf.get('/greet') 69 | request.api_info = APIInfo(router.get_path('/greet').get_operation('get')) 70 | with pytest.raises(ErroneousParameters) as ei: 71 | read_parameters(request) 72 | assert isinstance(ei.value.errors['greetee'], MissingParameter) 73 | 74 | 75 | @routers 76 | def test_invalid_collection_format(rf, router): 77 | request = rf.get('/invalid-collection-format?blep=foo') 78 | request.api_info = APIInfo(router.get_path('/invalid-collection-format').get_operation('get')) 79 | with pytest.raises((NotImplementedError, ImproperlyConfigured)): 80 | read_parameters(request) 81 | 82 | 83 | @routers 84 | def test_type_casting_errors(rf, router): 85 | request = rf.get('/add-numbers?a=foo&b=8') 86 | request.api_info = APIInfo(router.get_path('/add-numbers').get_operation('get')) 87 | with pytest.raises(ErroneousParameters) as ei: 88 | read_parameters(request, capture_errors=True) 89 | assert 'a' in ei.value.errors 90 | assert 'b' in ei.value.parameters 91 | 92 | 93 | @routers 94 | def test_header_parameter(rf, router): 95 | # Too bad there isn't a "requests"-like interface for testing that didn't 96 | # work by creating a `WSGIRequest` environment... Would be more truthful to test with something like that. 97 | request = rf.get('/header-parameter?blep=foo', HTTP_TOKEN='foo') 98 | request.api_info = APIInfo(router.get_path('/header-parameter').get_operation('get')) 99 | assert read_parameters(request)['token'] == 'foo' 100 | 101 | 102 | @routers 103 | def test_parameter_cascade(rf, router): 104 | request = rf.get('/cascade-parameters?a=7&b=10') 105 | request.api_info = APIInfo(router.get_path('/cascade-parameters').get_operation('get')) 106 | assert read_parameters(request) == {'a': 7, 'b': 10} 107 | request = rf.get('/cascade-parameter-override?a=yylmao') 108 | request.api_info = APIInfo(router.get_path('/cascade-parameter-override').get_operation('get')) 109 | assert read_parameters(request) == {'a': 'yylmao'} # this would fail in the typecast if override didn't work 110 | 111 | 112 | @routers 113 | def test_parameter_ref(rf, router): 114 | request = rf.get('/parameter-reference?age=86') 115 | request.api_info = APIInfo(router.get_path('/parameter-reference').get_operation('get')) 116 | assert read_parameters(request) == {'age': 86} 117 | 118 | 119 | @routers 120 | def test_parameters_ref(rf, router): 121 | # /parameters-reference refers the entire parameters object from parameter-reference, so the test is equivalent 122 | request = rf.get('/parameters-reference?age=86') 123 | request.api_info = APIInfo(router.get_path('/parameters-reference').get_operation('get')) 124 | assert read_parameters(request) == {'age': 86} 125 | -------------------------------------------------------------------------------- /lepo_tests/tests/test_pet_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import django.conf 4 | import pytest 5 | from django.utils.crypto import get_random_string 6 | 7 | from lepo.excs import ErroneousParameters, InvalidBodyContent, InvalidBodyFormat 8 | from lepo_tests.models import Pet 9 | from lepo_tests.tests.utils import get_data_from_response 10 | from lepo_tests.utils import urlconf_map 11 | 12 | try: 13 | # Django 2 14 | from django.urls import clear_url_caches, set_urlconf 15 | except: # pragma: no cover 16 | # Django 1.11 17 | from django.core.urlresolvers import clear_url_caches, set_urlconf 18 | 19 | 20 | # There's some moderate Py.test and Python magic going on here. 21 | # `urlconf_map` is a map of dynamically generated URLconf modules 22 | # that don't actually exist on disk, and this fixture ensures all 23 | # tests in this module which request the `api_urls` fixture (defined below) 24 | # actually get parametrized to include all versions in the map. 25 | 26 | 27 | def pytest_generate_tests(metafunc): 28 | if 'api_urls' in metafunc.fixturenames: 29 | module_names = [m.__name__ for m in urlconf_map.values()] 30 | metafunc.parametrize('api_urls', module_names, indirect=True) 31 | 32 | 33 | @pytest.fixture 34 | def api_urls(request): 35 | urls = request.param 36 | original_urlconf = django.conf.settings.ROOT_URLCONF 37 | django.conf.settings.ROOT_URLCONF = urls 38 | clear_url_caches() 39 | set_urlconf(None) 40 | 41 | def restore(): 42 | django.conf.settings.ROOT_URLCONF = original_urlconf 43 | clear_url_caches() 44 | set_urlconf(None) 45 | 46 | request.addfinalizer(restore) 47 | 48 | 49 | @pytest.mark.django_db 50 | def test_get_empty_list(client, api_urls): 51 | assert get_data_from_response(client.get('/api/pets')) == [] 52 | 53 | 54 | @pytest.mark.django_db 55 | def test_optional_trailing_slash(client, api_urls): 56 | assert get_data_from_response(client.get('/api/pets/')) == [] 57 | 58 | 59 | @pytest.mark.django_db 60 | @pytest.mark.parametrize('with_tag', (False, True)) 61 | def test_post_pet(client, api_urls, with_tag): 62 | payload = { 63 | 'name': get_random_string(15), 64 | } 65 | if with_tag: 66 | payload['tag'] = get_random_string(15) 67 | 68 | pet = get_data_from_response( 69 | client.post( 70 | '/api/pets', 71 | json.dumps(payload), 72 | content_type='application/json' 73 | ) 74 | ) 75 | assert pet['name'] == payload['name'] 76 | assert pet['id'] 77 | if with_tag: 78 | assert pet['tag'] == payload['tag'] 79 | 80 | # Test we can get the pet from the API now 81 | assert get_data_from_response(client.get('/api/pets')) == [pet] 82 | assert get_data_from_response(client.get(f"/api/pets/{pet['id']}")) == pet 83 | 84 | 85 | @pytest.mark.django_db 86 | def test_search_by_tag(client, api_urls): 87 | pet1 = Pet.objects.create(name='smolboye', tag='pupper') 88 | pet2 = Pet.objects.create(name='longboye', tag='doggo') 89 | assert len(get_data_from_response(client.get('/api/pets'))) == 2 90 | assert len(get_data_from_response(client.get('/api/pets', {'tags': 'pupper'}))) == 1 91 | assert len(get_data_from_response(client.get('/api/pets', {'tags': 'daggo'}))) == 0 92 | assert len(get_data_from_response(client.get('/api/pets', {'tags': 'doggo'}))) == 1 93 | assert len(get_data_from_response(client.get('/api/pets', {'tags': 'pupper,doggo'}))) == 2 94 | 95 | 96 | @pytest.mark.django_db 97 | def test_delete_pet(client, api_urls): 98 | pet1 = Pet.objects.create(name='henlo') 99 | pet2 = Pet.objects.create(name='worl') 100 | assert len(get_data_from_response(client.get('/api/pets'))) == 2 101 | client.delete(f'/api/pets/{pet1.id}') 102 | assert len(get_data_from_response(client.get('/api/pets'))) == 1 103 | 104 | 105 | @pytest.mark.django_db 106 | def test_update_pet(client, api_urls): 107 | pet1 = Pet.objects.create(name='henlo') 108 | payload = {'name': 'worl', 'tag': 'bunner'} 109 | resp = client.patch( 110 | f'/api/pets/{pet1.id}', 111 | json.dumps(payload), 112 | content_type='application/json' 113 | ) 114 | assert resp.status_code == 200 115 | 116 | pet_data = get_data_from_response(client.get('/api/pets'))[0] 117 | assert pet_data['name'] == 'worl' 118 | assert pet_data['tag'] == 'bunner' 119 | 120 | 121 | @pytest.mark.django_db 122 | def test_invalid_operation(client, api_urls): 123 | assert client.patch('/api/pets').status_code == 405 124 | 125 | 126 | @pytest.mark.django_db 127 | def test_invalid_body_format(client, api_urls): 128 | with pytest.raises(ErroneousParameters) as ei: 129 | client.post( 130 | '/api/pets', 131 | b'', 132 | content_type='application/xml' 133 | ) 134 | assert isinstance(ei.value.errors['pet'], InvalidBodyFormat) 135 | 136 | 137 | @pytest.mark.django_db 138 | def test_invalid_body_content(client, api_urls): 139 | with pytest.raises(ErroneousParameters) as ei: 140 | client.post( 141 | '/api/pets', 142 | b'{', 143 | content_type='application/json' 144 | ) 145 | assert isinstance(ei.value.errors['pet'], InvalidBodyContent) 146 | -------------------------------------------------------------------------------- /docs/banner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lepo_tests/tests/openapi3/petstore-expanded.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | servers: 3 | - url: 'http://petstore.swagger.io/api' 4 | info: 5 | version: 1.0.0 6 | title: Swagger Petstore 7 | description: >- 8 | A sample API that uses a petstore as an example to demonstrate features in 9 | the swagger-2.0 specification 10 | termsOfService: 'http://swagger.io/terms/' 11 | contact: 12 | name: Swagger API Team 13 | email: foo@example.com 14 | url: 'http://madskristensen.net' 15 | license: 16 | name: MIT 17 | url: 'http://github.com/gruntjs/grunt/blob/master/LICENSE-MIT' 18 | paths: 19 | /pets: 20 | get: 21 | description: | 22 | Returns all pets from the system that the user has access to 23 | operationId: findPets 24 | parameters: 25 | - name: tags 26 | in: query 27 | description: tags to filter by 28 | required: false 29 | style: form 30 | explode: false 31 | schema: 32 | type: array 33 | items: 34 | type: string 35 | - name: limit 36 | in: query 37 | description: maximum number of results to return 38 | required: false 39 | schema: 40 | type: integer 41 | format: int32 42 | responses: 43 | '200': 44 | description: pet response 45 | content: 46 | application/json: 47 | schema: 48 | type: array 49 | items: 50 | $ref: '#/components/schemas/Pet' 51 | default: 52 | description: unexpected error 53 | content: 54 | application/json: 55 | schema: 56 | $ref: '#/components/schemas/Error' 57 | post: 58 | description: Creates a new pet in the store. Duplicates are allowed 59 | operationId: addPet 60 | x-lepo-body-name: pet 61 | responses: 62 | '200': 63 | description: pet response 64 | content: 65 | application/json: 66 | schema: 67 | $ref: '#/components/schemas/Pet' 68 | default: 69 | description: unexpected error 70 | content: 71 | application/json: 72 | schema: 73 | $ref: '#/components/schemas/Error' 74 | requestBody: 75 | content: 76 | application/json: 77 | schema: 78 | $ref: '#/components/schemas/NewPet' 79 | description: Pet to add to the store 80 | required: true 81 | '/pets/{id}': 82 | get: 83 | description: >- 84 | Returns a user based on a single ID, if the user does not have access to 85 | the pet 86 | operationId: find pet by id 87 | parameters: 88 | - name: id 89 | in: path 90 | description: ID of pet to fetch 91 | required: true 92 | schema: 93 | type: integer 94 | format: int64 95 | responses: 96 | '200': 97 | description: pet response 98 | content: 99 | application/json: 100 | schema: 101 | $ref: '#/components/schemas/Pet' 102 | default: 103 | description: unexpected error 104 | content: 105 | application/json: 106 | schema: 107 | $ref: '#/components/schemas/Error' 108 | delete: 109 | description: deletes a single pet based on the ID supplied 110 | operationId: deletePet 111 | parameters: 112 | - name: id 113 | in: path 114 | description: ID of pet to delete 115 | required: true 116 | schema: 117 | type: integer 118 | format: int64 119 | responses: 120 | '204': 121 | description: pet deleted 122 | default: 123 | description: unexpected error 124 | content: 125 | application/json: 126 | schema: 127 | $ref: '#/components/schemas/Error' 128 | patch: 129 | description: updates a pet by ID 130 | operationId: updatePet 131 | x-lepo-body-name: pet 132 | parameters: 133 | - name: id 134 | in: path 135 | description: ID of pet to update 136 | required: true 137 | schema: 138 | type: integer 139 | format: int64 140 | responses: 141 | '200': 142 | description: pet updated 143 | default: 144 | description: unexpected error 145 | content: 146 | application/json: 147 | schema: 148 | $ref: '#/components/schemas/Error' 149 | requestBody: 150 | content: 151 | application/json: 152 | schema: 153 | $ref: '#/components/schemas/NewPet' 154 | description: Pet data to update 155 | required: true 156 | components: 157 | schemas: 158 | Pet: 159 | allOf: 160 | - $ref: '#/components/schemas/NewPet' 161 | - required: 162 | - id 163 | properties: 164 | id: 165 | type: integer 166 | format: int64 167 | NewPet: 168 | required: 169 | - name 170 | properties: 171 | name: 172 | type: string 173 | tag: 174 | type: string 175 | Error: 176 | required: 177 | - code 178 | - message 179 | properties: 180 | code: 181 | type: integer 182 | format: int32 183 | message: 184 | type: string 185 | -------------------------------------------------------------------------------- /lepo/router.py: -------------------------------------------------------------------------------- 1 | try: 2 | from collections.abc import Iterable 3 | except ImportError: 4 | from collections import Iterable 5 | 6 | from functools import reduce 7 | from importlib import import_module 8 | from inspect import isfunction, ismethod 9 | 10 | from django.http import HttpResponse 11 | from django.urls import re_path 12 | 13 | from lepo.apidef.doc import APIDefinition 14 | from lepo.excs import MissingHandler 15 | from lepo.utils import snake_case 16 | 17 | 18 | def root_view(request): 19 | return HttpResponse('API root') 20 | 21 | 22 | class Router: 23 | 24 | def __init__(self, api): 25 | """ 26 | Instantiate a new Lepo router. 27 | 28 | :param api: An APIDefinition object 29 | :type api: APIDefinition 30 | """ 31 | assert isinstance(api, APIDefinition) 32 | self.api = api 33 | self.handlers = {} 34 | 35 | @classmethod 36 | def from_file(cls, filename): 37 | """ 38 | Construct a Router by parsing the given `filename`. 39 | 40 | If PyYAML is installed, YAML files are supported. 41 | JSON files are always supported. 42 | 43 | :param filename: The filename to read. 44 | :rtype: Router 45 | """ 46 | return cls(api=APIDefinition.from_file(filename)) 47 | 48 | def get_path(self, path): 49 | return self.api.get_path(path) 50 | 51 | def get_paths(self): 52 | for path in self.api.get_path_names(): 53 | yield self.get_path(path) 54 | 55 | def get_path_view_class(self, path): 56 | return self.get_path(path).get_view_class(router=self) 57 | 58 | def get_urls( 59 | self, 60 | root_view_name=None, 61 | optional_trailing_slash=False, 62 | decorate=(), 63 | name_template='{name}', 64 | ): 65 | """ 66 | Get the router's URLs, ready to be installed in `urlpatterns` (directly or via `include`). 67 | 68 | :param root_view_name: The optional url name for an API root view. 69 | This may be useful for projects that do not explicitly know where the 70 | router is mounted; those projects can then use `reverse('api:root')`, 71 | for instance, if they need to construct URLs based on the API's root URL. 72 | :type root_view_name: str|None 73 | 74 | :param optional_trailing_slash: Whether to fix up the regexen for the router to make any trailing 75 | slashes optional. 76 | :type optional_trailing_slash: bool 77 | 78 | :param decorate: A function to decorate view functions with, or an iterable of such decorators. 79 | Use `(lepo.decorators.csrf_exempt,)` to mark all API views as CSRF exempt. 80 | :type decorate: function|Iterable[function] 81 | 82 | :param name_template: A `.format()` template for view naming. 83 | :type name_template: str 84 | 85 | :return: List of URL tuples. 86 | :rtype: list[tuple] 87 | """ 88 | if isinstance(decorate, Iterable): 89 | decorators = decorate 90 | 91 | def decorate(view): 92 | return reduce(lambda view, decorator: decorator(view), decorators, view) 93 | 94 | urls = [] 95 | for path in self.api.get_paths(): 96 | regex = path.regex 97 | if optional_trailing_slash: 98 | regex = regex.rstrip('$') 99 | if not regex.endswith('/'): 100 | regex += '/' 101 | regex += '?$' 102 | view = decorate(path.get_view_class(router=self).as_view()) 103 | urls.append(re_path(regex, view, name=name_template.format(name=path.name))) 104 | 105 | if root_view_name: 106 | urls.append(re_path(r'^$', root_view, name=name_template.format(name=root_view_name))) 107 | return urls 108 | 109 | def get_handler(self, operation_id): 110 | """ 111 | Get the handler function for a given operation. 112 | 113 | To remain Pythonic, both the original and the snake_cased version of the operation ID are 114 | supported. 115 | 116 | This could be overridden in a subclass. 117 | 118 | :param operation_id: Operation ID. 119 | :return: Handler function 120 | :rtype: function 121 | """ 122 | handler = ( 123 | self.handlers.get(operation_id) 124 | or self.handlers.get(snake_case(operation_id)) 125 | ) 126 | if handler: 127 | return handler 128 | raise MissingHandler( 129 | f'Missing handler for operation {operation_id} (tried {snake_case(operation_id)} too)' 130 | ) 131 | 132 | def add_handlers(self, namespace): 133 | """ 134 | Add handler functions from the given `namespace`, for instance a module. 135 | 136 | The namespace may be a string, in which case it is expected to be a name of a module. 137 | It may also be a dictionary mapping names to functions. 138 | 139 | Only non-underscore-prefixed functions and methods are imported. 140 | 141 | :param namespace: Namespace object. 142 | :type namespace: str|module|dict[str, function] 143 | """ 144 | if isinstance(namespace, str): 145 | namespace = import_module(namespace) 146 | 147 | if isinstance(namespace, dict): 148 | namespace = namespace.items() 149 | else: 150 | namespace = vars(namespace).items() 151 | 152 | for name, value in namespace: 153 | if name.startswith('_'): 154 | continue 155 | if isfunction(value) or ismethod(value): 156 | self.handlers[name] = value 157 | -------------------------------------------------------------------------------- /branding/small_bear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /branding/sleepy_polar_bear_with_text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /lepo/apidef/parameter/openapi.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from django.utils.functional import cached_property 4 | 5 | from lepo.apidef.parameter.base import NO_VALUE, BaseParameter, BaseTopParameter 6 | from lepo.apidef.parameter.utils import ( 7 | comma_split, 8 | dot_split, 9 | pipe_split, 10 | read_body, 11 | space_split, 12 | validate_schema, 13 | ) 14 | from lepo.decoders import get_decoder 15 | from lepo.excs import ( 16 | InvalidBodyFormat, 17 | InvalidComplexContent, 18 | InvalidParameterDefinition, 19 | ) 20 | from lepo.parameter_utils import cast_primitive_value 21 | from lepo.utils import get_content_type_specificity, match_content_type, maybe_resolve 22 | 23 | 24 | class WrappedValue: 25 | def __init__(self, value, parameter): 26 | self.value = value 27 | self.parameter = parameter 28 | 29 | 30 | class OpenAPI3Schema: 31 | def __init__(self, data): 32 | self.data = data 33 | 34 | @property 35 | def type(self): 36 | return self.data.get('type') 37 | 38 | @property 39 | def format(self): 40 | return self.data.get('format') 41 | 42 | @property 43 | def items(self): 44 | return self.data['items'] 45 | 46 | @property 47 | def has_default(self): 48 | return 'default' in self.data 49 | 50 | @property 51 | def default(self): 52 | return self.data.get('default') 53 | 54 | def cast(self, api, value): 55 | value = cast_primitive_value(self.type, self.format, value) 56 | value = validate_schema(self.data, api, value) 57 | return value 58 | 59 | 60 | class OpenAPI3BaseParameter(BaseParameter): 61 | @cached_property 62 | def schema(self): 63 | schema = self.data['schema'] 64 | schema = maybe_resolve(schema, resolve=(self.api.resolve_reference if self.api else None)) 65 | return OpenAPI3Schema(schema) 66 | 67 | @property 68 | def has_schema(self): 69 | return ('schema' in self.data) 70 | 71 | @property 72 | def type(self): 73 | return self.schema.type 74 | 75 | @property 76 | def has_default(self): 77 | return (self.has_schema and self.schema.has_default) 78 | 79 | @property 80 | def default(self): 81 | return self.schema.default 82 | 83 | def cast_array(self, api, value): 84 | assert isinstance(value, list) 85 | item_schema = OpenAPI3Schema(self.schema.items) 86 | return [item_schema.cast(api, item) for item in value] 87 | 88 | def cast(self, api, value): 89 | if self.type == 'array': 90 | value = self.cast_array(api, value) 91 | 92 | return self.schema.cast(api, value) 93 | 94 | 95 | class OpenAPI3Parameter(OpenAPI3BaseParameter, BaseTopParameter): 96 | location_to_style_and_explode = { 97 | 'query': ('form', True), 98 | 'path': ('simple', False), 99 | 'header': ('simple', False), 100 | 'cookie': ('form', True), 101 | } 102 | 103 | @cached_property 104 | def media_map(self): 105 | return OpenAPI3MediaMap(api=self.api, mapping_data=self.data['content']) 106 | 107 | def get_style_and_explode(self): 108 | explicit_style = self.data.get('style') 109 | explicit_explode = self.data.get('explode') 110 | default_style, default_explode = self.location_to_style_and_explode[self.location] 111 | style = (explicit_style if explicit_style is not None else default_style) 112 | explode = (explicit_explode if explicit_explode is not None else default_explode) 113 | return (style, explode) 114 | 115 | def get_value(self, request, view_kwargs): # noqa: C901 116 | if self.location == 'body': # pragma: no cover 117 | raise NotImplementedError('Should never get here, this is covered by OpenAPI3BodyParameter') 118 | 119 | if self.has_schema: 120 | (style, explode) = self.get_style_and_explode() 121 | type = self.schema.type 122 | splitter = comma_split 123 | complex = False 124 | else: # Complex object... 125 | style = 'simple' 126 | splitter = None 127 | explode = False 128 | type = None 129 | complex = True 130 | 131 | if style == 'deepObject': # pragma: no cover 132 | raise NotImplementedError('deepObjects are not supported at present. PRs welcome.') 133 | 134 | if self.location == 'query': 135 | if type == 'array' and style == 'form' and explode: 136 | return request.GET.getlist(self.name, NO_VALUE) 137 | if self.name not in request.GET: 138 | return NO_VALUE 139 | value = request.GET[self.name] 140 | splitter = { 141 | 'form': comma_split, 142 | 'spaceDelimited': space_split, 143 | 'pipeDelimited': pipe_split, 144 | }.get(style) 145 | 146 | elif self.location == 'header': 147 | if style != 'simple': # pragma: no cover 148 | raise InvalidParameterDefinition('Header parameters always use the simple style, says the spec') 149 | meta_key = f"HTTP_{self.name.upper().replace('-', '_')}" 150 | if meta_key not in request.META: 151 | return NO_VALUE 152 | value = request.META[meta_key] 153 | elif self.location == 'cookie': 154 | if style != 'form': # pragma: no cover 155 | raise InvalidParameterDefinition('Cookie parameters always use the form style, says the spec') 156 | if self.name not in request.COOKIES: 157 | return NO_VALUE 158 | value = request.COOKIES[self.name] 159 | elif self.location == 'path': 160 | if self.name not in view_kwargs: 161 | return NO_VALUE 162 | value = view_kwargs[self.name] 163 | if style == 'simple': 164 | pass 165 | elif style == 'label': 166 | value = value.lstrip('.') 167 | splitter = (dot_split if explode else comma_split) 168 | elif style == 'matrix': # pragma: no cover 169 | raise NotImplementedError('...') # TODO: Implement me 170 | else: # pragma: no cover 171 | return super().get_value(request, view_kwargs) 172 | 173 | if complex: 174 | # We've gotten the raw value from wherever it was stored, 175 | # so let's do content negotiation (guessing in this case) 176 | # and hopefully return a value we can cast and validate. 177 | return self._parse_complex(value) 178 | 179 | if type in ('array', 'object'): 180 | if not splitter: 181 | raise InvalidParameterDefinition( 182 | f'The location/style/explode combination ' 183 | f'{self.location}/{style}/{explode} is not supported' 184 | ) 185 | value = splitter(value) 186 | if type == 'object': 187 | value = OrderedDict(zip(value[::2], value[1::2])) 188 | 189 | return value 190 | 191 | def _parse_complex(self, value): 192 | errors = {} 193 | for content_type, param_obj in self.media_map.items(): 194 | decoder = get_decoder(content_type) 195 | if decoder: 196 | try: 197 | # This is spectacularly ugly, but we don't really have any other way 198 | # of passing the new parameter type up, for the `.cast()` call... 199 | return WrappedValue(value=decoder(value), parameter=param_obj) 200 | except Exception as exc: 201 | errors[content_type] = exc 202 | raise InvalidComplexContent( 203 | f'No decoder could handle the value {value!r}: {errors}', 204 | errors, 205 | ) 206 | 207 | def cast(self, api, value): 208 | if isinstance(value, WrappedValue): 209 | # In case of bodies or complex parameters, the parameter type 210 | # might have been "negotiated" within `get_value`, so unpeel. 211 | value, parameter = value.value, value.parameter 212 | else: 213 | parameter = super() 214 | 215 | return parameter.cast(api, value) 216 | 217 | 218 | class OpenAPI3BodyParameter(OpenAPI3Parameter): 219 | """ 220 | OpenAPI 3 Body Parameter 221 | """ 222 | 223 | name = '_body' 224 | 225 | @property 226 | def location(self): 227 | return 'body' 228 | 229 | def get_value(self, request, view_kwargs): 230 | media_map = self.media_map 231 | content_type_name = media_map.match(request.content_type) 232 | if not content_type_name: 233 | raise InvalidBodyFormat(f'Content-type {request.content_type} is not supported ({media_map.keys()!r} are)') 234 | parameter = media_map[content_type_name] 235 | value = read_body(request, parameter=parameter) 236 | return WrappedValue(parameter=parameter, value=value) 237 | 238 | 239 | class OpenAPI3MediaMap(OrderedDict): 240 | def __init__(self, api, mapping_data): 241 | self.api = api 242 | self.mapping_data = mapping_data 243 | selector_to_param = [ 244 | (selector, OpenAPI3BaseParameter(content_description, api=self.api)) 245 | for (selector, content_description) 246 | in self.mapping_data.items() 247 | ] 248 | super().__init__( 249 | sorted(selector_to_param, key=lambda kv: get_content_type_specificity(kv[0])) 250 | ) 251 | 252 | def match(self, content_type): 253 | return match_content_type(content_type, self) 254 | -------------------------------------------------------------------------------- /lepo_doc/static/lepo_doc/swagger-ui/swagger-ui.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";.swagger-ui html{box-sizing:border-box}.swagger-ui *,.swagger-ui :after,.swagger-ui :before{box-sizing:inherit}.swagger-ui body{margin:0;background:#fafafa}.swagger-ui .wrapper{width:100%;max-width:1460px;margin:0 auto;padding:0 20px}.swagger-ui .opblock-tag-section{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.swagger-ui .opblock-tag{display:-webkit-box;display:-ms-flexbox;display:flex;padding:10px 20px 10px 10px;cursor:pointer;transition:all .2s;border-bottom:1px solid rgba(59,65,81,.3);-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui .opblock-tag:hover{background:rgba(0,0,0,.02)}.swagger-ui .opblock-tag{font-size:24px;margin:0 0 5px;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .opblock-tag.no-desc span{-webkit-box-flex:1;-ms-flex:1;flex:1}.swagger-ui .opblock-tag svg{transition:all .4s}.swagger-ui .opblock-tag small{font-size:14px;font-weight:400;padding:0 10px;-webkit-box-flex:1;-ms-flex:1;flex:1;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .parаmeter__type{font-size:12px;padding:5px 0;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui .view-line-link{position:relative;top:3px;width:20px;margin:0 5px;cursor:pointer;transition:all .5s}.swagger-ui .opblock{margin:0 0 15px;border:1px solid #000;border-radius:4px;box-shadow:0 0 3px rgba(0,0,0,.19)}.swagger-ui .opblock.is-open .opblock-summary{border-bottom:1px solid #000}.swagger-ui .opblock .opblock-section-header{padding:8px 20px;background:hsla(0,0%,100%,.8);box-shadow:0 1px 2px rgba(0,0,0,.1)}.swagger-ui .opblock .opblock-section-header,.swagger-ui .opblock .opblock-section-header label{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui .opblock .opblock-section-header label{font-size:12px;font-weight:700;margin:0;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .opblock .opblock-section-header label span{padding:0 10px 0 0}.swagger-ui .opblock .opblock-section-header h4{font-size:14px;margin:0;-webkit-box-flex:1;-ms-flex:1;flex:1;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .opblock .opblock-summary-method{font-size:14px;font-weight:700;min-width:80px;padding:6px 15px;text-align:center;border-radius:3px;background:#000;text-shadow:0 1px 0 rgba(0,0,0,.1);font-family:Titillium Web,sans-serif;color:#fff}.swagger-ui .opblock .opblock-summary-path,.swagger-ui .opblock .opblock-summary-path__deprecated{font-size:16px;display:-webkit-box;display:-ms-flexbox;display:flex;padding:0 10px;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui .opblock .opblock-summary-path .view-line-link,.swagger-ui .opblock .opblock-summary-path__deprecated .view-line-link{position:relative;top:2px;width:0;margin:0;cursor:pointer;transition:all .5s}.swagger-ui .opblock .opblock-summary-path:hover .view-line-link,.swagger-ui .opblock .opblock-summary-path__deprecated:hover .view-line-link{width:18px;margin:0 5px}.swagger-ui .opblock .opblock-summary-path__deprecated{text-decoration:line-through}.swagger-ui .opblock .opblock-summary-description{font-size:13px;-webkit-box-flex:1;-ms-flex:1;flex:1;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .opblock .opblock-summary{display:-webkit-box;display:-ms-flexbox;display:flex;padding:5px;cursor:pointer;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui .opblock.opblock-post{border-color:#49cc90;background:rgba(73,204,144,.1)}.swagger-ui .opblock.opblock-post .opblock-summary-method{background:#49cc90}.swagger-ui .opblock.opblock-post .opblock-summary{border-color:#49cc90}.swagger-ui .opblock.opblock-put{border-color:#fca130;background:rgba(252,161,48,.1)}.swagger-ui .opblock.opblock-put .opblock-summary-method{background:#fca130}.swagger-ui .opblock.opblock-put .opblock-summary{border-color:#fca130}.swagger-ui .opblock.opblock-delete{border-color:#f93e3e;background:rgba(249,62,62,.1)}.swagger-ui .opblock.opblock-delete .opblock-summary-method{background:#f93e3e}.swagger-ui .opblock.opblock-delete .opblock-summary{border-color:#f93e3e}.swagger-ui .opblock.opblock-get{border-color:#61affe;background:rgba(97,175,254,.1)}.swagger-ui .opblock.opblock-get .opblock-summary-method{background:#61affe}.swagger-ui .opblock.opblock-get .opblock-summary{border-color:#61affe}.swagger-ui .opblock.opblock-patch{border-color:#50e3c2;background:rgba(80,227,194,.1)}.swagger-ui .opblock.opblock-patch .opblock-summary-method{background:#50e3c2}.swagger-ui .opblock.opblock-patch .opblock-summary{border-color:#50e3c2}.swagger-ui .opblock.opblock-head{border-color:#9012fe;background:rgba(144,18,254,.1)}.swagger-ui .opblock.opblock-head .opblock-summary-method{background:#9012fe}.swagger-ui .opblock.opblock-head .opblock-summary{border-color:#9012fe}.swagger-ui .opblock.opblock-options{border-color:#0d5aa7;background:rgba(13,90,167,.1)}.swagger-ui .opblock.opblock-options .opblock-summary-method{background:#0d5aa7}.swagger-ui .opblock.opblock-options .opblock-summary{border-color:#0d5aa7}.swagger-ui .opblock.opblock-deprecated{opacity:.6;border-color:#ebebeb;background:hsla(0,0%,92%,.1)}.swagger-ui .opblock.opblock-deprecated .opblock-summary-method{background:#ebebeb}.swagger-ui .opblock.opblock-deprecated .opblock-summary{border-color:#ebebeb}.swagger-ui .opblock .opblock-schemes{padding:8px 20px}.swagger-ui .opblock .opblock-schemes .schemes-title{padding:0 10px 0 0}.swagger-ui .tab{display:-webkit-box;display:-ms-flexbox;display:flex;margin:20px 0 10px;padding:0;list-style:none}.swagger-ui .tab li{font-size:12px;min-width:100px;min-width:90px;padding:0;cursor:pointer;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .tab li:first-of-type{position:relative;padding-left:0}.swagger-ui .tab li:first-of-type:after{position:absolute;top:0;right:6px;width:1px;height:100%;content:"";background:rgba(0,0,0,.2)}.swagger-ui .tab li.active{font-weight:700}.swagger-ui .opblock-description-wrapper,.swagger-ui .opblock-title_normal{padding:15px 20px}.swagger-ui .opblock-description-wrapper,.swagger-ui .opblock-description-wrapper h4,.swagger-ui .opblock-title_normal,.swagger-ui .opblock-title_normal h4{font-size:12px;margin:0 0 5px;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .opblock-description-wrapper p,.swagger-ui .opblock-title_normal p{font-size:14px;margin:0;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .execute-wrapper{padding:20px;text-align:right}.swagger-ui .execute-wrapper .btn{width:100%;padding:8px 40px}.swagger-ui .body-param-options{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.swagger-ui .body-param-options .body-param-edit{padding:10px 0}.swagger-ui .body-param-options label{padding:8px 0}.swagger-ui .body-param-options label select{margin:3px 0 0}.swagger-ui .responses-inner{padding:20px}.swagger-ui .responses-inner h4,.swagger-ui .responses-inner h5{font-size:12px;margin:10px 0 5px;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .response-col_status{font-size:14px;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .response-col_status .response-undocumented{font-size:11px;font-family:Source Code Pro,monospace;font-weight:600;color:#999}.swagger-ui .response-col_description__inner span{font-size:12px;font-style:italic;display:block;margin:10px 0;padding:10px;border-radius:4px;background:#41444e;font-family:Source Code Pro,monospace;font-weight:600;color:#fff}.swagger-ui .response-col_description__inner span p{margin:0}.swagger-ui .opblock-body pre{font-size:12px;margin:0;padding:10px;white-space:pre-wrap;border-radius:4px;background:#41444e;font-family:Source Code Pro,monospace;font-weight:600;color:#fff}.swagger-ui .opblock-body pre span{color:#fff!important}.swagger-ui .opblock-body pre .headerline{display:block}.swagger-ui .scheme-container{margin:0 0 20px;padding:30px 0;background:#fff;box-shadow:0 1px 2px 0 rgba(0,0,0,.15)}.swagger-ui .scheme-container .schemes{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui .scheme-container .schemes>label{font-size:12px;font-weight:700;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin:-20px 15px 0 0;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .scheme-container .schemes>label select{min-width:130px;text-transform:uppercase}.swagger-ui .loading-container{padding:40px 0 60px}.swagger-ui .loading-container .loading{position:relative}.swagger-ui .loading-container .loading:after{font-size:10px;font-weight:700;position:absolute;top:50%;left:50%;content:"loading";-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);text-transform:uppercase;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .loading-container .loading:before{position:absolute;top:50%;left:50%;display:block;width:60px;height:60px;margin:-30px;content:"";-webkit-animation:rotation 1s infinite linear,opacity .5s;animation:rotation 1s infinite linear,opacity .5s;opacity:1;border:2px solid rgba(85,85,85,.1);border-top-color:rgba(0,0,0,.6);border-radius:100%;-webkit-backface-visibility:hidden;backface-visibility:hidden}@-webkit-keyframes rotation{to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes rotation{to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@-webkit-keyframes blinker{50%{opacity:0}}@keyframes blinker{50%{opacity:0}}.swagger-ui .btn{font-size:14px;font-weight:700;padding:5px 23px;transition:all .3s;border:2px solid #888;border-radius:4px;background:transparent;box-shadow:0 1px 2px rgba(0,0,0,.1);font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .btn[disabled]{cursor:not-allowed;opacity:.3}.swagger-ui .btn:hover{box-shadow:0 0 5px rgba(0,0,0,.3)}.swagger-ui .btn.cancel{border-color:#ff6060;font-family:Titillium Web,sans-serif;color:#ff6060}.swagger-ui .btn.authorize{line-height:1;display:inline;color:#49cc90;border-color:#49cc90}.swagger-ui .btn.authorize span{float:left;padding:4px 20px 0 0}.swagger-ui .btn.authorize svg{fill:#49cc90}.swagger-ui .btn.execute{-webkit-animation:pulse 2s infinite;animation:pulse 2s infinite;color:#fff;border-color:#4990e2}@-webkit-keyframes pulse{0%{color:#fff;background:#4990e2;box-shadow:0 0 0 0 rgba(73,144,226,.8)}70%{box-shadow:0 0 0 5px rgba(73,144,226,0)}to{color:#fff;background:#4990e2;box-shadow:0 0 0 0 rgba(73,144,226,0)}}@keyframes pulse{0%{color:#fff;background:#4990e2;box-shadow:0 0 0 0 rgba(73,144,226,.8)}70%{box-shadow:0 0 0 5px rgba(73,144,226,0)}to{color:#fff;background:#4990e2;box-shadow:0 0 0 0 rgba(73,144,226,0)}}.swagger-ui .btn-group{display:-webkit-box;display:-ms-flexbox;display:flex;padding:30px}.swagger-ui .btn-group .btn{-webkit-box-flex:1;-ms-flex:1;flex:1}.swagger-ui .btn-group .btn:first-child{border-radius:4px 0 0 4px}.swagger-ui .btn-group .btn:last-child{border-radius:0 4px 4px 0}.swagger-ui .authorization__btn{padding:0 10px;border:none;background:none}.swagger-ui .authorization__btn.locked{opacity:1}.swagger-ui .authorization__btn.unlocked{opacity:.4}.swagger-ui .expand-methods,.swagger-ui .expand-operation{border:none;background:none}.swagger-ui .expand-methods svg,.swagger-ui .expand-operation svg{width:20px;height:20px}.swagger-ui .expand-methods{padding:0 10px}.swagger-ui .expand-methods:hover svg{fill:#444}.swagger-ui .expand-methods svg{transition:all .3s;fill:#777}.swagger-ui button{cursor:pointer;outline:none}.swagger-ui select{font-size:14px;font-weight:700;padding:5px 40px 5px 10px;border:2px solid #41444e;border-radius:4px;background:#f7f7f7 url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+ICAgIDxwYXRoIGQ9Ik0xMy40MTggNy44NTljLjI3MS0uMjY4LjcwOS0uMjY4Ljk3OCAwIC4yNy4yNjguMjcyLjcwMSAwIC45NjlsLTMuOTA4IDMuODNjLS4yNy4yNjgtLjcwNy4yNjgtLjk3OSAwbC0zLjkwOC0zLjgzYy0uMjctLjI2Ny0uMjctLjcwMSAwLS45NjkuMjcxLS4yNjguNzA5LS4yNjguOTc4IDBMMTAgMTFsMy40MTgtMy4xNDF6Ii8+PC9zdmc+) right 10px center no-repeat;background-size:20px;box-shadow:0 1px 2px 0 rgba(0,0,0,.25);font-family:Titillium Web,sans-serif;color:#3b4151;-webkit-appearance:none;-moz-appearance:none;appearance:none}.swagger-ui select[multiple]{margin:5px 0;padding:5px;background:#f7f7f7}.swagger-ui .opblock-body select{min-width:230px}.swagger-ui label{font-size:12px;font-weight:700;margin:0 0 5px;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui input[type=email],.swagger-ui input[type=password],.swagger-ui input[type=search],.swagger-ui input[type=text]{min-width:100px;margin:5px 0;padding:8px 10px;border:1px solid #d9d9d9;border-radius:4px;background:#fff}.swagger-ui input[type=email].invalid,.swagger-ui input[type=password].invalid,.swagger-ui input[type=search].invalid,.swagger-ui input[type=text].invalid{-webkit-animation:shake .4s 1;animation:shake .4s 1;border-color:#f93e3e;background:#feebeb}@-webkit-keyframes shake{10%,90%{-webkit-transform:translate3d(-1px,0,0);transform:translate3d(-1px,0,0)}20%,80%{-webkit-transform:translate3d(2px,0,0);transform:translate3d(2px,0,0)}30%,50%,70%{-webkit-transform:translate3d(-4px,0,0);transform:translate3d(-4px,0,0)}40%,60%{-webkit-transform:translate3d(4px,0,0);transform:translate3d(4px,0,0)}}@keyframes shake{10%,90%{-webkit-transform:translate3d(-1px,0,0);transform:translate3d(-1px,0,0)}20%,80%{-webkit-transform:translate3d(2px,0,0);transform:translate3d(2px,0,0)}30%,50%,70%{-webkit-transform:translate3d(-4px,0,0);transform:translate3d(-4px,0,0)}40%,60%{-webkit-transform:translate3d(4px,0,0);transform:translate3d(4px,0,0)}}.swagger-ui textarea{font-size:12px;width:100%;min-height:280px;padding:10px;border:none;border-radius:4px;outline:none;background:hsla(0,0%,100%,.8);font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui textarea:focus{border:2px solid #61affe}.swagger-ui textarea.curl{font-size:12px;min-height:100px;margin:0;padding:10px;resize:none;border-radius:4px;background:#41444e;font-family:Source Code Pro,monospace;font-weight:600;color:#fff}.swagger-ui .checkbox{padding:5px 0 10px;transition:opacity .5s;color:#333}.swagger-ui .checkbox label{display:-webkit-box;display:-ms-flexbox;display:flex}.swagger-ui .checkbox p{font-weight:400!important;font-style:italic;margin:0!important;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui .checkbox input[type=checkbox]{display:none}.swagger-ui .checkbox input[type=checkbox]+label>.item{position:relative;top:3px;display:inline-block;width:16px;height:16px;margin:0 8px 0 0;padding:5px;cursor:pointer;border-radius:1px;background:#e8e8e8;box-shadow:0 0 0 2px #e8e8e8;-webkit-box-flex:0;-ms-flex:none;flex:none}.swagger-ui .checkbox input[type=checkbox]+label>.item:active{-webkit-transform:scale(.9);transform:scale(.9)}.swagger-ui .checkbox input[type=checkbox]:checked+label>.item{background:#e8e8e8 url("data:image/svg+xml;charset=utf-8,%3Csvg width='10' height='8' viewBox='3 7 10 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%2341474E' fill-rule='evenodd' d='M6.333 15L3 11.667l1.333-1.334 2 2L11.667 7 13 8.333z'/%3E%3C/svg%3E") 50% no-repeat}.swagger-ui .dialog-ux{position:fixed;z-index:9999;top:0;right:0;bottom:0;left:0}.swagger-ui .dialog-ux .backdrop-ux{position:fixed;top:0;right:0;bottom:0;left:0;background:rgba(0,0,0,.8)}.swagger-ui .dialog-ux .modal-ux{position:absolute;z-index:9999;top:50%;left:50%;width:100%;min-width:300px;max-width:650px;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);border:1px solid #ebebeb;border-radius:4px;background:#fff;box-shadow:0 10px 30px 0 rgba(0,0,0,.2)}.swagger-ui .dialog-ux .modal-ux-content{overflow-y:auto;max-height:540px;padding:20px}.swagger-ui .dialog-ux .modal-ux-content p{font-size:12px;margin:0 0 5px;color:#41444e;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .dialog-ux .modal-ux-content h4{font-size:18px;font-weight:600;margin:15px 0 0;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .dialog-ux .modal-ux-header{display:-webkit-box;display:-ms-flexbox;display:flex;padding:12px 0;border-bottom:1px solid #ebebeb;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui .dialog-ux .modal-ux-header .close-modal{padding:0 10px;border:none;background:none;-webkit-appearance:none;-moz-appearance:none;appearance:none}.swagger-ui .dialog-ux .modal-ux-header h3{font-size:20px;font-weight:600;margin:0;padding:0 20px;-webkit-box-flex:1;-ms-flex:1;flex:1;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .model{font-size:12px;font-weight:300;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui .model-toggle{font-size:10px;position:relative;top:6px;display:inline-block;margin:auto .3em;cursor:pointer;transition:-webkit-transform .15s ease-in;transition:transform .15s ease-in;transition:transform .15s ease-in,-webkit-transform .15s ease-in;-webkit-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:50% 50%;transform-origin:50% 50%}.swagger-ui .model-toggle.collapsed{-webkit-transform:rotate(0deg);transform:rotate(0deg)}.swagger-ui .model-toggle:after{display:block;width:20px;height:20px;content:"";background:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z'/%3E%3C/svg%3E") 50% no-repeat;background-size:100%}.swagger-ui .model-jump-to-path{position:relative;cursor:pointer}.swagger-ui .model-jump-to-path .view-line-link{position:absolute;top:-.4em;cursor:pointer}.swagger-ui .model-title{position:relative}.swagger-ui .model-title:hover .model-hint{visibility:visible}.swagger-ui .model-hint{position:absolute;top:-1.8em;visibility:hidden;padding:.1em .5em;white-space:nowrap;color:#ebebeb;border-radius:4px;background:rgba(0,0,0,.7)}.swagger-ui section.models{margin:30px 0;border:1px solid rgba(59,65,81,.3);border-radius:4px}.swagger-ui section.models.is-open{padding:0 0 20px}.swagger-ui section.models.is-open h4{margin:0 0 5px;border-bottom:1px solid rgba(59,65,81,.3)}.swagger-ui section.models.is-open h4 svg{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.swagger-ui section.models h4{font-size:16px;display:-webkit-box;display:-ms-flexbox;display:flex;margin:0;padding:10px 20px 10px 10px;cursor:pointer;transition:all .2s;font-family:Titillium Web,sans-serif;color:#777;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui section.models h4 svg{transition:all .4s}.swagger-ui section.models h4 span{-webkit-box-flex:1;-ms-flex:1;flex:1}.swagger-ui section.models h4:hover{background:rgba(0,0,0,.02)}.swagger-ui section.models h5{font-size:16px;margin:0 0 10px;font-family:Titillium Web,sans-serif;color:#777}.swagger-ui section.models .model-jump-to-path{position:relative;top:5px}.swagger-ui section.models .model-container{margin:0 20px 15px;transition:all .5s;border-radius:4px;background:rgba(0,0,0,.05)}.swagger-ui section.models .model-container:hover{background:rgba(0,0,0,.07)}.swagger-ui section.models .model-container:first-of-type{margin:20px}.swagger-ui section.models .model-container:last-of-type{margin:0 20px}.swagger-ui section.models .model-box{background:none}.swagger-ui .model-box{padding:10px;border-radius:4px;background:rgba(0,0,0,.1)}.swagger-ui .model-box .model-jump-to-path{position:relative;top:4px}.swagger-ui .model-title{font-size:16px;font-family:Titillium Web,sans-serif;color:#555}.swagger-ui span>span.model,.swagger-ui span>span.model .brace-close{padding:0 0 0 10px}.swagger-ui .prop-type{color:#55a}.swagger-ui .prop-enum{display:block}.swagger-ui .prop-format{color:#999}.swagger-ui table{width:100%;padding:0 10px;border-collapse:collapse}.swagger-ui table.model tbody tr td{padding:0;vertical-align:top}.swagger-ui table.model tbody tr td:first-of-type{width:100px;padding:0}.swagger-ui table.headers td{font-size:12px;font-weight:300;vertical-align:middle;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui table tbody tr td{padding:10px 0 0;vertical-align:top}.swagger-ui table tbody tr td:first-of-type{width:20%;padding:10px 0}.swagger-ui table thead tr td,.swagger-ui table thead tr th{font-size:12px;font-weight:700;padding:12px 0;text-align:left;border-bottom:1px solid rgba(59,65,81,.2);font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .parameters-col_description p{font-size:14px;margin:0;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .parameters-col_description input[type=text]{width:100%;max-width:340px}.swagger-ui .parameter__name{font-size:16px;font-weight:400;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .parameter__name.required{font-weight:700}.swagger-ui .parameter__name.required:after{font-size:10px;position:relative;top:-6px;padding:5px;content:"required";color:rgba(255,0,0,.6)}.swagger-ui .parameter__in{font-size:12px;font-style:italic;font-family:Source Code Pro,monospace;font-weight:600;color:#888}.swagger-ui .table-container{padding:20px}.swagger-ui .topbar{padding:8px 30px;background-color:#89bf04}.swagger-ui .topbar .topbar-wrapper{-ms-flex-align:center}.swagger-ui .topbar .topbar-wrapper,.swagger-ui .topbar a{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;align-items:center}.swagger-ui .topbar a{font-size:1.5em;font-weight:700;max-width:300px;text-decoration:none;-webkit-box-flex:1;-ms-flex:1;flex:1;-ms-flex-align:center;font-family:Titillium Web,sans-serif;color:#fff}.swagger-ui .topbar a span{margin:0;padding:0 10px}.swagger-ui .topbar .download-url-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:3;-ms-flex:3;flex:3}.swagger-ui .topbar .download-url-wrapper input[type=text]{width:100%;min-width:350px;margin:0;border:2px solid #547f00;border-radius:4px 0 0 4px;outline:none}.swagger-ui .topbar .download-url-wrapper .download-url-button{font-size:16px;font-weight:700;padding:4px 40px;border:none;border-radius:0 4px 4px 0;background:#547f00;font-family:Titillium Web,sans-serif;color:#fff}.swagger-ui .info{margin:50px 0}.swagger-ui .info hgroup.main{margin:0 0 20px}.swagger-ui .info hgroup.main a{font-size:12px}.swagger-ui .info p{font-size:14px;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .info code{padding:3px 5px;border-radius:4px;background:rgba(0,0,0,.05);font-family:Source Code Pro,monospace;font-weight:600;color:#9012fe}.swagger-ui .info a{font-size:14px;transition:all .4s;font-family:Open Sans,sans-serif;color:#4990e2}.swagger-ui .info a:hover{color:#1f69c0}.swagger-ui .info>div{margin:0 0 5px}.swagger-ui .info .base-url{font-size:12px;font-weight:300!important;margin:0;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui .info .title{font-size:36px;margin:0;font-family:Open Sans,sans-serif;color:#3b4151}.swagger-ui .info .title small{font-size:10px;position:relative;top:-5px;display:inline-block;margin:0 0 0 5px;padding:2px 4px;vertical-align:super;border-radius:57px;background:#7d8492}.swagger-ui .info .title small pre{margin:0;font-family:Titillium Web,sans-serif;color:#fff}.swagger-ui .auth-btn-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;padding:10px 0;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.swagger-ui .auth-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1;flex:1;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.swagger-ui .auth-wrapper .authorize{padding-right:20px}.swagger-ui .auth-container{margin:0 0 10px;padding:10px 20px;border-bottom:1px solid #ebebeb}.swagger-ui .auth-container:last-of-type{margin:0;padding:10px 20px;border:0}.swagger-ui .auth-container h4{margin:5px 0 15px!important}.swagger-ui .auth-container .wrapper{margin:0;padding:0}.swagger-ui .auth-container input[type=password],.swagger-ui .auth-container input[type=text]{min-width:230px}.swagger-ui .auth-container .errors{font-size:12px;padding:10px;border-radius:4px;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui .scopes h2{font-size:14px;font-family:Titillium Web,sans-serif;color:#3b4151}.swagger-ui .scope-def{padding:0 0 20px}.swagger-ui .errors-wrapper{margin:20px;padding:10px 20px;-webkit-animation:scaleUp .5s;animation:scaleUp .5s;border:2px solid #f93e3e;border-radius:4px;background:rgba(249,62,62,.1)}.swagger-ui .errors-wrapper .error-wrapper{margin:0 0 10px}.swagger-ui .errors-wrapper .errors h4{font-size:14px;margin:0;font-family:Source Code Pro,monospace;font-weight:600;color:#3b4151}.swagger-ui .errors-wrapper .errors small{color:#666}.swagger-ui .errors-wrapper hgroup{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.swagger-ui .errors-wrapper hgroup h4{font-size:20px;margin:0;-webkit-box-flex:1;-ms-flex:1;flex:1;font-family:Titillium Web,sans-serif;color:#3b4151}@-webkit-keyframes scaleUp{0%{-webkit-transform:scale(.8);transform:scale(.8);opacity:0}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes scaleUp{0%{-webkit-transform:scale(.8);transform:scale(.8);opacity:0}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.swagger-ui .Resizer.vertical.disabled{display:none} 2 | /*# sourceMappingURL=swagger-ui.css.map*/ --------------------------------------------------------------------------------