Skip to content

Commit 1f552fd

Browse files
authored
Add support for newer openapi spec (#360)
Fixes #359
1 parent dfd340f commit 1f552fd

File tree

3 files changed

+133
-92
lines changed

3 files changed

+133
-92
lines changed

jupyterlab_server/spec.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
"""OpenAPI spec utils."""
22
import os
3+
import typing
34
from pathlib import Path
45

6+
if typing.TYPE_CHECKING:
7+
from openapi_core.spec.paths import Spec
8+
59
HERE = Path(os.path.dirname(__file__)).resolve()
610

711

8-
def get_openapi_spec():
12+
def get_openapi_spec() -> "Spec":
913
"""Get the OpenAPI spec object."""
10-
try:
11-
from openapi_core import OpenAPISpec as Spec
12-
13-
create_spec = Spec.create
14-
except ImportError:
15-
from openapi_core import create_spec # type:ignore
14+
from openapi_core.spec.shortcuts import create_spec
1615

1716
openapi_spec_dict = get_openapi_spec_dict()
1817
return create_spec(openapi_spec_dict)

jupyterlab_server/test_utils.py

Lines changed: 121 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44
import sys
55
from http.cookies import SimpleCookie
66
from pathlib import Path
7+
from typing import Optional
78
from urllib.parse import parse_qs, urlparse
89

910
import tornado.httpclient
1011
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
1818

1919
from jupyterlab_server.spec import get_openapi_spec
2020

@@ -24,86 +24,128 @@
2424
big_unicode_string = json.load(fpt)["@jupyterlab/unicode-extension:plugin"]["comment"]
2525

2626

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))
92135

93136

94137
def validate_request(response):
95138
"""Validate an API request"""
96139
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()
101140

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)
105143
result.raise_for_errors()
106144

145+
response = TornadoOpenAPIResponse(response)
146+
result2 = openapi_response_validator.validate(openapi_spec, request, response)
147+
result2.raise_for_errors()
148+
107149

108150
def maybe_patch_ioloop():
109151
"""a windows 3.8+ patch for the asyncio loop"""

pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
"importlib_metadata>=4.8.3;python_version<\"3.10\"",
3030
"jinja2>=3.0.3",
3131
"json5>=0.9.0",
32-
"jsonschema>=3.0.1",
32+
"jsonschema>=4.17.3",
3333
"jupyter_server>=1.21,<3",
3434
"packaging>=21.3",
3535
"requests>=2.28",
@@ -63,24 +63,24 @@ docs = [
6363
"jinja2<3.2.0"
6464
]
6565
openapi = [
66-
"openapi_core>=0.14.2",
66+
"openapi_core>=0.16.1",
6767
"ruamel.yaml",
6868
]
6969
test = [
7070
"codecov",
7171
"ipykernel",
7272
"pytest-jupyter[server]>=0.6",
73-
# openapi_core 0.15.0 alpha is not working
74-
"openapi_core~=0.14.2",
75-
"openapi-spec-validator<0.6",
73+
"openapi_core>=0.16.1",
74+
"openapi-spec-validator>=0.5.1",
7675
"sphinxcontrib_spelling",
7776
"requests_mock",
7877
"pytest>=7.0",
7978
"pytest-console-scripts",
8079
"pytest-cov",
8180
"pytest-timeout",
8281
"ruamel.yaml",
83-
"strict-rfc3339"
82+
"strict-rfc3339",
83+
"werkzeug",
8484
]
8585

8686
[tool.hatch.version]

0 commit comments

Comments
 (0)