pyramid_traversal_api 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyramid_traversal_api-0.1.0/LICENSE +21 -0
- pyramid_traversal_api-0.1.0/PKG-INFO +51 -0
- pyramid_traversal_api-0.1.0/README.md +26 -0
- pyramid_traversal_api-0.1.0/pyproject.toml +37 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/__init__.py +104 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/crud.py +56 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/deprecation.py +41 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/openapi/__init__.py +0 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/openapi/generator.py +232 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/openapi/helpers.py +111 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/resource/__init__.py +89 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/resource/collection.py +43 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/resource/sqla_collection.py +119 -0
- pyramid_traversal_api-0.1.0/src/pyramid_traversal_api/sunset.py +47 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ARTEFACTS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyramid_traversal_api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tools for writing REST APIs in Pyramid using the traversal API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Framework :: Pyramid
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Dist: marshmallow
|
|
13
|
+
Requires-Dist: pyramid
|
|
14
|
+
Requires-Dist: ruff ; extra == 'dev'
|
|
15
|
+
Requires-Dist: sphinx ; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-cov ; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-mock ; extra == 'dev'
|
|
19
|
+
Requires-Dist: webtest>=3.0.7 ; extra == 'dev'
|
|
20
|
+
Requires-Dist: sqlalchemy ; extra == 'sqla'
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Provides-Extra: sqla
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# pyramid-traversal-api
|
|
27
|
+
|
|
28
|
+
**NOTE** This library is currently WIP. Expect breakages, even between minor versions, while on major version 0.
|
|
29
|
+
|
|
30
|
+
A set of helpers that makes it easier to write traversal-based REST APIs for [pyramid](https://trypyramid.com/) with modern QoL features like
|
|
31
|
+
|
|
32
|
+
* Request/response validation
|
|
33
|
+
* Automatic OpenAPI
|
|
34
|
+
* Automatic SQLAlchemy requests
|
|
35
|
+
- But possible to write your own support for any backend
|
|
36
|
+
* Built around writing REST APIs
|
|
37
|
+
|
|
38
|
+
## Design goals
|
|
39
|
+
|
|
40
|
+
* Build tools FOR pyramid, not REPLACING pyramid
|
|
41
|
+
- No new abstraction layers on top of Pyramid, just new building blocks
|
|
42
|
+
* Easy to slap on top of an existing project, allowing gradual migration
|
|
43
|
+
- Start small finish big, like Pyramid
|
|
44
|
+
|
|
45
|
+
## Standing on the shoulder of giants
|
|
46
|
+
|
|
47
|
+
Thank you to the pylons project for Pyramid. Greetings also go to `Theron Luhn` for [pyramid-marshmallow](https://pypi.org/project/pyramid-marshmallow/), which the OpenAPI functionality of this package is based on.
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
Python 3.9 or later
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# pyramid-traversal-api
|
|
2
|
+
|
|
3
|
+
**NOTE** This library is currently WIP. Expect breakages, even between minor versions, while on major version 0.
|
|
4
|
+
|
|
5
|
+
A set of helpers that makes it easier to write traversal-based REST APIs for [pyramid](https://trypyramid.com/) with modern QoL features like
|
|
6
|
+
|
|
7
|
+
* Request/response validation
|
|
8
|
+
* Automatic OpenAPI
|
|
9
|
+
* Automatic SQLAlchemy requests
|
|
10
|
+
- But possible to write your own support for any backend
|
|
11
|
+
* Built around writing REST APIs
|
|
12
|
+
|
|
13
|
+
## Design goals
|
|
14
|
+
|
|
15
|
+
* Build tools FOR pyramid, not REPLACING pyramid
|
|
16
|
+
- No new abstraction layers on top of Pyramid, just new building blocks
|
|
17
|
+
* Easy to slap on top of an existing project, allowing gradual migration
|
|
18
|
+
- Start small finish big, like Pyramid
|
|
19
|
+
|
|
20
|
+
## Standing on the shoulder of giants
|
|
21
|
+
|
|
22
|
+
Thank you to the pylons project for Pyramid. Greetings also go to `Theron Luhn` for [pyramid-marshmallow](https://pypi.org/project/pyramid-marshmallow/), which the OpenAPI functionality of this package is based on.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
Python 3.9 or later
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyramid_traversal_api"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Tools for writing REST APIs in Pyramid using the traversal API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICEN[CS]E*"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Framework :: Pyramid",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"marshmallow",
|
|
18
|
+
"pyramid",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
sqla = [
|
|
23
|
+
"sqlalchemy"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
dev = [
|
|
27
|
+
"ruff",
|
|
28
|
+
"sphinx",
|
|
29
|
+
"pytest",
|
|
30
|
+
"pytest-cov",
|
|
31
|
+
"pytest-mock",
|
|
32
|
+
"webtest>=3.0.7",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from marshmallow import Schema
|
|
2
|
+
from pyramid.response import Response
|
|
3
|
+
from pyramid.viewderivers import VIEW
|
|
4
|
+
import pyramid.httpexceptions as exc
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def includeme(config):
|
|
12
|
+
# Forward some work to pyramid_marshmallow for now
|
|
13
|
+
config.add_view_deriver(view_validator)
|
|
14
|
+
config.add_view_deriver(view_marshaller, under="rendered_view", over=VIEW)
|
|
15
|
+
config.add_view_deriver(view_api_spec)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def process_schema(schema: dict):
|
|
19
|
+
"""Normalizes a schema. If it is a dict, convert it to Schema. If not, return as-is."""
|
|
20
|
+
if isinstance(schema, Schema):
|
|
21
|
+
return schema
|
|
22
|
+
elif isinstance(schema, dict):
|
|
23
|
+
return Schema.from_dict(schema)()
|
|
24
|
+
else:
|
|
25
|
+
raise TypeError(f"Schema {schema} is invalid type")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def process_schemas(schemas):
|
|
29
|
+
"""Handle a schemas passed in as a view deriver, creating a nonce schema if a
|
|
30
|
+
dictionary.
|
|
31
|
+
"""
|
|
32
|
+
if schemas is None:
|
|
33
|
+
return {}
|
|
34
|
+
new_schemas = {}
|
|
35
|
+
for response, schema in schemas.items():
|
|
36
|
+
new_schemas[response] = process_schema(schema)
|
|
37
|
+
return new_schemas
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def view_validator(view, info):
|
|
41
|
+
# https://github.com/luhn/pyramid-marshmallow/blob/main/pyramid_marshmallow/__init__.py
|
|
42
|
+
schema = info.options.get("validate")
|
|
43
|
+
if schema is None:
|
|
44
|
+
return view
|
|
45
|
+
schema = process_schema(schema)
|
|
46
|
+
|
|
47
|
+
def wrapped(context, request):
|
|
48
|
+
if request.method == "GET":
|
|
49
|
+
data = dict()
|
|
50
|
+
for k, v in request.GET.items():
|
|
51
|
+
field = schema.fields.get(k)
|
|
52
|
+
if isinstance(field, fields.List):
|
|
53
|
+
data.setdefault(k, []).append(v)
|
|
54
|
+
else:
|
|
55
|
+
data[k] = v
|
|
56
|
+
else:
|
|
57
|
+
data = request.json_body
|
|
58
|
+
request.data = schema.load(data)
|
|
59
|
+
return view(context, request)
|
|
60
|
+
|
|
61
|
+
return wrapped
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
view_validator.options = ("validate",)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def view_marshaller(view, info):
|
|
68
|
+
"""If there is a schema for the response code being returned, we marshal it
|
|
69
|
+
TODO: Maybe ignore the marshalling altogether and only use it for schema?"""
|
|
70
|
+
schemas = process_schemas(info.options.get("marshal_responses"))
|
|
71
|
+
if not any([schema is not None for schema in schemas.values()]):
|
|
72
|
+
return view
|
|
73
|
+
|
|
74
|
+
def wrapped(context, request):
|
|
75
|
+
output = view(context, request)
|
|
76
|
+
|
|
77
|
+
status = str(request.response.status_int)
|
|
78
|
+
|
|
79
|
+
if isinstance(output, Response) or status not in schemas:
|
|
80
|
+
return output
|
|
81
|
+
else:
|
|
82
|
+
dumped = schemas[status].dump(output)
|
|
83
|
+
|
|
84
|
+
errors = schemas[status].validate(dumped)
|
|
85
|
+
if len(errors) != 0:
|
|
86
|
+
log.info(f"Validation errors: {errors}")
|
|
87
|
+
raise exc.HTTPInternalServerError(
|
|
88
|
+
"Unable to generate correct response. This is on us. Sorry"
|
|
89
|
+
)
|
|
90
|
+
return dumped
|
|
91
|
+
|
|
92
|
+
return wrapped
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Ignore type as we are doing some Python dark magic here
|
|
96
|
+
view_marshaller.options = ("marshal_responses",) # type: ignore[attr-defined]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# TODO: consolidate view options
|
|
100
|
+
def view_api_spec(view, info):
|
|
101
|
+
return view
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
view_api_spec.options = "api_spec" # type: ignore[attr-defined]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Helper for automagically generating CRUD views for a resource
|
|
2
|
+
import marshmallow
|
|
3
|
+
import typing
|
|
4
|
+
import functools
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def crud(
|
|
11
|
+
create: typing.Optional[marshmallow.Schema],
|
|
12
|
+
read: typing.Optional[bool],
|
|
13
|
+
update: typing.Optional[marshmallow.Schema],
|
|
14
|
+
delete: typing.Optional[bool],
|
|
15
|
+
):
|
|
16
|
+
"""Decorator to attach to a resource class. For each kwarg in create, read, update, delete, creates a corresponding view using the schema if a value is provided.
|
|
17
|
+
For `read` and `delete`, you can only enable or disable creation of the view.
|
|
18
|
+
|
|
19
|
+
:param create:
|
|
20
|
+
Not yet implemented
|
|
21
|
+
:param read:
|
|
22
|
+
Not yet implemented
|
|
23
|
+
:param update:
|
|
24
|
+
Not yet implemented
|
|
25
|
+
:param delete:
|
|
26
|
+
Not yet implemented
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def inner(f):
|
|
30
|
+
@functools.wraps(f)
|
|
31
|
+
def wrapper(*args, **kwds):
|
|
32
|
+
cls = f(*args, **kwds)
|
|
33
|
+
|
|
34
|
+
log.debug(f"Creating crud for {cls}")
|
|
35
|
+
|
|
36
|
+
if create is not None:
|
|
37
|
+
log.debug("Creating create")
|
|
38
|
+
raise NotImplementedError("Not yet implemented")
|
|
39
|
+
|
|
40
|
+
if read is not None:
|
|
41
|
+
log.debug("Creating read")
|
|
42
|
+
raise NotImplementedError("Not yet implemented")
|
|
43
|
+
|
|
44
|
+
if update is not None:
|
|
45
|
+
log.debug("Creating update")
|
|
46
|
+
raise NotImplementedError("Not yet implemented")
|
|
47
|
+
|
|
48
|
+
if delete is not None:
|
|
49
|
+
log.debug("Creating delete")
|
|
50
|
+
raise NotImplementedError("Not yet implemented")
|
|
51
|
+
|
|
52
|
+
return cls
|
|
53
|
+
|
|
54
|
+
return wrapper
|
|
55
|
+
|
|
56
|
+
return inner
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Implements a decorator that allows you to warn API users of deprecation using RFC9745"""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import typing
|
|
5
|
+
import pyramid.httpexceptions as exc
|
|
6
|
+
from email.utils import formatdate
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from time import mktime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def deprecation(deprecation_datetime: str, link: typing.Optional[str]):
|
|
13
|
+
"""Implements the deprecation HTTP header (RFC9745) for a view. The view will continue to work after the deprecation datetime, but should be considered not wanted. The deprecation header will be injected both before and after deprecation time.
|
|
14
|
+
|
|
15
|
+
::param date:
|
|
16
|
+
The date (and time) of the endpoint being deprecated in ISO 8601 format.
|
|
17
|
+
::param link:
|
|
18
|
+
An optional link to information about the deprecation, provided to the user in the Link header
|
|
19
|
+
"""
|
|
20
|
+
parsed_deprecation_datetime = datetime.fromisoformat(deprecation_datetime)
|
|
21
|
+
headers = {
|
|
22
|
+
"Deprecation": formatdate(
|
|
23
|
+
timeval=mktime(parsed_deprecation_datetime.timetuple()),
|
|
24
|
+
localtime=False,
|
|
25
|
+
usegmt=True,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
if link is not None:
|
|
29
|
+
headers["Link"] = f'<{link}>;rel="deprecation";type="text/html"'
|
|
30
|
+
|
|
31
|
+
def inner(f):
|
|
32
|
+
@functools.wraps(f)
|
|
33
|
+
def wrapper(context, request):
|
|
34
|
+
for header in headers.keys():
|
|
35
|
+
request.response.headers[header] = headers[header]
|
|
36
|
+
|
|
37
|
+
return f(context, request)
|
|
38
|
+
|
|
39
|
+
return wrapper
|
|
40
|
+
|
|
41
|
+
return inner
|
|
File without changes
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Took for traversing a Pyramid Traversal node graph and generating an OpenAPI spec to describe it."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pyramid.path import DottedNameResolver
|
|
8
|
+
|
|
9
|
+
from .helpers import (
|
|
10
|
+
set_request_body,
|
|
11
|
+
set_query_params,
|
|
12
|
+
set_response_body,
|
|
13
|
+
get_operation_id,
|
|
14
|
+
split_docstring,
|
|
15
|
+
set_tag,
|
|
16
|
+
schema_name_resolver,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from apispec import APISpec, utils
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ops_from_view(
|
|
27
|
+
spec, introspectable, url_params, response_types, gen_external_docs_callback=None
|
|
28
|
+
):
|
|
29
|
+
"""Generates a dictionary of operations for a given introspectable(view).
|
|
30
|
+
|
|
31
|
+
`url_params` are url params that apply to the URL we are currently at.
|
|
32
|
+
`traverse_resource` deduces any url params for the current URL,
|
|
33
|
+
and also passes them recursively to child resources.
|
|
34
|
+
"""
|
|
35
|
+
# Do openapi magic here
|
|
36
|
+
# Taken from pyramid-marshmallow
|
|
37
|
+
operations = {}
|
|
38
|
+
methods = introspectable["request_methods"] or ["GET"]
|
|
39
|
+
if isinstance(methods, str):
|
|
40
|
+
methods = [methods]
|
|
41
|
+
for method in methods:
|
|
42
|
+
method = method.lower()
|
|
43
|
+
operations[method] = introspectable
|
|
44
|
+
|
|
45
|
+
final_ops = dict()
|
|
46
|
+
for method, view in operations.items():
|
|
47
|
+
summary, descr, user_op = split_docstring(view["callable"].__doc__)
|
|
48
|
+
op = {
|
|
49
|
+
"responses": dict(),
|
|
50
|
+
"parameters": url_params.copy(),
|
|
51
|
+
"operationId": get_operation_id(view)
|
|
52
|
+
if len(operations) == 1
|
|
53
|
+
else f"{method}_{get_operation_id(view)}",
|
|
54
|
+
}
|
|
55
|
+
if gen_external_docs_callback is not None:
|
|
56
|
+
op["externalDocs"] = gen_external_docs_callback(view["callable"])
|
|
57
|
+
if summary:
|
|
58
|
+
op["summary"] = summary
|
|
59
|
+
if descr:
|
|
60
|
+
op["description"] = descr
|
|
61
|
+
|
|
62
|
+
if "validate" in view:
|
|
63
|
+
if method == "get":
|
|
64
|
+
set_query_params(spec, op, view)
|
|
65
|
+
else:
|
|
66
|
+
set_request_body(spec, op, view)
|
|
67
|
+
if "marshal_responses" in view:
|
|
68
|
+
set_response_body(spec, op, view)
|
|
69
|
+
set_tag(spec, op, view)
|
|
70
|
+
final_op = utils.deepupdate(op, user_op)
|
|
71
|
+
final_op = utils.deepupdate(final_op, view.get("api_spec", dict()))
|
|
72
|
+
|
|
73
|
+
# We are required to have some response, so make one up.
|
|
74
|
+
if "200" not in final_op["responses"]:
|
|
75
|
+
final_op["responses"]["200"] = {
|
|
76
|
+
"description": "",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Naive auto-detection of resources that represent an SQL table
|
|
80
|
+
if "404" not in final_op["responses"] and len(url_params) > 0:
|
|
81
|
+
final_op["responses"]["404"] = {
|
|
82
|
+
"description": "An object was not found for "
|
|
83
|
+
+ " or ".join(map(lambda param: f"`{param['name']}`", url_params))
|
|
84
|
+
}
|
|
85
|
+
if "400" not in final_op["responses"] and "validate" in view:
|
|
86
|
+
final_op["responses"]["400"] = {"description": "Request validation failed"}
|
|
87
|
+
|
|
88
|
+
# Any response types generated by framework are also added
|
|
89
|
+
for key, value in response_types.items():
|
|
90
|
+
log.debug(f"Checking if {key} in responses")
|
|
91
|
+
if key not in final_op["responses"]:
|
|
92
|
+
log.debug(f"Adding response {key}")
|
|
93
|
+
final_op["responses"][key] = value
|
|
94
|
+
|
|
95
|
+
log.debug(f"FINAL OP: {final_op}")
|
|
96
|
+
final_ops[method] = final_op
|
|
97
|
+
|
|
98
|
+
return final_ops
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def populate_url_params(param):
|
|
102
|
+
"""Takes a param definition as defined on a resource,
|
|
103
|
+
and adds necessary fields to make it an OpenAPI URL parameter
|
|
104
|
+
"""
|
|
105
|
+
return {**param, "in": "path", "required": True}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def traverse_resource(
|
|
109
|
+
spec,
|
|
110
|
+
introspector,
|
|
111
|
+
context,
|
|
112
|
+
path="",
|
|
113
|
+
url_params=[],
|
|
114
|
+
response_types={},
|
|
115
|
+
gen_external_docs_callback=None,
|
|
116
|
+
):
|
|
117
|
+
log.info(
|
|
118
|
+
f"Traversing {context} at path {path} inherited response types {response_types}"
|
|
119
|
+
)
|
|
120
|
+
# Iterate all views of this resource
|
|
121
|
+
for item in introspector.get_category("views", context):
|
|
122
|
+
introspectable = item["introspectable"]
|
|
123
|
+
if introspectable["context"] == context:
|
|
124
|
+
view_path = f"{path}/{introspectable['name']}"
|
|
125
|
+
|
|
126
|
+
ops = ops_from_view(
|
|
127
|
+
spec,
|
|
128
|
+
introspectable,
|
|
129
|
+
url_params,
|
|
130
|
+
response_types,
|
|
131
|
+
gen_external_docs_callback,
|
|
132
|
+
)
|
|
133
|
+
spec.path(view_path, operations=ops)
|
|
134
|
+
else:
|
|
135
|
+
# log.info(item["introspectable"]["context"])
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# Then, check child resources
|
|
139
|
+
new_children = getattr(context, "static_children", None)
|
|
140
|
+
|
|
141
|
+
dynamic_child = context.get_dynamic_openapi_info()
|
|
142
|
+
if dynamic_child:
|
|
143
|
+
log.info("Path has dynamic children")
|
|
144
|
+
new_url_params = url_params + list(
|
|
145
|
+
map(populate_url_params, dynamic_child["params"])
|
|
146
|
+
)
|
|
147
|
+
# We need to determine some sane name for the url param
|
|
148
|
+
# So we pick table name + primary key name
|
|
149
|
+
traverse_resource(
|
|
150
|
+
spec,
|
|
151
|
+
introspector,
|
|
152
|
+
dynamic_child["resource"],
|
|
153
|
+
f"{path}/"
|
|
154
|
+
+ "/".join(
|
|
155
|
+
map(lambda param: "{" + param["name"] + "}", dynamic_child["params"])
|
|
156
|
+
),
|
|
157
|
+
new_url_params,
|
|
158
|
+
response_types,
|
|
159
|
+
gen_external_docs_callback,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if new_children:
|
|
163
|
+
log.info("Path has children")
|
|
164
|
+
|
|
165
|
+
# Supports both dynamic and static children at the same time
|
|
166
|
+
for k, v in new_children.items():
|
|
167
|
+
traverse_resource(
|
|
168
|
+
spec,
|
|
169
|
+
introspector,
|
|
170
|
+
v,
|
|
171
|
+
f"{path}/{k}",
|
|
172
|
+
url_params,
|
|
173
|
+
response_types,
|
|
174
|
+
gen_external_docs_callback,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
log.info("Path has no children")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def generate_apispec(
|
|
181
|
+
name,
|
|
182
|
+
version,
|
|
183
|
+
registry,
|
|
184
|
+
resource,
|
|
185
|
+
path="",
|
|
186
|
+
global_response_types={},
|
|
187
|
+
gen_external_docs_callback=None,
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""Returns an APISpec object from traversing the tree with a given resource as root.
|
|
190
|
+
`path` specifies an optional URL prefix for every endpoint
|
|
191
|
+
|
|
192
|
+
:param name:
|
|
193
|
+
The name of the API that is documented
|
|
194
|
+
:param version:
|
|
195
|
+
The version of the API that is documented
|
|
196
|
+
:param registry:
|
|
197
|
+
An instance of a pyramid registry
|
|
198
|
+
:param resource:
|
|
199
|
+
The traversal context to use as root
|
|
200
|
+
:param path:
|
|
201
|
+
An optional path prefix
|
|
202
|
+
:param global_response_types:
|
|
203
|
+
Any HTTP response codes that may be returned due to how the server is configured (f.ex extra validation or security)
|
|
204
|
+
:param gen_external_docs_callback:
|
|
205
|
+
An optional callback that takes a view and returns a dict containing an external documentation object. See https://swagger.io/specification/#external-documentation-object
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
name_resolver = DottedNameResolver()
|
|
209
|
+
MarshmallowPlugin = name_resolver.maybe_resolve(
|
|
210
|
+
registry.settings.get(
|
|
211
|
+
"openapi.plugin", "apispec.ext.marshmallow.MarshmallowPlugin"
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
marshmallow_plugin = MarshmallowPlugin(schema_name_resolver=schema_name_resolver)
|
|
216
|
+
|
|
217
|
+
spec = APISpec(
|
|
218
|
+
name,
|
|
219
|
+
version,
|
|
220
|
+
openapi_version="3.0.0",
|
|
221
|
+
plugins=[marshmallow_plugin],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
traverse_resource(
|
|
225
|
+
spec,
|
|
226
|
+
registry.introspector,
|
|
227
|
+
resource,
|
|
228
|
+
path,
|
|
229
|
+
response_types=global_response_types,
|
|
230
|
+
gen_external_docs_callback=gen_external_docs_callback,
|
|
231
|
+
)
|
|
232
|
+
return spec.to_dict()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Helpers for OpenAPI generation
|
|
2
|
+
Mostly copied verbatim or slightly modified from https://github.com/luhn/pyramid-marshmallow/blob/main/pyramid_marshmallow
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from marshmallow import Schema
|
|
6
|
+
from apispec import utils as apispec_utils
|
|
7
|
+
from apispec.ext.marshmallow.common import (
|
|
8
|
+
resolve_schema_cls,
|
|
9
|
+
resolve_schema_instance,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _schema(schema):
|
|
14
|
+
if isinstance(schema, dict):
|
|
15
|
+
return Schema.from_dict(schema)
|
|
16
|
+
else:
|
|
17
|
+
return schema
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def schema_name_resolver(schema):
|
|
21
|
+
cls = resolve_schema_cls(schema)
|
|
22
|
+
instance = resolve_schema_instance(schema)
|
|
23
|
+
name = cls.__name__
|
|
24
|
+
if not cls.opts.register:
|
|
25
|
+
# Unregistered schemas are put inline.
|
|
26
|
+
return False
|
|
27
|
+
if instance.only or instance.exclude:
|
|
28
|
+
# If schema includes only select fields, treat it as nonce
|
|
29
|
+
return False
|
|
30
|
+
if name.endswith("Schema"):
|
|
31
|
+
name = name[:-6] or name
|
|
32
|
+
if instance.partial:
|
|
33
|
+
name = "Partial" + name
|
|
34
|
+
return name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_request_body(spec, op, view):
|
|
38
|
+
op["requestBody"] = {
|
|
39
|
+
"content": {
|
|
40
|
+
"application/json": {
|
|
41
|
+
"schema": _schema(view["validate"]),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_query_params(spec, op, view):
|
|
48
|
+
op["parameters"].append(
|
|
49
|
+
{
|
|
50
|
+
"in": "query",
|
|
51
|
+
"schema": _schema(view["validate"]),
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def set_tag(spec, op, view):
|
|
57
|
+
context = view["context"]
|
|
58
|
+
if not context:
|
|
59
|
+
return
|
|
60
|
+
tag = getattr(context, "__tag__", None)
|
|
61
|
+
if not tag:
|
|
62
|
+
return
|
|
63
|
+
if isinstance(tag, dict):
|
|
64
|
+
# Cheating and using the private variable spec._tags
|
|
65
|
+
if not any(x["name"] == tag["name"] for x in spec._tags):
|
|
66
|
+
spec.tag(tag)
|
|
67
|
+
tag_name = tag["name"]
|
|
68
|
+
else:
|
|
69
|
+
tag_name = tag
|
|
70
|
+
op.setdefault("tags", []).append(tag_name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def split_docstring(docstring):
|
|
74
|
+
"""
|
|
75
|
+
Split a docstring in half, delineated with a "---". The first half is
|
|
76
|
+
returned verbatim, the second half is parsed as YAML.
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
split_lines = apispec_utils.trim_docstring(docstring).split("\n")
|
|
80
|
+
|
|
81
|
+
# Cut YAML from rest of docstring
|
|
82
|
+
for index, line in enumerate(split_lines):
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if line.startswith("---"):
|
|
85
|
+
cut_from = index
|
|
86
|
+
break
|
|
87
|
+
else:
|
|
88
|
+
cut_from = len(split_lines)
|
|
89
|
+
|
|
90
|
+
summary = split_lines[0].strip() or None
|
|
91
|
+
docs = "\n".join(split_lines[1:cut_from]).strip() or None
|
|
92
|
+
yaml_string = "\n".join(split_lines[cut_from:])
|
|
93
|
+
if yaml_string:
|
|
94
|
+
parsed = yaml.safe_load(yaml_string)
|
|
95
|
+
else:
|
|
96
|
+
parsed = dict()
|
|
97
|
+
return summary, docs, parsed
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def set_response_body(spec, op, view):
|
|
101
|
+
for response_code, schema in view["marshal_responses"].items():
|
|
102
|
+
op["responses"][response_code] = {
|
|
103
|
+
"description": "",
|
|
104
|
+
"content": {
|
|
105
|
+
"application/json": {"schema": schema},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_operation_id(view):
|
|
111
|
+
return view["callable"].__name__
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from typing import Type
|
|
5
|
+
|
|
6
|
+
log = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Resource:
|
|
10
|
+
# Static children are child nodes that can be reached by a static name without needing any backend lookup.
|
|
11
|
+
static_children: dict[str, Type["Resource"]] = {}
|
|
12
|
+
|
|
13
|
+
# Store known views (in class namespace, or "class static", so it is remembered)
|
|
14
|
+
# Key is the view name, and the value is a list of request methods
|
|
15
|
+
_known_views: dict[str, list[str]] = {}
|
|
16
|
+
|
|
17
|
+
def __init__(self, request, *args, **kwargs):
|
|
18
|
+
self.request = request
|
|
19
|
+
self.variables = kwargs or {}
|
|
20
|
+
|
|
21
|
+
def __getitem__(self, key: str) -> "Resource":
|
|
22
|
+
if self._determine_is_reserved_keyname(key):
|
|
23
|
+
raise KeyError("A view exists by this name, so stopping traversal")
|
|
24
|
+
|
|
25
|
+
if key not in self.static_children:
|
|
26
|
+
raise KeyError("no item")
|
|
27
|
+
item = self.static_children[key]
|
|
28
|
+
|
|
29
|
+
return self._mkchild(key, item)
|
|
30
|
+
|
|
31
|
+
def __getattr__(self, key):
|
|
32
|
+
if key in self.variables:
|
|
33
|
+
return self.variables[key]
|
|
34
|
+
|
|
35
|
+
# Attribute doesn't exist, provide some debug info for the developer
|
|
36
|
+
available_keys = ", ".join(self.variables.keys())
|
|
37
|
+
raise AttributeError(
|
|
38
|
+
f'Attribute "{key}" not found in {type(self).__name__} (class {self.__class__.__name__}). Available: {available_keys}'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def _mkchild(self, key: str, resource, *args, **kwargs):
|
|
42
|
+
"""Helper that sets necessary fields for Pyramid traversal"""
|
|
43
|
+
child = resource(self.request, **{**self.variables, **kwargs})
|
|
44
|
+
child.__parent__ = self
|
|
45
|
+
child.__name__ = key
|
|
46
|
+
|
|
47
|
+
return child
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def get_dynamic_openapi_info(cls):
|
|
51
|
+
"""Returns information about a child resoure and the url parameters required for it.
|
|
52
|
+
Implement it if your resource does some kind of lookup for its children"""
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def _determine_is_reserved_keyname(self, key):
|
|
56
|
+
"""Uses pyramid introspection (https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/introspector.html)
|
|
57
|
+
to determine if there is a view registered to this resource with the given key name. If so, we probably don't want to look it up in SQL,
|
|
58
|
+
so this function is a helper for used in __getitem__ implementations.
|
|
59
|
+
"""
|
|
60
|
+
log.debug(f"Checking if {key} is reserved")
|
|
61
|
+
if key in self._known_views.keys() and (
|
|
62
|
+
self._known_views[key] is None
|
|
63
|
+
or self.request.method in self._known_views[key]
|
|
64
|
+
):
|
|
65
|
+
# Note that we cannot store if a key is NOT a view as it would lead to a DOS vulnerability
|
|
66
|
+
log.debug(f"{self.request.method} {key} is known to be a view")
|
|
67
|
+
return True
|
|
68
|
+
# Performance tests on my local machine shows this function costs ~0.04ms. Debug logging is orders of magnitude more expensive.
|
|
69
|
+
# In the future we could try wrapping all the resource classes in factories that can perform the introspection at config-time?
|
|
70
|
+
t = time.perf_counter_ns()
|
|
71
|
+
|
|
72
|
+
introspector = self.request.registry.introspector
|
|
73
|
+
view_intr = introspector.get_category("views")
|
|
74
|
+
for view in view_intr:
|
|
75
|
+
context = view["introspectable"]["context"]
|
|
76
|
+
if context == self.__class__:
|
|
77
|
+
name = view["introspectable"]["name"]
|
|
78
|
+
request_methods = view["introspectable"]["request_methods"]
|
|
79
|
+
if name == key and (
|
|
80
|
+
request_methods is None or self.request.method in request_methods
|
|
81
|
+
):
|
|
82
|
+
t_e = time.perf_counter_ns()
|
|
83
|
+
# We want to be aware of traversal time for now.
|
|
84
|
+
log.debug(
|
|
85
|
+
f"Stopping traversal, done in {(t_e - t) / 1000 / 1000}ms"
|
|
86
|
+
)
|
|
87
|
+
self._known_views[key] = request_methods
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pyramid_traversal_api.resource import Resource
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ResourceCollectionValidatorMeta(type):
|
|
5
|
+
def __new__(cls, clsname, bases, attrs):
|
|
6
|
+
if clsname != "ResourceCollection":
|
|
7
|
+
if "child_resource_class" not in attrs:
|
|
8
|
+
raise ValueError(
|
|
9
|
+
f"child_resource_class must be set for ResourceClass when creating {clsname}"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
return super().__new__(cls, clsname, bases, attrs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResourceCollection(Resource, metaclass=ResourceCollectionValidatorMeta):
|
|
16
|
+
"""A node that wraps a collection of a resource, usually fetched from a database or similar.
|
|
17
|
+
Override the query_collection function with your own query logic"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
def __getitem__(self, key):
|
|
23
|
+
if self._determine_is_reserved_keyname(key):
|
|
24
|
+
raise KeyError("A view exists by this name, so stopping traversal")
|
|
25
|
+
|
|
26
|
+
instance = self.query_item(key)
|
|
27
|
+
|
|
28
|
+
if not instance:
|
|
29
|
+
raise KeyError("No object found")
|
|
30
|
+
|
|
31
|
+
resource = self._mkchild(
|
|
32
|
+
key, self.child_resource_class, **{self.__name__: instance}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return resource
|
|
36
|
+
|
|
37
|
+
def query_item(self, key):
|
|
38
|
+
"""Function intended to be overloaded with your own query logic"""
|
|
39
|
+
raise NotImplementedError("Remember to implement query_item")
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def get_dynamic_openapi_info(cls):
|
|
43
|
+
raise NotImplementedError("get_dynamic_openapi_info needs to be implemented")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from sqlalchemy.sql.schema import Column
|
|
2
|
+
from sqlalchemy.orm.decl_api import DeclarativeBase
|
|
3
|
+
from pyramid_traversal_api.resource.collection import (
|
|
4
|
+
ResourceCollection,
|
|
5
|
+
ResourceCollectionValidatorMeta,
|
|
6
|
+
)
|
|
7
|
+
from pyramid_traversal_api.resource import Resource
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
import sqlalchemy
|
|
10
|
+
import pyramid.httpexceptions as exc
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Optional, Type, Any
|
|
14
|
+
|
|
15
|
+
from uuid import UUID
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Key normalizers automatically translate the key (which is a string) to whatever type is required for the column type
|
|
19
|
+
def uuid_normalizer(self, key):
|
|
20
|
+
# Due to SQLite, if the colum type is UUID, we have to convert the key to uuid first
|
|
21
|
+
try:
|
|
22
|
+
return UUID(key)
|
|
23
|
+
except ValueError:
|
|
24
|
+
raise exc.HTTPBadRequest("Bad UUID")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def noop_normalizer(self, key):
|
|
28
|
+
return key
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SqlaCollectionValidatorMeta(ResourceCollectionValidatorMeta):
|
|
32
|
+
"""Validates a ResourceSqlaCollection and performs other setup tasks at class creation time, so no runtime validation is necessary"""
|
|
33
|
+
|
|
34
|
+
def __new__(cls, clsname, bases, attrs):
|
|
35
|
+
if clsname != "ResourceSqlaCollection":
|
|
36
|
+
if "sql_class" not in attrs:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"sql_class must be set for a ResourceSqlaCollection when creating {clsname}"
|
|
39
|
+
)
|
|
40
|
+
if "child_resource_class" not in attrs:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"child_resource_class must be set for a ResourceSqlaCollection when creating {clsname}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Optional
|
|
46
|
+
if "query_field" not in attrs:
|
|
47
|
+
attrs["query_field"] = cls._determine_sql_field(
|
|
48
|
+
clsname, attrs["sql_class"]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Some key types may need to be normalized before querying, determine that now
|
|
52
|
+
match type(attrs["query_field"].type):
|
|
53
|
+
case sqlalchemy.sql.sqltypes.Uuid:
|
|
54
|
+
attrs["key_normalizer"] = uuid_normalizer
|
|
55
|
+
case _:
|
|
56
|
+
attrs["key_normalizer"] = noop_normalizer
|
|
57
|
+
|
|
58
|
+
return super().__new__(cls, clsname, bases, attrs)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _determine_sql_field(clsname, sql_class):
|
|
62
|
+
"""This function introspects the SQLAlchemy entity and finds the primary key."""
|
|
63
|
+
if len(sql_class.__table__.primary_key.columns) != 1:
|
|
64
|
+
raise NotImplementedError(
|
|
65
|
+
f"Error validating {clsname} - SqlChildResource does not support sql models with multiple primary keys - sorry!"
|
|
66
|
+
)
|
|
67
|
+
return sql_class.__table__.primary_key.columns[0]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ResourceSqlaCollection(ResourceCollection, metaclass=SqlaCollectionValidatorMeta):
|
|
71
|
+
"""Helper resource class for cases where the child node is an instance of an SQLAlchemy enity.
|
|
72
|
+
By default it will auto-detect the column to query by looking for the primary key. You can also explicitly specify the query column using `query_field` if needed.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# By ignoring typing here, we force any downstream class to implement these
|
|
76
|
+
# These need to be set for any inheriting class to work properly
|
|
77
|
+
sql_class: Type[DeclarativeBase] = None # type: ignore
|
|
78
|
+
child_resource_class: Type[Resource] = None # type: ignore
|
|
79
|
+
# Optional, overrides the field to be queried
|
|
80
|
+
query_field: Optional[Type[Column]] = None
|
|
81
|
+
|
|
82
|
+
# Auto-populated by the metaclass
|
|
83
|
+
key_normalizer: Callable[[str], Any]
|
|
84
|
+
|
|
85
|
+
def __init__(self, *args, **kwargs):
|
|
86
|
+
super().__init__(*args, **kwargs)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def get_dynamic_openapi_info(cls):
|
|
90
|
+
return {
|
|
91
|
+
"resource": cls.child_resource_class,
|
|
92
|
+
"params": [
|
|
93
|
+
{
|
|
94
|
+
"name": cls.sql_class.__table__.name + "_" + cls.query_field.name,
|
|
95
|
+
"description": f"Primary key of {cls.sql_class.__table__.name}",
|
|
96
|
+
"schema": {
|
|
97
|
+
# TODO: autodetect a better type based on the primary key field type?
|
|
98
|
+
"type": "string",
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def _query_object(self, key):
|
|
105
|
+
"""Performs the SQLAlchemy query to fetch the object. By default uses the provided query field, or the one autodetected by the constructor.
|
|
106
|
+
Override this if you have special requirements for how the query is made
|
|
107
|
+
|
|
108
|
+
The database session is assumed to be reachable on request and be named dbsession.
|
|
109
|
+
TODO: make this configurable"""
|
|
110
|
+
|
|
111
|
+
normalized_key = self.key_normalizer(key)
|
|
112
|
+
|
|
113
|
+
return self.request.dbsession.scalars(
|
|
114
|
+
select(self.sql_class).where(self.query_field == normalized_key)
|
|
115
|
+
).first()
|
|
116
|
+
|
|
117
|
+
def query_item(self, key):
|
|
118
|
+
"""Queries SQLAlchemy for the specified key"""
|
|
119
|
+
return self._query_object(key)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Implements a decorator that allows you to warn of and enforce using RFC8594"""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import typing
|
|
5
|
+
import pyramid.httpexceptions as exc
|
|
6
|
+
from email.utils import formatdate
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from time import mktime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sunset(sunset_datetime: str, link: typing.Optional[str]):
|
|
13
|
+
"""Implements the sunset HTTP header (RFC8594) for a view. The view will automatically stop working entirely once the date is passed.
|
|
14
|
+
This decorator can be used for automatically enforcing grade periods for deprecated views.
|
|
15
|
+
|
|
16
|
+
::param date:
|
|
17
|
+
The date (and time) of the endpoint being sunset in ISO 8601 format. The endpoint will automatically stop working after this date.
|
|
18
|
+
::param link:
|
|
19
|
+
An optional link to information about the sunsetting, provided to the suer in the Link header.
|
|
20
|
+
"""
|
|
21
|
+
parsed_sunset_datetime = datetime.fromisoformat(sunset_datetime)
|
|
22
|
+
headers = {
|
|
23
|
+
"Sunset": formatdate(
|
|
24
|
+
timeval=mktime(parsed_sunset_datetime.timetuple()),
|
|
25
|
+
localtime=False,
|
|
26
|
+
usegmt=True,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
if link is not None:
|
|
30
|
+
headers["Link"] = f'<{link}>;rel="sunset";type="text/html"'
|
|
31
|
+
|
|
32
|
+
def inner(f):
|
|
33
|
+
@functools.wraps(f)
|
|
34
|
+
def wrapper(context, request):
|
|
35
|
+
if datetime.now() > parsed_sunset_datetime:
|
|
36
|
+
raise exc.HTTPBadRequest(
|
|
37
|
+
"This endpoint has been sunset", headers=headers
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
for header in headers.keys():
|
|
41
|
+
request.response.headers[header] = headers[header]
|
|
42
|
+
|
|
43
|
+
return f(context, request)
|
|
44
|
+
|
|
45
|
+
return wrapper
|
|
46
|
+
|
|
47
|
+
return inner
|