From fbbd08d8763920ce4127784fba708ed9b7abf433 Mon Sep 17 00:00:00 2001 From: jasdeepbhalla Date: Mon, 9 Feb 2026 15:06:55 -0800 Subject: [PATCH] feat(apigateway): L2 construct support for Routing Rules on custom domain names --- .../test/integ.routing-rules.ts | 64 +++++ packages/aws-cdk-lib/aws-apigateway/README.md | 61 +++++ .../aws-apigateway/lib/domain-name.ts | 89 +++++++ .../aws-cdk-lib/aws-apigateway/lib/index.ts | 1 + .../aws-apigateway/lib/routing-rule.ts | 229 ++++++++++++++++++ .../aws-apigateway/test/domains.test.ts | 118 +++++++++ .../aws-apigateway/test/routing-rule.test.ts | 225 +++++++++++++++++ 7 files changed, 787 insertions(+) create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-apigateway/test/integ.routing-rules.ts create mode 100644 packages/aws-cdk-lib/aws-apigateway/lib/routing-rule.ts create mode 100644 packages/aws-cdk-lib/aws-apigateway/test/routing-rule.test.ts diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-apigateway/test/integ.routing-rules.ts b/packages/@aws-cdk-testing/framework-integ/test/aws-apigateway/test/integ.routing-rules.ts new file mode 100644 index 0000000000000..09fd6593cea20 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-apigateway/test/integ.routing-rules.ts @@ -0,0 +1,64 @@ +import * as cdk from 'aws-cdk-lib'; +import { IntegTest } from '@aws-cdk/integ-tests-alpha'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as acm from 'aws-cdk-lib/aws-certificatemanager'; + +/** + * Integration test for API Gateway custom domain routing rules. + * Creates a domain with routingMode ROUTING_RULE_ONLY and multiple routing rules + * (path-based, header-based, and catch-all). + */ +class RoutingRulesStack extends cdk.Stack { + constructor(scope: cdk.App, id: string) { + super(scope, id); + + const certArn = 'arn:aws:acm:us-east-1:111111111111:certificate/00000000-0000-0000-0000-000000000000'; + const certificate = acm.Certificate.fromCertificateArn(this, 'Cert', certArn); + + const usersApi = new apigw.RestApi(this, 'UsersApi', { deploy: true }); + usersApi.root.addMethod('GET'); + + const ordersApi = new apigw.RestApi(this, 'OrdersApi', { deploy: true }); + ordersApi.root.addMethod('GET'); + + const defaultApi = new apigw.RestApi(this, 'DefaultApi', { deploy: true }); + defaultApi.root.addMethod('GET'); + + const domainName = `api-${this.stackName.toLowerCase().replace(/[^a-z0-9-]/g, '-')}.example.com`; + const domain = new apigw.DomainName(this, 'Domain', { + domainName, + certificate, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + domain.addRoutingRule('UsersRule', { + priority: 100, + conditions: { basePaths: ['users'] }, + action: { + restApi: usersApi, + stripBasePath: true, + }, + }); + + domain.addRoutingRule('HeaderV2Rule', { + priority: 50, + conditions: { + headers: [{ header: 'x-api-version', valueGlob: 'v2' }], + }, + action: { restApi: ordersApi }, + }); + + domain.addRoutingRule('CatchAllRule', { + priority: 999999, + action: { restApi: defaultApi }, + }); + } +} + +const app = new cdk.App(); +const stack = new RoutingRulesStack(app, 'integ-routing-rules'); + +new IntegTest(app, 'routing-rules', { + testCases: [stack], +}); diff --git a/packages/aws-cdk-lib/aws-apigateway/README.md b/packages/aws-cdk-lib/aws-apigateway/README.md index 3f94003b7872d..9bbf8438ac3b7 100644 --- a/packages/aws-cdk-lib/aws-apigateway/README.md +++ b/packages/aws-cdk-lib/aws-apigateway/README.md @@ -38,6 +38,7 @@ running on AWS Lambda, or any web application. - [Controlled triggering of deployments](#controlled-triggering-of-deployments) - [Deep dive: Invalidation of deployments](#deep-dive-invalidation-of-deployments) - [Custom Domains](#custom-domains) + - [Custom domain routing rules](#custom-domain-routing-rules) - [Custom Domains with multi-level api mapping](#custom-domains-with-multi-level-api-mapping) - [Access Logging](#access-logging) - [Cross Origin Resource Sharing (CORS)](#cross-origin-resource-sharing-cors) @@ -1276,6 +1277,66 @@ new route53.ARecord(this, 'CustomDomainAliasRecord', { }); ``` +### Custom domain routing rules + +You can route traffic by path or header using **routing rules**. Set `routingMode` to +`RoutingMode.ROUTING_RULE_ONLY` (or `ROUTING_RULE_THEN_API_MAPPING` to combine rules with API mappings). +Routing rules are only supported for **REGIONAL** endpoints. + +```ts +declare const certificate: acm.ICertificate; + +const domain = new apigateway.DomainName(this, 'Domain', { + domainName: 'api.example.com', + certificate, + endpointType: apigateway.EndpointType.REGIONAL, + routingMode: apigateway.RoutingMode.ROUTING_RULE_ONLY, +}); + +// Path-based: /users/* -> Users API +domain.addRoutingRule('UsersRule', { + priority: 100, + conditions: { basePaths: ['users'] }, + action: { + restApi: usersApi, + stripBasePath: true, + }, +}); + +// Header-based: x-api-version: v2 -> Orders v2 API +domain.addRoutingRule('HeaderV2Rule', { + priority: 50, + conditions: { + headers: [{ header: 'x-api-version', valueGlob: 'v2' }], + }, + action: { restApi: ordersApiV2 }, +}); + +// Catch-all (lowest priority) +domain.addRoutingRule('CatchAllRule', { + priority: 999999, + action: { restApi: defaultApi }, +}); +``` + +You can also create a `RoutingRule` standalone (for example, when the domain is in another stack): + +```ts +declare const importedDomain: apigateway.IDomainName; +declare const restApi: apigateway.RestApi; + +new apigateway.RoutingRule(this, 'Rule', { + domainName: importedDomain, + priority: 100, + conditions: { basePaths: ['users'] }, + action: { + restApi, + stage: restApi.deploymentStage, + stripBasePath: true, + }, +}); +``` + ### Custom Domains with multi-level api mapping Additional requirements for creating multi-level path mappings for RestApis: diff --git a/packages/aws-cdk-lib/aws-apigateway/lib/domain-name.ts b/packages/aws-cdk-lib/aws-apigateway/lib/domain-name.ts index 4bc84c370decd..a12b6b79791e4 100644 --- a/packages/aws-cdk-lib/aws-apigateway/lib/domain-name.ts +++ b/packages/aws-cdk-lib/aws-apigateway/lib/domain-name.ts @@ -5,6 +5,8 @@ import type { BasePathMappingOptions } from './base-path-mapping'; import { BasePathMapping } from './base-path-mapping'; import type { IRestApi } from './restapi'; import { EndpointType } from './restapi'; +import type { AddRoutingRuleOptions } from './routing-rule'; +import { RoutingRule } from './routing-rule'; import * as apigwv2 from '../../aws-apigatewayv2'; import type { IBucket } from '../../aws-s3'; import type { IResource } from '../../core'; @@ -43,6 +45,31 @@ export enum SecurityPolicy { TLS_1_2 = 'TLS_1_2', } +/** + * Routing mode for a custom domain name. + * Determines how API Gateway routes traffic from the domain to your APIs. + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-routing-rules.html + */ +export enum RoutingMode { + /** + * Only base path mappings and API mappings are used. Routing rules are ignored. + * This is the default. + */ + BASE_PATH_MAPPING_ONLY = 'BASE_PATH_MAPPING_ONLY', + + /** + * Only routing rules are evaluated. Base path mappings and API mappings are ignored. + * Use addRoutingRule() to define rules. Requires REGIONAL endpoint. + */ + ROUTING_RULE_ONLY = 'ROUTING_RULE_ONLY', + + /** + * Routing rules are evaluated first; if no rule matches, API mappings are used. + */ + ROUTING_RULE_THEN_API_MAPPING = 'ROUTING_RULE_THEN_API_MAPPING', +} + export interface DomainNameOptions { /** * The custom domain name for your API. Uppercase letters are not supported. @@ -83,6 +110,15 @@ export interface DomainNameOptions { * @default - map requests from the domain root (e.g. `example.com`). */ readonly basePath?: string; + + /** + * Routing mode for this domain. When set to ROUTING_RULE_ONLY or + * ROUTING_RULE_THEN_API_MAPPING, you can use addRoutingRule() to route by path/header. + * Routing rules are only supported for REGIONAL endpoints. + * + * @default RoutingMode.BASE_PATH_MAPPING_ONLY + */ + readonly routingMode?: RoutingMode; } export interface DomainNameProps extends DomainNameOptions { @@ -156,6 +192,7 @@ export class DomainName extends Resource implements IDomainName { private readonly basePaths = new Set(); private readonly securityPolicy?: SecurityPolicy; private readonly endpointType: EndpointType; + private readonly routingMode: RoutingMode; constructor(scope: Construct, id: string, props: DomainNameProps) { super(scope, id); @@ -163,9 +200,14 @@ export class DomainName extends Resource implements IDomainName { addConstructMetadata(this, props); this.endpointType = props.endpointType || EndpointType.REGIONAL; + this.routingMode = props.routingMode ?? RoutingMode.BASE_PATH_MAPPING_ONLY; const edge = this.endpointType === EndpointType.EDGE; this.securityPolicy = props.securityPolicy; + if (this.routingMode !== RoutingMode.BASE_PATH_MAPPING_ONLY && this.endpointType !== EndpointType.REGIONAL) { + throw new ValidationError('Routing rules are only supported for REGIONAL endpoint type', scope); + } + if (!Token.isUnresolved(props.domainName) && /[A-Z]/.test(props.domainName)) { throw new ValidationError(`Domain name does not support uppercase letters. Got: ${props.domainName}`, scope); } @@ -178,6 +220,7 @@ export class DomainName extends Resource implements IDomainName { endpointConfiguration: { types: [this.endpointType] }, mutualTlsAuthentication: mtlsConfig, securityPolicy: props.securityPolicy, + routingMode: this.routingMode, }); this.domainName = resource.ref; @@ -191,6 +234,13 @@ export class DomainName extends Resource implements IDomainName { ? resource.attrDistributionHostedZoneId : resource.attrRegionalHostedZoneId; + if (this.routingMode === RoutingMode.ROUTING_RULE_ONLY && props.mapping) { + throw new ValidationError( + 'Cannot set "mapping" when routingMode is ROUTING_RULE_ONLY. Use addRoutingRule() instead.', + scope, + ); + } + const multiLevel = this.validateBasePath(props.basePath); if (props.mapping && !multiLevel) { this.addBasePathMapping(props.mapping, { @@ -232,6 +282,12 @@ export class DomainName extends Resource implements IDomainName { */ @MethodMetadata() public addBasePathMapping(targetApi: IRestApiRef, options: BasePathMappingOptions = {}): BasePathMapping { + if (this.routingMode === RoutingMode.ROUTING_RULE_ONLY) { + throw new ValidationError( + 'Cannot call addBasePathMapping when routingMode is ROUTING_RULE_ONLY. Set routingMode to BASE_PATH_MAPPING_ONLY or ROUTING_RULE_THEN_API_MAPPING.', + this, + ); + } if (this.basePaths.has(options.basePath)) { throw new ValidationError(`DomainName ${this.node.id} already has a mapping for path ${options.basePath}`, this); } @@ -262,6 +318,12 @@ export class DomainName extends Resource implements IDomainName { */ @MethodMetadata() public addApiMapping(targetStage: IStageRef, options: ApiMappingOptions = {}): void { + if (this.routingMode === RoutingMode.ROUTING_RULE_ONLY) { + throw new ValidationError( + 'Cannot call addApiMapping when routingMode is ROUTING_RULE_ONLY. Set routingMode to BASE_PATH_MAPPING_ONLY or ROUTING_RULE_THEN_API_MAPPING.', + this, + ); + } if (this.basePaths.has(options.basePath)) { throw new ValidationError(`DomainName ${this.node.id} already has a mapping for path ${options.basePath}`, this); } @@ -276,6 +338,33 @@ export class DomainName extends Resource implements IDomainName { }); } + /** + * Adds a routing rule to this domain. Only allowed when routingMode is + * ROUTING_RULE_ONLY or ROUTING_RULE_THEN_API_MAPPING. Domain must be REGIONAL. + * + * @param id Unique id for the rule construct + * @param options Priority, conditions (base path and/or headers), and target API action + * @returns The RoutingRule construct + */ + @MethodMetadata() + public addRoutingRule(id: string, options: AddRoutingRuleOptions): RoutingRule { + if (this.routingMode === RoutingMode.BASE_PATH_MAPPING_ONLY) { + throw new ValidationError( + 'Cannot call addRoutingRule when routingMode is BASE_PATH_MAPPING_ONLY. Set routingMode to ROUTING_RULE_ONLY or ROUTING_RULE_THEN_API_MAPPING.', + this, + ); + } + if (this.endpointType !== EndpointType.REGIONAL) { + throw new ValidationError('Routing rules are only supported for REGIONAL endpoint type', this); + } + return new RoutingRule(this, id, { + domainName: this, + priority: options.priority, + conditions: options.conditions, + action: options.action, + }); + } + private configureMTLS(mtlsConfig?: MTLSConfig): CfnDomainName.MutualTlsAuthenticationProperty | undefined { if (!mtlsConfig) return undefined; return { diff --git a/packages/aws-cdk-lib/aws-apigateway/lib/index.ts b/packages/aws-cdk-lib/aws-apigateway/lib/index.ts index 04ae02e32666a..8c50342a1ff9f 100644 --- a/packages/aws-cdk-lib/aws-apigateway/lib/index.ts +++ b/packages/aws-cdk-lib/aws-apigateway/lib/index.ts @@ -16,6 +16,7 @@ export * from './authorizer'; export * from './json-schema'; export * from './domain-name'; export * from './base-path-mapping'; +export * from './routing-rule'; export * from './cors'; export * from './authorizers'; export * from './access-log'; diff --git a/packages/aws-cdk-lib/aws-apigateway/lib/routing-rule.ts b/packages/aws-cdk-lib/aws-apigateway/lib/routing-rule.ts new file mode 100644 index 0000000000000..8c0721717a448 --- /dev/null +++ b/packages/aws-cdk-lib/aws-apigateway/lib/routing-rule.ts @@ -0,0 +1,229 @@ +import type { Construct } from 'constructs'; +import type { IDomainNameRef, IRestApiRef } from './apigateway.generated'; +import { RestApiBase } from './restapi'; +import type { Stage } from './stage'; +import * as apigwv2 from '../../aws-apigatewayv2'; +import { Resource, Token } from '../../core'; +import { ValidationError } from '../../core/lib/errors'; +import { addConstructMetadata } from '../../core/lib/metadata-resource'; +import { propertyInjectable } from '../../core/lib/prop-injectable'; + +/** + * Routing rule priority. Lower values are evaluated first. + * Must be in range 0–999999. + */ +const PRIORITY_MIN = 0; +const PRIORITY_MAX = 999999; + +/** + * Header name max length and allowed pattern per AWS routing rules restrictions. + * Allowed: a-z, A-Z, 0-9, and *?-!#$%&'.^_`|~ + */ +const HEADER_NAME_MAX_LEN = 40; +const HEADER_NAME_PATTERN = /^[a-zA-Z0-9*?\-!#$%&'.^_`|~]+$/; + +/** + * Header value glob max length. Allowed: a-z, A-Z, 0-9, and *?-!#$%&'.^_`|~ + */ +const VALUE_GLOB_MAX_LEN = 128; + +/** + * Condition for matching a request in a routing rule. + * You can specify base paths, headers, or both (AND logic). + */ +export interface RoutingRuleCondition { + /** + * Base path(s) to match. Request path must match one of these (case-sensitive). + * Each base path is a single path segment (e.g. 'users', 'orders'). + */ + readonly basePaths?: string[]; + + /** + * Header condition(s). Request must match at least one of these (anyOf). + * Header names are case-insensitive; value globs are case-sensitive. + */ + readonly headers?: RoutingRuleHeaderCondition[]; +} + +/** + * A header name and value glob to match in a routing rule condition. + */ +export interface RoutingRuleHeaderCondition { + /** + * Case-insensitive header name. Max 40 chars; allowed: a-z, A-Z, 0-9, *?-!#$%&'.^_`|~ + */ + readonly header: string; + + /** + * Case-sensitive glob for the header value. Max 128 chars. + * Supports *prefix*, *suffix*, *infix* style wildcards. + */ + readonly valueGlob: string; +} + +/** + * Action for a routing rule: invoke a REST API stage. + */ +export interface RoutingRuleAction { + /** + * The REST API to invoke. + */ + readonly restApi: IRestApiRef; + + /** + * The stage to invoke. If omitted, uses the API's deployment stage. + */ + readonly stage?: Stage; + + /** + * When true, API Gateway strips the matched base path before forwarding to the target API. + * @default false + */ + readonly stripBasePath?: boolean; +} + +/** + * Options for adding a routing rule to a domain via DomainName.addRoutingRule(). + */ +export interface AddRoutingRuleOptions { + /** + * Priority (0–999999). Lower values are evaluated first. + */ + readonly priority: number; + + /** + * Conditions that must match for this rule to apply. + * Omit or pass empty for a catch-all rule. + */ + readonly conditions?: RoutingRuleCondition; + + /** + * Action to perform when the rule matches. + */ + readonly action: RoutingRuleAction; +} + +export interface RoutingRuleProps { + /** + * The domain name (or reference) this rule is attached to. + */ + readonly domainName: IDomainNameRef; + + /** + * Priority (0–999999). Lower values are evaluated first. + */ + readonly priority: number; + + /** + * Conditions that must match for this rule to apply. + * Omit or pass empty for a catch-all rule. + */ + readonly conditions?: RoutingRuleCondition; + + /** + * Action to perform when the rule matches. + */ + readonly action: RoutingRuleAction; +} + +function validatePriority(priority: number, scope: Construct): void { + if (Token.isUnresolved(priority)) return; + if (typeof priority !== 'number' || priority < PRIORITY_MIN || priority > PRIORITY_MAX) { + throw new ValidationError( + `Routing rule priority must be between ${PRIORITY_MIN} and ${PRIORITY_MAX}, got: ${priority}`, + scope, + ); + } +} + +function validateHeaderCondition(h: RoutingRuleHeaderCondition, scope: Construct): void { + if (Token.isUnresolved(h.header) || Token.isUnresolved(h.valueGlob)) return; + if (h.header.length > HEADER_NAME_MAX_LEN) { + throw new ValidationError( + `Routing rule header name must be at most ${HEADER_NAME_MAX_LEN} characters, got: ${h.header.length}`, + scope, + ); + } + if (!HEADER_NAME_PATTERN.test(h.header)) { + throw new ValidationError( + 'Routing rule header name may only contain a-z, A-Z, 0-9, and *?-!#$%&\'.^_`|~, got: ' + h.header, + scope, + ); + } + if (h.valueGlob.length > VALUE_GLOB_MAX_LEN) { + throw new ValidationError( + `Routing rule header valueGlob must be at most ${VALUE_GLOB_MAX_LEN} characters, got: ${h.valueGlob.length}`, + scope, + ); + } +} + +function validateConditions(conditions: RoutingRuleCondition | undefined, scope: Construct): void { + if (!conditions) return; + if (conditions.headers) { + if (conditions.headers.length > 2) { + throw new ValidationError('Routing rule may have at most 2 matchHeaders conditions', scope); + } + conditions.headers.forEach((h) => validateHeaderCondition(h, scope)); + } +} + +/** + * L2 construct for an API Gateway routing rule on a custom domain. + * Routes traffic to a REST API stage when conditions (base path and/or headers) match. + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-routing-rules-use.html + */ +@propertyInjectable +export class RoutingRule extends Resource { + public static readonly PROPERTY_INJECTION_ID: string = 'aws-cdk-lib.aws-apigateway.RoutingRule'; + + constructor(scope: Construct, id: string, props: RoutingRuleProps) { + super(scope, id); + addConstructMetadata(this, props); + + validatePriority(props.priority, this); + validateConditions(props.conditions, this); + + const domainNameArn = props.domainName.domainNameRef.domainNameArn; + const restApi = props.action.restApi; + const stage = props.action.stage ?? (restApi instanceof RestApiBase ? restApi.deploymentStage : undefined); + if (!stage) { + throw new ValidationError( + 'Routing rule action must specify a stage or use a RestApi with a deployment stage', + this, + ); + } + + // One condition object can have both matchBasePaths and matchHeaders (AND logic). + const conditionList: apigwv2.CfnRoutingRule.ConditionProperty[] = [ + { + ...(props.conditions?.basePaths?.length + ? { matchBasePaths: { anyOf: props.conditions.basePaths } } + : {}), + ...(props.conditions?.headers?.length + ? { + matchHeaders: { + anyOf: props.conditions.headers.map((h) => ({ header: h.header, valueGlob: h.valueGlob })), + }, + } + : {}), + }, + ]; + + new apigwv2.CfnRoutingRule(this, 'Resource', { + domainNameArn, + priority: props.priority, + conditions: conditionList, + actions: [ + { + invokeApi: { + apiId: restApi.restApiRef.restApiId, + stage: stage.stageRef.stageName, + stripBasePath: props.action.stripBasePath ?? false, + }, + }, + ], + }); + } +} diff --git a/packages/aws-cdk-lib/aws-apigateway/test/domains.test.ts b/packages/aws-cdk-lib/aws-apigateway/test/domains.test.ts index a1fa83a727150..9acf5cc73f04d 100644 --- a/packages/aws-cdk-lib/aws-apigateway/test/domains.test.ts +++ b/packages/aws-cdk-lib/aws-apigateway/test/domains.test.ts @@ -706,6 +706,124 @@ describe('domains', () => { }); }); + test('domain with routingMode ROUTING_RULE_ONLY sets RoutingMode on CfnDomainName', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'); + new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::DomainName', { + DomainName: 'api.example.com', + RoutingMode: 'ROUTING_RULE_ONLY', + }); + }); + + test('domain with routingMode BASE_PATH_MAPPING_ONLY does not allow addRoutingRule', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + }); + + expect(() => { + domain.addRoutingRule('Rule', { + priority: 100, + action: { restApi: api }, + }); + }).toThrow(/Cannot call addRoutingRule when routingMode is BASE_PATH_MAPPING_ONLY/); + }); + + test('domain with routingMode ROUTING_RULE_ONLY does not allow addBasePathMapping', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + expect(() => { + domain.addBasePathMapping(api, { basePath: 'api' }); + }).toThrow(/Cannot call addBasePathMapping when routingMode is ROUTING_RULE_ONLY/); + }); + + test('domain with routingMode ROUTING_RULE_ONLY does not allow mapping in constructor', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + + expect(() => { + new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + mapping: api, + }); + }).toThrow(/Cannot set "mapping" when routingMode is ROUTING_RULE_ONLY/); + }); + + test('routing rules require REGIONAL endpoint', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'); + + expect(() => { + new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.EDGE, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + }).toThrow(/Routing rules are only supported for REGIONAL endpoint type/); + }); + + test('addRoutingRule creates rule and uses domain ARN', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:1111111:certificate/11-3336f1-44483d-adc7-9cd375c5169d'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + domain.addRoutingRule('CatchAll', { + priority: 999999, + action: { restApi: api }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::RoutingRule', { + Priority: 999999, + DomainNameArn: { + 'Fn::GetAtt': ['Domain66AC69E0', 'DomainNameArn'], + }, + Conditions: [{}], + Actions: [ + { + InvokeApi: { + ApiId: { Ref: 'ApiC8550315' }, + Stage: { Ref: 'ApiDeploymentStageprod896C8101' }, + StripBasePath: false, + }, + }, + ], + }); + }); + test('base path mapping configures stage for SpecRestApi creation', () => { // GIVEN const stack = new Stack(); diff --git a/packages/aws-cdk-lib/aws-apigateway/test/routing-rule.test.ts b/packages/aws-cdk-lib/aws-apigateway/test/routing-rule.test.ts new file mode 100644 index 0000000000000..81cbe90934665 --- /dev/null +++ b/packages/aws-cdk-lib/aws-apigateway/test/routing-rule.test.ts @@ -0,0 +1,225 @@ +import { Template } from '../../assertions'; +import * as acm from '../../aws-certificatemanager'; +import { Stack } from '../../core'; +import * as apigw from '../lib'; + +describe('RoutingRule', () => { + test('creates AWS::ApiGatewayV2::RoutingRule with path condition', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:111:certificate/11'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + domain.addRoutingRule('UsersRule', { + priority: 100, + conditions: { basePaths: ['users'] }, + action: { + restApi: api, + stripBasePath: true, + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::DomainName', { + DomainName: 'api.example.com', + RoutingMode: 'ROUTING_RULE_ONLY', + }); + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::RoutingRule', { + Priority: 100, + Conditions: [ + { + MatchBasePaths: { AnyOf: ['users'] }, + }, + ], + Actions: [ + { + InvokeApi: { + ApiId: { Ref: 'Api49610EDF' }, + Stage: { Ref: 'ApiDeploymentStageprodE1054AF0' }, + StripBasePath: true, + }, + }, + ], + }); + }); + + test('creates routing rule with header condition', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:111:certificate/11'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + domain.addRoutingRule('HeaderRule', { + priority: 50, + conditions: { + headers: [{ header: 'x-api-version', valueGlob: 'v2' }], + }, + action: { restApi: api }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::RoutingRule', { + Priority: 50, + Conditions: [ + { + MatchHeaders: { + AnyOf: [{ Header: 'x-api-version', ValueGlob: 'v2' }], + }, + }, + ], + }); + }); + + test('creates routing rule with base path and headers (AND)', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:111:certificate/11'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + domain.addRoutingRule('CombinedRule', { + priority: 75, + conditions: { + basePaths: ['orders'], + headers: [{ header: 'x-api-version', valueGlob: 'v2' }], + }, + action: { restApi: api, stripBasePath: true }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::RoutingRule', { + Priority: 75, + Conditions: [ + { + MatchBasePaths: { AnyOf: ['orders'] }, + MatchHeaders: { + AnyOf: [{ Header: 'x-api-version', ValueGlob: 'v2' }], + }, + }, + ], + Actions: [ + { + InvokeApi: { + StripBasePath: true, + }, + }, + ], + }); + }); + + test('standalone RoutingRule with imported domain', () => { + const stack = new Stack(); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + + const domain = apigw.DomainName.fromDomainNameAttributes(stack, 'ImportedDomain', { + domainName: 'api.example.com', + domainNameAliasTarget: 'd-xxx.execute-api.us-east-1.amazonaws.com', + domainNameAliasHostedZoneId: 'Z123', + }); + + new apigw.RoutingRule(stack, 'Rule', { + domainName: domain, + priority: 100, + conditions: { basePaths: ['users'] }, + action: { + restApi: api, + stage: api.deploymentStage, + stripBasePath: true, + }, + }); + + Template.fromStack(stack).resourceCountIs('AWS::ApiGateway::DomainName', 0); + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::RoutingRule', { + Priority: 100, + Conditions: [{ MatchBasePaths: { AnyOf: ['users'] } }], + }); + }); + + test('throws when priority out of range', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:111:certificate/11'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + expect(() => { + domain.addRoutingRule('Bad', { + priority: 1000000, + action: { restApi: api }, + }); + }).toThrow(/priority must be between 0 and 999999/); + }); + + test('throws when header name too long', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:111:certificate/11'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + expect(() => { + domain.addRoutingRule('Bad', { + priority: 100, + conditions: { + headers: [{ header: 'x'.repeat(41), valueGlob: 'v2' }], + }, + action: { restApi: api }, + }); + }).toThrow(/header name must be at most 40/); + }); + + test('throws when more than 2 header conditions', () => { + const stack = new Stack(); + const cert = acm.Certificate.fromCertificateArn(stack, 'Cert', 'arn:aws:acm:us-east-1:111:certificate/11'); + const api = new apigw.RestApi(stack, 'Api'); + api.root.addMethod('GET'); + const domain = new apigw.DomainName(stack, 'Domain', { + domainName: 'api.example.com', + certificate: cert, + endpointType: apigw.EndpointType.REGIONAL, + routingMode: apigw.RoutingMode.ROUTING_RULE_ONLY, + }); + + expect(() => { + domain.addRoutingRule('Bad', { + priority: 100, + conditions: { + headers: [ + { header: 'a', valueGlob: '1' }, + { header: 'b', valueGlob: '2' }, + { header: 'c', valueGlob: '3' }, + ], + }, + action: { restApi: api }, + }); + }).toThrow(/at most 2 matchHeaders conditions/); + }); +});