mirror of
https://github.com/enpaul/kodak.git
synced 2025-09-18 21:21:59 +00:00
Rename project to fresnel-lens
This commit is contained in:
19
fresnel_lens/resources/__init__.py
Normal file
19
fresnel_lens/resources/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
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.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,
|
||||
Image,
|
||||
OpenAPI,
|
||||
ThumbnailScale,
|
||||
ThumbnailResize,
|
||||
)
|
110
fresnel_lens/resources/_shared.py
Normal file
110
fresnel_lens/resources/_shared.py
Normal file
@@ -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 FresnelResource(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 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS>`_
|
||||
"""
|
||||
|
||||
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 <https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD>`_
|
||||
|
||||
.. 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 <https://httpstatuses.com/200>`_
|
||||
: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
|
||||
)
|
113
fresnel_lens/resources/image.py
Normal file
113
fresnel_lens/resources/image.py
Normal file
@@ -0,0 +1,113 @@
|
||||
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
|
||||
|
||||
|
||||
class Image(FresnelResource):
|
||||
|
||||
routes = ("/image/<string:image_id>.jpeg",)
|
||||
|
||||
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
|
19
fresnel_lens/resources/openapi.py
Normal file
19
fresnel_lens/resources/openapi.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from fresnel_lens.resources._shared import FresnelResource
|
||||
|
||||
yaml = YAML(typ="safe")
|
||||
|
||||
|
||||
class OpenAPI(FresnelResource):
|
||||
|
||||
routes = ("/openapi.json",)
|
||||
|
||||
def get(self):
|
||||
|
||||
with (Path(__file__).parent, "openapi.yaml").open() as infile:
|
||||
data = yaml.load(infile)
|
||||
|
||||
return data, 200
|
17
fresnel_lens/resources/thumbnail.py
Normal file
17
fresnel_lens/resources/thumbnail.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fresnel_lens.resources._shared import FresnelResource
|
||||
|
||||
|
||||
class ThumbnailScale(FresnelResource):
|
||||
|
||||
routes = ("/thumb/<string:image_id>/scale/<int:scale_width>.jpg",)
|
||||
|
||||
def get(self, image_id: str, scale_width: int):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ThumbnailResize(FresnelResource):
|
||||
|
||||
routes = ("/thumb/<string:image_id>/size/<int:width>x<int:height>.jpg",)
|
||||
|
||||
def get(self, image_id: str, width: int, height: int):
|
||||
raise NotImplementedError
|
Reference in New Issue
Block a user