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

feat: improve custom domain integration #494

Merged
merged 13 commits into from
May 8, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/custom-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ appSync:
## Configuration

- `name`: Required. The fully qualified domain name to assiciate this API to.
- `certificateArn`: Required. A valid certificate ARN for the domain name.
- `certificateArn`: Optional. A valid certificate ARN for the domain name. If not provided, this plugin will try its best finding a certificate that matches the domain.
bboure marked this conversation as resolved.
Show resolved Hide resolved
- `useCloudFormation`: Boolean. Optional. Wheter to use CloudFormation or CLI commands to manage the domain. See [Using CloudFormation or CLI commands](#using-cloudformation-vs-the-cli-commands). Defaults to `true`.
- `retain`: Boolean. Optional. Whether to retain the domain and domain association when they are removed from CloudFormation. Defaults to `false`. See [Ejecting from CloudFormation](#ejecting-from-cloudformation)
- `route53`: See [Route53 configuration](#route53-configuration). Defaults to `true`
Expand Down
154 changes: 153 additions & 1 deletion src/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,180 @@ afterEach(() => {

describe('create domain', () => {
const createDomainName = jest.fn();
const listCertificates = jest.fn();
afterEach(() => {
createDomainName.mockClear();
listCertificates.mockClear();
});
it('should create a domain', async () => {
it('should create a domain with specified certificate ARN', async () => {
await runServerless({
fixture: 'appsync',
awsRequestStubMap: {
AppSync: {
createDomainName,
},
ACM: {
listCertificates,
},
},
command: 'appsync domain create',
configExt: {
appSync: {
domain: {
certificateArn:
'arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493',
},
},
},
});

expect(createDomainName).toHaveBeenCalledTimes(1);
expect(listCertificates).not.toHaveBeenCalled();
expect(createDomainName.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"certificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493",
"domainName": "api.example.com",
}
`);
});

it('should create a domain and find a matching certificate, exact match', async () => {
listCertificates.mockResolvedValueOnce({
CertificateSummaryList: [
{
DomainName: '*.example.com',
CertificateArn:
'arn:aws:acm:us-east-1:123456789012:certificate/fd8f67f7-bf19-4894-80db-0c49bf5dd507',
},
{
DomainName: 'foo.example.com',
CertificateArn:
'arn:aws:acm:us-east-1:123456789012:certificate/932b56de-bb63-45fe-8a31-b3150fb9accd',
},
{
DomainName: 'api.example.com',
CertificateArn:
'arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493',
},
],
});

await runServerless({
fixture: 'appsync',
awsRequestStubMap: {
AppSync: {
createDomainName,
},
ACM: {
listCertificates,
},
},
command: 'appsync domain create',
});

expect(listCertificates).toHaveBeenCalledTimes(1);
expect(listCertificates.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"CertificateStatuses": Array [
"ISSUED",
],
}
`);
expect(createDomainName).toHaveBeenCalledTimes(1);
expect(createDomainName.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"certificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493",
"domainName": "api.example.com",
}
`);
});

it('should fail creating a domain if ARN cannot be resolved', async () => {
listCertificates.mockResolvedValueOnce({
CertificateSummaryList: [
{
DomainName: 'foo.example.com',
CertificateArn:
'arn:aws:acm:us-east-1:123456789012:certificate/932b56de-bb63-45fe-8a31-b3150fb9accd',
},
],
});

await expect(
runServerless({
fixture: 'appsync',
awsRequestStubMap: {
AppSync: {
createDomainName,
},

ACM: {
listCertificates,
},
},

command: 'appsync domain create',
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"No certificate found for domain api.example.com."`,
);

expect(listCertificates).toHaveBeenCalledTimes(1);
expect(listCertificates.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"CertificateStatuses": Array [
"ISSUED",
],
}
`);
expect(createDomainName).not.toHaveBeenCalled();
});

it('should create a domain and find a matching certificate, wildcard match', async () => {
listCertificates.mockResolvedValueOnce({
CertificateSummaryList: [
{
DomainName: 'foo.example.com',
CertificateArn:
'arn:aws:acm:us-east-1:123456789012:certificate/932b56de-bb63-45fe-8a31-b3150fb9accd',
},
{
DomainName: '*.example.com',
CertificateArn:
'arn:aws:acm:us-east-1:123456789012:certificate/fd8f67f7-bf19-4894-80db-0c49bf5dd507',
},
],
});

await runServerless({
fixture: 'appsync',
awsRequestStubMap: {
AppSync: {
createDomainName,
},
ACM: {
listCertificates,
},
},
command: 'appsync domain create',
});

expect(listCertificates).toHaveBeenCalledTimes(1);
expect(listCertificates.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"CertificateStatuses": Array [
"ISSUED",
],
}
`);
expect(createDomainName).toHaveBeenCalledTimes(1);
expect(createDomainName.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"certificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/fd8f67f7-bf19-4894-80db-0c49bf5dd507",
"domainName": "api.example.com",
}
`);
});
});

describe('delete domain', () => {
Expand Down
1 change: 0 additions & 1 deletion src/__tests__/fixtures/appsync/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ appSync:
domain:
useCloudFormation: false
name: api.example.com
certificateArn: arn:aws:acm:us-east-1:123456789012:certificate/8acd9c69-1704-462c-be91-b5d7ce45c493

resolvers:
Query.user:
Expand Down
43 changes: 42 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { AppSyncValidationError, validateConfig } from './validation';
import {
confirmAction,
getHostedZoneName,
getWildCardDomainName,
parseDateTimeOrDuration,
wait,
} from './utils';
Expand All @@ -59,6 +60,11 @@ import {
ListHostedZonesByNameRequest,
ListHostedZonesByNameResponse,
} from 'aws-sdk/clients/route53';
import {
ListCertificatesRequest,
ListCertificatesResponse,
} from 'aws-sdk/clients/acm';
import { S3 } from 'aws-sdk';

const CONSOLE_BASE_URL = 'https://console.aws.amazon.com';

Expand Down Expand Up @@ -488,15 +494,50 @@ class ServerlessAppsyncPlugin {
return domain;
}

async getDomainCertificateArn() {
const { CertificateSummaryList } = await this.provider.request<
ListCertificatesRequest,
ListCertificatesResponse
>(
'ACM',
'listCertificates',
// only fully issued certificates
{ CertificateStatuses: ['ISSUED'] },
// certificates must always be in us-east-1
{ region: 'us-east-1' },
);

const domain = this.getDomain();

// try to find an exact match certificate
// fallback on wildcard
const matches = [domain.name, getWildCardDomainName(domain.name)];
for (const match of matches) {
const cert = CertificateSummaryList?.find(
({ DomainName }) => DomainName === match,
);
if (cert) {
return cert.CertificateArn;
}
}
}

async createDomain() {
try {
const domain = this.getDomain();
const certificateArn =
domain.certificateArn || (await this.getDomainCertificateArn());

if (!certificateArn) {
throw new Error(`No certificate found for domain ${domain.name}.`);
}

await this.provider.request<
CreateDomainNameRequest,
CreateDomainNameRequest
>('AppSync', 'createDomainName', {
domainName: domain.name,
certificateArn: domain.certificateArn,
certificateArn,
});
log.success(`Domain '${domain.name}' created successfully`);
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export type DomainConfig = {
useCloudFormation?: boolean;
retain?: boolean;
name: string;
certificateArn: string;
certificateArn?: string;
route53?:
| boolean
| {
Expand Down
3 changes: 2 additions & 1 deletion src/types/serverless.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ declare module 'serverless/lib/Serverless' {

declare module 'serverless/lib/plugins/aws/provider.js' {
import Serverless from 'serverless/lib/Serverless';

import { ServiceConfigurationOptions } from 'aws-sdk/lib/service';
declare class Provider {
constructor(serverless: Serverless);
naming: {
Expand All @@ -105,6 +105,7 @@ declare module 'serverless/lib/plugins/aws/provider.js' {
service: string,
method: string,
params: Input,
options?: ServiceConfigurationOptions,
) => Promise<Output>;
}

Expand Down
4 changes: 4 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export const getHostedZoneName = (domain: string) => {
return `${domain.split('.').slice(1).join('.')}.`;
};

export const getWildCardDomainName = (domain: string) => {
return `*.${domain.split('.').slice(1).join('.')}`;
};

export const question = async (question: string): Promise<string> => {
const rl = readline.createInterface({
input: process.stdin,
Expand Down
1 change: 1 addition & 0 deletions src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ export const appSyncSchema = {
},
},
},
required: ['name'],
},
xrayEnabled: { type: 'boolean' },
substitutions: { $ref: '#/definitions/substitutions' },
Expand Down