diff --git a/bookstore/bookstore_config.py b/bookstore/bookstore_config.py index d48be2a..0fa3c50 100644 --- a/bookstore/bookstore_config.py +++ b/bookstore/bookstore_config.py @@ -74,7 +74,7 @@ def validate_bookstore(settings: BookstoreSettings): Returns ------- validation_checks : dict - Existence of settings by category (general, archive, publish) + Statements about whether features are validly configured and available """ general_settings = [settings.s3_bucket != "", settings.s3_endpoint_url != ""] archive_settings = [*general_settings, settings.workspace_prefix != ""] @@ -85,6 +85,6 @@ def validate_bookstore(settings: BookstoreSettings): "bookstore_valid": all(general_settings), "archive_valid": all(archive_settings), "publish_valid": all(published_settings), - "cloning_valid": all(cloning_settings), + "clone_valid": all(cloning_settings), } return validation_checks diff --git a/bookstore/handlers.py b/bookstore/handlers.py index 9995946..6a6d200 100644 --- a/bookstore/handlers.py +++ b/bookstore/handlers.py @@ -20,24 +20,34 @@ class BookstoreVersionHandler(APIHandler): """Handler responsible for Bookstore version information Used to lay foundations for the bookstore package. Though, frontends can use this endpoint for feature detection. + + Methods + ------- + get(self) + Provides version info and feature availability based on serverside settings. + build_response_dict(self) + Helper to populate response. """ @web.authenticated def get(self): - self.finish( - json.dumps( - { - "bookstore": True, - "version": self.settings['bookstore']["version"], - "validation": self.settings['bookstore']["validation"], - } - ) - ) + """GET /api/bookstore/ + + Returns version info and validation info for various bookstore features. + """ + self.finish(json.dumps(self.build_response_dict())) + + def build_response_dict(self): + """Helper for building the version handler's response before serialization.""" + return { + "release": self.settings['bookstore']["release"], + "features": self.settings['bookstore']["features"], + } -# TODO: Add a check. Note: We need to ensure that publishing is not configured if bookstore settings are not -# set. Because of how the APIHandlers cannot be configurable, all we can do is reach into settings -# For applications this will mean checking the config and then applying it in +def build_settings_dict(validation): + """Helper for building the settings info that will be assigned to the web_app.""" + return {"release": version, "features": validation} def load_jupyter_server_extension(nb_app): @@ -48,7 +58,7 @@ def load_jupyter_server_extension(nb_app): bookstore_settings = BookstoreSettings(parent=nb_app) validation = validate_bookstore(bookstore_settings) - web_app.settings['bookstore'] = {"version": version, "validation": validation} + web_app.settings['bookstore'] = build_settings_dict(validation) handlers = collect_handlers(nb_app.log, base_url, validation) web_app.add_handlers(host_pattern, handlers) @@ -92,7 +102,7 @@ def collect_handlers(log, base_url, validation): else: log.info("[bookstore] Publishing disabled. s3_bucket or endpoint are not configured.") - if validation['cloning_valid']: + if validation['clone_valid']: log.info(f"[bookstore] Enabling bookstore cloning, version: {version}") handlers.append( (url_path_join(base_bookstore_api_pattern, r"/clone(?:/?)*"), BookstoreCloneAPIHandler) diff --git a/bookstore/tests/test_bookstore_config.py b/bookstore/tests/test_bookstore_config.py index 954959e..a844d98 100644 --- a/bookstore/tests/test_bookstore_config.py +++ b/bookstore/tests/test_bookstore_config.py @@ -10,7 +10,7 @@ def test_validate_bookstore_defaults(): "bookstore_valid": False, "publish_valid": False, "archive_valid": False, - "cloning_valid": True, + "clone_valid": True, } settings = BookstoreSettings() assert validate_bookstore(settings) == expected @@ -22,7 +22,7 @@ def test_validate_bookstore_published(): "bookstore_valid": True, "publish_valid": False, "archive_valid": True, - "cloning_valid": True, + "clone_valid": True, } settings = BookstoreSettings(s3_bucket="A_bucket", published_prefix="") assert validate_bookstore(settings) == expected @@ -34,7 +34,7 @@ def test_validate_bookstore_workspace(): "bookstore_valid": True, "publish_valid": True, "archive_valid": False, - "cloning_valid": True, + "clone_valid": True, } settings = BookstoreSettings(s3_bucket="A_bucket", workspace_prefix="") assert validate_bookstore(settings) == expected @@ -46,7 +46,7 @@ def test_validate_bookstore_endpoint(): "bookstore_valid": False, "publish_valid": False, "archive_valid": False, - "cloning_valid": True, + "clone_valid": True, } settings = BookstoreSettings(s3_endpoint_url="") assert validate_bookstore(settings) == expected @@ -58,7 +58,7 @@ def test_validate_bookstore_bucket(): "bookstore_valid": True, "publish_valid": True, "archive_valid": True, - "cloning_valid": True, + "clone_valid": True, } settings = BookstoreSettings(s3_bucket="A_bucket") assert validate_bookstore(settings) == expected @@ -70,7 +70,7 @@ def test_disable_cloning(): "bookstore_valid": True, "publish_valid": True, "archive_valid": True, - "cloning_valid": False, + "clone_valid": False, } settings = BookstoreSettings(s3_bucket="A_bucket", enable_cloning=False) assert validate_bookstore(settings) == expected diff --git a/bookstore/tests/test_handlers.py b/bookstore/tests/test_handlers.py index 60d5c70..7989f63 100644 --- a/bookstore/tests/test_handlers.py +++ b/bookstore/tests/test_handlers.py @@ -1,21 +1,25 @@ """Tests for handlers""" +from unittest.mock import Mock + import pytest import logging from unittest.mock import Mock -from bookstore.handlers import collect_handlers, BookstoreVersionHandler +from bookstore._version import __version__ +from bookstore.handlers import collect_handlers, build_settings_dict, BookstoreVersionHandler from bookstore.bookstore_config import BookstoreSettings, validate_bookstore from bookstore.clone import BookstoreCloneHandler, BookstoreCloneAPIHandler from bookstore.publish import BookstorePublishAPIHandler from notebook.base.handlers import path_regex -from tornado.web import Application +from tornado.testing import AsyncTestCase +from tornado.web import Application, HTTPError +from tornado.httpserver import HTTPRequest from traitlets.config import Config log = logging.getLogger('test_handlers') +version = __version__ - -def test_handlers(): - pass +from traitlets.config import Config def test_collect_handlers_all(): @@ -68,3 +72,94 @@ def test_collect_handlers_only_version(): validation = validate_bookstore(bookstore_settings) handlers = collect_handlers(log, '/', validation) assert expected == handlers + + +@pytest.fixture(scope="class") +def bookstore_settings(request): + mock_settings = { + "BookstoreSettings": { + "s3_access_key_id": "mock_id", + "s3_secret_access_key": "mock_access", + "s3_bucket": "my_bucket", + } + } + config = Config(mock_settings) + bookstore_settings = BookstoreSettings(config=config) + if request.cls is not None: + request.cls.bookstore_settings = bookstore_settings + return bookstore_settings + + +def test_build_settings_dict(bookstore_settings): + expected = { + 'features': { + 'archive_valid': True, + 'bookstore_valid': True, + 'publish_valid': True, + 'clone_valid': True, + }, + 'release': version, + } + validation = validate_bookstore(bookstore_settings) + assert expected == build_settings_dict(validation) + + +@pytest.mark.usefixtures("bookstore_settings") +class TestCloneAPIHandler(AsyncTestCase): + def setUp(self): + super().setUp() + + validation = validate_bookstore(self.bookstore_settings) + self.mock_application = Mock( + spec=Application, + ui_methods={}, + ui_modules={}, + settings={"bookstore": build_settings_dict(validation)}, + transforms=[], + ) + + def get_handler(self, uri, app=None): + if app is None: + app = self.mock_application + connection = Mock(context=Mock(protocol="https")) + payload_request = HTTPRequest( + method='GET', + uri=uri, + headers={"Host": "localhost:8888"}, + body=None, + connection=connection, + ) + return BookstoreVersionHandler(app, payload_request) + + def test_get(self): + """This is a simple test of the get API at /api/bookstore + + The most notable feature is the need to set _transforms on the handler. + + The default value of handler()._transforms is `None`. + This is iterated over when handler().flush() is called, raising a TypeError. + + In normal usage, the application assigns this when it creates a handler delegate. + + Because our mock application does not do this + As a result this raises an error when self.finish() (and therefore self.flush()) is called. + + At runtime on a live Jupyter server, application.transforms == []. + """ + get_handler = self.get_handler('/api/bookstore/') + setattr(get_handler, '_transforms', []) + return_val = get_handler.get() + assert return_val is None + + def test_build_response(self): + empty_handler = self.get_handler('/api/bookstore/') + expected = { + 'features': { + 'archive_valid': True, + 'bookstore_valid': True, + 'publish_valid': True, + 'clone_valid': True, + }, + 'release': version, + } + assert empty_handler.build_response_dict() == expected diff --git a/docs/source/bookstore_api.yaml b/docs/source/bookstore_api.yaml index 5dfc522..6a1cde9 100644 --- a/docs/source/bookstore_api.yaml +++ b/docs/source/bookstore_api.yaml @@ -194,27 +194,26 @@ components: format: type: string description: Format of content (one of null, 'text', 'base64', 'json') - ValidationInfo: + FeatureValidationInfo: type: object required: - - bookstore_validation - - archive_validation - - published_validation + - bookstore_valid + - archive_valid + - publish_valid + - clone_valid properties: - bookstore_validation: + bookstore_valid: type: boolean - archive_validation: + archive_valid: type: boolean - published_validation: + publish_valid: + type: boolean + clone_valid: type: boolean VersionInfo: type: object - required: - - s3path properties: - bookstore: - type: boolean - version: + release: type: string - validation: - $ref: '#/components/schemas/ValidationInfo' + features: + $ref: '#/components/schemas/FeatureValidationInfo'