Skip to content

Commit

Permalink
feat(aws): Refactor S3 logic for new (2019) region handling. (#334)
Browse files Browse the repository at this point in the history
Accounts for AWS’ requirement that buckets created outside of `us-east-1` **post** 2019-03-20 cannot be referenced via the `s3.amazonaws.com` virtual host.

(Hopefully?) fixes #332.

> You can use this client [s3.amazonaws.com] to create a bucket in any AWS
> Region that was launched until March 20, 2019. To create a bucket in
> Regions that were launched after March 20, 2019, you must create a
> client specific to the Region in which you want to create the bucket.
> For more information about enabling or disabling an AWS Region, see AWS
> Regions and Endpoints in the AWS General Reference.
> [...]
> Buckets created in Regions launched after March 20, 2019 are not
> reachable via the https://bucket.s3.amazonaws.com naming scheme.

Source: https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingBucket.html

Signed-off-by: Jesse Stuart <[email protected]>
  • Loading branch information
jessestuart authored Jul 9, 2019
1 parent 844a089 commit b8176b9
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 283 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# vim set ft=gitignore
.cache/
**/*.d.ts
**/*.map
*.tgz
Expand Down
7 changes: 5 additions & 2 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ module.exports = {
compact: true,
comments: false,
sourceRoot: 'src/',
ignore: ['./src/__tests__/*', './src/types/*'],
ignore: ['./src/__tests__/*'],
presets: ['@babel/preset-env', '@babel/preset-typescript', 'minify'],
plugins: ['@babel/plugin-transform-runtime'],
plugins: [
'@babel/plugin-transform-runtime',
'@babel/plugin-proposal-object-rest-spread',
],
}
2 changes: 1 addition & 1 deletion nodemon.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"exec": "yalc publish",
"exec": "yarn build && yalc push",
"ext": "ts",
"ignore": ["*.js", ".git/", "node_modules/"],
"watch": "src/"
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"aws-sdk": "2.489.0",
"bluebird": "3.5.5",
"fracty": "1.0.8",
"invariant": "2.2.4",
"lodash": "4.17.11",
"luxon": "1.16.1",
"mime-types": "2.1.24",
Expand All @@ -17,9 +18,9 @@
"@babel/core": "7.5.0",
"@babel/plugin-transform-runtime": "7.5.0",
"@babel/preset-typescript": "7.3.3",
"@babel/runtime": "7.5.2",
"@semantic-release/git": "7.0.16",
"@types/bluebird": "3.5.27",
"@types/invariant": "2.2.30",
"@types/jest": "24.0.15",
"@types/lodash": "4.14.136",
"@types/luxon": "1.15.2",
Expand Down
24 changes: 2 additions & 22 deletions src/__tests__/source-nodes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import _ from 'lodash'
import fp from 'lodash/fp'
import sourceFilesystem, { FileSystemNode } from 'gatsby-source-filesystem'

import * as Factory from 'factory.ts'
import Mitm from 'mitm'
import configureMockStore from 'redux-mock-store'
import sourceFilesystem, { FileSystemNode } from 'gatsby-source-filesystem'

import { getEntityNodeFields, sourceNodes } from '../source-nodes'
import { sourceNodes } from '../source-nodes'
import fixtures from './fixtures.json'

const FileSystemNodeMock = Factory.Sync.makeFactory<FileSystemNode>({})
Expand Down Expand Up @@ -88,24 +88,4 @@ describe('Source S3ImageAsset nodes.', () => {
expect(sourceFilesystem.createRemoteFileNode).toHaveBeenCalledTimes(0)
expect(entityNodes).toHaveLength(0)
})

test('Verify getEntityNodeFields utils func.', () => {
const ETag = '"833816655f9709cb1b2b8ac9505a3c65"'
const Key = '2019-04-10/DSC02943.jpg'
const fileNodeId = 'file-node-id'
const absolutePath = `/path/to/file/${Key}`
const entity = { ETag, Key }
const nodeFields = getEntityNodeFields({
entity,
fileNode: FileSystemNodeMock.build({ absolutePath, id: fileNodeId }),
})

expect(nodeFields).toEqual({
absolutePath,
fileNodeId,
Key,
mediaType: 'image/jpeg',
objectHash: '833816655f9709cb1b2b8ac9505a3c65',
})
})
})
37 changes: 33 additions & 4 deletions src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { constructS3UrlForAsset, isImage } from '../utils'
import { FileSystemNode } from 'gatsby-source-filesystem'
import * as Factory from 'factory.ts'

import { constructS3UrlForAsset, getEntityNodeFields, isImage } from '../utils'

const FileSystemNodeMock = Factory.Sync.makeFactory<FileSystemNode>({})

describe('utils', () => {
test('isImage', () => {
Expand All @@ -12,19 +17,23 @@ describe('utils', () => {
test('constructS3UrlForAsset: AWS', () => {
const s3Url: string = constructS3UrlForAsset({
bucketName: 'jesse.pics',
domain: 's3.amazonaws.com',
region: 'us-east-1',
key: 'my_image.jpg',
})
expect(s3Url).toBe('https://jesse.pics.s3.amazonaws.com/my_image.jpg')
expect(s3Url).toBe(
'https://jesse.pics.s3.us-east-1.amazonaws.com/my_image.jpg'
)
})

test('constructS3UrlForAsset: third-party implementation', () => {
const customUrl = constructS3UrlForAsset({
bucketName: 'js-bucket',
domain: 'minio.jesses.io',
key: 'my_image.jpg',
protocol: 'http',
protocol: 'https',
})
expect(customUrl).toBe('http://minio.jesses.io/js-bucket/my_image.jpg')
expect(customUrl).toBe('https://minio.jesses.io/js-bucket/my_image.jpg')
})

test('constructS3UrlForAsset: invalid input', () => {
Expand All @@ -38,4 +47,24 @@ describe('utils', () => {
})
}).toThrow()
})

test('Verify getEntityNodeFields utils func.', () => {
const ETag = '"833816655f9709cb1b2b8ac9505a3c65"'
const Key = '2019-04-10/DSC02943.jpg'
const fileNodeId = 'file-node-id'
const absolutePath = `/path/to/file/${Key}`
const entity = { ETag, Key }
const nodeFields = getEntityNodeFields({
entity,
fileNode: FileSystemNodeMock.build({ absolutePath, id: fileNodeId }),
})

expect(nodeFields).toEqual({
absolutePath,
fileNodeId,
Key,
mediaType: 'image/jpeg',
objectHash: '833816655f9709cb1b2b8ac9505a3c65',
})
})
})
2 changes: 1 addition & 1 deletion src/set-fields-on-graphql-node-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface ExtendNodeTypeOptions {
}
}

export default ({ type }: ExtendNodeTypeOptions): Promise<any> => {
export default ({ type }: ExtendNodeTypeOptions) => {
if (type.name !== 'S3ImageAsset') {
return Promise.resolve()
}
Expand Down
168 changes: 43 additions & 125 deletions src/source-nodes.ts
Original file line number Diff line number Diff line change
@@ -1,182 +1,100 @@
import { S3 } from 'aws-sdk'
import {
createRemoteFileNode,
CreateRemoteFileNodeArgs,
FileSystemNode,
} from 'gatsby-source-filesystem'
import _ from 'lodash'
import mime from 'mime-types'

import { constructS3UrlForAsset, isImage } from './utils'
import { createRemoteFileNode, FileSystemNode } from 'gatsby-source-filesystem'

// =========================
// Plugin-specific constants.
// =========================
export const S3SourceGatsbyNodeType = 'S3ImageAsset'
import {
constructS3UrlForAsset,
createS3ImageAssetNode,
createS3Instance,
isImage,
} from './utils'

// =================
// Type definitions.
// =================
export interface SourceS3Options {
// Required params.
// NOTE: Required params.
accessKeyId: string
secretAccessKey: string
bucketName: string
// Defaults to `${bucketName}.s3.amazonaws.com`, but may be overridden to
// e.g., support CDN's (such as CloudFront), or any other S3-compliant API
// (such as DigitalOcean Spaces.)
domain?: string
region?: string
// Defaults to HTTP.
protocol?: string
}

export const sourceNodes = async (
{ actions, cache, createNodeId, reporter, store },
{
// ================
accessKeyId,
secretAccessKey,
bucketName,
// ================
domain = 's3.amazonaws.com',
region = 'us-east-1',
protocol = 'http',
secretAccessKey,
}: SourceS3Options
): Promise<any> => {
) => {
const { createNode } = actions

const S3Instance: S3 = new S3({
accessKeyId,
secretAccessKey,
apiVersion: '2006-03-01',
endpoint: `${protocol}://${domain}`,
s3ForcePathStyle: true,
signatureVersion: 'v4',
})
const s3: S3 = createS3Instance({ accessKeyId, domain, secretAccessKey })

const listObjectsResponse: S3.ListObjectsV2Output = await S3Instance.listObjectsV2(
{ Bucket: bucketName }
).promise()
// prettier-ignore
const listObjectsResponse: S3.ListObjectsV2Output =
await s3.listObjectsV2({ Bucket: bucketName }).promise()

const s3Entities: S3.ObjectList | undefined = _.get(
listObjectsResponse,
'Contents'
)
if (!s3Entities || _.isEmpty(s3Entities)) {
const s3Entities: S3.ObjectList = _.get(listObjectsResponse, 'Contents', [])
if (_.isEmpty(s3Entities)) {
return []
}

return await Promise.all(
return Promise.all(
_.compact(
s3Entities.map(async (entity: S3.Object) => {
if (!isImage(entity)) {
const key = _.get(entity, 'Key')
if (!isImage(entity) || !key) {
return
}

const url: string | undefined = constructS3UrlForAsset({
bucketName,
domain,
key: entity.Key,
key,
region,
protocol,
})
if (!url) {
return
}

try {
const createRemoteFileNodeArgs: CreateRemoteFileNodeArgs = {
cache,
createNode,
createNodeId,
reporter,
store,
url,
}
const fileNode: FileSystemNode = await createRemoteFileNode(
createRemoteFileNodeArgs
)
if (!fileNode) {
return
}
const fileNode: FileSystemNode = await createRemoteFileNode({
cache,
createNode,
createNodeId,
reporter,
store,
url,
})

return await createS3ImageAssetNode({
createNode,
createNodeId,
entity,
fileNode,
url,
})
} catch (err) {
Promise.reject(`Error creating S3ImageAsset node: ${err}`)
if (!fileNode) {
return
}

return createS3ImageAssetNode({
createNode,
createNodeId,
entity,
fileNode,
url,
})
})
)
)
}

export const createS3ImageAssetNode = ({
createNode,
createNodeId,
entity,
fileNode,
url,
}: {
createNode: Function
createNodeId: (node: any) => string
entity: S3.Object
fileNode: FileSystemNode
url: string
}): Promise<any> => {
if (!fileNode) {
return Promise.reject(
'File node must be defined when invoking `createS3ImageAssetNode`.'
)
}

const {
absolutePath,
fileNodeId,
Key,
mediaType,
objectHash,
} = getEntityNodeFields({ entity, fileNode })

return createNode({
...entity,
absolutePath,
ETag: objectHash,
id: createNodeId(objectHash),
Key,
parent: fileNodeId,
internal: {
content: url,
contentDigest: objectHash,
mediaType,
type: S3SourceGatsbyNodeType,
},
})
}

export const getEntityNodeFields = ({
entity,
fileNode,
}: {
entity: S3.Object
fileNode: FileSystemNode
}) => {
const { ETag, Key = '' } = entity
const mediaType = mime.lookup(Key)
// Remove obnoxious escaped double quotes in S3 object's ETag. For reference:
// > The entity tag is a hash of the object. The ETag reflects changes only
// > to the contents of an object, not its metadata.
// @see https://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonResponseHeaders.html
const objectHash: string = ETag!.replace(/"/g, '')
const fileNodeId: string = _.get(fileNode, 'id')
const absolutePath: string = _.get(fileNode, 'absolutePath')
return {
absolutePath,
fileNodeId,
Key,
mediaType,
objectHash,
}
}

export default sourceNodes
7 changes: 7 additions & 0 deletions src/types/EntityNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default interface EntityNode {
absolutePath: string
fileNodeId: string
Key: string
mediaType: string
objectHash: string
}
Loading

0 comments on commit b8176b9

Please sign in to comment.