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ğı:

TypeScript
// 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:

TypeScript
// 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):

TypeScript
// 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#

TypeScript
// 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',
      }),
    };
  }
};
TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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#

TypeScript
// 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#

TypeScript
// 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#

TypeScript
// 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:

  1. Tek doğruluk kaynağı: Zod şemaları everything-else source of truth
  2. Automatic generation: Manual sync çalışmıyor, otomatik olmalı
  3. Schema evolution strategy: Breaking vs non-breaking changes planla
  4. Comprehensive testing: Schema changes test edilmeli
  5. 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.

Loading...

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!

Related Posts