অধ্যায় 3 · প্রোডাকশন FastAPI
FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং
- পৃষ্ঠা 8 / 22
- 22 মিনিট পড়া
FastAPI অ্যাপ সাধারণত একটা main.py দিয়ে শুরু হয়, আর উইকেন্ডের ডেমোর জন্য সেটাই নিখুঁত। তারপর AI ফিচারটা কাজ করে, টিম বড় হয়, আর ফাইলটা হাজার লাইনে পৌঁছায়: ভ্যালিডেশন, বিজনেস রুল, স্টোরেজ আর HTTP-র খুঁটিনাটি জট পাকিয়ে যায়, প্রতিটা বদলে সবকিছুতে হাত পড়ে, আর সার্ভার না চালিয়ে কিছুই টেস্ট করা যায় না। এই পাতা দেখায় কোন কাঠামো একটা FastAPI সার্ভিসকে বদলানো সহজ রাখে: HTTP-র জন্য রাউটার, ডেটার জন্য স্কিমা, নিয়মের জন্য সার্ভিস, স্টোরেজের জন্য রিপোজিটরি, আর একটা সুসংগত এরর ফরম্যাট, যার ওপর প্রতিটা ক্লায়েন্ট (React ফ্রন্টএন্ড, মোবাইল অ্যাপ, অন্য টিমের এজেন্ট) ভরসা করতে পারে।
আপনি বানাবেন BitByte Prompt Library, একটা ছোট সার্ভিস, যা LLM ফিচারের জন্য ভার্সন করা প্রম্পট টেমপ্লেট জমা রাখে এবং মান বসিয়ে রেন্ডার করে। AI টিমে প্রম্পট ম্যানেজমেন্ট সত্যিকারের প্রয়োজন: প্রম্পট ঘন ঘন বদলায়, কোডের মতো ভার্সন ও রিভিউ করতে হয়, আর অ্যাপ্লিকেশনের উচিত সেগুলো হার্ড-কোড না করে একটা জায়গা থেকে আনা।
যা শিখবেন
- রাউটার, স্কিমা, সার্ভিস ও রিপোজিটরিসহ প্রজেক্ট লেআউট, এবং কোন লেয়ার কাকে ইমপোর্ট করতে পারবে।
- প্রিফিক্স, ট্যাগ ও ডকুমেন্ট করা এরর রেসপন্সসহ
APIRouter, এবং রুটগুলোকে পাতলা রাখা। - অ্যাডভান্সড Pydantic: নেস্টেড মডেল, ফিল্ড ও মডেল ভ্যালিডেটর,
model_config, কম্পিউটেড ফিল্ড, অ্যালিয়াস ও সিরিয়ালাইজেশন। - কাস্টম এক্সেপশন হ্যান্ডলার, যা ভ্যালিডেশন এরর ও অজানা রুট সহ প্রতিটা এররকে একই স্ট্যান্ডার্ড JSON আকার দেয়।
- deprecated v1-কে v2-এর পাশে রেখে API ভার্সনিং, এবং OpenAPI ডকুমেন্ট কাস্টমাইজ করা।
লেআউট
প্রজেক্টটা কোথায় যাচ্ছে তা এখানে দেখুন। প্রতিটা ফাইলের একটাই কাজ:
prompt-api/
├── pyproject.toml dependencies + [tool.fastapi] entrypoint
└── app/
├── __init__.py
├── main.py create_app(): metadata, error handlers, routers
├── errors.py AppError classes and the one error format
├── schemas.py Pydantic models: what goes in and out over HTTP
├── repository.py storage (in memory now, a database later)
├── services.py business rules: no HTTP, no SQL
└── routers/
├── __init__.py
├── health.py
├── prompts_v1.py /v1/prompts
└── prompts_v2.py /v2/prompts| লেয়ার | যা জানে | যা জানা উচিত নয় |
|---|---|---|
| রাউটার | HTTP: পাথ, স্ট্যাটাস কোড, হেডার, কোয়েরি প্যারামিটার; একটা সার্ভিসকে কল করে | ডেটা কীভাবে জমা হয়; বিজনেস রুল |
| সার্ভিস | আপনার ডোমেইনের নিয়ম ("প্রম্পটের নাম ইউনিক", "রেন্ডার করতে প্রতিটা ভ্যারিয়েবল লাগে"); AppError তোলে | রিকোয়েস্ট, রেসপন্স, স্ট্যাটাস কোড, SQL |
| রিপোজিটরি | স্টোরেজ: আজ একটা ডিকশনারি, SQLAlchemy ও Alembic দিয়ে ডেটাবেস পাতায় SQLAlchemy | HTTP ও বিজনেস রুল |
| স্কিমা | ডেটার আকার ও ভ্যালিডেশন | ডেটা কোথা থেকে আসে |
ইমপোর্ট চলে একমুখী: main → routers → services → repository, আর সবাই schemas ও errors ইমপোর্ট করতে পারে। সার্ভিস কখনো FastAPI ইমপোর্ট করে না। এই নিয়মটাই একই সার্ভিসকে HTTP রুট, CLI কমান্ড, ব্যাকগ্রাউন্ড জব বা LLM এজেন্টের টুল থেকে কল করতে দেয়, আর সাধারণ ফাংশন কল দিয়ে টেস্ট করতে দেয়।
খুব ছোট সার্ভিসে সমতল লেআউট (
main.py,schemas.py,db.py) ঠিকই আছে। বড় কোডবেস প্রায়ই তার বদলে ফিচার ধরে গোছায় (app/prompts/{router,service,schemas}.py,app/users/...)। ফোল্ডারের নামের চেয়ে লেয়ারগুলোই বেশি গুরুত্বপূর্ণ।
প্রজেক্ট সেট আপ
mkdir -p ~/projects/prompt-api
cd ~/projects/prompt-api
uv init --bare -q
uv add -q "fastapi[standard]"
# pip দিয়ে: python -m venv .venv && source .venv/bin/activate && pip install -q "fastapi[standard]"
mkdir -p app/routers
touch app/__init__.py app/routers/__init__.py
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
EOF
tail -3 pyproject.toml
[tool.fastapi]
entrypoint = "app.main:app"[tool.fastapi] টেবিল fastapi dev আর fastapi run-কে জানায় অ্যাপ অবজেক্টটা কোথায়, ফলে পাথ ছাড়াই কমান্ড লেখা যায়, আর Docker ও CI কমান্ড ছোট থাকে।
স্কিমা: অ্যাডভান্সড Pydantic
স্কিমা আপনার API-র চুক্তি। যা ঢোকে তা ভ্যালিডেট করে, যা বের হয় তা ছেঁকে দেয়, আর OpenAPI ডকুমেন্ট বানায়। ইনপুট ও আউটপুটের জন্য আলাদা মডেল রাখা স্বাভাবিক: ক্লায়েন্ট পাঠায় নাম আর টেমপ্লেট, ফেরত পায় একটা id, একটা ভার্সন, আর কম্পিউটেড ফিল্ড, যেগুলো সে নিজে সেট করতে পারে না।
# file: app/schemas.py
"""Request and response models for the Prompt Library."""
import string
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
def template_variables(template: str) -> list[str]:
"""Names used as {placeholders} in a template, in order, without duplicates."""
names = [field for _, field, _, _ in string.Formatter().parse(template) if field]
return list(dict.fromkeys(names))
class ModelSettings(BaseModel):
"""How the LLM is called. Nested inside every prompt."""
model_config = ConfigDict(extra="forbid")
model: Literal["practice-llm-1", "practice-llm-2"] = "practice-llm-1"
temperature: float = Field(default=0.2, ge=0, le=2)
max_tokens: int = Field(default=512, ge=1, le=4096)
class PromptIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
name: str = Field(min_length=3, max_length=60, pattern=r"^[a-z0-9-]+$",
description="Unique slug, e.g. support-answer")
template: str = Field(min_length=1, max_length=4000)
tags: list[str] = Field(default_factory=list, max_length=5)
settings: ModelSettings = Field(default_factory=ModelSettings)
@field_validator("tags")
@classmethod
def normalise_tags(cls, tags: list[str]) -> list[str]:
return sorted({tag.strip().lower() for tag in tags if tag.strip()})
@model_validator(mode="after")
def template_needs_input(self) -> "PromptIn":
if "input" not in template_variables(self.template):
raise ValueError("template must contain an {input} placeholder")
return self
class PromptOut(BaseModel):
id: int
name: str
version: int
template: str
tags: list[str]
settings: ModelSettings
@computed_field
@property
def variables(self) -> list[str]:
return template_variables(self.template)
class RenderIn(BaseModel):
values: dict[str, str] = Field(description="A value for every template variable")
class RenderOut(BaseModel):
prompt_id: int
version: int
text: str
settings: ModelSettings
class PromptPage(BaseModel):
"""v2 list envelope: items plus a cursor for the next page."""
items: list[PromptOut]
next_cursor: int | Noneকোনো সার্ভার ছাড়াই মডেলগুলো সরাসরি চালিয়ে দেখুন। স্কিমা নিজের মডিউলে রাখার একটা সুবিধা এটাই:
from pydantic import ValidationError
from app.schemas import PromptIn, PromptOut
data = {
"name": " support-answer ",
"template": "You are BitByte's support assistant.\nAnswer in {language}: {input}",
"tags": ["Support", "support", " RAG "],
"settings": {"temperature": 0.3},
}
prompt = PromptIn.model_validate(data)
print(repr(prompt.name), prompt.tags)
print(prompt.settings)
try:
PromptIn.model_validate({"name": "Bad Name!", "template": "Summarise this.", "colour": "red",
"settings": {"temperature": 3}})
except ValidationError as exc:
for error in exc.errors():
print(error["loc"], error["type"], "-", error["msg"])'support-answer' ['rag', 'support']
model='practice-llm-1' temperature=0.3 max_tokens=512
('name',) string_pattern_mismatch - String should match pattern '^[a-z0-9-]+$'
('settings', 'temperature') less_than_equal - Input should be less than or equal to 2
('colour',) extra_forbidden - Extra inputs are not permittedstr_strip_whitespaceনামটা ছেঁটে দিয়েছে; ফিল্ড ভ্যালিডেটর ট্যাগগুলোকে ছোট হাতের করেছে, ডুপ্লিকেট সরিয়েছে এবং সাজিয়েছে।- নেস্টেড
settingsডিকশনারি ডিফল্ট ভরে একটাModelSettingsহয়ে গেছে। এর এরর পাথসহ আসে:('settings', 'temperature')। extra="forbid"অজানাcolourফিল্ডটা প্রত্যাখ্যান করেছে। এটা না থাকলে Pydantic অজানা ফিল্ড চুপচাপ উপেক্ষা করে, ফলে ক্লায়েন্টের টাইপো ("temprature") গৃহীত হয়ে কিছুই করত না: এমন বাগ কেউ টেরই পায় না।- মডেল ভ্যালিডেটর (
mode="after") পুরো অবজেক্ট জড়িত এমন নিয়ম যাচাই করে। এখানে সেটা চলেনি, কারণ ফিল্ড ভ্যালিডেশন আগেই ফেইল করেছিল; এটা শুধু অন্যথায় বৈধ ডেটায় চলে।
সিরিয়ালাইজেশন: আপনার API থেকে যা বের হয়
আউটপুট মডেল ঠিক করে ক্লায়েন্ট কী দেখবে। computed_field আউটপুটে হিসাব করা মান যোগ করে, আর model_dump / model_dump_json-এ সাধারণ প্রয়োজনের জন্য অপশন আছে:
out = PromptOut(id=1, version=1, **prompt.model_dump())
print(out.model_dump(include={"name", "variables"}))
print(out.model_dump_json(exclude={"template", "settings"})){'name': 'support-answer', 'variables': ['language', 'input']}
{"id":1,"name":"support-answer","version":1,"tags":["rag","support"],"variables":["language","input"]}আরও দুটো সিরিয়ালাইজেশন টুল আপনি দেখবেন। একটা অ্যালিয়াস জেনারেটর আপনার Python snake_case রেখেই JavaScript ফ্রন্টএন্ডকে camelCase JSON দেয়, আর datetime সবসময় অফসেটসহ ISO 8601 হিসেবে সিরিয়ালাইজ হয় (টাইমজোন-সচেতন datetime পাঠান, সম্ভব হলে UTC):
from datetime import datetime, timedelta, timezone
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class UsageOut(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True, serialize_by_alias=True)
prompt_tokens: int
completion_tokens: int
created_at: datetime
usage = UsageOut(prompt_tokens=12, completion_tokens=30,
created_at=datetime(2026, 1, 15, 10, 0, tzinfo=timezone(timedelta(hours=6))))
print(usage.model_dump_json())
print(UsageOut.model_validate({"promptTokens": 1, "completionTokens": 2, "createdAt": "2026-01-15T04:00:00Z"})){"promptTokens":12,"completionTokens":30,"createdAt":"2026-01-15T10:00:00+06:00"}
prompt_tokens=1 completion_tokens=2 created_at=datetime.datetime(2026, 1, 15, 4, 0, tzinfo=TzInfo(0))পুরো API-র জন্য একটা কনভেনশন বেছে নিয়ে তাতেই থাকুন। বেশির ভাগ Python API (এবং বেশির ভাগ LLM প্রোভাইডার API) snake_case ব্যবহার করে; এই পাতাও তা-ই করে।
পুরো API-র জন্য একটাই এরর ফরম্যাট
বিল্ট-ইনভাবে FastAPI বিভিন্ন আকারে এরর ফেরত দেয়: অনুপস্থিত রুটের জন্য {"detail": "Not Found"}, ভ্যালিডেশন এররের জন্য {"detail": [{...}, ...]}, HTTPException-এ যা-ই দিন তা-ই, আর ক্র্যাশের জন্য সাদামাটা টেক্সটের Internal Server Error। ক্লায়েন্টকে প্রতিটার জন্য আলাদা বিশেষ ব্যবস্থা রাখতে হয়। একটা আকার বেছে নিন আর প্রতিটা এররকে সেটা মানতে বাধ্য করুন:
{
"error": {
"code": "not_found",
"message": "Prompt 42 not found",
"details": null
}
}স্থির, মেশিন-পাঠযোগ্য একটা code ক্লায়েন্টকে তার ওপর ভিত্তি করে সিদ্ধান্ত নিতে দেয় (unauthorized এলে লগইন স্ক্রিন, rate_limited এলে রিট্রাই); message মানুষের জন্য, আর বদলাতে পারে। আরেকটা স্ট্যান্ডার্ড হলো RFC 9457 "Problem Details" (type, title, status, detail সহ application/problem+json); যেকোনোটাই চলে, গুরুত্বপূর্ণ হলো সামঞ্জস্য।
# file: app/errors.py
"""Application errors and the handlers that turn every error into one JSON shape."""
import logging
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
logger = logging.getLogger("prompt_api")
class ErrorBody(BaseModel):
code: str
message: str
details: Any = None
class ErrorResponse(BaseModel):
error: ErrorBody
class AppError(Exception):
"""Base class for errors the service raises on purpose."""
status_code = 400
code = "bad_request"
def __init__(self, message: str, *, details: Any = None) -> None:
super().__init__(message)
self.message = message
self.details = details
class NotFound(AppError):
status_code = 404
code = "not_found"
class Conflict(AppError):
status_code = 409
code = "conflict"
class MissingVariables(AppError):
status_code = 422
code = "missing_variables"
HTTP_CODES = {401: "unauthorized", 403: "forbidden", 404: "not_found", 405: "method_not_allowed"}
def error_response(status: int, code: str, message: str, details: Any = None,
headers: dict[str, str] | None = None) -> JSONResponse:
body = ErrorResponse(error=ErrorBody(code=code, message=message, details=details))
return JSONResponse(body.model_dump(), status_code=status, headers=headers)
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return error_response(exc.status_code, exc.code, exc.message, exc.details)
@app.exception_handler(RequestValidationError)
async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
details = [
{"field": ".".join(str(part) for part in err["loc"]), "message": err["msg"], "type": err["type"]}
for err in exc.errors()
]
return error_response(422, "validation_error", "The request is not valid", details)
@app.exception_handler(StarletteHTTPException)
async def handle_http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
code = HTTP_CODES.get(exc.status_code, f"http_{exc.status_code}")
return error_response(exc.status_code, code, str(exc.detail), headers=exc.headers)
@app.exception_handler(Exception)
async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
logger.exception("unhandled error on %s %s", request.method, request.url.path)
return error_response(500, "internal_error", "Something went wrong on our side")AppError-এর সাবক্লাসগুলো নিজেদের স্ট্যাটাস ও কোড বহন করে, তাই সার্ভিস HTTP রেসপন্স সম্পর্কে কিছু না জেনেইNotFound("...")তুলতে পারে।StarletteHTTPExceptionহ্যান্ডলার (FastAPI-র সাবক্লাস নয়) অজানা পাথ ও ভুল মেথডের জন্য Starlette-এর রাউটার যে ৪০৪ ও ৪০৫ তোলে, সেগুলোও ধরে।- সবকিছু-ধরা
Exceptionহ্যান্ডলার সার্ভারে পুরো ট্রেসব্যাক লগ করে এবং একটা সাধারণ বার্তা ফেরত দেয়: স্ট্যাক ট্রেস, SQL বা ফাইল পাথ কখনো ক্লায়েন্টকে পাঠাবেন না। (সার্ভার তবু এক্সেপশন লগ করে, আর টেস্টেTestClientডিফল্টে সেটা আবার তোলে, যাতে আপনি দেখতে পান।)
রিপোজিটরি ও সার্ভিস
রিপোজিটরি ইচ্ছে করেই একঘেয়ে: সে সারি জমা রাখে আর খুঁজে দেয়। আজ এটা একটা ডিকশনারি; SQLAlchemy ও Alembic দিয়ে ডেটাবেস পাতায় একই মেথডগুলো ডেটাবেসের সঙ্গে কথা বলবে, আর এর ওপরের কিছুই বদলাবে না।
# file: app/repository.py
"""Prompt storage. In memory for now: the interface is what matters."""
class PromptRepository:
def __init__(self) -> None:
self._rows: dict[int, dict] = {}
self._next_id = 1
def add(self, data: dict) -> dict:
row = {"id": self._next_id, "version": 1, **data}
self._rows[row["id"]] = row
self._next_id += 1
return row
def get(self, prompt_id: int) -> dict | None:
return self._rows.get(prompt_id)
def get_by_name(self, name: str) -> dict | None:
return next((row for row in self._rows.values() if row["name"] == name), None)
def list(self, *, tag: str | None = None, after_id: int = 0, limit: int = 20) -> list[dict]:
rows = [row for row in self._rows.values()
if row["id"] > after_id and (tag is None or tag in row["tags"])]
return sorted(rows, key=lambda row: row["id"])[:limit]
def replace(self, prompt_id: int, data: dict) -> dict:
row = self._rows[prompt_id]
row.update(data, version=row["version"] + 1)
return row
def delete(self, prompt_id: int) -> None:
del self._rows[prompt_id]সার্ভিসে থাকে নিয়মগুলো। খেয়াল করুন, সে সাধারণ ডেটা ও Pydantic মডেল নেয় ও ফেরত দেয়, আর AppError তোলে; এর ভেতরে কোনো Request, স্ট্যাটাস কোড বা HTTPException নেই:
# file: app/services.py
"""Business rules for prompts. No HTTP and no storage details in here."""
from app.errors import Conflict, MissingVariables, NotFound
from app.repository import PromptRepository
from app.schemas import PromptIn, template_variables
class PromptService:
def __init__(self, repo: PromptRepository) -> None:
self.repo = repo
def create(self, data: PromptIn) -> dict:
if self.repo.get_by_name(data.name):
raise Conflict(f"A prompt named {data.name!r} already exists")
return self.repo.add(data.model_dump())
def get(self, prompt_id: int) -> dict:
row = self.repo.get(prompt_id)
if row is None:
raise NotFound(f"Prompt {prompt_id} not found")
return row
def list(self, *, tag: str | None = None, after_id: int = 0, limit: int = 20) -> list[dict]:
return self.repo.list(tag=tag, after_id=after_id, limit=limit)
def new_version(self, prompt_id: int, data: PromptIn) -> dict:
current = self.get(prompt_id)
if data.name != current["name"]:
raise Conflict("The name of a prompt cannot change; create a new prompt instead")
return self.repo.replace(prompt_id, data.model_dump())
def delete(self, prompt_id: int) -> None:
self.get(prompt_id)
self.repo.delete(prompt_id)
def render(self, prompt_id: int, values: dict[str, str]) -> tuple[dict, str]:
row = self.get(prompt_id)
missing = [name for name in template_variables(row["template"]) if name not in values]
if missing:
raise MissingVariables("Some template variables have no value", details={"missing": missing})
return row, row["template"].format(**values)
_service = PromptService(PromptRepository())
def get_prompt_service() -> PromptService:
"""Dependency: one shared service (the next page replaces this with lifespan state)."""
return _serviceসার্ভিস সাধারণ Python বলে HTTP ছাড়াই প্রতিটা নিয়ম চালিয়ে দেখা যায়:
from app.errors import AppError
from app.repository import PromptRepository
from app.services import PromptService
service = PromptService(PromptRepository())
row = service.create(prompt)
print(row["id"], row["name"], row["version"])
for attempt in (lambda: service.create(prompt), lambda: service.get(7), lambda: service.render(1, {"input": "hi"})):
try:
attempt()
except AppError as exc:
print(type(exc).__name__, exc.status_code, exc.code, "-", exc.message, exc.details or "")
print(service.render(1, {"input": "How do I reset my password?", "language": "Bangla"})[1])1 support-answer 1
Conflict 409 conflict - A prompt named 'support-answer' already exists
NotFound 404 not_found - Prompt 7 not found
MissingVariables 422 missing_variables - Some template variables have no value {'missing': ['language']}
You are BitByte's support assistant.
Answer in Bangla: How do I reset my password?রাউটার: পাতলা HTTP লেয়ার
একটা APIRouter একটা মিনি-অ্যাপ: একই প্রিফিক্স, ট্যাগ (/docs-এর সেকশন), শেয়ার করা ডিপেন্ডেন্সি আর শেয়ার করা ডকুমেন্টেড রেসপন্সসহ একদল রুট। প্রতিটা রুট ফাংশন শুধু HTTP-কে সার্ভিস কলে অনুবাদ করে আবার ফেরত আনে। প্রতিটা প্যারামিটারে Annotated ব্যবহার করুন, এবং একটা রিটার্ন টাইপ দিন, যাতে FastAPI রেসপন্স ভ্যালিডেট, ফিল্টার ও ডকুমেন্ট করে:
# file: app/routers/prompts_v1.py
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Response, status
from app.errors import ErrorResponse
from app.schemas import PromptIn, PromptOut, RenderIn, RenderOut
from app.services import PromptService, get_prompt_service
router = APIRouter(
prefix="/v1/prompts",
tags=["prompts"],
responses={404: {"model": ErrorResponse, "description": "Prompt not found"}},
)
ServiceDep = Annotated[PromptService, Depends(get_prompt_service)]
PromptId = Annotated[int, Path(ge=1, description="The prompt's id")]
@router.get("", deprecated=True, summary="List prompts (deprecated: use /v2/prompts)")
def list_prompts(
service: ServiceDep,
response: Response,
tag: Annotated[str | None, Query(max_length=30)] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> list[PromptOut]:
response.headers["Deprecation"] = "@1798761600" # 2027-01-01 (RFC 9745)
response.headers["Sunset"] = "Fri, 01 Jan 2027 00:00:00 GMT" # RFC 8594
response.headers["Link"] = '</v2/prompts>; rel="successor-version"'
return service.list(tag=tag, limit=limit)
@router.post("", status_code=status.HTTP_201_CREATED,
responses={409: {"model": ErrorResponse, "description": "Name already used"}})
def create_prompt(data: PromptIn, service: ServiceDep) -> PromptOut:
return service.create(data)
@router.get("/{prompt_id}")
def get_prompt(prompt_id: PromptId, service: ServiceDep) -> PromptOut:
return service.get(prompt_id)
@router.put("/{prompt_id}", summary="Save a new version of a prompt")
def update_prompt(prompt_id: PromptId, data: PromptIn, service: ServiceDep) -> PromptOut:
return service.new_version(prompt_id, data)
@router.delete("/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_prompt(prompt_id: PromptId, service: ServiceDep) -> None:
service.delete(prompt_id)
@router.post("/{prompt_id}/render",
responses={422: {"model": ErrorResponse, "description": "Missing or invalid values"}})
def render_prompt(prompt_id: PromptId, body: RenderIn, service: ServiceDep) -> RenderOut:
row, text = service.render(prompt_id, body.values)
return RenderOut(prompt_id=row["id"], version=row["version"], text=text, settings=row["settings"])রুট ফাংশনগুলো def, async def নয়: তারা কিছুর জন্য অপেক্ষা (await) করে না, তাই FastAPI সেগুলো থ্রেড পুলে চালায় (কোনটা কখন বেছে নেবেন তা পরের পাতায় ব্যাখ্যা করা হয়েছে)। রিপোজিটরি ডিকশনারি ফেরত দেয়, আর -> PromptOut রিটার্ন টাইপ প্রতিটাকে পাবলিক আকারে বদলায়, কম্পিউটেড variables যোগ করে এবং সারিতে আর যা-ই থাকুক তা বাদ দেয়।
একটা হেলথ রাউটার আর অ্যাপ্লিকেশন ফ্যাক্টরি অ্যাপটাকে সম্পূর্ণ করে। create_app() মডিউল-লেভেল কোডের বদলে একটা ফাংশন, যাতে টেস্ট সম্ভবত আলাদা সেটিংসহ একটা নতুন অ্যাপ বানাতে পারে:
# file: app/routers/health.py
from fastapi import APIRouter
router = APIRouter(tags=["ops"])
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}# file: app/main.py
import re
from fastapi import FastAPI
from fastapi.routing import APIRoute
from app.errors import install_error_handlers
from app.routers import health, prompts_v1, prompts_v2
DESCRIPTION = """
Store **versioned prompt templates** for LLM features and render them with values.
All errors use one shape: `{"error": {"code", "message", "details"}}`.
"""
def operation_id(route: APIRoute) -> str:
"""Readable, unique operation ids such as v1-get_prompt (they become method names in generated clients)."""
first = route.path.strip("/").split("/")[0]
return f"{first}-{route.name}" if re.fullmatch(r"v\d+", first) else route.name
def create_app() -> FastAPI:
app = FastAPI(
title="BitByte Prompt Library",
version="1.2.0",
summary="Versioned prompt templates for BitByte's AI features.",
description=DESCRIPTION,
contact={"name": "BitByte AI team", "email": "ai-team@example.com"},
license_info={"name": "MIT", "identifier": "MIT"},
openapi_tags=[
{"name": "prompts", "description": "Create, version and render prompts."},
{"name": "ops", "description": "Health checks for load balancers and monitoring."},
],
generate_unique_id_function=operation_id,
)
install_error_handlers(app)
app.include_router(health.router)
app.include_router(prompts_v1.router)
app.include_router(prompts_v2.router)
return app
app = create_app()ভার্সনিং: v1 ও v2 পাশাপাশি
যেসব ক্লায়েন্ট আপনার নিয়ন্ত্রণে নেই (ব্যবহারকারীদের পকেটের মোবাইল অ্যাপ, পার্টনারের ইন্টিগ্রেশন, গত বছর কারও ডিপ্লয় করা এজেন্ট), রেসপন্সের আকার বদলালে সেগুলো ভেঙে যায়। যোগ করার পরিবর্তন নিরাপদ: নতুন ঐচ্ছিক ফিল্ড, নতুন এন্ডপয়েন্ট। ফিল্ড সরানো বা নাম বদল, টাইপ বদল বা এনভেলপ বদল একটা ব্রেকিং চেঞ্জ এবং নতুন ভার্সন দাবি করে। এখানে v1-এর লিস্ট একটা খালি JSON অ্যারে ফেরত দেয়, যা কার্সর বহন করতে পারে না; v2 আইটেমগুলোকে next_cursor-সহ একটা এনভেলপে মোড়ায়। দুই রাউটারই একই সার্ভিস কল করে, তাই বিজনেস লজিক নকল হয় না:
# file: app/routers/prompts_v2.py
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.schemas import PromptOut, PromptPage
from app.services import PromptService, get_prompt_service
router = APIRouter(prefix="/v2/prompts", tags=["prompts"])
ServiceDep = Annotated[PromptService, Depends(get_prompt_service)]
@router.get("", summary="List prompts with cursor pagination")
def list_prompts(
service: ServiceDep,
tag: Annotated[str | None, Query(max_length=30)] = None,
cursor: Annotated[int, Query(ge=0, description="next_cursor from the previous page")] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> PromptPage:
rows = service.list(tag=tag, after_id=cursor, limit=limit + 1)
items = [PromptOut.model_validate(row) for row in rows[:limit]]
next_cursor = items[-1].id if len(rows) > limit else None
return PromptPage(items=items, next_cursor=next_cursor)| কৌশল | উদাহরণ | মন্তব্য |
|---|---|---|
| URL পাথ | /v2/prompts | চোখে পড়ে, রাউট, ক্যাশ ও ডকুমেন্ট করা সহজ; সবচেয়ে প্রচলিত পছন্দ |
| হেডার | Accept: application/vnd.bitbyte.v2+json বা API-Version: 2026-10-01 | URL পরিষ্কার থাকে; ব্রাউজারে চেষ্টা করা কঠিন। তারিখ-ভিত্তিক হেডার ভার্সন Stripe-ধাঁচের API-তে ব্যবহার হয় |
| কোয়েরি প্যারামিটার | ?version=2 | সহজ, কিন্তু ভুলে যাওয়া সহজ; ভার্সনিংয়ের সঙ্গে ফিল্টারিং মিশে যায় |
কোনো ভার্সন deprecate করার সময় মেশিন-পাঠযোগ্য জায়গাতেও তা জানান: deprecated=True OpenAPI-তে অপারেশনটাকে চিহ্নিত করে, আর Deprecation, Sunset ও Link হেডার ক্লায়েন্টকে বলে সেটা কবে যাবে এবং কোথায় সরে যেতে হবে।
চালিয়ে প্রতিটা পথ পরীক্ষা করুন
uv run fastapi run --port 8000 > server.log 2>&1 &
echo $! > server.pid
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok"}দুটো প্রম্পট তৈরি করুন, তারপর প্রতিটা ধরনের এরর ঘটান। -w অপশন বডির পরে স্ট্যাটাস কোড ছাপে:
curl -s -w ' %{http_code}\n' -X POST http://localhost:8000/v1/prompts \
-H 'Content-Type: application/json' \
-d '{"name": "support-answer", "template": "Answer in {language}: {input}", "tags": ["Support", "RAG"]}'
curl -s -w ' %{http_code}\n' -X POST http://localhost:8000/v1/prompts \
-H 'Content-Type: application/json' \
-d '{"name": "summarise", "template": "Summarise in 3 bullet points:\n{input}", "tags": ["batch"], "settings": {"model": "practice-llm-2", "max_tokens": 300}}'
curl -s -w ' %{http_code}\n' -X POST http://localhost:8000/v1/prompts \
-H 'Content-Type: application/json' \
-d '{"name": "summarise", "template": "Summary: {input}"}'{"id":1,"name":"support-answer","version":1,"template":"Answer in {language}: {input}","tags":["rag","support"],"settings":{"model":"practice-llm-1","temperature":0.2,"max_tokens":512},"variables":["language","input"]} 201
{"id":2,"name":"summarise","version":1,"template":"Summarise in 3 bullet points:\n{input}","tags":["batch"],"settings":{"model":"practice-llm-2","temperature":0.2,"max_tokens":300},"variables":["input"]} 201
{"error":{"code":"conflict","message":"A prompt named 'summarise' already exists","details":null}} 409curl -s -X POST http://localhost:8000/v1/prompts -H 'Content-Type: application/json' \
-d '{"name": "x", "template": "No placeholder here", "settings": {"temperature": 5}}' | python -m json.tool{
"error": {
"code": "validation_error",
"message": "The request is not valid",
"details": [
{
"field": "body.name",
"message": "String should have at least 3 characters",
"type": "string_too_short"
},
{
"field": "body.settings.temperature",
"message": "Input should be less than or equal to 2",
"type": "less_than_equal"
}
]
}
}এটা আপনার ফরম্যাটে একটা রিকোয়েস্ট-ভ্যালিডেশন এরর, যেখানে প্রতিটা সমস্যা তালিকাভুক্ত আর আছে বিন্দু-দিয়ে-লেখা field পাথ, যা ফ্রন্টএন্ড ফর্মের ইনপুটের সঙ্গে মেলাতে পারে। খেয়াল করুন {input} নিয়মটা আসেনি: মডেল ভ্যালিডেটর চলে ফিল্ডগুলো বৈধ হওয়ার পরে। এবার অন্য এররগুলো:
curl -s -w ' %{http_code}\n' http://localhost:8000/v1/prompts/42
curl -s -w ' %{http_code}\n' http://localhost:8000/v1/prompts/abc
curl -s -w ' %{http_code}\n' http://localhost:8000/v1/nothing-here
curl -s -w ' %{http_code}\n' -X PATCH http://localhost:8000/v1/prompts/1
curl -s -w ' %{http_code}\n' -X POST http://localhost:8000/v1/prompts/1/render \
-H 'Content-Type: application/json' -d '{"values": {"input": "Where is my certificate?"}}'{"error":{"code":"not_found","message":"Prompt 42 not found","details":null}} 404
{"error":{"code":"validation_error","message":"The request is not valid","details":[{"field":"path.prompt_id","message":"Input should be a valid integer, unable to parse string as an integer","type":"int_parsing"}]}} 422
{"error":{"code":"not_found","message":"Not Found","details":null}} 404
{"error":{"code":"method_not_allowed","message":"Method Not Allowed","details":null}} 405
{"error":{"code":"missing_variables","message":"Some template variables have no value","details":{"missing":["language"]}}} 422পাঁচটা আলাদা ব্যর্থতা, একটাই আকার। আর সফল পথগুলো:
curl -s -X POST http://localhost:8000/v1/prompts/1/render -H 'Content-Type: application/json' \
-d '{"values": {"input": "Where is my certificate?", "language": "Bangla"}}' | python -m json.tool
curl -s 'http://localhost:8000/v2/prompts?limit=1'; echo
curl -s 'http://localhost:8000/v2/prompts?limit=1&cursor=1'; echo{
"prompt_id": 1,
"version": 1,
"text": "Answer in Bangla: Where is my certificate?",
"settings": {
"model": "practice-llm-1",
"temperature": 0.2,
"max_tokens": 512
}
}
{"items":[{"id":1,"name":"support-answer","version":1,"template":"Answer in {language}: {input}","tags":["rag","support"],"settings":{"model":"practice-llm-1","temperature":0.2,"max_tokens":512},"variables":["language","input"]}],"next_cursor":1}
{"items":[{"id":2,"name":"summarise","version":1,"template":"Summarise in 3 bullet points:\n{input}","tags":["batch"],"settings":{"model":"practice-llm-2","temperature":0.2,"max_tokens":300},"variables":["input"]}],"next_cursor":null}পুরোনো ক্লায়েন্টদের জন্য v1 লিস্ট এখনো কাজ করে, এবং হেডারে নিজের অবসরের ঘোষণা দেয়:
curl -s -D - -o /dev/null 'http://localhost:8000/v1/prompts?tag=rag' | tr -d '\r' | grep -iE '^(HTTP|deprecation|sunset|link)'
curl -s 'http://localhost:8000/v1/prompts?tag=rag' | python -c "import sys, json; print([p['name'] for p in json.load(sys.stdin)])"HTTP/1.1 200 OK
deprecation: @1798761600
sunset: Fri, 01 Jan 2027 00:00:00 GMT
link: </v2/prompts>; rel="successor-version"
['support-answer']OpenAPI ডকুমেন্ট
আপনি যা ঘোষণা করেছেন (মেটাডেটা, ট্যাগ, সামারি, প্যারামিটারের সীমা, রেসপন্স মডেল, ডকুমেন্টেড এরর রেসপন্স, ডেপ্রিকেশন) সবই /openapi.json-এ গিয়ে ওঠে, যা /docs, /redoc, ক্লায়েন্ট জেনারেটর (openapi-generator, Orval, openapi-python-client) আর LLM টুল ডেফিনিশন চালায়। যেকোনো JSON-এর মতো এটাও পরীক্ষা করুন:
import httpx
spec = httpx.get("http://localhost:8000/openapi.json").json()
print(spec["openapi"], "|", spec["info"]["title"], spec["info"]["version"], "|", spec["info"]["license"])
print([tag["name"] for tag in spec["tags"]])
for path, methods in spec["paths"].items():
for method, op in methods.items():
flag = " (deprecated)" if op.get("deprecated") else ""
print(f"{method.upper():6} {path:30} {op['operationId']}{flag}")
print(sorted(spec["paths"]["/v1/prompts/{prompt_id}"]["get"]["responses"]))
print(spec["components"]["schemas"]["ErrorBody"]["required"])3.1.0 | BitByte Prompt Library 1.2.0 | {'name': 'MIT', 'identifier': 'MIT'}
['prompts', 'ops']
GET /health health
GET /v1/prompts v1-list_prompts (deprecated)
POST /v1/prompts v1-create_prompt
GET /v1/prompts/{prompt_id} v1-get_prompt
PUT /v1/prompts/{prompt_id} v1-update_prompt
DELETE /v1/prompts/{prompt_id} v1-delete_prompt
POST /v1/prompts/{prompt_id}/render v1-render_prompt
GET /v2/prompts v2-list_prompts
['200', '404', '422']
['code', 'message']operationId-গুলো পাঠযোগ্য ও ইউনিক (OpenAPI-র শর্ত: দুই ভার্সনেই একটা list_prompts আছে) generate_unique_id_function-এর কারণে; FastAPI-র ডিফল্টে হতো get_prompt_v1_prompts__prompt_id__get-এর মতো নাম, যা জেনারেট করা ক্লায়েন্টে কুৎসিত মেথড নাম হয়ে যায়। আরও কিছু জানার মতো কাস্টমাইজেশন:
FastAPI(docs_url=None, redoc_url=None)ইন্টারঅ্যাকটিভ ডকস বন্ধ করে, আরopenapi_url=Noneস্কিমাও সরিয়ে দেয়। অনেক টিম পাবলিক প্রোডাকশনে এটা করে, অথবা ডকসকে অথেনটিকেশনের পেছনে রাখে; ইন্টারনাল সার্ভিস সাধারণত রেখে দেয়।- কোনো রুটে
include_in_schema=Falseদিলে সেটা লুকিয়ে যায় (যেমন ইন্টারনাল মেট্রিক্স এন্ডপয়েন্ট)। Field(examples=[...])এবংmodel_config = ConfigDict(json_schema_extra={"examples": [...]})ডকসে উদাহরণ পেলোড বসায়।openapi.json-এর একটা স্ন্যাপশট কমিট করে CI-তে যাচাই করুন: অপ্রত্যাশিত ডিফ মানে রিলিজের আগেই ধরা পড়া একটা ব্রেকিং চেঞ্জ (পেশাদার API টেস্ট করা পাতায় এটা করা হয়)।
কাজ শেষে সার্ভার বন্ধ করুন:
kill "$(cat server.pid)"AI কাজে এটা কোথায় দেখা যায়
- LLM প্ল্যাটফর্ম টিমগুলো ঠিক এরকম প্রম্পট সার্ভিসই চালায়, সঙ্গে যোগ করে অনুমোদন ওয়ার্কফ্লো, A/B ভার্সন আর ভার্সন-প্রতি ইভ্যালুয়েশন স্কোর।
- এজেন্ট ও MCP: পরিষ্কার সামারিসহ একটা গোছানো OpenAPI ডকুমেন্ট আর কড়া স্কিমাই একটা LLM-কে আপনার API সঠিকভাবে টুল হিসেবে কল করতে দেয়; অস্পষ্ট স্কিমা অস্পষ্ট টুল কল বানায়।
- মডেল-সার্ভিং API ইনফারেন্স লজিক একটা সার্ভিসে রাখে, যাতে একই কোড HTTP রিকোয়েস্ট, ব্যাচ জব আর ইভ্যালুয়েশন স্ক্রিপ্ট সবকিছু চালাতে পারে।
- ফ্রন্টএন্ডগুলো
openapi.jsonথেকে নিজেদের API ক্লায়েন্ট জেনারেট করে, ফলে একটাই সুসংগত এরর ফরম্যাট ক্লায়েন্টে একটাই এরর-হ্যান্ডলিং ফাংশন হয়ে যায়।
সাধারণ ভুল
- রুট ফাংশনে বিজনেস লজিক রাখা। সেটা জব থেকে ব্যবহার করা যায় না, HTTP ছাড়া টেস্টও করা যায় না, আর বাড়তে বাড়তে হাজার লাইনের
main.pyহয়ে ওঠে। রুটকে "পার্স করো, সার্ভিস কল করো, ফেরত দাও"-তে সীমিত রাখুন। - সার্ভিসের গভীরে
HTTPExceptionতোলা। তখন আপনার ডোমেইন কোড ওয়েব ফ্রেমওয়ার্কের ওপর নির্ভর করে। নিজেরAppErrorতুলুন এবং একটাই হ্যান্ডলারে সেগুলো HTTP-তে ম্যাপ করুন। - আউটপুট মডেল ছাড়া ORM অবজেক্ট বা ইন্টারনাল ডিকশনারি ফেরত দেওয়া। যেসব ফিল্ড প্রকাশ করার কথা ছিল না (পাসওয়ার্ড হ্যাশ, ইন্টারনাল নোট, API কি) ফাঁস হয়ে যায়। সবসময় রিটার্ন টাইপ বা
response_modelঘোষণা করুন। - অজানা ফিল্ড চুপচাপ মেনে নেওয়া। ক্লায়েন্টের JSON-এর টাইপো উপেক্ষিত হয় আর ক্লায়েন্ট কখনো জানতেই পারে না। ইনপুট মডেলে
extra="forbid"ব্যবহার করুন। - নতুন ভার্সন ছাড়া প্রকাশিত রেসপন্স ভেঙে দেওয়া। "শুধু v1-এ" একটা ফিল্ডের নাম বদলালে সব ক্লায়েন্ট একসঙ্গে ভাঙে। ফিল্ড নির্দ্বিধায় যোগ করুন; বাকি সবকিছুর জন্য নতুন ভার্সন যোগ করুন এবং পুরোনোটা তারিখসহ deprecate করুন।
নিজে চেষ্টা করুন
- সহজ:
app/errors.py-এ একটাUnauthorizedএরর ক্লাস যোগ করুন (401, কোডunauthorized), এবং ছোট একটা Python চেক দিয়ে দেখান যে সার্ভিস-লেয়ার প্যাটার্ন কাজ করে: একটা ইনস্ট্যান্স বানিয়ে তার স্ট্যাটাস, কোড ও বার্তা ছাপুন। - মাঝারি:
RenderIn-এ একটা ফিল্ড ভ্যালিডেটর যোগ করুন, যা ২,০০০ অক্ষরের বেশি লম্বা যেকোনো মান প্রত্যাখ্যান করে (বিশাল ইনপুট দিয়ে আপনার LLM বাজেট উড়িয়ে দেওয়ার বিরুদ্ধে সস্তা একটা পাহারা)। ২,০০১ অক্ষরের একটা মানের জন্য ভ্যালিডেশন এরর বার্তাটা দেখান। - কঠিন:
create_app()-এর বিরুদ্ধে FastAPI-রTestClientব্যবহার করে (httpx2 ইনস্টল করে, যেমন pytest দিয়ে আপনার API টেস্ট পাতায় করা হয়েছে) যাচাই করুন যেPUT /v1/prompts/1ভার্সন ২ বানায় এবং নাম বদলালে স্ট্যান্ডার্ড এরর ফরম্যাটে ৪০৯ আসে।
উত্তর
# 1. সহজ
from app.errors import AppError
class Unauthorized(AppError):
status_code = 401
code = "unauthorized"
exc = Unauthorized("Log in to use the prompt library")
print(exc.status_code, exc.code, exc.message)
# 2. মাঝারি
from pydantic import field_validator
from app.schemas import RenderIn
class SafeRenderIn(RenderIn):
@field_validator("values")
@classmethod
def limit_length(cls, values: dict[str, str]) -> dict[str, str]:
too_long = [name for name, value in values.items() if len(value) > 2000]
if too_long:
raise ValueError(f"values too long (max 2000 characters): {too_long}")
return values
try:
SafeRenderIn(values={"input": "x" * 2001})
except ValidationError as exc:
print(exc.errors()[0]["msg"])401 unauthorized Log in to use the prompt library
Value error, values too long (max 2000 characters): ['input']uv add -q --dev httpx2# 3. কঠিন
from fastapi.testclient import TestClient
from app.main import create_app
with TestClient(create_app()) as tc:
body = {"name": "faq-answer", "template": "FAQ: {input}"}
created = tc.post("/v1/prompts", json=body).json()
v2 = tc.put(f"/v1/prompts/{created['id']}", json={**body, "template": "FAQ (short): {input}"})
print(v2.status_code, v2.json()["version"], v2.json()["template"])
renamed = tc.put(f"/v1/prompts/{created['id']}", json={**body, "name": "faq-other"})
print(renamed.status_code, renamed.json())
with TestClient(create_app()) as another: # একটা "নতুন" অ্যাপ...
print([p["name"] for p in another.get("/v2/prompts").json()["items"]])200 2 FAQ (short): {input}
409 {'error': {'code': 'conflict', 'message': 'The name of a prompt cannot change; create a new prompt instead', 'details': None}}
['faq-answer']শেষ লাইনটা দেখুন: create_app() থেকে বানানো দ্বিতীয় অ্যাপে আগে থেকেই faq-answer আছে, কারণ সার্ভিসটা মডিউল-লেভেলের একটা সিঙ্গেলটন, যা প্রসেসের প্রতিটা অ্যাপ ভাগ করে নেয়। টেস্টে এর মানে একটা টেস্টের ডেটা পরেরটায় চুঁইয়ে পড়ে। ঠিক এই ধরনের লুকানো গ্লোবাল স্টেটই পরের পাতা lifespan আর ডিপেন্ডেন্সি দিয়ে সরিয়ে দেয়।
সারসংক্ষেপ
- বাড়তে থাকা সার্ভিসকে ভাগ করুন রাউটার (HTTP), সার্ভিস (নিয়ম), রিপোজিটরি (স্টোরেজ) ও স্কিমায় (ডেটার আকার), যেখানে ইমপোর্ট একমুখী আর সার্ভিসের ভেতরে FastAPI নেই।
APIRouterপ্রিফিক্স, ট্যাগ ও ডকুমেন্টেড রেসপন্সসহ রুট গোছায়; রুট ফাংশন পাতলা থাকে এবংAnnotatedপ্যারামিটার ও রিটার্ন টাইপ ব্যবহার করে।- Pydantic দেয় নেস্টেড মডেল,
field_validator/model_validator,model_config(extra="forbid", হোয়াইটস্পেস ছাঁটা, অ্যালিয়াস) আরcomputed_field; আউটপুট মডেল ঠিক করে API থেকে ঠিক কী বের হবে। - আপনার
AppError,RequestValidationError, Starlette-এরHTTPExceptionওException-এর এক্সেপশন হ্যান্ডলার প্রতিটা এররকে একটা ডকুমেন্টেড JSON আকার দেয় এবং ভেতরের কিছু ফাঁস করে না। - ব্রেকিং চেঞ্জে ভার্সন দিন (
/v1,/v2), OpenAPI ফ্ল্যাগ ওDeprecation/Sunsetহেডার দিয়ে deprecate করুন, আরopenapi.json-কে এমন চুক্তি হিসেবে দেখুন যা আপনি কাস্টমাইজ ও রিভিউ করেন।
এরপর: ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস মডিউল-লেভেল সিঙ্গেলটনকে সঠিক ডিপেন্ডেন্সি ইনজেকশন দিয়ে বদলায়, এবং যোগ করে স্টার্টআপে তৈরি শেয়ার্ড রিসোর্স, রিকোয়েস্ট ID, CORS, ব্যাকগ্রাউন্ড টাস্ক, লগিং ও সেটিংস।