blob: 83e7acb1a9eab6fd0e123f1e8ef4a652e65747bb [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Dispatcher middleware for Cloud Endpoints API server.
16
17This middleware does simple transforms on requests that come into the base path
18and then re-dispatches them to the main backend. It does not do any
19authentication, quota checking, DoS checking, etc.
20
21In addition, the middleware loads API configs prior to each call, in case the
22configuration has changed.
23"""
24
25# pylint: disable=g-bad-name
26from __future__ import absolute_import
27
28from six.moves import cStringIO
29from six.moves import http_client
30import json
31import logging
32import re
33import six
34from six.moves import urllib
35import wsgiref
36
37import pkg_resources
38
39from . import api_config_manager
40from . import api_exceptions
41from . import api_request
42from . import discovery_service
43from . import errors
44from . import parameter_converter
45from . import util
46
47_logger = logging.getLogger(__name__)
48
49
50__all__ = ['EndpointsDispatcherMiddleware']
51
52_SERVER_SOURCE_IP = '0.2.0.3'
53
54# Internal constants
55_CORS_HEADER_ORIGIN = 'Origin'
56_CORS_HEADER_REQUEST_METHOD = 'Access-Control-Request-Method'
57_CORS_HEADER_REQUEST_HEADERS = 'Access-Control-Request-Headers'
58_CORS_HEADER_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'
59_CORS_HEADER_ALLOW_METHODS = 'Access-Control-Allow-Methods'
60_CORS_HEADER_ALLOW_HEADERS = 'Access-Control-Allow-Headers'
61_CORS_HEADER_ALLOW_CREDS = 'Access-Control-Allow-Credentials'
62_CORS_HEADER_EXPOSE_HEADERS = 'Access-Control-Expose-Headers'
63_CORS_ALLOWED_METHODS = frozenset(('DELETE', 'GET', 'PATCH', 'POST', 'PUT'))
64_CORS_EXPOSED_HEADERS = frozenset(
65 ('Content-Encoding', 'Content-Length', 'Date', 'ETag', 'Server')
66)
67
68PROXY_HTML = pkg_resources.resource_string('endpoints', 'proxy.html')
69PROXY_PATH = 'static/proxy.html'
70
71
72class EndpointsDispatcherMiddleware(object):
73 """Dispatcher that handles requests to the built-in apiserver handlers."""
74
75 _API_EXPLORER_URL = 'https://apis-explorer.appspot.com/apis-explorer/?base='
76
77 def __init__(self, backend_wsgi_app, config_manager=None):
78 """Constructor for EndpointsDispatcherMiddleware.
79
80 Args:
81 backend_wsgi_app: A WSGI server that serves the app's endpoints.
82 config_manager: An ApiConfigManager instance that allows a caller to
83 set up an existing configuration for testing.
84 """
85 if config_manager is None:
86 config_manager = api_config_manager.ApiConfigManager()
87 self.config_manager = config_manager
88
89 self._backend = backend_wsgi_app
90 self._dispatchers = []
91 for base_path in self._backend.base_paths:
92 self._add_dispatcher('%sexplorer/?$' % base_path,
93 self.handle_api_explorer_request)
94 self._add_dispatcher('%sstatic/.*$' % base_path,
95 self.handle_api_static_request)
96
97 # Get API configuration so we know how to call the backend.
98 api_config_response = self.get_api_configs()
99 if api_config_response:
100 self.config_manager.process_api_config_response(api_config_response)
101 else:
102 raise api_exceptions.ApiConfigurationError('get_api_configs() returned no configs')
103
104 def _add_dispatcher(self, path_regex, dispatch_function):
105 """Add a request path and dispatch handler.
106
107 Args:
108 path_regex: A string regex, the path to match against incoming requests.
109 dispatch_function: The function to call for these requests. The function
110 should take (request, start_response) as arguments and
111 return the contents of the response body.
112 """
113 self._dispatchers.append((re.compile(path_regex), dispatch_function))
114
115 def _get_explorer_base_url(self, protocol, server, port, base_path):
116 show_port = ((protocol == 'http' and port != 80) or
117 (protocol != 'http' and port != 443))
118 url = ('{0}://{1}:{2}/{3}'.format(
119 protocol, server, port, base_path.lstrip('/\\')) if show_port else
120 '{0}://{1}/{2}'.format(protocol, server, base_path.lstrip('/\\')))
121
122 return url.rstrip('/\\')
123
124 def _get_explorer_redirect_url(self, server, port, base_path):
125 protocol = 'http' if 'localhost' in server else 'https'
126 base_url = self._get_explorer_base_url(protocol, server, port, base_path)
127 return self._API_EXPLORER_URL + base_url
128
129 def __call__(self, environ, start_response):
130 """Handle an incoming request.
131
132 Args:
133 environ: An environ dict for the request as defined in PEP-333.
134 start_response: A function used to begin the response to the caller.
135 This follows the semantics defined in PEP-333. In particular, it's
136 called with (status, response_headers, exc_info=None), and it returns
137 an object with a write(body_data) function that can be used to write
138 the body of the response.
139
140 Yields:
141 An iterable over strings containing the body of the HTTP response.
142 """
143 request = api_request.ApiRequest(environ,
144 base_paths=self._backend.base_paths)
145
146 # PEP-333 requires that we return an iterator that iterates over the
147 # response body. Yielding the returned body accomplishes this.
148 yield self.dispatch(request, start_response)
149
150 def dispatch(self, request, start_response):
151 """Handles dispatch to apiserver handlers.
152
153 This typically ends up calling start_response and returning the entire
154 body of the response.
155
156 Args:
157 request: An ApiRequest, the request from the user.
158 start_response: A function with semantics defined in PEP-333.
159
160 Returns:
161 A string, the body of the response.
162 """
163 # Check if this matches any of our special handlers.
164 dispatched_response = self.dispatch_non_api_requests(request,
165 start_response)
166 if dispatched_response is not None:
167 return dispatched_response
168
169 # Call the service.
170 try:
171 return self.call_backend(request, start_response)
172 except errors.RequestError as error:
173 return self._handle_request_error(request, error, start_response)
174
175 def dispatch_non_api_requests(self, request, start_response):
176 """Dispatch this request if this is a request to a reserved URL.
177
178 If the request matches one of our reserved URLs, this calls
179 start_response and returns the response body. This also handles OPTIONS
180 CORS requests.
181
182 Args:
183 request: An ApiRequest, the request from the user.
184 start_response: A function with semantics defined in PEP-333.
185
186 Returns:
187 None if the request doesn't match one of the reserved URLs this
188 handles. Otherwise, returns the response body.
189 """
190 for path_regex, dispatch_function in self._dispatchers:
191 if path_regex.match(request.relative_url):
192 return dispatch_function(request, start_response)
193
194 if request.http_method == 'OPTIONS':
195 cors_handler = self._create_cors_handler(request)
196 if cors_handler.allow_cors_request:
197 # The server returns 200 rather than 204, for some reason.
198 return util.send_wsgi_response('200', [], '', start_response,
199 cors_handler)
200
201 return None
202
203 def handle_api_explorer_request(self, request, start_response):
204 """Handler for requests to {base_path}/explorer.
205
206 This calls start_response and returns the response body.
207
208 Args:
209 request: An ApiRequest, the request from the user.
210 start_response: A function with semantics defined in PEP-333.
211
212 Returns:
213 A string containing the response body (which is empty, in this case).
214 """
215 redirect_url = self._get_explorer_redirect_url(
216 request.server, request.port, request.base_path)
217 return util.send_wsgi_redirect_response(redirect_url, start_response)
218
219 def handle_api_static_request(self, request, start_response):
220 """Handler for requests to {base_path}/static/.*.
221
222 This calls start_response and returns the response body.
223
224 Args:
225 request: An ApiRequest, the request from the user.
226 start_response: A function with semantics defined in PEP-333.
227
228 Returns:
229 A string containing the response body.
230 """
231 if request.path == PROXY_PATH:
232 return util.send_wsgi_response('200 OK',
233 [('Content-Type',
234 'text/html')],
235 PROXY_HTML, start_response)
236 else:
237 _logger.debug('Unknown static url requested: %s',
238 request.relative_url)
239 return util.send_wsgi_response('404 Not Found', [('Content-Type',
240 'text/plain')], 'Not Found',
241 start_response)
242
243 def get_api_configs(self):
244 return self._backend.get_api_configs()
245
246 @staticmethod
247 def verify_response(response, status_code, content_type=None):
248 """Verifies that a response has the expected status and content type.
249
250 Args:
251 response: The ResponseTuple to be checked.
252 status_code: An int, the HTTP status code to be compared with response
253 status.
254 content_type: A string with the acceptable Content-Type header value.
255 None allows any content type.
256
257 Returns:
258 True if both status_code and content_type match, else False.
259 """
260 status = int(response.status.split(' ', 1)[0])
261 if status != status_code:
262 return False
263
264 if content_type is None:
265 return True
266
267 for header, value in response.headers:
268 if header.lower() == 'content-type':
269 return value == content_type
270
271 # If we fall through to here, the verification has failed, so return False.
272 return False
273
274 def prepare_backend_environ(self, host, method, relative_url, headers, body,
275 source_ip, port):
276 """Build an environ object for the backend to consume.
277
278 Args:
279 host: A string containing the host serving the request.
280 method: A string containing the HTTP method of the request.
281 relative_url: A string containing path and query string of the request.
282 headers: A list of (key, value) tuples where key and value are both
283 strings.
284 body: A string containing the request body.
285 source_ip: The source IP address for the request.
286 port: The port to which to direct the request.
287
288 Returns:
289 An environ object with all the information necessary for the backend to
290 process the request.
291 """
292 body = six.ensure_str(body, 'ascii')
293
294 url = urllib.parse.urlsplit(relative_url)
295 if port != 80:
296 host = '%s:%s' % (host, port)
297 else:
298 host = host
299 environ = {'CONTENT_LENGTH': str(len(body)),
300 'PATH_INFO': url.path,
301 'QUERY_STRING': url.query,
302 'REQUEST_METHOD': method,
303 'REMOTE_ADDR': source_ip,
304 'SERVER_NAME': host,
305 'SERVER_PORT': str(port),
306 'SERVER_PROTOCOL': 'HTTP/1.1',
307 'wsgi.version': (1, 0),
308 'wsgi.url_scheme': 'http',
309 'wsgi.errors': cStringIO.StringIO(),
310 'wsgi.multithread': True,
311 'wsgi.multiprocess': True,
312 'wsgi.input': cStringIO.StringIO(body)}
313 util.put_headers_in_environ(headers, environ)
314 environ['HTTP_HOST'] = host
315 return environ
316
317 def call_backend(self, orig_request, start_response):
318 """Generate API call (from earlier-saved request).
319
320 This calls start_response and returns the response body.
321
322 Args:
323 orig_request: An ApiRequest, the original request from the user.
324 start_response: A function with semantics defined in PEP-333.
325
326 Returns:
327 A string containing the response body.
328 """
329 method_config, params = self.lookup_rest_method(orig_request)
330 if not method_config:
331 cors_handler = self._create_cors_handler(orig_request)
332 return util.send_wsgi_not_found_response(start_response,
333 cors_handler=cors_handler)
334
335 # Prepare the request for the back end.
336 transformed_request = self.transform_request(
337 orig_request, params, method_config)
338
339 # Check if this call is for the Discovery service. If so, route
340 # it to our Discovery handler.
341 discovery = discovery_service.DiscoveryService(
342 self.config_manager, self._backend)
343 discovery_response = discovery.handle_discovery_request(
344 transformed_request.path, transformed_request, start_response)
345 if discovery_response:
346 return discovery_response
347
348 url = transformed_request.base_path + transformed_request.path
349 transformed_request.headers['Content-Type'] = 'application/json'
350 transformed_environ = self.prepare_backend_environ(
351 orig_request.server, 'POST', url, transformed_request.headers.items(),
352 transformed_request.body, transformed_request.source_ip,
353 orig_request.port)
354
355 # Send the transformed request to the backend app and capture the response.
356 with util.StartResponseProxy() as start_response_proxy:
357 body_iter = self._backend(transformed_environ, start_response_proxy.Proxy)
358 status = start_response_proxy.response_status
359 headers = start_response_proxy.response_headers
360
361 # Get response body
362 body = start_response_proxy.response_body
363 # In case standard WSGI behavior is implemented later...
364 if not body:
365 body = ''.join(body_iter)
366
367 return self.handle_backend_response(orig_request, transformed_request,
368 status, headers, body, method_config,
369 start_response)
370
371 class __CheckCorsHeaders(object):
372 """Track information about CORS headers and our response to them."""
373
374 def __init__(self, request):
375 self.allow_cors_request = False
376 self.origin = None
377 self.cors_request_method = None
378 self.cors_request_headers = None
379
380 self.__check_cors_request(request)
381
382 def __check_cors_request(self, request):
383 """Check for a CORS request, and see if it gets a CORS response."""
384 # Check for incoming CORS headers.
385 self.origin = request.headers[_CORS_HEADER_ORIGIN]
386 self.cors_request_method = request.headers[_CORS_HEADER_REQUEST_METHOD]
387 self.cors_request_headers = request.headers[
388 _CORS_HEADER_REQUEST_HEADERS]
389
390 # Check if the request should get a CORS response.
391 if (self.origin and
392 ((self.cors_request_method is None) or
393 (self.cors_request_method.upper() in _CORS_ALLOWED_METHODS))):
394 self.allow_cors_request = True
395
396 def update_headers(self, headers_in):
397 """Add CORS headers to the response, if needed."""
398 if not self.allow_cors_request:
399 return
400
401 # Add CORS headers.
402 headers = wsgiref.headers.Headers(headers_in)
403 headers[_CORS_HEADER_ALLOW_CREDS] = 'true'
404 headers[_CORS_HEADER_ALLOW_ORIGIN] = self.origin
405 headers[_CORS_HEADER_ALLOW_METHODS] = ','.join(tuple(
406 _CORS_ALLOWED_METHODS))
407 headers[_CORS_HEADER_EXPOSE_HEADERS] = ','.join(tuple(
408 _CORS_EXPOSED_HEADERS))
409 if self.cors_request_headers is not None:
410 headers[_CORS_HEADER_ALLOW_HEADERS] = self.cors_request_headers
411
412 def _create_cors_handler(self, request):
413 return EndpointsDispatcherMiddleware.__CheckCorsHeaders(request)
414
415 def handle_backend_response(self, orig_request, backend_request,
416 response_status, response_headers,
417 response_body, method_config, start_response):
418 """Handle backend response, transforming output as needed.
419
420 This calls start_response and returns the response body.
421
422 Args:
423 orig_request: An ApiRequest, the original request from the user.
424 backend_request: An ApiRequest, the transformed request that was
425 sent to the backend handler.
426 response_status: A string, the status from the response.
427 response_headers: A dict, the headers from the response.
428 response_body: A string, the body of the response.
429 method_config: A dict, the API config of the method to be called.
430 start_response: A function with semantics defined in PEP-333.
431
432 Returns:
433 A string containing the response body.
434 """
435 # Verify that the response is json. If it isn't treat, the body as an
436 # error message and wrap it in a json error response.
437 for header, value in response_headers:
438 if (header.lower() == 'content-type' and
439 not value.lower().startswith('application/json')):
440 return self.fail_request(orig_request,
441 'Non-JSON reply: %s' % response_body,
442 start_response)
443
444 self.check_error_response(response_body, response_status)
445
446 # Check if the response from the API was empty. Empty REST responses
447 # generate a HTTP 204.
448 empty_response = self.check_empty_response(orig_request, method_config,
449 start_response)
450 if empty_response is not None:
451 return empty_response
452
453 body = self.transform_rest_response(response_body)
454
455 cors_handler = self._create_cors_handler(orig_request)
456 return util.send_wsgi_response(response_status, response_headers, body,
457 start_response, cors_handler=cors_handler)
458
459 def fail_request(self, orig_request, message, start_response):
460 """Write an immediate failure response to outfile, no redirect.
461
462 This calls start_response and returns the error body.
463
464 Args:
465 orig_request: An ApiRequest, the original request from the user.
466 message: A string containing the error message to be displayed to user.
467 start_response: A function with semantics defined in PEP-333.
468
469 Returns:
470 A string containing the body of the error response.
471 """
472 cors_handler = self._create_cors_handler(orig_request)
473 return util.send_wsgi_error_response(
474 message, start_response, cors_handler=cors_handler)
475
476 def lookup_rest_method(self, orig_request):
477 """Looks up and returns rest method for the currently-pending request.
478
479 Args:
480 orig_request: An ApiRequest, the original request from the user.
481
482 Returns:
483 A tuple of (method descriptor, parameters), or (None, None) if no method
484 was found for the current request.
485 """
486 method_name, method, params = self.config_manager.lookup_rest_method(
487 orig_request.path, orig_request.request_uri, orig_request.http_method)
488 orig_request.method_name = method_name
489 return method, params
490
491 def transform_request(self, orig_request, params, method_config):
492 """Transforms orig_request to apiserving request.
493
494 This method uses orig_request to determine the currently-pending request
495 and returns a new transformed request ready to send to the backend. This
496 method accepts a rest-style or RPC-style request.
497
498 Args:
499 orig_request: An ApiRequest, the original request from the user.
500 params: A dictionary containing path parameters for rest requests, or
501 None for an RPC request.
502 method_config: A dict, the API config of the method to be called.
503
504 Returns:
505 An ApiRequest that's a copy of the current request, modified so it can
506 be sent to the backend. The path is updated and parts of the body or
507 other properties may also be changed.
508 """
509 method_params = method_config.get('request', {}).get('parameters', {})
510 request = self.transform_rest_request(orig_request, params, method_params)
511 request.path = method_config.get('rosyMethod', '')
512 return request
513
514 def _add_message_field(self, field_name, value, params):
515 """Converts a . delimitied field name to a message field in parameters.
516
517 This adds the field to the params dict, broken out so that message
518 parameters appear as sub-dicts within the outer param.
519
520 For example:
521 {'a.b.c': ['foo']}
522 becomes:
523 {'a': {'b': {'c': ['foo']}}}
524
525 Args:
526 field_name: A string containing the '.' delimitied name to be converted
527 into a dictionary.
528 value: The value to be set.
529 params: The dictionary holding all the parameters, where the value is
530 eventually set.
531 """
532 if '.' not in field_name:
533 params[field_name] = value
534 return
535
536 root, remaining = field_name.split('.', 1)
537 sub_params = params.setdefault(root, {})
538 self._add_message_field(remaining, value, sub_params)
539
540 def _update_from_body(self, destination, source):
541 """Updates the dictionary for an API payload with the request body.
542
543 The values from the body should override those already in the payload, but
544 for nested fields (message objects) the values can be combined
545 recursively.
546
547 Args:
548 destination: A dictionary containing an API payload parsed from the
549 path and query parameters in a request.
550 source: A dictionary parsed from the body of the request.
551 """
552 for key, value in source.items():
553 destination_value = destination.get(key)
554 if isinstance(value, dict) and isinstance(destination_value, dict):
555 self._update_from_body(destination_value, value)
556 else:
557 destination[key] = value
558
559 def transform_rest_request(self, orig_request, params, method_parameters):
560 """Translates a Rest request into an apiserving request.
561
562 This makes a copy of orig_request and transforms it to apiserving
563 format (moving request parameters to the body).
564
565 The request can receive values from the path, query and body and combine
566 them before sending them along to the backend. In cases of collision,
567 objects from the body take precedence over those from the query, which in
568 turn take precedence over those from the path.
569
570 In the case that a repeated value occurs in both the query and the path,
571 those values can be combined, but if that value also occurred in the body,
572 it would override any other values.
573
574 In the case of nested values from message fields, non-colliding values
575 from subfields can be combined. For example, if '?a.c=10' occurs in the
576 query string and "{'a': {'b': 11}}" occurs in the body, then they will be
577 combined as
578
579 {
580 'a': {
581 'b': 11,
582 'c': 10,
583 }
584 }
585
586 before being sent to the backend.
587
588 Args:
589 orig_request: An ApiRequest, the original request from the user.
590 params: A dict with URL path parameters extracted by the config_manager
591 lookup.
592 method_parameters: A dictionary containing the API configuration for the
593 parameters for the request.
594
595 Returns:
596 A copy of the current request that's been modified so it can be sent
597 to the backend. The body is updated to include parameters from the
598 URL.
599 """
600 request = orig_request.copy()
601 body_json = {}
602
603 # Handle parameters from the URL path.
604 for key, value in params.items():
605 # Values need to be in a list to interact with query parameter values
606 # and to account for case of repeated parameters
607 body_json[key] = [value]
608
609 # Add in parameters from the query string.
610 if request.parameters:
611 # For repeated elements, query and path work together
612 for key, value in request.parameters.items():
613 if key in body_json:
614 body_json[key] = value + body_json[key]
615 else:
616 body_json[key] = value
617
618 # Validate all parameters we've merged so far and convert any '.' delimited
619 # parameters to nested parameters. We don't use items since we may
620 # modify body_json within the loop. For instance, 'a.b' is not a valid key
621 # and would be replaced with 'a'.
622 for key, value in body_json.items():
623 current_parameter = method_parameters.get(key, {})
624 repeated = current_parameter.get('repeated', False)
625
626 if not repeated:
627 body_json[key] = body_json[key][0]
628
629 # Order is important here. Parameter names are dot-delimited in
630 # parameters instead of nested in dictionaries as a message field is, so
631 # we need to call transform_parameter_value on them before calling
632 # _add_message_field.
633 body_json[key] = parameter_converter.transform_parameter_value(
634 key, body_json[key], current_parameter)
635 # Remove the old key and try to convert to nested message value
636 message_value = body_json.pop(key)
637 self._add_message_field(key, message_value, body_json)
638
639 # Add in values from the body of the request.
640 if request.body_json:
641 self._update_from_body(body_json, request.body_json)
642
643 request.body_json = body_json
644 request.body = json.dumps(request.body_json)
645 return request
646
647 def check_error_response(self, body, status):
648 """Raise an exception if the response from the backend was an error.
649
650 Args:
651 body: A string containing the backend response body.
652 status: A string containing the backend response status.
653
654 Raises:
655 BackendError if the response is an error.
656 """
657 status_code = int(status.split(' ', 1)[0])
658 if status_code >= 300:
659 raise errors.BackendError(body, status)
660
661 def check_empty_response(self, orig_request, method_config, start_response):
662 """If the response from the backend is empty, return a HTTP 204 No Content.
663
664 Args:
665 orig_request: An ApiRequest, the original request from the user.
666 method_config: A dict, the API config of the method to be called.
667 start_response: A function with semantics defined in PEP-333.
668
669 Returns:
670 If the backend response was empty, this returns a string containing the
671 response body that should be returned to the user. If the backend
672 response wasn't empty, this returns None, indicating that we should not
673 exit early with a 204.
674 """
675 response_config = method_config.get('response', {}).get('body')
676 if response_config == 'empty':
677 # The response to this function should be empty. We should return a 204.
678 # Note that it's possible that the backend returned something, but we'll
679 # ignore it. This matches the behavior in the Endpoints server.
680 cors_handler = self._create_cors_handler(orig_request)
681 return util.send_wsgi_no_content_response(start_response, cors_handler)
682
683 def transform_rest_response(self, response_body):
684 """Translates an apiserving REST response so it's ready to return.
685
686 Currently, the only thing that needs to be fixed here is indentation,
687 so it's consistent with what the live app will return.
688
689 Args:
690 response_body: A string containing the backend response.
691
692 Returns:
693 A reformatted version of the response JSON.
694 """
695 body_json = json.loads(response_body)
696 return json.dumps(body_json, indent=1, sort_keys=True)
697
698 def _handle_request_error(self, orig_request, error, start_response):
699 """Handle a request error, converting it to a WSGI response.
700
701 Args:
702 orig_request: An ApiRequest, the original request from the user.
703 error: A RequestError containing information about the error.
704 start_response: A function with semantics defined in PEP-333.
705
706 Returns:
707 A string containing the response body.
708 """
709 headers = [('Content-Type', 'application/json')]
710 status_code = error.status_code()
711 body = error.rest_error()
712
713 response_status = '%d %s' % (status_code,
714 http_client.responses.get(status_code,
715 'Unknown Error'))
716 cors_handler = self._create_cors_handler(orig_request)
717 return util.send_wsgi_response(response_status, headers, body,
718 start_response, cors_handler=cors_handler)