Pagination, Filter, File Upload, Error

Production API এর খুঁটিনাটি

রোডম্যাপের মাস ৪পড়তে প্রায় ১৭ মিনিট

একটা API "চলে" আর একটা API "production এ টিকে থাকে", এর মধ্যে পার্থক্য খুঁটিনাটিতে: pagination ঠিক আছে কিনা, file upload নিরাপদ কিনা, error গুলো একরকম কিনা, দুর্বল network এ দুইবার request গেলে কী হয়। এই অধ্যায়ে সেই খুঁটিনাটিগুলো। প্রতিটা বিষয় তোমার Flutter app এর অভিজ্ঞতার সাথে সরাসরি যুক্ত।

#Cursor pagination

API অধ্যায়ে দেখেছো infinite scroll এর জন্য cursor pagination ভালো। বানানো যাক:

Python
import base64
import json
from datetime import datetime
from pydantic import BaseModel


def encode_cursor(updated_at: datetime, note_id: int) -> str:
    raw = json.dumps({"u": updated_at.isoformat(), "i": note_id})
    return base64.urlsafe_b64encode(raw.encode()).decode()


def decode_cursor(cursor: str) -> tuple[datetime, int]:
    data = json.loads(base64.urlsafe_b64decode(cursor.encode()))
    return datetime.fromisoformat(data["u"]), data["i"]


class CursorPage(BaseModel):
    items: list[NoteOut]
    next_cursor: str | None          # None মানে শেষ


async def list_after(self, user_id: int, limit: int, cursor: str | None) -> CursorPage:
    stmt = select(Note).where(Note.owner_id == user_id, Note.deleted_at.is_(None))
    if cursor:
        u, i = decode_cursor(cursor)
        # (updated_at, id) জোড়া দিয়ে তুলনা: সমান সময়ের note থাকলেও কিছু বাদ পড়বে না
        stmt = stmt.where(tuple_(Note.updated_at, Note.id) < (u, i))
    stmt = stmt.order_by(Note.updated_at.desc(), Note.id.desc()).limit(limit + 1)  # একটা বেশি আনো
    rows = list(await self.db.scalars(stmt))
    has_more = len(rows) > limit
    rows = rows[:limit]
    next_cursor = encode_cursor(rows[-1].updated_at, rows[-1].id) if has_more else None
    return CursorPage(items=rows, next_cursor=next_cursor)
  • limit + 1 আনার কৌশল: আরও আছে কিনা জানতে আলাদা COUNT query লাগে না
  • (updated_at, id) জোড়া: শুধু updated_at দিলে একই সময়ের দুইটা note এর একটা হারিয়ে যেতে পারে
  • Cursor base64 করা যাতে client এটাকে "অস্পষ্ট টোকেন" ভাবে, ভেতরে হাত না দেয়

Flutter এ: প্রথম call এ cursor ছাড়া, তারপর list এর শেষে পৌঁছালে next_cursor দিয়ে পরের call, null এলে থামো।

#Filtering আর sorting

Python
from enum import StrEnum
from fastapi import Query


class NoteSort(StrEnum):
    UPDATED_DESC = "-updated_at"
    UPDATED_ASC = "updated_at"
    TITLE_ASC = "title"


SORT_MAP = {
    NoteSort.UPDATED_DESC: Note.updated_at.desc(),
    NoteSort.UPDATED_ASC: Note.updated_at.asc(),
    NoteSort.TITLE_ASC: Note.title.asc(),
}


@router.get("")
async def list_notes(
    user: CurrentUser,
    svc: ServiceDep,
    pinned: bool | None = None,
    created_after: datetime | None = None,
    sort: NoteSort = NoteSort.UPDATED_DESC,
): ...

#File upload

#ছোট file: সরাসরি API তে

Python
from fastapi import UploadFile, File
import uuid

ALLOWED = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
MAX_BYTES = 5 * 1024 * 1024     # ৫ MB


@router.post("/{note_id}/image")
async def upload_image(note_id: int, user: CurrentUser, svc: ServiceDep, file: UploadFile = File(...)):
    if file.content_type not in ALLOWED:
        raise ValidationAppError("শুধু JPG, PNG, WebP")
    data = await file.read(MAX_BYTES + 1)
    if len(data) > MAX_BYTES:
        raise ValidationAppError("ছবি ৫ MB এর বেশি হতে পারবে না")
    # আসল ধরন যাচাই: শুধু content_type বিশ্বাস করো না, file এর প্রথম কয়েক byte দেখো
    key = f"notes/{user.id}/{uuid.uuid4()}{ALLOWED[file.content_type]}"   # নিজের বানানো নাম
    await storage.put(key, data, content_type=file.content_type)
    return await svc.set_image(user.id, note_id, key)

নিরাপত্তার নিয়ম:

  • আকারের সীমা। না হলে কেউ ১০ GB পাঠিয়ে server এর memory বা disk ভরে দেবে।
  • ধরন যাচাই। Client এর বলা content_type মিথ্যা হতে পারে। file এর শুরুর byte (magic number) দেখে বা Pillow দিয়ে খুলে যাচাই করো।
  • নিজের নাম দাও। User এর দেওয়া filename (../../etc/passwd বা shell.php) কখনো সরাসরি ব্যবহার করো না।
  • Server এর disk এ না, object storage এ। Stateless থাকার জন্য, আর disk ভরে যাওয়া এড়াতে।

#বড় file: pre-signed URL

বড় file (video, অনেক MB এর PDF) app server দিয়ে পাঠালে worker অনেকক্ষণ আটকে থাকে। ভালো উপায়: server একটা অস্থায়ী, স্বাক্ষরিত URL দেয়, Flutter app সরাসরি storage এ upload করে।

Flutter app FastAPI Object storage S3 / R2 ১. upload করতে চাই ২. URL (১০ মিনিট) ৩. সরাসরি file upload (PUT) ৪. "upload শেষ" জানাও, key save
বিশাল file তোমার app server এর উপর দিয়ে যায়ই না। Server শুধু অনুমতি দেয় আর শেষে রেকর্ড রাখে।
Python
@router.post("/uploads/presign")
async def presign(data: PresignIn, user: CurrentUser):
    key = f"uploads/{user.id}/{uuid.uuid4()}.{data.ext}"
    url = s3.generate_presigned_url(
        "put_object",
        Params={"Bucket": BUCKET, "Key": key, "ContentType": data.content_type},
        ExpiresIn=600,
    )
    return {"upload_url": url, "key": key}

বিস্তারিত Object Storage অধ্যায়ে।

#Error এর একটাই ধরন

তিন রকম error এক রকম দেখাক:

Python
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException


def register_error_handlers(app: FastAPI) -> None:
    @app.exception_handler(AppError)
    async def app_error(request, exc: AppError):
        return JSONResponse(status_code=exc.status_code,
                            content={"error": {"code": exc.code, "message": exc.message}})

    @app.exception_handler(RequestValidationError)
    async def validation_error(request, exc: RequestValidationError):
        fields = {".".join(str(p) for p in e["loc"][1:]): e["msg"] for e in exc.errors()}
        return JSONResponse(status_code=422,
                            content={"error": {"code": "VALIDATION_ERROR", "message": "তথ্য ঠিক নেই", "fields": fields}})

    @app.exception_handler(StarletteHTTPException)
    async def http_error(request, exc):
        return JSONResponse(status_code=exc.status_code,
                            content={"error": {"code": f"HTTP_{exc.status_code}", "message": str(exc.detail)}})

    @app.exception_handler(Exception)
    async def unhandled(request, exc):
        logger.exception("Unhandled error")          # পুরো traceback log এ
        return JSONResponse(status_code=500,          # কিন্তু client কে ভেতরের কিছু না
                            content={"error": {"code": "INTERNAL_ERROR", "message": "সাময়িক সমস্যা, একটু পরে চেষ্টা করুন"}})
JSON
{"error": {"code": "VALIDATION_ERROR", "message": "তথ্য ঠিক নেই",
           "fields": {"title": "String should have at least 1 character"}}}

Flutter এ একটাই error parser, fields থেকে form এর প্রতিটা ঘরের নিচে লাল লেখা।

#Idempotency key: দুইবার request এর সমাধান

দুর্বল network এ Flutter app "Order দাও" পাঠালো, server order বানালো, কিন্তু উত্তর হারিয়ে গেল। App আবার পাঠালো। এখন দুইটা order।

Python
@router.post("/orders", status_code=201)
async def create_order(
    data: OrderCreate,
    user: CurrentUser,
    svc: OrderServiceDep,
    idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=16, max_length=64),
):
    return await svc.create_idempotent(user.id, idempotency_key, data)
Python
async def create_idempotent(self, user_id: int, key: str, data: OrderCreate) -> Order:
    existing = await self.repo.find_by_idempotency(user_id, key)
    if existing:
        return existing                           # আগে হয়ে গেছে, একই ফল ফেরত
    order = Order(user_id=user_id, idempotency_key=key, **...)
    self.db.add(order)
    try:
        await self.db.commit()                    # (user_id, idempotency_key) এ UNIQUE constraint
    except IntegrityError:
        await self.db.rollback()                  # একসাথে দুইটা এলে দ্বিতীয়টা এখানে
        return await self.repo.find_by_idempotency(user_id, key)
    return order

Flutter এ: "Order দাও" button চাপার সময় একটা UUID বানাও, retry হলে একই UUID পাঠাও। নতুন order এর জন্য নতুন UUID।

#Request id আর log

প্রতিটা request এ একটা id দাও, response header এ ফেরত দাও, আর সব log এ জুড়ে দাও:

Python
import uuid
from contextvars import ContextVar

request_id_var: ContextVar[str] = ContextVar("request_id", default="-")


@app.middleware("http")
async def add_request_id(request: Request, call_next):
    rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:16]
    request_id_var.set(rid)
    response = await call_next(request)
    response.headers["X-Request-ID"] = rid
    return response

User বললো "দুপুর ২টায় order দিতে গিয়ে error"। App এর error screen এ request id দেখালে সে সেটা পাঠাতে পারে, তুমি log এ সেই id দিয়ে খুঁজে সাথে সাথে পুরো ঘটনা পাবে। বিস্তারিত Observability তে।

#Rate limiting আর timeout

  • Rate limit: login, OTP, search এর মতো endpoint এ। Redis অধ্যায়ে বানাবো।
  • Request body এর আকার: reverse proxy তে সীমা (Nginx এ client_max_body_size)
  • বাইরের call এ timeout: I/O অধ্যায়ে দেখেছো, এখানে আবার মনে করিয়ে দিলাম

#অনুশীলন

#নিজেকে যাচাই করো

Cursor pagination এ `limit + 1` row আনার কারণ কী?

আরও data আছে কিনা জানার জন্য। একটা বেশি পেলে বোঝা যায় পরের পেজ আছে, তখন next_cursor দেওয়া হয়; আলাদা COUNT query লাগে না।

User এর দেওয়া filename সরাসরি ব্যবহার করা কেন বিপজ্জনক?

নামে ../ দিয়ে অন্য folder এ লেখা (path traversal), বা .php/.html এর মতো চালানো যায় এমন file রাখা, বা অন্য user এর file overwrite করা সম্ভব। সবসময় নিজে random নাম বানাতে হয়।

Idempotency key Flutter app কখন নতুন বানাবে আর কখন পুরনোটাই পাঠাবে?

User নতুন একটা কাজ শুরু করলে (button চাপলে) নতুন key। একই কাজের retry (network error এর পর আবার পাঠানো) হলে পুরনো key টাই, যাতে server বুঝে এটা একই কাজ।

500 error এর response এ traceback পাঠালে কী ক্ষতি?

আক্রমণকারী জেনে যায় code এর গঠন, file path, library version, database query, যা দিয়ে দুর্বলতা খোঁজা সহজ হয়। Traceback শুধু log এ রাখতে হয়।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।