Archivo del repositorio, tal como está en la rama main

gastos-stack.ts

infra/gastos-stack.ts

// La infraestructura del backend, escrita con AWS CDK en TypeScript.// `npm run synth` la convierte en una plantilla de CloudFormation y// `npm run deploy` la crea en tu proyecto de AWS. import { fileURLToPath } from 'node:url'import { CfnOutput, CfnParameter, Duration, RemovalPolicy, Stack, Tags, type StackProps } from 'aws-cdk-lib'import { LambdaIntegration, Period, RestApi } from 'aws-cdk-lib/aws-apigateway'import { AttributeType, BillingMode, CfnTable, Table } from 'aws-cdk-lib/aws-dynamodb'import { PolicyStatement } from 'aws-cdk-lib/aws-iam'import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'import type { Construct } from 'constructs' const ETAPA = 'v1'const ruta = (relativa: string) => fileURLToPath(new URL(relativa, import.meta.url)) export class GastosStack extends Stack {  constructor(scope: Construct, id: string, props?: StackProps) {    super(scope, id, props)     Tags.of(this).add('workshop', 'gastos-hormiga')     // Súbele uno para rotar la API key (lo hace npm run rotar-key).    const versionKey = new CfnParameter(this, 'VersionKey', {      type: 'String',      default: '1',      description: 'Súbele uno para rotar la API key (lo hace npm run rotar-key)',    })     // La tabla donde viven tus gastos. No se toca: si cambia, se borran tus gastos.    const tabla = new Table(this, 'GastosTable', {      tableName: `${this.stackName}-gastos`,      partitionKey: { name: 'pk', type: AttributeType.STRING },      sortKey: { name: 'sk', type: AttributeType.STRING },      billingMode: BillingMode.PAY_PER_REQUEST,      // CDK conserva las tablas por default. Aquí queremos que npm run limpiar la borre.      removalPolicy: RemovalPolicy.DESTROY,    })    // CDK arma el logical ID con un hash. Lo fijamos para que sea GastosTable en todas las ramas.    ;(tabla.node.defaultChild as CfnTable).overrideLogicalId('GastosTable')     // Cada Lambda tiene su log group con el mismo nombre: dura 14 días y se borra con el stack.    const crearFuncion = (id: string, nombre: string, handler: string) =>      new NodejsFunction(this, id, {        functionName: nombre,        entry: ruta(`../src/handlers/${handler}.ts`),        depsLockFilePath: ruta('../package-lock.json'),        handler: 'handler',        runtime: Runtime.NODEJS_24_X,        architecture: Architecture.ARM_64,        memorySize: 256,        timeout: Duration.seconds(10),        environment: { TABLE_NAME: tabla.tableName },        logGroup: new LogGroup(this, `${id}Logs`, {          logGroupName: `/aws/lambda/${nombre}`,          retention: RetentionDays.TWO_WEEKS,          removalPolicy: RemovalPolicy.DESTROY,        }),        // esbuild empaqueta el TypeScript. @aws-sdk/* ya viene en el runtime de Lambda.        bundling: { format: OutputFormat.ESM, target: 'es2022', externalModules: ['@aws-sdk/*'] },      })     // POST /gastos: registra un gasto. Solo puede escribir en la tabla.    const registrar = crearFuncion('RegistrarGastoFunction', `${this.stackName}-registrar`, 'registrar')    registrar.addToRolePolicy(new PolicyStatement({ actions: ['dynamodb:PutItem'], resources: [tabla.tableArn] }))     // GET /gastos: lista tus gastos. Solo puede leer la tabla.    const listar = crearFuncion('ListarGastosFunction', `${this.stackName}-listar`, 'listar')    listar.addToRolePolicy(new PolicyStatement({ actions: ['dynamodb:Query'], resources: [tabla.tableArn] }))     // REST API (no HTTP API): los usage plans y las API keys solo existen en REST.    const api = new RestApi(this, 'GastosApi', {      restApiName: `${this.stackName}-api`,      cloudWatchRole: false,      deployOptions: {        stageName: ETAPA,        // La API acepta hasta 5 peticiones por segundo durante la práctica.        // Si mandas más, responde 429: espera un momento e inténtalo de nuevo.        throttlingRateLimit: 5,        throttlingBurstLimit: 5,      },      defaultCorsPreflightOptions: {        allowOrigins: ['*'],        allowHeaders: ['Content-Type', 'x-api-key', 'x-origen'],        allowMethods: ['GET', 'POST', 'OPTIONS'],      },    })     // CDK agrega un output con la URL de la API. Lo quitamos: ApiUrl (abajo) ya la da sin diagonal final.    api.node.tryRemoveChild('Endpoint')     const gastos = api.root.addResource('gastos')    gastos.addMethod('POST', new LambdaIntegration(registrar), { apiKeyRequired: true })    // GET queda público solo en este workshop con datos ficticios.    gastos.addMethod('GET', new LambdaIntegration(listar))     // La API key que manda tu Atajo en el header x-api-key.    const apiKey = api.addApiKey('GastosApiKey', {      apiKeyName: `${this.stackName}-key-${versionKey.valueAsString}`,    })     // El Atajo usa este plan: 2 peticiones por segundo y 500 al día.    const plan = api.addUsagePlan('GastosUsagePlan', {      name: `${this.stackName}-plan`,      throttle: { rateLimit: 2, burstLimit: 4 },      quota: { limit: 500, period: Period.DAY },      apiStages: [{ stage: api.deploymentStage }],    })    plan.addApiKey(apiKey)     new CfnOutput(this, 'ApiUrl', {      description: 'URL base de tu API',      value: `https://${api.restApiId}.execute-api.${this.region}.amazonaws.com/${ETAPA}`,    })    new CfnOutput(this, 'ApiKeyId', {      description: 'ID de la API key (el valor lo ves en la consola de API Gateway o con npm run datos)',      value: apiKey.keyId,    })    new CfnOutput(this, 'TablaGastos', {      description: 'Nombre de la tabla de DynamoDB',      value: tabla.tableName,    })  }}