Skip to content

Commit

Permalink
Add support for newer openapi spec (#360)
Browse files Browse the repository at this point in the history
Fixes #359
  • Loading branch information
blink1073 authored Dec 24, 2022
1 parent dfd340f commit 1f552fd
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 92 deletions.
13 changes: 6 additions & 7 deletions jupyterlab_server/spec.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
200 changes: 121 additions & 79 deletions jupyterlab_server/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"""
Expand Down
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -63,24 +63,24 @@ 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",
"pytest-console-scripts",
"pytest-cov",
"pytest-timeout",
"ruamel.yaml",
"strict-rfc3339"
"strict-rfc3339",
"werkzeug",
]

[tool.hatch.version]
Expand Down

0 comments on commit 1f552fd

Please sign in to comment.