diff --git a/src/biodm/basics/k8scontroller.py b/src/biodm/basics/k8scontroller.py index a912105..73cd496 100644 --- a/src/biodm/basics/k8scontroller.py +++ b/src/biodm/basics/k8scontroller.py @@ -2,6 +2,7 @@ from copy import deepcopy from starlette.routing import Mount, Route +from starlette.requests import Request from starlette.responses import Response from biodm.components.controllers import Controller, HttpMethod @@ -18,8 +19,11 @@ def prefix(self): return "/k8s_instances" def routes(self, schema=False) -> Mount: - """ - schema: + """Routes for k8s instances management + + :param schema: when called from schema generation, defaults to False + :type schema: bool + :rtype: starlette.routing.Mount """ m = Mount(self.prefix, routes=[ Route( "/{manifest}", self.create, methods=[HttpMethod.POST.value]), @@ -36,7 +40,6 @@ def routes(self, schema=False) -> Mount: for key in keys: r_view = deepcopy(r_create) r_view.path = f"/{key}" - # dummy = function() def dummy(): """""" dummy.__doc__ = mans.__dict__[key].__doc__ @@ -48,12 +51,12 @@ def dummy(): def k8s(self): return self.app.k8s - async def list_instances(self, request) -> Response: + async def list_instances(self, request: Request) -> Response: """ """ return '{}' - async def create(self, request) -> Response: + async def create(self, request: Request) -> Response: """ --- description: Deploys manifest matching identifier and tie it to the user @@ -69,7 +72,7 @@ async def create(self, request) -> Response: return self.svc.create(self.k8s.manifests.__dict__[manifest]) raise ManifestError - async def instance_info(self, request) -> Response: + async def instance_info(self, request: Request) -> Response: """ """ return '{}' diff --git a/src/biodm/component.py b/src/biodm/component.py index 6ba586d..9d177d2 100644 --- a/src/biodm/component.py +++ b/src/biodm/component.py @@ -7,7 +7,11 @@ from biodm.api import Api class ApiComponent(ABC): - """Abstract API component, refrencing main server class and its loggger.""" + """Abstract API component, refrencing main server class and its loggger. + + :param app: Reference to running server class. + :type app: class:`biodm.Api` + """ app: Api logger: logging.logger diff --git a/src/biodm/components/controllers/controller.py b/src/biodm/components/controllers/controller.py index 31e24c4..1d43a53 100644 --- a/src/biodm/components/controllers/controller.py +++ b/src/biodm/components/controllers/controller.py @@ -1,8 +1,9 @@ +from __future__ import annotations import io import json from abc import abstractmethod from enum import Enum -from typing import Any +from typing import Any, List, TYPE_CHECKING from marshmallow.schema import Schema, EXCLUDE, INCLUDE from marshmallow.exceptions import ValidationError @@ -12,6 +13,10 @@ from biodm.exceptions import PayloadJSONDecodingError, PayloadValidationError, AsyncDBError from biodm.utils.utils import json_response +if TYPE_CHECKING: + from biodm.component import Base + + class HttpMethod(Enum): """HTTP Methods.""" @@ -38,7 +43,8 @@ def schema_gen(self): return self.app.schema_generator async def openapi_schema(self, _): - """ + """ Generates openapi schema for this controllers' routes. + Relevant Documentation: - starlette: https://www.starlette.io/schemas/ - doctrings: https://apispec.readthedocs.io/en/stable/ @@ -63,12 +69,19 @@ async def openapi_schema(self, _): class EntityController(Controller, CRUDApiComponent): """EntityController - A controller performing validation and serialization given a schema. Also requires CRUD methods implementation for that entity. + + :param schema: Entity schema class + :type schema: class:`marshmallow.schema.Schema` """ schema: Schema @classmethod - def deserialize(cls, data: Any) -> (Any | list | dict | None): - """Deserialize.""" + def validate(cls, data: bytes) -> (Any | list | dict | None): + """Checks incoming data against class schema and marshall to python dict. + + :param data: some request body + :type data: bytes + """ try: json_data = json.load(io.BytesIO(data)) cls.schema.many = isinstance(json_data, list) @@ -82,8 +95,14 @@ def deserialize(cls, data: Any) -> (Any | list | dict | None): raise e @classmethod - def serialize(cls, data: Any, many: bool) -> (str | Any): - """Serialize.""" + def serialize(cls, data: dict | Base | List[Base], many: bool) -> str: + """Serialize SQLAlchemy statement execution result to json. + + :param data: some request body + :type data: dict, class:`biodm.components.Base`, List[class:`biodm.components.Base`] + :param many: plurality flag, essential to marshmallow + :type data: bool + """ try: serialized = cls.schema.dump(data, many=many) return json.dumps(serialized, indent=cls.app.config.INDENT) diff --git a/src/biodm/components/controllers/resourcecontroller.py b/src/biodm/components/controllers/resourcecontroller.py index 9b2a394..e873e0e 100644 --- a/src/biodm/components/controllers/resourcecontroller.py +++ b/src/biodm/components/controllers/resourcecontroller.py @@ -1,8 +1,10 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List, Any from starlette.routing import Mount, Route +from starlette.requests import Request +from starlette.responses import Response from biodm.components.services import ( DatabaseService, @@ -34,6 +36,9 @@ def overload_docstring(f): Relevant SO posts: - https://stackoverflow.com/questions/38125271/extending-or-overwriting-a-docstring-when-composing-classes - https://stackoverflow.com/questions/1782843/python-decorator-handling-docstrings + + :param f: The method we overload the docstrings of. + :type f: Callable """ async def wrapper(self, *args, **kwargs): if self.app.config.DEV: @@ -48,8 +53,19 @@ class ResourceController(EntityController): """Class for controllers exposing routes constituting a ressource. Implements and exposes routes under a prefix named as the resource pluralized - that act as a standard REST-to-CRUD interface.""" + that act as a standard REST-to-CRUD interface. + + :param app: running server + :type app: Api + :param entity: entity name, defaults to None, inferred if None + :type entity: str, optional + :param table: entity table, defaults to None, inferred if None + :type table: Base, optional + :param schema: entity schema, defaults to None, inferred if None + :type schema: Schema, optional + """ def __init__(self, app: Api, entity: str=None, table: Base=None, schema: Schema=None): + """Constructor.""" super().__init__(app=app) self.resource = entity if entity else self._infer_entity_name() self.table = table if table else self._infer_table() @@ -126,21 +142,35 @@ def routes(self, child_routes=None, **_) -> Mount: Route(f'/{self.qp_id}', self.update, methods=[HttpMethod.PATCH.value]), ] + child_routes) - def _extract_pk_val(self, request): + def _extract_pk_val(self, request: Request) -> List[Any]: """Extracts id from request, raise exception if not found.""" pk_val = [request.path_params.get(k) for k in self.pk] if not pk_val: raise InvalidCollectionMethod return pk_val - async def _extract_body(self, request): + async def _extract_body(self, request: Request) -> bytes: + """Extracts body from request. + + :param request: incomming request + :type request: Request + :raises PayloadEmptyError: in case payload is empty + :return: request body + :rtype: bytes + """ body = await request.body() if not body: raise PayloadEmptyError return body - async def create(self, request): - """ + async def create(self, request: Request) -> Response: + """Creates associated entity. + + :param request: incomming request + :type request: Request + :return: created object in JSON form + :rtype: Response + --- responses: 201: description: Creates associated entity. @@ -149,7 +179,7 @@ async def create(self, request): 204: description: Empty Payload """ - validated_data = self.deserialize(await self._extract_body(request)) + validated_data = self.validate(await self._extract_body(request)) return json_response( data=await self.svc.create( data=validated_data, @@ -161,8 +191,14 @@ async def create(self, request): status_code=201, ) - async def read(self, request): - """ + async def read(self, request: Request) -> Response: + """Fetch associated entity matching id in the path. + + :param request: incomming request + :type request: Request + :return: JSON reprentation of the object + :rtype: Response + --- description: Query DB for entity with matching id. parameters: - in: path @@ -179,6 +215,7 @@ async def read(self, request): 404: description: Not Found """ + fields = request.query_params.get('fields') return json_response( data=await self.svc.read( @@ -189,11 +226,11 @@ async def read(self, request): status_code=200, ) - async def update(self, request): + async def update(self, request: Request): # TODO: Implement PATCH ? raise NotImplementedError - async def delete(self, request): + async def delete(self, request: Request): """ description: Delete DB entry for entity with matching id. parameters: @@ -208,9 +245,9 @@ async def delete(self, request): await self.svc.delete(pk_val=self._extract_pk_val(request)) return json_response("Deleted.", status_code=200) - async def create_update(self, request): + async def create_update(self, request: Request): """""" - validated_data = self.deserialize(await self._extract_body(request)) + validated_data = self.validate(await self._extract_body(request)) return json_response( data=await self.svc.create_update( pk_val=self._extract_pk_val(request), data=validated_data @@ -218,7 +255,7 @@ async def create_update(self, request): status_code=200, ) - async def filter(self, request): + async def filter(self, request: Request): """ querystring shape: prop1=val1: query for entries where prop1 = val1 diff --git a/src/biodm/components/controllers/s3controller.py b/src/biodm/components/controllers/s3controller.py index 3ee43fd..a061bd2 100644 --- a/src/biodm/components/controllers/s3controller.py +++ b/src/biodm/components/controllers/s3controller.py @@ -1,6 +1,7 @@ from pathlib import Path from starlette.routing import Route, Mount +from starlette.requests import Request from biodm.components.services import S3Service from .controller import HttpMethod @@ -24,18 +25,18 @@ def routes(self, child_routes=[], **_) -> Mount: return super().routes(child_routes=child_routes + file_routes) # TODO: Decorate with permissions. - async def download(self, request): + async def download(self, request: Request): """Returns aws s3 direct download URL with a redirect header. """ # TODO: Implement pass - async def file_download_success(self, request): + async def file_download_success(self, request: Request): """Used as a callback in s3 presigned download urls for statistics.""" # TODO: Implement pass - async def file_upload_success(self, request): + async def file_upload_success(self, request: Request): """ Used as a callback in the s3 presigned upload urls that are emitted. Uppon receival, update entity status in the DB.""" diff --git a/src/biodm/components/services/dbservice.py b/src/biodm/components/services/dbservice.py index 7b89bec..347ac4e 100644 --- a/src/biodm/components/services/dbservice.py +++ b/src/biodm/components/services/dbservice.py @@ -1,4 +1,4 @@ -from typing import List, Any, Tuple +from typing import List, Any, Tuple, Dict from sqlalchemy import select, update, delete from sqlalchemy.dialects.postgresql import insert @@ -88,7 +88,8 @@ async def _delete(self, stmt: Delete, session: AsyncSession) -> None: class UnaryEntityService(DatabaseService): - """Generic Service class for non-composite entities.""" + """Generic Service class for non-composite entities. + """ def __init__(self, app, table: Base, *args, **kwargs): # Entity info. self.table = table @@ -247,8 +248,16 @@ async def filter(self, query_params: dict, **kwargs) -> List[Base]: stmt = stmt.offset(offset).limit(limit) return await self._select_many(stmt, **kwargs) - async def read(self, pk_val, fields=None, **kwargs) -> Base: - """READ one row.""" + async def read(self, pk_val: List[Any], fields:List[str]=None, **kwargs) -> Base: + """READ one item from the value of it's primary key (components) + + :param pk_val: entity primary key values in order + :type pk_val: List[Any] + :param fields: fields to restrict the query on, defaults to None + :type fields: List[str], optional + :return: SQLAlchemy result item. + :rtype: Base + """ if fields: stmt = select( Bundle( @@ -262,7 +271,15 @@ async def read(self, pk_val, fields=None, **kwargs) -> Base: return await self._select(stmt, **kwargs) async def update(self, pk_val, data: dict, **kwargs) -> Base: - """UPDATE one row.""" + """UPDATE one row. + + :param pk_val: _description_ + :type pk_val: _type_ + :param data: _description_ + :type data: dict + :return: _description_ + :rtype: Base + """ stmt = update(self.table)\ .where(self.gen_cond(pk_val))\ .values(**data)\ @@ -278,8 +295,20 @@ async def delete(self, pk_val, **kwargs) -> Any: class CompositeEntityService(UnaryEntityService): """Special case for Composite Entities (i.e. containing nested entities attributes).""" class CompositeInsert: - """Class to hold composite entities statements before insertion.""" - def __init__(self, item: Insert, nested: dict, delayed: dict): + """Class to hold composite entities statements before insertion. + + :param item: Parent item insert statement + :type item: Insert + :param nested: Nested items insert statement indexed by attribute name + :type nested: Dict[str, Insert] + :param delayed: Nested list of items insert statements indexed by attribute name + :type delayed: Dict[str, Insert] + """ + def __init__(self, + item: Insert, + nested: Dict[str, Insert], + delayed: Dict[str, Insert]): + """Constructor.""" self.item = item self.nested = nested or {} self.delayed = delayed or {} @@ -288,8 +317,18 @@ def __init__(self, item: Insert, nested: dict, delayed: dict): async def _insert_composite( self, composite: CompositeInsert, - session: AsyncSession - ) -> Base: + session: AsyncSession) -> Base: + """Insert a composite entity into the db, accounting for nested entities, + populating ids, and inserting in order according to cardinality. + + :param composite: Statements representing the object before insertion + :type composite: CompositeInsert + :param session: SQLAlchemy session + :type session: AsyncSession + :return: Inserted item + :rtype: Base + """ + # Insert all nested objects, and keep track. for key, sub in composite.nested.items(): composite.nested[key] = await self._insert(sub, session)