mirror of
https://github.com/duthaho/claudekit.git
synced 2026-06-10 12:14:57 +03:00
feat: adding new skills, including testing patterns and methodologies, along with bundled resources for better usability.
This commit is contained in:
+30
-8
@@ -335,16 +335,34 @@ For strict TDD enforcement (no production code without failing test):
|
||||
Enable mandatory verification before completion claims:
|
||||
- Reference: `.claude/skills/methodology/verification-before-completion/SKILL.md`
|
||||
|
||||
### Available Methodology Skills
|
||||
### Available Skills
|
||||
|
||||
| Category | Skills |
|
||||
|----------|--------|
|
||||
| Planning | brainstorming, writing-plans, executing-plans |
|
||||
| Testing | test-driven-development, verification-before-completion, testing-anti-patterns |
|
||||
| Debugging | systematic-debugging, root-cause-tracing, defense-in-depth |
|
||||
| Collaboration | dispatching-parallel-agents, requesting-code-review, receiving-code-review, finishing-development-branch |
|
||||
| **Languages** | python, typescript, javascript |
|
||||
| **Frameworks** | fastapi, django, nextjs, react |
|
||||
| **Databases** | postgresql, mongodb |
|
||||
| **DevOps** | docker, github-actions |
|
||||
| **Frontend** | tailwind, shadcn-ui |
|
||||
| **Security** | owasp |
|
||||
| **API** | openapi |
|
||||
| **Testing** | pytest, vitest |
|
||||
| **Optimization** | token-efficient |
|
||||
| **Developer Patterns** | error-handling, state-management, logging, caching, api-client, authentication |
|
||||
| **Methodology - Planning** | brainstorming, writing-plans, executing-plans |
|
||||
| **Methodology - Testing** | test-driven-development, verification-before-completion, testing-anti-patterns |
|
||||
| **Methodology - Debugging** | systematic-debugging, root-cause-tracing, defense-in-depth |
|
||||
| **Methodology - Collaboration** | dispatching-parallel-agents, requesting-code-review, receiving-code-review, finishing-development-branch |
|
||||
| **Methodology - Reasoning** | sequential-thinking |
|
||||
|
||||
Skills location: `.claude/skills/methodology/`
|
||||
Skills location: `.claude/skills/`
|
||||
|
||||
Each skill includes:
|
||||
- YAML frontmatter with trigger description
|
||||
- "When to Use" / "When NOT to Use" sections
|
||||
- Core patterns with code examples
|
||||
- Best practices and common pitfalls
|
||||
- Bundled reference docs, templates, and scripts
|
||||
|
||||
### Sequential Thinking
|
||||
|
||||
@@ -436,6 +454,10 @@ pnpm install
|
||||
|
||||
## Kit Version
|
||||
|
||||
- **Claude Kit Version**: 2.0.0
|
||||
- **Last Updated**: 2025-01-29
|
||||
- **Claude Kit Version**: 3.0.0
|
||||
- **Last Updated**: 2026-03-30
|
||||
- **Compatible with**: Claude Code 1.0+
|
||||
- **Total Skills**: 38 (with YAML frontmatter, bundled resources)
|
||||
- **Total Commands**: 27+
|
||||
- **Total Agents**: 20
|
||||
- **Behavioral Modes**: 7
|
||||
|
||||
@@ -1,92 +1,837 @@
|
||||
# OpenAPI
|
||||
---
|
||||
name: openapi
|
||||
description: >
|
||||
Use this skill when designing, documenting, or generating REST API specifications using OpenAPI/Swagger. Trigger on keywords like OpenAPI, Swagger, API spec, REST documentation, API schema, request body, response schema, and API client generation. Also apply when adopting design-first API development, validating API contracts, or setting up auto-generated API documentation for FastAPI, Express, or NestJS endpoints.
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
OpenAPI/Swagger specification patterns for REST API documentation.
|
||||
# OpenAPI & REST API Design
|
||||
|
||||
## When to Use
|
||||
|
||||
- Documenting REST APIs
|
||||
- Generating API clients
|
||||
- API design-first development
|
||||
- Defining webhook contracts
|
||||
- Establishing pagination, versioning, or auth patterns for a new service
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Internal-only scripts or automation that do not expose HTTP endpoints
|
||||
- CLI tools and command-line utilities without a REST interface
|
||||
- GraphQL APIs where a different specification format applies
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Basic Specification
|
||||
### 1. OpenAPI 3.1 Specification Structure
|
||||
|
||||
A complete spec skeleton showing every top-level section. Use `$ref` to split
|
||||
large specs into per-resource files.
|
||||
|
||||
```yaml
|
||||
openapi: 3.0.3
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: My API
|
||||
version: 1.0.0
|
||||
title: Acme API
|
||||
version: 2.0.0
|
||||
description: Public API for the Acme platform.
|
||||
contact:
|
||||
name: API Support
|
||||
email: api@acme.dev
|
||||
license:
|
||||
name: MIT
|
||||
url: https://opensource.org/licenses/MIT
|
||||
|
||||
servers:
|
||||
- url: https://api.acme.dev/v2
|
||||
description: Production
|
||||
- url: https://staging-api.acme.dev/v2
|
||||
description: Staging
|
||||
|
||||
tags:
|
||||
- name: Users
|
||||
description: User management operations
|
||||
- name: Orders
|
||||
description: Order lifecycle operations
|
||||
|
||||
paths:
|
||||
/users:
|
||||
$ref: './paths/users.yaml'
|
||||
/users/{userId}:
|
||||
$ref: './paths/users-by-id.yaml'
|
||||
/orders:
|
||||
$ref: './paths/orders.yaml'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
$ref: './components/schemas/_index.yaml'
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
|
||||
webhooks:
|
||||
orderCompleted:
|
||||
$ref: './webhooks/order-completed.yaml'
|
||||
```
|
||||
|
||||
**Organizing with `$ref`** -- keep one file per resource under `paths/` and
|
||||
shared schemas under `components/schemas/`. A bundler such as
|
||||
`@redocly/cli bundle` resolves references into a single file for tooling.
|
||||
|
||||
```
|
||||
spec/
|
||||
├── openapi.yaml # Root document
|
||||
├── paths/
|
||||
│ ├── users.yaml
|
||||
│ ├── users-by-id.yaml
|
||||
│ └── orders.yaml
|
||||
├── components/
|
||||
│ └── schemas/
|
||||
│ ├── _index.yaml
|
||||
│ ├── User.yaml
|
||||
│ ├── Order.yaml
|
||||
│ └── ProblemDetail.yaml
|
||||
└── webhooks/
|
||||
└── order-completed.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Path & Operation Patterns
|
||||
|
||||
#### RESTful URL Naming Conventions
|
||||
|
||||
- Use **plural nouns** for collections: `/users`, `/orders`.
|
||||
- Use **path parameters** for single-resource access: `/users/{userId}`.
|
||||
- Nest only one level deep: `/users/{userId}/orders` (not deeper).
|
||||
- Use **query parameters** for filtering, sorting, and pagination.
|
||||
- Avoid verbs in paths -- let HTTP methods convey the action.
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
operationId: listUsers
|
||||
tags: [Users]
|
||||
summary: List users
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/PageCursor'
|
||||
- $ref: '#/components/parameters/PageSize'
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [active, inactive]
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated list of users
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserListResponse'
|
||||
|
||||
post:
|
||||
operationId: createUser
|
||||
tags: [Users]
|
||||
summary: Create a user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUserRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: User created
|
||||
headers:
|
||||
Location:
|
||||
schema:
|
||||
type: string
|
||||
description: URL of the new resource
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
|
||||
/users/{userId}:
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
|
||||
get:
|
||||
operationId: getUser
|
||||
tags: [Users]
|
||||
summary: Get a single user
|
||||
responses:
|
||||
'200':
|
||||
description: User found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
patch:
|
||||
operationId: updateUser
|
||||
tags: [Users]
|
||||
summary: Partially update a user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateUserRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: User updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'422':
|
||||
$ref: '#/components/responses/ValidationError'
|
||||
|
||||
delete:
|
||||
operationId: deleteUser
|
||||
tags: [Users]
|
||||
summary: Delete a user
|
||||
responses:
|
||||
'204':
|
||||
description: User deleted
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
```
|
||||
|
||||
#### Path Parameters vs Query Parameters
|
||||
|
||||
| Use case | Mechanism | Example |
|
||||
|----------|-----------|---------|
|
||||
| Identify a specific resource | Path parameter | `/orders/{orderId}` |
|
||||
| Filter a collection | Query parameter | `/orders?status=shipped` |
|
||||
| Sort a collection | Query parameter | `/orders?sort=-createdAt` |
|
||||
| Paginate | Query parameter | `/orders?cursor=abc&limit=20` |
|
||||
| Expand nested data | Query parameter | `/orders?expand=items,customer` |
|
||||
|
||||
---
|
||||
|
||||
### 3. Request Body Patterns
|
||||
|
||||
#### JSON Request Body with Validation
|
||||
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
CreateUserRequest:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
- name
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
maxLength: 254
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 100
|
||||
role:
|
||||
type: string
|
||||
enum: [admin, member, viewer]
|
||||
default: member
|
||||
additionalProperties: false
|
||||
```
|
||||
|
||||
Implementation in **FastAPI** (Python):
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: EmailStr
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
role: str = Field(default="member", pattern="^(admin|member|viewer)$")
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
```
|
||||
|
||||
Implementation in **Express** (TypeScript with Zod):
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
const CreateUserRequest = z.object({
|
||||
email: z.string().email().max(254),
|
||||
name: z.string().min(1).max(100),
|
||||
role: z.enum(["admin", "member", "viewer"]).default("member"),
|
||||
}).strict();
|
||||
|
||||
type CreateUserRequest = z.infer<typeof CreateUserRequest>;
|
||||
```
|
||||
|
||||
#### Multipart Form Data (File Uploads)
|
||||
|
||||
```yaml
|
||||
/users/{userId}/avatar:
|
||||
put:
|
||||
operationId: uploadAvatar
|
||||
tags: [Users]
|
||||
summary: Upload user avatar
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- file
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file (JPEG or PNG, max 5 MB)
|
||||
caption:
|
||||
type: string
|
||||
maxLength: 200
|
||||
encoding:
|
||||
file:
|
||||
contentType: image/jpeg, image/png
|
||||
responses:
|
||||
'200':
|
||||
description: Avatar updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
'413':
|
||||
$ref: '#/components/responses/PayloadTooLarge'
|
||||
```
|
||||
|
||||
#### Content Negotiation
|
||||
|
||||
Support multiple response formats by listing them under `content`:
|
||||
|
||||
```yaml
|
||||
responses:
|
||||
'200':
|
||||
description: Export data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExportData'
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
```
|
||||
|
||||
Clients select a format with the `Accept` header. Document which formats your
|
||||
API actually supports so consumers do not have to guess.
|
||||
|
||||
---
|
||||
|
||||
### 4. Response Patterns
|
||||
|
||||
#### Success Responses
|
||||
|
||||
| Code | Meaning | Typical use |
|
||||
|------|---------|-------------|
|
||||
| `200` | OK | GET, PATCH, general success |
|
||||
| `201` | Created | POST that creates a resource |
|
||||
| `202` | Accepted | Async operation started |
|
||||
| `204` | No Content | DELETE, or PUT with no body returned |
|
||||
|
||||
Always return a `Location` header with `201` pointing to the new resource.
|
||||
|
||||
#### Error Responses -- RFC 7807 Problem Details
|
||||
|
||||
Define a single reusable error schema based on RFC 7807:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
ProblemDetail:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- title
|
||||
- status
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
format: uri
|
||||
description: URI reference identifying the problem type.
|
||||
example: https://api.acme.dev/problems/validation-error
|
||||
title:
|
||||
type: string
|
||||
description: Short human-readable summary.
|
||||
example: Validation Error
|
||||
status:
|
||||
type: integer
|
||||
description: HTTP status code.
|
||||
example: 422
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence.
|
||||
example: "Field 'email' must be a valid email address."
|
||||
instance:
|
||||
type: string
|
||||
format: uri
|
||||
description: URI identifying this specific occurrence.
|
||||
errors:
|
||||
type: array
|
||||
description: Field-level validation errors (optional extension).
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
example: email
|
||||
message:
|
||||
type: string
|
||||
example: Must be a valid email address.
|
||||
code:
|
||||
type: string
|
||||
example: invalid_format
|
||||
|
||||
responses:
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetail'
|
||||
example:
|
||||
type: https://api.acme.dev/problems/not-found
|
||||
title: Not Found
|
||||
status: 404
|
||||
detail: User with ID '550e8400' was not found.
|
||||
|
||||
ValidationError:
|
||||
description: Request validation failed
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetail'
|
||||
example:
|
||||
type: https://api.acme.dev/problems/validation-error
|
||||
title: Validation Error
|
||||
status: 422
|
||||
errors:
|
||||
- field: email
|
||||
message: Must be a valid email address.
|
||||
code: invalid_format
|
||||
|
||||
Conflict:
|
||||
description: Resource conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetail'
|
||||
|
||||
PayloadTooLarge:
|
||||
description: Request payload exceeds limit
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetail'
|
||||
```
|
||||
|
||||
Use the `application/problem+json` media type for all error responses to signal
|
||||
RFC 7807 compliance.
|
||||
|
||||
---
|
||||
|
||||
### 5. Authentication Schemes
|
||||
|
||||
#### Bearer Token (JWT)
|
||||
|
||||
```yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
```
|
||||
|
||||
Override per-operation to allow unauthenticated access:
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
security: [] # No auth required
|
||||
responses:
|
||||
'200':
|
||||
description: Healthy
|
||||
```
|
||||
|
||||
#### API Key
|
||||
|
||||
```yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
ApiKeyHeader:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
ApiKeyQuery:
|
||||
type: apiKey
|
||||
in: query
|
||||
name: api_key
|
||||
```
|
||||
|
||||
#### OAuth2 Flows
|
||||
|
||||
```yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuth2:
|
||||
type: oauth2
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: https://auth.acme.dev/authorize
|
||||
tokenUrl: https://auth.acme.dev/token
|
||||
refreshUrl: https://auth.acme.dev/token
|
||||
scopes:
|
||||
users:read: Read user profiles
|
||||
users:write: Create and update users
|
||||
orders:read: Read orders
|
||||
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
security:
|
||||
- OAuth2: [users:read]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Pagination Patterns
|
||||
|
||||
#### Cursor-Based Pagination (Recommended)
|
||||
|
||||
Best for large, real-time datasets where rows may be inserted or deleted
|
||||
between pages.
|
||||
|
||||
```yaml
|
||||
components:
|
||||
parameters:
|
||||
PageCursor:
|
||||
name: cursor
|
||||
in: query
|
||||
description: Opaque cursor returned by a previous response.
|
||||
schema:
|
||||
type: string
|
||||
PageSize:
|
||||
name: limit
|
||||
in: query
|
||||
description: Maximum items per page.
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
|
||||
schemas:
|
||||
UserListResponse:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
- pagination
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/User'
|
||||
pagination:
|
||||
type: object
|
||||
required:
|
||||
- hasMore
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
nullable: true
|
||||
hasMore:
|
||||
type: boolean
|
||||
```
|
||||
|
||||
#### Offset-Based Pagination
|
||||
|
||||
Simpler but less efficient for large tables and susceptible to drift when data
|
||||
changes between requests.
|
||||
|
||||
```yaml
|
||||
components:
|
||||
parameters:
|
||||
PageOffset:
|
||||
name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
PageLimit:
|
||||
name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
|
||||
schemas:
|
||||
PaginatedResponse:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
- total
|
||||
- offset
|
||||
- limit
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: {}
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of matching records.
|
||||
offset:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
```
|
||||
|
||||
#### Response Envelope Pattern
|
||||
|
||||
Wrap every collection in a consistent envelope so clients always know where to
|
||||
find the data and metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [ ... ],
|
||||
"pagination": { "nextCursor": "abc123", "hasMore": true },
|
||||
"meta": { "requestId": "req_xyz", "timestamp": "2026-03-29T12:00:00Z" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. API Versioning
|
||||
|
||||
#### URL Versioning
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
- url: https://api.acme.dev/v1
|
||||
description: Version 1 (deprecated)
|
||||
- url: https://api.acme.dev/v2
|
||||
description: Version 2 (current)
|
||||
```
|
||||
|
||||
Pros: explicit, easy to route, cache-friendly.
|
||||
Cons: duplicates paths across versions, harder to share schemas.
|
||||
|
||||
#### Header Versioning
|
||||
|
||||
```yaml
|
||||
parameters:
|
||||
- name: X-API-Version
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: ['2024-01-15', '2025-06-01']
|
||||
default: '2025-06-01'
|
||||
description: Date-based API version. Defaults to latest stable.
|
||||
```
|
||||
|
||||
Pros: clean URLs, fine-grained control.
|
||||
Cons: less discoverable, harder to test in a browser.
|
||||
|
||||
#### Trade-offs Summary
|
||||
|
||||
| Approach | Discoverability | URL cleanliness | Caching | Migration effort |
|
||||
|----------|----------------|-----------------|---------|-----------------|
|
||||
| URL path | High | Lower | Easy | Higher (path changes) |
|
||||
| Header | Lower | High | Needs Vary header | Lower |
|
||||
| Query param | Medium | Medium | Easy | Lower |
|
||||
|
||||
Pick one approach and use it consistently. URL versioning is the most common
|
||||
choice for public APIs; header versioning suits internal services.
|
||||
|
||||
---
|
||||
|
||||
### 8. Webhook Specifications
|
||||
|
||||
OpenAPI 3.1 supports a top-level `webhooks` key for documenting outbound
|
||||
event payloads your API will send to consumer-registered URLs.
|
||||
|
||||
```yaml
|
||||
webhooks:
|
||||
orderCompleted:
|
||||
post:
|
||||
operationId: onOrderCompleted
|
||||
summary: Fired when an order reaches "completed" status.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrderCompletedEvent'
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/User'
|
||||
description: Webhook received successfully.
|
||||
|
||||
components:
|
||||
schemas:
|
||||
User:
|
||||
WebhookEventBase:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
- createdAt
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
email:
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
format: email
|
||||
required:
|
||||
- id
|
||||
- email
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
OrderCompletedEvent:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/WebhookEventBase'
|
||||
- type: object
|
||||
required:
|
||||
- data
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
const: order.completed
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
orderId:
|
||||
type: string
|
||||
format: uuid
|
||||
total:
|
||||
type: number
|
||||
format: double
|
||||
currency:
|
||||
type: string
|
||||
example: USD
|
||||
```
|
||||
|
||||
### Request Body
|
||||
Document a shared `WebhookEventBase` so all event payloads have a consistent
|
||||
envelope with `id`, `type`, and `createdAt`.
|
||||
|
||||
```yaml
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUser'
|
||||
example:
|
||||
email: user@example.com
|
||||
name: John
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
```yaml
|
||||
responses:
|
||||
'400':
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
```
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use $ref for reusable schemas
|
||||
2. Include examples
|
||||
3. Document all error responses
|
||||
4. Use proper HTTP status codes
|
||||
5. Add security schemes
|
||||
1. **Use consistent, plural resource names.** `/users`, `/orders`, `/invoices`
|
||||
-- never mix singular and plural within the same API.
|
||||
|
||||
2. **Make mutating operations idempotent.** Accept an `Idempotency-Key` header
|
||||
on POST endpoints so clients can safely retry without creating duplicates.
|
||||
|
||||
3. **Return rate-limit headers on every response.** Include `X-RateLimit-Limit`,
|
||||
`X-RateLimit-Remaining`, and `X-RateLimit-Reset` so clients can self-throttle.
|
||||
|
||||
4. **Provide `operationId` for every operation.** Code generators use this as
|
||||
the method name; without it, generated clients have meaningless names.
|
||||
|
||||
5. **Include realistic examples in the spec.** Examples power documentation UIs,
|
||||
mock servers, and contract tests. Add them at both the schema and operation
|
||||
level.
|
||||
|
||||
6. **Use `additionalProperties: false` on request schemas.** This catches typos
|
||||
in client payloads early and prevents silently ignored fields.
|
||||
|
||||
7. **Document hypermedia links (HATEOAS basics).** Even a minimal `_links`
|
||||
object with `self` and `next` URIs helps clients navigate without hardcoding
|
||||
paths.
|
||||
|
||||
8. **Version your spec file alongside code.** Store the OpenAPI document in the
|
||||
same repository as the implementation. Run a CI check (e.g., `redocly lint`)
|
||||
to validate the spec on every pull request.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Missing examples**: Add realistic examples
|
||||
- **No error docs**: Document all errors
|
||||
- **Inconsistent naming**: Use consistent conventions
|
||||
1. **Missing error documentation.** Every operation should list its possible
|
||||
`4xx` and `5xx` responses. Consumers cannot handle errors they do not know
|
||||
about. At minimum document `400`, `401`, `403`, `404`, and `500`.
|
||||
|
||||
2. **Overusing `200 OK` for everything.** Return `201` for resource creation,
|
||||
`204` for deletion, and `202` for asynchronous actions. Correct status codes
|
||||
let generic HTTP clients behave properly (e.g., following `Location` headers).
|
||||
|
||||
3. **Deeply nested resource URLs.** `/users/{uid}/orders/{oid}/items/{iid}/notes`
|
||||
is fragile and hard to cache. Flatten to `/order-items/{iid}/notes` once the
|
||||
relationship is established.
|
||||
|
||||
4. **Inconsistent naming conventions.** Mixing `camelCase` and `snake_case`
|
||||
within the same API confuses consumers. Pick one JSON field casing and enforce
|
||||
it with a linter rule.
|
||||
|
||||
5. **Ignoring `nullable` vs optional.** In OpenAPI 3.1, `nullable` is gone;
|
||||
use `type: ["string", "null"]` instead. A field that is not in `required`
|
||||
may be absent, but that is different from being explicitly `null`. Be precise
|
||||
about which you intend.
|
||||
|
||||
6. **No pagination on list endpoints.** Returning unbounded arrays will
|
||||
eventually cause timeouts or OOM errors. Every collection endpoint should
|
||||
accept `limit` and either `cursor` or `offset` from day one, even if the
|
||||
dataset is currently small.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/api-client` - Patterns for consuming and generating API clients from specs
|
||||
- `patterns/error-handling` - Consistent error response structures and handling
|
||||
- `frameworks/fastapi` - FastAPI framework with built-in OpenAPI generation
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# HTTP Status Codes for REST APIs
|
||||
|
||||
Quick reference for selecting the correct HTTP status code in REST API responses.
|
||||
|
||||
---
|
||||
|
||||
## 2xx Success
|
||||
|
||||
| Code | Name | When to Use |
|
||||
|------|------|-------------|
|
||||
| `200` | OK | General success. GET returns data, PUT/PATCH returns updated resource. |
|
||||
| `201` | Created | POST successfully created a resource. Include `Location` header. |
|
||||
| `202` | Accepted | Request accepted for async processing. Return a job/task ID. |
|
||||
| `204` | No Content | DELETE success or PUT/PATCH with no response body needed. |
|
||||
|
||||
**Guidelines:**
|
||||
- `200` is the default success response for GET, PUT, PATCH
|
||||
- `201` must be used when a new resource is created (POST)
|
||||
- `204` is preferred for DELETE (no body to return)
|
||||
- `202` signals "we got it, processing later" -- return a status URL
|
||||
|
||||
```json
|
||||
// 201 Created response
|
||||
{
|
||||
"id": "usr_abc123",
|
||||
"name": "Jane Doe",
|
||||
"created_at": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
// Header: Location: /api/v1/users/usr_abc123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3xx Redirection
|
||||
|
||||
| Code | Name | When to Use |
|
||||
|------|------|-------------|
|
||||
| `301` | Moved Permanently | Resource URL changed permanently. Clients should update bookmarks. |
|
||||
| `302` | Found | Temporary redirect. Original URL still valid. |
|
||||
| `304` | Not Modified | Conditional GET -- resource unchanged since `If-None-Match`/`If-Modified-Since`. |
|
||||
| `307` | Temporary Redirect | Like 302 but preserves HTTP method. Use for API redirects. |
|
||||
| `308` | Permanent Redirect | Like 301 but preserves HTTP method. |
|
||||
|
||||
**Guidelines:**
|
||||
- Prefer `307`/`308` over `302`/`301` in APIs (method preservation)
|
||||
- `304` reduces bandwidth when clients cache responses
|
||||
- Always include `Location` header with redirect responses
|
||||
|
||||
---
|
||||
|
||||
## 4xx Client Errors
|
||||
|
||||
| Code | Name | When to Use |
|
||||
|------|------|-------------|
|
||||
| `400` | Bad Request | Malformed syntax, invalid JSON, failed validation. |
|
||||
| `401` | Unauthorized | Missing or invalid authentication credentials. |
|
||||
| `403` | Forbidden | Authenticated but lacks permission for this resource. |
|
||||
| `404` | Not Found | Resource does not exist at this URL. |
|
||||
| `405` | Method Not Allowed | HTTP method not supported on this endpoint. |
|
||||
| `409` | Conflict | Request conflicts with current state (duplicate, version mismatch). |
|
||||
| `410` | Gone | Resource existed but has been permanently deleted. |
|
||||
| `415` | Unsupported Media Type | Content-Type header not supported. |
|
||||
| `422` | Unprocessable Entity | Valid JSON but semantically invalid (business rule violation). |
|
||||
| `429` | Too Many Requests | Rate limit exceeded. Include `Retry-After` header. |
|
||||
|
||||
**Guidelines:**
|
||||
- `400` for structural issues (bad JSON, missing required fields)
|
||||
- `422` for business logic failures (email already taken, invalid state transition)
|
||||
- `401` means "who are you?" -- `403` means "I know who you are, but no"
|
||||
- `409` for optimistic locking failures and unique constraint violations
|
||||
- `429` must include `Retry-After` header with seconds until retry
|
||||
|
||||
```json
|
||||
// 422 Unprocessable Entity
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Request validation failed",
|
||||
"details": [
|
||||
{ "field": "email", "message": "Email already registered" },
|
||||
{ "field": "age", "message": "Must be 18 or older" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// 429 Too Many Requests
|
||||
// Header: Retry-After: 60
|
||||
{
|
||||
"error": {
|
||||
"code": "RATE_LIMITED",
|
||||
"message": "Rate limit exceeded. Try again in 60 seconds."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5xx Server Errors
|
||||
|
||||
| Code | Name | When to Use |
|
||||
|------|------|-------------|
|
||||
| `500` | Internal Server Error | Unhandled exception. Generic server failure. |
|
||||
| `501` | Not Implemented | Endpoint exists but functionality not built yet. |
|
||||
| `502` | Bad Gateway | Upstream service returned invalid response. |
|
||||
| `503` | Service Unavailable | Server overloaded or in maintenance. Include `Retry-After`. |
|
||||
| `504` | Gateway Timeout | Upstream service did not respond in time. |
|
||||
|
||||
**Guidelines:**
|
||||
- `500` should never expose stack traces in production
|
||||
- `503` should include `Retry-After` header and a maintenance message
|
||||
- Log all 5xx errors with request context for debugging
|
||||
- Return a consistent error body format for all 5xx responses
|
||||
|
||||
```json
|
||||
// 500 Internal Server Error (production)
|
||||
{
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "An unexpected error occurred. Please try again.",
|
||||
"request_id": "req_7f3a9b2c"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Flowchart
|
||||
|
||||
```
|
||||
Request received
|
||||
|
|
||||
+-- Is it valid syntax? -- NO --> 400 Bad Request
|
||||
|
|
||||
+-- Is caller authenticated? -- NO --> 401 Unauthorized
|
||||
|
|
||||
+-- Is caller authorized? -- NO --> 403 Forbidden
|
||||
|
|
||||
+-- Does resource exist? -- NO --> 404 Not Found
|
||||
|
|
||||
+-- Is it rate-limited? -- YES --> 429 Too Many Requests
|
||||
|
|
||||
+-- Does it pass business rules? -- NO --> 422 Unprocessable Entity
|
||||
|
|
||||
+-- Any conflicts? -- YES --> 409 Conflict
|
||||
|
|
||||
+-- Server error? -- YES --> 500 Internal Server Error
|
||||
|
|
||||
+-- Success!
|
||||
GET --> 200 OK
|
||||
POST --> 201 Created
|
||||
PUT --> 200 OK
|
||||
PATCH --> 200 OK
|
||||
DELETE --> 204 No Content
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard Error Response Format
|
||||
|
||||
Use a consistent structure across all error responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "MACHINE_READABLE_CODE",
|
||||
"message": "Human-readable description",
|
||||
"details": [],
|
||||
"request_id": "req_..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
*Reference: [RFC 9110 - HTTP Semantics](https://httpwg.org/specs/rfc9110.html), [RFC 9457 - Problem Details](https://www.rfc-editor.org/rfc/rfc9457)*
|
||||
@@ -0,0 +1,196 @@
|
||||
# REST API Naming Conventions
|
||||
|
||||
Guidelines for consistent, predictable REST endpoint design.
|
||||
|
||||
---
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **Use plural nouns** for resource collections
|
||||
2. **Use kebab-case** for multi-word resources
|
||||
3. **Use path parameters** for identity, query parameters for filtering
|
||||
4. **Never use verbs** in URLs (HTTP methods convey the action)
|
||||
5. **Use lowercase** exclusively
|
||||
|
||||
---
|
||||
|
||||
## Resource Naming
|
||||
|
||||
| Pattern | Example | Notes |
|
||||
|---------|---------|-------|
|
||||
| Collection | `/users` | Plural noun |
|
||||
| Single resource | `/users/{id}` | Path parameter |
|
||||
| Multi-word resource | `/order-items` | Kebab-case |
|
||||
| Nested resource | `/users/{id}/orders` | Parent-child relationship |
|
||||
| Deep nesting (avoid) | `/users/{id}/orders/{oid}/items` | Max 2 levels deep |
|
||||
| Singleton sub-resource | `/users/{id}/profile` | One-to-one relationship |
|
||||
|
||||
### Good
|
||||
|
||||
```
|
||||
GET /users
|
||||
GET /users/123
|
||||
POST /users
|
||||
PUT /users/123
|
||||
DELETE /users/123
|
||||
GET /users/123/orders
|
||||
GET /order-items
|
||||
GET /user-profiles/123
|
||||
```
|
||||
|
||||
### Bad
|
||||
|
||||
```
|
||||
GET /getUsers # verb in URL
|
||||
GET /user/123 # singular collection
|
||||
GET /Users # uppercase
|
||||
POST /users/create # redundant verb
|
||||
GET /user_profiles # snake_case
|
||||
DELETE /users/123/delete # verb in URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CRUD Mapping
|
||||
|
||||
| Action | Method | Endpoint | Request Body | Response |
|
||||
|--------|--------|----------|-------------|----------|
|
||||
| List | `GET` | `/resources` | None | `200` + array |
|
||||
| Create | `POST` | `/resources` | Resource data | `201` + created |
|
||||
| Read | `GET` | `/resources/{id}` | None | `200` + object |
|
||||
| Update (full) | `PUT` | `/resources/{id}` | Full resource | `200` + updated |
|
||||
| Update (partial) | `PATCH` | `/resources/{id}` | Partial data | `200` + updated |
|
||||
| Delete | `DELETE` | `/resources/{id}` | None | `204` |
|
||||
|
||||
---
|
||||
|
||||
## Nested Resources
|
||||
|
||||
Use nesting to express clear parent-child relationships.
|
||||
|
||||
```
|
||||
# User's orders (user owns orders)
|
||||
GET /users/{userId}/orders
|
||||
POST /users/{userId}/orders
|
||||
|
||||
# Order's line items
|
||||
GET /orders/{orderId}/items
|
||||
```
|
||||
|
||||
**When to nest vs. top-level:**
|
||||
|
||||
| Scenario | Approach | Example |
|
||||
|----------|----------|---------|
|
||||
| Resource only exists under parent | Nest | `/users/{id}/sessions` |
|
||||
| Resource is independently accessible | Top-level with filter | `/orders?user_id=123` |
|
||||
| Shallow relationship | Top-level | `/comments?post_id=456` |
|
||||
|
||||
**Rule of thumb:** Never nest more than 2 levels. Use query parameters or top-level endpoints instead.
|
||||
|
||||
```
|
||||
# Too deep -- avoid
|
||||
GET /users/{id}/orders/{oid}/items/{iid}/reviews
|
||||
|
||||
# Better alternatives
|
||||
GET /order-items/{iid}/reviews
|
||||
GET /reviews?order_item_id={iid}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Parameters
|
||||
|
||||
### Filtering
|
||||
|
||||
```
|
||||
GET /products?category=electronics&brand=acme
|
||||
GET /users?status=active&role=admin
|
||||
GET /orders?created_after=2025-01-01&created_before=2025-02-01
|
||||
```
|
||||
|
||||
| Convention | Example |
|
||||
|-----------|---------|
|
||||
| Exact match | `?status=active` |
|
||||
| Date range | `?created_after=2025-01-01` |
|
||||
| Multiple values | `?status=active,pending` |
|
||||
| Search | `?q=search+term` |
|
||||
|
||||
### Sorting
|
||||
|
||||
```
|
||||
GET /products?sort=price # ascending (default)
|
||||
GET /products?sort=-price # descending (prefix -)
|
||||
GET /products?sort=-created_at,name # multi-field
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```
|
||||
# Offset-based (simple, common)
|
||||
GET /products?page=2&per_page=25
|
||||
|
||||
# Cursor-based (better for large datasets)
|
||||
GET /products?cursor=eyJpZCI6MTAwfQ&limit=25
|
||||
```
|
||||
|
||||
**Response envelope for paginated results:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"pagination": {
|
||||
"page": 2,
|
||||
"per_page": 25,
|
||||
"total": 150,
|
||||
"total_pages": 6
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field Selection
|
||||
|
||||
```
|
||||
GET /users/123?fields=id,name,email
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Non-CRUD Actions
|
||||
|
||||
Some operations do not map cleanly to CRUD. Use sub-resources with a noun or POST with an action resource.
|
||||
|
||||
| Action | Approach | Example |
|
||||
|--------|----------|---------|
|
||||
| Send an email | POST to action resource | `POST /users/{id}/verification-email` |
|
||||
| Archive | PATCH with status | `PATCH /orders/{id} { "status": "archived" }` |
|
||||
| Bulk delete | POST to action | `POST /users/bulk-delete { "ids": [...] }` |
|
||||
| Export | GET with format | `GET /reports/sales?format=csv` |
|
||||
| Search (complex) | POST with body | `POST /products/search { "filters": {...} }` |
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
| Strategy | Example | Pros | Cons |
|
||||
|----------|---------|------|------|
|
||||
| URL path | `/api/v1/users` | Simple, explicit | URL pollution |
|
||||
| Header | `Accept: application/vnd.api.v1+json` | Clean URLs | Hidden |
|
||||
| Query param | `/users?version=1` | Easy to test | Caching issues |
|
||||
|
||||
**Recommended:** URL path versioning (`/api/v1/`) for public APIs due to simplicity.
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
- [ ] Resources are plural nouns (`/users` not `/user`)
|
||||
- [ ] URLs are kebab-case and lowercase
|
||||
- [ ] No verbs in URLs
|
||||
- [ ] Nesting limited to 2 levels
|
||||
- [ ] Filtering uses query parameters
|
||||
- [ ] Sorting supports `-field` for descending
|
||||
- [ ] Pagination included on all list endpoints
|
||||
- [ ] API version in URL path for public APIs
|
||||
- [ ] Consistent error response format
|
||||
|
||||
*Reference: [Google API Design Guide](https://cloud.google.com/apis/design), [Microsoft REST Guidelines](https://github.com/microsoft/api-guidelines)*
|
||||
@@ -0,0 +1,240 @@
|
||||
openapi: "3.1.0"
|
||||
info:
|
||||
title: My API
|
||||
description: Starter API specification. Replace with your project details.
|
||||
version: "1.0.0"
|
||||
contact:
|
||||
name: API Support
|
||||
email: support@example.com
|
||||
|
||||
servers:
|
||||
- url: http://localhost:3000/api/v1
|
||||
description: Local development
|
||||
- url: https://api.example.com/v1
|
||||
description: Production
|
||||
|
||||
tags:
|
||||
- name: Users
|
||||
description: User management
|
||||
- name: Health
|
||||
description: Service health checks
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
tags: [Health]
|
||||
summary: Health check
|
||||
operationId: getHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Service is healthy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string, example: ok }
|
||||
timestamp: { type: string, format: date-time }
|
||||
|
||||
/users:
|
||||
get:
|
||||
tags: [Users]
|
||||
summary: List users
|
||||
operationId: listUsers
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PageParam"
|
||||
- $ref: "#/components/parameters/PerPageParam"
|
||||
- $ref: "#/components/parameters/SortParam"
|
||||
- name: status
|
||||
in: query
|
||||
schema: { type: string, enum: [active, inactive] }
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated list of users
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/User" }
|
||||
pagination: { $ref: "#/components/schemas/Pagination" }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
|
||||
post:
|
||||
tags: [Users]
|
||||
summary: Create a user
|
||||
operationId: createUser
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/CreateUserRequest" }
|
||||
responses:
|
||||
"201":
|
||||
description: User created
|
||||
headers:
|
||||
Location: { schema: { type: string }, description: URL of created user }
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/User" }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
/users/{userId}:
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
|
||||
get:
|
||||
tags: [Users]
|
||||
summary: Get a user by ID
|
||||
operationId: getUser
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
"200":
|
||||
description: User details
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/User" }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
patch:
|
||||
tags: [Users]
|
||||
summary: Update a user
|
||||
operationId: updateUser
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/UpdateUserRequest" }
|
||||
responses:
|
||||
"200":
|
||||
description: User updated
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/User" }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"422": { $ref: "#/components/responses/ValidationError" }
|
||||
|
||||
delete:
|
||||
tags: [Users]
|
||||
summary: Delete a user
|
||||
operationId: deleteUser
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses:
|
||||
"204": { description: User deleted }
|
||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
apiKeyAuth: { type: apiKey, in: header, name: X-API-Key }
|
||||
|
||||
parameters:
|
||||
PageParam:
|
||||
name: page
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, default: 1 }
|
||||
PerPageParam:
|
||||
name: per_page
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
|
||||
SortParam:
|
||||
name: sort
|
||||
in: query
|
||||
description: "Field to sort by. Prefix with - for descending."
|
||||
schema: { type: string, example: "-created_at" }
|
||||
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
required: [id, email, name, status, created_at]
|
||||
properties:
|
||||
id: { type: string, example: usr_abc123 }
|
||||
email: { type: string, format: email, example: jane@example.com }
|
||||
name: { type: string, example: Jane Doe }
|
||||
status: { type: string, enum: [active, inactive] }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
|
||||
CreateUserRequest:
|
||||
type: object
|
||||
required: [email, name]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
name: { type: string, minLength: 1, maxLength: 100 }
|
||||
role: { type: string, enum: [user, admin], default: user }
|
||||
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string, minLength: 1, maxLength: 100 }
|
||||
status: { type: string, enum: [active, inactive] }
|
||||
|
||||
Pagination:
|
||||
type: object
|
||||
properties:
|
||||
page: { type: integer }
|
||||
per_page: { type: integer }
|
||||
total: { type: integer }
|
||||
total_pages: { type: integer }
|
||||
|
||||
Error:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
details:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
field: { type: string }
|
||||
message: { type: string }
|
||||
request_id: { type: string }
|
||||
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Bad request
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
example: { error: { code: BAD_REQUEST, message: Malformed request body } }
|
||||
Unauthorized:
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
example: { error: { code: UNAUTHORIZED, message: Missing or invalid token } }
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
example: { error: { code: NOT_FOUND, message: Resource not found } }
|
||||
ValidationError:
|
||||
description: Validation failed
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
example:
|
||||
error:
|
||||
code: VALIDATION_ERROR
|
||||
message: Request validation failed
|
||||
details: [{ field: email, message: Email already registered }]
|
||||
@@ -1,73 +1,579 @@
|
||||
---
|
||||
name: mongodb
|
||||
description: >
|
||||
Use this skill whenever working with MongoDB, document databases, or NoSQL data modeling. Trigger on keywords like MongoDB, Mongo, document database, aggregation pipeline, collection, embedded documents, or BSON. Also applies when designing document schemas, building aggregation queries, handling unstructured or semi-structured data, or migrating from relational to document-based storage.
|
||||
---
|
||||
|
||||
# MongoDB
|
||||
|
||||
## Description
|
||||
|
||||
MongoDB patterns including document design, queries, and aggregation.
|
||||
|
||||
## When to Use
|
||||
|
||||
- MongoDB database operations
|
||||
- Document-based data modeling
|
||||
- Aggregation pipelines
|
||||
- Semi-structured or polymorphic data that varies per record
|
||||
- Rapid prototyping where schema flexibility accelerates iteration
|
||||
- Event logging, IoT telemetry, or content management systems
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Relational-heavy data models with complex joins and foreign key constraints
|
||||
- SQL-only projects where the entire stack is built around relational databases
|
||||
- Simple key-value storage where Redis or a lightweight store is more appropriate
|
||||
- Financial systems requiring multi-table ACID transactions as the norm
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Document Operations
|
||||
### 1. Schema Design
|
||||
|
||||
```javascript
|
||||
// Insert
|
||||
db.users.insertOne({
|
||||
email: 'user@example.com',
|
||||
name: 'John',
|
||||
createdAt: new Date()
|
||||
});
|
||||
The central decision in MongoDB modeling is **embed vs. reference**.
|
||||
|
||||
// Find
|
||||
db.users.find({ active: true }).sort({ createdAt: -1 }).limit(20);
|
||||
**Decision tree:**
|
||||
|
||||
// Update
|
||||
db.users.updateOne(
|
||||
{ _id: ObjectId('...') },
|
||||
{ $set: { name: 'Jane' } }
|
||||
);
|
||||
```
|
||||
Does the child data belong to exactly one parent?
|
||||
YES --> Is the child array unbounded (could grow to thousands)?
|
||||
YES --> Reference (separate collection)
|
||||
NO --> Embed
|
||||
NO --> Is it a many-to-many relationship?
|
||||
YES --> Reference (with array of ObjectIds on one or both sides)
|
||||
NO --> Reference
|
||||
```
|
||||
|
||||
### Aggregation
|
||||
**Embedding pattern -- best for data that is read together:**
|
||||
|
||||
```javascript
|
||||
// User with embedded address and preferences
|
||||
// Good: one read fetches everything the profile page needs
|
||||
db.users.insertOne({
|
||||
email: "user@example.com",
|
||||
name: "Alice Chen",
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Portland",
|
||||
state: "OR",
|
||||
zip: "97201"
|
||||
},
|
||||
preferences: {
|
||||
theme: "dark",
|
||||
language: "en",
|
||||
notifications: { email: true, push: false }
|
||||
},
|
||||
createdAt: new Date()
|
||||
});
|
||||
```
|
||||
|
||||
**Referencing pattern -- best for independent or unbounded data:**
|
||||
|
||||
```javascript
|
||||
// Orders reference the user by ID
|
||||
// Good: orders grow unboundedly, accessed independently
|
||||
db.orders.insertOne({
|
||||
userId: ObjectId("6651a..."),
|
||||
status: "shipped",
|
||||
totalCents: 4999,
|
||||
items: [
|
||||
{ sku: "WIDGET-001", name: "Blue Widget", qty: 2, priceCents: 1999 },
|
||||
{ sku: "GADGET-010", name: "Mini Gadget", qty: 1, priceCents: 1001 }
|
||||
],
|
||||
placedAt: new Date()
|
||||
});
|
||||
```
|
||||
|
||||
**Denormalization pattern -- duplicate data to avoid frequent lookups:**
|
||||
|
||||
```javascript
|
||||
// Store author name directly on the post (denormalized from users)
|
||||
// Trade-off: faster reads, but updates to user name require updating all posts
|
||||
db.posts.insertOne({
|
||||
title: "Getting Started with MongoDB",
|
||||
body: "...",
|
||||
author: {
|
||||
_id: ObjectId("6651a..."),
|
||||
name: "Alice Chen" // denormalized -- must be updated if name changes
|
||||
},
|
||||
tags: ["mongodb", "tutorial"],
|
||||
publishedAt: new Date()
|
||||
});
|
||||
```
|
||||
|
||||
**Polymorphic pattern -- different shapes in one collection:**
|
||||
|
||||
```javascript
|
||||
// Events collection stores different event types
|
||||
db.events.insertMany([
|
||||
{
|
||||
type: "page_view",
|
||||
userId: ObjectId("6651a..."),
|
||||
url: "/products/widget",
|
||||
timestamp: new Date()
|
||||
},
|
||||
{
|
||||
type: "purchase",
|
||||
userId: ObjectId("6651a..."),
|
||||
orderId: ObjectId("6651b..."),
|
||||
totalCents: 4999,
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
// Use a discriminator field (type) and query by it
|
||||
```
|
||||
|
||||
**Schema validation -- enforce structure at the database level:**
|
||||
|
||||
```javascript
|
||||
db.createCollection("users", {
|
||||
validator: {
|
||||
$jsonSchema: {
|
||||
bsonType: "object",
|
||||
required: ["email", "name", "createdAt"],
|
||||
properties: {
|
||||
email: {
|
||||
bsonType: "string",
|
||||
pattern: "^.+@.+\\..+$",
|
||||
description: "Must be a valid email"
|
||||
},
|
||||
name: {
|
||||
bsonType: "string",
|
||||
minLength: 1
|
||||
},
|
||||
role: {
|
||||
enum: ["admin", "editor", "viewer"],
|
||||
description: "Must be a valid role"
|
||||
},
|
||||
createdAt: { bsonType: "date" }
|
||||
}
|
||||
}
|
||||
},
|
||||
validationLevel: "strict",
|
||||
validationAction: "error"
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Aggregation Pipeline
|
||||
|
||||
Build complex data transformations as a sequence of stages.
|
||||
|
||||
```javascript
|
||||
// Revenue report: total and average spend per user, last 30 days
|
||||
db.orders.aggregate([
|
||||
{ $match: { status: 'completed' } },
|
||||
{ $group: {
|
||||
_id: '$userId',
|
||||
totalSpent: { $sum: '$amount' },
|
||||
orderCount: { $count: {} }
|
||||
// Stage 1: filter to recent delivered orders
|
||||
{ $match: {
|
||||
status: "delivered",
|
||||
placedAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
|
||||
}},
|
||||
{ $sort: { totalSpent: -1 } }
|
||||
|
||||
// Stage 2: group by user
|
||||
{ $group: {
|
||||
_id: "$userId",
|
||||
totalSpent: { $sum: "$totalCents" },
|
||||
orderCount: { $sum: 1 },
|
||||
avgOrderValue: { $avg: "$totalCents" }
|
||||
}},
|
||||
|
||||
// Stage 3: sort by spend
|
||||
{ $sort: { totalSpent: -1 } },
|
||||
|
||||
// Stage 4: limit to top 10
|
||||
{ $limit: 10 },
|
||||
|
||||
// Stage 5: join user details
|
||||
{ $lookup: {
|
||||
from: "users",
|
||||
localField: "_id",
|
||||
foreignField: "_id",
|
||||
as: "user"
|
||||
}},
|
||||
|
||||
// Stage 6: flatten the joined array
|
||||
{ $unwind: "$user" },
|
||||
|
||||
// Stage 7: reshape output
|
||||
{ $project: {
|
||||
_id: 0,
|
||||
userName: "$user.name",
|
||||
email: "$user.email",
|
||||
totalSpent: 1,
|
||||
orderCount: 1,
|
||||
avgOrderValue: { $round: ["$avgOrderValue", 0] }
|
||||
}}
|
||||
]);
|
||||
```
|
||||
|
||||
### Indexes
|
||||
**$unwind -- flatten arrays into individual documents:**
|
||||
|
||||
```javascript
|
||||
// Single field
|
||||
// Expand order items to analyze product-level metrics
|
||||
db.orders.aggregate([
|
||||
{ $unwind: "$items" },
|
||||
{ $group: {
|
||||
_id: "$items.sku",
|
||||
totalQty: { $sum: "$items.qty" },
|
||||
totalRevenue: { $sum: { $multiply: ["$items.qty", "$items.priceCents"] } }
|
||||
}},
|
||||
{ $sort: { totalRevenue: -1 } }
|
||||
]);
|
||||
```
|
||||
|
||||
**$lookup with pipeline -- filtered/correlated joins:**
|
||||
|
||||
```javascript
|
||||
// For each user, get their 3 most recent orders
|
||||
db.users.aggregate([
|
||||
{ $lookup: {
|
||||
from: "orders",
|
||||
let: { uid: "$_id" },
|
||||
pipeline: [
|
||||
{ $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
|
||||
{ $sort: { placedAt: -1 } },
|
||||
{ $limit: 3 },
|
||||
{ $project: { status: 1, totalCents: 1, placedAt: 1 } }
|
||||
],
|
||||
as: "recentOrders"
|
||||
}}
|
||||
]);
|
||||
```
|
||||
|
||||
**$facet -- run multiple aggregations in parallel:**
|
||||
|
||||
```javascript
|
||||
// Dashboard: get summary stats and top products in one query
|
||||
db.orders.aggregate([
|
||||
{ $match: { status: "delivered" } },
|
||||
{ $facet: {
|
||||
summary: [
|
||||
{ $group: {
|
||||
_id: null,
|
||||
totalRevenue: { $sum: "$totalCents" },
|
||||
totalOrders: { $sum: 1 }
|
||||
}}
|
||||
],
|
||||
topProducts: [
|
||||
{ $unwind: "$items" },
|
||||
{ $group: { _id: "$items.sku", sold: { $sum: "$items.qty" } } },
|
||||
{ $sort: { sold: -1 } },
|
||||
{ $limit: 5 }
|
||||
],
|
||||
monthlyTrend: [
|
||||
{ $group: {
|
||||
_id: { $dateToString: { format: "%Y-%m", date: "$placedAt" } },
|
||||
revenue: { $sum: "$totalCents" }
|
||||
}},
|
||||
{ $sort: { _id: 1 } }
|
||||
]
|
||||
}}
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Index Strategies
|
||||
|
||||
```javascript
|
||||
// Single field index -- most common
|
||||
db.users.createIndex({ email: 1 }, { unique: true });
|
||||
|
||||
// Compound
|
||||
db.posts.createIndex({ userId: 1, createdAt: -1 });
|
||||
// Compound index -- order matters, follows the ESR rule:
|
||||
// Equality fields first, Sort fields next, Range fields last
|
||||
db.orders.createIndex({ status: 1, placedAt: -1 });
|
||||
// Supports: find({status: "pending"}).sort({placedAt: -1})
|
||||
// Also supports: find({status: "pending"}) alone (prefix)
|
||||
|
||||
// Multikey index -- automatically indexes each array element
|
||||
db.posts.createIndex({ tags: 1 });
|
||||
// Supports: find({ tags: "mongodb" })
|
||||
|
||||
// Text index -- basic full-text search
|
||||
db.posts.createIndex(
|
||||
{ title: "text", body: "text" },
|
||||
{ weights: { title: 10, body: 1 }, name: "posts_text_search" }
|
||||
);
|
||||
// Usage:
|
||||
db.posts.find(
|
||||
{ $text: { $search: "mongodb aggregation" } },
|
||||
{ score: { $meta: "textScore" } }
|
||||
).sort({ score: { $meta: "textScore" } });
|
||||
|
||||
// TTL index -- auto-delete documents after expiry
|
||||
db.sessions.createIndex(
|
||||
{ expiresAt: 1 },
|
||||
{ expireAfterSeconds: 0 } // delete when expiresAt is in the past
|
||||
);
|
||||
// Documents must have a Date field; they are removed by a background task ~every 60s
|
||||
|
||||
// Partial index -- only index documents matching a filter
|
||||
db.orders.createIndex(
|
||||
{ placedAt: -1 },
|
||||
{ partialFilterExpression: { status: "pending" } }
|
||||
);
|
||||
// Smaller index; only used when the query includes the filter condition
|
||||
|
||||
// Wildcard index -- for querying arbitrary keys in a sub-document
|
||||
db.products.createIndex({ "attributes.$**": 1 });
|
||||
// Supports: find({ "attributes.color": "red" }) without knowing keys in advance
|
||||
|
||||
// Collation -- case-insensitive sorting and matching
|
||||
db.users.createIndex(
|
||||
{ name: 1 },
|
||||
{ collation: { locale: "en", strength: 2 } }
|
||||
);
|
||||
```
|
||||
|
||||
**The ESR rule for compound indexes:** order fields by **E**quality, **S**ort, **R**ange. This produces the most efficient index scans.
|
||||
|
||||
```javascript
|
||||
// Query: find active orders for a user, sorted by date, in a date range
|
||||
// Equality: userId, status
|
||||
// Sort: placedAt
|
||||
// Range: placedAt (but sort and range on same field -- sort wins)
|
||||
db.orders.createIndex({ userId: 1, status: 1, placedAt: -1 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Transactions
|
||||
|
||||
Multi-document transactions work across collections (requires replica set or sharded cluster).
|
||||
|
||||
```javascript
|
||||
const session = client.startSession();
|
||||
|
||||
try {
|
||||
session.startTransaction({
|
||||
readConcern: { level: "snapshot" },
|
||||
writeConcern: { w: "majority" },
|
||||
readPreference: "primary"
|
||||
});
|
||||
|
||||
const accounts = client.db("bank").collection("accounts");
|
||||
|
||||
// Transfer $50 from account A to account B
|
||||
const fromAccount = await accounts.findOne(
|
||||
{ _id: "account-A" },
|
||||
{ session }
|
||||
);
|
||||
|
||||
if (fromAccount.balanceCents < 5000) {
|
||||
await session.abortTransaction();
|
||||
throw new Error("Insufficient funds");
|
||||
}
|
||||
|
||||
await accounts.updateOne(
|
||||
{ _id: "account-A" },
|
||||
{ $inc: { balanceCents: -5000 } },
|
||||
{ session }
|
||||
);
|
||||
|
||||
await accounts.updateOne(
|
||||
{ _id: "account-B" },
|
||||
{ $inc: { balanceCents: 5000 } },
|
||||
{ session }
|
||||
);
|
||||
|
||||
// Record the transfer in a separate collection -- still in the same tx
|
||||
await client.db("bank").collection("transfers").insertOne({
|
||||
from: "account-A",
|
||||
to: "account-B",
|
||||
amountCents: 5000,
|
||||
timestamp: new Date()
|
||||
}, { session });
|
||||
|
||||
await session.commitTransaction();
|
||||
} catch (error) {
|
||||
await session.abortTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await session.endSession();
|
||||
}
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Keep transactions short -- they hold locks and consume resources
|
||||
- Design your schema to minimize the need for multi-document transactions
|
||||
- Transactions have a default 60-second timeout (`maxTimeMS`)
|
||||
- Retryable writes (`retryWrites=true` in connection string) handle transient errors automatically
|
||||
|
||||
---
|
||||
|
||||
### 5. Change Streams
|
||||
|
||||
Watch for real-time changes to collections, databases, or the entire deployment.
|
||||
|
||||
```javascript
|
||||
// Watch a single collection for inserts and updates
|
||||
const pipeline = [
|
||||
{ $match: {
|
||||
operationType: { $in: ["insert", "update"] },
|
||||
"fullDocument.status": "urgent"
|
||||
}}
|
||||
];
|
||||
|
||||
const changeStream = db.collection("tickets").watch(pipeline, {
|
||||
fullDocument: "updateLookup" // include the full document on updates
|
||||
});
|
||||
|
||||
changeStream.on("change", (change) => {
|
||||
console.log("Change detected:", change.operationType);
|
||||
console.log("Document:", change.fullDocument);
|
||||
console.log("Resume token:", change.resumeToken);
|
||||
|
||||
// Process the change (e.g., send notification, update cache)
|
||||
notifyTeam(change.fullDocument);
|
||||
});
|
||||
|
||||
// Handle errors and resume from last known position
|
||||
changeStream.on("error", (error) => {
|
||||
console.error("Change stream error:", error);
|
||||
// Reconnect using the stored resume token
|
||||
});
|
||||
```
|
||||
|
||||
**Resumable pattern for production:**
|
||||
|
||||
```javascript
|
||||
let resumeToken = await loadResumeTokenFromStorage();
|
||||
|
||||
async function watchWithResume(collection) {
|
||||
const options = { fullDocument: "updateLookup" };
|
||||
if (resumeToken) {
|
||||
options.resumeAfter = resumeToken;
|
||||
}
|
||||
|
||||
const stream = collection.watch([], options);
|
||||
|
||||
stream.on("change", async (change) => {
|
||||
// Process change
|
||||
await handleChange(change);
|
||||
|
||||
// Persist resume token so we can recover after restart
|
||||
resumeToken = change._id;
|
||||
await saveResumeTokenToStorage(resumeToken);
|
||||
});
|
||||
|
||||
stream.on("error", async () => {
|
||||
// Wait and reconnect
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
watchWithResume(collection);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases:** real-time dashboards, cache invalidation, event-driven architectures, syncing data to search indexes (e.g., Elasticsearch).
|
||||
|
||||
---
|
||||
|
||||
### 6. Performance
|
||||
|
||||
#### Reading explain() output
|
||||
|
||||
```javascript
|
||||
// Run explain to see the query plan
|
||||
db.orders.find({
|
||||
userId: ObjectId("6651a..."),
|
||||
status: "pending"
|
||||
}).sort({ placedAt: -1 }).explain("executionStats");
|
||||
```
|
||||
|
||||
**Key fields in executionStats:**
|
||||
|
||||
| Field | What to look for |
|
||||
|-------|-----------------|
|
||||
| `winningPlan.stage` | `IXSCAN` good, `COLLSCAN` bad (full collection scan) |
|
||||
| `totalKeysExamined` | Should be close to `nReturned` (no wasted index scans) |
|
||||
| `totalDocsExamined` | Should be close to `nReturned` (no wasted document reads) |
|
||||
| `executionTimeMillis` | Overall query time |
|
||||
| `rejectedPlans` | Shows alternatives the optimizer considered |
|
||||
|
||||
**Covered queries -- answered entirely from the index:**
|
||||
|
||||
```javascript
|
||||
// Create an index that covers the query
|
||||
db.orders.createIndex({ userId: 1, status: 1, totalCents: 1 });
|
||||
|
||||
// This query only needs fields in the index -- no document fetch
|
||||
db.orders.find(
|
||||
{ userId: ObjectId("6651a..."), status: "delivered" },
|
||||
{ _id: 0, totalCents: 1 } // projection must exclude _id and only include indexed fields
|
||||
);
|
||||
// explain() will show: "totalDocsExamined": 0
|
||||
```
|
||||
|
||||
**Projection optimization -- fetch only what you need:**
|
||||
|
||||
```javascript
|
||||
// BAD: fetches entire document including large body field
|
||||
const posts = await db.posts.find({ author: userId }).toArray();
|
||||
|
||||
// GOOD: only fetch fields needed for the list view
|
||||
const posts = await db.posts.find(
|
||||
{ author: userId },
|
||||
{ projection: { title: 1, publishedAt: 1, tags: 1 } }
|
||||
).toArray();
|
||||
```
|
||||
|
||||
**Bulk operations for write-heavy workloads:**
|
||||
|
||||
```javascript
|
||||
const bulk = db.products.initializeUnorderedBulkOp();
|
||||
|
||||
for (const update of priceUpdates) {
|
||||
bulk.find({ sku: update.sku })
|
||||
.updateOne({ $set: { priceCents: update.newPrice, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
const result = await bulk.execute();
|
||||
console.log(`Modified: ${result.nModified}, Errors: ${result.getWriteErrorCount()}`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Embed frequently accessed data
|
||||
2. Use references for large/independent data
|
||||
3. Create indexes for query patterns
|
||||
4. Use aggregation for complex queries
|
||||
5. Avoid unbounded arrays
|
||||
1. **Design schema around query patterns, not data relationships.** Ask "how will I read this data?" before "how does this data relate?" Embed data that is always fetched together; reference data accessed independently.
|
||||
|
||||
2. **Use the ESR rule for compound indexes.** Order index fields by Equality, Sort, Range. This maximizes the index's usefulness and minimizes keys examined.
|
||||
|
||||
3. **Set read/write concerns appropriately.** Use `w: "majority"` and `readConcern: "majority"` for data that must survive failovers. Use `w: 1` for non-critical writes where speed matters more than durability.
|
||||
|
||||
4. **Use projection to limit returned fields.** Transferring large documents over the network when you only need two fields wastes bandwidth and memory. Always project.
|
||||
|
||||
5. **Avoid unbounded array growth.** An embedded array that can grow to thousands of elements bloats the document (16 MB max) and degrades performance. Move to a separate collection with a reference when the array exceeds ~100 elements.
|
||||
|
||||
6. **Use bulk operations for batch writes.** Individual `insertOne` or `updateOne` calls in a loop are slow. Batch them with `bulkWrite` or `initializeUnorderedBulkOp` for 10-50x throughput improvement.
|
||||
|
||||
7. **Enable retryable writes.** Add `retryWrites=true` to your connection string. This handles transient network errors and primary elections automatically without application-level retry logic.
|
||||
|
||||
8. **Monitor with database profiler and serverStatus.** Use `db.setProfilingLevel(1, { slowms: 100 })` to log slow queries. Check `db.serverStatus().opcounters` and `db.serverStatus().connections` for overall health.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Unbounded arrays**: Limit array size
|
||||
- **Missing indexes**: Analyze query patterns
|
||||
- **Over-embedding**: Consider data access patterns
|
||||
1. **Treating MongoDB like a relational database.** Normalizing everything into separate collections and using `$lookup` for every query defeats the purpose. If you need heavy joins, PostgreSQL is likely a better fit. Design for embedding first.
|
||||
|
||||
2. **Missing indexes on query fields.** Every `find()`, `$match`, and `sort()` should be backed by an index. Use `db.collection.getIndexes()` and `explain()` to verify. A `COLLSCAN` on a large collection is almost always a bug.
|
||||
|
||||
3. **Ignoring the 16 MB document size limit.** Embedding unbounded arrays (comments, logs, events) will eventually hit this wall, crashing writes. Use the bucket pattern (fixed-size sub-documents) or reference a separate collection.
|
||||
|
||||
4. **Not using readPreference for read-heavy workloads.** By default all reads go to the primary. For analytics or non-critical reads, use `readPreference: "secondaryPreferred"` to distribute load across replicas.
|
||||
|
||||
5. **Forgetting that updates replace matched array elements, not all of them.** Using `$set` on a matched array element with positional `$` only updates the first match. Use `$[]` for all elements or `$[<identifier>]` with `arrayFilters` for conditional updates:
|
||||
|
||||
```javascript
|
||||
// Update price for a specific item in all orders
|
||||
db.orders.updateMany(
|
||||
{ "items.sku": "WIDGET-001" },
|
||||
{ $set: { "items.$[item].priceCents": 2499 } },
|
||||
{ arrayFilters: [{ "item.sku": "WIDGET-001" }] }
|
||||
);
|
||||
```
|
||||
|
||||
6. **Running aggregation pipelines without early $match.** Always filter as early as possible in the pipeline. A `$group` or `$unwind` before `$match` processes the entire collection unnecessarily. Put `$match` first to leverage indexes and reduce documents flowing through subsequent stages.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `databases/postgresql` - Relational database patterns for structured data with complex relationships
|
||||
- `patterns/caching` - Caching strategies to reduce database load
|
||||
- `patterns/logging` - Logging patterns for query debugging and monitoring
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# MongoDB Schema Design Patterns
|
||||
|
||||
Quick reference for embedding vs referencing decisions and common schema patterns.
|
||||
|
||||
## Embedding vs Referencing Decision Tree
|
||||
|
||||
```
|
||||
What is the relationship cardinality?
|
||||
|
|
||||
+-- One-to-Few (< 50 items)?
|
||||
| --> EMBED in parent document
|
||||
| Example: user.addresses, post.tags
|
||||
|
|
||||
+-- One-to-Many (50 - 1000s)?
|
||||
| |
|
||||
| +-- Child data always accessed with parent?
|
||||
| | --> EMBED (but watch 16 MB doc limit)
|
||||
| |
|
||||
| +-- Child data accessed independently?
|
||||
| | --> REFERENCE (store child _id in parent array)
|
||||
| |
|
||||
| +-- Need atomic updates across parent + children?
|
||||
| --> EMBED
|
||||
|
|
||||
+-- One-to-Millions?
|
||||
| --> REFERENCE from child to parent
|
||||
| Example: log_entry.host_id (not host.log_entry_ids)
|
||||
|
|
||||
+-- Many-to-Many?
|
||||
--> REFERENCE with array of _ids on one or both sides
|
||||
Example: student.course_ids[], course.student_ids[]
|
||||
```
|
||||
|
||||
## Decision Factors
|
||||
|
||||
| Factor | Favor Embedding | Favor Referencing |
|
||||
|--------|----------------|-------------------|
|
||||
| **Read pattern** | Always read together | Read independently |
|
||||
| **Write pattern** | Infrequent child updates | Frequent child updates |
|
||||
| **Data size** | Small, bounded children | Large or growing children |
|
||||
| **Atomicity** | Need single-doc transactions | Can tolerate multi-doc txn |
|
||||
| **Duplication** | OK to denormalize | Must avoid duplication |
|
||||
| **Cardinality** | Few items | Many/unbounded items |
|
||||
| **Document size** | Well under 16 MB limit | Approaching 16 MB |
|
||||
|
||||
## Pattern Catalog
|
||||
|
||||
### 1. Subset Pattern
|
||||
|
||||
**Problem**: Document is large but reads only need a few fields from embedded data.
|
||||
|
||||
**Solution**: Embed a subset; keep full data in a separate collection.
|
||||
|
||||
```javascript
|
||||
// products collection - fast reads for listing pages
|
||||
{
|
||||
_id: ObjectId("..."),
|
||||
name: "Widget",
|
||||
price: 29.99,
|
||||
// Only the 10 most recent reviews (subset)
|
||||
recent_reviews: [
|
||||
{ user: "alice", rating: 5, text: "Great!", date: ISODate("...") }
|
||||
],
|
||||
review_count: 247
|
||||
}
|
||||
|
||||
// reviews collection - full review data
|
||||
{
|
||||
_id: ObjectId("..."),
|
||||
product_id: ObjectId("..."),
|
||||
user: "alice",
|
||||
rating: 5,
|
||||
text: "Great!",
|
||||
date: ISODate("..."),
|
||||
helpful_votes: 12
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Product pages, user profiles, any "preview + detail" pattern.
|
||||
|
||||
### 2. Computed Pattern
|
||||
|
||||
**Problem**: Expensive aggregation queries run repeatedly on the same data.
|
||||
|
||||
**Solution**: Pre-compute and store the result, update on write.
|
||||
|
||||
```javascript
|
||||
// movies collection
|
||||
{
|
||||
_id: ObjectId("..."),
|
||||
title: "Example Movie",
|
||||
// Pre-computed from screenings collection
|
||||
computed: {
|
||||
total_revenue: 1250000,
|
||||
avg_rating: 4.2,
|
||||
rating_count: 843,
|
||||
last_computed: ISODate("2025-01-15T00:00:00Z")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Update strategy**: On each new rating, increment count and recalculate average. Or use a background job for less time-sensitive data.
|
||||
|
||||
**When to use**: Dashboards, leaderboards, summary statistics.
|
||||
|
||||
### 3. Bucket Pattern
|
||||
|
||||
**Problem**: Many small, time-series documents create overhead (indexes, storage per doc).
|
||||
|
||||
**Solution**: Group related data into fixed-size buckets.
|
||||
|
||||
```javascript
|
||||
// sensor_readings collection - one doc per sensor per hour
|
||||
{
|
||||
sensor_id: "sensor-42",
|
||||
bucket_start: ISODate("2025-01-15T14:00:00Z"),
|
||||
bucket_end: ISODate("2025-01-15T14:59:59Z"),
|
||||
count: 60,
|
||||
readings: [
|
||||
{ ts: ISODate("2025-01-15T14:00:00Z"), temp: 22.1, humidity: 45 },
|
||||
{ ts: ISODate("2025-01-15T14:01:00Z"), temp: 22.3, humidity: 44 }
|
||||
// ... up to 60 readings per bucket
|
||||
],
|
||||
// Pre-computed aggregates for the bucket
|
||||
summary: {
|
||||
avg_temp: 22.4,
|
||||
min_temp: 21.8,
|
||||
max_temp: 23.1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bucket sizing**: Choose a size that balances doc count reduction vs update frequency. Common choices: 1 hour, 1 day, 100 events.
|
||||
|
||||
**When to use**: IoT, time-series, event logging, analytics.
|
||||
|
||||
### 4. Outlier Pattern
|
||||
|
||||
**Problem**: A few documents have vastly more data than the norm (e.g., a viral post with millions of likes).
|
||||
|
||||
**Solution**: Flag outliers and overflow into separate documents.
|
||||
|
||||
```javascript
|
||||
// books collection - normal case
|
||||
{
|
||||
_id: ObjectId("..."),
|
||||
title: "Normal Book",
|
||||
customers_purchased: ["user1", "user2", "user3"],
|
||||
has_overflow: false
|
||||
}
|
||||
|
||||
// books collection - outlier (bestseller)
|
||||
{
|
||||
_id: ObjectId("..."),
|
||||
title: "Bestseller",
|
||||
customers_purchased: ["user1", "user2", /* ... first 1000 */],
|
||||
has_overflow: true
|
||||
}
|
||||
|
||||
// book_purchases_overflow collection
|
||||
{
|
||||
book_id: ObjectId("..."),
|
||||
page: 2,
|
||||
customers_purchased: ["user1001", "user1002", /* ... next 1000 */]
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Social media (viral posts), e-commerce (bestsellers), any data with power-law distribution.
|
||||
|
||||
### 5. Extended Reference Pattern
|
||||
|
||||
**Problem**: Frequent joins (lookups) to get a few fields from a referenced document.
|
||||
|
||||
**Solution**: Copy the most-accessed fields into the referencing document.
|
||||
|
||||
```javascript
|
||||
// orders collection
|
||||
{
|
||||
_id: ObjectId("..."),
|
||||
date: ISODate("..."),
|
||||
customer_id: ObjectId("..."),
|
||||
// Extended reference - copied fields for fast reads
|
||||
customer_name: "Alice Smith",
|
||||
customer_email: "alice@example.com",
|
||||
items: [
|
||||
{
|
||||
product_id: ObjectId("..."),
|
||||
product_name: "Widget", // copied
|
||||
price: 29.99, // copied (snapshot at time of order)
|
||||
quantity: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Trade-off**: Stale data is acceptable (order snapshots price at purchase time). For data that must be current, keep only the reference.
|
||||
|
||||
**When to use**: Orders (snapshot pricing), notifications (snapshot user name), audit logs.
|
||||
|
||||
### 6. Polymorphic Pattern
|
||||
|
||||
**Problem**: Objects share some fields but differ in others (e.g., different product types).
|
||||
|
||||
**Solution**: Store in a single collection with a type discriminator.
|
||||
|
||||
```javascript
|
||||
// vehicles collection
|
||||
{ type: "car", make: "Toyota", doors: 4, trunk_size_liters: 450 }
|
||||
{ type: "truck", make: "Ford", doors: 2, payload_kg: 5000 }
|
||||
{ type: "motorcycle", make: "Harley", engine_cc: 1200 }
|
||||
```
|
||||
|
||||
**Index strategy**: Index common fields. Use partial indexes for type-specific fields.
|
||||
|
||||
```javascript
|
||||
db.vehicles.createIndex(
|
||||
{ payload_kg: 1 },
|
||||
{ partialFilterExpression: { type: "truck" } }
|
||||
);
|
||||
```
|
||||
|
||||
**When to use**: Product catalogs, content management (articles, videos, images), mixed event streams.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Mistake | Problem | Fix |
|
||||
|---------|---------|-----|
|
||||
| Unbounded array growth | Document exceeds 16 MB | Use bucket or outlier pattern |
|
||||
| Deep nesting (> 3 levels) | Hard to query and index | Flatten or reference |
|
||||
| Normalizing everything | Too many lookups, slow reads | Embed when read together |
|
||||
| Embedding large blobs | Wastes RAM in working set | Store in GridFS or S3 |
|
||||
| No schema validation | Inconsistent data over time | Use JSON Schema validation |
|
||||
| Indexing every field | Slow writes, wasted space | Index based on query patterns |
|
||||
|
||||
## Schema Validation
|
||||
|
||||
Use `db.createCollection()` with `$jsonSchema` validator to enforce structure. Set `validationLevel: "moderate"` to apply only on inserts and updates (not existing docs).
|
||||
@@ -1,69 +1,612 @@
|
||||
---
|
||||
name: postgresql
|
||||
description: >
|
||||
Use this skill whenever working with PostgreSQL databases, writing SQL queries, designing schemas, or optimizing database performance. Trigger on keywords like PostgreSQL, Postgres, SQL query, schema design, indexing, migrations, EXPLAIN ANALYZE, connection pooling, or any relational database operation. Also applies when debugging slow queries, setting up database tables, or working with ORMs that target PostgreSQL.
|
||||
---
|
||||
|
||||
# PostgreSQL
|
||||
|
||||
## Description
|
||||
|
||||
PostgreSQL database patterns including queries, indexing, and optimization.
|
||||
|
||||
## When to Use
|
||||
|
||||
- PostgreSQL database operations
|
||||
- SQL query optimization
|
||||
- Schema design
|
||||
- Schema design and migrations
|
||||
- JSONB document storage within a relational model
|
||||
- Full-text search without a dedicated search engine
|
||||
- Complex analytical queries with window functions and CTEs
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- NoSQL-only projects where no relational database is involved
|
||||
- In-memory databases like Redis or SQLite used purely for caching or ephemeral storage
|
||||
- File-based storage scenarios that do not require a database engine
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Basic Queries
|
||||
### 1. Schema Design
|
||||
|
||||
Design tables with explicit constraints, proper types, and clear relationships.
|
||||
|
||||
```sql
|
||||
-- Select with filtering
|
||||
SELECT id, name, email
|
||||
FROM users
|
||||
WHERE active = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20 OFFSET 0;
|
||||
-- Enums for constrained value sets
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'editor', 'viewer');
|
||||
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');
|
||||
|
||||
-- Join
|
||||
SELECT u.*, COUNT(p.id) as post_count
|
||||
-- Composite types for reusable structures
|
||||
CREATE TYPE address AS (
|
||||
street TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
zip VARCHAR(10)
|
||||
);
|
||||
|
||||
-- Users table with constraints
|
||||
CREATE TABLE users (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL CHECK (char_length(name) >= 1),
|
||||
role user_role NOT NULL DEFAULT 'viewer',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Organizations with self-referencing hierarchy
|
||||
CREATE TABLE organizations (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id BIGINT REFERENCES organizations(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Membership join table with composite primary key
|
||||
CREATE TABLE org_memberships (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
role user_role NOT NULL DEFAULT 'viewer',
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, org_id)
|
||||
);
|
||||
|
||||
-- Orders with foreign keys, check constraints, and enum status
|
||||
CREATE TABLE orders (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
status order_status NOT NULL DEFAULT 'pending',
|
||||
total_cents BIGINT NOT NULL CHECK (total_cents >= 0),
|
||||
shipping address,
|
||||
items JSONB NOT NULL DEFAULT '[]',
|
||||
placed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Auto-update updated_at with a trigger
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
- Use `BIGINT GENERATED ALWAYS AS IDENTITY` over `SERIAL` for new projects
|
||||
- Use `TIMESTAMPTZ` (not `TIMESTAMP`) to store times with timezone awareness
|
||||
- Prefer `TEXT` over `VARCHAR(n)` unless a hard length limit is business-critical
|
||||
- Add `ON DELETE` actions on every foreign key (CASCADE, RESTRICT, or SET NULL)
|
||||
- Use `CHECK` constraints for business rules that live at the data level
|
||||
|
||||
---
|
||||
|
||||
### 2. Index Strategy
|
||||
|
||||
Choose the right index type based on your query patterns.
|
||||
|
||||
**Decision guide:**
|
||||
|
||||
| Query Pattern | Index Type | Example |
|
||||
|---------------|-----------|---------|
|
||||
| Equality (`=`) and range (`<`, `>`, `BETWEEN`) | B-tree (default) | `WHERE created_at > '2025-01-01'` |
|
||||
| Array containment (`@>`), JSONB queries | GIN | `WHERE tags @> '{postgres}'` |
|
||||
| Full-text search (`@@`) | GIN | `WHERE to_tsvector(body) @@ query` |
|
||||
| Geometry, range overlap | GiST | `WHERE location <-> point '(40.7,-74.0)' < 0.01` |
|
||||
| Filtered subset of rows | Partial | `WHERE active = true` |
|
||||
| Index-only scans (no heap lookup) | Covering (INCLUDE) | Frequently selected columns |
|
||||
|
||||
```sql
|
||||
-- B-tree: default, good for equality and range
|
||||
CREATE INDEX idx_orders_placed_at ON orders(placed_at DESC);
|
||||
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
|
||||
|
||||
-- GIN: arrays and JSONB containment
|
||||
CREATE INDEX idx_users_metadata ON users USING GIN (metadata);
|
||||
CREATE INDEX idx_orders_items ON orders USING GIN (items jsonb_path_ops);
|
||||
|
||||
-- GIN: full-text search
|
||||
ALTER TABLE articles ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
|
||||
|
||||
-- Full-text search query
|
||||
SELECT id, title, ts_rank(search_vector, query) AS rank
|
||||
FROM articles, plainto_tsquery('english', 'database optimization') AS query
|
||||
WHERE search_vector @@ query
|
||||
ORDER BY rank DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- GiST: geometry and range types
|
||||
CREATE INDEX idx_events_duration ON events USING GiST (
|
||||
tstzrange(starts_at, ends_at)
|
||||
);
|
||||
|
||||
-- Find overlapping events
|
||||
SELECT * FROM events
|
||||
WHERE tstzrange(starts_at, ends_at) && tstzrange('2025-06-01', '2025-06-02');
|
||||
|
||||
-- Partial index: only index rows you actually query
|
||||
CREATE INDEX idx_orders_pending ON orders(placed_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- Covering index: avoids heap lookup for common queries
|
||||
CREATE INDEX idx_users_email_covering ON users(email)
|
||||
INCLUDE (name, role);
|
||||
|
||||
-- This query can now be answered entirely from the index
|
||||
SELECT name, role FROM users WHERE email = 'user@example.com';
|
||||
```
|
||||
|
||||
**When to add an index:** Run `EXPLAIN ANALYZE` first. Add an index when you see sequential scans on large tables with selective WHERE clauses. Do not index columns with very low cardinality (e.g., a boolean on a small table) unless combined with other columns.
|
||||
|
||||
---
|
||||
|
||||
### 3. Query Optimization
|
||||
|
||||
#### Reading EXPLAIN ANALYZE
|
||||
|
||||
```sql
|
||||
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
|
||||
SELECT u.name, COUNT(o.id) AS order_count
|
||||
FROM users u
|
||||
LEFT JOIN posts p ON p.user_id = u.id
|
||||
GROUP BY u.id;
|
||||
JOIN orders o ON o.user_id = u.id
|
||||
WHERE o.placed_at > now() - INTERVAL '30 days'
|
||||
GROUP BY u.id, u.name
|
||||
ORDER BY order_count DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### Indexes
|
||||
**What to look for in the output:**
|
||||
- **Seq Scan on large tables** -- add an index or rewrite the WHERE clause
|
||||
- **Nested Loop with high row counts** -- consider a Hash Join (may need more `work_mem`)
|
||||
- **actual rows far exceeding estimated rows** -- run `ANALYZE tablename` to update statistics
|
||||
- **Buffers: shared read** large numbers -- data not cached, check `shared_buffers` sizing
|
||||
- **Sort Method: external merge** -- increase `work_mem` for this query
|
||||
|
||||
#### Common Query Rewrites
|
||||
|
||||
```sql
|
||||
-- Single column index
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
-- BAD: correlated subquery runs once per row
|
||||
SELECT u.name,
|
||||
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
|
||||
FROM users u;
|
||||
|
||||
-- Composite index
|
||||
CREATE INDEX idx_posts_user_date ON posts(user_id, created_at DESC);
|
||||
-- GOOD: single pass with JOIN + GROUP BY
|
||||
SELECT u.name, COUNT(o.id) AS order_count
|
||||
FROM users u
|
||||
LEFT JOIN orders o ON o.user_id = u.id
|
||||
GROUP BY u.id, u.name;
|
||||
|
||||
-- Partial index
|
||||
CREATE INDEX idx_active_users ON users(email) WHERE active = true;
|
||||
-- BAD: OR on different columns defeats index usage
|
||||
SELECT * FROM orders WHERE user_id = 5 OR status = 'pending';
|
||||
|
||||
-- GOOD: UNION ALL lets each branch use its own index
|
||||
SELECT * FROM orders WHERE user_id = 5
|
||||
UNION ALL
|
||||
SELECT * FROM orders WHERE status = 'pending' AND user_id != 5;
|
||||
|
||||
-- BAD: function call on indexed column prevents index use
|
||||
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';
|
||||
|
||||
-- GOOD: expression index or use citext
|
||||
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
|
||||
-- or better: define email as CITEXT type
|
||||
|
||||
-- Avoiding N+1: fetch users and their latest order in one query
|
||||
SELECT DISTINCT ON (u.id)
|
||||
u.id, u.name, o.id AS latest_order_id, o.total_cents, o.placed_at
|
||||
FROM users u
|
||||
LEFT JOIN orders o ON o.user_id = u.id
|
||||
ORDER BY u.id, o.placed_at DESC;
|
||||
```
|
||||
|
||||
### Migrations
|
||||
---
|
||||
|
||||
### 4. Migrations
|
||||
|
||||
Follow the up/down pattern and plan for zero-downtime deployments.
|
||||
|
||||
```sql
|
||||
-- Add column with default
|
||||
ALTER TABLE users ADD COLUMN role VARCHAR(50) DEFAULT 'user';
|
||||
-- ============================================
|
||||
-- Migration: 20250601_001_add_user_preferences
|
||||
-- ============================================
|
||||
|
||||
-- Add constraint
|
||||
ALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email);
|
||||
-- UP
|
||||
ALTER TABLE users ADD COLUMN preferences JSONB DEFAULT '{}';
|
||||
|
||||
-- Create index CONCURRENTLY to avoid locking the table
|
||||
CREATE INDEX CONCURRENTLY idx_users_preferences
|
||||
ON users USING GIN (preferences);
|
||||
|
||||
-- DOWN
|
||||
DROP INDEX IF EXISTS idx_users_preferences;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS preferences;
|
||||
```
|
||||
|
||||
**Safe vs unsafe operations:**
|
||||
|
||||
| Operation | Safe? | Notes |
|
||||
|-----------|-------|-------|
|
||||
| ADD COLUMN (nullable or with volatile default) | Yes | Instant in PG 11+ with non-volatile default too |
|
||||
| ADD COLUMN NOT NULL without default | No | Fails if rows exist; add nullable first, backfill, then set NOT NULL |
|
||||
| DROP COLUMN | Mostly | Quick, but ORM queries may break if they SELECT * |
|
||||
| RENAME COLUMN | Dangerous | Breaks all queries referencing old name; use a transition period |
|
||||
| ADD INDEX | Safe with CONCURRENTLY | Without CONCURRENTLY, locks writes for duration |
|
||||
| ADD CONSTRAINT (CHECK/FK) | Careful | Use NOT VALID then VALIDATE CONSTRAINT in two steps |
|
||||
| Change column type | Dangerous | Rewrites entire table; use a new column + migration instead |
|
||||
|
||||
```sql
|
||||
-- Zero-downtime: add NOT NULL constraint safely
|
||||
-- Step 1: add column as nullable
|
||||
ALTER TABLE users ADD COLUMN phone TEXT;
|
||||
|
||||
-- Step 2: backfill in batches
|
||||
UPDATE users SET phone = '' WHERE phone IS NULL AND id BETWEEN 1 AND 10000;
|
||||
UPDATE users SET phone = '' WHERE phone IS NULL AND id BETWEEN 10001 AND 20000;
|
||||
-- ... continue in batches
|
||||
|
||||
-- Step 3: add constraint without full table lock
|
||||
ALTER TABLE users ADD CONSTRAINT users_phone_not_null
|
||||
CHECK (phone IS NOT NULL) NOT VALID;
|
||||
|
||||
-- Step 4: validate (scans table but allows concurrent writes)
|
||||
ALTER TABLE users VALIDATE CONSTRAINT users_phone_not_null;
|
||||
|
||||
-- Step 5: optionally convert to proper NOT NULL
|
||||
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
|
||||
ALTER TABLE users DROP CONSTRAINT users_phone_not_null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. JSON/JSONB
|
||||
|
||||
Use JSONB for semi-structured data that lives alongside relational columns.
|
||||
|
||||
**When to use JSONB:**
|
||||
- User preferences, settings, or metadata with varying keys
|
||||
- API response caching or event payloads
|
||||
- Flexible attributes that differ per row
|
||||
|
||||
**When NOT to use JSONB:**
|
||||
- Data you regularly JOIN on or use in WHERE clauses across tables -- normalize it
|
||||
- Data that has a fixed, well-known schema -- use proper columns
|
||||
|
||||
```sql
|
||||
-- Querying JSONB: operators
|
||||
-- -> returns JSONB element (keeps type)
|
||||
-- ->> returns TEXT value
|
||||
-- @> containment (left contains right)
|
||||
-- ? key exists
|
||||
|
||||
-- Get a nested value
|
||||
SELECT
|
||||
metadata->>'department' AS department,
|
||||
metadata->'settings'->>'theme' AS theme
|
||||
FROM users
|
||||
WHERE metadata @> '{"role": "admin"}';
|
||||
|
||||
-- Check if a key exists
|
||||
SELECT * FROM users WHERE metadata ? 'avatar_url';
|
||||
|
||||
-- Query inside JSONB arrays
|
||||
SELECT * FROM orders
|
||||
WHERE items @> '[{"sku": "WIDGET-001"}]';
|
||||
|
||||
-- Update a nested JSONB field
|
||||
UPDATE users
|
||||
SET metadata = jsonb_set(metadata, '{settings,notifications}', '"email"')
|
||||
WHERE id = 42;
|
||||
|
||||
-- Remove a key
|
||||
UPDATE users
|
||||
SET metadata = metadata - 'deprecated_field'
|
||||
WHERE metadata ? 'deprecated_field';
|
||||
|
||||
-- Aggregate JSONB: expand array elements into rows
|
||||
SELECT o.id, item->>'sku' AS sku, (item->>'qty')::int AS qty
|
||||
FROM orders o, jsonb_array_elements(o.items) AS item
|
||||
WHERE o.status = 'pending';
|
||||
|
||||
-- Index strategies for JSONB
|
||||
-- General containment queries: GIN with jsonb_ops (default)
|
||||
CREATE INDEX idx_users_metadata_gin ON users USING GIN (metadata);
|
||||
|
||||
-- Containment-only queries (smaller, faster index): jsonb_path_ops
|
||||
CREATE INDEX idx_orders_items_path ON orders USING GIN (items jsonb_path_ops);
|
||||
|
||||
-- Specific key lookups: expression index on extracted value
|
||||
CREATE INDEX idx_users_department ON users ((metadata->>'department'));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. CTEs and Window Functions
|
||||
|
||||
#### Common Table Expressions (CTEs)
|
||||
|
||||
```sql
|
||||
-- Readable multi-step query with CTEs
|
||||
WITH monthly_revenue AS (
|
||||
SELECT
|
||||
date_trunc('month', placed_at) AS month,
|
||||
SUM(total_cents) AS revenue_cents
|
||||
FROM orders
|
||||
WHERE status = 'delivered'
|
||||
GROUP BY 1
|
||||
),
|
||||
revenue_with_growth AS (
|
||||
SELECT
|
||||
month,
|
||||
revenue_cents,
|
||||
LAG(revenue_cents) OVER (ORDER BY month) AS prev_month,
|
||||
ROUND(
|
||||
100.0 * (revenue_cents - LAG(revenue_cents) OVER (ORDER BY month))
|
||||
/ NULLIF(LAG(revenue_cents) OVER (ORDER BY month), 0),
|
||||
1
|
||||
) AS growth_pct
|
||||
FROM monthly_revenue
|
||||
)
|
||||
SELECT * FROM revenue_with_growth ORDER BY month DESC;
|
||||
|
||||
-- Recursive CTE: org hierarchy tree
|
||||
WITH RECURSIVE org_tree AS (
|
||||
-- Base case: top-level orgs
|
||||
SELECT id, name, parent_id, 0 AS depth, name::TEXT AS path
|
||||
FROM organizations
|
||||
WHERE parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive step
|
||||
SELECT o.id, o.name, o.parent_id, t.depth + 1, t.path || ' > ' || o.name
|
||||
FROM organizations o
|
||||
JOIN org_tree t ON o.parent_id = t.id
|
||||
)
|
||||
SELECT * FROM org_tree ORDER BY path;
|
||||
```
|
||||
|
||||
#### Window Functions
|
||||
|
||||
```sql
|
||||
-- ROW_NUMBER: assign rank within a partition
|
||||
SELECT
|
||||
user_id,
|
||||
id AS order_id,
|
||||
total_cents,
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY placed_at DESC) AS rn
|
||||
FROM orders;
|
||||
|
||||
-- Get each user's most recent order
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
o.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY placed_at DESC) AS rn
|
||||
FROM orders o
|
||||
) sub WHERE rn = 1;
|
||||
|
||||
-- LAG/LEAD: compare with previous/next row
|
||||
SELECT
|
||||
placed_at::date AS order_date,
|
||||
total_cents,
|
||||
LAG(total_cents) OVER (ORDER BY placed_at) AS prev_order_total,
|
||||
total_cents - LAG(total_cents) OVER (ORDER BY placed_at) AS diff
|
||||
FROM orders
|
||||
WHERE user_id = 42;
|
||||
|
||||
-- Running total
|
||||
SELECT
|
||||
placed_at::date AS order_date,
|
||||
total_cents,
|
||||
SUM(total_cents) OVER (
|
||||
ORDER BY placed_at
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
||||
) AS running_total
|
||||
FROM orders
|
||||
WHERE user_id = 42;
|
||||
|
||||
-- NTILE: divide rows into equal buckets (e.g., quartiles)
|
||||
SELECT
|
||||
user_id,
|
||||
SUM(total_cents) AS lifetime_spend,
|
||||
NTILE(4) OVER (ORDER BY SUM(total_cents) DESC) AS spend_quartile
|
||||
FROM orders
|
||||
GROUP BY user_id;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Transaction Isolation
|
||||
|
||||
PostgreSQL supports four isolation levels. The two most commonly used:
|
||||
|
||||
| Level | Dirty Read | Non-Repeatable Read | Phantom Read | Use Case |
|
||||
|-------|-----------|-------------------|-------------|----------|
|
||||
| READ COMMITTED (default) | No | Possible | Possible | Most OLTP workloads |
|
||||
| REPEATABLE READ | No | No | No (in PG) | Reports, consistent snapshots |
|
||||
| SERIALIZABLE | No | No | No | Financial transactions, inventory |
|
||||
|
||||
```sql
|
||||
-- Default: READ COMMITTED
|
||||
-- Each statement sees the latest committed data
|
||||
BEGIN;
|
||||
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
|
||||
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
|
||||
COMMIT;
|
||||
|
||||
-- SERIALIZABLE: full isolation, detects write conflicts
|
||||
BEGIN ISOLATION LEVEL SERIALIZABLE;
|
||||
-- Read current inventory
|
||||
SELECT quantity FROM inventory WHERE sku = 'WIDGET-001';
|
||||
-- Decrement if sufficient (PG will abort if concurrent tx conflicts)
|
||||
UPDATE inventory SET quantity = quantity - 1 WHERE sku = 'WIDGET-001';
|
||||
COMMIT;
|
||||
-- If another SERIALIZABLE tx modified the same row, one will get:
|
||||
-- ERROR: could not serialize access due to concurrent update
|
||||
-- Your application must retry on serialization failure (SQLSTATE 40001)
|
||||
|
||||
-- Advisory locks for application-level coordination
|
||||
SELECT pg_advisory_xact_lock(hashtext('process-user-' || '42'));
|
||||
-- Lock is held until transaction ends; no table-level contention
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Use READ COMMITTED for general CRUD operations
|
||||
- Use SERIALIZABLE when correctness requires that concurrent transactions behave as if run sequentially (e.g., balance transfers, seat reservations)
|
||||
- Always implement retry logic for serialization failures
|
||||
- Keep transactions as short as possible to reduce contention
|
||||
|
||||
---
|
||||
|
||||
### 8. Connection Pooling
|
||||
|
||||
Direct PostgreSQL connections are expensive (~1-10 MB RAM each). Use a pooler.
|
||||
|
||||
**PgBouncer configuration (pgbouncer.ini):**
|
||||
|
||||
```ini
|
||||
[databases]
|
||||
myapp = host=127.0.0.1 port=5432 dbname=myapp
|
||||
|
||||
[pgbouncer]
|
||||
listen_addr = 127.0.0.1
|
||||
listen_port = 6432
|
||||
auth_type = scram-sha-256
|
||||
auth_file = /etc/pgbouncer/userlist.txt
|
||||
|
||||
; Pool mode: transaction is best for most web apps
|
||||
pool_mode = transaction
|
||||
|
||||
; Sizing: start conservative, tune with monitoring
|
||||
default_pool_size = 20
|
||||
max_client_conn = 200
|
||||
min_pool_size = 5
|
||||
reserve_pool_size = 5
|
||||
reserve_pool_timeout = 3
|
||||
|
||||
; Timeouts
|
||||
server_idle_timeout = 300
|
||||
client_idle_timeout = 60
|
||||
query_timeout = 30
|
||||
```
|
||||
|
||||
**Pool sizing formula:**
|
||||
|
||||
```
|
||||
optimal_pool_size = ((2 * cpu_cores) + effective_disk_spindles)
|
||||
```
|
||||
|
||||
For a 4-core SSD server: `(2 * 4) + 1 = 9` connections is a good starting point. More connections does not mean more throughput -- too many causes contention.
|
||||
|
||||
**Pool modes:**
|
||||
|
||||
| Mode | Description | Caveats |
|
||||
|------|-------------|---------|
|
||||
| `transaction` | Connection returned after each transaction | Cannot use session-level features (LISTEN/NOTIFY, prepared statements, temp tables) |
|
||||
| `session` | Connection held for entire client session | Fewer pooling benefits; use only when session features needed |
|
||||
| `statement` | Connection returned after each statement | No multi-statement transactions; rarely used |
|
||||
|
||||
**Application-level pooling (Python example with asyncpg):**
|
||||
|
||||
```python
|
||||
import asyncpg
|
||||
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn="postgresql://user:pass@localhost:6432/myapp",
|
||||
min_size=5,
|
||||
max_size=20,
|
||||
max_inactive_connection_lifetime=300,
|
||||
command_timeout=30,
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch("SELECT * FROM users WHERE active = true")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use indexes for filtered/sorted columns
|
||||
2. Use EXPLAIN ANALYZE for slow queries
|
||||
3. Avoid SELECT * in production
|
||||
4. Use transactions for multiple operations
|
||||
5. Use connection pooling
|
||||
1. **Use parameterized queries everywhere.** Never concatenate user input into SQL strings. ORMs and query builders handle this, but verify in raw SQL contexts.
|
||||
|
||||
2. **Run ANALYZE after bulk data changes.** The query planner relies on statistics. After large imports or deletes, run `ANALYZE tablename` to update them.
|
||||
|
||||
3. **Prefer BIGINT for primary keys.** INTEGER (max ~2.1 billion) can be exhausted sooner than expected in high-write systems. BIGINT costs 4 extra bytes per row but avoids a painful migration later.
|
||||
|
||||
4. **Store money as integers (cents).** Floating-point arithmetic causes rounding errors. Use `BIGINT` for cents or `NUMERIC(19,4)` if sub-cent precision is needed.
|
||||
|
||||
5. **Add indexes for foreign keys.** PostgreSQL does not automatically index the child side of a foreign key. Without it, DELETE on the parent table triggers a sequential scan on the child.
|
||||
|
||||
6. **Use TIMESTAMPTZ, not TIMESTAMP.** `TIMESTAMP WITHOUT TIME ZONE` silently drops timezone info. Always use `TIMESTAMPTZ` and let the application control display timezone.
|
||||
|
||||
7. **Set statement_timeout for web requests.** Prevent runaway queries from holding connections: `SET statement_timeout = '5s';` at session start, or configure per-role in PostgreSQL.
|
||||
|
||||
8. **Monitor with pg_stat_statements.** Enable this extension to track query performance over time. The top queries by `total_exec_time` are your optimization targets.
|
||||
|
||||
```sql
|
||||
-- Find slowest queries
|
||||
SELECT
|
||||
calls,
|
||||
round(total_exec_time::numeric, 1) AS total_ms,
|
||||
round(mean_exec_time::numeric, 1) AS mean_ms,
|
||||
query
|
||||
FROM pg_stat_statements
|
||||
ORDER BY total_exec_time DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **N+1 queries**: Use JOINs or batch loading
|
||||
- **Missing indexes**: Add indexes for WHERE/ORDER BY
|
||||
- **Large transactions**: Keep transactions short
|
||||
1. **N+1 queries from ORM lazy loading.** Loading a list of users and then accessing `user.orders` in a loop generates one query per user. Use eager loading (`joinedload` in SQLAlchemy, `select_related` in Django) or batch the query with a JOIN.
|
||||
|
||||
2. **Locking the table during migrations.** `ALTER TABLE ... ADD COLUMN NOT NULL DEFAULT 'x'` is safe in PG 11+, but `CREATE INDEX` without `CONCURRENTLY` locks writes. Always use `CREATE INDEX CONCURRENTLY` in production migrations.
|
||||
|
||||
3. **Bloated tables from UPDATE-heavy workloads.** PostgreSQL MVCC creates dead tuples on every UPDATE. If autovacuum cannot keep up, table size and query times grow. Monitor `pg_stat_user_tables.n_dead_tup` and tune autovacuum settings for hot tables.
|
||||
|
||||
4. **Using OFFSET for pagination on large datasets.** `OFFSET 100000` forces PG to scan and discard 100,000 rows. Use keyset pagination instead:
|
||||
|
||||
```sql
|
||||
-- BAD: slow for deep pages
|
||||
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;
|
||||
|
||||
-- GOOD: keyset pagination
|
||||
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;
|
||||
```
|
||||
|
||||
5. **Ignoring connection limits.** Each PostgreSQL connection consumes RAM. Opening hundreds of direct connections (e.g., one per serverless function invocation) will exhaust `max_connections` and crash the server. Always use PgBouncer or an application-level pool.
|
||||
|
||||
6. **Storing large blobs in the database.** Files over a few KB should go in object storage (S3, R2). Store the URL/key in PostgreSQL. Large `bytea` or `TEXT` columns bloat the table, slow backups, and waste shared_buffers cache.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `databases/mongodb` - Document-based database patterns for non-relational data
|
||||
- `patterns/caching` - Caching strategies to reduce database load
|
||||
- `patterns/logging` - Logging patterns for query debugging and monitoring
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# PostgreSQL Index Decision Tree
|
||||
|
||||
Quick reference for choosing the right index type.
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
What are you querying?
|
||||
|
|
||||
+-- Equality (=) or Range (<, >, BETWEEN, ORDER BY)?
|
||||
| |
|
||||
| +-- On a single scalar column?
|
||||
| | --> B-tree (default)
|
||||
| |
|
||||
| +-- On a timestamp/date column with append-only inserts?
|
||||
| | --> BRIN (much smaller than B-tree)
|
||||
| |
|
||||
| +-- Need the index to also return columns without table lookup?
|
||||
| --> Covering Index (B-tree with INCLUDE)
|
||||
|
|
||||
+-- Array containment (@>, &&) or JSONB queries?
|
||||
| --> GIN
|
||||
|
|
||||
+-- Full-text search (tsvector, @@)?
|
||||
| --> GIN
|
||||
|
|
||||
+-- Geometric/spatial data (points, polygons, PostGIS)?
|
||||
| --> GiST
|
||||
|
|
||||
+-- Range types (int4range, tsrange, overlaps)?
|
||||
| --> GiST
|
||||
|
|
||||
+-- Nearest-neighbor / distance queries (KNN)?
|
||||
| --> GiST (or SP-GiST for partitioned space)
|
||||
|
|
||||
+-- Only a subset of rows match your WHERE clause?
|
||||
| --> Partial Index (any type + WHERE filter)
|
||||
|
|
||||
+-- Trigram similarity (LIKE '%pattern%', pg_trgm)?
|
||||
| --> GIN with pg_trgm (or GiST for smaller, slower)
|
||||
|
|
||||
+-- Hash equality only (= but never range)?
|
||||
--> Hash index (rarely better than B-tree in practice)
|
||||
```
|
||||
|
||||
## Index Type Comparison
|
||||
|
||||
| Type | Best For | Operators | Size | Write Cost | Notes |
|
||||
|------|----------|-----------|------|------------|-------|
|
||||
| **B-tree** | Equality, range, sorting | `= < > <= >= BETWEEN IN IS NULL` | Medium | Low | Default. Covers 90% of cases. |
|
||||
| **GIN** | Multi-valued data | `@> && @@ ? ?& ?|` | Large | High (slow updates) | Best for arrays, JSONB, full-text. Use `fastupdate=on`. |
|
||||
| **GiST** | Spatial, ranges, nearest-neighbor | `<< >> && @> <@ <->` | Medium | Medium | Lossy for some types. Supports KNN. |
|
||||
| **SP-GiST** | Partitioned search spaces | Same as GiST | Medium | Medium | Good for phone numbers, IP addresses, non-balanced trees. |
|
||||
| **BRIN** | Large sequential/append-only tables | `= < > <= >=` | Tiny | Very Low | 1000x smaller than B-tree. Only effective when physical order correlates with column values. |
|
||||
| **Hash** | Equality only | `=` | Medium | Low | WAL-logged since PG10. Rarely outperforms B-tree. |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Covering Index (Index-Only Scans)
|
||||
|
||||
Avoid heap lookups by including extra columns:
|
||||
|
||||
```sql
|
||||
-- Query: SELECT email, name FROM users WHERE email = ?
|
||||
CREATE INDEX idx_users_email_covering
|
||||
ON users (email) INCLUDE (name);
|
||||
```
|
||||
|
||||
### Partial Index (Filtered)
|
||||
|
||||
Index only the rows you actually query:
|
||||
|
||||
```sql
|
||||
-- Only index active orders (skip 95% of rows)
|
||||
CREATE INDEX idx_orders_active
|
||||
ON orders (created_at)
|
||||
WHERE status = 'active';
|
||||
```
|
||||
|
||||
### Composite Index (Multi-Column)
|
||||
|
||||
Column order matters -- put equality columns first, range columns last:
|
||||
|
||||
```sql
|
||||
-- Query: WHERE tenant_id = ? AND created_at > ?
|
||||
CREATE INDEX idx_events_tenant_date
|
||||
ON events (tenant_id, created_at);
|
||||
```
|
||||
|
||||
### Expression Index
|
||||
|
||||
Index a computed value:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_users_lower_email
|
||||
ON users (lower(email));
|
||||
```
|
||||
|
||||
### GIN for JSONB
|
||||
|
||||
```sql
|
||||
-- Index all keys and values in a JSONB column
|
||||
CREATE INDEX idx_metadata_gin
|
||||
ON products USING gin (metadata jsonb_path_ops);
|
||||
|
||||
-- Supports: metadata @> '{"color": "red"}'
|
||||
```
|
||||
|
||||
### GiST for Range Overlap
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_reservations_during
|
||||
ON reservations USING gist (during);
|
||||
|
||||
-- Supports: WHERE during && '[2025-01-01, 2025-01-31]'::daterange
|
||||
```
|
||||
|
||||
### BRIN for Time-Series
|
||||
|
||||
```sql
|
||||
-- Table has millions of rows inserted in timestamp order
|
||||
CREATE INDEX idx_logs_ts_brin
|
||||
ON logs USING brin (created_at)
|
||||
WITH (pages_per_range = 32);
|
||||
```
|
||||
|
||||
## Sizing Rules of Thumb
|
||||
|
||||
| Table Rows | B-tree Size | BRIN Size | GIN Size |
|
||||
|------------|-------------|-----------|----------|
|
||||
| 1M | ~20 MB | ~50 KB | ~30 MB |
|
||||
| 10M | ~200 MB | ~500 KB | ~300 MB |
|
||||
| 100M | ~2 GB | ~5 MB | ~3 GB |
|
||||
|
||||
## Diagnostic Queries
|
||||
|
||||
```sql
|
||||
-- Check if an index is being used
|
||||
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
|
||||
|
||||
-- Find unused indexes
|
||||
SELECT indexrelname, idx_scan
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE idx_scan = 0
|
||||
ORDER BY pg_relation_size(indexrelid) DESC;
|
||||
|
||||
-- Check index size
|
||||
SELECT pg_size_pretty(pg_relation_size('idx_name'));
|
||||
|
||||
-- Index bloat estimate
|
||||
SELECT * FROM pgstatindex('idx_name');
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Mistake | Why It Hurts |
|
||||
|---------|-------------|
|
||||
| Indexing every column | Slows writes, wastes disk, confuses planner |
|
||||
| Wrong column order in composite | Index cannot be used for the query |
|
||||
| GIN on tiny tables | Overhead exceeds benefit |
|
||||
| B-tree on low-cardinality columns | Planner prefers seq scan anyway |
|
||||
| Missing `CONCURRENTLY` on production | Locks the table during index build |
|
||||
| Forgetting `ANALYZE` after bulk load | Planner uses stale statistics |
|
||||
|
||||
## Safe Index Creation
|
||||
|
||||
```sql
|
||||
-- Non-blocking index creation (no table lock)
|
||||
CREATE INDEX CONCURRENTLY idx_name ON table (column);
|
||||
|
||||
-- Always run ANALYZE after bulk operations
|
||||
ANALYZE table;
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
-- =============================================================================
|
||||
-- Migration: [DESCRIPTION]
|
||||
-- Created: [DATE]
|
||||
-- Author: [AUTHOR]
|
||||
-- Ticket: [TICKET-ID]
|
||||
-- =============================================================================
|
||||
--
|
||||
-- SAFETY CHECKLIST (review before running):
|
||||
-- [ ] Tested on staging with production-size data
|
||||
-- [ ] Backward compatible with current application code
|
||||
-- [ ] No exclusive locks on large tables during peak hours
|
||||
-- [ ] Rollback (DOWN) section tested independently
|
||||
-- [ ] Estimated run time: ___
|
||||
-- [ ] Estimated lock duration: ___
|
||||
--
|
||||
|
||||
-- ============================================================
|
||||
-- UP MIGRATION
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Set a statement timeout to prevent long-running locks.
|
||||
-- Adjust as needed; remove for data-only migrations.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '30s';
|
||||
|
||||
-- ------------------------------------
|
||||
-- 1. Schema changes
|
||||
-- ------------------------------------
|
||||
|
||||
-- Add new table
|
||||
-- CREATE TABLE IF NOT EXISTS example (
|
||||
-- id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
-- name text NOT NULL,
|
||||
-- created_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- updated_at timestamptz NOT NULL DEFAULT now()
|
||||
-- );
|
||||
|
||||
-- Add column (safe: does not rewrite table)
|
||||
-- ALTER TABLE example ADD COLUMN IF NOT EXISTS description text;
|
||||
|
||||
-- Add column with default (PG 11+: does not rewrite table)
|
||||
-- ALTER TABLE example ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- Rename column (safe: metadata-only change)
|
||||
-- ALTER TABLE example RENAME COLUMN old_name TO new_name;
|
||||
|
||||
-- ------------------------------------
|
||||
-- 2. Constraints
|
||||
-- ------------------------------------
|
||||
|
||||
-- Add NOT NULL (requires all existing rows to satisfy it)
|
||||
-- ALTER TABLE example ALTER COLUMN name SET NOT NULL;
|
||||
|
||||
-- Add check constraint (NOT VALID avoids full table scan, then VALIDATE separately)
|
||||
-- ALTER TABLE example ADD CONSTRAINT chk_example_name CHECK (name <> '') NOT VALID;
|
||||
-- ALTER TABLE example VALIDATE CONSTRAINT chk_example_name;
|
||||
|
||||
-- Add foreign key (NOT VALID + VALIDATE pattern to avoid long locks)
|
||||
-- ALTER TABLE example ADD CONSTRAINT fk_example_parent
|
||||
-- FOREIGN KEY (parent_id) REFERENCES parent(id) NOT VALID;
|
||||
-- ALTER TABLE example VALIDATE CONSTRAINT fk_example_parent;
|
||||
|
||||
-- ------------------------------------
|
||||
-- 3. Indexes (use CONCURRENTLY outside transaction)
|
||||
-- ------------------------------------
|
||||
|
||||
-- NOTE: CREATE INDEX CONCURRENTLY cannot run inside a transaction.
|
||||
-- Run these statements separately after committing the transaction above.
|
||||
--
|
||||
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_example_name
|
||||
-- ON example (name);
|
||||
--
|
||||
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_example_created_at
|
||||
-- ON example USING brin (created_at);
|
||||
|
||||
-- ------------------------------------
|
||||
-- 4. Data migration
|
||||
-- ------------------------------------
|
||||
|
||||
-- Backfill in batches to avoid long transactions:
|
||||
-- UPDATE example SET description = 'default' WHERE description IS NULL;
|
||||
--
|
||||
-- For large tables, batch with:
|
||||
-- DO $$
|
||||
-- DECLARE
|
||||
-- batch_size int := 10000;
|
||||
-- rows_updated int;
|
||||
-- BEGIN
|
||||
-- LOOP
|
||||
-- UPDATE example
|
||||
-- SET description = 'default'
|
||||
-- WHERE id IN (
|
||||
-- SELECT id FROM example
|
||||
-- WHERE description IS NULL
|
||||
-- LIMIT batch_size
|
||||
-- FOR UPDATE SKIP LOCKED
|
||||
-- );
|
||||
-- GET DIAGNOSTICS rows_updated = ROW_COUNT;
|
||||
-- EXIT WHEN rows_updated = 0;
|
||||
-- RAISE NOTICE 'Updated % rows', rows_updated;
|
||||
-- COMMIT;
|
||||
-- END LOOP;
|
||||
-- END $$;
|
||||
|
||||
-- ------------------------------------
|
||||
-- 5. Permissions
|
||||
-- ------------------------------------
|
||||
|
||||
-- GRANT SELECT, INSERT, UPDATE ON example TO app_role;
|
||||
-- GRANT USAGE ON SEQUENCE example_id_seq TO app_role;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- DOWN MIGRATION (rollback)
|
||||
-- ============================================================
|
||||
-- Run this section to undo the UP migration.
|
||||
-- Test this independently before deploying the UP migration.
|
||||
|
||||
-- BEGIN;
|
||||
--
|
||||
-- -- Reverse data migration
|
||||
-- -- UPDATE example SET description = NULL;
|
||||
--
|
||||
-- -- Drop constraints
|
||||
-- -- ALTER TABLE example DROP CONSTRAINT IF EXISTS chk_example_name;
|
||||
-- -- ALTER TABLE example DROP CONSTRAINT IF EXISTS fk_example_parent;
|
||||
--
|
||||
-- -- Drop columns
|
||||
-- -- ALTER TABLE example DROP COLUMN IF EXISTS description;
|
||||
-- -- ALTER TABLE example DROP COLUMN IF EXISTS is_active;
|
||||
--
|
||||
-- -- Drop tables
|
||||
-- -- DROP TABLE IF EXISTS example;
|
||||
--
|
||||
-- COMMIT;
|
||||
--
|
||||
-- -- Drop indexes (outside transaction)
|
||||
-- -- DROP INDEX CONCURRENTLY IF EXISTS idx_example_name;
|
||||
-- -- DROP INDEX CONCURRENTLY IF EXISTS idx_example_created_at;
|
||||
@@ -1,70 +1,216 @@
|
||||
---
|
||||
name: docker
|
||||
description: >
|
||||
Use this skill whenever containerizing applications, writing Dockerfiles, configuring Docker Compose, or optimizing container images. Trigger on keywords like Docker, Dockerfile, container, docker-compose, multi-stage build, image, or container registry. Also applies when setting up local development environments with containers, debugging container networking, or preparing applications for container-based deployment in CI/CD pipelines.
|
||||
---
|
||||
|
||||
# Docker
|
||||
|
||||
## Description
|
||||
|
||||
Docker containerization including Dockerfiles, compose, and best practices.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Containerizing applications
|
||||
- Local development environments
|
||||
- CI/CD pipelines
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Serverless-only deployments where containers are not part of the architecture (e.g., pure AWS Lambda, Cloudflare Workers)
|
||||
- Local development without containers where native tooling is preferred
|
||||
- Simple scripts or utilities that do not need isolation or reproducible environments
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Multi-stage Dockerfile (Node.js)
|
||||
### 1. Multi-Stage Builds
|
||||
|
||||
Multi-stage builds separate build-time dependencies from the runtime image, producing
|
||||
smaller, more secure containers.
|
||||
|
||||
#### Python (builder + slim runtime)
|
||||
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# ---- Build stage ----
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/index.js"]
|
||||
```
|
||||
WORKDIR /build
|
||||
|
||||
### Python Dockerfile
|
||||
# Install build-only dependencies (gcc, etc.) needed by some wheels
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends gcc libpq-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
```dockerfile
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# ---- Runtime stage ----
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (caching)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy only the installed packages from the builder
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
COPY . .
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
COPY main.py .
|
||||
|
||||
# Run as non-root
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
#### Node.js (build + nginx/alpine)
|
||||
|
||||
```dockerfile
|
||||
# ---- Build stage ----
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first for layer caching
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source and build
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
RUN pnpm build
|
||||
|
||||
# ---- Runtime stage (static site served by nginx) ----
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Run as non-root
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chown -R nginx:nginx /var/cache/nginx && \
|
||||
chown -R nginx:nginx /var/log/nginx && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown -R nginx:nginx /var/run/nginx.pid
|
||||
USER nginx
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
#### Node.js (API server with alpine runtime)
|
||||
|
||||
```dockerfile
|
||||
# ---- Build stage ----
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN pnpm build
|
||||
|
||||
# Prune dev dependencies for a lighter production node_modules
|
||||
RUN pnpm prune --prod
|
||||
|
||||
# ---- Runtime stage ----
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
USER app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
```
|
||||
|
||||
#### Go (build + scratch)
|
||||
|
||||
```dockerfile
|
||||
# ---- Build stage ----
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Download dependencies first for caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build a static binary
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
|
||||
|
||||
# ---- Runtime stage (scratch = empty image) ----
|
||||
FROM scratch
|
||||
|
||||
# Copy CA certificates for HTTPS calls
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
# Copy the static binary
|
||||
COPY --from=builder /app/server /server
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/server"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Docker Compose for Development
|
||||
|
||||
A full-featured Compose file with services, volumes, networks, healthchecks, and
|
||||
environment variable management.
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: builder # Use builder stage for dev with hot-reload
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@db:5432/app
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: postgresql://user:pass@db:5432/app
|
||||
REDIS_URL: redis://redis:6379
|
||||
env_file:
|
||||
- .env.local # Local overrides (gitignored)
|
||||
volumes:
|
||||
- .:/app # Bind-mount source for hot-reload
|
||||
- /app/node_modules # Anonymous volume to preserve node_modules
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
@@ -72,23 +218,442 @@ services:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: pass
|
||||
POSTGRES_DB: app
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U user -d app"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- backend
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- backend
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.worker
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:pass@db:5432/app
|
||||
REDIS_URL: redis://redis:6379
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
networks:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
backend:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Layer Caching
|
||||
|
||||
Docker caches each layer. If a layer has not changed, every layer after it is also
|
||||
cached. Order instructions from least-frequently-changed to most-frequently-changed.
|
||||
|
||||
#### Optimal instruction order
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1. System dependencies (rarely change)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. Dependency manifests (change when adding packages)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 3. Application code (changes most often)
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
|
||||
```
|
||||
|
||||
#### .dockerignore patterns
|
||||
|
||||
Always include a `.dockerignore` to keep the build context small and avoid leaking
|
||||
secrets into layers.
|
||||
|
||||
```
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Dependencies (rebuilt inside container)
|
||||
node_modules
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
|
||||
# Build output
|
||||
dist
|
||||
build
|
||||
*.egg-info
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Docker files (not needed in context)
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# Documentation and misc
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
docs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Health Checks
|
||||
|
||||
Health checks let Docker (and orchestrators like Compose/Swarm/K8s) know when a
|
||||
container is actually ready to serve traffic.
|
||||
|
||||
#### HTTP health check with curl
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
```
|
||||
|
||||
#### HTTP health check with wget (alpine images without curl)
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
```
|
||||
|
||||
#### TCP port check (for non-HTTP services)
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD nc -z localhost 5432 || exit 1
|
||||
```
|
||||
|
||||
#### Python-native check (no extra binaries needed)
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
```
|
||||
|
||||
**Parameter reference:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|------------------|--------------------------------------------------|---------|
|
||||
| `--interval` | Time between checks | 30s |
|
||||
| `--timeout` | Max time for a single check | 30s |
|
||||
| `--start-period` | Grace period before checks count as failures | 0s |
|
||||
| `--retries` | Consecutive failures before marking unhealthy | 3 |
|
||||
|
||||
---
|
||||
|
||||
### 5. Security Hardening
|
||||
|
||||
#### Run as non-root user
|
||||
|
||||
```dockerfile
|
||||
# Debian/Ubuntu based images
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
USER app
|
||||
|
||||
# Alpine based images
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
USER app
|
||||
```
|
||||
|
||||
#### Use minimal base images
|
||||
|
||||
| Base Image | Size | Use Case |
|
||||
|--------------------|---------|---------------------------------------|
|
||||
| `alpine` | ~5 MB | General minimal base |
|
||||
| `*-slim` | ~50 MB | Debian-based with fewer packages |
|
||||
| `distroless` | ~20 MB | Google's no-shell, no-package-manager |
|
||||
| `scratch` | 0 MB | Static binaries only (Go, Rust) |
|
||||
|
||||
```dockerfile
|
||||
# Distroless for Python
|
||||
FROM gcr.io/distroless/python3-debian12
|
||||
COPY --from=builder /app /app
|
||||
CMD ["main.py"]
|
||||
```
|
||||
|
||||
#### Never put secrets in image layers
|
||||
|
||||
```dockerfile
|
||||
# BAD - secret is baked into image history
|
||||
COPY .env /app/.env
|
||||
RUN echo "API_KEY=secret123" >> /app/.env
|
||||
|
||||
# GOOD - pass secrets at runtime
|
||||
CMD ["python", "main.py"]
|
||||
# docker run -e API_KEY=secret123 myapp
|
||||
# or docker run --env-file .env myapp
|
||||
```
|
||||
|
||||
#### Multi-stage to exclude build tools
|
||||
|
||||
Build tools (compilers, package managers, source code) stay in the builder stage
|
||||
and never reach the runtime image. This reduces attack surface and image size.
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm build && pnpm prune --prod
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
# Only the built output and production deps are copied
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
USER node
|
||||
CMD ["node", "dist/index.js"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Environment Configuration
|
||||
|
||||
#### ARG vs ENV
|
||||
|
||||
| Directive | Available at | Persists in image | Use for |
|
||||
|-----------|-------------|-------------------|-----------------------------|
|
||||
| `ARG` | Build time | No | Build-time variables |
|
||||
| `ENV` | Build + run | Yes | Runtime configuration |
|
||||
|
||||
```dockerfile
|
||||
# ARG - only available during build
|
||||
ARG NODE_ENV=production
|
||||
ARG BUILD_VERSION=unknown
|
||||
|
||||
# ENV - available at build and runtime
|
||||
ENV NODE_ENV=${NODE_ENV}
|
||||
ENV APP_VERSION=${BUILD_VERSION}
|
||||
|
||||
# Build with: docker build --build-arg BUILD_VERSION=1.2.3 .
|
||||
```
|
||||
|
||||
#### .env files with Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
# Single .env file
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# Multiple files (later files override earlier ones)
|
||||
env_file:
|
||||
- .env.defaults
|
||||
- .env.local
|
||||
|
||||
# Inline environment variables (override env_file)
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
DEBUG: "true"
|
||||
```
|
||||
|
||||
#### Secrets management with Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
secrets:
|
||||
- db_password
|
||||
- api_key
|
||||
environment:
|
||||
DB_PASSWORD_FILE: /run/secrets/db_password
|
||||
|
||||
secrets:
|
||||
db_password:
|
||||
file: ./secrets/db_password.txt
|
||||
api_key:
|
||||
environment: API_KEY # Read from host environment
|
||||
```
|
||||
|
||||
Inside the container, secrets are mounted at `/run/secrets/<name>` as files.
|
||||
|
||||
---
|
||||
|
||||
### 7. Networking
|
||||
|
||||
#### Bridge networks for service isolation
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- frontend-net
|
||||
- backend-net # Can reach the API
|
||||
|
||||
api:
|
||||
build: ./api
|
||||
ports:
|
||||
- "8000:8000"
|
||||
networks:
|
||||
- backend-net # Reachable by frontend and workers
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
networks:
|
||||
- backend-net # Only reachable by api and workers
|
||||
# No ports exposed to host
|
||||
|
||||
worker:
|
||||
build: ./worker
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
networks:
|
||||
frontend-net:
|
||||
driver: bridge
|
||||
backend-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
#### Service discovery
|
||||
|
||||
Within a Docker Compose network, services reach each other by **service name**
|
||||
as the hostname.
|
||||
|
||||
```python
|
||||
# In the api service, connect to db using its service name
|
||||
DATABASE_URL = "postgresql://user:pass@db:5432/app"
|
||||
|
||||
# In the frontend service, call the api by service name
|
||||
API_URL = "http://api:8000"
|
||||
```
|
||||
|
||||
#### Exposing ports
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "3000:3000" # host:container, binds to 0.0.0.0
|
||||
- "127.0.0.1:3000:3000" # bind to localhost only (more secure)
|
||||
expose:
|
||||
- "3000" # expose to other containers only, not host
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use multi-stage builds
|
||||
2. Order commands for cache efficiency
|
||||
3. Use .dockerignore
|
||||
4. Run as non-root user
|
||||
5. Use specific image tags
|
||||
1. **Use multi-stage builds** -- Separate build dependencies from the runtime
|
||||
image. The final image should contain only what is needed to run the
|
||||
application.
|
||||
|
||||
2. **Pin image tags** -- Use `node:20.11-alpine` or a digest instead of
|
||||
`node:latest` or `node:20`. Floating tags lead to unpredictable builds.
|
||||
|
||||
3. **Order instructions for cache efficiency** -- Copy dependency manifests and
|
||||
install dependencies before copying application code. This ensures that code
|
||||
changes do not invalidate the dependency layer cache.
|
||||
|
||||
4. **Use .dockerignore** -- Exclude `.git`, `node_modules`, `__pycache__`, `.env`
|
||||
files, and anything not needed inside the container to keep the build context
|
||||
small and avoid leaking secrets.
|
||||
|
||||
5. **Run as non-root** -- Add a `USER` instruction to run the process as an
|
||||
unprivileged user. Never run production containers as root.
|
||||
|
||||
6. **Combine RUN commands** -- Merge related `RUN` instructions with `&&` to
|
||||
reduce layers and always clean up apt/apk caches in the same layer that
|
||||
installs packages.
|
||||
|
||||
7. **Use COPY instead of ADD** -- `COPY` is explicit and predictable. `ADD` has
|
||||
implicit behaviors (tar extraction, URL fetching) that can surprise you.
|
||||
|
||||
8. **Set explicit HEALTHCHECK** -- Define health checks in the Dockerfile so
|
||||
orchestrators know when the container is ready. This prevents routing traffic
|
||||
to containers that are still starting up.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Large images**: Use slim/alpine bases
|
||||
- **Cache busting**: Order COPY commands properly
|
||||
- **Root user**: Add USER instruction
|
||||
1. **Bloated images** -- Using full base images like `python:3.12` instead of
|
||||
`python:3.12-slim` adds hundreds of megabytes. Always prefer slim or alpine
|
||||
variants. Use multi-stage builds to exclude build tools.
|
||||
|
||||
2. **Cache invalidation by COPY order** -- Placing `COPY . .` before
|
||||
`RUN pip install` means every code change reinstalls all dependencies. Always
|
||||
copy the dependency manifest first, install, then copy the rest of the code.
|
||||
|
||||
3. **Running as root** -- Forgetting the `USER` instruction means the container
|
||||
process runs as root. If the application is compromised, the attacker has full
|
||||
control of the container filesystem.
|
||||
|
||||
4. **Secrets baked into layers** -- Using `COPY .env .` or `ARG` for secrets
|
||||
embeds them in the image layer history. Anyone with access to the image can
|
||||
extract them with `docker history`. Pass secrets at runtime via environment
|
||||
variables or Docker secrets.
|
||||
|
||||
5. **Missing .dockerignore** -- Without a `.dockerignore`, the entire directory
|
||||
(including `.git`, `node_modules`, `.env` files) is sent as build context.
|
||||
This slows builds, increases image size, and risks leaking credentials.
|
||||
|
||||
6. **Ignoring healthchecks in Compose** -- Using `depends_on` without
|
||||
`condition: service_healthy` means the dependent service starts as soon as
|
||||
the database container starts, not when the database is actually ready to
|
||||
accept connections. Always pair `depends_on` with healthchecks.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `devops/github-actions` - CI/CD workflows for building and deploying Docker containers
|
||||
- `security/owasp` - Security best practices for container hardening and vulnerability scanning
|
||||
- `patterns/logging` — Container logging and log aggregation
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# Dockerfile Best Practices Reference
|
||||
|
||||
Quick reference for writing efficient, secure, and maintainable Dockerfiles.
|
||||
|
||||
## Layer Ordering for Cache Optimization
|
||||
|
||||
Order instructions from least-frequently-changed to most-frequently-changed:
|
||||
|
||||
```dockerfile
|
||||
# 1. Base image (changes: rarely)
|
||||
FROM node:22-slim
|
||||
|
||||
# 2. System dependencies (changes: rarely)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 3. App dependency manifest (changes: sometimes)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# 4. Install dependencies (changes: sometimes, cached if manifests unchanged)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# 5. Copy source code (changes: frequently)
|
||||
COPY . .
|
||||
|
||||
# 6. Build step (changes: frequently)
|
||||
RUN pnpm build
|
||||
|
||||
# 7. Runtime config (changes: rarely, but placed last for clarity)
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/server.js"]
|
||||
```
|
||||
|
||||
**Key rule**: If a layer changes, all subsequent layers are rebuilt. Separate dependency installation from source code copying.
|
||||
|
||||
## Multi-Stage Builds
|
||||
|
||||
Reduce final image size by separating build and runtime stages.
|
||||
|
||||
```
|
||||
+-------------------+ +-------------------+
|
||||
| Build Stage | | Runtime Stage |
|
||||
| | | |
|
||||
| - Full toolchain | ---> | - Minimal base |
|
||||
| - Dev deps | | - Only artifacts |
|
||||
| - Source code | | - No build tools |
|
||||
| - Build output | | - No source code |
|
||||
+-------------------+ +-------------------+
|
||||
~800 MB ~80 MB
|
||||
```
|
||||
|
||||
**Benefits**: Smaller images, faster deploys, reduced attack surface, no build tools in production.
|
||||
|
||||
## Base Image Selection
|
||||
|
||||
| Image | Size | Use Case | Security | Package Manager |
|
||||
|-------|------|----------|----------|-----------------|
|
||||
| **alpine** | ~5 MB | Small images, CLI tools | Good (small surface) | apk |
|
||||
| **slim** (Debian) | ~80 MB | Most apps (Python, Node) | Good | apt |
|
||||
| **distroless** | ~20 MB | Production, no shell needed | Excellent (no shell) | None |
|
||||
| **scratch** | 0 MB | Static Go/Rust binaries | Excellent (nothing) | None |
|
||||
| **full** (Debian) | ~300 MB | Build stages, debugging | Fair (large surface) | apt |
|
||||
|
||||
### Recommendations by Language
|
||||
|
||||
| Language | Build Stage | Runtime Stage |
|
||||
|----------|-------------|---------------|
|
||||
| **Python** | `python:3.12-slim` | `python:3.12-slim` or `distroless/python3` |
|
||||
| **Node.js** | `node:22-slim` | `node:22-slim` or `distroless/nodejs22` |
|
||||
| **Go** | `golang:1.23` | `scratch` or `distroless/static` |
|
||||
| **Rust** | `rust:1.83` | `scratch` or `distroless/cc` |
|
||||
| **Java** | `eclipse-temurin:21-jdk` | `eclipse-temurin:21-jre-alpine` |
|
||||
|
||||
## Instruction Best Practices
|
||||
|
||||
### RUN: Combine and Clean Up
|
||||
|
||||
```dockerfile
|
||||
# BAD: Multiple layers, leftover cache
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl
|
||||
RUN apt-get install -y git
|
||||
|
||||
# GOOD: Single layer, cache cleaned
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
### COPY: Be Specific
|
||||
|
||||
```dockerfile
|
||||
# BAD: Copies everything, including .git, node_modules, etc.
|
||||
COPY . .
|
||||
|
||||
# GOOD: Copy only what's needed (use .dockerignore too)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY src/ ./src/
|
||||
COPY tsconfig.json ./
|
||||
```
|
||||
|
||||
### .dockerignore Essentials
|
||||
|
||||
```
|
||||
.git
|
||||
node_modules
|
||||
__pycache__
|
||||
.env
|
||||
*.log
|
||||
dist
|
||||
.venv
|
||||
.pytest_cache
|
||||
coverage
|
||||
.DS_Store
|
||||
```
|
||||
|
||||
### USER: Don't Run as Root
|
||||
|
||||
```dockerfile
|
||||
# Create non-root user
|
||||
RUN groupadd -r appuser && useradd -r -g appuser -s /bin/false appuser
|
||||
USER appuser
|
||||
```
|
||||
|
||||
### HEALTHCHECK
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
```
|
||||
|
||||
### ARG vs ENV
|
||||
|
||||
| Directive | Available at | Persists in image | Use for |
|
||||
|-----------|-------------|-------------------|---------|
|
||||
| `ARG` | Build time only | No | Build-time toggles, versions |
|
||||
| `ENV` | Build + runtime | Yes | App configuration |
|
||||
|
||||
```dockerfile
|
||||
ARG PYTHON_VERSION=3.12
|
||||
FROM python:${PYTHON_VERSION}-slim
|
||||
|
||||
ENV APP_ENV=production
|
||||
ENV PORT=8000
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
| Practice | Command/Example |
|
||||
|----------|----------------|
|
||||
| Pin base image digests | `FROM node:22-slim@sha256:abc123...` |
|
||||
| Run as non-root | `USER appuser` |
|
||||
| No secrets in layers | Use `--mount=type=secret` or build args |
|
||||
| Scan for vulnerabilities | `docker scout cves`, `trivy image` |
|
||||
| Read-only filesystem | `docker run --read-only` |
|
||||
| Drop capabilities | `docker run --cap-drop ALL` |
|
||||
| Use `.dockerignore` | Exclude `.env`, `.git`, credentials |
|
||||
| Minimal base image | Use slim/distroless/scratch |
|
||||
|
||||
### Secrets at Build Time (BuildKit)
|
||||
|
||||
```dockerfile
|
||||
# Mount a secret file without baking it into a layer
|
||||
RUN --mount=type=secret,id=npm_token \
|
||||
NPM_TOKEN=$(cat /run/secrets/npm_token) \
|
||||
npm install
|
||||
|
||||
# Build command:
|
||||
# docker build --secret id=npm_token,src=.npmrc .
|
||||
```
|
||||
|
||||
## Image Size Reduction Checklist
|
||||
|
||||
1. Use multi-stage builds
|
||||
2. Choose slim/alpine/distroless base
|
||||
3. Combine RUN commands
|
||||
4. Remove package manager caches (`rm -rf /var/lib/apt/lists/*`)
|
||||
5. Use `.dockerignore`
|
||||
6. Don't install dev dependencies in runtime stage
|
||||
7. Remove unnecessary files after build
|
||||
8. Use `--no-install-recommends` with apt
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Pitfall | Impact | Fix |
|
||||
|---------|--------|-----|
|
||||
| `COPY . .` before `npm install` | No dependency caching | Copy lockfile first, install, then copy source |
|
||||
| Using `latest` tag | Non-reproducible builds | Pin specific version tags or digests |
|
||||
| Secrets in `ENV` or `COPY` | Leaked in image layers | Use BuildKit secrets mount |
|
||||
| Running as root | Security vulnerability | Add `USER` directive |
|
||||
| No `.dockerignore` | Bloated context, slow builds | Add and maintain `.dockerignore` |
|
||||
| Installing build tools in final stage | Bloated image | Use multi-stage; build in first stage |
|
||||
| Not using `--frozen-lockfile` | Non-deterministic installs | Always use lockfile flags |
|
||||
@@ -0,0 +1,93 @@
|
||||
# =============================================================================
|
||||
# Multi-Stage Node.js Dockerfile
|
||||
# Usage:
|
||||
# docker build -t myapp .
|
||||
# docker run -p 3000:3000 myapp
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Install dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-slim AS deps
|
||||
|
||||
# Enable corepack for pnpm support.
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only package manifests first for dependency layer caching.
|
||||
# Dependencies are only reinstalled when these files change.
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install production and dev dependencies (dev deps needed for build step).
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Build the application
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies from the deps stage.
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/package.json /app/pnpm-lock.yaml ./
|
||||
|
||||
# Copy source code and config files needed for the build.
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
# COPY public/ ./public/ # Uncomment for Next.js or static assets
|
||||
|
||||
# Build the application.
|
||||
RUN pnpm build
|
||||
|
||||
# Remove dev dependencies after build to reduce size.
|
||||
RUN pnpm prune --prod
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Production runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:22-slim AS runtime
|
||||
|
||||
# Run as non-root for security.
|
||||
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /bin/false appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set production environment.
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
|
||||
# Copy only production artifacts from the builder stage.
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# For Next.js standalone output, use instead:
|
||||
# COPY --from=builder /app/.next/standalone ./
|
||||
# COPY --from=builder /app/.next/static ./.next/static
|
||||
# COPY --from=builder /app/public ./public
|
||||
|
||||
# Switch to non-root user.
|
||||
USER appuser
|
||||
|
||||
# Expose the application port.
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check -- adjust the endpoint to match your app.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:3000/health').then(r => { if (!r.ok) process.exit(1) })" || exit 1
|
||||
|
||||
# Run the application.
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
||||
# For Next.js standalone:
|
||||
# CMD ["node", "server.js"]
|
||||
|
||||
# For NestJS:
|
||||
# CMD ["node", "dist/main.js"]
|
||||
|
||||
# For Express with ts-node (dev only, not recommended for production):
|
||||
# CMD ["npx", "ts-node", "src/server.ts"]
|
||||
@@ -0,0 +1,78 @@
|
||||
# =============================================================================
|
||||
# Multi-Stage Python Dockerfile
|
||||
# Usage:
|
||||
# docker build -t myapp .
|
||||
# docker run -p 8000:8000 myapp
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Build dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
# Use slim for building - it has gcc and headers available via apt.
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
# Prevent Python from writing .pyc files and enable unbuffered output.
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Install build-time system dependencies (if any compiled packages need them).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies into a virtual environment.
|
||||
# Copying requirements first enables Docker layer caching --
|
||||
# dependencies are only reinstalled when requirements.txt changes.
|
||||
COPY requirements.txt .
|
||||
RUN python -m venv /app/.venv \
|
||||
&& /app/.venv/bin/pip install --no-cache-dir --upgrade pip \
|
||||
&& /app/.venv/bin/pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Runtime
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
# Prevent .pyc files and enable unbuffered output for logging.
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Install only runtime system dependencies (no build tools).
|
||||
# Add packages here if your app needs them at runtime (e.g., libpq for psycopg).
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# libpq5 \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create a non-root user for security.
|
||||
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /bin/false appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the virtual environment from the builder stage.
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
# Copy application source code.
|
||||
COPY src/ ./src/
|
||||
|
||||
# Switch to non-root user.
|
||||
USER appuser
|
||||
|
||||
# Expose the application port.
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check -- adjust the endpoint to match your app.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# Run the application.
|
||||
# For FastAPI/Uvicorn:
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
# For Django/Gunicorn:
|
||||
# CMD ["gunicorn", "src.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
|
||||
|
||||
# For a plain script:
|
||||
# CMD ["python", "-m", "src.main"]
|
||||
@@ -0,0 +1,100 @@
|
||||
# =============================================================================
|
||||
# Development Docker Compose
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yaml up
|
||||
# docker compose -f docker-compose.dev.yaml down -v # remove volumes too
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: builder # Use the build stage for dev (includes dev deps)
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app_dev
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
volumes:
|
||||
# Mount source code for hot-reload. Exclude node_modules.
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# Override CMD for development (hot-reload).
|
||||
command: ["pnpm", "dev"]
|
||||
# For Python:
|
||||
# command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "3000", "--reload"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL
|
||||
# ---------------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: app_dev
|
||||
volumes:
|
||||
# Persist data across restarts.
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
# Run init scripts on first start.
|
||||
# - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
# Persist data to disk every 60 seconds if at least 1 key changed.
|
||||
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: pgAdmin (database GUI)
|
||||
# ---------------------------------------------------------------------------
|
||||
# pgadmin:
|
||||
# image: dpage/pgadmin4:latest
|
||||
# ports:
|
||||
# - "5050:80"
|
||||
# environment:
|
||||
# PGADMIN_DEFAULT_EMAIL: admin@local.dev
|
||||
# PGADMIN_DEFAULT_PASSWORD: admin
|
||||
# depends_on:
|
||||
# - postgres
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: Mailpit (email testing)
|
||||
# ---------------------------------------------------------------------------
|
||||
# mailpit:
|
||||
# image: axllent/mailpit:latest
|
||||
# ports:
|
||||
# - "8025:8025" # Web UI
|
||||
# - "1025:1025" # SMTP
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
@@ -1,20 +1,33 @@
|
||||
---
|
||||
name: github-actions
|
||||
description: >
|
||||
Use this skill whenever setting up or modifying GitHub Actions CI/CD workflows, automating tests, builds, or deployments on GitHub. Trigger on keywords like GitHub Actions, workflow YAML, CI/CD pipeline, actions/checkout, matrix builds, workflow_dispatch, or .github/workflows. Also applies when configuring caching in workflows, managing GitHub secrets, or troubleshooting failed workflow runs.
|
||||
---
|
||||
|
||||
# GitHub Actions
|
||||
|
||||
## Description
|
||||
|
||||
GitHub Actions CI/CD workflows including testing, building, and deployment.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up CI/CD pipelines
|
||||
- Automating tests and builds
|
||||
- Deployment automation
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- GitLab CI projects using `.gitlab-ci.yml` configuration
|
||||
- Jenkins pipelines using Jenkinsfile or Groovy-based configuration
|
||||
- CircleCI, Travis CI, or other non-GitHub CI/CD systems
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Basic CI Workflow
|
||||
### 1. CI Pipeline
|
||||
|
||||
Complete CI workflow covering checkout, setup, install, lint, test, and build for
|
||||
both Python and Node.js projects.
|
||||
|
||||
#### Node.js CI Pipeline
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
@@ -25,64 +38,767 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm test -- --coverage
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: corepack enable
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm build
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist/
|
||||
retention-days: 5
|
||||
```
|
||||
|
||||
#### Python CI Pipeline
|
||||
|
||||
```yaml
|
||||
name: CI - Python
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- run: pip install -r requirements-dev.txt
|
||||
|
||||
- run: ruff check .
|
||||
|
||||
- run: ruff format --check .
|
||||
|
||||
- run: mypy src/
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: testdb
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- run: pip install -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
|
||||
run: pytest -v --cov=src --cov-report=xml
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-xml
|
||||
path: coverage.xml
|
||||
retention-days: 7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Matrix Strategy
|
||||
|
||||
Matrix builds run the same job across multiple combinations of OS, language
|
||||
version, or other variables.
|
||||
|
||||
#### OS and version matrix
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: [18, 20, 22]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "npm"
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: npm test
|
||||
```
|
||||
|
||||
### Matrix Builds
|
||||
#### Include and exclude
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
node: [18, 20]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python: ["3.11", "3.12"]
|
||||
exclude:
|
||||
# Skip Python 3.11 on Windows
|
||||
- os: windows-latest
|
||||
python: "3.11"
|
||||
include:
|
||||
# Add a specific combination with extra env
|
||||
- os: ubuntu-latest
|
||||
python: "3.13"
|
||||
experimental: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.experimental || false }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
python-version: ${{ matrix.python }}
|
||||
|
||||
- run: pip install -r requirements.txt
|
||||
|
||||
- run: pytest
|
||||
```
|
||||
|
||||
### Caching
|
||||
---
|
||||
|
||||
### 3. Caching
|
||||
|
||||
Caching avoids re-downloading dependencies on every run. Use `hashFiles` to
|
||||
generate cache keys from lockfiles so the cache invalidates when dependencies
|
||||
change.
|
||||
|
||||
#### npm cache
|
||||
|
||||
```yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
npm-${{ runner.os }}-
|
||||
```
|
||||
|
||||
### Secrets
|
||||
#### pnpm cache
|
||||
|
||||
```yaml
|
||||
- name: Deploy
|
||||
env:
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
run: deploy --key "$API_KEY"
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: echo "store=$(pnpm store path)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.store }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
```
|
||||
|
||||
#### pip cache
|
||||
|
||||
```yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-
|
||||
```
|
||||
|
||||
#### Docker layer cache
|
||||
|
||||
```yaml
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: myapp:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Reusable Workflows
|
||||
|
||||
Reusable workflows let you define a workflow once and call it from other
|
||||
workflows, reducing duplication across repositories.
|
||||
|
||||
#### Defining a reusable workflow (`.github/workflows/reusable-test.yml`)
|
||||
|
||||
```yaml
|
||||
name: Reusable Test Workflow
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node.js version to use"
|
||||
required: false
|
||||
type: string
|
||||
default: "20"
|
||||
working-directory:
|
||||
description: "Directory to run commands in"
|
||||
required: false
|
||||
type: string
|
||||
default: "."
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: "npm"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- run: npm test
|
||||
```
|
||||
|
||||
#### Calling a reusable workflow
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test-app:
|
||||
uses: ./.github/workflows/reusable-test.yml
|
||||
with:
|
||||
node-version: "20"
|
||||
working-directory: "packages/app"
|
||||
secrets: inherit # Pass all secrets to the called workflow
|
||||
|
||||
test-lib:
|
||||
uses: ./.github/workflows/reusable-test.yml
|
||||
with:
|
||||
node-version: "20"
|
||||
working-directory: "packages/lib"
|
||||
secrets: inherit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Composite Actions
|
||||
|
||||
Composite actions package multiple steps into a single reusable action. Unlike
|
||||
reusable workflows, they run inline within the calling job.
|
||||
|
||||
#### Action definition (`.github/actions/setup-project/action.yml`)
|
||||
|
||||
```yaml
|
||||
name: "Setup Project"
|
||||
description: "Install Node.js, enable corepack, and install dependencies"
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node.js version"
|
||||
required: false
|
||||
default: "20"
|
||||
install-command:
|
||||
description: "Command to install dependencies"
|
||||
required: false
|
||||
default: "pnpm install --frozen-lockfile"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Enable corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: echo "store=$(pnpm store path)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.store }}
|
||||
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: ${{ inputs.install-command }}
|
||||
```
|
||||
|
||||
#### Using the composite action
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ./.github/actions/setup-project
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- run: pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Deployment
|
||||
|
||||
Deployment workflows with environment protection rules, manual approval gates,
|
||||
and multi-stage promotion.
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target environment"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: corepack enable && pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm build
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist/
|
||||
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: staging
|
||||
url: https://staging.example.com
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist/
|
||||
|
||||
- name: Deploy to staging
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
echo "Deploying to staging..."
|
||||
# Replace with your actual deploy command
|
||||
# e.g., aws s3 sync, rsync, wrangler publish, etc.
|
||||
|
||||
deploy-production:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy-staging
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production'
|
||||
environment:
|
||||
name: production
|
||||
url: https://example.com
|
||||
# Production environment should have required reviewers configured
|
||||
# in GitHub Settings > Environments > production > Protection rules
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist/
|
||||
|
||||
- name: Deploy to production
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.PRODUCTION_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
echo "Deploying to production..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Artifacts
|
||||
|
||||
Artifacts let you share data between jobs in the same workflow or persist build
|
||||
outputs for later download.
|
||||
|
||||
#### Upload artifact
|
||||
|
||||
```yaml
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() # Upload even if tests fail
|
||||
with:
|
||||
name: test-results-${{ matrix.os }}-${{ matrix.node }}
|
||||
path: |
|
||||
test-results/
|
||||
coverage/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn # warn, error, or ignore
|
||||
```
|
||||
|
||||
#### Download artifact in another job
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci && npm run build
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- run: ls -la dist/
|
||||
```
|
||||
|
||||
#### Download all artifacts
|
||||
|
||||
```yaml
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-artifacts/
|
||||
# Each artifact is placed in a subdirectory named after the artifact
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Conditional Execution
|
||||
|
||||
Control when jobs and steps run using `if` expressions, job dependencies, and
|
||||
path filters.
|
||||
|
||||
#### Path filters on triggers
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "*.md"
|
||||
```
|
||||
|
||||
#### Conditional jobs
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'src/api/**'
|
||||
- 'requirements*.txt'
|
||||
frontend:
|
||||
- 'src/web/**'
|
||||
- 'package.json'
|
||||
|
||||
test-backend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: pip install -r requirements.txt && pytest
|
||||
|
||||
test-frontend:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci && npm test
|
||||
```
|
||||
|
||||
#### Conditional steps with if expressions
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Run only on main branch
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: echo "On main"
|
||||
|
||||
- name: Run only on pull requests
|
||||
if: github.event_name == 'pull_request'
|
||||
run: echo "PR event"
|
||||
|
||||
- name: Run only when previous step failed
|
||||
if: failure()
|
||||
run: echo "Something failed"
|
||||
|
||||
- name: Always run (cleanup)
|
||||
if: always()
|
||||
run: echo "Cleanup"
|
||||
|
||||
- name: Run only when a label is present
|
||||
if: contains(github.event.pull_request.labels.*.name, 'deploy')
|
||||
run: echo "Deploy label found"
|
||||
|
||||
- name: Skip for dependabot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
run: npm test
|
||||
```
|
||||
|
||||
#### Job dependencies
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Linting..."
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Testing..."
|
||||
|
||||
# Runs after both lint and test succeed
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- run: echo "Deploying..."
|
||||
|
||||
# Runs even if test fails, but only after it completes
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
if: always()
|
||||
steps:
|
||||
- run: echo "Test job status: ${{ needs.test.result }}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use caching for dependencies
|
||||
2. Run jobs in parallel when possible
|
||||
3. Use environment secrets
|
||||
4. Pin action versions
|
||||
5. Add proper triggers
|
||||
1. **Pin action versions with SHA** -- Use the full commit SHA instead of a
|
||||
mutable tag: `actions/checkout@b4ffde65f...` (or at minimum a major version
|
||||
tag like `@v4`). This prevents supply-chain attacks where a tag is moved.
|
||||
|
||||
2. **Use caching aggressively** -- Cache package manager stores (`~/.npm`,
|
||||
pnpm store, `~/.cache/pip`) and Docker layers. A well-cached pipeline can
|
||||
cut run times by 50-80%.
|
||||
|
||||
3. **Set minimal permissions** -- Add a top-level `permissions` block and grant
|
||||
only what is needed. Default permissions are overly broad and pose a security
|
||||
risk, especially for pull requests from forks.
|
||||
|
||||
4. **Run jobs in parallel** -- Structure independent jobs (lint, test, typecheck)
|
||||
to run concurrently. Use `needs` only when there is a real dependency between
|
||||
jobs.
|
||||
|
||||
5. **Use `fail-fast: false` in matrix builds** -- By default a failing matrix
|
||||
combination cancels all others. Setting `fail-fast: false` lets all
|
||||
combinations complete so you get the full picture of what is broken.
|
||||
|
||||
6. **Use environment protection rules** -- Configure required reviewers and wait
|
||||
timers on production environments in GitHub Settings. This adds a human gate
|
||||
before production deploys.
|
||||
|
||||
7. **Extract reusable workflows and composite actions** -- If the same steps
|
||||
appear in multiple workflows, factor them into a reusable workflow
|
||||
(`workflow_call`) or composite action to keep things DRY.
|
||||
|
||||
8. **Keep secrets out of logs** -- Never `echo` a secret. GitHub masks known
|
||||
secrets, but dynamically constructed values may leak. Use `::add-mask::` for
|
||||
runtime values that should be hidden.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Slow pipelines**: Add caching
|
||||
- **Secret exposure**: Never echo secrets
|
||||
- **Unpinned versions**: Use @v4 not @main
|
||||
1. **Unpinned action versions** -- Using `actions/checkout@main` means your
|
||||
workflow pulls whatever is on main today. A bad push to that action
|
||||
repository could break or compromise your builds. Pin to a tag (`@v4`) or
|
||||
SHA.
|
||||
|
||||
2. **Missing caching** -- Running `npm ci` or `pip install` from scratch on
|
||||
every run wastes minutes. Always configure caching for your package manager,
|
||||
or use the built-in `cache` option in setup actions (e.g.,
|
||||
`actions/setup-node` has a `cache` input).
|
||||
|
||||
3. **Overly broad triggers** -- Triggering on every push to every branch floods
|
||||
the queue. Restrict triggers to `main` and pull requests. Use `paths` or
|
||||
`paths-ignore` to skip runs when only docs or unrelated files change.
|
||||
|
||||
4. **Secret exposure in pull requests from forks** -- Secrets are NOT available
|
||||
in workflows triggered by `pull_request` from forks (by design). If your
|
||||
workflow needs secrets for fork PRs, use `pull_request_target` carefully and
|
||||
never check out untrusted code in that context.
|
||||
|
||||
5. **Large artifacts without retention limits** -- Uploading artifacts without
|
||||
setting `retention-days` uses the repository default (90 days), consuming
|
||||
storage quota. Set short retention for transient artifacts like test results
|
||||
and coverage reports.
|
||||
|
||||
6. **Ignoring `if: always()` for cleanup** -- Steps after a failure are skipped
|
||||
by default. If you need to upload test results, send notifications, or run
|
||||
cleanup regardless of prior step results, use `if: always()` or
|
||||
`if: failure()`.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `devops/docker` - Container patterns for building and deploying Dockerized applications in workflows
|
||||
- `testing/pytest` - Python test configuration for CI pipeline integration
|
||||
- `testing/vitest` - TypeScript/JavaScript test configuration for CI pipeline integration
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# GitHub Actions Syntax Quick Reference
|
||||
## Workflow File Structure
|
||||
|
||||
```yaml
|
||||
name: CI # Workflow name (shown in GitHub UI)
|
||||
|
||||
on: # Triggers
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions: # Workflow-level permissions
|
||||
contents: read
|
||||
|
||||
env: # Workflow-level environment variables
|
||||
NODE_ENV: test
|
||||
|
||||
jobs:
|
||||
build: # Job ID
|
||||
runs-on: ubuntu-latest # Runner
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # Action step
|
||||
- run: echo "Hello" # Shell step
|
||||
```
|
||||
|
||||
## Triggers (on:)
|
||||
|
||||
### Common Events
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release/**"]
|
||||
paths: ["src/**", "!src/**/*.test.ts"] # Path filtering
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch: # Manual trigger
|
||||
inputs:
|
||||
environment:
|
||||
type: choice
|
||||
options: [staging, production]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Every Monday at 6am UTC
|
||||
release:
|
||||
types: [published]
|
||||
workflow_call: # Reusable workflow
|
||||
inputs:
|
||||
node-version: { type: string, default: "22" }
|
||||
secrets:
|
||||
NPM_TOKEN: { required: true }
|
||||
```
|
||||
|
||||
## Jobs
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm test
|
||||
deploy:
|
||||
needs: [lint, test] # Runs after lint AND test succeed
|
||||
runs-on: ubuntu-latest
|
||||
steps: [...]
|
||||
```
|
||||
|
||||
### Matrix Strategy
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other jobs on failure
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
node: [20, 22]
|
||||
exclude:
|
||||
- os: macos-latest
|
||||
node: 20
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
node: 22
|
||||
coverage: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
### Action Step
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history (needed for some tools)
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
```
|
||||
|
||||
### Shell Step
|
||||
|
||||
```yaml
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
working-directory: ./packages/api
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
shell: bash
|
||||
continue-on-error: true # Don't fail the job
|
||||
timeout-minutes: 10
|
||||
```
|
||||
|
||||
### Multi-line Commands
|
||||
|
||||
```yaml
|
||||
- run: |
|
||||
echo "Line 1"
|
||||
echo "Line 2"
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Conditionals (if:)
|
||||
|
||||
```yaml
|
||||
# Run only on main branch
|
||||
- if: github.ref == 'refs/heads/main'
|
||||
|
||||
# Run only on pull requests
|
||||
- if: github.event_name == 'pull_request'
|
||||
|
||||
# Run only when previous step failed
|
||||
- if: failure()
|
||||
|
||||
# Always run (even if previous steps failed)
|
||||
- if: always()
|
||||
|
||||
# Run only when a matrix variable is set
|
||||
- if: matrix.coverage == true
|
||||
|
||||
# Run based on changed files (requires dorny/paths-filter or similar)
|
||||
- if: steps.filter.outputs.backend == 'true'
|
||||
|
||||
# Run on specific actor
|
||||
- if: github.actor != 'dependabot[bot]'
|
||||
```
|
||||
|
||||
## Environment and Secrets
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: production
|
||||
url: https://example.com
|
||||
env:
|
||||
APP_VERSION: ${{ github.sha }}
|
||||
steps:
|
||||
- run: deploy.sh
|
||||
env:
|
||||
API_KEY: ${{ secrets.API_KEY }} # Repository secret
|
||||
DEPLOY_TOKEN: ${{ vars.DEPLOY_TOKEN }} # Repository variable
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
### Built-in Cache (setup actions)
|
||||
|
||||
```yaml
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm" # Automatic pnpm cache
|
||||
```
|
||||
|
||||
### Manual Cache
|
||||
|
||||
```yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
.mypy_cache
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
```
|
||||
|
||||
## Artifacts
|
||||
|
||||
### Upload
|
||||
|
||||
```yaml
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
```
|
||||
|
||||
### Download (in another job)
|
||||
|
||||
```yaml
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: ./coverage
|
||||
```
|
||||
|
||||
## Services (Containers)
|
||||
|
||||
Define `services:` under a job with `image`, `env`, `ports`, and `options` (for health checks). Common: postgres, redis, mysql.
|
||||
|
||||
## Outputs (Passing Data Between Steps/Jobs)
|
||||
|
||||
```yaml
|
||||
# Step output: echo "key=value" >> "$GITHUB_OUTPUT"
|
||||
# Read in later step: ${{ steps.<step-id>.outputs.key }}
|
||||
# Job output: declare under jobs.<job>.outputs, read via needs.<job>.outputs.key
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
Common values: `contents: read`, `pull-requests: write`, `issues: write`, `packages: write`, `id-token: write` (OIDC).
|
||||
|
||||
## Reusable Workflows
|
||||
|
||||
Caller uses `uses: ./.github/workflows/reusable.yaml` with `with:` and `secrets: inherit`. Callee triggers on `workflow_call:` with `inputs:` and `secrets:` definitions.
|
||||
|
||||
## Useful Expressions
|
||||
|
||||
| Expression | Result |
|
||||
|-----------|--------|
|
||||
| `${{ github.sha }}` | Full commit SHA |
|
||||
| `${{ github.ref_name }}` | Branch or tag name |
|
||||
| `${{ github.event.pull_request.number }}` | PR number |
|
||||
| `${{ runner.os }}` | `Linux`, `macOS`, `Windows` |
|
||||
| `${{ hashFiles('**/lockfile') }}` | SHA256 of files |
|
||||
| `${{ contains(github.event.head_commit.message, '[skip ci]') }}` | Check commit message |
|
||||
@@ -0,0 +1,176 @@
|
||||
# =============================================================================
|
||||
# Node.js CI Pipeline
|
||||
# Runs: lint (eslint), type check (tsc), test (vitest), build
|
||||
# =============================================================================
|
||||
|
||||
name: Node CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.ts"
|
||||
- "**.tsx"
|
||||
- "**.js"
|
||||
- "**.jsx"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "tsconfig.json"
|
||||
- ".github/workflows/ci-node.yaml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.ts"
|
||||
- "**.tsx"
|
||||
- "**.js"
|
||||
- "**.jsx"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "tsconfig.json"
|
||||
- ".github/workflows/ci-node.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: "22"
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install dependencies (shared across jobs via cache)
|
||||
# ---------------------------------------------------------------------------
|
||||
install:
|
||||
name: Install
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lint with ESLint
|
||||
# ---------------------------------------------------------------------------
|
||||
lint:
|
||||
name: Lint
|
||||
needs: install
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type check with TypeScript compiler
|
||||
# ---------------------------------------------------------------------------
|
||||
type-check:
|
||||
name: Type Check
|
||||
needs: install
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tsc --noEmit
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test with Vitest
|
||||
# ---------------------------------------------------------------------------
|
||||
test:
|
||||
name: Test
|
||||
needs: install
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: pnpm vitest run --coverage --reporter=junit --outputFile=junit.xml
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: junit.xml
|
||||
retention-days: 7
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build
|
||||
# ---------------------------------------------------------------------------
|
||||
build:
|
||||
name: Build
|
||||
needs: [lint, type-check, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,164 @@
|
||||
# =============================================================================
|
||||
# Python CI Pipeline
|
||||
# Runs: lint (ruff), type check (mypy), test (pytest), coverage upload
|
||||
# =============================================================================
|
||||
|
||||
name: Python CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.py"
|
||||
- "requirements*.txt"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/ci-python.yaml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**.py"
|
||||
- "requirements*.txt"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/ci-python.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lint with Ruff
|
||||
# ---------------------------------------------------------------------------
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install ruff
|
||||
run: pip install ruff
|
||||
|
||||
- name: Ruff check (lint)
|
||||
run: ruff check .
|
||||
|
||||
- name: Ruff format (formatting)
|
||||
run: ruff format --check .
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type check with mypy
|
||||
# ---------------------------------------------------------------------------
|
||||
type-check:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install mypy
|
||||
|
||||
- name: Run mypy
|
||||
run: mypy src/ --ignore-missing-imports
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test with pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
# Uncomment to add a PostgreSQL service for integration tests.
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres:17-alpine
|
||||
# env:
|
||||
# POSTGRES_USER: test
|
||||
# POSTGRES_PASSWORD: test
|
||||
# POSTGRES_DB: test_db
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
# options: >-
|
||||
# --health-cmd pg_isready
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
pytest \
|
||||
--cov=src \
|
||||
--cov-report=xml:coverage.xml \
|
||||
--cov-report=term-missing \
|
||||
--junitxml=junit.xml \
|
||||
-v
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
# DATABASE_URL: postgresql://test:test@localhost:5432/test_db
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: junit.xml
|
||||
retention-days: 7
|
||||
|
||||
# Uncomment to upload coverage to Codecov.
|
||||
# - name: Upload to Codecov
|
||||
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# uses: codecov/codecov-action@v4
|
||||
# with:
|
||||
# files: coverage.xml
|
||||
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -1,91 +1,715 @@
|
||||
---
|
||||
name: django
|
||||
description: >
|
||||
Use this skill when working with Django web applications, Django ORM models, Django REST Framework APIs, or Django admin interfaces. Trigger for any mention of Django views, serializers, migrations, URL routing, class-based views, or Django middleware. Also applies when building Python web apps with templates, managing database schemas through Django migrations, or setting up admin panels.
|
||||
---
|
||||
|
||||
# Django
|
||||
|
||||
## Description
|
||||
|
||||
Django web framework with ORM, views, and REST framework patterns.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Python web applications
|
||||
- Admin interfaces
|
||||
- Django REST Framework APIs
|
||||
- Content-heavy sites with ORM-driven data models
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- FastAPI projects — use the `frameworks/fastapi` skill instead for async APIs and microservices
|
||||
- JavaScript/Node.js backends (Express, NestJS) — this skill is Python-only
|
||||
- Microservices architectures — consider FastAPI instead for lightweight, async services
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Models
|
||||
### 1. Models & ORM
|
||||
|
||||
#### Field types and relationships
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
class Organization(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class User(models.Model):
|
||||
class Role(models.TextChoices):
|
||||
ADMIN = "admin", "Administrator"
|
||||
MEMBER = "member", "Member"
|
||||
VIEWER = "viewer", "Viewer"
|
||||
|
||||
email = models.EmailField(unique=True)
|
||||
name = models.CharField(max_length=100)
|
||||
organization = models.ForeignKey(
|
||||
Organization,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="members",
|
||||
)
|
||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["email"]),
|
||||
models.Index(fields=["organization", "role"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["organization", "email"],
|
||||
name="unique_org_email",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
|
||||
class Project(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_projects")
|
||||
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name="projects")
|
||||
# OneToOneField for 1:1 relationships
|
||||
settings = models.OneToOneField(
|
||||
"ProjectSettings", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
|
||||
class ProjectSettings(models.Model):
|
||||
is_public = models.BooleanField(default=False)
|
||||
max_members = models.IntegerField(default=10)
|
||||
```
|
||||
|
||||
### Views (Class-based)
|
||||
#### Custom managers and QuerySet methods
|
||||
|
||||
```python
|
||||
from django.views.generic import ListView, DetailView
|
||||
class ActiveManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_active=True)
|
||||
|
||||
class UserListView(ListView):
|
||||
model = User
|
||||
template_name = 'users/list.html'
|
||||
context_object_name = 'users'
|
||||
class UserQuerySet(models.QuerySet):
|
||||
def admins(self):
|
||||
return self.filter(role=User.Role.ADMIN)
|
||||
|
||||
def in_organization(self, org_id):
|
||||
return self.filter(organization_id=org_id)
|
||||
|
||||
def with_project_count(self):
|
||||
return self.annotate(project_count=models.Count("owned_projects"))
|
||||
|
||||
class User(models.Model):
|
||||
# ... fields ...
|
||||
objects = UserQuerySet.as_manager()
|
||||
active = ActiveManager()
|
||||
```
|
||||
|
||||
#### F objects, Q objects, and annotations
|
||||
|
||||
```python
|
||||
from django.db.models import F, Q, Count, Avg, Sum, Value, When, Case
|
||||
|
||||
# F objects: reference model fields in queries
|
||||
Project.objects.filter(updated_at__gt=F("created_at"))
|
||||
User.objects.update(login_count=F("login_count") + 1) # Atomic increment
|
||||
|
||||
# Q objects: complex lookups with OR, AND, NOT
|
||||
User.objects.filter(
|
||||
Q(role="admin") | Q(role="member"),
|
||||
~Q(is_active=False), # NOT inactive
|
||||
)
|
||||
|
||||
# Annotations and aggregations
|
||||
orgs = Organization.objects.annotate(
|
||||
member_count=Count("members"),
|
||||
admin_count=Count("members", filter=Q(members__role="admin")),
|
||||
avg_projects=Avg("members__owned_projects"),
|
||||
).filter(member_count__gte=5)
|
||||
|
||||
# Conditional expressions
|
||||
users = User.objects.annotate(
|
||||
tier=Case(
|
||||
When(owned_projects__count__gte=10, then=Value("power")),
|
||||
When(owned_projects__count__gte=3, then=Value("active")),
|
||||
default=Value("starter"),
|
||||
)
|
||||
)
|
||||
|
||||
# Subqueries
|
||||
from django.db.models import Subquery, OuterRef
|
||||
latest_project = Project.objects.filter(
|
||||
owner=OuterRef("pk")
|
||||
).order_by("-created_at").values("title")[:1]
|
||||
users = User.objects.annotate(latest_project_title=Subquery(latest_project))
|
||||
```
|
||||
|
||||
### 2. Views
|
||||
|
||||
#### Function-based views
|
||||
|
||||
```python
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.http import JsonResponse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
|
||||
@login_required
|
||||
def project_detail(request, project_id):
|
||||
project = get_object_or_404(
|
||||
Project.objects.select_related("owner", "organization"),
|
||||
pk=project_id,
|
||||
)
|
||||
if request.method == "POST":
|
||||
form = ProjectForm(request.POST, instance=project)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("project-detail", project_id=project.id)
|
||||
else:
|
||||
form = ProjectForm(instance=project)
|
||||
|
||||
return render(request, "projects/detail.html", {
|
||||
"project": project,
|
||||
"form": form,
|
||||
})
|
||||
```
|
||||
|
||||
#### Class-based views
|
||||
|
||||
```python
|
||||
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
class ProjectListView(LoginRequiredMixin, ListView):
|
||||
model = Project
|
||||
template_name = "projects/list.html"
|
||||
context_object_name = "projects"
|
||||
paginate_by = 20
|
||||
|
||||
class UserDetailView(DetailView):
|
||||
model = User
|
||||
template_name = 'users/detail.html'
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset().select_related("owner", "organization")
|
||||
search = self.request.GET.get("q")
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(title__icontains=search) | Q(description__icontains=search)
|
||||
)
|
||||
return qs
|
||||
|
||||
class ProjectCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Project
|
||||
form_class = ProjectForm
|
||||
template_name = "projects/form.html"
|
||||
success_url = reverse_lazy("project-list")
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.owner = self.request.user
|
||||
form.instance.organization = self.request.user.organization
|
||||
return super().form_valid(form)
|
||||
|
||||
class ProjectDeleteView(PermissionRequiredMixin, DeleteView):
|
||||
model = Project
|
||||
permission_required = "projects.delete_project"
|
||||
success_url = reverse_lazy("project-list")
|
||||
```
|
||||
|
||||
### Django REST Framework
|
||||
#### Mixins for reuse
|
||||
|
||||
```python
|
||||
from rest_framework import serializers, viewsets
|
||||
class OrganizationFilterMixin:
|
||||
"""Filter queryset to the current user's organization."""
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(
|
||||
organization=self.request.user.organization
|
||||
)
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class ProjectListView(LoginRequiredMixin, OrganizationFilterMixin, ListView):
|
||||
model = Project
|
||||
# queryset is automatically filtered by organization
|
||||
```
|
||||
|
||||
#### API views with Django REST Framework
|
||||
|
||||
```python
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def project_list(request):
|
||||
if request.method == "GET":
|
||||
projects = Project.objects.filter(organization=request.user.organization)
|
||||
serializer = ProjectSerializer(projects, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = ProjectCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(owner=request.user)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
```
|
||||
|
||||
### 3. Migrations
|
||||
|
||||
#### Creating and running migrations
|
||||
|
||||
```bash
|
||||
# Generate migrations after model changes
|
||||
python manage.py makemigrations app_name
|
||||
|
||||
# Preview SQL without applying
|
||||
python manage.py sqlmigrate app_name 0001
|
||||
|
||||
# Apply migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Show migration status
|
||||
python manage.py showmigrations
|
||||
```
|
||||
|
||||
#### Data migrations with RunPython
|
||||
|
||||
```python
|
||||
from django.db import migrations
|
||||
|
||||
def populate_slugs(apps, schema_editor):
|
||||
Organization = apps.get_model("myapp", "Organization")
|
||||
from django.utils.text import slugify
|
||||
for org in Organization.objects.filter(slug=""):
|
||||
org.slug = slugify(org.name)
|
||||
org.save(update_fields=["slug"])
|
||||
|
||||
def reverse_populate_slugs(apps, schema_editor):
|
||||
pass # No-op reverse
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("myapp", "0005_add_slug_field"),
|
||||
]
|
||||
operations = [
|
||||
migrations.RunPython(populate_slugs, reverse_populate_slugs),
|
||||
]
|
||||
```
|
||||
|
||||
#### Squashing migrations
|
||||
|
||||
```bash
|
||||
# Squash migrations 0001 through 0010 into one
|
||||
python manage.py squashmigrations app_name 0001 0010
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
- Always provide a reverse function for `RunPython` (even if it is a no-op)
|
||||
- Use `apps.get_model()` in data migrations, never import models directly
|
||||
- Test migrations on a copy of production data before deploying
|
||||
|
||||
### 4. Forms
|
||||
|
||||
#### ModelForm with custom validation
|
||||
|
||||
```python
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
class ProjectForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'email', 'name', 'created_at']
|
||||
model = Project
|
||||
fields = ["title", "description", "tags"]
|
||||
widgets = {
|
||||
"description": forms.Textarea(attrs={"rows": 4}),
|
||||
"tags": forms.CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
def clean_title(self):
|
||||
title = self.cleaned_data["title"]
|
||||
if "test" in title.lower() and not self.instance.pk:
|
||||
raise ValidationError("Title cannot contain 'test' for new projects.")
|
||||
return title
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
title = cleaned.get("title", "")
|
||||
description = cleaned.get("description", "")
|
||||
if len(title) + len(description) < 20:
|
||||
raise ValidationError("Title + description must be at least 20 characters.")
|
||||
return cleaned
|
||||
```
|
||||
|
||||
### URLs
|
||||
#### Formsets
|
||||
|
||||
```python
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from django.forms import inlineformset_factory
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('users', UserViewSet)
|
||||
TaskFormSet = inlineformset_factory(
|
||||
Project,
|
||||
Task,
|
||||
fields=["title", "assigned_to", "due_date"],
|
||||
extra=2, # Number of empty forms
|
||||
can_delete=True,
|
||||
max_num=20,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('api/', include(router.urls)),
|
||||
# In a view
|
||||
def project_tasks(request, project_id):
|
||||
project = get_object_or_404(Project, pk=project_id)
|
||||
if request.method == "POST":
|
||||
formset = TaskFormSet(request.POST, instance=project)
|
||||
if formset.is_valid():
|
||||
formset.save()
|
||||
return redirect("project-detail", project_id=project.id)
|
||||
else:
|
||||
formset = TaskFormSet(instance=project)
|
||||
return render(request, "projects/tasks.html", {"formset": formset})
|
||||
```
|
||||
|
||||
### 5. Signals
|
||||
|
||||
```python
|
||||
from django.db.models.signals import post_save, pre_save, m2m_changed
|
||||
from django.dispatch import receiver
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
|
||||
@receiver(pre_save, sender=Project)
|
||||
def set_project_slug(sender, instance, **kwargs):
|
||||
if not instance.slug:
|
||||
from django.utils.text import slugify
|
||||
instance.slug = slugify(instance.title)
|
||||
|
||||
# Custom signals
|
||||
from django.dispatch import Signal
|
||||
|
||||
project_published = Signal() # Accepts sender
|
||||
|
||||
@receiver(project_published)
|
||||
def notify_members(sender, project, **kwargs):
|
||||
for member in project.organization.members.all():
|
||||
send_notification(member, f"Project '{project.title}' published")
|
||||
|
||||
# Firing a custom signal
|
||||
project_published.send(sender=Project, project=project)
|
||||
```
|
||||
|
||||
**When to use signals vs overriding `save()`:**
|
||||
- Use signals when the action is a side effect (notifications, logging, cache invalidation)
|
||||
- Override `save()` when the logic is core to the model's behavior (setting computed fields)
|
||||
|
||||
### 6. Middleware
|
||||
|
||||
```python
|
||||
import time
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
class TimingMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
request._start_time = time.perf_counter()
|
||||
|
||||
def process_response(self, request, response):
|
||||
if hasattr(request, "_start_time"):
|
||||
duration = time.perf_counter() - request._start_time
|
||||
response["X-Process-Time"] = f"{duration:.4f}"
|
||||
return response
|
||||
|
||||
# New-style middleware (function-based)
|
||||
def organization_middleware(get_response):
|
||||
def middleware(request):
|
||||
if request.user.is_authenticated:
|
||||
request.organization = request.user.organization
|
||||
else:
|
||||
request.organization = None
|
||||
response = get_response(request)
|
||||
return response
|
||||
return middleware
|
||||
|
||||
# Register in settings.py
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"myapp.middleware.organization_middleware", # Custom
|
||||
"myapp.middleware.TimingMiddleware", # Custom
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
```
|
||||
|
||||
### 7. Django REST Framework
|
||||
|
||||
#### Serializers
|
||||
|
||||
```python
|
||||
from rest_framework import serializers
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
project_count = serializers.IntegerField(read_only=True)
|
||||
full_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "name", "role", "full_name", "project_count", "created_at"]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
def get_full_name(self, obj):
|
||||
return f"{obj.name} ({obj.role})"
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
owner = UserSerializer(read_only=True)
|
||||
tags = serializers.SlugRelatedField(
|
||||
many=True, slug_field="name", queryset=Tag.objects.all()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "title", "description", "owner", "tags", "created_at"]
|
||||
|
||||
def validate_title(self, value):
|
||||
if len(value) < 3:
|
||||
raise serializers.ValidationError("Title must be at least 3 characters.")
|
||||
return value
|
||||
|
||||
class ProjectCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["title", "description", "tags"]
|
||||
```
|
||||
|
||||
#### ViewSets and routers
|
||||
|
||||
```python
|
||||
from rest_framework import viewsets, permissions, filters
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
class ProjectViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ProjectSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_fields = ["owner", "tags"]
|
||||
search_fields = ["title", "description"]
|
||||
ordering_fields = ["created_at", "title"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
return Project.objects.filter(
|
||||
organization=self.request.user.organization
|
||||
).select_related("owner").prefetch_related("tags")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
return ProjectCreateSerializer
|
||||
return ProjectSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
owner=self.request.user,
|
||||
organization=self.request.user.organization,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def publish(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
project.is_published = True
|
||||
project.save(update_fields=["is_published"])
|
||||
return Response({"status": "published"})
|
||||
|
||||
# urls.py
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("projects", ProjectViewSet, basename="project")
|
||||
router.register("users", UserViewSet, basename="user")
|
||||
|
||||
urlpatterns = [
|
||||
path("api/", include(router.urls)),
|
||||
]
|
||||
```
|
||||
|
||||
#### Permissions
|
||||
|
||||
```python
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
class IsOrganizationAdmin(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
return (
|
||||
request.user.is_authenticated
|
||||
and request.user.role == User.Role.ADMIN
|
||||
)
|
||||
|
||||
class IsOwnerOrReadOnly(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return obj.owner == request.user
|
||||
```
|
||||
|
||||
#### Pagination
|
||||
|
||||
```python
|
||||
from rest_framework.pagination import PageNumberPagination, CursorPagination
|
||||
|
||||
class StandardPagination(PageNumberPagination):
|
||||
page_size = 20
|
||||
page_size_query_param = "page_size"
|
||||
max_page_size = 100
|
||||
|
||||
class TimelinePagination(CursorPagination):
|
||||
page_size = 50
|
||||
ordering = "-created_at"
|
||||
|
||||
# settings.py
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PAGINATION_CLASS": "myapp.pagination.StandardPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Admin
|
||||
|
||||
```python
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
class TaskInline(admin.TabularInline):
|
||||
model = Task
|
||||
extra = 0
|
||||
fields = ["title", "assigned_to", "status", "due_date"]
|
||||
readonly_fields = ["created_at"]
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "owner_name", "organization", "tag_list", "created_at"]
|
||||
list_filter = ["organization", "tags", "created_at"]
|
||||
search_fields = ["title", "description", "owner__email"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
autocomplete_fields = ["owner", "organization"]
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
date_hierarchy = "created_at"
|
||||
inlines = [TaskInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
"fields": ("title", "slug", "description"),
|
||||
}),
|
||||
("Ownership", {
|
||||
"fields": ("owner", "organization", "tags"),
|
||||
}),
|
||||
("Metadata", {
|
||||
"classes": ("collapse",),
|
||||
"fields": ("created_at", "updated_at"),
|
||||
}),
|
||||
)
|
||||
|
||||
def owner_name(self, obj):
|
||||
return obj.owner.name
|
||||
owner_name.short_description = "Owner"
|
||||
owner_name.admin_order_field = "owner__name"
|
||||
|
||||
def tag_list(self, obj):
|
||||
return ", ".join(t.name for t in obj.tags.all())
|
||||
tag_list.short_description = "Tags"
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related(
|
||||
"owner", "organization"
|
||||
).prefetch_related("tags")
|
||||
|
||||
# Custom admin actions
|
||||
@admin.action(description="Mark selected projects as published")
|
||||
def make_published(self, request, queryset):
|
||||
count = queryset.update(is_published=True)
|
||||
self.message_user(request, f"{count} projects published.")
|
||||
|
||||
actions = [make_published]
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ["email", "name", "organization", "role", "is_active"]
|
||||
list_filter = ["role", "is_active", "organization"]
|
||||
search_fields = ["email", "name"]
|
||||
list_editable = ["role", "is_active"]
|
||||
list_per_page = 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use class-based views for standard CRUD
|
||||
2. Define model methods for business logic
|
||||
3. Use serializers for validation
|
||||
4. Add proper permissions
|
||||
5. Use select_related/prefetch_related for queries
|
||||
1. **Use `select_related` and `prefetch_related` on every query that touches relations** — `select_related` for ForeignKey/OneToOne (SQL JOIN), `prefetch_related` for ManyToMany and reverse ForeignKey (separate query). Check queries with `django-debug-toolbar`.
|
||||
|
||||
2. **Keep business logic in model methods or service functions, not in views** — views should handle HTTP, forms should handle validation, models/services should handle domain logic. This makes code testable without needing HTTP.
|
||||
|
||||
3. **Use `get_queryset()` for dynamic filtering instead of hardcoding querysets** — both in views and DRF ViewSets. This enables mixin composition and per-request filtering (e.g., by organization).
|
||||
|
||||
4. **Write data migrations for schema changes that require backfills** — never assume fields can be added as non-nullable without a migration to populate existing rows. Use `RunPython` with a reverse function.
|
||||
|
||||
5. **Configure Django REST Framework defaults in settings** — set `DEFAULT_PAGINATION_CLASS`, `DEFAULT_PERMISSION_CLASSES`, `DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK` dict to avoid repeating yourself on each ViewSet.
|
||||
|
||||
6. **Use `TextChoices` / `IntegerChoices` for enum fields** — they integrate with admin filters, serializer validation, and migrations automatically. Avoid plain strings or integers for status/role fields.
|
||||
|
||||
7. **Index frequently queried fields** — add `db_index=True` on individual fields or use `Meta.indexes` for composite indexes. Add `UniqueConstraint` for business-rule uniqueness.
|
||||
|
||||
8. **Use Django's `transaction.atomic()` for multi-step writes** — wrap create/update sequences that must succeed or fail together. DRF's `perform_create` and `perform_update` are good places for this.
|
||||
|
||||
```python
|
||||
from django.db import transaction
|
||||
|
||||
@transaction.atomic
|
||||
def transfer_project(project, new_owner):
|
||||
old_owner = project.owner
|
||||
project.owner = new_owner
|
||||
project.save(update_fields=["owner"])
|
||||
AuditLog.objects.create(
|
||||
action="transfer",
|
||||
project=project,
|
||||
from_user=old_owner,
|
||||
to_user=new_owner,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **N+1 queries**: Use select_related/prefetch_related
|
||||
- **Missing migrations**: Run makemigrations
|
||||
- **No validation**: Use serializers properly
|
||||
1. **N+1 queries** — accessing `project.owner.name` in a loop without `select_related("owner")` fires one query per iteration. Use `django-debug-toolbar` or `nplusone` to detect these. Always optimize queryset in `get_queryset()`.
|
||||
|
||||
2. **Importing models directly in data migrations** — models change over time, but migrations are frozen. Always use `apps.get_model("app_name", "ModelName")` inside `RunPython` functions, never `from myapp.models import Model`.
|
||||
|
||||
3. **Forgetting to call `full_clean()` in model saves** — Django's `save()` does NOT run validators by default. Only forms and serializers call `full_clean()`. If you save models directly, add explicit validation.
|
||||
|
||||
4. **Circular imports between apps** — referencing models across apps can cause import cycles. Use string references in ForeignKey: `models.ForeignKey("other_app.ModelName", ...)` instead of importing the class.
|
||||
|
||||
5. **Overusing signals for core logic** — signals make code harder to trace and debug. Use them for side effects (sending emails, cache invalidation), not for core domain logic. If logic should always run on save, override `save()` instead.
|
||||
|
||||
6. **Returning entire QuerySets from service functions** — QuerySets are lazy, which is usually good, but returning them from service layers can lead to unexpected queries executing in templates. Use `.values()`, `.values_list()`, or serialize to dicts when crossing layer boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `languages/python` — Python language patterns and best practices
|
||||
- `databases/postgresql` — Database integration and query optimization
|
||||
- `testing/pytest` — Testing Django applications with pytest-django
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# Django Patterns Quick Reference
|
||||
|
||||
## QuerySet Patterns
|
||||
|
||||
### select_related (FK/OneToOne - single JOIN)
|
||||
|
||||
```python
|
||||
# BAD: N+1 queries
|
||||
for order in Order.objects.all():
|
||||
print(order.customer.name) # Hits DB each iteration
|
||||
|
||||
# GOOD: 1 query with JOIN
|
||||
for order in Order.objects.select_related("customer"):
|
||||
print(order.customer.name)
|
||||
|
||||
# Chain through multiple FKs
|
||||
Order.objects.select_related("customer__company")
|
||||
```
|
||||
|
||||
### prefetch_related (M2M/reverse FK - separate query)
|
||||
|
||||
```python
|
||||
# BAD: N+1 on reverse FK
|
||||
for author in Author.objects.all():
|
||||
print(author.book_set.all()) # Query per author
|
||||
|
||||
# GOOD: 2 queries total
|
||||
for author in Author.objects.prefetch_related("books"):
|
||||
print(author.books.all())
|
||||
|
||||
# Custom prefetch with filtering
|
||||
from django.db.models import Prefetch
|
||||
|
||||
Author.objects.prefetch_related(
|
||||
Prefetch("books", queryset=Book.objects.filter(published=True), to_attr="published_books")
|
||||
)
|
||||
```
|
||||
|
||||
### F Objects (reference model fields in queries)
|
||||
|
||||
```python
|
||||
from django.db.models import F
|
||||
|
||||
Product.objects.filter(stock__lt=F("reorder_level")) # Compare fields
|
||||
Product.objects.filter(id=1).update(stock=F("stock") - 1) # Atomic update
|
||||
Order.objects.filter(amount__gt=F("customer__credit_limit")) # Across relations
|
||||
```
|
||||
|
||||
### Q Objects (complex lookups with OR/NOT)
|
||||
|
||||
```python
|
||||
from django.db.models import Q
|
||||
|
||||
# OR
|
||||
User.objects.filter(Q(role="admin") | Q(is_superuser=True))
|
||||
|
||||
# NOT
|
||||
User.objects.filter(~Q(status="banned"))
|
||||
|
||||
# Complex combinations
|
||||
User.objects.filter(
|
||||
(Q(role="admin") | Q(role="staff")) & ~Q(status="inactive")
|
||||
)
|
||||
|
||||
# Dynamic query building
|
||||
conditions = Q()
|
||||
if name: conditions &= Q(name__icontains=name)
|
||||
if email: conditions &= Q(email__icontains=email)
|
||||
User.objects.filter(conditions)
|
||||
```
|
||||
|
||||
### Subquery and OuterRef
|
||||
|
||||
```python
|
||||
from django.db.models import Subquery, OuterRef, Exists
|
||||
|
||||
# Subquery: latest order date per customer
|
||||
latest_order = Order.objects.filter(
|
||||
customer=OuterRef("pk")
|
||||
).order_by("-created_at").values("created_at")[:1]
|
||||
|
||||
Customer.objects.annotate(last_order=Subquery(latest_order))
|
||||
|
||||
# Exists: customers with orders
|
||||
Customer.objects.annotate(
|
||||
has_orders=Exists(Order.objects.filter(customer=OuterRef("pk")))
|
||||
).filter(has_orders=True)
|
||||
```
|
||||
|
||||
### Aggregation
|
||||
|
||||
```python
|
||||
from django.db.models import Count, Sum, Avg
|
||||
|
||||
# Aggregate (returns dict)
|
||||
Order.objects.aggregate(total=Sum("amount"), avg=Avg("amount"))
|
||||
|
||||
# Annotate (per-row)
|
||||
Customer.objects.annotate(order_count=Count("orders"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Patterns
|
||||
|
||||
### Abstract Base Model
|
||||
|
||||
```python
|
||||
class TimestampMixin(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True # No DB table created
|
||||
|
||||
class Order(TimestampMixin):
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
# Inherits created_at, updated_at
|
||||
```
|
||||
|
||||
### Proxy Model (same table, different behavior)
|
||||
|
||||
```python
|
||||
class PendingOrderManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(status="pending")
|
||||
|
||||
class PendingOrder(Order):
|
||||
objects = PendingOrderManager()
|
||||
class Meta:
|
||||
proxy = True # Same DB table as Order
|
||||
```
|
||||
|
||||
### Custom Manager and QuerySet
|
||||
|
||||
```python
|
||||
class PublishedQuerySet(models.QuerySet):
|
||||
def published(self):
|
||||
return self.filter(status="published")
|
||||
|
||||
def by_author(self, author):
|
||||
return self.filter(author=author)
|
||||
|
||||
class Article(models.Model):
|
||||
objects = PublishedQuerySet.as_manager()
|
||||
|
||||
# Chainable: Article.objects.published().by_author(user)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Patterns
|
||||
|
||||
### Class-Based View Mixins
|
||||
|
||||
| Mixin | Purpose |
|
||||
|-------|---------|
|
||||
| `LoginRequiredMixin` | Require authentication |
|
||||
| `PermissionRequiredMixin` | Require specific permission |
|
||||
| `UserPassesTestMixin` | Custom permission test |
|
||||
| `FormView` | Handle form display + submission |
|
||||
| `CreateView` / `UpdateView` | Model form CRUD |
|
||||
| `ListView` / `DetailView` | Read operations |
|
||||
|
||||
```python
|
||||
class OrderListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
model = Order
|
||||
permission_required = "orders.view_order"
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(customer=self.request.user)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Django REST Framework Patterns
|
||||
|
||||
### Nested Serializers
|
||||
|
||||
```python
|
||||
class AddressSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Address
|
||||
fields = ["street", "city", "zip_code"]
|
||||
|
||||
class CustomerSerializer(serializers.ModelSerializer):
|
||||
address = AddressSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ["id", "name", "address"]
|
||||
|
||||
def create(self, validated_data):
|
||||
address_data = validated_data.pop("address")
|
||||
address = Address.objects.create(**address_data)
|
||||
return Customer.objects.create(address=address, **validated_data)
|
||||
```
|
||||
|
||||
### Custom Permissions
|
||||
|
||||
```python
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
class IsOwner(BasePermission):
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return obj.owner == request.user
|
||||
|
||||
class IsAdminOrReadOnly(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
return True
|
||||
return request.user.is_staff
|
||||
|
||||
# Usage
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, IsOwner]
|
||||
```
|
||||
|
||||
### ViewSet Actions
|
||||
|
||||
```python
|
||||
from rest_framework.decorators import action
|
||||
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
queryset = Order.objects.all()
|
||||
serializer_class = OrderSerializer
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def cancel(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
order.cancel()
|
||||
return Response({"status": "cancelled"})
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def summary(self, request):
|
||||
return Response(self.get_queryset().aggregate(total=Sum("amount"), count=Count("id")))
|
||||
```
|
||||
|
||||
### Filtering (django-filter)
|
||||
|
||||
```python
|
||||
class OrderFilter(django_filters.FilterSet):
|
||||
min_amount = django_filters.NumberFilter(field_name="amount", lookup_expr="gte")
|
||||
max_amount = django_filters.NumberFilter(field_name="amount", lookup_expr="lte")
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ["status", "customer"]
|
||||
```
|
||||
@@ -1,89 +1,680 @@
|
||||
---
|
||||
name: fastapi
|
||||
description: >
|
||||
Use this skill when building REST APIs with Python and FastAPI, creating async web applications, or generating OpenAPI/Swagger documentation. Trigger for any mention of FastAPI, Pydantic models, async Python endpoints, dependency injection in Python APIs, or APIRouter patterns. Also applies when setting up Python microservices, adding request validation with Pydantic, or configuring ASGI applications.
|
||||
---
|
||||
|
||||
# FastAPI
|
||||
|
||||
## Description
|
||||
|
||||
FastAPI web framework with async patterns, Pydantic validation, and OpenAPI documentation.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building REST APIs with Python
|
||||
- Async web applications
|
||||
- OpenAPI/Swagger documentation needed
|
||||
- Python microservices
|
||||
- WebSocket real-time applications
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Django projects — use the `frameworks/django` skill instead
|
||||
- JavaScript/Node.js backends (Express, NestJS) — this skill is Python-only
|
||||
- Non-API applications such as CLI tools, desktop apps, or batch processing scripts
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Route Definition
|
||||
### 1. Project Structure
|
||||
|
||||
Recommended layout for medium-large FastAPI applications:
|
||||
|
||||
```
|
||||
project/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app creation, startup/shutdown
|
||||
│ ├── config.py # Settings via pydantic-settings
|
||||
│ ├── dependencies.py # Shared dependencies
|
||||
│ ├── exceptions.py # Custom exception handlers
|
||||
│ ├── middleware.py # Custom middleware
|
||||
│ ├── api/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # Root router aggregating all sub-routers
|
||||
│ │ ├── v1/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── users.py # /api/v1/users endpoints
|
||||
│ │ │ ├── items.py # /api/v1/items endpoints
|
||||
│ │ │ └── auth.py # /api/v1/auth endpoints
|
||||
│ │ └── v2/ # Future API version
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user.py # SQLAlchemy / SQLModel ORM models
|
||||
│ │ └── item.py
|
||||
│ ├── schemas/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user.py # Pydantic request/response schemas
|
||||
│ │ └── item.py
|
||||
│ ├── services/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user_service.py # Business logic layer
|
||||
│ │ └── item_service.py
|
||||
│ ├── repositories/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user_repo.py # Data access layer
|
||||
│ │ └── item_repo.py
|
||||
│ ├── core/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── database.py # DB engine, session factory
|
||||
│ │ └── security.py # JWT, hashing, auth utils
|
||||
│ └── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Fixtures: test client, test DB
|
||||
│ ├── test_users.py
|
||||
│ └── test_items.py
|
||||
├── alembic/ # Database migrations
|
||||
│ ├── env.py
|
||||
│ └── versions/
|
||||
├── alembic.ini
|
||||
├── pyproject.toml
|
||||
├── Dockerfile
|
||||
└── .env
|
||||
```
|
||||
|
||||
**Key conventions:**
|
||||
- Separate `schemas/` (Pydantic) from `models/` (ORM) to keep concerns clean
|
||||
- Use `services/` for business logic, `repositories/` for data access
|
||||
- Version API routes under `api/v1/`, `api/v2/` for backward compatibility
|
||||
- Keep `main.py` thin — it only wires things together
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
# app/main.py
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from app.api.router import api_router
|
||||
from app.config import settings
|
||||
from app.core.database import engine
|
||||
from app.middleware import add_middleware
|
||||
|
||||
app = FastAPI()
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: create tables, warm caches, connect to services
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown: close connections, flush buffers
|
||||
await engine.dispose()
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
add_middleware(app)
|
||||
app.include_router(api_router, prefix="/api")
|
||||
```
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
### 2. Route Patterns
|
||||
|
||||
@app.post("/users", response_model=UserResponse, status_code=201)
|
||||
async def create_user(user: UserCreate):
|
||||
# Create user logic
|
||||
return UserResponse(id=1, **user.model_dump())
|
||||
#### APIRouter with tags, prefixes, and dependencies
|
||||
|
||||
@app.get("/users/{user_id}", response_model=UserResponse)
|
||||
async def get_user(user_id: int):
|
||||
user = await get_user_by_id(user_id)
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, Query, Path, Body, HTTPException, status
|
||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate, UserList
|
||||
from app.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/users",
|
||||
tags=["users"],
|
||||
dependencies=[Depends(get_current_user)], # Applied to all routes
|
||||
responses={401: {"description": "Not authenticated"}},
|
||||
)
|
||||
|
||||
# Path parameters with validation
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int = Path(..., gt=0, description="The ID of the user to retrieve"),
|
||||
):
|
||||
user = await user_service.get(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
# Query parameters with defaults and validation
|
||||
@router.get("/", response_model=UserList)
|
||||
async def list_users(
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Max records to return"),
|
||||
search: str | None = Query(None, min_length=1, max_length=100),
|
||||
sort_by: str = Query("created_at", pattern="^(created_at|name|email)$"),
|
||||
):
|
||||
users = await user_service.list(skip=skip, limit=limit, search=search)
|
||||
return users
|
||||
|
||||
# Request body with status codes
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=UserResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new user",
|
||||
description="Creates a user account and sends a welcome email.",
|
||||
)
|
||||
async def create_user(user: UserCreate = Body(...)):
|
||||
return await user_service.create(user)
|
||||
|
||||
# Multiple response models for different status codes
|
||||
@router.put("/{user_id}", response_model=UserResponse, responses={
|
||||
404: {"description": "User not found"},
|
||||
409: {"description": "Email already taken"},
|
||||
})
|
||||
async def update_user(user_id: int, user: UserUpdate):
|
||||
return await user_service.update(user_id, user)
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(user_id: int):
|
||||
await user_service.delete(user_id)
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
#### Router aggregation
|
||||
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
# app/api/router.py
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import users, items, auth
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router, prefix="/v1")
|
||||
api_router.include_router(users.router, prefix="/v1")
|
||||
api_router.include_router(items.router, prefix="/v1")
|
||||
```
|
||||
|
||||
### 3. Dependency Injection
|
||||
|
||||
#### Basic dependency with Depends()
|
||||
|
||||
```python
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
async def verify_api_key(x_api_key: str = Header(...)):
|
||||
if x_api_key != settings.API_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
return x_api_key
|
||||
```
|
||||
|
||||
#### Nested dependencies
|
||||
|
||||
```python
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
yield session # yield dependency — cleanup runs after response
|
||||
|
||||
@app.get("/users")
|
||||
async def list_users(db: AsyncSession = Depends(get_db)):
|
||||
return await db.execute(select(User))
|
||||
async def get_user_repo(db: AsyncSession = Depends(get_db)) -> UserRepository:
|
||||
return UserRepository(db)
|
||||
|
||||
async def get_user_service(
|
||||
repo: UserRepository = Depends(get_user_repo),
|
||||
) -> UserService:
|
||||
return UserService(repo)
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(service: UserService = Depends(get_user_service)):
|
||||
return await service.list_all()
|
||||
```
|
||||
|
||||
### Router Organization
|
||||
#### Yield dependencies for cleanup
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
async def get_redis() -> AsyncGenerator[Redis, None]:
|
||||
redis = await aioredis.from_url(settings.REDIS_URL)
|
||||
try:
|
||||
yield redis
|
||||
finally:
|
||||
await redis.close() # Always runs, even on exceptions
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@router.get("/")
|
||||
async def list_users():
|
||||
pass
|
||||
|
||||
# In main.py
|
||||
app.include_router(router)
|
||||
async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
yield client
|
||||
```
|
||||
|
||||
#### Request-scoped dependencies with caching
|
||||
|
||||
```python
|
||||
# FastAPI caches dependency results per-request by default.
|
||||
# The same db session is reused if multiple deps request it.
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def dashboard(
|
||||
user_service: UserService = Depends(get_user_service),
|
||||
item_service: ItemService = Depends(get_item_service),
|
||||
# Both services share the same db session from get_db()
|
||||
):
|
||||
users = await user_service.count()
|
||||
items = await item_service.count()
|
||||
return {"users": users, "items": items}
|
||||
|
||||
# To disable caching (get fresh instance each time):
|
||||
@router.get("/example")
|
||||
async def example(
|
||||
db1: AsyncSession = Depends(get_db),
|
||||
db2: AsyncSession = Depends(get_db, use_cache=False),
|
||||
# db1 and db2 are different sessions
|
||||
):
|
||||
pass
|
||||
```
|
||||
|
||||
#### Class-based dependencies
|
||||
|
||||
```python
|
||||
class Pagination:
|
||||
def __init__(
|
||||
self,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
self.skip = skip
|
||||
self.limit = limit
|
||||
|
||||
@router.get("/items")
|
||||
async def list_items(pagination: Pagination = Depends()):
|
||||
# pagination.skip, pagination.limit
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. Middleware
|
||||
|
||||
#### Custom middleware
|
||||
|
||||
```python
|
||||
import time
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
class TimingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
duration = time.perf_counter() - start
|
||||
response.headers["X-Process-Time"] = f"{duration:.4f}"
|
||||
return response
|
||||
```
|
||||
|
||||
#### Pure ASGI middleware (higher performance)
|
||||
|
||||
```python
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
class RequestIDMiddleware:
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
||||
if scope["type"] == "http":
|
||||
request_id = uuid.uuid4().hex
|
||||
scope.setdefault("state", {})["request_id"] = request_id
|
||||
|
||||
async def send_with_header(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = dict(message.get("headers", []))
|
||||
headers[b"x-request-id"] = request_id.encode()
|
||||
message["headers"] = list(headers.items())
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_header)
|
||||
else:
|
||||
await self.app(scope, receive, send)
|
||||
```
|
||||
|
||||
#### Standard middleware configuration
|
||||
|
||||
```python
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
def add_middleware(app: FastAPI):
|
||||
# Order matters: first added = outermost (runs first on request, last on response)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS, # ["https://example.com"]
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
TrustedHostMiddleware,
|
||||
allowed_hosts=settings.ALLOWED_HOSTS, # ["example.com", "*.example.com"]
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500) # Compress responses > 500 bytes
|
||||
|
||||
app.add_middleware(TimingMiddleware)
|
||||
```
|
||||
|
||||
### 5. Background Tasks
|
||||
|
||||
#### Simple background tasks
|
||||
|
||||
```python
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
async def send_welcome_email(email: str, name: str):
|
||||
# This runs after the response is sent
|
||||
await email_service.send(
|
||||
to=email,
|
||||
subject="Welcome!",
|
||||
body=f"Hello {name}, welcome to our platform.",
|
||||
)
|
||||
|
||||
async def log_activity(user_id: int, action: str):
|
||||
await activity_repo.create(user_id=user_id, action=action)
|
||||
|
||||
@router.post("/users", status_code=201)
|
||||
async def create_user(
|
||||
user: UserCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
new_user = await user_service.create(user)
|
||||
# Queue multiple background tasks
|
||||
background_tasks.add_task(send_welcome_email, new_user.email, new_user.name)
|
||||
background_tasks.add_task(log_activity, new_user.id, "account_created")
|
||||
return new_user
|
||||
```
|
||||
|
||||
#### Long-running tasks with task queues
|
||||
|
||||
For tasks that take more than a few seconds, use a proper task queue:
|
||||
|
||||
```python
|
||||
from celery import Celery
|
||||
|
||||
celery_app = Celery("worker", broker=settings.CELERY_BROKER_URL)
|
||||
|
||||
@celery_app.task
|
||||
def generate_report(report_id: int):
|
||||
# Long-running: query data, build PDF, upload to S3
|
||||
...
|
||||
|
||||
@router.post("/reports", status_code=202)
|
||||
async def request_report(params: ReportRequest):
|
||||
report = await report_service.create(params)
|
||||
generate_report.delay(report.id) # Dispatch to Celery worker
|
||||
return {"report_id": report.id, "status": "processing"}
|
||||
|
||||
@router.get("/reports/{report_id}/status")
|
||||
async def report_status(report_id: int):
|
||||
report = await report_service.get(report_id)
|
||||
return {"status": report.status, "url": report.download_url}
|
||||
```
|
||||
|
||||
### 6. WebSocket
|
||||
|
||||
#### WebSocket endpoint with connection management
|
||||
|
||||
```python
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: dict[str, list[WebSocket]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, room: str):
|
||||
await websocket.accept()
|
||||
self.active_connections.setdefault(room, []).append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket, room: str):
|
||||
self.active_connections.get(room, []).remove(websocket)
|
||||
|
||||
async def broadcast(self, message: str, room: str):
|
||||
for connection in self.active_connections.get(room, []):
|
||||
try:
|
||||
await connection.send_text(message)
|
||||
except WebSocketDisconnect:
|
||||
self.disconnect(connection, room)
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
@app.websocket("/ws/{room}")
|
||||
async def websocket_endpoint(websocket: WebSocket, room: str):
|
||||
await manager.connect(websocket, room)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await manager.broadcast(f"Message: {data}", room)
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket, room)
|
||||
await manager.broadcast(f"User left the room", room)
|
||||
```
|
||||
|
||||
#### WebSocket with authentication
|
||||
|
||||
```python
|
||||
@app.websocket("/ws/private")
|
||||
async def private_ws(websocket: WebSocket, token: str = Query(...)):
|
||||
try:
|
||||
user = verify_token(token)
|
||||
except InvalidToken:
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
response = await process_message(user, data)
|
||||
await websocket.send_json(response)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
```
|
||||
|
||||
### 7. File Handling
|
||||
|
||||
#### Upload files
|
||||
|
||||
```python
|
||||
from fastapi import UploadFile, File
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(..., description="File to upload"),
|
||||
):
|
||||
# Validate file type and size
|
||||
if file.content_type not in ["image/png", "image/jpeg"]:
|
||||
raise HTTPException(400, "Only PNG and JPEG images are allowed")
|
||||
|
||||
if file.size and file.size > 5 * 1024 * 1024: # 5 MB
|
||||
raise HTTPException(400, "File too large (max 5 MB)")
|
||||
|
||||
contents = await file.read()
|
||||
path = f"uploads/{uuid.uuid4()}_{file.filename}"
|
||||
async with aiofiles.open(path, "wb") as f:
|
||||
await f.write(contents)
|
||||
|
||||
return {"filename": file.filename, "path": path, "size": len(contents)}
|
||||
|
||||
# Multiple file upload
|
||||
@router.post("/upload-multiple")
|
||||
async def upload_multiple(files: list[UploadFile] = File(...)):
|
||||
results = []
|
||||
for file in files:
|
||||
contents = await file.read()
|
||||
results.append({"filename": file.filename, "size": len(contents)})
|
||||
return results
|
||||
```
|
||||
|
||||
#### Streaming responses
|
||||
|
||||
```python
|
||||
from fastapi.responses import StreamingResponse
|
||||
import csv
|
||||
import io
|
||||
|
||||
@router.get("/export/users")
|
||||
async def export_users():
|
||||
async def generate_csv():
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["id", "name", "email"])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
async for user in user_service.stream_all():
|
||||
writer.writerow([user.id, user.name, user.email])
|
||||
yield output.getvalue()
|
||||
output.seek(0)
|
||||
output.truncate(0)
|
||||
|
||||
return StreamingResponse(
|
||||
generate_csv(),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=users.csv"},
|
||||
)
|
||||
```
|
||||
|
||||
#### Static files
|
||||
|
||||
```python
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
```
|
||||
|
||||
### 8. Testing
|
||||
|
||||
#### TestClient for synchronous tests
|
||||
|
||||
```python
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_create_user():
|
||||
response = client.post("/api/v1/users", json={
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "test@example.com"
|
||||
|
||||
def test_get_user_not_found():
|
||||
response = client.get("/api/v1/users/99999")
|
||||
assert response.status_code == 404
|
||||
```
|
||||
|
||||
#### Async testing with httpx
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from app.main import app
|
||||
|
||||
@pytest.fixture
|
||||
async def async_client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_users(async_client: AsyncClient):
|
||||
response = await async_client.get("/api/v1/users")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
```
|
||||
|
||||
#### Overriding dependencies for tests
|
||||
|
||||
```python
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
# Mock database session
|
||||
async def override_get_db():
|
||||
async with test_session_maker() as session:
|
||||
yield session
|
||||
|
||||
# Mock authenticated user
|
||||
async def override_get_current_user():
|
||||
return User(id=1, email="test@example.com", name="Test")
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
# In conftest.py — clean up after tests
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_overrides():
|
||||
yield
|
||||
app.dependency_overrides.clear()
|
||||
```
|
||||
|
||||
#### Testing WebSocket endpoints
|
||||
|
||||
```python
|
||||
def test_websocket():
|
||||
with client.websocket_connect("/ws/test-room") as ws:
|
||||
ws.send_text("hello")
|
||||
data = ws.receive_text()
|
||||
assert "hello" in data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use Pydantic models for request/response validation
|
||||
2. Organize routes with APIRouter
|
||||
3. Use dependency injection for services
|
||||
4. Return proper HTTP status codes
|
||||
5. Add OpenAPI descriptions
|
||||
1. **Use Pydantic models for all request/response validation** — never pass raw dicts through your API boundary. Define separate `Create`, `Update`, and `Response` schemas for each resource.
|
||||
|
||||
2. **Organize routes with APIRouter** — group related endpoints by resource and version. Apply shared dependencies at the router level, not on each individual route.
|
||||
|
||||
3. **Separate business logic from routes** — route functions should only handle HTTP concerns (parsing request, returning response). Delegate logic to service classes injected via `Depends()`.
|
||||
|
||||
4. **Use the lifespan context manager** — replace deprecated `on_event("startup")` and `on_event("shutdown")` with the `lifespan` async context manager for resource setup and teardown.
|
||||
|
||||
5. **Return proper HTTP status codes** — 201 for creation, 204 for deletion, 202 for accepted-but-not-done, 409 for conflicts. Use `status_code` parameter on route decorators.
|
||||
|
||||
6. **Add OpenAPI metadata** — provide `summary`, `description`, `tags`, and `responses` on routes. Set `title`, `version`, and `description` on the FastAPI app. This generates high-quality auto-docs.
|
||||
|
||||
7. **Use async all the way down** — if your route is `async def`, every I/O call inside it must also be async. Mixing sync blocking calls (e.g., `requests.get()`) in an async route will block the event loop.
|
||||
|
||||
8. **Configure settings with pydantic-settings** — load config from environment variables with validation and type coercion. Never hardcode secrets or connection strings.
|
||||
|
||||
```python
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
API_KEY: str
|
||||
DEBUG: bool = False
|
||||
|
||||
model_config = {"env_file": ".env"}
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Blocking I/O in async**: Use async libraries
|
||||
- **Missing response models**: Always define them
|
||||
- **No error handling**: Use HTTPException properly
|
||||
1. **Blocking I/O in async routes** — calling `requests.get()`, `time.sleep()`, or synchronous DB drivers inside `async def` routes starves the event loop. Use `httpx`, `asyncio.sleep()`, and async database drivers instead. If you must call sync code, use `run_in_executor`.
|
||||
|
||||
2. **Missing response_model** — without `response_model`, FastAPI returns whatever you return, potentially leaking internal fields (passwords, internal IDs). Always define a Pydantic response schema.
|
||||
|
||||
3. **Forgetting to await coroutines** — calling `await db.execute(query)` vs `db.execute(query)` is easy to miss. The latter returns a coroutine object instead of results. Enable linting rules that catch unawaited coroutines.
|
||||
|
||||
4. **Circular imports between models and schemas** — when schemas reference ORM models and vice versa, you get import cycles. Fix by using `TYPE_CHECKING` imports or by keeping schemas and models in separate modules that do not import each other.
|
||||
|
||||
5. **Not handling Pydantic validation errors** — FastAPI returns 422 by default, but the error format may confuse API consumers. Add a custom exception handler to reshape validation error responses to match your API's error format.
|
||||
|
||||
6. **Sharing mutable state across requests without locks** — global mutable variables (lists, dicts) accessed from async routes can cause race conditions. Use async-safe structures or dependency-injected per-request state.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `languages/python` — Python language patterns and best practices
|
||||
- `api/openapi` — OpenAPI specification and documentation standards
|
||||
- `databases/postgresql` — Database integration with async SQLAlchemy
|
||||
- `testing/pytest` — Testing FastAPI applications with pytest and httpx
|
||||
- `patterns/authentication` — JWT, OAuth2, and session patterns for FastAPI endpoints
|
||||
- `patterns/logging` — Structured logging for FastAPI applications
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# FastAPI Project Structure Reference
|
||||
|
||||
## Small Project (1-5 endpoints, single module)
|
||||
|
||||
```
|
||||
project/
|
||||
├── main.py # App factory, routes, startup
|
||||
├── models.py # Pydantic schemas + SQLAlchemy models
|
||||
├── database.py # DB connection, session factory
|
||||
├── config.py # Settings via pydantic-settings
|
||||
├── requirements.txt
|
||||
├── .env
|
||||
└── tests/
|
||||
├── conftest.py # Fixtures (test client, test DB)
|
||||
└── test_main.py
|
||||
```
|
||||
|
||||
**When to use**: Prototypes, microservices, internal tools, single-domain APIs.
|
||||
|
||||
**`main.py` structure**:
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# startup
|
||||
yield
|
||||
# shutdown
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@app.get("/health")
|
||||
async def health(): return {"status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medium Project (5-20 endpoints, feature-grouped)
|
||||
|
||||
```
|
||||
project/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # App factory, include routers
|
||||
│ ├── config.py # Settings (pydantic-settings)
|
||||
│ ├── database.py # Engine, SessionLocal, Base
|
||||
│ ├── dependencies.py # Shared deps (get_db, get_current_user)
|
||||
│ ├── exceptions.py # Custom exception handlers
|
||||
│ ├── middleware.py # CORS, logging, timing middleware
|
||||
│ │
|
||||
│ ├── auth/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # POST /login, POST /register
|
||||
│ │ ├── schemas.py # LoginRequest, TokenResponse
|
||||
│ │ ├── models.py # User SQLAlchemy model
|
||||
│ │ ├── service.py # Business logic (hash, verify, tokens)
|
||||
│ │ └── dependencies.py # get_current_user, require_role
|
||||
│ │
|
||||
│ ├── items/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # CRUD endpoints
|
||||
│ │ ├── schemas.py # ItemCreate, ItemRead, ItemUpdate
|
||||
│ │ ├── models.py # Item SQLAlchemy model
|
||||
│ │ └── service.py # Business logic
|
||||
│ │
|
||||
│ └── shared/
|
||||
│ ├── __init__.py
|
||||
│ ├── pagination.py # Pagination params + response schema
|
||||
│ └── filters.py # Common query filter patterns
|
||||
│
|
||||
├── alembic/ # DB migrations
|
||||
│ ├── env.py
|
||||
│ └── versions/
|
||||
├── alembic.ini
|
||||
├── requirements.txt
|
||||
├── pyproject.toml
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── tests/
|
||||
├── conftest.py
|
||||
├── auth/
|
||||
│ └── test_router.py
|
||||
└── items/
|
||||
├── test_router.py
|
||||
└── test_service.py
|
||||
```
|
||||
|
||||
**When to use**: Multi-feature APIs, team projects, typical SaaS backends.
|
||||
|
||||
**Key patterns**:
|
||||
- Each feature gets its own directory with router, schemas, models, service
|
||||
- `router.py` uses `APIRouter(prefix="/items", tags=["items"])`
|
||||
- `main.py` includes routers: `app.include_router(items.router)`
|
||||
- Shared deps in root `dependencies.py`, feature-specific in feature dir
|
||||
|
||||
---
|
||||
|
||||
## Large Project (20+ endpoints, domain-driven)
|
||||
|
||||
```
|
||||
project/
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # App factory only
|
||||
│ ├── config.py # Layered settings
|
||||
│ │
|
||||
│ ├── core/ # Framework-level concerns
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── database.py # Engine, session management
|
||||
│ │ ├── security.py # JWT, hashing, RBAC
|
||||
│ │ ├── exceptions.py # Base exceptions + handlers
|
||||
│ │ ├── middleware.py # All middleware stack
|
||||
│ │ ├── dependencies.py # Cross-cutting deps
|
||||
│ │ ├── events.py # Domain event bus
|
||||
│ │ └── pagination.py # Cursor + offset pagination
|
||||
│ │
|
||||
│ ├── domain/ # Business logic (framework-agnostic)
|
||||
│ │ ├── users/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── entity.py # Domain entity (plain dataclass)
|
||||
│ │ │ ├── repository.py # Abstract repository interface
|
||||
│ │ │ ├── service.py # Business rules
|
||||
│ │ │ └── events.py # Domain events
|
||||
│ │ ├── orders/
|
||||
│ │ │ └── ...
|
||||
│ │ └── payments/
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── infrastructure/ # External system adapters
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── models.py # All SQLAlchemy models
|
||||
│ │ │ ├── repositories/ # Concrete repo implementations
|
||||
│ │ │ │ ├── user_repo.py
|
||||
│ │ │ │ └── order_repo.py
|
||||
│ │ │ └── migrations/ # Alembic
|
||||
│ │ ├── cache/
|
||||
│ │ │ └── redis_client.py
|
||||
│ │ ├── email/
|
||||
│ │ │ └── smtp_service.py
|
||||
│ │ └── external/
|
||||
│ │ └── stripe_client.py
|
||||
│ │
|
||||
│ └── api/ # HTTP layer only
|
||||
│ ├── __init__.py
|
||||
│ ├── v1/
|
||||
│ │ ├── __init__.py # v1 router aggregator
|
||||
│ │ ├── users.py # Thin: parse request -> call service -> format response
|
||||
│ │ ├── orders.py
|
||||
│ │ └── payments.py
|
||||
│ ├── v2/
|
||||
│ │ └── ...
|
||||
│ ├── schemas/ # Request/response schemas
|
||||
│ │ ├── user_schemas.py
|
||||
│ │ ├── order_schemas.py
|
||||
│ │ └── common.py
|
||||
│ ├── dependencies.py # API-layer deps
|
||||
│ └── websockets/
|
||||
│ └── notifications.py
|
||||
│
|
||||
├── tests/
|
||||
│ ├── conftest.py
|
||||
│ ├── unit/
|
||||
│ │ ├── domain/
|
||||
│ │ │ └── test_user_service.py
|
||||
│ │ └── ...
|
||||
│ ├── integration/
|
||||
│ │ ├── test_user_api.py
|
||||
│ │ └── test_order_flow.py
|
||||
│ └── e2e/
|
||||
│ └── test_checkout.py
|
||||
│
|
||||
├── scripts/ # Dev/ops scripts
|
||||
│ ├── seed_db.py
|
||||
│ └── migrate.py
|
||||
├── pyproject.toml
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
**When to use**: Complex domains, multiple teams, long-lived products.
|
||||
|
||||
**Key patterns**:
|
||||
- **Domain layer** has zero framework imports (testable in isolation)
|
||||
- **Infrastructure** implements domain interfaces (repository pattern)
|
||||
- **API layer** is thin: validation, auth, call service, return schema
|
||||
- API versioning via `/api/v1/`, `/api/v2/`
|
||||
- Separate unit, integration, and e2e test directories
|
||||
|
||||
---
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
| File | Responsibility | Dependencies |
|
||||
|------|---------------|-------------|
|
||||
| `router.py` | HTTP handling, request parsing, response formatting | schemas, service, dependencies |
|
||||
| `schemas.py` | Pydantic models for request/response validation | None (or shared schemas) |
|
||||
| `models.py` | SQLAlchemy/ODM models (DB table mapping) | database |
|
||||
| `service.py` | Business logic, orchestration | repository/models, external services |
|
||||
| `dependencies.py` | FastAPI `Depends()` callables | database, config, auth |
|
||||
| `exceptions.py` | Custom exceptions + handlers | None |
|
||||
| `config.py` | `BaseSettings` with env loading | None |
|
||||
|
||||
## Router Registration Pattern
|
||||
|
||||
```python
|
||||
# app/main.py
|
||||
from fastapi import FastAPI
|
||||
from app.auth.router import router as auth_router
|
||||
from app.items.router import router as items_router
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="My API")
|
||||
app.include_router(auth_router)
|
||||
app.include_router(items_router)
|
||||
return app
|
||||
|
||||
app = create_app()
|
||||
```
|
||||
|
||||
```python
|
||||
# app/items/router.py
|
||||
from fastapi import APIRouter, Depends
|
||||
router = APIRouter(prefix="/items", tags=["items"])
|
||||
|
||||
@router.get("/")
|
||||
async def list_items(db=Depends(get_db)): ...
|
||||
```
|
||||
@@ -1,112 +1,692 @@
|
||||
---
|
||||
name: nextjs
|
||||
description: >
|
||||
Use this skill when working with Next.js applications, App Router, Server Components, or Server Actions. Trigger for any mention of Next.js, next/server, next/navigation, route handlers, SSR, SSG, ISR, middleware, or the app/ directory structure. Also applies when building full-stack React applications with API routes, implementing streaming or suspense boundaries, or configuring next.config.
|
||||
---
|
||||
|
||||
# Next.js
|
||||
|
||||
## Description
|
||||
|
||||
Next.js framework with App Router, Server Components, and full-stack development patterns.
|
||||
|
||||
## When to Use
|
||||
|
||||
- React applications with SSR/SSG
|
||||
- Full-stack applications
|
||||
- App Router patterns
|
||||
- SEO-critical sites needing server rendering
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Pure React SPAs without SSR needs — use the `frameworks/react` skill instead
|
||||
- Non-React frameworks (Vue, Svelte, Angular) — this skill is React/Next.js specific
|
||||
- Backend-only projects without a frontend — consider `frameworks/fastapi` or `frameworks/django`
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### App Router Structure
|
||||
### 1. App Router
|
||||
|
||||
#### Directory structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout
|
||||
├── page.tsx # Home page
|
||||
├── loading.tsx # Loading UI
|
||||
├── error.tsx # Error UI
|
||||
├── layout.tsx # Root layout (wraps entire app)
|
||||
├── page.tsx # Home page (/)
|
||||
├── loading.tsx # Root loading UI (Suspense fallback)
|
||||
├── error.tsx # Root error boundary
|
||||
├── not-found.tsx # Custom 404 page
|
||||
├── global-error.tsx # Error boundary for root layout itself
|
||||
├── favicon.ico
|
||||
├── globals.css
|
||||
├── api/
|
||||
│ └── users/
|
||||
│ └── route.ts # API route
|
||||
└── users/
|
||||
├── page.tsx # Users page
|
||||
└── [id]/
|
||||
└── page.tsx # User detail
|
||||
│ ├── users/
|
||||
│ │ └── route.ts # GET/POST /api/users
|
||||
│ │ └── [id]/
|
||||
│ │ └── route.ts # GET/PUT/DELETE /api/users/:id
|
||||
│ └── webhooks/
|
||||
│ └── stripe/
|
||||
│ └── route.ts # POST /api/webhooks/stripe
|
||||
├── (marketing)/ # Route group (no URL segment)
|
||||
│ ├── layout.tsx # Layout for marketing pages only
|
||||
│ ├── page.tsx # / (same as root, can override)
|
||||
│ ├── about/
|
||||
│ │ └── page.tsx # /about
|
||||
│ └── pricing/
|
||||
│ └── page.tsx # /pricing
|
||||
├── (app)/ # Route group for authenticated app
|
||||
│ ├── layout.tsx # App shell layout (sidebar, nav)
|
||||
│ ├── dashboard/
|
||||
│ │ ├── page.tsx # /dashboard
|
||||
│ │ ├── loading.tsx # Loading skeleton for dashboard
|
||||
│ │ └── error.tsx # Error boundary for dashboard
|
||||
│ ├── projects/
|
||||
│ │ ├── page.tsx # /projects
|
||||
│ │ └── [id]/
|
||||
│ │ ├── page.tsx # /projects/:id
|
||||
│ │ ├── edit/
|
||||
│ │ │ └── page.tsx # /projects/:id/edit
|
||||
│ │ └── layout.tsx # Shared layout for project detail
|
||||
│ └── settings/
|
||||
│ └── page.tsx # /settings
|
||||
└── @modal/ # Parallel route slot
|
||||
└── (.)projects/
|
||||
└── [id]/
|
||||
└── page.tsx # Intercepted route modal
|
||||
```
|
||||
|
||||
### Server Components
|
||||
#### Special files and their roles
|
||||
|
||||
| File | Purpose | Renders when |
|
||||
|------|---------|-------------|
|
||||
| `page.tsx` | Route UI | URL matches segment |
|
||||
| `layout.tsx` | Shared wrapper, preserved across navigation | Always for child routes |
|
||||
| `loading.tsx` | Suspense fallback | While page/data is loading |
|
||||
| `error.tsx` | Error boundary | When child throws |
|
||||
| `not-found.tsx` | 404 UI | When `notFound()` is called |
|
||||
| `route.ts` | API endpoint | HTTP request to segment |
|
||||
| `template.tsx` | Like layout but re-mounts on navigation | Every navigation |
|
||||
| `default.tsx` | Fallback for parallel routes | When slot has no match |
|
||||
|
||||
```tsx
|
||||
// app/users/page.tsx - Server Component (default)
|
||||
async function UsersPage() {
|
||||
const users = await db.users.findMany();
|
||||
// app/layout.tsx — Root layout (required)
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "My App", template: "%s | My App" },
|
||||
description: "Application description",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<nav>{/* Global navigation */}</nav>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
// app/error.tsx — Error boundary (must be client component)
|
||||
"use client";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong</h2>
|
||||
<button onClick={reset}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// app/not-found.tsx
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Page not found</h2>
|
||||
<p>The requested resource does not exist.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Server vs Client Components
|
||||
|
||||
#### Decision guide
|
||||
|
||||
| Use Server Component when | Use Client Component when |
|
||||
|---------------------------|--------------------------|
|
||||
| Fetching data | Using useState, useEffect, useRef |
|
||||
| Accessing backend resources directly | Adding event handlers (onClick, onChange) |
|
||||
| Keeping sensitive data on server | Using browser APIs (localStorage, window) |
|
||||
| Reducing client bundle size | Using third-party client libraries |
|
||||
| SEO-critical content | Animations, real-time updates |
|
||||
|
||||
#### Composition patterns
|
||||
|
||||
```tsx
|
||||
// Server Component (default — no directive needed)
|
||||
// app/projects/page.tsx
|
||||
import { ProjectList } from "./project-list";
|
||||
import { SearchBar } from "./search-bar"; // Client component
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const projects = await db.project.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Projects</h1>
|
||||
{/* Client component receives server data as props */}
|
||||
<SearchBar />
|
||||
{/* Server component can render client children */}
|
||||
<ProjectList projects={projects} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Client Component — must have "use client" at top
|
||||
// app/projects/search-bar.tsx
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
|
||||
export function SearchBar() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSearch(term: string) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (term) {
|
||||
params.set("q", term);
|
||||
} else {
|
||||
params.delete("q");
|
||||
}
|
||||
startTransition(() => {
|
||||
router.replace(`/projects?${params.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search projects..."
|
||||
defaultValue={searchParams.get("q") ?? ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className={isPending ? "opacity-50" : ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Key rule:** The `"use client"` directive creates a boundary. Everything imported into a client component becomes part of the client bundle. Pass server data down as serializable props (no functions, no classes).
|
||||
|
||||
### 3. Data Fetching
|
||||
|
||||
#### Server component fetch with caching
|
||||
|
||||
```tsx
|
||||
// Fetch with automatic deduplication and caching
|
||||
async function getProjects() {
|
||||
const res = await fetch("https://api.example.com/projects", {
|
||||
next: { revalidate: 60 }, // Revalidate every 60 seconds (ISR)
|
||||
// next: { tags: ["projects"] }, // Tag-based revalidation
|
||||
// cache: "no-store", // Always fresh (SSR)
|
||||
// cache: "force-cache", // Cache indefinitely (SSG)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch projects");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const projects = await getProjects();
|
||||
return <ProjectList projects={projects} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### generateStaticParams for static generation
|
||||
|
||||
```tsx
|
||||
// app/projects/[id]/page.tsx
|
||||
export async function generateStaticParams() {
|
||||
const projects = await db.project.findMany({ select: { id: true } });
|
||||
return projects.map((p) => ({ id: String(p.id) }));
|
||||
}
|
||||
|
||||
export default async function ProjectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const project = await db.project.findUnique({ where: { id } });
|
||||
if (!project) notFound();
|
||||
return <ProjectDetail project={project} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Route handlers (API routes)
|
||||
|
||||
```typescript
|
||||
// app/api/projects/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = Number(searchParams.get("page") ?? "1");
|
||||
const limit = Number(searchParams.get("limit") ?? "20");
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: projects, page, limit });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const project = await db.project.create({ data: body });
|
||||
return NextResponse.json(project, { status: 201 });
|
||||
}
|
||||
|
||||
// Dynamic route: app/api/projects/[id]/route.ts
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const project = await db.project.findUnique({ where: { id } });
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(project);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Server Actions
|
||||
|
||||
#### Form actions
|
||||
|
||||
```tsx
|
||||
// app/actions.ts
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
const ProjectSchema = z.object({
|
||||
title: z.string().min(3).max(200),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function createProject(prevState: unknown, formData: FormData) {
|
||||
const parsed = ProjectSchema.safeParse({
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { errors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const project = await db.project.create({ data: parsed.data });
|
||||
revalidatePath("/projects");
|
||||
redirect(`/projects/${project.id}`);
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string) {
|
||||
await db.project.delete({ where: { id } });
|
||||
revalidatePath("/projects");
|
||||
}
|
||||
```
|
||||
|
||||
#### Using actions in client components with useActionState
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { createProject } from "../actions";
|
||||
|
||||
export function CreateProjectForm() {
|
||||
const [state, formAction, isPending] = useActionState(createProject, null);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input name="title" placeholder="Project title" required />
|
||||
{state?.errors?.title && (
|
||||
<p className="text-red-500">{state.errors.title[0]}</p>
|
||||
)}
|
||||
<textarea name="description" placeholder="Description" />
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? "Creating..." : "Create Project"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Optimistic updates
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useOptimistic } from "react";
|
||||
import { deleteProject } from "../actions";
|
||||
|
||||
export function ProjectList({ projects }: { projects: Project[] }) {
|
||||
const [optimisticProjects, removeOptimistic] = useOptimistic(
|
||||
projects,
|
||||
(state, removedId: string) => state.filter((p) => p.id !== removedId),
|
||||
);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
removeOptimistic(id);
|
||||
await deleteProject(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{users.map(user => (
|
||||
<li key={user.id}>{user.name}</li>
|
||||
{optimisticProjects.map((project) => (
|
||||
<li key={project.id}>
|
||||
{project.title}
|
||||
<button onClick={() => handleDelete(project.id)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Client Components
|
||||
### 5. Middleware
|
||||
|
||||
```typescript
|
||||
// middleware.ts (root of project, NOT inside app/)
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Auth check
|
||||
const token = request.cookies.get("session")?.value;
|
||||
if (pathname.startsWith("/dashboard") && !token) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
// Add headers
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("x-pathname", pathname);
|
||||
|
||||
// Geo-based redirect
|
||||
const country = request.geo?.country;
|
||||
if (pathname === "/" && country === "DE") {
|
||||
return NextResponse.redirect(new URL("/de", request.url));
|
||||
}
|
||||
|
||||
// Rewrite (URL stays same, content changes)
|
||||
if (pathname.startsWith("/old-path")) {
|
||||
return NextResponse.rewrite(new URL("/new-path", request.url));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Matcher: only run middleware on specific paths
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Match all paths except static files and api routes
|
||||
"/((?!_next/static|_next/image|favicon.ico|api).*)",
|
||||
// Or match specific paths
|
||||
// "/dashboard/:path*",
|
||||
// "/projects/:path*",
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Caching
|
||||
|
||||
#### Cache layers overview
|
||||
|
||||
| Layer | What it caches | Control |
|
||||
|-------|---------------|---------|
|
||||
| Request Memoization | `fetch()` calls with same URL during single render | Automatic, per-request |
|
||||
| Data Cache | `fetch()` results across requests | `next: { revalidate }`, `cache` option |
|
||||
| Full Route Cache | HTML and RSC payload of static routes | `export const dynamic = "force-dynamic"` |
|
||||
| Router Cache | Client-side RSC payload | `router.refresh()`, time-based |
|
||||
|
||||
#### Revalidation strategies
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
// Time-based revalidation (ISR)
|
||||
fetch(url, { next: { revalidate: 3600 } }); // 1 hour
|
||||
|
||||
import { useState } from 'react';
|
||||
// On-demand revalidation by path
|
||||
import { revalidatePath } from "next/cache";
|
||||
revalidatePath("/projects"); // Revalidate specific page
|
||||
revalidatePath("/projects", "layout"); // Revalidate layout and all pages under it
|
||||
|
||||
export function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
// On-demand revalidation by tag
|
||||
import { revalidateTag } from "next/cache";
|
||||
// When fetching:
|
||||
fetch(url, { next: { tags: ["projects"] } });
|
||||
// When invalidating:
|
||||
revalidateTag("projects");
|
||||
|
||||
// Route segment config
|
||||
export const dynamic = "force-dynamic"; // Never cache (SSR)
|
||||
export const revalidate = 60; // ISR with 60s interval
|
||||
export const fetchCache = "default-cache";
|
||||
```
|
||||
|
||||
#### unstable_cache for non-fetch data
|
||||
|
||||
```tsx
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
const getCachedProjects = unstable_cache(
|
||||
async (orgId: string) => {
|
||||
return db.project.findMany({ where: { organizationId: orgId } });
|
||||
},
|
||||
["projects"], // Cache key parts
|
||||
{ revalidate: 60, tags: ["projects"] },
|
||||
);
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const projects = await getCachedProjects("org-123");
|
||||
return <ProjectList projects={projects} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Route Groups & Parallel Routes
|
||||
|
||||
#### Route groups with `(groupName)`
|
||||
|
||||
Route groups organize routes without affecting the URL:
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/ # URL: / , /about, /pricing (no "marketing" in URL)
|
||||
│ ├── layout.tsx # Marketing layout (hero, footer)
|
||||
│ ├── page.tsx
|
||||
│ └── about/page.tsx
|
||||
├── (app)/ # URL: /dashboard, /projects
|
||||
│ ├── layout.tsx # App layout (sidebar, auth)
|
||||
│ └── dashboard/page.tsx
|
||||
```
|
||||
|
||||
#### Parallel routes with `@slotName`
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx
|
||||
├── page.tsx
|
||||
├── @analytics/
|
||||
│ ├── page.tsx # Rendered in parallel
|
||||
│ └── default.tsx # Fallback when no match
|
||||
├── @sidebar/
|
||||
│ ├── page.tsx
|
||||
│ └── default.tsx
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx — receives parallel route slots as props
|
||||
export default function Layout({
|
||||
children,
|
||||
analytics,
|
||||
sidebar,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
analytics: React.ReactNode;
|
||||
sidebar: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={() => setCount(c => c + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
<div className="flex">
|
||||
<aside>{sidebar}</aside>
|
||||
<main>{children}</main>
|
||||
<aside>{analytics}</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### API Routes
|
||||
#### Intercepting routes
|
||||
|
||||
```typescript
|
||||
// app/api/users/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const users = await db.users.findMany();
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const data = await request.json();
|
||||
const user = await db.users.create({ data });
|
||||
return NextResponse.json(user, { status: 201 });
|
||||
}
|
||||
```
|
||||
app/
|
||||
├── projects/
|
||||
│ ├── page.tsx # /projects — full list
|
||||
│ └── [id]/
|
||||
│ └── page.tsx # /projects/:id — full page
|
||||
├── @modal/
|
||||
│ ├── (.)projects/
|
||||
│ │ └── [id]/
|
||||
│ │ └── page.tsx # Intercepts /projects/:id as modal
|
||||
│ └── default.tsx # No modal by default
|
||||
```
|
||||
|
||||
### Server Actions
|
||||
Convention: `(.)` = same level, `(..)` = one level up, `(...)` = root.
|
||||
|
||||
### 8. Image & Font Optimization
|
||||
|
||||
#### next/image
|
||||
|
||||
```tsx
|
||||
// app/actions.ts
|
||||
'use server';
|
||||
import Image from "next/image";
|
||||
|
||||
export async function createUser(formData: FormData) {
|
||||
const name = formData.get('name') as string;
|
||||
await db.users.create({ data: { name } });
|
||||
revalidatePath('/users');
|
||||
// Local image (automatically gets width/height)
|
||||
import heroImage from "@/public/hero.png";
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt="Hero banner"
|
||||
placeholder="blur" // Auto blur placeholder for local images
|
||||
priority // Preload for LCP images
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Remote image (must specify dimensions)
|
||||
export function Avatar({ url, name }: { url: string; name: string }) {
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
alt={name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// next.config.ts — allow remote image domains
|
||||
const config = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "avatars.githubusercontent.com" },
|
||||
{ protocol: "https", hostname: "**.cloudinary.com" },
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### next/font
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
});
|
||||
|
||||
const customFont = localFont({
|
||||
src: "./fonts/CustomFont.woff2",
|
||||
variable: "--font-custom",
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
|
||||
<body className="font-sans">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* In Tailwind config or globals.css */
|
||||
:root {
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use Server Components by default
|
||||
2. Add 'use client' only when needed
|
||||
3. Colocate data fetching with components
|
||||
4. Use loading.tsx for suspense boundaries
|
||||
5. Implement proper error boundaries
|
||||
1. **Default to Server Components** — only add `"use client"` when you need interactivity, event handlers, or browser APIs. Server Components reduce bundle size and allow direct data access.
|
||||
|
||||
2. **Colocate data fetching with the component that uses it** — fetch inside the Server Component that renders the data, not in a parent that passes it down. Next.js deduplicates identical `fetch()` calls automatically.
|
||||
|
||||
3. **Use `loading.tsx` for instant loading states** — every route segment can have a `loading.tsx` that wraps the page in a Suspense boundary. This gives users immediate feedback during navigation.
|
||||
|
||||
4. **Validate Server Action inputs** — Server Actions are public HTTP endpoints. Always validate with zod or similar. Never trust `formData` values without parsing and validating.
|
||||
|
||||
5. **Use route groups to share layouts without affecting URLs** — `(marketing)` and `(app)` let you have completely different layouts (public vs authenticated) without nesting URL segments.
|
||||
|
||||
6. **Prefer `revalidatePath`/`revalidateTag` over `cache: "no-store"`** — on-demand revalidation gives you fresh data when it changes while still serving cached content for performance. Only use `"no-store"` for truly dynamic per-request data.
|
||||
|
||||
7. **Put middleware at the project root** — `middleware.ts` must be at the same level as `app/`, not inside it. Use the `matcher` config to limit which paths it runs on for performance.
|
||||
|
||||
8. **Use `next/image` for all images** — it handles lazy loading, responsive sizes, format conversion (WebP/AVIF), and blur placeholders. Set `priority` on above-the-fold LCP images. Configure `remotePatterns` for external image sources.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Using hooks in Server Components**: Mark as 'use client'
|
||||
- **Large client bundles**: Keep client components small
|
||||
- **Missing loading states**: Add loading.tsx files
|
||||
1. **Using hooks in Server Components** — `useState`, `useEffect`, `useRouter` (from `next/navigation`) only work in Client Components. If you see "hooks can only be called inside a function component," add `"use client"` or restructure to push interactivity to a child component.
|
||||
|
||||
2. **Passing non-serializable props across the server/client boundary** — functions, class instances, and Dates cannot be passed from Server to Client Components. Serialize data to plain objects and strings before passing as props.
|
||||
|
||||
3. **Large client bundles from misplaced `"use client"`** — placing the directive too high in the tree pulls entire subtrees into the client bundle. Push `"use client"` as deep as possible, wrapping only the interactive leaf components.
|
||||
|
||||
4. **Stale data from aggressive caching** — the Full Route Cache and Data Cache can serve stale content. Use `revalidatePath()`/`revalidateTag()` in Server Actions and route handlers after mutations. Call `router.refresh()` on the client if needed.
|
||||
|
||||
5. **Missing `default.tsx` for parallel routes** — when navigating to a URL that does not match a parallel route slot, Next.js renders `default.tsx`. Without it, you get a 404. Always provide a default for every `@slot`.
|
||||
|
||||
6. **Forgetting `loading.tsx` leads to blank pages during navigation** — without loading boundaries, users see nothing while Server Components fetch data. Add `loading.tsx` at every route segment that does async work.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frameworks/react` — React component patterns, hooks, and state management
|
||||
- `languages/typescript` — TypeScript strict mode and type patterns
|
||||
- `frontend/tailwind` — Styling with Tailwind CSS
|
||||
- `frontend/shadcn-ui` — UI component library built on Radix and Tailwind
|
||||
- `patterns/authentication` — Protected routes and auth middleware for Next.js
|
||||
- `patterns/caching` — Next.js caching layers and invalidation
|
||||
- `patterns/state-management` — React state management in Next.js apps
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Next.js Patterns Quick Reference
|
||||
|
||||
> App Router (Next.js 13.4+). Pages Router patterns not covered.
|
||||
|
||||
## App Router File Conventions
|
||||
|
||||
| File | Purpose | Renders |
|
||||
|------|---------|---------|
|
||||
| `page.tsx` | Route UI (makes route publicly accessible) | Server Component |
|
||||
| `layout.tsx` | Shared UI wrapping children (preserved on nav) | Server Component |
|
||||
| `template.tsx` | Like layout but re-mounts on navigation | Server Component |
|
||||
| `loading.tsx` | Instant loading UI (Suspense boundary) | Server Component |
|
||||
| `error.tsx` | Error boundary for segment | **Client Component** |
|
||||
| `not-found.tsx` | UI for `notFound()` calls | Server Component |
|
||||
| `route.ts` | API endpoint (GET, POST, etc.) | N/A |
|
||||
| `default.tsx` | Fallback for parallel routes | Server Component |
|
||||
| `middleware.ts` | Runs before requests (root only) | Edge Runtime |
|
||||
| `opengraph-image.tsx` | Dynamic OG image generation | Edge Runtime |
|
||||
|
||||
### Route Segment Folders
|
||||
|
||||
| Pattern | Example | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Static | `app/about/page.tsx` | `/about` |
|
||||
| Dynamic | `app/blog/[slug]/page.tsx` | `/blog/hello-world` |
|
||||
| Catch-all | `app/docs/[...slug]/page.tsx` | `/docs/a/b/c` |
|
||||
| Optional catch-all | `app/docs/[[...slug]]/page.tsx` | `/docs` or `/docs/a/b` |
|
||||
| Route group | `app/(marketing)/about/page.tsx` | Groups without URL segment |
|
||||
| Parallel route | `app/@modal/login/page.tsx` | Simultaneous route slots |
|
||||
| Intercepted route | `app/(.)photo/[id]/page.tsx` | Intercept navigation |
|
||||
|
||||
---
|
||||
|
||||
## Caching Layers Summary
|
||||
|
||||
| Layer | What | Where | Duration | Opt-out |
|
||||
|-------|------|-------|----------|---------|
|
||||
| Request Memoization | `fetch()` dedup in single render | Server | Per request | `AbortController` |
|
||||
| Data Cache | `fetch()` results | Server | Persistent | `cache: 'no-store'` or `revalidate: 0` |
|
||||
| Full Route Cache | Static HTML + RSC payload | Server | Persistent | Dynamic functions or `revalidate` |
|
||||
| Router Cache | RSC payload | Client | Session (30s dynamic, 5min static) | `router.refresh()` |
|
||||
|
||||
### Revalidation Strategies
|
||||
|
||||
```typescript
|
||||
// Time-based
|
||||
fetch(url, { next: { revalidate: 60 } }); // Revalidate every 60s
|
||||
|
||||
// On-demand (from API route or Server Action)
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
revalidatePath("/blog"); // Revalidate path
|
||||
revalidateTag("posts"); // Revalidate by tag
|
||||
|
||||
// Tag a fetch for on-demand revalidation
|
||||
fetch(url, { next: { tags: ["posts"] } });
|
||||
|
||||
// Opt out entirely
|
||||
fetch(url, { cache: "no-store" });
|
||||
|
||||
// Route segment config
|
||||
export const dynamic = "force-dynamic"; // Always dynamic
|
||||
export const revalidate = 60; // Segment-level ISR
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Fetching Patterns
|
||||
|
||||
### Server Component (default, preferred)
|
||||
|
||||
```typescript
|
||||
// Async Server Component - fetch directly
|
||||
async function BlogPage() {
|
||||
const posts = await fetch("https://api.example.com/posts", {
|
||||
next: { revalidate: 3600 }
|
||||
}).then(r => r.json());
|
||||
|
||||
return <PostList posts={posts} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel Data Fetching
|
||||
|
||||
```typescript
|
||||
async function Dashboard() {
|
||||
// Start all fetches simultaneously
|
||||
const [user, orders, stats] = await Promise.all([
|
||||
getUser(),
|
||||
getOrders(),
|
||||
getStats(),
|
||||
]);
|
||||
return <DashboardView user={user} orders={orders} stats={stats} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming with Suspense
|
||||
|
||||
```typescript
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
{/* Shows instantly */}
|
||||
<StaticContent />
|
||||
|
||||
{/* Streams in when ready */}
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<SlowDataComponent />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<TableSkeleton />}>
|
||||
<AnotherSlowComponent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Cached Server Functions
|
||||
|
||||
```typescript
|
||||
import { cache } from "react";
|
||||
|
||||
// Deduplicated across components in same request
|
||||
export const getUser = cache(async (id: string) => {
|
||||
const res = await fetch(`/api/users/${id}`);
|
||||
return res.json();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Action Patterns
|
||||
|
||||
### Basic Form Action
|
||||
|
||||
```typescript
|
||||
// app/actions.ts
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export async function createPost(formData: FormData) {
|
||||
const title = formData.get("title") as string;
|
||||
const body = formData.get("body") as string;
|
||||
|
||||
await db.post.create({ data: { title, body } });
|
||||
revalidatePath("/posts");
|
||||
redirect("/posts");
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// In a Server Component
|
||||
import { createPost } from "./actions";
|
||||
|
||||
export default function NewPost() {
|
||||
return (
|
||||
<form action={createPost}>
|
||||
<input name="title" />
|
||||
<textarea name="body" />
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useOptimistic } from "react";
|
||||
|
||||
function TodoList({ todos, addTodo }) {
|
||||
const [optimisticTodos, addOptimistic] = useOptimistic(
|
||||
todos,
|
||||
(state, newTodo: string) => [...state, { text: newTodo, pending: true }]
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={async (formData) => {
|
||||
const text = formData.get("text") as string;
|
||||
addOptimistic(text);
|
||||
await addTodo(text);
|
||||
}}>
|
||||
{optimisticTodos.map(todo => (
|
||||
<div style={{ opacity: todo.pending ? 0.5 : 1 }}>{todo.text}</div>
|
||||
))}
|
||||
<input name="text" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Middleware Patterns
|
||||
|
||||
```typescript
|
||||
// middleware.ts (project root)
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Auth check
|
||||
const token = request.cookies.get("token")?.value;
|
||||
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
// Add headers
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("x-request-id", crypto.randomUUID());
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Only run on matching paths
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Match all except static files and api
|
||||
"/((?!_next/static|_next/image|favicon.ico|api/).*)",
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Common Middleware Uses
|
||||
|
||||
| Use Case | Pattern |
|
||||
|----------|---------|
|
||||
| Auth redirect | Check cookie/header, redirect to login |
|
||||
| i18n routing | Read Accept-Language, redirect to locale |
|
||||
| A/B testing | Set cookie, rewrite to variant |
|
||||
| Rate limiting | Check IP/token bucket, return 429 |
|
||||
| Bot protection | Check User-Agent, block or challenge |
|
||||
| Feature flags | Read flag, rewrite to feature variant |
|
||||
@@ -1,108 +1,715 @@
|
||||
---
|
||||
name: react
|
||||
description: >
|
||||
Use this skill when building React components, using React hooks, or managing component state. Trigger for any mention of React, JSX, TSX, useState, useEffect, useCallback, useMemo, useContext, custom hooks, or React component patterns. Also applies when implementing context providers, handling component lifecycle, optimizing re-renders, or structuring React application architecture.
|
||||
---
|
||||
|
||||
# React
|
||||
|
||||
## Description
|
||||
|
||||
React component patterns, hooks, and state management best practices.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building React components
|
||||
- Using React hooks
|
||||
- Component state management
|
||||
- Client-side interactivity in any React-based framework
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Vue, Svelte, or Angular projects — this skill is React-specific
|
||||
- Backend-only projects without a frontend UI layer
|
||||
- Static HTML pages that do not require a JavaScript framework
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Functional Components
|
||||
### 1. Hooks
|
||||
|
||||
#### When-to-use guide
|
||||
|
||||
| Hook | Use when you need | Do NOT use for |
|
||||
|------|-------------------|----------------|
|
||||
| `useState` | Simple local state (toggle, form input, counter) | Derived/computed values |
|
||||
| `useEffect` | Side effects: subscriptions, DOM mutations, timers | Data transformation (use useMemo) |
|
||||
| `useRef` | Mutable value that persists across renders without triggering re-render; DOM refs | State that should cause re-render |
|
||||
| `useMemo` | Expensive computation that should only rerun when deps change | Simple/cheap calculations |
|
||||
| `useCallback` | Stable function reference to prevent child re-renders | Every function (only when needed) |
|
||||
| `useReducer` | Complex state with multiple sub-values or state transitions | Simple boolean/string state |
|
||||
| `useContext` | Reading context values | Frequently changing global state (causes re-renders) |
|
||||
|
||||
#### useState
|
||||
|
||||
```tsx
|
||||
interface UserCardProps {
|
||||
user: User;
|
||||
onSelect?: (user: User) => void;
|
||||
// Simple state
|
||||
const [count, setCount] = useState(0);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
// Functional updates (when new state depends on previous)
|
||||
setCount((prev) => prev + 1);
|
||||
|
||||
// Lazy initialization (expensive initial value)
|
||||
const [data, setData] = useState(() => computeExpensiveDefault());
|
||||
```
|
||||
|
||||
#### useEffect
|
||||
|
||||
```tsx
|
||||
// Run on mount + cleanup on unmount
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchData(controller.signal).then(setData);
|
||||
return () => controller.abort(); // Cleanup
|
||||
}, []); // Empty deps = run once
|
||||
|
||||
// Run when dependency changes
|
||||
useEffect(() => {
|
||||
const handler = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []); // No deps needed — handler is stable
|
||||
|
||||
// Sync external system with state
|
||||
useEffect(() => {
|
||||
document.title = `${count} items`;
|
||||
}, [count]);
|
||||
```
|
||||
|
||||
#### useRef
|
||||
|
||||
```tsx
|
||||
// DOM reference
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const focusInput = () => inputRef.current?.focus();
|
||||
|
||||
// Mutable value (no re-render on change)
|
||||
const renderCount = useRef(0);
|
||||
useEffect(() => {
|
||||
renderCount.current += 1;
|
||||
});
|
||||
|
||||
// Previous value pattern
|
||||
const prevValueRef = useRef(value);
|
||||
useEffect(() => {
|
||||
prevValueRef.current = value;
|
||||
}, [value]);
|
||||
```
|
||||
|
||||
#### useReducer
|
||||
|
||||
```tsx
|
||||
interface State {
|
||||
items: Item[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function UserCard({ user, onSelect }: UserCardProps) {
|
||||
type Action =
|
||||
| { type: "FETCH_START" }
|
||||
| { type: "FETCH_SUCCESS"; payload: Item[] }
|
||||
| { type: "FETCH_ERROR"; error: string };
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "FETCH_START":
|
||||
return { ...state, loading: true, error: null };
|
||||
case "FETCH_SUCCESS":
|
||||
return { items: action.payload, loading: false, error: null };
|
||||
case "FETCH_ERROR":
|
||||
return { ...state, loading: false, error: action.error };
|
||||
}
|
||||
}
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Dispatch actions
|
||||
dispatch({ type: "FETCH_START" });
|
||||
```
|
||||
|
||||
### 2. Custom Hooks
|
||||
|
||||
#### Extraction pattern
|
||||
|
||||
Extract a custom hook when:
|
||||
- Two or more components share the same stateful logic
|
||||
- A component's hook logic is complex enough to deserve its own name and tests
|
||||
- You want to abstract away an external API (localStorage, WebSocket, etc.)
|
||||
|
||||
**Rules:**
|
||||
- Name must start with `use`
|
||||
- Can call other hooks (unlike regular functions)
|
||||
- Each call gets its own independent state
|
||||
|
||||
#### Practical examples
|
||||
|
||||
```tsx
|
||||
// useLocalStorage — persist state to localStorage
|
||||
function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
const [stored, setStored] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? (JSON.parse(item) as T) : initialValue;
|
||||
} catch {
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = useCallback(
|
||||
(value: T | ((prev: T) => T)) => {
|
||||
setStored((prev) => {
|
||||
const next = value instanceof Function ? value(prev) : value;
|
||||
window.localStorage.setItem(key, JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [stored, setValue] as const;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [theme, setTheme] = useLocalStorage("theme", "light");
|
||||
```
|
||||
|
||||
```tsx
|
||||
// useDebounce — debounce a rapidly changing value
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
useEffect(() => {
|
||||
fetchResults(debouncedSearch);
|
||||
}, [debouncedSearch]);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// useFetch — generic data fetching hook
|
||||
function useFetch<T>(url: string) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(url, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => setData(json as T))
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") setError(err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => controller.abort();
|
||||
}, [url]);
|
||||
|
||||
return { data, error, loading };
|
||||
}
|
||||
|
||||
// Usage
|
||||
const { data: users, loading, error } = useFetch<User[]>("/api/users");
|
||||
```
|
||||
|
||||
### 3. Component Patterns
|
||||
|
||||
#### Compound components
|
||||
|
||||
```tsx
|
||||
// Components that work together, sharing implicit state
|
||||
interface TabsContextType {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
}
|
||||
const TabsContext = createContext<TabsContextType | null>(null);
|
||||
|
||||
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
return (
|
||||
<div className="card" onClick={() => onSelect?.(user)}>
|
||||
<h3>{user.name}</h3>
|
||||
<p>{user.email}</p>
|
||||
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
<div role="tablist">{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TabTrigger({ value, children }: { value: string; children: ReactNode }) {
|
||||
const ctx = useContext(TabsContext)!;
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={ctx.activeTab === value}
|
||||
onClick={() => ctx.setActiveTab(value)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContent({ value, children }: { value: string; children: ReactNode }) {
|
||||
const ctx = useContext(TabsContext)!;
|
||||
if (ctx.activeTab !== value) return null;
|
||||
return <div role="tabpanel">{children}</div>;
|
||||
}
|
||||
|
||||
// Attach sub-components
|
||||
Tabs.Trigger = TabTrigger;
|
||||
Tabs.Content = TabContent;
|
||||
|
||||
// Usage
|
||||
<Tabs defaultTab="settings">
|
||||
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
|
||||
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
|
||||
<Tabs.Content value="profile"><ProfileForm /></Tabs.Content>
|
||||
<Tabs.Content value="settings"><SettingsForm /></Tabs.Content>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
#### Render props
|
||||
|
||||
```tsx
|
||||
interface MousePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function MouseTracker({ render }: { render: (pos: MousePosition) => ReactNode }) {
|
||||
const [pos, setPos] = useState<MousePosition>({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
|
||||
window.addEventListener("mousemove", handler);
|
||||
return () => window.removeEventListener("mousemove", handler);
|
||||
}, []);
|
||||
|
||||
return <>{render(pos)}</>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<MouseTracker render={({ x, y }) => <span>Mouse: {x}, {y}</span>} />
|
||||
```
|
||||
|
||||
#### Controlled vs uncontrolled
|
||||
|
||||
```tsx
|
||||
// Controlled — parent owns the state
|
||||
interface ControlledInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function ControlledInput({ value, onChange }: ControlledInputProps) {
|
||||
return <input value={value} onChange={(e) => onChange(e.target.value)} />;
|
||||
}
|
||||
|
||||
// Uncontrolled — component owns the state, parent reads via ref or callback
|
||||
function UncontrolledInput({ defaultValue }: { defaultValue?: string }) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
return <input ref={ref} defaultValue={defaultValue} />;
|
||||
}
|
||||
|
||||
// Flexible pattern — supports both controlled and uncontrolled
|
||||
function FlexibleInput({
|
||||
value: controlledValue,
|
||||
defaultValue = "",
|
||||
onChange,
|
||||
}: {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (!isControlled) setInternalValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}
|
||||
|
||||
return <input value={value} onChange={handleChange} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Context
|
||||
|
||||
#### Provider pattern with separate state and dispatch
|
||||
|
||||
```tsx
|
||||
// Split context to prevent unnecessary re-renders
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
theme: "light" | "dark";
|
||||
}
|
||||
|
||||
type AppAction =
|
||||
| { type: "SET_USER"; user: User | null }
|
||||
| { type: "TOGGLE_THEME" };
|
||||
|
||||
const AppStateContext = createContext<AppState | null>(null);
|
||||
const AppDispatchContext = createContext<React.Dispatch<AppAction> | null>(null);
|
||||
|
||||
function appReducer(state: AppState, action: AppAction): AppState {
|
||||
switch (action.type) {
|
||||
case "SET_USER":
|
||||
return { ...state, user: action.user };
|
||||
case "TOGGLE_THEME":
|
||||
return { ...state, theme: state.theme === "light" ? "dark" : "light" };
|
||||
}
|
||||
}
|
||||
|
||||
function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(appReducer, {
|
||||
user: null,
|
||||
theme: "light",
|
||||
});
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider value={state}>
|
||||
<AppDispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</AppDispatchContext.Provider>
|
||||
</AppStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Typed hooks for consumers
|
||||
function useAppState() {
|
||||
const ctx = useContext(AppStateContext);
|
||||
if (!ctx) throw new Error("useAppState must be used within AppProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function useAppDispatch() {
|
||||
const ctx = useContext(AppDispatchContext);
|
||||
if (!ctx) throw new Error("useAppDispatch must be used within AppProvider");
|
||||
return ctx;
|
||||
}
|
||||
```
|
||||
|
||||
**Why split?** Components that only dispatch actions (buttons) do not re-render when state changes. Only components that read state re-render.
|
||||
|
||||
#### Context splitting for performance
|
||||
|
||||
```tsx
|
||||
// Instead of one giant context with everything:
|
||||
const UserContext = createContext<User | null>(null);
|
||||
const ThemeContext = createContext<"light" | "dark">("light");
|
||||
const NotificationContext = createContext<Notification[]>([]);
|
||||
|
||||
// Components subscribe only to the context they need
|
||||
function Avatar() {
|
||||
const user = useContext(UserContext); // Only re-renders when user changes
|
||||
return <img src={user?.avatar} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Error Boundaries
|
||||
|
||||
#### Class-based error boundary
|
||||
|
||||
```tsx
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean; error: Error | null }
|
||||
> {
|
||||
constructor(props: { children: ReactNode; fallback: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("Error boundary caught:", error, info.componentStack);
|
||||
// Send to error tracking service
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### react-error-boundary library (recommended)
|
||||
|
||||
```tsx
|
||||
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
|
||||
|
||||
function ErrorFallback({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
error: Error;
|
||||
resetErrorBoundary: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="alert">
|
||||
<h2>Something went wrong</h2>
|
||||
<pre>{error.message}</pre>
|
||||
<button onClick={resetErrorBoundary}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onReset={() => {
|
||||
// Reset app state if needed
|
||||
}}
|
||||
resetKeys={[userId]} // Auto-reset when these values change
|
||||
>
|
||||
<Dashboard />
|
||||
</ErrorBoundary>
|
||||
|
||||
// Programmatic error throwing from child
|
||||
function SaveButton() {
|
||||
const { showBoundary } = useErrorBoundary();
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await saveData();
|
||||
} catch (error) {
|
||||
showBoundary(error); // Propagate to nearest ErrorBoundary
|
||||
}
|
||||
}
|
||||
|
||||
return <button onClick={handleSave}>Save</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Suspense
|
||||
|
||||
#### Suspense boundaries with lazy loading
|
||||
|
||||
```tsx
|
||||
import { Suspense, lazy } from "react";
|
||||
|
||||
// Code-split heavy components
|
||||
const HeavyChart = lazy(() => import("./heavy-chart"));
|
||||
const AdminPanel = lazy(() => import("./admin-panel"));
|
||||
|
||||
function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<HeavyChart />
|
||||
</Suspense>
|
||||
<Suspense fallback={<div>Loading admin panel...</div>}>
|
||||
<AdminPanel />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Hooks
|
||||
#### Suspense with data fetching (React 19+ / framework integration)
|
||||
|
||||
```tsx
|
||||
// useState
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
// useEffect
|
||||
useEffect(() => {
|
||||
const subscription = subscribe();
|
||||
return () => subscription.unsubscribe();
|
||||
}, [dependency]);
|
||||
|
||||
// useMemo
|
||||
const expensive = useMemo(() => compute(data), [data]);
|
||||
|
||||
// useCallback
|
||||
const handleClick = useCallback(() => {
|
||||
doSomething(id);
|
||||
}, [id]);
|
||||
```
|
||||
|
||||
### Custom Hooks
|
||||
|
||||
```tsx
|
||||
function useUser(id: string) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchUser(id)
|
||||
.then(setUser)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
return { user, loading };
|
||||
}
|
||||
```
|
||||
|
||||
### Context Pattern
|
||||
|
||||
```tsx
|
||||
const UserContext = createContext<User | null>(null);
|
||||
|
||||
export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
// With a Suspense-compatible data source (React Query, Next.js, Relay)
|
||||
function ProjectList() {
|
||||
return (
|
||||
<UserContext.Provider value={user}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
<Suspense fallback={<ProjectListSkeleton />}>
|
||||
<ProjectListContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
const context = useContext(UserContext);
|
||||
if (!context) throw new Error('useUser must be within UserProvider');
|
||||
return context;
|
||||
// The data-fetching component suspends while loading
|
||||
function ProjectListContent() {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: fetchProjects,
|
||||
});
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{data.map((p) => (
|
||||
<li key={p.id}>{p.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Named exports with lazy
|
||||
|
||||
```tsx
|
||||
// For named exports, wrap in a default export adapter
|
||||
const UserSettings = lazy(() =>
|
||||
import("./user-settings").then((mod) => ({ default: mod.UserSettings })),
|
||||
);
|
||||
```
|
||||
|
||||
### 7. Performance
|
||||
|
||||
#### React.memo
|
||||
|
||||
```tsx
|
||||
// Only re-renders when props change (shallow comparison)
|
||||
const ExpensiveList = React.memo(function ExpensiveList({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: Item[];
|
||||
onSelect: (item: Item) => void;
|
||||
}) {
|
||||
return (
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.id} onClick={() => onSelect(item)}>
|
||||
{item.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
});
|
||||
|
||||
// Custom comparison
|
||||
const Chart = React.memo(ChartComponent, (prev, next) => {
|
||||
return prev.data.length === next.data.length && prev.title === next.title;
|
||||
});
|
||||
```
|
||||
|
||||
#### useMemo and useCallback together
|
||||
|
||||
```tsx
|
||||
function ParentComponent({ items }: { items: Item[] }) {
|
||||
// Memoize expensive derived data
|
||||
const sortedItems = useMemo(
|
||||
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[items],
|
||||
);
|
||||
|
||||
// Stable function reference for memoized child
|
||||
const handleSelect = useCallback((item: Item) => {
|
||||
console.log("Selected:", item.id);
|
||||
}, []);
|
||||
|
||||
// ExpensiveList only re-renders when sortedItems or handleSelect change
|
||||
return <ExpensiveList items={sortedItems} onSelect={handleSelect} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Key prop optimization
|
||||
|
||||
```tsx
|
||||
// BAD: using index as key — breaks state when list order changes
|
||||
{items.map((item, index) => <Item key={index} data={item} />)}
|
||||
|
||||
// GOOD: stable unique key
|
||||
{items.map((item) => <Item key={item.id} data={item} />)}
|
||||
|
||||
// Force remount: change key to reset component state
|
||||
<ProfileForm key={userId} userId={userId} />
|
||||
// When userId changes, the form unmounts and remounts with fresh state
|
||||
```
|
||||
|
||||
#### Virtualization for large lists
|
||||
|
||||
```tsx
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
function VirtualList({ items }: { items: Item[] }) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 50, // Estimated row height in px
|
||||
overscan: 5, // Extra rows rendered above/below viewport
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={parentRef} style={{ height: "400px", overflow: "auto" }}>
|
||||
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
height: `${virtualRow.size}px`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{items[virtualRow.index].name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Keep components small and focused
|
||||
2. Use TypeScript for props
|
||||
3. Memoize expensive computations
|
||||
4. Clean up effects properly
|
||||
5. Lift state up when needed
|
||||
1. **Keep components small and single-purpose** — if a component exceeds ~100 lines or handles multiple concerns, extract sub-components or custom hooks. Name components after what they render, not what they do.
|
||||
|
||||
2. **Use TypeScript interfaces for all props** — define explicit prop types. Avoid `any`. Use discriminated unions for props that change based on a variant. Export prop types for reuse.
|
||||
|
||||
3. **Clean up all effects** — return a cleanup function from every `useEffect` that subscribes to events, starts timers, or creates abort controllers. Missing cleanups cause memory leaks and stale state bugs.
|
||||
|
||||
4. **Derive state instead of syncing it** — if a value can be computed from props or other state, compute it during render (or with `useMemo`). Never `useEffect` to sync derived state — it causes an extra render.
|
||||
|
||||
5. **Lift state to the lowest common ancestor** — not higher. State should live in the closest parent that needs it. If siblings need shared state, lift to their parent. If distant components need it, use context.
|
||||
|
||||
6. **Use `useCallback` and `React.memo` together, not alone** — `useCallback` only helps when the function is passed to a memoized child. `React.memo` only helps when the parent actually passes stable props. Using one without the other is wasted effort.
|
||||
|
||||
7. **Prefer composition over prop drilling** — instead of passing props through 5 levels, restructure so the parent renders the child directly (component composition) or use context for truly global state.
|
||||
|
||||
8. **Handle all async states** — every data-fetching component should handle loading, error, and empty states. Use Suspense and Error Boundaries for declarative handling. Never leave a component that shows nothing while loading.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Missing dependencies in hooks**: Include all dependencies
|
||||
- **State updates on unmounted**: Use cleanup functions
|
||||
- **Prop drilling**: Use context or composition
|
||||
1. **Missing or wrong dependency arrays** — forgetting to add a dependency to `useEffect`/`useMemo`/`useCallback` causes stale closures. Adding too many causes unnecessary re-runs. Use the `react-hooks/exhaustive-deps` ESLint rule.
|
||||
|
||||
2. **Setting state during render** — calling `setState` unconditionally in the render body causes infinite re-render loops. State updates should be in event handlers, effects, or callbacks — never at the top level of the component function.
|
||||
|
||||
3. **Prop drilling through many layers** — passing a prop through 4+ intermediate components that do not use it. Fix with composition (restructuring the component tree), context (for shared state), or a state management library.
|
||||
|
||||
4. **Creating objects/arrays in JSX props** — `<Child style={{ color: "red" }} />` creates a new object every render, defeating `React.memo`. Hoist constants outside the component or use `useMemo`.
|
||||
|
||||
5. **Using `useEffect` for derived state** — syncing state with `useEffect(() => setFullName(first + last), [first, last])` causes double renders. Just compute it: `const fullName = first + last`. Use `useMemo` if the computation is expensive.
|
||||
|
||||
6. **Not handling race conditions in effects** — when a component fetches data based on a prop, fast prop changes can cause older responses to arrive after newer ones, displaying stale data. Use `AbortController` or a boolean flag to ignore stale responses.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frameworks/nextjs` — Next.js App Router, SSR, and full-stack React patterns
|
||||
- `languages/typescript` — TypeScript strict mode and type patterns
|
||||
- `frontend/tailwind` — Styling with Tailwind CSS
|
||||
- `frontend/shadcn-ui` — UI component library built on Radix and Tailwind
|
||||
- `testing/vitest` — Testing React components with vitest and testing-library
|
||||
- `patterns/state-management` — State management patterns for React
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# React Patterns Quick Reference
|
||||
|
||||
## Hook Rules
|
||||
|
||||
1. Only call hooks at the **top level** (not inside loops, conditions, or nested functions)
|
||||
2. Only call hooks from **React function components** or **custom hooks**
|
||||
3. Custom hooks **must** start with `use`
|
||||
4. Hook call order must be **identical** on every render
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
if (isLoggedIn) {
|
||||
const [user, setUser] = useState(null); // Conditional hook call
|
||||
}
|
||||
|
||||
// GOOD
|
||||
const [user, setUser] = useState(null);
|
||||
// Use the state conditionally instead
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Hook Recipes
|
||||
|
||||
### useLocalStorage
|
||||
|
||||
```typescript
|
||||
function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch {
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}, [key, value]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [theme, setTheme] = useLocalStorage("theme", "dark");
|
||||
```
|
||||
|
||||
### useDebounce
|
||||
|
||||
```typescript
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResults(debouncedSearch);
|
||||
}, [debouncedSearch]);
|
||||
```
|
||||
|
||||
### useMediaQuery
|
||||
|
||||
```typescript
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(
|
||||
() => window.matchMedia(query).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
```
|
||||
|
||||
### usePrevious
|
||||
|
||||
```typescript
|
||||
function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const prevCount = usePrevious(count);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Compound Component Pattern
|
||||
|
||||
```typescript
|
||||
// Components that work together sharing implicit state
|
||||
const Accordion = ({ children }: { children: React.ReactNode }) => {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
return (
|
||||
<AccordionContext.Provider value={{ openIndex, setOpenIndex }}>
|
||||
<div role="tablist">{children}</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const AccordionItem = ({ index, title, children }: {
|
||||
index: number; title: string; children: React.ReactNode;
|
||||
}) => {
|
||||
const { openIndex, setOpenIndex } = useContext(AccordionContext);
|
||||
const isOpen = openIndex === index;
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setOpenIndex(isOpen ? null : index)}>
|
||||
{title}
|
||||
</button>
|
||||
{isOpen && <div>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Accordion.Item = AccordionItem;
|
||||
|
||||
// Usage - clean, declarative API
|
||||
<Accordion>
|
||||
<Accordion.Item index={0} title="Section 1">Content 1</Accordion.Item>
|
||||
<Accordion.Item index={1} title="Section 2">Content 2</Accordion.Item>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Patterns
|
||||
|
||||
### Split State and Dispatch
|
||||
|
||||
```typescript
|
||||
// Prevent unnecessary re-renders by splitting contexts
|
||||
const StateContext = createContext<State | null>(null);
|
||||
const DispatchContext = createContext<Dispatch | null>(null);
|
||||
|
||||
function Provider({ children }: { children: React.ReactNode }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
return (
|
||||
<DispatchContext.Provider value={dispatch}>
|
||||
<StateContext.Provider value={state}>
|
||||
{children}
|
||||
</StateContext.Provider>
|
||||
</DispatchContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Components that only dispatch won't re-render on state changes
|
||||
function useAppDispatch() {
|
||||
const ctx = useContext(DispatchContext);
|
||||
if (!ctx) throw new Error("Must be used within Provider");
|
||||
return ctx;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
### When to Memoize
|
||||
|
||||
| Situation | Solution |
|
||||
|-----------|----------|
|
||||
| Expensive computation | `useMemo(() => compute(data), [data])` |
|
||||
| Stable callback for child | `useCallback(fn, [deps])` |
|
||||
| Prevent child re-render | `React.memo(Component)` |
|
||||
| Stable object/array prop | `useMemo(() => ({ ... }), [deps])` |
|
||||
|
||||
### When NOT to Memoize
|
||||
|
||||
- Simple/cheap calculations
|
||||
- Primitives (strings, numbers, booleans) -- already stable
|
||||
- Functions only used in the same component
|
||||
- Components that are cheap to render
|
||||
|
||||
### Key Optimizations
|
||||
|
||||
```typescript
|
||||
// 1. Move state down (colocate state with where it's used)
|
||||
// BAD: parent re-renders everything
|
||||
function Parent() {
|
||||
const [hover, setHover] = useState(false);
|
||||
return <><ExpensiveChild /><HoverTarget onHover={setHover} /></>;
|
||||
}
|
||||
|
||||
// GOOD: isolate the stateful part
|
||||
function Parent() {
|
||||
return <><ExpensiveChild /><HoverSection /></>;
|
||||
}
|
||||
function HoverSection() {
|
||||
const [hover, setHover] = useState(false);
|
||||
return <HoverTarget onHover={setHover} />;
|
||||
}
|
||||
|
||||
// 2. Pass children to avoid re-rendering them
|
||||
function ScrollTracker({ children }: { children: React.ReactNode }) {
|
||||
const [scroll, setScroll] = useState(0);
|
||||
// children are created by parent, not re-created on scroll change
|
||||
return <div onScroll={e => setScroll(e.currentTarget.scrollTop)}>{children}</div>;
|
||||
}
|
||||
|
||||
// 3. Use key to reset component state
|
||||
<Form key={selectedId} initialData={data} />
|
||||
|
||||
// 4. Lazy load heavy components
|
||||
const HeavyChart = lazy(() => import("./HeavyChart"));
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<HeavyChart data={data} />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
### React DevTools Profiler Checklist
|
||||
|
||||
1. Enable "Highlight updates" to spot unnecessary re-renders
|
||||
2. Record a profiler session and look for:
|
||||
- Components re-rendering without visible changes
|
||||
- Long render times (>16ms blocks frames)
|
||||
- Cascading re-renders from context changes
|
||||
3. Fix with: state colocation, memo, context splitting, or external state
|
||||
@@ -1,87 +1,938 @@
|
||||
---
|
||||
name: shadcn-ui
|
||||
description: >
|
||||
Use this skill when building accessible React UI components with shadcn/ui and Radix primitives. Trigger on keywords like shadcn, Radix, component library, Dialog, Form, Button variants, cn() utility, and asChild composition. Also apply when setting up a design system based on shadcn/ui, customizing component themes via CSS variables, or integrating shadcn/ui forms with react-hook-form and Zod validation.
|
||||
---
|
||||
|
||||
# shadcn/ui
|
||||
|
||||
## Description
|
||||
|
||||
shadcn/ui component library patterns with Radix primitives and Tailwind styling.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building React component libraries
|
||||
- Accessible UI components
|
||||
- Customizable design systems
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Non-React projects using Vue, Svelte, Angular, or other frameworks
|
||||
- Projects already using a different component library such as MUI, Chakra UI, or Ant Design
|
||||
- Vanilla HTML/CSS projects without a React build pipeline
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Button Component
|
||||
### 1. Installation & Setup
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button"
|
||||
**Initialize shadcn/ui in a Next.js or Vite project:**
|
||||
|
||||
<Button variant="default">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
```bash
|
||||
# Initialize -- creates components.json and sets up paths
|
||||
npx shadcn@latest init
|
||||
|
||||
# You will be prompted for:
|
||||
# - Style (default or new-york)
|
||||
# - Base color
|
||||
# - CSS variables for colors (yes recommended)
|
||||
# - Tailwind config path
|
||||
# - Components alias path (@/components)
|
||||
# - Utils alias path (@/lib/utils)
|
||||
```
|
||||
|
||||
### Form with Validation
|
||||
**Install individual components as needed:**
|
||||
|
||||
```bash
|
||||
# Install specific components
|
||||
npx shadcn@latest add button
|
||||
npx shadcn@latest add card
|
||||
npx shadcn@latest add dialog
|
||||
npx shadcn@latest add form
|
||||
npx shadcn@latest add input
|
||||
npx shadcn@latest add table
|
||||
npx shadcn@latest add toast
|
||||
|
||||
# Install multiple at once
|
||||
npx shadcn@latest add button card input label textarea select
|
||||
|
||||
# List available components
|
||||
npx shadcn@latest add
|
||||
```
|
||||
|
||||
**Project structure after setup:**
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ └── ui/ # shadcn/ui components live here
|
||||
│ ├── button.tsx
|
||||
│ ├── card.tsx
|
||||
│ ├── dialog.tsx
|
||||
│ └── ...
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() utility
|
||||
└── app/
|
||||
└── globals.css # CSS variables for theming
|
||||
```
|
||||
|
||||
**The `cn()` utility -- the foundation of class merging:**
|
||||
|
||||
```ts
|
||||
// lib/utils.ts (auto-generated)
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Component Customization
|
||||
|
||||
**Extending an existing component with new variants:**
|
||||
|
||||
```tsx
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
// components/ui/button.tsx -- add a "brand" variant
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
// Custom variant added
|
||||
brand: "bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**Wrapping a shadcn component with project-specific defaults:**
|
||||
|
||||
```tsx
|
||||
// components/app/submit-button.tsx
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SubmitButtonProps extends ButtonProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
loading,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || loading}
|
||||
className={cn("min-w-[120px]", className)}
|
||||
{...props}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Using `asChild` for composition:**
|
||||
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
// Render as a Next.js Link instead of a <button>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
|
||||
// Render as an anchor tag
|
||||
<Button asChild variant="link">
|
||||
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
|
||||
External Link
|
||||
</a>
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 3. Form Patterns
|
||||
|
||||
**Complete form with react-hook-form + zod validation:**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email("Please enter a valid email"),
|
||||
category: z.enum(["general", "support", "billing"], {
|
||||
required_error: "Please select a category",
|
||||
}),
|
||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||
});
|
||||
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
type ContactFormValues = z.infer<typeof contactSchema>;
|
||||
|
||||
export function ContactForm() {
|
||||
const form = useForm<ContactFormValues>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: ContactFormValues) {
|
||||
// Handle form submission
|
||||
console.log(values);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="you@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>We will never share your email.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="general">General Inquiry</SelectItem>
|
||||
<SelectItem value="support">Technical Support</SelectItem>
|
||||
<SelectItem value="billing">Billing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Message</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="How can we help?"
|
||||
className="min-h-[120px] resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Sending..." : "Send Message"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog
|
||||
### 4. Data Table
|
||||
|
||||
**Column definitions with sorting and formatting:**
|
||||
|
||||
```tsx
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
"use client";
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Open</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Title</DialogTitle>
|
||||
</DialogHeader>
|
||||
Content
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: "pending" | "processing" | "success" | "failed";
|
||||
email: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Payment>[] = [
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Email
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as string;
|
||||
const variant = {
|
||||
pending: "secondary",
|
||||
processing: "outline",
|
||||
success: "default",
|
||||
failed: "destructive",
|
||||
}[status] as "secondary" | "outline" | "default" | "destructive";
|
||||
|
||||
return <Badge variant={variant}>{status}</Badge>;
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(row.getValue(id)),
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: () => <div className="text-right">Amount</div>,
|
||||
cell: ({ row }) => {
|
||||
const amount = parseFloat(row.getValue("amount"));
|
||||
const formatted = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
return <div className="text-right font-medium">{formatted}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(row.original.id)}>
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>View details</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**DataTable component with filtering and pagination:**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
state: { sorting, columnFilters },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{searchKey && (
|
||||
<Input
|
||||
placeholder={`Filter by ${searchKey}...`}
|
||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||
onChange={(e) => table.getColumn(searchKey)?.setFilterValue(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{table.getFilteredRowModel().rows.length} row(s) total
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Dialog / Sheet / Drawer
|
||||
|
||||
**Controlled dialog with form:**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function EditProfileDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
// Save profile...
|
||||
setOpen(false); // Close after success
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Edit Profile</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to your profile. Click save when you are done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">Name</Label>
|
||||
<Input id="name" defaultValue="John Doe" className="col-span-3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="username" className="text-right">Username</Label>
|
||||
<Input id="username" defaultValue="@johndoe" className="col-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Sheet for side panels (mobile nav, filters):**
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
export function MobileNav() {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[280px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Browse the application.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
<a href="/" className="rounded-md px-3 py-2 text-sm hover:bg-accent">Home</a>
|
||||
<a href="/about" className="rounded-md px-3 py-2 text-sm hover:bg-accent">About</a>
|
||||
<a href="/settings" className="rounded-md px-3 py-2 text-sm hover:bg-accent">Settings</a>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Confirmation dialog pattern (reusable):**
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onConfirm: () => void;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
destructive = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className={destructive ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" : ""}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Toast / Notifications
|
||||
|
||||
**Setup with Sonner (recommended approach):**
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add sonner
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx -- add the Toaster provider
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Using toast anywhere in your app
|
||||
import { toast } from "sonner";
|
||||
|
||||
function SaveButton() {
|
||||
async function handleSave() {
|
||||
try {
|
||||
await saveData();
|
||||
toast.success("Changes saved", {
|
||||
description: "Your profile has been updated.",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to save", {
|
||||
description: "Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return <Button onClick={handleSave}>Save</Button>;
|
||||
}
|
||||
|
||||
// Toast variants
|
||||
toast("Default notification");
|
||||
toast.success("Operation completed");
|
||||
toast.error("Something went wrong");
|
||||
toast.warning("Please review your input");
|
||||
toast.info("New version available");
|
||||
|
||||
// Toast with action
|
||||
toast("File deleted", {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => restoreFile(),
|
||||
},
|
||||
});
|
||||
|
||||
// Promise toast -- shows loading, success, and error states
|
||||
toast.promise(fetchData(), {
|
||||
loading: "Loading data...",
|
||||
success: "Data loaded successfully",
|
||||
error: "Failed to load data",
|
||||
});
|
||||
```
|
||||
|
||||
### 7. Theme System
|
||||
|
||||
**CSS variables in globals.css:**
|
||||
|
||||
```css
|
||||
/* app/globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Creating a custom color theme:**
|
||||
|
||||
```css
|
||||
/* Add a custom "ocean" theme alongside light and dark */
|
||||
.theme-ocean {
|
||||
--background: 210 50% 10%;
|
||||
--foreground: 195 80% 90%;
|
||||
--primary: 195 90% 50%;
|
||||
--primary-foreground: 210 50% 10%;
|
||||
--secondary: 200 40% 20%;
|
||||
--secondary-foreground: 195 80% 90%;
|
||||
--muted: 200 30% 18%;
|
||||
--muted-foreground: 195 30% 60%;
|
||||
--accent: 180 60% 40%;
|
||||
--accent-foreground: 195 80% 90%;
|
||||
--border: 200 30% 25%;
|
||||
--input: 200 30% 25%;
|
||||
--ring: 195 90% 50%;
|
||||
}
|
||||
```
|
||||
|
||||
**Theme provider for Next.js:**
|
||||
|
||||
```tsx
|
||||
// components/theme-provider.tsx
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// components/theme-toggle.tsx
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use cn() for conditional classes
|
||||
2. Compose primitives for custom components
|
||||
3. Follow accessibility patterns
|
||||
4. Customize via CSS variables
|
||||
5. Use asChild for composition
|
||||
1. **Install components individually** -- only add what you need. Each component is copied into your codebase, so unused components add dead code.
|
||||
2. **Customize at the source** -- since components live in your `components/ui/` directory, modify them directly rather than wrapping with overrides. This is the intended workflow.
|
||||
3. **Use `cn()` for all conditional styling** -- it merges Tailwind classes correctly, avoiding conflicts. Never concatenate class strings manually.
|
||||
4. **Keep forms type-safe end to end** -- define a zod schema, infer the TypeScript type from it, and pass it to `useForm<T>`. This gives you validation and type safety in one place.
|
||||
5. **Use `asChild` for semantic HTML** -- when a Button should be a link, or a DialogTrigger should be a custom component, use `asChild` to avoid nested interactive elements.
|
||||
6. **Follow the CSS variable naming convention** -- shadcn/ui expects HSL values without the `hsl()` wrapper (e.g., `220 14% 96%`). The `hsl()` is applied in Tailwind config.
|
||||
7. **Wrap layout-level providers once** -- place `ThemeProvider`, `Toaster`, and other providers in the root layout. Do not nest them in individual pages.
|
||||
8. **Prefer Sonner over the legacy toast** -- the Sonner integration is simpler, supports rich colors, promise toasts, and requires less boilerplate than the older toast component.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Missing cn import**: Import from lib/utils
|
||||
- **Wrong composition**: Use asChild properly
|
||||
- **Hardcoded colors**: Use CSS variables
|
||||
1. **Missing `cn` import** -- every component uses `cn()` from `@/lib/utils`. If you see class merging issues, verify this import exists and uses both `clsx` and `tailwind-merge`.
|
||||
2. **Incorrect `asChild` usage** -- `asChild` merges props onto the immediate child. If you wrap the child in a fragment or extra div, the props will not pass through correctly.
|
||||
3. **Hardcoded colors instead of CSS variables** -- using `bg-blue-500` instead of `bg-primary` bypasses the theme system. Always use semantic token names so dark mode and custom themes work.
|
||||
4. **Forgetting `"use client"` directive** -- shadcn/ui components using hooks (Dialog, Form, Sheet, etc.) require the `"use client"` directive in Next.js App Router. The UI primitives themselves include it, but your page-level components that use them may also need it.
|
||||
5. **Not handling controlled state in dialogs** -- for dialogs that contain forms, use the controlled `open` / `onOpenChange` pattern so you can close the dialog programmatically after submission.
|
||||
6. **Stale component versions** -- since components are copied into your project, they do not auto-update. Periodically check the shadcn/ui docs for fixes and re-run `npx shadcn@latest add <component>` to pull updates (review the diff before accepting).
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frontend/tailwind` - Tailwind CSS utility classes used for styling shadcn/ui components
|
||||
- `frameworks/react` - React patterns and hooks used alongside shadcn/ui
|
||||
- `frameworks/nextjs` - Next.js integration with shadcn/ui for full-stack applications
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# shadcn/ui Component Recipes
|
||||
|
||||
Common compositions using shadcn/ui. Install components via `npx shadcn@latest add`.
|
||||
|
||||
---
|
||||
|
||||
## Search Command Palette
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add command dialog
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
CommandDialog, CommandEmpty, CommandGroup,
|
||||
CommandInput, CommandItem, CommandList,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
const items = [
|
||||
{ group: "Pages", entries: [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/settings" },
|
||||
]},
|
||||
{ group: "Actions", entries: [
|
||||
{ label: "Create project", action: "new-project" },
|
||||
]},
|
||||
];
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{items.map((group) => (
|
||||
<CommandGroup key={group.group} heading={group.group}>
|
||||
{group.entries.map((entry) => (
|
||||
<CommandItem key={entry.label} onSelect={() => setOpen(false)}>
|
||||
{entry.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings Form
|
||||
|
||||
`npx shadcn@latest add form input switch button separator`
|
||||
|
||||
```tsx
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
|
||||
const schema = z.object({ displayName: z.string().min(2).max(50), email: z.string().email(), notifications: z.boolean() });
|
||||
|
||||
export function SettingsForm() {
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { displayName: "", email: "", notifications: true },
|
||||
});
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Settings</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage your account preferences.</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(console.log)} className="space-y-6">
|
||||
<FormField control={form.control} name="displayName" render={({ field }) => (
|
||||
<FormItem><FormLabel>Display Name</FormLabel><FormControl><Input placeholder="Jane Doe" {...field} /></FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="email" render={({ field }) => (
|
||||
<FormItem><FormLabel>Email</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="notifications" render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div><FormLabel>Notifications</FormLabel><FormDescription>Receive account emails.</FormDescription></div>
|
||||
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
|
||||
</FormItem>
|
||||
)} />
|
||||
<Button type="submit">Save changes</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Table with Filters
|
||||
|
||||
`npx shadcn@latest add table input button badge dropdown-menu`
|
||||
|
||||
```tsx
|
||||
import { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface User { id: string; name: string; email: string; role: "admin" | "user"; status: "active" | "inactive"; }
|
||||
|
||||
export function UsersTable({ users }: { users: User[] }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||
const filtered = useMemo(() => users.filter((u) => {
|
||||
const match = u.name.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
|
||||
return match && (!roleFilter || u.role === roleFilter);
|
||||
}), [users, search, roleFilter]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} className="max-w-sm" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">{roleFilter ?? "All roles"}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setRoleFilter(null)}>All</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setRoleFilter("admin")}>Admin</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setRoleFilter("user")}>User</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>Name</TableHead><TableHead>Email</TableHead><TableHead>Role</TableHead><TableHead>Status</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>{filtered.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell><Badge variant={u.role === "admin" ? "default" : "secondary"}>{u.role}</Badge></TableCell>
|
||||
<TableCell><Badge variant={u.status === "active" ? "default" : "outline"}>{u.status}</Badge></TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Layout with Sidebar
|
||||
|
||||
`npx shadcn@latest add button separator sheet avatar`
|
||||
|
||||
```tsx
|
||||
import { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Dashboard", href: "/", active: true },
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: "Settings", href: "/settings" },
|
||||
];
|
||||
|
||||
function SidebarNav() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="px-4 py-5 text-lg font-bold">App Name</div>
|
||||
<Separator />
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{navItems.map((item) => (
|
||||
<a key={item.href} href={item.href} className={`block rounded-md px-3 py-2 text-sm font-medium ${
|
||||
item.active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"}`}>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<Separator />
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<Avatar className="h-8 w-8"><AvatarImage src="/avatar.jpg" /><AvatarFallback>JD</AvatarFallback></Avatar>
|
||||
<div className="truncate">
|
||||
<p className="text-sm font-medium">Jane Doe</p>
|
||||
<p className="text-xs text-muted-foreground">jane@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<aside className="hidden w-64 border-r bg-card lg:block"><SidebarNav /></aside>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center gap-4 border-b px-4 lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild><Button variant="ghost" size="icon">Menu</Button></SheetTrigger>
|
||||
<SheetContent side="left" className="w-64 p-0"><SidebarNav /></SheetContent>
|
||||
</Sheet>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto p-6 lg:p-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `cn()` from `@/lib/utils` to merge conditional classes
|
||||
- Pair with `react-hook-form` + `zod` for form validation
|
||||
- Use `Sheet` for mobile sidebars, `Dialog` for modals
|
||||
- Customize theme in `globals.css` using CSS variables
|
||||
@@ -1,82 +1,588 @@
|
||||
---
|
||||
name: tailwind
|
||||
description: >
|
||||
Use this skill when styling web components with Tailwind CSS utility classes, building responsive layouts, or implementing dark mode support. Trigger on keywords like Tailwind, utility classes, responsive breakpoints, dark mode, flex, grid layout, and mobile-first design. Also apply when creating component variants with Tailwind, configuring tailwind.config, or migrating from traditional CSS to a utility-first approach.
|
||||
---
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
## Description
|
||||
|
||||
Tailwind CSS utility-first styling with responsive design and component patterns.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Styling React/Next.js components
|
||||
- Responsive design
|
||||
- Rapid UI development
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Backend-only projects with no frontend or UI layer
|
||||
- Projects using CSS-in-JS solutions like styled-components or Emotion
|
||||
- Non-web applications such as CLI tools, mobile native apps, or desktop utilities
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Layout
|
||||
### 1. Responsive Design
|
||||
|
||||
```html
|
||||
<!-- Flexbox -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left</div>
|
||||
<div>Right</div>
|
||||
</div>
|
||||
Tailwind uses a mobile-first breakpoint system. Styles without a prefix apply to all screen sizes; prefixed styles apply at that breakpoint and above.
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Item</div>
|
||||
</div>
|
||||
| Breakpoint | Min Width | Typical Target |
|
||||
|------------|-----------|----------------|
|
||||
| `sm` | 640px | Large phones |
|
||||
| `md` | 768px | Tablets |
|
||||
| `lg` | 1024px | Laptops |
|
||||
| `xl` | 1280px | Desktops |
|
||||
| `2xl` | 1536px | Large screens |
|
||||
|
||||
<!-- Container -->
|
||||
<div class="container mx-auto px-4">
|
||||
Content
|
||||
</div>
|
||||
```tsx
|
||||
// Mobile-first responsive text and spacing
|
||||
function HeroSection() {
|
||||
return (
|
||||
<section className="px-4 py-8 sm:px-6 sm:py-12 md:px-8 md:py-16 lg:py-24">
|
||||
<h1 className="text-2xl font-bold sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
|
||||
Build faster with Tailwind
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-gray-600 sm:text-base md:text-lg lg:max-w-2xl">
|
||||
A utility-first CSS framework for rapid UI development.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Responsive
|
||||
|
||||
```html
|
||||
<!-- Mobile-first breakpoints -->
|
||||
<div class="w-full md:w-1/2 lg:w-1/3">
|
||||
<!-- sm: 640px, md: 768px, lg: 1024px, xl: 1280px -->
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl md:text-2xl lg:text-4xl">
|
||||
Responsive text
|
||||
</h1>
|
||||
```tsx
|
||||
// Responsive container with constrained width
|
||||
function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### States
|
||||
```tsx
|
||||
// Responsive visibility - show/hide elements at breakpoints
|
||||
function ResponsiveNav() {
|
||||
return (
|
||||
<nav>
|
||||
{/* Mobile hamburger - hidden on desktop */}
|
||||
<button className="block md:hidden">
|
||||
<MenuIcon />
|
||||
</button>
|
||||
{/* Desktop nav links - hidden on mobile */}
|
||||
<div className="hidden md:flex md:items-center md:gap-6">
|
||||
<a href="/about">About</a>
|
||||
<a href="/pricing">Pricing</a>
|
||||
<a href="/docs">Docs</a>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<button class="
|
||||
bg-blue-500 hover:bg-blue-600
|
||||
focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
">
|
||||
Button
|
||||
### 2. Dark Mode
|
||||
|
||||
**Class strategy** (recommended) -- toggle via a `dark` class on the `<html>` element:
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
darkMode: "class",
|
||||
};
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Theme toggle component
|
||||
function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
|
||||
function toggle() {
|
||||
setDark(!dark);
|
||||
document.documentElement.classList.toggle("dark");
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="rounded-full p-2 text-gray-600 hover:bg-gray-100
|
||||
dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{dark ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Dark mode with CSS variables for flexible theming
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm
|
||||
dark:border-gray-700 dark:bg-gray-900 dark:shadow-gray-900/20">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Media strategy** -- follows the OS preference automatically:
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
darkMode: "media",
|
||||
};
|
||||
```
|
||||
|
||||
**CSS variables approach** for more granular theme control:
|
||||
|
||||
```css
|
||||
/* globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222 47% 11%;
|
||||
--primary: 221 83% 53%;
|
||||
--muted: 210 40% 96%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--muted: 217 33% 17%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// tailwind.config.js -- reference the CSS variables
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: "hsl(var(--primary))",
|
||||
muted: "hsl(var(--muted))",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Layout Patterns
|
||||
|
||||
**Sidebar layout:**
|
||||
|
||||
```tsx
|
||||
function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar - fixed width, scrollable */}
|
||||
<aside className="hidden w-64 flex-shrink-0 overflow-y-auto border-r
|
||||
border-gray-200 bg-gray-50 p-4 dark:border-gray-700
|
||||
dark:bg-gray-900 lg:block">
|
||||
<nav className="flex flex-col gap-1">
|
||||
<SidebarLink href="/dashboard" icon={<HomeIcon />} label="Home" />
|
||||
<SidebarLink href="/settings" icon={<GearIcon />} label="Settings" />
|
||||
</nav>
|
||||
</aside>
|
||||
{/* Main content - fills remaining space */}
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Card grid:**
|
||||
|
||||
```tsx
|
||||
function CardGrid({ items }: { items: CardItem[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm
|
||||
transition-shadow hover:shadow-md dark:border-gray-700
|
||||
dark:bg-gray-800"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Centered content with max-width:**
|
||||
|
||||
```tsx
|
||||
function ArticleLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<article className="mx-auto max-w-prose px-4 py-8">
|
||||
<div className="prose prose-gray dark:prose-invert lg:prose-lg">
|
||||
{children}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Sticky header with content scroll:**
|
||||
|
||||
```tsx
|
||||
function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="sticky top-0 z-40 flex h-16 items-center border-b
|
||||
border-gray-200 bg-white/80 px-6 backdrop-blur-sm
|
||||
dark:border-gray-800 dark:bg-gray-950/80">
|
||||
<Logo />
|
||||
<nav className="ml-auto flex items-center gap-4">
|
||||
<NavLinks />
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Component Styling
|
||||
|
||||
**Button variants using a helper:**
|
||||
|
||||
```tsx
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100",
|
||||
outline: "border border-gray-300 bg-transparent hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800",
|
||||
ghost: "hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-700",
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
md: "h-10 px-4",
|
||||
lg: "h-12 px-6 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Form input:**
|
||||
|
||||
```tsx
|
||||
function FormInput({ label, error, ...props }: InputProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
className={cn(
|
||||
"block w-full rounded-md border px-3 py-2 text-sm shadow-sm",
|
||||
"placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
"dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500",
|
||||
error
|
||||
? "border-red-500 focus:ring-red-500"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Navigation with active state:**
|
||||
|
||||
```tsx
|
||||
function NavLink({ href, active, children }: NavLinkProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
|
||||
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Animations & Transitions
|
||||
|
||||
**Built-in animations:**
|
||||
|
||||
```tsx
|
||||
// Spin for loading indicators
|
||||
<svg className="h-5 w-5 animate-spin text-white" viewBox="0 0 24 24">...</svg>
|
||||
|
||||
// Pulse for skeleton loaders
|
||||
function Skeleton() {
|
||||
return <div className="h-4 w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />;
|
||||
}
|
||||
|
||||
// Bounce for attention
|
||||
<div className="animate-bounce">
|
||||
<ArrowDownIcon />
|
||||
</div>
|
||||
|
||||
// Ping for notification badges
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
|
||||
</span>
|
||||
```
|
||||
|
||||
**Transitions for interactive elements:**
|
||||
|
||||
```tsx
|
||||
// Smooth hover transitions
|
||||
function HoverCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white p-4 shadow-sm transition-all
|
||||
duration-200 ease-in-out hover:-translate-y-1 hover:shadow-lg
|
||||
dark:border-gray-700 dark:bg-gray-800">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Color and opacity transitions
|
||||
<button className="bg-blue-500 text-white transition-colors duration-150
|
||||
hover:bg-blue-600 active:bg-blue-700">
|
||||
Submit
|
||||
</button>
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content
|
||||
// Scale on hover
|
||||
<div className="transform transition-transform duration-200 hover:scale-105">
|
||||
<img src={src} alt={alt} className="rounded-lg" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Custom keyframes in config:**
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
"fade-in": {
|
||||
"0%": { opacity: "0", transform: "translateY(8px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
"slide-in-right": {
|
||||
"0%": { transform: "translateX(100%)" },
|
||||
"100%": { transform: "translateX(0)" },
|
||||
},
|
||||
"scale-in": {
|
||||
"0%": { opacity: "0", transform: "scale(0.95)" },
|
||||
"100%": { opacity: "1", transform: "scale(1)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fade-in 0.3s ease-out",
|
||||
"slide-in-right": "slide-in-right 0.3s ease-out",
|
||||
"scale-in": "scale-in 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Using custom animations
|
||||
<div className="animate-fade-in">Content that fades in</div>
|
||||
<aside className="animate-slide-in-right">Sidebar panel</aside>
|
||||
```
|
||||
|
||||
### 6. Custom Theme
|
||||
|
||||
**Extending tailwind.config.js:**
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
const { fontFamily } = require("tailwindcss/defaultTheme");
|
||||
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
950: "#172554",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", ...fontFamily.sans],
|
||||
mono: ["JetBrains Mono", ...fontFamily.mono],
|
||||
},
|
||||
spacing: {
|
||||
18: "4.5rem",
|
||||
88: "22rem",
|
||||
128: "32rem",
|
||||
},
|
||||
borderRadius: {
|
||||
"4xl": "2rem",
|
||||
},
|
||||
fontSize: {
|
||||
"2xs": ["0.625rem", { lineHeight: "0.75rem" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/typography"),
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/container-queries"),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### 7. Performance
|
||||
|
||||
**Content configuration** -- ensure only used classes ship to production:
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
**Avoid dynamic class construction** -- Tailwind cannot detect dynamically built class names:
|
||||
|
||||
```tsx
|
||||
// BAD -- Tailwind will NOT include these classes
|
||||
const color = "red";
|
||||
<div className={`bg-${color}-500`}>...</div>
|
||||
|
||||
// GOOD -- use complete class names so Tailwind can detect them
|
||||
const bgColor = isError ? "bg-red-500" : "bg-green-500";
|
||||
<div className={bgColor}>...</div>
|
||||
```
|
||||
|
||||
**Safelist for truly dynamic values:**
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
safelist: [
|
||||
"bg-red-500",
|
||||
"bg-green-500",
|
||||
"bg-blue-500",
|
||||
{ pattern: /^text-(red|green|blue)-(400|500|600)$/ },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
**Keep class strings readable with cn():**
|
||||
|
||||
```ts
|
||||
// lib/utils.ts
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Composing classes cleanly
|
||||
<div className={cn(
|
||||
"rounded-lg border p-4",
|
||||
isActive && "border-blue-500 bg-blue-50",
|
||||
isDisabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use consistent spacing scale
|
||||
2. Mobile-first design
|
||||
3. Extract repeated patterns to components
|
||||
4. Use @apply sparingly
|
||||
5. Leverage dark mode utilities
|
||||
1. **Mobile-first always** -- write base styles for mobile, then layer breakpoint prefixes for larger screens. Never design desktop-down.
|
||||
2. **Use the spacing scale consistently** -- stick to Tailwind's default scale (4, 8, 12, 16...) rather than arbitrary values. Use `space-y-*` and `gap-*` instead of individual margins.
|
||||
3. **Extract repeated patterns to components** -- when the same set of classes appears three or more times, create a React component rather than duplicating the class string.
|
||||
4. **Use `@apply` sparingly** -- only for styles that cannot live in a component, such as global prose styles or third-party element overrides. Overusing `@apply` defeats the utility-first approach.
|
||||
5. **Prefer `cn()` / `twMerge` for conditional classes** -- avoids class conflicts and keeps logic readable compared to string template concatenation.
|
||||
6. **Use CSS variables for theme tokens** -- allows runtime theme switching and integrates well with dark mode, while keeping Tailwind as the styling layer.
|
||||
7. **Group related utilities logically** -- order classes as: layout, sizing, spacing, typography, colors, borders, effects, transitions. Consistent ordering improves readability.
|
||||
8. **Enable the typography plugin for prose content** -- `@tailwindcss/typography` provides sensible defaults for rendered markdown or CMS content without manual styling.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Too many classes**: Extract to components
|
||||
- **Inconsistent spacing**: Stick to scale
|
||||
- **Missing responsive**: Test all breakpoints
|
||||
1. **Dynamic class name construction** -- `bg-${color}-500` will not work because Tailwind scans source files statically. Always use complete, literal class names.
|
||||
2. **Forgetting content paths** -- if a class is not being generated, check that `content` in `tailwind.config.js` includes all files where Tailwind classes are used, including component libraries.
|
||||
3. **Class conflicts without twMerge** -- `className="p-4 p-6"` applies both; the result depends on CSS source order, not the order in the string. Use `twMerge` to resolve conflicts predictably.
|
||||
4. **Overusing arbitrary values** -- `w-[347px]` bypasses the design system. If you find many arbitrary values, extend the theme instead.
|
||||
5. **Not testing responsive breakpoints** -- always verify layouts at each breakpoint. Use browser dev tools' responsive mode or resize the viewport during development.
|
||||
6. **Ignoring dark mode from the start** -- adding dark mode later requires touching every component. Apply `dark:` variants alongside initial styling to avoid large retrofits.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frontend/shadcn-ui` - Component library built on Radix primitives with Tailwind styling
|
||||
- `frameworks/react` - React component patterns and best practices
|
||||
- `frameworks/nextjs` - Next.js framework with built-in Tailwind support
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Tailwind CSS UI Pattern Recipes
|
||||
|
||||
Copy-paste patterns for common UI components. All examples use Tailwind v3+ utility classes.
|
||||
|
||||
---
|
||||
|
||||
## Responsive Navbar
|
||||
|
||||
```html
|
||||
<nav class="bg-white shadow">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center gap-8">
|
||||
<a href="/" class="text-xl font-bold text-gray-900">Logo</a>
|
||||
<div class="hidden md:flex md:gap-6">
|
||||
<a href="#" class="text-sm font-medium text-gray-700 hover:text-gray-900">Dashboard</a>
|
||||
<a href="#" class="text-sm font-medium text-gray-500 hover:text-gray-900">Projects</a>
|
||||
<a href="#" class="text-sm font-medium text-gray-500 hover:text-gray-900">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="rounded-full bg-gray-100 p-2 text-gray-600 hover:bg-gray-200">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0a3 3 0 11-6 0m6 0H9" />
|
||||
</svg>
|
||||
</button>
|
||||
<img class="h-8 w-8 rounded-full" src="https://via.placeholder.com/32" alt="Avatar" />
|
||||
</div>
|
||||
<!-- Mobile menu button -->
|
||||
<button class="md:hidden rounded p-2 text-gray-600 hover:bg-gray-100">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Card Grid
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Card -->
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm
|
||||
transition hover:shadow-md">
|
||||
<img class="h-48 w-full object-cover" src="https://via.placeholder.com/400x200" alt="" />
|
||||
<div class="p-5">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Card Title</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Brief description of the card content goes here.
|
||||
</p>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5
|
||||
text-xs font-medium text-blue-700">Category</span>
|
||||
<a href="#" class="text-sm font-medium text-blue-600 hover:text-blue-500">
|
||||
View details →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Repeat cards... -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hero Section
|
||||
|
||||
```html
|
||||
<section class="bg-white">
|
||||
<div class="mx-auto max-w-7xl px-4 py-24 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
<span class="inline-block rounded-full bg-blue-50 px-3 py-1 text-sm
|
||||
font-medium text-blue-700">New Release</span>
|
||||
<h1 class="mt-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
|
||||
Build faster with modern tools
|
||||
</h1>
|
||||
<p class="mt-6 text-lg leading-8 text-gray-600">
|
||||
A concise value proposition that explains what the product does
|
||||
and why users should care.
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-4">
|
||||
<a href="#" class="rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold
|
||||
text-white shadow-sm hover:bg-blue-500">Get started</a>
|
||||
<a href="#" class="text-sm font-semibold text-gray-900 hover:text-gray-700">
|
||||
Learn more →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form Layout
|
||||
|
||||
```html
|
||||
<form class="mx-auto max-w-lg space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Full Name</label>
|
||||
<input type="text" id="name" name="name"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
|
||||
text-sm shadow-sm placeholder:text-gray-400
|
||||
focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input type="email" id="email" name="email"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
|
||||
text-sm shadow-sm placeholder:text-gray-400
|
||||
focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||
<p class="mt-1 text-sm text-gray-500">We'll never share your email.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="message" class="block text-sm font-medium text-gray-700">Message</label>
|
||||
<textarea id="message" name="message" rows="4"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
|
||||
text-sm shadow-sm placeholder:text-gray-400
|
||||
focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button type="button" class="rounded-md px-4 py-2 text-sm font-medium
|
||||
text-gray-700 hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm
|
||||
font-semibold text-white shadow-sm
|
||||
hover:bg-blue-500">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modal Overlay
|
||||
|
||||
```html
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<!-- Modal -->
|
||||
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||
<div class="flex items-start justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Confirm Action</h2>
|
||||
<button class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-600">
|
||||
Are you sure you want to proceed? This action cannot be undone.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button class="rounded-md px-4 py-2 text-sm font-medium text-gray-700
|
||||
hover:bg-gray-50">Cancel</button>
|
||||
<button class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold
|
||||
text-white hover:bg-red-500">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sidebar Layout
|
||||
|
||||
```html
|
||||
<div class="flex h-screen">
|
||||
<!-- Sidebar -->
|
||||
<aside class="hidden w-64 flex-shrink-0 border-r border-gray-200 bg-gray-50 lg:block">
|
||||
<div class="flex h-16 items-center px-6">
|
||||
<span class="text-lg font-bold text-gray-900">App Name</span>
|
||||
</div>
|
||||
<nav class="space-y-1 px-3 py-4">
|
||||
<a href="#" class="flex items-center gap-3 rounded-md bg-blue-50 px-3 py-2
|
||||
text-sm font-medium text-blue-700">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 rounded-md px-3 py-2 text-sm
|
||||
font-medium text-gray-700 hover:bg-gray-100">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
</svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 rounded-md px-3 py-2 text-sm
|
||||
font-medium text-gray-700 hover:bg-gray-100">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-y-auto bg-white">
|
||||
<div class="px-6 py-8 lg:px-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<div class="mt-6">
|
||||
<!-- Page content here -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `transition` and `hover:` for interactive feedback
|
||||
- Use `focus-visible:` instead of `focus:` for keyboard-only focus rings
|
||||
- Use `dark:` variants when supporting dark mode
|
||||
- Prefer `gap-*` over margin utilities for flex/grid spacing
|
||||
- Use `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8` as a standard container
|
||||
@@ -1,101 +1,724 @@
|
||||
---
|
||||
name: javascript
|
||||
description: >
|
||||
Trigger this skill whenever working with JavaScript files (.js, .mjs, .cjs), writing Node.js applications without TypeScript, or using ES6+ patterns like destructuring, async/await, optional chaining, and modules. Activate for browser scripting, vanilla JS projects, or when the user asks about JavaScript-specific idioms, ESLint configuration, or modern syntax. Also use when dealing with package.json scripts, CommonJS vs ESM, or JavaScript class patterns.
|
||||
---
|
||||
|
||||
# JavaScript
|
||||
|
||||
## Description
|
||||
|
||||
Modern JavaScript (ES6+) patterns and best practices for Node.js and browser environments.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Working with JavaScript files (.js, .mjs)
|
||||
- Browser scripting
|
||||
- Node.js applications without TypeScript
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- TypeScript projects -- use the `languages/typescript` skill instead, which covers typed JavaScript patterns
|
||||
- Python-only projects with no JavaScript components
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Modern Syntax
|
||||
### 1. Modern Syntax
|
||||
|
||||
#### Destructuring (Nested, Defaults, Rest)
|
||||
|
||||
```javascript
|
||||
// Destructuring
|
||||
const { name, email } = user;
|
||||
const [first, ...rest] = items;
|
||||
// Object destructuring with defaults and rename
|
||||
const { name, email, role = "user", address: { city } = {} } = user;
|
||||
|
||||
// Spread operator
|
||||
const merged = { ...defaults, ...options };
|
||||
const combined = [...array1, ...array2];
|
||||
// Nested destructuring
|
||||
const {
|
||||
data: {
|
||||
attributes: { title, body },
|
||||
},
|
||||
} = apiResponse;
|
||||
|
||||
// Template literals
|
||||
const message = `Hello, ${name}!`;
|
||||
// Array destructuring with rest
|
||||
const [first, second, ...remaining] = items;
|
||||
|
||||
// Optional chaining and nullish coalescing
|
||||
const city = user?.address?.city ?? 'Unknown';
|
||||
// Swap variables
|
||||
let a = 1, b = 2;
|
||||
[a, b] = [b, a];
|
||||
|
||||
// Function parameter destructuring
|
||||
function createUser({ name, email, role = "user" }) {
|
||||
return { name, email, role, createdAt: new Date() };
|
||||
}
|
||||
```
|
||||
|
||||
### Async Patterns
|
||||
#### Optional Chaining (?.)
|
||||
|
||||
```javascript
|
||||
// Async/await
|
||||
async function fetchData(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Fetch failed');
|
||||
return response.json();
|
||||
// Property access
|
||||
const city = user?.address?.city;
|
||||
|
||||
// Method call
|
||||
const uppercased = value?.toString?.();
|
||||
|
||||
// Array element
|
||||
const firstItem = data?.items?.[0];
|
||||
|
||||
// Combine with nullish coalescing for defaults
|
||||
const displayName = user?.profile?.displayName ?? user?.name ?? "Anonymous";
|
||||
```
|
||||
|
||||
#### Nullish Coalescing (??)
|
||||
|
||||
```javascript
|
||||
// Only falls through on null/undefined (not 0, "", false)
|
||||
const port = config.port ?? 3000;
|
||||
const name = input ?? "default";
|
||||
|
||||
// Contrast with || which falls through on all falsy values
|
||||
const count = data.count ?? 0; // preserves 0
|
||||
const count2 = data.count || 0; // replaces 0 with 0 (same here, but misleading)
|
||||
const label = data.label ?? ""; // preserves ""
|
||||
const label2 = data.label || "fallback"; // replaces "" with "fallback"
|
||||
```
|
||||
|
||||
#### Logical Assignment (&&=, ||=, ??=)
|
||||
|
||||
```javascript
|
||||
// ??= assigns only if null/undefined
|
||||
user.name ??= "Anonymous";
|
||||
|
||||
// ||= assigns if falsy
|
||||
config.retries ||= 3;
|
||||
|
||||
// &&= assigns only if truthy
|
||||
user.session &&= refreshSession(user.session);
|
||||
|
||||
// Practical: initialize nested objects
|
||||
const cache = {};
|
||||
(cache.users ??= []).push(newUser);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Async Patterns
|
||||
|
||||
#### Promises
|
||||
|
||||
```javascript
|
||||
function fetchJson(url) {
|
||||
return fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
// Promise.all for parallel
|
||||
const results = await Promise.all([
|
||||
fetchData(url1),
|
||||
fetchData(url2),
|
||||
// Chaining
|
||||
fetchJson("/api/user")
|
||||
.then((user) => fetchJson(`/api/posts?userId=${user.id}`))
|
||||
.then((posts) => console.log(posts))
|
||||
.catch((error) => console.error("Failed:", error.message));
|
||||
```
|
||||
|
||||
#### async/await
|
||||
|
||||
```javascript
|
||||
async function loadUserDashboard(userId) {
|
||||
const user = await fetchJson(`/api/users/${userId}`);
|
||||
const posts = await fetchJson(`/api/users/${userId}/posts`);
|
||||
return { user, posts };
|
||||
}
|
||||
```
|
||||
|
||||
#### Promise.all / allSettled / race / any
|
||||
|
||||
```javascript
|
||||
// Promise.all -- fail fast on first rejection
|
||||
const [users, posts, comments] = await Promise.all([
|
||||
fetchJson("/api/users"),
|
||||
fetchJson("/api/posts"),
|
||||
fetchJson("/api/comments"),
|
||||
]);
|
||||
|
||||
// Error handling
|
||||
try {
|
||||
const data = await fetchData(url);
|
||||
} catch (error) {
|
||||
console.error('Failed:', error.message);
|
||||
// Promise.allSettled -- wait for all, get status of each
|
||||
const results = await Promise.allSettled([
|
||||
fetchJson("/api/fast"),
|
||||
fetchJson("/api/slow"),
|
||||
fetchJson("/api/flaky"),
|
||||
]);
|
||||
const successes = results
|
||||
.filter((r) => r.status === "fulfilled")
|
||||
.map((r) => r.value);
|
||||
const failures = results
|
||||
.filter((r) => r.status === "rejected")
|
||||
.map((r) => r.reason);
|
||||
|
||||
// Promise.race -- first to settle wins
|
||||
const result = await Promise.race([
|
||||
fetchJson("/api/primary"),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timeout")), 5000)
|
||||
),
|
||||
]);
|
||||
|
||||
// Promise.any -- first to fulfill wins (ignores rejections)
|
||||
const fastest = await Promise.any([
|
||||
fetchJson("/api/mirror1"),
|
||||
fetchJson("/api/mirror2"),
|
||||
fetchJson("/api/mirror3"),
|
||||
]);
|
||||
```
|
||||
|
||||
#### AbortController
|
||||
|
||||
```javascript
|
||||
async function fetchWithTimeout(url, timeoutMs = 5000) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
return await response.json();
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellable request pattern
|
||||
function createRequest(url) {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
promise: fetch(url, { signal: controller.signal }),
|
||||
cancel: () => controller.abort(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Array Methods
|
||||
#### Async Iterators (for await...of)
|
||||
|
||||
```javascript
|
||||
// Map, filter, reduce
|
||||
const names = users.map(u => u.name);
|
||||
const active = users.filter(u => u.active);
|
||||
const total = items.reduce((sum, i) => sum + i.price, 0);
|
||||
async function* paginateApi(baseUrl) {
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const data = await fetchJson(`${baseUrl}?page=${page}`);
|
||||
if (data.items.length === 0) break;
|
||||
yield* data.items;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
// Find and includes
|
||||
const user = users.find(u => u.id === id);
|
||||
const hasAdmin = users.some(u => u.role === 'admin');
|
||||
// Consume the async iterator
|
||||
for await (const item of paginateApi("/api/records")) {
|
||||
processItem(item);
|
||||
}
|
||||
```
|
||||
|
||||
### Classes
|
||||
---
|
||||
|
||||
### 3. Closures & Scope
|
||||
|
||||
#### Closure Patterns
|
||||
|
||||
```javascript
|
||||
class UserService {
|
||||
#db; // Private field
|
||||
// Counter with private state
|
||||
function createCounter(initial = 0) {
|
||||
let count = initial;
|
||||
return {
|
||||
increment: () => ++count,
|
||||
decrement: () => --count,
|
||||
getCount: () => count,
|
||||
reset: () => { count = initial; },
|
||||
};
|
||||
}
|
||||
|
||||
constructor(database) {
|
||||
this.#db = database;
|
||||
const counter = createCounter(10);
|
||||
counter.increment(); // 11
|
||||
counter.getCount(); // 11
|
||||
```
|
||||
|
||||
#### Module Pattern (Private State via Closures)
|
||||
|
||||
```javascript
|
||||
const rateLimiter = (() => {
|
||||
const requests = new Map();
|
||||
|
||||
function isAllowed(clientId, maxPerMinute = 60) {
|
||||
const now = Date.now();
|
||||
const windowStart = now - 60_000;
|
||||
const clientRequests = (requests.get(clientId) ?? []).filter(
|
||||
(t) => t > windowStart
|
||||
);
|
||||
if (clientRequests.length >= maxPerMinute) return false;
|
||||
clientRequests.push(now);
|
||||
requests.set(clientId, clientRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
async findById(id) {
|
||||
return this.#db.users.find(u => u.id === id);
|
||||
function reset(clientId) {
|
||||
requests.delete(clientId);
|
||||
}
|
||||
|
||||
return { isAllowed, reset };
|
||||
})();
|
||||
```
|
||||
|
||||
#### WeakRef and FinalizationRegistry
|
||||
|
||||
```javascript
|
||||
// Cache that does not prevent garbage collection
|
||||
const cache = new Map();
|
||||
|
||||
function getCached(key, factory) {
|
||||
const ref = cache.get(key);
|
||||
const cached = ref?.deref();
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const value = factory();
|
||||
cache.set(key, new WeakRef(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
// Cleanup when objects are garbage collected
|
||||
const registry = new FinalizationRegistry((key) => {
|
||||
cache.delete(key);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Iteration Protocols
|
||||
|
||||
#### Custom Iterator
|
||||
|
||||
```javascript
|
||||
class Range {
|
||||
constructor(start, end, step = 1) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.step = step;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
let current = this.start;
|
||||
const { end, step } = this;
|
||||
return {
|
||||
next() {
|
||||
if (current < end) {
|
||||
const value = current;
|
||||
current += step;
|
||||
return { value, done: false };
|
||||
}
|
||||
return { done: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of new Range(0, 10, 2)) {
|
||||
console.log(n); // 0, 2, 4, 6, 8
|
||||
}
|
||||
```
|
||||
|
||||
#### Generators
|
||||
|
||||
```javascript
|
||||
function* fibonacci() {
|
||||
let a = 0, b = 1;
|
||||
while (true) {
|
||||
yield a;
|
||||
[a, b] = [b, a + b];
|
||||
}
|
||||
}
|
||||
|
||||
// Take first N values
|
||||
function take(iterable, count) {
|
||||
const result = [];
|
||||
for (const value of iterable) {
|
||||
result.push(value);
|
||||
if (result.length >= count) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
take(fibonacci(), 8); // [0, 1, 1, 2, 3, 5, 8, 13]
|
||||
```
|
||||
|
||||
#### Lazy Evaluation with Generators
|
||||
|
||||
```javascript
|
||||
function* map(iterable, fn) {
|
||||
for (const item of iterable) {
|
||||
yield fn(item);
|
||||
}
|
||||
}
|
||||
|
||||
function* filter(iterable, predicate) {
|
||||
for (const item of iterable) {
|
||||
if (predicate(item)) yield item;
|
||||
}
|
||||
}
|
||||
|
||||
// Compose lazily -- no intermediate arrays
|
||||
const data = filter(
|
||||
map(readLargeFile(), (line) => line.trim()),
|
||||
(line) => line.length > 0
|
||||
);
|
||||
```
|
||||
|
||||
#### Async Generators
|
||||
|
||||
```javascript
|
||||
async function* readChunks(reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
yield value;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream processing
|
||||
const response = await fetch("/api/large-data");
|
||||
for await (const chunk of readChunks(response.body.getReader())) {
|
||||
processChunk(chunk);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Proxy & Reflect
|
||||
|
||||
#### Validation Proxy
|
||||
|
||||
```javascript
|
||||
function createValidated(target, validators) {
|
||||
return new Proxy(target, {
|
||||
set(obj, prop, value) {
|
||||
const validate = validators[prop];
|
||||
if (validate && !validate(value)) {
|
||||
throw new TypeError(`Invalid value for ${String(prop)}: ${value}`);
|
||||
}
|
||||
return Reflect.set(obj, prop, value);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = createValidated(
|
||||
{ name: "", age: 0 },
|
||||
{
|
||||
name: (v) => typeof v === "string" && v.length > 0,
|
||||
age: (v) => typeof v === "number" && v >= 0 && v <= 150,
|
||||
}
|
||||
);
|
||||
|
||||
user.name = "Alice"; // works
|
||||
user.age = -1; // throws TypeError
|
||||
```
|
||||
|
||||
#### Observable Object
|
||||
|
||||
```javascript
|
||||
function createObservable(target, onChange) {
|
||||
return new Proxy(target, {
|
||||
set(obj, prop, value) {
|
||||
const oldValue = obj[prop];
|
||||
const result = Reflect.set(obj, prop, value);
|
||||
if (oldValue !== value) {
|
||||
onChange(prop, value, oldValue);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
deleteProperty(obj, prop) {
|
||||
const oldValue = obj[prop];
|
||||
const result = Reflect.deleteProperty(obj, prop);
|
||||
onChange(prop, undefined, oldValue);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const state = createObservable({}, (prop, newVal, oldVal) => {
|
||||
console.log(`${prop}: ${oldVal} -> ${newVal}`);
|
||||
});
|
||||
```
|
||||
|
||||
#### Property Access Logging
|
||||
|
||||
```javascript
|
||||
function withLogging(target, label = "access") {
|
||||
return new Proxy(target, {
|
||||
get(obj, prop) {
|
||||
console.log(`[${label}] get .${String(prop)}`);
|
||||
return Reflect.get(obj, prop);
|
||||
},
|
||||
has(obj, prop) {
|
||||
console.log(`[${label}] has .${String(prop)}`);
|
||||
return Reflect.has(obj, prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Module System
|
||||
|
||||
#### ESM (import/export)
|
||||
|
||||
```javascript
|
||||
// Named exports
|
||||
export function formatDate(date) { ... }
|
||||
export const MAX_RETRIES = 3;
|
||||
|
||||
// Default export
|
||||
export default class ApiClient { ... }
|
||||
|
||||
// Re-exports
|
||||
export { formatDate } from "./utils.js";
|
||||
export { default as ApiClient } from "./api-client.js";
|
||||
```
|
||||
|
||||
#### Dynamic import()
|
||||
|
||||
```javascript
|
||||
// Lazy load modules
|
||||
async function loadChart(type) {
|
||||
const module = await import(`./charts/${type}.js`);
|
||||
return new module.default();
|
||||
}
|
||||
|
||||
// Conditional loading
|
||||
const { marked } = await import("marked");
|
||||
|
||||
// With error handling
|
||||
async function tryLoadPlugin(name) {
|
||||
try {
|
||||
return await import(`./plugins/${name}.js`);
|
||||
} catch {
|
||||
console.warn(`Plugin ${name} not available`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### import.meta
|
||||
|
||||
```javascript
|
||||
// Current module URL
|
||||
console.log(import.meta.url);
|
||||
|
||||
// Resolve relative paths (Node.js)
|
||||
const configPath = new URL("./config.json", import.meta.url);
|
||||
|
||||
// Check if file is the entry point (Node.js)
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
|
||||
// Vite environment variables
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
```
|
||||
|
||||
#### Top-level await
|
||||
|
||||
```javascript
|
||||
// config.js -- top-level await in ESM modules
|
||||
const response = await fetch("/api/config");
|
||||
export const config = await response.json();
|
||||
|
||||
// db.js
|
||||
import { createPool } from "./db-pool.js";
|
||||
export const pool = await createPool(process.env.DATABASE_URL);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Performance
|
||||
|
||||
#### structuredClone
|
||||
|
||||
```javascript
|
||||
// Deep clone without library (replaces JSON.parse(JSON.stringify(...)))
|
||||
const original = { nested: { array: [1, 2, 3], date: new Date() } };
|
||||
const clone = structuredClone(original);
|
||||
|
||||
// Handles Date, Map, Set, ArrayBuffer, RegExp (but not functions)
|
||||
clone.nested.array.push(4);
|
||||
console.log(original.nested.array.length); // still 3
|
||||
```
|
||||
|
||||
#### requestAnimationFrame
|
||||
|
||||
```javascript
|
||||
// Smooth animation loop
|
||||
function animate(timestamp) {
|
||||
updatePosition(timestamp);
|
||||
render();
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
// Throttle DOM updates to frame rate
|
||||
let rafId = null;
|
||||
function scheduleUpdate(data) {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
applyDOMUpdate(data);
|
||||
rafId = null;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### requestIdleCallback
|
||||
|
||||
```javascript
|
||||
// Run low-priority work when the browser is idle
|
||||
function processQueue(queue) {
|
||||
requestIdleCallback((deadline) => {
|
||||
while (deadline.timeRemaining() > 0 && queue.length > 0) {
|
||||
const task = queue.shift();
|
||||
task();
|
||||
}
|
||||
if (queue.length > 0) {
|
||||
processQueue(queue); // schedule remaining
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Web Workers Basics
|
||||
|
||||
```javascript
|
||||
// main.js
|
||||
const worker = new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
worker.postMessage({ data: largeDataSet, operation: "sort" });
|
||||
worker.onmessage = (event) => {
|
||||
const sorted = event.data;
|
||||
renderResults(sorted);
|
||||
};
|
||||
|
||||
// worker.js
|
||||
self.onmessage = (event) => {
|
||||
const { data, operation } = event.data;
|
||||
if (operation === "sort") {
|
||||
self.postMessage(data.sort((a, b) => a - b));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### performance.mark / measure
|
||||
|
||||
```javascript
|
||||
// Measure operation duration
|
||||
performance.mark("fetch-start");
|
||||
const data = await fetchJson("/api/data");
|
||||
performance.mark("fetch-end");
|
||||
|
||||
performance.measure("fetch-duration", "fetch-start", "fetch-end");
|
||||
const measurement = performance.getEntriesByName("fetch-duration")[0];
|
||||
console.log(`Fetch took ${measurement.duration.toFixed(2)}ms`);
|
||||
|
||||
// Clean up
|
||||
performance.clearMarks();
|
||||
performance.clearMeasures();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use `const` by default, `let` when needed
|
||||
2. Avoid `var` - use block-scoped declarations
|
||||
3. Use arrow functions for callbacks
|
||||
4. Handle all promise rejections
|
||||
5. Use ESLint for consistent style
|
||||
1. **Use `const` by default, `let` only when reassignment is needed** -- never use `var`. Block scoping prevents entire categories of bugs from hoisting and accidental mutation.
|
||||
|
||||
2. **Handle all promise rejections** -- unhandled rejections crash Node.js processes. Always use try/catch with await, or attach `.catch()` to promise chains. Add a global handler as a safety net.
|
||||
```javascript
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("Unhandled rejection:", reason);
|
||||
process.exit(1);
|
||||
});
|
||||
```
|
||||
|
||||
3. **Use arrow functions for callbacks, regular functions for methods** -- arrow functions capture `this` from the enclosing scope, which is correct for callbacks but breaks object methods that need their own `this`.
|
||||
|
||||
4. **Prefer `for...of` over `for...in` for iteration** -- `for...in` iterates over all enumerable properties including inherited ones. Use `for...of` for arrays and iterables, `Object.entries()` for objects.
|
||||
|
||||
5. **Use ESLint and Prettier** -- enforce consistent style automatically. Configure in the project root and run on pre-commit hooks.
|
||||
|
||||
6. **Avoid mutating function arguments** -- create new objects and arrays with spread syntax instead of modifying inputs in place. This prevents action-at-a-distance bugs.
|
||||
|
||||
7. **Use `structuredClone` for deep copies** -- replaces the `JSON.parse(JSON.stringify(x))` hack. Handles Dates, Maps, Sets, and circular references correctly.
|
||||
|
||||
8. **Use private class fields (`#field`)** -- the `#` prefix creates truly private fields that cannot be accessed outside the class, unlike the `_` convention which is only a hint.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Implicit type coercion**: Use `===` instead of `==`
|
||||
- **Callback hell**: Use async/await
|
||||
- **Mutating objects**: Create new objects with spread
|
||||
- **Not handling errors**: Always catch promise rejections
|
||||
1. **Implicit type coercion** -- always use `===` and `!==`. The `==` operator performs type coercion with surprising rules (`"" == false`, `0 == null` is false but `0 == undefined` is also false, yet `null == undefined` is true).
|
||||
|
||||
2. **Forgetting `await`** -- a missing `await` silently returns a Promise object instead of the resolved value, causing hard-to-debug issues.
|
||||
```javascript
|
||||
// BAD -- data is a Promise, not the response
|
||||
const data = fetchJson("/api/data");
|
||||
|
||||
// GOOD
|
||||
const data = await fetchJson("/api/data");
|
||||
```
|
||||
|
||||
3. **`this` binding in callbacks** -- regular functions in callbacks lose their `this` context. Use arrow functions or `.bind()`.
|
||||
```javascript
|
||||
// BAD
|
||||
class Timer {
|
||||
start() { setTimeout(function() { this.tick(); }, 1000); }
|
||||
}
|
||||
|
||||
// GOOD
|
||||
class Timer {
|
||||
start() { setTimeout(() => this.tick(), 1000); }
|
||||
}
|
||||
```
|
||||
|
||||
4. **Mutating objects passed by reference** -- objects and arrays are passed by reference. Modifying a parameter modifies the original.
|
||||
```javascript
|
||||
// BAD
|
||||
function addDefaults(config) {
|
||||
config.retries = config.retries ?? 3; // mutates caller's object
|
||||
return config;
|
||||
}
|
||||
|
||||
// GOOD
|
||||
function addDefaults(config) {
|
||||
return { retries: 3, ...config };
|
||||
}
|
||||
```
|
||||
|
||||
5. **`for...in` on arrays** -- iterates over indices as strings and includes inherited properties. Use `for...of` or array methods.
|
||||
```javascript
|
||||
// BAD
|
||||
for (const i in [10, 20, 30]) {
|
||||
console.log(typeof i); // "string", not "number"
|
||||
}
|
||||
|
||||
// GOOD
|
||||
for (const value of [10, 20, 30]) {
|
||||
console.log(value); // 10, 20, 30
|
||||
}
|
||||
```
|
||||
|
||||
6. **Floating point arithmetic** -- `0.1 + 0.2 !== 0.3` in JavaScript. For financial calculations, work in integer cents or use a decimal library.
|
||||
```javascript
|
||||
// BAD
|
||||
const total = 0.1 + 0.2; // 0.30000000000000004
|
||||
|
||||
// GOOD
|
||||
const totalCents = 10 + 20; // 30
|
||||
const total = totalCents / 100; // 0.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `languages/typescript` -- TypeScript for typed JavaScript development
|
||||
- `frameworks/react` -- React component patterns
|
||||
- `frameworks/nextjs` -- Next.js full-stack framework
|
||||
- `testing/vitest` -- JavaScript/TypeScript testing with Vitest
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# Modern JavaScript Patterns Quick Reference
|
||||
|
||||
> ES2020+ patterns. All examples work in current Node.js (18+) and modern browsers.
|
||||
|
||||
## Destructuring Tricks
|
||||
|
||||
```javascript
|
||||
// Nested destructuring with rename and default
|
||||
const { data: { users: members = [] } = {} } = response;
|
||||
|
||||
// Array destructuring: skip elements
|
||||
const [first, , third] = [1, 2, 3];
|
||||
|
||||
// Swap variables
|
||||
[a, b] = [b, a];
|
||||
|
||||
// Rest in both arrays and objects
|
||||
const { id, ...rest } = user;
|
||||
const [head, ...tail] = items;
|
||||
|
||||
// Destructure function parameters
|
||||
function draw({ x = 0, y = 0, color = "black" } = {}) { /* ... */ }
|
||||
|
||||
// Dynamic property destructuring
|
||||
const key = "name";
|
||||
const { [key]: value } = { name: "Alice" }; // value = "Alice"
|
||||
|
||||
// Destructure from iterables
|
||||
const [a, b] = new Map([["a", 1], ["b", 2]]);
|
||||
```
|
||||
|
||||
## Optional Chaining (?.)
|
||||
|
||||
```javascript
|
||||
// Property access
|
||||
const city = user?.address?.city;
|
||||
|
||||
// Method call (only calls if method exists)
|
||||
const result = api?.getData?.();
|
||||
|
||||
// Bracket notation
|
||||
const val = obj?.["dynamic-key"];
|
||||
|
||||
// Array index
|
||||
const first = arr?.[0];
|
||||
|
||||
// Combine with nullish coalescing
|
||||
const name = user?.profile?.name ?? "Anonymous";
|
||||
|
||||
// Short-circuit: stops evaluating after first nullish
|
||||
const len = response?.data?.items?.length; // undefined if any is nullish
|
||||
```
|
||||
|
||||
## Nullish Coalescing (??) vs OR (||)
|
||||
|
||||
```javascript
|
||||
// ?? only triggers on null/undefined (NOT 0, "", false)
|
||||
0 ?? "fallback" // 0
|
||||
"" ?? "fallback" // ""
|
||||
null ?? "fallback" // "fallback"
|
||||
|
||||
// || triggers on any falsy value
|
||||
0 || "fallback" // "fallback"
|
||||
"" || "fallback" // "fallback"
|
||||
null || "fallback" // "fallback"
|
||||
|
||||
// Use ?? for values where 0/empty string are valid
|
||||
const port = config.port ?? 3000;
|
||||
const title = config.title ?? "Untitled";
|
||||
```
|
||||
|
||||
## Logical Assignment Operators
|
||||
|
||||
```javascript
|
||||
// ??= assigns only if current value is null/undefined
|
||||
user.name ??= "Anonymous";
|
||||
// Equivalent: user.name = user.name ?? "Anonymous"
|
||||
|
||||
// ||= assigns if current value is falsy
|
||||
opts.verbose ||= false;
|
||||
// Equivalent: opts.verbose = opts.verbose || false
|
||||
|
||||
// &&= assigns only if current value is truthy
|
||||
user.token &&= encrypt(user.token);
|
||||
// Equivalent: user.token = user.token && encrypt(user.token)
|
||||
```
|
||||
|
||||
## structuredClone (Deep Copy)
|
||||
|
||||
```javascript
|
||||
// Deep clone objects, arrays, Maps, Sets, Dates, RegExp, etc.
|
||||
const original = { date: new Date(), nested: { arr: [1, 2] } };
|
||||
const clone = structuredClone(original);
|
||||
clone.nested.arr.push(3); // original not affected
|
||||
|
||||
// Works with circular references
|
||||
const obj = { self: null };
|
||||
obj.self = obj;
|
||||
const copy = structuredClone(obj); // OK
|
||||
|
||||
// Does NOT clone: functions, DOM nodes, symbols, prototype chain
|
||||
// Throws on: functions, Error objects (in some engines)
|
||||
```
|
||||
|
||||
## Proxy
|
||||
|
||||
```javascript
|
||||
// Validation proxy
|
||||
const validated = new Proxy({}, {
|
||||
set(target, prop, value) {
|
||||
if (prop === "age" && (typeof value !== "number" || value < 0)) {
|
||||
throw new TypeError("Age must be a non-negative number");
|
||||
}
|
||||
target[prop] = value;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Read-only proxy
|
||||
function readonly(target) {
|
||||
return new Proxy(target, {
|
||||
set() { throw new Error("Read-only object"); },
|
||||
deleteProperty() { throw new Error("Read-only object"); }
|
||||
});
|
||||
}
|
||||
|
||||
// Default values proxy
|
||||
function withDefaults(target, defaults) {
|
||||
return new Proxy(target, {
|
||||
get(obj, prop) {
|
||||
return prop in obj ? obj[prop] : defaults[prop];
|
||||
}
|
||||
});
|
||||
}
|
||||
const config = withDefaults({}, { theme: "dark", lang: "en" });
|
||||
config.theme; // "dark"
|
||||
|
||||
// Logging / observation proxy
|
||||
function observable(target, onChange) {
|
||||
return new Proxy(target, {
|
||||
set(obj, prop, value) {
|
||||
const old = obj[prop];
|
||||
obj[prop] = value;
|
||||
onChange(prop, old, value);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Generators
|
||||
|
||||
```javascript
|
||||
// Basic generator
|
||||
function* range(start, end, step = 1) {
|
||||
for (let i = start; i < end; i += step) {
|
||||
yield i;
|
||||
}
|
||||
}
|
||||
for (const n of range(0, 10, 2)) { /* 0, 2, 4, 6, 8 */ }
|
||||
|
||||
// Infinite sequence
|
||||
function* ids() {
|
||||
let id = 0;
|
||||
while (true) yield id++;
|
||||
}
|
||||
const gen = ids();
|
||||
gen.next().value; // 0
|
||||
gen.next().value; // 1
|
||||
|
||||
// Delegate to another generator
|
||||
function* concat(...iterables) {
|
||||
for (const it of iterables) {
|
||||
yield* it;
|
||||
}
|
||||
}
|
||||
|
||||
// Two-way communication
|
||||
function* stateMachine() {
|
||||
let input;
|
||||
while (true) {
|
||||
input = yield `received: ${input}`;
|
||||
}
|
||||
}
|
||||
const sm = stateMachine();
|
||||
sm.next(); // { value: "received: undefined" }
|
||||
sm.next("hello"); // { value: "received: hello" }
|
||||
```
|
||||
|
||||
## Async Iterators
|
||||
|
||||
```javascript
|
||||
// for-await-of
|
||||
async function processStream(stream) {
|
||||
for await (const chunk of stream) {
|
||||
console.log(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// Async generator
|
||||
async function* fetchPages(url) {
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const res = await fetch(`${url}?page=${page}`);
|
||||
const data = await res.json();
|
||||
if (data.items.length === 0) return;
|
||||
yield data.items;
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
for await (const items of fetchPages("/api/users")) {
|
||||
console.log(items);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Other Modern Patterns
|
||||
|
||||
```javascript
|
||||
// Object.groupBy (ES2024)
|
||||
const grouped = Object.groupBy(users, u => u.role);
|
||||
|
||||
// at() - negative indexing
|
||||
[1, 2, 3].at(-1); // 3
|
||||
|
||||
// Object.hasOwn (replaces hasOwnProperty)
|
||||
Object.hasOwn(obj, "key"); // true/false
|
||||
|
||||
// Error cause chaining
|
||||
throw new Error("DB failed", { cause: originalError });
|
||||
|
||||
// AbortSignal.timeout (built-in timeout)
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
|
||||
// using keyword (explicit resource management, ES2024+)
|
||||
{ using handle = openFile("data.txt"); } // auto-disposed at block exit
|
||||
```
|
||||
|
||||
## Promise Combinators
|
||||
|
||||
| Method | Settles when | Returns |
|
||||
|--------|-------------|---------|
|
||||
| `Promise.all(ps)` | All fulfill or one rejects | Array of values |
|
||||
| `Promise.allSettled(ps)` | All settle | Array of `{status, value/reason}` |
|
||||
| `Promise.race(ps)` | First settles | First value or rejection |
|
||||
| `Promise.any(ps)` | First fulfills | First value (AggregateError if all reject) |
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: python
|
||||
description: >
|
||||
Trigger this skill whenever working with Python files (.py), writing Python scripts or applications, or using Python frameworks like Django, FastAPI, or Flask. Activate for any Python-specific patterns including type hints, async/await with asyncio, dataclasses, Pydantic models, context managers, virtual environments, or PEP 8 style questions. Also use when the user references Python package management, pip, or pyproject.toml.
|
||||
---
|
||||
|
||||
# Python
|
||||
|
||||
## Description
|
||||
|
||||
Python development expertise including type hints, async patterns, virtual environments, and Pythonic idioms.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Working with Python files (.py)
|
||||
@@ -11,59 +13,148 @@ Python development expertise including type hints, async patterns, virtual envir
|
||||
- Using Python frameworks (Django, FastAPI, Flask)
|
||||
- Data processing and automation
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- JavaScript or TypeScript-only projects with no Python components
|
||||
- Non-Python environments where another language skill is more appropriate
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Type Hints
|
||||
### 1. Type Hints
|
||||
|
||||
Use type hints on all public functions and module-level variables. Python 3.10+ syntax is preferred (use `X | Y` instead of `Union[X, Y]`).
|
||||
|
||||
#### Basic Types
|
||||
|
||||
```python
|
||||
from typing import Optional, List, Dict, Union
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
def process_items(
|
||||
items: List[str],
|
||||
callback: Callable[[str], None],
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> List[str]:
|
||||
"""Process items with optional callback."""
|
||||
return [callback(item) for item in items]
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}"
|
||||
|
||||
def process(count: int, factor: float = 1.0) -> float:
|
||||
return count * factor
|
||||
|
||||
def is_valid(data: bytes | None) -> bool:
|
||||
return data is not None and len(data) > 0
|
||||
```
|
||||
|
||||
### Async/Await
|
||||
#### Optional and Union
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from typing import List
|
||||
# Python 3.10+ syntax (preferred)
|
||||
def find_user(user_id: int) -> User | None:
|
||||
...
|
||||
|
||||
async def fetch_data(url: str) -> dict:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
return await response.json()
|
||||
# Pre-3.10 fallback
|
||||
from typing import Optional, Union
|
||||
|
||||
async def fetch_all(urls: List[str]) -> List[dict]:
|
||||
return await asyncio.gather(*[fetch_data(url) for url in urls])
|
||||
def find_user(user_id: int) -> Optional[User]:
|
||||
...
|
||||
|
||||
def parse_input(value: Union[str, int]) -> str:
|
||||
return str(value)
|
||||
```
|
||||
|
||||
### Context Managers
|
||||
#### Generic Collections
|
||||
|
||||
```python
|
||||
from contextlib import contextmanager
|
||||
# Python 3.9+ built-in generics (preferred)
|
||||
def process_items(items: list[str]) -> dict[str, int]:
|
||||
return {item: len(item) for item in items}
|
||||
|
||||
@contextmanager
|
||||
def managed_resource():
|
||||
resource = acquire_resource()
|
||||
try:
|
||||
yield resource
|
||||
finally:
|
||||
release_resource(resource)
|
||||
def merge_configs(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**base, **overrides}
|
||||
|
||||
# Usage
|
||||
with managed_resource() as r:
|
||||
r.do_something()
|
||||
# Nested generics
|
||||
def group_by_key(pairs: list[tuple[str, int]]) -> dict[str, list[int]]:
|
||||
result: dict[str, list[int]] = {}
|
||||
for key, value in pairs:
|
||||
result.setdefault(key, []).append(value)
|
||||
return result
|
||||
```
|
||||
|
||||
### Dataclasses
|
||||
#### Protocol for Structural Subtyping
|
||||
|
||||
```python
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
@runtime_checkable
|
||||
class Renderable(Protocol):
|
||||
def render(self) -> str: ...
|
||||
|
||||
class HtmlWidget:
|
||||
def render(self) -> str:
|
||||
return "<div>widget</div>"
|
||||
|
||||
def display(item: Renderable) -> None:
|
||||
print(item.render())
|
||||
|
||||
# HtmlWidget satisfies Renderable without inheriting from it
|
||||
display(HtmlWidget()) # works
|
||||
```
|
||||
|
||||
#### TypeVar for Generic Functions
|
||||
|
||||
```python
|
||||
from typing import TypeVar, Sequence
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
def first(items: Sequence[T]) -> T:
|
||||
return items[0]
|
||||
|
||||
# Bounded TypeVar
|
||||
Numeric = TypeVar("Numeric", int, float)
|
||||
|
||||
def clamp(value: Numeric, low: Numeric, high: Numeric) -> Numeric:
|
||||
return max(low, min(high, value))
|
||||
```
|
||||
|
||||
#### @overload for Multiple Signatures
|
||||
|
||||
```python
|
||||
from typing import overload
|
||||
|
||||
@overload
|
||||
def parse(raw: str) -> dict[str, Any]: ...
|
||||
@overload
|
||||
def parse(raw: bytes) -> dict[str, Any]: ...
|
||||
@overload
|
||||
def parse(raw: str, as_list: bool) -> list[Any]: ...
|
||||
|
||||
def parse(raw: str | bytes, as_list: bool = False) -> dict[str, Any] | list[Any]:
|
||||
data = raw if isinstance(raw, str) else raw.decode()
|
||||
parsed = json.loads(data)
|
||||
return list(parsed) if as_list else parsed
|
||||
```
|
||||
|
||||
#### TypeAlias and TypeGuard
|
||||
|
||||
```python
|
||||
from typing import TypeAlias, TypeGuard
|
||||
|
||||
# TypeAlias for complex types
|
||||
JsonValue: TypeAlias = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
Headers: TypeAlias = dict[str, str]
|
||||
|
||||
# TypeGuard for narrowing
|
||||
def is_string_list(val: list[Any]) -> TypeGuard[list[str]]:
|
||||
return all(isinstance(item, str) for item in val)
|
||||
|
||||
def process(items: list[Any]) -> None:
|
||||
if is_string_list(items):
|
||||
# items is now list[str] inside this branch
|
||||
print(", ".join(items))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Dataclasses & Pydantic
|
||||
|
||||
#### @dataclass with Options
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
@@ -75,36 +166,535 @@ class User:
|
||||
email: str
|
||||
name: str
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self):
|
||||
self.email = self.email.lower()
|
||||
self.email = self.email.strip().lower()
|
||||
```
|
||||
|
||||
### Pydantic Models
|
||||
#### Frozen and Slots
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Coordinate:
|
||||
"""Immutable, memory-efficient value object."""
|
||||
x: float
|
||||
y: float
|
||||
|
||||
@property
|
||||
def magnitude(self) -> float:
|
||||
return (self.x ** 2 + self.y ** 2) ** 0.5
|
||||
```
|
||||
|
||||
#### Pydantic BaseModel
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator, computed_field, model_validator
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
model_config = {"str_strip_whitespace": True, "frozen": False}
|
||||
|
||||
email: EmailStr
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
password: str = Field(min_length=8)
|
||||
age: int = Field(ge=0, le=150)
|
||||
|
||||
class Config:
|
||||
str_strip_whitespace = True
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_must_not_be_blank(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("Name must not be blank")
|
||||
return v.title()
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return f"{self.name} <{self.email}>"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_consistency(self) -> "UserCreate":
|
||||
if "admin" in self.name.lower() and self.age < 18:
|
||||
raise ValueError("Admins must be 18+")
|
||||
return self
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Async Patterns
|
||||
|
||||
#### Basic async/await
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
async def fetch_json(url: str) -> dict:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
```
|
||||
|
||||
#### asyncio.gather for Parallel Work
|
||||
|
||||
```python
|
||||
async def fetch_all(urls: list[str]) -> list[dict]:
|
||||
return await asyncio.gather(*[fetch_json(url) for url in urls])
|
||||
```
|
||||
|
||||
#### asyncio.TaskGroup (Python 3.11+)
|
||||
|
||||
```python
|
||||
async def fetch_all_safe(urls: list[str]) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
for url in urls:
|
||||
tg.create_task(fetch_and_append(url, results))
|
||||
return results
|
||||
|
||||
async def fetch_and_append(url: str, results: list[dict]) -> None:
|
||||
data = await fetch_json(url)
|
||||
results.append(data)
|
||||
```
|
||||
|
||||
#### Async Generators
|
||||
|
||||
```python
|
||||
async def paginate(url: str) -> AsyncIterator[dict]:
|
||||
page = 1
|
||||
while True:
|
||||
data = await fetch_json(f"{url}?page={page}")
|
||||
if not data["items"]:
|
||||
break
|
||||
for item in data["items"]:
|
||||
yield item
|
||||
page += 1
|
||||
|
||||
# Usage
|
||||
async for item in paginate("/api/users"):
|
||||
process(item)
|
||||
```
|
||||
|
||||
#### Async Context Managers
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def db_transaction(pool):
|
||||
conn = await pool.acquire()
|
||||
tx = await conn.begin()
|
||||
try:
|
||||
yield conn
|
||||
await tx.commit()
|
||||
except Exception:
|
||||
await tx.rollback()
|
||||
raise
|
||||
finally:
|
||||
await pool.release(conn)
|
||||
```
|
||||
|
||||
#### Semaphores for Concurrency Limiting
|
||||
|
||||
```python
|
||||
async def fetch_with_limit(urls: list[str], max_concurrent: int = 10) -> list[dict]:
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def limited_fetch(url: str) -> dict:
|
||||
async with semaphore:
|
||||
return await fetch_json(url)
|
||||
|
||||
return await asyncio.gather(*[limited_fetch(url) for url in urls])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Decorators
|
||||
|
||||
#### Function Decorator with functools.wraps
|
||||
|
||||
```python
|
||||
import functools
|
||||
import time
|
||||
|
||||
def timing(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
result = func(*args, **kwargs)
|
||||
elapsed = time.perf_counter() - start
|
||||
print(f"{func.__name__} took {elapsed:.4f}s")
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
@timing
|
||||
def slow_operation():
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
#### Decorator with Arguments
|
||||
|
||||
```python
|
||||
def retry(max_attempts: int = 3, delay: float = 1.0):
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_attempts - 1:
|
||||
await asyncio.sleep(delay * (2 ** attempt))
|
||||
raise last_error
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@retry(max_attempts=5, delay=0.5)
|
||||
async def unreliable_call(url: str) -> dict:
|
||||
return await fetch_json(url)
|
||||
```
|
||||
|
||||
#### Class Decorator
|
||||
|
||||
```python
|
||||
def singleton(cls):
|
||||
instances: dict[type, Any] = {}
|
||||
|
||||
@functools.wraps(cls)
|
||||
def get_instance(*args, **kwargs):
|
||||
if cls not in instances:
|
||||
instances[cls] = cls(*args, **kwargs)
|
||||
return instances[cls]
|
||||
|
||||
return get_instance
|
||||
|
||||
@singleton
|
||||
class AppConfig:
|
||||
def __init__(self):
|
||||
self.settings = load_settings()
|
||||
```
|
||||
|
||||
#### Caching Decorator
|
||||
|
||||
```python
|
||||
from functools import lru_cache, cache
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def fibonacci(n: int) -> int:
|
||||
if n < 2:
|
||||
return n
|
||||
return fibonacci(n - 1) + fibonacci(n - 2)
|
||||
|
||||
# Python 3.9+ unbounded cache
|
||||
@cache
|
||||
def load_config(path: str) -> dict:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Context Managers
|
||||
|
||||
#### Basic @contextmanager
|
||||
|
||||
```python
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def managed_connection(dsn: str):
|
||||
conn = connect(dsn)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
with managed_connection("postgres://...") as conn:
|
||||
conn.execute("SELECT 1")
|
||||
```
|
||||
|
||||
#### Temporary File Context Manager
|
||||
|
||||
```python
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
@contextmanager
|
||||
def temp_directory():
|
||||
dirpath = tempfile.mkdtemp()
|
||||
try:
|
||||
yield dirpath
|
||||
finally:
|
||||
shutil.rmtree(dirpath)
|
||||
|
||||
with temp_directory() as tmpdir:
|
||||
filepath = os.path.join(tmpdir, "data.json")
|
||||
write_json(filepath, data)
|
||||
```
|
||||
|
||||
#### Lock Context Manager
|
||||
|
||||
```python
|
||||
import threading
|
||||
|
||||
@contextmanager
|
||||
def timed_lock(lock: threading.Lock, timeout: float = 5.0):
|
||||
acquired = lock.acquire(timeout=timeout)
|
||||
if not acquired:
|
||||
raise TimeoutError("Could not acquire lock")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock.release()
|
||||
```
|
||||
|
||||
#### Async Context Manager
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def http_session():
|
||||
session = aiohttp.ClientSession()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Pattern Matching
|
||||
|
||||
#### Basic match/case
|
||||
|
||||
```python
|
||||
def handle_command(command: str) -> str:
|
||||
match command.split():
|
||||
case ["quit"]:
|
||||
return "Goodbye"
|
||||
case ["hello", name]:
|
||||
return f"Hello, {name}"
|
||||
case ["add", *items]:
|
||||
return f"Adding {len(items)} items"
|
||||
case _:
|
||||
return "Unknown command"
|
||||
```
|
||||
|
||||
#### Structural Patterns
|
||||
|
||||
```python
|
||||
def process_event(event: dict) -> None:
|
||||
match event:
|
||||
case {"type": "click", "x": int(x), "y": int(y)}:
|
||||
handle_click(x, y)
|
||||
case {"type": "keypress", "key": str(key)} if len(key) == 1:
|
||||
handle_keypress(key)
|
||||
case {"type": "resize", "width": w, "height": h}:
|
||||
handle_resize(w, h)
|
||||
```
|
||||
|
||||
#### Guard Clauses and OR Patterns
|
||||
|
||||
```python
|
||||
def classify_status(code: int) -> str:
|
||||
match code:
|
||||
case 200 | 201 | 204:
|
||||
return "success"
|
||||
case code if 300 <= code < 400:
|
||||
return "redirect"
|
||||
case 401 | 403:
|
||||
return "auth_error"
|
||||
case code if 400 <= code < 500:
|
||||
return "client_error"
|
||||
case code if 500 <= code < 600:
|
||||
return "server_error"
|
||||
case _:
|
||||
return "unknown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Error Handling
|
||||
|
||||
#### Custom Exception Hierarchies
|
||||
|
||||
```python
|
||||
class AppError(Exception):
|
||||
"""Base exception for the application."""
|
||||
def __init__(self, message: str, code: str | None = None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
class NotFoundError(AppError):
|
||||
"""Resource was not found."""
|
||||
def __init__(self, resource: str, resource_id: str):
|
||||
super().__init__(f"{resource} {resource_id} not found", code="NOT_FOUND")
|
||||
self.resource = resource
|
||||
self.resource_id = resource_id
|
||||
|
||||
class ValidationError(AppError):
|
||||
"""Input validation failed."""
|
||||
def __init__(self, errors: list[str]):
|
||||
super().__init__(f"Validation failed: {'; '.join(errors)}", code="VALIDATION")
|
||||
self.errors = errors
|
||||
```
|
||||
|
||||
#### ExceptionGroup (Python 3.11+)
|
||||
|
||||
```python
|
||||
async def process_batch(items: list[dict]) -> list[dict]:
|
||||
results = []
|
||||
errors = []
|
||||
for item in items:
|
||||
try:
|
||||
results.append(await process(item))
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
if errors:
|
||||
raise ExceptionGroup("Batch processing errors", errors)
|
||||
return results
|
||||
|
||||
# Handling with except*
|
||||
try:
|
||||
await process_batch(items)
|
||||
except* ValueError as eg:
|
||||
print(f"Validation errors: {len(eg.exceptions)}")
|
||||
except* ConnectionError as eg:
|
||||
print(f"Connection errors: {len(eg.exceptions)}")
|
||||
```
|
||||
|
||||
#### Exception Chaining
|
||||
|
||||
```python
|
||||
def load_config(path: str) -> dict:
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError as e:
|
||||
raise AppError(f"Config file missing: {path}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise AppError(f"Invalid JSON in {path}") from e
|
||||
```
|
||||
|
||||
#### contextlib.suppress
|
||||
|
||||
```python
|
||||
from contextlib import suppress
|
||||
|
||||
# Instead of try/except/pass
|
||||
with suppress(FileNotFoundError):
|
||||
os.remove("temp_file.txt")
|
||||
|
||||
# Instead of:
|
||||
# try:
|
||||
# os.remove("temp_file.txt")
|
||||
# except FileNotFoundError:
|
||||
# pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use type hints for all public functions
|
||||
2. Use dataclasses or Pydantic for data models
|
||||
3. Prefer context managers for resource management
|
||||
4. Use async for I/O-bound operations
|
||||
5. Follow PEP 8 style guidelines
|
||||
1. **Use type hints on all public functions** -- they serve as documentation, enable IDE autocompletion, and allow static analysis with mypy or pyright.
|
||||
|
||||
2. **Prefer dataclasses or Pydantic for structured data** -- avoid passing raw dicts around. Use `@dataclass` for internal data, Pydantic `BaseModel` for external boundaries (API input/output, config files).
|
||||
|
||||
3. **Use context managers for resource management** -- database connections, file handles, locks, and temporary resources should always be wrapped in `with` statements to guarantee cleanup.
|
||||
|
||||
4. **Prefer `asyncio.TaskGroup` over bare `gather`** -- TaskGroup (3.11+) provides proper error handling by cancelling sibling tasks when one fails, avoiding orphaned coroutines.
|
||||
|
||||
5. **Follow PEP 8 and use a formatter** -- use `ruff format` or `black` for consistent formatting, and `ruff check` for linting. Configure in `pyproject.toml`.
|
||||
|
||||
6. **Write small, composable functions** -- each function should do one thing. Prefer returning values over mutating state. Limit functions to ~20 lines when practical.
|
||||
|
||||
7. **Use `__all__` in public modules** -- explicitly declare the public API of a module to prevent accidental imports of internal helpers.
|
||||
|
||||
8. **Use `pathlib.Path` over `os.path`** -- pathlib provides a cleaner, object-oriented API for file system operations and works cross-platform.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Mutable default arguments**: Use `None` and initialize in function
|
||||
- **Not closing resources**: Use `with` statements
|
||||
- **Blocking in async**: Use `asyncio.to_thread()` for CPU work
|
||||
- **Catching bare exceptions**: Be specific with exception types
|
||||
1. **Mutable default arguments** -- default values are shared across calls. Use `None` and initialize inside the function body.
|
||||
```python
|
||||
# BAD
|
||||
def add_item(item: str, items: list[str] = []) -> list[str]: ...
|
||||
|
||||
# GOOD
|
||||
def add_item(item: str, items: list[str] | None = None) -> list[str]:
|
||||
if items is None:
|
||||
items = []
|
||||
items.append(item)
|
||||
return items
|
||||
```
|
||||
|
||||
2. **Blocking calls inside async functions** -- calling `time.sleep()`, `requests.get()`, or CPU-heavy code in an async function blocks the entire event loop. Use `asyncio.to_thread()` or `asyncio.sleep()`.
|
||||
```python
|
||||
# BAD
|
||||
async def fetch():
|
||||
return requests.get(url) # blocks event loop
|
||||
|
||||
# GOOD
|
||||
async def fetch():
|
||||
return await asyncio.to_thread(requests.get, url)
|
||||
```
|
||||
|
||||
3. **Catching bare `Exception`** -- always be specific about which exceptions you catch. Bare `except:` or `except Exception:` hides bugs.
|
||||
```python
|
||||
# BAD
|
||||
try:
|
||||
result = compute()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# GOOD
|
||||
try:
|
||||
result = compute()
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning("Computation failed: %s", e)
|
||||
result = default_value
|
||||
```
|
||||
|
||||
4. **Using `is` for value comparison** -- `is` checks identity, not equality. Only use `is` for `None`, `True`, `False`, and sentinel objects.
|
||||
```python
|
||||
# BAD
|
||||
if x is 42: ...
|
||||
|
||||
# GOOD
|
||||
if x == 42: ...
|
||||
if x is None: ...
|
||||
```
|
||||
|
||||
5. **Forgetting to close resources** -- file handles, database connections, and HTTP sessions leak if not closed. Always use context managers.
|
||||
```python
|
||||
# BAD
|
||||
f = open("data.txt")
|
||||
data = f.read()
|
||||
|
||||
# GOOD
|
||||
with open("data.txt") as f:
|
||||
data = f.read()
|
||||
```
|
||||
|
||||
6. **Circular imports** -- restructure code to avoid circular dependencies. Move shared types into a separate module, use `TYPE_CHECKING` for type-only imports, or use lazy imports.
|
||||
```python
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from myapp.models import User # only imported during type checking
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `languages/typescript` -- TypeScript language patterns for polyglot projects
|
||||
- `frameworks/fastapi` -- FastAPI web framework built on Python
|
||||
- `frameworks/django` -- Django web framework for Python
|
||||
- `testing/pytest` -- Python testing with pytest
|
||||
- `patterns/error-handling` -- Python error handling and exception hierarchies
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Python Type Hints Quick Reference
|
||||
|
||||
> Python 3.10+ syntax preferred. For 3.9, use `from __future__ import annotations`.
|
||||
|
||||
## Basic Types
|
||||
|
||||
| Type | Example | Notes |
|
||||
|------|---------|-------|
|
||||
| `int` | `x: int = 1` | |
|
||||
| `float` | `x: float = 1.0` | |
|
||||
| `str` | `x: str = "hi"` | |
|
||||
| `bool` | `x: bool = True` | |
|
||||
| `bytes` | `x: bytes = b"hi"` | |
|
||||
| `None` | `x: None = None` | Use as return type for side-effect functions |
|
||||
| `object` | `x: object` | Accepts anything, but no attribute access |
|
||||
| `Any` | `x: Any` | Escapes type checking entirely |
|
||||
|
||||
## Collection Types (3.10+)
|
||||
|
||||
| Type | Example | Notes |
|
||||
|------|---------|-------|
|
||||
| `list[int]` | `x: list[int] = [1, 2]` | Mutable sequence |
|
||||
| `tuple[int, str]` | `x: tuple[int, str]` | Fixed length |
|
||||
| `tuple[int, ...]` | `x: tuple[int, ...]` | Variable length |
|
||||
| `dict[str, int]` | `x: dict[str, int]` | |
|
||||
| `set[str]` | `x: set[str]` | |
|
||||
| `frozenset[str]` | `x: frozenset[str]` | |
|
||||
|
||||
## Union and Optional
|
||||
|
||||
```python
|
||||
# 3.10+ syntax
|
||||
def f(x: int | str) -> None: ...
|
||||
def g(x: int | None = None) -> None: ...
|
||||
|
||||
# Pre-3.10
|
||||
from typing import Union, Optional
|
||||
def f(x: Union[int, str]) -> None: ...
|
||||
def g(x: Optional[int] = None) -> None: ...
|
||||
```
|
||||
|
||||
## TypeAlias
|
||||
|
||||
```python
|
||||
from typing import TypeAlias
|
||||
|
||||
# Explicit alias (3.10+)
|
||||
Vector: TypeAlias = list[float]
|
||||
|
||||
# 3.12+ syntax
|
||||
type Vector = list[float]
|
||||
type Tree[T] = T | list["Tree[T]"] # recursive
|
||||
```
|
||||
|
||||
## Generics with TypeVar
|
||||
|
||||
```python
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
K = TypeVar("K", bound=str) # upper bound
|
||||
N = TypeVar("N", int, float) # constrained
|
||||
|
||||
def first(items: list[T]) -> T:
|
||||
return items[0]
|
||||
|
||||
# 3.12+ syntax (no TypeVar needed)
|
||||
def first[T](items: list[T]) -> T:
|
||||
return items[0]
|
||||
```
|
||||
|
||||
## ParamSpec and Concatenate
|
||||
|
||||
```python
|
||||
from typing import ParamSpec, Concatenate, Callable
|
||||
|
||||
P = ParamSpec("P")
|
||||
T = TypeVar("T")
|
||||
|
||||
# Preserve function signatures through decorators
|
||||
def logged(fn: Callable[P, T]) -> Callable[P, T]:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
print(f"Calling {fn.__name__}")
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
# Add a parameter to a function signature
|
||||
def with_user(
|
||||
fn: Callable[Concatenate[User, P], T]
|
||||
) -> Callable[P, T]:
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
return fn(get_current_user(), *args, **kwargs)
|
||||
return wrapper
|
||||
```
|
||||
|
||||
## Protocol (Structural Typing)
|
||||
|
||||
```python
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
class Renderable(Protocol):
|
||||
def render(self) -> str: ...
|
||||
|
||||
class Widget(Protocol):
|
||||
name: str
|
||||
def resize(self, width: int, height: int) -> None: ...
|
||||
|
||||
# Any class with matching methods satisfies the protocol
|
||||
class Button:
|
||||
def render(self) -> str:
|
||||
return "<button/>"
|
||||
|
||||
def draw(item: Renderable) -> None: # Button works here
|
||||
print(item.render())
|
||||
|
||||
# Runtime checking
|
||||
@runtime_checkable
|
||||
class Sized(Protocol):
|
||||
def __len__(self) -> int: ...
|
||||
|
||||
assert isinstance([1, 2], Sized) # True at runtime
|
||||
```
|
||||
|
||||
## @overload
|
||||
|
||||
```python
|
||||
from typing import overload
|
||||
|
||||
@overload
|
||||
def parse(data: str) -> dict[str, Any]: ...
|
||||
@overload
|
||||
def parse(data: bytes) -> list[int]: ...
|
||||
@overload
|
||||
def parse(data: str, raw: Literal[True]) -> str: ...
|
||||
|
||||
def parse(data: str | bytes, raw: bool = False) -> dict | list | str:
|
||||
"""Implementation handles all overloads."""
|
||||
...
|
||||
```
|
||||
|
||||
## TypeGuard and TypeIs
|
||||
|
||||
```python
|
||||
from typing import TypeGuard, TypeIs # TypeIs: 3.13+
|
||||
|
||||
# TypeGuard: narrows type in True branch only
|
||||
def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
|
||||
return all(isinstance(x, str) for x in val)
|
||||
|
||||
# TypeIs: narrows in both True and False branches
|
||||
def is_int(val: int | str) -> TypeIs[int]:
|
||||
return isinstance(val, int)
|
||||
|
||||
def f(val: int | str) -> None:
|
||||
if is_int(val):
|
||||
reveal_type(val) # int
|
||||
else:
|
||||
reveal_type(val) # str (only with TypeIs)
|
||||
```
|
||||
|
||||
## Literal and Final
|
||||
|
||||
```python
|
||||
from typing import Literal, Final
|
||||
|
||||
def set_mode(mode: Literal["read", "write", "append"]) -> None: ...
|
||||
|
||||
MAX_SIZE: Final = 100 # Cannot be reassigned
|
||||
PREFIX: Final[str] = "app_" # With explicit type
|
||||
```
|
||||
|
||||
## Callable
|
||||
|
||||
| Signature | Meaning |
|
||||
|-----------|---------|
|
||||
| `Callable[[int, str], bool]` | Function taking int and str, returning bool |
|
||||
| `Callable[..., bool]` | Any args, returning bool |
|
||||
| `Callable[P, T]` | Generic (use with ParamSpec) |
|
||||
|
||||
## TypedDict
|
||||
|
||||
```python
|
||||
from typing import TypedDict, NotRequired, Required
|
||||
|
||||
class Config(TypedDict):
|
||||
name: str
|
||||
debug: NotRequired[bool] # optional key
|
||||
|
||||
class PartialConfig(TypedDict, total=False):
|
||||
name: Required[str] # required even though total=False
|
||||
debug: bool # optional
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
| Pattern | Type Hint |
|
||||
|---------|-----------|
|
||||
| JSON value | `dict[str, Any]` or custom TypedDict |
|
||||
| Decorator preserving sig | `Callable[P, T] -> Callable[P, T]` |
|
||||
| Context manager | `AbstractContextManager[T]` |
|
||||
| Async context manager | `AbstractAsyncContextManager[T]` |
|
||||
| Generator | `Generator[YieldType, SendType, ReturnType]` |
|
||||
| Async generator | `AsyncGenerator[YieldType, SendType]` |
|
||||
| Class method returning self | `-> Self` (3.11+, `from typing import Self`) |
|
||||
| Numeric tower | `int | float` (avoid `numbers.Number`) |
|
||||
|
||||
## Type Narrowing Cheat Sheet
|
||||
|
||||
| Technique | Example |
|
||||
|-----------|---------|
|
||||
| `isinstance` | `if isinstance(x, str):` |
|
||||
| `is None` / `is not None` | `if x is not None:` |
|
||||
| `TypeGuard` / `TypeIs` | Custom narrowing functions |
|
||||
| `assert` | `assert isinstance(x, str)` |
|
||||
| `Literal` checks | `if x == "read":` |
|
||||
| `hasattr` | `if hasattr(x, "render"):` (limited) |
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: typescript
|
||||
description: >
|
||||
Trigger this skill whenever working with TypeScript files (.ts, .tsx), configuring tsconfig.json, or using TypeScript-specific features like strict typing, generics, utility types, or type guards. Activate for any TypeScript project setup, type definition authoring, Zod schema validation, or discriminated union patterns. Also use when the user asks about avoiding `any`, enabling strict mode, or migrating JavaScript to TypeScript.
|
||||
---
|
||||
|
||||
# TypeScript
|
||||
|
||||
## Description
|
||||
|
||||
TypeScript development with strict typing, advanced type utilities, and modern patterns.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Working with TypeScript files (.ts, .tsx)
|
||||
@@ -11,50 +13,284 @@ TypeScript development with strict typing, advanced type utilities, and modern p
|
||||
- React/Next.js development
|
||||
- Node.js backend development
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Pure Python projects with no TypeScript components
|
||||
- JavaScript projects that have no TypeScript setup and are not being migrated to TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Type Definitions
|
||||
### 1. Advanced Types
|
||||
|
||||
#### Discriminated Unions
|
||||
|
||||
```typescript
|
||||
type Shape =
|
||||
| { kind: "circle"; radius: number }
|
||||
| { kind: "rectangle"; width: number; height: number }
|
||||
| { kind: "triangle"; base: number; height: number };
|
||||
|
||||
function area(shape: Shape): number {
|
||||
switch (shape.kind) {
|
||||
case "circle":
|
||||
return Math.PI * shape.radius ** 2;
|
||||
case "rectangle":
|
||||
return shape.width * shape.height;
|
||||
case "triangle":
|
||||
return (shape.base * shape.height) / 2;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Branded Types
|
||||
|
||||
```typescript
|
||||
type UserId = string & { readonly __brand: unique symbol };
|
||||
type OrderId = string & { readonly __brand: unique symbol };
|
||||
|
||||
function createUserId(id: string): UserId {
|
||||
if (!id.startsWith("usr_")) throw new Error("Invalid user ID");
|
||||
return id as UserId;
|
||||
}
|
||||
|
||||
function getUser(id: UserId): User { ... }
|
||||
|
||||
// Prevents mixing IDs:
|
||||
const userId = createUserId("usr_123");
|
||||
const orderId = "ord_456" as OrderId;
|
||||
// getUser(orderId); // compile error
|
||||
```
|
||||
|
||||
#### Template Literal Types
|
||||
|
||||
```typescript
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
type ApiRoute = `/api/${string}`;
|
||||
type EventName = `on${Capitalize<string>}`;
|
||||
|
||||
// Combine for precise route definitions
|
||||
type Endpoint = `${Uppercase<HttpMethod>} ${ApiRoute}`;
|
||||
|
||||
// Pattern matching on string types
|
||||
type ExtractParam<T extends string> =
|
||||
T extends `${string}:${infer Param}/${infer Rest}`
|
||||
? Param | ExtractParam<Rest>
|
||||
: T extends `${string}:${infer Param}`
|
||||
? Param
|
||||
: never;
|
||||
|
||||
type Params = ExtractParam<"/users/:userId/posts/:postId">;
|
||||
// Result: "userId" | "postId"
|
||||
```
|
||||
|
||||
#### Conditional Types with infer
|
||||
|
||||
```typescript
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
type UnwrapArray<T> = T extends (infer U)[] ? U : T;
|
||||
|
||||
// Deeply unwrap nested promises
|
||||
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
|
||||
|
||||
// Extract function return type conditionally
|
||||
type AsyncReturnType<T extends (...args: any[]) => any> =
|
||||
ReturnType<T> extends Promise<infer U> ? U : ReturnType<T>;
|
||||
```
|
||||
|
||||
#### Mapped Types
|
||||
|
||||
```typescript
|
||||
// Make all properties optional and nullable
|
||||
type Nullable<T> = { [K in keyof T]: T[K] | null };
|
||||
|
||||
// Create a readonly version with getters
|
||||
type Getters<T> = {
|
||||
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
|
||||
};
|
||||
|
||||
// Remove specific keys
|
||||
type RemoveKind<T> = {
|
||||
[K in keyof T as Exclude<K, "kind">]: T[K];
|
||||
};
|
||||
```
|
||||
|
||||
#### Recursive Types
|
||||
|
||||
```typescript
|
||||
type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| Json[]
|
||||
| { [key: string]: Json };
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
||||
};
|
||||
|
||||
type DeepReadonly<T> = {
|
||||
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Utility Types
|
||||
|
||||
```typescript
|
||||
// Interfaces for objects
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "user" | "guest";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Types for unions and utilities
|
||||
type Status = 'pending' | 'active' | 'inactive';
|
||||
type UserWithStatus = User & { status: Status };
|
||||
|
||||
// Generic types
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
error?: string;
|
||||
status: number;
|
||||
};
|
||||
```
|
||||
|
||||
### Utility Types
|
||||
|
||||
```typescript
|
||||
// Partial - all properties optional
|
||||
// Partial -- all properties optional (useful for update payloads)
|
||||
type UserUpdate = Partial<User>;
|
||||
|
||||
// Pick - select properties
|
||||
type UserPreview = Pick<User, 'id' | 'name'>;
|
||||
// Required -- all properties required (undo optionality)
|
||||
type CompleteUser = Required<User>;
|
||||
|
||||
// Omit - exclude properties
|
||||
type UserWithoutId = Omit<User, 'id'>;
|
||||
// Pick -- select specific properties
|
||||
type UserPreview = Pick<User, "id" | "name">;
|
||||
|
||||
// Record - dictionary type
|
||||
type UserMap = Record<string, User>;
|
||||
// Omit -- exclude specific properties
|
||||
type UserCreate = Omit<User, "id" | "createdAt">;
|
||||
|
||||
// Record -- dictionary with typed keys and values
|
||||
type RolePermissions = Record<User["role"], string[]>;
|
||||
|
||||
// Exclude -- remove members from a union
|
||||
type NonGuestRole = Exclude<User["role"], "guest">;
|
||||
// Result: "admin" | "user"
|
||||
|
||||
// Extract -- keep only matching members from a union
|
||||
type PrivilegedRole = Extract<User["role"], "admin" | "moderator">;
|
||||
// Result: "admin"
|
||||
|
||||
// ReturnType -- extract return type of a function
|
||||
declare function getUser(): Promise<User>;
|
||||
type GetUserResult = ReturnType<typeof getUser>;
|
||||
// Result: Promise<User>
|
||||
|
||||
// Parameters -- extract parameter types as a tuple
|
||||
type GetUserParams = Parameters<typeof getUser>;
|
||||
|
||||
// Awaited -- unwrap Promise types
|
||||
type ResolvedUser = Awaited<ReturnType<typeof getUser>>;
|
||||
// Result: User
|
||||
|
||||
// NonNullable -- remove null and undefined
|
||||
type DefinitelyString = NonNullable<string | null | undefined>;
|
||||
// Result: string
|
||||
```
|
||||
|
||||
### Async Patterns
|
||||
---
|
||||
|
||||
### 3. Generics
|
||||
|
||||
#### Generic Functions
|
||||
|
||||
```typescript
|
||||
function first<T>(items: T[]): T | undefined {
|
||||
return items[0];
|
||||
}
|
||||
|
||||
function groupBy<T, K extends string | number>(
|
||||
items: T[],
|
||||
keyFn: (item: T) => K,
|
||||
): Record<K, T[]> {
|
||||
const result = {} as Record<K, T[]>;
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
(result[key] ??= []).push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
#### Generic Constraints with extends
|
||||
|
||||
```typescript
|
||||
interface HasId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
function findById<T extends HasId>(items: T[], id: string): T | undefined {
|
||||
return items.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
// Multiple constraints
|
||||
function merge<T extends object, U extends object>(a: T, b: U): T & U {
|
||||
return { ...a, ...b };
|
||||
}
|
||||
```
|
||||
|
||||
#### Generic Classes
|
||||
|
||||
```typescript
|
||||
class Repository<T extends HasId> {
|
||||
private items = new Map<string, T>();
|
||||
|
||||
save(item: T): void {
|
||||
this.items.set(item.id, item);
|
||||
}
|
||||
|
||||
findById(id: string): T | undefined {
|
||||
return this.items.get(id);
|
||||
}
|
||||
|
||||
findAll(): T[] {
|
||||
return [...this.items.values()];
|
||||
}
|
||||
}
|
||||
|
||||
const userRepo = new Repository<User>();
|
||||
```
|
||||
|
||||
#### Default Type Parameters
|
||||
|
||||
```typescript
|
||||
type ApiResponse<T, E = Error> = {
|
||||
data: T | null;
|
||||
error: E | null;
|
||||
status: number;
|
||||
};
|
||||
|
||||
// Uses default Error type
|
||||
const response: ApiResponse<User> = {
|
||||
data: user,
|
||||
error: null,
|
||||
status: 200,
|
||||
};
|
||||
|
||||
// Override with custom error
|
||||
const response2: ApiResponse<User, ValidationError> = { ... };
|
||||
```
|
||||
|
||||
#### const Type Parameters (TypeScript 5.0+)
|
||||
|
||||
```typescript
|
||||
function createRoute<const T extends readonly string[]>(
|
||||
methods: T,
|
||||
path: string,
|
||||
) {
|
||||
return { methods, path };
|
||||
}
|
||||
|
||||
// Infers literal tuple type ["GET", "POST"] instead of string[]
|
||||
const route = createRoute(["GET", "POST"], "/api/users");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Async Patterns
|
||||
|
||||
#### Promise Typing
|
||||
|
||||
```typescript
|
||||
async function fetchUser(id: string): Promise<User> {
|
||||
@@ -62,67 +298,396 @@ async function fetchUser(id: string): Promise<User> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
return response.json() as Promise<User>;
|
||||
}
|
||||
```
|
||||
|
||||
// Error handling
|
||||
async function safeOperation<T>(
|
||||
operation: () => Promise<T>
|
||||
): Promise<[T, null] | [null, Error]> {
|
||||
#### Promise.all with Tuple Types
|
||||
|
||||
```typescript
|
||||
async function loadDashboard(userId: string) {
|
||||
const [user, posts, notifications] = await Promise.all([
|
||||
fetchUser(userId),
|
||||
fetchPosts(userId),
|
||||
fetchNotifications(userId),
|
||||
] as const);
|
||||
// user: User, posts: Post[], notifications: Notification[]
|
||||
return { user, posts, notifications };
|
||||
}
|
||||
```
|
||||
|
||||
#### Result Pattern for Error Handling
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
async function safeAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
): Promise<Result<T>> {
|
||||
try {
|
||||
const result = await operation();
|
||||
return [result, null];
|
||||
return { ok: true, value: await fn() };
|
||||
} catch (error) {
|
||||
return [null, error as Error];
|
||||
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await safeAsync(() => fetchUser("123"));
|
||||
if (result.ok) {
|
||||
console.log(result.value.name);
|
||||
} else {
|
||||
console.error(result.error.message);
|
||||
}
|
||||
```
|
||||
|
||||
### Class Patterns
|
||||
#### AbortController Patterns
|
||||
|
||||
```typescript
|
||||
class UserService {
|
||||
constructor(private readonly db: Database) {}
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number = 5000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.db.users.findUnique({ where: { id } });
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async create(data: UserCreate): Promise<User> {
|
||||
return this.db.users.create({ data });
|
||||
}
|
||||
// Cancellable operation
|
||||
function createCancellableRequest(url: string) {
|
||||
const controller = new AbortController();
|
||||
const promise = fetch(url, { signal: controller.signal });
|
||||
return {
|
||||
promise,
|
||||
cancel: () => controller.abort(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Validation
|
||||
---
|
||||
|
||||
### 5. Zod Integration
|
||||
|
||||
#### Schema Definition and Inference
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
const UserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100),
|
||||
password: z.string().min(8),
|
||||
age: z.number().int().min(0).max(150),
|
||||
role: z.enum(["admin", "user", "guest"]),
|
||||
});
|
||||
|
||||
type UserInput = z.infer<typeof UserSchema>;
|
||||
type User = z.infer<typeof UserSchema>;
|
||||
```
|
||||
|
||||
function validateUser(data: unknown): UserInput {
|
||||
return UserSchema.parse(data);
|
||||
#### Refinements and Transforms
|
||||
|
||||
```typescript
|
||||
const PasswordSchema = z
|
||||
.string()
|
||||
.min(8)
|
||||
.refine((val) => /[A-Z]/.test(val), "Must contain uppercase")
|
||||
.refine((val) => /[0-9]/.test(val), "Must contain number");
|
||||
|
||||
const DateStringSchema = z
|
||||
.string()
|
||||
.transform((val) => new Date(val))
|
||||
.refine((date) => !isNaN(date.getTime()), "Invalid date");
|
||||
|
||||
const MoneySchema = z
|
||||
.string()
|
||||
.transform((val) => parseFloat(val.replace(/[$,]/g, "")))
|
||||
.pipe(z.number().positive());
|
||||
```
|
||||
|
||||
#### Discriminated Unions with Zod
|
||||
|
||||
```typescript
|
||||
const ShapeSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("circle"), radius: z.number().positive() }),
|
||||
z.object({ kind: z.literal("rectangle"), width: z.number(), height: z.number() }),
|
||||
]);
|
||||
|
||||
type Shape = z.infer<typeof ShapeSchema>;
|
||||
|
||||
function validateShape(input: unknown): Shape {
|
||||
return ShapeSchema.parse(input);
|
||||
}
|
||||
```
|
||||
|
||||
#### Zod with API Validation
|
||||
|
||||
```typescript
|
||||
const QueryParamsSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
sort: z.enum(["name", "date", "relevance"]).default("date"),
|
||||
});
|
||||
|
||||
type QueryParams = z.infer<typeof QueryParamsSchema>;
|
||||
|
||||
function parseQuery(raw: Record<string, string>): QueryParams {
|
||||
return QueryParamsSchema.parse(raw);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Module Patterns
|
||||
|
||||
#### Barrel Exports
|
||||
|
||||
```typescript
|
||||
// src/models/index.ts
|
||||
export { User, type UserCreate } from "./user.js";
|
||||
export { Post, type PostCreate } from "./post.js";
|
||||
export { Comment } from "./comment.js";
|
||||
```
|
||||
|
||||
#### Declaration Merging
|
||||
|
||||
```typescript
|
||||
// Extend an existing interface
|
||||
interface Window {
|
||||
analytics: AnalyticsClient;
|
||||
}
|
||||
|
||||
// Extend Express Request
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Module Augmentation
|
||||
|
||||
```typescript
|
||||
// Augment a third-party module
|
||||
import "express";
|
||||
|
||||
declare module "express" {
|
||||
interface Request {
|
||||
requestId: string;
|
||||
startTime: number;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Ambient Declarations (.d.ts)
|
||||
|
||||
```typescript
|
||||
// global.d.ts
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
env: {
|
||||
VITE_API_URL: string;
|
||||
VITE_APP_TITLE: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Declare untyped modules
|
||||
declare module "legacy-lib" {
|
||||
export function doSomething(input: string): string;
|
||||
}
|
||||
|
||||
export {};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Type Guards
|
||||
|
||||
#### Built-in Narrowing
|
||||
|
||||
```typescript
|
||||
function process(value: string | number | null) {
|
||||
if (typeof value === "string") {
|
||||
// value: string
|
||||
return value.toUpperCase();
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
// value: number
|
||||
return value.toFixed(2);
|
||||
}
|
||||
// value: null
|
||||
return "N/A";
|
||||
}
|
||||
```
|
||||
|
||||
#### instanceof Guard
|
||||
|
||||
```typescript
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return `API Error ${error.statusCode}: ${error.message}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
```
|
||||
|
||||
#### in Operator Guard
|
||||
|
||||
```typescript
|
||||
interface Dog { bark(): void; breed: string; }
|
||||
interface Cat { meow(): void; color: string; }
|
||||
|
||||
function speak(animal: Dog | Cat): void {
|
||||
if ("bark" in animal) {
|
||||
animal.bark(); // animal: Dog
|
||||
} else {
|
||||
animal.meow(); // animal: Cat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom Type Predicates (is)
|
||||
|
||||
```typescript
|
||||
function isUser(value: unknown): value is User {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
"email" in value &&
|
||||
typeof (value as User).id === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function processInput(data: unknown) {
|
||||
if (isUser(data)) {
|
||||
// data: User -- fully narrowed
|
||||
console.log(data.email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Assertion Functions (asserts)
|
||||
|
||||
```typescript
|
||||
function assertDefined<T>(
|
||||
value: T | null | undefined,
|
||||
message?: string,
|
||||
): asserts value is T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(message ?? "Value is null or undefined");
|
||||
}
|
||||
}
|
||||
|
||||
function processUser(maybeUser: User | null): string {
|
||||
assertDefined(maybeUser, "User is required");
|
||||
// maybeUser: User after this point
|
||||
return maybeUser.name;
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected value: ${value}`);
|
||||
}
|
||||
|
||||
// Exhaustiveness checking in switch
|
||||
function getLabel(role: "admin" | "user" | "guest"): string {
|
||||
switch (role) {
|
||||
case "admin": return "Administrator";
|
||||
case "user": return "Standard User";
|
||||
case "guest": return "Guest";
|
||||
default: return assertNever(role); // compile error if a case is missed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Enable strict mode in tsconfig.json
|
||||
2. Avoid `any` - use `unknown` and type guards
|
||||
3. Use interfaces for object shapes, types for unions
|
||||
4. Prefer `const` assertions for literal types
|
||||
5. Use discriminated unions for state
|
||||
1. **Enable strict mode in tsconfig.json** -- set `"strict": true` which enables `strictNullChecks`, `noImplicitAny`, `strictFunctionTypes`, and other safety checks.
|
||||
|
||||
2. **Never use `any` -- use `unknown` instead** -- when the type is truly unknown, use `unknown` and narrow with type guards. Reserve `any` only for exceptional migration scenarios, and flag it with `// eslint-disable-next-line`.
|
||||
|
||||
3. **Use interfaces for object shapes, types for unions** -- interfaces support declaration merging and produce clearer error messages. Types are better for unions, intersections, and mapped types.
|
||||
|
||||
4. **Prefer discriminated unions for state modeling** -- use a shared literal `kind` or `type` field to enable exhaustive switch statements and precise narrowing.
|
||||
|
||||
5. **Use `as const` for literal inference** -- `const assertions` preserve literal types in arrays and objects, avoiding unwanted widening to `string[]` or `number[]`.
|
||||
|
||||
6. **Validate external data at boundaries** -- use Zod or a similar runtime validator at API boundaries, config loading, and form inputs. Never trust `as` casts for unknown data.
|
||||
|
||||
7. **Prefer type predicates over type assertions** -- custom `is` guards are safer than `as` casts because they include a runtime check.
|
||||
|
||||
8. **Use `satisfies` for type checking without widening** -- the `satisfies` operator (TS 5.0+) validates that a value conforms to a type while preserving the narrower inferred type.
|
||||
```typescript
|
||||
const config = {
|
||||
apiUrl: "https://api.example.com",
|
||||
retries: 3,
|
||||
} satisfies Record<string, string | number>;
|
||||
// config.apiUrl is still string (not string | number)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Using `any`**: Defeats type safety
|
||||
- **Not handling null/undefined**: Use strict null checks
|
||||
- **Type assertions**: Prefer type guards
|
||||
- **Ignoring errors**: Handle all promise rejections
|
||||
1. **Overusing type assertions (`as`)** -- assertions bypass the type checker. Use type guards or schema validation instead.
|
||||
```typescript
|
||||
// BAD
|
||||
const user = data as User;
|
||||
|
||||
// GOOD
|
||||
if (isUser(data)) { ... }
|
||||
```
|
||||
|
||||
2. **Ignoring strict null checks** -- `undefined` and `null` cause runtime crashes when not handled. Always enable `strictNullChecks` and handle nullable values explicitly.
|
||||
|
||||
3. **Returning `any` from catch blocks** -- `catch (e)` gives `unknown` in strict mode. Always narrow before using the error.
|
||||
```typescript
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Mutation of readonly types at runtime** -- `Readonly<T>` and `readonly` only prevent mutation at compile time. The underlying object can still be mutated at runtime via `Object.assign` or casts.
|
||||
|
||||
5. **Forgetting `export {}` in ambient files** -- `.d.ts` files without any import/export are treated as global scripts rather than modules, which can cause unexpected declaration collisions.
|
||||
|
||||
6. **Using enums instead of const objects** -- TypeScript enums have quirks (reverse mappings, tree-shaking issues). Prefer `as const` objects or union types.
|
||||
```typescript
|
||||
// Prefer this:
|
||||
const Role = { Admin: "admin", User: "user" } as const;
|
||||
type Role = (typeof Role)[keyof typeof Role];
|
||||
|
||||
// Over this:
|
||||
enum Role { Admin = "admin", User = "user" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `languages/javascript` -- JavaScript patterns for JS interop and migration
|
||||
- `languages/python` -- Python language patterns for polyglot projects
|
||||
- `frameworks/react` -- React component patterns with TypeScript
|
||||
- `frameworks/nextjs` -- Next.js framework with TypeScript support
|
||||
- `testing/vitest` -- TypeScript testing with Vitest
|
||||
- `patterns/error-handling` -- TypeScript error handling patterns
|
||||
- `patterns/state-management` -- State management with TypeScript types
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
# TypeScript Advanced Types Quick Reference
|
||||
|
||||
## Discriminated Unions
|
||||
|
||||
```typescript
|
||||
// Tag each variant with a literal type field
|
||||
type Shape =
|
||||
| { kind: "circle"; radius: number }
|
||||
| { kind: "rect"; width: number; height: number }
|
||||
| { kind: "triangle"; base: number; height: number };
|
||||
|
||||
function area(s: Shape): number {
|
||||
switch (s.kind) {
|
||||
case "circle": return Math.PI * s.radius ** 2;
|
||||
case "rect": return s.width * s.height;
|
||||
case "triangle": return (s.base * s.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Exhaustiveness check helper
|
||||
function assertNever(x: never): never {
|
||||
throw new Error(`Unexpected value: ${x}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Branded Types
|
||||
|
||||
```typescript
|
||||
// Prevent mixing structurally identical types
|
||||
type Brand<T, B extends string> = T & { readonly __brand: B };
|
||||
|
||||
type UserId = Brand<string, "UserId">;
|
||||
type OrderId = Brand<string, "OrderId">;
|
||||
|
||||
function getUser(id: UserId) { /* ... */ }
|
||||
|
||||
const userId = "abc" as UserId;
|
||||
const orderId = "abc" as OrderId;
|
||||
|
||||
getUser(userId); // OK
|
||||
getUser(orderId); // Error: OrderId not assignable to UserId
|
||||
|
||||
// Validation-based branding
|
||||
type Email = Brand<string, "Email">;
|
||||
function parseEmail(input: string): Email {
|
||||
if (!input.includes("@")) throw new Error("Invalid email");
|
||||
return input as Email;
|
||||
}
|
||||
```
|
||||
|
||||
## Template Literal Types
|
||||
|
||||
```typescript
|
||||
// Build string types from unions
|
||||
type Method = "get" | "post" | "put" | "delete";
|
||||
type Route = "/users" | "/orders";
|
||||
type Endpoint = `${Uppercase<Method>} ${Route}`;
|
||||
// "GET /users" | "GET /orders" | "POST /users" | ...
|
||||
|
||||
// Event handler pattern
|
||||
type EventName = "click" | "focus" | "blur";
|
||||
type Handler = `on${Capitalize<EventName>}`;
|
||||
// "onClick" | "onFocus" | "onBlur"
|
||||
|
||||
// Extract parts from string types
|
||||
type ExtractParam<T extends string> =
|
||||
T extends `${string}:${infer Param}/${infer Rest}`
|
||||
? Param | ExtractParam<Rest>
|
||||
: T extends `${string}:${infer Param}`
|
||||
? Param
|
||||
: never;
|
||||
|
||||
type Params = ExtractParam<"/users/:id/posts/:postId">;
|
||||
// "id" | "postId"
|
||||
```
|
||||
|
||||
## Conditional Types
|
||||
|
||||
```typescript
|
||||
// Basic conditional
|
||||
type IsString<T> = T extends string ? true : false;
|
||||
|
||||
// Distributive over unions (when T is naked type parameter)
|
||||
type ToArray<T> = T extends unknown ? T[] : never;
|
||||
type Result = ToArray<string | number>; // string[] | number[]
|
||||
|
||||
// Prevent distribution with tuple wrapping
|
||||
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
|
||||
type Result2 = ToArrayNonDist<string | number>; // (string | number)[]
|
||||
|
||||
// infer keyword
|
||||
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
|
||||
type Unpacked<T> = T extends Promise<infer U> ? U :
|
||||
T extends Array<infer U> ? U : T;
|
||||
|
||||
// infer with constraints (TS 4.7+)
|
||||
type FirstString<T> =
|
||||
T extends [infer S extends string, ...unknown[]] ? S : never;
|
||||
```
|
||||
|
||||
## Mapped Types
|
||||
|
||||
```typescript
|
||||
// Transform all properties
|
||||
type Readonly<T> = { readonly [K in keyof T]: T[K] };
|
||||
type Optional<T> = { [K in keyof T]?: T[K] };
|
||||
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|
||||
type Required<T> = { [K in keyof T]-?: T[K] };
|
||||
|
||||
// Map to new value types
|
||||
type Getters<T> = {
|
||||
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
|
||||
};
|
||||
|
||||
interface Person { name: string; age: number }
|
||||
type PersonGetters = Getters<Person>;
|
||||
// { getName: () => string; getAge: () => number }
|
||||
```
|
||||
|
||||
## Key Remapping (via `as`)
|
||||
|
||||
```typescript
|
||||
// Filter keys
|
||||
type OnlyStrings<T> = {
|
||||
[K in keyof T as T[K] extends string ? K : never]: T[K];
|
||||
};
|
||||
|
||||
// Rename keys
|
||||
type Prefixed<T, P extends string> = {
|
||||
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
|
||||
};
|
||||
|
||||
// Remove specific keys
|
||||
type RemoveKind<T> = {
|
||||
[K in keyof T as Exclude<K, "kind">]: T[K];
|
||||
};
|
||||
|
||||
// Build from union
|
||||
type EventMap<T extends string> = {
|
||||
[K in T as `on${Capitalize<K>}`]: (event: K) => void;
|
||||
};
|
||||
type Handlers = EventMap<"click" | "scroll">;
|
||||
// { onClick: (event: "click") => void; onScroll: (event: "scroll") => void }
|
||||
```
|
||||
|
||||
## Recursive Types
|
||||
|
||||
```typescript
|
||||
// JSON type
|
||||
type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| Json[]
|
||||
| { [key: string]: Json };
|
||||
|
||||
// Deep readonly
|
||||
type DeepReadonly<T> = T extends Function
|
||||
? T
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||
: T;
|
||||
|
||||
// Deep partial
|
||||
type DeepPartial<T> = T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T;
|
||||
|
||||
// Flatten nested object paths
|
||||
type Paths<T, Prefix extends string = ""> = T extends object
|
||||
? {
|
||||
[K in keyof T & string]: T[K] extends object
|
||||
? Paths<T[K], `${Prefix}${K}.`>
|
||||
: `${Prefix}${K}`;
|
||||
}[keyof T & string]
|
||||
: never;
|
||||
|
||||
type P = Paths<{ a: { b: number; c: { d: string } } }>;
|
||||
// "a.b" | "a.c.d"
|
||||
```
|
||||
|
||||
## Utility Types Cheat Sheet
|
||||
|
||||
| Utility | Effect |
|
||||
|---------|--------|
|
||||
| `Partial<T>` | All properties optional |
|
||||
| `Required<T>` | All properties required |
|
||||
| `Readonly<T>` | All properties readonly |
|
||||
| `Record<K, V>` | Object with keys K and values V |
|
||||
| `Pick<T, K>` | Subset of properties |
|
||||
| `Omit<T, K>` | All except listed properties |
|
||||
| `Exclude<U, E>` | Remove members from union |
|
||||
| `Extract<U, E>` | Keep matching members from union |
|
||||
| `NonNullable<T>` | Remove null and undefined |
|
||||
| `ReturnType<F>` | Return type of function |
|
||||
| `Parameters<F>` | Tuple of parameter types |
|
||||
| `ConstructorParameters<C>` | Constructor parameter types |
|
||||
| `InstanceType<C>` | Instance type of constructor |
|
||||
| `Awaited<T>` | Unwrap Promise (deeply) |
|
||||
| `NoInfer<T>` | Prevent inference from this position (5.4+) |
|
||||
|
||||
## Satisfies Operator (4.9+)
|
||||
|
||||
```typescript
|
||||
// Validate type without widening
|
||||
const palette = {
|
||||
red: "#ff0000",
|
||||
green: [0, 255, 0],
|
||||
} satisfies Record<string, string | number[]>;
|
||||
|
||||
palette.red.toUpperCase(); // OK - knows it's string
|
||||
palette.green.map(x => x); // OK - knows it's number[]
|
||||
```
|
||||
|
||||
## const Type Parameters (5.0+)
|
||||
|
||||
```typescript
|
||||
// Infer narrow literal types from arguments
|
||||
function routes<const T extends readonly string[]>(paths: T): T {
|
||||
return paths;
|
||||
}
|
||||
|
||||
const r = routes(["/home", "/about"]);
|
||||
// Type: readonly ["/home", "/about"] (not string[])
|
||||
```
|
||||
|
||||
## Pattern: Type-Safe Event Emitter
|
||||
|
||||
```typescript
|
||||
type EventMap = {
|
||||
login: { userId: string };
|
||||
logout: undefined;
|
||||
error: { code: number; message: string };
|
||||
};
|
||||
|
||||
class Emitter<E extends Record<string, unknown>> {
|
||||
on<K extends keyof E>(
|
||||
event: K,
|
||||
handler: E[K] extends undefined
|
||||
? () => void
|
||||
: (payload: E[K]) => void
|
||||
): void { /* ... */ }
|
||||
|
||||
emit<K extends keyof E>(
|
||||
...args: E[K] extends undefined ? [K] : [K, E[K]]
|
||||
): void { /* ... */ }
|
||||
}
|
||||
```
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: brainstorming
|
||||
description: >
|
||||
Trigger this skill whenever the user wants to design, explore, or ideate on ANY new feature, architecture decision, or unclear requirement. Activate for keywords like "brainstorm", "design", "explore", "what if", "how should we", "options for", "trade-offs", or any open-ended question about implementation approach. Also trigger when requirements are vague, ambiguous, or when multiple valid solutions exist -- err on the side of brainstorming before jumping into code.
|
||||
---
|
||||
|
||||
# Brainstorming
|
||||
|
||||
## Description
|
||||
|
||||
Interactive design refinement methodology for turning rough ideas into fully-formed designs through collaborative dialogue. Use this skill during creative development phases before implementation begins.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Designing new features with unclear requirements
|
||||
@@ -14,9 +16,9 @@ Interactive design refinement methodology for turning rough ideas into fully-for
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Clear "mechanical" processes with known implementation
|
||||
- Simple bug fixes with obvious solutions
|
||||
- Tasks with explicit requirements already defined
|
||||
- Executing already-approved plans -- use `executing-plans` instead
|
||||
- Simple bug fixes with obvious solutions -- jump straight to fixing
|
||||
- Mechanical refactoring where the approach is already clear
|
||||
|
||||
---
|
||||
|
||||
@@ -188,3 +190,8 @@ For informed technology choices:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/writing-plans` -- After brainstorming produces a validated design, use writing-plans to create a detailed implementation plan
|
||||
- `methodology/sequential-thinking` -- For complex problems that benefit from structured step-by-step reasoning during the brainstorming process
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Brainstorming Question Patterns
|
||||
|
||||
Quick-reference catalog of effective question types for brainstorming sessions. Use these to systematically explore a problem space before jumping to solutions.
|
||||
|
||||
---
|
||||
|
||||
## Clarifying Questions
|
||||
|
||||
**Purpose:** Ensure you understand the actual problem before solving it. Most failed implementations stem from unclear requirements.
|
||||
|
||||
**When to use:** At the start of every brainstorming session, and whenever the request contains ambiguous terms.
|
||||
|
||||
| # | Question | Context |
|
||||
|---|----------|---------|
|
||||
| 1 | What exactly should happen when a user does X? | Use when the described behavior has multiple valid interpretations. Forces concrete scenario thinking. |
|
||||
| 2 | Who is the primary user of this feature, and what's their current workflow? | Use when the requester assumes you know the audience. Different users need different solutions. |
|
||||
| 3 | What does success look like? How will you know this is working? | Use to surface acceptance criteria early. Prevents building the wrong thing correctly. |
|
||||
| 4 | Can you walk me through a specific example from start to finish? | Use when the description is abstract. Concrete examples reveal hidden requirements. |
|
||||
|
||||
---
|
||||
|
||||
## Constraint Questions
|
||||
|
||||
**Purpose:** Identify boundaries that shape the solution space. Constraints eliminate options early and prevent wasted effort.
|
||||
|
||||
**When to use:** After clarifying the goal, before exploring solutions. Especially important when the requester says "just build X."
|
||||
|
||||
| # | Question | Context |
|
||||
|---|----------|---------|
|
||||
| 1 | What's the timeline? Is there a hard deadline or a target? | Use always. A 2-day solution looks nothing like a 2-month solution. |
|
||||
| 2 | What can't change? Are there existing systems, APIs, or schemas we must preserve? | Use when modifying an existing system. Reveals integration constraints. |
|
||||
| 3 | What's the performance budget? Expected load, response time, data volume? | Use for any feature touching data pipelines, APIs, or user-facing flows. |
|
||||
| 4 | Are there compliance, security, or accessibility requirements? | Use for anything involving user data, payments, or public-facing UI. Easy to forget, expensive to retrofit. |
|
||||
|
||||
---
|
||||
|
||||
## Alternative Questions
|
||||
|
||||
**Purpose:** Expand the solution space. The first idea is rarely the best idea.
|
||||
|
||||
**When to use:** After constraints are clear but before committing to an approach. Especially when the requester has already proposed a specific solution.
|
||||
|
||||
| # | Question | Context |
|
||||
|---|----------|---------|
|
||||
| 1 | What if we solved this without building anything new? Could an existing tool or configuration handle it? | Use to challenge the assumption that code is needed. Sometimes a config change or third-party tool is enough. |
|
||||
| 2 | What's the simplest version that still delivers value? | Use to find the MVP. Strips away nice-to-haves and focuses on the core need. |
|
||||
| 3 | Have you considered [opposite approach]? What would that look like? | Use to break anchoring bias. If they propose a push model, ask about pull. If sync, ask about async. |
|
||||
| 4 | What would we do if we had to ship this today? | Use to identify which parts are truly essential vs. which are aspirational. |
|
||||
|
||||
---
|
||||
|
||||
## Prioritization Questions
|
||||
|
||||
**Purpose:** Sequence work effectively when there's more to do than time allows.
|
||||
|
||||
**When to use:** When the feature has multiple components, when scope is growing, or when the team is debating what to build first.
|
||||
|
||||
| # | Question | Context |
|
||||
|---|----------|---------|
|
||||
| 1 | Which of these capabilities is most important to the first user? | Use to rank features by user impact rather than technical convenience. |
|
||||
| 2 | What's the MVP — the smallest thing we can ship and learn from? | Use when scope is expanding. Forces a shippable first increment. |
|
||||
| 3 | What can wait for v2 without blocking the core experience? | Use to defer non-essential work explicitly rather than letting it creep in. |
|
||||
| 4 | If we could only ship one of these this week, which one? | Use when the team can't agree on priority. Forces a direct comparison. |
|
||||
|
||||
---
|
||||
|
||||
## Technical Questions
|
||||
|
||||
**Purpose:** Ground the discussion in implementation reality. Surface architecture decisions that affect the solution.
|
||||
|
||||
**When to use:** Once the goal and constraints are clear, before writing a plan. Essential for features that touch multiple systems.
|
||||
|
||||
| # | Question | Context |
|
||||
|---|----------|---------|
|
||||
| 1 | What's the data model? What entities exist, and how do they relate? | Use for any feature involving persistent state. Data model drives everything. |
|
||||
| 2 | How does authentication and authorization work for this? Who can see/do what? | Use for any feature with access control. Auth is often assumed but rarely specified. |
|
||||
| 3 | What's the expected scale — users, requests/sec, data size? | Use to choose between simple and scalable approaches. Over-engineering is as wasteful as under-engineering. |
|
||||
| 4 | What existing code or patterns should this follow? Are there conventions to match? | Use to maintain consistency. New code that ignores existing patterns creates maintenance burden. |
|
||||
|
||||
---
|
||||
|
||||
## Using This Reference
|
||||
|
||||
1. **Don't ask all questions** — pick the 3-5 most relevant for the situation
|
||||
2. **Start with clarifying** — always ensure you understand the problem
|
||||
3. **Adapt the phrasing** — these are templates, not scripts
|
||||
4. **Listen for gaps** — the questions the requester struggles to answer reveal the areas that need more thought
|
||||
5. **Document answers** — capture decisions as they're made so you don't re-ask later
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: defense-in-depth
|
||||
description: >
|
||||
Trigger this skill after fixing any data-related bug, when building validation for critical data paths, or when a single validation point has already failed in production. Use whenever you hear "it slipped through," "the check was bypassed," or "it worked in tests but not production." Apply aggressively to any scenario involving data integrity, input validation across layers, or preventing bug recurrence through structural guarantees rather than single-point fixes.
|
||||
---
|
||||
|
||||
# Defense-in-Depth
|
||||
|
||||
## Description
|
||||
|
||||
Multi-layer validation strategy that makes bugs structurally impossible rather than merely fixed. After finding and fixing a bug's root cause, implement validation at every layer data passes through.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After fixing any data-related bug
|
||||
@@ -12,6 +14,12 @@ Multi-layer validation strategy that makes bugs structurally impossible rather t
|
||||
- Building robust systems
|
||||
- When single validation points have failed
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Greenfield prototyping where speed matters more than robustness and requirements are still fluid
|
||||
- Non-data-related bugs such as logic errors, race conditions, or algorithmic mistakes
|
||||
- UI styling issues where visual correctness is the concern, not data integrity
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
@@ -283,3 +291,9 @@ After fixing any bug:
|
||||
- [ ] Bug is structurally impossible, not just fixed
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/root-cause-tracing` - Use before defense-in-depth to find the actual source of the bug before adding multi-layer validation
|
||||
- `methodology/systematic-debugging` - General debugging methodology that pairs with defense-in-depth for comprehensive bug resolution
|
||||
- `security/owasp` - Security-specific validation patterns that complement defense-in-depth for security-sensitive code paths
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Validation Layers Reference
|
||||
|
||||
Multi-layer validation strategy ensuring no single point of failure.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
Request -> [Layer 1: Input] -> [Layer 2: Business] -> [Layer 3: Persistence] -> [Layer 4: Output] -> Response
|
||||
```
|
||||
|
||||
Each layer validates independently. A failure at any layer should produce a clear, actionable error. Never rely on a single layer.
|
||||
|
||||
## Layer 1: Input Boundary
|
||||
|
||||
**Purpose**: Reject malformed, oversized, or obviously invalid data at the edge.
|
||||
|
||||
### What to Validate
|
||||
|
||||
- Data types and shapes (string, number, object structure)
|
||||
- Required vs optional fields
|
||||
- String length, numeric ranges, allowed values
|
||||
- Format patterns (email, URL, UUID, date)
|
||||
- Content-Type headers, encoding
|
||||
- File upload size and MIME type
|
||||
- Request rate and authentication tokens
|
||||
|
||||
### Python (FastAPI + Pydantic)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from fastapi import FastAPI, Query
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: EmailStr
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
age: int = Field(ge=0, le=150)
|
||||
role: Literal["admin", "user", "viewer"]
|
||||
|
||||
@app.post("/users")
|
||||
async def create_user(req: CreateUserRequest):
|
||||
# req is already validated by Pydantic
|
||||
...
|
||||
```
|
||||
|
||||
### TypeScript (Zod + Express)
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(200),
|
||||
age: z.number().int().min(0).max(150),
|
||||
role: z.enum(["admin", "user", "viewer"]),
|
||||
});
|
||||
|
||||
app.post("/users", (req, res) => {
|
||||
const result = CreateUserSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ errors: result.error.issues });
|
||||
}
|
||||
// result.data is typed and validated
|
||||
});
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
| Language | Library | Purpose |
|
||||
|---|---|---|
|
||||
| Python | Pydantic, marshmallow, cerberus | Schema validation |
|
||||
| TypeScript | Zod, Yup, io-ts, Ajv | Schema validation |
|
||||
| Any | JSON Schema | Language-agnostic schema |
|
||||
|
||||
## Layer 2: Business Logic
|
||||
|
||||
**Purpose**: Enforce domain rules, state transitions, and authorization.
|
||||
|
||||
### What to Validate
|
||||
|
||||
- Business rules (e.g., "cannot cancel a shipped order")
|
||||
- State machine transitions (e.g., draft -> published, not draft -> archived)
|
||||
- Cross-field dependencies (e.g., "end_date must be after start_date")
|
||||
- Authorization (e.g., "only the owner can modify this resource")
|
||||
- Resource existence (e.g., "referenced entity must exist")
|
||||
- Idempotency and duplicate detection
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
class OrderService:
|
||||
def cancel_order(self, order_id: str, user_id: str) -> Order:
|
||||
order = self.repo.get(order_id)
|
||||
if order is None:
|
||||
raise NotFoundError(f"Order {order_id} not found")
|
||||
if order.owner_id != user_id:
|
||||
raise ForbiddenError("Only the order owner can cancel")
|
||||
if order.status not in ("pending", "confirmed"):
|
||||
raise BusinessRuleError(
|
||||
f"Cannot cancel order in '{order.status}' status"
|
||||
)
|
||||
order.status = "cancelled"
|
||||
return self.repo.save(order)
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```typescript
|
||||
class OrderService {
|
||||
cancelOrder(orderId: string, userId: string): Order {
|
||||
const order = this.repo.get(orderId);
|
||||
if (!order) throw new NotFoundError(`Order ${orderId} not found`);
|
||||
if (order.ownerId !== userId) throw new ForbiddenError("Only the order owner can cancel");
|
||||
|
||||
const cancellableStatuses = ["pending", "confirmed"] as const;
|
||||
if (!cancellableStatuses.includes(order.status)) {
|
||||
throw new BusinessRuleError(`Cannot cancel order in '${order.status}' status`);
|
||||
}
|
||||
order.status = "cancelled";
|
||||
return this.repo.save(order);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Keep validation logic in the service/domain layer, not in controllers
|
||||
- Use custom exception types that map to HTTP status codes
|
||||
- Business rules should be testable independently of HTTP/DB
|
||||
|
||||
## Layer 3: Data Persistence
|
||||
|
||||
**Purpose**: Enforce data integrity at the database level as the last line of defense.
|
||||
|
||||
### What to Validate
|
||||
|
||||
- NOT NULL constraints
|
||||
- UNIQUE constraints (email, username)
|
||||
- FOREIGN KEY constraints (referential integrity)
|
||||
- CHECK constraints (value ranges, enums)
|
||||
- Data types and precision
|
||||
- Default values
|
||||
|
||||
### PostgreSQL Examples
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL CHECK (char_length(name) > 0),
|
||||
age INTEGER CHECK (age >= 0 AND age <= 150),
|
||||
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user', 'viewer')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'confirmed', 'shipped', 'cancelled')),
|
||||
total_cents INTEGER NOT NULL CHECK (total_cents >= 0)
|
||||
);
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Mirror constraints in your ORM (SQLAlchemy `CheckConstraint`, Prisma `@unique`, etc.)
|
||||
- Database constraints are the safety net; they catch bugs in application code
|
||||
- Always handle constraint violation errors gracefully (unique violation -> 409 Conflict)
|
||||
- Use migrations to manage schema changes
|
||||
|
||||
## Layer 4: Output Boundary
|
||||
|
||||
**Purpose**: Ensure responses are safe, well-formed, and contain only intended data.
|
||||
|
||||
### What to Validate
|
||||
|
||||
- Strip sensitive fields (passwords, internal IDs, tokens)
|
||||
- HTML-encode user-generated content to prevent XSS
|
||||
- Validate response schema (catch accidental data leaks)
|
||||
- Set security headers (Content-Type, X-Content-Type-Options)
|
||||
- Limit response size
|
||||
|
||||
### Techniques
|
||||
|
||||
- **Python**: Use Pydantic `response_model` to exclude fields not in the response schema
|
||||
- **TypeScript**: Create explicit mapper functions (`toUserResponse()`) that pick only safe fields
|
||||
- **Headers**: Set `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Content-Security-Policy`
|
||||
- **Encoding**: HTML-encode user-generated content before rendering
|
||||
|
||||
## Layer Interaction Summary
|
||||
|
||||
| Layer | Catches | If Missing |
|
||||
|---|---|---|
|
||||
| Input | Malformed data, injection attempts | Bad data flows into business logic |
|
||||
| Business | Invalid operations, auth bypass | Violated business rules, data corruption |
|
||||
| Persistence | Constraint violations, duplicates | Inconsistent data in database |
|
||||
| Output | Data leaks, XSS | Sensitive data exposed to clients |
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: dispatching-parallel-agents
|
||||
description: >
|
||||
Trigger this skill when facing 3 or more independent failures across different domains, when multiple subsystems are broken with no shared state, or when test failures span unrelated modules. Use whenever you see independent bugs in auth, cart, user, or other separate domains that can be fixed concurrently. Activate aggressively for any scenario where parallel work would reduce total resolution time without creating merge conflicts.
|
||||
---
|
||||
|
||||
# Dispatching Parallel Agents
|
||||
|
||||
## Description
|
||||
|
||||
Pattern for handling multiple independent failures by dispatching concurrent agents. Use when 3+ independent problems exist across different domains that can be solved in parallel.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Multiple subsystems broken independently
|
||||
@@ -13,10 +15,9 @@ Pattern for handling multiple independent failures by dispatching concurrent age
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Related failures (fixing one solves others)
|
||||
- Exploratory debugging (need full context)
|
||||
- Problems require shared understanding
|
||||
- Sequential dependencies exist
|
||||
- Tasks with shared state or sequential dependencies where one fix affects another
|
||||
- Single-file changes that don't benefit from parallelization overhead
|
||||
- Sequential workflows where each step depends on the output of the previous step
|
||||
|
||||
---
|
||||
|
||||
@@ -258,3 +259,8 @@ After parallel completion:
|
||||
- [ ] Changes integrated successfully
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/executing-plans` - Use executing-plans when tasks are sequential; use dispatching-parallel-agents when tasks are independent and can run concurrently
|
||||
- `methodology/writing-plans` - Write a plan first to identify which tasks are independent before dispatching parallel agents
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
# Parallelization Patterns Reference
|
||||
|
||||
How to decide what to parallelize and which pattern to use.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Parallelize when tasks are **independent**: no shared mutable state, no ordering dependency, and results can be combined without conflict.
|
||||
|
||||
## Pattern 1: Independent Tasks
|
||||
|
||||
**When**: Two or more tasks share no state and have no ordering dependency.
|
||||
|
||||
**Always parallel.** This is the simplest and most common case.
|
||||
|
||||
### Examples
|
||||
|
||||
- Linting + type checking + unit tests (different tools, same codebase, read-only)
|
||||
- Researching two unrelated libraries
|
||||
- Generating tests for unrelated modules
|
||||
- Reviewing separate files
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
[Dispatcher]
|
||||
|--- Agent A: lint src/
|
||||
|--- Agent B: typecheck src/
|
||||
|--- Agent C: run tests
|
||||
\--- Agent D: security scan
|
||||
[Collect all results]
|
||||
```
|
||||
|
||||
### Decision Criteria
|
||||
|
||||
- Do they read/write the same files? No -> parallel
|
||||
- Does one need output from another? No -> parallel
|
||||
- Can they run in any order? Yes -> parallel
|
||||
|
||||
## Pattern 2: Fan-Out / Fan-In
|
||||
|
||||
**When**: A single task can be split into N identical subtasks, then results are merged.
|
||||
|
||||
### Examples
|
||||
|
||||
- Process each file in a directory independently
|
||||
- Run the same analysis on multiple services
|
||||
- Test multiple configurations
|
||||
- Investigate multiple potential causes of a bug
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
[Dispatcher: split work into N chunks]
|
||||
|--- Agent 1: process chunk 1
|
||||
|--- Agent 2: process chunk 2
|
||||
|--- Agent 3: process chunk 3
|
||||
\--- Agent N: process chunk N
|
||||
[Collector: merge results from all agents]
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
Split items across agents (round-robin, by directory, or by type), dispatch all simultaneously, collect results, handle failures by retrying individually, then merge into unified output.
|
||||
|
||||
## Pattern 3: Pipeline (Sequential)
|
||||
|
||||
**When**: Output of step N is input to step N+1.
|
||||
|
||||
**Must be sequential.** Cannot parallelize.
|
||||
|
||||
### Examples
|
||||
|
||||
- Parse code -> analyze AST -> generate report
|
||||
- Fetch data -> transform -> validate -> persist
|
||||
- Write code -> run tests -> fix failures
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
[Step 1: parse] --> [Step 2: analyze] --> [Step 3: report]
|
||||
```
|
||||
|
||||
### When Pipelines Contain Parallelizable Steps
|
||||
|
||||
A pipeline stage itself might fan out:
|
||||
|
||||
```
|
||||
[Step 1: identify files]
|
||||
--> [Step 2: analyze each file in parallel (fan-out/fan-in)]
|
||||
--> [Step 3: merge analysis into report]
|
||||
```
|
||||
|
||||
## Pattern 4: Pipeline with Parallel Stages
|
||||
|
||||
**When**: Some pipeline stages can run in parallel, others must be sequential.
|
||||
|
||||
### Example: Feature Implementation
|
||||
|
||||
```
|
||||
[Sequential: write plan]
|
||||
--> [Parallel: implement module A, implement module B, implement module C]
|
||||
--> [Sequential: integration test]
|
||||
--> [Parallel: write docs, update changelog]
|
||||
--> [Sequential: final review]
|
||||
```
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Task Characteristic | Pattern | Parallelizable? |
|
||||
|---|---|---|
|
||||
| No shared state, no ordering | Independent | Yes |
|
||||
| Same operation on many items | Fan-out/fan-in | Yes |
|
||||
| Output feeds next step | Pipeline | No |
|
||||
| Mixed dependencies | Pipeline + parallel stages | Partially |
|
||||
| Shared mutable state | Sequential or lock-based | No (usually) |
|
||||
| Non-deterministic ordering matters | Sequential | No |
|
||||
|
||||
## Common Parallel Task Patterns
|
||||
|
||||
### File-Per-Agent
|
||||
|
||||
Split work by file or directory. Each agent owns its files exclusively.
|
||||
|
||||
```
|
||||
Agent 1: src/auth/**
|
||||
Agent 2: src/orders/**
|
||||
Agent 3: src/users/**
|
||||
```
|
||||
|
||||
**Best for**: code review, refactoring, test generation, documentation.
|
||||
|
||||
**Watch out for**: shared utilities, cross-module imports. Assign shared code to one agent or make it read-only for all.
|
||||
|
||||
### Test Suite Splitting
|
||||
|
||||
Split tests by module, type, or estimated runtime.
|
||||
|
||||
```
|
||||
Agent 1: unit tests (fast)
|
||||
Agent 2: integration tests (medium)
|
||||
Agent 3: e2e tests (slow)
|
||||
```
|
||||
|
||||
**Best for**: CI acceleration, pre-merge validation.
|
||||
|
||||
### Multi-Service Investigation
|
||||
|
||||
When debugging spans multiple services, assign one agent per service.
|
||||
|
||||
```
|
||||
Agent 1: investigate auth service logs
|
||||
Agent 2: investigate order service logs
|
||||
Agent 3: investigate payment service logs
|
||||
```
|
||||
|
||||
**Best for**: distributed system debugging, incident response.
|
||||
|
||||
### Research Branches
|
||||
|
||||
Explore multiple hypotheses or approaches simultaneously.
|
||||
|
||||
```
|
||||
Agent 1: research approach A (Redis caching)
|
||||
Agent 2: research approach B (CDN edge caching)
|
||||
Agent 3: research approach C (application-level memoization)
|
||||
```
|
||||
|
||||
**Best for**: technology evaluation, design exploration, root cause hypotheses.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|---|---|---|
|
||||
| Parallelizing dependent tasks | Race conditions, wrong results | Identify dependencies first, use pipeline |
|
||||
| Too many agents | Overhead exceeds benefit | 2-5 agents is typical sweet spot |
|
||||
| No merge strategy | Results conflict or duplicate | Define merge/dedup logic before dispatching |
|
||||
| Shared file writes | Corruption, lost changes | Assign file ownership to one agent |
|
||||
| No failure handling | One failure blocks everything | Collect partial results, retry individually |
|
||||
|
||||
## Checklist Before Parallelizing
|
||||
|
||||
1. **List all tasks** that need to happen
|
||||
2. **Draw dependencies** between them (which needs output from which?)
|
||||
3. **Group independent tasks** into parallel batches
|
||||
4. **Define the merge strategy** for collecting results
|
||||
5. **Assign ownership** so no two agents write the same file
|
||||
6. **Plan for failure** of individual agents
|
||||
7. **Estimate whether parallelism helps** (overhead vs time saved)
|
||||
|
||||
## Quick Reference: Dispatch Decision
|
||||
|
||||
- Single atomic operation -> just do it, no parallelism
|
||||
- Splittable into independent chunks -> fan-out/fan-in
|
||||
- Each step depends on previous output -> pipeline (sequential)
|
||||
- Mix of independent and dependent steps -> pipeline with parallel stages
|
||||
- Everything independent -> run all in parallel
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: executing-plans
|
||||
description: >
|
||||
Trigger this skill whenever there is a written implementation plan ready to execute, or when the user says "execute", "run the plan", "implement the plan", "start building", or references a plan file. Also activate when using subagent-driven development with independent tasks, when the user wants automated execution with quality gates, or when picking up a previously written plan. If a plan document exists and no one is executing it yet, this is the skill to use.
|
||||
---
|
||||
|
||||
# Executing Plans
|
||||
|
||||
## Description
|
||||
|
||||
Subagent-driven development pattern for executing detailed implementation plans with quality gates. Uses fresh agents per task and mandatory code review between tasks.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Executing plans created with `writing-plans` skill
|
||||
@@ -13,9 +15,9 @@ Subagent-driven development pattern for executing detailed implementation plans
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Plan needs review first (use brainstorming)
|
||||
- Tasks are tightly coupled and need shared context
|
||||
- Plan requires revision during execution
|
||||
- No plan exists yet -- use `writing-plans` first to create one
|
||||
- Single-task work that does not need sequential execution or review gates
|
||||
- Research or exploration where the goal is learning, not building
|
||||
|
||||
---
|
||||
|
||||
@@ -257,3 +259,9 @@ Before declaring plan execution complete:
|
||||
- [ ] Ready for `finishing-development-branch`
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/writing-plans` -- Use to create the plan before executing it
|
||||
- `methodology/dispatching-parallel-agents` -- For coordinating multiple independent agents when plan tasks allow parallelism
|
||||
- `methodology/verification-before-completion` -- Ensures each task and the final result are properly verified before claiming completion
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Plan Execution Checklist
|
||||
|
||||
Step-by-step checklist for executing implementation plans. Follow this sequence for each plan to ensure consistent, high-quality delivery.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Pre-Execution
|
||||
|
||||
Complete all items before writing any code.
|
||||
|
||||
- [ ] **Read the full plan end-to-end** — Understand the complete scope before starting any task. Do not start task 1 without knowing what task N requires.
|
||||
- [ ] **Identify the dependency graph** — Which tasks depend on others? Which can run in parallel? Mark the critical path.
|
||||
- [ ] **Check external dependencies** — API keys available? Services running? Permissions granted? Third-party accounts set up?
|
||||
- [ ] **Verify the environment**
|
||||
- [ ] Correct branch checked out (or worktree created)
|
||||
- [ ] Dependencies installed and up to date
|
||||
- [ ] Existing tests pass before any changes
|
||||
- [ ] Build succeeds from clean state
|
||||
- [ ] **Clarify ambiguities** — If any task description is unclear, resolve it now. Do not guess during implementation.
|
||||
- [ ] **Estimate total effort** — Does the sum of task estimates feel realistic given what you know? Flag concerns early.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Per-Task Execution
|
||||
|
||||
Repeat for each task in plan order (respecting dependencies).
|
||||
|
||||
### Before Starting the Task
|
||||
|
||||
- [ ] **Read the task spec completely** — Including files to modify, changes, tests, and verification steps
|
||||
- [ ] **Confirm dependencies are met** — All prerequisite tasks marked complete and verified
|
||||
- [ ] **Check current state** — Run tests, confirm the codebase is in a good state before making changes
|
||||
|
||||
### During the Task
|
||||
|
||||
- [ ] **Write tests first** — If the plan includes tests for this task, write them before the implementation. They should fail initially.
|
||||
- [ ] **Implement the changes** — Follow the spec. If you need to deviate, document why.
|
||||
- [ ] **Run the task's specific tests** — All tests for this task must pass
|
||||
- [ ] **Run the full test suite** — Ensure no regressions from your changes
|
||||
- [ ] **Complete the task's verification steps** — Every verification item in the plan must be checked
|
||||
|
||||
### After Completing the Task
|
||||
|
||||
- [ ] **Mark the task complete** — Update the plan document
|
||||
- [ ] **Check for side effects** — Did anything unexpected break? Are there warnings?
|
||||
- [ ] **Commit the work** — One commit per task with a clear message referencing the plan
|
||||
```
|
||||
feat(scope): task description
|
||||
|
||||
Plan: [plan-name], Task N
|
||||
```
|
||||
- [ ] **Update the plan if needed** — If you discovered something that affects later tasks, note it now
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Post-Execution
|
||||
|
||||
Complete after all tasks are done.
|
||||
|
||||
### Verification
|
||||
|
||||
- [ ] **Run the full test suite** — All tests pass, not just the ones you added
|
||||
```bash
|
||||
# Python
|
||||
pytest -v --cov=src
|
||||
|
||||
# TypeScript
|
||||
pnpm test
|
||||
```
|
||||
- [ ] **Run the build** — Confirm the project builds without errors
|
||||
```bash
|
||||
pnpm build # or equivalent
|
||||
```
|
||||
- [ ] **Run linters and type checks** — No new warnings or errors
|
||||
- [ ] **Manual verification** — Walk through the acceptance criteria in the plan's Verification Plan section
|
||||
- [ ] **Check for leftover artifacts**
|
||||
- [ ] No TODO comments left unresolved
|
||||
- [ ] No commented-out code
|
||||
- [ ] No debug logging left in place
|
||||
- [ ] No temporary files committed
|
||||
|
||||
### Review
|
||||
|
||||
- [ ] **Self-review the diff** — Read your own changes as if reviewing someone else's PR
|
||||
```bash
|
||||
git diff main...HEAD
|
||||
```
|
||||
- [ ] **Check test quality** — Do tests verify behavior, not implementation? Are edge cases covered?
|
||||
- [ ] **Check documentation** — If the plan required doc updates, are they done?
|
||||
- [ ] **Verify acceptance criteria** — Every criterion in the plan marked as met
|
||||
|
||||
### Completion
|
||||
|
||||
- [ ] **Update plan status** — Mark as "Complete"
|
||||
- [ ] **Summarize deviations** — Document any changes from the original plan and why
|
||||
- [ ] **Create PR or merge** — Follow the project's git workflow
|
||||
- [ ] **Clean up** — Remove worktree if used, close related issues
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Common Failure Points
|
||||
|
||||
| Failure | Prevention |
|
||||
|---------|-----------|
|
||||
| Skipping plan review, then discovering blockers mid-task | Always complete Phase 1 fully |
|
||||
| Tests pass in isolation but fail together | Run full suite after every task |
|
||||
| Deviation from plan without updating it | Document changes as you make them |
|
||||
| "It works on my machine" | Verify in clean environment |
|
||||
| Forgetting to commit per-task | Commit immediately after verification |
|
||||
| Side effects in later tasks | Check for regressions after each task |
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: finishing-development-branch
|
||||
description: >
|
||||
Trigger this skill when implementation is complete and all tests pass, when ready to merge a feature branch, create a PR, or clean up after development. Use whenever you hear "ship it," "ready to merge," "branch is done," or "create a PR." Activate at the end of any feature, bugfix, or chore branch lifecycle to ensure proper verification, option presentation, and worktree cleanup.
|
||||
---
|
||||
|
||||
# Finishing a Development Branch
|
||||
|
||||
## Description
|
||||
|
||||
Structured 5-step workflow for completing development branches. Ensures tests pass, presents completion options, and handles cleanup.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After implementing a feature
|
||||
@@ -11,6 +13,12 @@ Structured 5-step workflow for completing development branches. Ensures tests pa
|
||||
- Ready to merge or create PR
|
||||
- Cleaning up after development
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Work is still in progress and not all planned changes have been implemented
|
||||
- Tests are failing and need to be fixed before the branch can be finalized
|
||||
- Uncommitted changes remain that have not been staged or committed yet
|
||||
|
||||
---
|
||||
|
||||
## The 5-Step Workflow
|
||||
@@ -273,3 +281,9 @@ Never:
|
||||
- Leave orphaned worktrees
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/requesting-code-review` - Use before finishing the branch to get review feedback, especially for Option 2 (Create PR)
|
||||
- `methodology/verification-before-completion` - Run verification checks before claiming the branch is ready to finish
|
||||
- `methodology/executing-plans` - If the branch was created from an execution plan, return to the plan to mark tasks complete
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# Branch Completion Checklist
|
||||
|
||||
Checklist and reference for completing a development branch and integrating work.
|
||||
|
||||
## Pre-Merge Checklist
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] All tests pass on the branch (`pytest -v` / `pnpm test`)
|
||||
- [ ] No linting errors (`ruff check` / `eslint .`)
|
||||
- [ ] Type checking passes (`mypy` / `tsc --noEmit`)
|
||||
- [ ] No TODO/FIXME without a ticket reference
|
||||
- [ ] No debugging artifacts (print statements, console.log, commented-out code)
|
||||
- [ ] No hardcoded secrets, API keys, or credentials
|
||||
|
||||
### Review
|
||||
|
||||
- [ ] Code review requested and approved
|
||||
- [ ] All review comments addressed (fixed, deferred with ticket, or discussed)
|
||||
- [ ] No unresolved conversations in the PR
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] Unit tests added for new behavior
|
||||
- [ ] Integration tests added for new endpoints/services
|
||||
- [ ] Edge cases covered (empty input, max size, unauthorized, concurrent)
|
||||
- [ ] Test coverage meets minimum threshold (80% overall, 95% critical paths)
|
||||
- [ ] Manual testing completed for UI/UX changes
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] Public API documentation updated (docstrings, OpenAPI spec)
|
||||
- [ ] README updated (if setup steps changed)
|
||||
- [ ] CHANGELOG entry added (if applicable)
|
||||
- [ ] Migration guide written (if breaking changes)
|
||||
- [ ] Architecture/design docs updated (if structural changes)
|
||||
|
||||
### Branch Hygiene
|
||||
|
||||
- [ ] Branch is up to date with main (rebase or merge)
|
||||
- [ ] No merge conflicts
|
||||
- [ ] Commit history is clean and meaningful
|
||||
- [ ] Branch name follows convention (`feature/`, `fix/`, `hotfix/`, `chore/`)
|
||||
|
||||
### CI/CD
|
||||
|
||||
- [ ] CI pipeline is green (all checks pass)
|
||||
- [ ] Build succeeds
|
||||
- [ ] No new warnings introduced
|
||||
- [ ] Performance benchmarks pass (if applicable)
|
||||
- [ ] Security scan passes (if applicable)
|
||||
|
||||
### Database/Infrastructure
|
||||
|
||||
- [ ] Migrations are reversible
|
||||
- [ ] Migrations have been tested (up and down)
|
||||
- [ ] No destructive schema changes without a migration plan
|
||||
- [ ] Environment variables documented (if new ones added)
|
||||
- [ ] Feature flags configured (if using progressive rollout)
|
||||
|
||||
## Merge Strategy Decision
|
||||
|
||||
### Merge Commit (`git merge --no-ff`)
|
||||
|
||||
**When to use:**
|
||||
- Feature branch with multiple meaningful commits
|
||||
- You want to preserve the full development history
|
||||
- Team convention requires merge commits
|
||||
|
||||
**Result:** Preserves all commits plus a merge commit. Creates a clear merge point in history.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --no-ff feature/TICKET-123-description
|
||||
```
|
||||
|
||||
### Squash Merge (`git merge --squash`)
|
||||
|
||||
**When to use:**
|
||||
- Feature branch has messy/WIP commits
|
||||
- The feature is a single logical unit
|
||||
- You want a clean linear history on main
|
||||
|
||||
**Result:** All commits become one commit on main.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --squash feature/TICKET-123-description
|
||||
git commit -m "feat(orders): add bulk order cancellation (#123)"
|
||||
```
|
||||
|
||||
### Rebase (`git rebase main` + fast-forward merge)
|
||||
|
||||
**When to use:**
|
||||
- Small number of clean, atomic commits
|
||||
- You want linear history without merge commits
|
||||
- Each commit builds on the previous logically
|
||||
|
||||
**Result:** Commits are replayed on top of main. No merge commit.
|
||||
|
||||
```bash
|
||||
git checkout feature/TICKET-123-description
|
||||
git rebase main
|
||||
git checkout main
|
||||
git merge --ff-only feature/TICKET-123-description
|
||||
```
|
||||
|
||||
### Decision Matrix
|
||||
|
||||
| Situation | Strategy |
|
||||
|---|---|
|
||||
| Feature with messy WIP commits | Squash |
|
||||
| Feature with clean, meaningful commits | Merge commit or rebase |
|
||||
| Single commit fix | Fast-forward (rebase) |
|
||||
| Long-lived branch, multiple contributors | Merge commit |
|
||||
| Team prefers linear history | Squash or rebase |
|
||||
| Need to bisect individual changes later | Merge commit or rebase (not squash) |
|
||||
|
||||
## Update Branch Before Merging
|
||||
|
||||
### Option A: Rebase onto main
|
||||
|
||||
```bash
|
||||
git checkout feature/TICKET-123-description
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
# Resolve conflicts if any
|
||||
git push --force-with-lease # update remote branch
|
||||
```
|
||||
|
||||
**Pros:** Clean linear history.
|
||||
**Cons:** Rewrites history (don't use if others are working on the branch).
|
||||
|
||||
### Option B: Merge main into branch
|
||||
|
||||
```bash
|
||||
git checkout feature/TICKET-123-description
|
||||
git fetch origin
|
||||
git merge origin/main
|
||||
# Resolve conflicts if any
|
||||
git push
|
||||
```
|
||||
|
||||
**Pros:** Safe, preserves history, works with shared branches.
|
||||
**Cons:** Adds merge commits to the feature branch.
|
||||
|
||||
## Post-Merge Steps
|
||||
|
||||
### Immediate
|
||||
|
||||
- [ ] Delete the feature branch (local and remote)
|
||||
```bash
|
||||
git branch -d feature/TICKET-123-description
|
||||
git push origin --delete feature/TICKET-123-description
|
||||
```
|
||||
- [ ] Verify main branch builds and tests pass
|
||||
- [ ] Verify deployment to staging/preview environment succeeds
|
||||
|
||||
### Follow-Up
|
||||
|
||||
- [ ] Close the associated ticket/issue
|
||||
- [ ] Notify the team (if significant change)
|
||||
- [ ] Monitor logs and error rates after deployment
|
||||
- [ ] Verify the feature works in the deployed environment
|
||||
- [ ] Update project board/tracker
|
||||
|
||||
### If Something Goes Wrong
|
||||
|
||||
| Problem | Action |
|
||||
|---|---|
|
||||
| Tests fail on main after merge | Revert the merge commit immediately, investigate on a new branch |
|
||||
| Deployment fails | Roll back deployment, investigate, do not push fixes to main under pressure |
|
||||
| Bug found in production | Create a hotfix branch from main, fix, test, deploy |
|
||||
| Need to undo a squash merge | `git revert <squash-commit-sha>` |
|
||||
| Need to undo a merge commit | `git revert -m 1 <merge-commit-sha>` |
|
||||
|
||||
## Quick Reference: Common Commands
|
||||
|
||||
```bash
|
||||
# Check if branch is up to date with main
|
||||
git fetch origin && git log HEAD..origin/main --oneline
|
||||
|
||||
# See what will be merged
|
||||
git log main..HEAD --oneline
|
||||
|
||||
# See the full diff against main
|
||||
git diff main...HEAD
|
||||
|
||||
# Check CI status (GitHub CLI)
|
||||
gh pr checks
|
||||
|
||||
# Merge via GitHub CLI
|
||||
gh pr merge --squash # or --merge, --rebase
|
||||
|
||||
# Delete branch after merge
|
||||
gh pr merge --squash --delete-branch
|
||||
```
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: receiving-code-review
|
||||
description: >
|
||||
Trigger this skill whenever code review feedback is received, whether from human reviewers, automated tools, or PR comments. Use when processing review comments, handling review rejections, iterating on feedback cycles, or deciding how to prioritize critical vs minor issues. Activate aggressively any time review feedback arrives -- categorize, prioritize, fix critical issues first, and re-request review with a clear summary of changes made.
|
||||
---
|
||||
|
||||
# Receiving Code Review
|
||||
|
||||
## Description
|
||||
|
||||
Workflow for processing code review feedback effectively. Prioritize issues, apply fixes, and iterate until approval.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After receiving review feedback
|
||||
@@ -11,6 +13,12 @@ Workflow for processing code review feedback effectively. Prioritize issues, app
|
||||
- Handling reviewer comments on PRs
|
||||
- Iterating after code review rejection
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Self-review of your own code where an independent perspective is what you actually need
|
||||
- Initial implementation before any review has been requested or received
|
||||
- Design or brainstorming phase where feedback is about ideas, not code
|
||||
|
||||
---
|
||||
|
||||
## Feedback Categories
|
||||
@@ -256,3 +264,9 @@ If review requires 3+ cycles:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/requesting-code-review` - Companion skill for initiating reviews with proper context before feedback is received
|
||||
- `methodology/systematic-debugging` - Use systematic debugging techniques when review feedback reveals bugs that need investigation
|
||||
- `methodology/verification-before-completion` - After addressing review feedback, verify all fixes before claiming completion
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Feedback Categories Reference
|
||||
|
||||
How to categorize, prioritize, and respond to code review feedback.
|
||||
|
||||
## Category Definitions
|
||||
|
||||
### Critical -- Must Fix Before Merge
|
||||
|
||||
**Impact**: Security vulnerability, data loss, crash, or correctness failure.
|
||||
|
||||
**Examples**:
|
||||
- SQL injection or XSS vulnerability
|
||||
- Missing authentication/authorization check
|
||||
- Data corruption or silent data loss
|
||||
- Unhandled exception that crashes the service
|
||||
- Race condition that causes incorrect results
|
||||
- Breaking change to public API without migration path
|
||||
|
||||
**Response**: Fix immediately. No merge until resolved. Thank the reviewer.
|
||||
|
||||
**Time**: Address within hours, not days.
|
||||
|
||||
### Important -- Should Fix
|
||||
|
||||
**Impact**: Logic error, missing edge case, performance issue, or maintainability concern.
|
||||
|
||||
**Examples**:
|
||||
- Missing null/undefined check on a code path that can be reached
|
||||
- N+1 query that will degrade with data growth
|
||||
- Missing error handling for a plausible failure mode
|
||||
- Incorrect business logic for an edge case
|
||||
- Missing test for a significant code path
|
||||
- Resource leak (connection, file handle, memory)
|
||||
|
||||
**Response**: Fix before merge unless there is a strong reason to defer (document with a ticket if deferring).
|
||||
|
||||
**Time**: Address before the next review round.
|
||||
|
||||
### Minor -- Fix If Easy
|
||||
|
||||
**Impact**: Code style, naming, comments, minor readability.
|
||||
|
||||
**Examples**:
|
||||
- Variable name could be clearer
|
||||
- Comment is slightly inaccurate
|
||||
- Could extract a helper function for readability
|
||||
- Import ordering
|
||||
- Unnecessary intermediate variable
|
||||
- Slightly verbose code that could be simplified
|
||||
|
||||
**Response**: Fix if the change is quick and low-risk. If fixing would require significant refactoring, note it for a follow-up.
|
||||
|
||||
**Time**: Address in the current PR or create a follow-up ticket.
|
||||
|
||||
### Subjective -- Discuss and Decide
|
||||
|
||||
**Impact**: Architectural preference, design philosophy, style choice where both options are valid.
|
||||
|
||||
**Examples**:
|
||||
- "I would have used a class here instead of functions"
|
||||
- "I prefer early returns over nested if-else"
|
||||
- "Consider using pattern X instead of pattern Y"
|
||||
- "This could also be modeled as an event-driven system"
|
||||
- Disagreement on level of abstraction
|
||||
|
||||
**Response**: Engage in discussion. Consider the merits. Agree on a direction or escalate to team lead. Neither side is necessarily wrong.
|
||||
|
||||
**Time**: Resolve within one discussion round if possible.
|
||||
|
||||
## Prioritization Matrix
|
||||
|
||||
| Category | Merge Blocker? | Default Action | Can Defer? |
|
||||
|---|---|---|---|
|
||||
| Critical | Yes | Fix now | No |
|
||||
| Important | Usually | Fix now or create ticket | With justification |
|
||||
| Minor | No | Fix if quick | Yes, with follow-up |
|
||||
| Subjective | No | Discuss | Yes, team decision |
|
||||
|
||||
## How to Handle Each Category
|
||||
|
||||
### Receiving Critical Feedback
|
||||
|
||||
1. Acknowledge the issue immediately
|
||||
2. Do not be defensive -- this is protecting users
|
||||
3. Fix and push the update
|
||||
4. Add a test that would catch the issue
|
||||
5. Consider if similar issues exist elsewhere
|
||||
|
||||
```
|
||||
> Reviewer: This SQL query uses string interpolation, which is vulnerable to injection.
|
||||
>
|
||||
> You: Good catch -- fixed in abc1234. Added parameterized query and a test
|
||||
> that verifies injection attempts are escaped. Also checked the other
|
||||
> queries in this module; they all use parameterized queries already.
|
||||
```
|
||||
|
||||
### Receiving Important Feedback
|
||||
|
||||
1. Evaluate whether the feedback is correct (verify, don't assume)
|
||||
2. If correct, fix it
|
||||
3. If you disagree, explain your reasoning with evidence
|
||||
4. If deferring, create a ticket and reference it
|
||||
|
||||
```
|
||||
> Reviewer: This will N+1 query when loading orders with items.
|
||||
>
|
||||
> You: You're right. Added eager loading with joinedload() in commit def5678.
|
||||
> Added a test that asserts query count stays constant regardless of item count.
|
||||
```
|
||||
|
||||
### Receiving Minor Feedback
|
||||
|
||||
1. Fix quickly if possible
|
||||
2. If it requires significant refactoring, note it
|
||||
|
||||
```
|
||||
> Reviewer: Consider renaming `data` to `order_summary` for clarity.
|
||||
>
|
||||
> You: Renamed in abc9012. Agreed it's clearer.
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
> Reviewer: This function could be extracted into a utility.
|
||||
>
|
||||
> You: Agree, but it's only used here for now. Created PROJ-789 to extract
|
||||
> it if we need it elsewhere. Keeping it inline for this PR.
|
||||
```
|
||||
|
||||
### Receiving Subjective Feedback
|
||||
|
||||
1. Consider the suggestion genuinely
|
||||
2. Present your reasoning if you disagree
|
||||
3. Look for objective criteria to decide (performance, testability, consistency with codebase)
|
||||
4. If no clear winner, defer to existing codebase conventions
|
||||
5. If still no consensus, the code author decides (or escalate)
|
||||
|
||||
```
|
||||
> Reviewer: I'd prefer a class-based approach here.
|
||||
>
|
||||
> You: I considered that. Went with functions because: (1) no shared state
|
||||
> between operations, (2) matches the pattern in src/services/auth.py,
|
||||
> (3) easier to test in isolation. Happy to discuss further if you see
|
||||
> benefits I'm missing.
|
||||
```
|
||||
|
||||
## Handling Disagreements
|
||||
|
||||
### Step-by-Step Process
|
||||
|
||||
1. **Verify the claim**: Run the test, check the docs, reproduce the scenario. Do not argue from assumption.
|
||||
2. **Propose an alternative**: If you disagree, suggest what you would do instead and explain why.
|
||||
3. **Look for objective evidence**: Benchmarks, test results, documentation, or existing patterns in the codebase.
|
||||
4. **Find common ground**: Often both approaches have merit. Look for a synthesis.
|
||||
5. **Escalate if stuck**: Bring in a third opinion (tech lead, team discussion). Do not let PRs stall.
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
- Do not dismiss feedback without investigation
|
||||
- Do not agree with everything to avoid conflict (performative agreement hides bugs)
|
||||
- Do not take feedback personally
|
||||
- Do not let disagreements block merges for days -- timebox the discussion
|
||||
- Do not relitigate decisions that were already agreed upon by the team
|
||||
|
||||
## Feedback Response Checklist
|
||||
|
||||
For each piece of feedback received:
|
||||
|
||||
- [ ] Read and understand the feedback fully
|
||||
- [ ] Categorize it (critical / important / minor / subjective)
|
||||
- [ ] If technical claim: verify it independently (run the code, check docs)
|
||||
- [ ] Respond with what you did (fixed, deferred with ticket, or discussed)
|
||||
- [ ] If fixed: reference the commit
|
||||
- [ ] If deferred: reference the ticket
|
||||
- [ ] If disagreeing: provide reasoning with evidence
|
||||
|
||||
## Quick Reference: Response Templates
|
||||
|
||||
**Agreeing and fixing:**
|
||||
> Fixed in [commit]. Added test to prevent regression.
|
||||
|
||||
**Agreeing and deferring:**
|
||||
> Agreed. Created [TICKET] to address this. Out of scope for this PR.
|
||||
|
||||
**Disagreeing with reasoning:**
|
||||
> Considered this. Went with [approach] because [reason 1], [reason 2]. Here's [evidence]. Open to discussion.
|
||||
|
||||
**Asking for clarification:**
|
||||
> Can you clarify what you mean by [X]? I want to make sure I address the right concern.
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: requesting-code-review
|
||||
description: >
|
||||
Trigger this skill after completing any task, implementing a feature, fixing a critical bug, or before merging to a main branch. Use whenever code is ready for feedback, when unsure about an implementation approach, or when changes touch security, authentication, or data handling. Activate before any PR creation or branch merge to ensure reviewers have complete context, clear scope, and focused areas of concern.
|
||||
---
|
||||
|
||||
# Requesting Code Review
|
||||
|
||||
## Description
|
||||
|
||||
Workflow for initiating code reviews with clear scope, context, and expectations. Ensures reviewers have everything needed for effective feedback.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After completing a task (before proceeding to next)
|
||||
@@ -12,6 +14,12 @@ Workflow for initiating code reviews with clear scope, context, and expectations
|
||||
- When unsure about implementation approach
|
||||
- After fixing critical bugs
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Mid-implementation work where the code is still incomplete and likely to change significantly
|
||||
- Research or exploration tasks where you are prototyping and not producing production code
|
||||
- Trivial one-line fixes like typo corrections or version bumps that carry no risk
|
||||
|
||||
---
|
||||
|
||||
## Review Request Components
|
||||
@@ -237,3 +245,9 @@ Reviewer will return:
|
||||
See `receiving-code-review` skill for detailed guidance.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/receiving-code-review` - Companion skill for processing and acting on review feedback after it is received
|
||||
- `methodology/verification-before-completion` - Run verification checks before requesting review to ensure code is actually ready
|
||||
- `methodology/finishing-development-branch` - Use after review approval to complete the branch merge/PR workflow
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
# Review Request Template
|
||||
|
||||
Use this template when requesting code review. Copy the structure below and fill in each section. Remove sections that are not applicable, but err on the side of including more context.
|
||||
|
||||
---
|
||||
|
||||
## Review Request
|
||||
|
||||
### Summary
|
||||
|
||||
_One to three sentences describing the change at a high level. What does this change do and why?_
|
||||
|
||||
**Type**: `feature` | `bugfix` | `refactor` | `performance` | `security` | `chore`
|
||||
|
||||
**Ticket/Issue**: [Link or ID]
|
||||
|
||||
**Branch**: `feature/TICKET-123-description` -> `main`
|
||||
|
||||
---
|
||||
|
||||
### Changes Made
|
||||
|
||||
_List the key changes. Group by area if touching multiple parts of the codebase._
|
||||
|
||||
**Core changes:**
|
||||
- [ ] Changed X in `src/path/to/file.py` to support Y
|
||||
- [ ] Added new endpoint `POST /api/resource` in `src/api/routes.py`
|
||||
- [ ] Updated database schema: added `column_name` to `table_name`
|
||||
|
||||
**Supporting changes:**
|
||||
- [ ] Added migration `migrations/0042_add_column.py`
|
||||
- [ ] Updated config for new feature flag `ENABLE_FEATURE_X`
|
||||
|
||||
**Files changed:** _N files, +X/-Y lines_ (or let the PR tool calculate)
|
||||
|
||||
---
|
||||
|
||||
### Testing Done
|
||||
|
||||
_Describe what testing was performed. Be specific._
|
||||
|
||||
- [ ] Unit tests added/updated: `tests/test_feature.py`
|
||||
- [ ] Integration tests added/updated: `tests/integration/test_api.py`
|
||||
- [ ] Manual testing steps:
|
||||
1. Step one
|
||||
2. Step two
|
||||
3. Expected result
|
||||
- [ ] Edge cases tested:
|
||||
- Empty input
|
||||
- Maximum size input
|
||||
- Unauthorized user
|
||||
- Concurrent requests
|
||||
- [ ] All existing tests pass: `pytest -v` / `pnpm test`
|
||||
|
||||
---
|
||||
|
||||
### Areas of Concern
|
||||
|
||||
_Be honest about parts you are unsure about. This helps reviewers focus._
|
||||
|
||||
- [ ] The caching logic in `src/services/cache.py` lines 42-67 may have race conditions under high concurrency
|
||||
- [ ] Not sure if the error handling in `handleTimeout()` covers all edge cases
|
||||
- [ ] Performance impact of the new query has not been benchmarked
|
||||
- [ ] _None -- I am confident in this change_
|
||||
|
||||
---
|
||||
|
||||
### Reviewer Focus Areas
|
||||
|
||||
_Tell the reviewer where to spend their time. Rank by priority._
|
||||
|
||||
1. **Security**: Authentication logic in `src/auth/middleware.py` -- does the token validation cover all cases?
|
||||
2. **Correctness**: State machine transitions in `src/services/order.py` -- are all transitions valid?
|
||||
3. **Performance**: New database query in `src/repos/order_repo.py` -- is it using the right index?
|
||||
4. **Design**: Is the service layer abstraction appropriate, or should this be split?
|
||||
|
||||
---
|
||||
|
||||
### How to Test Locally
|
||||
|
||||
_Step-by-step instructions so the reviewer can verify the change._
|
||||
|
||||
```bash
|
||||
# 1. Set up environment
|
||||
git checkout feature/TICKET-123-description
|
||||
pip install -r requirements.txt # or: pnpm install
|
||||
|
||||
# 2. Run migrations (if applicable)
|
||||
python manage.py migrate # or: pnpm db:migrate
|
||||
|
||||
# 3. Set required environment variables (if applicable)
|
||||
export FEATURE_X_ENABLED=true
|
||||
|
||||
# 4. Run the application
|
||||
python -m uvicorn main:app --reload # or: pnpm dev
|
||||
|
||||
# 5. Test the change
|
||||
curl -X POST http://localhost:8000/api/resource \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"key": "value"}'
|
||||
# Expected: 201 Created with response body { "id": "...", "key": "value" }
|
||||
|
||||
# 6. Run tests
|
||||
pytest tests/ -v # or: pnpm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Additional Context
|
||||
|
||||
_Optional. Screenshots, diagrams, links to design docs, related PRs, or anything else that helps the reviewer._
|
||||
|
||||
- Design doc: [link]
|
||||
- Related PR: #42
|
||||
- Screenshot of UI change: [attached]
|
||||
- Before/after performance metrics: [data]
|
||||
|
||||
---
|
||||
|
||||
## Quick Version (For Small Changes)
|
||||
|
||||
For small, low-risk changes, use this abbreviated format:
|
||||
|
||||
```
|
||||
## Review Request
|
||||
**Summary**: Fix off-by-one in pagination (returns N+1 results instead of N)
|
||||
**Ticket**: PROJ-456
|
||||
**Changes**: `src/api/pagination.py` line 23: `< limit` changed to `<= limit`
|
||||
**Tests**: Updated `tests/test_pagination.py`, all pass
|
||||
**Risk**: Low -- single line change, well-covered by tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist Before Submitting
|
||||
|
||||
- [ ] Self-reviewed the diff (read your own PR as if you were the reviewer)
|
||||
- [ ] Tests added for new behavior
|
||||
- [ ] No TODO/FIXME/HACK comments left without a ticket reference
|
||||
- [ ] No debugging artifacts (print statements, console.log, commented-out code)
|
||||
- [ ] Documentation updated (if user-facing behavior changed)
|
||||
- [ ] Migration is reversible (if schema changed)
|
||||
- [ ] No secrets in the diff
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: root-cause-tracing
|
||||
description: >
|
||||
Trigger this skill whenever a bug manifests far from its origin, when stack traces show multiple layers of indirection, or when data corruption appears with no obvious source. Use for any scenario involving "it was already wrong by the time it got here," deep execution stack errors, constraint violations caused by upstream failures, or mysterious data state issues. Always prefer this over surface-level fixes when the error location differs from the bug location.
|
||||
---
|
||||
|
||||
# Root Cause Tracing
|
||||
|
||||
## Description
|
||||
|
||||
Debugging technique for bugs that manifest deep in execution stacks. Systematically trace backward through the call chain to identify the original trigger, rather than fixing symptoms at the point of failure.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Errors occur far from entry points
|
||||
@@ -12,6 +14,12 @@ Debugging technique for bugs that manifest deep in execution stacks. Systematica
|
||||
- Stack traces show multiple levels of indirection
|
||||
- "It was already wrong by the time it got here"
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Surface-level UI bugs where the cause and effect are co-located
|
||||
- Known issues with documented fixes already available in the codebase or issue tracker
|
||||
- Performance optimization work where profiling tools are more appropriate than tracing
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
@@ -228,3 +236,9 @@ Fixing at the source:
|
||||
- [ ] Test proves fix works
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/systematic-debugging` - General debugging methodology; use root-cause-tracing when the bug location differs from the error location
|
||||
- `methodology/defense-in-depth` - After tracing the root cause, apply multi-layer validation to make the bug structurally impossible
|
||||
- `methodology/sequential-thinking` - Use sequential thinking to systematically document evidence and hypotheses during complex tracing sessions
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Tracing Techniques Reference
|
||||
|
||||
Backward-tracing techniques for systematic root cause analysis.
|
||||
|
||||
## Stack Trace Analysis
|
||||
|
||||
### Reading a Stack Trace
|
||||
|
||||
1. Start at the **bottom** (most recent call) to find the immediate failure
|
||||
2. Scan **upward** to find the first frame in **your code** (not library code)
|
||||
3. That frame is usually the symptom location, not the cause
|
||||
4. Continue upward to find where bad data or state originated
|
||||
|
||||
### Symptom vs Cause
|
||||
|
||||
| What You See | Likely Actual Cause |
|
||||
|---|---|
|
||||
| `NullPointerException` / `TypeError: cannot read property of undefined` | Value not set upstream, missing null check at origin |
|
||||
| `IndexOutOfBoundsException` | Off-by-one in loop logic or empty collection not guarded |
|
||||
| `ConnectionRefusedError` | Service down, wrong port, firewall rule, DNS resolution |
|
||||
| `TimeoutError` | Deadlock, resource exhaustion, slow query, network partition |
|
||||
| `ValidationError` | Caller passing wrong shape, schema mismatch, migration gap |
|
||||
|
||||
### Tips
|
||||
|
||||
- Filter out framework frames to reduce noise
|
||||
- In async code, the stack may be split; look for `caused by` or `previous` sections
|
||||
- In Python, read `__cause__` and `__context__` on chained exceptions
|
||||
- In TypeScript/Node, check `error.cause` (ES2022+)
|
||||
|
||||
## Binary Search / Git Bisect
|
||||
|
||||
### When to Use
|
||||
|
||||
- Bug exists now but worked at some known-good point
|
||||
- Reproducer is automatable (script, test command)
|
||||
|
||||
### Process
|
||||
|
||||
```bash
|
||||
git bisect start
|
||||
git bisect bad # current commit is broken
|
||||
git bisect good <known-good-sha> # last known working commit
|
||||
# Git checks out a midpoint; run your test
|
||||
git bisect good # or bad, based on result
|
||||
# Repeat until Git identifies the first bad commit
|
||||
git bisect reset # return to original branch
|
||||
```
|
||||
|
||||
### Automated Bisect
|
||||
|
||||
```bash
|
||||
git bisect start HEAD <good-sha>
|
||||
git bisect run ./test-script.sh
|
||||
# Exit 0 = good, exit 1 = bad, exit 125 = skip
|
||||
```
|
||||
|
||||
## Log Correlation
|
||||
|
||||
### Technique
|
||||
|
||||
1. Identify the **exact timestamp** of the error
|
||||
2. Search all related service logs within a window (e.g., +/- 30 seconds)
|
||||
3. Filter by **correlation ID**, **request ID**, or **user ID** across services
|
||||
4. Build a timeline of events across services
|
||||
|
||||
### Correlation Fields to Look For
|
||||
|
||||
- `request_id` or `trace_id` (distributed tracing)
|
||||
- `user_id` or `session_id`
|
||||
- Source IP or client identifier
|
||||
- Timestamps (normalize to UTC)
|
||||
|
||||
### Tools
|
||||
|
||||
- `grep` / `rg` with timestamp ranges
|
||||
- Structured logging with JSON output + `jq`
|
||||
- Distributed tracing (OpenTelemetry, Jaeger, Zipkin)
|
||||
|
||||
## Dependency Analysis (Backward Data Flow)
|
||||
|
||||
### Process
|
||||
|
||||
1. Start at the error location
|
||||
2. Identify the **variable or value** that is wrong
|
||||
3. Trace backward: where was this value set?
|
||||
4. At each step, ask: is this value correct here? If yes, move forward. If no, keep going back.
|
||||
5. The root cause is where correct data first becomes incorrect.
|
||||
|
||||
### Common Data Flow Points
|
||||
|
||||
```
|
||||
User Input -> Validation -> Transform -> Business Logic -> Persistence -> Query -> Response
|
||||
```
|
||||
|
||||
Trace backward through this chain from wherever the error manifests.
|
||||
|
||||
### Dependency Categories
|
||||
|
||||
| Dependency | What to Check |
|
||||
|---|---|
|
||||
| Function arguments | Caller passing wrong values |
|
||||
| Config / env vars | Wrong environment, stale config |
|
||||
| Database state | Missing migration, corrupt data |
|
||||
| External API | Changed response format, auth expiry |
|
||||
| Shared state | Race condition, stale cache |
|
||||
|
||||
## Instrumentation Points
|
||||
|
||||
### Where to Add Temporary Logging
|
||||
|
||||
1. **Entry/exit of suspected function** — log arguments and return value
|
||||
2. **Before/after external calls** — log request and response
|
||||
3. **Branch points** — log which path was taken and why
|
||||
4. **Data transformation steps** — log before and after
|
||||
5. **Error handlers** — log the full error with context
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Use a distinct prefix (e.g., `[DEBUG-TRACE]`) so logs are easy to find and remove
|
||||
- Log the **type** as well as the **value** (catches `"null"` vs `null`)
|
||||
- In production, use feature flags or debug log levels, not code changes
|
||||
- Remove all temporary logging before committing
|
||||
|
||||
### Python Example
|
||||
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def process_order(order_id: str) -> Order:
|
||||
logger.debug("[DEBUG-TRACE] process_order called with: %s (type: %s)", order_id, type(order_id))
|
||||
order = db.get_order(order_id)
|
||||
logger.debug("[DEBUG-TRACE] db.get_order returned: %s", order)
|
||||
# ... rest of logic
|
||||
```
|
||||
|
||||
### TypeScript Example
|
||||
|
||||
```typescript
|
||||
function processOrder(orderId: string): Order {
|
||||
console.debug(`[DEBUG-TRACE] processOrder called with: ${orderId} (type: ${typeof orderId})`);
|
||||
const order = db.getOrder(orderId);
|
||||
console.debug(`[DEBUG-TRACE] db.getOrder returned:`, order);
|
||||
// ... rest of logic
|
||||
}
|
||||
```
|
||||
|
||||
## Common Root Cause Categories
|
||||
|
||||
| Category | Symptoms | Investigation Approach |
|
||||
|---|---|---|
|
||||
| **Data issues** | Wrong output, validation errors, corrupt state | Trace the bad value backward through the data flow |
|
||||
| **Race conditions** | Intermittent failures, works-on-retry, order-dependent | Look for shared mutable state, add timing logs, test with delays |
|
||||
| **Config drift** | Works locally but not in staging/prod | Diff environment configs, check env vars, verify secrets |
|
||||
| **Dependency changes** | Broke after deploy with no code changes | Check lock file diffs, dependency changelogs, API version headers |
|
||||
| **Resource exhaustion** | Timeouts, OOM, connection pool errors | Monitor metrics (memory, CPU, connections, disk), check for leaks |
|
||||
| **Schema mismatch** | Serialization errors, missing fields | Compare expected vs actual schema, check migration status |
|
||||
|
||||
## Quick Decision: Which Technique to Use
|
||||
|
||||
| Situation | Start With |
|
||||
|---|---|
|
||||
| Have a stack trace | Stack trace analysis |
|
||||
| "It used to work" | Git bisect |
|
||||
| Multi-service issue | Log correlation |
|
||||
| Wrong data in output | Backward data flow |
|
||||
| No idea where to start | Add instrumentation at boundaries |
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: sequential-thinking
|
||||
description: >
|
||||
Trigger this skill for any complex problem requiring careful step-by-step reasoning, evidence collection, and confidence tracking. Use when debugging has multiple possible causes, when making architecture decisions with trade-offs, during security analysis or audits, for performance investigations, or whenever decisions need explicit documentation. Activate aggressively for any scenario where jumping to conclusions would be risky or where the reasoning chain matters as much as the answer.
|
||||
---
|
||||
|
||||
# Sequential Thinking
|
||||
|
||||
## Description
|
||||
|
||||
Step-by-step reasoning methodology with explicit evidence collection and confidence tracking. Use for complex problems requiring careful analysis and documented decision-making.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Complex debugging
|
||||
@@ -13,6 +15,12 @@ Step-by-step reasoning methodology with explicit evidence collection and confide
|
||||
- Any problem with multiple possible causes
|
||||
- When decisions need documentation
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Simple straightforward tasks where the answer is obvious and well-known
|
||||
- Mechanical code changes like renames, formatting, or boilerplate generation
|
||||
- When the MCP sequential-thinking server is unavailable and structured tool support is needed
|
||||
|
||||
---
|
||||
|
||||
## The Sequential Process
|
||||
@@ -243,3 +251,9 @@ needsMoreThoughts: If more analysis needed
|
||||
- Performance investigations
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/brainstorming` - Use brainstorming for open-ended creative exploration; use sequential thinking when you need structured evidence-based analysis
|
||||
- `methodology/root-cause-tracing` - Complements sequential thinking by providing the tracing methodology to follow during evidence gathering steps
|
||||
- `methodology/systematic-debugging` - Use systematic debugging for the overall debugging framework; sequential thinking adds rigorous documentation and confidence tracking
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: >
|
||||
Trigger this skill whenever encountering ANY bug, error, test failure, or unexpected behavior. Activate for keywords like "bug", "error", "failing", "broken", "doesn't work", "unexpected", "crash", "exception", "TypeError", "undefined", stack traces, or any error message. Also trigger when tests fail unexpectedly, when behavior differs from expectations, when investigating production incidents, or when flaky/intermittent issues appear. ALWAYS investigate root cause before proposing fixes -- never guess at solutions.
|
||||
---
|
||||
|
||||
# Systematic Debugging
|
||||
|
||||
## Description
|
||||
|
||||
Four-phase debugging methodology centered on finding root causes before implementing fixes. The foundational principle: **"NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST"**
|
||||
|
||||
## When to Use
|
||||
|
||||
- Bug reports with unclear cause
|
||||
@@ -12,6 +14,12 @@ Four-phase debugging methodology centered on finding root causes before implemen
|
||||
- Intermittent/flaky issues
|
||||
- Complex multi-component failures
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Known issues with documented fixes already available in the codebase or runbook
|
||||
- Simple typo or syntax errors that are immediately obvious from the error message
|
||||
- Configuration issues where the fix is simply updating an environment variable or config value
|
||||
|
||||
---
|
||||
|
||||
## The Four Phases
|
||||
@@ -256,3 +264,9 @@ Before declaring fixed:
|
||||
- [ ] Fix explained (can articulate why it works)
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/root-cause-tracing` -- Deep-dive technique for tracing issues back through complex dependency chains
|
||||
- `methodology/defense-in-depth` -- Add defensive layers to prevent similar bugs from recurring
|
||||
- `methodology/verification-before-completion` -- Ensures the fix is actually verified with evidence before claiming the bug is resolved
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Systematic Debugging Checklist
|
||||
|
||||
Step-by-step process for debugging any issue. Follow the steps in order — skipping ahead is the most common cause of wasted debugging time.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Reproduce
|
||||
|
||||
**Goal:** Confirm you can trigger the bug on demand.
|
||||
|
||||
- [ ] **Get the exact steps** — What did the user do? What input? What sequence?
|
||||
- [ ] **Reproduce it yourself** — If you can't reproduce it, you can't verify a fix
|
||||
- [ ] **Find the minimal reproduction** — Strip away everything that isn't necessary to trigger the bug
|
||||
- [ ] **Document the environment**
|
||||
- OS and version
|
||||
- Language/runtime version
|
||||
- Dependency versions
|
||||
- Environment variables or config that matters
|
||||
- [ ] **Note what you expect vs. what actually happens**
|
||||
|
||||
**If you can't reproduce:**
|
||||
- Check if it's environment-specific (OS, browser, node version)
|
||||
- Check if it's state-dependent (specific data, race condition, cache)
|
||||
- Add logging and wait for it to happen again
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Gather Evidence
|
||||
|
||||
**Goal:** Collect all available information before forming theories.
|
||||
|
||||
- [ ] **Read the error message carefully** — The answer is often in the message. Read the full text, not just the first line.
|
||||
- [ ] **Read the full stack trace** — Identify which line in YOUR code is the entry point (ignore framework internals at first)
|
||||
- [ ] **Check logs** — Application logs, server logs, browser console
|
||||
- [ ] **Check timestamps** — When did it start? Does it correlate with a deployment, config change, or data change?
|
||||
- [ ] **Check recent changes**
|
||||
```bash
|
||||
git log --oneline -20
|
||||
git diff HEAD~5..HEAD -- path/to/suspect/area/
|
||||
```
|
||||
- [ ] **Check monitoring/metrics** — Error rates, latency, resource usage
|
||||
- [ ] **Search for the error** — Has someone on the team seen this before? Check issues, Slack, docs.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Form Hypotheses
|
||||
|
||||
**Goal:** Generate candidate explanations ranked by likelihood.
|
||||
|
||||
- [ ] **What changed recently?** — The most common cause of new bugs is new code
|
||||
- [ ] **What assumptions might be wrong?** — About input format, data state, timing, permissions
|
||||
- [ ] **List 2-3 hypotheses** — Write them down explicitly:
|
||||
1. [Most likely] ...
|
||||
2. [Possible] ...
|
||||
3. [Less likely but worth checking] ...
|
||||
- [ ] **For each hypothesis, define what evidence would confirm or refute it**
|
||||
|
||||
**Common root causes to consider:**
|
||||
- Null/undefined where a value was expected
|
||||
- Off-by-one or boundary condition
|
||||
- Race condition or timing issue
|
||||
- Stale cache or state
|
||||
- Environment difference (local vs. prod)
|
||||
- Dependency version mismatch
|
||||
- Incorrect assumption about API contract
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Test Hypotheses
|
||||
|
||||
**Goal:** Confirm or eliminate each hypothesis with evidence.
|
||||
|
||||
- [ ] **Start with the most likely hypothesis**
|
||||
- [ ] **Add targeted logging** — Log the specific values your hypothesis predicts will be wrong
|
||||
```python
|
||||
# Python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Value at suspect point: {value!r}, type: {type(value)}")
|
||||
```
|
||||
```javascript
|
||||
// JavaScript
|
||||
console.log('Value at suspect point:', JSON.stringify(value), typeof value);
|
||||
```
|
||||
- [ ] **Use git bisect for regressions** — Find the exact commit that introduced the bug
|
||||
```bash
|
||||
git bisect start
|
||||
git bisect bad # Current commit is broken
|
||||
git bisect good v1.2.0 # This version was working
|
||||
# Test each commit bisect offers, mark good/bad
|
||||
```
|
||||
- [ ] **Isolate components** — Test each component in isolation to narrow the scope
|
||||
- [ ] **Use a debugger for complex state issues**
|
||||
|
||||
### Debugger Quick Reference
|
||||
|
||||
| Language | Tool | Start Command |
|
||||
|----------|------|--------------|
|
||||
| Python | pdb | `import pdb; pdb.set_trace()` or `breakpoint()` |
|
||||
| Python | logging | `logging.basicConfig(level=logging.DEBUG)` |
|
||||
| Python | traceback | `import traceback; traceback.print_exc()` |
|
||||
| JavaScript | debugger | `debugger;` statement in code |
|
||||
| JavaScript | console | `console.log()`, `console.trace()`, `console.table()` |
|
||||
| JavaScript | Chrome DevTools | Open DevTools > Sources > set breakpoint |
|
||||
| TypeScript | Node inspect | `node --inspect -r ts-node/register script.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Fix and Verify
|
||||
|
||||
**Goal:** Apply the minimal correct fix and prove it works.
|
||||
|
||||
- [ ] **Make the smallest fix possible** — Fix the bug, not the whole file. One concern per commit.
|
||||
- [ ] **Write a regression test** — A test that fails without your fix and passes with it
|
||||
```python
|
||||
def test_handles_empty_input_without_crash():
|
||||
"""Regression test for bug #123 — empty input caused TypeError."""
|
||||
result = process(input_data="")
|
||||
assert result == expected_default
|
||||
```
|
||||
- [ ] **Verify the fix resolves the original reproduction**
|
||||
- [ ] **Run the full test suite** — Confirm no side effects
|
||||
- [ ] **Check related code paths** — Is the same bug pattern present elsewhere?
|
||||
```bash
|
||||
# Search for similar patterns
|
||||
grep -rn "similar_pattern" src/
|
||||
```
|
||||
- [ ] **Test edge cases around the fix** — Boundary values, null inputs, concurrent access
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Document and Prevent
|
||||
|
||||
**Goal:** Prevent this class of bug from recurring.
|
||||
|
||||
- [ ] **Write a clear commit message** explaining what was wrong and why the fix works
|
||||
- [ ] **Update documentation** if the bug revealed a misunderstanding
|
||||
- [ ] **Consider systemic fixes:**
|
||||
- Could a type system catch this? (Add types)
|
||||
- Could a linter rule catch this? (Add rule)
|
||||
- Could input validation catch this? (Add validation)
|
||||
- Could monitoring catch this sooner? (Add alert)
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Debugging Anti-Patterns
|
||||
|
||||
| Anti-Pattern | What to Do Instead |
|
||||
|-------------|-------------------|
|
||||
| Changing random things until it works | Form a hypothesis, test it, iterate |
|
||||
| Debugging in production | Reproduce locally first |
|
||||
| Reading code for hours without running it | Add a log statement and run it |
|
||||
| Fixing the symptom, not the cause | Ask "why?" until you reach the root |
|
||||
| Not writing a regression test | Always write one before closing the bug |
|
||||
| Debugging alone for too long | Ask for help after 30 minutes of no progress |
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: test-driven-development
|
||||
description: >
|
||||
Trigger this skill whenever writing new features, fixing bugs, or changing any behavior in production code. Activate for keywords like "implement", "add feature", "fix bug", "write code", "build", "create endpoint", "add functionality", or any task that will result in production code changes. Also trigger when the user asks to refactor existing code, when tests need to be written, or when someone says "TDD". This skill should be the default for ALL implementation work -- no production code without a failing test first.
|
||||
---
|
||||
|
||||
# Test-Driven Development (TDD)
|
||||
|
||||
## Description
|
||||
|
||||
Strict test-driven development methodology requiring tests before implementation. The fundamental practice: "If you didn't watch the test fail, you don't know if it tests the right thing."
|
||||
|
||||
## When to Use
|
||||
|
||||
- New feature development
|
||||
@@ -11,11 +13,11 @@ Strict test-driven development methodology requiring tests before implementation
|
||||
- Refactoring (ensure tests exist before changing)
|
||||
- Any behavior change
|
||||
|
||||
## When NOT to Use (Requires Explicit Approval)
|
||||
## When NOT to Use
|
||||
|
||||
- Throwaway prototypes
|
||||
- Generated/scaffolded code
|
||||
- Pure configuration changes
|
||||
- Prototyping or throwaway code with explicit user approval to skip tests
|
||||
- Configuration-only changes (e.g., environment variables, CI config, linter rules)
|
||||
- Documentation updates that do not affect runtime behavior
|
||||
|
||||
---
|
||||
|
||||
@@ -241,3 +243,11 @@ This is faster than:
|
||||
7. Ship again
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/verification-before-completion` -- Ensures tests are actually run and passing before claiming work is done
|
||||
- `methodology/testing-anti-patterns` -- Avoid common testing mistakes that undermine TDD effectiveness
|
||||
- `testing/pytest` -- Python-specific testing patterns and best practices for TDD
|
||||
- `testing/vitest` -- TypeScript/JavaScript-specific testing patterns and best practices for TDD
|
||||
- `methodology/writing-plans` — Planning implementation tasks for TDD workflow
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# TDD Decision Tree
|
||||
|
||||
Quick reference for deciding when and how to apply Test-Driven Development.
|
||||
|
||||
---
|
||||
|
||||
## Decision: Should I Use TDD Here?
|
||||
|
||||
```
|
||||
Is this code...
|
||||
│
|
||||
├─ Business logic or data transformation?
|
||||
│ └─ YES: Always TDD. No exceptions.
|
||||
│
|
||||
├─ An API endpoint (REST, GraphQL, RPC)?
|
||||
│ └─ YES: Always TDD. Write request/response tests first.
|
||||
│
|
||||
├─ A bug fix?
|
||||
│ └─ YES: Always TDD. Write a failing test that reproduces the bug first.
|
||||
│
|
||||
├─ A utility function or helper?
|
||||
│ └─ YES: Always TDD. These are the easiest to TDD — pure input/output.
|
||||
│
|
||||
├─ A database query or repository method?
|
||||
│ └─ YES: Always TDD. Test the query behavior, not the SQL syntax.
|
||||
│
|
||||
├─ A state machine or workflow?
|
||||
│ └─ YES: Always TDD. Test each transition.
|
||||
│
|
||||
├─ UI layout or styling (CSS, Tailwind, visual positioning)?
|
||||
│ └─ TDD optional. Visual output is hard to assert meaningfully.
|
||||
│ Use snapshot tests or visual regression tools instead.
|
||||
│
|
||||
├─ Configuration or environment setup?
|
||||
│ └─ TDD optional. Test that config loads correctly, but don't
|
||||
│ TDD every config value. Integration tests are more useful.
|
||||
│
|
||||
├─ A database migration?
|
||||
│ └─ TDD optional. Test that migration runs forward and backward.
|
||||
│ Don't TDD the migration SQL itself.
|
||||
│
|
||||
├─ A prototype or spike?
|
||||
│ └─ TDD optional. Spikes are throwaway. But if the spike becomes
|
||||
│ real code, stop and add tests before continuing.
|
||||
│
|
||||
├─ Third-party integration glue code?
|
||||
│ └─ TDD the contract, not the integration. Write tests against
|
||||
│ the interface you expect, mock the external service.
|
||||
│
|
||||
└─ Generated code (scaffolding, boilerplate)?
|
||||
└─ TDD optional. Test the generator if you wrote it.
|
||||
Don't TDD the generated output.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision Factors
|
||||
|
||||
When the tree above doesn't give a clear answer, weigh these factors:
|
||||
|
||||
| Factor | Favors TDD | Favors Test-After |
|
||||
|--------|-----------|-------------------|
|
||||
| **Testability** | Clear inputs/outputs, deterministic | Heavy side effects, UI rendering |
|
||||
| **Complexity** | Multiple branches, edge cases | Straightforward single-path logic |
|
||||
| **Risk** | Failure is costly (data loss, security) | Failure is cosmetic or low-impact |
|
||||
| **Stability** | Requirements are clear and stable | Requirements are still changing |
|
||||
| **Team convention** | Team expects TDD | Team doesn't practice TDD |
|
||||
| **Confidence** | You're unsure how to implement it | You've built this exact thing before |
|
||||
|
||||
**Rule of thumb:** If you're unsure, use TDD. The cost of writing a test first is low. The cost of a bug in untested code is high.
|
||||
|
||||
---
|
||||
|
||||
## The TDD Cycle
|
||||
|
||||
```
|
||||
1. RED — Write a failing test that defines the desired behavior
|
||||
2. GREEN — Write the minimum code to make the test pass
|
||||
3. REFACTOR — Clean up without changing behavior (tests still pass)
|
||||
4. REPEAT — Next behavior
|
||||
```
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
- **Writing too much test at once** — Test one behavior per cycle
|
||||
- **Writing implementation before the test fails** — The failing test is the spec
|
||||
- **Skipping refactor** — Technical debt accumulates in GREEN if you don't clean up
|
||||
- **Testing implementation details** — Test what it does, not how it does it
|
||||
|
||||
---
|
||||
|
||||
## Handling Legacy Code Without Tests
|
||||
|
||||
Legacy code (code without tests) requires a different entry point into TDD.
|
||||
|
||||
### Step 1: Characterization Tests
|
||||
|
||||
Before changing anything, write tests that capture current behavior:
|
||||
|
||||
```python
|
||||
# Characterization test — documents what the code DOES, not what it SHOULD do
|
||||
def test_calculate_total_current_behavior():
|
||||
result = calculate_total(items=[{"price": 10, "qty": 2}])
|
||||
assert result == 20 # Observed behavior, may or may not be correct
|
||||
```
|
||||
|
||||
### Step 2: Identify the Change Boundary
|
||||
|
||||
What's the smallest piece of code you need to change? Draw a boundary around it.
|
||||
|
||||
### Step 3: Add Seams
|
||||
|
||||
If the code is untestable (hard dependencies, global state), add seams:
|
||||
- Extract method
|
||||
- Inject dependencies
|
||||
- Wrap external calls
|
||||
|
||||
### Step 4: TDD the Change
|
||||
|
||||
Now that you have characterization tests protecting existing behavior and seams allowing isolation, use the normal RED-GREEN-REFACTOR cycle for your change.
|
||||
|
||||
### Step 5: Decide What to Keep
|
||||
|
||||
After the change, decide which characterization tests to keep:
|
||||
- **Keep** tests that document important behavior
|
||||
- **Replace** tests that covered the code you changed (your TDD tests are better)
|
||||
- **Remove** tests that only existed to enable your refactoring
|
||||
|
||||
---
|
||||
|
||||
## TDD by Test Type
|
||||
|
||||
| Test Type | TDD Approach |
|
||||
|-----------|-------------|
|
||||
| **Unit tests** | Standard RED-GREEN-REFACTOR. One behavior per cycle. |
|
||||
| **Integration tests** | Write the test against the integration boundary first. May need stubs for external services during RED phase. |
|
||||
| **API tests** | Define the request and expected response first. Implement handler to make it pass. |
|
||||
| **E2E tests** | Not typically TDD'd per-cycle. Write E2E tests for critical paths after unit/integration TDD. |
|
||||
|
||||
---
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
Before claiming a task is done with TDD:
|
||||
|
||||
- [ ] Every production function has at least one test that was written before the function
|
||||
- [ ] No test was written after the code it tests (except characterization tests for legacy code)
|
||||
- [ ] All tests pass
|
||||
- [ ] Code has been refactored after going GREEN
|
||||
- [ ] Tests verify behavior, not implementation
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: testing-anti-patterns
|
||||
description: >
|
||||
Trigger this skill whenever writing, reviewing, or debugging tests. Activate for keywords like "mock", "stub", "test helper", "flaky test", "test passes but bug ships", "false positive", "test coverage", or when tests seem unreliable. Also trigger when reviewing test code in PRs, when tests pass but production breaks, when someone proposes heavy mocking, or when test failures are intermittent. If any test smells wrong or feels like it is not actually verifying real behavior, this skill applies.
|
||||
---
|
||||
|
||||
# Testing Anti-Patterns
|
||||
|
||||
## Description
|
||||
|
||||
Common testing mistakes that create false confidence in test suites. Learn to recognize and avoid these patterns that make tests pass while failing to verify actual behavior.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Writing new tests
|
||||
@@ -11,6 +13,12 @@ Common testing mistakes that create false confidence in test suites. Learn to re
|
||||
- Debugging flaky or unreliable tests
|
||||
- When tests pass but bugs still ship
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Writing production code that is not test-related
|
||||
- Test framework configuration or setup (e.g., jest.config, vitest.config)
|
||||
- Performance testing or load testing scenarios
|
||||
|
||||
---
|
||||
|
||||
## The Five Anti-Patterns
|
||||
@@ -256,3 +264,9 @@ Mocks should never:
|
||||
- Create false confidence
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/test-driven-development` -- TDD naturally prevents most testing anti-patterns by requiring tests before implementation
|
||||
- `testing/pytest` -- Python-specific testing best practices that complement anti-pattern awareness
|
||||
- `testing/vitest` -- TypeScript/JavaScript-specific testing best practices that complement anti-pattern awareness
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Testing Anti-Pattern Catalog
|
||||
|
||||
Quick reference of common testing anti-patterns. Each entry includes: what it looks like, why it's a problem, and how to fix it.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Ice Cream Cone
|
||||
|
||||
**Symptom:** Most tests are E2E or integration tests. Few or no unit tests. The test pyramid is inverted.
|
||||
|
||||
**Root cause:** Tests were written after the feature, following user flows instead of testing isolated logic. Or the code is tightly coupled, making unit tests hard to write.
|
||||
|
||||
**Impact:** Test suite is slow, brittle, and expensive to maintain. Failures are hard to diagnose because tests cover too much at once.
|
||||
|
||||
**Fix:** Refactor toward the test pyramid. Extract business logic into pure functions and unit test them. Reserve E2E tests for critical user flows only. Target ratio: 70% unit, 20% integration, 10% E2E.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Mockery
|
||||
|
||||
**Symptom:** Tests mock so aggressively that they're testing the mocks, not the actual code. The thing under test has all its dependencies replaced.
|
||||
|
||||
**Root cause:** Code has too many dependencies, or the developer equates "isolated" with "mock everything."
|
||||
|
||||
**Impact:** Tests pass even when the real code is broken. Refactoring breaks every test because mocks are coupled to implementation details.
|
||||
|
||||
**Fix:** Only mock external boundaries (network, database, filesystem, clock). Use real implementations for internal collaborators. If you need too many mocks, the code has too many dependencies — refactor first.
|
||||
|
||||
---
|
||||
|
||||
## 3. The Slow Suite
|
||||
|
||||
**Symptom:** Test suite takes more than a few minutes to run. Developers skip tests locally and only run them in CI.
|
||||
|
||||
**Root cause:** Too many integration/E2E tests, tests that hit real databases or network, no test parallelization, expensive setup/teardown.
|
||||
|
||||
**Impact:** Developers stop running tests, bugs slip through, CI becomes a bottleneck.
|
||||
|
||||
**Fix:**
|
||||
- Profile the suite to find the slowest tests
|
||||
- Replace slow integration tests with fast unit tests where possible
|
||||
- Use in-memory databases for integration tests
|
||||
- Parallelize test execution
|
||||
- Target: unit suite under 30 seconds, full suite under 5 minutes
|
||||
|
||||
---
|
||||
|
||||
## 4. The Flaky Test
|
||||
|
||||
**Symptom:** Test passes most of the time but fails unpredictably. Re-running usually makes it pass.
|
||||
|
||||
**Root cause:** Race conditions, time-dependent logic, shared mutable state between tests, reliance on external services, non-deterministic ordering.
|
||||
|
||||
**Impact:** Team loses trust in tests. "Oh that one's flaky" becomes an excuse to ignore real failures. CI results become meaningless.
|
||||
|
||||
**Fix:**
|
||||
- Isolate the flaky test and run it 100 times to confirm flakiness
|
||||
- Check for: shared state, date/time usage, async timing, test ordering
|
||||
- Fix the root cause (don't just add retries)
|
||||
- Quarantine truly unfixable flaky tests while investigating
|
||||
|
||||
---
|
||||
|
||||
## 5. The Assertion-Free Test
|
||||
|
||||
**Symptom:** Test runs code but doesn't assert anything meaningful. It only checks that no exception was thrown.
|
||||
|
||||
```python
|
||||
# Bad — this tests nothing useful
|
||||
def test_process_data():
|
||||
process_data(sample_input) # No assertion
|
||||
```
|
||||
|
||||
**Root cause:** Test was written to hit a coverage target rather than verify behavior.
|
||||
|
||||
**Impact:** False sense of security. Code "has tests" but bugs go undetected.
|
||||
|
||||
**Fix:** Every test must assert on the outcome. Ask: "What behavior am I verifying?" If you can't answer, the test isn't testing anything.
|
||||
|
||||
```python
|
||||
# Good — asserts the actual behavior
|
||||
def test_process_data_calculates_total():
|
||||
result = process_data(sample_input)
|
||||
assert result.total == 42.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. The Copy-Paste Test
|
||||
|
||||
**Symptom:** Test file has blocks of nearly identical code repeated with minor variations. Tests are long and look like each other.
|
||||
|
||||
**Root cause:** Developer tested a new case by copying an existing test and tweaking values instead of extracting a pattern.
|
||||
|
||||
**Impact:** Maintenance nightmare. A change to the interface requires updating dozens of near-identical tests. Easy to introduce subtle bugs in copies.
|
||||
|
||||
**Fix:** Use parameterized tests for variations on the same behavior:
|
||||
|
||||
```python
|
||||
# Python — pytest.mark.parametrize
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
("hello", "HELLO"),
|
||||
("", ""),
|
||||
("123", "123"),
|
||||
])
|
||||
def test_to_upper(input, expected):
|
||||
assert to_upper(input) == expected
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — test.each (vitest/jest)
|
||||
test.each([
|
||||
["hello", "HELLO"],
|
||||
["", ""],
|
||||
["123", "123"],
|
||||
])("to_upper(%s) returns %s", (input, expected) => {
|
||||
expect(toUpper(input)).toBe(expected);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. The Time Bomb
|
||||
|
||||
**Symptom:** Test passes today but will fail on a future date, or fails on certain days/times (new year, month boundary, DST change, leap year).
|
||||
|
||||
**Root cause:** Test uses `Date.now()`, `new Date()`, or similar without controlling the clock. Assertions are hardcoded to specific dates.
|
||||
|
||||
**Impact:** Sudden failures on specific dates. CI breaks on January 1, or during DST transitions.
|
||||
|
||||
**Fix:** Always inject or mock the clock:
|
||||
|
||||
```python
|
||||
# Python — freeze time
|
||||
from freezegun import freeze_time
|
||||
|
||||
@freeze_time("2025-06-15T12:00:00Z")
|
||||
def test_expiry_check():
|
||||
assert is_expired(created_at="2025-06-14T12:00:00Z", ttl_hours=23)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — vitest fake timers
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
|
||||
expect(isExpired(createdAt, 23)).toBe(true);
|
||||
vi.useRealTimers();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. The Hidden Dependency
|
||||
|
||||
**Symptom:** Test passes locally but fails in CI, or fails when run in isolation but passes as part of the full suite.
|
||||
|
||||
**Root cause:** Test depends on external state that isn't set up by the test itself: a running database, a file on disk, an environment variable, output from a previous test, or global state modified by another test.
|
||||
|
||||
**Impact:** Tests are order-dependent, environment-dependent, and unreliable. Debugging failures requires understanding the entire test suite's execution order.
|
||||
|
||||
**Fix:**
|
||||
- Each test must set up and tear down its own state
|
||||
- Use fixtures (pytest fixtures, beforeEach/afterEach) for shared setup
|
||||
- Run tests in random order to catch hidden dependencies
|
||||
```bash
|
||||
pytest -p randomly # Python
|
||||
vitest --sequence.shuffle # vitest
|
||||
```
|
||||
- Never rely on test execution order
|
||||
|
||||
---
|
||||
|
||||
## Quick Decision Table
|
||||
|
||||
| Symptom | Likely Anti-Pattern | First Action |
|
||||
|---------|-------------------|--------------|
|
||||
| Tests are slow | Ice Cream Cone or Slow Suite | Profile, find the slowest tests |
|
||||
| Tests break on refactor | The Mockery | Reduce mocks, test behavior not implementation |
|
||||
| Tests fail randomly | Flaky Test | Isolate and run 100x |
|
||||
| High coverage but bugs slip through | Assertion-Free Test | Audit assertions in coverage-targeted tests |
|
||||
| Tests are hard to maintain | Copy-Paste Test | Extract parameterized tests |
|
||||
| Tests fail on certain dates | Time Bomb | Inject/mock the clock |
|
||||
| Tests fail in CI only | Hidden Dependency | Run locally in random order |
|
||||
| Tests pass but code is clearly broken | The Mockery or Assertion-Free | Check what's actually being asserted |
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: verification-before-completion
|
||||
description: >
|
||||
Trigger this skill whenever about to claim ANY work is complete, fixed, passing, or done. Activate whenever you are tempted to say "done", "fixed", "tests pass", "build succeeds", "deployed", or any completion claim. Also trigger before committing code, before creating PRs, before responding to the user that a task is finished, or when reviewing agent-produced work. This is mandatory -- NEVER claim completion without running verification commands and reading their output. Evidence before assertions, always.
|
||||
---
|
||||
|
||||
# Verification Before Completion
|
||||
|
||||
## Description
|
||||
|
||||
Mandatory verification process before claiming any task is complete. This skill enforces evidence-based completion rather than assumption-based claims.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before claiming tests pass
|
||||
@@ -12,6 +14,12 @@ Mandatory verification process before claiming any task is complete. This skill
|
||||
- Before marking any task complete
|
||||
- Before declaring success to user
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Mid-task progress updates where you are reporting interim status, not claiming completion
|
||||
- Research or exploration tasks where the output is knowledge, not code
|
||||
- Design or brainstorming phases where no verifiable artifacts have been produced yet
|
||||
|
||||
---
|
||||
|
||||
## The 5-Step Verification Process
|
||||
@@ -260,3 +268,9 @@ Use before claiming completion:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/test-driven-development` -- TDD naturally produces verifiable work; verification confirms the TDD cycle was followed correctly
|
||||
- `methodology/systematic-debugging` -- After debugging, verification ensures the fix actually resolves the issue
|
||||
- `methodology/requesting-code-review` -- Verification should happen before requesting review to avoid wasting reviewer time on broken code
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Verification Checklist
|
||||
|
||||
Use this checklist before claiming any work is complete. Copy it into your task, PR, or plan and fill in the specifics. Every box must be checked with evidence — not assumptions.
|
||||
|
||||
---
|
||||
|
||||
## Core Verification
|
||||
|
||||
### Tests
|
||||
|
||||
- [ ] **All existing tests pass**
|
||||
- Command: `___`
|
||||
- Output: [paste summary or confirm "all N tests passed"]
|
||||
|
||||
- [ ] **New tests added for new behavior**
|
||||
- Test files: `___`
|
||||
- Coverage of changed code: `___`%
|
||||
|
||||
- [ ] **Edge cases tested**
|
||||
- [ ] Empty/null inputs
|
||||
- [ ] Boundary values
|
||||
- [ ] Error conditions
|
||||
- [ ] Concurrent access (if applicable)
|
||||
|
||||
### Build
|
||||
|
||||
- [ ] **Build succeeds with no errors**
|
||||
- Command: `___`
|
||||
- Output: [confirm clean build]
|
||||
|
||||
- [ ] **No new warnings introduced**
|
||||
- Linter: `___`
|
||||
- Type checker: `___`
|
||||
|
||||
### Manual Verification
|
||||
|
||||
- [ ] **The specific change works as intended**
|
||||
- What I did: [exact steps taken]
|
||||
- What I observed: [exact result]
|
||||
- What was expected: [matches requirement]
|
||||
|
||||
- [ ] **Related functionality still works**
|
||||
- Checked: [list related features tested]
|
||||
|
||||
---
|
||||
|
||||
## Safety Checks
|
||||
|
||||
### No Unintended Side Effects
|
||||
|
||||
- [ ] **Reviewed the full diff** — No accidental changes to unrelated files
|
||||
```bash
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
- [ ] **No debug code left in place** — No `console.log`, `print()`, `debugger`, `TODO: remove`
|
||||
|
||||
- [ ] **No commented-out code** — Either the code is needed or it isn't
|
||||
|
||||
### Error Handling
|
||||
|
||||
- [ ] **Errors produce clear messages** — Not generic "something went wrong"
|
||||
- [ ] **Errors don't leak sensitive information** — No stack traces, internal paths, or credentials in user-facing errors
|
||||
- [ ] **Failure modes are graceful** — The system degrades rather than crashes
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] **No hardcoded secrets** — API keys, passwords, tokens are in environment variables
|
||||
- [ ] **Input is validated** — User input is checked before processing
|
||||
- [ ] **Output is encoded** — Rendered content is escaped appropriately
|
||||
- [ ] **No new `eval()` or dynamic code execution**
|
||||
- [ ] **Dependencies are from trusted sources** — No typosquatting, pinned versions
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] **Code is self-documenting** — Clear names, obvious structure
|
||||
- [ ] **Complex logic has comments** — Explaining WHY, not WHAT
|
||||
- [ ] **Public API changes are documented** — Updated docstrings, OpenAPI specs, README
|
||||
- [ ] **Breaking changes are called out** — In commit message, PR description, or changelog
|
||||
|
||||
---
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] **All acceptance criteria from the plan/ticket are met**
|
||||
- [ ] Criterion 1: ___
|
||||
- [ ] Criterion 2: ___
|
||||
|
||||
- [ ] **The change has been verified with actual commands, not just code reading**
|
||||
|
||||
- [ ] **Confidence level:** High / Medium / Low
|
||||
- If Medium or Low, explain what additional verification would increase confidence: ___
|
||||
|
||||
---
|
||||
|
||||
## Evidence Summary
|
||||
|
||||
Record the key evidence here for reviewers:
|
||||
|
||||
| Check | Evidence |
|
||||
|-------|---------|
|
||||
| Tests pass | [command + result] |
|
||||
| Build clean | [command + result] |
|
||||
| Manual test | [steps + result] |
|
||||
| No regressions | [how verified] |
|
||||
|
||||
---
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- **Do not check boxes without evidence.** "I think it works" is not verification.
|
||||
- **Run commands and observe output.** Paste or summarize actual results.
|
||||
- **N/A is acceptable** for items that genuinely don't apply, but add a note explaining why.
|
||||
- **If confidence is low**, list what would increase it and discuss with the team before marking complete.
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: writing-plans
|
||||
description: >
|
||||
Trigger this skill whenever a multi-step implementation task needs to be broken down before coding begins. Activate for keywords like "plan", "break down", "implementation steps", "task list", "how to implement", "write a plan", or when a feature spans multiple files or components. Also trigger when handing off work to another developer, when the user says "let's plan this out", or when a task is complex enough that jumping straight to code would be risky. If in doubt, plan first.
|
||||
---
|
||||
|
||||
# Writing Plans
|
||||
|
||||
## Description
|
||||
|
||||
Generate comprehensive implementation plans with bite-sized tasks for engineers with minimal codebase familiarity. This skill bridges design completion and engineering execution with detailed, actionable steps.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After brainstorming/design is complete
|
||||
@@ -11,6 +13,12 @@ Generate comprehensive implementation plans with bite-sized tasks for engineers
|
||||
- When handing off work to another developer
|
||||
- For complex features requiring structured approach
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Single-file changes where the path forward is obvious
|
||||
- Already has a plan to execute -- use `executing-plans` instead
|
||||
- Exploration or research tasks where the goal is learning, not building
|
||||
|
||||
---
|
||||
|
||||
## Plan Document Format
|
||||
@@ -275,3 +283,9 @@ mark user as verified in database.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `methodology/brainstorming` -- Use before writing plans when requirements are unclear or need exploration
|
||||
- `methodology/executing-plans` -- Use after writing a plan to execute it with subagent-driven development and review gates
|
||||
- `methodology/test-driven-development` -- Plans follow TDD principles; reference this skill for strict red-green-refactor enforcement
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# [Feature Name] Implementation Plan
|
||||
|
||||
> **Author:** [name]
|
||||
> **Date:** [date]
|
||||
> **Status:** Draft | In Review | Approved | In Progress | Complete
|
||||
> **Estimated Total:** [X hours/days]
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Problem Statement
|
||||
[One paragraph describing what problem this solves and why it matters now.]
|
||||
|
||||
### Background
|
||||
[Any relevant context: prior decisions, related features, technical debt involved.]
|
||||
|
||||
### Goals
|
||||
- [Primary goal]
|
||||
- [Secondary goal]
|
||||
|
||||
### Non-Goals
|
||||
- [What this plan explicitly does NOT address]
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: [Name] (estimated: Xmin)
|
||||
|
||||
**Description:** [What this task accomplishes and why it's needed.]
|
||||
|
||||
**Files to modify:**
|
||||
- `path/to/file.ts` — [what changes]
|
||||
- `path/to/other.py` — [what changes]
|
||||
|
||||
**New files:**
|
||||
- `path/to/new-file.ts` — [purpose]
|
||||
|
||||
**Changes:**
|
||||
1. [Specific change with enough detail to implement without ambiguity]
|
||||
2. [Next change]
|
||||
|
||||
**Tests:**
|
||||
- [ ] [Test description — what behavior is verified]
|
||||
- [ ] [Edge case test]
|
||||
|
||||
**Verification:**
|
||||
- [ ] [How to verify this task is complete — command to run, behavior to observe]
|
||||
|
||||
---
|
||||
|
||||
### Task 2: [Name] (estimated: Xmin)
|
||||
|
||||
**Description:** [What this task accomplishes.]
|
||||
|
||||
**Dependencies:** Task 1 (requires [specific output])
|
||||
|
||||
**Files to modify:**
|
||||
- `path/to/file.ts` — [what changes]
|
||||
|
||||
**Changes:**
|
||||
1. [Specific change]
|
||||
2. [Next change]
|
||||
|
||||
**Tests:**
|
||||
- [ ] [Test description]
|
||||
|
||||
**Verification:**
|
||||
- [ ] [Verification step]
|
||||
|
||||
---
|
||||
|
||||
### Task 3: [Name] (estimated: Xmin)
|
||||
|
||||
[Repeat the same structure. Add as many tasks as needed.]
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Internal Dependencies
|
||||
| Task | Depends On | Reason |
|
||||
|------|-----------|--------|
|
||||
| Task 2 | Task 1 | [Why] |
|
||||
|
||||
### External Dependencies
|
||||
- [ ] [External service, API key, environment setup, etc.]
|
||||
- [ ] [Approval or decision needed from someone]
|
||||
|
||||
### Parallel Work
|
||||
[Which tasks can be done simultaneously? Group them.]
|
||||
|
||||
- **Group A (independent):** Task 1, Task 3
|
||||
- **Group B (requires Group A):** Task 2, Task 4
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| [What could go wrong] | Low/Med/High | Low/Med/High | [How to prevent or handle it] |
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Checks
|
||||
```bash
|
||||
# Run full test suite
|
||||
[test command]
|
||||
|
||||
# Run type checks
|
||||
[type check command]
|
||||
|
||||
# Run linter
|
||||
[lint command]
|
||||
```
|
||||
|
||||
### Manual Checks
|
||||
- [ ] [Specific scenario to test manually]
|
||||
- [ ] [Edge case to verify by hand]
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] [Criterion 1 — ties back to Goals section]
|
||||
- [ ] [Criterion 2]
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
[Any additional context, open questions, or decisions to revisit.]
|
||||
|
||||
---
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
**To use this template:**
|
||||
|
||||
1. Copy this file and rename it: `plan-[feature-name].md`
|
||||
2. Fill in all sections. If a section doesn't apply, write "N/A" rather than deleting it
|
||||
3. For **standard plans**: tasks should be 15-60 minutes each
|
||||
4. For **detailed plans** (`--detailed`): tasks should be 2-5 minutes with exact code snippets
|
||||
5. Every task must have at least one verification step
|
||||
6. Every task must list the specific files it touches
|
||||
7. Remove these usage instructions from your copy
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
name: token-efficient
|
||||
description: >
|
||||
Use this skill when optimizing token usage, reducing response verbosity, or working in high-volume development sessions. Trigger for any mention of token savings, cost optimization, concise output, compressed responses, or the --format=concise/ultra flags. Also applies during repetitive tasks, quick iterations, simple clear requests, or when the user activates token-efficient mode. This is a cross-cutting optimization that applies to all other skills.
|
||||
---
|
||||
|
||||
# Token Optimization
|
||||
|
||||
## Description
|
||||
|
||||
Patterns and techniques for reducing token usage while maintaining response quality. Achieve 30-70% cost savings through strategic output compression.
|
||||
|
||||
## When to Use
|
||||
|
||||
- High-volume development sessions
|
||||
@@ -12,6 +14,12 @@ Patterns and techniques for reducing token usage while maintaining response qual
|
||||
- Cost-sensitive projects
|
||||
- Quick iterations
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Learning or educational contexts where verbose explanations help the user understand concepts
|
||||
- Debugging complex issues where detailed analysis and step-by-step reasoning matter
|
||||
- Security reviews or architecture discussions where thoroughness is more important than brevity
|
||||
|
||||
---
|
||||
|
||||
## Compression Levels
|
||||
@@ -196,3 +204,7 @@ Monthly savings: ~7.5M tokens
|
||||
- Review needed → Full output
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- All skills — this is a cross-cutting optimization that can be combined with any other skill to reduce token usage while maintaining response quality
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
---
|
||||
name: api-client
|
||||
description: >
|
||||
HTTP client patterns for consuming REST APIs in Python and TypeScript. Use this skill when setting up axios, fetch, or httpx clients, implementing request interceptors, adding retry logic, handling authentication tokens, or generating type-safe API clients from OpenAPI specs. Trigger whenever code makes HTTP requests, integrates with external APIs, or needs robust error handling for network calls.
|
||||
---
|
||||
|
||||
# API Client Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up HTTP clients (axios, fetch, httpx) for consuming external REST APIs
|
||||
- Adding request/response interceptors for logging, auth tokens, or error transformation
|
||||
- Implementing retry logic with exponential backoff for transient failures
|
||||
- Generating type-safe API clients from OpenAPI or Swagger specifications
|
||||
- Managing authentication tokens (Bearer injection, auto-refresh on 401)
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Internal function calls or in-process service communication that does not cross a network boundary
|
||||
- Database queries -- use an ORM or database driver instead
|
||||
- WebSocket or real-time streaming connections -- use dedicated WebSocket client patterns
|
||||
- GraphQL clients -- use a GraphQL-specific library such as Apollo or urql
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. HTTP Client Setup
|
||||
|
||||
Create a single, pre-configured client instance per external service. Never scatter raw `fetch()` or `requests.get()` calls throughout the codebase.
|
||||
|
||||
**Python -- httpx (async)**
|
||||
|
||||
```python
|
||||
# BAD - creating a new client on every call, no shared config
|
||||
import httpx
|
||||
|
||||
async def get_user(user_id: int):
|
||||
response = httpx.get(f"https://api.example.com/users/{user_id}") # no timeout, no auth
|
||||
return response.json()
|
||||
|
||||
# GOOD - shared async client with base URL, timeout, and headers
|
||||
import httpx
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "myapp/1.0",
|
||||
},
|
||||
timeout=httpx.Timeout(10.0, connect=5.0),
|
||||
)
|
||||
|
||||
async def get_user(self, user_id: int) -> dict:
|
||||
response = await self._client.get(f"/users/{user_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
```
|
||||
|
||||
**Python -- httpx (sync)**
|
||||
|
||||
```python
|
||||
# GOOD - synchronous client for scripts, CLIs, or sync frameworks
|
||||
import httpx
|
||||
|
||||
client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=httpx.Timeout(10.0, connect=5.0),
|
||||
)
|
||||
|
||||
def get_user(user_id: int) -> dict:
|
||||
response = client.get(f"/users/{user_id}")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
```
|
||||
|
||||
**TypeScript -- fetch wrapper**
|
||||
|
||||
```typescript
|
||||
// BAD - raw fetch with no error handling or shared config
|
||||
const res = await fetch("https://api.example.com/users/1");
|
||||
const data = await res.json();
|
||||
|
||||
// GOOD - typed fetch wrapper with defaults
|
||||
interface RequestConfig extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private defaultHeaders: Record<string, string> = {},
|
||||
) {}
|
||||
|
||||
private async request<T>(path: string, config: RequestConfig = {}): Promise<T> {
|
||||
const url = new URL(path, this.baseUrl);
|
||||
if (config.params) {
|
||||
Object.entries(config.params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
...config,
|
||||
headers: { ...this.defaultHeaders, ...config.headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await response.text());
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
get<T>(path: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(path, { ...config, method: "GET" });
|
||||
}
|
||||
|
||||
post<T>(path: string, body: unknown, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(path, {
|
||||
...config,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json", ...config?.headers },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const api = new ApiClient("https://api.example.com", {
|
||||
Authorization: `Bearer ${process.env.API_KEY}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
```
|
||||
|
||||
**TypeScript -- axios instance**
|
||||
|
||||
```typescript
|
||||
// GOOD - axios instance with shared config
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "https://api.example.com",
|
||||
timeout: 10_000,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// All requests share the same base URL, timeout, and headers
|
||||
const user = await api.get<User>("/users/1");
|
||||
const created = await api.post<User>("/users", { name: "Alice" });
|
||||
```
|
||||
|
||||
### 2. Request/Response Interceptors
|
||||
|
||||
Interceptors centralize cross-cutting concerns so individual API calls stay clean.
|
||||
|
||||
**Axios interceptors (TypeScript)**
|
||||
|
||||
```typescript
|
||||
// GOOD - auth token injection
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = tokenStore.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// GOOD - request/response logging
|
||||
api.interceptors.request.use((config) => {
|
||||
console.debug(`[API] ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
console.debug(`[API] ${response.status} ${response.config.url}`);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error(`[API] Error ${error.response?.status} ${error.config?.url}`);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// GOOD - error transformation to application-specific errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
throw new ApiError(status, data?.message ?? "Unknown API error", data?.code);
|
||||
}
|
||||
if (error.code === "ECONNABORTED") {
|
||||
throw new TimeoutError(`Request timed out: ${error.config?.url}`);
|
||||
}
|
||||
throw new NetworkError("Network connection failed");
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**httpx event hooks (Python)**
|
||||
|
||||
```python
|
||||
# GOOD - logging and error hooks on httpx client
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("api_client")
|
||||
|
||||
async def log_request(request: httpx.Request):
|
||||
logger.debug(f"Request: {request.method} {request.url}")
|
||||
|
||||
async def log_response(response: httpx.Response):
|
||||
logger.debug(f"Response: {response.status_code} {response.url}")
|
||||
|
||||
async def raise_on_error(response: httpx.Response):
|
||||
if response.status_code >= 400:
|
||||
await response.aread()
|
||||
logger.error(f"API error {response.status_code}: {response.text[:200]}")
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://api.example.com",
|
||||
event_hooks={
|
||||
"request": [log_request],
|
||||
"response": [log_response, raise_on_error],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Retry Logic
|
||||
|
||||
Retry transient failures with exponential backoff. Never retry non-idempotent requests blindly.
|
||||
|
||||
**Python -- tenacity**
|
||||
|
||||
```python
|
||||
# GOOD - retry with exponential backoff for specific status codes
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
|
||||
import httpx
|
||||
|
||||
def is_retryable(exc: BaseException) -> bool:
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return exc.response.status_code in (429, 500, 502, 503, 504)
|
||||
if isinstance(exc, (httpx.ConnectTimeout, httpx.ReadTimeout)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@retry(
|
||||
retry=retry_if_exception(is_retryable),
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||
reraise=True,
|
||||
)
|
||||
async def fetch_with_retry(client: httpx.AsyncClient, url: str) -> dict:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
```
|
||||
|
||||
**Python -- manual retry with Retry-After**
|
||||
|
||||
```python
|
||||
# GOOD - respects Retry-After header from rate-limited responses
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
async def fetch_respecting_rate_limit(
|
||||
client: httpx.AsyncClient,
|
||||
url: str,
|
||||
max_retries: int = 3,
|
||||
) -> httpx.Response:
|
||||
for attempt in range(max_retries):
|
||||
response = await client.get(url)
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", 2 ** attempt))
|
||||
await asyncio.sleep(min(retry_after, 60))
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return response
|
||||
raise httpx.HTTPStatusError(
|
||||
"Max retries exceeded", request=response.request, response=response
|
||||
)
|
||||
```
|
||||
|
||||
**TypeScript -- custom retry wrapper**
|
||||
|
||||
```typescript
|
||||
// GOOD - generic retry wrapper with exponential backoff
|
||||
interface RetryOptions {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
retryableStatuses: number[];
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY: RetryOptions = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 10000,
|
||||
retryableStatuses: [429, 500, 502, 503, 504],
|
||||
};
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: Partial<RetryOptions> = {},
|
||||
): Promise<T> {
|
||||
const opts = { ...DEFAULT_RETRY, ...options };
|
||||
|
||||
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const status = error instanceof ApiError ? error.status : 0;
|
||||
const isRetryable = opts.retryableStatuses.includes(status);
|
||||
const isLastAttempt = attempt === opts.maxRetries;
|
||||
|
||||
if (!isRetryable || isLastAttempt) throw error;
|
||||
|
||||
const delay = Math.min(opts.baseDelay * 2 ** attempt, opts.maxDelay);
|
||||
const jitter = delay * (0.5 + Math.random() * 0.5);
|
||||
await new Promise((resolve) => setTimeout(resolve, jitter));
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
// Usage
|
||||
const user = await withRetry(() => api.get<User>("/users/1"));
|
||||
```
|
||||
|
||||
### 4. Type-Safe Clients from OpenAPI
|
||||
|
||||
Generate clients from OpenAPI specs to eliminate hand-written API types and reduce drift between backend and frontend.
|
||||
|
||||
**TypeScript -- openapi-typescript + openapi-fetch**
|
||||
|
||||
```bash
|
||||
# Generate types from an OpenAPI spec
|
||||
npx openapi-typescript https://api.example.com/openapi.json -o src/api/schema.d.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
// GOOD - fully typed client from generated schema
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "./schema";
|
||||
|
||||
const api = createClient<paths>({
|
||||
baseUrl: "https://api.example.com",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// Paths, methods, params, and response types are all inferred
|
||||
const { data, error } = await api.GET("/users/{id}", {
|
||||
params: { path: { id: 42 } },
|
||||
});
|
||||
// data is typed as the 200 response schema
|
||||
// error is typed as the error response schema
|
||||
```
|
||||
|
||||
**TypeScript -- zodios (Zod + axios)**
|
||||
|
||||
```typescript
|
||||
// GOOD - runtime-validated API client with Zod schemas
|
||||
import { makeApi, Zodios } from "@zodios/core";
|
||||
import { z } from "zod";
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const api = makeApi([
|
||||
{
|
||||
method: "get",
|
||||
path: "/users/:id",
|
||||
alias: "getUser",
|
||||
response: userSchema,
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
path: "/users",
|
||||
alias: "createUser",
|
||||
parameters: [{ name: "body", type: "Body", schema: userSchema.omit({ id: true }) }],
|
||||
response: userSchema,
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new Zodios("https://api.example.com", api);
|
||||
|
||||
// Fully typed and runtime validated
|
||||
const user = await client.getUser({ params: { id: 42 } });
|
||||
```
|
||||
|
||||
**Python -- datamodel-code-generator**
|
||||
|
||||
```bash
|
||||
# Generate Pydantic models from an OpenAPI spec
|
||||
pip install datamodel-code-generator
|
||||
datamodel-codegen --input openapi.json --output src/api/models.py --input-file-type openapi
|
||||
```
|
||||
|
||||
```python
|
||||
# Generated models are Pydantic BaseModel classes
|
||||
from api.models import User, CreateUserRequest
|
||||
|
||||
# Use them with httpx for typed requests
|
||||
async def create_user(client: httpx.AsyncClient, payload: CreateUserRequest) -> User:
|
||||
response = await client.post("/users", json=payload.model_dump())
|
||||
response.raise_for_status()
|
||||
return User.model_validate(response.json())
|
||||
```
|
||||
|
||||
### 5. Authentication
|
||||
|
||||
Centralize auth token management so every request gets the right credentials without per-call boilerplate.
|
||||
|
||||
**Bearer token injection (axios)**
|
||||
|
||||
```typescript
|
||||
// GOOD - automatic token refresh on 401
|
||||
let isRefreshing = false;
|
||||
let failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) reject(error);
|
||||
else resolve(token!);
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (error.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return api(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post("/auth/refresh", {
|
||||
refreshToken: tokenStore.getRefreshToken(),
|
||||
});
|
||||
tokenStore.setAccessToken(data.accessToken);
|
||||
processQueue(null, data.accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
tokenStore.clear();
|
||||
window.location.href = "/login";
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Python -- httpx auth class**
|
||||
|
||||
```python
|
||||
# GOOD - custom auth flow with automatic refresh
|
||||
import httpx
|
||||
import time
|
||||
|
||||
class BearerAuth(httpx.Auth):
|
||||
def __init__(self, token_url: str, client_id: str, client_secret: str):
|
||||
self.token_url = token_url
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self._access_token: str | None = None
|
||||
self._expires_at: float = 0
|
||||
|
||||
def auth_flow(self, request: httpx.Request):
|
||||
if self._is_expired():
|
||||
token_response = yield self._build_token_request()
|
||||
token_response.raise_for_status()
|
||||
data = token_response.json()
|
||||
self._access_token = data["access_token"]
|
||||
self._expires_at = time.time() + data["expires_in"] - 30 # 30s buffer
|
||||
|
||||
request.headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
yield request
|
||||
|
||||
def _is_expired(self) -> bool:
|
||||
return self._access_token is None or time.time() >= self._expires_at
|
||||
|
||||
def _build_token_request(self) -> httpx.Request:
|
||||
return httpx.Request(
|
||||
"POST",
|
||||
self.token_url,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
# Usage
|
||||
auth = BearerAuth(
|
||||
token_url="https://auth.example.com/token",
|
||||
client_id=os.environ["CLIENT_ID"],
|
||||
client_secret=os.environ["CLIENT_SECRET"],
|
||||
)
|
||||
client = httpx.AsyncClient(base_url="https://api.example.com", auth=auth)
|
||||
```
|
||||
|
||||
**API key via custom header**
|
||||
|
||||
```python
|
||||
# GOOD - API key as header, loaded from environment
|
||||
import os
|
||||
import httpx
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
base_url="https://api.example.com",
|
||||
headers={"X-API-Key": os.environ["EXAMPLE_API_KEY"]},
|
||||
)
|
||||
```
|
||||
|
||||
### 6. Error Handling
|
||||
|
||||
Distinguish between network errors, timeout errors, and API-level errors. Never swallow exceptions silently.
|
||||
|
||||
**TypeScript -- structured error classes**
|
||||
|
||||
```typescript
|
||||
// GOOD - error hierarchy for API calls
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: string,
|
||||
public readonly code?: string,
|
||||
) {
|
||||
super(`API error ${status}: ${body.slice(0, 200)}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
get isClientError(): boolean {
|
||||
return this.status >= 400 && this.status < 500;
|
||||
}
|
||||
|
||||
get isServerError(): boolean {
|
||||
return this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkError extends Error {
|
||||
constructor(message: string, public readonly cause?: Error) {
|
||||
super(message);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
class TimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Timeout and cancellation with AbortController**
|
||||
|
||||
```typescript
|
||||
// GOOD - cancel requests that take too long or on component unmount
|
||||
async function fetchWithTimeout<T>(url: string, timeoutMs: number = 5000): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
if (!response.ok) throw new ApiError(response.status, await response.text());
|
||||
return response.json() as Promise<T>;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new TimeoutError(`Request to ${url} timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// React -- cancel on unmount
|
||||
function useApiData(url: string) {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(url, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
.then(setData)
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") console.error(err);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [url]);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**Python -- structured error handling**
|
||||
|
||||
```python
|
||||
# GOOD - catch specific httpx exceptions
|
||||
import httpx
|
||||
|
||||
async def safe_api_call(client: httpx.AsyncClient, path: str) -> dict | None:
|
||||
try:
|
||||
response = await client.get(path)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.ConnectTimeout:
|
||||
logger.error(f"Connection timeout: {path}")
|
||||
raise
|
||||
except httpx.ReadTimeout:
|
||||
logger.error(f"Read timeout: {path}")
|
||||
raise
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error(f"HTTP {exc.response.status_code} from {path}: {exc.response.text[:200]}")
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Connection failed: {path}")
|
||||
raise
|
||||
```
|
||||
|
||||
### 7. Rate Limiting (Client-Side)
|
||||
|
||||
Respect API rate limits to avoid being throttled or banned.
|
||||
|
||||
**TypeScript -- request queue with concurrency control**
|
||||
|
||||
```typescript
|
||||
// GOOD - throttle outgoing requests to stay under rate limits
|
||||
class RequestQueue {
|
||||
private queue: Array<() => Promise<void>> = [];
|
||||
private running = 0;
|
||||
|
||||
constructor(
|
||||
private maxConcurrent: number = 5,
|
||||
private minDelay: number = 100,
|
||||
) {}
|
||||
|
||||
async add<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push(async () => {
|
||||
try {
|
||||
resolve(await fn());
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
this.process();
|
||||
});
|
||||
}
|
||||
|
||||
private async process(): Promise<void> {
|
||||
if (this.running >= this.maxConcurrent || this.queue.length === 0) return;
|
||||
|
||||
this.running++;
|
||||
const task = this.queue.shift()!;
|
||||
await task();
|
||||
await new Promise((resolve) => setTimeout(resolve, this.minDelay));
|
||||
this.running--;
|
||||
this.process();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage -- at most 5 concurrent requests, 100ms between each
|
||||
const queue = new RequestQueue(5, 100);
|
||||
const users = await Promise.all(
|
||||
userIds.map((id) => queue.add(() => api.get<User>(`/users/${id}`))),
|
||||
);
|
||||
```
|
||||
|
||||
**Python -- asyncio semaphore throttle**
|
||||
|
||||
```python
|
||||
# GOOD - limit concurrent requests with a semaphore
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
class ThrottledClient:
|
||||
def __init__(self, client: httpx.AsyncClient, max_concurrent: int = 5):
|
||||
self._client = client
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def get(self, url: str, **kwargs) -> httpx.Response:
|
||||
async with self._semaphore:
|
||||
response = await self._client.get(url, **kwargs)
|
||||
# Respect Retry-After if rate limited
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", "5"))
|
||||
await asyncio.sleep(retry_after)
|
||||
response = await self._client.get(url, **kwargs)
|
||||
return response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Create one client instance per external service.** Share it across your application. Instantiating new clients on every call wastes connections and prevents connection pooling.
|
||||
|
||||
2. **Always set explicit timeouts.** A missing timeout means a stuck request can hang your entire application. Set both connect and read timeouts. Five to ten seconds is a sensible default for most APIs.
|
||||
|
||||
3. **Centralize error handling in interceptors or middleware.** Do not scatter try/catch blocks around every individual API call. Use interceptors to transform HTTP errors into typed application errors.
|
||||
|
||||
4. **Add jitter to retry backoff.** Pure exponential backoff causes thundering herd problems when many clients retry simultaneously. Add random jitter to spread retries across time.
|
||||
|
||||
5. **Never retry non-idempotent requests automatically.** POST requests that create resources can cause duplicates if retried blindly. Only retry GET, HEAD, and PUT (idempotent methods) by default.
|
||||
|
||||
6. **Generate types from OpenAPI specs instead of writing them by hand.** This eliminates drift between backend and frontend types and reduces maintenance effort.
|
||||
|
||||
7. **Log request and response metadata, not bodies.** Log method, URL, status code, and duration. Avoid logging request or response bodies by default -- they may contain sensitive data like tokens or PII.
|
||||
|
||||
8. **Close clients when the application shuts down.** In Python, use `async with` or call `aclose()`. In Node.js, use AbortController or connection pool shutdown. Leaked connections cause resource exhaustion.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Not closing httpx clients.** Failing to call `aclose()` leaks connections and file descriptors. Use `async with httpx.AsyncClient() as client:` or register a shutdown handler.
|
||||
|
||||
2. **Storing API keys in source code.** Always load secrets from environment variables or a secret manager. Never commit API keys, tokens, or credentials to version control.
|
||||
|
||||
3. **Ignoring response status codes.** `fetch()` does not throw on 4xx/5xx -- you must check `response.ok` or call `.raise_for_status()`. This is the most common fetch mistake.
|
||||
|
||||
4. **Retrying 400-level errors.** Client errors (400, 401, 403, 404, 422) are not transient. Retrying them wastes time and load. Only retry on 429 (rate limit) and 5xx (server errors).
|
||||
|
||||
5. **Building URLs with string concatenation.** Concatenating user input into URLs creates injection risks and encoding bugs. Use `URL` constructor (JS) or `httpx.URL` (Python) for safe URL building.
|
||||
|
||||
6. **Not cancelling requests on component unmount.** In React, fetch requests that complete after unmount cause state-update-on-unmounted-component warnings and potential memory leaks. Always use AbortController with a cleanup function.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `api/openapi` - OpenAPI spec design and documentation
|
||||
- `patterns/error-handling` - Structured error handling patterns across the stack
|
||||
- `patterns/authentication` - Authentication token management and OAuth2 flows
|
||||
- `patterns/caching` - HTTP caching, conditional requests, and cache invalidation
|
||||
- `patterns/logging` - Logging HTTP requests and responses
|
||||
@@ -0,0 +1,250 @@
|
||||
# HTTP Client Patterns Quick Reference
|
||||
|
||||
## Python HTTP Clients
|
||||
|
||||
| Feature | httpx | requests | aiohttp |
|
||||
|---------|-------|----------|---------|
|
||||
| Async support | Yes (native) | No | Yes (async-only) |
|
||||
| HTTP/2 | Yes | No | No |
|
||||
| Connection pooling | Yes | Yes (Session) | Yes |
|
||||
| Streaming | Yes | Yes | Yes |
|
||||
| Type hints | Yes | Partial | Partial |
|
||||
| Timeout default | No timeout | No timeout | 5 min |
|
||||
| Recommended for | Modern projects | Simple scripts | Legacy async |
|
||||
|
||||
### httpx Setup (Recommended)
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
# Sync client with defaults
|
||||
client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
timeout=httpx.Timeout(10.0, connect=5.0),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
# Async client
|
||||
async_client = httpx.AsyncClient(
|
||||
base_url="https://api.example.com",
|
||||
timeout=10.0,
|
||||
http2=True,
|
||||
)
|
||||
|
||||
# Always use as context manager (ensures cleanup)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("/users")
|
||||
```
|
||||
|
||||
### httpx Retry Pattern
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||
retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)),
|
||||
)
|
||||
async def fetch_with_retry(client: httpx.AsyncClient, url: str) -> dict:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
```
|
||||
|
||||
### httpx Interceptor Pattern (Event Hooks)
|
||||
|
||||
```python
|
||||
def log_request(request: httpx.Request):
|
||||
print(f"--> {request.method} {request.url}")
|
||||
|
||||
def log_response(response: httpx.Response):
|
||||
print(f"<-- {response.status_code} {response.url} ({response.elapsed.total_seconds():.2f}s)")
|
||||
|
||||
def raise_on_error(response: httpx.Response):
|
||||
response.raise_for_status()
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
event_hooks={
|
||||
"request": [log_request],
|
||||
"response": [log_response, raise_on_error],
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JavaScript/TypeScript HTTP Clients
|
||||
|
||||
| Feature | fetch (native) | axios | ky |
|
||||
|---------|---------------|-------|-----|
|
||||
| Built-in | Yes | No (~13KB) | No (~3KB) |
|
||||
| Interceptors | No (manual) | Yes | Yes (hooks) |
|
||||
| Auto JSON | No (manual `.json()`) | Yes | Yes |
|
||||
| Timeout | AbortSignal.timeout() | Built-in | Built-in |
|
||||
| Retry | No | No (plugin) | Built-in |
|
||||
| Cancel | AbortController | CancelToken (deprecated) / AbortController | AbortController |
|
||||
| Streaming | Yes (ReadableStream) | Node only | Yes |
|
||||
| Recommended for | Simple needs, SSR | Large existing codebases | Modern projects |
|
||||
|
||||
### fetch Wrapper Pattern
|
||||
|
||||
```typescript
|
||||
class ApiClient {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private defaultHeaders: Record<string, string> = {}
|
||||
) {}
|
||||
|
||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...this.defaultHeaders, ...init?.headers },
|
||||
signal: init?.signal ?? AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new ApiError(response.status, body, url);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
get<T>(path: string, signal?: AbortSignal) {
|
||||
return this.request<T>(path, { signal });
|
||||
}
|
||||
|
||||
post<T>(path: string, data: unknown) {
|
||||
return this.request<T>(path, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
put<T>(path: string, data: unknown) {
|
||||
return this.request<T>(path, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
delete<T>(path: string) {
|
||||
return this.request<T>(path, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ky Setup (Recommended for JS)
|
||||
|
||||
```typescript
|
||||
import ky from "ky";
|
||||
|
||||
const api = ky.create({
|
||||
prefixUrl: "https://api.example.com",
|
||||
timeout: 10_000,
|
||||
retry: { limit: 3, methods: ["get"], statusCodes: [408, 429, 500, 502, 503] },
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
request.headers.set("Authorization", `Bearer ${getToken()}`);
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
async (_request, _options, response) => {
|
||||
if (response.status === 401) {
|
||||
await refreshToken();
|
||||
// ky will retry automatically
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
const users = await api.get("users").json<User[]>();
|
||||
const user = await api.post("users", { json: { name: "Alice" } }).json<User>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Typed Error Class
|
||||
|
||||
```typescript
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: string,
|
||||
public url: string,
|
||||
) {
|
||||
super(`HTTP ${status} from ${url}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
get isRetryable(): boolean {
|
||||
return this.status >= 500 || this.status === 429;
|
||||
}
|
||||
|
||||
get isAuthError(): boolean {
|
||||
return this.status === 401;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status: int, body: str, url: str):
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.url = url
|
||||
super().__init__(f"HTTP {status} from {url}")
|
||||
|
||||
@property
|
||||
def is_retryable(self) -> bool:
|
||||
return self.status >= 500 or self.status == 429
|
||||
```
|
||||
|
||||
### Error Handling Decision
|
||||
|
||||
| Status | Action |
|
||||
|--------|--------|
|
||||
| 400 | Don't retry. Fix the request. Log validation details. |
|
||||
| 401 | Refresh token and retry once. If still 401, re-authenticate. |
|
||||
| 403 | Don't retry. User lacks permission. |
|
||||
| 404 | Don't retry. Resource doesn't exist. |
|
||||
| 408, 429 | Retry with backoff. Respect `Retry-After` header. |
|
||||
| 500-503 | Retry with exponential backoff (max 3 attempts). |
|
||||
| Network error | Retry with backoff. Check connectivity. |
|
||||
| Timeout | Retry with longer timeout or fail fast. |
|
||||
|
||||
---
|
||||
|
||||
## Auth Token Refresh Pattern
|
||||
|
||||
```typescript
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
async function getValidToken(): Promise<string> {
|
||||
const token = getStoredToken();
|
||||
if (!isExpired(token)) return token;
|
||||
|
||||
// Deduplicate concurrent refresh calls
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshToken().finally(() => { refreshPromise = null; });
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup Checklist
|
||||
|
||||
| Concern | Implementation |
|
||||
|---------|---------------|
|
||||
| Base URL | Configure once in client factory |
|
||||
| Auth header | Interceptor / hook (not per-request) |
|
||||
| Timeout | Always set (10s default, 30s for uploads) |
|
||||
| Retry | 3 attempts, exponential backoff, only GET + idempotent |
|
||||
| Error handling | Typed errors, status-based decisions |
|
||||
| Cancellation | AbortController (pass signal to all requests) |
|
||||
| Logging | Log method, URL, status, duration (not bodies in prod) |
|
||||
| Content-Type | Set `application/json` as default, override for file uploads |
|
||||
@@ -0,0 +1,859 @@
|
||||
---
|
||||
name: authentication
|
||||
description: >
|
||||
Authentication and authorization patterns for web applications. Use this skill when implementing JWT tokens, OAuth2 flows, session management, role-based access control (RBAC), password hashing, or multi-factor authentication. Trigger whenever code handles login, signup, token refresh, protected routes, permission checks, or user identity verification. Also applies to middleware auth guards and API key authentication.
|
||||
---
|
||||
|
||||
# Authentication & Authorization Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Implementing login, signup, or logout flows for web applications
|
||||
- Setting up JWT access tokens and refresh token rotation
|
||||
- Building OAuth2 integrations (Google, GitHub, or custom providers)
|
||||
- Adding role-based or permission-based access control to API endpoints
|
||||
- Protecting routes with middleware guards in Next.js, Express, or FastAPI
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Public-only APIs that require no identity verification (e.g., open data endpoints)
|
||||
- Internal services secured entirely at the network level (VPC, service mesh mTLS) with no application-layer auth
|
||||
- Static sites with no user-specific content or server-side logic
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. JWT Patterns
|
||||
|
||||
Use short-lived access tokens for API authorization and long-lived refresh tokens for session continuity. Never store access tokens in localStorage.
|
||||
|
||||
**Token structure and signing**
|
||||
|
||||
```python
|
||||
# BAD - long-lived token, weak secret, symmetric HS256 with hardcoded key
|
||||
import jwt
|
||||
|
||||
token = jwt.encode(
|
||||
{"user_id": 1, "exp": datetime.utcnow() + timedelta(days=365)},
|
||||
"secret123",
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
# GOOD - short-lived access token, strong secret from env, RS256 for production
|
||||
import jwt
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
|
||||
REFRESH_TOKEN_EXPIRY = timedelta(days=7)
|
||||
|
||||
def create_access_token(user_id: int, role: str) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": str(user_id),
|
||||
"role": role,
|
||||
"iat": now,
|
||||
"exp": now + ACCESS_TOKEN_EXPIRY,
|
||||
"type": "access",
|
||||
},
|
||||
os.environ["JWT_PRIVATE_KEY"],
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": str(user_id),
|
||||
"iat": now,
|
||||
"exp": now + REFRESH_TOKEN_EXPIRY,
|
||||
"type": "refresh",
|
||||
"jti": str(uuid.uuid4()), # unique ID for revocation
|
||||
},
|
||||
os.environ["JWT_PRIVATE_KEY"],
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
return jwt.decode(
|
||||
token,
|
||||
os.environ["JWT_PUBLIC_KEY"],
|
||||
algorithms=["RS256"],
|
||||
options={"require": ["sub", "exp", "type"]},
|
||||
)
|
||||
```
|
||||
|
||||
**TypeScript -- JWT creation and verification**
|
||||
|
||||
```typescript
|
||||
// GOOD - short-lived tokens with jose (works in Node.js and edge runtimes)
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
const privateKey = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||
|
||||
async function createAccessToken(userId: string, role: string): Promise<string> {
|
||||
return new SignJWT({ role, type: "access" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("15m")
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
async function createRefreshToken(userId: string): Promise<string> {
|
||||
return new SignJWT({ type: "refresh", jti: crypto.randomUUID() })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
async function verifyToken(token: string): Promise<{ sub: string; role?: string }> {
|
||||
const { payload } = await jwtVerify(token, privateKey, {
|
||||
algorithms: ["HS256"],
|
||||
requiredClaims: ["sub", "exp", "type"],
|
||||
});
|
||||
return payload as { sub: string; role?: string };
|
||||
}
|
||||
```
|
||||
|
||||
**Secure cookie delivery**
|
||||
|
||||
```python
|
||||
# GOOD - deliver tokens in httpOnly cookies, not in response body
|
||||
from fastapi import Response
|
||||
|
||||
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
httponly=True, # not accessible via JavaScript
|
||||
secure=True, # HTTPS only
|
||||
samesite="lax", # CSRF protection
|
||||
max_age=int(ACCESS_TOKEN_EXPIRY.total_seconds()),
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=int(REFRESH_TOKEN_EXPIRY.total_seconds()),
|
||||
path="/auth/refresh", # only sent to refresh endpoint
|
||||
)
|
||||
|
||||
def clear_auth_cookies(response: Response):
|
||||
response.delete_cookie("access_token", path="/")
|
||||
response.delete_cookie("refresh_token", path="/auth/refresh")
|
||||
```
|
||||
|
||||
### 2. OAuth2 Flows
|
||||
|
||||
**Authorization code flow with PKCE (for SPAs and mobile apps)**
|
||||
|
||||
```typescript
|
||||
// GOOD - PKCE flow for single-page applications (no client secret exposed)
|
||||
import crypto from "crypto";
|
||||
|
||||
// Step 1: Generate code verifier and challenge
|
||||
function generatePKCE(): { verifier: string; challenge: string } {
|
||||
const verifier = crypto.randomBytes(32).toString("base64url");
|
||||
const challenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(verifier)
|
||||
.digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
// Step 2: Redirect user to authorization server
|
||||
function getAuthorizationUrl(codeChallenge: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||
redirect_uri: process.env.OAUTH_REDIRECT_URI!,
|
||||
scope: "openid profile email",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state: crypto.randomBytes(16).toString("hex"),
|
||||
});
|
||||
return `https://auth.example.com/authorize?${params}`;
|
||||
}
|
||||
|
||||
// Step 3: Exchange code for tokens on the callback
|
||||
async function exchangeCode(code: string, codeVerifier: string): Promise<TokenSet> {
|
||||
const response = await fetch("https://auth.example.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: process.env.OAUTH_REDIRECT_URI!,
|
||||
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<TokenSet>;
|
||||
}
|
||||
```
|
||||
|
||||
**Client credentials flow (server-to-server)**
|
||||
|
||||
```python
|
||||
# GOOD - machine-to-machine auth with client credentials
|
||||
import httpx
|
||||
import os
|
||||
|
||||
async def get_service_token() -> str:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://auth.example.com/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": os.environ["SERVICE_CLIENT_ID"],
|
||||
"client_secret": os.environ["SERVICE_CLIENT_SECRET"],
|
||||
"scope": "read:data write:data",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["access_token"]
|
||||
```
|
||||
|
||||
**Python -- OAuth2 callback handling with FastAPI**
|
||||
|
||||
```python
|
||||
# GOOD - secure callback handler with state validation
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
import httpx
|
||||
|
||||
router = APIRouter(prefix="/auth")
|
||||
|
||||
@router.get("/callback")
|
||||
async def oauth_callback(request: Request, code: str, state: str):
|
||||
# Validate state to prevent CSRF
|
||||
stored_state = request.session.get("oauth_state")
|
||||
if not stored_state or state != stored_state:
|
||||
raise HTTPException(status_code=400, detail="Invalid state parameter")
|
||||
|
||||
code_verifier = request.session.pop("code_verifier")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
"https://auth.example.com/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": os.environ["OAUTH_REDIRECT_URI"],
|
||||
"client_id": os.environ["OAUTH_CLIENT_ID"],
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
tokens = token_response.json()
|
||||
|
||||
# Create local session from OAuth tokens
|
||||
user_info = await fetch_user_info(tokens["access_token"])
|
||||
user = await get_or_create_user(user_info)
|
||||
response = RedirectResponse(url="/dashboard")
|
||||
set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id))
|
||||
return response
|
||||
```
|
||||
|
||||
### 3. Password Security
|
||||
|
||||
**Python -- argon2 (preferred) and bcrypt**
|
||||
|
||||
```python
|
||||
# BAD - MD5 or SHA-256 alone is trivially crackable
|
||||
import hashlib
|
||||
hashed = hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
# GOOD - argon2id (recommended by OWASP)
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
ph = PasswordHasher(
|
||||
time_cost=3, # iterations
|
||||
memory_cost=65536, # 64 MB
|
||||
parallelism=4,
|
||||
)
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return ph.hash(password)
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return ph.verify(hashed, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
# GOOD - bcrypt alternative
|
||||
from passlib.hash import bcrypt
|
||||
|
||||
hashed = bcrypt.using(rounds=12).hash(password)
|
||||
is_valid = bcrypt.verify(password, hashed)
|
||||
```
|
||||
|
||||
**TypeScript -- bcrypt**
|
||||
|
||||
```typescript
|
||||
// GOOD - bcrypt with sufficient cost factor
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
const SALT_ROUNDS = 12; // ~250ms on modern hardware, adjust as needed
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
```
|
||||
|
||||
**Password validation rules**
|
||||
|
||||
```typescript
|
||||
// GOOD - enforce minimum complexity without overly restrictive rules
|
||||
import { z } from "zod";
|
||||
|
||||
const PasswordSchema = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must not exceed 128 characters")
|
||||
.regex(/[a-z]/, "Must contain at least one lowercase letter")
|
||||
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
|
||||
.regex(/[0-9]/, "Must contain at least one digit");
|
||||
|
||||
// Python equivalent with Pydantic
|
||||
from pydantic import BaseModel, field_validator
|
||||
import re
|
||||
|
||||
class PasswordInput(BaseModel):
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
if len(v) > 128:
|
||||
raise ValueError("Password must not exceed 128 characters")
|
||||
if not re.search(r"[a-z]", v):
|
||||
raise ValueError("Must contain at least one lowercase letter")
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Must contain at least one uppercase letter")
|
||||
if not re.search(r"[0-9]", v):
|
||||
raise ValueError("Must contain at least one digit")
|
||||
return v
|
||||
```
|
||||
|
||||
**Timing-safe comparison**
|
||||
|
||||
```python
|
||||
# BAD - standard equality leaks timing information
|
||||
if stored_token == provided_token:
|
||||
grant_access()
|
||||
|
||||
# GOOD - constant-time comparison prevents timing attacks
|
||||
import hmac
|
||||
|
||||
def safe_compare(a: str, b: str) -> bool:
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
```
|
||||
|
||||
```typescript
|
||||
// GOOD - timing-safe comparison in Node.js
|
||||
import crypto from "crypto";
|
||||
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Session Management
|
||||
|
||||
**Cookie-based sessions with Redis store (Express)**
|
||||
|
||||
```typescript
|
||||
// GOOD - server-side sessions stored in Redis
|
||||
import session from "express-session";
|
||||
import RedisStore from "connect-redis";
|
||||
import { createClient } from "redis";
|
||||
|
||||
const redisClient = createClient({ url: process.env.REDIS_URL });
|
||||
await redisClient.connect();
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new RedisStore({ client: redisClient, prefix: "sess:" }),
|
||||
secret: process.env.SESSION_SECRET!,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 30 * 60 * 1000, // 30 minutes
|
||||
},
|
||||
name: "sid", // custom name -- do not use default "connect.sid"
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
**Session fixation prevention**
|
||||
|
||||
```typescript
|
||||
// GOOD - regenerate session ID after login to prevent fixation
|
||||
app.post("/login", async (req, res) => {
|
||||
const user = await authenticateUser(req.body.email, req.body.password);
|
||||
if (!user) return res.status(401).json({ error: "Invalid credentials" });
|
||||
|
||||
// Regenerate session to prevent fixation attacks
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: "Session error" });
|
||||
req.session.userId = user.id;
|
||||
req.session.role = user.role;
|
||||
req.session.loginAt = Date.now();
|
||||
res.json({ user: { id: user.id, name: user.name } });
|
||||
});
|
||||
});
|
||||
|
||||
// GOOD - clear session fully on logout
|
||||
app.post("/logout", (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return res.status(500).json({ error: "Logout failed" });
|
||||
res.clearCookie("sid");
|
||||
res.json({ message: "Logged out" });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Python -- FastAPI session with Redis**
|
||||
|
||||
```python
|
||||
# GOOD - server-side session using Redis
|
||||
from fastapi import Request, Response
|
||||
import redis.asyncio as redis
|
||||
import uuid
|
||||
import json
|
||||
|
||||
redis_client = redis.from_url(os.environ["REDIS_URL"])
|
||||
|
||||
SESSION_TTL = 1800 # 30 minutes
|
||||
|
||||
async def create_session(response: Response, data: dict) -> str:
|
||||
session_id = str(uuid.uuid4())
|
||||
await redis_client.setex(f"session:{session_id}", SESSION_TTL, json.dumps(data))
|
||||
response.set_cookie(
|
||||
key="session_id",
|
||||
value=session_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=SESSION_TTL,
|
||||
)
|
||||
return session_id
|
||||
|
||||
async def get_session(request: Request) -> dict | None:
|
||||
session_id = request.cookies.get("session_id")
|
||||
if not session_id:
|
||||
return None
|
||||
data = await redis_client.get(f"session:{session_id}")
|
||||
if data:
|
||||
# Refresh TTL on access (sliding expiry)
|
||||
await redis_client.expire(f"session:{session_id}", SESSION_TTL)
|
||||
return json.loads(data)
|
||||
return None
|
||||
|
||||
async def destroy_session(request: Request, response: Response):
|
||||
session_id = request.cookies.get("session_id")
|
||||
if session_id:
|
||||
await redis_client.delete(f"session:{session_id}")
|
||||
response.delete_cookie("session_id")
|
||||
```
|
||||
|
||||
### 5. RBAC Patterns
|
||||
|
||||
**Role and permission model**
|
||||
|
||||
```python
|
||||
# GOOD - permission-based RBAC, not just role names
|
||||
from enum import Enum
|
||||
|
||||
class Permission(str, Enum):
|
||||
READ_POSTS = "read:posts"
|
||||
WRITE_POSTS = "write:posts"
|
||||
DELETE_POSTS = "delete:posts"
|
||||
MANAGE_USERS = "manage:users"
|
||||
ADMIN_ALL = "admin:all"
|
||||
|
||||
ROLE_PERMISSIONS: dict[str, set[Permission]] = {
|
||||
"viewer": {Permission.READ_POSTS},
|
||||
"editor": {Permission.READ_POSTS, Permission.WRITE_POSTS},
|
||||
"admin": {Permission.READ_POSTS, Permission.WRITE_POSTS, Permission.DELETE_POSTS, Permission.MANAGE_USERS},
|
||||
"superadmin": {Permission.ADMIN_ALL},
|
||||
}
|
||||
|
||||
def has_permission(user_role: str, required: Permission) -> bool:
|
||||
permissions = ROLE_PERMISSIONS.get(user_role, set())
|
||||
return required in permissions or Permission.ADMIN_ALL in permissions
|
||||
```
|
||||
|
||||
**FastAPI -- dependency-based authorization**
|
||||
|
||||
```python
|
||||
# GOOD - reusable auth dependency with permission check
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
|
||||
async def get_current_user(request: Request) -> User:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user = await user_repo.get(int(payload["sub"]))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
def require_permission(permission: Permission):
|
||||
async def checker(user: User = Depends(get_current_user)):
|
||||
if not has_permission(user.role, permission):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return user
|
||||
return checker
|
||||
|
||||
@app.delete("/posts/{post_id}")
|
||||
async def delete_post(
|
||||
post_id: int,
|
||||
user: User = Depends(require_permission(Permission.DELETE_POSTS)),
|
||||
):
|
||||
post = await post_repo.get(post_id)
|
||||
if not post:
|
||||
raise HTTPException(status_code=404)
|
||||
await post_repo.delete(post_id)
|
||||
return {"deleted": True}
|
||||
```
|
||||
|
||||
**Express -- middleware-based authorization**
|
||||
|
||||
```typescript
|
||||
// GOOD - composable permission middleware
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
function requirePermission(...required: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const hasAll = required.every(
|
||||
(perm) => user.permissions.includes(perm) || user.permissions.includes("admin:all"),
|
||||
);
|
||||
|
||||
if (!hasAll) {
|
||||
return res.status(403).json({ error: "Insufficient permissions" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
app.delete("/posts/:id", requirePermission("delete:posts"), deletePostHandler);
|
||||
app.get("/admin/users", requirePermission("manage:users"), listUsersHandler);
|
||||
```
|
||||
|
||||
### 6. Protected Routes
|
||||
|
||||
**Next.js middleware (App Router)**
|
||||
|
||||
```typescript
|
||||
// middleware.ts -- runs on every matching request at the edge
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
const PUBLIC_PATHS = ["/", "/login", "/signup", "/api/auth"];
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Allow public paths
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const token = request.cookies.get("access_token")?.value;
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
|
||||
await jwtVerify(token, secret);
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
// Token invalid or expired -- redirect to login
|
||||
const response = NextResponse.redirect(new URL("/login", request.url));
|
||||
response.cookies.delete("access_token");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
**FastAPI -- dependency injection guard**
|
||||
|
||||
```python
|
||||
# GOOD - protect entire router with a dependency
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
protected_router = APIRouter(
|
||||
prefix="/api/v1",
|
||||
dependencies=[Depends(get_current_user)], # all routes require auth
|
||||
)
|
||||
|
||||
@protected_router.get("/profile")
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
return {"id": user.id, "name": user.name, "role": user.role}
|
||||
|
||||
@protected_router.get("/admin/stats")
|
||||
async def admin_stats(user: User = Depends(require_permission(Permission.ADMIN_ALL))):
|
||||
return await compute_stats()
|
||||
|
||||
# Mount protected and public routers separately
|
||||
app.include_router(auth_router) # /auth/* -- public
|
||||
app.include_router(protected_router) # /api/v1/* -- requires auth
|
||||
```
|
||||
|
||||
**Express -- route-level guard**
|
||||
|
||||
```typescript
|
||||
// GOOD - auth middleware applied selectively
|
||||
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const token = req.cookies.access_token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!);
|
||||
req.user = payload as AuthUser;
|
||||
next();
|
||||
} catch {
|
||||
res.clearCookie("access_token");
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
|
||||
// Public routes
|
||||
app.post("/auth/login", loginHandler);
|
||||
app.post("/auth/register", registerHandler);
|
||||
|
||||
// Protected routes
|
||||
app.use("/api", requireAuth);
|
||||
app.get("/api/profile", profileHandler);
|
||||
app.get("/api/posts", listPostsHandler);
|
||||
```
|
||||
|
||||
### 7. Multi-Factor Authentication (TOTP)
|
||||
|
||||
**Python -- pyotp**
|
||||
|
||||
```python
|
||||
# GOOD - TOTP setup and verification
|
||||
import pyotp
|
||||
|
||||
def generate_totp_secret() -> str:
|
||||
"""Generate a new TOTP secret for a user."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
def get_totp_provisioning_uri(secret: str, user_email: str, issuer: str = "MyApp") -> str:
|
||||
"""Generate a QR code URI for authenticator app setup."""
|
||||
return pyotp.totp.TOTP(secret).provisioning_uri(
|
||||
name=user_email,
|
||||
issuer_name=issuer,
|
||||
)
|
||||
|
||||
def verify_totp(secret: str, code: str) -> bool:
|
||||
"""Verify a TOTP code with a 30-second window tolerance."""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=1) # allows +/- 30 seconds
|
||||
```
|
||||
|
||||
**TypeScript -- otplib**
|
||||
|
||||
```typescript
|
||||
// GOOD - TOTP with otplib
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
function generateTotpSecret(): string {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
function getTotpUri(secret: string, email: string): string {
|
||||
return authenticator.keyuri(email, "MyApp", secret);
|
||||
}
|
||||
|
||||
function verifyTotp(secret: string, code: string): boolean {
|
||||
return authenticator.check(code, secret);
|
||||
}
|
||||
```
|
||||
|
||||
**Backup codes**
|
||||
|
||||
```python
|
||||
# GOOD - generate one-time backup codes for MFA recovery
|
||||
import secrets
|
||||
|
||||
def generate_backup_codes(count: int = 10) -> list[str]:
|
||||
"""Generate single-use backup codes. Store hashed, show once."""
|
||||
return [secrets.token_hex(4).upper() for _ in range(count)]
|
||||
# Example output: ["A1B2C3D4", "E5F6A7B8", ...]
|
||||
|
||||
# Store hashed backup codes in the database
|
||||
from argon2 import PasswordHasher
|
||||
ph = PasswordHasher()
|
||||
|
||||
async def store_backup_codes(user_id: int, codes: list[str]):
|
||||
hashed_codes = [ph.hash(code) for code in codes]
|
||||
await db.execute(
|
||||
"UPDATE users SET backup_codes = $1 WHERE id = $2",
|
||||
[json.dumps(hashed_codes), user_id],
|
||||
)
|
||||
|
||||
async def verify_backup_code(user_id: int, code: str) -> bool:
|
||||
user = await db.get(User, user_id)
|
||||
hashed_codes = json.loads(user.backup_codes)
|
||||
for i, hashed in enumerate(hashed_codes):
|
||||
try:
|
||||
if ph.verify(hashed, code):
|
||||
# Remove used code (single-use)
|
||||
hashed_codes.pop(i)
|
||||
await db.execute(
|
||||
"UPDATE users SET backup_codes = $1 WHERE id = $2",
|
||||
[json.dumps(hashed_codes), user_id],
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
```
|
||||
|
||||
**MFA login flow**
|
||||
|
||||
```python
|
||||
# GOOD - two-step login: credentials first, then MFA
|
||||
@router.post("/auth/login")
|
||||
async def login(credentials: LoginRequest, response: Response):
|
||||
user = await authenticate_user(credentials.email, credentials.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if user.mfa_enabled:
|
||||
# Issue a short-lived MFA challenge token (not a full session)
|
||||
mfa_token = create_mfa_challenge_token(user.id)
|
||||
return {"requires_mfa": True, "mfa_token": mfa_token}
|
||||
|
||||
# No MFA -- issue full tokens
|
||||
set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id))
|
||||
return {"user": {"id": user.id, "name": user.name}}
|
||||
|
||||
@router.post("/auth/mfa/verify")
|
||||
async def verify_mfa(payload: MfaVerifyRequest, response: Response):
|
||||
# Validate the MFA challenge token
|
||||
challenge = decode_mfa_challenge_token(payload.mfa_token)
|
||||
user = await user_repo.get(challenge["user_id"])
|
||||
|
||||
if not verify_totp(user.totp_secret, payload.code):
|
||||
# Also check backup codes as fallback
|
||||
if not await verify_backup_code(user.id, payload.code):
|
||||
raise HTTPException(status_code=401, detail="Invalid MFA code")
|
||||
|
||||
set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id))
|
||||
return {"user": {"id": user.id, "name": user.name}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use short-lived access tokens (5-15 minutes) paired with refresh tokens (7-30 days).** Short access tokens limit the damage window if a token is compromised. Refresh tokens allow seamless re-authentication without re-entering credentials.
|
||||
|
||||
2. **Deliver tokens in httpOnly, secure, sameSite cookies.** Never return tokens in JSON response bodies for browser-based apps. httpOnly prevents XSS from reading the token, secure ensures HTTPS-only transmission, and sameSite=lax mitigates CSRF.
|
||||
|
||||
3. **Hash passwords with argon2id or bcrypt, never with MD5, SHA-1, or SHA-256 alone.** Adaptive hashing functions include a work factor that makes brute-force attacks computationally expensive. Increase the cost factor as hardware improves.
|
||||
|
||||
4. **Regenerate session IDs after login.** Session fixation attacks exploit predictable or reused session IDs. Always issue a new session ID after successful authentication.
|
||||
|
||||
5. **Validate the state parameter in OAuth2 callbacks.** The state parameter prevents CSRF attacks during the authorization flow. Generate a cryptographically random value, store it in the session, and verify it when the callback arrives.
|
||||
|
||||
6. **Implement token revocation for refresh tokens.** Store refresh token JTIs (unique identifiers) in a database or Redis. On logout, revoke all active refresh tokens for the user. Check the revocation list on every refresh attempt.
|
||||
|
||||
7. **Apply the principle of least privilege in RBAC.** Default new users to the most restrictive role. Grant permissions explicitly, not implicitly. Check permissions at the object level, not just the role level.
|
||||
|
||||
8. **Rate-limit authentication endpoints aggressively.** Apply strict rate limits (5-10 attempts per minute) on login, registration, password reset, and MFA verification endpoints. Use both IP-based and account-based limiting.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Storing JWTs in localStorage.** Any XSS vulnerability can steal the token. Use httpOnly cookies instead. If you must use localStorage (e.g., for native apps), pair it with strict CSP and regular XSS auditing.
|
||||
|
||||
2. **Not validating the token type claim.** Without a `type` field in the JWT payload, a refresh token could be used as an access token and vice versa. Always include and verify a `type` claim.
|
||||
|
||||
3. **Using symmetric keys (HS256) with shared secrets across services.** If multiple services verify tokens, any service that can verify can also forge tokens. Use asymmetric keys (RS256/ES256) so only the auth service holds the private key.
|
||||
|
||||
4. **Checking authentication but not authorization.** A valid token proves identity but not permission. Always verify that the authenticated user has the specific permission required for the requested action.
|
||||
|
||||
5. **Returning different error messages for "user not found" vs "wrong password."** This leaks information about which accounts exist (user enumeration). Return a generic "Invalid credentials" message for both cases.
|
||||
|
||||
6. **Not setting absolute session timeouts.** Sliding expiry alone means a session can live forever with continuous activity. Set an absolute maximum lifetime (e.g., 8 hours) in addition to idle timeout (e.g., 30 minutes).
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Access tokens expire within 15 minutes, refresh tokens within 7-30 days
|
||||
- [ ] Tokens delivered in httpOnly, secure, sameSite cookies (not localStorage)
|
||||
- [ ] Passwords hashed with argon2id or bcrypt (12+ rounds)
|
||||
- [ ] Session IDs regenerated after successful login
|
||||
- [ ] OAuth2 state parameter validated on callback
|
||||
- [ ] Refresh tokens have unique JTI and can be revoked
|
||||
- [ ] RBAC permissions checked at object level, not just role level
|
||||
- [ ] Login, registration, and password reset endpoints are rate-limited
|
||||
- [ ] Error messages do not distinguish between "user not found" and "wrong password"
|
||||
- [ ] MFA backup codes are hashed and single-use
|
||||
- [ ] TOTP secrets are stored encrypted at rest
|
||||
- [ ] Absolute session timeout enforced alongside sliding expiry
|
||||
- [ ] CSRF protection in place for all state-changing endpoints
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `security/owasp` - OWASP Top 10 security patterns and secure coding practices
|
||||
- `patterns/api-client` - HTTP client patterns including auth token injection and refresh flows
|
||||
- `frameworks/fastapi` - FastAPI-specific dependency injection and middleware patterns
|
||||
- `frameworks/nextjs` - Next.js middleware and route protection patterns
|
||||
@@ -0,0 +1,237 @@
|
||||
# Authentication Flows Quick Reference
|
||||
|
||||
## Decision Tree: Which Auth Method?
|
||||
|
||||
```
|
||||
What are you building?
|
||||
│
|
||||
├─ Server-rendered web app (Next.js, Django, Rails)?
|
||||
│ └─> SESSION-BASED AUTH
|
||||
│ - HttpOnly cookies, server-side session store
|
||||
│ - Simple, secure, well-understood
|
||||
│
|
||||
├─ SPA + API backend (same domain)?
|
||||
│ └─> SESSION-BASED AUTH (still preferred)
|
||||
│ - Cookies sent automatically, no JS token handling
|
||||
│ - Or: JWT in HttpOnly cookie (not localStorage)
|
||||
│
|
||||
├─ SPA + API backend (different domain)?
|
||||
│ └─> JWT with access + refresh tokens
|
||||
│ - Access token: short-lived, in memory
|
||||
│ - Refresh token: HttpOnly cookie
|
||||
│
|
||||
├─ Mobile app?
|
||||
│ └─> JWT with access + refresh tokens
|
||||
│ - Store refresh token in secure storage (Keychain/Keystore)
|
||||
│ - Access token in memory
|
||||
│
|
||||
├─ Third-party API access?
|
||||
│ └─> OAuth2 + API keys
|
||||
│ - OAuth2 for user-delegated access
|
||||
│ - API keys for server-to-server
|
||||
│
|
||||
├─ Machine-to-machine (service-to-service)?
|
||||
│ └─> API KEYS or OAuth2 Client Credentials
|
||||
│ - API key: simple, rotate regularly
|
||||
│ - Client Credentials: when you need scoped access
|
||||
│
|
||||
└─ CLI tool?
|
||||
└─> OAuth2 Device Code Flow or API key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JWT Access + Refresh Token Flow
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Client │ │ Auth │ │ API │
|
||||
│ (SPA/ │ │ Server │ │ Server │
|
||||
│ Mobile) │ │ │ │ │
|
||||
└────┬──────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ 1. POST /auth/login │ │
|
||||
│ { email, password } │ │
|
||||
│─────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 2. 200 OK │ │
|
||||
│ { access_token (15min) } │ │
|
||||
│ Set-Cookie: refresh_token │ │
|
||||
│ (HttpOnly, Secure, 7d) │ │
|
||||
│<─────────────────────────────│ │
|
||||
│ │ │
|
||||
│ 3. GET /api/data │ │
|
||||
│ Authorization: Bearer <access_token> │
|
||||
│─────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 4. 200 OK { data } │ │
|
||||
│<─────────────────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ ── access_token expires ── │ │
|
||||
│ │ │
|
||||
│ 5. GET /api/data │ │
|
||||
│ Authorization: Bearer <expired_token> │
|
||||
│─────────────────────────────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 6. 401 { code: "token_expired" } │
|
||||
│<─────────────────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
│ 7. POST /auth/refresh │ │
|
||||
│ Cookie: refresh_token │ │
|
||||
│─────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 8. 200 { new access_token } │ │
|
||||
│ Set-Cookie: new refresh │ │
|
||||
│<─────────────────────────────│ │
|
||||
│ │ │
|
||||
│ 9. Retry original request with new access_token │
|
||||
│─────────────────────────────────────────────────────────────>│
|
||||
```
|
||||
|
||||
### JWT Best Practices
|
||||
|
||||
| Concern | Recommendation |
|
||||
|---------|---------------|
|
||||
| Access token lifetime | 5-15 minutes |
|
||||
| Refresh token lifetime | 7-30 days |
|
||||
| Access token storage | Memory only (JS variable) |
|
||||
| Refresh token storage | HttpOnly Secure cookie (web) or Keychain (mobile) |
|
||||
| Token rotation | Issue new refresh token on each refresh |
|
||||
| Revocation | Maintain server-side deny list for refresh tokens |
|
||||
| Algorithm | RS256 (asymmetric) for distributed systems, HS256 for single server |
|
||||
| Claims | Minimal: sub, exp, iat, roles/permissions |
|
||||
|
||||
---
|
||||
|
||||
## OAuth2 Authorization Code + PKCE Flow
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Client │ │ Auth │ │ Resource │
|
||||
│ (Browser)│ │ Provider│ │ Server │
|
||||
└────┬──────┘ └────┬─────┘ └────┬──────┘
|
||||
│ │ │
|
||||
│ 1. Generate: │ │
|
||||
│ code_verifier (random 43-128 chars) │
|
||||
│ code_challenge = SHA256(code_verifier) │
|
||||
│ │ │
|
||||
│ 2. Redirect to: │ │
|
||||
│ /authorize? │ │
|
||||
│ response_type=code │
|
||||
│ client_id=xxx │ │
|
||||
│ redirect_uri=https://app/callback │
|
||||
│ scope=openid profile │
|
||||
│ state=random_csrf_token │
|
||||
│ code_challenge=xxx │
|
||||
│ code_challenge_method=S256 │
|
||||
│────────────────────>│ │
|
||||
│ │ │
|
||||
│ 3. User logs in │ │
|
||||
│ and consents │ │
|
||||
│ │ │
|
||||
│ 4. Redirect to: │ │
|
||||
│ /callback?code=AUTH_CODE&state=xxx │
|
||||
│<────────────────────│ │
|
||||
│ │ │
|
||||
│ 5. Verify state matches │
|
||||
│ │ │
|
||||
│ 6. POST /token │ │
|
||||
│ { grant_type=authorization_code, │
|
||||
│ code=AUTH_CODE, │ │
|
||||
│ redirect_uri=...,│ │
|
||||
│ code_verifier=ORIGINAL_VERIFIER } │
|
||||
│────────────────────>│ │
|
||||
│ │ │
|
||||
│ 7. { access_token, │ │
|
||||
│ refresh_token,│ │
|
||||
│ id_token } │ │
|
||||
│<────────────────────│ │
|
||||
│ │ │
|
||||
│ 8. GET /api/resource │
|
||||
│ Authorization: Bearer <access_token> │
|
||||
│───────────────────────────────────────────>│
|
||||
```
|
||||
|
||||
### PKCE Key Points
|
||||
|
||||
| Term | Purpose |
|
||||
|------|---------|
|
||||
| `code_verifier` | Random string (43-128 chars), stored client-side |
|
||||
| `code_challenge` | `BASE64URL(SHA256(code_verifier))` sent in auth request |
|
||||
| `state` | CSRF protection (random, verify on callback) |
|
||||
| PKCE purpose | Prevents auth code interception (no client secret needed) |
|
||||
|
||||
---
|
||||
|
||||
## Session-Based Auth Flow
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────────┐
|
||||
│ Browser │ │ Server │
|
||||
│ │ │ (session store) │
|
||||
└────┬──────┘ └────┬──────────────┘
|
||||
│ │
|
||||
│ 1. POST /login │
|
||||
│ { email, password } │
|
||||
│─────────────────────────────>│
|
||||
│ │ 2. Verify credentials
|
||||
│ │ 3. Create session in store
|
||||
│ │ (Redis/DB/memory)
|
||||
│ 4. Set-Cookie: │
|
||||
│ session_id=abc123; │
|
||||
│ HttpOnly; Secure; │
|
||||
│ SameSite=Lax; Path=/ │
|
||||
│<─────────────────────────────│
|
||||
│ │
|
||||
│ 5. GET /dashboard │
|
||||
│ Cookie: session_id=abc123 │ (sent automatically)
|
||||
│─────────────────────────────>│
|
||||
│ │ 6. Lookup session abc123
|
||||
│ │ 7. Attach user to request
|
||||
│ 8. 200 OK │
|
||||
│<─────────────────────────────│
|
||||
│ │
|
||||
│ 9. POST /logout │
|
||||
│─────────────────────────────>│
|
||||
│ │ 10. Delete session from store
|
||||
│ 11. Clear cookie │
|
||||
│<─────────────────────────────│
|
||||
```
|
||||
|
||||
### Session Cookie Settings
|
||||
|
||||
| Attribute | Value | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| `HttpOnly` | Always | Prevent JS access (XSS protection) |
|
||||
| `Secure` | Always in prod | Only send over HTTPS |
|
||||
| `SameSite` | `Lax` (default) | CSRF protection (allows top-level navigation) |
|
||||
| `SameSite` | `Strict` | Stronger CSRF (breaks external link login) |
|
||||
| `Path` | `/` | Cookie scope |
|
||||
| `Max-Age` | 86400-2592000 | Session duration (1-30 days) |
|
||||
| `Domain` | Omit or explicit | Cookie scope to domain |
|
||||
|
||||
---
|
||||
|
||||
## Comparison: JWT vs Sessions vs API Keys
|
||||
|
||||
| Aspect | JWT | Sessions | API Keys |
|
||||
|--------|-----|----------|----------|
|
||||
| Stateless | Yes (no server lookup) | No (server-side store) | No (server-side lookup) |
|
||||
| Revocation | Hard (needs deny list) | Easy (delete session) | Easy (delete key) |
|
||||
| Scaling | Easy (no shared state) | Needs shared session store | Needs shared key store |
|
||||
| Security | Token theft = access until expiry | Session theft = access until revoked | Key theft = access until rotated |
|
||||
| Best for | Distributed APIs, mobile | Web apps, SSR | Service-to-service, CLI |
|
||||
| CSRF risk | Low (if not in cookie) | Needs CSRF tokens | N/A (header-based) |
|
||||
| XSS risk | High if in localStorage | Low (HttpOnly cookie) | Low (server-side only) |
|
||||
|
||||
### API Key Best Practices
|
||||
|
||||
| Practice | Details |
|
||||
|----------|---------|
|
||||
| Prefix keys | `sk_live_`, `pk_test_` (identify type/env) |
|
||||
| Hash before storing | Store `SHA256(key)`, never plaintext |
|
||||
| Scope keys | Limit permissions per key |
|
||||
| Set expiry | Auto-expire, require rotation |
|
||||
| Rate limit per key | Prevent abuse |
|
||||
| Transmit in header | `Authorization: Bearer <key>` or `X-API-Key: <key>` |
|
||||
| Never in URL | Query params end up in logs and browser history |
|
||||
@@ -0,0 +1,786 @@
|
||||
---
|
||||
name: caching
|
||||
description: >
|
||||
Caching patterns for web applications, APIs, and data layers. Use this skill when implementing memoization, HTTP cache headers, Redis caching, CDN configuration, or in-memory caches. Trigger whenever code deals with Cache-Control headers, ETags, functools.lru_cache, React useMemo, TanStack Query cache, or any caching strategy. Also applies to cache invalidation, TTL policies, and cache-aside patterns.
|
||||
---
|
||||
|
||||
# Caching
|
||||
|
||||
## When to Use
|
||||
|
||||
- Adding memoization to expensive pure functions or computations
|
||||
- Setting HTTP cache headers on API responses or static assets
|
||||
- Implementing a Redis or in-memory cache layer for database queries
|
||||
- Configuring CDN caching rules for edge distribution
|
||||
- Designing cache invalidation strategies for data that changes
|
||||
- Optimizing Next.js data fetching with built-in caching primitives
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Data that must always be real-time and consistent (financial transactions, inventory counts during checkout) — caching introduces staleness
|
||||
- Write-heavy workloads where invalidation cost exceeds the read savings
|
||||
- Small datasets that are fast to compute or fetch — the overhead of cache management is not worth it
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Memoization
|
||||
|
||||
Memoization caches the result of a function call based on its arguments. Use it for pure functions (same input always produces same output) that are called repeatedly with the same arguments.
|
||||
|
||||
#### Python — functools
|
||||
|
||||
```python
|
||||
from functools import lru_cache, cache
|
||||
|
||||
# lru_cache with a max size — evicts least recently used entries
|
||||
@lru_cache(maxsize=256)
|
||||
def compute_shipping_cost(weight_kg: float, zone: str) -> float:
|
||||
"""Expensive calculation based on weight and shipping zone."""
|
||||
# Complex rate lookup, distance calculation, surcharges...
|
||||
return base_rate * weight_factor * zone_multiplier
|
||||
|
||||
|
||||
# cache (Python 3.9+) — unbounded, equivalent to lru_cache(maxsize=None)
|
||||
@cache
|
||||
def parse_config(config_path: str) -> dict:
|
||||
"""Parse a config file. Result never changes for the same path."""
|
||||
with open(config_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# Check cache statistics
|
||||
print(compute_shipping_cost.cache_info())
|
||||
# CacheInfo(hits=142, misses=23, maxsize=256, currsize=23)
|
||||
|
||||
# Clear cache when needed
|
||||
compute_shipping_cost.cache_clear()
|
||||
```
|
||||
|
||||
**Important:** `lru_cache` requires hashable arguments. It does not work with lists, dicts, or mutable objects. For async functions, use `asyncache` or `aiocache`:
|
||||
|
||||
```python
|
||||
# Async memoization with aiocache
|
||||
from aiocache import cached, Cache
|
||||
|
||||
@cached(
|
||||
ttl=300, # 5 minutes
|
||||
cache=Cache.MEMORY,
|
||||
key_builder=lambda f, *args, **kwargs: f"user_profile:{args[0]}",
|
||||
)
|
||||
async def get_user_profile(user_id: int) -> UserProfile:
|
||||
return await user_repo.get_with_preferences(user_id)
|
||||
```
|
||||
|
||||
#### React — useMemo and useCallback
|
||||
|
||||
```tsx
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
interface OrderSummaryProps {
|
||||
items: OrderItem[];
|
||||
taxRate: number;
|
||||
onCheckout: (total: number) => void;
|
||||
}
|
||||
|
||||
function OrderSummary({ items, taxRate, onCheckout }: OrderSummaryProps) {
|
||||
// Memoize expensive computation — recalculates only when items or taxRate change
|
||||
const totals = useMemo(() => {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
return { subtotal, tax, total };
|
||||
}, [items, taxRate]);
|
||||
|
||||
// Memoize callback to avoid re-renders in child components
|
||||
const handleCheckout = useCallback(() => {
|
||||
onCheckout(totals.total);
|
||||
}, [onCheckout, totals.total]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Subtotal: ${totals.subtotal.toFixed(2)}</p>
|
||||
<p>Tax: ${totals.tax.toFixed(2)}</p>
|
||||
<p>Total: ${totals.total.toFixed(2)}</p>
|
||||
<CheckoutButton onClick={handleCheckout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**When NOT to memoize in React:** Do not wrap every value in `useMemo`. If the computation is trivial (simple arithmetic, string concatenation, array access), the overhead of memoization exceeds the cost of recalculation. Memoize only when profiling shows a performance problem or when the value is passed as a prop to a `React.memo` child.
|
||||
|
||||
### 2. HTTP Caching
|
||||
|
||||
HTTP caching lets browsers and CDNs serve responses without hitting your server. Get this right and you can eliminate 80% or more of redundant requests.
|
||||
|
||||
#### Cache-Control headers
|
||||
|
||||
```python
|
||||
# Python — FastAPI response headers
|
||||
from fastapi import Response
|
||||
|
||||
@router.get("/api/v1/products/{product_id}")
|
||||
async def get_product(product_id: int, response: Response):
|
||||
product = await product_service.get(product_id)
|
||||
|
||||
# Public: CDN and browser can cache. max-age: browser TTL. s-maxage: CDN TTL.
|
||||
response.headers["Cache-Control"] = "public, max-age=60, s-maxage=300"
|
||||
return product
|
||||
|
||||
|
||||
@router.get("/api/v1/me/profile")
|
||||
async def get_my_profile(response: Response, user: User = Depends(get_current_user)):
|
||||
# Private: only browser cache, not CDN (contains user-specific data)
|
||||
response.headers["Cache-Control"] = "private, max-age=300"
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/api/v1/orders")
|
||||
async def create_order(order: OrderCreate, response: Response):
|
||||
# No cache for mutations
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return await order_service.create(order)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — Express response headers
|
||||
app.get("/api/v1/products/:id", async (req, res) => {
|
||||
const product = await productService.get(req.params.id);
|
||||
|
||||
res.set("Cache-Control", "public, max-age=60, s-maxage=300");
|
||||
res.json(product);
|
||||
});
|
||||
|
||||
// stale-while-revalidate: serve stale content while fetching fresh in background
|
||||
app.get("/api/v1/feed", async (req, res) => {
|
||||
const feed = await feedService.getLatest();
|
||||
|
||||
// Browser uses cache for 60s, then serves stale for up to 600s while revalidating
|
||||
res.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=60, s-maxage=300, stale-while-revalidate=600"
|
||||
);
|
||||
res.json(feed);
|
||||
});
|
||||
```
|
||||
|
||||
#### ETag and conditional requests
|
||||
|
||||
```python
|
||||
# Python — ETag-based caching
|
||||
import hashlib
|
||||
from fastapi import Request, Response
|
||||
|
||||
@router.get("/api/v1/catalog")
|
||||
async def get_catalog(request: Request, response: Response):
|
||||
catalog = await catalog_service.get_full()
|
||||
catalog_json = json.dumps(catalog, sort_keys=True)
|
||||
|
||||
# Generate ETag from content hash
|
||||
etag = f'"{hashlib.md5(catalog_json.encode()).hexdigest()}"'
|
||||
|
||||
# Check if client already has this version
|
||||
if_none_match = request.headers.get("If-None-Match")
|
||||
if if_none_match == etag:
|
||||
return Response(status_code=304) # Not Modified
|
||||
|
||||
response.headers["ETag"] = etag
|
||||
response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
|
||||
return catalog
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — ETag middleware
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
app.get("/api/v1/catalog", async (req, res) => {
|
||||
const catalog = await catalogService.getFull();
|
||||
const body = JSON.stringify(catalog);
|
||||
|
||||
const etag = `"${createHash("md5").update(body).digest("hex")}"`;
|
||||
|
||||
if (req.headers["if-none-match"] === etag) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.set("ETag", etag);
|
||||
res.set("Cache-Control", "public, max-age=0, must-revalidate");
|
||||
res.json(catalog);
|
||||
});
|
||||
```
|
||||
|
||||
#### Cache-Control cheat sheet
|
||||
|
||||
| Directive | Meaning |
|
||||
|-----------|---------|
|
||||
| `public` | Any cache (CDN, browser) may store the response |
|
||||
| `private` | Only the browser may cache (not CDN) |
|
||||
| `no-store` | Do not cache at all |
|
||||
| `no-cache` | Cache but revalidate with server before using |
|
||||
| `max-age=N` | Browser cache TTL in seconds |
|
||||
| `s-maxage=N` | CDN/proxy cache TTL in seconds (overrides max-age for shared caches) |
|
||||
| `must-revalidate` | Once stale, must revalidate before using |
|
||||
| `stale-while-revalidate=N` | Serve stale while fetching fresh in background for N seconds |
|
||||
| `immutable` | Content will never change (use for hashed assets like `app.a1b2c3.js`) |
|
||||
|
||||
### 3. Redis Caching
|
||||
|
||||
Redis is the standard external cache for web applications. It survives process restarts, can be shared across multiple servers, and supports TTL-based expiry natively.
|
||||
|
||||
#### Cache-aside pattern (read-through)
|
||||
|
||||
The most common pattern: check cache first, fetch from source on miss, populate cache for next time.
|
||||
|
||||
```python
|
||||
# Python — redis cache-aside
|
||||
import json
|
||||
from typing import TypeVar, Callable, Awaitable
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
class RedisCache:
|
||||
def __init__(self, redis_url: str, default_ttl: int = 300):
|
||||
self.client = redis.from_url(redis_url, decode_responses=True)
|
||||
self.default_ttl = default_ttl
|
||||
|
||||
async def get_or_set(
|
||||
self,
|
||||
key: str,
|
||||
fetch_fn: Callable[[], Awaitable[T]],
|
||||
ttl: int | None = None,
|
||||
) -> T:
|
||||
"""Cache-aside: return cached value or fetch, cache, and return."""
|
||||
cached = await self.client.get(key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
# Cache miss — fetch from source
|
||||
value = await fetch_fn()
|
||||
await self.client.set(
|
||||
key,
|
||||
json.dumps(value, default=str),
|
||||
ex=ttl or self.default_ttl,
|
||||
)
|
||||
return value
|
||||
|
||||
async def invalidate(self, key: str) -> None:
|
||||
await self.client.delete(key)
|
||||
|
||||
async def invalidate_pattern(self, pattern: str) -> None:
|
||||
"""Delete all keys matching a pattern (e.g., 'user:42:*')."""
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await self.client.scan(cursor, match=pattern, count=100)
|
||||
if keys:
|
||||
await self.client.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
|
||||
# Usage
|
||||
cache = RedisCache("redis://localhost:6379/0")
|
||||
|
||||
async def get_user_profile(user_id: int) -> UserProfile:
|
||||
return await cache.get_or_set(
|
||||
key=f"user_profile:{user_id}",
|
||||
fetch_fn=lambda: user_repo.get_with_preferences(user_id),
|
||||
ttl=600, # 10 minutes
|
||||
)
|
||||
|
||||
async def update_user_profile(user_id: int, data: UserUpdate) -> UserProfile:
|
||||
profile = await user_repo.update(user_id, data)
|
||||
await cache.invalidate(f"user_profile:{user_id}")
|
||||
return profile
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — ioredis cache-aside
|
||||
import Redis from "ioredis";
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
|
||||
export class RedisCache {
|
||||
constructor(private defaultTtl = 300) {}
|
||||
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
fetchFn: () => Promise<T>,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
const cached = await redis.get(key);
|
||||
if (cached !== null) {
|
||||
return JSON.parse(cached) as T;
|
||||
}
|
||||
|
||||
const value = await fetchFn();
|
||||
await redis.set(key, JSON.stringify(value), "EX", ttl ?? this.defaultTtl);
|
||||
return value;
|
||||
}
|
||||
|
||||
async invalidate(key: string): Promise<void> {
|
||||
await redis.del(key);
|
||||
}
|
||||
|
||||
async invalidatePattern(pattern: string): Promise<void> {
|
||||
const stream = redis.scanStream({ match: pattern, count: 100 });
|
||||
const pipeline = redis.pipeline();
|
||||
for await (const keys of stream) {
|
||||
for (const key of keys as string[]) {
|
||||
pipeline.del(key);
|
||||
}
|
||||
}
|
||||
await pipeline.exec();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Write-through pattern
|
||||
|
||||
Update cache and source simultaneously on writes. Guarantees cache is always fresh at the cost of slower writes.
|
||||
|
||||
```python
|
||||
async def update_product(product_id: int, data: ProductUpdate) -> Product:
|
||||
# Update database
|
||||
product = await product_repo.update(product_id, data)
|
||||
|
||||
# Update cache atomically
|
||||
await cache.client.set(
|
||||
f"product:{product_id}",
|
||||
json.dumps(product.model_dump(), default=str),
|
||||
ex=3600,
|
||||
)
|
||||
|
||||
return product
|
||||
```
|
||||
|
||||
#### TTL strategies
|
||||
|
||||
| Data Type | Recommended TTL | Rationale |
|
||||
|-----------|----------------|-----------|
|
||||
| User session data | 15-30 minutes | Balance security with UX |
|
||||
| Product catalog | 5-60 minutes | Changes infrequently, high read volume |
|
||||
| Search results | 1-5 minutes | Acceptable staleness, expensive to compute |
|
||||
| Configuration | 5-15 minutes | Rarely changes, critical path |
|
||||
| Rate limit counters | Match the rate limit window | Must be precise |
|
||||
| Feature flags | 30-60 seconds | Needs to propagate quickly |
|
||||
|
||||
### 4. Application Cache
|
||||
|
||||
For single-server applications or per-process caching where Redis is overkill.
|
||||
|
||||
#### Python — cachetools
|
||||
|
||||
```python
|
||||
from cachetools import TTLCache, LRUCache
|
||||
from cachetools.keys import hashkey
|
||||
import asyncio
|
||||
|
||||
# TTL cache: entries expire after 300 seconds, max 1000 entries
|
||||
user_cache: TTLCache = TTLCache(maxsize=1000, ttl=300)
|
||||
|
||||
# LRU cache: evicts least recently used when full
|
||||
template_cache: LRUCache = LRUCache(maxsize=100)
|
||||
|
||||
# Thread-safe / async-safe wrapper
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
async def get_user_cached(user_id: int) -> User:
|
||||
key = hashkey(user_id)
|
||||
async with _cache_lock:
|
||||
if key in user_cache:
|
||||
return user_cache[key]
|
||||
|
||||
# Fetch outside the lock to avoid holding it during I/O
|
||||
user = await user_repo.get(user_id)
|
||||
|
||||
async with _cache_lock:
|
||||
user_cache[key] = user
|
||||
return user
|
||||
```
|
||||
|
||||
#### TypeScript — node-cache
|
||||
|
||||
```typescript
|
||||
import NodeCache from "node-cache";
|
||||
|
||||
// stdTTL: default TTL in seconds, checkperiod: cleanup interval
|
||||
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
|
||||
|
||||
export async function getUserCached(userId: number): Promise<User> {
|
||||
const cacheKey = `user:${userId}`;
|
||||
const cached = cache.get<User>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const user = await userRepo.findById(userId);
|
||||
cache.set(cacheKey, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
// Listen for eviction events
|
||||
cache.on("expired", (key: string, value: unknown) => {
|
||||
log.debug({ key }, "cache_entry_expired");
|
||||
});
|
||||
|
||||
// Cache statistics
|
||||
const stats = cache.getStats();
|
||||
// { hits: 1523, misses: 89, keys: 234, ksize: 4680, vsize: 156000 }
|
||||
```
|
||||
|
||||
### 5. CDN Caching
|
||||
|
||||
CDN caching moves content to edge servers close to users. Configure it correctly and your origin server handles a fraction of the traffic.
|
||||
|
||||
#### Cloudflare cache rules
|
||||
|
||||
```python
|
||||
# Python — set headers that Cloudflare respects
|
||||
@router.get("/api/v1/public/articles")
|
||||
async def list_articles(response: Response):
|
||||
articles = await article_service.list_published()
|
||||
|
||||
# Cloudflare respects s-maxage for edge cache TTL
|
||||
response.headers["Cache-Control"] = "public, s-maxage=3600, max-age=60"
|
||||
# Vary tells the CDN to cache different versions for different values
|
||||
response.headers["Vary"] = "Accept-Encoding, Accept-Language"
|
||||
return articles
|
||||
|
||||
|
||||
# Hashed static assets — cache forever
|
||||
@router.get("/assets/{filename}")
|
||||
async def serve_asset(filename: str, response: Response):
|
||||
# Filenames contain content hash: app.a3b2c1.js
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return FileResponse(f"static/{filename}")
|
||||
```
|
||||
|
||||
#### Cache purge on content update
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
async def purge_cdn_cache(urls: list[str]) -> None:
|
||||
"""Purge specific URLs from Cloudflare edge cache."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(
|
||||
f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache",
|
||||
headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
|
||||
json={"files": urls},
|
||||
)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — purge after content update
|
||||
export async function purgeCache(urls: string[]): Promise<void> {
|
||||
await fetch(
|
||||
`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${CF_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ files: urls }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Purge after publishing an article
|
||||
export async function publishArticle(id: string): Promise<void> {
|
||||
await articleRepo.publish(id);
|
||||
await purgeCache([
|
||||
`https://example.com/api/v1/articles/${id}`,
|
||||
`https://example.com/api/v1/articles`, // List endpoint
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
#### Vary header
|
||||
|
||||
The `Vary` header tells the CDN to maintain separate cached versions for different request header values:
|
||||
|
||||
```
|
||||
Vary: Accept-Encoding → separate cache for gzip vs brotli
|
||||
Vary: Accept-Language → separate cache per language
|
||||
Vary: Accept-Encoding, Authorization → DO NOT DO THIS — Authorization varies per user, so nothing is ever cached
|
||||
```
|
||||
|
||||
**Rule:** Never include `Authorization`, `Cookie`, or other high-cardinality headers in `Vary` unless you specifically want per-user caching (which defeats the purpose of a CDN).
|
||||
|
||||
### 6. Cache Invalidation
|
||||
|
||||
The two hardest problems in computer science are cache invalidation, naming things, and off-by-one errors. Here are patterns that make invalidation manageable.
|
||||
|
||||
#### Tag-based invalidation
|
||||
|
||||
Group related cache entries under tags so you can invalidate them together.
|
||||
|
||||
```python
|
||||
class TaggedCache:
|
||||
"""Redis-backed cache with tag-based invalidation."""
|
||||
|
||||
def __init__(self, redis_client):
|
||||
self.redis = redis_client
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int, tags: list[str]) -> None:
|
||||
pipe = self.redis.pipeline()
|
||||
pipe.set(key, value, ex=ttl)
|
||||
for tag in tags:
|
||||
pipe.sadd(f"tag:{tag}", key)
|
||||
pipe.expire(f"tag:{tag}", ttl + 60) # Tag lives slightly longer
|
||||
await pipe.execute()
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
return await self.redis.get(key)
|
||||
|
||||
async def invalidate_tag(self, tag: str) -> int:
|
||||
"""Delete all cache entries associated with a tag."""
|
||||
tag_key = f"tag:{tag}"
|
||||
keys = await self.redis.smembers(tag_key)
|
||||
if keys:
|
||||
pipe = self.redis.pipeline()
|
||||
pipe.delete(*keys)
|
||||
pipe.delete(tag_key)
|
||||
results = await pipe.execute()
|
||||
return results[0] # Number of deleted keys
|
||||
return 0
|
||||
|
||||
|
||||
# Usage
|
||||
cache = TaggedCache(redis_client)
|
||||
|
||||
# Cache a product, tagged with its category and brand
|
||||
await cache.set(
|
||||
key=f"product:{product.id}",
|
||||
value=json.dumps(product.dict()),
|
||||
ttl=3600,
|
||||
tags=[f"category:{product.category_id}", f"brand:{product.brand_id}"],
|
||||
)
|
||||
|
||||
# When a category is updated, invalidate all products in that category
|
||||
await cache.invalidate_tag(f"category:{category_id}")
|
||||
```
|
||||
|
||||
#### Event-driven invalidation
|
||||
|
||||
Invalidate caches in response to domain events rather than inline in business logic.
|
||||
|
||||
```python
|
||||
# events.py
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ProductUpdated:
|
||||
product_id: int
|
||||
category_id: int
|
||||
|
||||
@dataclass
|
||||
class CategoryUpdated:
|
||||
category_id: int
|
||||
|
||||
|
||||
# event_handlers.py
|
||||
async def handle_product_updated(event: ProductUpdated) -> None:
|
||||
await cache.invalidate(f"product:{event.product_id}")
|
||||
await cache.invalidate(f"product_list:category:{event.category_id}")
|
||||
|
||||
async def handle_category_updated(event: CategoryUpdated) -> None:
|
||||
await cache.invalidate_tag(f"category:{event.category_id}")
|
||||
```
|
||||
|
||||
#### Versioned keys
|
||||
|
||||
Instead of deleting cache entries, change the key so old entries become unreachable and expire naturally.
|
||||
|
||||
```python
|
||||
async def get_catalog_version() -> int:
|
||||
"""Stored in Redis or database. Increment on catalog changes."""
|
||||
version = await redis.get("catalog:version")
|
||||
return int(version) if version else 1
|
||||
|
||||
async def get_catalog_cached() -> list[Product]:
|
||||
version = await get_catalog_version()
|
||||
key = f"catalog:v{version}"
|
||||
return await cache.get_or_set(key, catalog_service.get_all, ttl=3600)
|
||||
|
||||
async def on_catalog_change() -> None:
|
||||
"""Called when any product is added, updated, or removed."""
|
||||
await redis.incr("catalog:version")
|
||||
# Old version keys expire naturally via TTL — no explicit deletion needed
|
||||
```
|
||||
|
||||
### 7. Next.js Caching
|
||||
|
||||
Next.js has multiple cache layers. Understanding which layer applies to your use case avoids common bugs.
|
||||
|
||||
#### Data cache with fetch
|
||||
|
||||
```typescript
|
||||
// Cached by default in Next.js App Router (builds use static generation)
|
||||
async function getProducts(): Promise<Product[]> {
|
||||
const res = await fetch("https://api.example.com/products", {
|
||||
// Revalidate every 60 seconds (ISR behavior)
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Opt out of caching entirely
|
||||
async function getCurrentUser(): Promise<User> {
|
||||
const res = await fetch("https://api.example.com/me", {
|
||||
cache: "no-store", // Always fetch fresh
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
```
|
||||
|
||||
#### Tag-based revalidation
|
||||
|
||||
```typescript
|
||||
// Fetch with tags for targeted invalidation
|
||||
async function getProduct(id: string): Promise<Product> {
|
||||
const res = await fetch(`https://api.example.com/products/${id}`, {
|
||||
next: {
|
||||
tags: [`product:${id}`, "products"],
|
||||
},
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Server action to revalidate
|
||||
"use server";
|
||||
import { revalidateTag, revalidatePath } from "next/cache";
|
||||
|
||||
export async function updateProduct(id: string, data: ProductUpdate) {
|
||||
await productApi.update(id, data);
|
||||
|
||||
// Revalidate specific product and the list
|
||||
revalidateTag(`product:${id}`);
|
||||
revalidateTag("products");
|
||||
|
||||
// Or revalidate an entire path
|
||||
revalidatePath("/products");
|
||||
}
|
||||
```
|
||||
|
||||
#### unstable_cache for non-fetch data
|
||||
|
||||
```typescript
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
// Cache database queries or any async function
|
||||
const getCachedProducts = unstable_cache(
|
||||
async (categoryId: string) => {
|
||||
return await db.product.findMany({
|
||||
where: { categoryId },
|
||||
});
|
||||
},
|
||||
["products-by-category"], // Cache key prefix
|
||||
{
|
||||
revalidate: 300, // 5 minutes
|
||||
tags: ["products"], // For manual invalidation
|
||||
}
|
||||
);
|
||||
|
||||
// Usage in a Server Component
|
||||
export default async function ProductList({ categoryId }: Props) {
|
||||
const products = await getCachedProducts(categoryId);
|
||||
return <ProductGrid products={products} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### Next.js cache layers summary
|
||||
|
||||
| Layer | What It Caches | Controlled By |
|
||||
|-------|---------------|---------------|
|
||||
| Request Memoization | Duplicate fetch calls in a single render | Automatic (same URL + options) |
|
||||
| Data Cache | fetch responses on the server | `next: { revalidate }`, `cache: "no-store"` |
|
||||
| Full Route Cache | Complete HTML and RSC payload | Static vs dynamic rendering |
|
||||
| Router Cache | RSC payload in the browser | `revalidatePath`, `revalidateTag`, `router.refresh()` |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Cache at the right layer** — choose the caching layer closest to the consumer. HTTP caching eliminates network hops. CDN caching eliminates origin hits. Application caching eliminates database queries. Memoization eliminates repeated computation. Layer them, do not pick just one.
|
||||
|
||||
2. **Set TTLs based on data characteristics** — how stale can this data be before it causes a user-visible problem? Set TTL to that tolerance. A product description can be stale for minutes. An account balance cannot be stale at all.
|
||||
|
||||
3. **Use cache-aside as the default pattern** — read from cache, on miss fetch from source, populate cache. It is simple, handles cache failures gracefully (just hits the source), and keeps business logic decoupled from cache logic.
|
||||
|
||||
4. **Always set a max size or TTL** — unbounded caches cause memory leaks. Every cache should have either a size limit with eviction (LRU, LFU) or a TTL, or both. Monitor memory usage.
|
||||
|
||||
5. **Monitor cache hit rates** — a cache with a low hit rate is wasting memory without improving performance. Track hits, misses, and evictions. If the hit rate is below 80%, reconsider your key design or TTL.
|
||||
|
||||
6. **Design cache keys carefully** — keys should be deterministic, unique, and human-readable for debugging. Include a prefix identifying the data type, the relevant IDs, and optionally a version: `product:42:v3`, `user:123:profile`.
|
||||
|
||||
7. **Handle cache failures gracefully** — if Redis is down, fall through to the database. A cache failure should degrade performance, not break the application. Wrap cache calls in try/catch and log failures.
|
||||
|
||||
8. **Warm critical caches on startup** — for data that is expensive to fetch and always needed (configuration, feature flags, popular items), pre-populate the cache at application startup rather than waiting for the first user request to trigger a cold miss.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Cache stampede (thundering herd)** — when a popular cache entry expires, hundreds of requests simultaneously hit the database to regenerate it. Mitigate with lock-based repopulation (only one request fetches, others wait), stale-while-revalidate (serve expired data while one request refreshes), or randomized TTLs (spread expiry times across a range).
|
||||
|
||||
```python
|
||||
# Python — lock-based stampede prevention
|
||||
async def get_with_lock(key: str, fetch_fn, ttl: int = 300) -> Any:
|
||||
cached = await redis.get(key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
lock_key = f"lock:{key}"
|
||||
acquired = await redis.set(lock_key, "1", ex=30, nx=True)
|
||||
|
||||
if acquired:
|
||||
try:
|
||||
value = await fetch_fn()
|
||||
await redis.set(key, json.dumps(value, default=str), ex=ttl)
|
||||
return value
|
||||
finally:
|
||||
await redis.delete(lock_key)
|
||||
else:
|
||||
# Another request is fetching — wait and retry
|
||||
await asyncio.sleep(0.1)
|
||||
return await get_with_lock(key, fetch_fn, ttl)
|
||||
```
|
||||
|
||||
2. **Stale data in production** — a user updates their profile but keeps seeing old data because the cache was not invalidated. Always invalidate or update the cache in every write path. Use write-through caching for critical data, and test invalidation logic as carefully as you test the write itself.
|
||||
|
||||
3. **Caching errors** — if a database query fails and you cache the error response, every subsequent request gets the error until the TTL expires. Only cache successful results. Check the response before writing to cache.
|
||||
|
||||
```python
|
||||
# Wrong — caches None on failure
|
||||
result = await fetch_fn()
|
||||
await redis.set(key, json.dumps(result), ex=ttl)
|
||||
|
||||
# Right — only cache valid results
|
||||
result = await fetch_fn()
|
||||
if result is not None:
|
||||
await redis.set(key, json.dumps(result), ex=ttl)
|
||||
```
|
||||
|
||||
4. **Over-memoizing in React** — wrapping every variable in `useMemo` and every function in `useCallback` adds overhead without benefit unless the value is passed to a memoized child component or is genuinely expensive to compute. Profile first, memoize second.
|
||||
|
||||
5. **Forgetting the Vary header** — if your API returns different content based on `Accept-Language` or `Accept-Encoding` but does not include `Vary`, the CDN may serve the wrong cached version to users. Always set `Vary` when response content depends on request headers.
|
||||
|
||||
6. **Cache key collisions** — using overly generic keys like `"products"` instead of `"products:category:5:page:2:sort:price"` causes different requests to share cached data. Include all parameters that affect the response in the cache key.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/state-management` — Client-side state management patterns that interact with cache layers
|
||||
- `databases/postgresql` — Query optimization and connection pooling that complement caching strategies
|
||||
- `databases/mongodb` — MongoDB query patterns and when to add a cache layer
|
||||
- `frameworks/nextjs` — Next.js data fetching, ISR, and caching architecture
|
||||
- `patterns/api-client` — Client-side caching for API responses
|
||||
@@ -0,0 +1,201 @@
|
||||
# Caching Decision Tree
|
||||
|
||||
## Primary Decision Tree
|
||||
|
||||
```
|
||||
What are you caching?
|
||||
│
|
||||
├─ PURE FUNCTION RESULT (same input = same output)
|
||||
│ │
|
||||
│ ├─ In React component?
|
||||
│ │ └─ useMemo(() => compute(data), [data])
|
||||
│ │
|
||||
│ ├─ Expensive computation called repeatedly?
|
||||
│ │ └─ Memoize the function
|
||||
│ │ Python: @functools.lru_cache or @functools.cache
|
||||
│ │ JS: hand-rolled Map cache or lodash.memoize
|
||||
│ │
|
||||
│ └─ Shared across requests/processes?
|
||||
│ └─ Use external cache (Redis) -- see below
|
||||
│
|
||||
├─ HTTP RESPONSE (browser or CDN caching)
|
||||
│ │
|
||||
│ ├─ Is it public (same for all users)?
|
||||
│ │ │
|
||||
│ │ ├─ Static asset (JS, CSS, images)?
|
||||
│ │ │ └─ Cache-Control: public, max-age=31536000, immutable
|
||||
│ │ │ (Use content hash in filename for busting)
|
||||
│ │ │
|
||||
│ │ ├─ API response that changes occasionally?
|
||||
│ │ │ └─ Cache-Control: public, max-age=60, stale-while-revalidate=300
|
||||
│ │ │ + ETag or Last-Modified for conditional requests
|
||||
│ │ │
|
||||
│ │ └─ HTML page?
|
||||
│ │ └─ Cache-Control: public, max-age=0, must-revalidate
|
||||
│ │ + ETag (let CDN/browser validate freshness)
|
||||
│ │
|
||||
│ └─ Is it private (user-specific)?
|
||||
│ └─ Cache-Control: private, max-age=60
|
||||
│ (Never cache auth tokens or sensitive data at CDN)
|
||||
│
|
||||
├─ DATABASE QUERY RESULT (shared across requests)
|
||||
│ │
|
||||
│ ├─ Read-heavy, rarely changes?
|
||||
│ │ └─ Redis/Memcached with TTL
|
||||
│ │ Pattern: Cache-aside (read-through)
|
||||
│ │
|
||||
│ ├─ Must always be fresh?
|
||||
│ │ └─ Don't cache. Optimize the query instead.
|
||||
│ │ (Add indexes, denormalize, materialized view)
|
||||
│ │
|
||||
│ └─ Needs real-time invalidation?
|
||||
│ └─ Write-through cache or event-driven invalidation
|
||||
│ (Update cache when DB changes)
|
||||
│
|
||||
├─ EXTERNAL API RESPONSE
|
||||
│ │
|
||||
│ ├─ API has rate limits?
|
||||
│ │ └─ Cache aggressively. Respect Cache-Control from API.
|
||||
│ │ Fallback: cache with reasonable TTL (5-60 min)
|
||||
│ │
|
||||
│ ├─ API is slow (>500ms)?
|
||||
│ │ └─ Cache + stale-while-revalidate pattern
|
||||
│ │ Serve stale, refresh in background
|
||||
│ │
|
||||
│ └─ API data is critical and must be fresh?
|
||||
│ └─ Short TTL (10-30s) + circuit breaker on failure
|
||||
│
|
||||
└─ EDGE/CDN CACHING
|
||||
│
|
||||
├─ Global audience, same content?
|
||||
│ └─ CDN with long TTL + purge on deploy
|
||||
│ (Cloudflare, CloudFront, Vercel Edge)
|
||||
│
|
||||
├─ Personalized at edge?
|
||||
│ └─ Edge compute (Cloudflare Workers, Vercel Edge Functions)
|
||||
│ Cache shared parts, inject personalization
|
||||
│
|
||||
└─ A/B testing at edge?
|
||||
└─ Vary by cookie or header
|
||||
Vary: Cookie (careful: reduces cache hit rate)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache-Aside Pattern (Most Common)
|
||||
|
||||
```
|
||||
Read:
|
||||
1. Check cache for key
|
||||
2. HIT --> return cached value
|
||||
3. MISS --> query DB, store in cache with TTL, return value
|
||||
|
||||
Write:
|
||||
1. Update DB
|
||||
2. Delete cache key (don't update -- avoids race conditions)
|
||||
3. Next read will repopulate cache
|
||||
```
|
||||
|
||||
```python
|
||||
# Python + Redis
|
||||
import redis, json
|
||||
|
||||
r = redis.Redis()
|
||||
TTL = 300 # 5 minutes
|
||||
|
||||
def get_user(user_id: str) -> dict:
|
||||
key = f"user:{user_id}"
|
||||
cached = r.get(key)
|
||||
if cached:
|
||||
return json.loads(cached)
|
||||
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
|
||||
r.setex(key, TTL, json.dumps(user))
|
||||
return user
|
||||
|
||||
def update_user(user_id: str, data: dict):
|
||||
db.execute("UPDATE users SET ... WHERE id = %s", user_id)
|
||||
r.delete(f"user:{user_id}") # Invalidate, don't update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TTL Strategy Guide
|
||||
|
||||
| Data Type | TTL | Rationale |
|
||||
|-----------|-----|-----------|
|
||||
| User session | 15-60 min | Balance security and UX |
|
||||
| User profile | 5-15 min | Changes infrequently |
|
||||
| Product catalog | 1-5 min | Needs reasonable freshness |
|
||||
| Search results | 30s-2 min | Changes frequently |
|
||||
| Static config | 1-24 hours | Rarely changes |
|
||||
| Feature flags | 30s-1 min | Needs fast propagation |
|
||||
| API rate limit counters | Match the rate limit window | Exact timing matters |
|
||||
| Dashboard aggregations | 1-5 min | Expensive to compute |
|
||||
|
||||
### TTL Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|-------------|---------|-----|
|
||||
| No TTL (cache forever) | Stale data, memory leak | Always set a TTL |
|
||||
| TTL too short (<1s) | Cache provides no benefit | Remove cache or increase TTL |
|
||||
| Same TTL for everything | Over/under-caching | Tune per data type |
|
||||
| Stampede on expiry | All caches expire at once, DB overload | Jitter: TTL + random(0, 60s) |
|
||||
|
||||
---
|
||||
|
||||
## Cache Invalidation Strategies
|
||||
|
||||
| Strategy | How | Best For |
|
||||
|----------|-----|----------|
|
||||
| TTL expiry | Automatic, time-based | Most cases |
|
||||
| Explicit delete | Delete key on write | Strong consistency needs |
|
||||
| Write-through | Update cache on every write | Read-heavy, write-infrequent |
|
||||
| Event-driven | Invalidate on DB change event | Microservices |
|
||||
| Version key | Append version to cache key | Bulk invalidation |
|
||||
| Tag-based | Group keys by tag, purge by tag | CDN, grouped content |
|
||||
|
||||
---
|
||||
|
||||
## Cache Headers Quick Reference
|
||||
|
||||
| Header | Example | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `Cache-Control` | `max-age=3600` | Primary caching directive |
|
||||
| `ETag` | `"abc123"` | Content fingerprint for conditional requests |
|
||||
| `Last-Modified` | `Wed, 29 Jan 2025 12:00:00 GMT` | Timestamp for conditional requests |
|
||||
| `Vary` | `Accept-Encoding, Authorization` | Cache varies by these headers |
|
||||
| `CDN-Cache-Control` | `max-age=86400` | CDN-specific (Cloudflare, etc.) |
|
||||
|
||||
### Common Cache-Control Patterns
|
||||
|
||||
```
|
||||
# Immutable static asset (hashed filename)
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# API data with background refresh
|
||||
Cache-Control: public, max-age=60, stale-while-revalidate=300
|
||||
|
||||
# Private user data
|
||||
Cache-Control: private, no-cache
|
||||
# (no-cache = must revalidate, NOT "don't cache")
|
||||
|
||||
# Never cache
|
||||
Cache-Control: no-store
|
||||
|
||||
# HTML pages (revalidate every time)
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
ETag: "content-hash-here"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When NOT to Cache
|
||||
|
||||
| Scenario | Why |
|
||||
|----------|-----|
|
||||
| Data changes on every request | Cache hit rate ~0% |
|
||||
| Data must be real-time consistent | Stale data is unacceptable |
|
||||
| Write-heavy workload | Constant invalidation negates benefit |
|
||||
| Data is cheap to compute/fetch | Cache overhead exceeds savings |
|
||||
| Sensitive data (PII, financial) | Risk of serving wrong user's data |
|
||||
| Early in development | Premature optimization; adds complexity |
|
||||
@@ -0,0 +1,922 @@
|
||||
---
|
||||
name: error-handling
|
||||
description: >
|
||||
Comprehensive error handling patterns for Python and TypeScript applications. Use this skill whenever writing try/catch blocks, creating custom error classes, implementing retry logic, designing error boundaries in React, building API error responses, or handling failures gracefully. Trigger for any code dealing with exceptions, error propagation, graceful degradation, or fault tolerance.
|
||||
---
|
||||
|
||||
# Error Handling Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building API endpoints that must return consistent error responses
|
||||
- Creating custom exception hierarchies for a domain model
|
||||
- Implementing retry logic for unreliable network calls or external services
|
||||
- Designing React error boundaries for component-level fault isolation
|
||||
- Wrapping third-party libraries that throw unpredictable errors
|
||||
- Converting between error representations at architectural boundaries (e.g., domain errors to HTTP errors)
|
||||
- Adopting the Result pattern to avoid exceptions for expected failure paths
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Simple one-off scripts or throwaway prototypes where unhandled crashes are acceptable
|
||||
- Configuration files, static data, or declarative markup with no runtime logic
|
||||
- Pure data transformation functions where invalid input should be prevented by types, not caught at runtime
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Custom Error Classes
|
||||
|
||||
Define a hierarchy of domain-specific errors so callers can catch at the right granularity.
|
||||
|
||||
**Python**
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ErrorCode(str, Enum):
|
||||
"""Central registry of machine-readable error codes."""
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
VALIDATION_FAILED = "VALIDATION_FAILED"
|
||||
DUPLICATE_ENTRY = "DUPLICATE_ENTRY"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
RATE_LIMITED = "RATE_LIMITED"
|
||||
EXTERNAL_SERVICE = "EXTERNAL_SERVICE"
|
||||
INTERNAL = "INTERNAL"
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base error for the entire application.
|
||||
|
||||
All domain errors inherit from this so a single except clause
|
||||
can catch everything the application intentionally raises.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
code: ErrorCode = ErrorCode.INTERNAL,
|
||||
*,
|
||||
details: dict | None = None,
|
||||
cause: Exception | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
if cause:
|
||||
self.__cause__ = cause
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"error": self.code.value,
|
||||
"message": str(self),
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
def __init__(self, resource: str, identifier: str) -> None:
|
||||
super().__init__(
|
||||
f"{resource} with id '{identifier}' not found",
|
||||
code=ErrorCode.NOT_FOUND,
|
||||
details={"resource": resource, "id": identifier},
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
def __init__(self, field: str, reason: str) -> None:
|
||||
super().__init__(
|
||||
f"Validation failed for '{field}': {reason}",
|
||||
code=ErrorCode.VALIDATION_FAILED,
|
||||
details={"field": field, "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
class ExternalServiceError(AppError):
|
||||
def __init__(self, service: str, cause: Exception) -> None:
|
||||
super().__init__(
|
||||
f"External service '{service}' failed: {cause}",
|
||||
code=ErrorCode.EXTERNAL_SERVICE,
|
||||
cause=cause,
|
||||
details={"service": service},
|
||||
)
|
||||
```
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
// error-codes.ts
|
||||
export const ErrorCode = {
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
VALIDATION_FAILED: "VALIDATION_FAILED",
|
||||
DUPLICATE_ENTRY: "DUPLICATE_ENTRY",
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
RATE_LIMITED: "RATE_LIMITED",
|
||||
EXTERNAL_SERVICE: "EXTERNAL_SERVICE",
|
||||
INTERNAL: "INTERNAL",
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
// app-error.ts
|
||||
export class AppError extends Error {
|
||||
public readonly code: ErrorCode;
|
||||
public readonly details: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: ErrorCode = ErrorCode.INTERNAL,
|
||||
details: Record<string, unknown> = {},
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "AppError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.code,
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} with id '${id}' not found`, ErrorCode.NOT_FOUND, {
|
||||
resource,
|
||||
id,
|
||||
});
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(field: string, reason: string) {
|
||||
super(
|
||||
`Validation failed for '${field}': ${reason}`,
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
{ field, reason }
|
||||
);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ExternalServiceError extends AppError {
|
||||
constructor(service: string, cause: Error) {
|
||||
super(
|
||||
`External service '${service}' failed: ${cause.message}`,
|
||||
ErrorCode.EXTERNAL_SERVICE,
|
||||
{ service },
|
||||
{ cause }
|
||||
);
|
||||
this.name = "ExternalServiceError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Error Boundaries (React)
|
||||
|
||||
Isolate component failures so a single broken widget does not take down the whole page.
|
||||
|
||||
**Using react-error-boundary (recommended)**
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ErrorBoundary,
|
||||
type FallbackProps,
|
||||
} from "react-error-boundary";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div role="alert" className="rounded border border-red-300 bg-red-50 p-4">
|
||||
<h2 className="font-semibold text-red-800">Something went wrong</h2>
|
||||
<pre className="mt-2 text-sm text-red-700">{error.message}</pre>
|
||||
<button
|
||||
onClick={resetErrorBoundary}
|
||||
className="mt-3 rounded bg-red-600 px-3 py-1 text-white"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onError={(error, info) => {
|
||||
// Send to error tracking service
|
||||
reportError({ error, componentStack: info.componentStack });
|
||||
}}
|
||||
onReset={() => {
|
||||
// Clear any stale state before retry
|
||||
queryClient.clear();
|
||||
}}
|
||||
>
|
||||
<Dashboard />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Granular boundaries per feature**
|
||||
|
||||
```typescript
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Each widget fails independently */}
|
||||
<ErrorBoundary FallbackComponent={WidgetErrorFallback}>
|
||||
<RevenueChart />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary FallbackComponent={WidgetErrorFallback}>
|
||||
<UserActivityFeed />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary FallbackComponent={WidgetErrorFallback}>
|
||||
<SystemHealthPanel />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded border border-dashed border-gray-300 p-6 text-gray-500">
|
||||
<p>This widget failed to load.</p>
|
||||
<button onClick={resetErrorBoundary} className="mt-2 underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Class-based error boundary (when you need full control)**
|
||||
|
||||
```typescript
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ManualErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught:", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? <p>Something went wrong.</p>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Retry Patterns
|
||||
|
||||
Retry transient failures with exponential backoff and jitter to avoid thundering herd.
|
||||
|
||||
**Python - Retry decorator with exponential backoff**
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import random
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import TypeVar, Callable, Awaitable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def retry(
|
||||
max_attempts: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
retryable: tuple[type[Exception], ...] = (Exception,),
|
||||
) -> Callable:
|
||||
"""Retry decorator with exponential backoff and full jitter.
|
||||
|
||||
Args:
|
||||
max_attempts: Total number of attempts including the first call.
|
||||
base_delay: Initial delay in seconds before the first retry.
|
||||
max_delay: Upper bound on the computed delay.
|
||||
retryable: Exception types eligible for retry.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
@wraps(fn)
|
||||
async def wrapper(*args, **kwargs) -> T:
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
except retryable as exc:
|
||||
last_exc = exc
|
||||
if attempt == max_attempts:
|
||||
break
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
jitter = random.uniform(0, delay)
|
||||
logger.warning(
|
||||
"Attempt %d/%d failed (%s), retrying in %.2fs",
|
||||
attempt,
|
||||
max_attempts,
|
||||
exc,
|
||||
jitter,
|
||||
)
|
||||
await asyncio.sleep(jitter)
|
||||
raise last_exc # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Usage
|
||||
@retry(max_attempts=3, base_delay=0.5, retryable=(ConnectionError, TimeoutError))
|
||||
async def fetch_remote_config(url: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
**TypeScript - Retry wrapper**
|
||||
|
||||
```typescript
|
||||
interface RetryOptions {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
isRetryable?: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
baseDelayMs = 1000,
|
||||
maxDelayMs = 30_000,
|
||||
isRetryable = () => true,
|
||||
} = options;
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxAttempts || !isRetryable(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const exponential = baseDelayMs * 2 ** (attempt - 1);
|
||||
const capped = Math.min(exponential, maxDelayMs);
|
||||
const jitter = Math.random() * capped;
|
||||
|
||||
console.warn(
|
||||
`Attempt ${attempt}/${maxAttempts} failed, retrying in ${jitter.toFixed(0)}ms`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, jitter));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const data = await withRetry(
|
||||
() => fetch("/api/config").then((r) => r.json()),
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 500,
|
||||
isRetryable: (err) =>
|
||||
err instanceof TypeError || (err as Response)?.status >= 500,
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Graceful Degradation
|
||||
|
||||
When a dependency fails, fall back to a degraded but functional state instead of crashing.
|
||||
|
||||
**Circuit breaker pattern (Python)**
|
||||
|
||||
```python
|
||||
import time
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CircuitState(Enum):
|
||||
CLOSED = "closed" # normal operation
|
||||
OPEN = "open" # failing, reject immediately
|
||||
HALF_OPEN = "half_open" # testing recovery
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""Prevents cascading failures by short-circuiting calls to an unhealthy dependency."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
failure_threshold: int = 5,
|
||||
recovery_timeout: float = 30.0,
|
||||
) -> None:
|
||||
self.failure_threshold = failure_threshold
|
||||
self.recovery_timeout = recovery_timeout
|
||||
self.state = CircuitState.CLOSED
|
||||
self.failure_count = 0
|
||||
self.last_failure_time = 0.0
|
||||
|
||||
def _trip(self) -> None:
|
||||
self.state = CircuitState.OPEN
|
||||
self.last_failure_time = time.monotonic()
|
||||
|
||||
def _reset(self) -> None:
|
||||
self.state = CircuitState.CLOSED
|
||||
self.failure_count = 0
|
||||
|
||||
async def call(self, fn, *args, fallback=None, **kwargs):
|
||||
if self.state == CircuitState.OPEN:
|
||||
if time.monotonic() - self.last_failure_time > self.recovery_timeout:
|
||||
self.state = CircuitState.HALF_OPEN
|
||||
else:
|
||||
if fallback is not None:
|
||||
return fallback() if callable(fallback) else fallback
|
||||
raise ExternalServiceError("circuit-breaker", RuntimeError("Circuit open"))
|
||||
|
||||
try:
|
||||
result = await fn(*args, **kwargs)
|
||||
if self.state == CircuitState.HALF_OPEN:
|
||||
self._reset()
|
||||
return result
|
||||
except Exception as exc:
|
||||
self.failure_count += 1
|
||||
if self.failure_count >= self.failure_threshold:
|
||||
self._trip()
|
||||
raise
|
||||
|
||||
|
||||
# Usage
|
||||
recommendations_circuit = CircuitBreaker(failure_threshold=3, recovery_timeout=60)
|
||||
|
||||
async def get_recommendations(user_id: str) -> list[dict]:
|
||||
return await recommendations_circuit.call(
|
||||
recommendation_service.fetch,
|
||||
user_id,
|
||||
fallback=lambda: [], # empty list when service is down
|
||||
)
|
||||
```
|
||||
|
||||
**Feature-flag degraded mode (TypeScript)**
|
||||
|
||||
```typescript
|
||||
interface FeatureFlags {
|
||||
enableRecommendations: boolean;
|
||||
enableRealTimeUpdates: boolean;
|
||||
enableAdvancedSearch: boolean;
|
||||
}
|
||||
|
||||
const defaultFlags: FeatureFlags = {
|
||||
enableRecommendations: true,
|
||||
enableRealTimeUpdates: true,
|
||||
enableAdvancedSearch: true,
|
||||
};
|
||||
|
||||
async function getFlags(): Promise<FeatureFlags> {
|
||||
try {
|
||||
const resp = await fetch("/api/feature-flags");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
return await resp.json();
|
||||
} catch {
|
||||
// Fall back to safe defaults when flag service is unavailable
|
||||
console.warn("Feature flag service unavailable, using defaults");
|
||||
return defaultFlags;
|
||||
}
|
||||
}
|
||||
|
||||
// Component that degrades gracefully
|
||||
function SearchPage() {
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BasicSearch />
|
||||
{flags.enableAdvancedSearch ? (
|
||||
<AdvancedFilters />
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
Advanced search is temporarily unavailable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. API Error Responses
|
||||
|
||||
Return consistent, machine-readable error payloads following RFC 7807 Problem Details.
|
||||
|
||||
**Python (FastAPI)**
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.status import (
|
||||
HTTP_400_BAD_REQUEST,
|
||||
HTTP_404_NOT_FOUND,
|
||||
HTTP_429_TOO_MANY_REQUESTS,
|
||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Map domain error codes to HTTP status codes
|
||||
STATUS_MAP: dict[ErrorCode, int] = {
|
||||
ErrorCode.NOT_FOUND: HTTP_404_NOT_FOUND,
|
||||
ErrorCode.VALIDATION_FAILED: HTTP_400_BAD_REQUEST,
|
||||
ErrorCode.DUPLICATE_ENTRY: 409,
|
||||
ErrorCode.UNAUTHORIZED: 401,
|
||||
ErrorCode.RATE_LIMITED: HTTP_429_TOO_MANY_REQUESTS,
|
||||
ErrorCode.EXTERNAL_SERVICE: 502,
|
||||
ErrorCode.INTERNAL: HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
status = STATUS_MAP.get(exc.code, 500)
|
||||
return JSONResponse(
|
||||
status_code=status,
|
||||
content={
|
||||
"type": f"https://docs.example.com/errors/{exc.code.value.lower()}",
|
||||
"title": exc.code.value.replace("_", " ").title(),
|
||||
"status": status,
|
||||
"detail": str(exc),
|
||||
**exc.details,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
# Never leak internal details to the client
|
||||
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"type": "https://docs.example.com/errors/internal",
|
||||
"title": "Internal Server Error",
|
||||
"status": 500,
|
||||
"detail": "An unexpected error occurred.",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**TypeScript (Express middleware)**
|
||||
|
||||
```typescript
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
const STATUS_MAP: Record<ErrorCode, number> = {
|
||||
NOT_FOUND: 404,
|
||||
VALIDATION_FAILED: 400,
|
||||
DUPLICATE_ENTRY: 409,
|
||||
UNAUTHORIZED: 401,
|
||||
RATE_LIMITED: 429,
|
||||
EXTERNAL_SERVICE: 502,
|
||||
INTERNAL: 500,
|
||||
};
|
||||
|
||||
function errorHandler(
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) {
|
||||
if (err instanceof AppError) {
|
||||
const status = STATUS_MAP[err.code] ?? 500;
|
||||
res.status(status).json({
|
||||
type: `https://docs.example.com/errors/${err.code.toLowerCase()}`,
|
||||
title: err.code.replace(/_/g, " ").toLowerCase(),
|
||||
status,
|
||||
detail: err.message,
|
||||
...err.details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Unhandled errors - log full details, return generic message
|
||||
console.error("Unhandled error:", err);
|
||||
res.status(500).json({
|
||||
type: "https://docs.example.com/errors/internal",
|
||||
title: "Internal Server Error",
|
||||
status: 500,
|
||||
detail: "An unexpected error occurred.",
|
||||
});
|
||||
}
|
||||
|
||||
// Register as the last middleware
|
||||
app.use(errorHandler);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Error Logging Integration
|
||||
|
||||
Attach structured context to errors so they are searchable and actionable in observability tools.
|
||||
|
||||
**Python - Structured error logging**
|
||||
|
||||
```python
|
||||
import logging
|
||||
import traceback
|
||||
from contextvars import ContextVar
|
||||
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="unknown")
|
||||
|
||||
|
||||
class StructuredErrorLogger:
|
||||
"""Wraps the standard logger to attach error context automatically."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
self.logger = logging.getLogger(name)
|
||||
|
||||
def error(
|
||||
self,
|
||||
msg: str,
|
||||
*,
|
||||
exc: Exception | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> None:
|
||||
context = {
|
||||
"request_id": request_id_var.get(),
|
||||
**(extra or {}),
|
||||
}
|
||||
|
||||
if exc is not None:
|
||||
context["error_type"] = type(exc).__name__
|
||||
context["error_message"] = str(exc)
|
||||
context["stacktrace"] = traceback.format_exception(exc)
|
||||
|
||||
if isinstance(exc, AppError):
|
||||
context["error_code"] = exc.code.value
|
||||
context["error_details"] = exc.details
|
||||
|
||||
self.logger.error(msg, extra={"structured": context}, exc_info=exc)
|
||||
|
||||
|
||||
# Usage in a FastAPI middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import uuid
|
||||
|
||||
log = StructuredErrorLogger(__name__)
|
||||
|
||||
|
||||
class ErrorLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
rid = request.headers.get("x-request-id", str(uuid.uuid4()))
|
||||
request_id_var.set(rid)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"Request failed",
|
||||
exc=exc,
|
||||
extra={"method": request.method, "path": request.url.path},
|
||||
)
|
||||
raise
|
||||
```
|
||||
|
||||
**TypeScript - Error context enrichment**
|
||||
|
||||
```typescript
|
||||
interface ErrorContext {
|
||||
requestId?: string;
|
||||
userId?: string;
|
||||
operation?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function logError(
|
||||
message: string,
|
||||
error: unknown,
|
||||
context: ErrorContext = {}
|
||||
): void {
|
||||
const payload: Record<string, unknown> = {
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...context,
|
||||
};
|
||||
|
||||
if (error instanceof AppError) {
|
||||
payload.errorCode = error.code;
|
||||
payload.errorMessage = error.message;
|
||||
payload.errorDetails = error.details;
|
||||
} else if (error instanceof Error) {
|
||||
payload.errorType = error.name;
|
||||
payload.errorMessage = error.message;
|
||||
payload.stack = error.stack;
|
||||
} else {
|
||||
payload.errorRaw = String(error);
|
||||
}
|
||||
|
||||
// Structured JSON log for ingestion by Datadog, CloudWatch, etc.
|
||||
console.error(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// Usage
|
||||
try {
|
||||
await processOrder(orderId);
|
||||
} catch (error) {
|
||||
logError("Order processing failed", error, {
|
||||
requestId: req.headers["x-request-id"],
|
||||
operation: "processOrder",
|
||||
orderId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Result Pattern
|
||||
|
||||
Use a Result type for operations where failure is an expected outcome. Avoids exception overhead and makes the failure path explicit in the type signature.
|
||||
|
||||
**Python - Using the `result` library**
|
||||
|
||||
```python
|
||||
# pip install result
|
||||
from result import Ok, Err, Result
|
||||
|
||||
|
||||
def parse_age(value: str) -> Result[int, str]:
|
||||
"""Parse a string to a valid age. Returns Err for invalid input."""
|
||||
try:
|
||||
age = int(value)
|
||||
except ValueError:
|
||||
return Err(f"'{value}' is not a number")
|
||||
|
||||
if age < 0 or age > 150:
|
||||
return Err(f"Age {age} is out of valid range (0-150)")
|
||||
|
||||
return Ok(age)
|
||||
|
||||
|
||||
def validate_registration(data: dict) -> Result[dict, list[str]]:
|
||||
"""Validate all fields, collecting every error instead of failing on the first."""
|
||||
errors: list[str] = []
|
||||
|
||||
match parse_age(data.get("age", "")):
|
||||
case Ok(age):
|
||||
data["age"] = age
|
||||
case Err(msg):
|
||||
errors.append(msg)
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
errors.append("Name is required")
|
||||
if len(name) > 100:
|
||||
errors.append("Name must be 100 characters or fewer")
|
||||
|
||||
if errors:
|
||||
return Err(errors)
|
||||
return Ok(data)
|
||||
|
||||
|
||||
# Caller handles both paths explicitly
|
||||
match validate_registration(form_data):
|
||||
case Ok(valid):
|
||||
user = create_user(valid)
|
||||
case Err(errs):
|
||||
return {"errors": errs}, 400
|
||||
```
|
||||
|
||||
**TypeScript - Discriminated union Result**
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: E };
|
||||
|
||||
function ok<T>(value: T): Result<T, never> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function err<E>(error: E): Result<never, E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
// Usage
|
||||
function parseAge(input: string): Result<number, string> {
|
||||
const age = Number(input);
|
||||
if (Number.isNaN(age)) return err(`'${input}' is not a number`);
|
||||
if (age < 0 || age > 150) return err(`Age ${age} out of range (0-150)`);
|
||||
return ok(age);
|
||||
}
|
||||
|
||||
function validateRegistration(
|
||||
data: Record<string, string>
|
||||
): Result<{ name: string; age: number }, string[]> {
|
||||
const errors: string[] = [];
|
||||
|
||||
const ageResult = parseAge(data.age ?? "");
|
||||
if (!ageResult.ok) errors.push(ageResult.error);
|
||||
|
||||
const name = data.name?.trim() ?? "";
|
||||
if (!name) errors.push("Name is required");
|
||||
if (name.length > 100) errors.push("Name must be 100 characters or fewer");
|
||||
|
||||
if (errors.length > 0) return err(errors);
|
||||
return ok({ name, age: (ageResult as { ok: true; value: number }).value });
|
||||
}
|
||||
|
||||
// Caller
|
||||
const result = validateRegistration(formData);
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ errors: result.error });
|
||||
}
|
||||
const user = await createUser(result.value);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Catch specific exceptions, not bare `except` or `catch`.** A catch-all hides bugs. Catch only the errors you know how to handle and let everything else propagate.
|
||||
|
||||
2. **Translate errors at architectural boundaries.** A database `IntegrityError` should become a domain `DuplicateEntryError` at the repository layer, then an HTTP 409 at the API layer. Each layer speaks its own error language.
|
||||
|
||||
3. **Preserve the original cause.** Always chain the original exception (`raise X from original` in Python, `{ cause }` in TypeScript) so the root cause is visible in logs and debuggers.
|
||||
|
||||
4. **Fail fast, recover high.** Detect errors as early as possible (validate inputs at the boundary) but handle them at the highest level that has enough context to decide what to do (e.g., return an HTTP response, show a fallback UI).
|
||||
|
||||
5. **Never swallow errors silently.** An empty `except: pass` or `catch {}` is almost always a bug. At minimum, log the error. If you intentionally ignore it, leave a comment explaining why.
|
||||
|
||||
6. **Use the Result pattern for expected failures.** When a function can legitimately fail (parsing, validation, lookups), return a Result instead of throwing. Reserve exceptions for truly unexpected situations.
|
||||
|
||||
7. **Make errors actionable.** Every error message should help the reader fix the problem. Include what happened, what was expected, and what the caller can do about it. `"User not found"` is worse than `"User with id '123' not found. Verify the id and check that the user has not been deleted."`.
|
||||
|
||||
8. **Test the error paths.** Write explicit tests for every error branch. Verify the error type, message, and status code. Error paths that are never tested are error paths that will break in production.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Catching too broadly.** Using `except Exception` or `catch (e: any)` silences programming errors like `TypeError` or `ReferenceError` that should crash loudly during development.
|
||||
|
||||
2. **Logging and re-throwing without deduplication.** If every layer logs the same error, you get five log entries for one failure. Log at the outermost handler and let inner layers propagate.
|
||||
|
||||
3. **Returning error data in the wrong shape.** Mixing `{ error: "..." }`, `{ message: "..." }`, and `{ errors: [...] }` across endpoints forces every client to handle multiple formats. Pick one shape and enforce it globally.
|
||||
|
||||
4. **Leaking internal details to clients.** Stack traces, database table names, and file paths in API responses are a security risk. Sanitize errors before they leave the server.
|
||||
|
||||
5. **Retrying non-idempotent operations.** Retrying a `POST /orders` that partially succeeded can create duplicate orders. Only retry operations that are safe to repeat, or use idempotency keys.
|
||||
|
||||
6. **Ignoring async error boundaries.** In React, error boundaries do not catch errors inside event handlers or async callbacks. Use try/catch inside `onClick`, `useEffect` cleanup, and promise chains separately.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/logging` - Structured logging setup and conventions
|
||||
- `patterns/api-client` - HTTP client wrappers with built-in error handling
|
||||
- `security/owasp` - Preventing information leakage through error messages
|
||||
- `languages/python` - Python exception syntax and idioms
|
||||
- `languages/typescript` - TypeScript error types and narrowing
|
||||
@@ -0,0 +1,170 @@
|
||||
# Error Taxonomy Quick Reference
|
||||
|
||||
## Two Fundamental Categories
|
||||
|
||||
### Operational Errors (Expected, Recoverable)
|
||||
|
||||
Errors that occur in correctly written programs due to external conditions.
|
||||
|
||||
**Response**: Handle gracefully. Log, retry, return error to user.
|
||||
|
||||
| Category | Description | HTTP Status | Example |
|
||||
|----------|-------------|-------------|---------|
|
||||
| Validation | Invalid input data | `400` | Missing required field, wrong format |
|
||||
| Authentication | Identity not established | `401` | Missing/expired/invalid token |
|
||||
| Authorization | Insufficient permissions | `403` | User lacks role for this action |
|
||||
| Not Found | Resource does not exist | `404` | Item with given ID not in DB |
|
||||
| Conflict | State conflict | `409` | Duplicate email, concurrent edit |
|
||||
| Rate Limit | Too many requests | `429` | API quota exceeded |
|
||||
| Payload Too Large | Request body too big | `413` | File upload exceeds limit |
|
||||
| Unprocessable | Valid syntax, invalid semantics | `422` | Transfer amount exceeds balance |
|
||||
| External Dependency | Third-party service failed | `502` / `503` | Payment gateway timeout |
|
||||
| Service Unavailable | System overloaded or in maintenance | `503` | DB connection pool exhausted |
|
||||
|
||||
### Programmer Errors (Bugs, Fix the Code)
|
||||
|
||||
Errors caused by mistakes in the code itself.
|
||||
|
||||
**Response**: Fix the code. Do NOT catch and continue. Crash, log, alert.
|
||||
|
||||
| Category | Example | Fix |
|
||||
|----------|---------|-----|
|
||||
| TypeError | Calling method on undefined | Add null check or fix data flow |
|
||||
| ReferenceError | Using undeclared variable | Fix variable name/scope |
|
||||
| Assertion failure | Invariant violated | Fix logic that broke invariant |
|
||||
| Wrong argument type | Passing string where number expected | Fix caller or add validation |
|
||||
| Missing error handling | Unhandled promise rejection | Add try/catch or .catch() |
|
||||
| Off-by-one | Array index out of bounds | Fix loop/index logic |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling by Category
|
||||
|
||||
### Validation Errors (400)
|
||||
|
||||
```python
|
||||
# Python/FastAPI
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
class CreateUser(BaseModel):
|
||||
email: str
|
||||
age: int
|
||||
|
||||
@validator("age")
|
||||
def validate_age(cls, v):
|
||||
if v < 0 or v > 150:
|
||||
raise ValueError("Age must be between 0 and 150")
|
||||
return v
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript
|
||||
class ValidationError extends AppError {
|
||||
constructor(public fields: Record<string, string>) {
|
||||
super("Validation failed", 400);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
| Scenario | Response | Action |
|
||||
|----------|----------|--------|
|
||||
| No token provided | 401 + `WWW-Authenticate` header | Client should authenticate |
|
||||
| Token expired | 401 + error code `token_expired` | Client should refresh token |
|
||||
| Token invalid | 401 + error code `invalid_token` | Client should re-authenticate |
|
||||
|
||||
### Not Found (404)
|
||||
|
||||
| Scenario | Use 404? |
|
||||
|----------|----------|
|
||||
| Resource by ID doesn't exist | Yes |
|
||||
| Search returns no results | **No** -- return empty list with 200 |
|
||||
| Resource soft-deleted | Depends on visibility rules |
|
||||
| User lacks access to resource | Consider 403, or 404 to hide existence |
|
||||
|
||||
### Conflict (409)
|
||||
|
||||
| Scenario | Resolution |
|
||||
|----------|------------|
|
||||
| Duplicate unique field | Return which field conflicts |
|
||||
| Optimistic locking failure | Return current version, client retries |
|
||||
| State transition invalid | Return current state and valid transitions |
|
||||
|
||||
### External Dependency (502/503)
|
||||
|
||||
| Strategy | When |
|
||||
|----------|------|
|
||||
| Retry with backoff | Transient failures (timeouts, 503) |
|
||||
| Circuit breaker | Repeated failures from same service |
|
||||
| Fallback / degraded mode | Non-critical dependency |
|
||||
| Queue for later | Async-compatible operations |
|
||||
|
||||
---
|
||||
|
||||
## Error Response Format
|
||||
|
||||
### Standard Error Response (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"details": [
|
||||
{ "field": "email", "message": "Invalid email format" },
|
||||
{ "field": "age", "message": "Must be a positive number" }
|
||||
],
|
||||
"request_id": "req_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Code Convention
|
||||
|
||||
| Code Pattern | Meaning |
|
||||
|-------------|---------|
|
||||
| `VALIDATION_ERROR` | Input validation failed |
|
||||
| `AUTH_TOKEN_EXPIRED` | Token needs refresh |
|
||||
| `RESOURCE_NOT_FOUND` | Entity doesn't exist |
|
||||
| `RATE_LIMIT_EXCEEDED` | Throttled |
|
||||
| `CONFLICT_DUPLICATE` | Uniqueness violation |
|
||||
| `INTERNAL_ERROR` | Unexpected server error (hide details) |
|
||||
|
||||
---
|
||||
|
||||
## Decision Guide
|
||||
|
||||
```
|
||||
Error occurred
|
||||
|
|
||||
+--> Can the program continue safely?
|
||||
| |
|
||||
| +--> YES (operational error)
|
||||
| | |
|
||||
| | +--> Is it the client's fault? --> 4xx
|
||||
| | +--> Is it our fault? --> 5xx
|
||||
| | +--> Is it a dependency? --> 502/503
|
||||
| |
|
||||
| +--> NO (programmer error)
|
||||
| |
|
||||
| +--> Log full stack trace
|
||||
| +--> Return generic 500 to client
|
||||
| +--> Alert on-call
|
||||
| +--> Fix the code
|
||||
|
|
||||
+--> Should the client see details?
|
||||
|
|
||||
+--> 4xx: Yes, help them fix their request
|
||||
+--> 5xx: No, generic message + request_id for support
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Why It's Bad | Instead |
|
||||
|-------------|-------------|---------|
|
||||
| Catch-all silently | Hides bugs | Catch specific errors, rethrow unknown |
|
||||
| Return 200 with error body | Breaks HTTP semantics | Use proper status codes |
|
||||
| Expose stack traces in prod | Security risk | Log internally, return request_id |
|
||||
| String error matching | Fragile, breaks on message change | Use error codes/classes |
|
||||
| Catch and log only | Request hangs or returns wrong data | Handle or propagate |
|
||||
@@ -0,0 +1,962 @@
|
||||
---
|
||||
name: logging
|
||||
description: >
|
||||
Structured logging patterns for Python and Node.js applications. Use this skill when setting up loggers, choosing log levels, implementing correlation IDs for request tracing, redacting sensitive data from logs, or configuring log aggregation. Trigger whenever code uses console.log, print(), logging module, winston, pino, structlog, or any logging library. Also applies when building observability, debugging production issues, or adding telemetry.
|
||||
---
|
||||
|
||||
# Logging
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up structured logging in a new application or service
|
||||
- Replacing `console.log` or `print()` with proper logging infrastructure
|
||||
- Adding request tracing with correlation IDs across microservices
|
||||
- Redacting sensitive data (passwords, tokens, PII) from log output
|
||||
- Building observability pipelines with log aggregation (ELK, Datadog, CloudWatch)
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Static analysis or linting tasks that do not involve runtime output
|
||||
- Pure computation functions where logging would add unnecessary noise
|
||||
- Test assertions — use testing frameworks' built-in assertion messages, not log output
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Structured Logging Setup
|
||||
|
||||
Structured logging outputs machine-parseable JSON instead of free-form strings. This enables searching, filtering, and alerting in log aggregation systems.
|
||||
|
||||
#### Python with structlog
|
||||
|
||||
```python
|
||||
# logging_config.py
|
||||
import logging
|
||||
import structlog
|
||||
|
||||
def configure_logging(log_level: str = "INFO", json_output: bool = True) -> None:
|
||||
"""Configure structured logging for the application.
|
||||
|
||||
Call this once at application startup, before any loggers are created.
|
||||
"""
|
||||
# Set the stdlib logging level as the baseline filter
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
level=getattr(logging, log_level.upper()),
|
||||
)
|
||||
|
||||
# Choose renderers based on environment
|
||||
if json_output:
|
||||
renderer = structlog.processors.JSONRenderer()
|
||||
else:
|
||||
# Human-readable output for local development
|
||||
renderer = structlog.dev.ConsoleRenderer(colors=True)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
renderer,
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# Usage anywhere in the application
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
async def create_user(email: str) -> User:
|
||||
logger.info("creating_user", email=email)
|
||||
user = await user_repo.create(email=email)
|
||||
logger.info("user_created", user_id=user.id, email=email)
|
||||
return user
|
||||
```
|
||||
|
||||
**Output (JSON mode):**
|
||||
```json
|
||||
{"event": "user_created", "user_id": 42, "email": "alice@example.com", "logger": "app.services.user", "level": "info", "timestamp": "2025-06-15T10:30:00.123Z"}
|
||||
```
|
||||
|
||||
#### Node.js with pino
|
||||
|
||||
```typescript
|
||||
// logger.ts
|
||||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
// Use pretty printing only in development
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? { target: "pino-pretty", options: { colorize: true } }
|
||||
: undefined,
|
||||
// Base fields included in every log line
|
||||
base: {
|
||||
service: process.env.SERVICE_NAME ?? "api",
|
||||
version: process.env.APP_VERSION ?? "unknown",
|
||||
},
|
||||
// Customize serialization
|
||||
serializers: {
|
||||
err: pino.stdSerializers.err,
|
||||
req: pino.stdSerializers.req,
|
||||
res: pino.stdSerializers.res,
|
||||
},
|
||||
// Redact sensitive fields (see Pattern 4)
|
||||
redact: ["req.headers.authorization", "req.headers.cookie"],
|
||||
});
|
||||
|
||||
// Create child loggers for specific modules
|
||||
export function createLogger(module: string): pino.Logger {
|
||||
return logger.child({ module });
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage in a service
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("user-service");
|
||||
|
||||
export async function createUser(email: string): Promise<User> {
|
||||
log.info({ email }, "creating_user");
|
||||
const user = await userRepo.create({ email });
|
||||
log.info({ userId: user.id, email }, "user_created");
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**Output (JSON):**
|
||||
```json
|
||||
{"level":30,"time":1718444400123,"service":"api","module":"user-service","userId":42,"email":"alice@example.com","msg":"user_created"}
|
||||
```
|
||||
|
||||
### 2. Log Levels
|
||||
|
||||
Use log levels consistently to control verbosity and enable filtering in production.
|
||||
|
||||
| Level | When to Use | Example |
|
||||
|-------|-------------|---------|
|
||||
| `DEBUG` | Detailed diagnostic information useful only during development or debugging | Variable values, SQL queries, cache hits/misses |
|
||||
| `INFO` | Confirmation that things are working as expected | Request received, user created, job completed |
|
||||
| `WARNING` | Something unexpected happened but the application can continue | Deprecated API called, retry attempt, approaching rate limit |
|
||||
| `ERROR` | A specific operation failed but the application continues running | Database query failed, external API returned 500, payment declined |
|
||||
| `CRITICAL` | The application cannot continue or is in an unrecoverable state | Database connection pool exhausted, out of disk space, configuration missing |
|
||||
|
||||
#### Python examples
|
||||
|
||||
```python
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# DEBUG: Detailed internals for troubleshooting
|
||||
logger.debug("cache_lookup", key="user:42", hit=True, ttl_remaining=120)
|
||||
|
||||
# INFO: Normal business events
|
||||
logger.info("order_placed", order_id="ORD-123", total=99.99, items=3)
|
||||
|
||||
# WARNING: Degraded but functional
|
||||
logger.warning(
|
||||
"rate_limit_approaching",
|
||||
current_rate=450,
|
||||
limit=500,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
# ERROR: Operation failed, needs attention
|
||||
logger.error(
|
||||
"payment_failed",
|
||||
order_id="ORD-123",
|
||||
provider="stripe",
|
||||
error_code="card_declined",
|
||||
exc_info=True, # Include stack trace
|
||||
)
|
||||
|
||||
# CRITICAL: System-level failure
|
||||
logger.critical(
|
||||
"database_pool_exhausted",
|
||||
active_connections=100,
|
||||
max_connections=100,
|
||||
waiting_requests=47,
|
||||
)
|
||||
```
|
||||
|
||||
#### TypeScript examples
|
||||
|
||||
```typescript
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("order-service");
|
||||
|
||||
// DEBUG: Internal details
|
||||
log.debug({ key: "user:42", hit: true, ttlRemaining: 120 }, "cache_lookup");
|
||||
|
||||
// INFO: Normal events
|
||||
log.info({ orderId: "ORD-123", total: 99.99, items: 3 }, "order_placed");
|
||||
|
||||
// WARNING: Degraded state
|
||||
log.warn(
|
||||
{ currentRate: 450, limit: 500, windowSeconds: 60 },
|
||||
"rate_limit_approaching"
|
||||
);
|
||||
|
||||
// ERROR: Operation failure
|
||||
log.error(
|
||||
{ orderId: "ORD-123", provider: "stripe", errorCode: "card_declined" },
|
||||
"payment_failed"
|
||||
);
|
||||
|
||||
// FATAL: Unrecoverable
|
||||
log.fatal(
|
||||
{ activeConnections: 100, maxConnections: 100, waitingRequests: 47 },
|
||||
"database_pool_exhausted"
|
||||
);
|
||||
```
|
||||
|
||||
**Level selection rule of thumb:** If you would page someone at 3 AM, it is ERROR or CRITICAL. If it is useful context for investigating an issue, it is INFO. If it is only useful when actively debugging a specific problem, it is DEBUG.
|
||||
|
||||
### 3. Correlation IDs
|
||||
|
||||
Correlation IDs (also called request IDs or trace IDs) tie together all log entries from a single request as it flows through your system.
|
||||
|
||||
#### Python — FastAPI middleware with contextvars
|
||||
|
||||
```python
|
||||
# middleware/correlation.py
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
# Context variable accessible from any async task in the same request
|
||||
correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="")
|
||||
|
||||
class CorrelationIDMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Accept an incoming correlation ID or generate a new one
|
||||
correlation_id = request.headers.get("X-Correlation-ID", uuid.uuid4().hex)
|
||||
correlation_id_var.set(correlation_id)
|
||||
|
||||
# Bind to structlog context so all logs in this request include it
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(correlation_id=correlation_id)
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
return response
|
||||
```
|
||||
|
||||
```python
|
||||
# Register the middleware
|
||||
from middleware.correlation import CorrelationIDMiddleware
|
||||
|
||||
app.add_middleware(CorrelationIDMiddleware)
|
||||
```
|
||||
|
||||
```python
|
||||
# Any logger call in any module now includes correlation_id automatically
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
async def get_user(user_id: int) -> User:
|
||||
logger.info("fetching_user", user_id=user_id)
|
||||
# Output: {"event": "fetching_user", "user_id": 42, "correlation_id": "a1b2c3d4...", ...}
|
||||
return await user_repo.get(user_id)
|
||||
```
|
||||
|
||||
#### TypeScript — Express middleware with AsyncLocalStorage
|
||||
|
||||
```typescript
|
||||
// middleware/correlation.ts
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "../logger";
|
||||
|
||||
interface RequestContext {
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
export const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export function correlationMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const correlationId =
|
||||
(req.headers["x-correlation-id"] as string) ?? randomUUID();
|
||||
|
||||
res.setHeader("X-Correlation-ID", correlationId);
|
||||
|
||||
asyncLocalStorage.run({ correlationId }, () => {
|
||||
next();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// logger.ts — augment the logger to include correlation ID
|
||||
import { asyncLocalStorage } from "./middleware/correlation";
|
||||
|
||||
export function getContextLogger(): pino.Logger {
|
||||
const store = asyncLocalStorage.getStore();
|
||||
if (store) {
|
||||
return logger.child({ correlationId: store.correlationId });
|
||||
}
|
||||
return logger;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage in any module
|
||||
import { getContextLogger } from "./logger";
|
||||
|
||||
export async function getUser(userId: number): Promise<User> {
|
||||
const log = getContextLogger();
|
||||
log.info({ userId }, "fetching_user");
|
||||
// Output includes correlationId automatically
|
||||
return await userRepo.findById(userId);
|
||||
}
|
||||
```
|
||||
|
||||
#### Propagating to downstream services
|
||||
|
||||
When calling other microservices, forward the correlation ID:
|
||||
|
||||
```python
|
||||
# Python — httpx client
|
||||
import httpx
|
||||
from middleware.correlation import correlation_id_var
|
||||
|
||||
async def call_billing_service(user_id: int) -> dict:
|
||||
correlation_id = correlation_id_var.get()
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"http://billing-service/api/v1/invoices?user_id={user_id}",
|
||||
headers={"X-Correlation-ID": correlation_id},
|
||||
)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
```typescript
|
||||
// TypeScript — fetch with correlation ID
|
||||
import { asyncLocalStorage } from "./middleware/correlation";
|
||||
|
||||
export async function callBillingService(userId: number): Promise<Invoice[]> {
|
||||
const store = asyncLocalStorage.getStore();
|
||||
const response = await fetch(
|
||||
`http://billing-service/api/v1/invoices?user_id=${userId}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Correlation-ID": store?.correlationId ?? "",
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Sensitive Data Redaction
|
||||
|
||||
Never log passwords, API keys, tokens, credit card numbers, or personally identifiable information (PII). Build redaction into the logging pipeline so developers cannot accidentally leak secrets.
|
||||
|
||||
#### Python — structlog processor
|
||||
|
||||
```python
|
||||
# processors/redact.py
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Patterns for sensitive field names (case-insensitive matching)
|
||||
SENSITIVE_KEYS = re.compile(
|
||||
r"(password|passwd|secret|token|api_key|apikey|authorization|"
|
||||
r"credit_card|card_number|cvv|ssn|social_security)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern for credit card numbers in string values
|
||||
CREDIT_CARD_PATTERN = re.compile(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b")
|
||||
|
||||
# Pattern for bearer tokens in string values
|
||||
BEARER_PATTERN = re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*", re.IGNORECASE)
|
||||
|
||||
|
||||
def redact_sensitive_data(
|
||||
logger: Any, method_name: str, event_dict: dict
|
||||
) -> dict:
|
||||
"""Structlog processor that masks sensitive values."""
|
||||
return _redact_dict(event_dict)
|
||||
|
||||
|
||||
def _redact_dict(data: dict) -> dict:
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if SENSITIVE_KEYS.search(key):
|
||||
result[key] = "***REDACTED***"
|
||||
elif isinstance(value, dict):
|
||||
result[key] = _redact_dict(value)
|
||||
elif isinstance(value, str):
|
||||
result[key] = _redact_string(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
_redact_dict(item) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _redact_string(value: str) -> str:
|
||||
value = CREDIT_CARD_PATTERN.sub("****-****-****-****", value)
|
||||
value = BEARER_PATTERN.sub("Bearer ***REDACTED***", value)
|
||||
return value
|
||||
```
|
||||
|
||||
```python
|
||||
# Add the processor to structlog configuration
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
redact_sensitive_data, # Add before the renderer
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
# ...
|
||||
)
|
||||
```
|
||||
|
||||
#### TypeScript — pino redaction
|
||||
|
||||
```typescript
|
||||
// pino has built-in redaction support
|
||||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: "info",
|
||||
redact: {
|
||||
paths: [
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"apiKey",
|
||||
"authorization",
|
||||
"creditCard",
|
||||
"req.headers.authorization",
|
||||
"req.headers.cookie",
|
||||
"body.password",
|
||||
"body.creditCardNumber",
|
||||
"*.password",
|
||||
"*.secret",
|
||||
],
|
||||
censor: "***REDACTED***",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For more complex redaction (regex-based), use a custom serializer:
|
||||
|
||||
```typescript
|
||||
// redact.ts
|
||||
const CREDIT_CARD_RE = /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g;
|
||||
const BEARER_RE = /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi;
|
||||
|
||||
export function redactValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
let result = value.replace(CREDIT_CARD_RE, "****-****-****-****");
|
||||
result = result.replace(BEARER_RE, "Bearer ***REDACTED***");
|
||||
return result;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return redactObject(value as Record<string, unknown>);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEYS =
|
||||
/^(password|passwd|secret|token|api_?key|authorization|credit_?card|cvv|ssn)$/i;
|
||||
|
||||
function redactObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEYS.test(key)) {
|
||||
result[key] = "***REDACTED***";
|
||||
} else {
|
||||
result[key] = redactValue(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Request/Response Logging
|
||||
|
||||
Log every HTTP request and response with method, path, status code, duration, and body size. This is the single most valuable log line for production debugging.
|
||||
|
||||
#### Python — FastAPI middleware
|
||||
|
||||
```python
|
||||
# middleware/request_logging.py
|
||||
import time
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = structlog.get_logger("http")
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Log request
|
||||
logger.info(
|
||||
"http_request_started",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
query=str(request.url.query) or None,
|
||||
client_ip=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
logger.error(
|
||||
"http_request_failed",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
content_length = response.headers.get("content-length")
|
||||
|
||||
# Choose log level based on status code
|
||||
log_method = logger.info
|
||||
if response.status_code >= 500:
|
||||
log_method = logger.error
|
||||
elif response.status_code >= 400:
|
||||
log_method = logger.warning
|
||||
|
||||
log_method(
|
||||
"http_request_completed",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=response.status_code,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
content_length=int(content_length) if content_length else None,
|
||||
)
|
||||
|
||||
return response
|
||||
```
|
||||
|
||||
#### TypeScript — Express middleware
|
||||
|
||||
```typescript
|
||||
// middleware/request-logging.ts
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { getContextLogger } from "../logger";
|
||||
|
||||
export function requestLogging(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const start = process.hrtime.bigint();
|
||||
const log = getContextLogger();
|
||||
|
||||
log.info(
|
||||
{
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
clientIp: req.ip,
|
||||
userAgent: req.get("user-agent"),
|
||||
},
|
||||
"http_request_started"
|
||||
);
|
||||
|
||||
// Hook into the response finish event
|
||||
res.on("finish", () => {
|
||||
const durationMs =
|
||||
Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
|
||||
const logFn =
|
||||
res.statusCode >= 500
|
||||
? log.error.bind(log)
|
||||
: res.statusCode >= 400
|
||||
? log.warn.bind(log)
|
||||
: log.info.bind(log);
|
||||
|
||||
logFn(
|
||||
{
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Math.round(durationMs * 100) / 100,
|
||||
contentLength: res.get("content-length")
|
||||
? parseInt(res.get("content-length")!, 10)
|
||||
: undefined,
|
||||
},
|
||||
"http_request_completed"
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Register middleware (order matters)
|
||||
app.use(correlationMiddleware);
|
||||
app.use(requestLogging);
|
||||
```
|
||||
|
||||
### 6. Error Logging
|
||||
|
||||
When logging errors, always include the stack trace, relevant context, and enough information to reproduce the issue without accessing the production environment.
|
||||
|
||||
#### Python — exception logging with context
|
||||
|
||||
```python
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
async def process_order(order_id: str) -> Order:
|
||||
logger.info("processing_order", order_id=order_id)
|
||||
|
||||
try:
|
||||
order = await order_repo.get(order_id)
|
||||
if not order:
|
||||
logger.error("order_not_found", order_id=order_id)
|
||||
raise OrderNotFoundError(order_id)
|
||||
|
||||
payment = await payment_service.charge(
|
||||
amount=order.total,
|
||||
currency=order.currency,
|
||||
customer_id=order.customer_id,
|
||||
)
|
||||
logger.info(
|
||||
"payment_processed",
|
||||
order_id=order_id,
|
||||
payment_id=payment.id,
|
||||
amount=order.total,
|
||||
)
|
||||
|
||||
except PaymentError as exc:
|
||||
# Log the error with full context for debugging
|
||||
logger.error(
|
||||
"payment_failed",
|
||||
order_id=order_id,
|
||||
customer_id=order.customer_id,
|
||||
amount=order.total,
|
||||
error_code=exc.code,
|
||||
error_message=str(exc),
|
||||
exc_info=True, # Includes full stack trace
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
# Catch-all for unexpected errors
|
||||
logger.exception(
|
||||
"order_processing_unexpected_error",
|
||||
order_id=order_id,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
```
|
||||
|
||||
#### TypeScript — error logging with breadcrumbs
|
||||
|
||||
```typescript
|
||||
import { getContextLogger } from "./logger";
|
||||
|
||||
const log = getContextLogger();
|
||||
|
||||
export async function processOrder(orderId: string): Promise<Order> {
|
||||
log.info({ orderId }, "processing_order");
|
||||
|
||||
try {
|
||||
const order = await orderRepo.findById(orderId);
|
||||
if (!order) {
|
||||
log.error({ orderId }, "order_not_found");
|
||||
throw new OrderNotFoundError(orderId);
|
||||
}
|
||||
|
||||
const payment = await paymentService.charge({
|
||||
amount: order.total,
|
||||
currency: order.currency,
|
||||
customerId: order.customerId,
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ orderId, paymentId: payment.id, amount: order.total },
|
||||
"payment_processed"
|
||||
);
|
||||
return order;
|
||||
} catch (err) {
|
||||
if (err instanceof PaymentError) {
|
||||
log.error(
|
||||
{
|
||||
orderId,
|
||||
errorCode: err.code,
|
||||
errorMessage: err.message,
|
||||
err, // pino serializes Error objects with stack traces
|
||||
},
|
||||
"payment_failed"
|
||||
);
|
||||
} else {
|
||||
log.error(
|
||||
{
|
||||
orderId,
|
||||
err,
|
||||
errorType: (err as Error).constructor.name,
|
||||
},
|
||||
"order_processing_unexpected_error"
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key principle:** Log at the point where you have the most context, not at every layer. A single error log with full context is more useful than five partial logs scattered across the call stack.
|
||||
|
||||
### 7. Performance Logging
|
||||
|
||||
Track operation durations to identify slow endpoints, queries, and external calls.
|
||||
|
||||
#### Python — timing decorator
|
||||
|
||||
```python
|
||||
import functools
|
||||
import time
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger("performance")
|
||||
|
||||
F = TypeVar("F", bound=Callable)
|
||||
|
||||
def log_duration(operation: str, slow_threshold_ms: float = 1000.0):
|
||||
"""Decorator that logs the duration of a function call.
|
||||
|
||||
Args:
|
||||
operation: A descriptive name for the operation.
|
||||
slow_threshold_ms: Threshold in milliseconds above which
|
||||
the log level escalates to WARNING.
|
||||
"""
|
||||
def decorator(func: F) -> F:
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
log_fn = (
|
||||
logger.warning
|
||||
if duration_ms > slow_threshold_ms
|
||||
else logger.debug
|
||||
)
|
||||
log_fn(
|
||||
"operation_duration",
|
||||
operation=operation,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
slow=duration_ms > slow_threshold_ms,
|
||||
)
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
log_fn = (
|
||||
logger.warning
|
||||
if duration_ms > slow_threshold_ms
|
||||
else logger.debug
|
||||
)
|
||||
log_fn(
|
||||
"operation_duration",
|
||||
operation=operation,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
slow=duration_ms > slow_threshold_ms,
|
||||
)
|
||||
|
||||
import asyncio
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper # type: ignore
|
||||
return sync_wrapper # type: ignore
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Usage
|
||||
@log_duration("fetch_user_profile", slow_threshold_ms=200)
|
||||
async def get_user_profile(user_id: int) -> UserProfile:
|
||||
return await user_repo.get_with_preferences(user_id)
|
||||
```
|
||||
|
||||
#### Python — context manager for ad-hoc timing
|
||||
|
||||
```python
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger("performance")
|
||||
|
||||
@contextmanager
|
||||
def log_timing(operation: str, **extra_fields):
|
||||
"""Context manager for timing arbitrary code blocks."""
|
||||
start = time.perf_counter()
|
||||
yield
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
logger.info(
|
||||
"operation_duration",
|
||||
operation=operation,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
**extra_fields,
|
||||
)
|
||||
|
||||
# Usage
|
||||
async def rebuild_search_index():
|
||||
with log_timing("rebuild_search_index", index="products"):
|
||||
products = await product_repo.get_all()
|
||||
await search_service.reindex(products)
|
||||
```
|
||||
|
||||
#### TypeScript — timing wrapper
|
||||
|
||||
```typescript
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const perfLog = createLogger("performance");
|
||||
|
||||
export async function withTiming<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
slowThresholdMs = 1000
|
||||
): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await fn();
|
||||
return result;
|
||||
} finally {
|
||||
const durationMs = performance.now() - start;
|
||||
const logFn = durationMs > slowThresholdMs ? perfLog.warn : perfLog.debug;
|
||||
logFn(
|
||||
{
|
||||
operation,
|
||||
durationMs: Math.round(durationMs * 100) / 100,
|
||||
slow: durationMs > slowThresholdMs,
|
||||
},
|
||||
"operation_duration"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const profile = await withTiming("fetch_user_profile", () =>
|
||||
userRepo.getWithPreferences(userId)
|
||||
);
|
||||
```
|
||||
|
||||
#### Slow query logging
|
||||
|
||||
```python
|
||||
# Python — SQLAlchemy event listener for slow queries
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger("database")
|
||||
|
||||
SLOW_QUERY_THRESHOLD_MS = 500
|
||||
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.perf_counter())
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total_ms = (time.perf_counter() - conn.info["query_start_time"].pop()) * 1000
|
||||
if total_ms > SLOW_QUERY_THRESHOLD_MS:
|
||||
logger.warning(
|
||||
"slow_query",
|
||||
duration_ms=round(total_ms, 2),
|
||||
statement=statement[:500], # Truncate long queries
|
||||
parameters=str(parameters)[:200],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use structured logging from day one** — start with JSON output and key-value pairs instead of formatted strings. Switching from `f"User {user_id} created"` to `logger.info("user_created", user_id=user_id)` costs nothing upfront but saves hours when debugging in production.
|
||||
|
||||
2. **Log events, not sentences** — use snake_case event names (`order_placed`, `payment_failed`) rather than prose messages (`"An order was placed by the user"`). Event names are searchable, filterable, and easy to aggregate.
|
||||
|
||||
3. **Include the right context at the right level** — every log line should include enough context to be useful in isolation: relevant IDs (user, order, request), operation name, and outcome. Avoid logging the same error at every layer of the call stack.
|
||||
|
||||
4. **Set log levels per environment** — use DEBUG in development, INFO in staging, and INFO or WARNING in production. Never leave DEBUG enabled in production — it generates excessive volume and may expose sensitive internals.
|
||||
|
||||
5. **Centralize logging configuration** — configure loggers once at application startup, not in individual modules. Every module should call `get_logger(__name__)` and inherit the shared configuration.
|
||||
|
||||
6. **Always redact sensitive data** — build redaction into the logging pipeline as a processor or serializer. Do not rely on developers remembering to exclude passwords or tokens from log calls.
|
||||
|
||||
7. **Use correlation IDs for every request** — generate a unique ID at the entry point and propagate it through all downstream calls. This is the single most important pattern for debugging distributed systems.
|
||||
|
||||
8. **Set up log rotation and retention policies** — configure maximum file sizes, rotation intervals, and retention periods. Production logs without rotation will fill disks. Use log aggregation services (ELK, Datadog, CloudWatch) rather than relying on local files.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Logging sensitive data** — passwords, API keys, JWTs, credit card numbers, and PII end up in logs more often than expected. Once written, they persist in log storage and backups. Build redaction into the pipeline rather than relying on code review to catch every instance.
|
||||
|
||||
2. **Using print() or console.log in production** — `print()` in Python and `console.log` in Node.js write to stdout without timestamps, levels, or structure. They cannot be filtered, aggregated, or searched. Replace them with a proper logger before deploying.
|
||||
|
||||
3. **Logging too much at high levels** — setting every log call to INFO or ERROR creates alert fatigue and obscures real problems. Use DEBUG for diagnostic details and reserve ERROR for situations that require action.
|
||||
|
||||
4. **Missing stack traces on errors** — logging `str(exception)` loses the stack trace. In Python, use `exc_info=True` or `logger.exception()`. In pino, pass the error as `{ err }` to get the full stack serialized.
|
||||
|
||||
5. **Not testing log output** — logging code is code. If your redaction processor has a bug, secrets leak. Write unit tests that capture log output and assert on structure, redacted fields, and expected context.
|
||||
|
||||
6. **Synchronous logging in async applications** — writing to files or network sinks synchronously from an async event loop blocks request processing. Use async-compatible transports (pino's worker thread, structlog with stdlib async handlers) or write to stdout and let the infrastructure handle routing.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/error-handling` — Exception handling patterns that complement error logging
|
||||
- `patterns/api-client` — HTTP client patterns including logging outbound requests
|
||||
- `frameworks/fastapi` — FastAPI middleware setup for request logging and correlation IDs
|
||||
- `devops/docker` — Container logging drivers and log aggregation in Docker environments
|
||||
- `databases/postgresql` — Logging database queries and slow query detection
|
||||
- `databases/mongodb` — Logging database operations and aggregation pipelines
|
||||
@@ -0,0 +1,157 @@
|
||||
# Log Levels Quick Reference
|
||||
|
||||
## Level Summary
|
||||
|
||||
| Level | When to Use | Audience | Production Default |
|
||||
|-------|------------|----------|-------------------|
|
||||
| **DEBUG** | Detailed diagnostic info | Developers debugging | Off |
|
||||
| **INFO** | Routine operational events | Ops team monitoring | On |
|
||||
| **WARNING** | Something unexpected but handled | Ops + Devs | On |
|
||||
| **ERROR** | Operation failed, needs attention | On-call engineers | On |
|
||||
| **CRITICAL** | System is unusable or data at risk | On-call + management | On + alert |
|
||||
|
||||
---
|
||||
|
||||
## DEBUG
|
||||
|
||||
**Purpose**: Fine-grained information useful only when diagnosing problems.
|
||||
|
||||
**Turn on**: During local development or when investigating a specific issue.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Parsing config file: /etc/app/config.yaml` | `Entering function parse_config` |
|
||||
| `Cache miss for key user:123, fetching from DB` | `x = 5` |
|
||||
| `SQL: SELECT * FROM users WHERE id=$1 [params: 123]` | `Here we go!` |
|
||||
| `Retry attempt 2/3 for payment gateway` | `Debug debug debug` |
|
||||
| `JWT token expires at 2025-01-29T10:00:00Z` | `token = eyJhbG...` (secret!) |
|
||||
|
||||
**Rule**: Never log secrets, tokens, passwords, or PII at any level.
|
||||
|
||||
---
|
||||
|
||||
## INFO
|
||||
|
||||
**Purpose**: Confirm the system is working as expected. Key business events.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Server started on port 8080` | `Server is running` (which port? which version?) |
|
||||
| `User user:456 created account via OAuth (Google)` | `New user` |
|
||||
| `Order ord:789 placed, total=$45.00, items=3` | `Order created` |
|
||||
| `Migration v42 applied successfully (12 tables)` | `Migration done` |
|
||||
| `Scheduled job "daily-report" completed in 4.2s` | `Job finished` |
|
||||
| `Payment processed: txn:abc, amount=$99, method=card` | `Payment OK` |
|
||||
|
||||
**Rule**: Include enough context to answer "what happened, to what, and relevant numbers."
|
||||
|
||||
---
|
||||
|
||||
## WARNING
|
||||
|
||||
**Purpose**: Something unexpected happened, but the system handled it. May indicate a future problem.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Connection pool at 85% capacity (17/20)` | `Pool getting full` |
|
||||
| `Deprecated API v1 called by client app:legacy (use v2)` | `Old API used` |
|
||||
| `Disk space below 10% on /data (2.1 GB remaining)` | `Low disk` |
|
||||
| `Request took 4.8s (threshold: 5s) for GET /api/search` | `Slow request` |
|
||||
| `Config REDIS_URL missing, falling back to in-memory cache` | `No Redis` |
|
||||
| `Rate limit approaching for IP 10.0.0.5: 90/100 requests` | `Almost rate limited` |
|
||||
|
||||
**Rule**: Warnings should be actionable. If nobody would investigate, it's DEBUG or INFO.
|
||||
|
||||
---
|
||||
|
||||
## ERROR
|
||||
|
||||
**Purpose**: An operation failed. The system can continue, but something broke.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Failed to send email to user:123: SMTP timeout after 30s` | `Email error` |
|
||||
| `Payment declined for order:789: card_expired (Stripe)` | `Payment failed` |
|
||||
| `Database query timeout after 10s: SELECT FROM orders WHERE...` | `DB error` |
|
||||
| `File upload failed: S3 returned 503, bucket=media, key=img/456.jpg` | `Upload error` |
|
||||
| `Unhandled exception in POST /api/orders: ValueError("...")` | (stack trace only, no context) |
|
||||
|
||||
**Rule**: Include the operation, the target/ID, the error detail, and what was attempted.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
**Purpose**: System is unusable or data integrity is at risk. Requires immediate human intervention.
|
||||
|
||||
| Good | Bad |
|
||||
|------|-----|
|
||||
| `Database connection lost, all pools exhausted, 0/20 available` | `DB down` |
|
||||
| `Disk full on /data, writes failing, data loss possible` | `No disk space` |
|
||||
| `Security: 500 failed login attempts from IP 10.0.0.5 in 60s` | `Too many logins` |
|
||||
| `Data corruption detected: order:789 total=-$50.00` | `Bad data` |
|
||||
| `TLS certificate expires in 24h, auto-renewal failed` | `Cert expiring` |
|
||||
|
||||
**Rule**: Every CRITICAL log should trigger an alert (PagerDuty, Slack, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Structured Logging Format
|
||||
|
||||
### Python (structlog)
|
||||
|
||||
```python
|
||||
import structlog
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
log.info("order.placed", order_id="ord:789", total=45.00, items=3)
|
||||
log.error("email.send_failed", user_id="user:123", error="SMTP timeout", retry=2)
|
||||
```
|
||||
|
||||
### TypeScript (pino)
|
||||
|
||||
```typescript
|
||||
import pino from "pino";
|
||||
|
||||
const log = pino({ level: "info" });
|
||||
|
||||
log.info({ orderId: "ord:789", total: 45.0, items: 3 }, "order.placed");
|
||||
log.error({ userId: "user:123", err, retry: 2 }, "email.send_failed");
|
||||
```
|
||||
|
||||
### Key-Value Best Practices
|
||||
|
||||
| Field | Purpose | Example |
|
||||
|-------|---------|---------|
|
||||
| `event` / message | What happened | `"order.placed"` |
|
||||
| `request_id` | Trace across services | `"req_abc123"` |
|
||||
| `user_id` | Who triggered it | `"user:456"` |
|
||||
| `duration_ms` | How long it took | `142` |
|
||||
| `error` | Error message (not stack in prod) | `"connection refused"` |
|
||||
| `component` | Which module/service | `"payment-gateway"` |
|
||||
|
||||
---
|
||||
|
||||
## Configuration by Environment
|
||||
|
||||
| Environment | Minimum Level | Structured? | Destination |
|
||||
|-------------|--------------|-------------|-------------|
|
||||
| Local dev | DEBUG | No (human-readable) | stdout |
|
||||
| CI/Test | WARNING | No | stdout |
|
||||
| Staging | DEBUG | Yes (JSON) | Log aggregator |
|
||||
| Production | INFO | Yes (JSON) | Log aggregator |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Fix |
|
||||
|-------------|---------|-----|
|
||||
| Logging PII/secrets | Security/compliance violation | Redact or mask sensitive fields |
|
||||
| `log.error()` in a loop | Log flooding, storage cost | Log once with count |
|
||||
| `log.error("Error: " + err)` | Missing context, hard to search | Use structured fields |
|
||||
| Logging at wrong level | Alert fatigue or missed issues | Follow the guide above |
|
||||
| Catch-log-rethrow | Duplicate log entries | Log at the handling site only |
|
||||
| No request_id | Cannot correlate logs | Add correlation ID middleware |
|
||||
| Logging full request bodies | Performance, storage, PII risk | Log summary fields only |
|
||||
@@ -0,0 +1,782 @@
|
||||
---
|
||||
name: state-management
|
||||
description: >
|
||||
State management patterns for React and Python applications. Use this skill when choosing between useState, useReducer, context, Zustand, Jotai, or TanStack Query. Also applies to server state, form state, URL state, and Python application state with dataclasses and Pydantic. Trigger whenever someone asks about state architecture, global state, caching API responses, or managing complex form state.
|
||||
---
|
||||
|
||||
# State Management Patterns
|
||||
|
||||
## When to Use
|
||||
|
||||
- Choosing between local, shared, or global state in a React application
|
||||
- Setting up server state caching with TanStack Query or SWR
|
||||
- Building forms with validation, arrays, and nested fields
|
||||
- Syncing application state with URL search parameters
|
||||
- Designing Python domain models with dataclasses or Pydantic
|
||||
- Refactoring prop-drilling into a shared store
|
||||
- Deciding whether to add a state management library or keep things simple
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Static sites with no interactive state (pure content pages, docs)
|
||||
- Server-only rendering with no client-side interactivity
|
||||
- Simple CRUD backends where database is the only source of truth and there is no in-process state to manage
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Local vs Global State Decision Tree
|
||||
|
||||
Before reaching for a library, walk through this decision tree.
|
||||
|
||||
```
|
||||
Is the state used by a single component?
|
||||
├── YES --> useState or useReducer
|
||||
└── NO
|
||||
Is it shared by a parent and 1-2 direct children?
|
||||
├── YES --> Lift state up to the common parent, pass via props
|
||||
└── NO
|
||||
Is it server data (fetched from an API)?
|
||||
├── YES --> TanStack Query (useQuery / useMutation)
|
||||
└── NO
|
||||
Is it URL-representable (filters, pagination, tabs)?
|
||||
├── YES --> URL state (useSearchParams / nuqs)
|
||||
└── NO
|
||||
Is it form data with validation?
|
||||
├── YES --> react-hook-form + zod
|
||||
└── NO
|
||||
Zustand store (or Jotai for atomic state)
|
||||
```
|
||||
|
||||
**Rules of thumb:**
|
||||
|
||||
- Start with the simplest option. Only add a library when props become painful.
|
||||
- Server state and client state are different concerns. Never put fetched API data in Zustand; use TanStack Query instead.
|
||||
- URL state is free persistence. If the user should be able to bookmark or share the current view, put it in the URL.
|
||||
- Form state belongs to the form library. Do not mirror react-hook-form values in a Zustand store.
|
||||
|
||||
---
|
||||
|
||||
### 2. React State Patterns
|
||||
|
||||
**useState for simple values**
|
||||
|
||||
```typescript
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<button onClick={() => setCount((prev) => prev + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useReducer for complex state with multiple transitions**
|
||||
|
||||
```typescript
|
||||
interface TimerState {
|
||||
status: "idle" | "running" | "paused";
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
type TimerAction =
|
||||
| { type: "start" }
|
||||
| { type: "pause" }
|
||||
| { type: "reset" }
|
||||
| { type: "tick" };
|
||||
|
||||
function timerReducer(state: TimerState, action: TimerAction): TimerState {
|
||||
switch (action.type) {
|
||||
case "start":
|
||||
return { ...state, status: "running" };
|
||||
case "pause":
|
||||
return { ...state, status: "paused" };
|
||||
case "reset":
|
||||
return { status: "idle", elapsed: 0 };
|
||||
case "tick":
|
||||
return state.status === "running"
|
||||
? { ...state, elapsed: state.elapsed + 1 }
|
||||
: state;
|
||||
}
|
||||
}
|
||||
|
||||
function Timer() {
|
||||
const [state, dispatch] = useReducer(timerReducer, {
|
||||
status: "idle",
|
||||
elapsed: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "running") return;
|
||||
const id = setInterval(() => dispatch({ type: "tick" }), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [state.status]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>{state.elapsed}s</p>
|
||||
{state.status !== "running" && (
|
||||
<button onClick={() => dispatch({ type: "start" })}>Start</button>
|
||||
)}
|
||||
{state.status === "running" && (
|
||||
<button onClick={() => dispatch({ type: "pause" })}>Pause</button>
|
||||
)}
|
||||
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**When to pick which:**
|
||||
|
||||
| Criteria | useState | useReducer |
|
||||
|----------|----------|------------|
|
||||
| Single primitive value | Yes | Overkill |
|
||||
| Multiple related fields | Possible | Preferred |
|
||||
| Complex transitions | Messy | Clean |
|
||||
| Needs testing in isolation | Hard | Easy (test the reducer) |
|
||||
|
||||
---
|
||||
|
||||
### 3. Global State (Zustand)
|
||||
|
||||
Zustand is lightweight, TypeScript-friendly, and avoids the boilerplate of Redux.
|
||||
|
||||
**Basic store**
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand";
|
||||
|
||||
interface AuthStore {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (user: User, token: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthStore>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
login: (user, token) => set({ user, token }),
|
||||
logout: () => set({ user: null, token: null }),
|
||||
}));
|
||||
|
||||
// In components - use selectors to avoid unnecessary re-renders
|
||||
function UserMenu() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
if (!user) return <LoginButton />;
|
||||
return (
|
||||
<div>
|
||||
<span>{user.name}</span>
|
||||
<button onClick={logout}>Log out</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Slices pattern for large stores**
|
||||
|
||||
```typescript
|
||||
import { create, type StateCreator } from "zustand";
|
||||
import { devtools, persist } from "zustand/middleware";
|
||||
|
||||
// Each slice is its own interface + creator
|
||||
interface CartSlice {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
clearCart: () => void;
|
||||
}
|
||||
|
||||
interface UISlice {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const createCartSlice: StateCreator<
|
||||
CartSlice & UISlice,
|
||||
[],
|
||||
[],
|
||||
CartSlice
|
||||
> = (set) => ({
|
||||
items: [],
|
||||
addItem: (item) =>
|
||||
set((state) => ({ items: [...state.items, item] })),
|
||||
removeItem: (id) =>
|
||||
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
|
||||
clearCart: () => set({ items: [] }),
|
||||
});
|
||||
|
||||
const createUISlice: StateCreator<
|
||||
CartSlice & UISlice,
|
||||
[],
|
||||
[],
|
||||
UISlice
|
||||
> = (set) => ({
|
||||
sidebarOpen: false,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
});
|
||||
|
||||
// Combine slices with middleware
|
||||
const useAppStore = create<CartSlice & UISlice>()(
|
||||
devtools(
|
||||
persist(
|
||||
(...args) => ({
|
||||
...createCartSlice(...args),
|
||||
...createUISlice(...args),
|
||||
}),
|
||||
{
|
||||
name: "app-store",
|
||||
partialize: (state) => ({ items: state.items }), // only persist cart
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**Zustand best practices:**
|
||||
|
||||
- Always use selectors (`useStore((s) => s.field)`) instead of the whole store.
|
||||
- Keep stores small and focused. One store per domain, not one mega-store.
|
||||
- Use `persist` middleware for state that should survive page reloads.
|
||||
- Use `devtools` middleware in development for Redux DevTools integration.
|
||||
|
||||
---
|
||||
|
||||
### 4. Server State (TanStack Query)
|
||||
|
||||
Server state (data from APIs) has different needs than client state: caching, background refetching, deduplication, pagination.
|
||||
|
||||
**Basic query**
|
||||
|
||||
```typescript
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Query key conventions: [entity, ...params]
|
||||
const userKeys = {
|
||||
all: ["users"] as const,
|
||||
list: (filters: UserFilters) => ["users", "list", filters] as const,
|
||||
detail: (id: string) => ["users", "detail", id] as const,
|
||||
};
|
||||
|
||||
function useUser(id: string) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.detail(id),
|
||||
queryFn: () => api.users.get(id),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
function UserProfile({ id }: { id: string }) {
|
||||
const { data: user, isLoading, error } = useUser(id);
|
||||
|
||||
if (isLoading) return <Skeleton />;
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Mutations with optimistic updates**
|
||||
|
||||
```typescript
|
||||
function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateUserInput) => api.users.update(data.id, data),
|
||||
onMutate: async (newData) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: userKeys.detail(newData.id),
|
||||
});
|
||||
|
||||
// Snapshot current value for rollback
|
||||
const previous = queryClient.getQueryData(userKeys.detail(newData.id));
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(userKeys.detail(newData.id), (old: User) => ({
|
||||
...old,
|
||||
...newData,
|
||||
}));
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, newData, context) => {
|
||||
// Rollback on failure
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(
|
||||
userKeys.detail(newData.id),
|
||||
context.previous
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, variables) => {
|
||||
// Always refetch after mutation to ensure consistency
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: userKeys.detail(variables.id),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Prefetching for instant navigation**
|
||||
|
||||
```typescript
|
||||
function UserListItem({ user }: { user: UserSummary }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const prefetch = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: userKeys.detail(user.id),
|
||||
queryFn: () => api.users.get(user.id),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
onMouseEnter={prefetch}
|
||||
onFocus={prefetch}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Form State
|
||||
|
||||
Use react-hook-form for performance (uncontrolled inputs) and zod for schema validation.
|
||||
|
||||
**Basic form with validation**
|
||||
|
||||
```typescript
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
email: z.string().email("Invalid email address"),
|
||||
role: z.enum(["admin", "editor", "viewer"]),
|
||||
notifications: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type CreateUserForm = z.infer<typeof createUserSchema>;
|
||||
|
||||
function CreateUserForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateUserForm>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: {
|
||||
role: "viewer",
|
||||
notifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: CreateUserForm) => {
|
||||
await api.users.create(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<label htmlFor="name">Name</label>
|
||||
<input id="name" {...register("name")} />
|
||||
{errors.name && <p className="text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input id="email" type="email" {...register("email")} />
|
||||
{errors.email && <p className="text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role">Role</label>
|
||||
<select id="role" {...register("role")}>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create User"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Dynamic field arrays**
|
||||
|
||||
```typescript
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
|
||||
const orderSchema = z.object({
|
||||
customer: z.string().min(1),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
productId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(999),
|
||||
})
|
||||
)
|
||||
.min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
type OrderForm = z.infer<typeof orderSchema>;
|
||||
|
||||
function OrderForm() {
|
||||
const { register, control, handleSubmit } = useForm<OrderForm>({
|
||||
resolver: zodResolver(orderSchema),
|
||||
defaultValues: { items: [{ productId: "", quantity: 1 }] },
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "items",
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<input
|
||||
{...register(`items.${index}.productId`)}
|
||||
placeholder="Product ID"
|
||||
/>
|
||||
<input
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
type="number"
|
||||
min={1}
|
||||
/>
|
||||
<button type="button" onClick={() => remove(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => append({ productId: "", quantity: 1 })}
|
||||
>
|
||||
Add item
|
||||
</button>
|
||||
<button type="submit">Place order</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. URL State
|
||||
|
||||
Encode filters, pagination, and view settings in the URL so users can bookmark and share.
|
||||
|
||||
**Using nuqs (type-safe URL search params)**
|
||||
|
||||
```typescript
|
||||
import { useQueryState, parseAsInteger, parseAsStringEnum } from "nuqs";
|
||||
|
||||
const sortOptions = ["name", "date", "price"] as const;
|
||||
|
||||
function ProductList() {
|
||||
const [search, setSearch] = useQueryState("q", { defaultValue: "" });
|
||||
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
|
||||
const [sort, setSort] = useQueryState(
|
||||
"sort",
|
||||
parseAsStringEnum(sortOptions).withDefault("name")
|
||||
);
|
||||
|
||||
// URL looks like: /products?q=shoes&page=2&sort=price
|
||||
const { data } = useQuery({
|
||||
queryKey: ["products", { search, page, sort }],
|
||||
queryFn: () => api.products.list({ search, page, sort }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
placeholder="Search products..."
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||||
>
|
||||
{sortOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<ProductGrid products={data?.items ?? []} />
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
total={data?.totalPages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Using React Router useSearchParams**
|
||||
|
||||
```typescript
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
function FilteredList() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const status = searchParams.get("status") ?? "all";
|
||||
const page = Number(searchParams.get("page") ?? "1");
|
||||
|
||||
const updateFilter = (key: string, value: string | null) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (value === null) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
// Reset page when filter changes
|
||||
if (key !== "page") next.set("page", "1");
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => updateFilter("status", e.target.value)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Python State
|
||||
|
||||
Use dataclasses for lightweight domain objects and Pydantic for validated external data. Combine with the repository pattern for persistence.
|
||||
|
||||
**Dataclasses for domain objects**
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
DRAFT = "draft"
|
||||
CONFIRMED = "confirmed"
|
||||
SHIPPED = "shipped"
|
||||
DELIVERED = "delivered"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderItem:
|
||||
product_id: str
|
||||
quantity: int
|
||||
unit_price: float
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return self.quantity * self.unit_price
|
||||
|
||||
|
||||
@dataclass
|
||||
class Order:
|
||||
customer_id: str
|
||||
items: list[OrderItem] = field(default_factory=list)
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
status: OrderStatus = OrderStatus.DRAFT
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def subtotal(self) -> float:
|
||||
return sum(item.total for item in self.items)
|
||||
|
||||
def confirm(self) -> None:
|
||||
if self.status != OrderStatus.DRAFT:
|
||||
raise ValueError(f"Cannot confirm order in '{self.status}' state")
|
||||
if not self.items:
|
||||
raise ValueError("Cannot confirm an empty order")
|
||||
self.status = OrderStatus.CONFIRMED
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self.status in (OrderStatus.DELIVERED, OrderStatus.CANCELLED):
|
||||
raise ValueError(f"Cannot cancel order in '{self.status}' state")
|
||||
self.status = OrderStatus.CANCELLED
|
||||
```
|
||||
|
||||
**Pydantic for validated external input**
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
customer_id: str = Field(min_length=1, max_length=50)
|
||||
items: list["OrderItemInput"] = Field(min_length=1)
|
||||
|
||||
@field_validator("items")
|
||||
@classmethod
|
||||
def no_duplicate_products(cls, items: list["OrderItemInput"]) -> list["OrderItemInput"]:
|
||||
product_ids = [item.product_id for item in items]
|
||||
if len(product_ids) != len(set(product_ids)):
|
||||
raise ValueError("Duplicate product IDs are not allowed")
|
||||
return items
|
||||
|
||||
|
||||
class OrderItemInput(BaseModel):
|
||||
product_id: str = Field(min_length=1)
|
||||
quantity: int = Field(ge=1, le=999)
|
||||
```
|
||||
|
||||
**Repository pattern for persistence**
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class OrderRepository(ABC):
|
||||
@abstractmethod
|
||||
async def save(self, order: Order) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, order_id: UUID) -> Order | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_customer(self, customer_id: str) -> list[Order]: ...
|
||||
|
||||
|
||||
class PostgresOrderRepository(OrderRepository):
|
||||
def __init__(self, pool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
async def save(self, order: Order) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO orders (id, customer_id, status, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE SET status = $3
|
||||
""",
|
||||
order.id,
|
||||
order.customer_id,
|
||||
order.status.value,
|
||||
order.created_at,
|
||||
)
|
||||
# Upsert items...
|
||||
|
||||
async def get(self, order_id: UUID) -> Order | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM orders WHERE id = $1", order_id
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
items = await conn.fetch(
|
||||
"SELECT * FROM order_items WHERE order_id = $1", order_id
|
||||
)
|
||||
return self._row_to_order(row, items)
|
||||
|
||||
async def list_by_customer(self, customer_id: str) -> list[Order]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC",
|
||||
customer_id,
|
||||
)
|
||||
# Fetch items for each order...
|
||||
return [self._row_to_order(row, items) for row, items in results]
|
||||
|
||||
def _row_to_order(self, row, item_rows) -> Order:
|
||||
return Order(
|
||||
id=row["id"],
|
||||
customer_id=row["customer_id"],
|
||||
status=OrderStatus(row["status"]),
|
||||
created_at=row["created_at"],
|
||||
items=[
|
||||
OrderItem(
|
||||
product_id=r["product_id"],
|
||||
quantity=r["quantity"],
|
||||
unit_price=float(r["unit_price"]),
|
||||
)
|
||||
for r in item_rows
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start local, promote when needed.** Begin with `useState`. Only move state up or into a store when two or more unrelated components need the same data. Premature globalization makes refactoring painful.
|
||||
|
||||
2. **Separate server state from client state.** Use TanStack Query (or SWR) for anything fetched from an API. These libraries handle caching, deduplication, background refetching, and stale-while-revalidate. Do not duplicate fetched data in Zustand.
|
||||
|
||||
3. **Use selectors to prevent re-renders.** In Zustand, always select the specific field you need: `useStore((s) => s.count)`, not `useStore()`. The latter re-renders on every store change.
|
||||
|
||||
4. **Co-locate state with the component that owns it.** If only `<Sidebar>` uses `isOpen`, keep that state inside `<Sidebar>`. Moving it to a global store just because "it might be needed later" creates unnecessary coupling.
|
||||
|
||||
5. **Derive, do not duplicate.** If `fullName` can be computed from `firstName` and `lastName`, compute it on the fly or with `useMemo`. Storing derived values introduces synchronization bugs.
|
||||
|
||||
6. **Validate at the boundary, trust internally.** Use Pydantic or Zod to validate data when it enters the system (API requests, form submissions, external events). Once validated, pass typed objects without re-checking.
|
||||
|
||||
7. **Keep URL state minimal.** Only encode values the user would want to bookmark or share: active tab, search query, page number, sort column. Do not put ephemeral UI state (hover, open dropdown) in the URL.
|
||||
|
||||
8. **Treat form state as its own domain.** Let react-hook-form manage form values, dirty tracking, and validation. Submit the validated result to your mutation or API call. Do not synchronize form fields with external stores.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Putting everything in global state.** Not all state needs to be global. A modal's open/closed state, an input's current text, or a component's loading spinner should stay local. Global stores should hold state that genuinely needs to be shared across distant parts of the tree.
|
||||
|
||||
2. **Storing server data in Zustand.** Zustand has no built-in cache invalidation, stale detection, or background refetch. Using it for API data means you are rebuilding TanStack Query poorly. Use the right tool for the job.
|
||||
|
||||
3. **Forgetting to invalidate queries after mutations.** After a `useMutation` succeeds, call `queryClient.invalidateQueries` with the affected keys. Without this, the UI shows stale data until the next refetch interval.
|
||||
|
||||
4. **Over-using React Context for frequently changing state.** Every Context value change re-renders every consumer. Context is good for low-frequency values (theme, locale, auth). For high-frequency updates (cursor position, scroll offset), use Zustand or a ref.
|
||||
|
||||
5. **Duplicating form state.** Calling `useForm()` and then also storing the same values in `useState` or Zustand means two sources of truth that can drift apart. Let the form library be the single owner.
|
||||
|
||||
6. **Ignoring URL state for filterable lists.** If a user applies filters and then hits the back button or refreshes, losing the filters is a bad experience. Encode filters in the URL so they survive navigation.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `frameworks/react` - React component patterns and hooks
|
||||
- `frameworks/nextjs` - Next.js server components and data fetching
|
||||
- `languages/typescript` - TypeScript types and generics
|
||||
- `patterns/caching` - Cache strategies and invalidation patterns
|
||||
@@ -0,0 +1,143 @@
|
||||
# State Management Decision Tree
|
||||
|
||||
## Primary Decision Tree
|
||||
|
||||
```
|
||||
What kind of state is it?
|
||||
│
|
||||
├─ SERVER DATA (fetched from API/DB)
|
||||
│ │
|
||||
│ ├─ React/Next.js project?
|
||||
│ │ ├─ YES ──> TanStack Query (React Query)
|
||||
│ │ │ - Auto caching, dedup, background refresh
|
||||
│ │ │ - Stale-while-revalidate out of the box
|
||||
│ │ │ - DevTools for debugging
|
||||
│ │ │
|
||||
│ │ └─ Next.js App Router with Server Components?
|
||||
│ │ └─ Consider: fetch() in Server Components + revalidation
|
||||
│ │ - No client-side state library needed
|
||||
│ │ - Use TanStack Query only for client-interactive data
|
||||
│ │
|
||||
│ └─ Need real-time sync?
|
||||
│ ├─ WebSocket data ──> TanStack Query + custom subscription
|
||||
│ └─ Collaborative ──> Liveblocks, Yjs, or Partykit
|
||||
│
|
||||
├─ URL STATE (filters, pagination, search, tabs)
|
||||
│ │
|
||||
│ ├─ Next.js App Router ──> useSearchParams() + useRouter()
|
||||
│ ├─ React Router ──> useSearchParams()
|
||||
│ └─ Plain React ──> nuqs or custom URL sync hook
|
||||
│
|
||||
│ Why URL state? Shareable links, back/forward navigation,
|
||||
│ bookmarkable, SSR-friendly.
|
||||
│
|
||||
├─ FORM STATE (input values, validation, dirty/touched)
|
||||
│ │
|
||||
│ ├─ Complex forms (multi-step, dynamic fields, arrays)
|
||||
│ │ └─ react-hook-form + zod
|
||||
│ │ - Uncontrolled by default (performant)
|
||||
│ │ - Schema validation with zod resolver
|
||||
│ │
|
||||
│ ├─ Simple forms (login, search, contact)
|
||||
│ │ └─ Server Actions (Next.js) or native form + useState
|
||||
│ │
|
||||
│ └─ Form + server state (edit existing record)
|
||||
│ └─ TanStack Query (fetch) + react-hook-form (edit)
|
||||
│ - Populate form with query data
|
||||
│ - Submit with mutation
|
||||
│
|
||||
├─ GLOBAL CLIENT STATE (shared across many components)
|
||||
│ │
|
||||
│ ├─ Simple (theme, sidebar open, user preferences)
|
||||
│ │ │
|
||||
│ │ ├─ Changes rarely ──> React Context
|
||||
│ │ │ (Wrap app, useContext to consume)
|
||||
│ │ │
|
||||
│ │ └─ Changes often or many consumers ──> Zustand
|
||||
│ │ - Avoids Context re-render problem
|
||||
│ │ - Selector-based subscriptions
|
||||
│ │ - Tiny bundle, minimal boilerplate
|
||||
│ │
|
||||
│ ├─ Complex (shopping cart, multi-step wizard, editor)
|
||||
│ │ └─ Zustand (or Jotai for atomic state)
|
||||
│ │
|
||||
│ └─ Need devtools and time-travel debugging?
|
||||
│ └─ Zustand with devtools middleware
|
||||
│
|
||||
├─ LOCAL COMPONENT STATE (only used in one component)
|
||||
│ │
|
||||
│ ├─ Single value ──> useState
|
||||
│ │ const [count, setCount] = useState(0);
|
||||
│ │
|
||||
│ ├─ Related values or complex transitions ──> useReducer
|
||||
│ │ const [state, dispatch] = useReducer(reducer, initial);
|
||||
│ │
|
||||
│ └─ Derived value (computed from other state) ──> useMemo
|
||||
│ const total = useMemo(() => items.reduce(...), [items]);
|
||||
│
|
||||
└─ TRANSIENT UI STATE (animations, hover, drag position)
|
||||
│
|
||||
├─ CSS can handle it? ──> Use CSS (transitions, :hover)
|
||||
├─ Ref-based (no re-render needed) ──> useRef
|
||||
└─ Needs re-render ──> useState (local)
|
||||
```
|
||||
|
||||
## Quick Lookup Table
|
||||
|
||||
| State Type | Recommended Tool | When NOT to Use |
|
||||
|-----------|-----------------|-----------------|
|
||||
| Server data | TanStack Query | Data never changes, or SSR-only |
|
||||
| URL params | useSearchParams | Ephemeral UI state (hover, etc.) |
|
||||
| Form inputs | react-hook-form | Single `<input>` |
|
||||
| Global UI | Zustand | Only 1-2 consumers (use Context) |
|
||||
| Global UI (simple) | React Context | Frequent updates with many consumers |
|
||||
| Local state | useState | Complex state transitions |
|
||||
| Complex local | useReducer | Single boolean toggle |
|
||||
| Derived data | useMemo | Cheap computations |
|
||||
| No re-render needed | useRef | Value that should trigger re-render |
|
||||
|
||||
## Library Comparison
|
||||
|
||||
| Library | Bundle Size | Boilerplate | Learning Curve | Best For |
|
||||
|---------|------------|-------------|----------------|----------|
|
||||
| useState/useReducer | 0 KB | Minimal | Low | Local state |
|
||||
| React Context | 0 KB | Low | Low | Rarely-changing global state |
|
||||
| Zustand | ~1 KB | Minimal | Low | Global client state |
|
||||
| Jotai | ~3 KB | Minimal | Medium | Atomic/derived state |
|
||||
| TanStack Query | ~12 KB | Medium | Medium | Server state |
|
||||
| Redux Toolkit | ~30 KB | High | High | Large teams needing strict patterns |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Problem | Fix |
|
||||
|---------|---------|-----|
|
||||
| Storing server data in Zustand/Redux | Manual cache invalidation, stale data | Use TanStack Query |
|
||||
| Storing URL state in useState | Not shareable, lost on refresh | Use URL search params |
|
||||
| Putting everything in global state | Unnecessary re-renders, complexity | Colocate state where used |
|
||||
| Context for frequently changing data | Re-renders all consumers | Use Zustand with selectors |
|
||||
| Duplicating derived state | Out-of-sync bugs | Compute with useMemo |
|
||||
| useState for complex transitions | Inconsistent intermediate states | Use useReducer |
|
||||
|
||||
## Zustand Quick Setup
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand";
|
||||
|
||||
interface CartStore {
|
||||
items: CartItem[];
|
||||
addItem: (item: CartItem) => void;
|
||||
removeItem: (id: string) => void;
|
||||
total: () => number;
|
||||
}
|
||||
|
||||
const useCartStore = create<CartStore>((set, get) => ({
|
||||
items: [],
|
||||
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
|
||||
removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
|
||||
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
|
||||
}));
|
||||
|
||||
// Usage with selector (only re-renders when items change)
|
||||
const items = useCartStore((s) => s.items);
|
||||
const addItem = useCartStore((s) => s.addItem);
|
||||
```
|
||||
@@ -1,74 +1,556 @@
|
||||
# OWASP Security
|
||||
---
|
||||
name: owasp
|
||||
description: >
|
||||
Use this skill when reviewing code for security vulnerabilities, implementing authentication or authorization flows, handling user input validation, or building web endpoints exposed to untrusted data. Trigger on keywords like XSS, SQL injection, CSRF, input sanitization, password hashing, and security headers. Also apply when auditing existing code for OWASP Top 10 compliance or conducting security-focused code reviews.
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
OWASP Top 10 security practices and secure coding patterns.
|
||||
# OWASP Web Application Security
|
||||
|
||||
## When to Use
|
||||
|
||||
- Security code reviews
|
||||
- Implementing authentication
|
||||
- Handling user input
|
||||
- Implementing authentication or authorization
|
||||
- Handling user input from untrusted sources
|
||||
- Building or auditing web API endpoints
|
||||
- Configuring CORS, CSP, or other security headers
|
||||
- Managing secrets, tokens, or credentials in code
|
||||
- Setting up rate limiting or brute force protection
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- General code style or formatting reviews with no security implications
|
||||
- Non-web applications such as CLI tools, batch scripts, or desktop utilities
|
||||
- Performance optimization tasks where security is not the concern
|
||||
- Infrastructure-level security (firewall rules, network segmentation)
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Input Validation
|
||||
### 1. Input Validation & Sanitization
|
||||
|
||||
Always validate input at the boundary. Use allowlists over denylists.
|
||||
|
||||
**Python (Pydantic)**
|
||||
|
||||
```python
|
||||
# Always validate and sanitize
|
||||
from pydantic import BaseModel, EmailStr
|
||||
# BAD - no validation, accepts anything
|
||||
@app.post("/users")
|
||||
async def create_user(request: Request):
|
||||
data = await request.json()
|
||||
name = data["name"] # no length check, no type check
|
||||
email = data["email"] # no format validation
|
||||
role = data["role"] # user controls their own role
|
||||
db.execute(f"INSERT INTO users VALUES ('{name}', '{email}', '{role}')")
|
||||
|
||||
class UserInput(BaseModel):
|
||||
# GOOD - strict schema validation with Pydantic
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from enum import Enum
|
||||
|
||||
class UserRole(str, Enum):
|
||||
viewer = "viewer"
|
||||
editor = "editor"
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100, pattern=r"^[a-zA-Z\s\-]+$")
|
||||
email: EmailStr
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
role: UserRole = UserRole.viewer # default to least privilege
|
||||
|
||||
@app.post("/users")
|
||||
async def create_user(payload: CreateUserRequest):
|
||||
# Pydantic rejects invalid data before this code runs
|
||||
db.add(User(name=payload.name, email=payload.email, role=payload.role))
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
```python
|
||||
# Never concatenate user input
|
||||
# Bad
|
||||
query = f"SELECT * FROM users WHERE id = {user_id}"
|
||||
|
||||
# Good - parameterized
|
||||
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||
```
|
||||
|
||||
### XSS Prevention
|
||||
**TypeScript (Zod)**
|
||||
|
||||
```typescript
|
||||
// Never use innerHTML with user data
|
||||
// Bad
|
||||
element.innerHTML = userInput;
|
||||
// BAD - trusting req.body directly
|
||||
app.post("/users", (req, res) => {
|
||||
const { name, email, role } = req.body; // no validation
|
||||
db.query(`INSERT INTO users VALUES ('${name}', '${email}', '${role}')`);
|
||||
});
|
||||
|
||||
// Good
|
||||
element.textContent = userInput;
|
||||
// GOOD - validate with Zod at the boundary
|
||||
import { z } from "zod";
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s\-]+$/),
|
||||
email: z.string().email(),
|
||||
role: z.enum(["viewer", "editor"]).default("viewer"),
|
||||
});
|
||||
|
||||
app.post("/users", (req, res) => {
|
||||
const result = CreateUserSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ errors: result.error.flatten() });
|
||||
}
|
||||
// result.data is typed and validated
|
||||
await prisma.user.create({ data: result.data });
|
||||
});
|
||||
```
|
||||
|
||||
### Authentication
|
||||
**File Upload Validation**
|
||||
|
||||
```python
|
||||
# Hash passwords properly
|
||||
# GOOD - validate MIME type (not just extension), size, and sanitize filename
|
||||
import magic
|
||||
|
||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf"}
|
||||
MAX_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
def validate_upload(file_bytes: bytes, filename: str) -> bool:
|
||||
if len(file_bytes) > MAX_SIZE:
|
||||
raise ValueError("File too large")
|
||||
if magic.from_buffer(file_bytes, mime=True) not in ALLOWED_TYPES:
|
||||
raise ValueError("Disallowed file type")
|
||||
if ".." in filename or filename.startswith("."):
|
||||
raise ValueError("Invalid filename")
|
||||
return True
|
||||
```
|
||||
|
||||
### 2. SQL Injection Prevention
|
||||
|
||||
Never concatenate user input into SQL strings. Always use parameterized queries or ORM methods.
|
||||
|
||||
**Raw SQL (Python)**
|
||||
|
||||
```python
|
||||
# BAD - string interpolation creates injection vector
|
||||
def get_user(user_id: str):
|
||||
query = f"SELECT * FROM users WHERE id = '{user_id}'"
|
||||
# Input: "'; DROP TABLE users; --" destroys the table
|
||||
cursor.execute(query)
|
||||
|
||||
# GOOD - parameterized query
|
||||
def get_user(user_id: str):
|
||||
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||
return cursor.fetchone()
|
||||
```
|
||||
|
||||
**SQLAlchemy (Python)**
|
||||
|
||||
```python
|
||||
# BAD - text() with f-string
|
||||
from sqlalchemy import text
|
||||
result = session.execute(text(f"SELECT * FROM users WHERE name = '{name}'"))
|
||||
|
||||
# GOOD - bound parameters with text()
|
||||
result = session.execute(text("SELECT * FROM users WHERE name = :name"), {"name": name})
|
||||
|
||||
# GOOD - ORM query (automatically parameterized)
|
||||
user = session.query(User).filter(User.name == name).first()
|
||||
```
|
||||
|
||||
**Prisma (TypeScript)**
|
||||
|
||||
```typescript
|
||||
// BAD - raw query with interpolation
|
||||
const user = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = '${id}'`);
|
||||
|
||||
// GOOD - tagged template (auto-parameterized)
|
||||
const user = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`;
|
||||
|
||||
// GOOD - Prisma client methods (always safe)
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
```
|
||||
|
||||
### 3. XSS Prevention
|
||||
|
||||
Prevent cross-site scripting by encoding output, setting CSP headers, and sanitizing HTML.
|
||||
|
||||
**Output Encoding**
|
||||
|
||||
```typescript
|
||||
// BAD - renders raw user content as HTML
|
||||
element.innerHTML = userComment;
|
||||
|
||||
// GOOD - use textContent for plain text
|
||||
element.textContent = userComment;
|
||||
|
||||
// GOOD - React auto-escapes by default (don't bypass it)
|
||||
return <div>{userComment}</div>;
|
||||
|
||||
// BAD - dangerouslySetInnerHTML defeats React's protection
|
||||
return <div dangerouslySetInnerHTML={{ __html: userComment }} />;
|
||||
```
|
||||
|
||||
**Sanitizing HTML When You Must Render It**
|
||||
|
||||
```typescript
|
||||
// GOOD - sanitize with DOMPurify when HTML rendering is required
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const cleanHtml = DOMPurify.sanitize(userHtml, {
|
||||
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
|
||||
ALLOWED_ATTR: ["href", "title"],
|
||||
});
|
||||
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
|
||||
```
|
||||
|
||||
### 4. Authentication Patterns
|
||||
|
||||
**Password Hashing**
|
||||
|
||||
```python
|
||||
# BAD - plain text or weak hashing
|
||||
hashed = hashlib.md5(password.encode()).hexdigest() # trivially crackable
|
||||
|
||||
# GOOD - use argon2 (preferred) or bcrypt with proper cost
|
||||
from passlib.hash import argon2
|
||||
|
||||
hashed = argon2.hash(password)
|
||||
verified = argon2.verify(password, hashed)
|
||||
is_valid = argon2.verify(password, hashed)
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
```typescript
|
||||
// GOOD - bcrypt in Node.js
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
- [ ] Input validation on all user data
|
||||
- [ ] Parameterized queries
|
||||
- [ ] Output encoding
|
||||
- [ ] Strong password hashing
|
||||
- [ ] Secure session management
|
||||
- [ ] HTTPS everywhere
|
||||
- [ ] Security headers configured
|
||||
const SALT_ROUNDS = 12;
|
||||
const hashed = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
const isValid = await bcrypt.compare(password, hashed);
|
||||
```
|
||||
|
||||
**JWT Best Practices**
|
||||
|
||||
```python
|
||||
# BAD - long-lived token, weak secret
|
||||
token = jwt.encode({"user_id": 1, "exp": datetime.utcnow() + timedelta(days=365)},
|
||||
"secret123", algorithm="HS256")
|
||||
|
||||
# GOOD - short expiry, strong secret, httpOnly cookie delivery
|
||||
ACCESS_TOKEN_EXPIRY = timedelta(minutes=15)
|
||||
|
||||
def create_access_token(user_id: int) -> str:
|
||||
return jwt.encode(
|
||||
{"sub": user_id, "exp": datetime.now(timezone.utc) + ACCESS_TOKEN_EXPIRY},
|
||||
os.environ["JWT_SECRET_KEY"], algorithm="HS256",
|
||||
)
|
||||
|
||||
def set_token_cookie(response: Response, token: str):
|
||||
response.set_cookie(
|
||||
key="access_token", value=token,
|
||||
httponly=True, secure=True, samesite="lax", # not accessible via JS, HTTPS only
|
||||
max_age=int(ACCESS_TOKEN_EXPIRY.total_seconds()),
|
||||
)
|
||||
```
|
||||
|
||||
**Session Management Rules**
|
||||
|
||||
- Set session timeouts (30 minutes idle, 8 hours absolute)
|
||||
- Regenerate session ID after login to prevent session fixation
|
||||
- Store sessions server-side (Redis, database), not in cookies
|
||||
- Clear sessions on logout (`request.session.clear()`)
|
||||
- Use `httponly`, `secure`, and `samesite=lax` on session cookies
|
||||
|
||||
### 5. Authorization & Access Control
|
||||
|
||||
**RBAC Pattern**
|
||||
|
||||
```python
|
||||
# GOOD - role-based access control with decorator
|
||||
from enum import Enum
|
||||
|
||||
class Role(str, Enum):
|
||||
admin = "admin"
|
||||
editor = "editor"
|
||||
viewer = "viewer"
|
||||
|
||||
ROLE_HIERARCHY = {Role.admin: 3, Role.editor: 2, Role.viewer: 1}
|
||||
|
||||
def require_role(minimum_role: Role):
|
||||
def decorator(func):
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
user = request.state.user
|
||||
if ROLE_HIERARCHY.get(user.role, 0) < ROLE_HIERARCHY[minimum_role]:
|
||||
raise HTTPException(status_code=403)
|
||||
return await func(request, *args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@app.delete("/posts/{post_id}")
|
||||
@require_role(Role.editor)
|
||||
async def delete_post(request: Request, post_id: int): ...
|
||||
```
|
||||
|
||||
**Middleware-Based Authorization (Express)**
|
||||
|
||||
```typescript
|
||||
// GOOD - authorization middleware
|
||||
function requireRole(...allowedRoles: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user || !allowedRoles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
app.delete("/posts/:id", requireRole("admin", "editor"), deletePostHandler);
|
||||
```
|
||||
|
||||
**Object-Level Permissions**
|
||||
|
||||
```python
|
||||
# BAD - checks auth but not ownership (any user can edit any document)
|
||||
@app.put("/documents/{doc_id}")
|
||||
async def update_document(doc_id: int, payload: UpdateDoc, user=Depends(get_current_user)):
|
||||
doc = await db.get(Document, doc_id)
|
||||
doc.content = payload.content
|
||||
|
||||
# GOOD - verify ownership or admin role on every mutation
|
||||
@app.put("/documents/{doc_id}")
|
||||
async def update_document(doc_id: int, payload: UpdateDoc, user=Depends(get_current_user)):
|
||||
doc = await db.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404)
|
||||
if doc.owner_id != user.id and user.role != Role.admin:
|
||||
raise HTTPException(status_code=403)
|
||||
doc.content = payload.content
|
||||
```
|
||||
|
||||
### 6. CORS Configuration
|
||||
|
||||
**FastAPI**
|
||||
|
||||
```python
|
||||
# BAD - allows everything
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
# GOOD - restrictive CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://app.example.com", "https://staging.example.com"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
```
|
||||
|
||||
**Express**
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
|
||||
// GOOD - explicit allowlist with callback
|
||||
const ALLOWED_ORIGINS = ["https://app.example.com"];
|
||||
app.use(cors({
|
||||
origin: (origin, cb) => {
|
||||
if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true);
|
||||
else cb(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE"],
|
||||
}));
|
||||
```
|
||||
|
||||
### 7. Security Headers
|
||||
|
||||
**Express with Helmet**
|
||||
|
||||
```typescript
|
||||
// GOOD - Helmet sets secure defaults for all critical headers
|
||||
import helmet from "helmet";
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||
}));
|
||||
```
|
||||
|
||||
**FastAPI**
|
||||
|
||||
```python
|
||||
# GOOD - security headers middleware
|
||||
@app.middleware("http")
|
||||
async def security_headers(request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none';"
|
||||
return response
|
||||
```
|
||||
|
||||
### 8. Secret Management
|
||||
|
||||
```python
|
||||
# BAD - hardcoded secrets
|
||||
DATABASE_URL = "postgresql://admin:p@ssw0rd@localhost/mydb"
|
||||
API_KEY = "sk-1234567890abcdef"
|
||||
JWT_SECRET = "mysecret"
|
||||
|
||||
# GOOD - environment variables with validation
|
||||
import os
|
||||
|
||||
def get_required_env(key: str) -> str:
|
||||
value = os.environ.get(key)
|
||||
if not value:
|
||||
raise RuntimeError(f"Required environment variable {key} is not set")
|
||||
return value
|
||||
|
||||
DATABASE_URL = get_required_env("DATABASE_URL")
|
||||
API_KEY = get_required_env("API_KEY")
|
||||
JWT_SECRET = get_required_env("JWT_SECRET")
|
||||
```
|
||||
|
||||
**.env and .gitignore**
|
||||
|
||||
```bash
|
||||
# .env (NEVER commit this file)
|
||||
DATABASE_URL=postgresql://admin:securepass@localhost/mydb
|
||||
JWT_SECRET=a-very-long-random-string-from-openssl-rand
|
||||
API_KEY=sk-prod-xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
```gitignore
|
||||
# .gitignore - always include these
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
credentials.json
|
||||
```
|
||||
|
||||
Commit a `.env.example` with empty values to document required variables without exposing secrets.
|
||||
|
||||
### 9. Rate Limiting
|
||||
|
||||
**Python (FastAPI with slowapi)**
|
||||
|
||||
```python
|
||||
# GOOD - rate limiting on sensitive endpoints
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
app.state.limiter = limiter
|
||||
|
||||
@app.post("/login")
|
||||
@limiter.limit("5/minute") # brute force protection
|
||||
async def login(request: Request, credentials: LoginRequest):
|
||||
...
|
||||
|
||||
@app.post("/api/data")
|
||||
@limiter.limit("100/minute") # general API rate limit
|
||||
async def get_data(request: Request):
|
||||
...
|
||||
```
|
||||
|
||||
**Express (express-rate-limit)**
|
||||
|
||||
```typescript
|
||||
// GOOD - tiered rate limiting
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
||||
const generalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
|
||||
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
|
||||
|
||||
app.use("/api/", generalLimiter);
|
||||
app.use("/auth/login", authLimiter);
|
||||
app.use("/auth/register", authLimiter);
|
||||
```
|
||||
|
||||
### 10. Dependency Security
|
||||
|
||||
```bash
|
||||
# Python - audit dependencies
|
||||
pip install pip-audit
|
||||
pip-audit # scan for known vulnerabilities
|
||||
pip-audit --fix # auto-fix where possible
|
||||
|
||||
# Node.js - audit dependencies
|
||||
npm audit # list vulnerabilities
|
||||
npm audit fix # auto-fix compatible updates
|
||||
pnpm audit # pnpm equivalent
|
||||
|
||||
# Always commit lock files to ensure reproducible builds
|
||||
# Python: requirements.txt or poetry.lock
|
||||
# Node.js: package-lock.json, pnpm-lock.yaml, or yarn.lock
|
||||
```
|
||||
|
||||
Run `npm audit --audit-level=high` and `pip-audit --strict` in CI (e.g., GitHub Actions on every PR and weekly schedule). Treat high-severity findings as build failures.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Validate at the boundary, trust nothing inside.** Every piece of user input -- query params, headers, request bodies, file uploads -- must be validated before processing. Use Pydantic or Zod schemas, not manual checks.
|
||||
|
||||
2. **Apply the principle of least privilege everywhere.** Default to the most restrictive access. Grant permissions explicitly. Use role-based access control and verify object-level ownership on every mutation.
|
||||
|
||||
3. **Never store or log secrets in plain text.** Use environment variables, a secret manager, or encrypted storage. Ensure secrets never appear in logs, error messages, or version control.
|
||||
|
||||
4. **Use strong, adaptive password hashing.** Always use argon2 or bcrypt with a sufficient work factor. Never use MD5, SHA-1, or SHA-256 alone for password storage.
|
||||
|
||||
5. **Set security headers on every response.** Enable HSTS, CSP, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy. Use Helmet for Express and middleware for FastAPI.
|
||||
|
||||
6. **Fail closed, not open.** When authentication or authorization checks encounter errors, deny access by default. Never fall through to an unprotected code path on exception.
|
||||
|
||||
7. **Keep dependencies updated and audited.** Run `npm audit` and `pip-audit` in CI pipelines. Pin dependency versions with lock files. Review changelogs before major upgrades.
|
||||
|
||||
8. **Enforce rate limiting on all public-facing endpoints.** Apply stricter limits on authentication and password reset endpoints. Use IP-based and account-based limiting together for defense in depth.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Trusting user input**: Always validate
|
||||
- **SQL concatenation**: Use parameters
|
||||
- **Storing plain passwords**: Use argon2/bcrypt
|
||||
1. **Trusting client-side validation alone.** Attackers bypass browser validation trivially. Always re-validate on the server.
|
||||
|
||||
2. **Using wildcard CORS with credentials.** `allow_origins=["*"]` with credentials is insecure and browsers reject it. Specify exact origins.
|
||||
|
||||
3. **Storing JWTs in localStorage.** Any XSS can steal them. Use httpOnly, secure, sameSite cookies instead.
|
||||
|
||||
4. **Returning detailed error messages in production.** Stack traces help attackers. Return generic messages to clients, log details server-side.
|
||||
|
||||
5. **Using ORM raw query methods unsafely.** `$queryRawUnsafe` and `text()` with f-strings bypass ORM protections. Audit every raw SQL call.
|
||||
|
||||
6. **Checking authentication but not authorization.** "Logged in" does not mean "authorized." Check object-level permissions on every write.
|
||||
|
||||
7. **Disabling security in dev and shipping it.** CSP, CORS, HTTPS disabled for convenience can reach production. Use environment-aware config.
|
||||
|
||||
8. **Ignoring dependency vulnerabilities.** Known CVEs in transitive deps are a top attack vector. Automate auditing in CI.
|
||||
|
||||
---
|
||||
|
||||
## Security Review Checklist
|
||||
|
||||
- [ ] All user input validated with schema (Pydantic / Zod) before processing
|
||||
- [ ] No string concatenation or interpolation in SQL queries
|
||||
- [ ] Passwords hashed with argon2 or bcrypt (never MD5/SHA)
|
||||
- [ ] JWTs have short expiry, use httpOnly cookies, strong secret from env
|
||||
- [ ] Authorization checked at object level, not just authentication
|
||||
- [ ] CORS configured with explicit origin allowlist (no wildcards with credentials)
|
||||
- [ ] Security headers set: CSP, HSTS, X-Content-Type-Options, X-Frame-Options
|
||||
- [ ] No secrets hardcoded in source -- all from environment variables
|
||||
- [ ] .env files listed in .gitignore, .env.example committed
|
||||
- [ ] Rate limiting applied to login, registration, and password reset endpoints
|
||||
- [ ] File uploads validated by MIME type, size, and sanitized filename
|
||||
- [ ] Error responses do not leak stack traces or internal details
|
||||
- [ ] Dependencies audited with npm audit / pip-audit (no high-severity CVEs)
|
||||
- [ ] HTTPS enforced in production with HSTS preload
|
||||
- [ ] No use of eval(), dangerouslySetInnerHTML (without DOMPurify), or innerHTML
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `patterns/authentication` - Authentication and authorization implementation patterns
|
||||
- `patterns/error-handling` - Secure error handling that avoids leaking sensitive information
|
||||
- `devops/docker` — Container security hardening
|
||||
- `methodology/defense-in-depth` — Multi-layer security validation
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# OWASP Top 10 (2021) Cheat Sheet
|
||||
|
||||
Quick reference for the OWASP Top 10 web application security risks.
|
||||
|
||||
---
|
||||
|
||||
## A01: Broken Access Control
|
||||
|
||||
**Risk**: Users act outside intended permissions (view other users' data, modify access).
|
||||
|
||||
**Prevention**: Deny by default. Enforce ownership. Disable directory listing. Log failures.
|
||||
|
||||
```python
|
||||
# Enforce ownership check
|
||||
def get_order(order_id, current_user):
|
||||
order = db.query(Order).get(order_id)
|
||||
if order.user_id != current_user.id:
|
||||
raise PermissionError("Access denied")
|
||||
return order
|
||||
```
|
||||
|
||||
## A02: Cryptographic Failures
|
||||
|
||||
**Risk**: Exposure of sensitive data due to weak or missing encryption.
|
||||
|
||||
**Prevention**: Encrypt data at rest and in transit. Use strong algorithms (AES-256, bcrypt). Never store plaintext passwords.
|
||||
|
||||
```python
|
||||
from passlib.hash import bcrypt
|
||||
hashed = bcrypt.hash(password)
|
||||
assert bcrypt.verify(password, hashed)
|
||||
```
|
||||
|
||||
## A03: Injection
|
||||
|
||||
**Risk**: Untrusted data sent to an interpreter as part of a command or query.
|
||||
|
||||
**Prevention**: Use parameterized queries. Validate and sanitize all input. Use ORMs.
|
||||
|
||||
```python
|
||||
# WRONG: cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
||||
# RIGHT:
|
||||
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||
```
|
||||
|
||||
```typescript
|
||||
// WRONG: db.query(`SELECT * FROM users WHERE id = ${id}`)
|
||||
// RIGHT:
|
||||
db.query("SELECT * FROM users WHERE id = $1", [id]);
|
||||
```
|
||||
|
||||
## A04: Insecure Design
|
||||
|
||||
**Risk**: Missing or ineffective security controls due to flawed architecture.
|
||||
|
||||
**Prevention**: Use threat modeling. Apply secure design patterns. Establish reference architectures. Write abuse-case tests.
|
||||
|
||||
```python
|
||||
# Rate-limit sensitive operations
|
||||
from functools import lru_cache
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
LOGIN_ATTEMPTS = {} # Use Redis in production
|
||||
|
||||
def check_rate_limit(ip: str, max_attempts=5, window=300):
|
||||
now = datetime.now().timestamp()
|
||||
attempts = [t for t in LOGIN_ATTEMPTS.get(ip, []) if now - t < window]
|
||||
if len(attempts) >= max_attempts:
|
||||
raise RateLimitExceeded()
|
||||
attempts.append(now)
|
||||
LOGIN_ATTEMPTS[ip] = attempts
|
||||
```
|
||||
|
||||
## A05: Security Misconfiguration
|
||||
|
||||
**Risk**: Default configs, incomplete setups, open cloud storage, verbose errors.
|
||||
|
||||
**Prevention**: Repeatable hardening process. Minimal platform. Remove unused features. Review cloud permissions.
|
||||
|
||||
```yaml
|
||||
# Docker: don't run as root
|
||||
FROM python:3.12-slim
|
||||
RUN useradd -m appuser
|
||||
USER appuser
|
||||
```
|
||||
|
||||
## A06: Vulnerable and Outdated Components
|
||||
|
||||
**Risk**: Using components with known vulnerabilities.
|
||||
|
||||
**Prevention**: Remove unused dependencies. Monitor CVEs. Use `pip audit`, `npm audit`. Pin versions.
|
||||
|
||||
```bash
|
||||
pip audit # Python
|
||||
npm audit # Node.js
|
||||
npx depcheck # Find unused deps
|
||||
```
|
||||
|
||||
## A07: Identification and Authentication Failures
|
||||
|
||||
**Risk**: Weak authentication, credential stuffing, session fixation.
|
||||
|
||||
**Prevention**: MFA. Strong password policies. Secure session management. Throttle failed logins.
|
||||
|
||||
```python
|
||||
# Secure session config (Flask)
|
||||
app.config.update(
|
||||
SESSION_COOKIE_SECURE=True,
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=1),
|
||||
)
|
||||
```
|
||||
|
||||
## A08: Software and Data Integrity Failures
|
||||
|
||||
**Risk**: Code and infrastructure that does not protect against integrity violations (CI/CD, unsigned updates).
|
||||
|
||||
**Prevention**: Verify signatures. Use lock files. Review CI/CD pipelines. Use Subresource Integrity.
|
||||
|
||||
```html
|
||||
<!-- Subresource Integrity -->
|
||||
<script src="https://cdn.example.com/lib.js"
|
||||
integrity="sha384-abc123..."
|
||||
crossorigin="anonymous"></script>
|
||||
```
|
||||
|
||||
## A09: Security Logging and Monitoring Failures
|
||||
|
||||
**Risk**: Insufficient logging makes breaches undetectable.
|
||||
|
||||
**Prevention**: Log auth events, access control failures, input validation failures. Set up alerts.
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("security")
|
||||
|
||||
def login(username, password):
|
||||
user = authenticate(username, password)
|
||||
if not user:
|
||||
logger.warning("Failed login attempt", extra={
|
||||
"username": username,
|
||||
"ip": request.remote_addr,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
})
|
||||
raise AuthenticationError()
|
||||
logger.info("Successful login", extra={"user_id": user.id})
|
||||
```
|
||||
|
||||
## A10: Server-Side Request Forgery (SSRF)
|
||||
|
||||
**Risk**: Application fetches remote resources without validating user-supplied URLs.
|
||||
|
||||
**Prevention**: Allowlist URLs/domains. Block private IP ranges. Disable redirects.
|
||||
|
||||
```python
|
||||
from urllib.parse import urlparse
|
||||
import ipaddress
|
||||
|
||||
ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}
|
||||
|
||||
def validate_url(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
if parsed.hostname not in ALLOWED_HOSTS:
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(parsed.hostname)
|
||||
if ip.is_private or ip.is_loopback:
|
||||
return False
|
||||
except ValueError:
|
||||
pass # hostname, not IP — already checked against allowlist
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Table
|
||||
|
||||
| ID | Name | Key Control |
|
||||
|-----|-------------------------------|--------------------------------|
|
||||
| A01 | Broken Access Control | Deny by default, enforce ownership |
|
||||
| A02 | Cryptographic Failures | Encrypt in transit + at rest |
|
||||
| A03 | Injection | Parameterized queries |
|
||||
| A04 | Insecure Design | Threat modeling, abuse cases |
|
||||
| A05 | Security Misconfiguration | Hardened defaults, minimal surface |
|
||||
| A06 | Vulnerable Components | Audit deps, pin versions |
|
||||
| A07 | Auth Failures | MFA, session security |
|
||||
| A08 | Integrity Failures | Verify signatures, lock files |
|
||||
| A09 | Logging Failures | Log security events, alert |
|
||||
| A10 | SSRF | Allowlist URLs, block private IPs |
|
||||
|
||||
*Source: [OWASP Top 10 (2021)](https://owasp.org/Top10/)*
|
||||
@@ -0,0 +1,217 @@
|
||||
# Security Headers Reference
|
||||
|
||||
Comprehensive reference for HTTP security headers with recommended values and implementation examples.
|
||||
|
||||
---
|
||||
|
||||
## Header Reference Table
|
||||
|
||||
| Header | Purpose | Recommended Value |
|
||||
|--------|---------|-------------------|
|
||||
| `Content-Security-Policy` | Prevent XSS, data injection | See detailed section below |
|
||||
| `Strict-Transport-Security` | Force HTTPS | `max-age=63072000; includeSubDomains; preload` |
|
||||
| `X-Frame-Options` | Prevent clickjacking | `DENY` or `SAMEORIGIN` |
|
||||
| `X-Content-Type-Options` | Prevent MIME sniffing | `nosniff` |
|
||||
| `Referrer-Policy` | Control referer leakage | `strict-origin-when-cross-origin` |
|
||||
| `Permissions-Policy` | Restrict browser features | See detailed section below |
|
||||
|
||||
---
|
||||
|
||||
## Content-Security-Policy (CSP)
|
||||
|
||||
Controls which resources the browser is allowed to load.
|
||||
|
||||
**Starter policy (strict):**
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
|
||||
```
|
||||
|
||||
**Key directives:**
|
||||
|
||||
| Directive | Controls | Example |
|
||||
|-----------|----------|---------|
|
||||
| `default-src` | Fallback for all resource types | `'self'` |
|
||||
| `script-src` | JavaScript sources | `'self' https://cdn.example.com` |
|
||||
| `style-src` | CSS sources | `'self' 'unsafe-inline'` |
|
||||
| `img-src` | Image sources | `'self' data: https:` |
|
||||
| `connect-src` | Fetch, XHR, WebSocket targets | `'self' https://api.example.com` |
|
||||
| `frame-ancestors` | Who can embed this page | `'none'` |
|
||||
| `form-action` | Form submission targets | `'self'` |
|
||||
|
||||
## Strict-Transport-Security (HSTS)
|
||||
|
||||
Forces browsers to use HTTPS for all future requests to this domain.
|
||||
|
||||
```
|
||||
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
|
||||
```
|
||||
|
||||
- `max-age=63072000` — 2 years (minimum for preload list)
|
||||
- `includeSubDomains` — apply to all subdomains
|
||||
- `preload` — opt into browser preload lists
|
||||
|
||||
## X-Frame-Options
|
||||
|
||||
Prevents the page from being embedded in iframes (clickjacking protection).
|
||||
|
||||
```
|
||||
X-Frame-Options: DENY
|
||||
```
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `DENY` | Never allow framing |
|
||||
| `SAMEORIGIN` | Allow framing by same origin only |
|
||||
|
||||
Note: `frame-ancestors` in CSP is the modern replacement but set both for backward compatibility.
|
||||
|
||||
## X-Content-Type-Options
|
||||
|
||||
Prevents browsers from MIME-sniffing the response content type.
|
||||
|
||||
```
|
||||
X-Content-Type-Options: nosniff
|
||||
```
|
||||
|
||||
Always pair with correct `Content-Type` headers on responses.
|
||||
|
||||
## Referrer-Policy
|
||||
|
||||
Controls how much referrer information is sent with requests.
|
||||
|
||||
```
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
```
|
||||
|
||||
| Value | Cross-Origin Sends | Same-Origin Sends |
|
||||
|-------|-------------------|-------------------|
|
||||
| `no-referrer` | Nothing | Nothing |
|
||||
| `origin` | Origin only | Origin only |
|
||||
| `strict-origin-when-cross-origin` | Origin (HTTPS only) | Full URL |
|
||||
| `same-origin` | Nothing | Full URL |
|
||||
|
||||
## Permissions-Policy
|
||||
|
||||
Restricts which browser features the page can use.
|
||||
|
||||
```
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
|
||||
```
|
||||
|
||||
| Feature | Recommended | Description |
|
||||
|---------|-------------|-------------|
|
||||
| `camera` | `()` | Disable camera access |
|
||||
| `microphone` | `()` | Disable microphone |
|
||||
| `geolocation` | `()` | Disable location |
|
||||
| `payment` | `()` | Disable Payment API |
|
||||
| `usb` | `()` | Disable USB access |
|
||||
| `fullscreen` | `(self)` | Allow fullscreen for same origin |
|
||||
|
||||
---
|
||||
|
||||
## Implementation: Python (FastAPI)
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
)
|
||||
response.headers["Strict-Transport-Security"] = (
|
||||
"max-age=63072000; includeSubDomains; preload"
|
||||
)
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=()"
|
||||
)
|
||||
return response
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
```
|
||||
|
||||
## Implementation: Node.js (Express)
|
||||
|
||||
```typescript
|
||||
import helmet from "helmet";
|
||||
import express from "express";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
frameAncestors: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
},
|
||||
},
|
||||
strictTransportSecurity: {
|
||||
maxAge: 63072000,
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
frameguard: { action: "deny" },
|
||||
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
|
||||
permissionsPolicy: {
|
||||
features: {
|
||||
camera: [],
|
||||
microphone: [],
|
||||
geolocation: [],
|
||||
payment: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Implementation: Next.js
|
||||
|
||||
```typescript
|
||||
// next.config.ts
|
||||
const securityHeaders = [
|
||||
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
|
||||
];
|
||||
|
||||
export default {
|
||||
async headers() {
|
||||
return [{ source: "/(.*)", headers: securityHeaders }];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check headers on a live site
|
||||
curl -I https://example.com
|
||||
|
||||
# Use securityheaders.com for a grade
|
||||
# https://securityheaders.com/?q=https://example.com
|
||||
```
|
||||
|
||||
*Source: [MDN HTTP Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers), [OWASP Secure Headers](https://owasp.org/www-project-secure-headers/)*
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Security audit scanner for common vulnerabilities.
|
||||
|
||||
Scans source files for hardcoded secrets, eval() usage, SQL string
|
||||
concatenation, and sensitive data in console output. Outputs JSON.
|
||||
|
||||
Usage:
|
||||
python security-audit.py ./src
|
||||
python security-audit.py ./src --severity high --format pretty
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
SCAN_EXTENSIONS = {
|
||||
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go",
|
||||
".rb", ".php", ".env", ".yaml", ".yml", ".toml", ".json",
|
||||
}
|
||||
|
||||
SKIP_DIRS = {
|
||||
"node_modules", ".git", "__pycache__", ".venv", "venv",
|
||||
"dist", "build", ".next", ".nuxt", "vendor",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
file: str
|
||||
line: int
|
||||
rule: str
|
||||
severity: str
|
||||
message: str
|
||||
snippet: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditReport:
|
||||
scanned_files: int = 0
|
||||
findings: list = field(default_factory=list)
|
||||
summary: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
# --- Detection Rules ---
|
||||
|
||||
SECRET_PATTERNS = [
|
||||
(r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\'][A-Za-z0-9_\-]{16,}["\']', "Possible API key"),
|
||||
(r'(?i)(secret|password|passwd|pwd)\s*[=:]\s*["\'][^"\']{8,}["\']', "Possible hardcoded secret"),
|
||||
(r'(?i)(aws_access_key_id|aws_secret_access_key)\s*[=:]\s*["\'][^"\']+["\']', "AWS credential"),
|
||||
(r'(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}', "Possible bearer token"),
|
||||
(r'(?i)(ghp_|gho_|github_pat_)[A-Za-z0-9_]{20,}', "GitHub token"),
|
||||
(r'(?i)(sk-|pk_live_|pk_test_|sk_live_|sk_test_)[A-Za-z0-9]{20,}', "API secret key"),
|
||||
(r'-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----', "Private key in source"),
|
||||
]
|
||||
|
||||
EVAL_PATTERNS = [
|
||||
(r'\beval\s*\(', "eval() usage detected"),
|
||||
(r'\bexec\s*\(', "exec() usage detected (Python)"),
|
||||
(r'new\s+Function\s*\(', "new Function() usage (dynamic code)"),
|
||||
(r'\bchild_process\.exec\s*\(', "child_process.exec (command injection risk)"),
|
||||
(r'subprocess\.call\s*\([^)]*shell\s*=\s*True', "subprocess with shell=True"),
|
||||
(r'os\.system\s*\(', "os.system() usage (command injection risk)"),
|
||||
]
|
||||
|
||||
SQL_PATTERNS = [
|
||||
(r'(?i)(SELECT|INSERT|UPDATE|DELETE|DROP)\s+.*([\+]|\.format\(|f["\']|%\s)', "SQL string concatenation"),
|
||||
(r'(?i)execute\s*\(\s*f["\']', "SQL f-string in execute()"),
|
||||
(r'(?i)\.query\s*\(\s*`[^`]*\$\{', "SQL template literal injection"),
|
||||
(r'(?i)\.raw\s*\(\s*f["\']', "Raw SQL with f-string"),
|
||||
]
|
||||
|
||||
SENSITIVE_LOG_PATTERNS = [
|
||||
(r'console\.log\s*\(.*(?i)(password|secret|token|key|credential)', "Sensitive data in console.log"),
|
||||
(r'print\s*\(.*(?i)(password|secret|token|key|credential)', "Sensitive data in print()"),
|
||||
(r'logger?\.(info|debug|warn)\s*\(.*(?i)(password|secret|token)', "Sensitive data in logger"),
|
||||
]
|
||||
|
||||
RULES = [
|
||||
("hardcoded-secret", "high", SECRET_PATTERNS),
|
||||
("dangerous-eval", "high", EVAL_PATTERNS),
|
||||
("sql-injection", "high", SQL_PATTERNS),
|
||||
("sensitive-logging", "medium", SENSITIVE_LOG_PATTERNS),
|
||||
]
|
||||
|
||||
|
||||
def should_scan(path: Path) -> bool:
|
||||
if path.suffix not in SCAN_EXTENSIONS:
|
||||
return False
|
||||
for part in path.parts:
|
||||
if part in SKIP_DIRS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def scan_file(filepath: Path) -> list[Finding]:
|
||||
findings = []
|
||||
try:
|
||||
content = filepath.read_text(encoding="utf-8", errors="ignore")
|
||||
except (OSError, PermissionError):
|
||||
return findings
|
||||
|
||||
lines = content.splitlines()
|
||||
for line_num, line in enumerate(lines, start=1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(("#", "//", "*", "/*")):
|
||||
continue
|
||||
for rule_name, severity, patterns in RULES:
|
||||
for pattern, message in patterns:
|
||||
if re.search(pattern, line):
|
||||
findings.append(Finding(
|
||||
file=str(filepath),
|
||||
line=line_num,
|
||||
rule=rule_name,
|
||||
severity=severity,
|
||||
message=message,
|
||||
snippet=line.strip()[:120],
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def scan_directory(target: Path, severity_filter: str | None = None) -> AuditReport:
|
||||
report = AuditReport()
|
||||
severity_order = {"high": 3, "medium": 2, "low": 1}
|
||||
min_severity = severity_order.get(severity_filter, 0) if severity_filter else 0
|
||||
|
||||
for root, dirs, files in os.walk(target):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
for fname in files:
|
||||
fpath = Path(root) / fname
|
||||
if not should_scan(fpath):
|
||||
continue
|
||||
report.scanned_files += 1
|
||||
for finding in scan_file(fpath):
|
||||
if severity_order.get(finding.severity, 0) >= min_severity:
|
||||
report.findings.append(finding)
|
||||
|
||||
report.summary = {
|
||||
"total": len(report.findings),
|
||||
"high": sum(1 for f in report.findings if f.severity == "high"),
|
||||
"medium": sum(1 for f in report.findings if f.severity == "medium"),
|
||||
"low": sum(1 for f in report.findings if f.severity == "low"),
|
||||
"by_rule": {},
|
||||
}
|
||||
for f in report.findings:
|
||||
report.summary["by_rule"][f.rule] = report.summary["by_rule"].get(f.rule, 0) + 1
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scan source files for common security issues.",
|
||||
epilog="Example: python security-audit.py ./src --severity high",
|
||||
)
|
||||
parser.add_argument("target", help="Directory or file to scan")
|
||||
parser.add_argument(
|
||||
"--severity", choices=["low", "medium", "high"],
|
||||
help="Minimum severity to report (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format", choices=["json", "pretty"], default="json",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
target = Path(args.target)
|
||||
if not target.exists():
|
||||
print(f"Error: {target} does not exist", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
report = scan_directory(target, args.severity)
|
||||
output = {
|
||||
"scanned_files": report.scanned_files,
|
||||
"summary": report.summary,
|
||||
"findings": [asdict(f) for f in report.findings],
|
||||
}
|
||||
|
||||
if args.format == "pretty":
|
||||
print(f"\nScanned {report.scanned_files} files\n")
|
||||
print(f"Findings: {report.summary['total']} total "
|
||||
f"({report.summary['high']} high, {report.summary['medium']} medium)")
|
||||
print("-" * 60)
|
||||
for f in report.findings:
|
||||
print(f"[{f.severity.upper()}] {f.file}:{f.line}")
|
||||
print(f" Rule: {f.rule}")
|
||||
print(f" {f.message}")
|
||||
print(f" > {f.snippet}")
|
||||
print()
|
||||
else:
|
||||
print(json.dumps(output, indent=2))
|
||||
|
||||
sys.exit(1 if report.summary.get("high", 0) > 0 else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
# Security Code Review Checklist
|
||||
|
||||
**Project**: _______________
|
||||
**Reviewer**: _______________
|
||||
**Date**: _______________
|
||||
**Scope**: _______________
|
||||
|
||||
---
|
||||
|
||||
## Authentication and Session Management
|
||||
|
||||
- [ ] Passwords hashed with bcrypt/argon2 (not MD5/SHA1)
|
||||
- [ ] Session tokens are cryptographically random
|
||||
- [ ] Session cookies use `Secure`, `HttpOnly`, `SameSite` flags
|
||||
- [ ] Session timeout is enforced (idle and absolute)
|
||||
- [ ] Failed login attempts are rate-limited
|
||||
- [ ] MFA is available for sensitive accounts
|
||||
- [ ] Password reset tokens expire and are single-use
|
||||
|
||||
## Authorization and Access Control
|
||||
|
||||
- [ ] Access denied by default (allowlist approach)
|
||||
- [ ] Server-side authorization on every request
|
||||
- [ ] Resource ownership verified before access
|
||||
- [ ] Role/permission checks cannot be bypassed via direct URL
|
||||
- [ ] Admin endpoints have separate authentication
|
||||
- [ ] CORS policy restricts allowed origins
|
||||
|
||||
## Input Validation
|
||||
|
||||
- [ ] All user input validated server-side
|
||||
- [ ] Parameterized queries used for all database access
|
||||
- [ ] No string concatenation in SQL/commands
|
||||
- [ ] File uploads validated (type, size, content)
|
||||
- [ ] Path traversal prevented on file operations
|
||||
- [ ] JSON/XML parsers configured against XXE
|
||||
|
||||
## Output Encoding
|
||||
|
||||
- [ ] HTML output properly escaped (XSS prevention)
|
||||
- [ ] Content-Type headers set correctly on all responses
|
||||
- [ ] API responses do not leak stack traces in production
|
||||
- [ ] Error messages do not reveal system internals
|
||||
- [ ] Sensitive data excluded from logs
|
||||
|
||||
## Cryptography
|
||||
|
||||
- [ ] TLS 1.2+ enforced for all connections
|
||||
- [ ] Sensitive data encrypted at rest
|
||||
- [ ] No hardcoded secrets, keys, or passwords in source
|
||||
- [ ] Secrets loaded from environment variables or vault
|
||||
- [ ] Strong algorithms used (AES-256, RSA-2048+, SHA-256+)
|
||||
- [ ] No custom cryptographic implementations
|
||||
|
||||
## Security Headers
|
||||
|
||||
- [ ] Content-Security-Policy configured
|
||||
- [ ] Strict-Transport-Security enabled
|
||||
- [ ] X-Frame-Options set to DENY
|
||||
- [ ] X-Content-Type-Options set to nosniff
|
||||
- [ ] Referrer-Policy configured
|
||||
- [ ] Permissions-Policy restricts unused features
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [ ] No known vulnerabilities (`npm audit` / `pip audit` clean)
|
||||
- [ ] Unused dependencies removed
|
||||
- [ ] Dependencies pinned to specific versions
|
||||
- [ ] Lock file committed and up to date
|
||||
|
||||
## Logging and Monitoring
|
||||
|
||||
- [ ] Authentication events logged (success and failure)
|
||||
- [ ] Authorization failures logged
|
||||
- [ ] Sensitive data not written to logs
|
||||
- [ ] Log injection prevented (user input sanitized in logs)
|
||||
- [ ] Alerts configured for suspicious patterns
|
||||
|
||||
## API Security
|
||||
|
||||
- [ ] Rate limiting on all public endpoints
|
||||
- [ ] Request size limits configured
|
||||
- [ ] API keys/tokens not exposed in URLs
|
||||
- [ ] Pagination enforced on list endpoints
|
||||
- [ ] HTTPS required (HTTP redirects or blocks)
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- [ ] Debug mode disabled in production
|
||||
- [ ] Default credentials changed
|
||||
- [ ] Unnecessary ports/services disabled
|
||||
- [ ] Container runs as non-root user
|
||||
- [ ] Environment variables not logged at startup
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Pass | Fail | N/A |
|
||||
|----------|------|------|-----|
|
||||
| Authentication | | | |
|
||||
| Authorization | | | |
|
||||
| Input Validation | | | |
|
||||
| Output Encoding | | | |
|
||||
| Cryptography | | | |
|
||||
| Security Headers | | | |
|
||||
| Dependencies | | | |
|
||||
| Logging | | | |
|
||||
| API Security | | | |
|
||||
| Infrastructure | | | |
|
||||
|
||||
**Overall Assessment**: [ ] Pass / [ ] Conditional Pass / [ ] Fail
|
||||
|
||||
**Notes**:
|
||||
|
||||
|
||||
|
||||
**Follow-up Actions**:
|
||||
|
||||
|
||||
@@ -1,90 +1,689 @@
|
||||
---
|
||||
name: pytest
|
||||
description: >
|
||||
Trigger this skill whenever writing, debugging, or refactoring Python tests, or when pytest fixtures, parametrization, mocking, or coverage are mentioned. Activate for any .py test file, test_* function, conftest.py, pytest.ini, or pyproject.toml [tool.pytest] reference. Also use when the user asks about Python test patterns, test organization, or test-driven development in a Python context.
|
||||
---
|
||||
|
||||
# pytest
|
||||
|
||||
## Description
|
||||
|
||||
Python testing with pytest including fixtures, parametrization, and mocking.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Writing Python tests
|
||||
- Test fixtures and setup
|
||||
- Mocking dependencies
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- JavaScript or TypeScript testing -- use the `testing/vitest` skill instead
|
||||
- Projects that explicitly mandate unittest-only by convention with no pytest dependency
|
||||
- Non-Python test files or environments
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Basic Tests
|
||||
### 1. Fixtures
|
||||
|
||||
Fixtures provide reusable setup and teardown logic. They are requested by name as test function parameters.
|
||||
|
||||
#### Function-Scoped Fixtures (default)
|
||||
|
||||
A new instance is created for every test that requests it.
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from myapp.models import User
|
||||
from myapp.db import Session
|
||||
|
||||
def test_addition():
|
||||
assert 1 + 1 == 2
|
||||
|
||||
def test_exception():
|
||||
with pytest.raises(ValueError, match="Invalid"):
|
||||
raise ValueError("Invalid input")
|
||||
```
|
||||
|
||||
### Fixtures
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def user():
|
||||
return User(id=1, name="Test")
|
||||
"""Fresh user instance per test."""
|
||||
return User(id=1, name="Alice", email="alice@example.com")
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
session = create_session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
def test_with_fixtures(user, db_session):
|
||||
db_session.add(user)
|
||||
assert user.id is not None
|
||||
def test_user_display_name(user):
|
||||
assert user.display_name() == "Alice"
|
||||
|
||||
|
||||
def test_user_email_domain(user):
|
||||
assert user.email_domain() == "example.com"
|
||||
```
|
||||
|
||||
### Parametrization
|
||||
#### Class and Module Scope
|
||||
|
||||
Use broader scopes for expensive resources that are safe to share.
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
@pytest.fixture(scope="class")
|
||||
def api_client():
|
||||
"""Shared across all tests in a test class."""
|
||||
client = APIClient(base_url="http://testserver")
|
||||
client.authenticate(token="test-token")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def database_schema():
|
||||
"""Created once per test module, shared across all tests in the file."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def redis_connection():
|
||||
"""Created once for the entire test session."""
|
||||
conn = Redis(host="localhost", port=6379, db=15)
|
||||
conn.flushdb()
|
||||
yield conn
|
||||
conn.flushdb()
|
||||
conn.close()
|
||||
```
|
||||
|
||||
#### Yield Fixtures for Teardown
|
||||
|
||||
`yield` separates setup from teardown. Code after `yield` runs after the test completes, even if the test fails.
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
session = Session()
|
||||
session.begin()
|
||||
yield session
|
||||
session.rollback()
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config(tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("debug: true\nlog_level: INFO\n")
|
||||
yield config_file
|
||||
# tmp_path is automatically cleaned up by pytest
|
||||
```
|
||||
|
||||
#### Autouse Fixtures
|
||||
|
||||
Apply a fixture to every test automatically without requesting it by name.
|
||||
|
||||
```python
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_environment(monkeypatch):
|
||||
"""Ensure each test starts with clean environment variables."""
|
||||
monkeypatch.delenv("API_KEY", raising=False)
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def freeze_time():
|
||||
"""Pin time for deterministic tests."""
|
||||
with freeze_time("2025-06-15T12:00:00Z"):
|
||||
yield
|
||||
```
|
||||
|
||||
#### Factory Fixtures
|
||||
|
||||
Return a factory function when tests need multiple instances with varying parameters.
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def make_user():
|
||||
"""Factory that creates users with sensible defaults."""
|
||||
created = []
|
||||
|
||||
def _make_user(name="Test User", role="viewer", active=True):
|
||||
user = User(name=name, role=role, active=active)
|
||||
created.append(user)
|
||||
return user
|
||||
|
||||
yield _make_user
|
||||
|
||||
# Teardown: clean up all created users
|
||||
for u in created:
|
||||
u.delete()
|
||||
|
||||
|
||||
def test_admin_permissions(make_user):
|
||||
admin = make_user(name="Admin", role="admin")
|
||||
viewer = make_user(name="Viewer", role="viewer")
|
||||
assert admin.can_delete_users() is True
|
||||
assert viewer.can_delete_users() is False
|
||||
```
|
||||
|
||||
#### Parametrized Fixtures with request.param
|
||||
|
||||
Run the same test against multiple fixture variants.
|
||||
|
||||
```python
|
||||
@pytest.fixture(params=["sqlite", "postgresql"])
|
||||
def db_engine(request):
|
||||
"""Test against multiple database backends."""
|
||||
if request.param == "sqlite":
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
elif request.param == "postgresql":
|
||||
engine = create_engine("postgresql://test:test@localhost/testdb")
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_insert_and_query(db_engine):
|
||||
# This test runs twice: once with sqlite, once with postgresql
|
||||
with db_engine.connect() as conn:
|
||||
conn.execute(text("CREATE TABLE t (id INT)"))
|
||||
conn.execute(text("INSERT INTO t VALUES (1)"))
|
||||
result = conn.execute(text("SELECT * FROM t")).fetchall()
|
||||
assert len(result) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Parametrize
|
||||
|
||||
#### Single Parameter
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("email", [
|
||||
"user@example.com",
|
||||
"admin@test.org",
|
||||
"name+tag@domain.co.uk",
|
||||
])
|
||||
def test_valid_email_accepted(email):
|
||||
assert is_valid_email(email) is True
|
||||
```
|
||||
|
||||
#### Multiple Parameters
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("input_text, expected", [
|
||||
("hello", "HELLO"),
|
||||
("world", "WORLD"),
|
||||
("", ""),
|
||||
("already UPPER", "ALREADY UPPER"),
|
||||
])
|
||||
def test_uppercase(input, expected):
|
||||
assert input.upper() == expected
|
||||
def test_uppercase(input_text, expected):
|
||||
assert input_text.upper() == expected
|
||||
```
|
||||
|
||||
### Mocking
|
||||
#### Custom IDs for Readable Output
|
||||
|
||||
```python
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
def test_with_mock():
|
||||
service = Mock()
|
||||
service.get_user.return_value = {"id": 1}
|
||||
|
||||
result = service.get_user(1)
|
||||
assert result["id"] == 1
|
||||
|
||||
@patch('module.external_api')
|
||||
def test_with_patch(mock_api):
|
||||
mock_api.fetch.return_value = {"data": []}
|
||||
# Test code that uses external_api
|
||||
@pytest.mark.parametrize("status_code, should_retry", [
|
||||
pytest.param(200, False, id="success-no-retry"),
|
||||
pytest.param(429, True, id="rate-limited-retry"),
|
||||
pytest.param(500, True, id="server-error-retry"),
|
||||
pytest.param(404, False, id="not-found-no-retry"),
|
||||
])
|
||||
def test_retry_logic(status_code, should_retry):
|
||||
response = MockResponse(status_code=status_code)
|
||||
assert should_retry_request(response) is should_retry
|
||||
```
|
||||
|
||||
#### Indirect Parametrize
|
||||
|
||||
Pass parameters through a fixture rather than directly to the test.
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def user_role(request):
|
||||
"""Create a user with the given role."""
|
||||
return User(name="Test", role=request.param)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_role", ["admin", "editor", "viewer"], indirect=True)
|
||||
def test_dashboard_access(user_role):
|
||||
if user_role.role == "admin":
|
||||
assert user_role.can_access("/admin/dashboard") is True
|
||||
else:
|
||||
assert user_role.can_access("/admin/dashboard") is False
|
||||
```
|
||||
|
||||
#### Stacking Parametrize Decorators
|
||||
|
||||
Creates the cartesian product of all parameter sets.
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"])
|
||||
@pytest.mark.parametrize("auth", ["token", "session", "none"])
|
||||
def test_endpoint_auth(method, auth):
|
||||
# Runs 4 x 3 = 12 test cases
|
||||
response = make_request(method=method, auth_type=auth)
|
||||
if auth == "none":
|
||||
assert response.status_code == 401
|
||||
else:
|
||||
assert response.status_code in (200, 201, 204)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Mocking
|
||||
|
||||
#### monkeypatch -- Environment Variables and Attributes
|
||||
|
||||
```python
|
||||
def test_reads_api_key_from_env(monkeypatch):
|
||||
monkeypatch.setenv("API_KEY", "test-key-12345")
|
||||
config = load_config()
|
||||
assert config.api_key == "test-key-12345"
|
||||
|
||||
|
||||
def test_missing_api_key_raises(monkeypatch):
|
||||
monkeypatch.delenv("API_KEY", raising=False)
|
||||
with pytest.raises(ConfigError, match="API_KEY is required"):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_override_attribute(monkeypatch):
|
||||
monkeypatch.setattr("myapp.settings.MAX_RETRIES", 0)
|
||||
assert retry_request(failing_url) is None # No retries attempted
|
||||
|
||||
|
||||
def test_override_dict_item(monkeypatch):
|
||||
monkeypatch.setitem(app_config, "timeout", 1)
|
||||
assert app_config["timeout"] == 1
|
||||
```
|
||||
|
||||
#### unittest.mock.patch
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, Mock, AsyncMock
|
||||
|
||||
|
||||
@patch("myapp.services.payment.stripe.Charge.create")
|
||||
def test_charge_customer(mock_charge):
|
||||
mock_charge.return_value = Mock(id="ch_123", status="succeeded")
|
||||
|
||||
result = process_payment(amount=1000, currency="usd", token="tok_visa")
|
||||
|
||||
mock_charge.assert_called_once_with(
|
||||
amount=1000, currency="usd", source="tok_visa"
|
||||
)
|
||||
assert result.charge_id == "ch_123"
|
||||
|
||||
|
||||
@patch("myapp.services.email.send_email")
|
||||
@patch("myapp.services.user.UserRepository.find_by_id")
|
||||
def test_send_welcome_email(mock_find, mock_send):
|
||||
mock_find.return_value = User(id=1, email="new@example.com")
|
||||
mock_send.return_value = True
|
||||
|
||||
send_welcome(user_id=1)
|
||||
|
||||
mock_send.assert_called_once_with(
|
||||
to="new@example.com", template="welcome"
|
||||
)
|
||||
```
|
||||
|
||||
#### responses Library for HTTP Mocking
|
||||
|
||||
```python
|
||||
import responses
|
||||
import requests
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_fetch_user_from_api():
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.example.com/users/1",
|
||||
json={"id": 1, "name": "Alice"},
|
||||
status=200,
|
||||
)
|
||||
|
||||
result = fetch_user(user_id=1)
|
||||
|
||||
assert result["name"] == "Alice"
|
||||
assert len(responses.calls) == 1
|
||||
assert responses.calls[0].request.url == "https://api.example.com/users/1"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_timeout_handling():
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.example.com/users/1",
|
||||
body=requests.exceptions.ConnectionError("Connection timed out"),
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceUnavailableError):
|
||||
fetch_user(user_id=1)
|
||||
```
|
||||
|
||||
#### pytest-mock's mocker Fixture
|
||||
|
||||
```python
|
||||
def test_with_mocker(mocker):
|
||||
mock_repo = mocker.patch("myapp.services.OrderRepository")
|
||||
mock_repo.return_value.get_by_id.return_value = Order(
|
||||
id=1, status="pending"
|
||||
)
|
||||
|
||||
service = OrderService()
|
||||
order = service.get_order(1)
|
||||
|
||||
assert order.status == "pending"
|
||||
mock_repo.return_value.get_by_id.assert_called_once_with(1)
|
||||
|
||||
|
||||
def test_spy_on_method(mocker):
|
||||
spy = mocker.spy(UserService, "validate_email")
|
||||
|
||||
service = UserService()
|
||||
service.register("alice@example.com")
|
||||
|
||||
spy.assert_called_once_with(service, "alice@example.com")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Async Testing
|
||||
|
||||
#### pytest-asyncio Basics
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_fetch():
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get("https://httpbin.org/get")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception():
|
||||
with pytest.raises(ValueError, match="invalid"):
|
||||
await validate_async_input("")
|
||||
```
|
||||
|
||||
#### Async Fixtures
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def async_db_session():
|
||||
session = AsyncSession(bind=async_engine)
|
||||
await session.begin()
|
||||
yield session
|
||||
await session.rollback()
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_query(async_db_session):
|
||||
result = await async_db_session.execute(
|
||||
select(User).where(User.active == True)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
assert len(users) >= 0
|
||||
```
|
||||
|
||||
#### Configuring asyncio Mode
|
||||
|
||||
In `pyproject.toml` or `pytest.ini`, set the default mode to avoid repeating the marker:
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
```
|
||||
|
||||
With `asyncio_mode = "auto"`, any `async def test_*` function is automatically treated as async -- no `@pytest.mark.asyncio` needed.
|
||||
|
||||
---
|
||||
|
||||
### 5. Test Organization
|
||||
|
||||
#### conftest.py Hierarchy
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Session/global fixtures (db connection, app client)
|
||||
├── unit/
|
||||
│ ├── conftest.py # Unit-specific fixtures (mocked services)
|
||||
│ ├── test_models.py
|
||||
│ └── test_utils.py
|
||||
├── integration/
|
||||
│ ├── conftest.py # Integration fixtures (real db session, test server)
|
||||
│ ├── test_api.py
|
||||
│ └── test_repositories.py
|
||||
└── e2e/
|
||||
├── conftest.py # E2E fixtures (browser, full app)
|
||||
└── test_workflows.py
|
||||
```
|
||||
|
||||
Fixtures in a `conftest.py` are available to all tests in the same directory and below. No imports needed.
|
||||
|
||||
#### Test Discovery
|
||||
|
||||
pytest discovers tests by default based on these rules:
|
||||
- Files matching `test_*.py` or `*_test.py`
|
||||
- Classes prefixed with `Test` (no `__init__` method)
|
||||
- Functions prefixed with `test_`
|
||||
|
||||
Configure custom discovery in `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
```
|
||||
|
||||
#### Markers
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
# Built-in markers
|
||||
@pytest.mark.skip(reason="Not implemented yet")
|
||||
def test_future_feature():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="Unix-only functionality"
|
||||
)
|
||||
def test_unix_permissions():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="Known bug #1234, fix pending")
|
||||
def test_known_broken():
|
||||
result = buggy_function()
|
||||
assert result == "expected"
|
||||
```
|
||||
|
||||
#### Custom Markers
|
||||
|
||||
Register markers in `pyproject.toml` to avoid warnings:
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"integration: marks integration tests requiring external services",
|
||||
"smoke: critical path tests for quick validation",
|
||||
]
|
||||
```
|
||||
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
def test_full_data_migration():
|
||||
migrate_all_records() # Takes 30+ seconds
|
||||
assert count_records() == EXPECTED_TOTAL
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_health_endpoint(client):
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
Run selectively:
|
||||
|
||||
```bash
|
||||
pytest -m "smoke" # Only smoke tests
|
||||
pytest -m "not slow" # Skip slow tests
|
||||
pytest -m "integration and not slow" # Integration but not slow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Coverage
|
||||
|
||||
#### Basic Usage
|
||||
|
||||
```bash
|
||||
pytest --cov=src --cov-report=term-missing
|
||||
pytest --cov=src --cov-report=html # Generates htmlcov/
|
||||
pytest --cov=src --cov-branch # Enable branch coverage
|
||||
```
|
||||
|
||||
#### Configuration in pyproject.toml
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--cov=src --cov-report=term-missing --cov-fail-under=80"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src"]
|
||||
branch = true
|
||||
omit = [
|
||||
"*/migrations/*",
|
||||
"*/tests/*",
|
||||
"*/__pycache__/*",
|
||||
"*/conftest.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
"@overload",
|
||||
]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
```
|
||||
|
||||
#### .coveragerc Alternative
|
||||
|
||||
If not using `pyproject.toml`, create `.coveragerc`:
|
||||
|
||||
```ini
|
||||
[run]
|
||||
source = src
|
||||
branch = true
|
||||
|
||||
[report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
if TYPE_CHECKING:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Assertions
|
||||
|
||||
#### pytest.raises for Exceptions
|
||||
|
||||
```python
|
||||
def test_raises_value_error():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_age("not-a-number")
|
||||
assert "invalid literal" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_raises_with_match():
|
||||
with pytest.raises(PermissionError, match=r"User .+ lacks role 'admin'"):
|
||||
authorize(user=viewer, required_role="admin")
|
||||
```
|
||||
|
||||
#### pytest.approx for Floating Point
|
||||
|
||||
```python
|
||||
def test_circle_area():
|
||||
assert calculate_area(radius=5) == pytest.approx(78.5398, rel=1e-4)
|
||||
|
||||
|
||||
def test_approx_list():
|
||||
result = distribute_evenly(total=100, buckets=3)
|
||||
assert result == pytest.approx([33.33, 33.33, 33.34], abs=0.01)
|
||||
```
|
||||
|
||||
#### Custom Assertion Helpers
|
||||
|
||||
Build reusable assertion logic for domain-specific validation.
|
||||
|
||||
```python
|
||||
def assert_valid_api_response(response, expected_status=200):
|
||||
"""Reusable assertion for API responses."""
|
||||
assert response.status_code == expected_status, (
|
||||
f"Expected {expected_status}, got {response.status_code}: "
|
||||
f"{response.text}"
|
||||
)
|
||||
data = response.json()
|
||||
assert "error" not in data, f"Unexpected error: {data['error']}"
|
||||
return data
|
||||
|
||||
|
||||
def test_create_user(client):
|
||||
response = client.post("/users", json={"name": "Alice"})
|
||||
data = assert_valid_api_response(response, expected_status=201)
|
||||
assert data["name"] == "Alice"
|
||||
assert "id" in data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use fixtures for test setup
|
||||
2. Parametrize for multiple test cases
|
||||
3. Mock external dependencies
|
||||
4. Use descriptive test names
|
||||
5. Keep tests independent
|
||||
1. **Name tests descriptively** -- Use `test_[function]_[scenario]_[expected]` so failures are self-explanatory without reading the test body. `test_parse_date_invalid_format_raises_valueerror` tells you everything.
|
||||
|
||||
2. **Keep tests independent** -- Never rely on test execution order. Each test should set up its own state via fixtures and tear it down afterward. Shared mutable state between tests is the top cause of flaky suites.
|
||||
|
||||
3. **One assertion focus per test** -- A test can have multiple `assert` statements, but they should all verify the same behavior. If you need to check two independent behaviors, write two tests.
|
||||
|
||||
4. **Use fixtures over setup methods** -- Prefer composable fixtures over `setUp`/`tearDown` methods or `setup_function`. Fixtures are explicit about dependencies, reusable across files via `conftest.py`, and support scoping.
|
||||
|
||||
5. **Mock at the boundary, not in the middle** -- Mock external services, databases, and network calls. Do not mock internal functions unless they are truly expensive. Over-mocking produces tests that pass but verify nothing.
|
||||
|
||||
6. **Use `tmp_path` for file operations** -- pytest's built-in `tmp_path` fixture provides a unique temporary directory per test. Never write to the real filesystem in tests.
|
||||
|
||||
7. **Pin randomness and time** -- When testing code that depends on randomness or the current time, use `random.seed()` or a time-freezing library to make tests deterministic.
|
||||
|
||||
8. **Run the full suite in CI with branch coverage** -- Local development can use `pytest -x` for fast feedback (stop on first failure), but CI must run the full suite with `--cov-branch` to catch untested branches and regressions.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Shared state**: Use fresh fixtures
|
||||
- **Over-mocking**: Only mock boundaries
|
||||
- **Slow tests**: Use markers for slow tests
|
||||
1. **Shared mutable fixtures** -- A module-scoped fixture returning a mutable object (list, dict, instance) gets modified by one test and breaks another. Return fresh copies or use function scope for mutable data.
|
||||
|
||||
2. **Patching the wrong import path** -- `@patch("myapp.services.requests.get")` patches where `requests.get` is looked up, not where it is defined. If `services.py` does `from requests import get`, you must patch `myapp.services.get`, not `requests.get`.
|
||||
|
||||
3. **Forgetting to await in async tests** -- Omitting `await` makes the test pass vacuously because it never actually runs the coroutine. Always `await` the function under test and use `@pytest.mark.asyncio`.
|
||||
|
||||
4. **Tests that depend on execution order** -- If test B relies on side effects from test A, parallel test execution (pytest-xdist) and `--randomly` will expose the coupling immediately. Fix by making each test self-contained.
|
||||
|
||||
5. **Asserting on mock call count without checking arguments** -- `mock.assert_called_once()` confirms the call count but not what was passed. Use `assert_called_once_with(...)` or inspect `mock.call_args` to verify the actual arguments.
|
||||
|
||||
6. **Ignoring warnings as errors** -- Configure `filterwarnings = ["error"]` in `pyproject.toml` to catch deprecation warnings early. A passing test suite that emits 50 deprecation warnings is a time bomb.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `testing/vitest` -- JavaScript/TypeScript testing counterpart
|
||||
- `languages/python` -- Python language patterns and idioms
|
||||
- `methodology/test-driven-development` -- TDD workflow for writing tests first
|
||||
- `devops/github-actions` — Running pytest in CI/CD pipelines
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
# pytest Fixture Patterns
|
||||
|
||||
Catalog of reusable fixture patterns for common testing scenarios.
|
||||
|
||||
## 1. Factory Fixture
|
||||
|
||||
Create multiple instances with customizable defaults.
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from dataclasses import dataclass
|
||||
|
||||
@pytest.fixture
|
||||
def make_user():
|
||||
"""Factory fixture: creates User instances with sensible defaults."""
|
||||
created = []
|
||||
|
||||
def _make_user(
|
||||
name: str = "Test User",
|
||||
email: str | None = None,
|
||||
is_active: bool = True,
|
||||
):
|
||||
if email is None:
|
||||
email = f"user-{len(created)}@test.com"
|
||||
user = User(name=name, email=email, is_active=is_active)
|
||||
created.append(user)
|
||||
return user
|
||||
|
||||
yield _make_user
|
||||
|
||||
# Cleanup: delete all created users
|
||||
for user in created:
|
||||
user.delete()
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```python
|
||||
def test_deactivate_user(make_user):
|
||||
user = make_user(name="Alice", is_active=True)
|
||||
user.deactivate()
|
||||
assert not user.is_active
|
||||
```
|
||||
|
||||
## 2. Database Session (SQLAlchemy)
|
||||
|
||||
Transaction-isolated database session that rolls back after each test.
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def engine():
|
||||
"""Create a test database engine (once per test session)."""
|
||||
engine = create_engine("postgresql://test:test@localhost:5432/test_db")
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tables(engine):
|
||||
"""Create all tables once, drop after all tests."""
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(engine, tables):
|
||||
"""Provide a transactional database session that rolls back after each test."""
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = sessionmaker(bind=connection)()
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
```
|
||||
|
||||
## 3. Temporary Files and Directories
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def sample_config(tmp_path: Path) -> Path:
|
||||
"""Create a temporary config file with test content."""
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text(
|
||||
"""\
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
debug: true
|
||||
"""
|
||||
)
|
||||
return config
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temporary directory structure for testing."""
|
||||
(tmp_path / "input").mkdir()
|
||||
(tmp_path / "output").mkdir()
|
||||
(tmp_path / "input" / "data.csv").write_text("id,name\n1,Alice\n2,Bob\n")
|
||||
return tmp_path
|
||||
```
|
||||
|
||||
## 4. Mock External Service
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client():
|
||||
"""Mock an HTTP client with pre-configured responses."""
|
||||
client = MagicMock()
|
||||
client.get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "ok"},
|
||||
)
|
||||
client.post.return_value = MagicMock(
|
||||
status_code=201,
|
||||
json=lambda: {"id": "new-123"},
|
||||
)
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def mock_payment_gateway():
|
||||
"""Mock a payment gateway service."""
|
||||
with patch("app.services.payment.PaymentGateway") as mock_cls:
|
||||
instance = mock_cls.return_value
|
||||
instance.charge.return_value = {
|
||||
"transaction_id": "txn-test-123",
|
||||
"status": "succeeded",
|
||||
}
|
||||
instance.refund.return_value = {
|
||||
"refund_id": "ref-test-456",
|
||||
"status": "refunded",
|
||||
}
|
||||
yield instance
|
||||
|
||||
# Async version
|
||||
@pytest.fixture
|
||||
def mock_email_service():
|
||||
"""Mock an async email service."""
|
||||
with patch("app.services.email.EmailService") as mock_cls:
|
||||
instance = mock_cls.return_value
|
||||
instance.send = AsyncMock(return_value={"message_id": "msg-test-789"})
|
||||
yield instance
|
||||
```
|
||||
|
||||
## 5. Authenticated Test Client (FastAPI)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from app.main import app
|
||||
from app.auth import create_access_token
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token():
|
||||
"""Generate a valid JWT token for testing."""
|
||||
return create_access_token(
|
||||
data={"sub": "test-user-id", "role": "admin"},
|
||||
expires_minutes=60,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(auth_token: str) -> dict[str, str]:
|
||||
"""HTTP headers with Bearer token."""
|
||||
return {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Unauthenticated async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(auth_headers):
|
||||
"""Authenticated async test client."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=auth_headers,
|
||||
) as c:
|
||||
yield c
|
||||
```
|
||||
|
||||
## 6. Environment Variables
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def env_vars(monkeypatch):
|
||||
"""Set environment variables for the test, automatically restored after."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://test:test@localhost/test")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.delenv("PRODUCTION_API_KEY", raising=False)
|
||||
```
|
||||
|
||||
## 7. Freezing Time
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def frozen_time():
|
||||
"""Freeze time to a specific moment."""
|
||||
fixed = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("app.services.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fixed
|
||||
mock_dt.utcnow.return_value = fixed
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
yield fixed
|
||||
```
|
||||
|
||||
Alternative: use `freezegun` library with `@freeze_time("2025-01-15 12:00:00")`.
|
||||
|
||||
## 8. Parametrized Fixture
|
||||
|
||||
```python
|
||||
@pytest.fixture(params=["sqlite", "postgresql"])
|
||||
def db_url(request):
|
||||
"""Run tests against multiple database backends."""
|
||||
urls = {
|
||||
"sqlite": "sqlite:///test.db",
|
||||
"postgresql": "postgresql://test:test@localhost/test",
|
||||
}
|
||||
return urls[request.param]
|
||||
```
|
||||
|
||||
## Fixture Scope Reference
|
||||
|
||||
| Scope | Lifetime | Use For |
|
||||
|-------|----------|---------|
|
||||
| `function` (default) | Each test | Most fixtures, mutable state |
|
||||
| `class` | Each test class | Shared setup for a class |
|
||||
| `module` | Each test file | Expensive setup shared across file |
|
||||
| `session` | Entire test run | Database engine, heavy resources |
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `yield` (not `return`) when cleanup is needed after the test.
|
||||
- Use `autouse=True` sparingly -- only for things every test needs.
|
||||
- Keep fixtures small and composable -- combine them in tests, not in other fixtures.
|
||||
- Use `monkeypatch` instead of `unittest.mock.patch` for env vars and attributes when possible.
|
||||
- Name fixtures after what they provide, not what they do: `db_session` not `setup_database`.
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Starter conftest.py -- common fixtures for pytest.
|
||||
|
||||
Usage:
|
||||
Place this file at the root of your tests/ directory.
|
||||
pytest automatically discovers conftest.py and makes its fixtures
|
||||
available to all tests in the same directory and below.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# If using FastAPI + httpx:
|
||||
# from httpx import ASGITransport, AsyncClient
|
||||
# from app.main import app
|
||||
|
||||
# If using SQLAlchemy:
|
||||
# from sqlalchemy import create_engine
|
||||
# from sqlalchemy.orm import Session, sessionmaker
|
||||
# from app.models import Base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Environment Variables
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _test_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set safe default environment variables for all tests.
|
||||
|
||||
autouse=True ensures this runs for every test automatically.
|
||||
monkeypatch restores original values after each test.
|
||||
"""
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("DEBUG", "true")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-not-for-production")
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
# Remove any production secrets that should never leak into tests.
|
||||
monkeypatch.delenv("PRODUCTION_API_KEY", raising=False)
|
||||
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Temporary Directory
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir(tmp_path: Path) -> Path:
|
||||
"""Provide a temporary directory with input/output subdirectories."""
|
||||
(tmp_path / "input").mkdir()
|
||||
(tmp_path / "output").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_file(tmp_path: Path) -> Path:
|
||||
"""Create a sample text file for testing file operations."""
|
||||
f = tmp_path / "sample.txt"
|
||||
f.write_text("line 1\nline 2\nline 3\n")
|
||||
return f
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Database Session (SQLAlchemy)
|
||||
# ==========================================================================
|
||||
|
||||
# Uncomment this section if using SQLAlchemy.
|
||||
|
||||
# TEST_DATABASE_URL = os.getenv(
|
||||
# "TEST_DATABASE_URL", "postgresql://test:test@localhost:5432/test_db"
|
||||
# )
|
||||
#
|
||||
#
|
||||
# @pytest.fixture(scope="session")
|
||||
# def engine():
|
||||
# """Create database engine for the entire test session."""
|
||||
# eng = create_engine(TEST_DATABASE_URL)
|
||||
# yield eng
|
||||
# eng.dispose()
|
||||
#
|
||||
#
|
||||
# @pytest.fixture(scope="session")
|
||||
# def tables(engine):
|
||||
# """Create tables at start of session, drop at end."""
|
||||
# Base.metadata.create_all(engine)
|
||||
# yield
|
||||
# Base.metadata.drop_all(engine)
|
||||
#
|
||||
#
|
||||
# @pytest.fixture
|
||||
# def db_session(engine, tables) -> Generator[Session, None, None]:
|
||||
# """Transactional database session -- rolls back after each test."""
|
||||
# connection = engine.connect()
|
||||
# transaction = connection.begin()
|
||||
# session = sessionmaker(bind=connection)()
|
||||
#
|
||||
# yield session
|
||||
#
|
||||
# session.close()
|
||||
# transaction.rollback()
|
||||
# connection.close()
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# HTTP Test Client (FastAPI)
|
||||
# ==========================================================================
|
||||
|
||||
# Uncomment this section if using FastAPI.
|
||||
|
||||
# @pytest.fixture
|
||||
# async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||
# """Async HTTP client for testing API endpoints."""
|
||||
# transport = ASGITransport(app=app)
|
||||
# async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
# yield c
|
||||
#
|
||||
#
|
||||
# @pytest.fixture
|
||||
# def auth_headers() -> dict[str, str]:
|
||||
# """Authorization headers with a test JWT token."""
|
||||
# from app.auth import create_access_token
|
||||
# token = create_access_token(data={"sub": "test-user", "role": "admin"})
|
||||
# return {"Authorization": f"Bearer {token}"}
|
||||
#
|
||||
#
|
||||
# @pytest.fixture
|
||||
# async def auth_client(auth_headers) -> AsyncGenerator[AsyncClient, None]:
|
||||
# """Authenticated async HTTP client."""
|
||||
# transport = ASGITransport(app=app)
|
||||
# async with AsyncClient(
|
||||
# transport=transport, base_url="http://test", headers=auth_headers
|
||||
# ) as c:
|
||||
# yield c
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Mock External Services
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client() -> MagicMock:
|
||||
"""Generic mock HTTP client with default 200/201 responses."""
|
||||
client = MagicMock()
|
||||
client.get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"status": "ok"},
|
||||
)
|
||||
client.post.return_value = MagicMock(
|
||||
status_code=201,
|
||||
json=lambda: {"id": "new-123"},
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
# @pytest.fixture
|
||||
# def mock_email_service():
|
||||
# """Mock email service to prevent real emails in tests."""
|
||||
# with patch("app.services.email.send_email") as mock_send:
|
||||
# mock_send.return_value = {"message_id": "test-msg-001"}
|
||||
# yield mock_send
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# Factory Fixtures
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
# @pytest.fixture
|
||||
# def make_user(db_session):
|
||||
# """Factory fixture: creates User instances with defaults."""
|
||||
# created = []
|
||||
#
|
||||
# def _make_user(
|
||||
# name: str = "Test User",
|
||||
# email: str | None = None,
|
||||
# is_active: bool = True,
|
||||
# ):
|
||||
# from app.models import User
|
||||
# if email is None:
|
||||
# email = f"user-{len(created)}@test.com"
|
||||
# user = User(name=name, email=email, is_active=is_active)
|
||||
# db_session.add(user)
|
||||
# db_session.flush()
|
||||
# created.append(user)
|
||||
# return user
|
||||
#
|
||||
# return _make_user
|
||||
@@ -1,89 +1,845 @@
|
||||
---
|
||||
name: vitest
|
||||
description: >
|
||||
Trigger this skill whenever writing, debugging, or refactoring JavaScript or TypeScript tests, or when Vitest mocking, coverage, or configuration are mentioned. Activate for any .test.ts, .test.tsx, .test.js, .spec.ts, .spec.js file, vitest.config.ts reference, or React component testing with Testing Library. Also use when the user asks about JS/TS test patterns, test organization, or vi.mock/vi.fn usage.
|
||||
---
|
||||
|
||||
# Vitest
|
||||
|
||||
## Description
|
||||
|
||||
Modern JavaScript/TypeScript testing with Vitest including mocking and coverage.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Testing JavaScript/TypeScript
|
||||
- React component testing
|
||||
- Unit and integration tests
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- Python testing -- use the `testing/pytest` skill instead
|
||||
- Projects that explicitly mandate Jest-only by convention with no Vitest dependency
|
||||
- Non-JavaScript/TypeScript projects
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Basic Tests
|
||||
### 1. Test Structure
|
||||
|
||||
#### describe / it / expect
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatCurrency } from './format';
|
||||
|
||||
describe('math', () => {
|
||||
it('should add numbers', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
describe('formatCurrency', () => {
|
||||
it('should format whole dollars', () => {
|
||||
expect(formatCurrency(100)).toBe('$100.00');
|
||||
});
|
||||
|
||||
it('should throw on invalid input', () => {
|
||||
expect(() => divide(1, 0)).toThrow('Division by zero');
|
||||
it('should format cents correctly', () => {
|
||||
expect(formatCurrency(9.5)).toBe('$9.50');
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
expect(formatCurrency(0)).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('should throw on negative values', () => {
|
||||
expect(() => formatCurrency(-5)).toThrow('Amount must be non-negative');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking
|
||||
#### Lifecycle Hooks
|
||||
|
||||
```typescript
|
||||
import { vi, describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||
import { Database } from './database';
|
||||
|
||||
// Mock module
|
||||
vi.mock('./api', () => ({
|
||||
fetchUser: vi.fn().mockResolvedValue({ id: 1 })
|
||||
describe('UserRepository', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Runs once before all tests in this describe block
|
||||
db = await Database.connect('test://localhost/testdb');
|
||||
await db.migrate();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Runs before each test
|
||||
await db.seed({ users: [{ id: 1, name: 'Alice' }] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.truncate('users');
|
||||
});
|
||||
|
||||
it('should find user by id', async () => {
|
||||
const user = await db.users.findById(1);
|
||||
expect(user).toEqual({ id: 1, name: 'Alice' });
|
||||
});
|
||||
|
||||
it('should return null for missing user', async () => {
|
||||
const user = await db.users.findById(999);
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### test.each for Parametrized Tests
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, test } from 'vitest';
|
||||
import { validateEmail } from './validators';
|
||||
|
||||
describe('validateEmail', () => {
|
||||
test.each([
|
||||
{ email: 'user@example.com', expected: true },
|
||||
{ email: 'admin@test.org', expected: true },
|
||||
{ email: 'name+tag@domain.co.uk', expected: true },
|
||||
])('should accept valid email: $email', ({ email, expected }) => {
|
||||
expect(validateEmail(email)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ email: '', reason: 'empty string' },
|
||||
{ email: 'no-at-sign', reason: 'missing @' },
|
||||
{ email: '@no-local.com', reason: 'missing local part' },
|
||||
{ email: 'spaces in@email.com', reason: 'contains spaces' },
|
||||
])('should reject invalid email ($reason): $email', ({ email }) => {
|
||||
expect(validateEmail(email)).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Nested describe Blocks
|
||||
|
||||
```typescript
|
||||
describe('ShoppingCart', () => {
|
||||
describe('when empty', () => {
|
||||
it('should have zero total', () => {
|
||||
const cart = new ShoppingCart();
|
||||
expect(cart.total()).toBe(0);
|
||||
});
|
||||
|
||||
it('should have zero item count', () => {
|
||||
const cart = new ShoppingCart();
|
||||
expect(cart.itemCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with items', () => {
|
||||
let cart: ShoppingCart;
|
||||
|
||||
beforeEach(() => {
|
||||
cart = new ShoppingCart();
|
||||
cart.add({ name: 'Widget', price: 9.99, quantity: 2 });
|
||||
cart.add({ name: 'Gadget', price: 24.99, quantity: 1 });
|
||||
});
|
||||
|
||||
it('should calculate total', () => {
|
||||
expect(cart.total()).toBeCloseTo(44.97);
|
||||
});
|
||||
|
||||
it('should count all items', () => {
|
||||
expect(cart.itemCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Mocking
|
||||
|
||||
#### vi.mock for Module Mocking
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { sendWelcomeEmail } from './onboarding';
|
||||
|
||||
// Mock the entire email module -- hoisted to the top of the file automatically
|
||||
vi.mock('./email', () => ({
|
||||
sendEmail: vi.fn().mockResolvedValue({ messageId: 'msg-123' }),
|
||||
}));
|
||||
|
||||
// Mock function
|
||||
const callback = vi.fn();
|
||||
callback('arg');
|
||||
expect(callback).toHaveBeenCalledWith('arg');
|
||||
```
|
||||
// Import AFTER vi.mock declaration
|
||||
import { sendEmail } from './email';
|
||||
|
||||
### Async Tests
|
||||
describe('sendWelcomeEmail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
```typescript
|
||||
it('should fetch data', async () => {
|
||||
const data = await fetchData();
|
||||
expect(data).toEqual({ id: 1 });
|
||||
});
|
||||
it('should send email with welcome template', async () => {
|
||||
await sendWelcomeEmail('alice@example.com');
|
||||
|
||||
it('should reject on error', async () => {
|
||||
await expect(fetchData()).rejects.toThrow('Error');
|
||||
expect(sendEmail).toHaveBeenCalledWith({
|
||||
to: 'alice@example.com',
|
||||
template: 'welcome',
|
||||
subject: 'Welcome to our platform!',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the message id', async () => {
|
||||
const result = await sendWelcomeEmail('alice@example.com');
|
||||
expect(result.messageId).toBe('msg-123');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### React Testing
|
||||
#### vi.fn for Function Spies
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('EventEmitter', () => {
|
||||
it('should call listener on emit', () => {
|
||||
const emitter = new EventEmitter();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('click', listener);
|
||||
emitter.emit('click', { x: 10, y: 20 });
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith({ x: 10, y: 20 });
|
||||
});
|
||||
|
||||
it('should track multiple calls', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
callback('first');
|
||||
callback('second');
|
||||
callback('third');
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(3);
|
||||
expect(callback.mock.calls).toEqual([['first'], ['second'], ['third']]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### vi.spyOn
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import * as mathUtils from './math-utils';
|
||||
|
||||
describe('calculateTax', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should use the tax rate function', () => {
|
||||
const spy = vi.spyOn(mathUtils, 'getTaxRate').mockReturnValue(0.08);
|
||||
|
||||
const result = calculateTax(100);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith();
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
|
||||
it('should spy without changing behavior', () => {
|
||||
const spy = vi.spyOn(console, 'warn');
|
||||
|
||||
triggerDeprecationWarning();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deprecated')
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### mockResolvedValue / mockRejectedValue
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('UserService', () => {
|
||||
it('should return user on successful fetch', async () => {
|
||||
const fetchUser = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' });
|
||||
|
||||
const user = await fetchUser(1);
|
||||
expect(user).toEqual({ id: 1, name: 'Alice' });
|
||||
});
|
||||
|
||||
it('should throw on failed fetch', async () => {
|
||||
const fetchUser = vi.fn().mockRejectedValue(new Error('User not found'));
|
||||
|
||||
await expect(fetchUser(999)).rejects.toThrow('User not found');
|
||||
});
|
||||
|
||||
it('should return different values on successive calls', async () => {
|
||||
const getToken = vi.fn()
|
||||
.mockResolvedValueOnce('token-1')
|
||||
.mockResolvedValueOnce('token-2')
|
||||
.mockRejectedValueOnce(new Error('Expired'));
|
||||
|
||||
expect(await getToken()).toBe('token-1');
|
||||
expect(await getToken()).toBe('token-2');
|
||||
await expect(getToken()).rejects.toThrow('Expired');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### MSW (Mock Service Worker) for API Mocking
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { fetchUsers } from './api-client';
|
||||
|
||||
const server = setupServer(
|
||||
http.get('https://api.example.com/users', () => {
|
||||
return HttpResponse.json([
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
]);
|
||||
}),
|
||||
|
||||
http.post('https://api.example.com/users', async ({ request }) => {
|
||||
const body = await request.json() as { name: string };
|
||||
return HttpResponse.json(
|
||||
{ id: 3, name: body.name },
|
||||
{ status: 201 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('API Client', () => {
|
||||
it('should fetch users', async () => {
|
||||
const users = await fetchUsers();
|
||||
expect(users).toHaveLength(2);
|
||||
expect(users[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should handle server errors', async () => {
|
||||
server.use(
|
||||
http.get('https://api.example.com/users', () => {
|
||||
return HttpResponse.json(
|
||||
{ message: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(fetchUsers()).rejects.toThrow('Server error');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. React Testing
|
||||
|
||||
#### Render and Query
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { Greeting } from './Greeting';
|
||||
|
||||
it('should handle click', async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
describe('Greeting', () => {
|
||||
it('should display the user name', () => {
|
||||
render(<Greeting name="Alice" />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
// getBy* throws if not found -- use for elements that must exist
|
||||
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display admin badge for regular users', () => {
|
||||
render(<Greeting name="Alice" role="viewer" />);
|
||||
|
||||
// queryBy* returns null if not found -- use for asserting absence
|
||||
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display admin badge for admins', () => {
|
||||
render(<Greeting name="Alice" role="admin" />);
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### userEvent for Interactions
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LoginForm } from './LoginForm';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('should submit credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<LoginForm onSubmit={onSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
|
||||
await user.type(screen.getByLabelText('Password'), 'secret123');
|
||||
await user.click(screen.getByRole('button', { name: 'Sign In' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
email: 'alice@example.com',
|
||||
password: 'secret123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show validation error on empty submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Sign In' }));
|
||||
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle password visibility', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
expect(passwordInput).toHaveAttribute('type', 'password');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show password' }));
|
||||
expect(passwordInput).toHaveAttribute('type', 'text');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### findBy for Async Rendering and waitFor
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UserProfile } from './UserProfile';
|
||||
|
||||
describe('UserProfile', () => {
|
||||
it('should load and display user data', async () => {
|
||||
render(<UserProfile userId={1} />);
|
||||
|
||||
// findBy* waits for the element to appear (async query)
|
||||
const heading = await screen.findByRole('heading', { name: 'Alice' });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
render(<UserProfile userId={1} />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update after action', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserProfile userId={1} />);
|
||||
|
||||
await screen.findByRole('heading', { name: 'Alice' });
|
||||
await user.click(screen.getByRole('button', { name: 'Deactivate' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Status: Inactive')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Testing with Context Providers
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from './ThemeContext';
|
||||
import { ThemedButton } from './ThemedButton';
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, options?: { theme?: 'light' | 'dark' }) {
|
||||
const theme = options?.theme ?? 'light';
|
||||
return render(
|
||||
<ThemeProvider value={theme}>
|
||||
{ui}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemedButton', () => {
|
||||
it('should apply light theme styles', () => {
|
||||
renderWithProviders(<ThemedButton>Click me</ThemedButton>, { theme: 'light' });
|
||||
expect(screen.getByRole('button')).toHaveClass('btn-light');
|
||||
});
|
||||
|
||||
it('should apply dark theme styles', () => {
|
||||
renderWithProviders(<ThemedButton>Click me</ThemedButton>, { theme: 'dark' });
|
||||
expect(screen.getByRole('button')).toHaveClass('btn-dark');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Async Testing
|
||||
|
||||
#### Promises and async/await
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fetchUser, processQueue } from './services';
|
||||
|
||||
describe('async operations', () => {
|
||||
it('should resolve with user data', async () => {
|
||||
const user = await fetchUser(1);
|
||||
expect(user).toEqual({ id: 1, name: 'Alice' });
|
||||
});
|
||||
|
||||
it('should reject with descriptive error', async () => {
|
||||
await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID');
|
||||
});
|
||||
|
||||
it('should process all items in queue', async () => {
|
||||
const results = await processQueue(['a', 'b', 'c']);
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.every((r) => r.status === 'done')).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Fake Timers
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { debounce } from './debounce';
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not call function before delay', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 300);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call function after delay', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 300);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should reset timer on subsequent calls', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 300);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(200);
|
||||
debounced(); // reset
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Fake Timers with Date
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { isExpired } from './token';
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('should detect expired tokens', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
|
||||
|
||||
const token = { expiresAt: '2025-06-15T11:00:00Z' };
|
||||
expect(isExpired(token)).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Snapshot Testing
|
||||
|
||||
#### toMatchSnapshot
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
describe('Badge', () => {
|
||||
it('should match snapshot for success variant', () => {
|
||||
const { container } = render(<Badge variant="success">Active</Badge>);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### toMatchInlineSnapshot
|
||||
|
||||
Inline snapshots embed the expected value directly in the test file. Vitest updates them automatically on first run.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatError } from './errors';
|
||||
|
||||
describe('formatError', () => {
|
||||
it('should format validation error', () => {
|
||||
const error = formatError({ field: 'email', rule: 'required' });
|
||||
|
||||
expect(error).toMatchInlineSnapshot(`
|
||||
{
|
||||
"code": "VALIDATION_ERROR",
|
||||
"field": "email",
|
||||
"message": "email is required",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### When to Use Snapshots (and When Not To)
|
||||
|
||||
**Use snapshots for:**
|
||||
- Serialized output that is tedious to write by hand (large objects, rendered markup)
|
||||
- Catching unintended changes in generated output
|
||||
- Error message formatting
|
||||
|
||||
**Do not use snapshots for:**
|
||||
- Business logic assertions -- write explicit `expect(value).toBe(expected)` instead
|
||||
- Frequently changing output -- snapshot churn leads to mindless updates
|
||||
- Large component trees -- a small change deep in the tree makes the diff unreadable; test specific elements instead
|
||||
|
||||
---
|
||||
|
||||
### 6. Coverage
|
||||
|
||||
#### vitest.config.ts Coverage Settings
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8', // or 'istanbul'
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/*.d.ts',
|
||||
'src/**/index.ts', // barrel files
|
||||
'src/test-utils/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Running Coverage
|
||||
|
||||
```bash
|
||||
vitest run --coverage # Run once with coverage
|
||||
vitest --coverage # Watch mode with coverage
|
||||
vitest run --coverage.provider=v8 # Override provider via CLI
|
||||
```
|
||||
|
||||
#### Per-File Thresholds
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
thresholds: {
|
||||
// Global thresholds
|
||||
statements: 80,
|
||||
// Per-glob overrides for critical paths
|
||||
'src/auth/**': {
|
||||
statements: 95,
|
||||
branches: 95,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Setup and Configuration
|
||||
|
||||
#### vitest.config.ts Basics
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true, // Use describe/it/expect without imports
|
||||
environment: 'jsdom', // DOM environment for React (or 'happy-dom')
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist', 'e2e'],
|
||||
testTimeout: 10_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Setup File
|
||||
|
||||
```typescript
|
||||
// src/test-setup.ts
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
// Automatic cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
```
|
||||
|
||||
#### Workspace Configuration
|
||||
|
||||
For monorepos with multiple packages:
|
||||
|
||||
```typescript
|
||||
// vitest.workspace.ts
|
||||
import { defineWorkspace } from 'vitest/config';
|
||||
|
||||
export default defineWorkspace([
|
||||
{
|
||||
extends: './vitest.config.ts',
|
||||
test: {
|
||||
name: 'ui',
|
||||
include: ['packages/ui/**/*.test.{ts,tsx}'],
|
||||
environment: 'jsdom',
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: './vitest.config.ts',
|
||||
test: {
|
||||
name: 'api',
|
||||
include: ['packages/api/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
#### Environment Per File
|
||||
|
||||
Use a magic comment at the top of a test file to override the environment:
|
||||
|
||||
```typescript
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('DOM-heavy tests', () => {
|
||||
it('should create elements', () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'Hello';
|
||||
expect(div.textContent).toBe('Hello');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Globals Mode
|
||||
|
||||
When `globals: true` is set in config, you do not need to import `describe`, `it`, `expect`, `vi`, etc. Add the types to `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use describe blocks for grouping
|
||||
2. Prefer async/await for async tests
|
||||
3. Use userEvent over fireEvent
|
||||
4. Mock at module boundaries
|
||||
5. Clean up after tests
|
||||
1. **Use `userEvent` over `fireEvent`** -- `userEvent` simulates real user behavior (focus, keystrokes, blur) while `fireEvent` dispatches raw DOM events. `userEvent` catches bugs that `fireEvent` misses, such as disabled buttons still receiving clicks.
|
||||
|
||||
2. **Query by role and label, not test IDs** -- Prefer `getByRole('button', { name: 'Submit' })` and `getByLabelText('Email')` over `getByTestId('submit-btn')`. Accessible queries validate your markup and are resilient to refactors.
|
||||
|
||||
3. **Clear mocks between tests** -- Call `vi.clearAllMocks()` in `beforeEach` or `vi.restoreAllMocks()` in `afterEach`. Leaked mock state between tests causes order-dependent failures that are painful to debug.
|
||||
|
||||
4. **Keep tests focused on one behavior** -- Each `it` block should test a single user-observable behavior. If your test description contains "and", split it into two tests.
|
||||
|
||||
5. **Avoid testing implementation details** -- Do not assert on component state, internal method calls, or private variables. Test what the user sees and what the component outputs. Implementation tests break on every refactor without catching real bugs.
|
||||
|
||||
6. **Use MSW for network mocking over vi.mock on fetch** -- MSW intercepts at the network level, so your tests exercise the actual fetch/axios code paths. Mocking `fetch` directly skips serialization, headers, and error handling logic.
|
||||
|
||||
7. **Colocate tests with source files** -- Place `Button.test.tsx` next to `Button.tsx`. This makes it obvious which files have tests and simplifies imports. Reserve a top-level `e2e/` folder only for end-to-end tests.
|
||||
|
||||
8. **Run tests in watch mode during development** -- `vitest` (no flags) starts in watch mode and re-runs only affected tests on file change. Use `vitest run` in CI for a single full run with exit code.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Not awaiting async**: Always await promises
|
||||
- **Stale mocks**: Clear mocks between tests
|
||||
- **Testing implementation**: Test behavior
|
||||
1. **Forgetting to await userEvent calls** -- Every `userEvent` method is async. Omitting `await` causes the assertion to run before the interaction completes, leading to false passes or intermittent failures.
|
||||
|
||||
2. **vi.mock hoisting confusion** -- `vi.mock()` calls are hoisted to the top of the file. If you define a mock implementation that references a variable declared below the `vi.mock` call, it will be `undefined`. Use `vi.mock` with a factory function or move the variable above.
|
||||
|
||||
3. **Not cleaning up after fake timers** -- Forgetting `vi.useRealTimers()` in `afterEach` causes subsequent tests to silently use fake timers, producing mysterious timeouts and passing tests that should fail.
|
||||
|
||||
4. **Using `getBy` queries for elements that may not exist** -- `getByText('Error')` throws immediately if the element is absent. When asserting that something is NOT rendered, use `queryByText('Error')` which returns `null`.
|
||||
|
||||
5. **Snapshot overuse** -- Developers update snapshots without reviewing the diff. Over time, snapshots become rubber stamps. Limit snapshots to serialized output and error formatting; use explicit assertions for behavior.
|
||||
|
||||
6. **Testing third-party library internals** -- Do not test that React Router navigates correctly or that Zustand updates state. Test that your component renders the right thing after navigation or state change. Trust library authors; test your code.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `testing/pytest` -- Python testing counterpart
|
||||
- `languages/typescript` -- TypeScript language patterns and strict typing
|
||||
- `frameworks/react` -- React component patterns for component testing
|
||||
- `methodology/test-driven-development` -- TDD workflow for writing tests first
|
||||
- `devops/github-actions` — Running vitest in CI/CD pipelines
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Vitest Mock Patterns
|
||||
|
||||
Catalog of mocking patterns for common testing scenarios.
|
||||
|
||||
## 1. Module Mock (Full)
|
||||
|
||||
Replace an entire module with mock implementations.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Mock the entire module BEFORE importing code that uses it.
|
||||
vi.mock("@/services/payment", () => ({
|
||||
chargeCard: vi.fn().mockResolvedValue({
|
||||
transactionId: "txn-123",
|
||||
status: "succeeded",
|
||||
}),
|
||||
refundCharge: vi.fn().mockResolvedValue({
|
||||
refundId: "ref-456",
|
||||
status: "refunded",
|
||||
}),
|
||||
}));
|
||||
|
||||
import { chargeCard } from "@/services/payment";
|
||||
import { checkout } from "@/services/checkout";
|
||||
|
||||
describe("checkout", () => {
|
||||
it("should charge the card and return success", async () => {
|
||||
const result = await checkout({ amount: 42, cardToken: "tok_test" });
|
||||
|
||||
expect(chargeCard).toHaveBeenCalledWith({
|
||||
amount: 42,
|
||||
token: "tok_test",
|
||||
});
|
||||
expect(result.status).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 2. Partial Module Mock
|
||||
|
||||
Mock only specific exports; keep the rest real.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@/utils/config", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/config")>();
|
||||
return {
|
||||
...actual,
|
||||
// Override only this one export
|
||||
getFeatureFlag: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
import { getFeatureFlag, parseConfig } from "@/utils/config";
|
||||
|
||||
describe("with feature flag enabled", () => {
|
||||
it("should use the new algorithm", () => {
|
||||
// getFeatureFlag is mocked, parseConfig is real
|
||||
expect(getFeatureFlag("new-algo")).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 3. Manual Mock Reset / Per-Test Overrides
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fetchUser } from "@/api/users";
|
||||
|
||||
vi.mock("@/api/users");
|
||||
|
||||
// Type the mock for autocomplete
|
||||
const mockFetchUser = vi.mocked(fetchUser);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); // Clear call history AND implementations
|
||||
});
|
||||
|
||||
describe("user profile", () => {
|
||||
it("shows user data on success", async () => {
|
||||
mockFetchUser.mockResolvedValueOnce({ id: "1", name: "Alice" });
|
||||
// ...test
|
||||
});
|
||||
|
||||
it("shows error on failure", async () => {
|
||||
mockFetchUser.mockRejectedValueOnce(new Error("Network error"));
|
||||
// ...test
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 4. API Mock with MSW (Mock Service Worker)
|
||||
|
||||
Best for integration tests that should exercise real fetch/axios code.
|
||||
|
||||
```typescript
|
||||
// test/mocks/handlers.ts
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export const handlers = [
|
||||
http.get("/api/users/:id", ({ params }) => {
|
||||
return HttpResponse.json({ id: params.id, name: "Alice", email: "alice@example.com" });
|
||||
}),
|
||||
http.post("/api/users", async ({ request }) => {
|
||||
const body = await request.json();
|
||||
return HttpResponse.json({ id: "new-1", ...body }, { status: 201 });
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
```typescript
|
||||
// test/mocks/server.ts
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
```
|
||||
|
||||
```typescript
|
||||
// test/setup.ts (referenced in vitest.config.ts setupFiles)
|
||||
import { afterAll, afterEach, beforeAll } from "vitest";
|
||||
import { server } from "./mocks/server";
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Usage in tests -- override handlers per test
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../mocks/server";
|
||||
|
||||
it("handles server error", async () => {
|
||||
server.use(
|
||||
http.get("/api/users/:id", () => {
|
||||
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}),
|
||||
);
|
||||
// ...test error handling
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Timer Mocks
|
||||
|
||||
Control `setTimeout`, `setInterval`, `Date.now`.
|
||||
|
||||
```typescript
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("should call the function after the delay", () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 300);
|
||||
|
||||
debounced();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// Fake date: vi.setSystemTime(new Date("2025-01-15T12:00:00Z"))
|
||||
```
|
||||
|
||||
## 6. Spy Patterns
|
||||
|
||||
Observe calls without replacing implementation.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
describe("logging", () => {
|
||||
it("should log errors to console", () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
logError("something went wrong");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("something went wrong"),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// Spy on object method without changing behavior
|
||||
it("should call save", () => {
|
||||
const repo = new UserRepository();
|
||||
const saveSpy = vi.spyOn(repo, "save");
|
||||
|
||||
repo.createUser({ name: "Alice" });
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledOnce();
|
||||
expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ name: "Alice" }));
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Global / Window Mocks
|
||||
|
||||
```typescript
|
||||
// Mock window.location
|
||||
vi.spyOn(window, "location", "get").mockReturnValue({ ...window.location, pathname: "/dashboard" });
|
||||
|
||||
// Mock localStorage
|
||||
const storage: Record<string, string> = {};
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => storage[key] ?? null);
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => { storage[key] = val; });
|
||||
|
||||
// Mock fetch (when not using MSW)
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ data: "test" }) });
|
||||
```
|
||||
|
||||
## 8. Class Mock
|
||||
|
||||
```typescript
|
||||
vi.mock("@/services/analytics", () => ({
|
||||
AnalyticsClient: vi.fn().mockImplementation(() => ({
|
||||
track: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
flush: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
```
|
||||
|
||||
## Quick Reference: Mock Functions
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `vi.fn()` | Create a standalone mock function |
|
||||
| `vi.fn().mockReturnValue(x)` | Always return `x` |
|
||||
| `vi.fn().mockReturnValueOnce(x)` | Return `x` once, then default |
|
||||
| `vi.fn().mockResolvedValue(x)` | Return `Promise.resolve(x)` |
|
||||
| `vi.fn().mockRejectedValue(e)` | Return `Promise.reject(e)` |
|
||||
| `vi.fn().mockImplementation(fn)` | Use custom implementation |
|
||||
| `vi.spyOn(obj, "method")` | Spy on existing method |
|
||||
| `vi.mocked(fn)` | Type helper for mocked function |
|
||||
| `vi.mock("module")` | Auto-mock all exports |
|
||||
| `vi.resetAllMocks()` | Reset history and implementations |
|
||||
| `vi.restoreAllMocks()` | Restore original implementations |
|
||||
| `vi.clearAllMocks()` | Clear call history only |
|
||||
@@ -0,0 +1,100 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
// -------------------------------------------------------------------------
|
||||
// Path aliases -- must match tsconfig.json "paths"
|
||||
// -------------------------------------------------------------------------
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@test": path.resolve(__dirname, "test"),
|
||||
},
|
||||
},
|
||||
|
||||
test: {
|
||||
// -----------------------------------------------------------------------
|
||||
// Environment
|
||||
// -----------------------------------------------------------------------
|
||||
// "node" -- default, for backend / library code
|
||||
// "jsdom" -- for code that accesses DOM APIs (React, etc.)
|
||||
// "happy-dom" -- faster jsdom alternative
|
||||
environment: "jsdom",
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Globals
|
||||
// -----------------------------------------------------------------------
|
||||
// Set to true to use describe/it/expect without importing from "vitest".
|
||||
// Requires adding "vitest/globals" to tsconfig "types".
|
||||
globals: true,
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Setup files -- run before each test file
|
||||
// -----------------------------------------------------------------------
|
||||
setupFiles: [
|
||||
"./test/setup.ts",
|
||||
// "./test/mocks/server.ts", // MSW server setup
|
||||
],
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// File patterns
|
||||
// -----------------------------------------------------------------------
|
||||
include: [
|
||||
"src/**/*.{test,spec}.{ts,tsx}",
|
||||
"test/**/*.{test,spec}.{ts,tsx}",
|
||||
],
|
||||
exclude: [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"e2e/**",
|
||||
],
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Coverage
|
||||
// -----------------------------------------------------------------------
|
||||
coverage: {
|
||||
provider: "v8", // or "istanbul"
|
||||
reporter: ["text", "text-summary", "lcov", "json"],
|
||||
reportsDirectory: "./coverage",
|
||||
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"src/**/*.spec.{ts,tsx}",
|
||||
"src/**/index.ts", // barrel files
|
||||
"src/types/**",
|
||||
],
|
||||
|
||||
// Minimum thresholds -- fail if coverage drops below these.
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Timeouts
|
||||
// -----------------------------------------------------------------------
|
||||
testTimeout: 10_000, // 10s per test
|
||||
hookTimeout: 10_000, // 10s per beforeEach/afterEach
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reporters
|
||||
// -----------------------------------------------------------------------
|
||||
reporters: ["default"],
|
||||
// For CI, add JUnit output:
|
||||
// reporters: ["default", "junit"],
|
||||
// outputFile: { junit: "./junit.xml" },
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Other options
|
||||
// -----------------------------------------------------------------------
|
||||
// restoreMocks: true, // Automatically restore mocks after each test
|
||||
// clearMocks: true, // Clear mock call history after each test
|
||||
// mockReset: true, // Reset mocks (clear + remove implementations)
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,7 @@ A comprehensive toolkit for Claude Code to accelerate development workflows for
|
||||
|
||||
- **20 Specialized Agents** - From planning to deployment
|
||||
- **27+ Slash Commands** - Workflow automation with flag support
|
||||
- **30+ Skills** - Framework, language, methodology, and optimization expertise
|
||||
- **38 Skills** - Framework, language, methodology, patterns, and optimization expertise (all with YAML frontmatter and bundled resources)
|
||||
- **7 Behavioral Modes** - Task-specific response optimization
|
||||
- **Command Flag System** - Combinable `--flag` syntax for customization
|
||||
- **Token Optimization** - 30-70% cost savings with compressed output modes
|
||||
@@ -29,11 +29,18 @@ A comprehensive toolkit for Claude Code to accelerate development workflows for
|
||||
├── commands/ # 27+ workflow commands
|
||||
├── modes/ # 7 behavioral mode definitions
|
||||
├── mcp/ # MCP server configurations
|
||||
└── skills/ # Framework, language, and methodology skills
|
||||
├── frameworks/ # FastAPI, Next.js, React, etc.
|
||||
└── skills/ # 38 skills with YAML frontmatter & bundled resources
|
||||
├── api/ # OpenAPI specification patterns
|
||||
├── databases/ # PostgreSQL, MongoDB
|
||||
├── devops/ # Docker, GitHub Actions
|
||||
├── frameworks/ # FastAPI, Django, Next.js, React
|
||||
├── frontend/ # Tailwind CSS, shadcn/ui
|
||||
├── languages/ # Python, TypeScript, JavaScript
|
||||
├── methodology/ # TDD, debugging, planning (14 skills)
|
||||
└── optimization/ # Token efficiency patterns
|
||||
├── methodology/ # TDD, debugging, planning, review (14 skills)
|
||||
├── optimization/ # Token efficiency patterns
|
||||
├── patterns/ # Error handling, state, logging, caching, auth, API client
|
||||
├── security/ # OWASP security patterns
|
||||
└── testing/ # pytest, vitest
|
||||
```
|
||||
|
||||
## Agents
|
||||
@@ -112,34 +119,55 @@ A comprehensive toolkit for Claude Code to accelerate development workflows for
|
||||
/spawn [task] # Launch parallel background task
|
||||
```
|
||||
|
||||
## Skills
|
||||
## Skills (38 Total)
|
||||
|
||||
Every skill includes YAML frontmatter for reliable triggering, "When to Use" / "When NOT to Use" sections, core patterns with code examples, best practices, common pitfalls, cross-references, and bundled resources (reference docs, templates, scripts).
|
||||
|
||||
### Languages
|
||||
- Python, TypeScript, JavaScript
|
||||
- **Python** — Type hints, async, dataclasses, Pydantic, decorators, pattern matching
|
||||
- **TypeScript** — Advanced types, generics, Zod, discriminated unions, branded types
|
||||
- **JavaScript** — ES6+, async patterns, Proxy/Reflect, generators, modules
|
||||
|
||||
### Frameworks
|
||||
- FastAPI, Django, Next.js, React
|
||||
- **FastAPI** — Routes, dependency injection, middleware, WebSocket, testing
|
||||
- **Django** — ORM, views, migrations, DRF, signals, admin
|
||||
- **Next.js** — App Router, server/client components, caching, middleware
|
||||
- **React** — Hooks, custom hooks, context, Suspense, error boundaries, performance
|
||||
|
||||
### Databases
|
||||
- PostgreSQL, MongoDB
|
||||
- **PostgreSQL** — Schema, indexing (B-tree/GIN/GiST), migrations, CTEs, JSONB
|
||||
- **MongoDB** — Schema design, aggregation pipelines, indexing, transactions
|
||||
|
||||
### DevOps
|
||||
- Docker, GitHub Actions
|
||||
- **Docker** — Multi-stage builds, Compose, security hardening, layer caching
|
||||
- **GitHub Actions** — CI/CD, matrix strategy, reusable workflows, deployment
|
||||
|
||||
### Frontend
|
||||
- Tailwind CSS, shadcn/ui
|
||||
- **Tailwind CSS** — Responsive, dark mode, animations, theme customization
|
||||
- **shadcn/ui** — Components, forms, data tables, theming, toast
|
||||
|
||||
### API
|
||||
- **OpenAPI** — 3.1 spec, pagination, versioning, error schemas, webhooks
|
||||
|
||||
### Security
|
||||
- OWASP best practices
|
||||
- **OWASP** — Top 10, auth, CORS, CSP, secret management, rate limiting
|
||||
|
||||
### Testing
|
||||
- pytest, vitest
|
||||
- **pytest** — Fixtures, parametrize, mocking, async, coverage
|
||||
- **vitest** — React Testing Library, mocking, MSW, snapshots, configuration
|
||||
|
||||
### Optimization
|
||||
- Token-efficient output patterns
|
||||
- Sequential thinking methodology
|
||||
- **Token-efficient** — Compressed output modes (30-70% cost savings)
|
||||
|
||||
### Methodology (Superpowers)
|
||||
### Developer Patterns (New)
|
||||
- **error-handling** — Custom errors, retry patterns, Result type, error boundaries
|
||||
- **state-management** — React state, Zustand, TanStack Query, form state, URL state
|
||||
- **logging** — Structured logging, log levels, correlation IDs, redaction
|
||||
- **caching** — Memoization, HTTP cache, Redis, CDN, cache invalidation
|
||||
- **api-client** — HTTP clients, interceptors, retry, type-safe clients
|
||||
- **authentication** — JWT, OAuth2, sessions, RBAC, MFA, password hashing
|
||||
|
||||
### Methodology (14 Skills)
|
||||
|
||||
| Category | Skills |
|
||||
|----------|--------|
|
||||
@@ -147,6 +175,7 @@ A comprehensive toolkit for Claude Code to accelerate development workflows for
|
||||
| **Testing** | test-driven-development, verification-before-completion, testing-anti-patterns |
|
||||
| **Debugging** | systematic-debugging, root-cause-tracing, defense-in-depth |
|
||||
| **Collaboration** | dispatching-parallel-agents, requesting-code-review, receiving-code-review, finishing-development-branch |
|
||||
| **Reasoning** | sequential-thinking |
|
||||
|
||||
Key methodology principles:
|
||||
- **TDD Strict**: No production code without failing test first
|
||||
@@ -155,6 +184,16 @@ Key methodology principles:
|
||||
- **Bite-sized Tasks**: 2-5 minute increments with exact code
|
||||
- **Sequential Thinking**: Step-by-step reasoning with confidence scores
|
||||
|
||||
### Bundled Resources
|
||||
|
||||
Skills include progressive-disclosure resources loaded on demand:
|
||||
|
||||
| Resource Type | Purpose | Examples |
|
||||
|---------------|---------|----------|
|
||||
| **references/** | Cheat sheets, decision trees, pattern catalogs | OWASP Top 10, index decision tree, auth flows |
|
||||
| **templates/** | Starter files, boilerplate, configs | OpenAPI spec, Dockerfile, CI workflows, conftest.py |
|
||||
| **scripts/** | Executable helpers for deterministic tasks | Security audit scanner, OpenAPI validator |
|
||||
|
||||
## Behavioral Modes
|
||||
|
||||
Switch modes to optimize responses for different task types:
|
||||
@@ -307,16 +346,45 @@ Use $ARGUMENTS for command arguments.
|
||||
|
||||
Create a new skill in `.claude/skills/category/skillname/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
# Skill Name
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
description: >
|
||||
What this skill does and when to trigger it. Be specific — list
|
||||
contexts, keywords, and scenarios. 2-4 pushy sentences.
|
||||
---
|
||||
```
|
||||
|
||||
## Description
|
||||
Brief description for matching.
|
||||
```markdown
|
||||
# My Skill
|
||||
|
||||
Brief overview.
|
||||
|
||||
## When to Use
|
||||
- Scenario 1
|
||||
- Scenario 2
|
||||
|
||||
## When NOT to Use
|
||||
- Anti-trigger scenario
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
Your patterns and examples here.
|
||||
## Core Patterns
|
||||
### Pattern Name
|
||||
Code examples with good/bad comparisons.
|
||||
|
||||
## Best Practices
|
||||
## Common Pitfalls
|
||||
## Related Skills
|
||||
```
|
||||
|
||||
Optionally add bundled resources:
|
||||
```
|
||||
my-skill/
|
||||
├── SKILL.md
|
||||
├── references/ # Loaded into context on demand
|
||||
├── scripts/ # Executed without loading into context
|
||||
└── templates/ # Scaffolded into user's project
|
||||
```
|
||||
|
||||
## Workflow Chains
|
||||
|
||||
Reference in New Issue
Block a user