Skip to content

Commit

Permalink
Api: added external custom domain support (#1138)
Browse files Browse the repository at this point in the history
* added external custom domain support to Api construct

* removed console log statement

* refactored the code

* allow uppercase domain names

* Rewrite to make the code more readable

* Add tests for WebSocket

* Add docs

Co-authored-by: Frank <wangfanjie@gmail.com>
  • Loading branch information
Manitej66 and fwang committed Jan 16, 2022
1 parent 6f64e60 commit 10126d8
Show file tree
Hide file tree
Showing 6 changed files with 445 additions and 177 deletions.
340 changes: 212 additions & 128 deletions packages/resources/src/util/apiGatewayV2Domain.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Construct } from 'constructs';
import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";
import * as apig from "@aws-cdk/aws-apigatewayv2-alpha";
import * as route53 from "aws-cdk-lib/aws-route53";
Expand All @@ -10,6 +10,7 @@ export interface CustomDomainProps {
readonly hostedZone?: string | route53.IHostedZone;
readonly certificate?: acm.ICertificate;
readonly path?: string;
readonly isExternalDomain?: boolean;
}

export interface CustomDomainData {
Expand All @@ -28,161 +29,244 @@ export function buildCustomDomainData(
if (customDomain === undefined) {
return;
}

// To be implemented: to allow more flexible use cases, SST should support 3 more use cases:
// 1. Allow user passing in `hostedZone` object. The use case is when there are multiple
// HostedZones with the same domain, but one is public, and one is private.
// 2. Allow user passing in `certificate` object. The use case is for user to create wildcard
// certificate or using an imported certificate.
// 3. Allow user passing in `apigDomain` object. The use case is a user creates multiple API
// endpoints, and is mapping them under the same custom domain. `sst.Api` needs to expose the
// `apigDomain` construct created in the first Api, and lets user pass it in when creating
// the second Api.

let domainName,
hostedZone,
hostedZoneDomain,
certificate,
apigDomain,
mappingKey;
let isApigDomainCreated = false;
let isCertificatedCreated = false;

///////////////////
// Parse input
///////////////////

// customDomain is a string
if (typeof customDomain === "string") {
// validate: customDomain is a TOKEN string
// ie. imported SSM value: ssm.StringParameter.valueForStringParameter()
if (cdk.Token.isUnresolved(customDomain)) {
throw new Error(
`You also need to specify the "hostedZone" if the "domainName" is passed in as a reference.`
);
}

domainName = customDomain;
assertDomainNameIsLowerCase(domainName);
hostedZoneDomain = customDomain.split(".").slice(1).join(".");
else if (typeof customDomain === "string") {
return buildDataForStringInput(scope, customDomain);
}

// customDomain.domainName not exists
else if (!customDomain.domainName) {
throw new Error(`Missing "domainName" in sst.Api's customDomain setting`);
}

// customDomain.domainName is a string
else if (typeof customDomain.domainName === "string") {
// parse customDomain.domainName
if (cdk.Token.isUnresolved(customDomain.domainName)) {
// If customDomain is a TOKEN string, "hostedZone" has to be passed in. This
// is because "hostedZone" cannot be parsed from a TOKEN value.
if (!customDomain.hostedZone) {
throw new Error(
`You also need to specify the "hostedZone" if the "domainName" is passed in as a reference.`
);
}
domainName = customDomain.domainName;
} else {
domainName = customDomain.domainName;
assertDomainNameIsLowerCase(domainName);
}

// parse customDomain.hostedZone
if (!customDomain.hostedZone) {
hostedZoneDomain = domainName.split(".").slice(1).join(".");
} else if (typeof customDomain.hostedZone === "string") {
hostedZoneDomain = customDomain.hostedZone;
} else {
hostedZone = customDomain.hostedZone;
}
return customDomain.isExternalDomain
? buildDataForExternalDomainInput(scope, customDomain)
: buildDataForInternalDomainInput(scope, customDomain);
}
// customDomain.domainName is a construct
return buildDataForConstructInput(scope, customDomain);
}

certificate = customDomain.certificate;
mappingKey = customDomain.path;
function buildDataForStringInput(
scope: Construct,
customDomain: string
): CustomDomainData {
// validate: customDomain is a TOKEN string
// ie. imported SSM value: ssm.StringParameter.valueForStringParameter()
if (cdk.Token.isUnresolved(customDomain)) {
throw new Error(
`You also need to specify the "hostedZone" if the "domainName" is passed in as a reference.`
);
}

// customDomain.domainName is a construct
else {
if (customDomain.hostedZone) {
throw new Error(
`Cannot configure the "hostedZone" when the "domainName" is a construct`
);
}
if (customDomain.certificate) {
assertDomainNameIsLowerCase(customDomain);

const domainName = customDomain;
const hostedZoneDomain = domainName.split(".").slice(1).join(".");
const hostedZone = lookupHostedZone(scope, hostedZoneDomain);
const certificate = createCertificate(scope, domainName, hostedZone);
const apigDomain = createApigDomain(scope, domainName, certificate);
const record = createARecord(scope, hostedZone, domainName, apigDomain);

return {
apigDomain,
certificate,
isApigDomainCreated: true,
isCertificatedCreated: true,
url: buildDomainUrl(domainName),
};
}

function buildDataForInternalDomainInput(
scope: Construct,
customDomain: CustomDomainProps
): CustomDomainData {
// If customDomain is a TOKEN string, "hostedZone" has to be passed in. This
// is because "hostedZone" cannot be parsed from a TOKEN value.
if (cdk.Token.isUnresolved(customDomain.domainName)) {
if (!customDomain.hostedZone) {
throw new Error(
`Cannot configure the "certificate" when the "domainName" is a construct`
`You also need to specify the "hostedZone" if the "domainName" is passed in as a reference.`
);
}

domainName = customDomain.domainName.name;
apigDomain = customDomain.domainName;
mappingKey = customDomain.path;
} else {
assertDomainNameIsLowerCase(customDomain.domainName as string);
}

///////////////////
// Create domain
///////////////////
if (!apigDomain && domainName) {
// Look up hosted zone
if (!hostedZone && hostedZoneDomain) {
hostedZone = route53.HostedZone.fromLookup(scope, "HostedZone", {
domainName: hostedZoneDomain,
});
}
const domainName = customDomain.domainName as string;

// Create certificate
if (!certificate) {
certificate = new acm.Certificate(scope, "Certificate", {
domainName,
validation: acm.CertificateValidation.fromDns(hostedZone),
});
isCertificatedCreated = true;
}
// Lookup hosted zone
// Note: Allow user passing in `hostedZone` object. The use case is when
// there are multiple HostedZones with the same domain, but one is
// public, and one is private.

// Create custom domain in API Gateway
apigDomain = new apig.DomainName(scope, "DomainName", {
domainName,
certificate,
});

// Create DNS record
const record = new route53.ARecord(scope, "AliasRecord", {
recordName: domainName,
zone: hostedZone as route53.IHostedZone,
target: route53.RecordTarget.fromAlias(
new route53Targets.ApiGatewayv2DomainProperties(
apigDomain.regionalDomainName,
apigDomain.regionalHostedZoneId
)
),
});
// note: If domainName is a TOKEN string ie. ${TOKEN..}, the route53.ARecord
// construct will append ".${hostedZoneName}" to the end of the domain.
// This is because the construct tries to check if the record name
// ends with the domain name. If not, it will append the domain name.
// So, we need remove this behavior.
if (cdk.Token.isUnresolved(domainName)) {
const cfnRecord = record.node.defaultChild as route53.CfnRecordSet;
cfnRecord.name = domainName;
}
let hostedZone;
if (!customDomain.hostedZone) {
const hostedZoneDomain = domainName.split(".").slice(1).join(".");
hostedZone = lookupHostedZone(scope, hostedZoneDomain);
} else if (typeof customDomain.hostedZone === "string") {
const hostedZoneDomain = customDomain.hostedZone;
hostedZone = lookupHostedZone(scope, hostedZoneDomain);
} else {
hostedZone = customDomain.hostedZone;
}

isApigDomainCreated = true;
// Create certificate
// Note: Allow user passing in `certificate` object. The use case is for
// user to create wildcard certificate or using an imported certificate.
let certificate, isCertificatedCreated;
if (customDomain.certificate) {
certificate = customDomain.certificate;
isCertificatedCreated = false;
} else {
certificate = createCertificate(scope, domainName, hostedZone);
isCertificatedCreated = true;
}

const apigDomain = createApigDomain(scope, domainName, certificate);
const mappingKey = customDomain.path;
const record = createARecord(scope, hostedZone, domainName, apigDomain);

return {
apigDomain: apigDomain as apig.IDomainName,
apigDomain,
mappingKey,
certificate,
isApigDomainCreated,
isApigDomainCreated: true,
isCertificatedCreated,
// Note: If mapping key is set, the URL needs a trailing slash. Without the
// trailing slash, the API fails with the error
// {"message":"Not Found"}
url: mappingKey ? `${domainName}/${mappingKey}/` : domainName,
url: buildDomainUrl(domainName, mappingKey),
};
}

function buildDataForExternalDomainInput(
scope: Construct,
customDomain: CustomDomainProps
): CustomDomainData {
// if it is external, then a certificate is required
if (!customDomain.certificate) {
throw new Error(
`A valid certificate is required when "isExternalDomain" is set to "true".`
);
}
// if it is external, then the hostedZone is not required
if (customDomain.hostedZone) {
throw new Error(
`Hosted zones can only be configured for domains hosted on Amazon Route 53. Do not set the "customDomain.hostedZone" when "isExternalDomain" is enabled.`
);
}

const domainName = customDomain.domainName as string;
assertDomainNameIsLowerCase(domainName);
const certificate = customDomain.certificate;
const apigDomain = createApigDomain(scope, domainName, certificate);
const mappingKey = customDomain.path;

return {
apigDomain,
mappingKey,
certificate,
isApigDomainCreated: true,
isCertificatedCreated: false,
url: buildDomainUrl(domainName, mappingKey),
};
}

function buildDataForConstructInput(
scope: Construct,
customDomain: CustomDomainProps
): CustomDomainData {
// Allow user passing in `apigDomain` object. The use case is a user creates
// multiple API endpoints, and is mapping them under the same custom domain.
// `sst.Api` needs to expose the `apigDomain` construct created in the first
// Api, and lets user pass it in when creating the second Api.

if (customDomain.hostedZone) {
throw new Error(
`Cannot configure the "hostedZone" when the "domainName" is a construct`
);
}
if (customDomain.certificate) {
throw new Error(
`Cannot configure the "certificate" when the "domainName" is a construct`
);
}

const apigDomain = customDomain.domainName as apig.IDomainName;
const domainName = apigDomain.name;
const mappingKey = customDomain.path;

return {
apigDomain,
mappingKey,
certificate: undefined,
isApigDomainCreated: false,
isCertificatedCreated: false,
url: buildDomainUrl(domainName, mappingKey),
};
}

function lookupHostedZone(scope: Construct, hostedZoneDomain: string) {
return route53.HostedZone.fromLookup(scope, "HostedZone", {
domainName: hostedZoneDomain,
});
}

function createCertificate(
scope: Construct,
domainName: string,
hostedZone: route53.IHostedZone
) {
return new acm.Certificate(scope, "Certificate", {
domainName,
validation: acm.CertificateValidation.fromDns(hostedZone),
});
}

function createApigDomain(
scope: Construct,
domainName: string,
certificate: acm.ICertificate
) {
return new apig.DomainName(scope, "DomainName", {
domainName,
certificate,
});
}

function createARecord(
scope: Construct,
hostedZone: route53.IHostedZone,
domainName: string,
apigDomain: apig.IDomainName
) {
// create DNS record
const record = new route53.ARecord(scope, "AliasRecord", {
recordName: domainName,
zone: hostedZone,
target: route53.RecordTarget.fromAlias(
new route53Targets.ApiGatewayv2DomainProperties(
apigDomain.regionalDomainName,
apigDomain.regionalHostedZoneId
)
),
});
// note: If domainName is a TOKEN string ie. ${TOKEN..}, the route53.ARecord
// construct will append ".${hostedZoneName}" to the end of the domain.
// This is because the construct tries to check if the record name
// ends with the domain name. If not, it will append the domain name.
// So, we need remove this behavior.
if (cdk.Token.isUnresolved(domainName)) {
const cfnRecord = record.node.defaultChild as route53.CfnRecordSet;
cfnRecord.name = domainName;
}
}

function buildDomainUrl(domainName: string, mappingKey?: string) {
// Note: If mapping key is set, the URL needs a trailing slash. Without the
// trailing slash, the API fails with the error
// {"message":"Not Found"}
return mappingKey ? `${domainName}/${mappingKey}/` : domainName;
}

function assertDomainNameIsLowerCase(domainName: string): void {
if (domainName !== domainName.toLowerCase()) {
throw new Error(`The domain name needs to be in lowercase`);
Expand Down

0 comments on commit 10126d8

Please sign in to comment.