এই পর্বে যা শিখবেন
- FastAPI, Starlette, Pydantic আর uvicorn কে কী করে, আর ASGI কেন WSGI থেকে আলাদা
- Type hint দিয়ে path, query আর body নেওয়া, 422-এর ভাষা পড়া, আর
response_modelদিয়ে ফাঁস আটকানো Dependsদিয়ে ডেটাবেস session, বর্তমান ব্যবহারকারী আর test-এর নকল বসানোasync defআরdef-এর পার্থক্য মেপে দেখা, আর event loop আটকে যাওয়া চেনা- SQLAlchemy 2-এ একটি সম্পর্কসহ বাজারঘরের products API, JWT login, background কাজ আর pytest
১ · FastAPI কোথায় বসে: ASGI, Starlette আর Pydantic
এতদিন বাজারঘরের backend লিখেছেন Express-এ (পর্ব ২৫), input যাচাই করেছেন Zod দিয়ে (পর্ব ২৭), আর OpenAPI document লিখেছেন আলাদা করে (পর্ব ৩৫)। FastAPI এই তিনটি কাজ একটি জায়গা থেকে করে: Python-এর type hint. আপনি function-এর parameter-এ type লেখেন, আর সেই একই লেখা থেকে FastAPI তিনটি জিনিস পায়: ইনপুট রূপান্তর ও যাচাই, editor-এর autocomplete, আর API-র লিখিত চুক্তি।
দর্জির দোকানের কথা ভাবুন। মাপের খাতায় একবার লেখা হলো “বুক ৩৮, হাতা ২৪”। সেই একই খাতা দেখে কাটার মিস্ত্রি কাপড় কাটেন, সেলাইয়ের মিস্ত্রি মিলিয়ে দেখেন, আর ক্রেতাকে রসিদেও একই মাপ লেখা হয়। মাপ তিন জায়গায় আলাদা করে লেখা হলে কোথাও না কোথাও ভুল হতোই। FastAPI-তে type hint হলো এই মাপের খাতা: যাচাই, docs আর editor সবাই একটি জায়গা থেকে পড়ে।
ইতিহাস: FastAPI-র প্রথম প্রকাশ্য সংস্করণ এসেছিল ২০১৮ সালের শেষ দিকে, কলম্বিয়ার ডেভেলপার Sebastián Ramírez-এর হাতে, যিনি অনলাইনে tiangolo নামে পরিচিত। তিনি নতুন করে সব বানাননি। HTTP-র কাজের জন্য নিলেন Tom Christie-র Starlette, আর ডেটা যাচাইয়ের জন্য Samuel Colvin-এর Pydantic (২০১৭-এর দিকে শুরু)। Pydantic v2 (২০২৩) তার মূল অংশ Rust-এ নতুন করে লিখেছে। লেখার সময়ে এই পর্বের সব run হয়েছে FastAPI 0.162.0, Pydantic 2.14.2, Starlette 1.10.0 আর uvicorn 0.54.0 দিয়ে, Python 3.14-এ। FastAPI-র version এখনো 0.x, তাই minor version বদলেও আচরণ বদলাতে পারে। নিজের version-এর release notes পড়ার অভ্যাস রাখুন।
WSGI বনাম ASGI: server আর app-এর মধ্যের চুক্তি
Python-এ web server (যে socket থেকে bytes পড়ে) আর web app (যে আপনার কোড চালায়) আলাদা। দুজনের মধ্যে একটি মানা চুক্তি লাগে। পুরোনো চুক্তির নাম WSGI (PEP 333, ২০০৩; Python 3-এর জন্য PEP 3333, ২০১০)। এতে app একটি সাধারণ function: একটি request নেয়, পুরো উত্তর ফেরত দেয়, আর ততক্ষণ সেই worker আটকে থাকে। Django আর Flask এই পথে বড় হয়েছে।
নতুন চুক্তি ASGI, যা Django Channels-এর কাজ থেকে (Andrew Godwin-এর নেতৃত্বে, ২০১৬ থেকে ২০১৯-এর মধ্যে) দাঁড়িয়েছে। এতে app একটি async function, যা তিনটি জিনিস পায়: scope (কোন path, কোন header), receive (client-এর পাঠানো বার্তা পড়ার জন্য) আর send (উত্তর পাঠানোর জন্য)। একটি connection অনেকক্ষণ খোলা রেখে অনেক বার্তা আদান-প্রদান করা যায়, তাই WebSocket (পর্ব ৬০) আর streaming (পর্ব ৬১) স্বাভাবিকভাবে চলে, আর অপেক্ষার সময় একই process অন্য request সামলায়।
| দিক | WSGI | ASGI |
|---|---|---|
| App-এর রূপ | সাধারণ function: app(environ, start_response) | async function: app(scope, receive, send) |
| অপেক্ষার সময় | worker বা thread আটকে থাকে | event loop অন্য request চালায় |
| WebSocket, লম্বা stream | স্বাভাবিকভাবে নয় | চুক্তির অংশ |
| সাধারণ server | Gunicorn, uWSGI | uvicorn, Hypercorn, Daphne |
| পরিচিত framework | Flask, পুরোনো ধাঁচের Django | FastAPI, Starlette; Django-ও এখন ASGI-তে চলতে পারে |
Node.js-এ এই ভাগ ছিল না, কারণ Node শুরু থেকেই event loop-এ চলে (পর্ব ২৪)। Python-এ async/await এসেছে Python 3.5-এ (২০১৫), আর তার সাথে পুরো web জগতের জন্য নতুন চুক্তি লেগেছে। নিচের ছবিতে বাজারঘরের একটি request কোন স্তর পেরিয়ে যায়, তা দেখুন।
FastAPI-র স্তরগুলো
uvicorn speaks ASGI; Starlette routes; FastAPI adds validation, Depends and OpenAPI
async def হলে event loop-এ, সাধারণ def হলে thread pool-এ চলে।এখানে একটি কথা পরিষ্কার রাখুন: FastAPI নিজে কোনো socket খোলে না। uvicorn হলো server, FastAPI হলো app. Express-এ দুটি একসাথে আসে, Python-এ আলাদা। Python ভাষা, type hint আর async-এর মূল ধারণা এই কোর্সে আবার শেখানো হবে না। দরকার হলে Python কোর্সের type hint আর mypy আর asyncio পর্ব দেখুন। FastAPI-র hello world আর প্রথম CRUD আছে ওয়েব ব্যাকএন্ড — FastAPI-র শুরু পর্বে। এখানে আমরা একজন ফুল-স্ট্যাক ডেভেলপারের চোখে দেখব: Express-এর সাথে তুলনা, নিরাপত্তা, মাপা আচরণ আর সিদ্ধান্ত।
২ · প্রথম path operation: path, query আর type hint
FastAPI-তে একটি route-কে বলে path operation: একটি path (/products/{product_id}) আর একটি HTTP method (GET) মিলে একটি কাজ। Decorator দিয়ে function-টিকে সেই কাজের সাথে জোড়া হয়। Express-এর app.get('/products/:id', handler)-এর সাথে মিলিয়ে দেখুন।
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query, status
app = FastAPI(title='বাজারঘর API', version='1.0.0')
def pagination(limit: int = Query(default=20, le=100),
offset: int = Query(default=0, ge=0)):
return {'limit': limit, 'offset': offset}
@app.get('/products/{product_id}', response_model=ProductOut)
def get_product(product_id: int, db: DB):
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Product not found')
return productFastAPI নিয়মটি এভাবে পড়ে: path-এ {product_id} আছে, আর function-এ একই নামের parameter আছে, তাই এটি path parameter. Path-এ নেই এমন সাধারণ parameter, যেমন limit, হয়ে যায় query parameter (?limit=2). আর int লেখা মানে: URL-এ সবকিছু string হয়ে আসে, FastAPI তাকে সংখ্যায় রূপান্তর করবে, না পারলে 422. পর্ব ২৭-এ Zod-এ z.coerce.number() নিজে লিখতে হয়েছিল, এখানে type hint-ই সেটি করে। Query(le=100) একটি সীমা যোগ করে, যেন কেউ এক অনুরোধে লাখ সারি না চায় (পর্ব ৩৪)।
সত্যিকারের run · FastAPI 0.162.0, uvicorn 0.54.0 · ~/bazarghar-এ, 127.0.0.1-এ
$ uvicorn main:app --host 127.0.0.1 --port 8769
INFO: Started server process [9904]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8769 (Press CTRL+C to quit)
$ curl -s "127.0.0.1:8769/products?limit=2"
[{"id":1,"name":"Miniket Rice 5kg","price":420,"stock":30,"category":{"id":1,"name":"Grocery"}},
{"id":2,"name":"Soybean Oil 1L","price":190,"stock":0,"category":{"id":1,"name":"Grocery"}}]
$ curl -s 127.0.0.1:8769/products/3
{"id":3,"name":"Sylhet Tea 400g","price":260,"stock":18,"category":{"id":2,"name":"Tea"}}
$ curl -s -w '\nHTTP %{http_code}\n' 127.0.0.1:8769/products/abc
{"detail":[{"type":"int_parsing","loc":["path","product_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
HTTP 422
$ curl -s -w '\nHTTP %{http_code}\n' "127.0.0.1:8769/products?limit=101"
{"detail":[{"type":"less_than_equal","loc":["query","limit"],"msg":"Input should be less than or equal to 100","input":"101","ctx":{"le":100}}]}
HTTP 422
Handler-এর ভেতরে একটিও if লেখা হয়নি, তবু abc আর 101 দুটিই থেমেছে। Error-এর loc বলে দেয় সমস্যা কোথায়: ["path","product_id"] মানে path-এ, ["query","limit"] মানে query-তে। Frontend এই loc দেখে ঠিক input-এর পাশে বার্তা দেখাতে পারে। প্রথম লাইনের JSON-এ category একটি nested object, যা আসছে SQLAlchemy-র একটি সম্পর্ক থেকে, অংশ ৮-এ দেখবেন।
Python না জানলে যা পড়তে হবে: @app.get(...) একটি decorator, যা নিচের function-কে route-এ জুড়ে দেয়। product_id: int মানে “এই parameter-এর type int”. Annotated[Session, Depends(get_db)] মানে “type Session, আর মানটি আসবে get_db থেকে”। async def এমন function যার ভেতরে await লেখা যায়। এর বেশি Python এই পর্বে লাগবে না।
নিচের ছোট মডেলটি দেখায় type hint থেকে যাচাই আসলে কোথা থেকে আসে। এটি Pydantic নয়, শুধু ধারণা দেখানোর জন্য Python-এর dataclasses আর typing.get_type_hints দিয়ে বানানো একটি খেলনা রূপ। Python runtime-এ class-এর type hint পড়তে পারে, আর FastAPI ও Pydantic ঠিক এই সুবিধাটিই কাজে লাগায়।
# শেখানোর মডেল: type hint পড়ে যাচাই, Pydantic-এর ছোট খেলনা রূপ
from dataclasses import dataclass, fields, MISSING
from typing import get_type_hints
@dataclass
class ProductIn:
name: str
price: int
stock: int = 0
def validate(model, data):
hints, errors, clean = get_type_hints(model), [], {}
allowed = {f.name: f for f in fields(model)}
for key in data.keys() - allowed.keys():
errors.append(('extra_forbidden', key, 'Extra inputs are not permitted'))
for name, f in allowed.items():
if name not in data:
if f.default is MISSING:
errors.append(('missing', name, 'Field required'))
continue
value, typ = data[name], hints[name]
try:
clean[name] = typ(value) # lax mode: "650" -> 650
except (TypeError, ValueError):
errors.append((f'{typ.__name__}_parsing', name, f'Input should be a valid {typ.__name__}'))
if errors:
return 422, errors
return 201, model(**clean)
print(get_type_hints(ProductIn))
print(validate(ProductIn, {'name': 'Ghee 500g', 'price': '650'}))
print(validate(ProductIn, {'name': 'Ghee', 'price': 'ten', 'is_admin': True}))
print(validate(ProductIn, {'price': 95}))প্রথম লাইন {'name': <class 'str'>, 'price': <class 'int'>, 'stock': <class 'int'>} দেখায়, চলার সময়েও hint হাতে পাওয়া যায়। দ্বিতীয় লাইনে "650" রূপান্তর হয়ে price=650, আর না দেওয়া stock নিল ডিফল্ট 0, ফল 201. তৃতীয় লাইনে দুটি ভুল একসাথে: অচেনা is_admin (extra_forbidden) আর 'ten' সংখ্যা নয় (int_parsing). প্রথম ভুলে থেমে না গিয়ে সব ভুল একসাথে জমানো, এটিই Pydantic-এর আচরণ, যাতে ব্যবহারকারী একবারেই সব ঠিক করতে পারেন। চতুর্থ লাইনে name নেই: missing. আসল Pydantic এর চেয়ে অনেক বেশি করে: nested model, সীমা (gt, max_length), union, তারিখ, আর সবই Rust-এ লেখা দ্রুত core-এ।
৩ · Pydantic v2: request model, 422 আর “নরম” রূপান্তর
Request body-র জন্য একটি Pydantic model লিখুন। Function-এর parameter-এ সেই model দিলে FastAPI বোঝে, এটি JSON body থেকে আসবে।
from pydantic import BaseModel, ConfigDict, Field
class ProductIn(BaseModel):
model_config = ConfigDict(extra='forbid') # অচেনা field মানে 422
name: str = Field(min_length=1, max_length=120)
price: int = Field(gt=0, description='দাম, টাকায়')
stock: int = Field(default=0, ge=0)
category_id: int
@app.post('/products', response_model=ProductOut, status_code=201)
def create_product(item: ProductIn, db: DB, user: CurrentUser):
...সত্যিকারের run · admin-এর token দিয়ে (token কীভাবে আসে, অংশ ৯-এ) · $AT হলো সেই token · পরের দুটি command-এ একই header, সংক্ষেপে ...
$ curl -s -X POST 127.0.0.1:8769/products -H "Authorization: Bearer $AT" \
-H 'Content-Type: application/json' -d '{"name":"","price":0,"category_id":1}'
{"detail":[{"type":"string_too_short","loc":["body","name"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}},{"type":"greater_than","loc":["body","price"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}]}
HTTP 422
$ curl ... -d '{"name":"Ghee 500g","price":650,"category_id":1,"cost_price":1}'
{"detail":[{"type":"extra_forbidden","loc":["body","cost_price"],"msg":"Extra inputs are not permitted","input":1}]}
HTTP 422
$ curl ... -d '{"name":"Ghee 500g","price":"650","category_id":1}'
{"id":8,"name":"Ghee 500g","price":650,"stock":0,"category":{"id":1,"name":"Grocery"}}
HTTP 201
তিনটি উত্তর তিনটি শিক্ষা দেয়। প্রথমটিতে দুটি ভুল একসাথে, প্রতিটির type, loc, msg, যে input এসেছিল সেটি, আর নিয়মের মান (ctx). দ্বিতীয়টিতে ক্রেতা চুপিচুপি cost_price (ক্রয়মূল্য, যা শুধু দোকানের জানার কথা) পাঠাতে চেয়েছিলেন। এটি mass assignment-এর চেষ্টা, পর্ব ২৭-এ Zod-এর .strict() দিয়ে যা আটকেছিলেন। extra='forbid' একই কাজ করে।
তৃতীয়টি অবাক করতে পারে: "650" একটি string, তবু গৃহীত হলো, আর ডেটাবেসে গেল সংখ্যা 650. Pydantic-এর ডিফল্ট হলো lax mode, মানে নিরাপদ মনে হলে সে coercion করে: সংখ্যার মতো দেখতে string থেকে সংখ্যা। কড়া হতে চাইলে strict=True. আর মনে রাখুন, extra-র ডিফল্ট forbid নয়, ignore: অচেনা field চুপচাপ ফেলে দেওয়া হয়।
সত্যিকারের run · Pydantic 2.14.2 · একই body দুই রকম model-এ
body = {'name': 'Ghee 500g', 'price': '650', 'is_admin': True}
default: Loose(name='Ghee 500g', price=650)
strict : int_type ('price',) Input should be a valid integer
strict : extra_forbidden ('is_admin',) Extra inputs are not permitted
ডিফল্ট model-এ is_admin নীরবে বাদ গেছে। এটি নিরাপদ (ডেটায় ঢোকেনি), কিন্তু client জানতেও পারল না যে তার পাঠানো একটি field কাজে লাগেনি। কোনটি বেছে নেবেন, তা দলের সিদ্ধান্ত। বাজারঘরে লেখার API-তে আমরা forbid রেখেছি, কারণ ভুল বানানের field (stok) নীরবে হারালে bug খুঁজে পাওয়া কঠিন।
| বিষয় | Express + Zod (পর্ব ২৭) | FastAPI + Pydantic |
|---|---|---|
| যাচাই কোথায় লেখা | আলাদা schema, middleware-এ parse | parameter-এর type hint, নিজে থেকে |
| অচেনা field | ডিফল্টে বাদ, .strict()-এ ভুল | ডিফল্টে বাদ, extra='forbid'-এ 422 |
| “650” থেকে 650 | শুধু z.coerce দিলে | ডিফল্টে হ্যাঁ, strict=True-এ না |
| ভুলের status | আপনি যা ঠিক করেন (কোর্সে 422) | সবসময় 422, detail তালিকাসহ |
| OpenAPI | আলাদা tool দিয়ে (পর্ব ৩৫) | নিজে থেকে |
পর্ব ৩৩-এ (Status code আর error format) দেখেছেন, “JSON পড়া যায় কিন্তু নিয়ম ভাঙে” মানে 422, আর ভুলের ছাঁচ সবখানে এক হওয়া উচিত। FastAPI-র {"detail":[...]} ছাঁচ RFC 9457-এর problem details নয়। দরকার হলে RequestValidationError-এর জন্য নিজের exception handler লিখে ছাঁচ বদলানো যায়, তবে তখন OpenAPI-তে লেখা 422-এর বর্ণনাও মিলিয়ে নিতে হয়।
৪ · response_model: যা পাঠাবেন না, তা আটকানো
ঢোকার দরজা পাহারা দেওয়া হলো। এবার বেরোনোর দরজা। ডেটাবেসের একটি সারিতে প্রায়ই এমন তথ্য থাকে যা client-এর জানার কথা নয়: ব্যবহারকারীর password hash, admin কি না, পণ্যের ক্রয়মূল্য। Handler যদি পুরো সারি ফেরত দেয়, সবই চলে যায়। একে বলে excessive data exposure.
পরীক্ষার জন্য বাজারঘরে দুটি route রাখা হলো, একই কাজ, একটি পার্থক্য। প্রথমটি AI-এর লেখা কোডে প্রায়ই দেখা “সব column-কে dict বানিয়ে ফেরত দাও” ধাঁচের, কোনো response_model নেই। দ্বিতীয়টিতে response_model=UserOut, যেখানে শুধু id আর email.
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True) # ORM object থেকে পড়া যাবে
id: int
email: str
@app.get('/users/me', response_model=UserOut)
def me(user: CurrentUser):
return user
@app.get('/users/me/raw') # ভুলটি: response_model নেই
def me_raw(user: CurrentUser):
return {c.name: getattr(user, c.name) for c in User.__table__.columns}সত্যিকারের run · একজন সাধারণ ক্রেতার token দিয়ে ($RT) · hash-টি আসল argon2id hash, একটি পরীক্ষার password-এর
$ curl -s 127.0.0.1:8769/users/me -H "Authorization: Bearer $RT"
{"id":2,"email":"rina@bazarghar.test"}
$ curl -s 127.0.0.1:8769/users/me/raw -H "Authorization: Bearer $RT"
{"id":2,"email":"rina@bazarghar.test","password_hash":"$argon2id$v=19$m=65536,t=3,p=4$uTtXniGM+dcOLUfoeD6nLQ$yDOg7eV8NYsFZWYRnEThcrKKUZ59cReFFzqGpD12Aio","is_admin":false}
দ্বিতীয় উত্তরে hash-এর সাথে hash-এর সব setting-ও আছে (memory ৬৫,৫৩৬ KiB, ৩ বার, ৪ lane). Argon2 ধীর বলে সঙ্গে সঙ্গে password বের করা কঠিন, কিন্তু hash বাইরে গেলে আক্রমণকারী নিজের যন্ত্রে যত খুশি অনুমান চালাতে পারেন, কোনো rate limit ছাড়া। আর is_admin জানিয়ে দেয় কোন account-এ আক্রমণ লাভজনক। এই ফাঁস ধরার মতো কোনো error হয়নি, status 200. তাই এটি ধরা পড়ে শুধু review বা test-এ।
response_model একটি allowlist. Handler যা-ই ফেরত দিক, FastAPI সেটি model-এর ভেতর দিয়ে চালায়, আর model-এ যে field নেই, তা ফেলে দেয়। তাই ঢোকার জন্য একটি model (ProductIn), বেরোনোর জন্য আরেকটি (ProductOut), আর ডেটাবেসের জন্য তৃতীয়টি (SQLAlchemy-র Product). তিনটি এক করে ফেলা সহজ, আর ঠিক সেখানেই ফাঁস আর mass assignment দুটিই জন্মায়।
বাজারঘরের Product table-এ cost_price আছে, কিন্তু ProductOut-এ নেই। তাই আগের সব product-এর উত্তরে ক্রয়মূল্য নেই, যদিও handler পুরো ORM object ফেরত দিয়েছে। Return type দিয়েও (-> ProductOut) একই কাজ হয়, আর দুটি একসাথে দিলে response_model প্রাধান্য পায়। এই সমস্যার পূর্ণ ছবি, যাকে OWASP API Security Top 10 (২০২৩) “Broken Object Property Level Authorization” বলে, পাবেন API নিরাপত্তা পর্বে।
দুটি সাধারণ ফাঁদ: (১) ProductOut(ProductIn)-এর মতো উত্তরাধিকার দিলে ঢোকার model-এ পরে কেউ গোপন field যোগ করলে সেটি বেরোনোর model-এও চলে আসে। বেরোনোর model আলাদা করে লিখুন। (২) Handler থেকে সরাসরি JSONResponse(...) ফেরত দিলে response_model-এর ছাঁকনি চলে না, কারণ আপনি নিজেই তৈরি উত্তর দিয়েছেন।
৫ · স্বয়ংক্রিয় docs: OpenAPI 3.1 আর /docs
পর্ব ৩৫-এ (Versioning আর OpenAPI) OpenAPI document লিখতে আলাদা tool লেগেছিল, আর কোড বদলালে document পিছিয়ে পড়ার ভয় ছিল। FastAPI-তে document তৈরি হয় চলার সময়ে, কোড থেকেই। কিছু না লিখেই বাজারঘর API-তে দুটি ঠিকানা আছে: /openapi.json আর /docs.
সত্যিকারের run · /openapi.json থেকে বাছাই করা অংশ, Python দিয়ে ছাপানো
3.1.0 {'title': 'বাজারঘর API', 'version': '1.0.0'}
POST /token Login ['200', '422']
GET /users/me Me ['200']
GET /users/me/raw Me Raw ['200']
GET /products List Products ['200', '422']
POST /products Create Product ['201', '422']
GET /products/{product_id} Get Product ['200', '422']
POST /orders Place Order ['202']
components.schemas.ProductIn:
"name": {"type": "string", "maxLength": 120, "minLength": 1, "title": "Name"}
"price": {"type": "integer", "exclusiveMinimum": 0.0, "title": "Price", "description": "দাম, টাকায়"}
"stock": {"type": "integer", "minimum": 0.0, "title": "Stock", "default": 0}
"category_id": {"type": "integer", "title": "Category Id"}
"additionalProperties": false, "required": ["name", "price", "category_id"]
securitySchemes: {"OAuth2PasswordBearer": {"type": "oauth2",
"flows": {"password": {"scopes": {}, "tokenUrl": "token"}}}}
$ curl -s 127.0.0.1:8769/docs | grep -E "title|swagger-ui-bundle"
<title>বাজারঘর API - Swagger UI</title>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
লক্ষ করুন, কোডের প্রতিটি সিদ্ধান্ত document-এ চলে এসেছে। Field(gt=0) হলো exclusiveMinimum, extra='forbid' হলো additionalProperties: false, description='দাম, টাকায়' বাংলাসহ এসেছে, আর OAuth2PasswordBearer থেকে login-এর বর্ণনা। /users/me-তে 422 নেই, কারণ সেখানে যাচাই করার মতো কোনো parameter নেই। একটি ছোট খুঁত: সীমার মান 0.0 হিসেবে এসেছে, পূর্ণসংখ্যা হিসেবে নয়। কাজে প্রভাব নেই, কিন্তু document থেকে কোড বানানোর tool এ রকম খুঁটিনাটিতে আটকাতে পারে, তাই নিজের tool দিয়ে একবার যাচাই করুন।
- কোড
@app.postroute আর type hint - schema
app.openapi()model থেকে JSON Schema - JSON
/openapi.json3.1.0 - docs
/docsSwagger UI
@app.post('/products', response_model=ProductOut,
status_code=201)
def create_product(item: ProductIn, db: DB,
user: CurrentUser): ...
decorator route-টি app-এর তালিকায় রাখে
for route in app.routes:
body ← ProductIn.model_json_schema()
201 ← ProductOut
422 ← HTTPValidationError
security ← OAuth2PasswordBearer
ফল app.openapi_schema-তে জমা থাকে
{"openapi": "3.1.0",
"info": {"title": "বাজারঘর API", ...},
"paths": {"/products": {"post": {...}}},
"components": {"schemas": {"ProductIn": {
..., "additionalProperties": false}}}}
<title>বাজারঘর API - Swagger UI</title>
<script src="https://cdn.jsdelivr.net/npm/
swagger-ui-dist@5/swagger-ui-bundle.js">
url: '/openapi.json'
ছোট একটি HTML, ভারী অংশ CDN থেকে
Authorize → POST /token (form)
POST /products {"name": "", "price": 0, ...}
422 string_too_short · greater_than
form-টি schema থেকে, request-টি আসল
- কোডই উৎস। Module load হওয়ার সময় decorator প্রতিটি path operation-কে app-এর route তালিকায় রাখে, সাথে parameter-এর type,
response_modelআরstatus_code. - Schema তৈরি। প্রথমবার document চাওয়া হলে FastAPI সব route ঘুরে প্রতিটি Pydantic model-এর JSON Schema নেয়, 422-এর জন্য নিজের
HTTPValidationErrorযোগ করে, আর ফলটি জমিয়ে রাখে। পরের অনুরোধে আর হিসাব হয় না। - যন্ত্রের পড়ার মতো চুক্তি।
/openapi.jsonOpenAPI 3.1.0 দেয়। 3.1 সংস্করণ JSON Schema-র সাথে মিলিয়ে লেখা, তাই Pydantic-এর schema প্রায় হুবহু বসে যায়। এই ফাইল থেকে TypeScript client বানানো যায় (পর্ব ৩৫)। - মানুষের পড়ার মতো docs.
/docsএকটি ছোট HTML, যা Swagger UI-র JavaScript CDN থেকে আনে আর/openapi.jsonপড়ে। তাই ইন্টারনেট ছাড়া বা কড়া Content-Security-Policy-তে পাতাটি ফাঁকা আসতে পারে। তখন file গুলো নিজের server থেকে দিতে হয়।/redoc-এ আরেক ধাঁচের docs আছে। - চেষ্টা করে দেখা। Browser-এ “Authorize” চাপলে login form, যা
/token-এ যায়। “Try it out” আসল request পাঠায়, তাই ভুল body দিলে আসল 422 ফেরত আসে। Production-এ এই পাতা কারা দেখবে, ভাবুন:FastAPI(docs_url=None)দিয়ে বন্ধ করা যায়, বা login-এর পেছনে রাখা যায়।
নিচের খেলনা মডেলটি দ্বিতীয় ধাপের মূল ভাবনা দেখায়: একটি dataclass-এর type hint আর metadata পড়ে JSON Schema বানানো, তারপর সেটিকে OpenAPI-র কাঠামোয় বসানো।
# শেখানোর মডেল: dataclass থেকে OpenAPI-ধাঁচের JSON Schema
import json
from dataclasses import dataclass, field, fields, MISSING
from typing import get_type_hints
JSON_TYPE = {str: 'string', int: 'integer', float: 'number', bool: 'boolean'}
@dataclass
class ProductIn:
name: str = field(metadata={'minLength': 1, 'maxLength': 120})
price: int = field(metadata={'exclusiveMinimum': 0, 'description': 'price in taka'})
stock: int = field(default=0, metadata={'minimum': 0})
def schema_of(model):
hints, props, required = get_type_hints(model), {}, []
for f in fields(model):
prop = {'type': JSON_TYPE[hints[f.name]], **f.metadata}
if f.default is MISSING:
required.append(f.name)
else:
prop['default'] = f.default
props[f.name] = prop
return {'type': 'object', 'properties': props, 'required': required,
'additionalProperties': False, 'title': model.__name__}
doc = {'openapi': '3.1.0', 'info': {'title': 'Bazarghar API', 'version': '1.0.0'},
'paths': {'/products': {'post': {
'requestBody': {'content': {'application/json': {
'schema': {'$ref': '#/components/schemas/ProductIn'}}}},
'responses': {'201': {'description': 'Created'},
'422': {'description': 'Validation Error'}}}}},
'components': {'schemas': {'ProductIn': schema_of(ProductIn)}}}
print(json.dumps(doc['components'], indent=1))
print('paths:', [(m.upper(), p) for p, ops in doc['paths'].items() for m in ops])ফলের "required": ["name", "price"] এসেছে সেই field গুলো থেকে যাদের ডিফল্ট নেই, আর stock-এ "default": 0. সীমাগুলো ("minLength": 1, "exclusiveMinimum": 0) metadata থেকে সরাসরি বসেছে, আর শেষ লাইন paths: [('POST', '/products')]. আসল FastAPI-র ফলের সাথে মিলিয়ে দেখুন: একই আকৃতি। পার্থক্য হলো Pydantic title নিজে বানায় ("Category Id"), nested model-কে $ref দিয়ে জোড়ে, আর union বা optional-কে anyOf দিয়ে লেখে। মূল কথাটি একই: চলার সময়ে type পড়া যায় বলেই document কোডের সাথে কখনো অমিল হয় না।
৬ · Depends: ডেটাবেস session, বর্তমান ব্যবহারকারী আর test
পর্ব ২৯-এ (Backend-এর গঠন) দেখেছেন, dependency injection আসলে “দরকারি জিনিস parameter হিসেবে দেওয়া”। Express-এ সেটি আপনি হাতে জুড়েছিলেন। FastAPI-তে এর জন্য একটি ভাষা আছে: Depends. Parameter-এর পাশে লিখে দিন কোন function মানটি বানাবে, বাকিটা FastAPI করবে।
def get_db():
with Session(engine) as session: # request শেষে session বন্ধ
yield session
DB = Annotated[Session, Depends(get_db)]
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)], db: DB) -> User:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) # ভুলে 401
user = db.get(User, int(payload['sub']))
...
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
@app.post('/products', response_model=ProductOut, status_code=201)
def create_product(item: ProductIn, db: DB, user: CurrentUser):
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, 'Admins only')
...তিনটি জিনিস লক্ষ করুন। এক, get_db-এ return নয়, yield: এটি একটি yield dependency. yield-এর আগের অংশ request-এর আগে চলে, পরের অংশ (এখানে with-এর শেষ, মানে session বন্ধ) কাজ শেষে। দুই, dependency-র নিজেরও dependency থাকতে পারে: get_current_user নিজেই DB চায়। তিন, Annotated দিয়ে একবার নাম দিলে (DB, CurrentUser) প্রতিটি route-এ শুধু ছোট নামটি লিখলেই হয়।
হোটেলের রিসেপশনের কথা ভাবুন। অতিথি (handler) শুধু বলেন “আমার একটি চাবি আর একজন বেয়ারা লাগবে”। কোন ঘর খালি, কোন বেয়ারা ডিউটিতে, সেটি রিসেপশন (FastAPI) ঠিক করে। চেক-আউটে চাবি ফেরত নেওয়াও রিসেপশনের কাজ। আর হোটেল পরিদর্শনের দিন রিসেপশন চাইলে নকল চাবি দিতে পারে, অতিথির কিছু বদলাতে হয় না। এটিই test-এ dependency বদলানো।
নিচের খেলনা resolver দেখায় FastAPI কীভাবে parameter পড়ে dependency ডাকে। এটি আসল কোড নয়, তবে মূল তিনটি নিয়ম এখানে আছে: signature পড়া, এক request-এ একবারই বানানো (cache), আর yield-এর পরের অংশ শেষে চালানো।
# শেখানোর মডেল: Depends-এর মতো একটি ছোট resolver
import inspect
class Depends:
def __init__(self, fn): self.fn = fn
def solve(fn, request, cache, cleanups):
kwargs = {}
for name, p in inspect.signature(fn).parameters.items():
if isinstance(p.default, Depends):
dep = p.default.fn
if dep not in cache: # একই request-এ একবারই
value = solve(dep, request, cache, cleanups)
if inspect.isgenerator(value): # yield থাকলে: setup, পরে cleanup
cleanups.append(value)
value = next(value)
cache[dep] = value
kwargs[name] = cache[dep]
else:
kwargs[name] = request[name]
return fn(**kwargs)
def get_db():
print(' open session'); yield 'session#1'; print(' close session')
def get_current_user(db=Depends(get_db)):
print(f' find user with {db}'); return {'id': 2, 'email': 'rina@bazarghar.test'}
def place_order(product_id, db=Depends(get_db), user=Depends(get_current_user)):
return f'order for product {product_id} by user {user["id"]} using {db}'
cleanups = []
print('result:', solve(place_order, {'product_id': 3}, {}, cleanups))
for gen in reversed(cleanups):
next(gen, None) # yield-এর পরের অংশ চলেplace_order আর get_current_user দুজনেই get_db চায়, তবু open session একবারই ছাপা হয়েছে, আর দুজনে একই session#1 পেয়েছে। এটি গুরুত্বপূর্ণ: একই request-এর সব কাজ একই session-এ, মানে একই transaction-এ। ক্রমটিও দেখুন: আগে session খোলা, তারপর ব্যবহারকারী খোঁজা, তারপর handler-এর ফল order for product 3 by user 2 using session#1, আর একেবারে শেষে close session. আসল FastAPI-তেও এক request-এ একই dependency ডিফল্টে একবারই চলে (use_cache=False দিলে প্রতিবার)।
এবার পুরো ছবি। নিচের stepper-এ একটি আসল request, admin-এর token আর string-এ লেখা দাম "650" নিয়ে, ধাপে ধাপে চলছে। ক্রমটি আসল FastAPI-তে একটি ছোট probe চালিয়ে মিলিয়ে নেওয়া হয়েছে।
1POST /products Bearer … {"price":"650",…}2route match → create_product3Depends: oauth2_scheme, get_db, get_current_user4ProductIn.model_validate(body)5create_product(item, db, user) → Product6response_model=ProductOut → JSON 201
| db | |
| user | |
| item.price | |
| ফেরত মান |
- Request এলো। uvicorn bytes পড়ে ASGI-র
scopeবানাল। Header-এ admin-এর token, body-তে দাম string হিসেবে"650". - Route মিলল। Starlette method আর path মিলিয়ে
create_productপেল। এখনো কিছু যাচাই হয়নি। - আগে dependency.
oauth2_schemeheader থেকে token নিল (না থাকলে এখানেই 401),get_dbsession খুলল,get_current_usertoken যাচাই করে সেই session দিয়ে ব্যবহারকারী আনল। Probe-এ দেখা গেছে body ভুল হলেও এই ধাপ আগে চলে, আর token ছাড়া ভুল body পাঠালে উত্তর 422 নয়, 401. - Body যাচাই।
ProductInlax mode-এ"650"-কে650বানাল,stockনিল ডিফল্ট ০, অচেনা field নেই। কোনো ভুল থাকলে সব ভুল একসাথে 422-এ যেত, আর handler চলত না। - Handler চলল।
user.is_adminসত্য, তাই 403 নয়। Product লেখা হলো, commit হলো। ফেরত দেওয়া ORM object-এcost_price-ও আছে। - বেরোনোর ছাঁকনি।
ProductOutশুধু id, name, price, stock আর nested category রাখল, তারপর JSON হলো, status 201. উত্তর পাঠানোর পরেget_db-এরwithশেষ হয়ে session বন্ধ হলো।
Cleanup লিখুন with বা try/finally-তে। একটি probe-এ get_db লেখা হয়েছিল এভাবে: print('open'); yield s; print('close'). ঠিক body-তে close ছাপা হলো। কিন্তু ভুল body-তে (422) close ছাপাই হলো না, কারণ ভুলটি yield-এর জায়গায় exception হয়ে ঢোকে, আর পরের লাইন বাদ পড়ে। আসল ডেটাবেসে এর মানে ফেরত না যাওয়া connection. FastAPI-র docs-ও তাই try/finally দেখায়। ডিফল্টে cleanup চলে উত্তর পাঠানোর পরে; উত্তরের আগে চাইলে Depends(..., scope="function").
Test-এ dependency বদলানো
Depends-এর সবচেয়ে বড় উপহার test-এ। app.dependency_overrides একটি dictionary: আসল dependency-কে key করে নকল function দিলে পুরো app-এ সেটিই চলে। আসল token বানাতে হয় না, আসল ডেটাবেসও লাগে না। নিচে pytest দিয়ে চারটি test, মেমরির SQLite আর নকল ব্যবহারকারী নিয়ে। Test-এর তত্ত্ব আছে Test double পর্বে, আর Python-এ pytest-এর মূল কথা pytest পর্বে।
from fastapi.testclient import TestClient
from main import app, get_current_user, get_db
def as_user(is_admin):
return lambda: User(id=1, email='t@bazarghar.test', password_hash='x', is_admin=is_admin)
def test_buyer_gets_403(client): # client fixture: get_db বদলানো
app.dependency_overrides[get_current_user] = as_user(False)
r = client.post('/products', json={'name': 'Green Tea', 'price': 180, 'category_id': 1})
assert r.status_code == 403
def test_extra_field_rejected(client):
app.dependency_overrides[get_current_user] = as_user(True)
r = client.post('/products', json={'name': 'Ghee', 'price': 650,
'category_id': 1, 'cost_price': 1})
assert r.json()['detail'][0]['type'] == 'extra_forbidden'সত্যিকারের run · pytest 9.2.0, httpx 0.28.1 · path ছোট করা হয়েছে
$ python -m pytest -q test_api.py
.... [100%]
============================== warnings summary ===============================
venv\Lib\site-packages\fastapi\testclient.py:1
~/bazarghar/venv/…/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
4 passed, 1 warning in 0.57s
$ pip install httpx2==2.16.0
$ python -m pytest -q test_api.py
.... [100%]
4 passed in 0.42s
এই warning একটি ভালো শিক্ষা। TestClient আসলে Starlette-এর। লেখার সময়ে FastAPI-র testing পাতা এখনো বলে “TestClient ব্যবহার করতে httpx install করুন”, কিন্তু Starlette 1.10-এর source আগে httpx2 খোঁজে, না পেলে httpx নিয়ে warning দেয়। httpx2 (2.16.0) install করার পরে warning চলে গেল, test একই। Docs আর আসল আচরণ মেলেনি, তাই সিদ্ধান্ত নিন আপনার installed version-এর আচরণ দেখে, আর warning-কে উপেক্ষা না করে পড়ুন। আজ যা warning, পরের version-এ তা error হতে পারে।
৭ · async def, def আর আটকে যাওয়া event loop
FastAPI-তে path operation দুইভাবে লেখা যায়, আর দুটির চলার জায়গা আলাদা। async def চলে event loop-এ, যে একটি thread-এ অনেক request পালা করে চালায়। সাধারণ def FastAPI পাঠায় একটি thread pool-এ, যাতে সে loop আটকাতে না পারে। পর্ব ২৪-এ (Node.js server) Node-এর loop আটকানো দেখেছেন। Python-এ ফাঁদটি আরও সহজে পাতা যায়, কারণ async def লিখলেই কোড async হয় না।
চায়ের দোকানে একজন দোকানি। কেতলিতে পানি চাপিয়ে তিনি অন্য খদ্দেরের বিস্কুট দেন, টাকা নেন, তারপর ফিরে এসে চা বানান। এটি await: অপেক্ষার সময় অন্য কাজ। এবার ধরুন তিনি কেতলির সামনে দাঁড়িয়ে পানি ফোটা পর্যন্ত তাকিয়ে থাকলেন। দোকানে যত খদ্দেরই আসুক, কেউ কিছু পাবে না। এটি async def-এর ভেতরে time.sleep, বা সাধারণ requests.get, বা sync ডেটাবেস driver. আর সাধারণ def মানে দোকানি একজন সহকারীকে কেতলির পাশে দাঁড় করিয়ে নিজে কাউন্টারে থাকলেন।
বাজারঘরের ল্যাবে তিনটি route বানানো হলো, প্রতিটি ১ সেকেন্ডের “ধীর কাজ” করে, আর একটি /ping যা কিছুই করে না।
@app.get('/async-await')
async def async_await():
await asyncio.sleep(1) # অপেক্ষা, কিন্তু loop অন্যদের চালায়
return {'ok': True}
@app.get('/async-blocking')
async def async_blocking():
time.sleep(1) # পুরো event loop থেমে যায়
return {'ok': True}
@app.get('/sync-def')
def sync_def():
time.sleep(1) # সাধারণ def: FastAPI thread pool-এ চালায়
return {'ok': True}একটি client প্রতিটি route-এ একসাথে ৫টি request পাঠাল, আর ০.১ সেকেন্ড পরে একটি /ping.
সত্যিকারের run · uvicorn 0.54.0, একটি process · একটি local কম্পিউটারে, দুবার চালানো, দুবারই একই ছবি
/async-await 5 requests at once -> finished at 1.02 1.02 1.02 1.02 1.02 s; /ping sent at 0.10 s answered at 0.11 s
/async-blocking 5 requests at once -> finished at 1.01 5.02 5.02 5.02 5.02 s; /ping sent at 0.10 s answered at 5.02 s
/sync-def 5 requests at once -> finished at 1.02 1.02 1.02 1.02 1.02 s; /ping sent at 0.10 s answered at 0.11 s
1t=0.00 5 × GET /async-blocking এলো2t=0.10 GET /ping এলো, loop ব্যস্ত3t=1.00 req1 শেষ, req2 time.sleep শুরু4t=1→5 req2…req5 একে একে5t=5.02 বাকি সব উত্তর আর /ping6তুলনা: await বা def
| event loop | |
| উত্তর পেয়েছে | |
| /ping |
- পাঁচটি request একসাথে। Loop প্রথমটির handler চালাল। Handler-এ
time.sleep(1), যাawaitনয়, তাই loop-এর হাতে নিয়ন্ত্রণ ফেরে না। বাকি চারটি অপেক্ষায়। /pingএলো। এর কাজ এক মিলিসেকেন্ডেরও কম। কিন্তু socket থেকে request পড়াও loop-এর কাজ, আর loop এখন ঘুমন্ত handler-এর ভেতরে। তাই/pingপড়াই হয়নি।- প্রথম উত্তর। ১ সেকেন্ডে req1 শেষ, উত্তর গেল 1.01 s-এ। Loop পরের কাজ ধরল, আর সেটি req2-এর
time.sleep: আবার আটকে গেল। - এক এক করে। req2 থেকে req5 পরপর চলল, প্রতিটি ১ সেকেন্ড। একটি একটি thread-এ, তাই মোট ৫ সেকেন্ড। এই run-এ তাদের উত্তর লেখার ধাপও পিছিয়ে গেছে, আরেক run-এ (access log বন্ধ করে) উত্তর এসেছিল 1, 2, 3, 4 আর 5 সেকেন্ডে। কখন কোনটি বেরোবে তা run-ভেদে বদলায়, মোট ৫ সেকেন্ড বদলায় না।
- সবাই একসাথে। 5.02 s-এ বাকি চারটি উত্তর আর
/ping. একটি ১ ms-এর health check ৫ সেকেন্ড আটকে থাকল। Load balancer এ রকম হলে server-কে মৃত ধরে নিতে পারে। - দুটি সঠিক পথ।
await asyncio.sleep(1)-এ loop অপেক্ষার সময় অন্যদের চালায়, সবাই 1.02 s-এ। সাধারণdef-এ প্রতিটি request আলাদা thread-এ ঘুমায়, loop মুক্ত, ফলও 1.02 s আর/ping0.11 s.
async def একটি প্রতিশ্রুতি: “আমি ভেতরে কখনো loop আটকাব না।” ভেতরের সব ধীর কাজ await-যোগ্য হলে (httpx.AsyncClient, async ডেটাবেস driver) async def লিখুন। ভেতরে কোনো sync library থাকলে (requests, sync SQLAlchemy session, ফাইল পড়া, ছবি ছোট করা) সাধারণ def লিখুন। সন্দেহ থাকলে def নিরাপদ পছন্দ।
Thread pool-এরও সীমা আছে
তাহলে কি সব def লিখলেই হলো? Thread pool অসীম নয়। FastAPI সাধারণ def চালায় AnyIO-র thread pool-এ, যার ডিফল্ট সীমা ৪০টি thread. ল্যাবে ৫০টি request একসাথে পাঠিয়ে মাপা হলো।
সত্যিকারের run · প্রতিটি request ১ সেকেন্ডের কাজ · একটি local কম্পিউটারে, দুবার
/sync-def 50 at once: 40 done by 1.5 s, last at 2.05 s
/async-await 50 at once: 50 done by 1.5 s, last at 1.32 s
/sync-def 50 at once: 40 done by 1.5 s, last at 2.04 s
/async-await 50 at once: 50 done by 1.5 s, last at 1.33 s
হিসাবটি সোজা। ৪০টি thread প্রথম সেকেন্ডে ৪০টি request শেষ করল, বাকি ১০টি দ্বিতীয় দফায়: ⌈৫০ ÷ ৪০⌉ = ২ দফা × ১ সেকেন্ড = প্রায় ২ সেকেন্ড। await-এর পথে কোনো thread লাগে না, তাই ৫০টিই প্রথম দফায়, শেষটি ১.৩ সেকেন্ডে (বাড়তি সময়টুকু ৫০টি connection সামলানোর)। এগুলো একটি local কম্পিউটারের মাপ, সাধারণ benchmark নয়। কিন্তু সূত্রটি সাধারণ: সাধারণ def-এ একসাথে চলা blocking কাজের সংখ্যা thread pool-এর আকারে বাঁধা।
Python-এর asyncio-র মূল ধারণা, gather আর TaskGroup, পাবেন asyncio পর্বে। নিচের খেলনা মডেলটি server ছাড়াই একই ঘটনা দেখায়: একটি “heartbeat” task যার প্রতি ০.০৫ সেকেন্ডে টিক দেওয়ার কথা, আর তিনটি handler.
# শেখানোর মডেল: এক event loop, তিনটি request, আর একটি heartbeat
import asyncio, time
async def handler(name, blocking, t0, log):
if blocking:
time.sleep(0.1) # পুরো loop থেমে যায়
else:
await asyncio.sleep(0.1) # অপেক্ষার সময় loop অন্যদের চালায়
log.append(f'{name} done at {time.perf_counter() - t0:.1f} s')
async def heartbeat(t0, ticks):
for _ in range(4):
ticks.append(round(time.perf_counter() - t0, 2))
await asyncio.sleep(0.05)
async def run(blocking):
t0, log, ticks = time.perf_counter(), [], []
await asyncio.gather(heartbeat(t0, ticks),
*(handler(f'req{i}', blocking, t0, log) for i in (1, 2, 3)))
label = 'time.sleep ' if blocking else 'asyncio.sleep'
print(f'{label}: ' + ', '.join(log))
gaps = [b - a for a, b in zip(ticks, ticks[1:])]
print(f'{"":15}heartbeat should tick every 0.05 s, largest gap {max(gaps):.2f} s')
asyncio.run(run(blocking=False))
asyncio.run(run(blocking=True))asyncio.sleep-এ তিনটি request-ই 0.1 s-এ শেষ, আর heartbeat-এর সবচেয়ে বড় ফাঁক ০.০৫ সেকেন্ডের কাছাকাছি (ঘড়ির সামান্য এদিক-ওদিকসহ)। time.sleep-এ request শেষ হলো 0.1, 0.2 আর 0.3 s-এ, একটির পরে একটি, আর heartbeat-এর ফাঁক 0.30 s: পুরো সময় loop কাউকে চালাতে পারেনি। Server-এ heartbeat-এর জায়গায় থাকে অন্য সব ক্রেতার request. লেখার ধরন দুই ক্ষেত্রেই async def, পার্থক্য শুধু ভেতরের একটি লাইনে।
আরও গভীরে: ASGI app আসলে async def app(scope, receive, send), আর uvicorn প্রতিটি connection-এর জন্য একটি asyncio task বানায়। FastAPI সাধারণ def-কে চালায় Starlette-এর run_in_threadpool দিয়ে, যা AnyIO-র to_thread.run_sync-এর ওপর বসানো, আর সীমাটি AnyIO-র ডিফল্ট capacity limiter থেকে আসে (৪০), যা প্রয়োজনে বদলানো যায়। CPU-ভারী কাজে thread-ও পুরো সমাধান নয়, কারণ সাধারণ CPython-এ GIL এক মুহূর্তে একটি thread-কেই Python bytecode চালাতে দেয়। Python 3.13 থেকে GIL-ছাড়া একটি আলাদা build পাওয়া যায়, যা শুরুতে পরীক্ষামূলক ছিল। সাধারণ build-এ CPU-ভারী কাজের পথ এখনো একাধিক process বা আলাদা worker.
৮ · SQLAlchemy 2: model, সম্পর্ক আর query গোনা
FastAPI কোনো ORM সাথে আনে না, এটি Django-র সাথে বড় পার্থক্য। Python জগতে সবচেয়ে প্রচলিত পছন্দ SQLAlchemy (Mike Bayer, ২০০৬ থেকে), যার 2.0 সংস্করণ (২০২৩) type hint-ভিত্তিক নতুন লেখার ধরন এনেছে। লেখার সময়ে এই ল্যাবে SQLAlchemy 2.1.6, ডেটাবেস SQLite. FastAPI-র লেখকের আরেকটি library SQLModel একই class-কে Pydantic model আর SQLAlchemy table দুটিই বানায়। এটি সুবিধাজনক, তবে লেখার সময়ে তার version 0.0.48, আর অংশ ৪-এর কারণে ঢোকা-বেরোনোর model আলাদা রাখার নিয়ম সেখানেও মানতে হয়। এখানে আমরা সরাসরি SQLAlchemy ব্যবহার করেছি। ORM-এর ধারণা পর্ব ৩৮-এ, SQLAlchemy-র বিস্তারিত Python কোর্সের SQLAlchemy পর্বে।
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Category(Base):
__tablename__ = 'categories'
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(80), unique=True)
products: Mapped[list['Product']] = relationship(back_populates='category')
class Product(Base):
__tablename__ = 'products'
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120))
price: Mapped[int]
stock: Mapped[int] = mapped_column(default=0)
cost_price: Mapped[int] = mapped_column(default=0) # শুধু দোকানের জন্য
category_id: Mapped[int] = mapped_column(ForeignKey('categories.id'))
category: Mapped[Category] = relationship(back_populates='products')এখানেও type hint কাজ করছে: Mapped[int] থেকে SQLAlchemy column-এর type বোঝে, আর Mapped[list['Product']] থেকে বোঝে এটি এক-থেকে-অনেক সম্পর্ক (একটি category, অনেক product). এটিই পর্ব ৪১-এর (সম্পর্ক) shop-এর মডেল, Prisma-র বদলে Python-এ। এই মডিউলের অন্য framework গুলোর মতো একই ডেটা দেওয়া হলো: ৩টি category, ৬টি product. তারপর সব product তার category-র নামসহ পড়া হলো, তিনভাবে, আর প্রতিবার SQL গোনা হলো।
সত্যিকারের run · SQLAlchemy 2.1.6, SQLite · প্রতিবার নতুন session · SQL-এর প্রথম ৭০ অক্ষর
$ python seed.py
Seeded 3 categories, 6 products, 2 users
lazy (default): 6 products, 4 queries
SELECT products.id, products.name, products.price, products.stock, pro
SELECT categories.id, categories.name
SELECT categories.id, categories.name
SELECT categories.id, categories.name
selectinload: 6 products, 2 queries
SELECT products.id, products.name, products.price, products.stock, pro
SELECT categories.id, categories.name
joinedload: 6 products, 1 queries
SELECT products.id, products.name, products.price, products.stock, pro
এখানে একটি মজার পার্থক্য আছে। পর্ব ৪২-এ (N+1 সমস্যা) আর এই মডিউলের অন্য ORM-এর run-এ একই ডেটায় lazy পথ দিয়েছিল ৭টি query: ১টি product-এর জন্য, আর প্রতিটি product-এর জন্য ১টি। SQLAlchemy দিল ৪টি: ১ + ৩. কারণ তার identity map. একটি many-to-one সম্পর্কে (product থেকে category) SQLAlchemy আগে দেখে, এই primary key-র category কি এই session-এ আগেই আনা হয়েছে? হলে আর query নেই। তাই ৬টি product-এর জন্য ৩টি আলাদা category-ই আনা হলো। এটি N+1-এর সমাধান নয়, শুধু ভাগ্য: ১০০টি category-তে ১০০টি query হবে।
আসল সমাধান eager loading. selectinload দ্বিতীয় একটি query-তে WHERE id IN (…) দিয়ে সব category একবারে আনে: মোট ২টি, product যতই হোক। joinedload একটি JOIN দিয়ে ১টিতে করে, many-to-one-এ ভালো, কিন্তু one-to-many-তে সারি বহুগুণ হতে পারে। বাজারঘরের /products route তাই selectinload ব্যবহার করে, আর server log-এ নিজেই query গোনে:
@app.get('/products', response_model=list[ProductOut])
def list_products(db: DB, page: Annotated[dict, Depends(pagination)]):
stmt = (select(Product).options(selectinload(Product.category))
.order_by(Product.id).limit(page['limit']).offset(page['offset']))
return db.scalars(stmt).all()সত্যিকারের run · uvicorn log থেকে
list_products: 2 SQL queries
INFO: 127.0.0.1:8387 - "GET /products?limit=2 HTTP/1.1" 200 OK
Lazy load আর response_model-এর লুকানো যোগ: ProductOut-এ category আছে, তাই উত্তর বানানোর সময় Pydantic প্রতিটি product-এর .category পড়ে। selectinload না দিলে এই পড়াগুলো lazy query চালায়, handler-এর বাইরে, যেখানে কেউ খোঁজে না। আর async session-এ lazy load আরও খারাপ: সেখানে সাধারণত error হয়। তাই response model-এ যে সম্পর্ক আছে, query-তে তা আগেই আনুন।
Migration-এর জন্য SQLAlchemy-র সঙ্গী tool হলো Alembic, যা পর্ব ৪০-এর (Migration আর seed) কাজটি করে। এই ল্যাবে শুধু create_all ব্যবহার হয়েছে, যা নতুন table বানায় কিন্তু পুরোনো table বদলায় না, তাই আসল project-এ এটি যথেষ্ট নয়। Async route-এ ডেটাবেস চাইলে SQLAlchemy-র AsyncSession আর async driver (যেমন PostgreSQL-এর জন্য asyncpg) লাগে। সেটি এখানে চালানো হয়নি।
৯ · Login, background কাজ, AI মডেল আর কখন FastAPI
OAuth2 password flow আর JWT
FastAPI-র security tool গুলো OAuth2-এর নামে, কারণ OpenAPI-তে সেভাবে লিখলে /docs-এ “Authorize” বোতাম আসে। বাজারঘরের login হলো OAuth2 password flow: client /token-এ form হিসেবে username আর password পাঠায়, উত্তরে পায় একটি JWT access token, তারপর প্রতিটি request-এ Authorization: Bearer …. JWT-এর গঠন, মেয়াদ, refresh আর browser-এ রাখার প্রশ্ন পর্ব ৪৬-এ (JWT login) আছে, তাই এখানে শুধু FastAPI-র অংশ।
import jwt # PyJWT
from pwdlib import PasswordHash
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
SECRET_KEY = 'example-secret-change-me-32-bytes-long' # আসলে environment থেকে
ALGORITHM = 'HS256'
password_hash = PasswordHash.recommended() # Argon2
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='token')
@app.post('/token')
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()], db: DB):
user = db.scalar(select(User).where(User.email == form.username))
if user is None or not password_hash.verify(form.password, user.password_hash):
raise HTTPException(401, 'Wrong email or password',
headers={'WWW-Authenticate': 'Bearer'})
exp = datetime.now(timezone.utc) + timedelta(minutes=15)
token = jwt.encode({'sub': str(user.id), 'exp': exp}, SECRET_KEY, algorithm=ALGORITHM)
return {'access_token': token, 'token_type': 'bearer'}সত্যিকারের run · PyJWT 2.15.1, pwdlib 0.3.1, python-multipart 0.0.33 · পরীক্ষার account আর পরীক্ষার password · কিছু header বাদ, status পাশে লেখা, token ছোট করা
$ curl -s -i -X POST 127.0.0.1:8769/products -H 'Content-Type: application/json' -d '{"name":"Green Tea","price":180,"category_id":2}'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
{"detail":"Not authenticated"}
$ curl -s -X POST 127.0.0.1:8769/token -d 'username=rina@bazarghar.test&password=wrong'
{"detail":"Wrong email or password"} HTTP 401
$ curl -s -X POST 127.0.0.1:8769/token -d 'username=rina@bazarghar.test&password=example-pass-2'
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwiZXhwIjoxNzkzNjQ3NDQ4fQ.xxuv…","token_type":"bearer"}
$ curl -s -X POST 127.0.0.1:8769/products -H "Authorization: Bearer $RT" ...
{"detail":"Admins only"} HTTP 403
$ curl -s 127.0.0.1:8769/users/me -H "Authorization: Bearer abc.def.ghi"
{"detail":"Invalid token"} HTTP 401
401 আর 403-এর পার্থক্য এখানে স্পষ্ট (পর্ব ৩৩): token নেই বা ভুল মানে “আপনি কে জানি না”, 401. Rina-র token ঠিক কিন্তু তিনি admin নন, মানে “জানি, কিন্তু অনুমতি নেই”, 403. Login-এর form পড়তে python-multipart package লাগে, না থাকলে FastAPI চালুর সময়েই বলে দেয়। এই ধাঁচের password flow নিজের প্রথম-পক্ষের app-এর জন্য, যেখানে আপনার নিজের login পাতা। অন্য কারও app-কে ব্যবহারকারীর password দেওয়ার জন্য OAuth-এর এই grant এখন নিরুৎসাহিত, সেখানে authorization code flow (পর্ব ৪৭)। নিরাপত্তার পূর্ণ তত্ত্ব Session আর token পর্বে।
পুরোনো tutorial থেকে সাবধান: FastAPI-র পুরোনো docs আর অনেক blog JWT-র জন্য python-jose আর password-এর জন্য passlib দেখাত। লেখার সময়ে FastAPI-র security পাতা PyJWT আর pwdlib[argon2] দেখায়। AI প্রায়ই পুরোনোটি লেখে। আর SECRET_KEY কখনো কোডে নয়: এখানকার মানটি ইচ্ছা করেই নকল।
BackgroundTasks বনাম আসল queue
অর্ডারের পরে invoice পাঠানো ক্রেতার অপেক্ষার অংশ হওয়া উচিত নয় (পর্ব ৬২)। FastAPI-তে সবচেয়ে সহজ উপায় BackgroundTasks: উত্তর পাঠানোর পরে একই process-এ একটি function চালানো।
def send_invoice(order_id: int):
time.sleep(1) # ধরে নিন: PDF বানানো, mail পাঠানো
log.info('invoice for order %d sent', order_id)
@app.post('/orders', status_code=202)
def place_order(background: BackgroundTasks, user: CurrentUser):
order_id = 501
background.add_task(send_invoice, order_id)
log.info('order %d accepted, responding now', order_id)
return {'order_id': order_id, 'status': 'accepted'}সত্যিকারের run · client-এর মাপ আর server log
$ curl -s -w '\nHTTP %{http_code} time %{time_total}s\n' -X POST 127.0.0.1:8769/orders -H "Authorization: Bearer $RT"
{"order_id":501,"status":"accepted"}
HTTP 202 time 0.003008s
order 501 accepted, responding now
INFO: 127.0.0.1:8470 - "POST /orders HTTP/1.1" 202 Accepted
invoice for order 501 sent
ক্রেতা ৩ ms-এ উত্তর পেলেন, আর log-এর ক্রম বলে invoice গেছে উত্তরের পরে। কিন্তু এটি পর্ব ৬২-এর fire-and-forget-এর আত্মীয়: কাজটি process-এর মেমরিতে। Deploy বা crash-এ হারাবে, retry নেই, আর কাজটি সেই server-এরই CPU খায়। Log লেখা, ছোট cache গরম করা, এমন হারালে-ক্ষতি-নেই কাজে BackgroundTasks ঠিক আছে। Invoice, টাকা, ক্রেতাকে দেওয়া কথার জন্য আসল job queue লাগে। Python-এ প্রচলিত উদাহরণ Celery, ARQ বা Dramatiq, বা PostgreSQL-ভিত্তিক queue. কোনটি, তা আপনার পরিকাঠামোর ওপর।
AI মডেল serve করা: একটি প্রচলিত ব্যবহার
অনেক দল FastAPI বেছে নেয় একটি সাধারণ কারণে: তাদের machine learning মডেল আগে থেকেই Python-এ (scikit-learn, PyTorch ইত্যাদি)। মডেলের চারপাশে একটি typed HTTP API, input যাচাই Pydantic-এ, আর docs নিজে থেকে, এটি model serving-এর একটি সহজ শুরু। বাজারঘরে যেমন “এই পণ্যের সাথে আর কী কেনা হয়” সুপারিশের মডেল একটি আলাদা FastAPI সেবা হতে পারে, আর মূল Next.js অ্যাপ তাকে HTTP-তে ডাকতে পারে।
কিন্তু অংশ ৭-এর ফাঁদ এখানে সবচেয়ে বড়। মডেল চালানো (inference) সাধারণত CPU-bound বা GPU-র কাজ, await-যোগ্য অপেক্ষা নয়। async def-এর ভেতরে model.predict(...) লিখলে প্রতিটি অনুমানের সময় পুরো server থেমে যায়। সাধারণ পথ হলো: def route (thread pool), বা আলাদা worker process, বা মডেল চালানোর জন্য বিশেষ serving tool, আর বড় মডেলের উত্তর stream করা (পর্ব ৬১)। মডেলটি চালুর সময় একবার মেমরিতে আনুন (FastAPI-র lifespan), প্রতি request-এ নয়। কোন পথ আপনার জন্য, তা মডেলের আকার আর ভিড় মেপে ঠিক করুন।
কখন FastAPI বেছে নেবেন
| পরিস্থিতি | FastAPI | বিকল্প যা ভাবা যায় |
|---|---|---|
| শুধু JSON API, frontend আলাদা (React, মোবাইল) | খুব মানানসই | Express (পর্ব ২৫), Next.js route handler (পর্ব ৫৭) |
| ML, data বা AI-র কোড Python-এ | স্বাভাবিক পছন্দ | Django REST Framework, বা Node থেকে Python সেবা ডাকা |
| Admin panel, template, ব্যাটারি সব চাই | অনেক কিছু নিজে জুড়তে হয় | Django (পর্ব ৬৮), Laravel (পর্ব ৭০) |
| পুরো দল TypeScript-এ, frontend-backend type ভাগ | OpenAPI থেকে type বানাতে হয় | Express বা Next.js-এ tRPC (পর্ব ৩৭) |
| অনেক WebSocket, দীর্ঘ stream | ASGI-র কারণে ভালো | Node.js-ও সমান স্বাভাবিক |
| বড় পুরোনো Java প্রতিষ্ঠান | সম্ভব, কিন্তু ভিন্ন জগৎ | Spring Boot (পর্ব ৭১) |
FastAPI-র নিজের পাতায় গতির বড় দাবি আছে, কিন্তু বাজারঘরের মতো অ্যাপে সময়ের বেশিরভাগ যায় ডেটাবেসে আর network-এ, framework-এর ভেতরে নয়। তাই framework বাছাইয়ে গতির চেয়ে বড় প্রশ্ন: আপনার দল কোন ভাষায় দক্ষ, কোন library লাগবে, আর কত কিছু নিজে জুড়তে রাজি।
পরীক্ষা ও ইন্টারভিউতে:
- WSGI আর ASGI-র পার্থক্য? WSGI sync, এক request এক worker আটকায়; ASGI async,
scope/receive/send, WebSocket আর stream চলে। - FastAPI-তে validation error-এর status? 422,
detail-এ প্রতিটি ভুলেরtype,loc,msg. response_modelকেন? বেরোনোর allowlist: model-এ না থাকা field (password hash, ক্রয়মূল্য) বাদ যায়, আর docs-এ উত্তরের আকৃতি আসে।async def-এtime.sleepদিলে? পুরো event loop থামে, অন্য সব request অপেক্ষা করে।await asyncio.sleepবা সাধারণdef.- Test-এ ডেটাবেস বা লগইন বদলাবেন কীভাবে?
app.dependency_overrides[get_db] = fake.
১০ · AI-এর যুগে: FastAPI কোড যাচাই
FastAPI-র কোড ছোট আর পড়তে সহজ, তাই AI খুব দ্রুত একটি “চলা” API লিখে দেয়। সমস্যা হলো, এই পর্বের প্রায় প্রতিটি ভুল কোনো error দেয় না: password hash ফাঁস হলে status 200, loop আটকালেও উত্তর আসে, শুধু দেরিতে। AI-এর সাধারণ ভুলগুলো:
| AI-এর ভুল | যা ঘটবে | যাচাই |
|---|---|---|
Pydantic v1-এর API: .dict(), orm_mode, @validator | deprecation warning বা ভুল আচরণ | v2-তে model_dump(), from_attributes, field_validator |
python-jose, passlib, বা নিজের বানানো hash | পুরোনো বা দুর্বল পথ | নিজের version-এর FastAPI security docs |
async def-এর ভেতরে requests, time.sleep, sync session | ভিড়ে পুরো server থামে | একসাথে কয়েকটি request আর একটি /ping মেপে দেখা |
response_model নেই, ORM object বা dict সরাসরি | hash, admin flag, ক্রয়মূল্য ফাঁস | উত্তরের সব key তালিকা করে মেলানো, test-এ assert 'password_hash' not in |
| একটি model দিয়েই ঢোকা আর বেরোনো | mass assignment বা ফাঁস | XIn, XOut আলাদা, extra='forbid' |
| Route-এ শুধু login দেখা, মালিকানা বা role নয় | যে কেউ অন্যের ডেটা বদলায় | অন্য ব্যবহারকারীর token দিয়ে 403 আশা করে test |
yield dependency-তে finally নেই | ভুলের পথে connection ফেরত যায় না | 422-এর পথে cleanup চলে কি না দেখা |
SECRET_KEY কোডে, allow_origins=["*"] credential-সহ | token জাল করা, অন্য site থেকে অনুরোধ | environment থেকে পড়া, CORS তালিকা নির্দিষ্ট (পর্ব ৫০) |
Invoice বা টাকার কাজ BackgroundTasks-এ | deploy-এ কাজ হারায় | Process মেরে গুনে দেখা, আসল queue |
AI-কে কাজ দেওয়ার সময় version, নিরাপত্তার শর্ত আর যাচাইয়ের পথ নিজেই বলে দিন। তাহলে উত্তরটি মেলানো সহজ হয়।
AI-এর লেখা FastAPI কোড হাতে পেলে
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১Python-এর web জগৎ কেন WSGI থেকে ASGI-তে সরল, আর এই বদল পুরোনো কোডের জন্য কী মানে রাখে?
২০০৩ সালের আগে Python-এর প্রতিটি web framework নিজের মতো করে server-এর সাথে কথা বলত। একটি framework বেছে নিলে server-ও প্রায় বাঁধা হয়ে যেত। PEP 333 (WSGI) একটি সাধারণ চুক্তি দিল: app একটি callable, যা request-এর তথ্য পায় আর উত্তর ফেরত দেয়। এই সরলতাই WSGI-র সাফল্য। Gunicorn, uWSGI, mod_wsgi যেকোনো WSGI app চালাতে পারল, আর Django, Flask বড় হলো সেই নিশ্চিত ভিত্তির ওপর। ২০১০ সালে PEP 3333 Python 3-এর bytes আর string-এর পার্থক্যের জন্য চুক্তিটি হালনাগাদ করল।
কিন্তু WSGI-র মূল অনুমান ছিল “একটি request, একটি উত্তর, তারপর শেষ”। ২০১০-এর দশকে web বদলাল: WebSocket, দীর্ঘ সময় খোলা connection, server থেকে push, আর অনেক বাইরের API-কে ডেকে অপেক্ষা করা। WSGI-তে প্রতিটি অপেক্ষমাণ connection একটি worker বা thread আটকে রাখে। হাজার connection মানে হাজার thread, যা মেমরি আর সময় দুটিই খায়। Node.js এই জায়গায় সুবিধা পেয়েছিল, কারণ সে শুরু থেকেই event loop-এ।
Python 3.5-এ (২০১৫) async/await আসার পরে Django Channels-এর কাজের মধ্য দিয়ে, Andrew Godwin-এর নেতৃত্বে, ASGI চুক্তি দাঁড়াল। এর মূল কথা: app একটি async callable, যা একটি connection-এর পুরো জীবনে বার্তা নেয় আর পাঠায়। একই চুক্তিতে HTTP, WebSocket আর app চালু-বন্ধের (lifespan) ঘটনা চলে। uvicorn, Hypercorn, Daphne এই চুক্তির server, আর Starlette ও FastAPI তার ওপরের framework.
পুরোনো কোডের জন্য অর্থ মিশ্র। ASGI server-এ sync কোড চালানো যায়, যেমন FastAPI সাধারণ def-কে thread pool-এ পাঠায়, আর Django নিজে ASGI-তে চলতে পারে। কিন্তু async-এর সুবিধা পেতে হলে নিচের সব স্তর, বিশেষ করে ডেটাবেস driver আর HTTP client, async হতে হয়। একটি sync লাইব্রেরি async কোডের ভেতরে ঢুকে পড়লে, এই পর্বে মাপা ৫ সেকেন্ডের মতো, পুরো সুবিধা উল্টে ক্ষতি হয়। তাই Python-এ async দুটি রঙের function-এর জগৎ তৈরি করেছে, যাকে অনেকে “function coloring” সমস্যা বলেন।
মূল উপলব্ধি: ASGI অপেক্ষাকে সস্তা করেছে, কিন্তু সেই সুবিধা তখনই আসে যখন নিচের প্রতিটি স্তর একই চুক্তি মানে। একটি blocking স্তরই পুরো শিকল ধীর করে দেয়।
প্র ০২Type hint তৈরি হয়েছিল static checker-এর জন্য। FastAPI আর Pydantic সেগুলো চলার সময়ে ব্যবহার করে। এতে Python ভাষার নকশায় কী টানাপোড়েন তৈরি হয়েছিল?
PEP 484 (২০১৪, Python 3.5) type hint-এর ভাষা ঠিক করল, আর তার স্পষ্ট লক্ষ্য ছিল mypy-র মতো static checker: কোড চালানোর আগেই ভুল ধরা। Python নিজে চলার সময়ে hint দেখে কিছু করে না, price: int লিখে string দিলেও Python আপত্তি করে না। কিন্তু hint গুলো চলার সময়ে object-এর __annotations__-এ থাকে, আর typing.get_type_hints দিয়ে পড়া যায়, যেমনটি এই পর্বের প্রথম খেলনা মডেল দেখিয়েছে। Pydantic আর FastAPI এই দরজা দিয়েই ঢুকেছে: একই hint থেকে যাচাই, রূপান্তর আর docs.
টানাপোড়েন প্রকাশ্যে আসে PEP 563-কে ঘিরে। এর প্রস্তাব ছিল annotation-কে তৈরির সময়ে হিসাব না করে string হিসেবে রাখা, যাতে import-এর সময় বাঁচে আর আগে-পরে সংজ্ঞায়িত class-এর নাম লেখা সহজ হয়। এটি Python 3.10-এ ডিফল্ট হওয়ার কথা ছিল। কিন্তু runtime-এ hint পড়া library গুলোর জন্য এটি সমস্যা, কারণ string থেকে আসল type ফিরে পেতে সঠিক namespace লাগে, যা সবসময় পাওয়া যায় না। ২০২১ সালে Pydantic আর FastAPI-র রক্ষণাবেক্ষণকারীসহ অনেকে উদ্বেগ জানান, আর Python-এর steering council শেষ মুহূর্তে সিদ্ধান্তটি স্থগিত করে বলে প্রকাশ্যে জানা যায়।
পরে PEP 649 একটি মাঝামাঝি পথ দিল: annotation অলসভাবে, দরকার হলে তবেই হিসাব হবে, কিন্তু string নয়, আসল মান হিসেবে। এটি Python 3.14-এ এসেছে (PEP 749-এর সাথে)। এই পর্বের সব run Python 3.14-এ, আর FastAPI ঠিকভাবেই hint পড়েছে।
এই ইতিহাস একটি বড় কথা বলে। একটি ভাষার বৈশিষ্ট্য যে উদ্দেশ্যে আসে, সমাজ প্রায়ই তাকে অন্য কাজে লাগায়, আর তখন সেই নতুন ব্যবহারও ভাষার নকশার অংশ হয়ে যায়। TypeScript-এ ঠিক উল্টো: type চলার সময়ে মুছে যায় (পর্ব ০৬), তাই Zod-এর মতো আলাদা schema লাগে। Python-এ hint থেকে যায়, তাই schema আর type একই জিনিস হতে পারে।
মূল উপলব্ধি: FastAPI-র মূল কৌশল হলো চলার সময়ে type পড়া। এই সুবিধা Python-এর একটি নকশা-সিদ্ধান্তের ওপর দাঁড়িয়ে, যা একসময় হারিয়ে যেতে বসেছিল, আর এর জন্যই document কোডের সাথে মেলে।
প্র ০৩যাচাইয়ের ভুলে FastAPI 422 দেয়, কিন্তু অনেক framework ডিফল্টে 400 দেয়। 422 এলো কোথা থেকে, আর কোনটি “ঠিক”?
422 কোডের জন্ম HTTP-র মূল নিয়মে নয়, WebDAV-এ। WebDAV হলো HTTP-র ওপর ফাইল সম্পাদনার একটি এক্সটেনশন, যার RFC 4918 (২০০৭) “422 Unprocessable Entity” সংজ্ঞায়িত করে: অনুরোধের syntax ঠিক, content type বোঝা যায়, কিন্তু ভেতরের নির্দেশ প্রক্রিয়া করা যায় না। বহু বছর এটি WebDAV-এর নিজস্ব কোড ছিল, তবু API-র লেখকেরা এটি ধার করতে শুরু করেন, কারণ 400 Bad Request খুব বেশি জিনিস একসাথে বোঝায়।
২০২২ সালে HTTP semantics-এর নতুন বিবরণ RFC 9110 কোডটিকে সাধারণ HTTP-তে নিয়ে আসে, নাম দেয় “422 Unprocessable Content”. পর্ব ৭০ আর ৭১-এর Laravel ও Rails ল্যাবের আসল output-এও এই নতুন নামই ছাপা হয়েছে। অর্থাৎ এখন 422 আর কোনো এক্সটেনশনের কোড নয়।
তবু framework গুলো একমত নয়। FastAPI আর Laravel (JSON request-এ) যাচাইয়ের ভুলে নিজে থেকেই 422 দেয়, ভুলের বিস্তারিতসহ, আর Rails-এর scaffold-এর তৈরি controller-ও 422 ফেরত দেয়। Spring Boot-এর ডিফল্ট @Valid ভুলে 400, ডিফল্টে field-এর বিস্তারিত ছাড়া (পর্ব ৭১-এর ল্যাবে এটি দেখবেন)। Express-এ কোনো ডিফল্ট নেই, আপনি যা লেখেন। পর্ব ৩৩-এর পরামর্শ ছিল: JSON-ই পড়া না গেলে 400, JSON ঠিক কিন্তু নিয়ম ভাঙলে 422.
কোনটি “ঠিক”, তার চেয়ে বড় প্রশ্ন ধারাবাহিকতা। Client-এর কোডকে জানতে হয় কোন status-এ কী আকৃতির body আসবে, আর সেটি পুরো API-তে এক রকম হলে frontend একটি সাধারণ handler লিখতে পারে। FastAPI-তে একটি সূক্ষ্মতা আছে: আপনার নিজের লেখা HTTPException(422, 'Unknown category') দেয় {"detail": "Unknown category"}, একটি string, অথচ Pydantic-এর 422 দেয় detail-এ একটি তালিকা। একই status, দুই আকৃতি। এই পর্বের ল্যাবেই দুটি পাশাপাশি আছে। Frontend দুটিই সামলাতে হবে, অথবা নিজের ভুলও তালিকার আকৃতিতে দিন।
মূল উপলব্ধি: 422 এখন HTTP-র মানসম্মত অংশ, আর যাচাইয়ের ভুলের জন্য বেশ মানানসই। তবে status বাছাইয়ের চেয়ে জরুরি হলো ভুলের body-র আকৃতি পুরো API-তে এক রাখা আর তা docs-এ লেখা।
প্র ০৪Mass assignment আর অতিরিক্ত তথ্য ফাঁস কেন বারবার বাস্তবে ঘটে, আর FastAPI-র তিন-model ধাঁচ কতটা রক্ষা দেয়?
২০১২ সালে Ruby on Rails-এর mass assignment দুর্বলতা নিয়ে একটি পরিচিত ঘটনা প্রকাশ্যে আসে। একজন নিরাপত্তা গবেষক একটি বড় code hosting সাইটে (GitHub) form-এ বাড়তি field পাঠিয়ে এমন অনুমতি পেয়ে যান যা তাঁর পাওয়ার কথা নয়, আর বিষয়টি দেখাতে একটি project-এ নিজের নামে commit করেন বলে জানা যায়। কারণ ছিল: framework form-এর সব field সরাসরি model-এ বসাত। এরপর Rails strong parameters চালু করে, যার আজকের রূপ params.expect পর্ব ৭১-এর Rails ল্যাবে দেখবেন।
উল্টো দিকের সমস্যা, অর্থাৎ বেশি তথ্য পাঠানো, আরও নীরব। OWASP API Security Top 10-এর ২০১৯ সংস্করণে এ দুটি আলাদা ছিল: “Excessive Data Exposure” আর “Mass Assignment”. ২০২৩ সংস্করণে দুটিকে এক করা হয় “Broken Object Property Level Authorization” নামে, কারণ মূল প্রশ্ন একই: একটি object-এর কোন property কে পড়তে বা লিখতে পারবে।
কেন বারবার ঘটে? কারণ সবচেয়ে কম কোডের পথটিই ভুল পথ। ডেটাবেসের model সরাসরি ফেরত দেওয়া বা Product(**await request.json()) লেখা এক লাইনের কাজ। আর ডেটাবেসে নতুন column যোগ হলে, যেমন পরে কেউ cost_price বা is_admin যোগ করলেন, পুরোনো কোড কোনো বদল ছাড়াই সেটি বাইরে পাঠাতে বা ভেতরে নিতে শুরু করে। কেউ কোনো ভুল লেখেননি, তবু ফাঁস হলো।
FastAPI-র ধাঁচ, অর্থাৎ ঢোকার model, বেরোনোর model আর ডেটাবেসের model আলাদা, এই সমস্যার মূলে আঘাত করে: নতুন column নিজে থেকে কোথাও যায় না, কারণ allowlist-এ নেই। extra='forbid' ঢোকার দিকে, response_model বেরোনোর দিকে। কিন্তু সীমাও আছে। Property-স্তরের অনুমতি প্রায়ই ব্যবহারকারীভেদে বদলায়: admin ক্রয়মূল্য দেখবেন, ক্রেতা নয়। তখন একটি ProductOut যথেষ্ট নয়, ভূমিকা অনুযায়ী আলাদা model বা ছাঁকনি লাগে (পর্ব ৪৮)। আর response_model ভুলে বাদ পড়লে কোনো সুরক্ষা নেই, তাই test-এ নিষিদ্ধ key খোঁজা দরকার।
মূল উপলব্ধি: নিরাপদ পথকে সবচেয়ে সহজ পথ বানালে ভুল কমে। তিন-model ধাঁচ সেটি করে, কিন্তু “কে কোন field দেখবে” প্রশ্নের উত্তর আপনাকেই লিখতে হয়।
প্র ০৫“FastAPI দ্রুত” কথাটির মানে কী? একটি API-র গতি আসলে কী দিয়ে ঠিক হয়, আর কখন async কোনো সাহায্যই করে না?
FastAPI-র নামে “Fast” দুই অর্থে: চালানোর গতি আর লেখার গতি। প্রকল্পের নিজের পাতা কিছু তৃতীয় পক্ষের benchmark-এর উল্লেখ করে, যেখানে এটি Python framework গুলোর মধ্যে দ্রুততমদের একটি। কিন্তু এ ধরনের benchmark সাধারণত “hello world” মাপে, যেখানে ডেটাবেস নেই, বাইরের API নেই। সেখানে framework-এর নিজস্ব খরচই প্রধান। বাস্তব অ্যাপে চিত্র আলাদা।
বাজারঘরের একটি সাধারণ request-এর সময় ভাবুন: ডেটাবেসে দুটি query, প্রতিটি কয়েক মিলিসেকেন্ড, হয়তো একটি payment gateway-কে ডাকা, কয়েকশো মিলিসেকেন্ড। Framework-এর routing আর যাচাই এর তুলনায় খুব ছোট। তাই সবচেয়ে বড় লাভ আসে query কমানো (৪ থেকে ২), index, cache (পর্ব ৪৪) আর ধীর কাজ queue-তে পাঠানো থেকে। Framework বদলে নয়।
Async-এর লাভ আসলে throughput-এ, একক request-এর গতিতে নয়। এই পর্বের মাপে await আর def দুটিতেই ৫টি request ১ সেকেন্ডে শেষ হয়েছে। পার্থক্য এসেছে ৫০টিতে: thread pool ৪০-এ আটকে দ্বিতীয় দফা লাগল, async-এ লাগল না। অর্থাৎ async সাহায্য করে যখন অনেক request একসাথে অপেক্ষা করে, I/O-র জন্য। যে কাজ CPU খায়, যেমন বড় JSON বানানো, ছবি প্রক্রিয়া বা মডেলের অনুমান, সেখানে async কিছুই দেয় না, বরং ভুল জায়গায় লিখলে ক্ষতি করে। তখন লাগে একাধিক process (যেমন uvicorn-এর --workers), বা আলাদা worker সেবা।
লেখার গতির দাবিটি বেশি বাস্তব। Type hint থেকে যাচাই, docs আর editor-এর সাহায্য একসাথে পাওয়া, আর Depends দিয়ে test-এ সহজে বদলানো, এগুলো দলের সময় বাঁচায়। তবে এর দামও আছে: ORM, migration, admin, auth-এর ধরন সব নিজে বেছে জুড়তে হয়, যা Django দিয়ে দেয়।
মূল উপলব্ধি: নিজের অ্যাপের সময় কোথায় যায় তা মাপুন। প্রায় সব ক্ষেত্রে উত্তর হবে ডেটাবেস আর network, আর সেখানে কাজ করলে যেকোনো framework-এ লাভ বেশি।
অনুশীলন
হিসাব, command, নকশা বিচার আর ফল আগে থেকে বলা, চার রকম কাজ আছে।
-
Numerical: একটি FastAPI route ভেতরে একটি sync ডেটাবেস call করে, যাতে ০.২ সেকেন্ড লাগে। একটি uvicorn process, thread pool-এর সীমা ৪০। ১০০টি request একসাথে এলো। (ক) route সাধারণ
defহলে শেষ request কখন শেষ হবে? (খ) একই কোডasync def-এর ভেতরে থাকলে? (গ) (খ)-এর সময়ে শুরুতে পাঠানো একটি/pingসবচেয়ে খারাপ ক্ষেত্রে কতক্ষণ অপেক্ষা করবে? (ঘ) SQLAlchemy-তে একটি নতুন session-এ ২০টি product পড়া হলো, যারা ৫টি category-তে ছড়ানো, আর প্রতিটির.category.nameপড়া হলো। lazy,selectinloadআরjoinedload-এ কয়টি query?(ক) ⌈১০০ ÷ ৪০⌉ = ৩ দফা (৪০, ৪০, ২০), প্রতিটি ০.২ সেকেন্ড, তাই প্রায় ০.৬ সেকেন্ড.
(খ) Loop একটির পর একটি চালাবে: ১০০ × ০.২ = ২০ সেকেন্ড.
(গ) প্রায় ২০ সেকেন্ড: loop মুক্ত না হওয়া পর্যন্ত
/pingপড়াই হবে না, যেমন ল্যাবে ৫টি request-এ/ping৫.০২ সেকেন্ড অপেক্ষা করেছিল।(ঘ) Lazy: ১টি product-এর জন্য + ৫টি আলাদা category (identity map একই category আর আনে না) = ৬টি.
selectinload: ২টি.joinedload: ১টি. ২০টি আলাদা category হলে lazy-তে ২১টি হতো, সাধারণ N+1. -
Command: বাজারঘর API চলছে
127.0.0.1:8769-এ। admin-এর emailadmin@bazarghar.test, passwordexample-pass-1. curl দিয়ে token নিন, তারপর token-সহ একটি নতুন product বানান (name “Black Cumin 100g”, price 120, stock 10, category 3). তারপর বলুন: (ক) header ছাড়া একই request, (খ) সাধারণ ক্রেতার token-এ ঠিক body, (গ) সাধারণ ক্রেতার token-এ"price": -5, কোনটিতে কী status?AT=$(curl -s -X POST 127.0.0.1:8769/token \ -d 'username=admin@bazarghar.test&password=example-pass-1' \ | python -c "import sys,json; print(json.load(sys.stdin)['access_token'])") curl -s -X POST 127.0.0.1:8769/products -H "Authorization: Bearer $AT" \ -H 'Content-Type: application/json' \ -d '{"name":"Black Cumin 100g","price":120,"stock":10,"category_id":3}'ল্যাবে এর আসল উত্তর ছিল
{"id":7,"name":"Black Cumin 100g","price":120,"stock":10,"category":{"id":3,"name":"Spices"}}, status 201. Token নিতে form (-d) লাগে, JSON নয়। (ক) 401Not authenticated. (খ) 403Admins only. (গ) 422, 403 নয়: dependency চলে, ক্রেতা চেনা যায়, কিন্তু body-র ভুলে handler চলেই না, আর admin-পরীক্ষা handler-এর ভেতরে। ল্যাবে এটিই দেখা গেছে:greater_than,["body","price"]. -
নকশা বিচার: AI বাজারঘরের জন্য নিচের কোড দিল। অন্তত পাঁচটি সমস্যা ধরুন।
SECRET = "bazarghar123" @app.post("/products") async def create(request: Request): data = await request.json() product = Product(**data) session.add(product); session.commit() # global sync session requests.post(SLACK_URL, json={"text": "new product"}) return product.__dict__ @app.get("/users/{id}") async def user(id: int): return session.get(User, id).__dict__- যাচাই নেই, mass assignment:
request.json()সরাসরিProduct(**data)-এ।ProductIn(extra='forbid') দিন। - ফাঁস:
__dict__ফেরত,response_modelনেই।/users/{id}password hash আরis_adminদেয়, আর SQLAlchemy-র ভেতরের_sa_instance_state-ও থাকতে পারে। - Loop আটকানো:
async def-এর ভেতরে sync session আরrequests.post. সাধারণdefবা async client আর async session. - Global session: সব request একই session ভাগ করে, transaction মিশে যায়।
Depends(get_db). - Auth নেই: যে কেউ product বানায়, যে কেউ যেকোনো ব্যবহারকারী দেখে (পর্ব ৪৮)।
CurrentUserআর মালিকানা বা role যাচাই। - Secret কোডে আর দুর্বল: environment থেকে লম্বা এলোমেলো মান।
- বাড়তি: Slack-এ খবর পাঠানো request-এর পথে, ব্যর্থ হলে product বানানোও ব্যর্থ দেখাবে। পরে বা queue-তে। ব্যবহারকারী না পেলে
None.__dict__-এ 500, 404 নয়। Status 201 নেই।
- যাচাই নেই, mass assignment:
-
ফল আগে বলুন: নিচের app-এ দুটি request যায়: প্রথমটির body
{"name": "Ghee", "price": "650"}, দ্বিতীয়টির{"name": "Ghee", "price": "x"}. কী কী ছাপা হবে, কোন ক্রমে, আর status কী?def get_db(): print('get_db: open session'); yield 'session'; print('get_db: close session') def get_current_user(db: Annotated[str, Depends(get_db)]): print('get_current_user: uses', db); return {'id': 1} class ProductIn(BaseModel): name: str price: int # একটি validator এখানে 'ProductIn: body validated' ছাপায় @app.post('/products', status_code=201) def create(item: ProductIn, db: Annotated[str, Depends(get_db)], user: Annotated[dict, Depends(get_current_user)]): print('handler: create_product'); return {'name': item.name}সত্যিকারের run · FastAPI 0.162.0, TestClient দিয়ে
valid body: get_db: open session get_current_user: uses session ProductIn: body validated handler: create_product get_db: close session -> 201 invalid body: get_db: open session get_current_user: uses session -> 422তিনটি শিক্ষা।
get_dbদুজন চাইলেও একবারই চলে। Dependency আগে, body-র যাচাই পরে। আর ভুল body-তেclose sessionছাপাই হয়নি: ভুলটিyield-এর জায়গায় exception হয়ে ঢোকে, তাই পরের লাইন বাদ যায়। Cleanuptry/finallyবাwith-এ রাখুন।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমFastAPI-তে token-সহ একটি POST /products request-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- uvicorn bytes পড়ে ASGI-র scope বানায়
- Starlette method আর path মিলিয়ে route খোঁজে
- Dependency চলে: token পড়া, session খোলা, ব্যবহারকারী আনা
- Pydantic body যাচাই ও রূপান্তর করে
- Handler চলে আর ORM object ফেরত দেয়
response_modelছেঁকে JSON বানায়, তারপর session বন্ধ হয়
একটি uvicorn process-এ একটি FastAPI route এভাবে লেখা: async def, আর ভেতরে requests.get(...) যা ২ সেকেন্ড নেয়। একসাথে ৩টি request এলো। শেষ request প্রায় কখন উত্তর পাবে?
- প্রায় ২ সেকেন্ডে
- প্রায় ১ সেকেন্ডে
- প্রায় ৬ সেকেন্ডে
- FastAPI error দেবে, কারণ async def-এ sync call নিষেধ
Pydantic v2-এ একটি BaseModel-এ কোনো model_config দেওয়া নেই। Client এমন একটি field পাঠাল যা model-এ নেই। কী হবে?
- 422,
extra_forbidden - Field টি নীরবে বাদ যাবে, request সফল
- Field টি model-এ রেখে দেওয়া হবে
- 500 Internal Server Error
একটি route response_model=UserOut দিয়ে লেখা, যেখানে UserOut-এ শুধু id আর email. Handler ফেরত দিল {"id": 2, "email": "rina@bazarghar.test", "password_hash": "…", "is_admin": False}. Client কী পাবে?
- চারটি key-ই
- 500, কারণ উত্তর model-এর সাথে মেলে না
- 422
- শুধু
{"id": 2, "email": "rina@bazarghar.test"}
একটি route-এ OAuth2PasswordBearer-ভিত্তিক current-user dependency আর একটি Pydantic body আছে। Client কোনো Authorization header ছাড়া, ভুল body (দাম ০) পাঠাল। সাধারণত কোন status আসবে?
- 422
- 403
- 401
- 500
SQLAlchemy 2-এ একটি নতুন session-এ ৬টি product পড়া হলো, যারা ৩টি category-তে ছড়ানো। কোনো loader option নেই (ডিফল্ট lazy), আর প্রতিটি product-এর .category.name পড়া হলো। মোট কয়টি SQL query?
- ৭টি
- ৪টি
- ২টি
- ১টি
একটি FastAPI অ্যাপে অর্ডারের পরে invoice email পাঠাতে হবে, আর deploy বা crash-এও কোনো invoice হারানো চলবে না। কোনটি সবচেয়ে উপযুক্ত?
BackgroundTasks.add_task(send_invoice, …)async defroute-এর ভেতরে sync SMTP দিয়ে পাঠানোasyncio.create_task(send_invoice(...))করে উত্তর দেওয়া- একটি টেকসই job queue-তে job দেওয়া, আলাদা worker process চালাবে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- স্তর চিনুন: uvicorn ASGI server, Starlette routing, FastAPI যাচাই, Depends আর docs, Pydantic model. ASGI-তে একটি process অপেক্ষার সময় অন্য request চালায়।
- Type hint-ই চুক্তি: path, query আর body রূপান্তর ও যাচাই হয়, ভুলে 422 আর
loc-সহ তালিকা। Lax mode-এ “650” থেকে 650, অচেনা field ডিফল্টে বাদ,extra='forbid'-এ 422. - দুই দরজায় দুই model:
XInঢোকার জন্য,response_model=XOutবেরোনোর allowlist. না দিলে আসল run-এ password hash বেরিয়েছে। OpenAPI 3.1 আর/docsএকই কোড থেকে। - Depends: session আর ব্যবহারকারী parameter হয়ে আসে, এক request-এ একবার, body-র আগে। Cleanup
withবাfinally-তে। Test-এdependency_overrides. async defএকটি প্রতিশ্রুতি: ভেতরে blocking call মানে পুরো server থামা (৫ সেকেন্ড,/ping-সহ)। Sync library-তে সাধারণdef(thread pool, ৪০), সম্পর্কেselectinload, আর টেকসই কাজ queue-তে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৭০ · Laravel — Eloquent, Blade আর PHP-র আধুনিক পথপরবর্তী পর্বPHP-র আধুনিক পথ: Eloquent, Blade আর বাংলাদেশের shared hosting.
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ, আর পরের পর্ব ৭০ · Laravel.
- পর্ব ২৭ · Request যাচাইপেছনের পর্বZod দিয়ে schema, coercion আর mass assignment, Express-এর দিক থেকে।
- পর্ব ২৯ · Backend-এর গঠনপেছনের পর্বDependency injection আর নকল repository দিয়ে test.
- পর্ব ৩৫ · Versioning আর OpenAPIপেছনের পর্বOpenAPI document থেকে typed client আর ভাঙন ধরা।
- ওয়েব ব্যাকএন্ড — FastAPI-র শুরুপাশের ট্র্যাকPython কোর্সে FastAPI-র প্রথম অ্যাপ আর CRUD.
- asyncioপাশের ট্র্যাকasync def, await, gather আর event loop আটকানোর বাগ।
- ORM — SQLAlchemy দিয়ে ডেটাবেসপাশের ট্র্যাকSession, relationship আর N+1, Python-এর দিক থেকে।
- API নিরাপত্তাগভীরে যানProperty-স্তরের অনুমতি, mass assignment আর অতিরিক্ত তথ্য ফাঁস।