Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ An API for the Marble platform.

- MongoDB server

## Authentication and Authorization

Marble API does not do any authentication or authorization (authn/z). That is left to other
applications (such as [Magpie](https://github.com/ouranosinc/magpie)).

Marble API assumes that only users with administrator access should be able to access all routes
prefixed with `/vX/admin/` (where `X` is a version number).

Marble API also assumes that only users with a given user name or id `Y` should be able to access
all routes prefixed with `/vX/users/Y/` (where `X` is a version number).

When integrating Marble API with the [birdhouse](https://github.com/bird-house/birdhouse-deploy/) platform we
recommend enabling it with the
[Marble API component](https://github.com/DACCS-Climate/marble-config/tree/main/components/marble-api).
This enables the basic authn/z rules described above through [Magpie](https://github.com/ouranosinc/magpie).

## Developing

To start a development server:
Expand Down
6 changes: 4 additions & 2 deletions marble_api/versions/v1/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from fastapi import FastAPI

from marble_api.versions.v1.data_request.routes import router as data_request_router
from marble_api.versions.v1.data_request.routes import admin_router as data_request_admin_router
from marble_api.versions.v1.data_request.routes import user_router as data_request_user_router

app = FastAPI(version="1")

app.include_router(data_request_router)
app.include_router(data_request_user_router)
app.include_router(data_request_admin_router)
13 changes: 11 additions & 2 deletions marble_api/versions/v1/data_request/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
ConfigDict,
EmailStr,
Field,
FieldSerializationInfo,
ValidationInfo,
field_serializer,
field_validator,
)
from pydantic.functional_validators import BeforeValidator
Expand Down Expand Up @@ -46,7 +48,7 @@ class DataRequest(BaseModel):
"""

id: SkipJsonSchema[PyObjectId | None] = Field(default=None, validation_alias="_id", exclude=True)
user: str
user: SkipJsonSchema[str | None] = None # user is set by the route after the model is first validated
title: str
description: str | None = None
authors: list[Author]
Expand All @@ -60,7 +62,7 @@ class DataRequest(BaseModel):
extra_properties: dict[str, str] = {}
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)

@field_validator("user", "title", "description", "authors", "path", "contact")
@field_validator("title", "description", "authors", "path", "contact")
@classmethod
def min_length_if_set(cls, value: Sized | None, info: ValidationInfo) -> Sized | None:
"""Raise an error if the value is not None and is empty."""
Expand All @@ -75,6 +77,12 @@ def validate_geometries(cls, value: GeoJSON | None) -> dict | None:
validate_collapsible(value)
return value

@field_serializer("user")
def require_user_set(self, value: str, info: FieldSerializationInfo) -> str:
"""Require that the user_name is set when the model is serialized."""
assert value, f"{info.field_name} must be set and non-empty"
return value


@partial_model
class DataRequestUpdate(DataRequest):
Expand All @@ -96,6 +104,7 @@ class DataRequestPublic(DataRequest):
"""

id: Annotated[str, BeforeValidator(str)] = Field(..., validation_alias="_id")
user: str # user is required to be set in the database
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow")

@property
Expand Down
91 changes: 70 additions & 21 deletions marble_api/versions/v1/data_request/routes.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from collections.abc import AsyncGenerator
from typing import Annotated

import pymongo
from bson import ObjectId
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from pydantic_core import PydanticSerializationError
from pymongo import ReturnDocument

from marble_api.database import client
Expand All @@ -14,26 +16,52 @@
DataRequestUpdate,
)

router = APIRouter(prefix="/data-requests")

async def _handle_serialization_error() -> AsyncGenerator[None]:
try:
yield
except PydanticSerializationError as e:
raise HTTPException(status_code=422, detail=str(e)) from e


user_router = APIRouter(prefix="/users/{user}/data-requests", tags=["User"])
admin_router = APIRouter(
prefix="/admin/data-requests", tags=["Admin"], dependencies=[Depends(_handle_serialization_error)]
)


def _data_request_id(id_: str) -> ObjectId:
return object_id(id_, HTTPException(status_code=404, detail=f"data publish request with id={id_} not found"))


@router.post("/")
async def post_data_request(data_request: DataRequest) -> DataRequestPublic:
def _is_router_scope(request: Request, router: APIRouter) -> bool:
return request.scope.get("route").path.startswith(f"{router.prefix}/")


@user_router.post("/")
@admin_router.post("/")
async def post_data_request_user(user: str, data_request: DataRequest) -> DataRequestPublic:
"""Create a new data request and return the newly created data request."""
data_request.user = user
new_data_request = data_request.model_dump(by_alias=True)
result = await client.db["data-request"].insert_one(new_data_request)
new_data_request["id"] = str(result.inserted_id)
return new_data_request


@router.patch("/{request_id}")
async def patch_data_request(request_id: str, data_request: DataRequestUpdate) -> DataRequestPublic:
@user_router.patch("/{request_id}")
@admin_router.patch("/{request_id}")
async def patch_data_request(
request_id: str, data_request: DataRequestUpdate, request: Request, user: str | None = None
) -> DataRequestPublic:
"""Update fields of data request and return the updated data request."""
updated_fields = data_request.model_dump(exclude_unset=True, by_alias=True)
updated_user = updated_fields.get("user")
if updated_user and _is_router_scope(request, user_router) and user != updated_user:
# Users cannot change the data request so that it belongs to a different user
raise HTTPException(status_code=403, detail="Forbidden")
if user:
data_request.user = user
selector = {"_id": _data_request_id(request_id)}
if updated_fields:
updated_fields.update(data_request.model_dump(include="stac_item"))
Expand All @@ -49,10 +77,16 @@ async def patch_data_request(request_id: str, data_request: DataRequestUpdate) -
raise HTTPException(status_code=404, detail="data publish request not found")


@router.get("/{request_id}", response_model_by_alias=False)
async def get_data_request(request_id: str, stac: bool = False) -> DataRequestPublic:
@user_router.get("/{request_id}", response_model_by_alias=False)
@admin_router.get("/{request_id}", response_model_by_alias=False)
async def get_data_request(
request_id: str, request: Request, stac: bool = False, user: str | None = None
) -> DataRequestPublic:
"""Get a data request with the given request_id."""
if (result := await client.db["data-request"].find_one({"_id": _data_request_id(request_id)})) is not None:
selector = {"_id": _data_request_id(request_id)}
if _is_router_scope(request, user_router):
selector["user"] = user
if (result := await client.db["data-request"].find_one(selector)) is not None:
if stac:
try:
result["stac_item"] = DataRequestPublic(**result).stac_item
Expand All @@ -63,20 +97,26 @@ async def get_data_request(request_id: str, stac: bool = False) -> DataRequestPu
raise HTTPException(status_code=404, detail="data publish request not found")


@router.delete("/{request_id}")
async def delete_data_request(request_id: str) -> Response:
@user_router.delete("/{request_id}")
@admin_router.delete("/{request_id}")
async def delete_data_request(request_id: str, request: Request, user: str | None = None) -> Response:
"""Delete a data request with the given request_id."""
result = await client.db["data-request"].delete_one({"_id": _data_request_id(request_id)})
selector = {"_id": _data_request_id(request_id)}
if _is_router_scope(request, user_router):
selector["user"] = user

result = await client.db["data-request"].delete_one(selector)
if result.deleted_count == 1:
return Response(status_code=status.HTTP_204_NO_CONTENT)

raise HTTPException(status_code=404, detail="data publish request not found")


@router.get("/")
@user_router.get("/")
@admin_router.get("/")
async def get_data_requests(
request: Request,
user: str | None = None,
after: str | None = None,
before: str | None = None,
limit: Annotated[int, Query(le=100, gt=0)] = 10,
Expand All @@ -89,21 +129,27 @@ async def get_data_requests(
Use the offset and limit parameters to select specific ranges of data requests.
"""
reverse_it = False
selector = {}
if _is_router_scope(request, user_router):
selector["user"] = user
if after:
db_request = (
client.db["data-request"].find({"_id": {"$gte": _data_request_id(after)}}).sort("_id", pymongo.ASCENDING)
client.db["data-request"]
.find({**selector, "_id": {"$gt": _data_request_id(after)}})
.sort("_id", pymongo.ASCENDING)
)
elif before:
db_request = (
client.db["data-request"].find({"_id": {"$lte": _data_request_id(before)}}).sort("_id", pymongo.DESCENDING)
client.db["data-request"]
.find({**selector, "_id": {"$lt": _data_request_id(before)}})
.sort("_id", pymongo.DESCENDING)
)
reverse_it = True # put the eventual result back in ascending order for consistency
else:
db_request = client.db["data-request"].find({}).sort("_id", pymongo.ASCENDING)

db_request = client.db["data-request"].find(selector).sort("_id", pymongo.ASCENDING)
data_requests = await db_request.limit(limit + 1).to_list()
if reverse_it:
data_requests = reversed(data_requests)
data_requests = list(reversed(data_requests))

query_params = {}

Expand All @@ -112,14 +158,17 @@ async def get_data_requests(
if data_requests:
if after:
if over_limit:
data_requests.pop()
query_params["after"] = data_requests[-1]["_id"]
query_params["before"] = data_requests.pop(0)["_id"]
query_params["before"] = data_requests[0]["_id"]
elif before:
if over_limit:
data_requests.pop(0)
query_params["before"] = data_requests[0]["_id"]
query_params["after"] = data_requests.pop()["_id"]
query_params["after"] = data_requests[-1]["_id"]
elif over_limit:
query_params["after"] = data_requests.pop()["_id"]
data_requests.pop()
query_params["after"] = data_requests[-1]["_id"]

links = []

Expand Down
Loading