@sbfw/fastify-microservice
v1.0.0
Published
A Fastify-based microservice framework with pluggable auth, permissions, and file storage
Readme
@sbfw/fastify-microservice
A Fastify-based microservice framework with pluggable auth, permissions, and file storage.
Features
- Pluggable auth — implement
SessionResolver<T>to wire any token/session backend - Post-handler permission guard —
PostHandlerGuardverifies permission checks ran before the response is sent - File storage adapters — built-in Azure Blob and AWS S3, extensible via
FileStorageAdapter - Injectable logger — provide your Winston/Pino instance or fall back to console
- Configurable API prefix — default
/api/v1, change for versioned APIs - OpenAPI/Swagger — enabled via
OPEN_API_DOCSenv var - CORS, multipart, file serve — opt-in via server options
Installation
npm install @sbfw/fastify-microservice fastify @fastify/cors @fastify/multipart @fastify/swagger @fastify/swagger-ui @sbfw/coreFor Azure Blob storage:
npm install @azure/storage-blobFor AWS S3 storage:
npm install @aws-sdk/client-s3Quick Start
import { FastifyServerBase, FastifyEndpoints, RequestController } from '@sbfw/fastify-microservice';
import { MySessionResolver } from './my-session-resolver.js';
class HealthController extends RequestController {}
const healthEndpoints = new FastifyEndpoints(HealthController, new MySessionResolver());
healthEndpoints.get('/health', {
auth: false,
outputSchema: { type: 'object', properties: { status: { type: 'string' } } },
handler: async () => ({ status: 'ok' }),
});
const server = FastifyServerBase.init({
serviceName: 'my-service',
port: 3000,
models: [],
endpoints: [{ setup: healthEndpoints, options: { prefix: 'health' } }],
});
await server.start();Core Concepts
SessionResolver
Implement SessionResolver<TSession> to plug in your auth system. Called on every protected route's onRequest hook.
import { SessionResolver, BaseSessionData, AuthorizationRequirements } from '@sbfw/fastify-microservice';
import { FastifyRequest, FastifyReply } from 'fastify';
interface MySession extends BaseSessionData {
userId: string;
role: string;
companyId: string;
}
class MySessionResolver implements SessionResolver<MySession> {
async authorize(
request: FastifyRequest,
reply: FastifyReply,
requirements: AuthorizationRequirements,
allowNoSession = false,
): Promise<MySession | null> {
const token = request.headers['x-session-token'] as string;
if (!token) {
if (!allowNoSession) reply.status(401).send('token-missing');
return null;
}
const session = await db.sessions.findOne({ token });
if (!session) {
if (!allowNoSession) reply.status(401).send('no-session');
return null;
}
if (requirements.minRole && session.role !== requirements.minRole) {
reply.status(403).send('insufficient-role');
return null;
}
return session;
}
}FastifyEndpoints
Extend or instantiate FastifyEndpoints to define typed routes:
import { FastifyEndpoints, RequestController } from '@sbfw/fastify-microservice';
class ArticleController extends RequestController<MySession> {
async getArticle(id: string) {
return db.articles.findOne(id);
}
}
const articleEndpoints = new FastifyEndpoints(ArticleController, new MySessionResolver());
// Public endpoint
articleEndpoints.get('/articles/:id', {
auth: false,
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
outputSchema: { type: 'object', properties: { title: { type: 'string' } } },
handler: async ({ params }) => db.articles.findOne(params!.id),
});
// Protected endpoint
articleEndpoints.post('/articles', {
auth: { minRole: 'editor' },
inputSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
outputSchema: { type: 'object', properties: { id: { type: 'string' } } },
handler: async ({ body, session }) => db.articles.create({ ...body, author: session!.userId }),
});Available methods: get, post, put, patch, delete, deleteInputSchema.
afterAuth hook
Override afterAuth in a subclass to run logic after auth on every protected request (e.g., inject language headers):
class MyEndpoints extends FastifyEndpoints<MyController> {
protected async afterAuth(request, reply): Promise<void> {
request.headers['accept-language'] ??= request.session?.preferredLanguage ?? 'en';
}
}PostHandlerGuard
Implement PostHandlerGuard to verify that fine-grained permission checks ran inside the handler before the response is sent. The framework registers an onSend hook that returns 403 if the guard fails.
import { PostHandlerGuard, PostHandlerGuardResult } from '@sbfw/fastify-microservice';
import { FastifyRequest } from 'fastify';
const myGuard: PostHandlerGuard = {
shouldRun(request: FastifyRequest): boolean {
return !!(request as any).permissionChecker;
},
check(request: FastifyRequest): PostHandlerGuardResult {
const checker = (request as any).permissionChecker;
return {
permitted: checker.isPermitted(),
fullyExecuted: checker.isComplete(),
message: 'Access restricted.',
};
},
};
FastifyServerBase.init({
serviceName: 'my-service',
postHandlerGuard: myGuard,
// ...
});FileStorageAdapter
Implement FileStorageAdapter or use the built-in Azure/S3 adapters:
import { AzureStorageAdapter } from '@sbfw/fastify-microservice/storage';
// or
import { S3StorageAdapter } from '@sbfw/fastify-microservice/storage';
const storage = new AzureStorageAdapter();
await storage.initialize();
FastifyServerBase.init({
serviceName: 'my-service',
storageAdapter: storage,
enableFileUpload: true,
enableFileServe: true,
// ...
});Custom adapter
import { FileStorageAdapter, IUploadedFile, IUploadOptions } from '@sbfw/fastify-microservice';
class InMemoryStorageAdapter implements FileStorageAdapter {
private store = new Map<string, { buffer: Buffer; contentType: string }>();
async upload(buffer, filename, mimeType, options): Promise<IUploadedFile> {
const key = `${options.storagePath}/${filename}`;
this.store.set(key, { buffer, contentType: mimeType });
return { url: key, storagePath: options.storagePath, filename, mimeType, size: buffer.length, blobName: key };
}
async download(blobName) {
const entry = this.store.get(blobName)!;
return { buffer: entry.buffer, contentType: entry.contentType };
}
async delete(blobName) {
return this.store.delete(blobName);
}
async exists(blobName) {
return this.store.has(blobName);
}
}Logger
Provide your own logger via IFrameworkLogger. If not provided, console is used.
import winston from 'winston';
import { IFrameworkLogger } from '@sbfw/fastify-microservice';
const winstonLogger = winston.createLogger({ /* ... */ });
const frameworkLogger: IFrameworkLogger = {
log(level: string, message: string) {
winstonLogger.log(level, message);
},
};
FastifyServerBase.init({
serviceName: 'my-service',
frameworkLogger,
// ...
});API Prefix
The default route prefix is /api/v1. Override it for versioned APIs:
FastifyServerBase.init({
serviceName: 'my-service',
apiPrefix: '/api/v2',
// ...
});Use apiPrefix: '/' for bare setups without a prefix.
Server Options
| Option | Type | Default | Description |
|---|---|---|---|
| serviceName | string | required | Used for logging and Swagger title |
| port | number | 3000 / PORT env | Port to listen on |
| host | string | 0.0.0.0 / HOST env | Host to bind |
| models | ModelSetupHandler[] | required | Fastify schema registration callbacks |
| endpoints | EndpointSetup[] | required | Endpoint registration entries |
| apiPrefix | string | '/api/v1' | Route prefix for all endpoints |
| cors | string \| string[] \| null | CORS env | CORS origin(s) |
| frameworkLogger | IFrameworkLogger | console | Injectable logger |
| postHandlerGuard | PostHandlerGuard | none | Post-response permission guard |
| storageAdapter | FileStorageAdapter | none | Required when enableFileServe: true |
| enableFileUpload | boolean | false | Register @fastify/multipart |
| enableFileServe | boolean | false | Register file-serve endpoint |
| swagger | SwaggerInfoOptions | {} | OpenAPI info overrides |
| onStart | () => Promise<void> | none | Called before server starts listening |
| onStop | () => Promise<void> | none | Called on graceful shutdown |
Environment Variables
| Variable | Description |
|---|---|
| PORT | Server port (default: 3000) |
| HOST | Server host (default: 0.0.0.0) |
| CORS | Comma-separated CORS origins |
| OPEN_API_DOCS | Enable Swagger UI (1, true, or a URL fragment filter) |
| VIRTUAL_PATH | Virtual path prefix for docker-compose Swagger server entry |
| Azure adapter | |
| AZURE_STORAGE_CONNECTION_STRING | Azure Storage connection string |
| AZURE_STORAGE_CONTAINER_NAME | Default blob container name |
| S3 adapter | |
| AWS_REGION | AWS region (default: us-east-1) |
| AWS_S3_BUCKET | Default S3 bucket name |
| AWS_ACCESS_KEY_ID | AWS access key (optional, falls back to SDK credential chain) |
| AWS_SECRET_ACCESS_KEY | AWS secret key (optional) |
Error Classes
| Class | Status | Description |
|---|---|---|
| InputApiError | 400 | Validation / bad input |
| UnauthorizedApiError | 401 | Missing or invalid credentials |
| ForbiddenApiError | 403 | Insufficient permissions |
| NotFoundApiError | 404 | Resource not found |
| BusinessLogicApiError | 409 | Business rule violation |
| MicroserviceApiError | 500 | Downstream service failure |
| StorageApiError | 500 | File storage failure |
| ServerError | 500 | Unexpected server error |
All extend BaseApiError which implements Fastify's FastifyError interface.
Schema Utilities
import { objectSchema, arraySchema, extendObjectSchema, errorResponse } from '@sbfw/fastify-microservice';
const UserSchema = objectSchema<User>(
{ id: { type: 'string' }, name: { type: 'string' }, email: { type: 'string' } },
{ includeRequired: ['id', 'name'] },
);
const UsersListSchema = arraySchema<User>(
{ id: { type: 'string' }, name: { type: 'string' } },
);
const AdminUserSchema = extendObjectSchema<User, { adminFlag: boolean }>({
base: { id: { type: 'string' }, name: { type: 'string' } },
extension: { adminFlag: { type: 'boolean' } },
exclude: ['name'],
});