cc-codeconductor 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +319 -0
- package/dist/index.js +12741 -0
- package/package.json +51 -0
- package/policy.yml +124 -0
- package/presets/claude/CLAUDE.md +598 -0
- package/presets/claude/commands/cc/feature.md +115 -0
- package/presets/claude/commands/cc/fix.md +121 -0
- package/presets/claude/commands/cc/refactor.md +148 -0
- package/presets/claude/commands/cc/review.md +126 -0
- package/presets/claude/commands/cc/tdd-cycle.md +226 -0
- package/presets/claude/commands/cc/test-plan.md +138 -0
- package/presets/claude/settings.json +37 -0
- package/presets/claude/skills/api-versioning/SKILL.md +389 -0
- package/presets/claude/skills/django-orm/SKILL.md +455 -0
- package/presets/claude/skills/django-testing/SKILL.md +409 -0
- package/presets/claude/skills/jpa-postgres/SKILL.md +618 -0
- package/presets/claude/skills/python/SKILL.md +606 -0
- package/presets/claude/skills/python-django-stack/SKILL.md +492 -0
- package/presets/claude/skills/python-fastapi-stack/SKILL.md +457 -0
- package/presets/claude/skills/spring-boot-feature/SKILL.md +558 -0
- package/presets/claude/skills/spring-boot-kotlin/SKILL.md +400 -0
- package/presets/claude/skills/sqlalchemy/SKILL.md +466 -0
- package/presets/claude/skills/testing-strategy/SKILL.md +479 -0
- package/presets/codex/AGENTS.md +883 -0
- package/presets/codex/README.md +102 -0
- package/presets/codex/skills/api-versioning/SKILL.md +389 -0
- package/presets/codex/skills/django-orm/SKILL.md +455 -0
- package/presets/codex/skills/django-testing/SKILL.md +409 -0
- package/presets/codex/skills/jpa-postgres/SKILL.md +618 -0
- package/presets/codex/skills/python/SKILL.md +606 -0
- package/presets/codex/skills/python-django-stack/SKILL.md +492 -0
- package/presets/codex/skills/python-fastapi-stack/SKILL.md +457 -0
- package/presets/codex/skills/spring-boot-feature/SKILL.md +558 -0
- package/presets/codex/skills/spring-boot-kotlin/SKILL.md +400 -0
- package/presets/codex/skills/sqlalchemy/SKILL.md +466 -0
- package/presets/codex/skills/testing-strategy/SKILL.md +479 -0
- package/presets/opencode/README.md +191 -0
- package/presets/opencode/agents/architect.md +133 -0
- package/presets/opencode/agents/docs.md +113 -0
- package/presets/opencode/agents/implementer.md +116 -0
- package/presets/opencode/agents/orchestrator.md +248 -0
- package/presets/opencode/agents/repo-explorer.md +119 -0
- package/presets/opencode/agents/reviewer.md +130 -0
- package/presets/opencode/agents/task-coach.md +103 -0
- package/presets/opencode/agents/tester.md +269 -0
- package/presets/opencode/commands/cc-feature.md +114 -0
- package/presets/opencode/commands/cc-fix.md +123 -0
- package/presets/opencode/commands/cc-refactor.md +148 -0
- package/presets/opencode/commands/cc-review.md +141 -0
- package/presets/opencode/commands/cc-tdd-cycle.md +225 -0
- package/presets/opencode/commands/cc-test-plan.md +144 -0
- package/presets/opencode/opencode.jsonc +89 -0
- package/presets/opencode/prompts/v0.1.0/architect.md +213 -0
- package/presets/opencode/prompts/v0.1.0/docs.md +181 -0
- package/presets/opencode/prompts/v0.1.0/implementer.md +154 -0
- package/presets/opencode/prompts/v0.1.0/orchestrator.md +169 -0
- package/presets/opencode/prompts/v0.1.0/repo-explorer.md +102 -0
- package/presets/opencode/prompts/v0.1.0/reviewer.md +183 -0
- package/presets/opencode/prompts/v0.1.0/task-coach.md +142 -0
- package/presets/opencode/prompts/v0.1.0/tester.md +160 -0
- package/presets/opencode/prompts/v0.2.0/architect.md +219 -0
- package/presets/opencode/prompts/v0.2.0/docs.md +187 -0
- package/presets/opencode/prompts/v0.2.0/implementer.md +160 -0
- package/presets/opencode/prompts/v0.2.0/orchestrator.md +238 -0
- package/presets/opencode/prompts/v0.2.0/repo-explorer.md +108 -0
- package/presets/opencode/prompts/v0.2.0/reviewer.md +190 -0
- package/presets/opencode/prompts/v0.2.0/task-coach.md +153 -0
- package/presets/opencode/prompts/v0.2.0/tester.md +249 -0
- package/presets/opencode/skills/api-versioning/SKILL.md +388 -0
- package/presets/opencode/skills/astro/SKILL.md +313 -0
- package/presets/opencode/skills/code-review/SKILL.md +202 -0
- package/presets/opencode/skills/django-orm/SKILL.md +455 -0
- package/presets/opencode/skills/django-testing/SKILL.md +408 -0
- package/presets/opencode/skills/django-uv/SKILL.md +400 -0
- package/presets/opencode/skills/jpa-postgres/SKILL.md +617 -0
- package/presets/opencode/skills/nextjs-typescript/SKILL.md +385 -0
- package/presets/opencode/skills/python/SKILL.md +605 -0
- package/presets/opencode/skills/python-django-stack/SKILL.md +491 -0
- package/presets/opencode/skills/python-fastapi-stack/SKILL.md +456 -0
- package/presets/opencode/skills/security/SKILL.md +376 -0
- package/presets/opencode/skills/spring-boot-feature/SKILL.md +557 -0
- package/presets/opencode/skills/spring-boot-kotlin/SKILL.md +399 -0
- package/presets/opencode/skills/spring-boot-testing-strategy/SKILL.md +470 -0
- package/presets/opencode/skills/sqlalchemy/SKILL.md +465 -0
- package/presets/opencode/skills/testing-tdd/SKILL.md +586 -0
- package/src/presets/council/council.yml +59 -0
- package/src/presets/manifests/claude.yml +23 -0
- package/src/presets/manifests/codex.yml +12 -0
- package/src/presets/manifests/opencode.yml +18 -0
- package/src/presets/models/claude.yml +37 -0
- package/src/presets/models/codex.yml +38 -0
- package/src/presets/models/opencode.yml +37 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: python-fastapi-stack
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
name: Python FastAPI Stack
|
|
5
|
+
description: >
|
|
6
|
+
FastAPI conventions for REST APIs: routers, Pydantic v2 schemas, dependency
|
|
7
|
+
injection, error handling, pagination, and project structure.
|
|
8
|
+
Trigger: When writing any router, endpoint, schema, or dependency in a FastAPI project.
|
|
9
|
+
|
|
10
|
+
compatibility:
|
|
11
|
+
tools: [claude, codex, opencode]
|
|
12
|
+
stacks:
|
|
13
|
+
languages: [python]
|
|
14
|
+
frameworks: [fastapi, pydantic, uvicorn, httpx]
|
|
15
|
+
|
|
16
|
+
risk:
|
|
17
|
+
level: medium
|
|
18
|
+
can_execute_shell: true
|
|
19
|
+
can_modify_files: true
|
|
20
|
+
requires_network: false
|
|
21
|
+
|
|
22
|
+
inputs:
|
|
23
|
+
- router files (routers/*.py)
|
|
24
|
+
- schema files (schemas/*.py)
|
|
25
|
+
- dependency files (dependencies/*.py)
|
|
26
|
+
- main application file (main.py)
|
|
27
|
+
- existing endpoint or service code
|
|
28
|
+
|
|
29
|
+
outputs:
|
|
30
|
+
- APIRouter implementations with prefix and tags
|
|
31
|
+
- Pydantic v2 request/response schemas
|
|
32
|
+
- Dependency injection functions (Depends)
|
|
33
|
+
- HTTP exception handlers
|
|
34
|
+
- paginated list endpoints
|
|
35
|
+
- lifespan-based startup/shutdown
|
|
36
|
+
- settings via pydantic-settings
|
|
37
|
+
|
|
38
|
+
quality:
|
|
39
|
+
reviewed_by: codeconductor-core
|
|
40
|
+
version: 0.1.0
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## When to Use
|
|
44
|
+
|
|
45
|
+
- Writing any new endpoint, router, or schema
|
|
46
|
+
- Designing the structure of a new FastAPI feature
|
|
47
|
+
- Adding error handling or validation
|
|
48
|
+
- Implementing pagination or filtering
|
|
49
|
+
- Configuring dependencies (DB session, auth, settings)
|
|
50
|
+
|
|
51
|
+
## Project Structure
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
src/
|
|
55
|
+
├── main.py # App factory, lifespan, router inclusion
|
|
56
|
+
├── config.py # Settings via pydantic-settings
|
|
57
|
+
├── dependencies.py # Shared Depends() functions (db, auth)
|
|
58
|
+
├── routers/
|
|
59
|
+
│ ├── __init__.py
|
|
60
|
+
│ ├── products.py
|
|
61
|
+
│ └── orders.py
|
|
62
|
+
├── schemas/
|
|
63
|
+
│ ├── __init__.py
|
|
64
|
+
│ ├── product.py # ProductCreate, ProductRead, ProductUpdate
|
|
65
|
+
│ └── order.py
|
|
66
|
+
├── models/ # SQLAlchemy models (see sqlalchemy skill)
|
|
67
|
+
│ ├── __init__.py
|
|
68
|
+
│ └── product.py
|
|
69
|
+
├── services/ # Business logic — never in routers
|
|
70
|
+
│ └── product.py
|
|
71
|
+
└── tests/
|
|
72
|
+
├── conftest.py
|
|
73
|
+
└── test_products.py
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Rule**: Routers delegate to services. Services contain all business logic.
|
|
77
|
+
Never write business logic directly in an endpoint function.
|
|
78
|
+
|
|
79
|
+
## Application Factory
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# main.py
|
|
83
|
+
from contextlib import asynccontextmanager
|
|
84
|
+
from fastapi import FastAPI
|
|
85
|
+
from src.routers import products, orders
|
|
86
|
+
from src.config import settings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@asynccontextmanager
|
|
90
|
+
async def lifespan(app: FastAPI):
|
|
91
|
+
# startup
|
|
92
|
+
yield
|
|
93
|
+
# shutdown
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def create_app() -> FastAPI:
|
|
97
|
+
app = FastAPI(
|
|
98
|
+
title=settings.app_name,
|
|
99
|
+
version=settings.app_version,
|
|
100
|
+
lifespan=lifespan,
|
|
101
|
+
)
|
|
102
|
+
app.include_router(products.router)
|
|
103
|
+
app.include_router(orders.router)
|
|
104
|
+
return app
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
app = create_app()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Use `lifespan` — never `@app.on_event("startup")` (deprecated).
|
|
111
|
+
|
|
112
|
+
## Settings
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# config.py
|
|
116
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Settings(BaseSettings):
|
|
120
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
app_name: str = "My API"
|
|
123
|
+
app_version: str = "0.1.0"
|
|
124
|
+
database_url: str
|
|
125
|
+
secret_key: str
|
|
126
|
+
debug: bool = False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
settings = Settings()
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Never hardcode secrets. Never import `os.environ` directly in app code — always
|
|
133
|
+
go through `settings`.
|
|
134
|
+
|
|
135
|
+
## Routers
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# routers/products.py
|
|
139
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
140
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
141
|
+
from src.dependencies import get_db
|
|
142
|
+
from src.schemas.product import ProductCreate, ProductRead, ProductListResponse
|
|
143
|
+
from src.services.product import ProductService
|
|
144
|
+
|
|
145
|
+
router = APIRouter(prefix="/products", tags=["products"])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.get("/", response_model=ProductListResponse)
|
|
149
|
+
async def list_products(
|
|
150
|
+
offset: int = 0,
|
|
151
|
+
limit: int = 20,
|
|
152
|
+
db: AsyncSession = Depends(get_db),
|
|
153
|
+
):
|
|
154
|
+
return await ProductService.list(db, offset=offset, limit=limit)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.get("/{product_id}", response_model=ProductRead)
|
|
158
|
+
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
|
|
159
|
+
product = await ProductService.get_or_404(db, product_id)
|
|
160
|
+
return product
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.post("/", response_model=ProductRead, status_code=status.HTTP_201_CREATED)
|
|
164
|
+
async def create_product(payload: ProductCreate, db: AsyncSession = Depends(get_db)):
|
|
165
|
+
return await ProductService.create(db, payload)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.patch("/{product_id}", response_model=ProductRead)
|
|
169
|
+
async def update_product(
|
|
170
|
+
product_id: int,
|
|
171
|
+
payload: ProductUpdate,
|
|
172
|
+
db: AsyncSession = Depends(get_db),
|
|
173
|
+
):
|
|
174
|
+
return await ProductService.update(db, product_id, payload)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
178
|
+
async def delete_product(product_id: int, db: AsyncSession = Depends(get_db)):
|
|
179
|
+
await ProductService.delete(db, product_id)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**HTTP method rules**:
|
|
183
|
+
|
|
184
|
+
- `GET` — read, never mutates
|
|
185
|
+
- `POST` — create → `201`
|
|
186
|
+
- `PATCH` — partial update → `200`
|
|
187
|
+
- `PUT` — full replace → `200`
|
|
188
|
+
- `DELETE` — remove → `204` (no body)
|
|
189
|
+
|
|
190
|
+
## Schemas (Pydantic v2)
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
# schemas/product.py
|
|
194
|
+
from datetime import datetime
|
|
195
|
+
from pydantic import BaseModel, Field, model_validator
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ProductBase(BaseModel):
|
|
199
|
+
name: str = Field(..., min_length=1, max_length=255)
|
|
200
|
+
price: float = Field(..., gt=0)
|
|
201
|
+
stock: int = Field(default=0, ge=0)
|
|
202
|
+
is_active: bool = True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ProductCreate(ProductBase):
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ProductUpdate(BaseModel):
|
|
210
|
+
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
211
|
+
price: float | None = Field(default=None, gt=0)
|
|
212
|
+
stock: int | None = Field(default=None, ge=0)
|
|
213
|
+
is_active: bool | None = None
|
|
214
|
+
|
|
215
|
+
@model_validator(mode="after")
|
|
216
|
+
def at_least_one_field(self) -> "ProductUpdate":
|
|
217
|
+
if all(v is None for v in self.model_dump().values()):
|
|
218
|
+
raise ValueError("at least one field must be provided")
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class ProductRead(ProductBase):
|
|
223
|
+
model_config = {"from_attributes": True}
|
|
224
|
+
|
|
225
|
+
id: int
|
|
226
|
+
created_at: datetime
|
|
227
|
+
updated_at: datetime
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class ProductListResponse(BaseModel):
|
|
231
|
+
results: list[ProductRead]
|
|
232
|
+
total: int
|
|
233
|
+
has_more: bool
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Schema split rule**:
|
|
237
|
+
|
|
238
|
+
- `Base` — shared fields
|
|
239
|
+
- `Create` — input for POST (no id, no timestamps)
|
|
240
|
+
- `Update` — all fields optional (PATCH semantics)
|
|
241
|
+
- `Read` — output with `from_attributes = True` (ORM → schema)
|
|
242
|
+
|
|
243
|
+
Never return ORM model objects directly from endpoints — always go through a
|
|
244
|
+
`Read` schema.
|
|
245
|
+
|
|
246
|
+
## Dependency Injection
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# dependencies.py
|
|
250
|
+
from collections.abc import AsyncGenerator
|
|
251
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
252
|
+
from src.db import async_session_factory
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
256
|
+
async with async_session_factory() as session:
|
|
257
|
+
try:
|
|
258
|
+
yield session
|
|
259
|
+
await session.commit()
|
|
260
|
+
except Exception:
|
|
261
|
+
await session.rollback()
|
|
262
|
+
raise
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Rule**: commit in the dependency, not in the service. Services are
|
|
266
|
+
commit-agnostic. The dependency owns the transaction boundary.
|
|
267
|
+
|
|
268
|
+
## Error Handling
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
# Raise HTTPException in services for expected errors
|
|
272
|
+
from fastapi import HTTPException, status
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class ProductService:
|
|
276
|
+
@staticmethod
|
|
277
|
+
async def get_or_404(db: AsyncSession, product_id: int) -> Product:
|
|
278
|
+
product = await db.get(Product, product_id)
|
|
279
|
+
if product is None:
|
|
280
|
+
raise HTTPException(
|
|
281
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
282
|
+
detail=f"Product {product_id} not found",
|
|
283
|
+
)
|
|
284
|
+
return product
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
# main.py — global exception handlers for unexpected errors
|
|
289
|
+
from fastapi import Request
|
|
290
|
+
from fastapi.responses import JSONResponse
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@app.exception_handler(Exception)
|
|
294
|
+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
295
|
+
return JSONResponse(
|
|
296
|
+
status_code=500,
|
|
297
|
+
content={"detail": "Internal server error"},
|
|
298
|
+
)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**Status code table**:
|
|
302
|
+
|
|
303
|
+
| Code | When |
|
|
304
|
+
| ---- | ------------------------------------------ |
|
|
305
|
+
| 200 | Successful GET, PATCH, PUT |
|
|
306
|
+
| 201 | Successful POST (resource created) |
|
|
307
|
+
| 204 | Successful DELETE (no body) |
|
|
308
|
+
| 400 | Validation error, bad input |
|
|
309
|
+
| 401 | Not authenticated |
|
|
310
|
+
| 403 | Authenticated but not authorized |
|
|
311
|
+
| 404 | Resource not found |
|
|
312
|
+
| 409 | Conflict (duplicate, constraint violation) |
|
|
313
|
+
| 422 | Pydantic validation error (automatic) |
|
|
314
|
+
| 500 | Unhandled server error |
|
|
315
|
+
|
|
316
|
+
Never return `200` for a creation — use `201`. Never return a body for `204`.
|
|
317
|
+
|
|
318
|
+
## Pagination
|
|
319
|
+
|
|
320
|
+
Offset/limit — no page numbers. Default 20, max 100:
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
# schemas
|
|
324
|
+
class ProductListResponse(BaseModel):
|
|
325
|
+
results: list[ProductRead]
|
|
326
|
+
total: int
|
|
327
|
+
has_more: bool
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# service
|
|
331
|
+
class ProductService:
|
|
332
|
+
@staticmethod
|
|
333
|
+
async def list(
|
|
334
|
+
db: AsyncSession,
|
|
335
|
+
offset: int = 0,
|
|
336
|
+
limit: int = 20,
|
|
337
|
+
) -> ProductListResponse:
|
|
338
|
+
limit = min(limit, 100)
|
|
339
|
+
|
|
340
|
+
count_result = await db.execute(select(func.count()).select_from(Product))
|
|
341
|
+
total = count_result.scalar_one()
|
|
342
|
+
|
|
343
|
+
result = await db.execute(
|
|
344
|
+
select(Product).offset(offset).limit(limit).order_by(Product.id)
|
|
345
|
+
)
|
|
346
|
+
items = result.scalars().all()
|
|
347
|
+
|
|
348
|
+
return ProductListResponse(
|
|
349
|
+
results=items,
|
|
350
|
+
total=total,
|
|
351
|
+
has_more=(offset + limit) < total,
|
|
352
|
+
)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Async vs Sync Endpoints
|
|
356
|
+
|
|
357
|
+
| Use async | Use sync |
|
|
358
|
+
| --------------------------------------- | ------------------------- |
|
|
359
|
+
| DB queries (AsyncSession) | CPU-bound transformations |
|
|
360
|
+
| External HTTP calls (httpx.AsyncClient) | Pure in-memory logic |
|
|
361
|
+
| Any `await` inside the handler | No I/O at all |
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
# async — has I/O
|
|
365
|
+
@router.get("/{id}")
|
|
366
|
+
async def get_product(id: int, db: AsyncSession = Depends(get_db)):
|
|
367
|
+
return await ProductService.get_or_404(db, id)
|
|
368
|
+
|
|
369
|
+
# sync — pure computation, no I/O
|
|
370
|
+
@router.get("/health")
|
|
371
|
+
def health_check():
|
|
372
|
+
return {"status": "ok"}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Never mix `async def` with synchronous blocking calls (e.g., `requests.get`).
|
|
376
|
+
Use `httpx.AsyncClient` for external HTTP.
|
|
377
|
+
|
|
378
|
+
## Naming Conventions
|
|
379
|
+
|
|
380
|
+
| Concern | Convention | Example |
|
|
381
|
+
| -------------------- | -------------------------- | ------------------------------- |
|
|
382
|
+
| Router files | `snake_case.py` | `product_variants.py` |
|
|
383
|
+
| Schema classes | `PascalCase` + suffix | `ProductCreate`, `ProductRead` |
|
|
384
|
+
| Endpoint functions | `verb_resource` | `list_products`, `create_order` |
|
|
385
|
+
| Service methods | `@staticmethod`, verb_noun | `get_or_404`, `create`, `list` |
|
|
386
|
+
| Dependency functions | `get_*` | `get_db`, `get_current_user` |
|
|
387
|
+
| Router prefix | lowercase, plural, kebab | `/product-variants` |
|
|
388
|
+
| Tags | lowercase, plural | `["product-variants"]` |
|
|
389
|
+
|
|
390
|
+
## Service Layer
|
|
391
|
+
|
|
392
|
+
Business logic lives in `services/`. Services are classes with `@staticmethod`
|
|
393
|
+
only:
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
# services/product.py
|
|
397
|
+
from sqlalchemy import select, func
|
|
398
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
399
|
+
from fastapi import HTTPException, status
|
|
400
|
+
from src.models.product import Product
|
|
401
|
+
from src.schemas.product import ProductCreate, ProductUpdate, ProductListResponse
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class ProductService:
|
|
405
|
+
@staticmethod
|
|
406
|
+
async def get_or_404(db: AsyncSession, product_id: int) -> Product:
|
|
407
|
+
product = await db.get(Product, product_id)
|
|
408
|
+
if product is None:
|
|
409
|
+
raise HTTPException(status_code=404, detail=f"Product {product_id} not found")
|
|
410
|
+
return product
|
|
411
|
+
|
|
412
|
+
@staticmethod
|
|
413
|
+
async def create(db: AsyncSession, payload: ProductCreate) -> Product:
|
|
414
|
+
product = Product(**payload.model_dump())
|
|
415
|
+
db.add(product)
|
|
416
|
+
await db.flush() # get the ID without committing
|
|
417
|
+
await db.refresh(product)
|
|
418
|
+
return product
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
async def update(db: AsyncSession, product_id: int, payload: ProductUpdate) -> Product:
|
|
422
|
+
product = await ProductService.get_or_404(db, product_id)
|
|
423
|
+
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
424
|
+
setattr(product, field, value)
|
|
425
|
+
await db.flush()
|
|
426
|
+
await db.refresh(product)
|
|
427
|
+
return product
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
async def delete(db: AsyncSession, product_id: int) -> None:
|
|
431
|
+
product = await ProductService.get_or_404(db, product_id)
|
|
432
|
+
await db.delete(product)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Rules**:
|
|
436
|
+
|
|
437
|
+
- `db.flush()` to get the ID without committing — commit is in `get_db`
|
|
438
|
+
- `db.refresh(product)` to reload relationships after flush
|
|
439
|
+
- `payload.model_dump(exclude_unset=True)` for PATCH — only update provided
|
|
440
|
+
fields
|
|
441
|
+
|
|
442
|
+
## Commands
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
uvicorn src.main:app --reload # dev server
|
|
446
|
+
uvicorn src.main:app --host 0.0.0.0 # production-like
|
|
447
|
+
pytest tests/ -v # test suite
|
|
448
|
+
ruff check . # lint
|
|
449
|
+
ruff format . # format
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
## Resources
|
|
453
|
+
|
|
454
|
+
- **FastAPI docs**: <https://fastapi.tiangolo.com/>
|
|
455
|
+
- **Pydantic v2 docs**: <https://docs.pydantic.dev/latest/>
|
|
456
|
+
- **pydantic-settings**:
|
|
457
|
+
<https://docs.pydantic.dev/latest/concepts/pydantic_settings/>
|