first commit
This commit is contained in:
commit
d416fe4cce
69 changed files with 8269 additions and 0 deletions
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
43
backend/app/api/auth.py
Normal file
43
backend/app/api/auth.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from app.core.security import create_access_token
|
||||
from app.db.mongo import get_database
|
||||
from app.schemas.auth import (
|
||||
AuthBootstrapResponse,
|
||||
AuthLoginBootstrapRequest,
|
||||
AuthLoginRequest,
|
||||
AuthSignupRequest,
|
||||
AuthTokenResponse,
|
||||
)
|
||||
from app.services.auth import create_account, get_account_by_identifier, verify_account_login
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/signup", response_model=AuthTokenResponse)
|
||||
async def signup(payload: AuthSignupRequest, database: AsyncIOMotorDatabase = Depends(get_database)) -> AuthTokenResponse:
|
||||
owner_id = await create_account(database, payload)
|
||||
access_token = create_access_token(owner_id)
|
||||
return AuthTokenResponse(accessToken=access_token, ownerId=owner_id)
|
||||
|
||||
|
||||
@router.post("/bootstrap", response_model=AuthBootstrapResponse)
|
||||
async def bootstrap(
|
||||
payload: AuthLoginBootstrapRequest,
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> AuthBootstrapResponse:
|
||||
account = await get_account_by_identifier(database, payload.identifierType.value, payload.identifier)
|
||||
return AuthBootstrapResponse(
|
||||
identifierType=payload.identifierType,
|
||||
identifier=account["identifier"],
|
||||
saltAuth=account["saltAuth"],
|
||||
saltEnc=account["saltEnc"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthTokenResponse)
|
||||
async def login(payload: AuthLoginRequest, database: AsyncIOMotorDatabase = Depends(get_database)) -> AuthTokenResponse:
|
||||
owner_id = await verify_account_login(database, payload)
|
||||
access_token = create_access_token(owner_id)
|
||||
return AuthTokenResponse(accessToken=access_token, ownerId=owner_id)
|
||||
61
backend/app/api/children.py
Normal file
61
backend/app/api/children.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from app.dependencies.auth import get_current_owner_id
|
||||
from app.db.mongo import get_database
|
||||
from app.schemas.records import ChildCreateRequest, ChildRecordResponse, ChildUpdateRequest
|
||||
from app.services.records import create_child, delete_child, get_child, list_children, update_child
|
||||
|
||||
router = APIRouter(prefix="/parents/{parent_id}/children", tags=["children"])
|
||||
|
||||
|
||||
@router.post("", response_model=ChildRecordResponse)
|
||||
async def create_child_record(
|
||||
parent_id: str,
|
||||
payload: ChildCreateRequest,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> ChildRecordResponse:
|
||||
if payload.parentId != parent_id:
|
||||
payload = ChildCreateRequest(parentId=parent_id, iv=payload.iv, ciphertext=payload.ciphertext)
|
||||
return await create_child(database, owner_id, payload)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ChildRecordResponse])
|
||||
async def list_child_records(
|
||||
parent_id: str,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> list[ChildRecordResponse]:
|
||||
return await list_children(database, owner_id, parent_id)
|
||||
|
||||
|
||||
@router.get("/{child_id}", response_model=ChildRecordResponse)
|
||||
async def get_child_record(
|
||||
parent_id: str,
|
||||
child_id: str,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> ChildRecordResponse:
|
||||
return await get_child(database, owner_id, parent_id, child_id)
|
||||
|
||||
|
||||
@router.put("/{child_id}", response_model=ChildRecordResponse)
|
||||
async def update_child_record(
|
||||
parent_id: str,
|
||||
child_id: str,
|
||||
payload: ChildUpdateRequest,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> ChildRecordResponse:
|
||||
return await update_child(database, owner_id, parent_id, child_id, payload)
|
||||
|
||||
|
||||
@router.delete("/{child_id}", status_code=204)
|
||||
async def delete_child_record(
|
||||
parent_id: str,
|
||||
child_id: str,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> None:
|
||||
await delete_child(database, owner_id, parent_id, child_id)
|
||||
8
backend/app/api/health.py
Normal file
8
backend/app/api/health.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def health_check() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
54
backend/app/api/parents.py
Normal file
54
backend/app/api/parents.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
|
||||
from app.dependencies.auth import get_current_owner_id
|
||||
from app.db.mongo import get_database
|
||||
from app.schemas.records import ParentCreateRequest, ParentRecordResponse, ParentUpdateRequest
|
||||
from app.services.records import create_parent, delete_parent, get_parent, list_parents, update_parent
|
||||
|
||||
router = APIRouter(prefix="/parents", tags=["parents"])
|
||||
|
||||
|
||||
@router.post("", response_model=ParentRecordResponse)
|
||||
async def create_parent_record(
|
||||
payload: ParentCreateRequest,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> ParentRecordResponse:
|
||||
return await create_parent(database, owner_id, payload)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ParentRecordResponse])
|
||||
async def list_parent_records(
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> list[ParentRecordResponse]:
|
||||
return await list_parents(database, owner_id)
|
||||
|
||||
|
||||
@router.get("/{parent_id}", response_model=ParentRecordResponse)
|
||||
async def get_parent_record(
|
||||
parent_id: str,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> ParentRecordResponse:
|
||||
return await get_parent(database, owner_id, parent_id)
|
||||
|
||||
|
||||
@router.put("/{parent_id}", response_model=ParentRecordResponse)
|
||||
async def update_parent_record(
|
||||
parent_id: str,
|
||||
payload: ParentUpdateRequest,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> ParentRecordResponse:
|
||||
return await update_parent(database, owner_id, parent_id, payload)
|
||||
|
||||
|
||||
@router.delete("/{parent_id}", status_code=204)
|
||||
async def delete_parent_record(
|
||||
parent_id: str,
|
||||
owner_id: str = Depends(get_current_owner_id),
|
||||
database: AsyncIOMotorDatabase = Depends(get_database),
|
||||
) -> None:
|
||||
await delete_parent(database, owner_id, parent_id)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
31
backend/app/core/config.py
Normal file
31
backend/app/core/config.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from dataclasses import dataclass, field
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
backend_env_path = Path(__file__).resolve().parents[2] / ".env"
|
||||
load_dotenv(dotenv_path=backend_env_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
app_name: str = os.getenv("APP_NAME", "Zero Knowledge Platform API")
|
||||
mongo_uri: str = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
|
||||
mongo_db_name: str = os.getenv("MONGODB_DB_NAME", "zero_knowledge_platform")
|
||||
jwt_secret: str = os.getenv("JWT_SECRET", "replace-with-a-long-random-secret")
|
||||
jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256")
|
||||
access_token_expire_minutes: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
||||
cors_origins: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
origin.strip()
|
||||
for origin in os.getenv(
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:5173,http://127.0.0.1:5173",
|
||||
).split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
37
backend/app/core/security.py
Normal file
37
backend/app/core/security.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def normalize_identifier(identifier_type: str, identifier: str) -> str:
|
||||
value = identifier.strip()
|
||||
if identifier_type == "email":
|
||||
return value.lower()
|
||||
if identifier_type == "username":
|
||||
return value
|
||||
if identifier_type == "mobile":
|
||||
return re.sub(r"[\s\-().]", "", value)
|
||||
return value
|
||||
|
||||
|
||||
def hash_auth_key(auth_key_material: str) -> str:
|
||||
return pwd_context.hash(auth_key_material)
|
||||
|
||||
|
||||
def verify_auth_key(auth_key_material: str, hashed_auth_key: str) -> bool:
|
||||
return pwd_context.verify(auth_key_material, hashed_auth_key)
|
||||
|
||||
|
||||
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload: dict[str, Any] = {"sub": subject, "exp": expires_at, "iat": datetime.now(timezone.utc)}
|
||||
if extra_claims:
|
||||
payload.update(extra_claims)
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
29
backend/app/db/mongo.py
Normal file
29
backend/app/db/mongo.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
client: AsyncIOMotorClient | None = None
|
||||
|
||||
|
||||
def get_database() -> AsyncIOMotorDatabase:
|
||||
if client is None:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
return client[settings.mongo_db_name]
|
||||
|
||||
|
||||
def get_client() -> AsyncIOMotorClient:
|
||||
if client is None:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
return client
|
||||
|
||||
|
||||
async def connect_to_mongo() -> None:
|
||||
global client
|
||||
client = AsyncIOMotorClient(settings.mongo_uri)
|
||||
|
||||
|
||||
async def close_mongo_connection() -> None:
|
||||
global client
|
||||
if client is not None:
|
||||
client.close()
|
||||
client = None
|
||||
0
backend/app/dependencies/__init__.py
Normal file
0
backend/app/dependencies/__init__.py
Normal file
33
backend/app/dependencies/auth.py
Normal file
33
backend/app/dependencies/auth.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_owner_id(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
) -> str:
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated.",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token.",
|
||||
) from exc
|
||||
|
||||
owner_id = payload.get("sub")
|
||||
if not isinstance(owner_id, str) or not owner_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload.",
|
||||
)
|
||||
return owner_id
|
||||
51
backend/app/main.py
Normal file
51
backend/app/main.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.children import router as children_router
|
||||
from app.api.parents import router as parents_router
|
||||
from app.api.health import router as health_router
|
||||
from app.core.config import settings
|
||||
from app.db.mongo import close_mongo_connection, connect_to_mongo, get_database
|
||||
from app.services.records import ensure_record_indexes
|
||||
from app.services.auth import ensure_auth_indexes
|
||||
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await connect_to_mongo()
|
||||
await ensure_auth_indexes(get_database())
|
||||
await ensure_record_indexes(get_database())
|
||||
yield
|
||||
await close_mongo_connection()
|
||||
|
||||
|
||||
app = FastAPI(title="Zero Knowledge Platform API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(parents_router)
|
||||
app.include_router(children_router)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error."})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> dict[str, str]:
|
||||
return {"message": "Zero Knowledge Platform API"}
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
49
backend/app/schemas/auth.py
Normal file
49
backend/app/schemas/auth.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class IdentifierType(str, Enum):
|
||||
email = "email"
|
||||
username = "username"
|
||||
mobile = "mobile"
|
||||
|
||||
|
||||
class AuthIdentifierBase(BaseModel):
|
||||
identifierType: IdentifierType
|
||||
identifier: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@field_validator("identifier")
|
||||
@classmethod
|
||||
def strip_identifier(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("identifier cannot be empty")
|
||||
return normalized
|
||||
|
||||
|
||||
class AuthSignupRequest(AuthIdentifierBase):
|
||||
saltAuth: str = Field(min_length=8)
|
||||
saltEnc: str = Field(min_length=8)
|
||||
authKeyMaterial: str = Field(min_length=8)
|
||||
|
||||
|
||||
class AuthLoginBootstrapRequest(AuthIdentifierBase):
|
||||
pass
|
||||
|
||||
|
||||
class AuthLoginRequest(AuthIdentifierBase):
|
||||
authKeyMaterial: str = Field(min_length=8)
|
||||
|
||||
|
||||
class AuthBootstrapResponse(BaseModel):
|
||||
identifierType: IdentifierType
|
||||
identifier: str
|
||||
saltAuth: str
|
||||
saltEnc: str
|
||||
|
||||
|
||||
class AuthTokenResponse(BaseModel):
|
||||
accessToken: str
|
||||
tokenType: str = "bearer"
|
||||
ownerId: str
|
||||
47
backend/app/schemas/records.py
Normal file
47
backend/app/schemas/records.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ParentBlobBase(BaseModel):
|
||||
iv: str = Field(min_length=1)
|
||||
ciphertext: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ParentCreateRequest(ParentBlobBase):
|
||||
pass
|
||||
|
||||
|
||||
class ParentUpdateRequest(ParentBlobBase):
|
||||
pass
|
||||
|
||||
|
||||
class ChildCreateRequest(ParentBlobBase):
|
||||
parentId: str = Field(min_length=1)
|
||||
|
||||
@field_validator("parentId")
|
||||
@classmethod
|
||||
def strip_parent_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("parentId cannot be empty")
|
||||
return normalized
|
||||
|
||||
|
||||
class ChildUpdateRequest(ParentBlobBase):
|
||||
pass
|
||||
|
||||
|
||||
class ParentRecordResponse(ParentBlobBase):
|
||||
id: str
|
||||
ownerId: str
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
|
||||
class ChildRecordResponse(ParentBlobBase):
|
||||
id: str
|
||||
parentId: str
|
||||
ownerId: str
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
68
backend/app/services/auth.py
Normal file
68
backend/app/services/auth.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from app.core.security import hash_auth_key, normalize_identifier, verify_auth_key
|
||||
from app.schemas.auth import AuthLoginRequest, AuthSignupRequest
|
||||
|
||||
ACCOUNTS_COLLECTION = "accounts"
|
||||
|
||||
|
||||
async def ensure_auth_indexes(database: AsyncIOMotorDatabase) -> None:
|
||||
await database[ACCOUNTS_COLLECTION].create_index(
|
||||
[("identifierType", 1), ("identifier", 1)],
|
||||
unique=True,
|
||||
name="uq_accounts_identifier",
|
||||
)
|
||||
|
||||
|
||||
def _account_filter(identifier_type: str, identifier: str) -> dict[str, str]:
|
||||
return {
|
||||
"identifierType": identifier_type,
|
||||
"identifier": normalize_identifier(identifier_type, identifier),
|
||||
}
|
||||
|
||||
|
||||
async def create_account(database: AsyncIOMotorDatabase, payload: AuthSignupRequest) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
account_document = {
|
||||
"identifierType": payload.identifierType.value,
|
||||
"identifier": normalize_identifier(payload.identifierType.value, payload.identifier),
|
||||
"hashedAuthKey": hash_auth_key(payload.authKeyMaterial),
|
||||
"saltAuth": payload.saltAuth,
|
||||
"saltEnc": payload.saltEnc,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await database[ACCOUNTS_COLLECTION].insert_one(account_document)
|
||||
except DuplicateKeyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An account with that identifier already exists.",
|
||||
) from exc
|
||||
|
||||
return str(result.inserted_id)
|
||||
|
||||
|
||||
async def get_account_by_identifier(database: AsyncIOMotorDatabase, identifier_type: str, identifier: str) -> dict[str, str]:
|
||||
account = await database[ACCOUNTS_COLLECTION].find_one(_account_filter(identifier_type, identifier))
|
||||
if account is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Account not found.",
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
async def verify_account_login(database: AsyncIOMotorDatabase, payload: AuthLoginRequest) -> str:
|
||||
account = await get_account_by_identifier(database, payload.identifierType.value, payload.identifier)
|
||||
if not verify_auth_key(payload.authKeyMaterial, account["hashedAuthKey"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials.",
|
||||
)
|
||||
return str(account["_id"])
|
||||
235
backend/app/services/records.py
Normal file
235
backend/app/services/records.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import HTTPException, status
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pymongo import ReturnDocument
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from app.schemas.records import (
|
||||
ChildCreateRequest,
|
||||
ChildRecordResponse,
|
||||
ChildUpdateRequest,
|
||||
ParentCreateRequest,
|
||||
ParentRecordResponse,
|
||||
ParentUpdateRequest,
|
||||
)
|
||||
|
||||
PARENTS_COLLECTION = "parents"
|
||||
CHILDREN_COLLECTION = "children"
|
||||
|
||||
|
||||
class RecordNotFoundError(HTTPException):
|
||||
def __init__(self, detail: str = "Record not found.") -> None:
|
||||
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||
|
||||
|
||||
class BadRequestError(HTTPException):
|
||||
def __init__(self, detail: str = "Invalid request.") -> None:
|
||||
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _to_object_id(value: str, field_name: str) -> ObjectId:
|
||||
if not ObjectId.is_valid(value):
|
||||
raise BadRequestError(f"Invalid {field_name}.")
|
||||
return ObjectId(value)
|
||||
|
||||
|
||||
def _serialize_parent(document: dict) -> ParentRecordResponse:
|
||||
return ParentRecordResponse(
|
||||
id=str(document["_id"]),
|
||||
ownerId=document["ownerId"],
|
||||
iv=document["iv"],
|
||||
ciphertext=document["ciphertext"],
|
||||
createdAt=document["createdAt"],
|
||||
updatedAt=document["updatedAt"],
|
||||
)
|
||||
|
||||
|
||||
def _serialize_child(document: dict) -> ChildRecordResponse:
|
||||
return ChildRecordResponse(
|
||||
id=str(document["_id"]),
|
||||
parentId=str(document["parentId"]),
|
||||
ownerId=document["ownerId"],
|
||||
iv=document["iv"],
|
||||
ciphertext=document["ciphertext"],
|
||||
createdAt=document["createdAt"],
|
||||
updatedAt=document["updatedAt"],
|
||||
)
|
||||
|
||||
|
||||
async def ensure_record_indexes(database: AsyncIOMotorDatabase) -> None:
|
||||
await database[PARENTS_COLLECTION].create_index([("ownerId", 1)], name="ix_parents_ownerId")
|
||||
await database[CHILDREN_COLLECTION].create_index([("ownerId", 1)], name="ix_children_ownerId")
|
||||
await database[CHILDREN_COLLECTION].create_index([("parentId", 1)], name="ix_children_parentId")
|
||||
await database[CHILDREN_COLLECTION].create_index(
|
||||
[("ownerId", 1), ("parentId", 1)],
|
||||
name="ix_children_ownerId_parentId",
|
||||
)
|
||||
|
||||
|
||||
async def create_parent(database: AsyncIOMotorDatabase, owner_id: str, payload: ParentCreateRequest) -> ParentRecordResponse:
|
||||
now = _now()
|
||||
document = {
|
||||
"ownerId": owner_id,
|
||||
"iv": payload.iv,
|
||||
"ciphertext": payload.ciphertext,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
result = await database[PARENTS_COLLECTION].insert_one(document)
|
||||
document["_id"] = result.inserted_id
|
||||
return _serialize_parent(document)
|
||||
|
||||
|
||||
async def list_parents(database: AsyncIOMotorDatabase, owner_id: str) -> list[ParentRecordResponse]:
|
||||
parents: list[ParentRecordResponse] = []
|
||||
cursor = database[PARENTS_COLLECTION].find({"ownerId": owner_id}).sort("createdAt", -1)
|
||||
async for document in cursor:
|
||||
parents.append(_serialize_parent(document))
|
||||
return parents
|
||||
|
||||
|
||||
async def get_parent(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> ParentRecordResponse:
|
||||
document = await database[PARENTS_COLLECTION].find_one(
|
||||
{"_id": _to_object_id(parent_id, "parentId"), "ownerId": owner_id}
|
||||
)
|
||||
if document is None:
|
||||
raise RecordNotFoundError("Parent record not found.")
|
||||
return _serialize_parent(document)
|
||||
|
||||
|
||||
async def update_parent(
|
||||
database: AsyncIOMotorDatabase,
|
||||
owner_id: str,
|
||||
parent_id: str,
|
||||
payload: ParentUpdateRequest,
|
||||
) -> ParentRecordResponse:
|
||||
document = await database[PARENTS_COLLECTION].find_one_and_update(
|
||||
{"_id": _to_object_id(parent_id, "parentId"), "ownerId": owner_id},
|
||||
{
|
||||
"$set": {
|
||||
"iv": payload.iv,
|
||||
"ciphertext": payload.ciphertext,
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
if document is None:
|
||||
raise RecordNotFoundError("Parent record not found.")
|
||||
return _serialize_parent(document)
|
||||
|
||||
|
||||
async def delete_parent(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> None:
|
||||
client = database.client
|
||||
if client is None:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
|
||||
parent_object_id = _to_object_id(parent_id, "parentId")
|
||||
async with await client.start_session() as session:
|
||||
async with session.start_transaction():
|
||||
parent = await database[PARENTS_COLLECTION].find_one(
|
||||
{"_id": parent_object_id, "ownerId": owner_id},
|
||||
session=session,
|
||||
)
|
||||
if parent is None:
|
||||
raise RecordNotFoundError("Parent record not found.")
|
||||
|
||||
await database[CHILDREN_COLLECTION].delete_many(
|
||||
{"parentId": parent_object_id, "ownerId": owner_id},
|
||||
session=session,
|
||||
)
|
||||
await database[PARENTS_COLLECTION].delete_one(
|
||||
{"_id": parent_object_id, "ownerId": owner_id},
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
async def create_child(
|
||||
database: AsyncIOMotorDatabase,
|
||||
owner_id: str,
|
||||
payload: ChildCreateRequest,
|
||||
) -> ChildRecordResponse:
|
||||
parent_object_id = _to_object_id(payload.parentId, "parentId")
|
||||
parent = await database[PARENTS_COLLECTION].find_one({"_id": parent_object_id, "ownerId": owner_id})
|
||||
if parent is None:
|
||||
raise RecordNotFoundError("Parent record not found.")
|
||||
|
||||
now = _now()
|
||||
document = {
|
||||
"ownerId": owner_id,
|
||||
"parentId": parent_object_id,
|
||||
"iv": payload.iv,
|
||||
"ciphertext": payload.ciphertext,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
result = await database[CHILDREN_COLLECTION].insert_one(document)
|
||||
document["_id"] = result.inserted_id
|
||||
return _serialize_child(document)
|
||||
|
||||
|
||||
async def list_children(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str) -> list[ChildRecordResponse]:
|
||||
parent_object_id = _to_object_id(parent_id, "parentId")
|
||||
children: list[ChildRecordResponse] = []
|
||||
cursor = database[CHILDREN_COLLECTION].find({"ownerId": owner_id, "parentId": parent_object_id}).sort("createdAt", -1)
|
||||
async for document in cursor:
|
||||
children.append(_serialize_child(document))
|
||||
return children
|
||||
|
||||
|
||||
async def get_child(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str, child_id: str) -> ChildRecordResponse:
|
||||
document = await database[CHILDREN_COLLECTION].find_one(
|
||||
{
|
||||
"_id": _to_object_id(child_id, "childId"),
|
||||
"parentId": _to_object_id(parent_id, "parentId"),
|
||||
"ownerId": owner_id,
|
||||
}
|
||||
)
|
||||
if document is None:
|
||||
raise RecordNotFoundError("Child record not found.")
|
||||
return _serialize_child(document)
|
||||
|
||||
|
||||
async def update_child(
|
||||
database: AsyncIOMotorDatabase,
|
||||
owner_id: str,
|
||||
parent_id: str,
|
||||
child_id: str,
|
||||
payload: ChildUpdateRequest,
|
||||
) -> ChildRecordResponse:
|
||||
document = await database[CHILDREN_COLLECTION].find_one_and_update(
|
||||
{
|
||||
"_id": _to_object_id(child_id, "childId"),
|
||||
"parentId": _to_object_id(parent_id, "parentId"),
|
||||
"ownerId": owner_id,
|
||||
},
|
||||
{
|
||||
"$set": {
|
||||
"iv": payload.iv,
|
||||
"ciphertext": payload.ciphertext,
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
if document is None:
|
||||
raise RecordNotFoundError("Child record not found.")
|
||||
return _serialize_child(document)
|
||||
|
||||
|
||||
async def delete_child(database: AsyncIOMotorDatabase, owner_id: str, parent_id: str, child_id: str) -> None:
|
||||
result = await database[CHILDREN_COLLECTION].delete_one(
|
||||
{
|
||||
"_id": _to_object_id(child_id, "childId"),
|
||||
"parentId": _to_object_id(parent_id, "parentId"),
|
||||
"ownerId": owner_id,
|
||||
}
|
||||
)
|
||||
if result.deleted_count == 0:
|
||||
raise RecordNotFoundError("Child record not found.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue