Custom Endpoints Recipe
Practical examples of creating custom endpoints using adapters.
Goal
Learn how to extend your generated API with custom endpoints for real-world use cases:
- Image upload with transformation
- Webhook receiver
- Bulk import endpoint
- External API proxy
- Custom validation endpoint
Recipe 1: Image Upload with Transformation
Use Case
Upload a product image, resize it, upload to storage, and create the product in one request.
Prisma Schema
// @tg_form()
model Product {
id String @id @default(uuid())
name String
description String
imageUrl String?
thumbnailUrl String?
price Float
createdAt DateTime @default(now())
}
Step 1: Install Dependencies
npm install sharp minio
Step 2: Create Upload Helper
// src/adapters/helpers.ts
import { Minio } from 'minio';
import sharp from 'sharp';
const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT!,
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY!,
secretKey: process.env.MINIO_SECRET_KEY!,
});
helpers.upload.minio = async (file: Express.Multer.File, bucket: string = 'products') => {
const filename = `${helpers.uuid()}-${file.originalname}`;
// Upload original
await minioClient.putObject(bucket, filename, file.buffer, file.size, {
'Content-Type': file.mimetype,
});
return `https://${process.env.MINIO_ENDPOINT}/${bucket}/${filename}`;
};
helpers.upload.thumbnail = async (file: Express.Multer.File, bucket: string = 'products') => {
const filename = `thumb-${helpers.uuid()}.webp`;
// Resize to thumbnail
const thumbnail = await sharp(file.buffer).resize(200, 200, { fit: 'cover' }).webp({ quality: 80 }).toBuffer();
await minioClient.putObject(bucket, filename, thumbnail, thumbnail.length, {
'Content-Type': 'image/webp',
});
return `https://${process.env.MINIO_ENDPOINT}/${bucket}/${filename}`;
};
Step 3: Create Adapter
// src/features/product/adapters/create-with-image.adapter.ts
import { adapter } from '@/adapters/runtime';
export default adapter.multipart(
{
method: 'POST',
path: '/with-image',
target: 'ProductService.create',
auth: 'JwtAuthGuard',
summary: 'Create product with image upload',
description: 'Upload product image, generate thumbnail, and create product',
},
async (ctx) => {
const { body, files, helpers } = ctx;
// Validate
helpers.assert(files, 'Image file is required');
helpers.assert(body.name, 'Product name is required');
helpers.assert(body.price, 'Price is required');
const file = Array.isArray(files) ? files[0] : files;
// Validate image type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
helpers.assert(allowedTypes.includes(file.mimetype), 'Only JPEG, PNG, and WebP images are allowed');
// Upload full image and thumbnail in parallel
const [imageUrl, thumbnailUrl] = await Promise.all([
helpers.upload.minio(file, 'products'),
helpers.upload.thumbnail(file, 'products'),
]);
return {
args: {
name: body.name,
description: body.description || '',
price: parseFloat(body.price),
imageUrl,
thumbnailUrl,
},
};
},
);
Step 4: Test
curl -X POST http://localhost:3000/tg-api/products/with-image \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "name=Premium Widget" \
-F "description=High quality product" \
-F "price=99.99" \
-F "file=@/path/to/image.jpg"
Recipe 2: Webhook Receiver
Use Case
Receive and process Stripe webhooks for payment updates.
Prisma Schema
// @tg_form()
model Order {
id String @id @default(uuid())
userId String
amount Float
status String // pending, paid, failed
stripePaymentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
Step 1: Create Webhook Adapter
// src/features/order/adapters/stripe-webhook.adapter.ts
import { adapter } from '@/adapters/runtime';
import Stripe from 'stripe';
import { BadRequestException } from '@nestjs/common';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export default adapter.json(
{
method: 'POST',
path: '/webhook/stripe',
// No auth - Stripe signature verification instead
summary: 'Stripe webhook receiver',
description: 'Processes Stripe payment events',
},
async (ctx) => {
const { body, headers, di } = ctx;
// Verify Stripe signature
const signature = headers['stripe-signature'];
if (!signature) {
throw new BadRequestException('Missing stripe-signature header');
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
JSON.stringify(body),
signature as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
throw new BadRequestException(`Webhook signature verification failed: ${err.message}`);
}
// Handle different event types
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
// Update order status
await di.prisma.order.update({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'paid' },
});
console.log(`✅ Payment succeeded: ${paymentIntent.id}`);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
// Update order status
await di.prisma.order.update({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'failed' },
});
console.log(`❌ Payment failed: ${paymentIntent.id}`);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return adapter.response(200, { received: true });
},
);
Step 2: Register Webhook in Stripe
stripe listen --forward-to http://localhost:3000/tg-api/orders/webhook/stripe
Step 3: Test
stripe trigger payment_intent.succeeded
Recipe 3: Bulk Import Endpoint
Use Case
Import multiple users from a CSV file.
Prisma Schema
// @tg_form()
model User {
id String @id @default(uuid())
email String @unique
name String
role String @default("user")
createdAt DateTime @default(now())
}
Step 1: Install CSV Parser
npm install csv-parse
Step 2: Create Bulk Import Adapter
// src/features/user/adapters/bulk-import.adapter.ts
import { adapter } from '@/adapters/runtime';
import { parse } from 'csv-parse/sync';
import { BadRequestException } from '@nestjs/common';
export default adapter.multipart(
{
method: 'POST',
path: '/bulk-import',
auth: ['JwtAuthGuard', 'AdminGuard'],
summary: 'Bulk import users from CSV',
description: 'Upload CSV file with user data (email, name, role)',
},
async (ctx) => {
const { files, helpers, di } = ctx;
helpers.assert(files, 'CSV file is required');
const file = Array.isArray(files) ? files[0] : files;
// Validate CSV file
helpers.assert(file.mimetype === 'text/csv' || helpers.ext(file.originalname) === 'csv', 'File must be a CSV');
// Parse CSV
const csvContent = file.buffer.toString('utf-8');
const records = parse(csvContent, {
columns: true,
skip_empty_lines: true,
trim: true,
});
helpers.assert(records.length > 0, 'CSV file is empty');
helpers.assert(records.length <= 1000, 'Maximum 1000 users per import');
// Validate required columns
const requiredColumns = ['email', 'name'];
const firstRecord = records[0];
for (const col of requiredColumns) {
helpers.assert(col in firstRecord, `Missing required column: ${col}`);
}
// Prepare user data
const users = records.map((record: any, index: number) => {
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(record.email)) {
throw new BadRequestException(`Invalid email at row ${index + 2}: ${record.email}`);
}
return {
email: record.email.toLowerCase().trim(),
name: record.name.trim(),
role: record.role || 'user',
id: helpers.uuid(),
};
});
// Check for duplicate emails in CSV
const emails = users.map((u) => u.email);
const duplicates = emails.filter((e, i) => emails.indexOf(e) !== i);
helpers.assert(duplicates.length === 0, `Duplicate emails in CSV: ${duplicates.join(', ')}`);
// Check for existing emails in database
const existing = await di.prisma.user.findMany({
where: { email: { in: emails } },
select: { email: true },
});
if (existing.length > 0) {
const existingEmails = existing.map((u) => u.email).join(', ');
throw new BadRequestException(`These emails already exist: ${existingEmails}`);
}
// Bulk insert
const result = await di.prisma.user.createMany({
data: users,
skipDuplicates: true,
});
return adapter.response(201, {
success: true,
imported: result.count,
total: records.length,
});
},
);
Step 3: Test
Create test CSV file (users.csv):
email,name,role
john@example.com,John Doe,user
jane@example.com,Jane Smith,admin
bob@example.com,Bob Johnson,user
Upload:
curl -X POST http://localhost:3000/tg-api/users/bulk-import \
-H "Authorization: Bearer ADMIN_TOKEN" \
-F "file=@users.csv"
Recipe 4: External API Proxy
Use Case
Fetch GitHub user data and sync to local database.
Prisma Schema
// @tg_form()
model Developer {
id String @id @default(uuid())
githubUsername String @unique
githubId Int?
name String?
bio String?
avatarUrl String?
followers Int @default(0)
repos Int @default(0)
lastSyncedAt DateTime?
createdAt DateTime @default(now())
}
Step 1: Create GitHub Sync Adapter
// src/features/developer/adapters/sync-github.adapter.ts
import { adapter } from '@/adapters/runtime';
import { NotFoundException } from '@nestjs/common';
export default adapter.json(
{
method: 'POST',
path: '/sync-github',
auth: 'JwtAuthGuard',
summary: 'Sync developer from GitHub',
description: 'Fetch GitHub user data and create/update developer profile',
},
async (ctx) => {
const { body, helpers, di } = ctx;
helpers.assert(body.githubUsername, 'GitHub username is required');
// Fetch from GitHub API
const response = await fetch(`https://api.github.com/users/${body.githubUsername}`, {
headers: {
'User-Agent': 'TGraph-App',
Accept: 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
if (response.status === 404) {
throw new NotFoundException(`GitHub user not found: ${body.githubUsername}`);
}
throw new Error(`GitHub API error: ${response.statusText}`);
}
const githubData = await response.json();
// Check if developer exists
const existing = await di.prisma.developer.findUnique({
where: { githubUsername: body.githubUsername },
});
const data = {
githubUsername: githubData.login,
githubId: githubData.id,
name: githubData.name || githubData.login,
bio: githubData.bio,
avatarUrl: githubData.avatar_url,
followers: githubData.followers,
repos: githubData.public_repos,
lastSyncedAt: new Date(),
};
if (existing) {
// Update existing
const updated = await di.prisma.developer.update({
where: { id: existing.id },
data,
});
return adapter.response(200, {
data: updated,
message: 'Developer profile updated',
});
} else {
// Create new
const created = await di.prisma.developer.create({
data: { id: helpers.uuid(), ...data },
});
return adapter.response(201, {
data: created,
message: 'Developer profile created',
});
}
},
);
Step 2: Test
curl -X POST http://localhost:3000/tg-api/developers/sync-github \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"githubUsername": "torvalds"}'
Recipe 5: Custom Validation Endpoint
Use Case
Validate promo code before creating an order.
Prisma Schema
// @tg_form()
model PromoCode {
id String @id @default(uuid())
code String @unique
discount Float // percentage 0-100
maxUses Int
usedCount Int @default(0)
expiresAt DateTime
active Boolean @default(true)
createdAt DateTime @default(now())
}
// @tg_form()
model Order {
id String @id @default(uuid())
userId String
amount Float
discount Float @default(0)
promoCodeId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
}
Step 1: Create Validation Adapter
// src/features/order/adapters/create-with-promo.adapter.ts
import { adapter } from '@/adapters/runtime';
import { BadRequestException } from '@nestjs/common';
export default adapter.json(
{
method: 'POST',
path: '/with-promo',
target: 'OrderService.create',
auth: 'JwtAuthGuard',
summary: 'Create order with promo code',
description: 'Validate promo code and apply discount',
},
async (ctx) => {
const { body, user, helpers, di } = ctx;
helpers.assert(body.amount, 'Amount is required');
helpers.assert(body.amount > 0, 'Amount must be positive');
let discount = 0;
let promoCodeId = null;
// Validate promo code if provided
if (body.promoCode) {
const promoCode = await di.prisma.promoCode.findUnique({
where: { code: body.promoCode.toUpperCase() },
});
// Validation checks
if (!promoCode) {
throw new BadRequestException('Invalid promo code');
}
if (!promoCode.active) {
throw new BadRequestException('Promo code is no longer active');
}
if (new Date() > new Date(promoCode.expiresAt)) {
throw new BadRequestException('Promo code has expired');
}
if (promoCode.usedCount >= promoCode.maxUses) {
throw new BadRequestException('Promo code has reached its usage limit');
}
// Calculate discount
discount = (body.amount * promoCode.discount) / 100;
promoCodeId = promoCode.id;
// Increment usage count
await di.prisma.promoCode.update({
where: { id: promoCode.id },
data: { usedCount: promoCode.usedCount + 1 },
});
}
const finalAmount = body.amount - discount;
return {
args: {
userId: user.id,
amount: finalAmount,
discount,
promoCodeId,
},
};
},
);
Step 2: Test
# Without promo code
curl -X POST http://localhost:3000/tg-api/orders/with-promo \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"amount": 100}'
# With promo code
curl -X POST http://localhost:3000/tg-api/orders/with-promo \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"amount": 100, "promoCode": "SAVE20"}'
Advanced: Combining Multiple Patterns
Use Case
Create a product with image upload, external API sync, and validation.
// src/features/product/adapters/create-advanced.adapter.ts
import { adapter } from '@/adapters/runtime';
import { BadRequestException } from '@nestjs/common';
export default adapter.multipart(
{
method: 'POST',
path: '/advanced',
target: 'ProductService.create',
auth: ['JwtAuthGuard', 'AdminGuard'],
summary: 'Advanced product creation',
description: 'Upload image, validate with external API, and create product',
},
async (ctx) => {
const { body, files, helpers, di } = ctx;
// 1. Validate inputs
helpers.assert(body.name, 'Product name is required');
helpers.assert(body.price, 'Price is required');
helpers.assert(files, 'Product image is required');
const price = parseFloat(body.price);
helpers.assert(price > 0, 'Price must be positive');
// 2. Check for duplicate product name
const existing = await di.prisma.product.findFirst({
where: { name: body.name },
});
if (existing) {
throw new BadRequestException(`Product "${body.name}" already exists`);
}
// 3. Validate with external pricing API
const pricingResponse = await fetch('https://api.example.com/validate-price', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: body.category, price }),
});
if (!pricingResponse.ok) {
throw new BadRequestException('Price validation failed');
}
const pricingData = await pricingResponse.json();
if (!pricingData.valid) {
throw new BadRequestException(
`Invalid price for category. Suggested range: $${pricingData.min}-$${pricingData.max}`,
);
}
// 4. Upload and process image
const file = Array.isArray(files) ? files[0] : files;
const [imageUrl, thumbnailUrl] = await Promise.all([
helpers.upload.minio(file, 'products'),
helpers.upload.thumbnail(file, 'products'),
]);
// 5. Generate slug
const slug = helpers.slugify(body.name);
return {
args: {
name: body.name,
slug,
description: body.description || '',
category: body.category,
price,
imageUrl,
thumbnailUrl,
},
};
},
);
Next Steps
- Custom Adapters Guide - Comprehensive usage guide
- Adapters API Reference - Complete API documentation
- Authentication Guards - Securing your adapters