From aeb46d9b54f4ef48bdd2cd1d2255bbd6dea9aaff Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Mon, 15 Oct 2018 08:21:40 -0400 Subject: [PATCH] open_api spec --- .gitignore | 1 + Pipfile | 4 +- Pipfile.lock | 140 ++++++---------------------------------- responder/api.py | 74 ++++++++++++++++++++- responder/routes.py | 6 +- setup.py | 1 + tests/test_responder.py | 31 +++++++++ 7 files changed, 131 insertions(+), 126 deletions(-) diff --git a/.gitignore b/.gitignore index b103db8..31289de 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ build responder.egg-info/ dist/ app.py +app2.py diff --git a/Pipfile b/Pipfile index c1b7c46..235bcc3 100644 --- a/Pipfile +++ b/Pipfile @@ -6,6 +6,7 @@ name = "pypi" [packages] responder = {editable = true, path = "."} + [dev-packages] pytest = "*" "flake8" = "*" @@ -13,8 +14,7 @@ black = "*" twine = "*" flask = "*" sphinx = "*" -locust = "*" -locustio = "*" +marshmallow = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index aee594f..9f7fb86 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2451f318256743779ce294c2ac771e14665e2eb89137b301acc7e0b6be963c3c" + "sha256": "9b959d9507c521f6088646507633207db03afec6ac31aeab07adf0d737dbb45b" }, "pipfile-spec": 6, "requires": { @@ -30,6 +30,13 @@ ], "version": "==3.0.2" }, + "apispec": { + "hashes": [ + "sha256:c2e6ac6471aaf7c6ec6d12714821898910c6b3c87c189de9a2e3754786b86ada", + "sha256:fa7dfa8a292bae9b1e70c44a50bf61901805821726c5b804568c9f2501f57ebb" + ], + "version": "==1.0.0b3" + }, "certifi": { "hashes": [ "sha256:376690d6f16d32f9d1fe8932551d80b23e9d393a8578c5633a2ed39a64861638", @@ -167,9 +174,9 @@ }, "starlette": { "hashes": [ - "sha256:9f42bba2c3140402df7fe645b79aadc694cca80140d7bdd43b8a7175f84a8a70" + "sha256:2c7ec085440fce7146a9be2b6d53b7110c3866ce6fa03d901efdc1fbe97e0f36" ], - "version": "==0.4.1" + "version": "==0.4.2" }, "urllib3": { "hashes": [ @@ -304,7 +311,6 @@ "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" ], - "markers": "sys_platform == 'win32' and platform_python_implementation == 'CPython'", "version": "==1.11.5" }, "chardet": { @@ -392,59 +398,6 @@ ], "version": "==0.16.0" }, - "gevent": { - "hashes": [ - "sha256:1f277c5cf060b30313c5f9b91588f4c645e11839e14a63c83fcf6f24b1bc9b95", - "sha256:298a04a334fb5e3dcd6f89d063866a09155da56041bc94756da59db412cb45b1", - "sha256:30e9b2878d5b57c68a40b3a08d496bcdaefc79893948989bb9b9fab087b3f3c0", - "sha256:33533bc5c6522883e4437e901059fe5afa3ea74287eeea27a130494ff130e731", - "sha256:3f06f4802824c577272960df003a304ce95b3e82eea01dad2637cc8609c80e2c", - "sha256:419fd562e4b94b91b58cccb3bd3f17e1a11f6162fca6c591a7822bc8a68f023d", - "sha256:4ea938f44b882e02cca9583069d38eb5f257cc15a03e918980c307e7739b1038", - "sha256:51143a479965e3e634252a0f4a1ea07e5307cf8dc773ef6bf9dfe6741785fb4c", - "sha256:5bf9bd1dd4951552d9207af3168f420575e3049016b9c10fe0c96760ce3555b7", - "sha256:6004512833707a1877cc1a5aea90fd182f569e089bc9ab22a81d480dad018f1b", - "sha256:640b3b52121ab519e0980cb38b572df0d2bc76941103a697e828c13d76ac8836", - "sha256:6951655cc18b8371d823e81c700883debb0f633aae76f425dfeb240f76b95a67", - "sha256:71eeb8d9146e8131b65c3364bb760b097c21b7b9fdbec91bf120685a510f997a", - "sha256:7c899e5a6f94d6310352716740f05e41eb8c52d995f27fc01e90380913aa8f22", - "sha256:8465f84ba31aaf52a080837e9c5ddd592ab0a21dfda3212239ce1e1796f4d503", - "sha256:99de2e38dde8669dd30a8a1261bdb39caee6bd00a5f928d01dfdb85ab0502562", - "sha256:9fa4284b44bc42bef6e437488d000ae37499ccee0d239013465638504c4565b7", - "sha256:a1beea0443d3404c03e069d4c4d9fc13d8ec001771c77f9a23f01911a41f0e49", - "sha256:a66a26b78d90d7c4e9bf9efb2b2bd0af49234604ac52eaca03ea79ac411e3f6d", - "sha256:a94e197bd9667834f7bb6bd8dff1736fab68619d0f8cd78a9c1cebe3c4944677", - "sha256:ac0331d3a3289a3d16627742be9c8969f293740a31efdedcd9087dadd6b2da57", - "sha256:d26b57c50bf52fb38dadf3df5bbecd2236f49d7ac98f3cf32d6b8a2d25afc27f", - "sha256:fd23b27387d76410eb6a01ea13efc7d8b4b95974ba212c336e8b1d6ab45a9578" - ], - "version": "==1.3.7" - }, - "greenlet": { - "hashes": [ - "sha256:000546ad01e6389e98626c1367be58efa613fa82a1be98b0c6fc24b563acc6d0", - "sha256:0d48200bc50cbf498716712129eef819b1729339e34c3ae71656964dac907c28", - "sha256:23d12eacffa9d0f290c0fe0c4e81ba6d5f3a5b7ac3c30a5eaf0126bf4deda5c8", - "sha256:37c9ba82bd82eb6a23c2e5acc03055c0e45697253b2393c9a50cef76a3985304", - "sha256:51503524dd6f152ab4ad1fbd168fc6c30b5795e8c70be4410a64940b3abb55c0", - "sha256:8041e2de00e745c0e05a502d6e6db310db7faa7c979b3a5877123548a4c0b214", - "sha256:81fcd96a275209ef117e9ec91f75c731fa18dcfd9ffaa1c0adbdaa3616a86043", - "sha256:853da4f9563d982e4121fed8c92eea1a4594a2299037b3034c3c898cb8e933d6", - "sha256:8b4572c334593d449113f9dc8d19b93b7b271bdbe90ba7509eb178923327b625", - "sha256:9416443e219356e3c31f1f918a91badf2e37acf297e2fa13d24d1cc2380f8fbc", - "sha256:9854f612e1b59ec66804931df5add3b2d5ef0067748ea29dc60f0efdcda9a638", - "sha256:99a26afdb82ea83a265137a398f570402aa1f2b5dfb4ac3300c026931817b163", - "sha256:a19bf883b3384957e4a4a13e6bd1ae3d85ae87f4beb5957e35b0be287f12f4e4", - "sha256:a9f145660588187ff835c55a7d2ddf6abfc570c2651c276d3d4be8a2766db490", - "sha256:ac57fcdcfb0b73bb3203b58a14501abb7e5ff9ea5e2edfa06bb03035f0cff248", - "sha256:bcb530089ff24f6458a81ac3fa699e8c00194208a724b644ecc68422e1111939", - "sha256:beeabe25c3b704f7d56b573f7d2ff88fc99f0138e43480cecdfcaa3b87fe4f87", - "sha256:d634a7ea1fc3380ff96f9e44d8d22f38418c1c381d5fac680b272d7d90883720", - "sha256:d97b0661e1aead761f0ded3b769044bb00ed5d33e1ec865e891a8b128bf7c656" - ], - "markers": "platform_python_implementation == 'CPython'", - "version": "==0.4.15" - }, "idna": { "hashes": [ "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", @@ -472,27 +425,20 @@ ], "version": "==2.10" }, - "locust": { - "hashes": [ - "sha256:aaa38b525795e9c1a35ac3620543cf4e62f82948714f60a32023ea8c9b8edc2e" - ], - "index": "pypi", - "version": "==0.0" - }, - "locustio": { - "hashes": [ - "sha256:be7b44468b8683def983e7451ab505cd85fff8d06f6b75ad7c899cedbbf789ac", - "sha256:c77b471e0e08e215c93a7af9a95b79193268072873fbbc0effca40f3d9b58be4" - ], - "index": "pypi", - "version": "==0.9.0" - }, "markupsafe": { "hashes": [ "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" ], "version": "==1.0" }, + "marshmallow": { + "hashes": [ + "sha256:1ca4820515332fe61cd83551afd791dd0ee16fc70cf57883f6735e5b1d9d50ed", + "sha256:e076fae11bcdd6ee94b2c78d670c2ca35583dd97cc5f1d646b851c9f53368f0a" + ], + "index": "pypi", + "version": "==3.0.0b17" + }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", @@ -508,26 +454,6 @@ ], "version": "==4.3.0" }, - "msgpack": { - "hashes": [ - "sha256:0b3b1773d2693c70598585a34ca2715873ba899565f0a7c9a1545baef7e7fbdc", - "sha256:0bae5d1538c5c6a75642f75a1781f3ac2275d744a92af1a453c150da3446138b", - "sha256:0ee8c8c85aa651be3aa0cd005b5931769eaa658c948ce79428766f1bd46ae2c3", - "sha256:1369f9edba9500c7a6489b70fdfac773e925342f4531f1e3d4c20ac3173b1ae0", - "sha256:22d9c929d1d539f37da3d1b0e16270fa9d46107beab8c0d4d2bddffffe895cee", - "sha256:2ff43e3247a1e11d544017bb26f580a68306cec7a6257d8818893c1fda665f42", - "sha256:31a98047355d34d047fcdb55b09cb19f633cf214c705a765bd745456c142130c", - "sha256:8767eb0032732c3a0da92cbec5ac186ef89a3258c6edca09161472ca0206c45f", - "sha256:8acc8910218555044e23826980b950e96685dc48124a290c86f6f41a296ea172", - "sha256:ab189a6365be1860a5ecf8159c248f12d33f79ea799ae9695fa6a29896dcf1d4", - "sha256:cfd6535feb0f1cf1c7cdb25773e965cc9f92928244a8c3ef6f8f8a8e1f7ae5c4", - "sha256:e274cd4480d8c76ec467a85a9c6635bbf2258f0649040560382ab58cabb44bcf", - "sha256:f86642d60dca13e93260187d56c2bef2487aa4d574a669e8ceefcf9f4c26fd00", - "sha256:f8a57cbda46a94ed0db55b73e6ab0c15e78b4ede8690fa491a0e55128d552bb0", - "sha256:fcea97a352416afcbccd7af9625159d80704a25c519c251c734527329bb20d0e" - ], - "version": "==0.5.6" - }, "packaging": { "hashes": [ "sha256:0886227f54515e592aaa2e5a553332c73962917f2831f1b0f9b9f4380a4b9807", @@ -605,36 +531,6 @@ ], "version": "==2018.5" }, - "pyzmq": { - "hashes": [ - "sha256:25a0715c8f69cf72f67cfe5a68a3f3ed391c67c063d2257bec0fe7fc2c7f08f8", - "sha256:2bab63759632c6b9e0d5bf19cc63c3b01df267d660e0abcf230cf0afaa966349", - "sha256:30ab49d99b24bf0908ebe1cdfa421720bfab6f93174e4883075b7ff38cc555ba", - "sha256:32c7ca9fc547a91e3c26fc6080b6982e46e79819e706eb414dd78f635a65d946", - "sha256:41219ae72b3cc86d97557fe5b1ef5d1adc1057292ec597b50050874a970a39cf", - "sha256:4b8c48a9a13cea8f1f16622f9bd46127108af14cd26150461e3eab71e0de3e46", - "sha256:55724997b4a929c0d01b43c95051318e26ddbae23565018e138ae2dc60187e59", - "sha256:65f0a4afae59d4fc0aad54a917ab599162613a761b760ba167d66cc646ac3786", - "sha256:6f88591a8b246f5c285ee6ce5c1bf4f6bd8464b7f090b1333a446b6240a68d40", - "sha256:75022a4c60dcd8765bb9ca32f6de75a0ec83b0d96e0309dc479f4c7b21f26cb7", - "sha256:76ea493bfab18dcb090d825f3662b5612e2def73dffc196d51a5194b0294a81d", - "sha256:7b60c045b80709e4e3c085bab9b691e71761b44c2b42dbb047b8b498e7bc16b3", - "sha256:8e6af2f736734aef8ed6f278f9f552ec7f37b1a6b98e59b887484a840757f67d", - "sha256:9ac2298e486524331e26390eac14e4627effd3f8e001d4266ed9d8f1d2d31cce", - "sha256:9ba650f493a9bc1f24feca1d90fce0e5dd41088a252ac9840131dfbdbf3815ca", - "sha256:a02a4a385e394e46012dc83d2e8fd6523f039bb52997c1c34a2e0dd49ed839c1", - "sha256:a3ceee84114d9f5711fa0f4db9c652af0e4636c89eabc9b7f03a3882569dd1ed", - "sha256:a72b82ac1910f2cf61a49139f4974f994984475f771b0faa730839607eeedddf", - "sha256:ab136ac51027e7c484c53138a0fab4a8a51e80d05162eb7b1585583bcfdbad27", - "sha256:c095b224300bcac61e6c445e27f9046981b1ac20d891b2f1714da89d34c637c8", - "sha256:c5cc52d16c06dc2521340d69adda78a8e1031705924e103c0eb8fc8af861d810", - "sha256:d612e9833a89e8177f8c1dc68d7b4ff98d3186cd331acd616b01bbdab67d3a7b", - "sha256:e828376a23c66c6fe90dcea24b4b72cd774f555a6ee94081670872918df87a19", - "sha256:e9767c7ab2eb552796440168d5c6e23a99ecaade08dda16266d43ad461730192", - "sha256:ebf8b800d42d217e4710d1582b0c8bff20cdcb4faad7c7213e52644034300924" - ], - "version": "==17.1.2" - }, "readme-renderer": { "hashes": [ "sha256:237ca8705ffea849870de41101dba41543561da05c0ae45b2f1c547efa9843d2", diff --git a/responder/api.py b/responder/api.py index b9f3056..8d05801 100644 --- a/responder/api.py +++ b/responder/api.py @@ -11,6 +11,9 @@ from graphql_server import encode_execution_results, json_encode, default_format from starlette.routing import Router from starlette.staticfiles import StaticFiles from starlette.testclient import TestClient +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin +from apispec import yaml_utils from . import models from . import status_codes @@ -31,12 +34,23 @@ class API: status_codes = status_codes def __init__( - self, static_dir="static", templates_dir="templates", enable_hsts=False + self, + *, + title=None, + version=None, + openapi="2.0", + static_dir="static", + templates_dir="templates", + enable_hsts=False, ): + self.title = title + self.version = version + self.openapi_version = openapi self.static_dir = Path(os.path.abspath(static_dir)) self.static_route = f"/{static_dir}" self.templates_dir = Path(os.path.abspath(templates_dir)) self.routes = {} + self.schemas = {} self.hsts_enabled = enable_hsts self.static_files = StaticFiles(directory=str(self.static_dir)) @@ -52,6 +66,34 @@ class API: self._session = None self.background = BackgroundQueue() + if self.openapi_version: + self.add_route("/schema.yml", self.schema_response) + + @property + def _apispec(self): + spec = APISpec( + title=self.title, + version=self.version, + openapi_version=self.openapi_version, + plugins=[MarshmallowPlugin()], + ) + + for route in self.routes: + if self.routes[route].description: + operations = yaml_utils.load_operations_from_docstring( + self.routes[route].description + ) + spec.add_path(path=route, operations=operations) + + for name, schema in self.schemas.items(): + spec.definition(name, schema=schema) + + return spec + + @property + def openapi(self): + return self._apispec.to_yaml() + def __call__(self, scope): path = scope["path"] root_path = scope.get("root_path", "") @@ -73,6 +115,32 @@ class API: return asgi + def add_schema(self, name, schema, check_existing=True): + """Adds a mashmallow schema to the API specification.""" + if check_existing: + assert name not in self.schemas + + self.schemas[name] = schema + + def schema(self, name, **options): + """Decorator for creating new routes around function and class definitions. + + Usage:: + + from marshmallow import Schema, fields + + @api.schema("Pet") + class PetSchema(Schema): + name = fields.Str() + + """ + + def decorator(f): + self.add_schema(name=name, schema=f, **options) + return f + + return decorator + def path_matches_route(self, path): """Given a path portion of a URL, tests that it matches against any registered route. @@ -156,6 +224,10 @@ class API: resp.status_code = status_codes.HTTP_404 resp.text = "Not found." + def schema_response(self, req, resp): + resp.status_code = status_codes.HTTP_200 + resp.content = self.openapi + def redirect( self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301 ): diff --git a/responder/routes.py b/responder/routes.py index ed417b7..51a0150 100644 --- a/responder/routes.py +++ b/responder/routes.py @@ -3,7 +3,7 @@ from parse import parse, search def memoize(f): def helper(self, s): - memoize_key = f'{f.__name__}:{s}' + memoize_key = f"{f.__name__}:{s}" if memoize_key not in self._memo: self._memo[memoize_key] = f(self, s) return self._memo[memoize_key] @@ -28,6 +28,10 @@ class Route: # Strings. return self.does_match(other) + @property + def description(self): + return self.endpoint.__doc__ + @property def has_parameters(self): return all([("{" in self.route), ("}" in self.route)]) diff --git a/setup.py b/setup.py index 7a200a7..36c1437 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ required = [ "rfc3986", "python-multipart", "chardet", + "apispec", ] diff --git a/tests/test_responder.py b/tests/test_responder.py index 3f98fae..86c905c 100644 --- a/tests/test_responder.py +++ b/tests/test_responder.py @@ -290,3 +290,34 @@ def test_yaml_downloads(api, session): r = session.get(api.url_for(route), headers={"Content-Type": "application/x-yaml"}) assert yaml.safe_load(r.content) == dump + + +def test_schema_generation(): + import responder + from marshmallow import Schema, fields + + api = responder.API(title="Web Service", openapi="3.0") + + @api.schema("Pet") + class PetSchema(Schema): + name = fields.Str() + + @api.route("/") + def route(req, resp): + """A cute furry animal endpoint. + --- + get: + description: Get a random pet + responses: + 200: + description: A pet to be returned + schema: + $ref = "#/components/schemas/Pet" + """ + resp.media = PetSchema().dump({"name": "little orange"}) + + r = api.session().get("http://;/schema.yml") + dump = yaml.safe_load(r.content) + + assert dump + assert dump["openapi"] == "3.0"