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(edge-api): add redirection endpoint type #22

Merged
merged 33 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
70a73fa
feat(edge-api): add redirection endpoint type
joshbalfour Nov 16, 2023
232fdba
fix construct test
joshbalfour Nov 16, 2023
493cd1a
fix tests
joshbalfour Nov 16, 2023
6734602
Apply readme changes
joshbalfour Nov 16, 2023
5f9c147
possibly fix integration test
joshbalfour Nov 16, 2023
31be3d9
Merge branch 'feat/edge-api-redirect' of https://github.com/reapit/ts…
joshbalfour Nov 16, 2023
2d222d8
forgot the flag
joshbalfour Nov 17, 2023
7a6f871
Apply readme changes
joshbalfour Nov 17, 2023
8fa9d1d
add more tests
joshbalfour Nov 17, 2023
d82c261
error test
joshbalfour Nov 17, 2023
2eeb116
don't need req-host header for s3 origins
joshbalfour Nov 17, 2023
0200ef8
Merge branch 'feat/edge-api-redirect' of https://github.com/reapit/ts…
joshbalfour Nov 17, 2023
66d6ee2
fix unit tests
joshbalfour Nov 17, 2023
0f63659
see if this fixes it
joshbalfour Nov 17, 2023
078e88b
log outDir
joshbalfour Nov 17, 2023
629e5aa
Apply integration test snapshot changes
joshbalfour Nov 17, 2023
f511f0e
fix construct test
joshbalfour Nov 17, 2023
3260e94
Merge branch 'feat/edge-api-redirect' of https://github.com/reapit/ts…
joshbalfour Nov 17, 2023
8cfd301
Apply readme changes
joshbalfour Nov 17, 2023
f702c68
change redirector
joshbalfour Nov 20, 2023
f266302
revert frontend endpoint changes
joshbalfour Nov 20, 2023
a81c2e5
Apply integration test snapshot changes
joshbalfour Nov 20, 2023
31029d2
update test
joshbalfour Nov 20, 2023
b3c0adc
Merge branch 'feat/edge-api-redirect' of https://github.com/reapit/ts…
joshbalfour Nov 20, 2023
bd4b7db
Apply readme changes
joshbalfour Nov 20, 2023
0bb58ea
Apply integration test snapshot changes
joshbalfour Nov 20, 2023
90f7627
add more tests
joshbalfour Nov 20, 2023
6008f72
Merge branch 'feat/edge-api-redirect' of https://github.com/reapit/ts…
joshbalfour Nov 20, 2023
17be571
Apply readme changes
joshbalfour Nov 20, 2023
c2fba2d
more tests
joshbalfour Nov 20, 2023
7c1e32d
Merge branch 'feat/edge-api-redirect' of https://github.com/reapit/ts…
joshbalfour Nov 20, 2023
737b1ee
Apply readme changes
joshbalfour Nov 20, 2023
625acb0
Apply integration test snapshot changes
joshbalfour Nov 20, 2023
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
7 changes: 7 additions & 0 deletions packages/constructs/edge-api/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ api.addEndpoint({
],
})

// send the user to https://google.com/redirect-me
api.addEndpoint({
pathPattern: '/redirect-me',
redirect: true,
destination: 'https://google.com',
})

const zone = HostedZone.fromLookup(stack, 'zone', {
domainName: 'example.org',
})
Expand Down
25 changes: 24 additions & 1 deletion packages/constructs/edge-api/src/dev-edge-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
endpointIsFrontendEndpoint,
endpointIsLambdaEndpoint,
endpointIsProxyEndpoint,
endpointIsRedirectionEndpoint,
} from './types'
import { DomainName, HttpApi, HttpMethod, ParameterMapping } from '@aws-cdk/aws-apigatewayv2-alpha'
import { HttpLambdaIntegration, HttpUrlIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha'
Expand Down Expand Up @@ -70,8 +71,12 @@ export class DevEdgeAPI extends Construct {
return this.redirector
}

private replaceStr(str: string, find: string, replace: string) {
return Fn.join(replace, Fn.split(find, str))
}

private ensureHTTPS(url: string) {
return `https://${Fn.join('', Fn.split('https://', url))}`
return `https://${this.replaceStr(this.replaceStr(url, 'http://', ''), 'https://', '')}`
}

private pickDestination(destination: Destination): string {
Expand Down Expand Up @@ -155,5 +160,23 @@ export class DevEdgeAPI extends Construct {
methods: [HttpMethod.GET],
})
}
if (endpointIsRedirectionEndpoint(endpoint)) {
const { pathPattern, destination } = endpoint
const integration = new HttpLambdaIntegration(pathPattern + '-integration', this.getRedirector(), {
parameterMapping: this.generateParameterMapping({ destination: this.pickDestination(destination) }),
})
this.api.addRoutes({
path: pathPattern.replace('/*', ''),
integration,
methods: [HttpMethod.GET],
})
if (pathPattern.endsWith('/*')) {
this.api.addRoutes({
path: pathPattern.replace('/*', '/{proxy+}'),
integration,
methods: [HttpMethod.GET],
})
}
}
}
}
90 changes: 90 additions & 0 deletions packages/constructs/edge-api/src/lambdas/production-redirector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { CloudFrontRequest, CloudFrontRequestEvent, CloudFrontResponse } from 'aws-lambda'
import { Destination } from '../types'

const getEnv = (event: CloudFrontRequest): Record<string, any> => {
const str = event.origin?.s3?.customHeaders['env']?.[0]?.value
return str ? JSON.parse(str) : {}
}

const pickDestination = (destination: Destination, host: string): string => {
if (typeof destination === 'string') {
return destination
}
const map = destination[host]
if (!map) {
throw new Error(`Unable to find destination from ${JSON.stringify(destination)} for host "${host}"`)
}
if (typeof map === 'string') {
return map
}
if (typeof map.destination === 'string') {
return map.destination
}
throw new Error(`Unable to find destination from ${JSON.stringify(destination)} for host "${host}"`)
}

const ensureHTTPS = (url: string): string => {
if (!url.startsWith('https://')) {
return `https://${url}`
}
return url
}

export const handler = async (event: CloudFrontRequestEvent): Promise<CloudFrontResponse> => {
try {
const [Record] = event.Records
if (!Record) {
throw new Error('no Record present')
}
const req = Record.cf.request
const hostHeaders = req.headers['host']
if (!hostHeaders?.length) {
throw new Error('no host header present')
}
const host = hostHeaders[0].value
if (!host) {
throw new Error('no host header present')
}

const { destination } = getEnv(req) as { destination?: Destination }
if (!destination) {
throw new Error('no destination present on request')
}

const location = ensureHTTPS(pickDestination(destination, host))

const { uri, querystring } = req
const value = `${location}${uri}${querystring ? `?${querystring}` : ''}`

return {
status: '302',
statusDescription: 'Found',
headers: {
location: [
{
key: 'location',
value,
},
],
},
}
} catch (e) {
console.log(JSON.stringify(event))
console.error(e)
return {
status: '302',
statusDescription: 'Found',
headers: {
location: [
{
key: 'location',
value: `/error?${new URLSearchParams({
error: (e as Error).name,
message: (e as Error).message,
}).toString()}`,
},
],
},
}
}
}
21 changes: 21 additions & 0 deletions packages/constructs/edge-api/src/production-edge-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import {
HttpMethod,
LambdaEndpoint,
ProxyEndpoint,
RedirectionEndpoint,
RequestMiddleware,
ResponseMiddleware,
endpointIsFrontendEndpoint,
endpointIsLambdaEndpoint,
endpointIsProxyEndpoint,
endpointIsRedirectionEndpoint,
isRequestMiddleware,
isResponseMiddleware,
} from './types'
Expand Down Expand Up @@ -346,9 +348,28 @@ export class ProductionEdgeAPI extends Construct {
return this.proxyEndpointToAddBehaviorOptions(endpoint)
}

if (endpointIsRedirectionEndpoint(endpoint)) {
return this.redirectionEndpointToAddBehaviorOptions(endpoint)
}

throw new Error('unhandled endpoint type')
}

private redirectionEndpointToAddBehaviorOptions(endpoint: RedirectionEndpoint): EndpointBehaviorOptions[] {
const lambda = new EdgeAPILambda(this, endpoint.pathPattern + '-redirector', {
runtime: Runtime.NODEJS_18_X,
handler: 'production-redirector.handler',
code: Code.fromAsset(path.resolve(__dirname, 'lambdas')),
environment: {
destination: endpoint.destination,
} as any,
})
return this.lambdaEndpointToAddBehaviorOptions({
pathPattern: endpoint.pathPattern,
lambda,
})
}

private endpointToBehaviorOptions(endpoint: Endpoint): BehaviorOptions {
const [{ addBehaviorOptions, origin }] = this.endpointToAddBehaviorOptions(endpoint)
return {
Expand Down
13 changes: 11 additions & 2 deletions packages/constructs/edge-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,19 @@ export interface ProxyEndpoint extends BaseEndpoint {
methods?: HttpMethod[]
}

export type Endpoint = LambdaEndpoint | FrontendEndpoint | ProxyEndpoint
export interface RedirectionEndpoint extends BaseEndpoint {
destination: Destination
redirect: true
}

export type Endpoint = LambdaEndpoint | FrontendEndpoint | ProxyEndpoint | RedirectionEndpoint

// TODO: there must be a better way to do this
export type DefaultEndpoint =
| Omit<LambdaEndpoint, 'pathPattern'>
| Omit<FrontendEndpoint, 'pathPattern'>
| Omit<ProxyEndpoint, 'pathPattern'>
| Omit<RedirectionEndpoint, 'pathPattern'>

export interface EdgeAPIProps {
devMode?: boolean
Expand All @@ -73,4 +79,7 @@ export const endpointIsFrontendEndpoint = (endpoint: Endpoint): endpoint is Fron
!!(endpoint as FrontendEndpoint).bucket

export const endpointIsProxyEndpoint = (endpoint: Endpoint): endpoint is ProxyEndpoint =>
!!(endpoint as ProxyEndpoint).destination
!!(endpoint as ProxyEndpoint).destination && !endpointIsRedirectionEndpoint(endpoint)

export const endpointIsRedirectionEndpoint = (endpoint: Endpoint): endpoint is RedirectionEndpoint =>
!!(endpoint as RedirectionEndpoint).redirect
99 changes: 97 additions & 2 deletions packages/constructs/edge-api/tests/construct.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ describe('edge-api', () => {
},
})
})

test('add a lambda endpoint - POST', () => {
const { api, stack, template } = synth('us-east-1', {})
api.addEndpoint({
Expand Down Expand Up @@ -669,6 +670,26 @@ describe('edge-api', () => {
expect(lambdaResult.uri).toBe('/oauth2/authorize')
expect(lambdaResult.querystring).toBe('identity_provider=b')
})

test('add a redirection endpoint', () => {
const { api, template } = synth('us-east-1', {})
api.addEndpoint({
pathPattern: '/redirect-me',
destination: 'google.com',
redirect: true,
})

const result = template()
result.hasResourceProperties('AWS::CloudFront::Distribution', {
DistributionConfig: {
CacheBehaviors: [
{
AllowedMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE'],
},
],
},
})
})
})
describe('dev', () => {
test('synthesizes', () => {
Expand All @@ -693,7 +714,23 @@ describe('edge-api', () => {
},
IntegrationMethod: 'ANY',
IntegrationType: 'HTTP_PROXY',
IntegrationUri: 'https://example.com/{proxy}',
IntegrationUri: {
'Fn::Join': [
'',
[
'https://',
{
'Fn::Join': [
'',
{
'Fn::Split': ['https://', 'example.com'],
},
],
},
'/{proxy}',
],
],
},
})
result.hasResourceProperties('AWS::ApiGatewayV2::DomainName', {
DomainName: 'example.org',
Expand Down Expand Up @@ -920,9 +957,67 @@ describe('edge-api', () => {
},
IntegrationMethod: 'ANY',
IntegrationType: 'HTTP_PROXY',
IntegrationUri: 'https://google.com/google',
IntegrationUri: {
'Fn::Join': [
'',
[
'https://',
{
'Fn::Join': [
'',
{
'Fn::Split': ['https://', 'google.com'],
},
],
},
'/google',
],
],
},
PayloadFormatVersion: '1.0',
})
})
test('add a redirect endpoint', () => {
const { api, template } = synth('us-east-1', { devMode: true })
api.addEndpoint({
pathPattern: '/redirect-me',
destination: 'google.com',
redirect: true,
})
const result = template()
result.hasResourceProperties('AWS::ApiGatewayV2::Route', {
ApiId: {
Ref: 'api215E4D4B',
},
AuthorizationType: 'NONE',
RouteKey: 'GET /redirect-me',
Target: {
'Fn::Join': [
'',
[
'integrations/',
{
Ref: 'apiGETredirectmeredirectmeintegration7DBACAF6',
},
],
],
},
})
result.hasResourceProperties('AWS::ApiGatewayV2::Integration', {
ApiId: {
Ref: 'api215E4D4B',
},
IntegrationType: 'AWS_PROXY',
IntegrationUri: {
'Fn::GetAtt': ['apiredirect5F095797', 'Arn'],
},
PayloadFormatVersion: '2.0',
RequestParameters: {
'overwrite:header.env': {
'Fn::Base64': '{"destination":"google.com"}',
},
},
})
})
})
})
6 changes: 6 additions & 0 deletions packages/constructs/edge-api/tests/integ-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export const edgeAPITest = (devMode?: boolean) => {
}),
})

api.addEndpoint({
pathPattern: '/redirect-me',
redirect: true,
destination: 'https://google.com',
})

new CfnOutput(stack, 'output', {
value: `https://${domainName}`,
})
Expand Down
11 changes: 11 additions & 0 deletions packages/constructs/edge-api/tests/integ.dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,15 @@ describe('edge-api dev integration', () => {
expect(resJson).toBeDefined()
expect(resJson).toHaveProperty('url', 'https://httpbin.org/get')
})

integ.it('/redirect-me - should redirect to google', async () => {
const endpoint = integ.outputs.output
const res = await fetch(`${endpoint}/redirect-me`, {
redirect: 'manual',
})
expect(res.status).toBe(302)
const locHeader = res.headers.get('location')
expect(locHeader).not.toBeNull()
expect(locHeader).toBe('https://google.com/redirect-me')
})
})
11 changes: 11 additions & 0 deletions packages/constructs/edge-api/tests/integ.prod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,15 @@ describe('edge-api prod integration', () => {
expect(resJson).toBeDefined()
expect(resJson).toHaveProperty('url', 'https://httpbin.org/get')
})

integ.it('/redirect-me - should redirect to google', async () => {
const endpoint = integ.outputs.output
const res = await fetch(`${endpoint}/redirect-me`, {
redirect: 'manual',
})
expect(res.status).toBe(302)
const locHeader = res.headers.get('location')
expect(locHeader).not.toBeNull()
expect(locHeader).toBe('https://google.com/redirect-me')
})
})
Binary file not shown.
Loading