npm package: @sbfw/fastify-microservice
Description: A Fastify-based microservice framework with pluggable auth, permissions, and file storage
Author: Richard Habermann
Latest version: 1.0.0

# @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** — `PostHandlerGuard` verifies 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_DOCS` env var
- **CORS, multipart, file serve** — opt-in via server options

## Installation

```bash
npm install @sbfw/fastify-microservice fastify @fastify/cors @fastify/multipart @fastify/swagger @fastify/swagger-ui @sbfw/core
```

For Azure Blob storage:
```bash
npm install @azure/storage-blob
```

For AWS S3 storage:
```bash
npm install @aws-sdk/client-s3
```

## Quick Start

```ts
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.

```ts
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:

```ts
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):

```ts
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.

```ts
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:

```ts
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

```ts
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.

```ts
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:

```ts
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

```ts
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'],
});
```