mirror of
https://github.com/enpaul/kodak.git
synced 2025-11-09 20:22:31 +00:00
Rename project to kodak
This commit is contained in:
9
kodak/__about__.py
Normal file
9
kodak/__about__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Programatically accessible project metadata"""
|
||||
|
||||
|
||||
__title__ = "kodak"
|
||||
__version__ = "0.1.0"
|
||||
__authors__ = ["Ethan Paul <24588726+enpaul@users.noreply.github.com>"]
|
||||
__license__ = "MIT"
|
||||
__summary__ = "HTTP server for uploading images and generating thumbnails"
|
||||
__url__ = "https://github.com/enpaul/kodak/"
|
||||
0
kodak/__init__.py
Normal file
0
kodak/__init__.py
Normal file
43
kodak/__main__.py
Normal file
43
kodak/__main__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Development server stub entrypoint
|
||||
|
||||
Flask comes with a built-in development server. This entrypoint allows ``kodak``
|
||||
to be run directly to run the development server and expose some simple config options for ease of
|
||||
access. Run the below command to start the server:
|
||||
|
||||
::
|
||||
|
||||
python -m kodak
|
||||
|
||||
In addition to the helpful CLI flags, the Flask development server run by this module will also
|
||||
load any ``.env`` files in the current working directory when running the application.
|
||||
|
||||
.. warning:: As the development server will tell you on startup, do not use this for production
|
||||
deployments.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from kodak.application import APPLICATION
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--bind",
|
||||
help="Address or socket to bind the server to",
|
||||
default="127.0.0.1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--port", help="Port bind the server to", default=5000, type=int
|
||||
)
|
||||
parser.add_argument(
|
||||
"-D", "--debug", help="Run Flask in debug mode", action="store_true"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
APPLICATION.run(host=args.bind, port=args.port, debug=args.debug, load_dotenv=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
32
kodak/_server.py
Normal file
32
kodak/_server.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import flask
|
||||
|
||||
from kodak import configuration
|
||||
from kodak import database
|
||||
from kodak import exceptions
|
||||
|
||||
|
||||
def make_the_tea() -> None:
|
||||
"""Just for fun
|
||||
|
||||
https://en.wikipedia.org/wiki/Hyper_Text_Coffee_Pot_Control_Protocol
|
||||
"""
|
||||
if flask.request.content_type == "message/coffeepot":
|
||||
raise exceptions.IAmATeapotError(
|
||||
f"Coffee brewing request for '{flask.request.path}' cannot be completed by teapot application"
|
||||
)
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
"""Initialize the database connection"""
|
||||
database.initialize(flask.current_app.appconfig)
|
||||
|
||||
|
||||
class KodakFlask(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 ¯\\_(ツ)_/¯
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.appconfig: configuration.KodakConfig = configuration.load()
|
||||
17
kodak/application.py
Normal file
17
kodak/application.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import flask_restful
|
||||
|
||||
from kodak import resources
|
||||
from kodak._server import initialize_database
|
||||
from kodak._server import KodakFlask
|
||||
from kodak._server import make_the_tea
|
||||
|
||||
|
||||
APPLICATION = KodakFlask(__name__)
|
||||
API = flask_restful.Api(APPLICATION, catch_all_404s=True)
|
||||
|
||||
|
||||
APPLICATION.before_request(make_the_tea)
|
||||
APPLICATION.before_first_request(initialize_database)
|
||||
|
||||
for resource in resources.RESOURCES:
|
||||
API.add_resource(resource, *resource.routes)
|
||||
160
kodak/configuration.py
Normal file
160
kodak/configuration.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import NamedTuple
|
||||
from typing import Optional
|
||||
from typing import Sequence
|
||||
from typing import Union
|
||||
|
||||
from kodak import constants
|
||||
|
||||
|
||||
class DimensionConfig(NamedTuple):
|
||||
strategy: constants.DimensionStrategy
|
||||
anchor: constants.Anchor
|
||||
value: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseSqliteConfig:
|
||||
path: Path = Path.cwd() / "fresnel.db"
|
||||
pragmas: Dict[str, Any] = field(
|
||||
default_factory=lambda: constants.DEFAULT_SQLITE_PRAGMAS
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls(
|
||||
path=Path(os.environ.get("KODAK_DB_SQLITE_PATH", cls.path)),
|
||||
pragmas=json.loads(os.environ["KODAK_DB_SQLITE_PRAGMAS"])
|
||||
if "KODAK_DB_SQLITE_PRAGMAS" in os.environ
|
||||
else constants.DEFAULT_SQLITE_PRAGMAS,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseMariaConfig:
|
||||
|
||||
hostname: str = "localhost"
|
||||
username: str = "root"
|
||||
password: Optional[str] = None
|
||||
port: int = 3306
|
||||
schema: str = "fresnel"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls(
|
||||
hostname=os.getenv("KODAK_DB_MARIA_HOSTNAME", cls.hostname),
|
||||
username=os.getenv("KODAK_DB_MARIA_USERNAME", cls.username),
|
||||
password=os.environ.get("KODAK_DB_MARIA_PASSWORD", cls.password),
|
||||
port=int(os.environ.get("KODAK_DB_MARIA_PORT", cls.port)),
|
||||
schema=os.getenv("KODAK_DB_MARIA_SCHEMA", cls.schema),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
|
||||
backend: constants.DatabaseBackend = constants.DatabaseBackend.SQLITE
|
||||
sqlite: DatabaseSqliteConfig = field(default_factory=DatabaseSqliteConfig.from_env)
|
||||
mariadb: DatabaseMariaConfig = field(default_factory=DatabaseMariaConfig.from_env)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls(
|
||||
backend=constants.DatabaseBackend[os.environ["KODAK_DB_BACKEND"].upper()]
|
||||
if "KODAK_DB_BACKEND" in os.environ
|
||||
else cls.backend
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManipConfig:
|
||||
name: str
|
||||
strategy: constants.DimensionStrategy = constants.DimensionStrategy.SCALE
|
||||
anchor: constants.Anchor = constants.Anchor.C
|
||||
formats: Sequence[constants.ImageFormat] = (
|
||||
constants.ImageFormat.JPEG,
|
||||
constants.ImageFormat.PNG,
|
||||
)
|
||||
horizontal: Optional[Union[int, float]] = None
|
||||
vertical: Optional[Union[int, float]] = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, key: str):
|
||||
strategy = (
|
||||
constants.DimensionStrategy[
|
||||
os.environ[f"KODAK_MANIP_{key}_STRATEGY"].upper()
|
||||
]
|
||||
if f"KODAK_MANIP_{key}_STRATEGY" in os.environ
|
||||
else cls.strategy
|
||||
)
|
||||
|
||||
dimension_conversion = (
|
||||
float if strategy == constants.DimensionStrategy.RELATIVE else int
|
||||
)
|
||||
|
||||
return cls(
|
||||
name=os.getenv(f"KODAK_MANIP_{key}_NAME", key.lower()),
|
||||
strategy=strategy,
|
||||
anchor=constants.Anchor(os.environ[f"KODAK_MANIP_{key}_ANCHOR"].lower())
|
||||
if f"KODAK_MANIP_{key}_ANCHOR" in os.environ
|
||||
else cls.anchor,
|
||||
formats=[
|
||||
constants.ImageFormat[item.upper()]
|
||||
for item in os.environ[f"KODAK_MANIP_{key}_FORMATS"].split(",")
|
||||
]
|
||||
if f"KODAK_MANIP_{key}_FORMATS" in os.environ
|
||||
else cls.formats,
|
||||
horizontal=dimension_conversion(os.environ[f"KODAK_MANIP_{key}_HORIZONTAL"])
|
||||
if f"KODAK_MANIP_{key}_HORIZONTAL" in os.environ
|
||||
else cls.horizontal,
|
||||
vertical=dimension_conversion(os.environ[f"KODAK_MANIP_{key}_VERTICAL"])
|
||||
if f"KODAK_MANIP_{key}_VERTICAL" in os.environ
|
||||
else cls.vertical,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KodakConfig:
|
||||
database: DatabaseConfig = field(default_factory=DatabaseConfig.from_env)
|
||||
sourcedir: Path = Path.cwd() / "images"
|
||||
manipdir: Path = Path.cwd() / "images"
|
||||
expose_source: bool = False
|
||||
private: bool = False
|
||||
manips: Dict[str, ManipConfig] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
manip_names = set(
|
||||
[
|
||||
key.replace("KODAK_MANIP_", "").partition("_")[0]
|
||||
for key in os.environ.keys()
|
||||
if key.startswith("KODAK_MANIP_")
|
||||
]
|
||||
)
|
||||
return cls(
|
||||
sourcedir=Path(os.environ.get("KODAK_SOURCEDIR", cls.sourcedir))
|
||||
.expanduser()
|
||||
.resolve(),
|
||||
manipdir=Path(os.environ.get("KODAK_MANIPDIR", cls.manipdir))
|
||||
.expanduser()
|
||||
.resolve(),
|
||||
expose_source=os.getenv(
|
||||
"KODAK_EXPOSE_SOURCE", str(cls.expose_source)
|
||||
).lower()
|
||||
== "true",
|
||||
private=os.getenv("KODAK_PRIVATE", str(cls.private)).lower() == "true",
|
||||
manips={name.lower(): ManipConfig.from_env(name) for name in manip_names},
|
||||
)
|
||||
|
||||
|
||||
def load() -> KodakConfig:
|
||||
try:
|
||||
return KodakConfig.from_env()
|
||||
except (ValueError, TypeError, IndexError, KeyError) as err:
|
||||
raise RuntimeError(err)
|
||||
41
kodak/constants.py
Normal file
41
kodak/constants.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import enum
|
||||
|
||||
import peewee
|
||||
|
||||
|
||||
class DatabaseBackend(enum.Enum):
|
||||
MARIADB = peewee.MySQLDatabase
|
||||
SQLITE = peewee.SqliteDatabase
|
||||
|
||||
|
||||
class DimensionStrategy(enum.Enum):
|
||||
CROP = enum.auto()
|
||||
SCALE = enum.auto()
|
||||
RELATIVE = enum.auto()
|
||||
|
||||
|
||||
class ImageFormat(enum.Enum):
|
||||
JPEG = enum.auto()
|
||||
PNG = enum.auto()
|
||||
GIF = enum.auto()
|
||||
|
||||
|
||||
class Anchor(enum.Enum):
|
||||
TL = "top-left"
|
||||
TC = "top-center"
|
||||
TR = "top-center"
|
||||
CL = "center-left"
|
||||
C = "center"
|
||||
CR = "center-right"
|
||||
BL = "bottom-left"
|
||||
BC = "bottom-center"
|
||||
BR = "bottom-right"
|
||||
|
||||
|
||||
DEFAULT_SQLITE_PRAGMAS = {
|
||||
"journal_mode": "wal",
|
||||
"cache_size": -1 * 64000,
|
||||
"foreign_keys": 1,
|
||||
"ignore_check_constraints": 0,
|
||||
"synchronous": 0,
|
||||
}
|
||||
58
kodak/database/__init__.py
Normal file
58
kodak/database/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
import peewee
|
||||
|
||||
from kodak import constants
|
||||
from kodak.configuration import KodakConfig
|
||||
from kodak.database._shared import INTERFACE as interface
|
||||
from kodak.database._shared import KodakModel
|
||||
from kodak.database.image import ImageRecord
|
||||
from kodak.database.thumbnail import ThumbnailRecord
|
||||
|
||||
|
||||
MODELS: Tuple[KodakModel, ...] = (ImageRecord, ThumbnailRecord)
|
||||
|
||||
|
||||
def initialize(config: KodakConfig):
|
||||
"""Initialize the database interface
|
||||
|
||||
Defining the database as an
|
||||
`unconfigured proxy object <http://docs.peewee-orm.com/en/latest/peewee/database.html#setting-the-database-at-run-time>`_
|
||||
allows it to be configured at runtime based on the config values.
|
||||
|
||||
:param config: Populated configuration container object
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if config.database.backend == constants.SupportedDatabaseBackend.SQLITE:
|
||||
logger.debug("Using SQLite database backend")
|
||||
logger.debug(f"Applying SQLite pragmas: {config.database.sqlite.pragmas}")
|
||||
database = peewee.SqliteDatabase(
|
||||
config.database.sqlite.path, pragmas=config.database.sqlite.pragmas
|
||||
)
|
||||
elif config.database.backend == constants.SupportedDatabaseBackend.MARIADB:
|
||||
logger.debug("Using MariaDB database backend")
|
||||
logger.debug(
|
||||
"Configuring MariaDB:"
|
||||
f" {config.database.mariadb.username}@{config.database.mariadb.hostname}:{config.database.mariadb.port},"
|
||||
f" with database '{config.database.mariadb.schema}'"
|
||||
)
|
||||
database = peewee.MySQLDatabase(
|
||||
config.database.mariadb.schema,
|
||||
host=config.database.mariadb.hostname,
|
||||
port=config.database.mariadb.port,
|
||||
user=config.database.mariadb.username,
|
||||
password=config.database.mariadb.password,
|
||||
charset="utf8mb4",
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid storage backend in configuration: {config.database.backend}"
|
||||
)
|
||||
|
||||
interface.initialize(database)
|
||||
|
||||
with interface.atomic():
|
||||
interface.create_tables(MODELS)
|
||||
15
kodak/database/_shared.py
Normal file
15
kodak/database/_shared.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
import peewee
|
||||
|
||||
|
||||
INTERFACE = peewee.DatabaseProxy()
|
||||
|
||||
|
||||
class KodakModel(peewee.Model):
|
||||
class Meta: # pylint: disable=too-few-public-methods,missing-class-docstring
|
||||
database = INTERFACE
|
||||
|
||||
uuid = peewee.UUIDField(null=False, unique=True, default=uuid.uuid4)
|
||||
created = peewee.DateTimeField(null=False, default=datetime.datetime.utcnow)
|
||||
30
kodak/database/image.py
Normal file
30
kodak/database/image.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import json
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
import peewee
|
||||
|
||||
from kodak.database._shared import KodakModel
|
||||
|
||||
|
||||
class ImageRecord(KodakModel):
|
||||
"""Database record for"""
|
||||
|
||||
width = peewee.IntegerField(null=False)
|
||||
height = peewee.IntegerField(null=False)
|
||||
format = peewee.CharField(null=False)
|
||||
deleted = peewee.BooleanField(null=False, default=False)
|
||||
public = peewee.BooleanField(null=False, default=False)
|
||||
owner = peewee.UUIDField(null=False)
|
||||
sha256 = peewee.CharField(null=False)
|
||||
_readable = peewee.CharField(null=False, default="[]")
|
||||
|
||||
@property
|
||||
def readable(self) -> List[uuid.UUID]:
|
||||
"""List of UUIDs corresponding to accounts that can read the file"""
|
||||
return [uuid.UUID(item) for item in json.loads(self._readable)]
|
||||
|
||||
@readable.setter
|
||||
def readable(self, value: List[uuid.UUID]):
|
||||
"""Update the list of UUIDs for accounts that can read the file"""
|
||||
self._readable = json.dumps([str(item) for item in value])
|
||||
11
kodak/database/thumbnail.py
Normal file
11
kodak/database/thumbnail.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import peewee
|
||||
|
||||
from kodak.database._shared import KodakModel
|
||||
from kodak.database.image import ImageRecord
|
||||
|
||||
|
||||
class ThumbnailRecord(KodakModel):
|
||||
|
||||
parent = peewee.ForeignKeyField(ImageRecord)
|
||||
width = peewee.IntegerField(null=False)
|
||||
height = peewee.IntegerField(null=False)
|
||||
39
kodak/exceptions.py
Normal file
39
kodak/exceptions.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Application exceptions
|
||||
|
||||
::
|
||||
|
||||
KodakException
|
||||
+-- ClientError
|
||||
+-- ServerError
|
||||
"""
|
||||
|
||||
|
||||
class KodakException(Exception):
|
||||
"""Whomp whomp, something went wrong
|
||||
|
||||
But seriously, don't ever raise this exception
|
||||
"""
|
||||
|
||||
status: int
|
||||
|
||||
|
||||
class ClientError(KodakException):
|
||||
"""Error while processing client side input"""
|
||||
|
||||
status = 400
|
||||
|
||||
|
||||
class ImageResourceDeletedError(ClientError):
|
||||
"""Requested image resource has been deleted"""
|
||||
|
||||
status = 410
|
||||
|
||||
|
||||
class ServerError(KodakException):
|
||||
"""Error while processing server side data"""
|
||||
|
||||
status = 500
|
||||
|
||||
|
||||
class ImageFileRemovedError(ServerError):
|
||||
"""Image file removed from server"""
|
||||
0
kodak/py.typed
Normal file
0
kodak/py.typed
Normal file
15
kodak/resources/__init__.py
Normal file
15
kodak/resources/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from typing import Tuple
|
||||
|
||||
from kodak.resources._shared import KodakResource
|
||||
from kodak.resources.alias import ImageAlias
|
||||
from kodak.resources.heartbeat import Heartbeat
|
||||
from kodak.resources.image import Image
|
||||
from kodak.resources.openapi import OpenAPI
|
||||
|
||||
|
||||
RESOURCES: Tuple[KodakResource, ...] = (
|
||||
Heartbeat,
|
||||
Image,
|
||||
ImageAlias,
|
||||
OpenAPI,
|
||||
)
|
||||
112
kodak/resources/_shared.py
Normal file
112
kodak/resources/_shared.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""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
|
||||
|
||||
from kodak import __about__
|
||||
|
||||
|
||||
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 KodakResource(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.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
|
||||
# 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
|
||||
)
|
||||
10
kodak/resources/alias.py
Normal file
10
kodak/resources/alias.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from kodak.resources._shared import KodakResource
|
||||
from kodak.resources._shared import ResponseTuple
|
||||
|
||||
|
||||
class ImageAlias(KodakResource):
|
||||
|
||||
routes = ("/image/<string:image_name>/<string:alias>",)
|
||||
|
||||
def get(self, image_name: str, alias: str) -> ResponseTuple:
|
||||
raise NotImplementedError
|
||||
18
kodak/resources/heartbeat.py
Normal file
18
kodak/resources/heartbeat.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from kodak import configuration
|
||||
from kodak import database
|
||||
from kodak.resources._shared import KodakResource
|
||||
from kodak.resources._shared import ResponseTuple
|
||||
|
||||
|
||||
class Heartbeat(KodakResource):
|
||||
|
||||
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())
|
||||
10
kodak/resources/image.py
Normal file
10
kodak/resources/image.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from kodak.resources._shared import KodakResource
|
||||
from kodak.resources._shared import ResponseTuple
|
||||
|
||||
|
||||
class Image(KodakResource):
|
||||
|
||||
routes = ("/image/<string:image_name>",)
|
||||
|
||||
def get(self, image_name: str) -> ResponseTuple:
|
||||
raise NotImplementedError
|
||||
25
kodak/resources/openapi.py
Normal file
25
kodak/resources/openapi.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from kodak.resources._shared import KodakResource
|
||||
from kodak.resources._shared import ResponseTuple
|
||||
|
||||
yaml = YAML(typ="safe")
|
||||
|
||||
|
||||
class OpenAPI(KodakResource):
|
||||
"""Handle requests for the OpenAPI specification resource"""
|
||||
|
||||
routes = ("/openapi.json",)
|
||||
|
||||
def get(self) -> ResponseTuple:
|
||||
"""Retrieve the OpenAPI specification document"""
|
||||
with (Path(__file__).parent / "openapi.yaml").open() as infile:
|
||||
data = yaml.load(infile)
|
||||
|
||||
return self.make_response(data)
|
||||
|
||||
def head(self) -> ResponseTuple:
|
||||
"""Alias of GET with no response body"""
|
||||
return self._head(self.get())
|
||||
Reference in New Issue
Block a user