2025-09-04
Zod ve OpenAPI ile Type-Safe AWS Lambda API'leri
'Basit' bir API değişikliği bir kurumsal müşteri entegrasyonunu nasıl bozdu, dokümantasyon drift'i neden gerçek sorunlara yol açar ve Zod schema'larından otomatik OpenAPI spec'i üreten pratik bir sistem.
API dokümantasyon drift’i hakkında acı bir ders: Bir kullanıcı API’sine OpenAPI spec’i güncellenmeden opsiyonel bir alan eklenmesi, bir kurumsal müşterinin entegrasyonunu bir gecede bozdu. Müşterinin code generation pipeline’ı eski şemayı bekleyen TypeScript arayüzleri üretti; bu da yüzlerce başarısız kullanıcı kaydına ve önemli gelir kaybına yol açtı.
Bu olay API dokümantasyonunun yalnızca “olsa iyi olur” bir şey olmadığını ortaya koydu; kritik bir iş altyapısıdır. Bu yaklaşım, OpenAPI spec’lerini Zod şemalarından otomatik üretecek sistemlerin yeniden kurulmasını içerir ve daha güvenli API evrimini mümkün kılar.
Kritik Ders: Dokümantasyon Kayması Neden İşleri Öldürür
Klasik serverless API geliştirme tuzağı, dört farklı doğruluk kaynağının sessizce birbirinden sapmasıdır:
// 1. TypeScript interfaces (what developers think the API does)
interface CreateUserRequest {
email: string;
username: string;
age?: number;
// I added this field...
company?: string;
}
// 2. OpenAPI spec (what clients generate code from)
const openApiSpec = {
paths: {
'/users': {
post: {
requestBody: {
// But forgot to update this
schema: {
type: 'object',
properties: {
email: { type: 'string' },
username: { type: 'string' },
age: { type: 'number' }
// Missing: company field
}
}
}
}
}
}
};
// 3. Lambda validation (inconsistent and incomplete)
if (!event.body.email || typeof event.body.email !== 'string') {
throw new Error('Invalid email');
}
// No validation for company field
// 4. Database schema (yet another source of truth)
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL,
age INTEGER,
company_name VARCHAR(100) -- Different field name!
);
Dört farklı tanım. Hatalar için dört fırsat. Bir öfkeli kurumsal müşteri.
Asıl maliyet, yalnızca anlık zarar değildi; takip toplantıları, zedelenen güven ve geliştirme yaklaşımımızın köklü bir değişikliğe ihtiyaç duyduğunu fark etmekti.
Çözüm: Tek Doğruluk Kaynağı
Tek Doğruluk Kaynağı ilkesinden hareketle, bu yaklaşım Zod şemalarını kesin API sözleşmesi olarak kullanır ve otomatik olarak şunları üretir:
- Derleme zamanı TypeScript tipleri (artık interface drift yok)
- OpenAPI 3.0 spesifikasyonları (her zaman senkron)
- Çalışma zamanı doğrulaması (hatalı veri veritabanına ulaşmadan yakalanır)
- Yapılandırılmış hata yanıtları (istemciler tam olarak neyin yanlış gittiğini bilir)
- Veritabanı migration’ları (özel araçlarla)
Production’da gözlemlenen faydalar:
- Şema sapmasından kaynaklanan entegrasyon hatalarında azalma
- Manuel spec bakımına harcanan sürede düşüş
- Doğru, otomatik üretilmiş SDK’larla daha sorunsuz müşteri onboarding’i
- Daha az destek sorusuna yol açan net hata mesajları
İşte mimari:
Şema Sapmasını Önleyen Temel
Güvenli API evrimini destekleyen kurulum şu şekilde:
# Core dependencies
npm install zod @anatine/zod-openapi @asteasolutions/zod-to-openapi
npm install uuid @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
npm install --save-dev @types/aws-lambda @types/uuid
# For production monitoring
npm install @aws-lambda-powertools/logger @aws-lambda-powertools/tracer @aws-lambda-powertools/metrics
Hataları Derleme Zamanında Yakalayan Tip Sistemi
Şema sapmasını önlemeye yardımcı olan bir temel şu şekilde:
// lib/api/types.ts - Core schema definitions
import { z } from 'zod';
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import {
APIGatewayProxyEventV2,
APIGatewayProxyResultV2,
Context
} from 'aws-lambda';
// Extend Zod with OpenAPI capabilities
extendZodWithOpenApi(z);
// Error schema for structured error responses
export const ErrorResponseSchema = z.object({
error: z.string().openapi({
example: 'Validation failed',
description: 'Human-readable error message'
}),
details: z.array(z.object({
path: z.string().openapi({
example: 'email',
description: 'The field that failed validation'
}),
message: z.string().openapi({
example: 'Invalid email format',
description: 'Specific validation error'
}),
code: z.string().openapi({
example: 'INVALID_EMAIL',
description: 'Machine-readable error code'
})
})).optional(),
requestId: z.string().uuid().openapi({
example: '123e4567-e89b-12d3-a456-426614174000',
description: 'Unique request identifier for debugging'
}),
timestamp: z.string().datetime().openapi({
example: '2023-01-15T10:30:45.123Z',
description: 'When the error occurred'
})
}).openapi('ErrorResponse');
// Response wrapper that provides consistent client experience
export const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.boolean().openapi({
example: true,
description: 'Whether the request succeeded'
}),
data: dataSchema.optional(),
error: ErrorResponseSchema.optional(),
metadata: z.object({
timestamp: z.string().datetime().openapi({
example: '2023-01-15T10:30:45.123Z'
}),
version: z.string().openapi({
example: '1.2.3',
description: 'API version that processed this request'
}),
requestId: z.string().uuid(),
// Performance metrics for debugging
executionTime: z.number().openapi({
example: 245,
description: 'Request processing time in milliseconds'
})
})
});
// Handler configuration for type-safe API routes
export interface HandlerConfig<
TBody extends z.ZodType,
TQuery extends z.ZodType,
TPath extends z.ZodType,
TResponse extends z.ZodType
> {
body?: TBody;
query?: TQuery;
path?: TPath;
response: TResponse;
headers?: z.ZodType;
// Added after learning from production issues
auth?: {
required: boolean;
roles?: string[];
};
rateLimit?: {
requestsPerMinute: number;
burstLimit?: number;
};
// OpenAPI metadata
openapi?: {
summary: string;
description: string;
tags: string[];
deprecated?: boolean;
};
}
// Type inference that provides full IntelliSense
export type InferredHandler<T extends HandlerConfig<any, any, any, any>> = {
body: T['body'] extends z.ZodType ? z.infer<T['body']> : never;
query: T['query'] extends z.ZodType ? z.infer<T['query']> : never;
path: T['path'] extends z.ZodType ? z.infer<T['path']> : never;
response: z.infer<T['response']>;
};
API Sözleşmelerini Doğrulayan Handler Wrapper
Bu wrapper kapsamlı istek doğrulaması ve hata yönetimi sağlar:
// lib/api/handler.ts - Type-safe request/response wrapper
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context } from 'aws-lambda';
import { z, ZodError } from 'zod';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from '@aws-lambda-powertools/logger';
import { Tracer } from '@aws-lambda-powertools/tracer';
import { Metrics } from '@aws-lambda-powertools/metrics';
const logger = new Logger();
const tracer = new Tracer();
const metrics = new Metrics();
export function createHandler<T extends HandlerConfig<any, any, any, any>>(
config: T,
handler: (
event: {
body: T['body'] extends z.ZodType ? z.infer<T['body']> : undefined;
query: T['query'] extends z.ZodType ? z.infer<T['query']> : undefined;
path: T['path'] extends z.ZodType ? z.infer<T['path']> : undefined;
headers: T['headers'] extends z.ZodType ? z.infer<T['headers']> : Record<string, string>;
raw: APIGatewayProxyEventV2;
userId?: string; // Added after auth integration
},
context: Context
) => Promise<z.infer<T['response']>>
): (event: APIGatewayProxyEventV2, context: Context) => Promise<APIGatewayProxyResultV2> {
return async (event: APIGatewayProxyEventV2, context: Context): Promise<APIGatewayProxyResultV2> => {
const requestId = context.requestId || uuidv4();
const startTime = Date.now();
// Add trace metadata
tracer.putAnnotation('requestId', requestId);
tracer.putAnnotation('httpMethod', event.requestContext.http.method);
tracer.putAnnotation('path', event.requestContext.http.path);
logger.info('Request received', {
requestId,
method: event.requestContext.http.method,
path: event.requestContext.http.path,
userAgent: event.headers['user-agent'],
sourceIp: event.requestContext.http.sourceIp,
});
try {
// Parse and validate request components with detailed error tracking
let parsedBody: any;
try {
if (config.body && event.body) {
const bodyJson = JSON.parse(event.body);
parsedBody = config.body.parse(bodyJson);
logger.debug('Body validation successful', { requestId });
}
} catch (error) {
if (error instanceof SyntaxError) {
metrics.addMetric('ValidationErrors', 'Count', 1);
throw new Error('Invalid JSON in request body');
}
throw error;
}
const parsedQuery = config.query && event.queryStringParameters
? config.query.parse(event.queryStringParameters)
: undefined;
const parsedPath = config.path && event.pathParameters
? config.path.parse(event.pathParameters)
: undefined;
const parsedHeaders = config.headers && event.headers
? config.headers.parse(event.headers)
: event.headers;
// Auth validation (learned from security incidents)
let userId: string | undefined;
if (config.auth?.required) {
const authHeader = event.headers.authorization;
if (!authHeader) {
metrics.addMetric('AuthenticationErrors', 'Count', 1);
throw new Error('Authorization header required');
}
// Extract user ID from JWT or other auth mechanism
userId = await validateAuthToken(authHeader);
tracer.putAnnotation('userId', userId);
}
// Execute handler with validated inputs
const result = await handler({
body: parsedBody,
query: parsedQuery,
path: parsedPath,
headers: parsedHeaders,
raw: event,
userId
}, context);
// Validate response against schema
const validatedResponse = config.response.parse(result);
const executionTime = Date.now() - startTime;
// Record success metrics
metrics.addMetric('SuccessfulRequests', 'Count', 1);
metrics.addMetric('ExecutionTime', 'Milliseconds', executionTime);
logger.info('Request completed successfully', {
requestId,
executionTime,
responseSize: JSON.stringify(validatedResponse).length
});
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'X-API-Version': process.env.API_VERSION || '1.0.0',
// Security headers learned from production
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block'
},
body: JSON.stringify({
success: true,
data: validatedResponse,
metadata: {
timestamp: new Date().toISOString(),
version: process.env.API_VERSION || '1.0.0',
requestId,
executionTime: Date.now() - startTime
}
})
};
} catch (error) {
const executionTime = Date.now() - startTime;
// Track error metrics
metrics.addMetric('ErrorRequests', 'Count', 1);
metrics.addMetric('ErrorExecutionTime', 'Milliseconds', executionTime);
// Handle validation errors (these save support tickets)
if (error instanceof ZodError) {
logger.warn('Validation error', {
requestId,
errors: error.issues,
path: event.requestContext.http.path,
method: event.requestContext.http.method
});
metrics.addMetric('ValidationErrors', 'Count', 1);
return {
statusCode: 400,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'X-API-Version': process.env.API_VERSION || '1.0.0'
},
body: JSON.stringify({
success: false,
error: {
error: 'Validation failed',
details: error.issues.map(e => ({
path: e.path.join('.'),
message: e.message,
code: `INVALID_${e.path.join('_').toUpperCase()}`,
received: e.input
})),
requestId,
timestamp: new Date().toISOString()
},
metadata: {
timestamp: new Date().toISOString(),
version: process.env.API_VERSION || '1.0.0',
requestId,
executionTime
}
})
};
}
// Handle authentication errors
if (error.message === 'Authorization header required' ||
error.message.includes('Invalid token')) {
return {
statusCode: 401,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId
},
body: JSON.stringify({
success: false,
error: {
error: 'Unauthorized',
requestId,
timestamp: new Date().toISOString()
},
metadata: {
timestamp: new Date().toISOString(),
version: process.env.API_VERSION || '1.0.0',
requestId,
executionTime
}
})
};
}
// Handle other errors (with proper logging for debugging)
logger.error('Handler error', {
requestId,
error: error.message,
stack: error.stack,
path: event.requestContext.http.path,
method: event.requestContext.http.method,
userId: userId || 'anonymous'
});
metrics.addMetric('InternalErrors', 'Count', 1);
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
'X-API-Version': process.env.API_VERSION || '1.0.0'
},
body: JSON.stringify({
success: false,
error: {
error: 'Internal server error',
requestId,
timestamp: new Date().toISOString(),
// In development, show actual error
...(process.env.NODE_ENV === 'development' && {
details: error.message,
stack: error.stack
})
},
metadata: {
timestamp: new Date().toISOString(),
version: process.env.API_VERSION || '1.0.0',
requestId,
executionTime
}
})
};
}
};
}
// Helper function for auth validation
async function validateAuthToken(authHeader: string): Promise<string> {
// Implementation depends on your auth provider
// Could be JWT validation, Cognito, etc.
const token = authHeader.replace('Bearer ', '');
// This is where you'd validate the token
// For example purposes, we'll just extract a fake user ID
try {
// Your token validation logic here
return 'user-123'; // Return actual user ID
} catch (error) {
throw new Error('Invalid token');
}
}
Eksiksiz Bir Kullanıcı Yönetimi API Örneği
Bu kalıpları gösteren kapsamlı bir kullanıcı yönetimi API’si:
// lib/api/schemas/user.ts
import { z } from 'zod';
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
extendZodWithOpenApi(z);
// Shared schemas
export const UserIdSchema = z.string().uuid().openapi({
example: '123e4567-e89b-12d3-a456-426614174000'
});
export const EmailSchema = z.string().email().openapi({
example: '[email protected]',
description: 'Valid email address'
});
// User entity
export const UserSchema = z.object({
id: UserIdSchema,
email: EmailSchema,
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/).openapi({
example: 'john_doe',
description: 'Alphanumeric username with underscores and hyphens'
}),
fullName: z.string().min(1).max(100).openapi({
example: 'John Doe'
}),
age: z.number().int().min(13).max(120).optional().openapi({
example: 25,
description: 'User age (13-120)'
}),
role: z.enum(['user', 'admin', 'moderator']).default('user').openapi({
example: 'user'
}),
metadata: z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
lastLoginAt: z.string().datetime().optional()
}),
preferences: z.object({
notifications: z.boolean().default(true),
theme: z.enum(['light', 'dark', 'system']).default('system'),
language: z.string().default('en')
}).optional()
}).openapi('User');
// Request schemas
export const CreateUserRequestSchema = UserSchema
.pick({ email: true, username: true, fullName: true, age: true })
.openapi('CreateUserRequest');
export const UpdateUserRequestSchema = UserSchema
.pick({ fullName: true, age: true, preferences: true })
.partial()
.openapi('UpdateUserRequest');
export const ListUsersQuerySchema = z.object({
limit: z.string().regex(/^\d+$/).transform(Number).pipe(
z.number().int().min(1).max(100)
).default('20').openapi({
example: '20',
description: 'Number of users to return (1-100)'
}),
offset: z.string().regex(/^\d+$/).transform(Number).pipe(
z.number().int().min(0)
).default('0').openapi({
example: '0'
}),
role: z.enum(['user', 'admin', 'moderator']).optional(),
sortBy: z.enum(['createdAt', 'username', 'email']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc')
}).openapi('ListUsersQuery');
// Response schemas
export const UserResponseSchema = UserSchema.openapi('UserResponse');
export const UsersListResponseSchema = z.object({
users: z.array(UserResponseSchema),
pagination: z.object({
total: z.number().int(),
limit: z.number().int(),
offset: z.number().int(),
hasMore: z.boolean()
})
}).openapi('UsersListResponse');
Lambda Handler’ları
Şimdi type-safe wrapper’ımızı kullanarak Lambda fonksiyonlarını uygulayalım:
// lambda/handlers/createUser.ts
import { createHandler } from '../../lib/api/handler';
import { CreateUserRequestSchema, UserResponseSchema } from '../../lib/api/schemas/user';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import { v4 as uuidv4 } from 'uuid';
const dynamodb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.USERS_TABLE!;
export const handler = createHandler({
body: CreateUserRequestSchema,
response: UserResponseSchema
}, async ({ body }) => {
const userId = uuidv4();
const now = new Date().toISOString();
const user = {
id: userId,
...body,
role: 'user' as const,
metadata: {
createdAt: now,
updatedAt: now
}
};
await dynamodb.send(new PutCommand({
TableName: TABLE_NAME,
Item: {
PK: `USER#${userId}`,
SK: `USER#${userId}`,
...user
},
ConditionExpression: 'attribute_not_exists(PK)'
}));
return user;
});
// lambda/handlers/listUsers.ts
import { createHandler } from '../../lib/api/handler';
import { ListUsersQuerySchema, UsersListResponseSchema } from '../../lib/api/schemas/user';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
const dynamodb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.USERS_TABLE!;
export const handler = createHandler({
query: ListUsersQuerySchema,
response: UsersListResponseSchema
}, async ({ query }) => {
const { limit, offset, role, sortBy, sortOrder } = query;
// In production, implement proper pagination with DynamoDB
const result = await dynamodb.send(new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: 'begins_with(PK, :pk)',
ExpressionAttributeValues: {
':pk': 'USER#'
},
Limit: limit + 1, // Fetch one extra to check hasMore
ScanIndexForward: sortOrder === 'asc'
}));
const items = result.Items || [];
const hasMore = items.length > limit;
const users = items.slice(0, limit).map(item => ({
id: item.id,
email: item.email,
username: item.username,
fullName: item.fullName,
age: item.age,
role: item.role,
metadata: item.metadata,
preferences: item.preferences
}));
return {
users,
pagination: {
total: result.Count || 0,
limit,
offset,
hasMore
}
};
});
// lambda/handlers/getUser.ts
import { createHandler } from '../../lib/api/handler';
import { UserIdSchema, UserResponseSchema } from '../../lib/api/schemas/user';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
import { z } from 'zod';
const dynamodb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE_NAME = process.env.USERS_TABLE!;
export const handler = createHandler({
path: z.object({ userId: UserIdSchema }),
response: UserResponseSchema
}, async ({ path }) => {
const result = await dynamodb.send(new GetCommand({
TableName: TABLE_NAME,
Key: {
PK: `USER#${path.userId}`,
SK: `USER#${path.userId}`
}
}));
if (!result.Item) {
throw new Error('User not found');
}
return {
id: result.Item.id,
email: result.Item.email,
username: result.Item.username,
fullName: result.Item.fullName,
age: result.Item.age,
role: result.Item.role,
metadata: result.Item.metadata,
preferences: result.Item.preferences
};
});
OpenAPI Spesifikasyonları Üretme
Asıl sihir, OpenAPI spec’lerini Zod şemalarımızdan otomatik üretmemizle gerçekleşir:
// lib/api/openapi.ts
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import {
CreateUserRequestSchema,
UpdateUserRequestSchema,
ListUsersQuerySchema,
UserResponseSchema,
UsersListResponseSchema,
UserIdSchema
} from './schemas/user';
import { ErrorResponseSchema, ApiResponseSchema } from './types';
import { z } from 'zod';
export function generateOpenApiSpec() {
const registry = new OpenAPIRegistry();
// Register all schemas
registry.register('User', UserResponseSchema);
registry.register('CreateUserRequest', CreateUserRequestSchema);
registry.register('UpdateUserRequest', UpdateUserRequestSchema);
registry.register('ErrorResponse', ErrorResponseSchema);
// Define paths
registry.registerPath({
method: 'post',
path: '/users',
summary: 'Create a new user',
tags: ['Users'],
request: {
body: {
content: {
'application/json': {
schema: CreateUserRequestSchema
}
}
}
},
responses: {
200: {
description: 'User created successfully',
content: {
'application/json': {
schema: ApiResponseSchema(UserResponseSchema)
}
}
},
400: {
description: 'Validation error',
content: {
'application/json': {
schema: ApiResponseSchema(z.never()).extend({
error: ErrorResponseSchema
})
}
}
}
}
});
registry.registerPath({
method: 'get',
path: '/users',
summary: 'List users',
tags: ['Users'],
request: {
query: ListUsersQuerySchema
},
responses: {
200: {
description: 'Users retrieved successfully',
content: {
'application/json': {
schema: ApiResponseSchema(UsersListResponseSchema)
}
}
}
}
});
registry.registerPath({
method: 'get',
path: '/users/{userId}',
summary: 'Get user by ID',
tags: ['Users'],
request: {
params: z.object({
userId: UserIdSchema
})
},
responses: {
200: {
description: 'User retrieved successfully',
content: {
'application/json': {
schema: ApiResponseSchema(UserResponseSchema)
}
}
},
404: {
description: 'User not found',
content: {
'application/json': {
schema: ApiResponseSchema(z.never()).extend({
error: ErrorResponseSchema
})
}
}
}
}
});
// Generate OpenAPI document
const generator = new OpenApiGeneratorV3(registry.definitions);
return generator.generateDocument({
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'User Management API',
description: 'Type-safe serverless API with automatic OpenAPI generation'
},
servers: [
{
url: 'https://api.example.com',
description: 'Production'
}
]
});
}
// Build script to generate spec file
if (require.main === module) {
const fs = require('fs');
const path = require('path');
const spec = generateOpenApiSpec();
const outputPath = path.join(__dirname, '../../openapi.json');
fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2));
console.log(`OpenAPI spec generated at: ${outputPath}`);
}
CDK Altyapısı
Şimdi her şeyi AWS CDK ile birbirine bağlayalım:
// lib/stacks/api-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as path from 'path';
import { generateOpenApiSpec } from '../api/openapi';
export class ApiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// DynamoDB table
const usersTable = new dynamodb.Table(this, 'UsersTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true
});
// Common Lambda environment
const environment = {
USERS_TABLE: usersTable.tableName,
API_VERSION: '1.0.0',
NODE_OPTIONS: '--enable-source-maps'
};
// Lambda functions
const createUserFn = new NodejsFunction(this, 'CreateUserFunction', {
entry: path.join(__dirname, '../../lambda/handlers/createUser.ts'),
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
environment,
bundling: {
minify: true,
sourceMap: true,
sourcesContent: false,
target: 'es2022',
format: 'esm'
}
});
const listUsersFn = new NodejsFunction(this, 'ListUsersFunction', {
entry: path.join(__dirname, '../../lambda/handlers/listUsers.ts'),
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
environment
});
const getUserFn = new NodejsFunction(this, 'GetUserFunction', {
entry: path.join(__dirname, '../../lambda/handlers/getUser.ts'),
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
environment
});
// Grant permissions
usersTable.grantReadWriteData(createUserFn);
usersTable.grantReadData(listUsersFn);
usersTable.grantReadData(getUserFn);
// API Gateway
const api = new apigateway.RestApi(this, 'UserApi', {
restApiName: 'User Management API',
description: 'Type-safe API with Zod and OpenAPI',
deployOptions: {
stageName: 'prod',
tracingEnabled: true,
loggingLevel: apigateway.MethodLoggingLevel.INFO,
dataTraceEnabled: true
},
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS
}
});
// API resources
const users = api.root.addResource('users');
const user = users.addResource('{userId}');
// Wire up endpoints
users.addMethod('POST', new apigateway.LambdaIntegration(createUserFn));
users.addMethod('GET', new apigateway.LambdaIntegration(listUsersFn));
user.addMethod('GET', new apigateway.LambdaIntegration(getUserFn));
// Generate and export OpenAPI spec
const openApiSpec = generateOpenApiSpec();
new cdk.CfnOutput(this, 'OpenApiSpec', {
value: JSON.stringify(openApiSpec),
description: 'OpenAPI specification for the API'
});
// Export API URL
new cdk.CfnOutput(this, 'ApiUrl', {
value: api.url,
description: 'API Gateway URL'
});
}
}
İleri Düzey Kalıplar
Middleware Sistemi
Ortak ihtiyaçlar için yeniden kullanılabilir middleware oluşturun:
// lib/api/middleware.ts
export interface MiddlewareContext<T> {
event: T;
context: Context;
next: () => Promise<any>;
}
export type Middleware<T = any> = (
ctx: MiddlewareContext<T>
) => Promise<void>;
export function compose<T>(...middlewares: Middleware<T>[]): Middleware<T> {
return async (ctx: MiddlewareContext<T>) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) throw new Error('next() called multiple times');
index = i;
const middleware = middlewares[i];
if (!middleware) return;
await middleware({
...ctx,
next: () => dispatch(i + 1)
});
}
await dispatch(0);
};
}
// Auth middleware
export const authMiddleware: Middleware = async (ctx) => {
const token = ctx.event.headers?.authorization?.replace('Bearer ', '');
if (!token) {
throw new Error('Unauthorized');
}
// Verify token (example with Cognito)
const payload = await verifyToken(token);
ctx.event.user = payload;
await ctx.next();
};
// Rate limiting middleware
export const rateLimitMiddleware: Middleware = async (ctx) => {
const ip = ctx.event.requestContext.identity.sourceIp;
const key = `rate-limit:${ip}`;
// Check rate limit (Redis/DynamoDB)
const count = await incrementCounter(key);
if (count > 100) {
throw new Error('Rate limit exceeded');
}
await ctx.next();
};
Şema Kompozisyonu
Karmaşık şemaları yeniden kullanılabilir parçalardan oluşturun:
// lib/api/schemas/common.ts
import { z } from 'zod';
// Pagination mixin
export const PaginationSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('asc')
});
// Timestamps mixin
export const TimestampsSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
deletedAt: z.string().datetime().nullable().optional()
});
// Audit fields mixin
export const AuditFieldsSchema = z.object({
createdBy: z.string().uuid(),
updatedBy: z.string().uuid(),
version: z.number().int().min(0)
});
// Resource schema factory
export function createResourceSchema<T extends z.ZodRawShape>(
name: string,
shape: T
) {
return z.object({
id: z.string().uuid(),
type: z.literal(name.toLowerCase()),
attributes: z.object(shape),
metadata: TimestampsSchema.merge(AuditFieldsSchema)
}).openapi(name);
}
// Usage
export const ProductResourceSchema = createResourceSchema('Product', {
name: z.string().min(1).max(255),
description: z.string().optional(),
price: z.number().positive(),
currency: z.enum(['USD', 'EUR', 'GBP']),
inventory: z.object({
quantity: z.number().int().min(0),
reserved: z.number().int().min(0).default(0),
available: z.number().int().min(0)
})
});
Performans Optimizasyonu
Lazy loading ile cold start’ları optimize edin:
// lib/api/lazy.ts
export class LazyContainer<T> {
private instance?: T;
private initializer: () => T;
constructor(initializer: () => T) {
this.initializer = initializer;
}
get(): T {
if (!this.instance) {
this.instance = this.initializer();
}
return this.instance;
}
}
// Usage in Lambda
const dynamoClient = new LazyContainer(
() => DynamoDBDocumentClient.from(new DynamoDBClient({}))
);
export const handler = createHandler({
// ... config
}, async ({ body }) => {
const client = dynamoClient.get();
// Use client
});
Test Stratejileri
Şemaların Birim Testi
// tests/schemas/user.test.ts
import { CreateUserRequestSchema } from '../../lib/api/schemas/user';
describe('CreateUserRequestSchema', () => {
it('validates correct input', () => {
const result = CreateUserRequestSchema.safeParse({
email: '[email protected]',
username: 'test_user',
fullName: 'Test User',
age: 25
});
expect(result.success).toBe(true);
});
it('rejects invalid email', () => {
const result = CreateUserRequestSchema.safeParse({
email: 'not-an-email',
username: 'test_user',
fullName: 'Test User'
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['email']);
});
it('enforces username constraints', () => {
const result = CreateUserRequestSchema.safeParse({
email: '[email protected]',
username: 'a', // Too short
fullName: 'Test User'
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].message).toContain('at least 3');
});
});
Entegrasyon Testi
// tests/integration/createUser.test.ts
import { handler } from '../../lambda/handlers/createUser';
import { APIGatewayProxyEventV2, Context } from 'aws-lambda';
describe('Create User Handler', () => {
it('creates user with valid input', async () => {
const event: Partial<APIGatewayProxyEventV2> = {
body: JSON.stringify({
email: '[email protected]',
username: 'test_user',
fullName: 'Test User'
}),
headers: {
'content-type': 'application/json'
}
};
const context: Partial<Context> = {
requestId: '123',
functionName: 'createUser'
};
const response = await handler(
event as APIGatewayProxyEventV2,
context as Context
);
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body!);
expect(body.success).toBe(true);
expect(body.data.email).toBe('[email protected]');
expect(body.data.id).toBeDefined();
});
it('returns validation error for invalid input', async () => {
const event: Partial<APIGatewayProxyEventV2> = {
body: JSON.stringify({
email: 'invalid-email',
username: 'test_user'
})
};
const response = await handler(
event as APIGatewayProxyEventV2,
{} as Context
);
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body!);
expect(body.success).toBe(false);
expect(body.error.details).toBeDefined();
});
});
Deployment ve CI/CD
GitHub Actions Workflow’u
# .github/workflows/deploy.yml
name: Deploy API
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Type check
run: npm run typecheck
- name: Generate OpenAPI spec
run: npm run generate:openapi
- name: Upload OpenAPI spec
uses: actions/upload-artifact@v3
with:
name: openapi-spec
path: openapi.json
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Deploy CDK
run: npx cdk deploy --require-approval never
İzleme ve Gözlemlenebilirlik
Yapılandırılmış logging ve tracing ekleyin:
// lib/api/observability.ts
import { Tracer } from '@aws-lambda-powertools/tracer';
import { Logger } from '@aws-lambda-powertools/logger';
import { Metrics } from '@aws-lambda-powertools/metrics';
const tracer = new Tracer();
const logger = new Logger();
const metrics = new Metrics();
export function createObservableHandler<T extends HandlerConfig<any, any, any, any>>(
config: T,
handler: HandlerFunction<T>
) {
const baseHandler = createHandler(config, handler);
return tracer.captureLambdaHandler(
logger.injectLambdaContext(
metrics.logMetrics(baseHandler)
)
);
}
// Usage
export const handler = createObservableHandler({
body: CreateUserRequestSchema,
response: UserResponseSchema
}, async ({ body }, context) => {
logger.info('Creating user', { email: body.email });
// Add custom metric
metrics.addMetric('UserCreated', 'Count', 1);
// Add trace annotation
tracer.putAnnotation('userEmail', body.email);
// Business logic...
});
En İyi Uygulamalar
1. Şema Versiyonlama
// lib/api/schemas/v1/user.ts
export const UserSchemaV1 = z.object({
// V1 schema
});
// lib/api/schemas/v2/user.ts
export const UserSchemaV2 = UserSchemaV1.extend({
// V2 additions
preferences: PreferencesSchema
});
// Handler with version support
export const handler = createHandler({
headers: z.object({
'api-version': z.enum(['v1', 'v2']).default('v2')
}),
body: z.union([UserSchemaV1, UserSchemaV2]),
response: z.union([UserResponseV1, UserResponseV2])
}, async ({ headers, body }) => {
if (headers['api-version'] === 'v1') {
return handleV1(body);
}
return handleV2(body);
});
2. Hata Kurtarma
export const resilientHandler = createHandler({
// ... config
}, async ({ body }, context) => {
// Circuit breaker pattern
const breaker = new CircuitBreaker(dynamoClient.send, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000
});
try {
return await breaker.fire(new PutCommand({
TableName: TABLE_NAME,
Item: user
}));
} catch (error) {
// Fallback to SQS
await sqsClient.send(new SendMessageCommand({
QueueUrl: DLQ_URL,
MessageBody: JSON.stringify({ user, error: error.message })
}));
throw new Error('Service temporarily unavailable');
}
});
3. Güvenlik Başlıkları
export function addSecurityHeaders(response: APIGatewayProxyResultV2): APIGatewayProxyResultV2 {
return {
...response,
headers: {
...response.headers,
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'"
}
};
}
4. Şema Evrimi
Şema versiyonlama sözleşmenin kendisidir. Asıl ayrım, geriye dönük uyumlu eklemeler ile kırıcı değişiklikler arasındadır. Opsiyonel alanlar şemayı mevcut istemcileri etkilemeden genişletir. Yeni bir zorunlu alan ise kırıcı bir değişikliktir ve yeni bir versiyon gerektirir.
// Adding fields - backward compatible
const CreateUserRequestSchemaV2 = CreateUserRequestSchema.extend({
// New optional fields are safe
phoneNumber: z.string().optional(),
preferences: z.object({
newsletter: z.boolean(),
notifications: z.boolean(),
}).optional(),
});
// Versioning for breaking changes
const CreateUserRequestSchemaV3 = z.object({
// Adding a required field is a breaking change
email: z.string().email(),
username: z.string().min(3),
fullName: z.string().min(1), // New required field - V3
});
5. Hata Çevirisi
Bağımsız bir çevirici, hatadan HTTP durum koduna giden eşlemeyi tek bir yerde tutar. Doğrulama hataları 400, koşullu yazma hataları 409, bulunamayan kaynaklar 404 olur. Beklenmeyen her durum ise iç ayrıntıları sızdırmadan 500 koduna düşer.
// lib/api/error-handler.ts
export const handleApiError = (error: unknown): APIGatewayProxyResultV2 => {
console.error('API Error:', error);
// Zod validation errors
if (error instanceof z.ZodError) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: 'Validation failed',
details: error.issues.map(e => ({
field: e.path.join('.'),
message: e.message,
received: e.input,
})),
}),
};
}
// AWS errors
if (error && typeof error === 'object' && 'name' in error) {
switch (error.name) {
case 'ConditionalCheckFailedException':
return {
statusCode: 409,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Resource already exists' }),
};
case 'ResourceNotFoundException':
return {
statusCode: 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Resource not found' }),
};
}
}
// Generic server error - log the stack trace but never return it to the client
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Internal server error' }),
};
};
6. Doğrulama Belleğe Alma
Şemaları modül kapsamında tanımlamak, her çağrıda onları sıcak yoldan uzak tutar. Küçük, sınırlı bir önbellek, aynı veri için tekrarlanan doğrulamayı kısa devre yaptırır. Bu, aynı girdinin istek başına birden çok kez ayrıştırıldığı durumlarda işe yarar.
// Define schemas at global scope - better for cold start performance
const SCHEMAS = {
createUser: CreateUserRequestSchema,
user: UserResponseSchema,
} as const;
// Validation sonuçlarını her şema için ayrı cache'le; şema kimliğine göre
// anahtarlamak, aynı payload'u kabul eden iki farklı şemanın sonucu paylaşmasını önler
const MAX_CACHE_ENTRIES = 500;
const validationCache = new WeakMap<z.ZodSchema, Map<string, unknown>>();
export const validateWithCache = <T>(schema: z.ZodSchema<T>, data: unknown): T => {
let schemaCache = validationCache.get(schema);
if (!schemaCache) {
schemaCache = new Map();
validationCache.set(schema, schemaCache);
}
const cacheKey = JSON.stringify(data);
if (schemaCache.has(cacheKey)) {
return schemaCache.get(cacheKey) as T;
}
const result = schema.parse(data);
// Cache'i sınırla ki sıcak container benzersiz payload'larda bellek sızdırmasın.
// Map ekleme sırasını korur, bu yüzden en eski anahtar önce çıkarılır.
if (schemaCache.size >= MAX_CACHE_ENTRIES) {
const oldestKey = schemaCache.keys().next().value as string | undefined;
if (oldestKey !== undefined) {
schemaCache.delete(oldestKey);
}
}
schemaCache.set(cacheKey, result);
return result;
};
Sonuç
Zod’un çalışma zamanı doğrulamasını OpenAPI üretimiyle birleştirerek, bu yaklaşım şu özelliklere sahip type-safe bir serverless API oluşturur:
- Tipler, doğrulama ve dokümantasyon arasındaki manuel senkronizasyonu ortadan kaldırır
- Tam TypeScript entegrasyonuyla hataları derleme zamanında yakalar
- Detaylı hata mesajlarıyla çalışma zamanında doğrulama yapar
- Doğru dokümantasyonu otomatik üretir
- AWS Lambda ve CDK ile verimli şekilde ölçeklenir
Bu yaklaşım, API geliştirmeyi hataya açık manuel koordinasyondan akıcı, otomatik bir sürece dönüştürür. Şemalarınız tek doğruluk kaynağı haline gelir ve serverless stack’inizin her katmanında tutarlılık sağlar.
Sonraki Adımlar
- Authentication ekleyin: AWS Cognito veya özel JWT doğrulaması ile
- Caching uygulayın: API Gateway caching veya ElastiCache ile
- WebSocket desteği ekleyin: gerçek zamanlı özellikler için
- AWS X-Ray ile entegre edin: dağıtık tracing için
- API versiyonlama kurun: stage variable’lar ile
- Contract testing ekleyin: Pact veya benzeri araçlar ile
Bu temel, modern API geliştirmenin karmaşıklığını ele alırken serverless’i çekici kılan basitliği korur.
Kaynaklar
- Zod Belgeleri - TypeScript öncelikli şema doğrulama kütüphanesinin API referansı ve kullanım kılavuzlarını içeren resmi belgeleme.
- OpenAPI Specification v3.0.3 - RESTful API’ler için standart arayüzü tanımlayan resmi OpenAPI Belirtimi.
- Amazon API Gateway REST API Belgeleri - Amazon API Gateway’de REST API oluşturma ve yapılandırmaya yönelik resmi kılavuz.
- AWS Lambda En İyi Uygulamaları - Lambda fonksiyon geliştirme, performans ve hata yönetimi için resmi AWS rehberi.
- AWS CDK v2 Geliştirici Kılavuzu - AWS Cloud Development Kit construct’ları ve deployment kalıplarına yönelik referans belgeleme.
- Swagger/OpenAPI Belgeleme Kılavuzu - OpenAPI 3.0 spesifikasyonu ve API belgeleme yazımı için pratik kılavuz.
İlgili yazılar
AWS CDK, DynamoDB ve Lambda ile production-grade link kısaltıcı kurulumu. Gerçek mimari kararlar, ilk kurulum ve büyük ölçekte URL kısaltıcıları inşa etmenin dersleri.
Yönlendirme motoru, analytics toplama ve API Gateway konfigürasyonu. Günlük milyonlarca yönlendirmeyi işlemenin gerçek performans optimizasyonları ve debugging stratejileri.
Amazon Cognito'nun gelişmiş özellikleri üzerine kapsamlı teknik kılavuz: özel authentication akışları, federation pattern'leri, multi-tenancy mimarileri, migration stratejileri ve production-grade güvenlik implementasyonu.
AWS Lambda, API Gateway, DynamoDB ve Step Functions için hızlı geri bildirim ve production güvenilirliği sağlayan kapsamlı bir test stratejisi oluşturmayı öğrenin.
AWS CDK, Lambda ve GitHub Actions kullanarak otomatik preview ortamları oluşturmayı öğrenin - sorunsuz PR test ve inceleme süreçleri için