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

Filtering request using OpenAPI spec #645

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
22 changes: 22 additions & 0 deletions .github/workflows/python-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,25 @@ jobs:
cd docs
pip install -r doc-requirements.txt
make html SPHINXOPTS="-W"

no-tornado-openapi3:
runs-on: ${{ matrix.os }}-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu]
python-version: ["3.10"]
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Install the Python dependencies
run: |
pip install -e ".[test]"
# Remove optional dependency
pip uninstall -y tornado-openapi3
- name: Run some tests
run: |
# Use that files as it contains some tests that should pass and other that needs to be skipped
pytest -vv jupyter_server/tests/extension/test_launch.py
95 changes: 95 additions & 0 deletions docs/source/developers/extensions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,101 @@ To make your extension executable from anywhere on your system, point an entry-p
}
)

Filtering requests
------------------

When launching the ``ExtensionApp`` in standalone mode, you may want to restrict the available endpoints. You can do this by defining
either ``_allowed_spec`` or ``_blocked_spec`` class attributes as OpenAPI v3 specifications. The allowed paths will blocked any requests
not matching the specification and the blocked paths will allow any requests except the ones specified.

.. note::

If both are defined, the blocked paths take precedence on the allowed one; i.e. if a path is allowed and blocked, it will be blocked.

OpenAPI v3 does not support URL path arguments that contains slashes ``/``. For the specification,
``/api/contents/{path}`` can only be ``/api/contents/[^/]+``. To circumvent this limitation, you can provide a list of regex's through
the class attributes ``_slash_encoder``. The groups matching one of the encoder will have their slashes encoded (``/`` replaced by ``%2F``).
This will allow the validation of path through the OpenAPI v3 specification.

Here is an example for the unit tests.

.. code-block:: python

class MyExtensionApp(ExtensionApp):

_allowed_spec = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It feels like these should be public attributes if they are meant to be part of the API

Copy link
Member Author

Choose a reason for hiding this comment

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

Following the idea of jupyterlab/jupyterlab_server#167, the scenario imagined to design this PR is the following:

James wants to create a SmithApp that inherits from ExtensionApp. That app can be executed either as an Jupyter server extension or as a standalone application. But in the case of the standalone application, he only wants the new handlers of his extension and the get contents endpoint to be available.

To achieve this, James will overrides the protected class attribute _allowed_spec in the child class SmithApp. Users of the SmithApp will never be able to change the endpoints available in standalone mode*.

*of course because this is Python, this is only a convention that protected attribute should not be modified externally.

pinging @bollwyvl to get his opinion

"openapi": "3.0.1",
"info": {"title": "Test specs", "version": "0.0.1"},
"paths": {
"/api/contents/{path}": {
# Will be blocked by _blocked_spec
"get": {
"parameters": [
{
"name": "path",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {"200": {"description": ""}},
},
},
"/api/contents/{path}/checkpoints": {
"post": {
"parameters": [
{
"name": "path",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {"201": {"description": ""}},
},
},
"/api/sessions/{sessionId}": {
"get": {
"parameters": [
{
"name": "sessionId",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {"200": {"description": ""}},
},
},
},
}

_blocked_spec = {
"openapi": "3.0.1",
"info": {"title": "Test specs", "version": "0.0.1"},
"paths": {
"/api/contents/{path}": {
"get": {
"parameters": [
{
"name": "path",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {"200": {"description": ""}},
},
},
},
}

_slash_encoder = (
r"/api/contents/([^/?]+(?:(?:/[^/]+)*?))/checkpoints$",
r"/api/contents/([^/?]+(?:(?:/[^/]+)*?))$",
)


``ExtensionApp`` as a classic Notebook server extension
-------------------------------------------------------

Expand Down
23 changes: 21 additions & 2 deletions jupyter_server/extension/application.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import logging
import re
import sys
from typing import Iterable
from typing import Optional

from jinja2 import Environment
from jinja2 import FileSystemLoader
Expand Down Expand Up @@ -136,6 +138,13 @@ class method. This method can be set as a entry_point in
the extensions setup.py
"""

# Filtering rules to apply on handlers registration
# Subclasses can override this list to filter handlers
# They will be applied on the ServerApp
_allowed_spec: Optional[dict] = None
_blocked_spec: Optional[dict] = None
_slash_encoder: Optional[Iterable[str]] = None

# Subclasses should override this trait. Tells the server if
# this extension allows other other extensions to be loaded
# side-by-side when launched directly.
Expand Down Expand Up @@ -180,6 +189,14 @@ def get_extension_package(cls):
def get_extension_point(cls):
return cls.__module__

@classmethod
fcollonval marked this conversation as resolved.
Show resolved Hide resolved
def get_openapi3_spec_rules(cls):
return {
"allowed": cls._allowed_spec,
"blocked": cls._blocked_spec,
"slash_encoder": cls._slash_encoder,
}

# Extension URL sets the default landing page for this extension.
extension_url = "/"

Expand Down Expand Up @@ -495,7 +512,8 @@ def load_classic_server_extension(cls, serverapp):
RedirectHandler,
{
"url": url_path_join(
serverapp.base_url, "static/base/images/favicon-notebook.ico"
serverapp.base_url,
"static/base/images/favicon-notebook.ico",
)
},
),
Expand All @@ -504,7 +522,8 @@ def load_classic_server_extension(cls, serverapp):
RedirectHandler,
{
"url": url_path_join(
serverapp.base_url, "static/base/images/favicon-terminal.ico"
serverapp.base_url,
"static/base/images/favicon-terminal.ico",
)
},
),
Expand Down
50 changes: 50 additions & 0 deletions jupyter_server/serverapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@
import urllib
import webbrowser
from base64 import encodebytes
from typing import Iterable
from typing import Optional

from tornado import httputil

try:
from jupyter_server.specvalidator import SpecValidator
except ImportError:
SpecValidator = None

try:
import resource
Expand Down Expand Up @@ -222,7 +231,24 @@ def __init__(
default_url,
settings_overrides,
jinja_env_options,
spec_validators=None,
):
if SpecValidator is None or spec_validators is None:

class DummyValidator:
"""Dummy request validator that is always valid."""

def validate(self, request):
return True

self.__requestValidator: Optional[SpecValidator] = DummyValidator()
else:
self.__requestValidator: Optional[SpecValidator] = SpecValidator(
base_url,
spec_validators.get("allowed"),
spec_validators.get("blocked"),
spec_validators.get("slash_encoder"),
)

settings = self.init_settings(
jupyter_app,
Expand Down Expand Up @@ -433,6 +459,14 @@ def init_handlers(self, default_services, settings):
new_handlers.append((r"(.*)", Template404))
return new_handlers

def find_handler(
self, request: httputil.HTTPServerRequest, **kwargs: Any
) -> "web._HandlerDelegate":
if self.__requestValidator.validate(request):
return super().find_handler(request, **kwargs)
else:
return self.get_handler_delegate(request, web.ErrorHandler, {"status_code": 403})

def last_activity(self):
"""Get a UTC timestamp for when the server last did something.

Expand Down Expand Up @@ -756,6 +790,12 @@ class ServerApp(JupyterApp):
"view",
)

# Filtering rules to apply on handlers registration
# Subclasses can override this list to filter handlers
_allowed_spec: Optional[dict] = None
_blocked_spec: Optional[dict] = None
_slash_encoder: Optional[Iterable[str]] = None

_log_formatter_cls = LogFormatter

@default("log_level")
Expand Down Expand Up @@ -1843,6 +1883,11 @@ def init_webapp(self):
self.default_url,
self.tornado_settings,
self.jinja_environment_options,
spec_validators={
"allowed": self._allowed_spec,
"blocked": self._blocked_spec,
"slash_encoder": self._slash_encoder,
},
)
if self.certfile:
self.ssl_options["certfile"] = self.certfile
Expand Down Expand Up @@ -2316,6 +2361,11 @@ def initialize(
# Set starter_app property.
if point.app:
self._starter_app = point.app
# Apply endpoint filters from the extension app
spec_rules = point.app.get_openapi3_spec_rules()
self._allowed_spec = spec_rules["allowed"]
self._blocked_spec = spec_rules["blocked"]
self._slash_encoder = spec_rules["slash_encoder"]
# Load any configuration that comes from the Extension point.
self.update_config(Config(point.config))

Expand Down
Loading