├── 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 |  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 | [](https://travis-ci.org/akx/lepo) []() 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 |