Zod ve OpenAPI ile Type-Safe AWS Lambda API'leri
'Basit' bir API değişikliği nasıl 80 bin dolara mal oldu, dokümantasyon drift'i neden işletmeleri öldürür ve Zod schema'larından otomatik OpenAPI spec'i üreten production-tested sistem.
Ocak 2023. En büyük kurumsal müşterimizin entegrasyonu bir gecede bozuldu. Suçlu? Kullanıcı API'mize "zararsız" bir opsiyonel alan eklemiş, ama OpenAPI spec'ini güncellememiştim. Onların kod generation pipeline'ı eski şemayı bekleyen TypeScript arayüzleri üretti. Sonuç: 847 başarısız kullanıcı kaydı, 80.000$ kayıp gelir ve çok kızgın bir CTO.
Bu olay bana API dokümantasyonunun sadece nice-to-have olmadığını öğretti - kritik iş altyapısıdır. Zod şemalarından otomatik olarak OpenAPI spec'leri üreten sistemi yeniden oluşturduktan sonra, tek bir entegrasyon hatası olmadan 100'den fazla API değişikliğini yönettik.
80K$ Ders: Dokümantasyon Kayması Neden İşleri Öldürür#
Olayımızdan önce, klasik serverless API geliştirme kabusumuz vardı - dört farklı doğruluk kaynağı:
// 1. TypeScript arayüzleri (geliştiricilerin API'nin ne yaptığını düşündüğü)
interface CreateUserRequest {
email: string;
username: string;
age?: number;
// Bu alanı ekledim...
company?: string;
}
// 2. OpenAPI spec (müşterilerin kod ürettiği)
const openApiSpec = {
paths: {
'/users': {
post: {
requestBody: {
// Ama bunu güncellemeyi unuttum
schema: {
type: 'object',
properties: {
email: { type: 'string' },
username: { type: 'string' },
age: { type: 'number' }
// Eksik: company alanı
}
}
}
}
}
}
};
// 3. Lambda validation (çalışma zamanında gerçekte ne doğrulanıyor)
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const body = JSON.parse(event.body || '{}');
// Manuel validation - sync dışı kalmaya meyilli
if (!body.email || !body.username) {
return { statusCode: 400, body: 'Missing required fields' };
}
// Yeni company alanını kullanıyorum ama dokümante etmemiş
const user = await createUser({
email: body.email,
username: body.username,
age: body.age,
company: body.company, // Bu müşteri entegrasyonunu bozdu
});
return { statusCode: 201, body: JSON.stringify(user) };
};
// 4. Test cases (umutla güncel olanlar)
describe('POST /users', () => {
it('should create user', async () => {
const response = await request(app)
.post('/users')
.send({
email: 'test@example.com',
username: 'testuser',
age: 25
// Company alanını test etmeyi unuttum
});
expect(response.status).toBe(201);
});
});
Bu dört kaynağın sync'te kalması imkansızdı. Her yeni özellik release'i Rus ruleti gibiydi - hangi müşteri entegrasyonunun bozulacağını bilemezdik.
Çözüm: Zod-First API Development#
Zod şemalarını tek doğruluk kaynağı olarak kullanarak, her şeyi onlardan generate etmeye başladık:
// schemas/user.ts - TEK doğruluk kaynağı
import { z } from 'zod';
export const CreateUserRequestSchema = z.object({
email: z.string().email('Geçerli email gerekli'),
username: z.string().min(3, 'Username en az 3 karakter olmalı').max(20),
age: z.number().int().min(13).max(120).optional(),
company: z.string().min(2).max(100).optional(), // Yeni alan - bir yerde tanımla, her yerde güncel
});
export const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
username: z.string(),
age: z.number().optional(),
company: z.string().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export type UserResponse = z.infer<typeof UserResponseSchema>;
Production CDK Stack'i#
İşte gerçek CDK kodumuzu içeren stack (hiçbir güzelleştirme yok):
// lib/api-stack.ts
import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RestApi, LambdaIntegration, RequestValidator, Model, JsonSchemaType } from 'aws-cdk-lib/aws-apigateway';
import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
export class TypeSafeApiStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// DynamoDB table
const usersTable = new Table(this, 'UsersTable', {
partitionKey: { name: 'id', type: AttributeType.STRING },
billingMode: BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
});
// Lambda handlers - her endpoint için ayrı fonksiyon (monolith degil)
const createUserHandler = new NodejsFunction(this, 'CreateUserHandler', {
entry: 'src/handlers/users/create.ts',
runtime: Runtime.NODEJS_20_X,
timeout: Duration.seconds(10),
memorySize: 512,
environment: {
USERS_TABLE_NAME: usersTable.tableName,
NODE_ENV: 'production',
},
bundling: {
externalModules: ['@aws-sdk/*'],
minify: true,
},
});
const getUserHandler = new NodejsFunction(this, 'GetUserHandler', {
entry: 'src/handlers/users/get.ts',
runtime: Runtime.NODEJS_20_X,
timeout: Duration.seconds(5),
memorySize: 256, // Read operation için daha az memory
environment: {
USERS_TABLE_NAME: usersTable.tableName,
},
bundling: {
externalModules: ['@aws-sdk/*'],
minify: true,
},
});
const listUsersHandler = new NodejsFunction(this, 'ListUsersHandler', {
entry: 'src/handlers/users/list.ts',
runtime: Runtime.NODEJS_20_X,
timeout: Duration.seconds(15),
memorySize: 1024, // List operation potansiyel olarak daha fazla data
environment: {
USERS_TABLE_NAME: usersTable.tableName,
},
bundling: {
externalModules: ['@aws-sdk/*'],
minify: true,
},
});
// DynamoDB izinleri
usersTable.grantReadWriteData(createUserHandler);
usersTable.grantReadData(getUserHandler);
usersTable.grantReadData(listUsersHandler);
// API Gateway
const api = new RestApi(this, 'TypeSafeApi', {
restApiName: 'TypeSafe User API',
description: 'Zod-validated API with auto-generated OpenAPI',
defaultCorsPreflightOptions: {
allowOrigins: ['*'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
},
});
// Request validation models - Zod şemalarından generate edildi
const createUserModel = new Model(this, 'CreateUserModel', {
restApi: api,
modelName: 'CreateUserRequest',
contentType: 'application/json',
schema: {
type: JsonSchemaType.OBJECT,
required: ['email', 'username'],
properties: {
email: {
type: JsonSchemaType.STRING,
format: 'email',
},
username: {
type: JsonSchemaType.STRING,
minLength: 3,
maxLength: 20,
},
age: {
type: JsonSchemaType.INTEGER,
minimum: 13,
maximum: 120,
},
company: {
type: JsonSchemaType.STRING,
minLength: 2,
maxLength: 100,
},
},
},
});
// Request validator
const requestValidator = new RequestValidator(this, 'RequestValidator', {
restApi: api,
validateRequestBody: true,
validateRequestParameters: true,
});
// Users resource
const usersResource = api.root.addResource('users');
// POST /users
usersResource.addMethod('POST', new LambdaIntegration(createUserHandler), {
requestValidator,
requestModels: {
'application/json': createUserModel,
},
});
// GET /users
usersResource.addMethod('GET', new LambdaIntegration(listUsersHandler));
// GET /users/{id}
const userResource = usersResource.addResource('{id}');
userResource.addMethod('GET', new LambdaIntegration(getUserHandler), {
requestValidator,
requestParameters: {
'method.request.path.id': true,
},
});
}
}
Type-Safe Lambda Handlers#
// src/handlers/users/create.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
import { CreateUserRequestSchema, UserResponseSchema } from '../../schemas/user';
import { randomUUID } from 'crypto';
const dynamoClient = new DynamoDBClient({});
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
console.log('Create user request:', {
body: event.body,
headers: event.headers,
});
// 1. Parse ve validate request body - Zod otomatik type safety sağlıyor
const body = JSON.parse(event.body || '{}');
const validatedData = CreateUserRequestSchema.parse(body);
// 2. User object oluştur
const user = {
id: randomUUID(),
...validatedData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// 3. DynamoDB'ye kaydet
await dynamoClient.send(new PutItemCommand({
TableName: process.env.USERS_TABLE_NAME!,
Item: marshall(user),
ConditionExpression: 'attribute_not_exists(id)', // Duplicate prevention
}));
// 4. Response'u validate et - bu bile type-safe
const validatedResponse = UserResponseSchema.parse(user);
return {
statusCode: 201,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify(validatedResponse),
};
} catch (error) {
console.error('Create user error:', error);
// Zod validation hatalarını özel olarak işle
if (error instanceof z.ZodError) {
return {
statusCode: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
})),
}),
};
}
// DynamoDB conditional check failures
if (error.name === 'ConditionalCheckFailedException') {
return {
statusCode: 409,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
error: 'User already exists',
}),
};
}
// Generic server error
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
error: 'Internal server error',
}),
};
}
};
// src/handlers/users/get.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
import { UserResponseSchema } from '../../schemas/user';
import { z } from 'zod';
const dynamoClient = new DynamoDBClient({});
// Path parameter validation
const GetUserParamsSchema = z.object({
id: z.string().uuid('Valid UUID required'),
});
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
// Path parameter'ları validate et
const params = GetUserParamsSchema.parse({
id: event.pathParameters?.id,
});
// DynamoDB'den user'ı getir
const result = await dynamoClient.send(new GetItemCommand({
TableName: process.env.USERS_TABLE_NAME!,
Key: marshall({ id: params.id }),
}));
if (!result.Item) {
return {
statusCode: 404,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
error: 'User not found',
}),
};
}
// Response'u validate et
const user = unmarshall(result.Item);
const validatedUser = UserResponseSchema.parse(user);
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify(validatedUser),
};
} catch (error) {
console.error('Get user error:', error);
if (error instanceof z.ZodError) {
return {
statusCode: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
error: 'Invalid parameters',
details: error.errors,
}),
};
}
return {
statusCode: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
body: JSON.stringify({
error: 'Internal server error',
}),
};
}
};
OpenAPI Generation#
En güzel kısım - Zod şemalarından otomatik OpenAPI spec generation:
// scripts/generate-openapi.ts
import { CreateUserRequestSchema, UserResponseSchema } from '../src/schemas/user';
import { zodToJsonSchema } from 'zod-to-json-schema';
import fs from 'fs';
const generateOpenApiSpec = () => {
const spec = {
openapi: '3.0.0',
info: {
title: 'TypeSafe User API',
version: '1.0.0',
description: 'Zod-validated API with automatic OpenAPI generation',
},
servers: [
{
url: process.env.API_URL || 'https://api.example.com',
description: 'Production server',
},
],
paths: {
'/users': {
post: {
summary: 'Create a new user',
requestBody: {
required: true,
content: {
'application/json': {
schema: zodToJsonSchema(CreateUserRequestSchema, 'CreateUserRequest'),
},
},
},
responses: {
'201': {
description: 'User created successfully',
content: {
'application/json': {
schema: zodToJsonSchema(UserResponseSchema, 'UserResponse'),
},
},
},
'400': {
description: 'Validation error',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: { type: 'string' },
details: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
message: { type: 'string' },
},
},
},
},
},
},
},
},
},
},
get: {
summary: 'List all users',
responses: {
'200': {
description: 'List of users',
content: {
'application/json': {
schema: {
type: 'array',
items: zodToJsonSchema(UserResponseSchema, 'UserResponse'),
},
},
},
},
},
},
},
'/users/{id}': {
get: {
summary: 'Get user by ID',
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: {
type: 'string',
format: 'uuid',
},
},
],
responses: {
'200': {
description: 'User details',
content: {
'application/json': {
schema: zodToJsonSchema(UserResponseSchema, 'UserResponse'),
},
},
},
'404': {
description: 'User not found',
},
},
},
},
},
components: {
schemas: {
CreateUserRequest: zodToJsonSchema(CreateUserRequestSchema, 'CreateUserRequest'),
UserResponse: zodToJsonSchema(UserResponseSchema, 'UserResponse'),
},
},
};
// OpenAPI spec'ini dosyaya yaz
fs.writeFileSync('openapi.json', JSON.stringify(spec, null, 2));
console.log('✅ OpenAPI spec generated: openapi.json');
return spec;
};
// CI/CD pipeline'da çalıştır
if (require.main === module) {
generateOpenApiSpec();
}
Testing Strategy#
Type safety testleri de kapsar:
// tests/handlers/users.test.ts
import { handler as createUserHandler } from '../../src/handlers/users/create';
import { handler as getUserHandler } from '../../src/handlers/users/get';
import { CreateUserRequestSchema } from '../../src/schemas/user';
describe('Users API', () => {
describe('POST /users', () => {
it('should create user with valid data', async () => {
const validRequest = {
email: 'test@example.com',
username: 'testuser',
age: 25,
company: 'Test Corp',
};
// Bu static olarak type-safe olduğundan emin ol
const _typeCheck: typeof validRequest = CreateUserRequestSchema.parse(validRequest);
const response = await createUserHandler({
body: JSON.stringify(validRequest),
httpMethod: 'POST',
path: '/users',
headers: {},
pathParameters: null,
queryStringParameters: null,
requestContext: {} as any,
resource: '',
stageVariables: null,
isBase64Encoded: false,
});
expect(response.statusCode).toBe(201);
const responseBody = JSON.parse(response.body);
expect(responseBody).toHaveProperty('id');
expect(responseBody.email).toBe(validRequest.email);
expect(responseBody.username).toBe(validRequest.username);
});
it('should reject invalid email', async () => {
const invalidRequest = {
email: 'not-an-email', // Invalid!
username: 'testuser',
};
const response = await createUserHandler({
body: JSON.stringify(invalidRequest),
httpMethod: 'POST',
path: '/users',
// ... diğer event properties
} as any);
expect(response.statusCode).toBe(400);
const responseBody = JSON.parse(response.body);
expect(responseBody.error).toBe('Validation failed');
expect(responseBody.details).toContainEqual(
expect.objectContaining({
field: 'email',
message: expect.stringContaining('email'),
})
);
});
});
});
Production'da Öğrenilen Dersler#
1. Schema Evolution#
// Yeni alanlar ekleme - backward compatible
const CreateUserRequestSchemaV2 = CreateUserRequestSchema.extend({
// Yeni optional alanlar sorun değil
phoneNumber: z.string().optional(),
preferences: z.object({
newsletter: z.boolean(),
notifications: z.boolean(),
}).optional(),
});
// Breaking changes için versioning
const CreateUserRequestSchemaV3 = z.object({
// Required alan eklemek breaking change
email: z.string().email(),
username: z.string().min(3),
fullName: z.string().min(1), // Yeni required field - V3
});
2. Error Handling Best Practices#
// src/utils/error-handler.ts
export const handleApiError = (error: unknown): APIGatewayProxyResult => {
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.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
received: err.received,
})),
}),
};
}
// 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 - production'da stack trace'i logla ama müşteriye gösterme
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Internal server error' }),
};
};
3. Performance Optimization#
// Schema'ları global scope'ta tanımla - cold start performansı için
const SCHEMAS = {
createUser: CreateUserRequestSchema,
user: UserResponseSchema,
} as const;
// Runtime'da validation cache'le
const validationCache = new Map<string, any>();
export const validateWithCache = <T>(schema: z.ZodSchema<T>, data: unknown): T => {
const cacheKey = JSON.stringify(data);
if (validationCache.has(cacheKey)) {
return validationCache.get(cacheKey);
}
const result = schema.parse(data);
validationCache.set(cacheKey, result);
return result;
};
Sonuç#
Bu Zod + OpenAPI + Lambda yaklaşımını uyguladıktan sonra:
- ✅ Hiç API dokümantasyon drift'i yaşamadık
- ✅ 100+ API değişikliği sıfır entegrasyon hatası ile geçti
- ✅ Client code generation 100% güvenilir hale geldi
- ✅ Validation errors anlamlı ve actionable
- ✅ Development velocity arttı (type safety sayesinde)
Kritik Öğrenimler:
- Tek doğruluk kaynağı: Zod şemaları everything-else source of truth
- Automatic generation: Manual sync çalışmıyor, otomatik olmalı
- Schema evolution strategy: Breaking vs non-breaking changes planla
- Comprehensive testing: Schema changes test edilmeli
- Error handling: Validation errors actionable olmalı
Bu sistem bizi ayda yaklaşık 20 saat manuel dokümantasyon güncellemesinden kurtardı ve müşteri entegrasyonlarında sıfır hata verdi. Zod'un öğrenme eğrisi var ama yatırım değer.
Deployment and CI/CD#
Çeviri eklenecek.
Monitoring and Observability#
Çeviri eklenecek.
Best Practices#
Çeviri eklenecek.
Conclusion#
Çeviri eklenecek.
Next Steps#
Çeviri eklenecek.
Yorumlar (0)
Sohbete katıl
Düşüncelerini paylaşmak ve toplulukla etkileşim kurmak için giriş yap
Henüz yorum yok
Bu yazı hakkında ilk düşüncelerini paylaşan sen ol!
Yorumlar (0)
Sohbete katıl
Düşüncelerini paylaşmak ve toplulukla etkileşim kurmak için giriş yap
Henüz yorum yok
Bu yazı hakkında ilk düşüncelerini paylaşan sen ol!