|
4 | 4 | import sys
|
5 | 5 | from http.cookies import SimpleCookie
|
6 | 6 | from pathlib import Path
|
| 7 | +from typing import Optional |
7 | 8 | from urllib.parse import parse_qs, urlparse
|
8 | 9 |
|
9 | 10 | import tornado.httpclient
|
10 | 11 | import tornado.web
|
11 |
| -from openapi_core.validation.request.datatypes import ( # type:ignore |
12 |
| - OpenAPIRequest, |
13 |
| - RequestParameters, |
14 |
| -) |
15 |
| -from openapi_core.validation.request.validators import RequestValidator # type:ignore |
16 |
| -from openapi_core.validation.response.datatypes import OpenAPIResponse # type:ignore |
17 |
| -from openapi_core.validation.response.validators import ResponseValidator # type:ignore |
| 12 | +from openapi_core.spec.paths import Spec |
| 13 | +from openapi_core.validation.request import openapi_request_validator |
| 14 | +from openapi_core.validation.request.datatypes import RequestParameters |
| 15 | +from openapi_core.validation.response import openapi_response_validator |
| 16 | +from tornado.httpclient import HTTPRequest, HTTPResponse |
| 17 | +from werkzeug.datastructures import Headers, ImmutableMultiDict |
18 | 18 |
|
19 | 19 | from jupyterlab_server.spec import get_openapi_spec
|
20 | 20 |
|
|
24 | 24 | big_unicode_string = json.load(fpt)["@jupyterlab/unicode-extension:plugin"]["comment"]
|
25 | 25 |
|
26 | 26 |
|
27 |
| -def wrap_request(request, spec): |
28 |
| - """Wrap a tornado request as an open api request""" |
29 |
| - # Extract cookie dict from cookie header |
30 |
| - cookie: SimpleCookie = SimpleCookie() |
31 |
| - cookie.load(request.headers.get("Set-Cookie", "")) |
32 |
| - cookies = {} |
33 |
| - for key, morsel in cookie.items(): |
34 |
| - cookies[key] = morsel.value |
35 |
| - |
36 |
| - # extract the path |
37 |
| - o = urlparse(request.url) |
38 |
| - |
39 |
| - # extract the best matching url |
40 |
| - # work around lack of support for path parameters which can contain slashes |
41 |
| - # https://github.com/OAI/OpenAPI-Specification/issues/892 |
42 |
| - url = None |
43 |
| - for path in spec["paths"]: |
44 |
| - if url: |
45 |
| - continue |
46 |
| - has_arg = "{" in path |
47 |
| - if has_arg: |
48 |
| - path = path[: path.index("{")] |
49 |
| - if path in o.path: |
50 |
| - u = o.path[o.path.index(path) :] |
51 |
| - if not has_arg and len(u) == len(path): |
52 |
| - url = u |
53 |
| - if has_arg and not u.endswith("/"): |
54 |
| - url = u[: len(path)] + r"foo" |
55 |
| - |
56 |
| - if url is None: |
57 |
| - raise ValueError(f"Could not find matching pattern for {o.path}") |
58 |
| - |
59 |
| - # gets deduced by path finder against spec |
60 |
| - path = {} |
61 |
| - |
62 |
| - # Order matters because all tornado requests |
63 |
| - # include Accept */* which does not necessarily match the content type |
64 |
| - mimetype = ( |
65 |
| - request.headers.get("Content-Type") or request.headers.get("Accept") or "application/json" |
66 |
| - ) |
67 |
| - |
68 |
| - parameters = RequestParameters( |
69 |
| - query=parse_qs(o.query), |
70 |
| - header=dict(request.headers), |
71 |
| - cookie=cookies, |
72 |
| - path=path, |
73 |
| - ) |
74 |
| - |
75 |
| - return OpenAPIRequest( |
76 |
| - full_url_pattern=url, |
77 |
| - method=request.method.lower(), |
78 |
| - parameters=parameters, |
79 |
| - body=request.body, |
80 |
| - mimetype=mimetype, |
81 |
| - ) |
82 |
| - |
83 |
| - |
84 |
| -def wrap_response(response): |
85 |
| - """Wrap a tornado response as an open api response""" |
86 |
| - mimetype = response.headers.get("Content-Type") or "application/json" |
87 |
| - return OpenAPIResponse( |
88 |
| - data=response.body, |
89 |
| - status_code=response.code, |
90 |
| - mimetype=mimetype, |
91 |
| - ) |
| 27 | +class TornadoOpenAPIRequest: |
| 28 | + """ |
| 29 | + Converts a torando request to an OpenAPI one |
| 30 | + """ |
| 31 | + |
| 32 | + def __init__(self, request: HTTPRequest, spec: Spec): |
| 33 | + """Initialize the request.""" |
| 34 | + self.request = request |
| 35 | + self.spec = spec |
| 36 | + if request.url is None: |
| 37 | + raise RuntimeError("Request URL is missing") |
| 38 | + self._url_parsed = urlparse(request.url) |
| 39 | + |
| 40 | + cookie: SimpleCookie = SimpleCookie() |
| 41 | + cookie.load(request.headers.get("Set-Cookie", "")) |
| 42 | + cookies = {} |
| 43 | + for key, morsel in cookie.items(): |
| 44 | + cookies[key] = morsel.value |
| 45 | + |
| 46 | + # extract the path |
| 47 | + o = urlparse(request.url) |
| 48 | + |
| 49 | + # gets deduced by path finder against spec |
| 50 | + path: dict = {} |
| 51 | + |
| 52 | + self.parameters = RequestParameters( |
| 53 | + query=ImmutableMultiDict(parse_qs(o.query)), |
| 54 | + header=Headers(dict(request.headers)), |
| 55 | + cookie=ImmutableMultiDict(cookies), |
| 56 | + path=path, |
| 57 | + ) |
| 58 | + |
| 59 | + @property |
| 60 | + def host_url(self) -> str: |
| 61 | + url = self.request.url |
| 62 | + return url[: url.index('/lab')] |
| 63 | + |
| 64 | + @property |
| 65 | + def path(self) -> str: |
| 66 | + # extract the best matching url |
| 67 | + # work around lack of support for path parameters which can contain slashes |
| 68 | + # https://github.com/OAI/OpenAPI-Specification/issues/892 |
| 69 | + url = None |
| 70 | + o = urlparse(self.request.url) |
| 71 | + for path in self.spec["paths"]: |
| 72 | + if url: |
| 73 | + continue |
| 74 | + has_arg = "{" in path |
| 75 | + if has_arg: |
| 76 | + path = path[: path.index("{")] |
| 77 | + if path in o.path: |
| 78 | + u = o.path[o.path.index(path) :] |
| 79 | + if not has_arg and len(u) == len(path): |
| 80 | + url = u |
| 81 | + if has_arg and not u.endswith("/"): |
| 82 | + url = u[: len(path)] + r"foo" |
| 83 | + |
| 84 | + if url is None: |
| 85 | + raise ValueError(f"Could not find matching pattern for {o.path}") |
| 86 | + return url |
| 87 | + |
| 88 | + @property |
| 89 | + def method(self) -> str: |
| 90 | + method = self.request.method |
| 91 | + return method and method.lower() or "" |
| 92 | + |
| 93 | + @property |
| 94 | + def body(self) -> Optional[str]: |
| 95 | + if not isinstance(self.request.body, bytes): |
| 96 | + raise AssertionError('Request body is invalid') |
| 97 | + return self.request.body.decode("utf-8") |
| 98 | + |
| 99 | + @property |
| 100 | + def mimetype(self) -> str: |
| 101 | + # Order matters because all tornado requests |
| 102 | + # include Accept */* which does not necessarily match the content type |
| 103 | + request = self.request |
| 104 | + return ( |
| 105 | + request.headers.get("Content-Type") |
| 106 | + or request.headers.get("Accept") |
| 107 | + or "application/json" |
| 108 | + ) |
| 109 | + |
| 110 | + |
| 111 | +class TornadoOpenAPIResponse: |
| 112 | + """A tornado open API response.""" |
| 113 | + |
| 114 | + def __init__(self, response: HTTPResponse): |
| 115 | + """Initialize the response.""" |
| 116 | + self.response = response |
| 117 | + |
| 118 | + @property |
| 119 | + def data(self) -> str: |
| 120 | + if not isinstance(self.response.body, bytes): |
| 121 | + raise AssertionError('Response body is invalid') |
| 122 | + return self.response.body.decode("utf-8") |
| 123 | + |
| 124 | + @property |
| 125 | + def status_code(self) -> int: |
| 126 | + return int(self.response.code) |
| 127 | + |
| 128 | + @property |
| 129 | + def mimetype(self) -> str: |
| 130 | + return str(self.response.headers.get("Content-Type", "application/json")) |
| 131 | + |
| 132 | + @property |
| 133 | + def headers(self) -> Headers: |
| 134 | + return Headers(dict(self.response.headers)) |
92 | 135 |
|
93 | 136 |
|
94 | 137 | def validate_request(response):
|
95 | 138 | """Validate an API request"""
|
96 | 139 | openapi_spec = get_openapi_spec()
|
97 |
| - validator = RequestValidator(openapi_spec) |
98 |
| - request = wrap_request(response.request, openapi_spec) |
99 |
| - result = validator.validate(request) |
100 |
| - result.raise_for_errors() |
101 | 140 |
|
102 |
| - validator = ResponseValidator(openapi_spec) |
103 |
| - response = wrap_response(response) |
104 |
| - result = validator.validate(request, response) |
| 141 | + request = TornadoOpenAPIRequest(response.request, openapi_spec) |
| 142 | + result = openapi_request_validator.validate(openapi_spec, request) |
105 | 143 | result.raise_for_errors()
|
106 | 144 |
|
| 145 | + response = TornadoOpenAPIResponse(response) |
| 146 | + result2 = openapi_response_validator.validate(openapi_spec, request, response) |
| 147 | + result2.raise_for_errors() |
| 148 | + |
107 | 149 |
|
108 | 150 | def maybe_patch_ioloop():
|
109 | 151 | """a windows 3.8+ patch for the asyncio loop"""
|
|
0 commit comments