Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added external custom domain support to Api construct #1138

Merged
merged 7 commits into from
Jan 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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