Skip to content

Commit a5e65c9

Browse files
authored
Add a simple REST API for fetching a file's ID (#72)
* add a simple REST API for fetching a file's ID * add missing dependency in tests * Split into two endpoints after code review * Check the error message in the unit tests
1 parent ffd3148 commit a5e65c9

File tree

5 files changed

+170
-7
lines changed

5 files changed

+170
-7
lines changed

conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
pytest_plugins = ["jupyter_server.pytest_plugin", "jupyter_server_fileid.pytest_plugin"]
1+
pytest_plugins = [
2+
"jupyter_server.pytest_plugin",
3+
"jupyter_server_fileid.pytest_plugin",
4+
"pytest_jupyter",
5+
]

jupyter_server_fileid/extension.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from jupyter_events.logger import EventLogger
22
from jupyter_server.extension.application import ExtensionApp
3-
from traitlets import Type
3+
from traitlets import Instance, Type
44

5+
from jupyter_server_fileid.handler import FileIDHandler, FilePathHandler
56
from jupyter_server_fileid.manager import ArbitraryFileIdManager, BaseFileIdManager
67

78

@@ -10,28 +11,33 @@ class FileIdExtension(ExtensionApp):
1011

1112
file_id_manager_class = Type(
1213
klass=BaseFileIdManager,
13-
help="""File ID manager instance to use.
14+
help="""File ID manager class to use.
1415
1516
Defaults to ArbitraryFileIdManager.
1617
""",
1718
config=True,
1819
default_value=ArbitraryFileIdManager,
1920
)
2021

22+
file_id_manager = Instance(
23+
klass=BaseFileIdManager, help="An instance of the File ID manager.", allow_none=True
24+
)
25+
26+
handlers = [("/api/fileid/id", FileIDHandler), ("/api/fileid/path", FilePathHandler)]
27+
2128
def initialize_settings(self):
2229
self.log.info(f"Configured File ID manager: {self.file_id_manager_class.__name__}")
23-
file_id_manager = self.file_id_manager_class(
30+
self.file_id_manager = self.file_id_manager_class(
2431
log=self.log, root_dir=self.serverapp.root_dir, config=self.config
2532
)
26-
self.settings.update({"file_id_manager": file_id_manager})
33+
self.settings.update({"file_id_manager": self.file_id_manager})
2734

2835
# attach listener to contents manager events (requires jupyter_server~=2)
2936
if "event_logger" in self.settings:
3037
self.initialize_event_listeners()
3138

3239
def initialize_event_listeners(self):
33-
file_id_manager = self.settings["file_id_manager"]
34-
handlers_by_action = file_id_manager.get_handlers_by_action()
40+
handlers_by_action = self.file_id_manager.get_handlers_by_action()
3541

3642
async def cm_listener(logger: EventLogger, schema_id: str, data: dict) -> None:
3743
handler = handlers_by_action[data["action"]]

jupyter_server_fileid/handler.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from jupyter_server.auth.decorator import authorized
2+
from jupyter_server.base.handlers import APIHandler
3+
from tornado import web
4+
from tornado.escape import json_encode
5+
6+
from .manager import BaseFileIdManager
7+
8+
9+
class BaseHandler(APIHandler):
10+
auth_resource = "contents"
11+
12+
@property
13+
def file_id_manager(self) -> BaseFileIdManager:
14+
return self.settings.get("file_id_manager")
15+
16+
17+
class FileIDHandler(BaseHandler):
18+
"""A handler that fetches a file ID from the file path."""
19+
20+
@web.authenticated
21+
@authorized
22+
def get(self):
23+
try:
24+
path = self.get_argument("path")
25+
id = self.file_id_manager.get_id(path)
26+
# If the path cannot be found, it returns None. Raise a helpful
27+
# error to the client.
28+
if id is None:
29+
raise web.HTTPError(
30+
404,
31+
log_message=f"The ID for file, {path}, could not be found.",
32+
reason=f"The ID for file, {path}, could not be found.",
33+
)
34+
self.write(json_encode({"id": id, "path": path}))
35+
except web.MissingArgumentError:
36+
raise web.HTTPError(
37+
400, log_message="'path' parameter was not provided in the request."
38+
)
39+
40+
41+
class FilePathHandler(BaseHandler):
42+
"""A handler that fetches a file path from the file ID."""
43+
44+
@web.authenticated
45+
@authorized
46+
def get(self):
47+
try:
48+
id = self.get_argument("id")
49+
path = self.file_id_manager.get_path(id)
50+
# If the ID cannot be found, it returns None. Raise a helpful
51+
# error to the client.
52+
if path is None:
53+
error_msg = f"The path for file, {id}, could not be found."
54+
raise web.HTTPError(
55+
404,
56+
log_message=error_msg,
57+
reason=error_msg,
58+
)
59+
self.write(json_encode({"id": id, "path": path}))
60+
except web.MissingArgumentError:
61+
raise web.HTTPError(400, log_message="'id' parameter was not provided in the request.")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
test = [
3030
"pytest",
3131
"pytest-cov",
32+
"pytest-jupyter",
3233
"jupyter_server[test]>=1.15, <3"
3334
]
3435

tests/test_handler.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
from tornado.escape import json_decode
5+
from tornado.httpclient import HTTPClientError
6+
7+
from jupyter_server_fileid.manager import BaseFileIdManager
8+
9+
10+
class MockFileIdManager(BaseFileIdManager):
11+
_normalize_path = MagicMock()
12+
_from_normalized_path = MagicMock()
13+
index = MagicMock()
14+
move = MagicMock()
15+
copy = MagicMock()
16+
delete = MagicMock()
17+
save = MagicMock()
18+
get_handlers_by_action = MagicMock()
19+
get_path = MagicMock(return_value="mock_path")
20+
get_id = MagicMock(return_value="mock_id")
21+
22+
23+
@pytest.fixture
24+
def jp_server_config():
25+
yield {
26+
"ServerApp": {"jpserver_extensions": {"jupyter_server_fileid": True}},
27+
"FileIdExtension": {"file_id_manager_class": MockFileIdManager},
28+
}
29+
30+
31+
@pytest.fixture
32+
def file_id_extension(jp_serverapp):
33+
ext_pkg = jp_serverapp.extension_manager.extensions["jupyter_server_fileid"]
34+
ext_point = ext_pkg.extension_points["jupyter_server_fileid"]
35+
return ext_point.app
36+
37+
38+
async def test_file_id_handler(jp_fetch, file_id_extension):
39+
response = await jp_fetch("api/fileid/id", params={"path": "test"})
40+
file_id_extension.file_id_manager.get_id.assert_called_once()
41+
body = json_decode(response.body)
42+
assert "id" in body
43+
assert body["id"] == "mock_id"
44+
45+
46+
async def test_file_path_handler(jp_fetch, file_id_extension):
47+
response = await jp_fetch("api/fileid/path", params={"id": "test"})
48+
file_id_extension.file_id_manager.get_path.assert_called_once()
49+
body = json_decode(response.body)
50+
assert "path" in body
51+
assert body["path"] == "mock_path"
52+
53+
54+
async def test_missing_query_param_in_id_handler(jp_fetch):
55+
with pytest.raises(HTTPClientError) as err:
56+
response = await jp_fetch("api/fileid/id")
57+
58+
assert err.value.code == 400
59+
60+
61+
async def test_missing_query_param_in_path_handler(jp_fetch):
62+
with pytest.raises(HTTPClientError) as err:
63+
response = await jp_fetch("api/fileid/path")
64+
65+
assert err.value.code == 400
66+
67+
68+
async def test_resource_not_found_in_id_handler(jp_fetch, monkeypatch):
69+
def mock_get_id_with_no_entry(self, path):
70+
return None
71+
72+
monkeypatch.setattr(MockFileIdManager, "get_id", mock_get_id_with_no_entry)
73+
74+
with pytest.raises(HTTPClientError) as err:
75+
await jp_fetch("api/fileid/id", params={"path": "test"})
76+
77+
assert err.value.code == 404
78+
assert err.value.message.startswith("The ID for file")
79+
80+
81+
async def test_resource_not_found_in_path_handler(jp_fetch, monkeypatch):
82+
def mock_get_path_with_no_entry(self, id):
83+
return None
84+
85+
monkeypatch.setattr(MockFileIdManager, "get_path", mock_get_path_with_no_entry)
86+
87+
with pytest.raises(HTTPClientError) as err:
88+
await jp_fetch("api/fileid/path", params={"id": "test"})
89+
90+
assert err.value.code == 404
91+
assert err.value.message.startswith("The path for file")

0 commit comments

Comments
 (0)