diff --git a/packages/@aws-cdk/aws-appmesh/README.md b/packages/@aws-cdk/aws-appmesh/README.md index 9b228e77bbb86..af8d876f1fea3 100644 --- a/packages/@aws-cdk/aws-appmesh/README.md +++ b/packages/@aws-cdk/aws-appmesh/README.md @@ -406,6 +406,18 @@ A `route` is associated with a virtual router, and it's used to match requests f If your `route` matches a request, you can distribute traffic to one or more target virtual nodes with relative weighting. +The _RouteSpec_ class provides an easy interface for defining new protocol specific route specs. +The `tcp()`, `http()`, `http2()`, and `grpc()` methods provide the spec necessary to define a protocol specific spec. + +For HTTP based routes, the match field can be used to match on +path (prefix, exact, or regex), HTTP method, scheme, HTTP headers, and query parameters. +By default, an HTTP based route will match all requests. + +For gRPC based routes, the match field can be used to match on service name, method name, and metadata. +When specifying the method name, service name must also be specified. + +For example, here's how to add an HTTP route that matches based on a prefix of the URL path: + ```ts router.addRoute('route-http', { routeSpec: appmesh.RouteSpec.http({ @@ -415,13 +427,14 @@ router.addRoute('route-http', { }, ], match: { - prefixPath: '/path-to-app', + // Path that is passed to this method must start with '/'. + path: appmesh.HttpRoutePathMatch.startsWith('/path-to-app'), }, }), }); ``` -Add an HTTP2 route that matches based on method, scheme and header: +Add an HTTP2 route that matches based on exact path, method, scheme, headers, and query parameters: ```ts router.addRoute('route-http2', { @@ -432,14 +445,18 @@ router.addRoute('route-http2', { }, ], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.exactly('/exact'), method: appmesh.HttpRouteMethod.POST, protocol: appmesh.HttpRouteProtocol.HTTPS, headers: [ // All specified headers must match for the route to match. appmesh.HeaderMatch.valueIs('Content-Type', 'application/json'), appmesh.HeaderMatch.valueIsNot('Content-Type', 'application/json'), - ] + ], + queryParameters: [ + // All specified query parameters must match for the route to match. + appmesh.QueryParameterMatch.valueIs('query-field', 'value') + ], }, }), }); @@ -461,7 +478,7 @@ router.addRoute('route-http', { }, ], match: { - prefixPath: '/path-to-app', + path: appmesh.HttpRoutePathMatch.startsWith('/path-to-app'), }, }), }); @@ -511,12 +528,27 @@ router.addRoute('route-grpc-retry', { }); ``` -The _RouteSpec_ class provides an easy interface for defining new protocol specific route specs. -The `tcp()`, `http()` and `http2()` methods provide the spec necessary to define a protocol specific spec. +Add an gRPC route that matches based on method name and metadata: -For HTTP based routes, the match field can be used to match on a route prefix. -By default, an HTTP based route will match on `/`. All matches must start with a leading `/`. -The timeout field can also be specified for `idle` and `perRequest` timeouts. +```ts +router.addRoute('route-grpc-retry', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode: node }], + match: { + // When method name is specified, service name must be also specified. + methodName: 'methodname', + serviceName: 'servicename', + metadata: [ + // All specified metadata must match for the route to match. + appmesh.HeaderMatch.valueStartsWith('Content-Type', 'application/'), + appmesh.HeaderMatch.valueDoesNotStartWith('Content-Type', 'text/'), + ], + }, + }), +}); +``` + +Add a gRPC route with time out: ```ts router.addRoute('route-http', { diff --git a/packages/@aws-cdk/aws-appmesh/lib/http-route-path-match.ts b/packages/@aws-cdk/aws-appmesh/lib/http-route-path-match.ts new file mode 100644 index 0000000000000..819cc3821b429 --- /dev/null +++ b/packages/@aws-cdk/aws-appmesh/lib/http-route-path-match.ts @@ -0,0 +1,97 @@ +import { CfnRoute } from './appmesh.generated'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +/** + * The type returned from the `bind()` method in {@link HttpRoutePathMatch}. + */ +export interface HttpRoutePathMatchConfig { + /** + * Route configuration for matching on the complete URL path of the request. + * + * @default - no matching will be performed on the complete URL path + */ + readonly wholePathMatch?: CfnRoute.HttpPathMatchProperty; + + /** + * Route configuration for matching on the prefix of the URL path of the request. + * + * @default - no matching will be performed on the prefix of the URL path + */ + readonly prefixPathMatch?: string; +} + +/** + * Defines HTTP route matching based on the URL path of the request. + */ +export abstract class HttpRoutePathMatch { + /** + * The value of the path must match the specified value exactly. + * The provided `path` must start with the '/' character. + * + * @param path the exact path to match on + */ + public static exactly(path: string): HttpRoutePathMatch { + return new HttpRouteWholePathMatch({ exact: path }); + } + + /** + * The value of the path must match the specified regex. + * + * @param regex the regex used to match the path + */ + public static regex(regex: string): HttpRoutePathMatch { + return new HttpRouteWholePathMatch({ regex: regex }); + } + + /** + * The value of the path must match the specified prefix. + * + * @param prefix the value to use to match the beginning of the path part of the URL of the request. + * It must start with the '/' character. If provided as "/", matches all requests. + * For example, if your virtual service name is "my-service.local" + * and you want the route to match requests to "my-service.local/metrics", your prefix should be "/metrics". + */ + public static startsWith(prefix: string): HttpRoutePathMatch { + return new HttpRoutePrefixPathMatch(prefix); + } + + /** + * Returns the route path match configuration. + */ + public abstract bind(scope: Construct): HttpRoutePathMatchConfig; +} + +class HttpRoutePrefixPathMatch extends HttpRoutePathMatch { + constructor(private readonly prefix: string) { + super(); + + if (this.prefix && this.prefix[0] !== '/') { + throw new Error(`Prefix Path for the match must start with \'/\', got: ${this.prefix}`); + } + } + + bind(_scope: Construct): HttpRoutePathMatchConfig { + return { + prefixPathMatch: this.prefix, + }; + } +} + +class HttpRouteWholePathMatch extends HttpRoutePathMatch { + constructor(private readonly match: CfnRoute.HttpPathMatchProperty) { + super(); + + if (this.match.exact && this.match.exact[0] !== '/') { + throw new Error(`Exact Path for the match must start with \'/\', got: ${this.match.exact}`); + } + } + + bind(_scope: Construct): HttpRoutePathMatchConfig { + return { + wholePathMatch: this.match, + }; + } +} diff --git a/packages/@aws-cdk/aws-appmesh/lib/index.ts b/packages/@aws-cdk/aws-appmesh/lib/index.ts index dbdd9d79bb610..d5ea8fedb70d6 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/index.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/index.ts @@ -21,3 +21,5 @@ export * from './tls-validation'; export * from './tls-client-policy'; export * from './http-route-method'; export * from './header-match'; +export * from './query-parameter-match'; +export * from './http-route-path-match'; diff --git a/packages/@aws-cdk/aws-appmesh/lib/private/utils.ts b/packages/@aws-cdk/aws-appmesh/lib/private/utils.ts index cb442ac34044f..6f07827aa78ff 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/private/utils.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/private/utils.ts @@ -2,6 +2,8 @@ import { Token, TokenComparison } from '@aws-cdk/core'; import { CfnVirtualNode } from '../appmesh.generated'; import { HeaderMatch } from '../header-match'; import { ListenerTlsOptions } from '../listener-tls-options'; +import { QueryParameterMatch } from '../query-parameter-match'; +import { GrpcRouteMatch } from '../route-spec'; import { TlsClientPolicy } from '../tls-client-policy'; // keep this import separate from other imports to reduce chance for merge conflicts with v2-main @@ -95,13 +97,38 @@ export function renderMeshOwner(resourceAccount: string, meshAccount: string) : } /** - * This is the helper method to validate the length of match array when it is specified. + * This is the helper method to validate the length of HTTP match array when it is specified. */ -export function validateMatchArrayLength(headers?: HeaderMatch[]) { +export function validateHttpMatchArrayLength(headers?: HeaderMatch[], queryParameters?: QueryParameterMatch[]) { const MIN_LENGTH = 1; const MAX_LENGTH = 10; if (headers && (headers.length < MIN_LENGTH || headers.length > MAX_LENGTH)) { throw new Error(`Number of headers provided for matching must be between ${MIN_LENGTH} and ${MAX_LENGTH}, got: ${headers.length}`); } + + if (queryParameters && (queryParameters.length < MIN_LENGTH || queryParameters.length > MAX_LENGTH)) { + throw new Error(`Number of query parameters provided for matching must be between ${MIN_LENGTH} and ${MAX_LENGTH}, got: ${queryParameters.length}`); + } +} + +/** + * This is the helper method to validate the length of gRPC match array when it is specified. + */ +export function validateGrpcMatchArrayLength(metadata?: HeaderMatch[]): void { + const MIN_LENGTH = 1; + const MAX_LENGTH = 10; + + if (metadata && (metadata.length < MIN_LENGTH || metadata.length > MAX_LENGTH)) { + throw new Error(`Number of metadata provided for matching must be between ${MIN_LENGTH} and ${MAX_LENGTH}, got: ${metadata.length}`); + } +} + +/** + * This is the helper method to validate at least one of gRPC match type is defined. + */ +export function validateGrpcMatch(match: GrpcRouteMatch): void { + if (match.serviceName === undefined && match.metadata === undefined && match.methodName === undefined) { + throw new Error('At least one gRPC match property must be provided'); + } } diff --git a/packages/@aws-cdk/aws-appmesh/lib/query-parameter-match.ts b/packages/@aws-cdk/aws-appmesh/lib/query-parameter-match.ts new file mode 100644 index 0000000000000..585d810cef051 --- /dev/null +++ b/packages/@aws-cdk/aws-appmesh/lib/query-parameter-match.ts @@ -0,0 +1,54 @@ +import { CfnRoute } from './appmesh.generated'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + +/** + * Configuration for `QueryParameterMatch` + */ +export interface QueryParameterMatchConfig { + /** + * Route CFN configuration for route query parameter match. + */ + readonly queryParameterMatch: CfnRoute.QueryParameterProperty; +} + +/** + * Used to generate query parameter matching methods. + */ +export abstract class QueryParameterMatch { + /** + * The value of the query parameter with the given name in the request must match the + * specified value exactly. + * + * @param queryParameterName the name of the query parameter to match against + * @param queryParameterValue The exact value to test against + */ + static valueIs(queryParameterName: string, queryParameterValue: string): QueryParameterMatch { + return new QueryParameterMatchImpl(queryParameterName, { exact: queryParameterValue }); + } + + /** + * Returns the query parameter match configuration. + */ + public abstract bind(scope: Construct): QueryParameterMatchConfig; +} + +class QueryParameterMatchImpl extends QueryParameterMatch { + constructor( + private readonly queryParameterName: string, + private readonly matchProperty: CfnRoute.HttpQueryParameterMatchProperty, + ) { + super(); + } + + bind(_scope: Construct): QueryParameterMatchConfig { + return { + queryParameterMatch: { + match: this.matchProperty, + name: this.queryParameterName, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts b/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts index bdaa8a5fc486b..0ff6bb8d88af1 100644 --- a/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts +++ b/packages/@aws-cdk/aws-appmesh/lib/route-spec.ts @@ -2,7 +2,9 @@ import * as cdk from '@aws-cdk/core'; import { CfnRoute } from './appmesh.generated'; import { HeaderMatch } from './header-match'; import { HttpRouteMethod } from './http-route-method'; -import { validateMatchArrayLength } from './private/utils'; +import { HttpRoutePathMatch } from './http-route-path-match'; +import { validateGrpcMatch, validateGrpcMatchArrayLength, validateHttpMatchArrayLength } from './private/utils'; +import { QueryParameterMatch } from './query-parameter-match'; import { GrpcTimeout, HttpTimeout, Protocol, TcpTimeout } from './shared-interfaces'; import { IVirtualNode } from './virtual-node'; @@ -28,16 +30,15 @@ export interface WeightedTarget { } /** - * The criterion for determining a request match for this GatewayRoute + * The criterion for determining a request match for this Route */ export interface HttpRouteMatch { /** - * Specifies the path to match requests with. - * This parameter must always start with /, which by itself matches all requests to the virtual service name. - * You can also match for path-based routing of requests. For example, if your virtual service name is my-service.local - * and you want the route to match requests to my-service.local/metrics, your prefix should be /metrics. + * Specifies how is the request matched based on the path part of its URL. + * + * @default - matches requests with all paths */ - readonly prefixPath: string; + readonly path?: HttpRoutePathMatch; /** * Specifies the client request headers to match on. All specified headers @@ -60,6 +61,14 @@ export interface HttpRouteMatch { * @default - do not match on HTTP2 request protocol */ readonly protocol?: HttpRouteProtocol; + + /** + * The query parameters to match on. + * All specified query parameters must match for the route to match. + * + * @default - do not match on query parameters + */ + readonly queryParameters?: QueryParameterMatch[]; } /** @@ -78,13 +87,32 @@ export enum HttpRouteProtocol { } /** - * The criterion for determining a request match for this GatewayRoute + * The criterion for determining a request match for this Route. + * At least one match type must be selected. */ export interface GrpcRouteMatch { /** - * The fully qualified domain name for the service to match from the request + * Create service name based gRPC route match. + * + * @default - do not match on service name + */ + readonly serviceName?: string; + + /** + * Create metadata based gRPC route match. + * All specified metadata must match for the route to match. + * + * @default - do not match on metadata */ - readonly serviceName: string; + readonly metadata?: HeaderMatch[]; + + /** + * The method name to match from the request. + * If the method name is specified, service name must be also provided. + * + * @default - do not match on method name + */ + readonly methodName?: string; } /** @@ -297,7 +325,7 @@ export enum GrpcRetryEvent { } /** - * All Properties for GatewayRoute Specs + * All Properties for Route Specs */ export interface RouteSpecConfig { /** @@ -371,7 +399,7 @@ export abstract class RouteSpec { } /** - * Called when the GatewayRouteSpec type is initialized. Can be used to enforce + * Called when the RouteSpec type is initialized. Can be used to enforce * mutual exclusivity with future properties */ public abstract bind(scope: Construct): RouteSpecConfig; @@ -415,23 +443,25 @@ class HttpRouteSpec extends RouteSpec { } public bind(scope: Construct): RouteSpecConfig { - const prefixPath = this.match ? this.match.prefixPath : '/'; - if (prefixPath[0] != '/') { - throw new Error(`Prefix Path must start with \'/\', got: ${prefixPath}`); - } + const pathMatchConfig = (this.match?.path ?? HttpRoutePathMatch.startsWith('/')).bind(scope); + // Set prefix path match to '/' if none of path matches are defined. const headers = this.match?.headers; - validateMatchArrayLength(headers); + const queryParameters = this.match?.queryParameters; + + validateHttpMatchArrayLength(headers, queryParameters); const httpConfig: CfnRoute.HttpRouteProperty = { action: { weightedTargets: renderWeightedTargets(this.weightedTargets), }, match: { - prefix: prefixPath, + prefix: pathMatchConfig.prefixPathMatch, + path: pathMatchConfig.wholePathMatch, headers: headers?.map(header => header.bind(scope).headerMatch), method: this.match?.method, scheme: this.match?.protocol, + queryParameters: queryParameters?.map(queryParameter => queryParameter.bind(scope).queryParameterMatch), }, timeout: renderTimeout(this.timeout), retryPolicy: this.retryPolicy ? renderHttpRetryPolicy(this.retryPolicy) : undefined, @@ -520,7 +550,18 @@ class GrpcRouteSpec extends RouteSpec { } } - public bind(_scope: Construct): RouteSpecConfig { + public bind(scope: Construct): RouteSpecConfig { + const serviceName = this.match.serviceName; + const methodName = this.match.methodName; + const metadata = this.match.metadata; + + validateGrpcMatch(this.match); + validateGrpcMatchArrayLength(metadata); + + if (methodName && !serviceName) { + throw new Error('If you specify a method name, you must also specify a service name'); + } + return { priority: this.priority, grpcRouteSpec: { @@ -528,7 +569,9 @@ class GrpcRouteSpec extends RouteSpec { weightedTargets: renderWeightedTargets(this.weightedTargets), }, match: { - serviceName: this.match.serviceName, + serviceName: serviceName, + methodName: methodName, + metadata: metadata?.map(singleMetadata => singleMetadata.bind(scope).headerMatch), }, timeout: renderTimeout(this.timeout), retryPolicy: this.retryPolicy ? renderGrpcRetryPolicy(this.retryPolicy) : undefined, @@ -538,8 +581,8 @@ class GrpcRouteSpec extends RouteSpec { } /** -* Utility method to add weighted route targets to an existing route -*/ + * Utility method to add weighted route targets to an existing route + */ function renderWeightedTargets(weightedTargets: WeightedTarget[]): CfnRoute.WeightedTargetProperty[] { const renderedTargets: CfnRoute.WeightedTargetProperty[] = []; for (const t of weightedTargets) { diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json index 3744fe7583cf1..faafaa4f96ea6 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.expected.json @@ -931,6 +931,102 @@ "RouteName": "route-grpc-retry" } }, + "meshrouterroute699804AE1": { + "Type": "AWS::AppMesh::Route", + "Properties": { + "MeshName": { + "Fn::GetAtt": [ + "meshACDFE68E", + "MeshName" + ] + }, + "Spec": { + "Http2Route": { + "Action": { + "WeightedTargets": [ + { + "VirtualNode": { + "Fn::GetAtt": [ + "meshnode2092BA426", + "VirtualNodeName" + ] + }, + "Weight": 30 + } + ] + }, + "Match": { + "Path": { + "Regex": "regex" + }, + "QueryParameters": [ + { + "Match": { + "Exact": "value" + }, + "Name": "query-field" + } + ] + } + } + }, + "VirtualRouterName": { + "Fn::GetAtt": [ + "meshrouter81B8087E", + "VirtualRouterName" + ] + }, + "RouteName": "route-6" + } + }, + "meshrouterroute76C21E6E7": { + "Type": "AWS::AppMesh::Route", + "Properties": { + "MeshName": { + "Fn::GetAtt": [ + "meshACDFE68E", + "MeshName" + ] + }, + "Spec": { + "GrpcRoute": { + "Action": { + "WeightedTargets": [ + { + "VirtualNode": { + "Fn::GetAtt": [ + "meshnode4AE87F692", + "VirtualNodeName" + ] + }, + "Weight": 20 + } + ] + }, + "Match": { + "Metadata": [ + { + "Invert": false, + "Match": { + "Exact": "application/json" + }, + "Name": "Content-Type" + } + ], + "MethodName": "test-method", + "ServiceName": "test-service" + } + } + }, + "VirtualRouterName": { + "Fn::GetAtt": [ + "meshrouter81B8087E", + "VirtualRouterName" + ] + }, + "RouteName": "route-7" + } + }, "meshnode726C787D": { "Type": "AWS::AppMesh::VirtualNode", "Properties": { diff --git a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts index 45c1a2c4917fe..41a047415f5dc 100644 --- a/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts +++ b/packages/@aws-cdk/aws-appmesh/test/integ.mesh.ts @@ -55,7 +55,7 @@ router.addRoute('route-1', { }, ], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), }, timeout: { idle: cdk.Duration.seconds(10), @@ -158,7 +158,7 @@ router.addRoute('route-2', { }, ], match: { - prefixPath: '/path2', + path: appmesh.HttpRoutePathMatch.startsWith('/path2'), }, timeout: { idle: cdk.Duration.seconds(11), @@ -202,7 +202,7 @@ router.addRoute('route-matching', { routeSpec: appmesh.RouteSpec.http2({ weightedTargets: [{ virtualNode: node3 }], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), method: appmesh.HttpRouteMethod.POST, protocol: appmesh.HttpRouteProtocol.HTTPS, headers: [ @@ -254,6 +254,41 @@ router.addRoute('route-grpc-retry', { }), }); +router.addRoute('route-6', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [ + { + virtualNode: node2, + weight: 30, + }, + ], + match: { + path: appmesh.HttpRoutePathMatch.regex('regex'), + queryParameters: [ + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + ], + }, + }), +}); + +router.addRoute('route-7', { + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [ + { + virtualNode: node4, + weight: 20, + }, + ], + match: { + serviceName: 'test-service', + methodName: 'test-method', + metadata: [ + appmesh.HeaderMatch.valueIs('Content-Type', 'application/json'), + ], + }, + }), +}); + const gateway = mesh.addVirtualGateway('gateway1', { accessLog: appmesh.AccessLog.fromFilePath('/dev/stdout'), virtualGatewayName: 'gateway1', diff --git a/packages/@aws-cdk/aws-appmesh/test/test.route.ts b/packages/@aws-cdk/aws-appmesh/test/test.route.ts index 3e268fdbc8bd2..f28d443e35317 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.route.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.route.ts @@ -237,7 +237,7 @@ export = { }, ], match: { - prefixPath: '/node', + path: appmesh.HttpRoutePathMatch.startsWith('/node'), }, timeout: { idle: cdk.Duration.seconds(10), @@ -635,7 +635,7 @@ export = { routeSpec: appmesh.RouteSpec.http2({ weightedTargets: [{ virtualNode }], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), headers: [ appmesh.HeaderMatch.valueIs('Content-Type', 'application/json'), appmesh.HeaderMatch.valueIsNot('Content-Type', 'text/html'), @@ -749,7 +749,7 @@ export = { routeSpec: appmesh.RouteSpec.http2({ weightedTargets: [{ virtualNode }], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), method: appmesh.HttpRouteMethod.GET, }, }), @@ -791,7 +791,7 @@ export = { routeSpec: appmesh.RouteSpec.http2({ weightedTargets: [{ virtualNode }], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), protocol: appmesh.HttpRouteProtocol.HTTP, }, }), @@ -812,6 +812,281 @@ export = { test.done(); }, + 'should match routes based on metadata'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + new appmesh.Route(stack, 'test-grpc-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.grpc({ + weightedTargets: [{ virtualNode }], + match: { + metadata: [ + appmesh.HeaderMatch.valueIs('Content-Type', 'application/json'), + appmesh.HeaderMatch.valueIsNot('Content-Type', 'text/html'), + appmesh.HeaderMatch.valueStartsWith('Content-Type', 'application/'), + appmesh.HeaderMatch.valueDoesNotStartWith('Content-Type', 'text/'), + appmesh.HeaderMatch.valueEndsWith('Content-Type', '/json'), + appmesh.HeaderMatch.valueDoesNotEndWith('Content-Type', '/json+foobar'), + appmesh.HeaderMatch.valueMatchesRegex('Content-Type', 'application/.*'), + appmesh.HeaderMatch.valueDoesNotMatchRegex('Content-Type', 'text/.*'), + appmesh.HeaderMatch.valuesIsInRange('Max-Forward', 1, 5), + appmesh.HeaderMatch.valuesIsNotInRange('Max-Forward', 1, 5), + ], + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + GrpcRoute: { + Match: { + Metadata: [ + { + Invert: false, + Match: { Exact: 'application/json' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Exact: 'text/html' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { Prefix: 'application/' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Prefix: 'text/' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { Suffix: '/json' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Suffix: '/json+foobar' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { Regex: 'application/.*' }, + Name: 'Content-Type', + }, + { + Invert: true, + Match: { Regex: 'text/.*' }, + Name: 'Content-Type', + }, + { + Invert: false, + Match: { + Range: { + End: 5, + Start: 1, + }, + }, + Name: 'Max-Forward', + }, + { + Invert: true, + Match: { + Range: { + End: 5, + Start: 1, + }, + }, + Name: 'Max-Forward', + }, + ], + }, + }, + }, + })); + + test.done(); + }, + + 'should match routes based on path'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + new appmesh.Route(stack, 'test-http-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.http({ + weightedTargets: [{ virtualNode }], + match: { + path: appmesh.HttpRoutePathMatch.exactly('/exact'), + }, + }), + }); + + new appmesh.Route(stack, 'test-http2-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode }], + match: { + path: appmesh.HttpRoutePathMatch.regex('regex'), + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + HttpRoute: { + Match: { + Path: { + Exact: '/exact', + }, + }, + }, + }, + })); + + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + Http2Route: { + Match: { + Path: { + Regex: 'regex', + }, + }, + }, + }, + })); + + test.done(); + }, + + 'should match routes based query parameter'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + new appmesh.Route(stack, 'test-http-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.http({ + weightedTargets: [{ virtualNode }], + match: { + queryParameters: [appmesh.QueryParameterMatch.valueIs('query-field', 'value')], + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + HttpRoute: { + Match: { + QueryParameters: [ + { + Name: 'query-field', + Match: { + Exact: 'value', + }, + }, + ], + }, + }, + }, + })); + + test.done(); + }, + + 'should match routes based method name'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + new appmesh.Route(stack, 'test-http-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.grpc({ + priority: 20, + weightedTargets: [{ virtualNode }], + match: { + serviceName: 'test', + methodName: 'testMethod', + }, + }), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AppMesh::Route', { + Spec: { + GrpcRoute: { + Match: { + ServiceName: 'test', + MethodName: 'testMethod', + }, + }, + }, + })); + + test.done(); + }, + 'should throw an error with invalid number of headers'(test: Test) { // GIVEN const stack = new cdk.Stack(); @@ -834,7 +1109,7 @@ export = { routeSpec: appmesh.RouteSpec.http2({ weightedTargets: [{ virtualNode }], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), // Empty header headers: [], }, @@ -847,7 +1122,7 @@ export = { routeSpec: appmesh.RouteSpec.http2({ weightedTargets: [{ virtualNode }], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), // 11 headers headers: [ appmesh.HeaderMatch.valueIs('Content-Type', 'application/json'), @@ -870,6 +1145,191 @@ export = { test.done(); }, + 'should throw an error with invalid number of query parameters'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + THEN + test.throws(() => { + router.addRoute('route', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode }], + match: { + path: appmesh.HttpRoutePathMatch.startsWith('/'), + // Empty header + queryParameters: [], + }, + }), + }); + }, /Number of query parameters provided for matching must be between 1 and 10, got: 0/); + + test.throws(() => { + router.addRoute('route2', { + routeSpec: appmesh.RouteSpec.http2({ + weightedTargets: [{ virtualNode }], + match: { + path: appmesh.HttpRoutePathMatch.startsWith('/'), + // 11 headers + queryParameters: [ + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + appmesh.QueryParameterMatch.valueIs('query-field', 'value'), + ], + }, + }), + }); + }, /Number of query parameters provided for matching must be between 1 and 10, got: 11/); + + test.done(); + }, + + 'should throw an error with invalid number of metadata'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + THEN + test.throws(() => { + new appmesh.Route(stack, 'test-http-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.grpc({ + priority: 20, + weightedTargets: [{ virtualNode }], + match: { + metadata: [], + }, + }), + }); + }, /Number of metadata provided for matching must be between 1 and 10, got: 0/); + + // WHEN + THEN + test.throws(() => { + new appmesh.Route(stack, 'test-http-route-1', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.grpc({ + priority: 20, + weightedTargets: [{ virtualNode }], + match: { + metadata: [ + appmesh.HeaderMatch.valueIs('Content-Type', 'application/json'), + appmesh.HeaderMatch.valueIs('Content-Type', 'application/json'), + appmesh.HeaderMatch.valueIsNot('Content-Type', 'text/html'), + appmesh.HeaderMatch.valueStartsWith('Content-Type', 'application/'), + appmesh.HeaderMatch.valueDoesNotStartWith('Content-Type', 'text/'), + appmesh.HeaderMatch.valueEndsWith('Content-Type', '/json'), + appmesh.HeaderMatch.valueDoesNotEndWith('Content-Type', '/json+foobar'), + appmesh.HeaderMatch.valueMatchesRegex('Content-Type', 'application/.*'), + appmesh.HeaderMatch.valueDoesNotMatchRegex('Content-Type', 'text/.*'), + appmesh.HeaderMatch.valuesIsInRange('Max-Forward', 1, 5), + appmesh.HeaderMatch.valuesIsNotInRange('Max-Forward', 1, 5), + ], + }, + }), + }); + }, /Number of metadata provided for matching must be between 1 and 10, got: 11/); + + test.done(); + }, + + 'should throw an error if no gRPC match type is defined'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + THEN + test.throws(() => { + new appmesh.Route(stack, 'test-http-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.grpc({ + priority: 20, + weightedTargets: [{ virtualNode }], + match: {}, + }), + }); + }, /At least one gRPC match property must be provided/); + + test.done(); + }, + + 'should throw an error if method name is specified without service name'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const mesh = new appmesh.Mesh(stack, 'mesh', { + meshName: 'test-mesh', + }); + + const router = new appmesh.VirtualRouter(stack, 'router', { + mesh, + }); + + const virtualNode = mesh.addVirtualNode('test-node', { + serviceDiscovery: appmesh.ServiceDiscovery.dns('test'), + listeners: [appmesh.VirtualNodeListener.http()], + }); + + // WHEN + THEN + test.throws(() => { + new appmesh.Route(stack, 'test-http-route', { + mesh: mesh, + virtualRouter: router, + routeSpec: appmesh.RouteSpec.grpc({ + priority: 20, + weightedTargets: [{ virtualNode }], + match: { + methodName: 'test_method', + }, + }), + }); + }, /If you specify a method name, you must also specify a service name/); + + test.done(); + }, + 'should allow route priority'(test: Test) { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts b/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts index 4999b4425a1c7..3f7070121db4e 100644 --- a/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts +++ b/packages/@aws-cdk/aws-appmesh/test/test.virtual-router.ts @@ -1,7 +1,6 @@ import { ABSENT, expect, haveResourceLike } from '@aws-cdk/assert-internal'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; - import * as appmesh from '../lib'; export = { @@ -149,7 +148,7 @@ export = { }, ], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), }, }), }); @@ -236,7 +235,7 @@ export = { }, ], match: { - prefixPath: '/', + path: appmesh.HttpRoutePathMatch.startsWith('/'), }, }), }); @@ -250,7 +249,7 @@ export = { }, ], match: { - prefixPath: '/path2', + path: appmesh.HttpRoutePathMatch.startsWith('/path2'), }, }), }); @@ -264,7 +263,7 @@ export = { }, ], match: { - prefixPath: '/path3', + path: appmesh.HttpRoutePathMatch.startsWith('/path3'), }, }), });