1
0
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:
2021-10-28 19:03:09 -04:00
parent f9c38a5bcc
commit 888225bdf7
31 changed files with 155 additions and 160 deletions

View 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,
)

View 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
)

View 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

View 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

View 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