diff --git a/jupyterlab_server/spec.py b/jupyterlab_server/spec.py index 5c6941a7..05c7941b 100644 --- a/jupyterlab_server/spec.py +++ b/jupyterlab_server/spec.py @@ -1,18 +1,17 @@ """OpenAPI spec utils.""" import os +import typing from pathlib import Path +if typing.TYPE_CHECKING: + from openapi_core.spec.paths import Spec + HERE = Path(os.path.dirname(__file__)).resolve() -def get_openapi_spec(): +def get_openapi_spec() -> "Spec": """Get the OpenAPI spec object.""" - try: - from openapi_core import OpenAPISpec as Spec - - create_spec = Spec.create - except ImportError: - from openapi_core import create_spec # type:ignore + from openapi_core.spec.shortcuts import create_spec openapi_spec_dict = get_openapi_spec_dict() return create_spec(openapi_spec_dict) diff --git a/jupyterlab_server/test_utils.py b/jupyterlab_server/test_utils.py index 7e261b5a..c5f5f2f4 100644 --- a/jupyterlab_server/test_utils.py +++ b/jupyterlab_server/test_utils.py @@ -4,17 +4,17 @@ import sys from http.cookies import SimpleCookie from pathlib import Path +from typing import Optional from urllib.parse import parse_qs, urlparse import tornado.httpclient import tornado.web -from openapi_core.validation.request.datatypes import ( # type:ignore - OpenAPIRequest, - RequestParameters, -) -from openapi_core.validation.request.validators import RequestValidator # type:ignore -from openapi_core.validation.response.datatypes import OpenAPIResponse # type:ignore -from openapi_core.validation.response.validators import ResponseValidator # type:ignore +from openapi_core.spec.paths import Spec +from openapi_core.validation.request import openapi_request_validator +from openapi_core.validation.request.datatypes import RequestParameters +from openapi_core.validation.response import openapi_response_validator +from tornado.httpclient import HTTPRequest, HTTPResponse +from werkzeug.datastructures import Headers, ImmutableMultiDict from jupyterlab_server.spec import get_openapi_spec @@ -24,86 +24,128 @@ big_unicode_string = json.load(fpt)["@jupyterlab/unicode-extension:plugin"]["comment"] -def wrap_request(request, spec): - """Wrap a tornado request as an open api request""" - # Extract cookie dict from cookie header - cookie: SimpleCookie = SimpleCookie() - cookie.load(request.headers.get("Set-Cookie", "")) - cookies = {} - for key, morsel in cookie.items(): - cookies[key] = morsel.value - - # extract the path - o = urlparse(request.url) - - # extract the best matching url - # work around lack of support for path parameters which can contain slashes - # https://github.com/OAI/OpenAPI-Specification/issues/892 - url = None - for path in spec["paths"]: - if url: - continue - has_arg = "{" in path - if has_arg: - path = path[: path.index("{")] - if path in o.path: - u = o.path[o.path.index(path) :] - if not has_arg and len(u) == len(path): - url = u - if has_arg and not u.endswith("/"): - url = u[: len(path)] + r"foo" - - if url is None: - raise ValueError(f"Could not find matching pattern for {o.path}") - - # gets deduced by path finder against spec - path = {} - - # Order matters because all tornado requests - # include Accept */* which does not necessarily match the content type - mimetype = ( - request.headers.get("Content-Type") or request.headers.get("Accept") or "application/json" - ) - - parameters = RequestParameters( - query=parse_qs(o.query), - header=dict(request.headers), - cookie=cookies, - path=path, - ) - - return OpenAPIRequest( - full_url_pattern=url, - method=request.method.lower(), - parameters=parameters, - body=request.body, - mimetype=mimetype, - ) - - -def wrap_response(response): - """Wrap a tornado response as an open api response""" - mimetype = response.headers.get("Content-Type") or "application/json" - return OpenAPIResponse( - data=response.body, - status_code=response.code, - mimetype=mimetype, - ) +class TornadoOpenAPIRequest: + """ + Converts a torando request to an OpenAPI one + """ + + def __init__(self, request: HTTPRequest, spec: Spec): + """Initialize the request.""" + self.request = request + self.spec = spec + if request.url is None: + raise RuntimeError("Request URL is missing") + self._url_parsed = urlparse(request.url) + + cookie: SimpleCookie = SimpleCookie() + cookie.load(request.headers.get("Set-Cookie", "")) + cookies = {} + for key, morsel in cookie.items(): + cookies[key] = morsel.value + + # extract the path + o = urlparse(request.url) + + # gets deduced by path finder against spec + path: dict = {} + + self.parameters = RequestParameters( + query=ImmutableMultiDict(parse_qs(o.query)), + header=Headers(dict(request.headers)), + cookie=ImmutableMultiDict(cookies), + path=path, + ) + + @property + def host_url(self) -> str: + url = self.request.url + return url[: url.index('/lab')] + + @property + def path(self) -> str: + # extract the best matching url + # work around lack of support for path parameters which can contain slashes + # https://github.com/OAI/OpenAPI-Specification/issues/892 + url = None + o = urlparse(self.request.url) + for path in self.spec["paths"]: + if url: + continue + has_arg = "{" in path + if has_arg: + path = path[: path.index("{")] + if path in o.path: + u = o.path[o.path.index(path) :] + if not has_arg and len(u) == len(path): + url = u + if has_arg and not u.endswith("/"): + url = u[: len(path)] + r"foo" + + if url is None: + raise ValueError(f"Could not find matching pattern for {o.path}") + return url + + @property + def method(self) -> str: + method = self.request.method + return method and method.lower() or "" + + @property + def body(self) -> Optional[str]: + if not isinstance(self.request.body, bytes): + raise AssertionError('Request body is invalid') + return self.request.body.decode("utf-8") + + @property + def mimetype(self) -> str: + # Order matters because all tornado requests + # include Accept */* which does not necessarily match the content type + request = self.request + return ( + request.headers.get("Content-Type") + or request.headers.get("Accept") + or "application/json" + ) + + +class TornadoOpenAPIResponse: + """A tornado open API response.""" + + def __init__(self, response: HTTPResponse): + """Initialize the response.""" + self.response = response + + @property + def data(self) -> str: + if not isinstance(self.response.body, bytes): + raise AssertionError('Response body is invalid') + return self.response.body.decode("utf-8") + + @property + def status_code(self) -> int: + return int(self.response.code) + + @property + def mimetype(self) -> str: + return str(self.response.headers.get("Content-Type", "application/json")) + + @property + def headers(self) -> Headers: + return Headers(dict(self.response.headers)) def validate_request(response): """Validate an API request""" openapi_spec = get_openapi_spec() - validator = RequestValidator(openapi_spec) - request = wrap_request(response.request, openapi_spec) - result = validator.validate(request) - result.raise_for_errors() - validator = ResponseValidator(openapi_spec) - response = wrap_response(response) - result = validator.validate(request, response) + request = TornadoOpenAPIRequest(response.request, openapi_spec) + result = openapi_request_validator.validate(openapi_spec, request) result.raise_for_errors() + response = TornadoOpenAPIResponse(response) + result2 = openapi_response_validator.validate(openapi_spec, request, response) + result2.raise_for_errors() + def maybe_patch_ioloop(): """a windows 3.8+ patch for the asyncio loop""" diff --git a/pyproject.toml b/pyproject.toml index 9e587173..2758f0b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "importlib_metadata>=4.8.3;python_version<\"3.10\"", "jinja2>=3.0.3", "json5>=0.9.0", - "jsonschema>=3.0.1", + "jsonschema>=4.17.3", "jupyter_server>=1.21,<3", "packaging>=21.3", "requests>=2.28", @@ -63,16 +63,15 @@ docs = [ "jinja2<3.2.0" ] openapi = [ - "openapi_core>=0.14.2", + "openapi_core>=0.16.1", "ruamel.yaml", ] test = [ "codecov", "ipykernel", "pytest-jupyter[server]>=0.6", - # openapi_core 0.15.0 alpha is not working - "openapi_core~=0.14.2", - "openapi-spec-validator<0.6", + "openapi_core>=0.16.1", + "openapi-spec-validator>=0.5.1", "sphinxcontrib_spelling", "requests_mock", "pytest>=7.0", @@ -80,7 +79,8 @@ test = [ "pytest-cov", "pytest-timeout", "ruamel.yaml", - "strict-rfc3339" + "strict-rfc3339", + "werkzeug", ] [tool.hatch.version]