Setup initial project
This commit is contained in:
commit
db79af06f7
119 changed files with 20761 additions and 0 deletions
324
docs/instructions/fastify.md
Normal file
324
docs/instructions/fastify.md
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# Fastify Best Practices — MeshiTrack API
|
||||
|
||||
> Instruction file for developing the Fastify backend (`packages/api`).
|
||||
|
||||
## Module Organization
|
||||
|
||||
### One route plugin per domain feature
|
||||
|
||||
Each business domain gets its own folder under `src/modules/`:
|
||||
|
||||
```
|
||||
src/modules/
|
||||
├── health/
|
||||
│ ├── health.routes.ts
|
||||
│ └── health.routes.test.ts
|
||||
├── users/
|
||||
│ ├── users.routes.ts
|
||||
│ ├── users.service.ts
|
||||
│ ├── users.repository.ts
|
||||
│ └── users.routes.test.ts
|
||||
├── households/
|
||||
│ ├── households.routes.ts
|
||||
│ ├── households.service.ts
|
||||
│ ├── households.repository.ts
|
||||
│ └── households.routes.test.ts
|
||||
├── products/
|
||||
│ ├── products.routes.ts
|
||||
│ ├── products.service.ts
|
||||
│ ├── products.repository.ts
|
||||
│ └── products.routes.test.ts
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Route plugins
|
||||
|
||||
Each module exports a Fastify plugin using `fastify-plugin` (`fp()`):
|
||||
|
||||
```typescript
|
||||
import fp from 'fastify-plugin';
|
||||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { ProductsRepository } from './products.repository.js';
|
||||
import { ProductsService } from './products.service.js';
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
// Register DI
|
||||
fastify.diContainer.register({
|
||||
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
|
||||
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/products',
|
||||
schema: {
|
||||
querystring: ListProductsQuerySchema,
|
||||
response: { 200: ProductListResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = request.diScope.resolve<ProductsService>('productsService');
|
||||
const result = await service.list(request.householdId, request.query);
|
||||
return reply.send(result);
|
||||
},
|
||||
});
|
||||
},
|
||||
{ name: 'products-routes' },
|
||||
);
|
||||
```
|
||||
|
||||
## Dependency Injection with Awilix
|
||||
|
||||
### Constructor injection via destructuring
|
||||
|
||||
Awilix injects dependencies by matching constructor parameter names:
|
||||
|
||||
```typescript
|
||||
export class ProductsService {
|
||||
private readonly productsRepository: ProductsRepository;
|
||||
|
||||
constructor({ productsRepository }: { productsRepository: ProductsRepository }) {
|
||||
this.productsRepository = productsRepository;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Registration
|
||||
|
||||
Register classes in the route plugin that owns them:
|
||||
|
||||
```typescript
|
||||
import { asClass, asValue, Lifetime } from 'awilix';
|
||||
|
||||
fastify.diContainer.register({
|
||||
productsRepository: asClass(ProductsRepository, { lifetime: Lifetime.SINGLETON }),
|
||||
productsService: asClass(ProductsService, { lifetime: Lifetime.SINGLETON }),
|
||||
});
|
||||
```
|
||||
|
||||
### Resolving per-request
|
||||
|
||||
Use `request.diScope.resolve()` in handlers:
|
||||
|
||||
```typescript
|
||||
handler: async (request) => {
|
||||
const service = request.diScope.resolve<ProductsService>('productsService');
|
||||
return service.findById(request.params.id);
|
||||
};
|
||||
```
|
||||
|
||||
### Lifetime rules
|
||||
|
||||
- **SINGLETON** for stateless services and repositories (default choice)
|
||||
- **SCOPED** only when you need per-request state (e.g., transaction context)
|
||||
- Never use **TRANSIENT** unless you have a specific reason
|
||||
|
||||
## Request Validation with Zod
|
||||
|
||||
### Use `fastify-type-provider-zod`
|
||||
|
||||
Set up the Zod type provider at app level:
|
||||
|
||||
```typescript
|
||||
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
|
||||
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
```
|
||||
|
||||
### Schema definitions
|
||||
|
||||
Define schemas in `packages/shared` and import them in route definitions:
|
||||
|
||||
```typescript
|
||||
app.route({
|
||||
method: 'POST',
|
||||
url: '/api/v1/products',
|
||||
schema: {
|
||||
body: CreateProductSchema,
|
||||
response: { 201: ProductResponseSchema },
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
// request.body is fully typed from CreateProductSchema
|
||||
const product = await service.create(request.householdId, request.body);
|
||||
return reply.status(201).send(product);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
### Use `fastify-plugin` for shared plugins
|
||||
|
||||
Plugins that need to be visible to sibling routes must use `fp()`:
|
||||
|
||||
```typescript
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
export default fp(
|
||||
async (fastify) => {
|
||||
// decorations/hooks registered here are visible to all routes
|
||||
},
|
||||
{ name: 'my-plugin', dependencies: ['other-plugin'] },
|
||||
);
|
||||
```
|
||||
|
||||
### Plugin ordering matters
|
||||
|
||||
Register plugins in this order in `main.ts`:
|
||||
|
||||
1. Security plugins (`@fastify/helmet`, `@fastify/cors`)
|
||||
2. Compression (`@fastify/compress`)
|
||||
3. Swagger (`@fastify/swagger`, `@fastify/swagger-ui`)
|
||||
4. DI container (`@fastify/awilix`)
|
||||
5. Database (`mongoose.plugin`)
|
||||
6. Auth (`auth.plugin`)
|
||||
7. Household guard (`household.plugin`)
|
||||
8. Route modules (health, users, households, etc.)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom AppError hierarchy
|
||||
|
||||
```typescript
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly error: string,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Subclasses: NotFoundError, UnauthorizedError, ForbiddenError, ConflictError, BadRequestError
|
||||
```
|
||||
|
||||
### Throw from services, catch in global handler
|
||||
|
||||
Services throw `AppError` subclasses. The global error handler in `main.ts` maps them to `ApiError` response shape:
|
||||
|
||||
```typescript
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
statusCode: error.statusCode,
|
||||
error: error.error,
|
||||
message: error.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
// ... handle Zod validation errors, unexpected errors
|
||||
});
|
||||
```
|
||||
|
||||
## Route Configuration
|
||||
|
||||
### Marking routes as public
|
||||
|
||||
```typescript
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/health',
|
||||
config: { public: true },
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Skipping household validation
|
||||
|
||||
```typescript
|
||||
app.route({
|
||||
method: 'GET',
|
||||
url: '/api/v1/users/me',
|
||||
config: { skipHousehold: true },
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
### Keep Mongoose queries in repositories
|
||||
|
||||
```typescript
|
||||
export class ProductsRepository {
|
||||
async findByHousehold(householdId: string, cursor?: string, limit = 20) {
|
||||
const query: Record<string, unknown> = { householdId };
|
||||
if (cursor) query['_id'] = { $gt: cursor };
|
||||
|
||||
return ProductModel.find(query)
|
||||
.sort({ _id: 1 })
|
||||
.limit(limit + 1)
|
||||
.lean()
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Always use `.lean().exec()`
|
||||
|
||||
Every read query must use `.lean().exec()` for performance:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
const product = await ProductModel.findById(id).lean().exec();
|
||||
|
||||
// Bad — returns full Mongoose document with all overhead
|
||||
const product = await ProductModel.findById(id);
|
||||
```
|
||||
|
||||
## Testing with Vitest
|
||||
|
||||
### Use `app.inject()` for route tests
|
||||
|
||||
Fastify's built-in `inject()` method tests routes without starting a real HTTP server:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Products Routes', () => {
|
||||
it('GET /api/v1/products returns products for household', async () => {
|
||||
const app = await buildTestApp();
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/products',
|
||||
headers: {
|
||||
authorization: 'Bearer <test-jwt>',
|
||||
'x-household-id': 'test-household-id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toHaveProperty('items');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Service unit tests with manual DI
|
||||
|
||||
No test module builder needed — just pass mock dependencies:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('ProductsService', () => {
|
||||
const mockRepo = {
|
||||
findByHousehold: vi.fn(),
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const service = new ProductsService({ productsRepository: mockRepo as any });
|
||||
|
||||
it('should create a product', async () => {
|
||||
mockRepo.create.mockResolvedValue({ id: '1', name: 'Chicken' });
|
||||
const result = await service.create('hh1', { name: 'Chicken' });
|
||||
expect(result).toEqual({ id: '1', name: 'Chicken' });
|
||||
expect(mockRepo.create).toHaveBeenCalledWith('hh1', { name: 'Chicken' });
|
||||
});
|
||||
});
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue