-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathgeneric.py
247 lines (214 loc) · 11.2 KB
/
generic.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
from typing import TYPE_CHECKING
from pyramid.authentication import Authenticated
from pyramid.exceptions import PredicateMismatch
from pyramid.httpexceptions import (
HTTPException,
HTTPForbidden,
HTTPInternalServerError,
HTTPMethodNotAllowed,
HTTPNotAcceptable,
HTTPNotFound,
HTTPServerError,
HTTPUnauthorized
)
from pyramid.request import Request
from simplejson import JSONDecodeError
from magpie.api import exception as ax
from magpie.api import schemas as s
from magpie.api.requests import get_principals
from magpie.utils import (
CONTENT_TYPE_ANY,
CONTENT_TYPE_HTML,
CONTENT_TYPE_JSON,
FORMAT_TYPE_MAPPING,
SUPPORTED_ACCEPT_TYPES,
get_header,
get_logger,
is_magpie_ui_path
)
if TYPE_CHECKING:
# pylint: disable=W0611,unused-import
from typing import Callable, Optional, Tuple, Union
from pyramid.registry import Registry
from pyramid.response import Response
from magpie.typedefs import JSON, Str
LOGGER = get_logger(__name__)
def internal_server_error(request):
# type: (Request) -> HTTPException
"""
Overrides default HTTP.
"""
content = get_request_info(request, exception_details=True,
default_message=s.InternalServerErrorResponseSchema.description)
return ax.raise_http(nothrow=True, http_error=HTTPInternalServerError, detail=content["detail"], content=content)
def not_found_or_method_not_allowed(request):
# type: (Request) -> HTTPException
"""
Overrides the default ``HTTPNotFound`` [404] by appropriate ``HTTPMethodNotAllowed`` [405] when applicable.
Not found response can correspond to underlying process operation not finding a required item, or a completely
unknown route (path did not match any existing API definition).
Method not allowed is more specific to the case where the path matches an existing API route, but the specific
request method (GET, POST, etc.) is not allowed on this path.
Without this fix, both situations return [404] regardless.
"""
if isinstance(request.exception, PredicateMismatch) and request.method not in ["HEAD", "GET"]:
http_err = HTTPMethodNotAllowed
http_msg = "" # auto-generated by HTTPMethodNotAllowed
else:
http_err = HTTPNotFound
http_msg = s.NotFoundResponseSchema.description
content = get_request_info(request, default_message=http_msg)
return ax.raise_http(nothrow=True, http_error=http_err, detail=content["detail"], content=content)
def unauthorized_or_forbidden(request):
# type: (Request) -> HTTPException
"""
Overrides the default ``HTTPForbidden`` [403] by appropriate ``HTTPUnauthorized`` [401] when applicable.
Unauthorized response is for restricted user access according to missing credentials and/or authorization headers.
Forbidden response is for operation refused by the underlying process operations or due to insufficient permissions.
Without this fix, both situations return [403] regardless.
.. seealso::
- http://www.restapitutorial.com/httpstatuscodes.html
In case the request references to `Magpie UI` route, it is redirected to
:meth:`magpie.ui.home.HomeViews.error_view` for it to handle and display the error accordingly.
"""
http_err = HTTPForbidden
http_msg = s.HTTPForbiddenResponseSchema.description
principals = get_principals(request)
if Authenticated not in principals:
http_err = HTTPUnauthorized
http_msg = s.UnauthorizedResponseSchema.description
content = get_request_info(request, default_message=http_msg)
if is_magpie_ui_path(request):
# need to handle 401/403 immediately otherwise target view is not even called
from magpie.ui.utils import redirect_error
return redirect_error(request, code=http_err.code, content=content)
return ax.raise_http(nothrow=True, http_error=http_err, detail=content["detail"], content=content)
def guess_target_format(request):
# type: (Request) -> Tuple[Str, bool]
"""
Guess the best applicable response ``Content-Type`` header according to request ``Accept`` header and ``format``
query, or defaulting to :py:data:`CONTENT_TYPE_JSON`.
:returns: tuple of matched MIME-type and where it was found (``True``: header, ``False``: query)
"""
content_type = FORMAT_TYPE_MAPPING.get(request.params.get("format"))
is_header = False
if not content_type:
is_header = True
content_type = get_header("accept", request.headers, default=CONTENT_TYPE_JSON, split=";,")
if content_type != CONTENT_TYPE_JSON:
# because most browsers enforce some 'visual' list of accept header, revert to JSON if detected
# explicit request set by other client (e.g.: using 'requests') will have full control over desired content
user_agent = get_header("user-agent", request.headers)
if user_agent and any(browser in user_agent for browser in ["Mozilla", "Chrome", "Safari"]):
content_type = CONTENT_TYPE_JSON
if not content_type or content_type == CONTENT_TYPE_ANY:
is_header = True
content_type = CONTENT_TYPE_JSON
return content_type, is_header
def validate_accept_header_tween(handler, registry): # noqa: F811
# type: (Callable[[Request], Response], Registry) -> Callable[[Request], Response]
"""
Tween that validates that the specified request ``Accept`` header or ``format`` query (if any) is a supported one by
the application and for the given context.
:raises HTTPNotAcceptable: if desired ``Content-Type`` is not supported.
"""
def validate_format(request):
# type: (Request) -> Response
"""
Validates the specified request according to its ``Accept`` header or ``format`` query, ignoring UI related
routes that require more content-types than the ones supported by the API for displaying purposes of other
elements (styles, images, etc.).
"""
if not is_magpie_ui_path(request):
accept, _ = guess_target_format(request)
http_msg = s.NotAcceptableResponseSchema.description
content = get_request_info(request, default_message=http_msg)
ax.verify_param(accept, is_in=True, param_compare=SUPPORTED_ACCEPT_TYPES,
param_name="Accept Header or Format Query",
http_error=HTTPNotAcceptable, msg_on_fail=http_msg,
content=content, content_type=CONTENT_TYPE_JSON) # enforce type to avoid recursion
return handler(request)
return validate_format
def apply_response_format_tween(handler, registry): # noqa: F811
# type: (Callable[[Request], HTTPException], Registry) -> Callable[[Request], Response]
"""
Tween that obtains the request ``Accept`` header or ``format`` query (if any) to generate the response with the
desired ``Content-Type``.
The target ``Content-Type`` is expected to have been validated by :func:`validate_accept_header_tween` beforehand
to handle not-acceptable errors.
The tween also ensures that additional request metadata extracted from :func:`get_request_info` is applied to
the response body if not already provided by a previous operation.
"""
def apply_format(request):
# type: (Request) -> HTTPException
"""
Validates the specified request according to its ``Accept`` header, ignoring UI related routes that request more
content-types than the ones supported by the application for display purposes (styles, images etc.).
Alternatively, if no ``Accept`` header is found, look for equivalent value provided via query parameter.
"""
# all magpie API routes expected to either call 'valid_http' or 'raise_http' of 'magpie.api.exception' module
# an HTTPException is always returned, and content is a JSON-like string
content_type, is_header = guess_target_format(request)
if not is_header:
# NOTE:
# enforce the accept header in case it was specified with format query, since some renderer implementations
# will afterward erroneously overwrite the 'content-type' value that we enforce when converting the response
# from the HTTPException. See:
# - https://github.com/Pylons/webob/issues/204
# - https://github.com/Pylons/webob/issues/238
# - https://github.com/Pylons/pyramid/issues/1344
request.accept = content_type
resp = handler(request) # no exception when EXCVIEW tween is placed under this tween
if is_magpie_ui_path(request):
if not resp.content_type:
resp.content_type = CONTENT_TYPE_HTML
return resp
# return routes already converted (valid_http/raise_http where not used, pyramid already generated response)
if not isinstance(resp, HTTPException):
return resp
# forward any headers such as session cookies to be applied
metadata = get_request_info(request)
resp_kwargs = {"headers": resp.headers}
return ax.generate_response_http_format(type(resp), resp_kwargs, resp.text, content_type, metadata)
return apply_format
def get_exception_info(response, content=None, exception_details=False):
# type: (Union[HTTPException, Request, Response], Optional[JSON], bool) -> JSON
"""
Obtains additional exception content details about the :paramref:`response` according to available information.
"""
content = content or {}
if hasattr(response, "exception"):
# handle error raised simply by checking for "json" property in python 3 when body is invalid
has_json = False
try:
has_json = hasattr(response.exception, "json")
except JSONDecodeError:
pass
if has_json and isinstance(response.exception.json, dict):
content.update(response.exception.json)
elif isinstance(response.exception, HTTPServerError) and hasattr(response.exception, "message"):
content.update({"exception": str(response.exception.message)})
elif isinstance(response.exception, Exception) and exception_details:
content.update({"exception": type(response.exception).__name__})
# get 'request.exc_info' or 'sys.exc_info', whichever one is available
LOGGER.error("Request exception.", exc_info=getattr(response, "exc_info", True))
if not content.get("detail"):
detail = response.exception
content["detail"] = str(detail) if detail is not None else None
elif hasattr(response, "matchdict"):
if response.matchdict is not None and response.matchdict != "":
content.update(response.matchdict)
return content
def get_request_info(request, default_message=None, exception_details=False):
# type: (Union[Request, HTTPException], Optional[Str], bool) -> JSON
"""
Obtains additional content details about the :paramref:`request` according to available information.
"""
content = {
"path": str(request.upath_info),
"url": str(request.url),
"detail": default_message,
"method": request.method
}
content.update(get_exception_info(request, content=content, exception_details=exception_details))
return content