Coverage for gco / stacks / regional_api_gateway_stack.py: 100.00%
98 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""
2Regional API Gateway bridge for authenticated access to private regional ALBs.
4Every deployment creates this regional bridge so the centralized aggregator has
5a reachable, IAM-authenticated path into each regional VPC. In the commercial
6``aws`` partition, ``api_gateway.regional_api_enabled`` optionally admits other
7same-account principals. In every other AWS partition, Global Accelerator is
8omitted and this regional IAM path is enabled for same-account callers
9regardless of that setting.
11Architecture:
12 Aggregator → Regional API Gateway → buffered VPC Lambda → Internal ALB → EKS pods
13 User (optional in ``aws``; required elsewhere) ────────┤
14 └→ streaming VPC Lambda → inference proxy
16Security:
17 - API Gateway uses AWS-managed TLS and IAM authentication (SigV4)
18 - The resource policy always admits only the aggregator role by default
19 - Optional direct mode additionally admits IAM-authorized account principals
20 - Lambda runs inside the VPC with access to the internal ALB
21 - Lambda verifies the deployment-local ALB certificate with explicit SNI
22 - Lambda adds a short-lived per-request HMAC envelope to the ALB request
23 - No public exposure of the ALB or EKS API
25Configuration:
26 In the commercial ``aws`` partition, set
27 ``api_gateway.regional_api_enabled`` to ``true`` when callers need direct
28 region-pinned access. Outside that partition, the regional API is the
29 supported workload ingress and same-account access is forced on. Global
30 aggregation always uses its dedicated role in every partition.
31"""
33from typing import Any
35from aws_cdk import (
36 CfnOutput,
37 Duration,
38 RemovalPolicy,
39 Stack,
40)
41from aws_cdk import aws_apigateway as apigateway
42from aws_cdk import aws_ec2 as ec2
43from aws_cdk import aws_iam as iam
44from aws_cdk import aws_lambda as lambda_
45from aws_cdk import aws_logs as logs
46from constructs import Construct
48from gco.config.config_loader import ConfigLoader
49from gco.stacks.constants import (
50 AGGREGATOR_REGIONAL_API_ROUTES,
51 DEFAULT_MAX_REQUEST_BODY_BYTES,
52 LAMBDA_NODEJS_RUNTIME,
53 LAMBDA_PYTHON_RUNTIME,
54 api_gateway_auth_secret_name,
55 backend_tls_root_ca_parameter_name,
56 backend_tls_server_name,
57 validated_request_body_limit,
58)
59from gco.stacks.lambda_assets import lambda_source_code
61# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit
62# Generated at (UTC): 2026-10-16T16:57:50Z
63# Generated from Git commit: 3458f76ef1541be718c00458b8ecc8f7c770c6ad
64# Flowchart(s) generated from this file:
65# * ``GCORegionalApiGatewayStack.__init__`` -> ``diagrams/code_diagrams/gco/stacks/regional_api_gateway_stack.GCORegionalApiGatewayStack___init__.html``
66# (PNG: ``diagrams/code_diagrams/gco/stacks/regional_api_gateway_stack.GCORegionalApiGatewayStack___init__.png``)
67# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``.
68# <pyflowchart-code-diagram> END
71class GCORegionalApiGatewayStack(Stack):
72 """Regional aggregation bridge with optional direct caller access.
74 The VPC Lambda gives the global aggregator a reachable path to one internal
75 regional ALB. Direct region-pinned access for other IAM-authorized account
76 principals is optional in the commercial ``aws`` partition and mandatory
77 in partitions where Global Accelerator is unavailable.
79 Attributes:
80 api: Regional REST API with IAM authentication.
81 proxy_lambda: Buffered VPC Lambda for ``/api/v1/*`` requests.
82 inference_proxy_lambda: Response-streaming VPC Lambda for ``/inference/*``.
83 """
85 def __init__(
86 self,
87 scope: Construct,
88 construct_id: str,
89 config: ConfigLoader,
90 region: str,
91 vpc: ec2.IVpc,
92 auth_secret_arn: str,
93 aggregator_role_arn: str,
94 alb_dns_name: str | None = None,
95 **kwargs: Any,
96 ) -> None:
97 super().__init__(scope, construct_id, **kwargs)
99 self.config = config
100 self.deployment_region = region
101 self.vpc = vpc
102 self.alb_dns_name = alb_dns_name
103 supports_global_accelerator = getattr(config, "supports_global_accelerator", None)
104 self.global_accelerator_enabled = (
105 bool(supports_global_accelerator()) if callable(supports_global_accelerator) else True
106 )
107 self.auth_secret_arn = auth_secret_arn
108 self.aggregator_role_arn = aggregator_role_arn
110 # Keep control-plane calls on the established buffered Python proxy and
111 # give inference a separate Node.js response-streaming runtime.
112 self.proxy_lambda = self._create_vpc_proxy_lambda()
113 self.inference_proxy_lambda = self._create_inference_proxy_lambda()
115 # Create regional API Gateway
116 self.api = self._create_api_gateway()
118 # Export outputs
119 self._create_outputs()
121 # Apply cdk-nag suppressions
122 self._apply_nag_suppressions()
124 def _apply_nag_suppressions(self) -> None:
125 """Apply cdk-nag suppressions for this stack."""
126 from gco.stacks.nag_suppressions import apply_all_suppressions
128 apply_all_suppressions(
129 self,
130 stack_type="regional_api_gateway",
131 global_region=self.config.get_global_region(),
132 project_name=self.config.get_project_name(),
133 )
135 def _auth_secret_read_resource(self) -> str:
136 """IAM resource for reading the backend HMAC auth secret.
138 Built from the secret's name, the API Gateway Region, and this
139 account, never from ``self.auth_secret_arn``. That argument is a
140 cross-stack reference: an ARN built from the secret's name, without the
141 random suffix, when this stack is in another Region from the API
142 Gateway stack, but a native export
143 (``<project>-api-gateway:ExportsOutputRef...``) when it shares that
144 Region. A grant built on the export failed ``cdk synth`` there (PR #461;
145 issue #125 was the same failure on the regional stack's roles).
147 The Region must be the API Gateway's, not the global one: the API
148 Gateway stack creates the secret, and ``deployment_regions`` lets the
149 two differ, in which case a grant in the global Region denies every
150 read. ``-??????`` matches the six random characters Secrets Manager
151 appends to the name in the ARN, and no other secret. IAM evaluates the
152 request against that full ARN even though, outside the API Gateway
153 Region, the Lambdas name the secret by the suffix-less ARN they get in
154 ``SECRET_ARN``.
155 """
156 return (
157 f"arn:{self.partition}:secretsmanager:{self.config.get_api_gateway_region()}:"
158 f"{self.account}:secret:"
159 f"{api_gateway_auth_secret_name(self.config.get_project_name())}-??????"
160 )
162 def _create_vpc_proxy_lambda(self) -> lambda_.Function:
163 """Create VPC Lambda that proxies requests to internal ALB."""
164 project_name = self.config.get_project_name()
165 backend_tls_config = self.config.get_backend_tls_config()
166 root_ca_parameter_name = backend_tls_root_ca_parameter_name(project_name)
168 # Create security group for Lambda
169 lambda_sg = ec2.SecurityGroup(
170 self,
171 "ProxyLambdaSg",
172 vpc=self.vpc,
173 description="Security group for regional API proxy Lambdas",
174 allow_all_outbound=True,
175 )
176 self._proxy_lambda_security_group = lambda_sg
178 # Create IAM role for Lambda
179 # role_name intentionally omitted - let CDK generate unique name
180 lambda_role = iam.Role(
181 self,
182 "ProxyLambdaRole",
183 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
184 managed_policies=[
185 iam.ManagedPolicy.from_aws_managed_policy_name(
186 "service-role/AWSLambdaVPCAccessExecutionRole"
187 )
188 ],
189 )
191 # Grant read access to the auth secret, scoped to its deterministic ARN
192 # in the API Gateway Region (see ``_auth_secret_read_resource``).
193 lambda_role.add_to_policy(
194 iam.PolicyStatement(
195 effect=iam.Effect.ALLOW,
196 actions=[
197 "secretsmanager:GetSecretValue",
198 "secretsmanager:DescribeSecret",
199 ],
200 resources=[self._auth_secret_read_resource()],
201 )
202 )
204 # The Ingress-created ALB does not exist during CDK synthesis. Resolve
205 # its current hostname from the project-scoped SSM registry at request
206 # time, then verify that the hostname belongs to this account, region,
207 # EKS cluster, and platform Ingress before forwarding any request.
208 registry_region = self.config.get_global_region()
209 registry_parameter_arn = (
210 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
211 f"parameter/{project_name}/alb-hostname-{self.deployment_region}"
212 )
213 root_ca_parameter_arn = (
214 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
215 f"parameter/{root_ca_parameter_name.lstrip('/')}"
216 )
217 lambda_role.add_to_policy(
218 iam.PolicyStatement(
219 effect=iam.Effect.ALLOW,
220 actions=["ssm:GetParameter"],
221 resources=[registry_parameter_arn, root_ca_parameter_arn],
222 )
223 )
224 lambda_role.add_to_policy(
225 iam.PolicyStatement(
226 effect=iam.Effect.ALLOW,
227 actions=[
228 "elasticloadbalancing:DescribeLoadBalancers",
229 "elasticloadbalancing:DescribeTags",
230 ],
231 resources=["*"],
232 )
233 )
235 from gco.stacks.nag_suppressions import acknowledge_nag_findings
237 acknowledge_nag_findings(
238 lambda_role,
239 [
240 {
241 "id": "AwsSolutions-IAM5",
242 "reason": (
243 "ELB DescribeLoadBalancers and DescribeTags do not support "
244 "resource-level scoping. They are read-only and are used only "
245 "to verify that the SSM-registered hostname belongs to this "
246 "account's exact regional GCO cluster and platform Ingress."
247 ),
248 "appliesTo": ["Resource::*"],
249 }
250 ],
251 )
253 # Create log group
254 # log_group_name intentionally omitted - let CDK generate unique name
255 log_group = logs.LogGroup(
256 self,
257 "ProxyLambdaLogGroup",
258 retention=logs.RetentionDays.ONE_WEEK,
259 removal_policy=RemovalPolicy.DESTROY,
260 )
262 # A literal endpoint remains available for isolated stack synthesis and
263 # compatibility callers. Production app wiring omits it so replacements
264 # are discovered from SSM without requiring an ALB at deploy time.
265 environment = {
266 "SECRET_ARN": self.auth_secret_arn,
267 "REGISTRY_REGION": registry_region,
268 "TARGET_REGION": self.deployment_region,
269 "PROJECT_NAME": project_name,
270 "AWS_ACCOUNT_ID": self.account,
271 "AWS_URL_SUFFIX": self.url_suffix,
272 "BACKEND_TLS_SERVER_NAME": backend_tls_server_name(project_name),
273 "BACKEND_TLS_ROOT_CA_PARAMETER": root_ca_parameter_name,
274 "BACKEND_TLS_ROOT_CA_REGION": registry_region,
275 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str(backend_tls_config["trust_cache_ttl_seconds"]),
276 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str(
277 backend_tls_config["trust_cache_max_stale_seconds"]
278 ),
279 }
280 if self.alb_dns_name:
281 environment["ALB_ENDPOINT"] = self.alb_dns_name
283 # Create Lambda function in VPC
284 proxy_lambda = lambda_.Function(
285 self,
286 "RegionalProxyFunction",
287 function_name=f"{project_name}-regional-proxy-{self.deployment_region}",
288 runtime=getattr(lambda_.Runtime, LAMBDA_PYTHON_RUNTIME),
289 handler="handler.lambda_handler",
290 code=lambda_source_code("lambda/regional-api-proxy"),
291 timeout=Duration.seconds(29),
292 memory_size=256,
293 role=lambda_role,
294 vpc=self.vpc,
295 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
296 security_groups=[lambda_sg],
297 environment=environment,
298 log_group=log_group,
299 description=f"Regional API proxy for {self.deployment_region} (VPC Lambda)",
300 tracing=lambda_.Tracing.ACTIVE,
301 )
303 return proxy_lambda
305 def _create_inference_proxy_lambda(self) -> lambda_.Function:
306 """Create the VPC Lambda that streams inference responses from the ALB."""
307 project_name = self.config.get_project_name()
308 backend_tls_config = self.config.get_backend_tls_config()
309 max_request_body_bytes = validated_request_body_limit(
310 self.config.get_manifest_processor_config().get(
311 "max_request_body_bytes", DEFAULT_MAX_REQUEST_BODY_BYTES
312 )
313 )
314 registry_region = self.config.get_global_region()
315 root_ca_parameter_name = backend_tls_root_ca_parameter_name(project_name)
316 registry_parameter_arn = (
317 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
318 f"parameter/{project_name}/alb-hostname-{self.deployment_region}"
319 )
320 root_ca_parameter_arn = (
321 f"arn:{self.partition}:ssm:{registry_region}:{self.account}:"
322 f"parameter/{root_ca_parameter_name.lstrip('/')}"
323 )
325 role = iam.Role(
326 self,
327 "InferenceStreamingProxyRole",
328 assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
329 managed_policies=[
330 iam.ManagedPolicy.from_aws_managed_policy_name(
331 "service-role/AWSLambdaVPCAccessExecutionRole"
332 )
333 ],
334 )
335 # The HMAC secret lives in the API Gateway Region, not the global one
336 # ``registry_region`` names (see ``_auth_secret_read_resource``).
337 role.add_to_policy(
338 iam.PolicyStatement(
339 effect=iam.Effect.ALLOW,
340 actions=[
341 "secretsmanager:GetSecretValue",
342 "secretsmanager:DescribeSecret",
343 ],
344 resources=[self._auth_secret_read_resource()],
345 )
346 )
347 role.add_to_policy(
348 iam.PolicyStatement(
349 effect=iam.Effect.ALLOW,
350 actions=["ssm:GetParameter"],
351 resources=[registry_parameter_arn, root_ca_parameter_arn],
352 )
353 )
354 role.add_to_policy(
355 iam.PolicyStatement(
356 effect=iam.Effect.ALLOW,
357 actions=[
358 "elasticloadbalancing:DescribeLoadBalancers",
359 "elasticloadbalancing:DescribeTags",
360 ],
361 resources=["*"],
362 )
363 )
365 from gco.stacks.nag_suppressions import acknowledge_nag_findings
367 acknowledge_nag_findings(
368 role,
369 [
370 {
371 "id": "AwsSolutions-IAM5",
372 "reason": (
373 "ELB ownership verification and the Lambda VPC/X-Ray APIs do not "
374 "support resource-level scoping. Secret and SSM reads remain "
375 "scoped to this deployment's exact resources."
376 ),
377 "appliesTo": ["Resource::*"],
378 }
379 ],
380 )
382 log_group = logs.LogGroup(
383 self,
384 "InferenceStreamingProxyLogGroup",
385 retention=logs.RetentionDays.ONE_WEEK,
386 removal_policy=RemovalPolicy.DESTROY,
387 )
388 return lambda_.Function(
389 self,
390 "InferenceStreamingProxyFunction",
391 function_name=(f"{project_name}-regional-inference-proxy-{self.deployment_region}"),
392 runtime=getattr(lambda_.Runtime, LAMBDA_NODEJS_RUNTIME),
393 handler="index.handler",
394 code=lambda_.Code.from_asset("lambda/inference-streaming-proxy-build"),
395 timeout=Duration.minutes(15),
396 memory_size=256,
397 role=role,
398 vpc=self.vpc,
399 vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
400 security_groups=[self._proxy_lambda_security_group],
401 environment={
402 "ROUTING_MODE": "regional",
403 "MAX_REQUEST_BODY_BYTES": str(max_request_body_bytes),
404 "SECRET_ARN": self.auth_secret_arn,
405 "REGISTRY_REGION": registry_region,
406 "TARGET_REGION": self.deployment_region,
407 "PROJECT_NAME": project_name,
408 "AWS_ACCOUNT_ID": self.account,
409 "AWS_URL_SUFFIX": self.url_suffix,
410 "BACKEND_TLS_SERVER_NAME": backend_tls_server_name(project_name),
411 "BACKEND_TLS_ROOT_CA_PARAMETER": root_ca_parameter_name,
412 "BACKEND_TLS_ROOT_CA_REGION": registry_region,
413 "BACKEND_TLS_CA_CACHE_TTL_SECONDS": str(
414 backend_tls_config["trust_cache_ttl_seconds"]
415 ),
416 "BACKEND_TLS_CA_MAX_STALE_SECONDS": str(
417 backend_tls_config["trust_cache_max_stale_seconds"]
418 ),
419 },
420 log_group=log_group,
421 description=(
422 f"Regional inference response-streaming proxy for {self.deployment_region}"
423 ),
424 tracing=lambda_.Tracing.ACTIVE,
425 )
427 def _create_api_gateway(self) -> apigateway.RestApi:
428 """Create regional API Gateway with IAM authentication."""
429 project_name = self.config.get_project_name()
431 # Create CloudWatch log group
432 # log_group_name intentionally omitted - let CDK generate unique name
433 api_log_group = logs.LogGroup(
434 self,
435 "ApiGatewayLogs",
436 retention=logs.RetentionDays.ONE_MONTH,
437 removal_policy=RemovalPolicy.DESTROY,
438 )
440 api_config = self.config.get_api_gateway_config()
441 configured_log_level = str(api_config["log_level"]).upper()
442 logging_levels = {
443 "OFF": apigateway.MethodLoggingLevel.OFF,
444 "ERROR": apigateway.MethodLoggingLevel.ERROR,
445 "INFO": apigateway.MethodLoggingLevel.INFO,
446 }
447 if configured_log_level not in logging_levels:
448 raise ValueError(
449 "api_gateway.log_level must be one of OFF, ERROR, or INFO; "
450 f"got {configured_log_level!r}"
451 )
453 # Create regional REST API
454 api = apigateway.RestApi(
455 self,
456 "RegionalApi",
457 rest_api_name=f"{project_name}-regional-api-{self.deployment_region}",
458 description=f"Direct regional API for {project_name} in {self.deployment_region}",
459 endpoint_types=[apigateway.EndpointType.REGIONAL],
460 deploy=True,
461 deploy_options=apigateway.StageOptions(
462 stage_name="prod",
463 throttling_rate_limit=api_config["throttle_rate_limit"],
464 throttling_burst_limit=api_config["throttle_burst_limit"],
465 logging_level=logging_levels[configured_log_level],
466 # Never put inference prompts/responses (or other API bodies)
467 # into execution logs. Standard access logs and metrics remain.
468 data_trace_enabled=False,
469 metrics_enabled=api_config["metrics_enabled"],
470 tracing_enabled=api_config["tracing_enabled"],
471 access_log_destination=apigateway.LogGroupLogDestination(api_log_group),
472 access_log_format=apigateway.AccessLogFormat.json_with_standard_fields(
473 caller=True,
474 http_method=True,
475 ip=True,
476 protocol=True,
477 request_time=True,
478 resource_path=True,
479 response_length=True,
480 status=True,
481 user=True,
482 ),
483 ),
484 cloud_watch_role=True,
485 # CDK otherwise retains the generated API Gateway account role.
486 cloud_watch_role_removal_policy=RemovalPolicy.DESTROY,
487 )
489 # The bridge is private at the authorization layer by default: only
490 # the aggregator execution role is named in the API resource policy.
491 api.add_to_resource_policy(
492 iam.PolicyStatement(
493 effect=iam.Effect.ALLOW,
494 principals=[iam.ArnPrincipal(self.aggregator_role_arn)],
495 actions=["execute-api:Invoke"],
496 resources=[
497 f"execute-api:/*/{method}/{path}"
498 for method, path in AGGREGATOR_REGIONAL_API_ROUTES
499 ],
500 )
501 )
503 # Direct regional mode is an explicit opt-in in the commercial
504 # partition. It becomes the required supported ingress in partitions
505 # where Global Accelerator does not exist. Methods still require SigV4
506 # and callers still need identity-policy permission to invoke.
507 if (
508 self.config.get_api_gateway_config()["regional_api_enabled"]
509 or not self.global_accelerator_enabled
510 ):
511 api.add_to_resource_policy(
512 iam.PolicyStatement(
513 effect=iam.Effect.ALLOW,
514 principals=[iam.AnyPrincipal()],
515 actions=["execute-api:Invoke"],
516 resources=["execute-api:/*"],
517 conditions={
518 "StringEquals": {"aws:PrincipalAccount": self.account},
519 "ArnNotEquals": {"aws:PrincipalArn": self.aggregator_role_arn},
520 },
521 )
522 )
524 # Keep control-plane integration semantics unchanged. Inference uses
525 # InvokeWithResponseStream and may remain open for API Gateway's full
526 # 15-minute streaming integration window; request bodies are buffered.
527 control_plane_integration = apigateway.LambdaIntegration(
528 self.proxy_lambda, proxy=True, timeout=Duration.seconds(29)
529 )
530 inference_integration = apigateway.LambdaIntegration(
531 self.inference_proxy_lambda,
532 proxy=True,
533 timeout=Duration.minutes(15),
534 response_transfer_mode=apigateway.ResponseTransferMode.STREAM,
535 )
537 # API Gateway greedy resources do not cross a root segment, so
538 # /api/v1/{proxy+} cannot match /inference/{endpoint}/....
539 api_resource = api.root.add_resource("api")
540 v1_resource = api_resource.add_resource("v1")
541 api_proxy_resource = v1_resource.add_resource("{proxy+}")
542 inference_resource = api.root.add_resource("inference")
543 inference_proxy_resource = inference_resource.add_resource("{proxy+}")
545 for method in ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]:
546 api_proxy_resource.add_method(
547 method,
548 control_plane_integration,
549 authorization_type=apigateway.AuthorizationType.IAM,
550 method_responses=[
551 apigateway.MethodResponse(status_code="200"),
552 apigateway.MethodResponse(status_code="400"),
553 apigateway.MethodResponse(status_code="403"),
554 apigateway.MethodResponse(status_code="500"),
555 ],
556 )
558 for method in ["GET", "HEAD", "POST"]:
559 inference_proxy_resource.add_method(
560 method,
561 inference_integration,
562 authorization_type=apigateway.AuthorizationType.IAM,
563 method_responses=[
564 apigateway.MethodResponse(status_code="200"),
565 apigateway.MethodResponse(status_code="400"),
566 apigateway.MethodResponse(status_code="404"),
567 apigateway.MethodResponse(status_code="500"),
568 apigateway.MethodResponse(status_code="502"),
569 ],
570 )
572 from gco.stacks.nag_suppressions import acknowledge_nag_findings
574 acknowledge_nag_findings(
575 api.deployment_stage,
576 [
577 {
578 "id": "AwsSolutions-APIG3",
579 "reason": (
580 "This regional bridge is not a general public API: every method "
581 "requires SigV4 and its resource policy admits only the exact "
582 "aggregator role unless account-local direct access is explicitly "
583 "enabled. A separate WAF would duplicate those identity controls."
584 ),
585 },
586 {
587 "id": "NIST.800.53.R5-APIGWAssociatedWithWAF",
588 "reason": (
589 "The IAM-authenticated regional bridge has an aggregator-only resource "
590 "policy by default; unauthorized traffic is rejected before integration."
591 ),
592 },
593 {
594 "id": "PCI.DSS.321-APIGWAssociatedWithWAF",
595 "reason": (
596 "The IAM-authenticated regional bridge has an aggregator-only resource "
597 "policy by default and carries no payment-card-specific public surface."
598 ),
599 },
600 ],
601 )
603 return api
605 def _create_outputs(self) -> None:
606 """Export regional API Gateway endpoint."""
607 project_name = self.config.get_project_name()
609 CfnOutput(
610 self,
611 "RegionalApiEndpoint",
612 value=self.api.url,
613 description=f"Regional API Gateway endpoint for {self.deployment_region}",
614 export_name=f"{project_name}-regional-api-endpoint-{self.deployment_region}",
615 )