Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for newer openapi spec #360

Merged
merged 4 commits into from
Dec 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor nit, but perhaps we keep it DRYer with jupyterlab_server[openapi]

"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