Skip to content

Commit 48aadd8

Browse files
committed
Merge branch 'release/0.4.7'
2 parents 4531385 + b8112c0 commit 48aadd8

19 files changed

+407
-52
lines changed

README.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,32 @@ Start the flask app and you can browse to the swagger UI to try out the API::
171171

172172
http://localhost:5000/api/v1/swagger/ui
173173

174+
175+
176+
CORS
177+
====
178+
179+
To enable CORS your API Interface (for Flask this is `ApiBlueprint`) needs to
180+
be wrapped with the CORS wrapper eg::
181+
182+
from flask import Flask
183+
from odinweb.flask import ApiBlueprint
184+
from odinweb.cors import CORS, AnyOrigin
185+
186+
app = flask.Flask(__name__)
187+
188+
app.register_blueprint(
189+
CORS(
190+
ApiBlueprint(
191+
api.ApiVersion(
192+
UserApi()
193+
)
194+
),
195+
origins=AnyOrigin
196+
)
197+
)
198+
199+
For customisation the CORS class can easily be inherited to customise how the
200+
origin is determined (handy if your application is behind a reverse proxy).
201+
The CORS wrapper also accepts `max_age`, `allow_credentials`, `expose_headers`
202+
and `allow_headers` options.

odinweb/api.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,36 @@
99
__author_email__ = "tim@savage.company"
1010
__copyright__ = "Copyright (C) 2016-2017 Tim Savage"
1111

12-
from .constants import * # noqa
13-
from .containers import * # noqa
14-
from .decorators import * # noqa
15-
from .exceptions import * # noqa
16-
from .helpers import * # noqa
17-
from .data_structures import UrlPath, PathParam # noqa
12+
from .constants import (
13+
HTTPStatus,
14+
Method,
15+
PathType,
16+
In,
17+
Type,
18+
) # noqa
19+
from .containers import (
20+
ResourceApi,
21+
ApiCollection,
22+
ApiVersion,
23+
) # noqa
24+
from .decorators import (
25+
Operation, ListOperation, ResourceOperation, security,
26+
# Basic routes
27+
collection, collection_action, action, operation,
28+
# Shortcuts
29+
listing, create, detail, update, patch, delete,
30+
) # noqa
31+
from .exceptions import (
32+
ImmediateHttpResponse,
33+
HttpError,
34+
PermissionDenied,
35+
AccessDenied,
36+
) # noqa
37+
from .helpers import (
38+
get_resource,
39+
create_response,
40+
) # noqa
41+
from .data_structures import (
42+
UrlPath,
43+
PathParam,
44+
) # noqa

odinweb/constants.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33

44
from odin import fields
55

6-
__all__ = ('HTTPStatus', 'Method', 'PathType', 'In', 'Type')
7-
86
try:
97
from http import HTTPStatus
108
except ImportError:

odinweb/containers.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,26 @@
77
"""
88
from __future__ import absolute_import
99

10-
import logging
11-
12-
# Imports for typing support
1310
import collections
14-
from typing import Union, Tuple, Any, Generator, Dict, Type # noqa
15-
from odin import Resource # noqa
11+
import logging
1612

1713
from odin.codecs import json_codec
1814
from odin.exceptions import ValidationError
1915
from odin.utils import getmeta
2016

17+
# Imports for typing support
18+
from typing import Union, Tuple, Any, Generator, Dict, Type # noqa
19+
from odin import Resource # noqa
20+
2121
from . import _compat
2222
from . import content_type_resolvers
2323
from .constants import Method, HTTPStatus
2424
from .data_structures import UrlPath, NoPath, HttpResponse, MiddlewareList
25-
from .decorators import Operation
25+
from .decorators import Operation, Tags
2626
from .exceptions import ImmediateHttpResponse
2727
from .helpers import resolve_content_type, create_response
2828
from .resources import Error
2929

30-
__all__ = ('ResourceApi', 'ApiCollection', 'ApiVersion')
3130

3231
logger = logging.getLogger(__name__)
3332

@@ -157,7 +156,7 @@ def __init__(self, *containers, **options):
157156
for container in self.containers:
158157
container.parent = self
159158

160-
# Having options at the end is work around until support for
159+
# Having options at the end is work around until support for
161160
# Python < 3.5 is dropped, at that point keyword only args will
162161
# be used in place of the options kwargs. eg:
163162
# (*containers:Union[Operation, ApiContainer], name:str=None, path_prefix:Union[str, UrlPath]=None) -> None
@@ -382,14 +381,17 @@ def _dispatch(self, operation, request, path_args):
382381

383382
def dispatch(self, operation, request, **path_args):
384383
"""
385-
Dispatch incoming request and capture top level exeptions.
384+
Dispatch incoming request and capture top level exceptions.
386385
"""
387386
# Add current operation to the request (for convenience in middleware methods)
388387
request.current_operation = operation
389388

390389
try:
391390
for middleware in self.middleware.pre_request:
392-
middleware(request, path_args)
391+
response = middleware(request, path_args)
392+
# Return HttpResponse if one is returned.
393+
if isinstance(response, HttpResponse):
394+
return response
393395

394396
response = self._dispatch(operation, request, path_args)
395397

odinweb/cors.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from __future__ import absolute_import
2+
3+
from . import api
4+
from .data_structures import HttpResponse, UrlPath
5+
from .utils import dict_filter
6+
7+
# Imports for typing support
8+
from typing import Optional, Any, Sequence, Tuple, Dict, Union, List, Type # noqa
9+
from .containers import ApiInterfaceBase # noqa
10+
11+
12+
class AnyOrigin(object):
13+
pass
14+
15+
16+
Origins = Union[Sequence[str], Type[AnyOrigin]]
17+
18+
19+
class CORS(object):
20+
"""
21+
CORS (Cross-Origin Request Sharing) support for OdinWeb APIs.
22+
23+
See `MDN documentation <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_
24+
for a technical description of CORS.
25+
26+
:param origins: List of whitelisted origins or use `AnyOrigin` to return a
27+
'*' or allow all.
28+
:param max_age: Max length of time access control headers can be cached
29+
in seconds. `None`, disables this header; a value of -1 will disable
30+
caching, requiring a pre-flight *OPTIONS* check for all calls.
31+
:param allow_credentials: Indicate that credentials can be submitted to
32+
this API.
33+
:param expose_headers: Request headers can be access by a client beyond
34+
the simple headers, *Cache-Control*, *Content-Language*,
35+
*Content-Type*, *Expires*, *Last-Modified*, *Pragma*.
36+
:param allow_headers: Headers that are allowed to be sent by the browser
37+
beyond the simple headers, *Accept*, *Accept-Language*,
38+
*Content-Language*, *Content-Type*.
39+
40+
"""
41+
priority = 1
42+
43+
def __new__(cls, api_interface, *args, **kwargs):
44+
# type: (CORS, ApiInterfaceBase, *Any, **Any) -> ApiInterfaceBase
45+
instance = object.__new__(cls)
46+
instance.__init__(api_interface, *args, **kwargs)
47+
48+
# Add instance as middleware
49+
api_interface.middleware.append(instance)
50+
51+
return api_interface
52+
53+
def __init__(self, api_interface, origins, max_age=None, allow_credentials=None,
54+
expose_headers=None, allow_headers=None):
55+
# type: (ApiInterfaceBase, Origins, Optional[int], Optional[bool], Sequence[str], Sequence[str]) -> None
56+
self.origins = origins if origins is AnyOrigin else set(origins)
57+
self.max_age = max_age
58+
self.expose_headers = expose_headers
59+
self.allow_headers = allow_headers
60+
self.allow_credentials = allow_credentials
61+
62+
self._register_options(api_interface)
63+
64+
def _register_options(self, api_interface):
65+
# type: (ApiInterfaceBase) -> None
66+
"""
67+
Register CORS options endpoints.
68+
"""
69+
op_paths = api_interface.op_paths(collate_methods=True)
70+
for path, operations in op_paths.items():
71+
if api.Method.OPTIONS not in operations:
72+
self._options_operation(api_interface, path, operations.keys())
73+
74+
def _options_operation(self, api_interface, path, methods):
75+
# type: (ApiInterfaceBase, UrlPath, List[api.Method]) -> None
76+
"""
77+
Generate an options operation for the specified path
78+
"""
79+
# Trim off path prefix.
80+
if path.startswith(api_interface.path_prefix):
81+
path = path[len(api_interface.path_prefix):]
82+
83+
methods = set(methods)
84+
methods.add(api.Method.OPTIONS)
85+
86+
@api_interface.operation(path, api.Method.OPTIONS)
87+
def _cors_options(request, **_):
88+
return HttpResponse(None, headers=self.option_headers(request, methods))
89+
90+
_cors_options.operation_id = path.format(separator='.') + '.cors_options'
91+
92+
def origin_components(self, request):
93+
# type: (Any) -> Tuple[str, str]
94+
"""
95+
Return URL components that make up the origin.
96+
97+
This allows for customisation in the case or custom headers/proxy
98+
configurations.
99+
100+
:return: Tuple consisting of Scheme, Host/Port
101+
102+
"""
103+
return request.scheme, request.host
104+
105+
def allow_origin(self, request):
106+
# type: (Any) -> str
107+
"""
108+
Generate allow origin header
109+
"""
110+
origins = self.origins
111+
if origins is AnyOrigin:
112+
return '*'
113+
else:
114+
origin = "{}://{}".format(*self.origin_components(request))
115+
return origin if origin in origins else ''
116+
117+
def option_headers(self, request, methods):
118+
# type: (Any, Sequence[api.Method]) -> Dict[str, str]
119+
"""
120+
Generate option headers.
121+
"""
122+
return dict_filter({
123+
'Access-Control-Allow-Origin': self.allow_origin(request),
124+
'Access-Control-Allow-Methods': ', '.join(m.value for m in methods),
125+
'Access-Control-Allow-Credentials': {True: 'true', False: 'false'}.get(self.allow_credentials),
126+
'Access-Control-Allow-Headers': ', '.join(self.allow_headers) if self.allow_headers else None,
127+
'Access-Control-Expose-Headers': ', '.join(self.expose_headers) if self.expose_headers else None,
128+
'Access-Control-Max-Age': str(self.max_age) if self.max_age else None,
129+
'Cache-Control': 'no-cache, no-store'
130+
})
131+
132+
def post_request(self, request, response):
133+
# type: (Any, HttpResponse) -> HttpResponse
134+
"""
135+
Post-request hook to allow CORS headers to responses.
136+
"""
137+
if request.method != api.Method.OPTIONS:
138+
response.headers['Access-Control-Allow-Origin'] = self.allow_origin(request)
139+
return response

odinweb/data_structures.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
from .constants import HTTPStatus, In, Type
1313
from .utils import dict_filter, sort_by_priority
1414

15-
__all__ = ('DefaultResource', 'HttpResponse', 'UrlPath', 'PathParam', 'NoPath', 'Param', 'Response')
16-
1715

1816
class DefaultResource(object):
1917
"""
@@ -98,7 +96,7 @@ def _to_swagger(base=None, description=None, resource=None, options=None):
9896

9997

10098
# Naming scheme that follows standard python naming rules for variables/methods
101-
PATH_NODE_RE = re.compile(r'^{([a-zA-Z]\w*)(?::([a-zA-Z]\w*))?(?::([-^$+*:\w\\\[\]\|]+))?}$')
99+
PATH_NODE_RE = re.compile(r'^{([a-zA-Z]\w*)(?::([a-zA-Z]\w*))?(?::([-^$+*:\w\\\[\]|]+))?}$')
102100

103101

104102
class UrlPath(object):
@@ -167,6 +165,9 @@ def __hash__(self):
167165
def __str__(self):
168166
return self.format()
169167

168+
def __len__(self):
169+
return len(self._nodes)
170+
170171
def __repr__(self):
171172
return "{}({})".format(
172173
self.__class__.__name__,
@@ -201,6 +202,18 @@ def __getitem__(self, item):
201202
# type: (Union[int, slice]) -> UrlPath
202203
return UrlPath(*force_tuple(self._nodes[item]))
203204

205+
def startswith(self, other):
206+
# type: (UrlPath) -> bool
207+
"""
208+
Return True if this path starts with the other path.
209+
"""
210+
try:
211+
other = UrlPath.from_object(other)
212+
except ValueError:
213+
raise TypeError('startswith first arg must be UrlPath, str, PathParam, not {}'.format(type(other)))
214+
else:
215+
return self._nodes[:len(other._nodes)] == other._nodes
216+
204217
def apply_args(self, **kwargs):
205218
# type: (**str) -> UrlPath
206219
"""
@@ -249,7 +262,7 @@ def odinweb_node_formatter(path_node):
249262
args.append(path_node.type_args)
250263
return "{{{}}}".format(':'.join(args))
251264

252-
def format(self, node_formatter=None):
265+
def format(self, node_formatter=None, separator='/'):
253266
# type: (Optional[Callable[[PathParam], str]]) -> str
254267
"""
255268
Format a URL path.
@@ -259,10 +272,10 @@ def format(self, node_formatter=None):
259272
260273
"""
261274
if self._nodes == ('',):
262-
return '/'
275+
return separator
263276
else:
264277
node_formatter = node_formatter or self.odinweb_node_formatter
265-
return '/'.join(node_formatter(n) if isinstance(n, PathParam) else n for n in self._nodes)
278+
return separator.join(node_formatter(n) if isinstance(n, PathParam) else n for n in self._nodes)
266279

267280

268281
NoPath = UrlPath()
@@ -759,7 +772,7 @@ def values(self, multi=False):
759772
itervalues = values
760773

761774
def valuelists(self):
762-
# type: (bool) -> Iterator[List[Any]]
775+
# type: () -> Iterator[List[Any]]
763776
"""
764777
Return an iterator of all values associated with a key. Zipping
765778
:meth:`keys` and this is the same as calling :meth:`lists`:

0 commit comments

Comments
 (0)