Custom Adapters Guide
Learn how to create custom endpoints using adapters - lightweight transformation layers that extend your generated API without modifying controller or service files.
Overview
Adapters provide a clean way to add custom endpoints to your generated API. They act as transformation or proxy layers between HTTP requests and your service methods, allowing you to:
- Transform request data before calling services
- Upload and process files
- Integrate with external APIs
- Create webhook receivers
- Bypass services entirely for custom logic
Key Benefits:
- No generated file edits - Adapters live in separate files
- Automatic discovery - Found and integrated during
tgraph api - Full type safety - TypeScript support throughout
- Auto validation - Generated DTOs with class-validator
- OpenAPI docs - Swagger documentation generated automatically
When to Use Adapters
Use adapters when you need:
- Request transformation - Convert data before service calls
- File processing - Handle uploads with custom logic
- External integrations - Call third-party APIs
- Custom validation - Complex business rules
- Webhooks - Receive and process external events
Don’t use adapters for:
- Simple CRUD operations (use generated endpoints)
- Core business logic (put in services)
- Shared functionality (create utility functions)
Creating Your First Adapter
Step 1: Create Adapter File
Adapters live in {modulePath}/adapters/*.adapter.ts:
// src/features/post/adapters/create-with-slug.adapter.ts
import { adapter } from '@/adapters/runtime';
// Define the request body type for full type safety
interface CreatePostBody {
title: string;
content: string;
}
export default adapter.json<CreatePostBody>(
{
method: 'POST',
path: '/with-slug',
target: 'PostService.create',
auth: 'JwtAuthGuard',
},
async (ctx) => {
const { body, helpers } = ctx;
// body is now typed as CreatePostBody - get full IntelliSense!
const slug = helpers.slugify(body.title); // ✓ body.title is string
// Return args for service method
return {
args: {
...body,
slug,
},
};
},
);
Step 2: Run Generator
tgraph api
The generator will:
- Discover your adapter
- Validate configuration
- Generate input DTO
- Inject endpoint into controller
- Generate OpenAPI docs
Step 3: Test Your Endpoint
curl -X POST http://localhost:3000/tg-api/posts/with-slug \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"title": "Hello World", "content": "..."}'
Response:
{
"data": {
"id": "...",
"title": "Hello World",
"slug": "hello-world",
"content": "..."
}
}
Adapter Types
JSON Adapters
For standard JSON requests:
export default adapter.json(
{
method: 'POST',
path: '/custom-endpoint',
target: 'ServiceName.methodName',
},
async (ctx) => {
// ctx.body contains parsed JSON
return { args: { ...ctx.body } };
},
);
Multipart Adapters
For file uploads:
export default adapter.multipart(
{
method: 'POST',
path: '/upload-image',
target: 'PostService.update',
},
async (ctx) => {
const { files, body, helpers } = ctx;
// files is Express.Multer.File or Express.Multer.File[]
const file = Array.isArray(files) ? files[0] : files;
// Upload to storage
const url = await helpers.upload.minio(file, 'images');
return {
args: {
id: body.id,
imageUrl: url,
},
};
},
);
Configuration Options
Required Fields
{
method: 'POST', // HTTP method: GET, POST, PUT, DELETE, PATCH
path: '/endpoint', // Route path (relative to controller)
}
Optional Fields
{
target: 'PostService.createOne', // Service method to call (or null)
auth: 'JwtAuthGuard', // Guard(s) to apply
select: ['id', 'title'], // Fields to return
include: ['author'], // Relations to include
description: 'Upload image', // OpenAPI description
summary: 'Upload post image', // OpenAPI summary
tags: ['images'], // OpenAPI tags
}
Auth Configuration
Single guard:
{
auth: 'JwtAuthGuard',
}
Multiple guards:
{
auth: ['JwtAuthGuard', 'AdminGuard'],
}
No auth:
{
// Omit auth field
}
Context API
The adapter handler receives a context object with everything you need:
async (ctx) => {
// URL and parameters
ctx.url; // Full request URL
ctx.params; // Route parameters: { id: '123' }
ctx.query; // Query string: { page: '1' }
// Request data
ctx.body; // Parsed JSON body
ctx.headers; // Request headers
ctx.files; // Uploaded file(s) (multipart only)
// User and auth
ctx.user; // Authenticated user from guard
// Dependencies
ctx.di; // Dependency injection container
ctx.di.prisma; // PrismaService instance
// Helpers
ctx.helpers; // Utility functions
// Raw objects (advanced)
ctx.req; // Express Request
ctx.res; // Express Response
};
Helper Functions
uuid()
Generate UUID v4:
const id = helpers.uuid();
// "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
slugify()
Convert to URL-friendly slug:
const slug = helpers.slugify('Hello World!');
// "hello-world"
ext()
Extract file extension:
const extension = helpers.ext('image.jpg');
// "jpg"
pick()
Select specific object properties:
const subset = helpers.pick(user, ['id', 'email']);
// { id: '123', email: 'user@example.com' }
assert()
Validate conditions:
helpers.assert(user.age >= 18, 'Must be 18 or older');
helpers.assert(price > 0, 'Price must be positive');
Throws BadRequestException if condition is false.
upload (user-implemented)
Upload utilities are placeholders. Implement your own:
// In your codebase, create upload utilities
// src/adapters/helpers.ts
helpers.upload.minio = async (file, bucket = 'default') => {
const minioClient = new Minio.Client({
/* config */
});
const filename = `${uuid()}-${file.originalname}`;
await minioClient.putObject(bucket, filename, file.buffer);
return `https://cdn.example.com/${bucket}/${filename}`;
};
Return Types
Service Call
Return { args } to call the target service:
return {
args: {
title: 'My Title',
content: 'My Content',
},
};
The generator will call ServiceName.methodName(args).
Direct Response
Use adapter.response() to bypass the service:
return adapter.response(200, {
success: true,
message: 'Processed successfully',
});
With custom headers:
return adapter.response(
201,
{ id: '123' },
{
'X-Custom-Header': 'value',
},
);
Response Shaping
Select Fields
Return specific fields only:
adapter.json(
{
method: 'GET',
path: '/summary',
target: 'PostService.getOne',
select: ['id', 'title', 'createdAt'],
},
async (ctx) => {
return { args: ctx.params.id };
},
);
Response:
{
"data": {
"id": "123",
"title": "Hello",
"createdAt": "2024-01-01T00:00:00Z"
}
}
Include Relations
Load related data:
adapter.json(
{
method: 'GET',
path: '/with-author',
target: 'PostService.getOne',
include: ['author', 'comments'],
},
async (ctx) => {
return { args: ctx.params.id };
},
);
Note: You cannot use both select and include.
Advanced Patterns
External API Integration
export default adapter.json(
{
method: 'POST',
path: '/sync-stripe',
},
async (ctx) => {
const { body, di } = ctx;
// Call external API
const stripeCustomer = await stripe.customers.create({
email: body.email,
});
// Save to database
await di.prisma.user.update({
where: { id: body.userId },
data: { stripeCustomerId: stripeCustomer.id },
});
return adapter.response(200, {
success: true,
customerId: stripeCustomer.id,
});
},
);
Webhook Receiver
export default adapter.json(
{
method: 'POST',
path: '/webhook/stripe',
},
async (ctx) => {
const { body, headers } = ctx;
// Verify signature
const signature = headers['stripe-signature'];
stripe.webhooks.constructEvent(body, signature, webhookSecret);
// Process event
await processStripeEvent(body);
return adapter.response(200, { received: true });
},
);
Bulk Operations
export default adapter.json(
{
method: 'POST',
path: '/bulk-create',
},
async (ctx) => {
const { body, di } = ctx;
const results = await di.prisma.post.createMany({
data: body.posts,
});
return adapter.response(201, {
created: results.count,
});
},
);
Custom Validation
export default adapter.json(
{
method: 'POST',
path: '/validate-and-create',
target: 'UserService.create',
},
async (ctx) => {
const { body, helpers, di } = ctx;
// Custom validation
helpers.assert(body.age >= 18, 'Must be 18 or older');
helpers.assert(body.password.length >= 8, 'Password too short');
// Check uniqueness
const existing = await di.prisma.user.findUnique({
where: { email: body.email },
});
helpers.assert(!existing, 'Email already registered');
return { args: body };
},
);
Error Handling
Adapters throw standard NestJS exceptions:
import { BadRequestException, NotFoundException } from '@nestjs/common';
export default adapter.json({
method: 'POST',
path: '/custom',
}, async (ctx) => {
if (!ctx.body.required Field) {
throw new BadRequestException('Required field is missing');
}
const item = await ctx.di.prisma.item.findUnique({
where: { id: ctx.body.id },
});
if (!item) {
throw new NotFoundException('Item not found');
}
return { args: item };
});
Testing Adapters
Unit Testing
describe('CreateWithSlug Adapter', () => {
it('should generate slug from title', async () => {
const ctx = {
body: { title: 'Hello World', content: 'Test' },
helpers: { slugify: (s: string) => s.toLowerCase().replace(/\s+/g, '-') },
};
const result = await adapterHandler(ctx);
expect(result.args.slug).toBe('hello-world');
});
});
Integration Testing
describe('POST /posts/with-slug', () => {
it('should create post with slug', async () => {
const response = await request(app.getHttpServer())
.post('/tg-api/posts/with-slug')
.set('Authorization', `Bearer ${token}`)
.send({ title: 'Test Post', content: 'Content' })
.expect(201);
expect(response.body.data.slug).toBe('test-post');
});
});
Best Practices
1. Keep Adapters Focused
Each adapter should have a single, clear purpose:
✅ Good:
// upload-avatar.adapter.ts - Clear purpose
// validate-promo-code.adapter.ts - Clear purpose
❌ Bad:
// misc-operations.adapter.ts - Too vague
2. Use Descriptive Names
Name adapters after their action:
// Good names
create-with-slug.adapter.ts
upload-featured-image.adapter.ts
sync-to-external-api.adapter.ts
// Bad names
custom1.adapter.ts
temp.adapter.ts
3. Validate Inputs
Always validate and assert expectations:
helpers.assert(body.email, 'Email required');
helpers.assert(body.age >= 18, 'Must be 18+');
4. Document Complex Logic
Add comments for non-obvious operations:
export default adapter.json(
{
method: 'POST',
path: '/process',
description: 'Process payment and send confirmation email',
summary: 'Process Payment',
},
async (ctx) => {
// 1. Charge payment via Stripe
const charge = await stripe.charges.create(/* ... */);
// 2. Update order status
await ctx.di.prisma.order.update(/* ... */);
// 3. Send confirmation email
await sendEmail(/* ... */);
return adapter.response(200, { orderId: charge.metadata.orderId });
},
);
5. Handle Errors Gracefully
Provide clear error messages:
try {
const result = await externalAPI.call();
return { args: result };
} catch (error) {
throw new BadRequestException(`External API failed: ${error.message}`);
}
6. Leverage Dependency Injection
Access Prisma and custom services:
async (ctx) => {
const { di } = ctx;
// Use Prisma
const user = await di.prisma.user.findUnique({});
// Use custom services (if injected)
const result = await di.emailService.send({});
};
TypeScript Typing
Adapters support full TypeScript type safety including generics for strong typing of request/response data.
Type-Safe Context with Generics
By default, ctx.body, ctx.query, and ctx.params are typed as any. You can provide explicit types using generic parameters:
interface CreatePostBody {
title: string;
content: string;
authorId: string;
}
interface PostQuery {
published?: boolean;
page?: string;
}
interface PostParams {
categoryId: string;
}
// Pass generic types: <TBody, TQuery, TParams>
export default adapter.json<CreatePostBody, PostQuery, PostParams>(
{
method: 'POST',
path: '/:categoryId/posts',
target: 'PostService.create',
},
async (ctx) => {
// Now fully typed - no more 'any'!
const title: string = ctx.body.title; // ✓ Typed as string
const content: string = ctx.body.content; // ✓ Typed as string
const published = ctx.query.published; // ✓ Typed as boolean | undefined
const categoryId: string = ctx.params.categoryId; // ✓ Typed as string
return { args: ctx.body };
},
);
Generic Parameter Order:
TBody- Request body type (accessed viactx.body)TQuery- Query parameters (accessed viactx.query)TParams- Route parameters (accessed viactx.params)
You can provide 1, 2, or all 3 types:
// Body only
export default adapter.json<CreatePostBody>(
{
method: 'POST',
path: '/posts',
target: 'PostService.create',
},
async (ctx) => {
ctx.body; // ✓ Typed as CreatePostBody
ctx.query; // any
ctx.params; // any
},
);
// Body and query
export default adapter.json<CreatePostBody, PostQuery>(
{
method: 'POST',
path: '/posts',
target: 'PostService.create',
},
async (ctx) => {
ctx.body; // ✓ Typed as CreatePostBody
ctx.query; // ✓ Typed as PostQuery
ctx.params; // any
},
);
// All three types
export default adapter.json<CreatePostBody, PostQuery, PostParams>(
{
method: 'POST',
path: '/:categoryId/posts',
target: 'PostService.create',
},
async (ctx) => {
ctx.body; // ✓ Typed as CreatePostBody
ctx.query; // ✓ Typed as PostQuery
ctx.params; // ✓ Typed as PostParams
},
);
Benefits:
- Full IntelliSense - Get autocomplete for all properties
- Type checking - Catch errors at compile time
- Better DTOs - Generator can infer types for adapter DTOs
- Documentation - Types serve as inline documentation
Synchronous vs Asynchronous Handlers
Adapters support both async and synchronous handlers:
// Async handler (most common)
export default adapter.json(
{
method: 'POST',
path: '/create',
target: 'PostService.create',
},
async (ctx) => {
await someAsyncOperation();
return { args: ctx.body };
},
);
// Synchronous handler (no TypeScript errors)
export default adapter.json(
{
method: 'POST',
path: '/create',
target: 'PostService.create',
},
(ctx) => {
// No await needed - TypeScript won't complain
return { args: ctx.body };
},
);
Note: TypeScript allows both return types so you won’t get errors when declaring async functions without await.
Typing Helper Functions
The helpers.assert() function preserves TypeScript type guards:
export default adapter.json(
{
method: 'POST',
path: '/create',
target: 'PostService.create',
},
async (ctx) => {
const { body, helpers } = ctx;
// Before assertion: body.title might be undefined
helpers.assert(body.title, 'Title is required');
// After assertion: TypeScript knows body.title is truthy
const upperTitle = body.title.toUpperCase(); // ✓ No error
return { args: { ...body, title: upperTitle } };
},
);
Important: Don’t explicitly type helpers as typeof helpers - this loses the assertion signature. Use helpers directly from the context.
Type Safety Requirements
Ensure you have these packages installed in your project:
npm install --save-dev @types/express @types/multer
These provide type definitions for Express.Request, Express.Response, and Multer.File used in adapter templates.
Troubleshooting
Adapter Not Discovered
Problem: Adapter not showing up in generated controller.
Solutions:
- Check file location:
{modulePath}/adapters/*.adapter.ts - Verify file naming:
*.adapter.tsor*.adapter.js - Run
tgraph apito regenerate - Check console for parsing errors
Validation Errors
Problem: Adapter validation fails during generation.
Solutions:
- Check required fields:
methodandpath - Verify path starts with
/ - Validate target format:
ServiceName.methodName - Check select/include fields exist on model
Type Errors
Problem: TypeScript errors in adapter code.
Solutions:
- Install required type packages:
npm install --save-dev @types/express @types/multer - Import types if needed:
import type { AdapterContext } from '@/adapters/types'; - Don’t type
helpersastypeof helpers- it loses assertion signatures - Check helper function signatures
- Ensure adapter files are properly exported with
export default
File Upload Issues
Problem: Files not available in multipart adapter.
Solutions:
- Use
adapter.multipart()notadapter.json() - Check
Content-Type: multipart/form-dataheader - Access files via
ctx.files - Verify file field name matches interceptor config
Migration Guide
From Custom Controllers
Before (custom controller):
@Controller('posts')
export class CustomPostController {
@Post('with-slug')
async createWithSlug(@Body() body: any) {
const slug = slugify(body.title);
return this.postService.create({ ...body, slug });
}
}
After (adapter):
export default adapter.json(
{
method: 'POST',
path: '/with-slug',
target: 'PostService.create',
},
async (ctx) => {
const slug = ctx.helpers.slugify(ctx.body.title);
return { args: { ...ctx.body, slug } };
},
);
Benefits:
- No manual controller management
- Auto-generated validation
- OpenAPI docs included
- Consistent with generator patterns
Next Steps
- API Reference - Detailed API documentation
- Recipes - More practical examples
- Authentication Guards - Securing endpoints