diff --git a/.env.example b/.env.example index 01f153f..5869f05 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ MESH_MEMBER_HEARTBEAT_TTL_SECONDS=600 # Maximum connection time (expiresAt) for a group MESH_MAX_CONNECTION_TIME_SECONDS=1500 +# Custom Domain Settings +# APPSYNC_CUSTOM_DOMAIN=graphql.api.smalruby.app +# ROUTE53_PARENT_ZONE_NAME=api.smalruby.app + # Development Recommended Values (uncomment to use) # MESH_HOST_HEARTBEAT_INTERVAL_SECONDS=15 # MESH_HOST_HEARTBEAT_TTL_SECONDS=60 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e51e476..9dd035f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,8 @@ jobs: - name: Synthesize CDK stack run: npx cdk synth --context stage=stg + env: + APPSYNC_CUSTOM_DOMAIN: 'false' security: name: Security Audits diff --git a/bin/mesh-v2.ts b/bin/mesh-v2.ts index a7f94f2..31d450b 100644 --- a/bin/mesh-v2.ts +++ b/bin/mesh-v2.ts @@ -15,17 +15,8 @@ const stackName = stage === 'prod' ? 'MeshV2Stack' : `MeshV2Stack-${stage}`; new MeshV2Stack(app, stackName, { stackName: stackName, - /* If you don't specify 'env', this stack will be environment-agnostic. - * Account/Region-dependent features and context lookups will not work, - * but a single synthesized template can be deployed anywhere. */ - - /* Uncomment the next line to specialize this stack for the AWS Account - * and Region that are implied by the current CLI configuration. */ - // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, - - /* Uncomment the next line if you know exactly what Account and Region you - * want to deploy the stack to. */ - // env: { account: '123456789012', region: 'us-east-1' }, - - /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ + env: { + account: process.env.CDK_DEFAULT_ACCOUNT || process.env.AWS_ACCOUNT_ID, + region: process.env.CDK_DEFAULT_REGION || process.env.AWS_REGION || 'ap-northeast-1', + }, }); diff --git a/cdk.context.json b/cdk.context.json index e26be24..4be60ea 100644 --- a/cdk.context.json +++ b/cdk.context.json @@ -1,5 +1,9 @@ { "acknowledged-issue-numbers": [ 34892 - ] + ], + "hosted-zone:account=007325983811:domainName=api.smalruby.app:region=ap-northeast-1": { + "Id": "/hostedzone/Z04680371PP076PW332Q5", + "Name": "api.smalruby.app." + } } diff --git a/lib/mesh-v2-stack.ts b/lib/mesh-v2-stack.ts index 8662d4f..15e7c6a 100644 --- a/lib/mesh-v2-stack.ts +++ b/lib/mesh-v2-stack.ts @@ -2,6 +2,9 @@ import * as cdk from 'aws-cdk-lib/core'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as appsync from 'aws-cdk-lib/aws-appsync'; import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as route53 from 'aws-cdk-lib/aws-route53'; +import * as acm from 'aws-cdk-lib/aws-certificatemanager'; +import * as targets from 'aws-cdk-lib/aws-route53-targets'; import * as path from 'path'; import { Construct } from 'constructs'; @@ -16,6 +19,33 @@ export class MeshV2Stack extends cdk.Stack { const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg'; const stageSuffix = stage === 'prod' ? '' : `-${stage}`; + // Custom Domain configuration from environment variables + const parentZoneName = process.env.ROUTE53_PARENT_ZONE_NAME || 'api.smalruby.app'; + const defaultCustomDomain = stage === 'prod' ? `graphql.${parentZoneName}` : `${stage}.graphql.${parentZoneName}`; + const customDomain = process.env.APPSYNC_CUSTOM_DOMAIN === 'false' + ? undefined + : (process.env.APPSYNC_CUSTOM_DOMAIN || defaultCustomDomain); + + let domainOptions: appsync.DomainOptions | undefined; + let zone: route53.IHostedZone | undefined; + + if (customDomain) { + zone = route53.HostedZone.fromLookup(this, 'HostedZone', { + domainName: parentZoneName, + }); + + const certificate = new acm.DnsValidatedCertificate(this, 'ApiCertificate', { + domainName: customDomain, + hostedZone: zone, + region: 'us-east-1', + }); + + domainOptions = { + certificate, + domainName: customDomain, + }; + } + // Stack全体にタグ付与 cdk.Tags.of(this).add('Project', 'MeshV2'); cdk.Tags.of(this).add('Stage', stage); @@ -77,6 +107,7 @@ export class MeshV2Stack extends cdk.Stack { this.api = new appsync.GraphqlApi(this, 'MeshV2Api', { name: `MeshV2Api${stageSuffix}`, definition: appsync.Definition.fromFile(path.join(__dirname, '../graphql/schema.graphql')), + domainName: domainOptions, authorizationConfig: { defaultAuthorization: { authorizationType: appsync.AuthorizationType.API_KEY, @@ -100,6 +131,18 @@ export class MeshV2Stack extends cdk.Stack { }, }); + // Route53 Alias record for Custom Domain + if (customDomain && zone) { + // Extract subdomain from customDomain (e.g., "graphql.api.smalruby.app" -> "graphql") + const subdomain = customDomain.replace(`.${parentZoneName}`, ''); + + new route53.ARecord(this, 'ApiAliasRecord', { + zone, + recordName: subdomain, + target: route53.RecordTarget.fromAlias(new targets.AppSyncTarget(this.api)), + }); + } + // AppSync APIにタグ付与 cdk.Tags.of(this.api).add('ResourceType', 'GraphQLAPI'); @@ -420,5 +463,13 @@ export class MeshV2Stack extends cdk.Stack { value: this.api.apiId, description: 'AppSync GraphQL API ID', }); + + // Output Custom Domain URL + if (customDomain) { + new cdk.CfnOutput(this, 'CustomDomainUrl', { + value: `https://${customDomain}/graphql`, + description: 'AppSync Custom Domain URL', + }); + } } } diff --git a/spec/requests/subscriptions_spec.rb b/spec/requests/subscriptions_spec.rb index aea171b..7deb3c1 100644 --- a/spec/requests/subscriptions_spec.rb +++ b/spec/requests/subscriptions_spec.rb @@ -138,7 +138,7 @@ puts "2. Connect to AppSync WebSocket endpoint:" puts " API_URL='#{ENV["APPSYNC_ENDPOINT"]}'" puts " API_KEY='#{ENV["APPSYNC_API_KEY"]}'" - puts " WS_URL=$(echo $API_URL | sed 's/https:/wss:/g' | sed 's/graphql$/graphql\\/connect/g')" + puts " WS_URL=$(echo $API_URL | sed 's/https:/wss:/g' | sed 's/graphql$/graphql\\/realtime/g')" puts "" puts "3. Subscribe to onMessageInGroup:" puts " subscription {" diff --git a/spec/support/appsync_subscription_helper.rb b/spec/support/appsync_subscription_helper.rb index 9d3f43a..f1f6986 100644 --- a/spec/support/appsync_subscription_helper.rb +++ b/spec/support/appsync_subscription_helper.rb @@ -132,9 +132,16 @@ def subscribe_and_execute(query, variables = {}, wait_time: 2, timeout: 15, &blo private def convert_to_websocket_endpoint(https_endpoint) - https_endpoint - .sub("https://", "wss://") - .sub(".appsync-api.", ".appsync-realtime-api.") + if https_endpoint.include?(".appsync-api.") + https_endpoint + .sub("https://", "wss://") + .sub(".appsync-api.", ".appsync-realtime-api.") + else + # For custom domains, AppSync WebSocket endpoint requires /realtime path + https_endpoint + .sub("https://", "wss://") + .sub(/\/graphql\/?$/, "/graphql/realtime") + end end def extract_host(endpoint)