diff --git a/imagemonk/resources/__init__.py b/imagemonk/resources/__init__.py index 5a66702..2dcc921 100644 --- a/imagemonk/resources/__init__.py +++ b/imagemonk/resources/__init__.py @@ -1,7 +1,17 @@ +from typing import Tuple + +from imagemonk.resources._shared import ImageMonkResource from imagemonk.resources.image import Image from imagemonk.resources.image import ImageUpload +from imagemonk.resources.openapi import OpenAPI from imagemonk.resources.thumbnail import ThumbnailResize from imagemonk.resources.thumbnail import ThumbnailScale -RESOURCES = (ImageUpload, Image, ThumbnailScale, ThumbnailResize) +RESOURCES: Tuple[ImageMonkResource, ...] = ( + ImageUpload, + Image, + OpenAPI, + ThumbnailScale, + ThumbnailResize, +) diff --git a/imagemonk/resources/_shared.py b/imagemonk/resources/_shared.py new file mode 100644 index 0000000..746df55 --- /dev/null +++ b/imagemonk/resources/_shared.py @@ -0,0 +1,110 @@ +"""Shared resource base with common functionality""" +import logging +from typing import Any +from typing import Dict +from typing import List +from typing import NamedTuple +from typing import Optional +from typing import Tuple +from typing import Union + +import flask +import flask_restful + + +ResponseBody = Optional[Union[Dict[str, Any], List[Dict[str, Any]], List[str]]] + + +ResponseHeaders = Dict[str, str] + + +class ResponseTuple(NamedTuple): + """Namedtuple representing the format of a flask-restful response tuple + + :param body: Response body; must be comprised only of JSON-friendly primative types + :param code: HTTP response code + :param headers: Dictionary of headers + """ + + body: ResponseBody + code: int + headers: ResponseHeaders + + +class ImageMonkResource(flask_restful.Resource): + """Extension of the default :class:`flask_restful.Resource` class + + Add a couple of useful things to the default resource class: + + * Adds the :meth:`options` method to respond to HTTP OPTION requests + * Adds the :meth:`_head` method as a stub helper for responding to HTTP HEAD requests + * Adds the :meth:`make_response` method which handles response formatting boilerplate + * Type hints the :attr:`routes` attribute for usage in subclasses + * Adds an instance logger + + .. warning:: This class is a stub and should not be directly attached to an application + + :attribute routes: Tuple of route paths that this resource should handle; can be unpacked into + ``flask_restful.Api().add_route()`` + """ + + routes: Tuple[str, ...] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = logging.getLogger() + + def options( + self, *args, **kwargs + ) -> ResponseTuple: # pylint: disable=unused-argument + """Implement HTTP ``OPTIONS`` support + + `Reference documentation `_ + """ + + verbs = ",".join([verb.upper() for verb in flask.request.url_rule.methods]) + + return self.make_response(None, 204, {"Allowed": verbs}) + + def _head(self, response: ResponseTuple) -> ResponseTuple: + """Wrapper to implement HTTP ``HEAD`` support + + `Reference documentation `_ + + .. note:: The ``head`` method cannot be implemented directly as an alias of ``get`` because + that would require a uniform signature for ``get`` across all resources; or some + hacky nonsense that wouldn't be worth it. This stub instead lets child resources + implement ``head`` as a oneliner. + """ + return self.make_response(None, response.code, response.headers) + + def make_response( + self, + data: ResponseBody, + code: int = 200, + headers: Optional[ResponseHeaders] = None, + ): + """Create a response tuple from the current context + + Helper function for generating defaults, parsing common data, and formatting the response. + + :param data: Response data to return from the request + :param code: Response code to return; defaults to `200: Ok `_ + :param headers: Additional headers to return with the request; the default headers will + be added automatically and do not need to be passed. + :returns: Response tuple ready to be returned out of a resource method + + .. note:: This function will handle pagination and header assembly internally. The response + data passed to the ``data`` parameter should be unpaginated. + """ + + headers = headers or {} + headers = {**headers, **flask.request.make_response_headers()} + + # 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 + # on the server side + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204 + return ResponseTuple( + body=data if code != 204 else None, code=code, headers=headers + ) diff --git a/imagemonk/resources/image.py b/imagemonk/resources/image.py index a27844f..9fedb06 100644 --- a/imagemonk/resources/image.py +++ b/imagemonk/resources/image.py @@ -1,26 +1,20 @@ -import flask_restful +from imagemonk.resources._shared import ImageMonkResource -class ImageUpload(flask_restful.Resource): +class ImageUpload(ImageMonkResource): - route = "/image/" + route = ("/image/",) - def put(self): - raise NotImplementedError - - def options(self): + def post(self): raise NotImplementedError -class Image(flask_restful.Resource): +class Image(ImageMonkResource): - route = "/image/.jpg" + route = ("/image/.jpg",) def get(self, image_id: str): raise NotImplementedError def delete(self, image_id: str): raise NotImplementedError - - def options(self, image_id: str): - raise NotImplementedError diff --git a/imagemonk/resources/openapi.py b/imagemonk/resources/openapi.py index b5bd942..aea0a6e 100644 --- a/imagemonk/resources/openapi.py +++ b/imagemonk/resources/openapi.py @@ -1,12 +1,16 @@ from pathlib import Path -import flask_restful from ruamel.yaml import YAML +from imagemonk.resources._shared import ImageMonkResource + yaml = YAML(typ="safe") -class OpenAPI(flask_restful.Resource): +class OpenAPI(ImageMonkResource): + + routes = ("/openapi.json",) + def get(self): with (Path(__file__).parent, "openapi.yaml").open() as infile: diff --git a/imagemonk/resources/thumbnail.py b/imagemonk/resources/thumbnail.py index 2b5eca9..b8d2ddb 100644 --- a/imagemonk/resources/thumbnail.py +++ b/imagemonk/resources/thumbnail.py @@ -1,17 +1,17 @@ -import flask_restful +from imagemonk.resources._shared import ImageMonkResource -class ThumbnailScale(flask_restful.Resource): +class ThumbnailScale(ImageMonkResource): - route = "/thumb//scale/.jpg" + routes = ("/thumb//scale/.jpg",) def get(self, image_id: str, scale_width: int): raise NotImplementedError -class ThumbnailResize(flask_restful.Resource): +class ThumbnailResize(ImageMonkResource): - route = "/thumb//size/x.jpg" + routes = ("/thumb//size/x.jpg",) def get(self, image_id: str, width: int, height: int): raise NotImplementedError