import { Construct } from 'constructs' ; import * as cdk from "aws-cdk-lib" ; import * as logs from "aws-cdk-lib/aws-logs" ; import * as route53 from "aws-cdk-lib/aws-route53" ; import * as route53Targets from "aws-cdk-lib/aws-route53-targets" ; import * as acm from "aws-cdk-lib/aws-certificatemanager" ; import * as apig from "aws-cdk-lib/aws-apigateway" ; import * as apigV1AccessLog from "./util/apiGatewayV1AccessLog" ; import { App } from "./App" ; import { getFunctionRef , SSTConstruct , isCDKConstruct } from "./Construct" ; import { Function as Fn , FunctionProps , FunctionDefinition } from "./Function" ; import { Permissions } from "./util/permission" ; const allowedMethods = [ "ANY" , "GET" , "PUT" , "POST" , "HEAD" , "PATCH" , "DELETE" , "OPTIONS" , ] ; export interface ApiGatewayV1ApiProps { readonly restApi ? : apig . IRestApi | apig . RestApiProps ; readonly routes ? : { [ key ] : FunctionDefinition | ApiGatewayV1ApiRouteProps ; } ; readonly cors ? : boolean ; readonly accessLog ? : boolean | string | ApiGatewayV1ApiAcccessLogProps ; readonly customDomain ? : string | ApiGatewayV1ApiCustomDomainProps ; readonly importedPaths ? : { [ path ] : string ; } ; readonly defaultFunctionProps ? : FunctionProps ; readonly defaultAuthorizer ? : apig . IAuthorizer ; readonly defaultAuthorizationType ? : apig . AuthorizationType ; readonly defaultAuthorizationScopes ? : string [ ] ; } export interface ApiGatewayV1ApiRouteProps { readonly function : FunctionDefinition ; readonly methodOptions ? : apig . MethodOptions ; readonly integrationOptions ? : apig . LambdaIntegrationOptions ; } export interface ApiGatewayV1ApiCustomDomainProps { readonly domainName : string | apig . IDomainName ; readonly hostedZone ? : string | route53 . IHostedZone ; readonly certificate ? : acm . ICertificate ; readonly path ? : string ; readonly endpointType ? : apig . EndpointType ; readonly mtls ? : apig . MTLSConfig ; readonly securityPolicy ? : apig . SecurityPolicy ; } export type ApiGatewayV1ApiAcccessLogProps = apigV1AccessLog . AccessLogProps ; export class ApiGatewayV1Api extends Construct implements SSTConstruct { public readonly restApi : apig . RestApi ; public accessLogGroup ? : logs . LogGroup ; public apiGatewayDomain ? : apig . DomainName ; public acmCertificate ? : acm . Certificate | acm . DnsValidatedCertificate ; private _deployment ? : apig . Deployment ; private _customDomainUrl ? : string ; private importedResources : { [ path ] : apig . IResource ; } ; private readonly functions : { [ key ] : Fn ; } ; private readonly permissionsAttachedForAllRoutes : Permissions [ ] ; private readonly defaultFunctionProps ? : FunctionProps ; private readonly defaultAuthorizer ? : apig . IAuthorizer ; private readonly defaultAuthorizationType ? : apig . AuthorizationType ; private readonly defaultAuthorizationScopes ? : string [ ] ; constructor ( scope , id , props ? ) { super ( scope , id ) ; const root = scope . node . root as App ; const { restApi , routes , cors , accessLog , customDomain , importedPaths , defaultFunctionProps , defaultAuthorizer , defaultAuthorizationType , defaultAuthorizationScopes , } = props || { } ; this . functions = { } ; this . importedResources = { } ; this . permissionsAttachedForAllRoutes = [ ] ; this . defaultFunctionProps = defaultFunctionProps ; this . defaultAuthorizer = defaultAuthorizer ; this . defaultAuthorizationType = defaultAuthorizationType ; this . defaultAuthorizationScopes = defaultAuthorizationScopes ; if ( isCDKConstruct ( restApi ) ) { if ( cors !== undefined ) { throw new Error ( `Cannot configure the "cors" when the "restApi" is imported` ) ; } if ( accessLog !== undefined ) { throw new Error ( `Cannot configure the "accessLog" when the "restApi" is imported` ) ; } if ( customDomain !== undefined ) { throw new Error ( `Cannot configure the "customDomain" when the "restApi" is imported` ) ; } this . restApi = restApi as apig . RestApi ; this . _deployment = new apig . Deployment ( this , "Deployment" , { api : this . restApi , } ) ; const cfnDeployment = this . _deployment . node . defaultChild as apig . CfnDeployment ; cfnDeployment . stageName = root . stage ; if ( importedPaths ) { this . importResources ( importedPaths ) ; } } else { const restApiProps = ( restApi || { } ) as apig . RestApiProps ; if ( importedPaths !== undefined ) { throw new Error ( `Cannot import route paths when creating a new API.` ) ; } if ( customDomain !== undefined && restApiProps . domainName !== undefined ) { throw new Error ( `Use either the "customDomain" or the "restApi.domainName" to configure the Api domain. Do not use both.` ) ; } if ( cors !== undefined && restApiProps . defaultCorsPreflightOptions !== undefined ) { throw new Error ( `Use either the "cors" or the "restApi.defaultCorsPreflightOptions" to configure the Api's CORS config. Do not use both.` ) ; } if ( accessLog !== undefined && restApiProps . deployOptions ?. accessLogDestination !== undefined ) { throw new Error ( `Use either the "accessLog" or the "restApi.deployOptions.accessLogDestination" to configure the Api's access log. Do not use both.` ) ; } if ( accessLog !== undefined && restApiProps . deployOptions ?. accessLogFormat !== undefined ) { throw new Error ( `Use either the "accessLog" or the "restApi.deployOptions.accessLogFormat" to configure the Api's access log. Do not use both.` ) ; } const stageName = restApiProps . deployOptions ?. stageName || ( this . node . root as App ) . stage ; const accessLogData = apigV1AccessLog . buildAccessLogData ( this , accessLog ) ; this . accessLogGroup = accessLogData ?. logGroup ; this . restApi = new apig . RestApi ( this , "Api" , { ... restApiProps , restApiName : root . logicalPrefixedName ( id ) , domainName : restApiProps . domainName , defaultCorsPreflightOptions : restApiProps . defaultCorsPreflightOptions || this . buildCorsConfig ( cors ) , deployOptions : { ... ( restApiProps . deployOptions || { } ) , accessLogDestination : restApiProps . deployOptions ?. accessLogDestination || accessLogData ?. destination , accessLogFormat : restApiProps . deployOptions ?. accessLogFormat || accessLogData ?. format , stageName : stageName , tracingEnabled : restApiProps . deployOptions ?. tracingEnabled === undefined ? true : restApiProps . deployOptions ?. tracingEnabled , } , } ) ; this . createCustomDomain ( customDomain ) ; this . createGatewayResponseForCors ( cors ) ; } if ( routes ) { Object . keys ( routes ) . forEach ( ( routeKey ) => this . addRoute ( this , routeKey , routes [ routeKey ] ) ) ; } } public get url ( ) : string { return this . restApi . url ; } public get customDomainUrl ( ) : string | undefined { return this . _customDomainUrl ; } public get routes ( ) : string [ ] { return Object . keys ( this . functions ) ; } public addRoutes ( scope , routes ) { Object . keys ( routes ) . forEach ( ( routeKey ) => { const fn = this . addRoute ( scope , routeKey , routes [ routeKey ] ) ; this . permissionsAttachedForAllRoutes . forEach ( ( permissions ) => fn . attachPermissions ( permissions ) ) ; } ) ; } public getFunction ( routeKey ) { return this . functions [ this . normalizeRouteKey ( routeKey ) ] ; } public attachPermissions ( permissions ) { Object . values ( this . functions ) . forEach ( ( fn ) => fn . attachPermissions ( permissions ) ) ; this . permissionsAttachedForAllRoutes . push ( permissions ) ; } public getConstructMetadata ( ) { return { type : "ApiGatewayV1Api" as const , data : { customDomainUrl : this . _customDomainUrl , restApiId : this . restApi . restApiId , routes : Object . entries ( this . functions ) . map ( ( [ key , data ] ) => { return { route : key , fn : getFunctionRef ( data ) , } ; } ) , } , } ; } public attachPermissionsToRoute ( routeKey , permissions ) { const fn = this . getFunction ( routeKey ) ; if ( ! fn ) { throw new Error ( `Failed to attach permissions. Route "${ routeKey }" does not exist.` ) ; } fn . attachPermissions ( permissions ) ; } private buildCorsConfig ( cors ? ) { if ( cors === false ) { return undefined ; } return { allowOrigins : apig . Cors . ALL_ORIGINS , } as apig . CorsOptions ; } private createGatewayResponseForCors ( cors ? ) { if ( ! cors ) { return ; } this . restApi . addGatewayResponse ( "GatewayResponseDefault4XX" , { type : apig . ResponseType . DEFAULT_4XX , responseHeaders : { "Access-Control-Allow-Origin" : "'*'" , "Access-Control-Allow-Headers" : "'*'" , } , } ) ; this . restApi . addGatewayResponse ( "GatewayResponseDefault5XX" , { type : apig . ResponseType . DEFAULT_5XX , responseHeaders : { "Access-Control-Allow-Origin" : "'*'" , "Access-Control-Allow-Headers" : "'*'" , } , } ) ; } private createCustomDomain ( customDomain ? ) { if ( customDomain === undefined ) { return ; } let domainName , hostedZone , hostedZoneDomain , certificate , apigDomainName , basePath , endpointType , mtls , securityPolicy ; if ( typeof customDomain === "string" ) { if ( cdk . Token . isUnresolved ( customDomain ) ) { throw new Error ( `You also need to specify the "hostedZone" if the "domainName" is passed in as a reference.` ) ; } domainName = customDomain ; this . assertDomainNameIsLowerCase ( domainName ) ; hostedZoneDomain = customDomain . split ( "." ) . slice ( 1 ) . join ( "." ) ; } else if ( ! customDomain . domainName ) { throw new Error ( `Missing "domainName" in Api's customDomain setting` ) ; } else if ( typeof customDomain . domainName === "string" ) { domainName = customDomain . domainName ; if ( cdk . Token . isUnresolved ( customDomain . domainName ) ) { if ( ! customDomain . hostedZone ) { throw new Error ( `You also need to specify the "hostedZone" if the "domainName" is passed in as a reference.` ) ; } domainName = customDomain . domainName ; } else { domainName = customDomain . domainName ; this . assertDomainNameIsLowerCase ( domainName ) ; } if ( ! customDomain . hostedZone ) { hostedZoneDomain = domainName . split ( "." ) . slice ( 1 ) . join ( "." ) ; } else if ( typeof customDomain . hostedZone === "string" ) { hostedZoneDomain = customDomain . hostedZone ; } else { hostedZone = customDomain . hostedZone ; } certificate = customDomain . certificate ; basePath = customDomain . path ; endpointType = customDomain . endpointType ; mtls = customDomain . mtls ; securityPolicy = customDomain . securityPolicy ; } else { apigDomainName = customDomain . domainName ; if ( apigDomainName && customDomain . hostedZone ) { throw new Error ( `Cannot configure the "hostedZone" when the "domainName" is a construct` ) ; } if ( apigDomainName && customDomain . certificate ) { throw new Error ( `Cannot configure the "certificate" when the "domainName" is a construct` ) ; } if ( apigDomainName && customDomain . endpointType ) { throw new Error ( `Cannot configure the "endpointType" when the "domainName" is a construct` ) ; } if ( apigDomainName && customDomain . mtls ) { throw new Error ( `Cannot configure the "mtls" when the "domainName" is a construct` ) ; } if ( apigDomainName && customDomain . securityPolicy ) { throw new Error ( `Cannot configure the "securityPolicy" when the "domainName" is a construct` ) ; } basePath = customDomain . path ; } if ( ! apigDomainName && ! hostedZone ) { if ( ! hostedZone && hostedZoneDomain ) { hostedZone = route53 . HostedZone . fromLookup ( this , "HostedZone" , { domainName : hostedZoneDomain , } ) ; } } if ( ! apigDomainName && ! certificate ) { if ( endpointType === apig . EndpointType . EDGE ) { certificate = new acm . DnsValidatedCertificate ( this , "CrossRegionCertificate" , { domainName : domainName as string , hostedZone : hostedZone as route53 . IHostedZone , region : "us-east-1" , } ) ; } else { certificate = new acm . Certificate ( this , "Certificate" , { domainName : domainName as string , validation : acm . CertificateValidation . fromDns ( hostedZone ) , } ) ; } this . acmCertificate = certificate ; } if ( ! apigDomainName && domainName ) { apigDomainName = new apig . DomainName ( this , "DomainName" , { domainName , certificate : certificate as acm . ICertificate , endpointType , mtls , securityPolicy , } ) ; this . apiGatewayDomain = apigDomainName ; const record = new route53 . ARecord ( this , "AliasRecord" , { recordName : domainName , zone : hostedZone as route53 . IHostedZone , target : route53 . RecordTarget . fromAlias ( new route53Targets . ApiGatewayDomain ( apigDomainName ) ) , } ) ; if ( cdk . Token . isUnresolved ( domainName ) ) { const cfnRecord = record . node . defaultChild as route53 . CfnRecordSet ; cfnRecord . name = domainName ; } } if ( apigDomainName ) { new apig . BasePathMapping ( this , "BasePath" , { domainName : apigDomainName , restApi : this . restApi , basePath , } ) ; } if ( domainName && ! cdk . Token . isUnresolved ( domainName ) ) { this . _customDomainUrl = basePath ? `https://${ domainName }/${ basePath }/` : `https://${ domainName }` ; } } private importResources ( resources ) { Object . keys ( resources ) . forEach ( ( path ) => { const resource = apig . Resource . fromResourceAttributes ( this , `Resource_${ path }` , { path , resourceId : resources [ path ] , restApi : this . restApi , } ) ; this . importedResources [ path ] = resource ; } ) ; } private getResourceForPath ( path ) { if ( this . importedResources [ path ] ) { return this . importedResources [ path ] ; } const parts = path . split ( "/" ) ; for ( let i = parts . length ; i >= 1 ; i -- ) { const partialPath = parts . slice ( 0 , i ) . join ( "/" ) ; if ( this . importedResources [ partialPath ] ) { return this . importedResources [ partialPath ] . resourceForPath ( parts . slice ( i ) . join ( "/" ) ) ; } } return this . restApi . root . resourceForPath ( path ) ; } private addRoute ( scope , routeKey , routeValue ) { const routeProps = ( this . isInstanceOfApiRouteProps ( routeValue as ApiGatewayV1ApiRouteProps ) ? routeValue : { function : routeValue as FunctionDefinition , } ) as ApiGatewayV1ApiRouteProps ; routeKey = this . normalizeRouteKey ( routeKey ) ; if ( this . functions [ routeKey ] ) { throw new Error ( `A route already exists for "${ routeKey }"` ) ; } const routeKeyParts = routeKey . split ( " " ) ; if ( routeKeyParts . length !== 2 ) { throw new Error ( `Invalid route ${ routeKey }` ) ; } const methodStr = routeKeyParts [ 0 ] . toUpperCase ( ) ; const path = routeKeyParts [ 1 ] ; const method = allowedMethods . find ( ( per ) => per === methodStr ) ; if ( ! method ) { throw new Error ( `Invalid method defined for "${ routeKey }"` ) ; } if ( path . length === 0 ) { throw new Error ( `Invalid path defined for "${ routeKey }"` ) ; } let resource ; if ( path . endsWith ( "/{proxy+}" ) ) { const parentResource = this . getResourceForPath ( path . split ( "/" ) . slice ( 0 , - 1 ) . join ( "/" ) ) ; resource = parentResource . addProxy ( { anyMethod : false } ) ; } else { resource = this . getResourceForPath ( path ) ; } const lambda = Fn . fromDefinition ( scope , `Lambda_${ methodStr }_${ path }` , routeProps . function , this . defaultFunctionProps , `The "defaultFunctionProps" cannot be applied if an instance of a Function construct is passed in. Make sure to define all the routes using FunctionProps, so the Api construct can apply the "defaultFunctionProps" to them.` ) ; const integration = new apig . LambdaIntegration ( lambda , routeProps . integrationOptions ) ; const methodOptions = this . buildRouteMethodOptions ( routeProps . methodOptions ) ; const apigMethod = resource . addMethod ( method , integration , methodOptions ) ; const root = scope . node . root as App ; if ( root . local ) { lambda . addEnvironment ( "SST_DEBUG_IS_API_ROUTE" , "1" , { removeInEdge : true , } ) ; } if ( this . _deployment ) { this . _deployment . addToLogicalId ( { route : { routeKey , routeValue } } ) ; this . _deployment . node . addDependency ( apigMethod ) ; } this . functions [ routeKey ] = lambda ; return lambda ; } private buildRouteMethodOptions ( options ? ) { const methodOptions = { authorizationType : this . defaultAuthorizationType , ... ( options || { } ) , } ; if ( methodOptions . authorizationType !== apig . AuthorizationType . NONE && methodOptions . authorizationType !== apig . AuthorizationType . IAM ) { methodOptions . authorizer = methodOptions . authorizer || this . defaultAuthorizer ; methodOptions . authorizationScopes = methodOptions . authorizationScopes || this . defaultAuthorizationScopes ; } return methodOptions ; } private isInstanceOfApiRouteProps ( object ) { return object . function !== undefined ; } private normalizeRouteKey ( routeKey ) { return routeKey . split ( /\s+/ ) . join ( " " ) ; } private assertDomainNameIsLowerCase ( domainName ) { if ( domainName !== domainName . toLowerCase ( ) ) { throw new Error ( `The domain name needs to be in lowercase` ) ; } } }