diff --git a/fresnel_lens/_server.py b/fresnel_lens/_server.py index 94cfb3e..a76520a 100644 --- a/fresnel_lens/_server.py +++ b/fresnel_lens/_server.py @@ -1,11 +1,8 @@ import flask -from fresnel_lens import __about__ from fresnel_lens import configuration -from fresnel_lens import constants from fresnel_lens import database from fresnel_lens import exceptions -from fresnel_lens.resources import ResponseHeaders def make_the_tea() -> None: @@ -24,31 +21,12 @@ def initialize_database() -> None: database.initialize(flask.current_app.appconfig) -class FresnelRequest(flask.Request): - """Extend the default Flask request object to add custom application state settings""" - - def make_response_headers(self) -> ResponseHeaders: - """Create the headers dictionary of the standard response headers - - This function should be used when determining response headers so that the header names, - their contents, and formatting are universal. - - :returns: Dictionary of headers - """ - - return { - constants.HTTP_HEADER_RESPONSE_VERSION: __about__.__version__, - } - - -class ImageMuckFlask(flask.Flask): +class FresnelFlask(flask.Flask): """Extend the default Flask object to add the custom application config There's probably an easier/more kosher way to do this, but ¯\\_(ツ)_/¯ """ - request_class = FresnelRequest - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.appconfig: configuration.FresnelConfig = configuration.load() diff --git a/fresnel_lens/application.py b/fresnel_lens/application.py index 1d81a69..c3c1fd3 100644 --- a/fresnel_lens/application.py +++ b/fresnel_lens/application.py @@ -1,22 +1,17 @@ import flask_restful from fresnel_lens import resources -from fresnel_lens._server import ImageMuckFlask +from fresnel_lens._server import FresnelFlask from fresnel_lens._server import initialize_database from fresnel_lens._server import make_the_tea -APPLICATION = ImageMuckFlask(__name__) +APPLICATION = FresnelFlask(__name__) API = flask_restful.Api(APPLICATION, catch_all_404s=True) -def _set_upload_limit() -> None: - APPLICATION.config["MAX_CONTENT_LENGTH"] = APPLICATION.appconfig.upload.size_limit - - APPLICATION.before_request(make_the_tea) APPLICATION.before_first_request(initialize_database) -APPLICATION.before_first_request(_set_upload_limit) for resource in resources.RESOURCES: API.add_resource(resource, *resource.routes) diff --git a/fresnel_lens/resources/__init__.py b/fresnel_lens/resources/__init__.py index 9c45fad..e1f5863 100644 --- a/fresnel_lens/resources/__init__.py +++ b/fresnel_lens/resources/__init__.py @@ -1,19 +1,15 @@ from typing import Tuple from fresnel_lens.resources._shared import FresnelResource -from fresnel_lens.resources._shared import ResponseBody -from fresnel_lens.resources._shared import ResponseHeaders +from fresnel_lens.resources.alias import ImageAlias +from fresnel_lens.resources.heartbeat import Heartbeat from fresnel_lens.resources.image import Image -from fresnel_lens.resources.image import ImageUpload from fresnel_lens.resources.openapi import OpenAPI -from fresnel_lens.resources.thumbnail import ThumbnailResize -from fresnel_lens.resources.thumbnail import ThumbnailScale RESOURCES: Tuple[FresnelResource, ...] = ( - ImageUpload, + Heartbeat, Image, + ImageAlias, OpenAPI, - ThumbnailScale, - ThumbnailResize, ) diff --git a/fresnel_lens/resources/_shared.py b/fresnel_lens/resources/_shared.py index c0f10a7..c98c759 100644 --- a/fresnel_lens/resources/_shared.py +++ b/fresnel_lens/resources/_shared.py @@ -11,6 +11,8 @@ from typing import Union import flask import flask_restful +from fresnel_lens import __about__ + ResponseBody = Optional[Union[Dict[str, Any], List[Dict[str, Any]], List[str]]] @@ -99,7 +101,7 @@ class FresnelResource(flask_restful.Resource): """ headers = headers or {} - headers = {**headers, **flask.request.make_response_headers()} + headers.update({"Server": f"{__about__.__title__}-{__about__.__version__}"}) # 204 code specifies that it must never include a response body. Most clients will ignore # any response body when a 204 is given, but that's no reason to abandon best practices here diff --git a/fresnel_lens/resources/alias.py b/fresnel_lens/resources/alias.py new file mode 100644 index 0000000..8976110 --- /dev/null +++ b/fresnel_lens/resources/alias.py @@ -0,0 +1,10 @@ +from fresnel_lens.resources._shared import FresnelResource +from fresnel_lens.resources._shared import ResponseTuple + + +class ImageAlias(FresnelResource): + + routes = ("/image//",) + + def get(self, image_name: str, alias: str) -> ResponseTuple: + raise NotImplementedError diff --git a/fresnel_lens/resources/heartbeat.py b/fresnel_lens/resources/heartbeat.py new file mode 100644 index 0000000..00d87cf --- /dev/null +++ b/fresnel_lens/resources/heartbeat.py @@ -0,0 +1,18 @@ +from fresnel_lens import configuration +from fresnel_lens import database +from fresnel_lens.resources._shared import FresnelResource +from fresnel_lens.resources._shared import ResponseTuple + + +class Heartbeat(FresnelResource): + + routes = ("/heartbeat",) + + def get(self) -> ResponseTuple: + configuration.load() + database.ImageRecord.select().count() + + return self.make_response(None) + + def head(self) -> ResponseTuple: + return self._head(self.get()) diff --git a/fresnel_lens/resources/image.py b/fresnel_lens/resources/image.py index 5c8d6e1..f141dfe 100644 --- a/fresnel_lens/resources/image.py +++ b/fresnel_lens/resources/image.py @@ -1,113 +1,10 @@ -import hashlib -import shutil -import uuid - -import flask - -from fresnel_lens import constants -from fresnel_lens import database -from fresnel_lens import exceptions from fresnel_lens.resources._shared import FresnelResource - - -class ImageUpload(FresnelResource): - - routes = ("/image/",) - - def post(self): - if "image" not in flask.request.files: - raise - - uploaded = flask.request.files["image"] - - if not uploaded.filename: - raise - - format = uploaded.filename.rpartition(".")[-1].lower() - - if format not in flask.current_app.appconfig.upload.formats: - raise - - image = database.ImageRecord(format=format, width=0, height=0, owner="foobar") - - imagedir = flask.current_app.appconfig.storage_path / str(image.uuid) - - imagedir.mkdir() - - uploaded.save(imagedir / f"base.{format}") - - with (imagedir / f"base.{format}").open() as infile: - image.sha256 = hashlib.sha256(infile.read()).hexdigest() - - with database.interface.atomic(): - image.save() - - return None, 201 +from fresnel_lens.resources._shared import ResponseTuple class Image(FresnelResource): - routes = ("/image/.jpeg",) + routes = ("/image/",) - def get(self, image_id: str): - - image = database.ImageRecord.get( - database.ImageRecord.uuid == uuid.UUID(image_id) - ) - - if image.deleted: - raise exceptions.ImageResourceDeletedError( - f"Image with ID '{image_id}' was deleted" - ) - - filepath = ( - flask.current_app.appconfig.storage_path - / str(image.uuid) - / f"base.{image.format}" - ) - - if not filepath.exists(): - with database.interface.atomic(): - image.deleted = True - image.save() - raise exceptions.ImageFileRemovedError( - f"Image file with ID '{image_id}' removed from the server" - ) - - flask.send_file( - filepath, - mimetype=f"image/{'jpeg' if image.format == 'jpg' else image.format}", - # images are indexed by UUID with no ability to update, y'all should cache - # this thing 'till the sun explodes - cache_timeout=(60 * 60 * 24 * 365), - ) - - return ( - None, - 200, - {constants.HTTP_HEADER_RESPONSE_DIGEST: f"sha-256={image.sha256}"}, - ) - - def delete(self, image_id: str, format: str): - - image = database.ImageRecord.get( - database.ImageRecord.uuid - == uuid.UUID(image_id) & database.ImageRecord.format - == format - ) - - if image.deleted: - raise exceptions.ImageResourceDeletedError( - f"Image with ID '{image_id}' was deleted" - ) - - filepath = flask.current_app.appconfig.storage_path / str(image.uuid) - - with database.interface.atomic(): - image.deleted = True - image.save() - - if filepath.exists(): - shutil.rmtree(filepath) - - return None, 204 + def get(self, image_name: str) -> ResponseTuple: + raise NotImplementedError diff --git a/fresnel_lens/resources/openapi.py b/fresnel_lens/resources/openapi.py index afd3368..367cf76 100644 --- a/fresnel_lens/resources/openapi.py +++ b/fresnel_lens/resources/openapi.py @@ -3,17 +3,23 @@ from pathlib import Path from ruamel.yaml import YAML from fresnel_lens.resources._shared import FresnelResource +from fresnel_lens.resources._shared import ResponseTuple yaml = YAML(typ="safe") class OpenAPI(FresnelResource): + """Handle requests for the OpenAPI specification resource""" routes = ("/openapi.json",) - def get(self): - - with (Path(__file__).parent, "openapi.yaml").open() as infile: + def get(self) -> ResponseTuple: + """Retrieve the OpenAPI specification document""" + with (Path(__file__).parent / "openapi.yaml").open() as infile: data = yaml.load(infile) - return data, 200 + return self.make_response(data) + + def head(self) -> ResponseTuple: + """Alias of GET with no response body""" + return self._head(self.get()) diff --git a/fresnel_lens/resources/thumbnail.py b/fresnel_lens/resources/thumbnail.py deleted file mode 100644 index d9049cd..0000000 --- a/fresnel_lens/resources/thumbnail.py +++ /dev/null @@ -1,17 +0,0 @@ -from fresnel_lens.resources._shared import FresnelResource - - -class ThumbnailScale(FresnelResource): - - routes = ("/thumb//scale/.jpg",) - - def get(self, image_id: str, scale_width: int): - raise NotImplementedError - - -class ThumbnailResize(FresnelResource): - - routes = ("/thumb//size/x.jpg",) - - def get(self, image_id: str, width: int, height: int): - raise NotImplementedError