Skip to content

Commit

Permalink
Try with only python3.11
Browse files Browse the repository at this point in the history
  • Loading branch information
Etienne Jodry authored and Etienne Jodry committed Oct 31, 2024
1 parent 6a9e26c commit bfcb6a8
Show file tree
Hide file tree
Showing 24 changed files with 223 additions and 116 deletions.
21 changes: 20 additions & 1 deletion docs/developer_manual/advanced_use.rst
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ a ``ResourceController``.
.. code-block:: python
:caption: s3controller.py
from biodm.routing import Route, PublicRoute
class S3Controller(ResourceController):
"""Controller for entities involving file management leveraging an S3Service."""
def _infer_svc(self) -> Type[S3Service]:
Expand All @@ -200,7 +202,7 @@ a ``ResourceController``.
prefix = f'{self.prefix}/{self.qp_id}/'
file_routes = [
Route(f'{prefix}download', self.download, methods=[HttpMethod.GET]),
Route(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]),
PublicRoute(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]),
...
]
# Set an extra attribute for later.
Expand Down Expand Up @@ -420,3 +422,20 @@ A lot of that code has to do with retrieving async SQLAlchemy objects attributes
# Generate a new form.
await self.gen_upload_form(file, session=session)
return file
.. _dev-routing:

Routing and Auth
----------------

As shown in the ``S3Controller`` example above, ``BioDM`` provides two
``Routes`` class: ``PublicRoute`` and ``Route``.

In case you are defining your own routes you should use those ones instead of
starlette's ``Route``.

Ultimately, this allows to use the config parameter ``REQUIRE_AUTH`` which when set to ``True``
will require authentication on all endpoints routed
with simple ``Routes`` while leaving endpoints marked with ``PublicRoute`` public.
This distinction can be important as in the example above, s3 bucket is **not** authenticated
when sending us a successful notice of file upload.
2 changes: 1 addition & 1 deletion docs/developer_manual/demo.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ we will go over the following minimal example.
# Tables
class Dataset(bd.components.Versioned, bd.components.Base):
id = Column(Integer, primary_key=True, autoincrement=not 'sqlite' in config.DATABASE_URL)
id = Column(Integer, primary_key=True, autoincrement=not 'sqlite' in str(config.DATABASE_URL))
name : sao.Mapped[str] = sa.Column(sa.String(50), nullable=False)
description : sao.Mapped[str] = sa.Column(sa.String(500), nullable=False)
username_owner: sao.Mapped[int] = sa.Column(sa.ForeignKey("USER.username"), nullable=False)
Expand Down
11 changes: 11 additions & 0 deletions docs/developer_manual/permissions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ be provided in a ``.env`` file at the same level as your ``demo.py`` script.
KC_CLIENT_ID=
KC_CLIENT_SECRET=
Server level: REQUIRE_AUTH
--------------------------

Setting ``REQUIRE_AUTH=True`` config argument, will make all routes, except the ones explicitely
marked public (such as ``/login`` and ``/[resources/|]schemas)`` require authentication.


See more at :ref:`dev-routing`


Coarse: Static rule on a Controller endpoint
---------------------------------------------

Expand Down
17 changes: 9 additions & 8 deletions docs/user_manual.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,11 @@ This triggers creation of a new row with a version increment.

.. note::

``PUT /release`` is the way of updating versioned resources.
The endpoint ``PUT /`` (a.k.a ``update``) will not be available for such resources, and
any attempt at updating by reference through ``POST /`` will raise an error.
``POST /release`` is the way of updating versioned resources.
The endpoint ``PUT /`` (a.k.a ``update``) is available, however it is meant to be used
in order to update nested objects and collections of that resource. Thus,
any attempt at updating a versioned resource through either ``PUT /`` or ``POST /``
shall raise an error.


**E.g.**
Expand Down Expand Up @@ -178,17 +180,16 @@ and followed by:
* Use ``nested.field=val`` to select on a nested attribute field
* Use ``*`` in a string attribute for wildcards

* operators ``field.op([value])``
* numeric operators ``field.op([value])``

* ``[lt, le, gt, ge]`` are supported with a value.

* ``[min, max]`` are supported without a value

**e.g.**

.. note::

When querying with ``curl``, don't forget to escape ``&`` symbol or enclose the whole url in quotes, else your scripting language may intepret it as several commands.
When querying with ``curl``, don't forget to escape ``&`` symbol or enclose the whole url
in quotes, else your scripting language may intepret it as several commands.


Query a nested collection
Expand Down Expand Up @@ -368,6 +369,6 @@ otherwise.

- Passing a top level group will allow all descending children group for that verb/resource tuple.

- Permissions are taken into account if and only if keyclaok functionalities are enabled.
- Permissions are taken into account if and only if keycloak functionalities are enabled.

- Without keycloak, no token exchange -> No way of getting back protected data.
14 changes: 4 additions & 10 deletions src/biodm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,10 @@ def __init__(self, app: ASGIApp, server_host: str) -> None:
super().__init__(app, self.dispatch)

async def dispatch(self, request: Request, call_next: Callable) -> Any:
if request.state.user_info.info:
user_id = request.state.user_info.info[0]
user_groups = request.state.user_info.info[1]
else:
user_id = "anon"
user_groups = ['no_groups']

endpoint = str(request.url).rsplit(self.server_host, maxsplit=1)[-1]
body = await request.body()
entry = {
'user_username': user_id,
'user_username': request.user.display_name,
'endpoint': endpoint,
'method': request.method,
'content': str(body) if body else ""
Expand All @@ -84,7 +77,8 @@ async def dispatch(self, request: Request, call_next: Callable) -> Any:
# Log
timestamp = datetime.now().strftime("%I:%M%p on %B %d, %Y")
History.svc.app.logger.info(
f'{timestamp}\t{user_id}\t{",".join(user_groups)}\t'
f'{timestamp}\t'
f'{request.user.display_name}\t{",".join(request.user.groups)}\t'
f'{endpoint}\t-\t{request.method}'
)

Expand Down Expand Up @@ -177,7 +171,7 @@ def __init__(
# Middlewares -> Stack goes in reverse order.
self.add_middleware(HistoryMiddleware, server_host=config.SERVER_HOST)
self.add_middleware(AuthenticationMiddleware)
if self.scope is Scope.PROD:
if Scope.DEBUG not in self.scope:
self.add_middleware(TimeoutMiddleware, timeout=config.SERVER_TIMEOUT)
# CORS last (i.e. first).
self.add_middleware(
Expand Down
16 changes: 9 additions & 7 deletions src/biodm/basics/rootcontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,26 @@

from starlette.requests import Request
from starlette.responses import Response, PlainTextResponse
from starlette.routing import Route
# from starlette.routing import Route

from biodm import config
from biodm.components.controllers import Controller
from biodm.utils.security import admin_required, login_required
from biodm.utils.utils import json_response
from biodm.routing import Route, PublicRoute

from biodm import tables as bt


class RootController(Controller):
"""Bundles Routes located at the root of the app i.e. '/'."""
def routes(self, **_):
return [
Route("/live", endpoint=self.live),
Route("/login", endpoint=self.login),
Route("/syn_ack", endpoint=self.syn_ack),
PublicRoute("/live", endpoint=self.live),
PublicRoute("/login", endpoint=self.login),
PublicRoute("/syn_ack", endpoint=self.syn_ack),
PublicRoute("/schema", endpoint=self.openapi_schema),
Route("/authenticated", endpoint=self.authenticated),
Route("/schema", endpoint=self.openapi_schema),
] + (
[Route("/kc_sync", endpoint=self.keycloak_sync)]
if hasattr(self.app, 'kc') else []
Expand Down Expand Up @@ -106,8 +108,8 @@ async def authenticated(self, request: Request) -> Response:
description: Unauthorized.
"""
assert request.state.user_info.info
user_id, groups = request.state.user_info.info
assert request.user.info
user_id, groups = request.user.info
return PlainTextResponse(f"{user_id}, {groups}\n")

@admin_required
Expand Down
6 changes: 4 additions & 2 deletions src/biodm/components/controllers/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from io import BytesIO
from typing import Any, Iterable, List, Dict, TYPE_CHECKING, Optional

from marshmallow import RAISE
from marshmallow.schema import Schema
from marshmallow.exceptions import ValidationError
from sqlalchemy.exc import MissingGreenlet
Expand All @@ -17,7 +16,7 @@
from biodm import config
from biodm.component import ApiComponent
from biodm.exceptions import (
PayloadJSONDecodingError, AsyncDBError, SchemaError
DataError, PayloadJSONDecodingError, AsyncDBError, SchemaError
)
from biodm.utils.utils import json_response

Expand Down Expand Up @@ -104,6 +103,9 @@ def validate(
json_data = json.loads(data) # Accepts **kwargs in case support needed.
return cls.schema.load(json_data, many=many, partial=partial)

except ValidationError as ve:
raise DataError(str(ve.messages))

except json.JSONDecodeError as e:
raise PayloadJSONDecodingError(cls.__name__) from e

Expand Down
23 changes: 12 additions & 11 deletions src/biodm/components/controllers/resourcecontroller.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from marshmallow.schema import RAISE
from marshmallow.class_registry import get_class
from marshmallow.exceptions import RegistryError
from starlette.routing import Mount, Route, BaseRoute
from starlette.routing import Mount, BaseRoute
from starlette.requests import Request
from starlette.responses import Response

Expand All @@ -32,6 +32,7 @@
from biodm.utils.utils import json_response
from biodm.utils.apispec import register_runtime_schema, process_apispec_docstrings
from biodm.components import Base
from biodm.routing import Route, PublicRoute
from .controller import HttpMethod, EntityController

if TYPE_CHECKING:
Expand Down Expand Up @@ -199,7 +200,7 @@ def routes(self, **_) -> List[Mount | Route] | List[Mount] | List[BaseRoute]:
Route(f"{self.prefix}", self.create, methods=[HttpMethod.POST]),
Route(f"{self.prefix}", self.filter, methods=[HttpMethod.GET]),
Mount(self.prefix, routes=[
Route('/schema', self.openapi_schema, methods=[HttpMethod.GET]),
PublicRoute('/schema', self.openapi_schema, methods=[HttpMethod.GET]),
Route(f'/{self.qp_id}', self.read, methods=[HttpMethod.GET]),
Route(f'/{self.qp_id}/{{attribute}}', self.read, methods=[HttpMethod.GET]),
Route(f'/{self.qp_id}', self.delete, methods=[HttpMethod.DELETE]),
Expand Down Expand Up @@ -321,7 +322,7 @@ async def create(self, request: Request) -> Response:
created = await self.svc.write(
data=validated_data,
stmt_only=False,
user_info=request.state.user_info,
user_info=request.user,
serializer=partial(self.serialize, many=isinstance(validated_data, list))
)
return json_response(data=created, status_code=201)
Expand Down Expand Up @@ -378,14 +379,14 @@ async def read(self, request: Request) -> Response:

fields = ctrl._extract_fields(
dict(request.query_params),
user_info=request.state.user_info
user_info=request.user
)
return json_response(
data=await self.svc.read(
pk_val=self._extract_pk_val(request),
fields=fields,
nested_attribute=nested_attribute,
user_info=request.state.user_info,
user_info=request.user,
serializer=partial(ctrl.serialize, many=many, only=fields),
),
status_code=200,
Expand Down Expand Up @@ -436,7 +437,7 @@ async def update(self, request: Request) -> Response:
data=await self.svc.write(
data=validated_data,
stmt_only=False,
user_info=request.state.user_info,
user_info=request.user,
serializer=partial(self.serialize, many=isinstance(validated_data, list)),
),
status_code=201,
Expand Down Expand Up @@ -464,7 +465,7 @@ async def delete(self, request: Request) -> Response:
"""
await self.svc.delete(
pk_val=self._extract_pk_val(request),
user_info=request.state.user_info,
user_info=request.user,
)
return json_response("Deleted.", status_code=200)

Expand Down Expand Up @@ -496,12 +497,12 @@ async def filter(self, request: Request) -> Response:
schema: Schema
"""
params = dict(request.query_params)
fields = self._extract_fields(params, user_info=request.state.user_info)
fields = self._extract_fields(params, user_info=request.user)
return json_response(
await self.svc.filter(
fields=fields,
params=params,
user_info=request.state.user_info,
user_info=request.user,
serializer=partial(self.serialize, many=True, only=fields),
),
status_code=200,
Expand Down Expand Up @@ -542,14 +543,14 @@ async def release(self, request: Request) -> Response:

fields = self._extract_fields(
dict(request.query_params),
user_info=request.state.user_info
user_info=request.user
)

return json_response(
await self.svc.release(
pk_val=self._extract_pk_val(request),
update=validated_data,
user_info=request.state.user_info,
user_info=request.user,
serializer=partial(self.serialize, many=False, only=fields),
), status_code=200
)
5 changes: 3 additions & 2 deletions src/biodm/components/controllers/s3controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import List, Type

from marshmallow import Schema, RAISE
from starlette.routing import Route, Mount, BaseRoute
from starlette.routing import Mount, BaseRoute
from starlette.requests import Request
from starlette.responses import RedirectResponse

Expand All @@ -14,6 +14,7 @@
from biodm.exceptions import ImplementionError
from biodm.utils.security import UserInfo
from biodm.utils.utils import json_response
from biodm.routing import PublicRoute, Route
from .controller import HttpMethod
from .resourcecontroller import ResourceController

Expand Down Expand Up @@ -50,8 +51,8 @@ def routes(self, **_) -> List[Mount | Route] | List[Mount] | List[BaseRoute]:
prefix = f'{self.prefix}/{self.qp_id}/'
file_routes = [
Route(f'{prefix}download', self.download, methods=[HttpMethod.GET]),
Route(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]),
Route(f'{prefix}complete_multipart', self.complete_multipart, methods=[HttpMethod.PUT]),
PublicRoute(f'{prefix}post_success', self.post_success, methods=[HttpMethod.GET]),
]
self.post_upload_callback = Path(file_routes[1].path)

Expand Down
Loading

0 comments on commit bfcb6a8

Please sign in to comment.