From 2cb10dbee63ac8168be413f3887ed6e0637f3585 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 26 Sep 2023 19:53:54 -0700 Subject: [PATCH 01/35] First cut of `@aws-lite/s3` `PutObject` method Specifically just the chunked / streamed upload part --- package.json | 3 +- plugins/s3/package.json | 21 ++++ plugins/s3/readme.md | 17 ++++ plugins/s3/src/index.mjs | 14 +++ plugins/s3/src/put-object.mjs | 153 ++++++++++++++++++++++++++++++ readme.md | 1 + scripts/generate-plugins/index.js | 1 + src/client-factory.js | 34 +++++-- src/request.js | 7 +- 9 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 plugins/s3/package.json create mode 100644 plugins/s3/readme.md create mode 100644 plugins/s3/src/index.mjs create mode 100644 plugins/s3/src/put-object.mjs diff --git a/package.json b/package.json index 100f753a..35b012cf 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "src" ], "workspaces": [ - "plugins/dynamodb" + "plugins/dynamodb", + "plugins/s3" ], "eslintConfig": { "extends": "@architect/eslint-config" diff --git a/plugins/s3/package.json b/plugins/s3/package.json new file mode 100644 index 00000000..7a520ef6 --- /dev/null +++ b/plugins/s3/package.json @@ -0,0 +1,21 @@ +{ + "name": "@aws-lite/s3", + "version": "0.0.0", + "description": "Official `aws-lite` plugin for S3", + "homepage": "https://github.com/architect/aws-lite", + "repository": { + "type": "git", + "url": "https://github.com/architect/aws-lite", + "directory": "plugins/s3" + }, + "bugs": "https://github.com/architect/aws-lite/issues", + "main": "src/index.mjs", + "engines": { + "node": ">=16" + }, + "author": "@architect", + "license": "Apache-2.0", + "files": [ + "src" + ] +} \ No newline at end of file diff --git a/plugins/s3/readme.md b/plugins/s3/readme.md new file mode 100644 index 00000000..e3f4abf1 --- /dev/null +++ b/plugins/s3/readme.md @@ -0,0 +1,17 @@ +# `@aws-lite/s3` + +> Official `aws-lite` plugin for S3 + +> Maintained by: [@architect](https://github.com/architect) + + +## Install + +```sh +npm i @aws-lite/s3 +``` + + +## Learn more + +Please see the [main `aws-lite` readme](https://github.com/architect/aws-lite) for more information about `aws-lite` plugins. diff --git a/plugins/s3/src/index.mjs b/plugins/s3/src/index.mjs new file mode 100644 index 00000000..a8fdb30a --- /dev/null +++ b/plugins/s3/src/index.mjs @@ -0,0 +1,14 @@ +import PutObject from './put-object.mjs' + +const service = 's3' + +/** + * Plugin maintained by: @architect + */ +export default { + service, + methods: { + // https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html + PutObject + } +} diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs new file mode 100644 index 00000000..5e007842 --- /dev/null +++ b/plugins/s3/src/put-object.mjs @@ -0,0 +1,153 @@ +import aws4 from 'aws4' +import crypto from 'node:crypto' +import { readFile, stat } from 'node:fs/promises' +import { Readable } from 'node:stream' + +const required = true + +const minSize = 1024 * 1024 * 5 +const intToHexString = int => String(Number(int).toString(16)) +const algo = 'sha256', utf8 = 'utf8', hex = 'hex' +const hash = str => crypto.createHash(algo).update(str, utf8).digest(hex) +const hmac = (key, str, enc) => crypto.createHmac(algo, key).update(str, utf8).digest(enc) + +let chunkBreak = `\r\n` +function payloadMetadata (chunkSize, signature) { + // Don't forget: after the signature + break would normally follow the body + one more break + return intToHexString(chunkSize) + `;chunk-signature=${signature}` + chunkBreak +} + +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html +// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html +const PutObject = { + validate: { + Bucket: { type: 'string', required }, + Key: { type: 'string', required }, + file: { type: 'string', required }, + headers: { type: 'object' }, + minChunkSize: { type: 'number' }, + }, + request: async (params, utils) => { + let { Bucket, Key, file, headers = {}, minChunkSize } = params + let { credentials, region } = utils + minChunkSize = minChunkSize || minSize + + let dataSize + try { + let stats = await stat(file) + dataSize = stats.size + } + catch (err) { + console.log(`Error reading file: ${file}`) + throw err + } + + // TODO non-streaming upload + if (dataSize > minChunkSize) { + // We'll assemble file indices of chunks here + let chunks = [ + // Reminder: no payload is sent with the canonical request + { canonicalRequest: true }, + ] + + // We'll need to compute all chunk sizes (including metadata) so that we can get the total content-length for the canonical request + let totalRequestSize = dataSize + let dummySig = 'a'.repeat(64) + let emptyHash = hash('') + + // Multipart uploading requires an extra zero-data chunk to denote completion + let chunkAmount = Math.ceil(dataSize / minChunkSize) + 1 + + for (let i = 0; i < chunkAmount; i++) { + // Get start end byte position for streaming + let start = i === 0 ? 0 : i * minChunkSize + let end = (i * minChunkSize) + minChunkSize + + let chunk = {}, chunkSize + // The last real chunk + if (end > dataSize) { + end = dataSize + } + // The 0-byte trailing chunk + if (start > dataSize) { + chunkSize = 0 + chunk.finalRequest = true + } + // Normal + else { + chunkSize = end - start + chunk.start = start + chunk.end = end + } + + totalRequestSize += payloadMetadata(chunkSize, dummySig).length + + chunkBreak.length + chunks.push({ ...chunk, chunkSize }) + } + + headers = { + ...headers, + 'content-encoding': 'aws-chunked', + 'content-length': totalRequestSize, + 'x-amz-content-sha256': 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD', + 'x-amz-decoded-content-length': dataSize, + } + let canonicalReq = aws4.sign({ + service: 's3', + region, + method: 'PUT', + path: `/${Bucket}/${Key}`, + headers, + }, credentials) + let seedSignature = canonicalReq.headers.Authorization.split('Signature=')[1] + chunks[0].signature = seedSignature + + let date = canonicalReq.headers['X-Amz-Date'] || + canonicalReq.headers['x-amz-date'] + let yyyymmdd = date.split('T')[0] + let payloadSigHeader = `AWS4-HMAC-SHA256-PAYLOAD\n` + + `${date}\n` + + `${yyyymmdd}/${canonicalReq.region}/s3/aws4_request\n` + + // TODO make this streamable + let data = await readFile(file) + let stream = new Readable() + chunks.forEach((chunk, i) => { + if (chunk.canonicalRequest) return + + // Ideally we'd use start/end with fs.createReadStream + let { start, end } = chunk + let body = chunk.finalRequest ? '' : data.slice(start, end) + let chunkHash = chunk.finalRequest ? emptyHash : hash(body) + + let payloadSigValues = [ + chunks[i - 1].signature, // Previous chunk signature + emptyHash, // Hash of an empty line ¯\_(ツ)_/¯ + chunkHash, // Hash of the current chunk + ].join('\n') + let signing = payloadSigHeader + payloadSigValues + + // lol at this cascade of hmacs + let kDate = hmac('AWS4' + credentials.secretAccessKey, yyyymmdd) + let kRegion = hmac(kDate, region) + let kService = hmac(kRegion, 's3') + let kCredentials = hmac(kService, 'aws4_request') + let chunkSignature = hmac(kCredentials, signing, hex) + + // Important: populate the signature for the next chunk down the line + chunks[i].signature = chunkSignature + + // Now add the chunk to the stream + let part = payloadMetadata(chunk.chunkSize, chunkSignature) + body + chunkBreak + stream.push(part) + + if (chunk.finalRequest) { + stream.push(null) + } + }) + canonicalReq.stream = stream + return canonicalReq + } + }, +} +export default PutObject diff --git a/readme.md b/readme.md index ff89fc98..71d07f21 100644 --- a/readme.md +++ b/readme.md @@ -399,6 +399,7 @@ export default { - [DynamoDB](https://www.npmjs.com/package/@aws-lite/dynamodb) +- [S3](https://www.npmjs.com/package/@aws-lite/s3) diff --git a/scripts/generate-plugins/index.js b/scripts/generate-plugins/index.js index 676d358e..c17551c0 100644 --- a/scripts/generate-plugins/index.js +++ b/scripts/generate-plugins/index.js @@ -9,6 +9,7 @@ const cwd = process.cwd() // - maintainers: array of GitHub handles of the individual(s) or org(s) responsible for maintaining the plugin const plugins = [ { name: 'dynamodb', service: 'DynamoDB', maintainers: [ '@architect' ] }, + { name: 's3', service: 'S3', maintainers: [ '@architect' ] }, ].sort() const pluginTmpl = readFileSync(join(__dirname, '_plugin-tmpl.mjs')).toString() const readmeTmpl = readFileSync(join(__dirname, '_readme-tmpl.md')).toString() diff --git a/src/client-factory.js b/src/client-factory.js index 5890fd26..f289e981 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -7,6 +7,8 @@ let { awsjson } = require('./lib') let { marshall, unmarshall } = require('./_vendor') let errorHandler = require('./error') +let copy = obj => JSON.parse(JSON.stringify(obj)) + // Never autoload these `@aws-lite/*` packages: let ignored = [ 'client', 'arc' ] @@ -82,7 +84,19 @@ module.exports = async function clientFactory (config, creds, region) { throw ReferenceError(`All plugin error methods must be a function: ${service}`) } }) - let pluginUtils = { awsjsonMarshall: marshall, awsjsonUnmarshall: unmarshall } + + let credentials = copy(creds) + Object.defineProperty(config, 'secretAccessKey', { enumerable: false }) + Object.defineProperty(config, 'secretAccessKey', { enumerable: false }) + Object.defineProperty(credentials, 'sessionToken', { enumerable: false }) + Object.defineProperty(credentials, 'sessionToken', { enumerable: false }) + let pluginUtils = { + awsjsonMarshall: marshall, + awsjsonUnmarshall: unmarshall, + config: copy(config), + credentials, + region, + } let clientMethods = {} Object.entries(methods).forEach(([ name, method ]) => { // For convenient error reporting (and jic anyone wants to enumerate everything) try to ensure the AWS API method names pass through @@ -92,38 +106,38 @@ module.exports = async function clientFactory (config, creds, region) { // Run plugin.request() try { - var result = await method.request(input, pluginUtils) - result = result || {} + var req = await method.request(input, pluginUtils) + req = req || {} } catch (methodError) { errorHandler({ error: methodError, metadata }) } // Hit plugin.validate - let params = { ...input, ...result } + let params = { ...input, ...req } if (method.validate) { validateInput(method.validate, params, metadata) } // Make the request try { - let response = await request({ ...params, ...result, service }, creds, selectedRegion, config, metadata) + let response = await request({ ...params, service }, creds, selectedRegion, config, metadata) // Run plugin.response() /* istanbul ignore next */ // TODO remove as soon as plugin.response() API settles if (method.response) { try { - var result = await method.response(response, pluginUtils) - if (result && result.response === undefined) { + var pluginRes = await method.response(response, pluginUtils) + if (pluginRes && pluginRes.response === undefined) { throw TypeError('Response plugins must return a response property') } } catch (methodError) { errorHandler({ error: methodError, metadata }) } - response = result?.awsjson - ? awsjson.unmarshall(result.response, result.awsjson) - : result?.response || response + response = pluginRes?.awsjson + ? awsjson.unmarshall(pluginRes.response, pluginRes.awsjson) + : pluginRes?.response || response } return response } diff --git a/src/request.js b/src/request.js index b0595b6f..50f91074 100644 --- a/src/request.js +++ b/src/request.js @@ -13,7 +13,8 @@ module.exports = function request (params, creds, region, config, metadata) { return new Promise((resolve, reject) => { // Path - params.path = params.endpoint || '/' + // Note: params.path may be passed if the request is coming from a plugin that pre-signed with aws4 + params.path = params.endpoint || params.path || '/' if (!params.path.startsWith('/')) { params.path = '/' + params.path } @@ -147,6 +148,8 @@ module.exports = function request (params, creds, region, config, metadata) { port: options.port, } })) - req.end(options.body || '') + /* istanbul ignore next */ // TODO remove and test + if (options.stream) options.stream.pipe(req) + else req.end(options.body || '') }) } From 2ac274c9b7ab08a75e4d7b08f2c26f6290116df5 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 09:14:51 -0700 Subject: [PATCH 02/35] Enable non-streaming upload in `@aws-lite/s3` `PutObject` --- plugins/s3/src/put-object.mjs | 15 +++++++++++---- src/request.js | 6 ++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index 5e007842..c58c99d1 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -42,8 +42,16 @@ const PutObject = { throw err } - // TODO non-streaming upload - if (dataSize > minChunkSize) { + if (dataSize <= minChunkSize) { + let payload = await readFile(file) + return { + path: `/${Bucket}/${Key}`, + method: 'PUT', + headers, + payload, + } + } + else { // We'll assemble file indices of chunks here let chunks = [ // Reminder: no payload is sent with the canonical request @@ -80,8 +88,7 @@ const PutObject = { chunk.end = end } - totalRequestSize += payloadMetadata(chunkSize, dummySig).length + - chunkBreak.length + totalRequestSize += payloadMetadata(chunkSize, dummySig).length + chunkBreak.length chunks.push({ ...chunk, chunkSize }) } diff --git a/src/request.js b/src/request.js index 50f91074..7440f926 100644 --- a/src/request.js +++ b/src/request.js @@ -41,7 +41,7 @@ module.exports = function request (params, creds, region, config, metadata) { // Body - JSON-ify payload where convenient! let body = params.payload || params.body || params.data || params.json // Lots of potentially weird valid json (like just a null), deal with it if / when we need to I guess - if (typeof body === 'object') { + if (typeof body === 'object' && !(body instanceof Buffer)) { // Backfill content-type if it's just an object if (!contentType) contentType = 'application/json' @@ -103,7 +103,9 @@ module.exports = function request (params, creds, region, config, metadata) { /* istanbul ignore next */ if (config.debug) { let { method = 'GET', service, host, path, port = '', headers, protocol, body } = options - let truncatedBody = body?.length > 1000 ? body?.substring(0, 1000) + '...' : body + let truncatedBody + if (body instanceof Buffer) truncatedBody = `` + else truncatedBody = body?.length > 1000 ? body?.substring(0, 1000) + '...' : body console.error('[aws-lite] Requesting:', { service, method, From e7538f2ff50ab52696c1d155bd3e4857ab29c8f2 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 12:35:47 -0700 Subject: [PATCH 03/35] Enumerate `@aws-lite/s3` header settings Tidy up property names Experiment with adding comments to methods Experiment with moving AWS doc links into a method property --- plugins/s3/src/put-object.mjs | 70 ++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index c58c99d1..9b1a14ac 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -17,33 +17,69 @@ function payloadMetadata (chunkSize, signature) { return intToHexString(chunkSize) + `;chunk-signature=${signature}` + chunkBreak } -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html -// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html const PutObject = { + awsDoc: 'https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html', + // See also: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html validate: { - Bucket: { type: 'string', required }, - Key: { type: 'string', required }, - file: { type: 'string', required }, - headers: { type: 'object' }, - minChunkSize: { type: 'number' }, + Bucket: { type: 'string', required, comment: 'S3 bucket name' }, + Key: { type: 'string', required, comment: 'S3 key / file name' }, + File: { type: 'string', required, comment: 'File path to be read and uploaded from the local filesystem' }, + MinChunkSize: { type: 'number', default: minSize, comment: 'Minimum size (in bytes) to utilize AWS-chunk-encoded uploads to S3' }, + // Here come the headers + ACL: { type: 'string', comment: 'Sets header: x-amz-acl' }, + BucketKeyEnabled: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-bucket-key-enabled' }, + CacheControl: { type: 'string', comment: 'Sets header: Cache-Control' }, + ChecksumAlgorithm: { type: 'string', comment: 'Sets header: x-amz-sdk-checksum-algorithm' }, + ChecksumCRC32: { type: 'string', comment: 'Sets header: x-amz-checksum-crc32' }, + ChecksumCRC32C: { type: 'string', comment: 'Sets header: x-amz-checksum-crc32c' }, + ChecksumSHA1: { type: 'string', comment: 'Sets header: x-amz-checksum-sha1' }, + ChecksumSHA256: { type: 'string', comment: 'Sets header: x-amz-checksum-sha256' }, + ContentDisposition: { type: 'string', comment: 'Sets header: Content-Disposition' }, + ContentEncoding: { type: 'string', comment: 'Sets header: Content-Encoding' }, + ContentLanguage: { type: 'string', comment: 'Sets header: Content-Language' }, + ContentLength: { type: 'string', comment: 'Sets header: Content-Length' }, + ContentMD5: { type: 'string', comment: 'Sets header: Content-MD5' }, + ContentType: { type: 'string', comment: 'Sets header: Content-Type' }, + ExpectedBucketOwner: { type: 'string', comment: 'Sets header: x-amz-expected-bucket-owner' }, + Expires: { type: 'string', comment: 'Sets header: Expires' }, + GrantFullControl: { type: 'string', comment: 'Sets header: x-amz-grant-full-control' }, + GrantRead: { type: 'string', comment: 'Sets header: x-amz-grant-read' }, + GrantReadACP: { type: 'string', comment: 'Sets header: x-amz-grant-read-acp' }, + GrantWriteACP: { type: 'string', comment: 'Sets header: x-amz-grant-write-acp' }, + ObjectLockLegalHoldStatus: { type: 'string', comment: 'Sets header: x-amz-object-lock-legal-hold' }, + ObjectLockMode: { type: 'string', comment: 'Sets header: x-amz-object-lock-mode' }, + ObjectLockRetainUntilDate: { type: 'string', comment: 'Sets header: x-amz-object-lock-retain-until-date' }, + RequestPayer: { type: 'string', comment: 'Sets header: x-amz-request-payer' }, + ServerSideEncryption: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption' }, + SSECustomerAlgorithm: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-customer-algorithm' }, + SSECustomerKey: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-customer-key' }, + SSECustomerKeyMD5: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-customer-key-MD5' }, + SSEKMSEncryptionContext: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-context' }, + SSEKMSKeyId: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-aws-kms-key-id' }, + StorageClass: { type: 'string', comment: 'Sets header: x-amz-storage-class' }, + Tagging: { type: 'string', comment: 'Sets header: x-amz-tagging' }, + WebsiteRedirectLocation: { type: 'string', comment: 'Sets header: x-amz-website-redirect-location' }, }, request: async (params, utils) => { - let { Bucket, Key, file, headers = {}, minChunkSize } = params + let { Bucket, Key, File, MinChunkSize } = params let { credentials, region } = utils - minChunkSize = minChunkSize || minSize + MinChunkSize = MinChunkSize || minSize + + let { ACL, BucketKeyEnabled, CacheControl, ChecksumAlgorithm, ChecksumCRC32, ChecksumCRC32C, ChecksumSHA1, ChecksumSHA256, ContentDisposition, ContentEncoding, ContentLanguage, ContentLength, ContentMD5, ContentType, ExpectedBucketOwner, Expires, GrantFullControl, GrantRead, GrantReadACP, GrantWriteACP, ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetainUntilDate, RequestPayer, ServerSideEncryption, SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, SSEKMSEncryptionContext, SSEKMSKeyId, StorageClass, Tagging, WebsiteRedirectLocation } = params + let headers = { ACL, BucketKeyEnabled, CacheControl, ChecksumAlgorithm, ChecksumCRC32, ChecksumCRC32C, ChecksumSHA1, ChecksumSHA256, ContentDisposition, ContentEncoding, ContentLanguage, ContentLength, ContentMD5, ContentType, ExpectedBucketOwner, Expires, GrantFullControl, GrantRead, GrantReadACP, GrantWriteACP, ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetainUntilDate, RequestPayer, ServerSideEncryption, SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, SSEKMSEncryptionContext, SSEKMSKeyId, StorageClass, Tagging, WebsiteRedirectLocation } let dataSize try { - let stats = await stat(file) + let stats = await stat(File) dataSize = stats.size } catch (err) { - console.log(`Error reading file: ${file}`) + console.log(`Error reading file: ${File}`) throw err } - if (dataSize <= minChunkSize) { - let payload = await readFile(file) + if (dataSize <= MinChunkSize) { + let payload = await readFile(File) return { path: `/${Bucket}/${Key}`, method: 'PUT', @@ -64,12 +100,12 @@ const PutObject = { let emptyHash = hash('') // Multipart uploading requires an extra zero-data chunk to denote completion - let chunkAmount = Math.ceil(dataSize / minChunkSize) + 1 + let chunkAmount = Math.ceil(dataSize / MinChunkSize) + 1 for (let i = 0; i < chunkAmount; i++) { // Get start end byte position for streaming - let start = i === 0 ? 0 : i * minChunkSize - let end = (i * minChunkSize) + minChunkSize + let start = i === 0 ? 0 : i * MinChunkSize + let end = (i * MinChunkSize) + MinChunkSize let chunk = {}, chunkSize // The last real chunk @@ -117,7 +153,7 @@ const PutObject = { `${yyyymmdd}/${canonicalReq.region}/s3/aws4_request\n` // TODO make this streamable - let data = await readFile(file) + let data = await readFile(File) let stream = new Readable() chunks.forEach((chunk, i) => { if (chunk.canonicalRequest) return From 4c5d47108aeb502b024fc6d7d98e201967505988 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 12:46:27 -0700 Subject: [PATCH 04/35] Enable tagged-incomplete service API methods for docs enumeration --- plugins/dynamodb/readme.md | 7 +++++++ plugins/s3/readme.md | 6 ++++++ plugins/s3/src/incomplete.mjs | 2 ++ plugins/s3/src/index.mjs | 5 +++-- src/client-factory.js | 4 ++++ 5 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 plugins/s3/src/incomplete.mjs diff --git a/plugins/dynamodb/readme.md b/plugins/dynamodb/readme.md index 15e31b04..78df97f3 100644 --- a/plugins/dynamodb/readme.md +++ b/plugins/dynamodb/readme.md @@ -12,6 +12,13 @@ npm i @aws-lite/dynamodb ``` +## Docs + + + + + + ## Usage This plugin covers all DynamoDB methods (listed & linked below), utilizing DynamoDB's semantics. diff --git a/plugins/s3/readme.md b/plugins/s3/readme.md index e3f4abf1..11677e24 100644 --- a/plugins/s3/readme.md +++ b/plugins/s3/readme.md @@ -11,6 +11,12 @@ npm i @aws-lite/s3 ``` +## Docs + + + + + ## Learn more diff --git a/plugins/s3/src/incomplete.mjs b/plugins/s3/src/incomplete.mjs new file mode 100644 index 00000000..c65af6b1 --- /dev/null +++ b/plugins/s3/src/incomplete.mjs @@ -0,0 +1,2 @@ +const x = false +export default { AbortMultipartUpload: x, CompleteMultipartUpload: x, CopyObject: x, CreateBucket: x, CreateMultipartUpload: x, DeleteBucket: x, DeleteBucketAnalyticsConfiguration: x, DeleteBucketCors: x, DeleteBucketEncryption: x, DeleteBucketIntelligentTieringConfiguration: x, DeleteBucketInventoryConfiguration: x, DeleteBucketLifecycle: x, DeleteBucketMetricsConfiguration: x, DeleteBucketOwnershipControls: x, DeleteBucketPolicy: x, DeleteBucketReplication: x, DeleteBucketTagging: x, DeleteBucketWebsite: x, DeleteObject: x, DeleteObjects: x, DeleteObjectTagging: x, DeletePublicAccessBlock: x, GetBucketAccelerateConfiguration: x, GetBucketAcl: x, GetBucketAnalyticsConfiguration: x, GetBucketCors: x, GetBucketEncryption: x, GetBucketIntelligentTieringConfiguration: x, GetBucketInventoryConfiguration: x, GetBucketLifecycle: x, GetBucketLifecycleConfiguration: x, GetBucketLocation: x, GetBucketLogging: x, GetBucketMetricsConfiguration: x, GetBucketNotification: x, GetBucketNotificationConfiguration: x, GetBucketOwnershipControls: x, GetBucketPolicy: x, GetBucketPolicyStatus: x, GetBucketReplication: x, GetBucketRequestPayment: x, GetBucketTagging: x, GetBucketVersioning: x, GetBucketWebsite: x, GetObject: x, GetObjectAcl: x, GetObjectAttributes: x, GetObjectLegalHold: x, GetObjectLockConfiguration: x, GetObjectRetention: x, GetObjectTagging: x, GetObjectTorrent: x, GetPublicAccessBlock: x, HeadBucket: x, HeadObject: x, ListBucketAnalyticsConfigurations: x, ListBucketIntelligentTieringConfigurations: x, ListBucketInventoryConfigurations: x, ListBucketMetricsConfigurations: x, ListBuckets: x, ListMultipartUploads: x, ListObjects: x, ListObjectsV2: x, ListObjectVersions: x, ListParts: x, PutBucketAccelerateConfiguration: x, PutBucketAcl: x, PutBucketAnalyticsConfiguration: x, PutBucketCors: x, PutBucketEncryption: x, PutBucketIntelligentTieringConfiguration: x, PutBucketInventoryConfiguration: x, PutBucketLifecycle: x, PutBucketLifecycleConfiguration: x, PutBucketLogging: x, PutBucketMetricsConfiguration: x, PutBucketNotification: x, PutBucketNotificationConfiguration: x, PutBucketOwnershipControls: x, PutBucketPolicy: x, PutBucketReplication: x, PutBucketRequestPayment: x, PutBucketTagging: x, PutBucketVersioning: x, PutBucketWebsite: x, PutObjectAcl: x, PutObjectLegalHold: x, PutObjectLockConfiguration: x, PutObjectRetention: x, PutObjectTagging: x, PutPublicAccessBlock: x, RestoreObject: x, SelectObjectContent: x, UploadPart: x, UploadPartCopy: x, WriteGetObjectResponse: x } diff --git a/plugins/s3/src/index.mjs b/plugins/s3/src/index.mjs index a8fdb30a..0742cf66 100644 --- a/plugins/s3/src/index.mjs +++ b/plugins/s3/src/index.mjs @@ -1,3 +1,4 @@ +import incomplete from './incomplete.mjs' import PutObject from './put-object.mjs' const service = 's3' @@ -8,7 +9,7 @@ const service = 's3' export default { service, methods: { - // https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html - PutObject + PutObject, + ...incomplete } } diff --git a/src/client-factory.js b/src/client-factory.js index f289e981..57de03ea 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -99,6 +99,10 @@ module.exports = async function clientFactory (config, creds, region) { } let clientMethods = {} Object.entries(methods).forEach(([ name, method ]) => { + // Allow for falsy methods to be denoted as incomplete in generated docs + /* istanbul ignore next */ // TODO remove and test + if (!method || method.disabled) return + // For convenient error reporting (and jic anyone wants to enumerate everything) try to ensure the AWS API method names pass through clientMethods[name] = Object.defineProperty(async input => { let selectedRegion = input?.region || region From 3ba0a9dcde6fd0b63db96a609dc0efa5d2f571dd Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 12:51:42 -0700 Subject: [PATCH 05/35] Move plugin generator script to ESM --- package.json | 2 +- scripts/generate-plugins/_readme-tmpl.md | 7 +++++++ scripts/generate-plugins/{index.js => index.mjs} | 10 +++++----- 3 files changed, 13 insertions(+), 6 deletions(-) rename scripts/generate-plugins/{index.js => index.mjs} (87%) diff --git a/package.json b/package.json index 35b012cf..ac2806c0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "bugs": "https://github.com/architect/aws-lite/issues", "scripts": { "generate": "npm run generate-plugins", - "generate-plugins": "node scripts/generate-plugins", + "generate-plugins": "node scripts/generate-plugins/index.mjs", "publish-plugins": "node scripts/publish-plugins", "lint": "eslint --fix .", "test": "npm run lint && npm run coverage", diff --git a/scripts/generate-plugins/_readme-tmpl.md b/scripts/generate-plugins/_readme-tmpl.md index 12149b5f..3fe4d5ea 100644 --- a/scripts/generate-plugins/_readme-tmpl.md +++ b/scripts/generate-plugins/_readme-tmpl.md @@ -12,6 +12,13 @@ npm i $NAME ``` +## Docs + + + + + + ## Learn more Please see the [main `aws-lite` readme](https://github.com/architect/aws-lite) for more information about `aws-lite` plugins. diff --git a/scripts/generate-plugins/index.js b/scripts/generate-plugins/index.mjs similarity index 87% rename from scripts/generate-plugins/index.js rename to scripts/generate-plugins/index.mjs index c17551c0..facda298 100644 --- a/scripts/generate-plugins/index.js +++ b/scripts/generate-plugins/index.mjs @@ -1,6 +1,6 @@ #! /usr/bin/env node -let { join } = require('path') -let { existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs') +import { join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' const cwd = process.cwd() // Break this into a separate file if it becomes too big / unwieldy! @@ -11,9 +11,9 @@ const plugins = [ { name: 'dynamodb', service: 'DynamoDB', maintainers: [ '@architect' ] }, { name: 's3', service: 'S3', maintainers: [ '@architect' ] }, ].sort() -const pluginTmpl = readFileSync(join(__dirname, '_plugin-tmpl.mjs')).toString() -const readmeTmpl = readFileSync(join(__dirname, '_readme-tmpl.md')).toString() -const packageTmpl = readFileSync(join(__dirname, '_package-tmpl.json')) +const pluginTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_plugin-tmpl.mjs')).toString() +const readmeTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_readme-tmpl.md')).toString() +const packageTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_package-tmpl.json')) plugins.forEach(plugin => { if (!plugin.name || typeof plugin.name !== 'string' || From 0688947005b535fbbf0b874688407b0166108b26 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 14:24:05 -0700 Subject: [PATCH 06/35] Add method generator and generated method docs to `@aws-lite/s3` --- plugins/s3/readme.md | 190 ++++++++++++++++++++++++++++- scripts/generate-plugins/index.mjs | 139 +++++++++++++-------- 2 files changed, 277 insertions(+), 52 deletions(-) diff --git a/plugins/s3/readme.md b/plugins/s3/readme.md index 11677e24..ce02786e 100644 --- a/plugins/s3/readme.md +++ b/plugins/s3/readme.md @@ -11,11 +11,193 @@ npm i @aws-lite/s3 ``` -## Docs - - - +## Methods + + + +### `PutObject` + +[Canonical AWS API doc](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html) + +Properties: +- **`Bucket` (string) [required]** + - S3 bucket name +- **`Key` (string) [required]** + - S3 key / file name +- **`File` (string) [required]** + - File path to be read and uploaded from the local filesystem +- **`MinChunkSize` (number)** + - Minimum size (in bytes) to utilize AWS-chunk-encoded uploads to S3 +- **`ACL` (string)** + - Sets header: x-amz-acl +- **`BucketKeyEnabled` (string)** + - Sets header: x-amz-server-side-encryption-bucket-key-enabled +- **`CacheControl` (string)** + - Sets header: Cache-Control +- **`ChecksumAlgorithm` (string)** + - Sets header: x-amz-sdk-checksum-algorithm +- **`ChecksumCRC32` (string)** + - Sets header: x-amz-checksum-crc32 +- **`ChecksumCRC32C` (string)** + - Sets header: x-amz-checksum-crc32c +- **`ChecksumSHA1` (string)** + - Sets header: x-amz-checksum-sha1 +- **`ChecksumSHA256` (string)** + - Sets header: x-amz-checksum-sha256 +- **`ContentDisposition` (string)** + - Sets header: Content-Disposition +- **`ContentEncoding` (string)** + - Sets header: Content-Encoding +- **`ContentLanguage` (string)** + - Sets header: Content-Language +- **`ContentLength` (string)** + - Sets header: Content-Length +- **`ContentMD5` (string)** + - Sets header: Content-MD5 +- **`ContentType` (string)** + - Sets header: Content-Type +- **`ExpectedBucketOwner` (string)** + - Sets header: x-amz-expected-bucket-owner +- **`Expires` (string)** + - Sets header: Expires +- **`GrantFullControl` (string)** + - Sets header: x-amz-grant-full-control +- **`GrantRead` (string)** + - Sets header: x-amz-grant-read +- **`GrantReadACP` (string)** + - Sets header: x-amz-grant-read-acp +- **`GrantWriteACP` (string)** + - Sets header: x-amz-grant-write-acp +- **`ObjectLockLegalHoldStatus` (string)** + - Sets header: x-amz-object-lock-legal-hold +- **`ObjectLockMode` (string)** + - Sets header: x-amz-object-lock-mode +- **`ObjectLockRetainUntilDate` (string)** + - Sets header: x-amz-object-lock-retain-until-date +- **`RequestPayer` (string)** + - Sets header: x-amz-request-payer +- **`ServerSideEncryption` (string)** + - Sets header: x-amz-server-side-encryption +- **`SSECustomerAlgorithm` (string)** + - Sets header: x-amz-server-side-encryption-customer-algorithm +- **`SSECustomerKey` (string)** + - Sets header: x-amz-server-side-encryption-customer-key +- **`SSECustomerKeyMD5` (string)** + - Sets header: x-amz-server-side-encryption-customer-key-MD5 +- **`SSEKMSEncryptionContext` (string)** + - Sets header: x-amz-server-side-encryption-context +- **`SSEKMSKeyId` (string)** + - Sets header: x-amz-server-side-encryption-aws-kms-key-id +- **`StorageClass` (string)** + - Sets header: x-amz-storage-class +- **`Tagging` (string)** + - Sets header: x-amz-tagging +- **`WebsiteRedirectLocation` (string)** + - Sets header: x-amz-website-redirect-location + + +### Methods yet to be implemented + +> Please help out by [opening a PR](https://github.com/architect/aws-lite#authoring-aws-lite-plugins)! + +- `AbortMultipartUpload` +- `CompleteMultipartUpload` +- `CopyObject` +- `CreateBucket` +- `CreateMultipartUpload` +- `DeleteBucket` +- `DeleteBucketAnalyticsConfiguration` +- `DeleteBucketCors` +- `DeleteBucketEncryption` +- `DeleteBucketIntelligentTieringConfiguration` +- `DeleteBucketInventoryConfiguration` +- `DeleteBucketLifecycle` +- `DeleteBucketMetricsConfiguration` +- `DeleteBucketOwnershipControls` +- `DeleteBucketPolicy` +- `DeleteBucketReplication` +- `DeleteBucketTagging` +- `DeleteBucketWebsite` +- `DeleteObject` +- `DeleteObjects` +- `DeleteObjectTagging` +- `DeletePublicAccessBlock` +- `GetBucketAccelerateConfiguration` +- `GetBucketAcl` +- `GetBucketAnalyticsConfiguration` +- `GetBucketCors` +- `GetBucketEncryption` +- `GetBucketIntelligentTieringConfiguration` +- `GetBucketInventoryConfiguration` +- `GetBucketLifecycle` +- `GetBucketLifecycleConfiguration` +- `GetBucketLocation` +- `GetBucketLogging` +- `GetBucketMetricsConfiguration` +- `GetBucketNotification` +- `GetBucketNotificationConfiguration` +- `GetBucketOwnershipControls` +- `GetBucketPolicy` +- `GetBucketPolicyStatus` +- `GetBucketReplication` +- `GetBucketRequestPayment` +- `GetBucketTagging` +- `GetBucketVersioning` +- `GetBucketWebsite` +- `GetObject` +- `GetObjectAcl` +- `GetObjectAttributes` +- `GetObjectLegalHold` +- `GetObjectLockConfiguration` +- `GetObjectRetention` +- `GetObjectTagging` +- `GetObjectTorrent` +- `GetPublicAccessBlock` +- `HeadBucket` +- `HeadObject` +- `ListBucketAnalyticsConfigurations` +- `ListBucketIntelligentTieringConfigurations` +- `ListBucketInventoryConfigurations` +- `ListBucketMetricsConfigurations` +- `ListBuckets` +- `ListMultipartUploads` +- `ListObjects` +- `ListObjectsV2` +- `ListObjectVersions` +- `ListParts` +- `PutBucketAccelerateConfiguration` +- `PutBucketAcl` +- `PutBucketAnalyticsConfiguration` +- `PutBucketCors` +- `PutBucketEncryption` +- `PutBucketIntelligentTieringConfiguration` +- `PutBucketInventoryConfiguration` +- `PutBucketLifecycle` +- `PutBucketLifecycleConfiguration` +- `PutBucketLogging` +- `PutBucketMetricsConfiguration` +- `PutBucketNotification` +- `PutBucketNotificationConfiguration` +- `PutBucketOwnershipControls` +- `PutBucketPolicy` +- `PutBucketReplication` +- `PutBucketRequestPayment` +- `PutBucketTagging` +- `PutBucketVersioning` +- `PutBucketWebsite` +- `PutObjectAcl` +- `PutObjectLegalHold` +- `PutObjectLockConfiguration` +- `PutObjectRetention` +- `PutObjectTagging` +- `PutPublicAccessBlock` +- `RestoreObject` +- `SelectObjectContent` +- `UploadPart` +- `UploadPartCopy` +- `WriteGetObjectResponse` + ## Learn more diff --git a/scripts/generate-plugins/index.mjs b/scripts/generate-plugins/index.mjs index facda298..1d256f24 100644 --- a/scripts/generate-plugins/index.mjs +++ b/scripts/generate-plugins/index.mjs @@ -1,7 +1,10 @@ #! /usr/bin/env node import { join } from 'node:path' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' + const cwd = process.cwd() +const pluginListRegex = /(?<=(\n))[\s\S]*?(?=())/g +const pluginMethodsRegex = /(?<=(\n))[\s\S]*?(?=())/g // Break this into a separate file if it becomes too big / unwieldy! // - name: the official service name; example: `cloudformation` @@ -15,60 +18,100 @@ const pluginTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_plugi const readmeTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_readme-tmpl.md')).toString() const packageTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_package-tmpl.json')) -plugins.forEach(plugin => { - if (!plugin.name || typeof plugin.name !== 'string' || - !plugin.service || typeof plugin.service !== 'string' || - !plugin.maintainers || !Array.isArray(plugin.maintainers)) { - throw ReferenceError(`Specified plugin must have 'name' (string), 'service' (string), and 'maintainers' (array)`) - } - - let pluginDir = join(cwd, 'plugins', plugin.name) - let maintainers = plugin.maintainers.join(', ') - if (!existsSync(pluginDir)) { - let pluginSrc = join(pluginDir, 'src') - mkdirSync(pluginSrc, { recursive: true }) +async function main () { + for (let plugin of plugins) { + if (!plugin.name || typeof plugin.name !== 'string' || + !plugin.service || typeof plugin.service !== 'string' || + !plugin.maintainers || !Array.isArray(plugin.maintainers)) { + throw ReferenceError(`Specified plugin must have 'name' (string), 'service' (string), and 'maintainers' (array)`) + } let name = `@aws-lite/${plugin.name}` - let desc = `Official \`aws-lite\` plugin for ${plugin.service}` + let pluginDir = join(cwd, 'plugins', plugin.name) + let maintainers = plugin.maintainers.join(', ') + if (!existsSync(pluginDir)) { + let pluginSrc = join(pluginDir, 'src') + mkdirSync(pluginSrc, { recursive: true }) - // Plugin: src/index.js - let src = pluginTmpl - .replace(/\$NAME/g, plugin.name) - .replace(/\$MAINTAINERS/g, maintainers) - writeFileSync(join(pluginSrc, 'index.mjs'), src) + let desc = `Official \`aws-lite\` plugin for ${plugin.service}` - // Plugin: package.json - let pkg = JSON.parse(packageTmpl) - pkg.name = name - pkg.description = desc - pkg.author = maintainers - pkg.repository.directory = `plugins/${plugin.name}` - writeFileSync(join(pluginDir, 'package.json'), JSON.stringify(pkg, null, 2)) + // Plugin: src/index.js + let src = pluginTmpl + .replace(/\$NAME/g, plugin.name) + .replace(/\$MAINTAINERS/g, maintainers) + writeFileSync(join(pluginSrc, 'index.mjs'), src) + + // Plugin: package.json + let pkg = JSON.parse(packageTmpl) + pkg.name = name + pkg.description = desc + pkg.author = maintainers + pkg.repository.directory = `plugins/${plugin.name}` + writeFileSync(join(pluginDir, 'package.json'), JSON.stringify(pkg, null, 2)) + + // Plugin: readme.md + let maintainerLinks = plugin.maintainers.map(p => `[${p}](https://github.com/${p.replace('@', '')})`).join(', ') + let readme = readmeTmpl + .replace(/\$NAME/g, name) + .replace(/\$DESC/g, desc) + .replace(/\$MAINTAINERS/g, maintainerLinks) + writeFileSync(join(pluginDir, 'readme.md'), readme) + + // Project: package.json + let projectPkgFile = join(cwd, 'package.json') + let projectPkg = JSON.parse(readFileSync(projectPkgFile)) + let workspace = `plugins/${plugin.name}` + if (!projectPkg.workspaces.includes(workspace)) { + projectPkg.workspaces.push(workspace) + projectPkg.workspaces = projectPkg.workspaces.sort() + writeFileSync(projectPkgFile, JSON.stringify(projectPkg, null, 2)) + } + } + // Maybe update docs + else { + // TODO ↓ remove once things are nice and dialed in! ↓ + if (plugin.name !== 's3') continue - // Plugin: readme.md - let maintainerLinks = plugin.maintainers.map(p => `[${p}](https://github.com/${p.replace('@', '')})`).join(', ') - let readme = readmeTmpl - .replace(/\$NAME/g, name) - .replace(/\$DESC/g, desc) - .replace(/\$MAINTAINERS/g, maintainerLinks) - writeFileSync(join(pluginDir, 'readme.md'), readme) + const pluginReadmeFile = join(pluginDir, 'readme.md') + let pluginReadme = readFileSync(pluginReadmeFile).toString() + // Generate docs markdown + const { default: _plugin } = await import(name) + let incompleteMethods = [] + let methodDocs = Object.keys(_plugin.methods).map(methodName => { + let header = `### \`${methodName}\`\n\n` + if (!_plugin.methods[methodName] || _plugin.methods[methodName].disabled) { + incompleteMethods.push(methodName) + return + } + const { awsDoc, validate } = _plugin.methods[methodName] + if (!awsDoc) throw ReferenceError(`All methods must refer to an AWS service API doc: ${name} ${methodName}`) + header += `[Canonical AWS API doc](${awsDoc})\n` + if (validate) { + header += `\nProperties:\n` + Object.entries(validate).map(([ param, values ]) => { + const { type, required, comment } = values + const _req = required ? ' [required]' : '' + const _com = comment ? `\n - ${comment}` : '' + return `- **\`${param}\` (${type})${_req}**${_com}` + }).join('\n') + } + return header + }).filter(Boolean).join('\n\n\n') + '\n' - // Project: package.json - let projectPkgFile = join(cwd, 'package.json') - let projectPkg = JSON.parse(readFileSync(projectPkgFile)) - let workspace = `plugins/${plugin.name}` - if (!projectPkg.workspaces.includes(workspace)) { - projectPkg.workspaces.push(workspace) - projectPkg.workspaces = projectPkg.workspaces.sort() - writeFileSync(projectPkgFile, JSON.stringify(projectPkg, null, 2)) + if (incompleteMethods.length) { + methodDocs += `\n\n### Methods yet to be implemented\n\n` + + `> Please help out by [opening a PR](https://github.com/architect/aws-lite#authoring-aws-lite-plugins)!\n\n` + + incompleteMethods.map(methodName => `- \`${methodName}\``).join('\n') + '\n' + } + pluginReadme = pluginReadme.replace(pluginMethodsRegex, methodDocs) + writeFileSync(pluginReadmeFile, pluginReadme) } } -}) -// Project readme.md -let projectReadmeFile = join(cwd, 'readme.md') -let projectReadme = readFileSync(projectReadmeFile).toString() -let pluginListRegex = /(?<=(\n))[\s\S]*?(?=())/g -let pluginList = plugins.map(({ name, service }) => `- [${service}](https://www.npmjs.com/package/@aws-lite/${name})`) -projectReadme = projectReadme.replace(pluginListRegex, pluginList.join('\n') + '\n') -writeFileSync(projectReadmeFile, projectReadme) + // Project readme.md + const projectReadmeFile = join(cwd, 'readme.md') + let projectReadme = readFileSync(projectReadmeFile).toString() + const pluginList = plugins.map(({ name, service }) => `- [${service}](https://www.npmjs.com/package/@aws-lite/${name})`) + projectReadme = projectReadme.replace(pluginListRegex, pluginList.join('\n') + '\n') + writeFileSync(projectReadmeFile, projectReadme) +} +main() From 84d956730fd7fdc6f4e1484ae0ee4c22418137aa Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 14:49:39 -0700 Subject: [PATCH 07/35] Move `@aws-lite/dynamodb` AWS doc links into their corresponding methods --- plugins/dynamodb/src/index.mjs | 106 ++++++++++++++++----------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/plugins/dynamodb/src/index.mjs b/plugins/dynamodb/src/index.mjs index 03582cf2..0e15f004 100644 --- a/plugins/dynamodb/src/index.mjs +++ b/plugins/dynamodb/src/index.mjs @@ -28,8 +28,8 @@ const awsjsonContentType = { 'content-type': 'application/x-amz-json-1.0' } * Plugin maintained by: @architect */ -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchExecuteStatement.html const BatchExecuteStatement = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchExecuteStatement.html', validate: { Statements: { ...arr, required }, ReturnConsumedCapacity, @@ -58,8 +58,8 @@ const BatchExecuteStatement = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchGetItem.html const BatchGetItem = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchGetItem.html', validate: { RequestItems: { ...obj, required }, ReturnConsumedCapacity, @@ -92,8 +92,8 @@ const BatchGetItem = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html const BatchWriteItem = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html', validate: { RequestItems: { ...obj, required }, ReturnConsumedCapacity, @@ -141,8 +141,8 @@ const BatchWriteItem = { } } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateBackup.html const CreateBackup = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateBackup.html', validate: { TableName, BackupName: { ...str, required }, @@ -153,8 +153,8 @@ const CreateBackup = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateGlobalTable.html const CreateGlobalTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateGlobalTable.html', validate: { GlobalTableName: TableName, ReplicationGroup: { ...arr, required }, @@ -165,8 +165,8 @@ const CreateGlobalTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateTable.html const CreateTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateTable.html', validate: { TableName, AttributeDefinitions: { ...arr, required }, @@ -187,8 +187,8 @@ const CreateTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteBackup.html const DeleteBackup = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteBackup.html', validate: { BackupArn: { ...str, required }, }, @@ -198,8 +198,8 @@ const DeleteBackup = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteItem.html const DeleteItem = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteItem.html', validate: { TableName, Key, @@ -224,8 +224,8 @@ const DeleteItem = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteTable.html const DeleteTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteTable.html', validate: { TableName, }, @@ -235,8 +235,8 @@ const DeleteTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeBackup.html const DescribeBackup = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeBackup.html', validate: { BackupArn: { ...str, required }, }, @@ -246,8 +246,8 @@ const DescribeBackup = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeContinuousBackups.html const DescribeContinuousBackups = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeContinuousBackups.html', validate: { TableName, }, @@ -257,8 +257,8 @@ const DescribeContinuousBackups = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeContributorInsights.html const DescribeContributorInsights = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeContributorInsights.html', validate: { TableName, IndexName: str, @@ -269,15 +269,15 @@ const DescribeContributorInsights = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeEndpoints.html const DescribeEndpoints = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeEndpoints.html', request: async () => ({ headers: headers('DescribeEndpoints'), }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeExport.html const DescribeExport = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeExport.html', validate: { ExportArn: { ...str, required }, }, @@ -287,8 +287,8 @@ const DescribeExport = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeGlobalTable.html const DescribeGlobalTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeGlobalTable.html', validate: { GlobalTableName: { ...str, required }, }, @@ -298,8 +298,8 @@ const DescribeGlobalTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeGlobalTableSettings.html const DescribeGlobalTableSettings = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeGlobalTableSettings.html', validate: { GlobalTableName: { ...str, required }, }, @@ -309,8 +309,8 @@ const DescribeGlobalTableSettings = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeImport.html const DescribeImport = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeImport.html', validate: { ImportArn: { ...str, required }, }, @@ -320,8 +320,8 @@ const DescribeImport = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeKinesisStreamingDestination.html const DescribeKinesisStreamingDestination = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeKinesisStreamingDestination.html', validate: { TableName, }, @@ -331,15 +331,15 @@ const DescribeKinesisStreamingDestination = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeLimits.html const DescribeLimits = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeLimits.html', request: async () => ({ headers: headers('DescribeLimits'), }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTable.html const DescribeTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTable.html', validate: { TableName, }, @@ -349,8 +349,8 @@ const DescribeTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTableReplicaAutoScaling.html const DescribeTableReplicaAutoScaling = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTableReplicaAutoScaling.html', validate: { TableName, }, @@ -360,8 +360,8 @@ const DescribeTableReplicaAutoScaling = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTimeToLive.html const DescribeTimeToLive = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTimeToLive.html', validate: { TableName, }, @@ -371,8 +371,8 @@ const DescribeTimeToLive = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DisableKinesisStreamingDestination.html const DisableKinesisStreamingDestination = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DisableKinesisStreamingDestination.html', validate: { TableName, StreamArn: { ...str, required }, @@ -383,8 +383,8 @@ const DisableKinesisStreamingDestination = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_EnableKinesisStreamingDestination.html const EnableKinesisStreamingDestination = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_EnableKinesisStreamingDestination.html', validate: { TableName, StreamArn: { ...str, required }, @@ -395,8 +395,8 @@ const EnableKinesisStreamingDestination = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteStatement.html const ExecuteStatement = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteStatement.html', validate: { TableName, Statement: { ...str, required }, @@ -422,8 +422,8 @@ const ExecuteStatement = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteTransaction.html const ExecuteTransaction = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteTransaction.html', validate: { TableName, TransactStatements: { ...arr, required }, @@ -453,8 +453,8 @@ const ExecuteTransaction = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExportTableToPointInTime.html const ExportTableToPointInTime = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExportTableToPointInTime.html', validate: { S3Bucket: { ...str, required }, TableArn: { ...str, required }, @@ -472,8 +472,8 @@ const ExportTableToPointInTime = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_GetItem.html const GetItem = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_GetItem.html', validate: { TableName, Key, @@ -491,8 +491,8 @@ const GetItem = { response: unmarshall(awsjsonRes), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ImportTable.html const ImportTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ImportTable.html', validate: { InputFormat: { ...str, required }, S3BucketSource: { ...obj, required }, @@ -507,8 +507,8 @@ const ImportTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListBackups.html const ListBackups = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListBackups.html', validate: { BackupType: str, ExclusiveStartBackupArn: str, @@ -523,8 +523,8 @@ const ListBackups = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListContributorInsights.html const ListContributorInsights = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListContributorInsights.html', validate: { MaxResults: num, NextToken: str, @@ -536,8 +536,8 @@ const ListContributorInsights = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListExports.html const ListExports = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListExports.html', validate: { MaxResults: num, NextToken: str, @@ -549,8 +549,8 @@ const ListExports = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListGlobalTables.html const ListGlobalTables = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListGlobalTables.html', validate: { ExclusiveStartGlobalTableName: str, Limit: num, @@ -562,8 +562,8 @@ const ListGlobalTables = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListImports.html const ListImports = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListImports.html', validate: { NextToken: str, PageSize: num, @@ -575,8 +575,8 @@ const ListImports = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListTables.html const ListTables = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListTables.html', validate: { ExclusiveStartTableName: str, Limit: num, @@ -587,8 +587,8 @@ const ListTables = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListTagsOfResource.html const ListTagsOfResource = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListTagsOfResource.html', validate: { NextToken: str, ResourceArn: { ...str, required }, @@ -599,8 +599,8 @@ const ListTagsOfResource = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html const PutItem = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html', validate: { TableName, Item, @@ -622,8 +622,8 @@ const PutItem = { response: unmarshall([ 'Attributes', ]), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html const Query = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html', validate: { TableName, AttributesToGet: arr, @@ -658,8 +658,8 @@ const Query = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_RestoreTableFromBackup.html const RestoreTableFromBackup = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_RestoreTableFromBackup.html', validate: { BackupArn: { ...str, required }, TargetTableName: { ...str, required }, @@ -675,8 +675,8 @@ const RestoreTableFromBackup = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_RestoreTableToPointInTime.html const RestoreTableToPointInTime = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_RestoreTableToPointInTime.html', validate: { TargetTableName: { ...str, required }, BillingModeOverride: str, @@ -695,8 +695,8 @@ const RestoreTableToPointInTime = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html const Scan = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html', validate: { TableName, AttributesToGet: arr, @@ -725,8 +725,8 @@ const Scan = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TagResource.html const TagResource = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TagResource.html', validate: { ResourceArn: { ...str, required }, Tags: { ...arr, required }, @@ -737,8 +737,8 @@ const TagResource = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html const TransactGetItems = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html', validate: { TransactItems: arr, ReturnConsumedCapacity: str, @@ -763,8 +763,8 @@ const TransactGetItems = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html const TransactWriteItems = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html', validate: { TransactItems: arr, ClientRequestToken: str, @@ -826,8 +826,8 @@ const TransactWriteItems = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UntagResource.html const UntagResource = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UntagResource.html', validate: { ResourceArn: { ...str, required }, TagKeys: { ...arr, required }, @@ -838,8 +838,8 @@ const UntagResource = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateContinuousBackups.html const UpdateContinuousBackups = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateContinuousBackups.html', validate: { TableName, PointInTimeRecoverySpecification: obj, @@ -850,8 +850,8 @@ const UpdateContinuousBackups = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateContributorInsights.html const UpdateContributorInsights = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateContributorInsights.html', validate: { TableName, ContributorInsightsAction: str, @@ -863,8 +863,8 @@ const UpdateContributorInsights = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateGlobalTable.html const UpdateGlobalTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateGlobalTable.html', validate: { GlobalTableName: { ...str, required }, ReplicaUpdates: arr, @@ -875,8 +875,8 @@ const UpdateGlobalTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateGlobalTableSettings.html const UpdateGlobalTableSettings = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateGlobalTableSettings.html', validate: { GlobalTableName: { ...str, required }, GlobalTableBillingMode: str, @@ -891,8 +891,8 @@ const UpdateGlobalTableSettings = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html const UpdateItem = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html', validate: { Key, TableName, @@ -923,8 +923,8 @@ const UpdateItem = { }, } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTable.html const UpdateTable = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTable.html', validate: { TableName, AttributeDefinitions: arr, @@ -943,8 +943,8 @@ const UpdateTable = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTableReplicaAutoScaling.html const UpdateTableReplicaAutoScaling = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTableReplicaAutoScaling.html', validate: { TableName, GlobalSecondaryIndexUpdates: arr, @@ -957,8 +957,8 @@ const UpdateTableReplicaAutoScaling = { }), } -// https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTimeToLive.html const UpdateTimeToLive = { + awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTimeToLive.html', validate: { TableName, TimeToLiveSpecification: obj, From e010b4af9b2ceb57037c08d0d99bffa53fe39951 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 14:57:34 -0700 Subject: [PATCH 08/35] Dry up `@aws-lite/dynamodb` AWS doc links Update plugin template --- plugins/dynamodb/src/index.mjs | 107 +++++++++++----------- scripts/generate-plugins/_plugin-tmpl.mjs | 5 +- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/plugins/dynamodb/src/index.mjs b/plugins/dynamodb/src/index.mjs index 0e15f004..729399f9 100644 --- a/plugins/dynamodb/src/index.mjs +++ b/plugins/dynamodb/src/index.mjs @@ -1,5 +1,6 @@ const service = 'dynamodb' const required = true +const docRoot = 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/' // Common params to be AWS-flavored JSON-encoded const awsjsonReq = [ 'ExclusiveStartKey', 'ExpressionAttributeValues', 'Item', 'Key', ] @@ -29,7 +30,7 @@ const awsjsonContentType = { 'content-type': 'application/x-amz-json-1.0' } */ const BatchExecuteStatement = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchExecuteStatement.html', + awsDoc: docRoot + 'API_BatchExecuteStatement.html', validate: { Statements: { ...arr, required }, ReturnConsumedCapacity, @@ -59,7 +60,7 @@ const BatchExecuteStatement = { } const BatchGetItem = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchGetItem.html', + awsDoc: docRoot + 'API_BatchGetItem.html', validate: { RequestItems: { ...obj, required }, ReturnConsumedCapacity, @@ -93,7 +94,7 @@ const BatchGetItem = { } const BatchWriteItem = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html', + awsDoc: docRoot + 'API_BatchWriteItem.html', validate: { RequestItems: { ...obj, required }, ReturnConsumedCapacity, @@ -142,7 +143,7 @@ const BatchWriteItem = { } const CreateBackup = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateBackup.html', + awsDoc: docRoot + 'API_CreateBackup.html', validate: { TableName, BackupName: { ...str, required }, @@ -154,7 +155,7 @@ const CreateBackup = { } const CreateGlobalTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateGlobalTable.html', + awsDoc: docRoot + 'API_CreateGlobalTable.html', validate: { GlobalTableName: TableName, ReplicationGroup: { ...arr, required }, @@ -166,7 +167,7 @@ const CreateGlobalTable = { } const CreateTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_CreateTable.html', + awsDoc: docRoot + 'API_CreateTable.html', validate: { TableName, AttributeDefinitions: { ...arr, required }, @@ -188,7 +189,7 @@ const CreateTable = { } const DeleteBackup = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteBackup.html', + awsDoc: docRoot + 'API_DeleteBackup.html', validate: { BackupArn: { ...str, required }, }, @@ -199,7 +200,7 @@ const DeleteBackup = { } const DeleteItem = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteItem.html', + awsDoc: docRoot + 'API_DeleteItem.html', validate: { TableName, Key, @@ -225,7 +226,7 @@ const DeleteItem = { } const DeleteTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DeleteTable.html', + awsDoc: docRoot + 'API_DeleteTable.html', validate: { TableName, }, @@ -236,7 +237,7 @@ const DeleteTable = { } const DescribeBackup = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeBackup.html', + awsDoc: docRoot + 'API_DescribeBackup.html', validate: { BackupArn: { ...str, required }, }, @@ -247,7 +248,7 @@ const DescribeBackup = { } const DescribeContinuousBackups = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeContinuousBackups.html', + awsDoc: docRoot + 'API_DescribeContinuousBackups.html', validate: { TableName, }, @@ -258,7 +259,7 @@ const DescribeContinuousBackups = { } const DescribeContributorInsights = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeContributorInsights.html', + awsDoc: docRoot + 'API_DescribeContributorInsights.html', validate: { TableName, IndexName: str, @@ -270,14 +271,14 @@ const DescribeContributorInsights = { } const DescribeEndpoints = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeEndpoints.html', + awsDoc: docRoot + 'API_DescribeEndpoints.html', request: async () => ({ headers: headers('DescribeEndpoints'), }), } const DescribeExport = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeExport.html', + awsDoc: docRoot + 'API_DescribeExport.html', validate: { ExportArn: { ...str, required }, }, @@ -288,7 +289,7 @@ const DescribeExport = { } const DescribeGlobalTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeGlobalTable.html', + awsDoc: docRoot + 'API_DescribeGlobalTable.html', validate: { GlobalTableName: { ...str, required }, }, @@ -299,7 +300,7 @@ const DescribeGlobalTable = { } const DescribeGlobalTableSettings = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeGlobalTableSettings.html', + awsDoc: docRoot + 'API_DescribeGlobalTableSettings.html', validate: { GlobalTableName: { ...str, required }, }, @@ -310,7 +311,7 @@ const DescribeGlobalTableSettings = { } const DescribeImport = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeImport.html', + awsDoc: docRoot + 'API_DescribeImport.html', validate: { ImportArn: { ...str, required }, }, @@ -321,7 +322,7 @@ const DescribeImport = { } const DescribeKinesisStreamingDestination = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeKinesisStreamingDestination.html', + awsDoc: docRoot + 'API_DescribeKinesisStreamingDestination.html', validate: { TableName, }, @@ -332,14 +333,14 @@ const DescribeKinesisStreamingDestination = { } const DescribeLimits = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeLimits.html', + awsDoc: docRoot + 'API_DescribeLimits.html', request: async () => ({ headers: headers('DescribeLimits'), }), } const DescribeTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTable.html', + awsDoc: docRoot + 'API_DescribeTable.html', validate: { TableName, }, @@ -350,7 +351,7 @@ const DescribeTable = { } const DescribeTableReplicaAutoScaling = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTableReplicaAutoScaling.html', + awsDoc: docRoot + 'API_DescribeTableReplicaAutoScaling.html', validate: { TableName, }, @@ -361,7 +362,7 @@ const DescribeTableReplicaAutoScaling = { } const DescribeTimeToLive = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DescribeTimeToLive.html', + awsDoc: docRoot + 'API_DescribeTimeToLive.html', validate: { TableName, }, @@ -372,7 +373,7 @@ const DescribeTimeToLive = { } const DisableKinesisStreamingDestination = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_DisableKinesisStreamingDestination.html', + awsDoc: docRoot + 'API_DisableKinesisStreamingDestination.html', validate: { TableName, StreamArn: { ...str, required }, @@ -384,7 +385,7 @@ const DisableKinesisStreamingDestination = { } const EnableKinesisStreamingDestination = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_EnableKinesisStreamingDestination.html', + awsDoc: docRoot + 'API_EnableKinesisStreamingDestination.html', validate: { TableName, StreamArn: { ...str, required }, @@ -396,7 +397,7 @@ const EnableKinesisStreamingDestination = { } const ExecuteStatement = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteStatement.html', + awsDoc: docRoot + 'API_ExecuteStatement.html', validate: { TableName, Statement: { ...str, required }, @@ -423,7 +424,7 @@ const ExecuteStatement = { } const ExecuteTransaction = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExecuteTransaction.html', + awsDoc: docRoot + 'API_ExecuteTransaction.html', validate: { TableName, TransactStatements: { ...arr, required }, @@ -454,7 +455,7 @@ const ExecuteTransaction = { } const ExportTableToPointInTime = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ExportTableToPointInTime.html', + awsDoc: docRoot + 'API_ExportTableToPointInTime.html', validate: { S3Bucket: { ...str, required }, TableArn: { ...str, required }, @@ -473,7 +474,7 @@ const ExportTableToPointInTime = { } const GetItem = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_GetItem.html', + awsDoc: docRoot + 'API_GetItem.html', validate: { TableName, Key, @@ -492,7 +493,7 @@ const GetItem = { } const ImportTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ImportTable.html', + awsDoc: docRoot + 'API_ImportTable.html', validate: { InputFormat: { ...str, required }, S3BucketSource: { ...obj, required }, @@ -508,7 +509,7 @@ const ImportTable = { } const ListBackups = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListBackups.html', + awsDoc: docRoot + 'API_ListBackups.html', validate: { BackupType: str, ExclusiveStartBackupArn: str, @@ -524,7 +525,7 @@ const ListBackups = { } const ListContributorInsights = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListContributorInsights.html', + awsDoc: docRoot + 'API_ListContributorInsights.html', validate: { MaxResults: num, NextToken: str, @@ -537,7 +538,7 @@ const ListContributorInsights = { } const ListExports = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListExports.html', + awsDoc: docRoot + 'API_ListExports.html', validate: { MaxResults: num, NextToken: str, @@ -550,7 +551,7 @@ const ListExports = { } const ListGlobalTables = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListGlobalTables.html', + awsDoc: docRoot + 'API_ListGlobalTables.html', validate: { ExclusiveStartGlobalTableName: str, Limit: num, @@ -563,7 +564,7 @@ const ListGlobalTables = { } const ListImports = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListImports.html', + awsDoc: docRoot + 'API_ListImports.html', validate: { NextToken: str, PageSize: num, @@ -576,7 +577,7 @@ const ListImports = { } const ListTables = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListTables.html', + awsDoc: docRoot + 'API_ListTables.html', validate: { ExclusiveStartTableName: str, Limit: num, @@ -588,7 +589,7 @@ const ListTables = { } const ListTagsOfResource = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_ListTagsOfResource.html', + awsDoc: docRoot + 'API_ListTagsOfResource.html', validate: { NextToken: str, ResourceArn: { ...str, required }, @@ -600,7 +601,7 @@ const ListTagsOfResource = { } const PutItem = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html', + awsDoc: docRoot + 'API_PutItem.html', validate: { TableName, Item, @@ -623,7 +624,7 @@ const PutItem = { } const Query = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html', + awsDoc: docRoot + 'API_Query.html', validate: { TableName, AttributesToGet: arr, @@ -659,7 +660,7 @@ const Query = { } const RestoreTableFromBackup = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_RestoreTableFromBackup.html', + awsDoc: docRoot + 'API_RestoreTableFromBackup.html', validate: { BackupArn: { ...str, required }, TargetTableName: { ...str, required }, @@ -676,7 +677,7 @@ const RestoreTableFromBackup = { } const RestoreTableToPointInTime = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_RestoreTableToPointInTime.html', + awsDoc: docRoot + 'API_RestoreTableToPointInTime.html', validate: { TargetTableName: { ...str, required }, BillingModeOverride: str, @@ -696,7 +697,7 @@ const RestoreTableToPointInTime = { } const Scan = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Scan.html', + awsDoc: docRoot + 'API_Scan.html', validate: { TableName, AttributesToGet: arr, @@ -726,7 +727,7 @@ const Scan = { } const TagResource = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TagResource.html', + awsDoc: docRoot + 'API_TagResource.html', validate: { ResourceArn: { ...str, required }, Tags: { ...arr, required }, @@ -738,7 +739,7 @@ const TagResource = { } const TransactGetItems = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactGetItems.html', + awsDoc: docRoot + 'API_TransactGetItems.html', validate: { TransactItems: arr, ReturnConsumedCapacity: str, @@ -764,7 +765,7 @@ const TransactGetItems = { } const TransactWriteItems = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html', + awsDoc: docRoot + 'API_TransactWriteItems.html', validate: { TransactItems: arr, ClientRequestToken: str, @@ -827,7 +828,7 @@ const TransactWriteItems = { } const UntagResource = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UntagResource.html', + awsDoc: docRoot + 'API_UntagResource.html', validate: { ResourceArn: { ...str, required }, TagKeys: { ...arr, required }, @@ -839,7 +840,7 @@ const UntagResource = { } const UpdateContinuousBackups = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateContinuousBackups.html', + awsDoc: docRoot + 'API_UpdateContinuousBackups.html', validate: { TableName, PointInTimeRecoverySpecification: obj, @@ -851,7 +852,7 @@ const UpdateContinuousBackups = { } const UpdateContributorInsights = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateContributorInsights.html', + awsDoc: docRoot + 'API_UpdateContributorInsights.html', validate: { TableName, ContributorInsightsAction: str, @@ -864,7 +865,7 @@ const UpdateContributorInsights = { } const UpdateGlobalTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateGlobalTable.html', + awsDoc: docRoot + 'API_UpdateGlobalTable.html', validate: { GlobalTableName: { ...str, required }, ReplicaUpdates: arr, @@ -876,7 +877,7 @@ const UpdateGlobalTable = { } const UpdateGlobalTableSettings = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateGlobalTableSettings.html', + awsDoc: docRoot + 'API_UpdateGlobalTableSettings.html', validate: { GlobalTableName: { ...str, required }, GlobalTableBillingMode: str, @@ -892,7 +893,7 @@ const UpdateGlobalTableSettings = { } const UpdateItem = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateItem.html', + awsDoc: docRoot + 'API_UpdateItem.html', validate: { Key, TableName, @@ -924,7 +925,7 @@ const UpdateItem = { } const UpdateTable = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTable.html', + awsDoc: docRoot + 'API_UpdateTable.html', validate: { TableName, AttributeDefinitions: arr, @@ -944,7 +945,7 @@ const UpdateTable = { } const UpdateTableReplicaAutoScaling = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTableReplicaAutoScaling.html', + awsDoc: docRoot + 'API_UpdateTableReplicaAutoScaling.html', validate: { TableName, GlobalSecondaryIndexUpdates: arr, @@ -958,7 +959,7 @@ const UpdateTableReplicaAutoScaling = { } const UpdateTimeToLive = { - awsDoc: 'https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_UpdateTimeToLive.html', + awsDoc: docRoot + 'API_UpdateTimeToLive.html', validate: { TableName, TimeToLiveSpecification: obj, diff --git a/scripts/generate-plugins/_plugin-tmpl.mjs b/scripts/generate-plugins/_plugin-tmpl.mjs index adcbd80d..98cd760c 100644 --- a/scripts/generate-plugins/_plugin-tmpl.mjs +++ b/scripts/generate-plugins/_plugin-tmpl.mjs @@ -7,9 +7,9 @@ const required = true export default { service, methods: { - // TODO: include a reference link with each method, example: - // https://docs.aws.amazon.com/lambda/latest/dg/API_GetFunctionConfiguration.html $ReplaceMe: { + // Include a reference link with each method, for example: + awsDoc: 'https://docs.aws.amazon.com/lambda/latest/dg/API_GetFunctionConfiguration.html', validate: { name: { type: 'string', required }, }, @@ -33,6 +33,7 @@ export default { // TODO: add API link $ReplaceMeToo: { + awsDoc: 'https://docs.aws.amazon.com/...', validate: { name: { type: 'string', required }, }, From f768a04af13280b81e51e65b54f0d2b1e5f2a76f Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Thu, 28 Sep 2023 15:00:11 -0700 Subject: [PATCH 09/35] Dry up `@aws-lite/s3` method comments --- plugins/s3/src/put-object.mjs | 67 ++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index 9b1a14ac..002bfd90 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -4,6 +4,7 @@ import { readFile, stat } from 'node:fs/promises' import { Readable } from 'node:stream' const required = true +const setsReqHeader = 'Set request header: ' const minSize = 1024 * 1024 * 5 const intToHexString = int => String(Number(int).toString(16)) @@ -26,39 +27,39 @@ const PutObject = { File: { type: 'string', required, comment: 'File path to be read and uploaded from the local filesystem' }, MinChunkSize: { type: 'number', default: minSize, comment: 'Minimum size (in bytes) to utilize AWS-chunk-encoded uploads to S3' }, // Here come the headers - ACL: { type: 'string', comment: 'Sets header: x-amz-acl' }, - BucketKeyEnabled: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-bucket-key-enabled' }, - CacheControl: { type: 'string', comment: 'Sets header: Cache-Control' }, - ChecksumAlgorithm: { type: 'string', comment: 'Sets header: x-amz-sdk-checksum-algorithm' }, - ChecksumCRC32: { type: 'string', comment: 'Sets header: x-amz-checksum-crc32' }, - ChecksumCRC32C: { type: 'string', comment: 'Sets header: x-amz-checksum-crc32c' }, - ChecksumSHA1: { type: 'string', comment: 'Sets header: x-amz-checksum-sha1' }, - ChecksumSHA256: { type: 'string', comment: 'Sets header: x-amz-checksum-sha256' }, - ContentDisposition: { type: 'string', comment: 'Sets header: Content-Disposition' }, - ContentEncoding: { type: 'string', comment: 'Sets header: Content-Encoding' }, - ContentLanguage: { type: 'string', comment: 'Sets header: Content-Language' }, - ContentLength: { type: 'string', comment: 'Sets header: Content-Length' }, - ContentMD5: { type: 'string', comment: 'Sets header: Content-MD5' }, - ContentType: { type: 'string', comment: 'Sets header: Content-Type' }, - ExpectedBucketOwner: { type: 'string', comment: 'Sets header: x-amz-expected-bucket-owner' }, - Expires: { type: 'string', comment: 'Sets header: Expires' }, - GrantFullControl: { type: 'string', comment: 'Sets header: x-amz-grant-full-control' }, - GrantRead: { type: 'string', comment: 'Sets header: x-amz-grant-read' }, - GrantReadACP: { type: 'string', comment: 'Sets header: x-amz-grant-read-acp' }, - GrantWriteACP: { type: 'string', comment: 'Sets header: x-amz-grant-write-acp' }, - ObjectLockLegalHoldStatus: { type: 'string', comment: 'Sets header: x-amz-object-lock-legal-hold' }, - ObjectLockMode: { type: 'string', comment: 'Sets header: x-amz-object-lock-mode' }, - ObjectLockRetainUntilDate: { type: 'string', comment: 'Sets header: x-amz-object-lock-retain-until-date' }, - RequestPayer: { type: 'string', comment: 'Sets header: x-amz-request-payer' }, - ServerSideEncryption: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption' }, - SSECustomerAlgorithm: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-customer-algorithm' }, - SSECustomerKey: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-customer-key' }, - SSECustomerKeyMD5: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-customer-key-MD5' }, - SSEKMSEncryptionContext: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-context' }, - SSEKMSKeyId: { type: 'string', comment: 'Sets header: x-amz-server-side-encryption-aws-kms-key-id' }, - StorageClass: { type: 'string', comment: 'Sets header: x-amz-storage-class' }, - Tagging: { type: 'string', comment: 'Sets header: x-amz-tagging' }, - WebsiteRedirectLocation: { type: 'string', comment: 'Sets header: x-amz-website-redirect-location' }, + ACL: { type: 'string', comment: setsReqHeader + '`x-amz-acl`' }, + BucketKeyEnabled: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-bucket-key-enabled`' }, + CacheControl: { type: 'string', comment: setsReqHeader + '`Cache-Control`' }, + ChecksumAlgorithm: { type: 'string', comment: setsReqHeader + '`x-amz-sdk-checksum-algorithm`' }, + ChecksumCRC32: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-crc32`' }, + ChecksumCRC32C: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-crc32c`' }, + ChecksumSHA1: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-sha1`' }, + ChecksumSHA256: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-sha256`' }, + ContentDisposition: { type: 'string', comment: setsReqHeader + '`Content-Disposition`' }, + ContentEncoding: { type: 'string', comment: setsReqHeader + '`Content-Encoding`' }, + ContentLanguage: { type: 'string', comment: setsReqHeader + '`Content-Language`' }, + ContentLength: { type: 'string', comment: setsReqHeader + '`Content-Length`' }, + ContentMD5: { type: 'string', comment: setsReqHeader + '`Content-MD5`' }, + ContentType: { type: 'string', comment: setsReqHeader + '`Content-Type`' }, + ExpectedBucketOwner: { type: 'string', comment: setsReqHeader + '`x-amz-expected-bucket-owner`' }, + Expires: { type: 'string', comment: setsReqHeader + '`Expires`' }, + GrantFullControl: { type: 'string', comment: setsReqHeader + '`x-amz-grant-full-control`' }, + GrantRead: { type: 'string', comment: setsReqHeader + '`x-amz-grant-read`' }, + GrantReadACP: { type: 'string', comment: setsReqHeader + '`x-amz-grant-read-acp`' }, + GrantWriteACP: { type: 'string', comment: setsReqHeader + '`x-amz-grant-write-acp`' }, + ObjectLockLegalHoldStatus: { type: 'string', comment: setsReqHeader + '`x-amz-object-lock-legal-hold`' }, + ObjectLockMode: { type: 'string', comment: setsReqHeader + '`x-amz-object-lock-mode`' }, + ObjectLockRetainUntilDate: { type: 'string', comment: setsReqHeader + '`x-amz-object-lock-retain-until-date`' }, + RequestPayer: { type: 'string', comment: setsReqHeader + '`x-amz-request-payer`' }, + ServerSideEncryption: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption`' }, + SSECustomerAlgorithm: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-customer-algorithm`' }, + SSECustomerKey: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-customer-key`' }, + SSECustomerKeyMD5: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-customer-key-MD5`' }, + SSEKMSEncryptionContext: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-context`' }, + SSEKMSKeyId: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-aws-kms-key-id`' }, + StorageClass: { type: 'string', comment: setsReqHeader + '`x-amz-storage-class`' }, + Tagging: { type: 'string', comment: setsReqHeader + '`x-amz-tagging`' }, + WebsiteRedirectLocation: { type: 'string', comment: setsReqHeader + '`x-amz-website-redirect-location`' }, }, request: async (params, utils) => { let { Bucket, Key, File, MinChunkSize } = params From d5107888ab5fa721a9bee13907cd7de40c54b82f Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Fri, 29 Sep 2023 08:36:34 -0700 Subject: [PATCH 10/35] Clean up header param validation / generation Will probably put this in a plugin lib shortly --- plugins/s3/readme.md | 66 ++++++++++++------------- plugins/s3/src/put-object.mjs | 93 +++++++++++++++++++++-------------- 2 files changed, 90 insertions(+), 69 deletions(-) diff --git a/plugins/s3/readme.md b/plugins/s3/readme.md index ce02786e..65c1a0f8 100644 --- a/plugins/s3/readme.md +++ b/plugins/s3/readme.md @@ -30,71 +30,71 @@ Properties: - **`MinChunkSize` (number)** - Minimum size (in bytes) to utilize AWS-chunk-encoded uploads to S3 - **`ACL` (string)** - - Sets header: x-amz-acl + - Sets request header: `x-amz-acl` - **`BucketKeyEnabled` (string)** - - Sets header: x-amz-server-side-encryption-bucket-key-enabled + - Sets request header: `x-amz-server-side-encryption-bucket-key-enabled` - **`CacheControl` (string)** - - Sets header: Cache-Control + - Sets request header: `cache-control` - **`ChecksumAlgorithm` (string)** - - Sets header: x-amz-sdk-checksum-algorithm + - Sets request header: `x-amz-sdk-checksum-algorithm` - **`ChecksumCRC32` (string)** - - Sets header: x-amz-checksum-crc32 + - Sets request header: `x-amz-checksum-crc32` - **`ChecksumCRC32C` (string)** - - Sets header: x-amz-checksum-crc32c + - Sets request header: `x-amz-checksum-crc32c` - **`ChecksumSHA1` (string)** - - Sets header: x-amz-checksum-sha1 + - Sets request header: `x-amz-checksum-sha1` - **`ChecksumSHA256` (string)** - - Sets header: x-amz-checksum-sha256 + - Sets request header: `x-amz-checksum-sha256` - **`ContentDisposition` (string)** - - Sets header: Content-Disposition + - Sets request header: `content-disposition` - **`ContentEncoding` (string)** - - Sets header: Content-Encoding + - Sets request header: `content-encoding` - **`ContentLanguage` (string)** - - Sets header: Content-Language + - Sets request header: `content-language` - **`ContentLength` (string)** - - Sets header: Content-Length + - Sets request header: `content-length` - **`ContentMD5` (string)** - - Sets header: Content-MD5 + - Sets request header: `content-md5` - **`ContentType` (string)** - - Sets header: Content-Type + - Sets request header: `content-type` - **`ExpectedBucketOwner` (string)** - - Sets header: x-amz-expected-bucket-owner + - Sets request header: `x-amz-expected-bucket-owner` - **`Expires` (string)** - - Sets header: Expires + - Sets request header: `expires` - **`GrantFullControl` (string)** - - Sets header: x-amz-grant-full-control + - Sets request header: `x-amz-grant-full-control` - **`GrantRead` (string)** - - Sets header: x-amz-grant-read + - Sets request header: `x-amz-grant-read` - **`GrantReadACP` (string)** - - Sets header: x-amz-grant-read-acp + - Sets request header: `x-amz-grant-read-acp` - **`GrantWriteACP` (string)** - - Sets header: x-amz-grant-write-acp + - Sets request header: `x-amz-grant-write-acp` - **`ObjectLockLegalHoldStatus` (string)** - - Sets header: x-amz-object-lock-legal-hold + - Sets request header: `x-amz-object-lock-legal-hold` - **`ObjectLockMode` (string)** - - Sets header: x-amz-object-lock-mode + - Sets request header: `x-amz-object-lock-mode` - **`ObjectLockRetainUntilDate` (string)** - - Sets header: x-amz-object-lock-retain-until-date + - Sets request header: `x-amz-object-lock-retain-until-date` - **`RequestPayer` (string)** - - Sets header: x-amz-request-payer + - Sets request header: `x-amz-request-payer` - **`ServerSideEncryption` (string)** - - Sets header: x-amz-server-side-encryption + - Sets request header: `x-amz-server-side-encryption` - **`SSECustomerAlgorithm` (string)** - - Sets header: x-amz-server-side-encryption-customer-algorithm + - Sets request header: `x-amz-server-side-encryption-customer-algorithm` - **`SSECustomerKey` (string)** - - Sets header: x-amz-server-side-encryption-customer-key + - Sets request header: `x-amz-server-side-encryption-customer-key` - **`SSECustomerKeyMD5` (string)** - - Sets header: x-amz-server-side-encryption-customer-key-MD5 + - Sets request header: `x-amz-server-side-encryption-customer-key-md5` - **`SSEKMSEncryptionContext` (string)** - - Sets header: x-amz-server-side-encryption-context + - Sets request header: `x-amz-server-side-encryption-context` - **`SSEKMSKeyId` (string)** - - Sets header: x-amz-server-side-encryption-aws-kms-key-id + - Sets request header: `x-amz-server-side-encryption-aws-kms-key-id` - **`StorageClass` (string)** - - Sets header: x-amz-storage-class + - Sets request header: `x-amz-storage-class` - **`Tagging` (string)** - - Sets header: x-amz-tagging + - Sets request header: `x-amz-tagging` - **`WebsiteRedirectLocation` (string)** - - Sets header: x-amz-website-redirect-location + - Sets request header: `x-amz-website-redirect-location` ### Methods yet to be implemented diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index 002bfd90..7e5aee5e 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -4,7 +4,6 @@ import { readFile, stat } from 'node:fs/promises' import { Readable } from 'node:stream' const required = true -const setsReqHeader = 'Set request header: ' const minSize = 1024 * 1024 * 5 const intToHexString = int => String(Number(int).toString(16)) @@ -18,6 +17,50 @@ function payloadMetadata (chunkSize, signature) { return intToHexString(chunkSize) + `;chunk-signature=${signature}` + chunkBreak } +// Commonly used headers +const comment = header => `Sets request header: \`${header}\`` +const getValidateHeaders = (...headers) => headers.reduce((acc, h) => { + if (!headerMappings?.[h]?.header) throw ReferenceError(`Header not found: ${h}`) + acc[h] = { type: 'string', comment: comment(headerMappings[h].header) } + return acc +}, {}) +// The !x-amz headers are documented as old school pascal-case headers; lowcasing them to be HTTP 2.0 compliant +let headerMappings = { + ACL: { header: 'x-amz-acl' }, + BucketKeyEnabled: { header: 'x-amz-server-side-encryption-bucket-key-enabled' }, + CacheControl: { header: 'cache-control' }, + ChecksumAlgorithm: { header: 'x-amz-sdk-checksum-algorithm' }, + ChecksumCRC32: { header: 'x-amz-checksum-crc32' }, + ChecksumCRC32C: { header: 'x-amz-checksum-crc32c' }, + ChecksumSHA1: { header: 'x-amz-checksum-sha1' }, + ChecksumSHA256: { header: 'x-amz-checksum-sha256' }, + ContentDisposition: { header: 'content-disposition' }, + ContentEncoding: { header: 'content-encoding' }, + ContentLanguage: { header: 'content-language' }, + ContentLength: { header: 'content-length' }, + ContentMD5: { header: 'content-md5' }, + ContentType: { header: 'content-type' }, + ExpectedBucketOwner: { header: 'x-amz-expected-bucket-owner' }, + Expires: { header: 'expires' }, + GrantFullControl: { header: 'x-amz-grant-full-control' }, + GrantRead: { header: 'x-amz-grant-read' }, + GrantReadACP: { header: 'x-amz-grant-read-acp' }, + GrantWriteACP: { header: 'x-amz-grant-write-acp' }, + ObjectLockLegalHoldStatus: { header: 'x-amz-object-lock-legal-hold' }, + ObjectLockMode: { header: 'x-amz-object-lock-mode' }, + ObjectLockRetainUntilDate: { header: 'x-amz-object-lock-retain-until-date' }, + RequestPayer: { header: 'x-amz-request-payer' }, + ServerSideEncryption: { header: 'x-amz-server-side-encryption' }, + SSECustomerAlgorithm: { header: 'x-amz-server-side-encryption-customer-algorithm' }, + SSECustomerKey: { header: 'x-amz-server-side-encryption-customer-key' }, + SSECustomerKeyMD5: { header: 'x-amz-server-side-encryption-customer-key-md5' }, + SSEKMSEncryptionContext: { header: 'x-amz-server-side-encryption-context' }, + SSEKMSKeyId: { header: 'x-amz-server-side-encryption-aws-kms-key-id' }, + StorageClass: { header: 'x-amz-storage-class' }, + Tagging: { header: 'x-amz-tagging' }, + WebsiteRedirectLocation: { header: 'x-amz-website-redirect-location' }, +} + const PutObject = { awsDoc: 'https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html', // See also: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html @@ -27,47 +70,25 @@ const PutObject = { File: { type: 'string', required, comment: 'File path to be read and uploaded from the local filesystem' }, MinChunkSize: { type: 'number', default: minSize, comment: 'Minimum size (in bytes) to utilize AWS-chunk-encoded uploads to S3' }, // Here come the headers - ACL: { type: 'string', comment: setsReqHeader + '`x-amz-acl`' }, - BucketKeyEnabled: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-bucket-key-enabled`' }, - CacheControl: { type: 'string', comment: setsReqHeader + '`Cache-Control`' }, - ChecksumAlgorithm: { type: 'string', comment: setsReqHeader + '`x-amz-sdk-checksum-algorithm`' }, - ChecksumCRC32: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-crc32`' }, - ChecksumCRC32C: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-crc32c`' }, - ChecksumSHA1: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-sha1`' }, - ChecksumSHA256: { type: 'string', comment: setsReqHeader + '`x-amz-checksum-sha256`' }, - ContentDisposition: { type: 'string', comment: setsReqHeader + '`Content-Disposition`' }, - ContentEncoding: { type: 'string', comment: setsReqHeader + '`Content-Encoding`' }, - ContentLanguage: { type: 'string', comment: setsReqHeader + '`Content-Language`' }, - ContentLength: { type: 'string', comment: setsReqHeader + '`Content-Length`' }, - ContentMD5: { type: 'string', comment: setsReqHeader + '`Content-MD5`' }, - ContentType: { type: 'string', comment: setsReqHeader + '`Content-Type`' }, - ExpectedBucketOwner: { type: 'string', comment: setsReqHeader + '`x-amz-expected-bucket-owner`' }, - Expires: { type: 'string', comment: setsReqHeader + '`Expires`' }, - GrantFullControl: { type: 'string', comment: setsReqHeader + '`x-amz-grant-full-control`' }, - GrantRead: { type: 'string', comment: setsReqHeader + '`x-amz-grant-read`' }, - GrantReadACP: { type: 'string', comment: setsReqHeader + '`x-amz-grant-read-acp`' }, - GrantWriteACP: { type: 'string', comment: setsReqHeader + '`x-amz-grant-write-acp`' }, - ObjectLockLegalHoldStatus: { type: 'string', comment: setsReqHeader + '`x-amz-object-lock-legal-hold`' }, - ObjectLockMode: { type: 'string', comment: setsReqHeader + '`x-amz-object-lock-mode`' }, - ObjectLockRetainUntilDate: { type: 'string', comment: setsReqHeader + '`x-amz-object-lock-retain-until-date`' }, - RequestPayer: { type: 'string', comment: setsReqHeader + '`x-amz-request-payer`' }, - ServerSideEncryption: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption`' }, - SSECustomerAlgorithm: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-customer-algorithm`' }, - SSECustomerKey: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-customer-key`' }, - SSECustomerKeyMD5: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-customer-key-MD5`' }, - SSEKMSEncryptionContext: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-context`' }, - SSEKMSKeyId: { type: 'string', comment: setsReqHeader + '`x-amz-server-side-encryption-aws-kms-key-id`' }, - StorageClass: { type: 'string', comment: setsReqHeader + '`x-amz-storage-class`' }, - Tagging: { type: 'string', comment: setsReqHeader + '`x-amz-tagging`' }, - WebsiteRedirectLocation: { type: 'string', comment: setsReqHeader + '`x-amz-website-redirect-location`' }, + ...getValidateHeaders('ACL', 'BucketKeyEnabled', 'CacheControl', 'ChecksumAlgorithm', 'ChecksumCRC32', + 'ChecksumCRC32C', 'ChecksumSHA1', 'ChecksumSHA256', 'ContentDisposition', 'ContentEncoding', + 'ContentLanguage', 'ContentLength', 'ContentMD5', 'ContentType', 'ExpectedBucketOwner', 'Expires', + 'GrantFullControl', 'GrantRead', 'GrantReadACP', 'GrantWriteACP', 'ObjectLockLegalHoldStatus', + 'ObjectLockMode', 'ObjectLockRetainUntilDate', 'RequestPayer', 'ServerSideEncryption', + 'SSECustomerAlgorithm', 'SSECustomerKey', 'SSECustomerKeyMD5', 'SSEKMSEncryptionContext', + 'SSEKMSKeyId', 'StorageClass', 'Tagging', 'WebsiteRedirectLocation') }, request: async (params, utils) => { let { Bucket, Key, File, MinChunkSize } = params let { credentials, region } = utils MinChunkSize = MinChunkSize || minSize - let { ACL, BucketKeyEnabled, CacheControl, ChecksumAlgorithm, ChecksumCRC32, ChecksumCRC32C, ChecksumSHA1, ChecksumSHA256, ContentDisposition, ContentEncoding, ContentLanguage, ContentLength, ContentMD5, ContentType, ExpectedBucketOwner, Expires, GrantFullControl, GrantRead, GrantReadACP, GrantWriteACP, ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetainUntilDate, RequestPayer, ServerSideEncryption, SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, SSEKMSEncryptionContext, SSEKMSKeyId, StorageClass, Tagging, WebsiteRedirectLocation } = params - let headers = { ACL, BucketKeyEnabled, CacheControl, ChecksumAlgorithm, ChecksumCRC32, ChecksumCRC32C, ChecksumSHA1, ChecksumSHA256, ContentDisposition, ContentEncoding, ContentLanguage, ContentLength, ContentMD5, ContentType, ExpectedBucketOwner, Expires, GrantFullControl, GrantRead, GrantReadACP, GrantWriteACP, ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetainUntilDate, RequestPayer, ServerSideEncryption, SSECustomerAlgorithm, SSECustomerKey, SSECustomerKeyMD5, SSEKMSEncryptionContext, SSEKMSKeyId, StorageClass, Tagging, WebsiteRedirectLocation } + let headers = Object.keys(params).reduce((acc, param) => { + if (headerMappings[param]?.header) { + acc[headerMappings[param].header] = params[param] + } + return acc + }, {}) let dataSize try { From 12b10651788a06d61f8dda2e3ef7200c2fb9c077 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Fri, 29 Sep 2023 08:59:17 -0700 Subject: [PATCH 11/35] Move client instantiation file reads to async --- src/client-factory.js | 6 +- src/get-creds.js | 13 ++-- src/get-region.js | 13 ++-- src/index.js | 4 +- src/lib.js | 9 ++- test/unit/src/get-creds-test.js | 111 +++++++++++++++++++------------ test/unit/src/get-region-test.js | 82 ++++++++++++++--------- 7 files changed, 146 insertions(+), 92 deletions(-) diff --git a/src/client-factory.js b/src/client-factory.js index 57de03ea..aea92a09 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -1,4 +1,4 @@ -let { readdirSync } = require('fs') +let { readdir } = require('fs/promises') let { join } = require('path') let { services } = require('./services') let request = require('./request') @@ -31,10 +31,10 @@ module.exports = async function clientFactory (config, creds, region) { /* istanbul ignore next */ // TODO check once plugins are published if (autoloadPlugins) { let nodeModulesDir = join(process.cwd(), 'node_modules') - let mods = readdirSync(nodeModulesDir) + let mods = await readdir(nodeModulesDir) // Find first-party plugins if (mods.includes('@aws-lite')) { - let knownPlugins = readdirSync(join(nodeModulesDir, '@aws-lite')) + let knownPlugins = await readdir(join(nodeModulesDir, '@aws-lite')) let filtered = knownPlugins.filter(p => !ignored.includes(p)).map(p => `@aws-lite/${p}`) plugins.push(...filtered) } diff --git a/src/get-creds.js b/src/get-creds.js index c09f4033..e52d0cd6 100644 --- a/src/get-creds.js +++ b/src/get-creds.js @@ -1,10 +1,11 @@ -let { existsSync, readFileSync } = require('fs') +let { readFile } = require('fs/promises') +let { exists } = require('./lib') let { join } = require('path') let os = require('os') let ini = require('ini') // https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html -module.exports = function getCreds (params) { +module.exports = async function getCreds (params) { let paramsCreds = validate(params) if (paramsCreds) return paramsCreds @@ -13,7 +14,7 @@ module.exports = function getCreds (params) { let isInLambda = process.env.AWS_LAMBDA_FUNCTION_NAME if (!isInLambda) { - let credsFileCreds = getCredsFromFile(params) + let credsFileCreds = await getCredsFromFile(params) if (credsFileCreds) return credsFileCreds } @@ -29,14 +30,14 @@ function getCredsFromEnv () { return validate({ accessKeyId, secretAccessKey, sessionToken }) } -function getCredsFromFile (params) { +async function getCredsFromFile (params) { let { AWS_SHARED_CREDENTIALS_FILE, AWS_PROFILE } = process.env let profile = params.profile || AWS_PROFILE || 'default' let home = os.homedir() let credsFile = AWS_SHARED_CREDENTIALS_FILE || join(home, '.aws', 'credentials') - if (existsSync(credsFile)) { - let file = readFileSync(credsFile) + if (await exists(credsFile)) { + let file = await readFile(credsFile) let creds = ini.parse(file.toString()) if (!creds[profile]) { diff --git a/src/get-region.js b/src/get-region.js index 9a1b1bff..7af1f711 100644 --- a/src/get-region.js +++ b/src/get-region.js @@ -1,10 +1,11 @@ -let { existsSync, readFileSync } = require('fs') +let { readFile } = require('fs/promises') +let { exists } = require('./lib') let { join } = require('path') let os = require('os') let ini = require('ini') let regions = require('./regions.json') -module.exports = function getRegion (params) { +module.exports = async function getRegion (params) { let { region } = params let paramsRegion = validateRegion(region) @@ -15,7 +16,7 @@ module.exports = function getRegion (params) { let isInLambda = process.env.AWS_LAMBDA_FUNCTION_NAME if (!isInLambda) { - let configRegion = getRegionFromConfig(params) + let configRegion = await getRegionFromConfig(params) if (configRegion) return configRegion } @@ -29,7 +30,7 @@ function getRegionFromEnv () { return validateRegion(region) } -function getRegionFromConfig (params) { +async function getRegionFromConfig (params) { let { AWS_SDK_LOAD_CONFIG, AWS_CONFIG_FILE, AWS_PROFILE } = process.env if (!AWS_SDK_LOAD_CONFIG) return false @@ -38,8 +39,8 @@ function getRegionFromConfig (params) { let home = os.homedir() let configFile = AWS_CONFIG_FILE || join(home, '.aws', 'config') - if (existsSync(configFile)) { - let file = readFileSync(configFile) + if (await exists(configFile)) { + let file = await readFile(configFile) let config = ini.parse(file.toString()) if (!config[profileName]) { diff --git a/src/index.js b/src/index.js index 094cee12..75d9ac2e 100644 --- a/src/index.js +++ b/src/index.js @@ -21,8 +21,8 @@ let clientFactory = require('./client-factory') */ module.exports = async function awsLite (config = {}) { // Creds + region first - let creds = getCreds(config) - let region = getRegion(config) + let creds = await getCreds(config) + let region = await getRegion(config) // Set defaults config.protocol = config.protocol ?? 'https' diff --git a/src/lib.js b/src/lib.js index 846efb99..998a6684 100644 --- a/src/lib.js +++ b/src/lib.js @@ -1,5 +1,7 @@ +let { stat } = require('fs/promises') let { marshall, unmarshall } = require('./_vendor') +// AWS-flavored JSON stuff function marshaller (method, obj, awsjsonSetting) { // We may not be able to AWS JSON-[en|de]code the whole payload, check for specified keys if (Array.isArray(awsjsonSetting)) { @@ -17,6 +19,11 @@ let awsjson = { unmarshall: marshaller.bind({}, unmarshall), } +async function exists (file) { + try { await stat(file); return true } + catch { return false } +} + // Probably this is going to need some refactoring in Arc 11 // Certainly it is not reliable in !Arc local Lambda emulation let nonLocalEnvs = [ 'staging', 'production' ] @@ -30,4 +37,4 @@ function useAWS () { return true } -module.exports = { awsjson, useAWS } +module.exports = { awsjson, exists, useAWS } diff --git a/test/unit/src/get-creds-test.js b/test/unit/src/get-creds-test.js index 6061022e..1105f214 100644 --- a/test/unit/src/get-creds-test.js +++ b/test/unit/src/get-creds-test.js @@ -18,36 +18,36 @@ test('Set up env', t => { t.ok(getCreds, 'getCreds module is present') }) -test('Get credentials from passed params', t => { +test('Get credentials from passed params', async t => { t.plan(4) resetAWSEnvVars() let passed, result // Key + secret only passed = { accessKeyId: ok, secretAccessKey: ok } - result = getCreds(passed) + result = await getCreds(passed) t.deepEqual(result, { ...passed, sessionToken: undefined }, 'Returned correct credentials from passed params') // Key + secret + sessionToken passed = { accessKeyId: ok, secretAccessKey: ok, sessionToken: ok } - result = getCreds(passed) + result = await getCreds(passed) t.deepEqual(result, passed, 'Returned correct credentials from passed params') // Prioritize passed params before env or creds file process.env.AWS_ACCESS_KEY_ID = nope process.env.AWS_SECRET_ACCESS_KEY = nope process.env.AWS_SESSION_TOKEN = nope - result = getCreds(passed) + result = await getCreds(passed) t.deepEqual(result, passed, 'Returned correct credentials from passed params') resetAWSEnvVars() process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsMock - result = getCreds(passed) + result = await getCreds(passed) t.deepEqual(result, passed, 'Returned correct credentials from passed params') resetAWSEnvVars() }) -test('Get credentials from env vars', t => { +test('Get credentials from env vars', async t => { t.plan(3) resetAWSEnvVars() let passed, result @@ -57,23 +57,23 @@ test('Get credentials from env vars', t => { // Key + secret only passed = { accessKeyId: ok, secretAccessKey: ok } - result = getCreds({}) + result = await getCreds({}) t.deepEqual(result, { ...passed, sessionToken: undefined }, 'Returned correct credentials from env vars') // Key + secret + sessionToken process.env.AWS_SESSION_TOKEN = ok passed = { accessKeyId: ok, secretAccessKey: ok, sessionToken: ok } - result = getCreds({}) + result = await getCreds({}) t.deepEqual(result, passed, 'Returned correct credentials from env vars') // Prioritize passed params before creds file process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsMock - result = getCreds({}) + result = await getCreds({}) t.deepEqual(result, passed, 'Returned correct credentials from env vars') resetAWSEnvVars() }) -test('Get credentials from credentials file', t => { +test('Get credentials from credentials file', async t => { t.plan(5) resetAWSEnvVars() let result @@ -94,78 +94,105 @@ test('Get credentials from credentials file', t => { let home = os.homedir() let credsFile = join(home, '.aws', 'credentials') mockFs({ [credsFile]: mockFs.load(credentialsMock) }) - result = getCreds({}) + result = await getCreds({}) t.deepEqual(result, defaultProfile, 'Returned correct credentials from credentials file (~/.aws file location)') mockFs.restore() resetAWSEnvVars() // Configured file locations process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsMock - result = getCreds({}) + result = await getCreds({}) t.deepEqual(result, defaultProfile, 'Returned correct credentials from credentials file (default profile)') resetAWSEnvVars() // params.profile process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsMock - result = getCreds({ profile: 'profile_1' }) + result = await getCreds({ profile: 'profile_1' }) t.deepEqual(result, nonDefaultProfile, 'Returned correct credentials from credentials file (params.profile)') resetAWSEnvVars() // AWS_PROFILE env var process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsMock process.env.AWS_PROFILE = profile - result = getCreds({}) + result = await getCreds({}) t.deepEqual(result, nonDefaultProfile, 'Returned correct credentials from credentials file (AWS_PROFILE env var)') resetAWSEnvVars() // Credential file checks are skipped in Lambda process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsMock process.env.AWS_LAMBDA_FUNCTION_NAME = 'true' - t.throws(() => { - getCreds({}) - }, /You must supply AWS credentials via/, 'Did not look for credentials file on disk in Lambda') + try { + await getCreds({}) + } + catch (err) { + t.match(err.message, /You must supply AWS credentials via/, 'Did not look for credentials file on disk in Lambda') + } resetAWSEnvVars() }) -test('Validate credentials', t => { +test('Validate credentials', async t => { t.plan(8) resetAWSEnvVars() - t.throws(() => { - getCreds({ accessKeyId: num }) - }, /Access key must be a string/, 'Threw on invalid access key') + try { + await getCreds({ accessKeyId: num }) + } + catch (err) { + t.match(err.message, /Access key must be a string/, 'Threw on invalid access key') + } - t.throws(() => { - getCreds({ secretAccessKey: num }) - }, /Secret access key must be a string/, 'Threw on invalid secret key') + try { + await getCreds({ secretAccessKey: num }) + } + catch (err) { + t.match(err.message, /Secret access key must be a string/, 'Threw on invalid secret key') + } - t.throws(() => { - getCreds({ sessionToken: num }) - }, /Session token must be a string/, 'Threw on invalid session token') + try { + await getCreds({ sessionToken: num }) + } + catch (err) { + t.match(err.message, /Session token must be a string/, 'Threw on invalid session token') + } - t.throws(() => { - getCreds({ accessKeyId: ok }) - }, /You must supply both an access key ID & secret access key/, 'Threw on invalid credentials combo') + try { + await getCreds({ accessKeyId: ok }) + } + catch (err) { + t.match(err.message, /You must supply both an access key ID & secret access key/, 'Threw on invalid credentials combo') + } - t.throws(() => { - getCreds({ secretAccessKey: ok }) - }, /You must supply both an access key ID & secret access key/, 'Threw on invalid credentials combo') + try { + await getCreds({ secretAccessKey: ok }) + } + catch (err) { + t.match(err.message, /You must supply both an access key ID & secret access key/, 'Threw on invalid credentials combo') + } - t.throws(() => { + try { process.env.AWS_SHARED_CREDENTIALS_FILE = 'meh' // jic dev has actual creds file - getCreds({ sessionToken: ok }) - }, /You must supply AWS credentials via/, 'Threw on invalid credentials combo') + await getCreds({ sessionToken: ok }) + } + catch (err) { + t.match(err.message, /You must supply AWS credentials via/, 'Threw on invalid credentials combo') + } - t.throws(() => { + try { process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsMock process.env.AWS_PROFILE = 'idk' - getCreds({}) - }, /Profile not found/, 'Threw on missing profile') + await getCreds({}) + } + catch (err) { + t.match(err.message, /Profile not found/, 'Threw on missing profile') + } resetAWSEnvVars() - t.throws(() => { + try { process.env.AWS_SHARED_CREDENTIALS_FILE = 'meh' // jic dev has actual creds file - getCreds({}) - }, /You must supply AWS credentials via params, environment variables, or credentials file/, 'Threw on no available credentials') + await getCreds({}) + } + catch (err) { + t.match(err.message, /You must supply AWS credentials via params, environment variables, or credentials file/, 'Threw on no available credentials') + } resetAWSEnvVars() }) diff --git a/test/unit/src/get-region-test.js b/test/unit/src/get-region-test.js index ec265ef5..66f3c0eb 100644 --- a/test/unit/src/get-region-test.js +++ b/test/unit/src/get-region-test.js @@ -19,35 +19,35 @@ test('Set up env', t => { t.ok(getRegion, 'getRegion module is present') }) -test('Get region from passed params', t => { +test('Get region from passed params', async t => { t.plan(1) let region = east1 - let result = getRegion({ region }) + let result = await getRegion({ region }) t.equal(result, region, 'Returned correct region from passed params') }) -test('Get region from env vars', t => { +test('Get region from env vars', async t => { t.plan(3) resetAWSEnvVars() let result process.env.AWS_REGION = east1 - result = getRegion({}) + result = await getRegion({}) t.equal(result, east1, 'Returned correct region from env vars') resetAWSEnvVars() process.env.AWS_DEFAULT_REGION = east1 - result = getRegion({}) + result = await getRegion({}) t.equal(result, east1, 'Returned correct region from env vars') resetAWSEnvVars() process.env.AMAZON_REGION = east1 - result = getRegion({}) + result = await getRegion({}) t.equal(result, east1, 'Returned correct region from env vars') resetAWSEnvVars() }) -test('Get region from config file', t => { +test('Get region from config file', async t => { t.plan(5) resetAWSEnvVars() let result @@ -58,7 +58,7 @@ test('Get region from config file', t => { let configFile = join(home, '.aws', 'config') mockFs({ [configFile]: mockFs.load(configMock) }) process.env.AWS_SDK_LOAD_CONFIG = true - result = getRegion({}) + result = await getRegion({}) t.equal(result, west1, 'Returned correct region from config file (~/.aws file location)') mockFs.restore() resetAWSEnvVars() @@ -66,14 +66,14 @@ test('Get region from config file', t => { // Configured file locations process.env.AWS_SDK_LOAD_CONFIG = true process.env.AWS_CONFIG_FILE = configMock - result = getRegion({}) + result = await getRegion({}) t.equal(result, west1, 'Returned correct region from config file (default profile)') resetAWSEnvVars() // params.profile process.env.AWS_SDK_LOAD_CONFIG = true process.env.AWS_CONFIG_FILE = configMock - result = getRegion({ profile: 'profile_1' }) + result = await getRegion({ profile: 'profile_1' }) t.equal(result, west2, 'Returned correct region from config file (params.profile)') resetAWSEnvVars() @@ -81,7 +81,7 @@ test('Get region from config file', t => { process.env.AWS_SDK_LOAD_CONFIG = true process.env.AWS_CONFIG_FILE = configMock process.env.AWS_PROFILE = profile - result = getRegion({}) + result = await getRegion({}) t.equal(result, west2, 'Returned correct region from config file (AWS_PROFILE env var)') resetAWSEnvVars() @@ -89,40 +89,58 @@ test('Get region from config file', t => { process.env.AWS_SDK_LOAD_CONFIG = true process.env.AWS_CONFIG_FILE = configMock process.env.AWS_LAMBDA_FUNCTION_NAME = 'true' - t.throws(() => { - getRegion({}) - }, /You must supply AWS region/, 'Did not look for config file on disk in Lambda') + try { + await getRegion({}) + } + catch (err) { + t.match(err.message, /You must supply AWS region/, 'Did not look for config file on disk in Lambda') + } resetAWSEnvVars() }) -test('Validate config', t => { +test('Validate config', async t => { t.plan(5) resetAWSEnvVars() - t.throws(() => { - getRegion({ region: num }) - }, /Region must be a string/, 'Threw on invalid region') - - t.throws(() => { - getRegion({ region: 'us-south-14' }) - }, /Invalid region specified/, 'Threw on invalid region') - - t.throws(() => { + try { + await getRegion({ region: num }) + } + catch (err) { + t.match(err.message, /Region must be a string/, 'Threw on invalid region') + } + + try { + await getRegion({ region: 'us-south-14' }) + } + catch (err) { + t.match(err.message, /Invalid region specified/, 'Threw on invalid region') + } + + try { process.env.AWS_SDK_LOAD_CONFIG = true process.env.AWS_CONFIG_FILE = configMock process.env.AWS_PROFILE = 'idk' - getRegion({}) - }, /Profile not found/, 'Threw on missing profile') + await getRegion({}) + } + catch (err) { + t.match(err.message, /Profile not found/, 'Threw on missing profile') + } resetAWSEnvVars() - t.throws(() => { + try { process.env.AWS_SDK_LOAD_CONFIG = true process.env.AWS_CONFIG_FILE = 'meh' - getRegion({}) - }, /You must supply AWS region/, 'Threw on no available config (after attempting to checking filesystem)') + await getRegion({}) + } + catch (err) { + t.match(err.message, /You must supply AWS region/, 'Threw on no available config (after attempting to checking filesystem)') + } resetAWSEnvVars() - t.throws(() => { - getRegion({}) - }, /You must supply AWS region/, 'Threw on no available config') + try { + await getRegion({}) + } + catch (err) { + t.match(err.message, /You must supply AWS region/, 'Threw on no available config') + } }) From 1e6a8e89f1b08e25d78baf67e9aae8452b195a2b Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Fri, 29 Sep 2023 11:01:12 -0700 Subject: [PATCH 12/35] Run an initial validation pass to catch issues before going onto `request()` hook Add links to docs in errors --- readme.md | 4 ++++ src/client-factory.js | 18 +++++++++++++- test/mock/plugins/errors.js | 5 ++++ test/unit/src/index-plugins-test.js | 37 ++++++++++++++++++++++++++++- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 71d07f21..d8ac265a 100644 --- a/readme.md +++ b/readme.md @@ -261,6 +261,10 @@ export default { aws.dynamodb.PutItem({ TableName: 12345 }) // Throws validation error ``` +Additionally, two optional metadata properties may be added that will be included in any method errors: +- `awsDoc` (string) [optional] - intended to be a link to the AWS API doc pertaining to this method; should usually start with `https://docs.aws.amazon.com/...` +- `readme` (string) [optional] - a link to a relevant section in your plugin's readme or docs + Example plugins can be found below, in [`plugins/` dir (containing `@aws-lite/*` plugins)](https://github.com/architect/aws-lite/tree/main/plugins), and in [tests](https://github.com/architect/aws-lite/tree/main/test/mock/plugins). diff --git a/src/client-factory.js b/src/client-factory.js index aea92a09..e305d964 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -105,8 +105,24 @@ module.exports = async function clientFactory (config, creds, region) { // For convenient error reporting (and jic anyone wants to enumerate everything) try to ensure the AWS API method names pass through clientMethods[name] = Object.defineProperty(async input => { + input = input || {} let selectedRegion = input?.region || region let metadata = { service, name } + if (method.awsDoc) { + metadata.awsDoc = method.awsDoc + } + // Printed after the AWS doc + if (pluginName.startsWith('@aws-lite/')) { + metadata.readme = `https://github.com/architect/aws-lite/blob/main/plugins/${service}/readme.md#${name}` + } + else if (method.readme) { + metadata.readme = method.readme + } + + // Initial validation + if (method.validate) { + validateInput(method.validate, input, metadata) + } // Run plugin.request() try { @@ -117,7 +133,7 @@ module.exports = async function clientFactory (config, creds, region) { errorHandler({ error: methodError, metadata }) } - // Hit plugin.validate + // Validate combined inputs of user + plugin let params = { ...input, ...req } if (method.validate) { validateInput(method.validate, params, metadata) diff --git a/test/mock/plugins/errors.js b/test/mock/plugins/errors.js index 855914eb..f6c81f7a 100644 --- a/test/mock/plugins/errors.js +++ b/test/mock/plugins/errors.js @@ -5,6 +5,7 @@ module.exports = { service: 'lambda', methods: { requestMethodBlowsUp: { + awsDoc: 'https://requestMethodBlowsUp.lol', request: async (input) => { input.foo.bar = 'idk' return input @@ -14,6 +15,8 @@ module.exports = { request: passthrough, }, errorMethodMutatesError: { + awsDoc: 'https://errorMethodMutatesError.lol', + readme: 'lolidk', request: noop, error: async (error) => { if (error.statusCode === 400 && @@ -24,10 +27,12 @@ module.exports = { } }, errorMethodNoop: { + awsDoc: 'https://errorMethodNoop.lol', request: noop, error: noop, }, errorMethodBlowsUp: { + awsDoc: 'https://errorMethodBlowsUp.lol', request: noop, error: async (err) => { err.metadata.type = message diff --git a/test/unit/src/index-plugins-test.js b/test/unit/src/index-plugins-test.js index 075c7926..9b070e2a 100644 --- a/test/unit/src/index-plugins-test.js +++ b/test/unit/src/index-plugins-test.js @@ -88,6 +88,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({}) } catch (err) { + console.log(err) t.match(err.message, /Missing required parameter: required/, 'Errored on missing required param') } @@ -96,6 +97,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: num }) } catch (err) { + console.log(err) t.match(err.message, /Parameter 'required' must be: string/, 'Errored on wrong required param type') } @@ -104,6 +106,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ arr: true }) } catch (err) { + console.log(err) t.match(err.message, /Parameter 'arr' must be: array/, 'Errored on wrong optional param type') } @@ -112,6 +115,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ disabled: str }) } catch (err) { + console.log(err) t.match(err.message, /Parameter 'disabled' must not be used/, 'Errored on disabled param') } @@ -120,6 +124,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, invalidType: str }) } catch (err) { + console.log(err) t.match(err.message, /Invalid type found: invalidType \(lolidk\)/, 'Errored on invalid validation type (string)') } @@ -128,6 +133,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, invalidTypeList: str }) } catch (err) { + console.log(err) t.match(err.message, /Invalid type found: invalidTypeList \(listidk\)/, 'Errored on invalid validation type (list)') } @@ -136,6 +142,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, invalidTypeType: str }) } catch (err) { + console.log(err) t.match(err.message, /Validator 'type' property must be a string or array/, 'Errored on invalid validation type') } @@ -144,6 +151,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, invalidTypeListType: str }) } catch (err) { + console.log(err) t.match(err.message, /Invalid type found: invalidTypeListType \(12345\)/, 'Errored on invalid validation type (list)') } @@ -152,6 +160,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, missingType: str }) } catch (err) { + console.log(err) t.match(err.message, /Validator is missing required 'type' property/, 'Errored on missing validation type') } @@ -175,6 +184,7 @@ test('Plugins - input validation', async t => { t.fail(`Incorrect ${k} validation failed`) } catch (err) { + console.log(err) let re = new RegExp(`Parameter '${k}' must be`) t.match(err.message, re, `Incorrect ${k} validation succeeded`) } @@ -185,6 +195,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, payload: num }) } catch (err) { + console.log(err) t.match(err.message, /Parameter 'payload' must be one of/, 'Errored on wrong param (from type array)') } @@ -193,6 +204,7 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, data: num }) } catch (err) { + console.log(err) t.match(err.message, /Parameter 'data' must be one of/, 'Errored on wrong param (from type array, payload alias)') } @@ -201,13 +213,14 @@ test('Plugins - input validation', async t => { await aws.lambda.testTypes({ required: str, payload: str, data: str }) } catch (err) { + console.log(err) t.match(err.message, /Found duplicate payload parameters/, 'Errored on duplicate payload params') } reset() }) test('Plugins - error handling', async t => { - t.plan(36) + t.plan(43) let name = 'my-lambda' let payload = { ok: true } let responseBody, responseHeaders, responseStatusCode @@ -235,6 +248,7 @@ test('Plugins - error handling', async t => { console.log(err) t.match(err.message, /\@aws-lite\/client: lambda.requestMethodBlowsUp: Cannot set/, 'Error included basic method information') t.equal(err.service, service, 'Error has service metadata') + t.equal(err.awsDoc, 'https://requestMethodBlowsUp.lol', 'Error has AWS API doc') t.ok(err.stack.includes(errorsPlugin), 'Stack trace includes failing plugin') t.ok(err.stack.includes(__filename), 'Stack trace includes this test') reset() @@ -253,6 +267,8 @@ test('Plugins - error handling', async t => { t.match(err.message, /\@aws-lite\/client: lambda.errorMethodMutatesError/, 'Error included basic method information') t.equal(err.statusCode, responseStatusCode, 'Error has status code') t.equal(err.service, service, 'Error has service metadata') + t.equal(err.awsDoc, 'https://errorMethodMutatesError.lol', 'Error has AWS API doc') + t.equal(err.readme, 'lolidk', 'Error has custom readme doc') t.equal(err.other, responseBody.other, 'Error has other metadata') t.notOk(err.type, 'Error does not have type (via plugin error)') t.ok(err.stack.includes(__filename), 'Stack trace includes this test') @@ -272,6 +288,8 @@ test('Plugins - error handling', async t => { t.match(err.message, /\@aws-lite\/client: lambda.errorMethodMutatesError/, 'Error included basic method information') t.equal(err.statusCode, responseStatusCode, 'Error has status code') t.equal(err.service, service, 'Error has service metadata') + t.equal(err.awsDoc, 'https://errorMethodMutatesError.lol', 'Error has AWS API doc') + t.equal(err.readme, 'lolidk', 'Error has custom readme doc') t.equal(err.other, responseBody.other, 'Error has other metadata') t.equal(err.type, 'Lambda validation error', 'Error has type (via plugin error)') t.ok(err.stack.includes(__filename), 'Stack trace includes this test') @@ -307,6 +325,7 @@ test('Plugins - error handling', async t => { t.match(err.message, /\@aws-lite\/client: lambda.errorMethodNoop/, 'Error included basic method information') t.equal(err.statusCode, responseStatusCode, 'Error has status code') t.equal(err.service, service, 'Error has service metadata') + t.equal(err.awsDoc, 'https://errorMethodNoop.lol', 'Error has AWS API doc') t.equal(err.other, responseBody.other, 'Error has other metadata') t.notOk(err.type, 'Error does not have type (via plugin error)') t.ok(err.stack.includes(__filename), 'Stack trace includes this test') @@ -325,6 +344,7 @@ test('Plugins - error handling', async t => { console.log(err) t.match(err.message, /\@aws-lite\/client: lambda.errorMethodBlowsUp: Cannot set/, 'Error included basic method information') t.equal(err.service, service, 'Error has service metadata') + t.notOk(err.awsDoc, 'Error does not have a doc') t.notOk(err.other, 'Error does not have other metadata') t.notOk(err.type, 'Error metadata was not mutated') t.ok(err.stack.includes(errorsPlugin), 'Stack trace includes failing plugin') @@ -333,6 +353,21 @@ test('Plugins - error handling', async t => { } }) +test('Plugins - error docs (@aws-lite)', async t => { + t.plan(2) + let aws = await client({ ...config, plugins: [ '@aws-lite/s3' ] }) + + try { + await aws.s3.PutObject() + reset() + } + catch (err) { + console.log(err) + t.equal(err.awsDoc, 'https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html', 'Error has a doc') + t.equal(err.readme, 'https://github.com/architect/aws-lite/blob/main/plugins/s3/readme.md#PutObject', 'Error has link to method in readme') + } +}) + test('Plugins - plugin validation', async t => { t.plan(11) From ebe5a0ae8c8abfc6842b6c2aef44d20dd20c7971 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Fri, 29 Sep 2023 11:38:10 -0700 Subject: [PATCH 13/35] Unify payload to include readable streams Add simple progress indication when debugging HTTP streaming --- plugins/s3/src/put-object.mjs | 2 +- readme.md | 5 +++-- src/request.js | 28 +++++++++++++++++++++++----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index 7e5aee5e..c9b3d49a 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -210,7 +210,7 @@ const PutObject = { stream.push(null) } }) - canonicalReq.stream = stream + canonicalReq.payload = stream return canonicalReq } }, diff --git a/readme.md b/readme.md index d8ac265a..d2a5277a 100644 --- a/readme.md +++ b/readme.md @@ -171,9 +171,10 @@ The following parameters may be passed with individual client requests; only `se - **`headers` (object)** - Header names + values to be added to your request - By default, all headers are included in [authentication via AWS signature v4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) -- **`payload` (object or string)** +- **`payload` (object, buffer, readable stream, string)** - Aliases: `body`, `data`, `json` - - As a convenience, any passed objects are automatically JSON-encoded (with the appropriate `content-type` header set, if not already present); strings pass through + - As a convenience, any passed objects are automatically JSON-encoded (with the appropriate `content-type` header set, if not already present); buffers and strings simply pass through as is + - Passing a Node.js readable stream is currently experimental; this initiates an HTTP data stream to the API endpoint instead of writing a normal HTTP body payload - **`query` (object)** - Serialize the passed object and append it to your `endpoint` as a query string in your request - **`service` (string) [required]** diff --git a/src/request.js b/src/request.js index 7440f926..4bdb5827 100644 --- a/src/request.js +++ b/src/request.js @@ -1,4 +1,5 @@ let qs = require('querystring') +let { Readable } = require('stream') let aws4 = require('aws4') let { globalServices, semiGlobalServices } = require('./services') let { is } = require('./validate') @@ -40,8 +41,11 @@ module.exports = function request (params, creds, region, config, metadata) { // Body - JSON-ify payload where convenient! let body = params.payload || params.body || params.data || params.json + let isBuffer = body instanceof Buffer + let isStream = body instanceof Readable + // Lots of potentially weird valid json (like just a null), deal with it if / when we need to I guess - if (typeof body === 'object' && !(body instanceof Buffer)) { + if (typeof body === 'object' && !isBuffer && !isStream) { // Backfill content-type if it's just an object if (!contentType) contentType = 'application/json' @@ -59,8 +63,11 @@ module.exports = function request (params, creds, region, config, metadata) { // Final JSON encoding params.body = JSON.stringify(body) } - // Everything else just passes through - else params.body = body + // Everything besides streams pass through for signing + else { + /* istanbul ignore next */ + params.body = isStream ? undefined : body + } // Finalize headers, content-type if (contentType) headers['content-type'] = contentType @@ -104,7 +111,8 @@ module.exports = function request (params, creds, region, config, metadata) { if (config.debug) { let { method = 'GET', service, host, path, port = '', headers, protocol, body } = options let truncatedBody - if (body instanceof Buffer) truncatedBody = `` + /**/ if (isBuffer) truncatedBody = `` + else if (isStream) truncatedBody = `` else truncatedBody = body?.length > 1000 ? body?.substring(0, 1000) + '...' : body console.error('[aws-lite] Requesting:', { service, @@ -150,8 +158,18 @@ module.exports = function request (params, creds, region, config, metadata) { port: options.port, } })) + /* istanbul ignore next */ // TODO remove and test - if (options.stream) options.stream.pipe(req) + if (isStream) { + body.pipe(req) + if (config.debug) { + let bytes = 0 + body.on('data', chunk => { + bytes += chunk.length + console.error(`Bytes streamed: ${bytes}`) + }) + } + } else req.end(options.body || '') }) } From 3c9dcf297200489e22d47abc49eb8fad499680fb Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Sat, 30 Sep 2023 12:49:45 -0700 Subject: [PATCH 14/35] Refactor `response()` to include `statusCode`, `headers`, and `payload` Include response headers in error output (where available) Add plugin helper utils section to readme --- readme.md | 66 +++++++++++++++++++++++++----- src/client-factory.js | 33 ++++++++------- src/error.js | 5 ++- src/request.js | 10 ++--- test/unit/src/index-client-test.js | 26 +++++++----- 5 files changed, 98 insertions(+), 42 deletions(-) diff --git a/readme.md b/readme.md index d2a5277a..cba239b8 100644 --- a/readme.md +++ b/readme.md @@ -17,6 +17,7 @@ - [`request()`](#request) - [`response()`](#response) - [`error()`](#error) + - [Plugin utils](#plugin-utils) - [List of official `@aws-lite/*` plugins](#list-of-official-aws-lite-plugins) - [Contributing](#contributing) - [Setup](#setup) @@ -250,6 +251,8 @@ The above four lifecycle hooks must be exported as an object named `methods`, al // A simple plugin for validating input export default { service: 'dynamodb', + awsDoc: 'https://docs.aws.../API_PutItem.html', + readme: 'https://github...#PutItem', methods: { PutItem: { validate: { @@ -262,7 +265,7 @@ export default { aws.dynamodb.PutItem({ TableName: 12345 }) // Throws validation error ``` -Additionally, two optional metadata properties may be added that will be included in any method errors: +Additionally, two optional (but highly recommended) metadata properties may be added that will be included in any method errors: - `awsDoc` (string) [optional] - intended to be a link to the AWS API doc pertaining to this method; should usually start with `https://docs.aws.amazon.com/...` - `readme` (string) [optional] - a link to a relevant section in your plugin's readme or docs @@ -310,7 +313,7 @@ The `request()` lifecycle hook is an optional async function that enables that e - **`params` (object)** - The method's input parameters - **`utils` (object)** - - Helper utilities for (de)serializing AWS-flavored JSON: `awsjsonMarshall`, `awsjsonUnmarshall` + - [Plugin helper utilities](#plugin-utils) The `request()` method may return nothing, or a [valid client request](#client-requests). An example: @@ -340,12 +343,18 @@ The `response()` lifecycle hook is an async function that enables mutation of se `response()` is executed with two positional arguments: -- **`response` (any)** - - Raw non-error response from AWS service API request; if the entire payload is JSON or AWS-flavored JSON, `aws-lite` will attempt to parse it prior to executing `response()`. Responses that are primarily JSON, but with nested AWS-flavored JSON, will be parsed only as JSON and may require additional deserialization with the `awsjsonUnmarshall` utility +- **`params` (object)** + - An object containing three properties from the API response: + - **`statusCode` (number)** + - HTTP response status code + - **`headers` (object)** + - HTTP response headers + - **`payload` (object or string)** + - Raw non-error response from AWS service API request; if the entire payload is JSON or AWS-flavored JSON, `aws-lite` will attempt to parse it prior to executing `response()`. Responses that are primarily JSON, but with nested AWS-flavored JSON, will be parsed only as JSON and may require additional deserialization with the `awsjsonUnmarshall` utility - **`utils` (object)** - - Helper utilities for (de)serializing AWS-flavored JSON: `awsjsonMarshall`, `awsjsonUnmarshall` + - [Plugin helper utilities](#plugin-utils) -The `response()` method may return nothing, but if it does return a mutated response, it must come in the form of an object containing a `response` property, and an optional `awsjson` property (that behaves the same as in [client requests](#client-requests)). An example: +The `response()` method may return nothing, or it may return an object containing the following optional properties: `statusCode` (number), `headers` (object), `payload` (object or string), and `awsjson` (that behaves the same as in [client requests](#client-requests)). An example: ```js // Automatically deserialize AWS-flavored JSON @@ -353,9 +362,9 @@ export default { service: 'dynamodb', methods: { GetItem: { - // Successful responses always have an AWS-flavored JSON `Item` property - response: async (response, utils) => { - return { awsjson: [ 'Item' ], response } + // Assume successful responses always have an AWS-flavored JSON `Item` property + response: async (params, utils) => { + return { awsjson: [ 'Item' ], ...params } } } } @@ -375,9 +384,9 @@ The `error()` lifecycle hook is an async function that enables mutation of servi - **`metadata` (object)** - `aws-lite` error metadata; to improve the quality of the errors presented by `aws-lite`, please only append to this object - **`statusCode` (number or undefined)** - resulting status code of the API response; if an HTTP connection error occurred, no `statusCode` will be present - **`utils` (object)** - - Helper utilities for (de)serializing AWS-flavored JSON: `awsjsonMarshall`, `awsjsonUnmarshall` + - [Plugin helper utilities](#plugin-utils) -The `error()` method may return nothing, a new or mutated version of the error payload it was passed, a string, an object, or a JS error. An example +The `error()` method may return nothing, a new or mutated version of the error payload it was passed, a string, an object, or a JS error. An example: ```js // Improve clarity of error output @@ -399,6 +408,41 @@ export default { ``` +#### Plugin utils + +[`request()`](#request), [`response()`](#response), and [`error()`](#error) are all passed a second argument of helper utilities and data pertaining to the client: + +- **`awsjsonMarshall` (function)** + - Utility for marshalling data to the format underlying AWS-flavored JSON serialization; accepts a plain object, returns a marshalled object +- **`awsjsonUnmarshall` (function)** + - Utility for unmarshalling data from the format underlying AWS-flavored JSON serialization; accepts a marshalled object, returns a plain object +- **`config` (object)** + - The current [client configuration](#configuration); any configured credentials are found in the `credentials` object +- **`credentials` (object)** + - `accessKeyId`, `secretAccessKey`, and `sessionToken` being used in this request + - Note: `secretAccessKey` and `sessionToken` are present in this object, but non-enumerable +- **`region` (string)** + - Canonical service region being used in this request; this value may differ from the region set in the `config` object if overridden per-request + +An example of plugin utils: + +```js +async function request (params, utils) { + let awsStyle = utils.awsjsonMarshall({ ok: true, hi: 'there' }) + console.log(marshalled) // { ok: { BOOL: true }, hi: { S: 'there' } } + + let plain = utils.awsjsonUnmarshall({ ok: { BOOL: true }, hi: { S: 'there' } }) + console.log(unmarshalled) // { ok: true, hi: 'there' } + + console.log(config) // { profile: 'my-profile', autoloadPlugins: true, ... } + + console.log(credentials) // { accessKeyId: 'abc123...' } secrets are non-enumerable + + console.log(region) // 'us-west-1' +} +``` + + ### List of official `@aws-lite/*` plugins diff --git a/src/client-factory.js b/src/client-factory.js index e305d964..865ad6ee 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -7,6 +7,7 @@ let { awsjson } = require('./lib') let { marshall, unmarshall } = require('./_vendor') let errorHandler = require('./error') +let credentialProps = [ 'accessKeyId', 'secretAccessKey', 'sessionToken' ] let copy = obj => JSON.parse(JSON.stringify(obj)) // Never autoload these `@aws-lite/*` packages: @@ -85,17 +86,16 @@ module.exports = async function clientFactory (config, creds, region) { } }) + let configuration = copy(config) + credentialProps.forEach(p => delete configuration[p]) let credentials = copy(creds) - Object.defineProperty(config, 'secretAccessKey', { enumerable: false }) - Object.defineProperty(config, 'secretAccessKey', { enumerable: false }) - Object.defineProperty(credentials, 'sessionToken', { enumerable: false }) + Object.defineProperty(credentials, 'secretAccessKey', { enumerable: false }) Object.defineProperty(credentials, 'sessionToken', { enumerable: false }) let pluginUtils = { awsjsonMarshall: marshall, awsjsonUnmarshall: unmarshall, - config: copy(config), + config: configuration, credentials, - region, } let clientMethods = {} Object.entries(methods).forEach(([ name, method ]) => { @@ -126,7 +126,7 @@ module.exports = async function clientFactory (config, creds, region) { // Run plugin.request() try { - var req = await method.request(input, pluginUtils) + var req = await method.request(input, { ...pluginUtils, region: selectedRegion }) req = req || {} } catch (methodError) { @@ -147,17 +147,22 @@ module.exports = async function clientFactory (config, creds, region) { /* istanbul ignore next */ // TODO remove as soon as plugin.response() API settles if (method.response) { try { - var pluginRes = await method.response(response, pluginUtils) - if (pluginRes && pluginRes.response === undefined) { - throw TypeError('Response plugins must return a response property') - } + var pluginRes = await method.response(response, { ...pluginUtils, region: selectedRegion }) } catch (methodError) { errorHandler({ error: methodError, metadata }) } - response = pluginRes?.awsjson - ? awsjson.unmarshall(pluginRes.response, pluginRes.awsjson) - : pluginRes?.response || response + if (pluginRes) { + let { statusCode, headers, payload } = pluginRes + if (pluginRes.awsjson) { + payload = awsjson.unmarshall(payload, pluginRes.awsjson) + } + response = { + statusCode: statusCode || response.statusCode, + headers: headers || response.headers, + payload: payload || response.payload, + } + } } return response } @@ -165,7 +170,7 @@ module.exports = async function clientFactory (config, creds, region) { // Run plugin.error() if (method.error && !(input instanceof Error)) { try { - let updatedError = await method.error(err, pluginUtils) + let updatedError = await method.error(err, { ...pluginUtils, region: selectedRegion }) errorHandler(updatedError || err) } catch (methodError) { diff --git a/src/error.js b/src/error.js index 4225654b..a745f48d 100644 --- a/src/error.js +++ b/src/error.js @@ -4,13 +4,16 @@ module.exports = function errorHandler (input) { throw input } - let { error, statusCode, metadata } = input + let { statusCode, headers, error, metadata } = input // If the error passed is an actual Error, it probably came from a plugin method failing, so we should attempt to retain its beautiful, beautiful stack trace let err = error instanceof Error ? error : Error() if (statusCode) { err.statusCode = statusCode } + if (headers) { + err.headers = headers + } // The most common error response from AWS services if (typeof error === 'object') { diff --git a/src/request.js b/src/request.js index 4bdb5827..337b24d3 100644 --- a/src/request.js +++ b/src/request.js @@ -131,20 +131,20 @@ module.exports = function request (params, creds, region, config, metadata) { res.on('data', chunk => data.push(chunk)) res.on('end', () => { // TODO The following string coersion will definitely need be changed when we get into binary response payloads - let result = Buffer.concat(data).toString() + let payload = Buffer.concat(data).toString() let contentType = headers['content-type'] || headers['Content-Type'] || '' if (JSONContentType(contentType) || AwsJSONContentType(contentType)) { - result = JSON.parse(result) + payload = JSON.parse(payload) } // Some services may attempt to respond with regular JSON, but an AWS JSON content-type. Sure. Ok. Anyway, try to guard against that. if (AwsJSONContentType(contentType)) { try { - result = awsjson.unmarshall(result) + payload = awsjson.unmarshall(payload) } catch { /* noop, it's already parsed */ } } - if (ok) resolve(result) - else reject({ error: result, metadata, statusCode }) + if (ok) resolve({ statusCode, headers, payload }) + else reject({ statusCode, headers, error: payload, metadata }) }) }) req.on('error', error => reject({ diff --git a/test/unit/src/index-client-test.js b/test/unit/src/index-client-test.js index f772ecf3..2633c9e4 100644 --- a/test/unit/src/index-client-test.js +++ b/test/unit/src/index-client-test.js @@ -16,7 +16,7 @@ test('Set up env', async t => { }) test('Primary client - core functionality', async t => { - t.plan(28) + t.plan(30) let request, result, body, query, responseBody, url let headers = { 'content-type': 'application/json' } @@ -27,7 +27,9 @@ test('Primary client - core functionality', async t => { result = await aws({ service, endpoint }) request = server.getCurrentRequest() t.notOk(request.body, 'Request included no body') - t.equal(result, '', 'Client returned empty response body as empty string') + t.equal(result.statusCode, 200, 'Client returned status code of response') + t.ok(result.headers, 'Client returned response headers') + t.equal(result.payload, '', 'Client returned empty response body as empty string') basicRequestChecks(t, 'GET') // Basic get request with query string params @@ -43,7 +45,7 @@ test('Primary client - core functionality', async t => { result = await aws({ service, endpoint, body }) request = server.getCurrentRequest() t.deepEqual(request.body, body, 'Request included correct body') - t.deepEqual(result, responseBody, 'Client returned response body as parsed JSON') + t.deepEqual(result.payload, responseBody, 'Client returned response body as parsed JSON') basicRequestChecks(t, 'POST') // Basic post with query string params @@ -147,7 +149,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, headers: headersAwsJSON() }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: { BOOL: true } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -157,7 +159,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, headers: headersAwsJSON() }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: { BOOL: false } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -167,7 +169,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, awsjson: true }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: { BOOL: false } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -177,7 +179,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, awsjson: [ 'fine' ] }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: true, fine: { BOOL: false } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -186,12 +188,12 @@ test('Primary client - AWS JSON payloads', async t => { server.use({ responseBody: regularJSON, responseHeaders: headersAwsJSON() }) result = await aws({ service, endpoint }) request = server.getCurrentRequest() - t.deepEqual(result, regularJSON, 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, regularJSON, 'Client returned response body as parsed, unmarshalled JSON') reset() }) test('Primary client - error handling', async t => { - t.plan(17) + t.plan(19) let responseStatusCode, responseBody, responseHeaders // Normal error @@ -207,7 +209,8 @@ test('Primary client - error handling', async t => { console.log(err) t.match(err.message, /\@aws-lite\/client: lambda: lolno/, 'Error included basic information') t.equal(err.other, responseBody.other, 'Error has other metadata') - t.equal(err.statusCode, responseStatusCode, 'Error has status code') + t.equal(err.statusCode, responseStatusCode, 'Error has response status code') + t.ok(err.headers, 'Error has response headers') t.equal(err.service, service, 'Error has service') t.ok(err.stack.includes(__filename), 'Stack trace includes this test') reset() @@ -227,7 +230,8 @@ test('Primary client - error handling', async t => { console.log(err) t.match(err.message, /\@aws-lite\/client: lambda/, 'Error included basic information') t.ok(err.message.includes(responseBody), 'Error has message') - t.equal(err.statusCode, responseStatusCode, 'Error has status code') + t.equal(err.statusCode, responseStatusCode, 'Error has response status code') + t.ok(err.headers, 'Error has response headers') t.equal(err.service, service, 'Error has service') t.ok(err.stack.includes(__filename), 'Stack trace includes this test') reset() From 0d7fc77835fdbfb78ddd53dcc2019166ddb5e765 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Sun, 1 Oct 2023 14:05:51 -0700 Subject: [PATCH 15/35] Refactor `response()` handling to enable returning arbitrary data Fix tests borken by previous changes Update readme --- .github/workflows/build.yml | 8 ++++++++ readme.md | 10 +++++++--- src/client-factory.js | 18 +++++++++--------- test/live/_iam.mjs | 2 ++ test/live/_lambda.js | 2 ++ 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ccb61c7a..05d75411 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,14 @@ jobs: - name: Install run: npm install + - name: Link (npm < 7) + if: matrix.node-version == '14.x' + run: | + cd plugins/s3 + npm link + cd ../../ + npm link @aws-lite/s3 + - name: Test run: npm test env: diff --git a/readme.md b/readme.md index cba239b8..518a3fd7 100644 --- a/readme.md +++ b/readme.md @@ -174,7 +174,7 @@ The following parameters may be passed with individual client requests; only `se - By default, all headers are included in [authentication via AWS signature v4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) - **`payload` (object, buffer, readable stream, string)** - Aliases: `body`, `data`, `json` - - As a convenience, any passed objects are automatically JSON-encoded (with the appropriate `content-type` header set, if not already present); buffers and strings simply pass through as is + - As a convenience, any passed objects are automatically JSON-encoded (with the appropriate `content-type` header set, if not already present); buffers and strings simply pass through as-is - Passing a Node.js readable stream is currently experimental; this initiates an HTTP data stream to the API endpoint instead of writing a normal HTTP body payload - **`query` (object)** - Serialize the passed object and append it to your `endpoint` as a query string in your request @@ -343,7 +343,7 @@ The `response()` lifecycle hook is an async function that enables mutation of se `response()` is executed with two positional arguments: -- **`params` (object)** +- **`response` (object)** - An object containing three properties from the API response: - **`statusCode` (number)** - HTTP response status code @@ -354,7 +354,11 @@ The `response()` lifecycle hook is an async function that enables mutation of se - **`utils` (object)** - [Plugin helper utilities](#plugin-utils) -The `response()` method may return nothing, or it may return an object containing the following optional properties: `statusCode` (number), `headers` (object), `payload` (object or string), and `awsjson` (that behaves the same as in [client requests](#client-requests)). An example: +The `response()` method may return: nothing (which will pass through the `response` object as-is), a mutated version of the `response` object, or any other data type (most commonly an object or string). + +Should you return an object, you may also include an `awsjson` property (that behaves the same as in [client requests](#client-requests)). The `awsjson` property will be stripped from any returned response. + +An example: ```js // Automatically deserialize AWS-flavored JSON diff --git a/src/client-factory.js b/src/client-factory.js index 865ad6ee..fe5c9088 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -152,16 +152,16 @@ module.exports = async function clientFactory (config, creds, region) { catch (methodError) { errorHandler({ error: methodError, metadata }) } - if (pluginRes) { - let { statusCode, headers, payload } = pluginRes - if (pluginRes.awsjson) { - payload = awsjson.unmarshall(payload, pluginRes.awsjson) - } - response = { - statusCode: statusCode || response.statusCode, - headers: headers || response.headers, - payload: payload || response.payload, + if (pluginRes !== undefined) { + let unmarshalling = pluginRes.awsjson + if (unmarshalling) { + delete pluginRes.awsjson + // If a payload property isn't included, it _is_ the payload + let payload = pluginRes.payload || pluginRes + let unmarshalled = awsjson.unmarshall(payload, unmarshalling) + response = { ...pluginRes, ...unmarshalled } } + else response = pluginRes } } return response diff --git a/test/live/_iam.mjs b/test/live/_iam.mjs index 2fdd68ae..5cc26c3c 100644 --- a/test/live/_iam.mjs +++ b/test/live/_iam.mjs @@ -12,6 +12,7 @@ export default { } } }, + response: async ({ payload }) => payload, }, CreateRole: { request: async function ({ name, policyDoc, desc, path }) { @@ -26,6 +27,7 @@ export default { } } }, + response: async ({ payload }) => payload, } } } diff --git a/test/live/_lambda.js b/test/live/_lambda.js index 7936567e..aa60cdae 100644 --- a/test/live/_lambda.js +++ b/test/live/_lambda.js @@ -22,6 +22,7 @@ module.exports = { endpoint: `/2015-03-31/functions/${name}/configuration` } }, + response: async ({ payload }) => payload, }, // https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html @@ -35,6 +36,7 @@ module.exports = { endpoint: `/2015-03-31/functions/${name}/invocations` } }, + response: async ({ payload }) => payload, }, } } From 8ff55831b8df8d56d3d4fee2996057318b05c71d Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Sun, 1 Oct 2023 18:32:58 -0700 Subject: [PATCH 16/35] Update `@aws-lite/dynamodb` to accommodate new `response()` semantics --- plugins/dynamodb/src/index.mjs | 140 +++++++++++++++++++++------------ readme.md | 11 +-- 2 files changed, 97 insertions(+), 54 deletions(-) diff --git a/plugins/dynamodb/src/index.mjs b/plugins/dynamodb/src/index.mjs index 729399f9..993627f4 100644 --- a/plugins/dynamodb/src/index.mjs +++ b/plugins/dynamodb/src/index.mjs @@ -21,7 +21,8 @@ const Item = { ...obj, required } const ReturnConsumedCapacity = str const ReturnItemCollectionMetrics = str -const unmarshall = keys => async response => ({ awsjson: keys, response }) +const defaultResponse = ({ payload }) => payload +const unmarshall = keys => ({ payload }) => ({ awsjson: keys, ...payload }) const headers = (method, additional) => ({ 'X-Amz-Target': `DynamoDB_20120810.${method}`, ...additional }) const awsjsonContentType = { 'content-type': 'application/x-amz-json-1.0' } @@ -47,15 +48,15 @@ const BatchExecuteStatement = { payload: { ...params, Statements } } }, - response: async (response, { awsjsonUnmarshall }) => { - if (response?.Responses?.length) { - response.Responses = response.Responses.map(r => { + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (payload?.Responses?.length) { + payload.Responses = payload.Responses.map(r => { if (r?.Error?.Item) r.Error.Item = awsjsonUnmarshall(r.Error.Item) if (r?.Item) r.Item = awsjsonUnmarshall(r.Item) return r }) } - return { response } + return payload }, } @@ -77,19 +78,19 @@ const BatchGetItem = { payload: { ...params, RequestItems } } }, - response: async (response, { awsjsonUnmarshall }) => { - let Responses = Object.keys(response.Responses) + response: async ({ payload }, { awsjsonUnmarshall }) => { + let Responses = Object.keys(payload.Responses) if (Responses.length) { - Responses.forEach(i => response.Responses[i] = response.Responses[i]?.map(awsjsonUnmarshall)) + Responses.forEach(i => payload.Responses[i] = payload.Responses[i]?.map(awsjsonUnmarshall)) } - let UnprocessedKeys = Object.keys(response.UnprocessedKeys) + let UnprocessedKeys = Object.keys(payload.UnprocessedKeys) if (UnprocessedKeys.length) { - UnprocessedKeys.forEach(i => response.UnprocessedKeys[i] = { - ...response.UnprocessedKeys[i], - Keys: response.UnprocessedKeys[i]?.Keys?.map(awsjsonUnmarshall) + UnprocessedKeys.forEach(i => payload.UnprocessedKeys[i] = { + ...payload.UnprocessedKeys[i], + Keys: payload.UnprocessedKeys[i]?.Keys?.map(awsjsonUnmarshall) }) } - return { response } + return payload }, } @@ -122,9 +123,9 @@ const BatchWriteItem = { payload: { ...params, RequestItems } } }, - response: async (response, { awsjsonUnmarshall }) => { + response: async ({ payload }, { awsjsonUnmarshall }) => { let UnprocessedItems = {} - Object.entries(response.UnprocessedItems).forEach(([ table, items ]) => { + Object.entries(payload.UnprocessedItems).forEach(([ table, items ]) => { UnprocessedItems[table] = items.map(i => { let request = {} Object.entries(i).forEach(([ op, data ]) => { @@ -138,7 +139,7 @@ const BatchWriteItem = { return request }) }) - return { response: { ...response, UnprocessedItems } } + return { ...payload, UnprocessedItems } } } @@ -152,6 +153,7 @@ const CreateBackup = { headers: headers('CreateBackup'), // Undocumented as of author time payload: params, }), + response: defaultResponse, } const CreateGlobalTable = { @@ -164,6 +166,7 @@ const CreateGlobalTable = { headers: headers('CreateGlobalTable'), // Undocumented as of author time payload: params, }), + response: defaultResponse, } const CreateTable = { @@ -186,6 +189,7 @@ const CreateTable = { headers: headers('CreateTable'), payload: params, }), + response: defaultResponse, } const DeleteBackup = { @@ -197,6 +201,7 @@ const DeleteBackup = { headers: headers('DeleteBackup'), payload: params, }), + response: defaultResponse, } const DeleteItem = { @@ -219,9 +224,9 @@ const DeleteItem = { headers: headers('DeleteItem'), payload: params, }), - response: async (response, { awsjsonUnmarshall }) => { - if (response?.Attributes) response.Attributes = awsjsonUnmarshall(response.Attributes) - return { response } + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (payload?.Attributes) payload.Attributes = awsjsonUnmarshall(payload.Attributes) + return payload }, } @@ -234,6 +239,7 @@ const DeleteTable = { headers: headers('DeleteTable'), payload: params, }), + response: defaultResponse, } const DescribeBackup = { @@ -245,6 +251,7 @@ const DescribeBackup = { headers: headers('DescribeBackup'), payload: params, }), + response: defaultResponse, } const DescribeContinuousBackups = { @@ -256,6 +263,7 @@ const DescribeContinuousBackups = { headers: headers('DescribeContinuousBackups'), payload: params, }), + response: defaultResponse, } const DescribeContributorInsights = { @@ -268,6 +276,7 @@ const DescribeContributorInsights = { headers: headers('DescribeContributorInsights'), payload: params, }), + response: defaultResponse, } const DescribeEndpoints = { @@ -275,6 +284,7 @@ const DescribeEndpoints = { request: async () => ({ headers: headers('DescribeEndpoints'), }), + response: defaultResponse, } const DescribeExport = { @@ -286,6 +296,7 @@ const DescribeExport = { headers: headers('DescribeExport'), payload: params, }), + response: defaultResponse, } const DescribeGlobalTable = { @@ -297,6 +308,7 @@ const DescribeGlobalTable = { headers: headers('DescribeGlobalTable'), payload: params, }), + response: defaultResponse, } const DescribeGlobalTableSettings = { @@ -308,6 +320,7 @@ const DescribeGlobalTableSettings = { headers: headers('DescribeGlobalTableSettings'), payload: params, }), + response: defaultResponse, } const DescribeImport = { @@ -319,6 +332,7 @@ const DescribeImport = { headers: headers('DescribeImport'), payload: params, }), + response: defaultResponse, } const DescribeKinesisStreamingDestination = { @@ -330,6 +344,7 @@ const DescribeKinesisStreamingDestination = { headers: headers('DescribeKinesisStreamingDestination'), payload: params, }), + response: defaultResponse, } const DescribeLimits = { @@ -337,6 +352,7 @@ const DescribeLimits = { request: async () => ({ headers: headers('DescribeLimits'), }), + response: defaultResponse, } const DescribeTable = { @@ -348,6 +364,7 @@ const DescribeTable = { headers: headers('DescribeTable'), payload: params, }), + response: defaultResponse, } const DescribeTableReplicaAutoScaling = { @@ -359,6 +376,7 @@ const DescribeTableReplicaAutoScaling = { headers: headers('DescribeTableReplicaAutoScaling'), payload: params, }), + response: defaultResponse, } const DescribeTimeToLive = { @@ -370,6 +388,7 @@ const DescribeTimeToLive = { headers: headers('DescribeTimeToLive'), payload: params, }), + response: defaultResponse, } const DisableKinesisStreamingDestination = { @@ -382,6 +401,7 @@ const DisableKinesisStreamingDestination = { headers: headers('DisableKinesisStreamingDestination'), payload: params, }), + response: defaultResponse, } const EnableKinesisStreamingDestination = { @@ -394,6 +414,7 @@ const EnableKinesisStreamingDestination = { headers: headers('EnableKinesisStreamingDestination'), payload: params, }), + response: defaultResponse, } const ExecuteStatement = { @@ -415,11 +436,12 @@ const ExecuteStatement = { payload: params, } }, - response: async (response, { awsjsonUnmarshall }) => { - if (response?.Items?.length) { - response.Items = response.Items.map(awsjsonUnmarshall) + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (payload?.Items?.length) { + payload.Items = payload.Items.map(awsjsonUnmarshall) } - return { awsjson: [ 'LastEvaluatedKey' ], response } + payload.awsjson = [ 'LastEvaluatedKey' ] + return payload }, } @@ -443,14 +465,14 @@ const ExecuteTransaction = { payload: params, } }, - response: async (response, { awsjsonUnmarshall }) => { - if (response?.Responses?.length) { - response.Responses = response.Responses.map(i => { + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (payload?.Responses?.length) { + payload.Responses = payload.Responses.map(i => { i.Item = awsjsonUnmarshall(i.Item) return i }) } - return { response } + return payload }, } @@ -471,6 +493,7 @@ const ExportTableToPointInTime = { headers: headers('ExportTableToPointInTime'), payload: params, }), + response: defaultResponse, } const GetItem = { @@ -506,6 +529,7 @@ const ImportTable = { headers: headers('ImportTable'), payload: params, }), + response: defaultResponse, } const ListBackups = { @@ -522,6 +546,7 @@ const ListBackups = { headers: headers('ListBackups'), payload: params, }), + response: defaultResponse, } const ListContributorInsights = { @@ -535,6 +560,7 @@ const ListContributorInsights = { headers: headers('ListContributorInsights'), payload: params, }), + response: defaultResponse, } const ListExports = { @@ -548,6 +574,7 @@ const ListExports = { headers: headers('ListExports'), payload: params, }), + response: defaultResponse, } const ListGlobalTables = { @@ -561,6 +588,7 @@ const ListGlobalTables = { headers: headers('ListGlobalTables'), payload: params, }), + response: defaultResponse, } const ListImports = { @@ -574,6 +602,7 @@ const ListImports = { headers: headers('ListImports'), payload: params, }), + response: defaultResponse, } const ListTables = { @@ -586,6 +615,7 @@ const ListTables = { headers: headers('ListTables'), payload: params, }), + response: defaultResponse, } const ListTagsOfResource = { @@ -598,6 +628,7 @@ const ListTagsOfResource = { headers: headers('ListTagsOfResource'), payload: params, }), + response: defaultResponse, } const PutItem = { @@ -649,13 +680,13 @@ const Query = { headers: headers('Query'), payload: params, }), - response: async (response, { awsjsonUnmarshall }) => { - if (response?.Items?.length) response.Items = response.Items.map(awsjsonUnmarshall) - if (response?.LastEvaluatedKey) { - let key = response.LastEvaluatedKey[Object.keys(response.LastEvaluatedKey)[0]] - response.LastEvaluatedKey = awsjsonUnmarshall(key) + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (payload?.Items?.length) payload.Items = payload.Items.map(awsjsonUnmarshall) + if (payload?.LastEvaluatedKey) { + let key = payload.LastEvaluatedKey[Object.keys(payload.LastEvaluatedKey)[0]] + payload.LastEvaluatedKey = awsjsonUnmarshall(key) } - return { response } + return payload }, } @@ -674,6 +705,7 @@ const RestoreTableFromBackup = { headers: headers('RestoreTableFromBackup'), payload: params, }), + response: defaultResponse, } const RestoreTableToPointInTime = { @@ -694,6 +726,7 @@ const RestoreTableToPointInTime = { headers: headers('RestoreTableToPointInTime'), payload: params, }), + response: defaultResponse, } const Scan = { @@ -720,9 +753,9 @@ const Scan = { headers: headers('Scan'), payload: params, }), - response: async (response, { awsjsonUnmarshall }) => { - if (response?.Items?.length) response.Items = response.Items.map(awsjsonUnmarshall) - return { response } + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (payload?.Items?.length) payload.Items = payload.Items.map(awsjsonUnmarshall) + return payload }, } @@ -736,6 +769,7 @@ const TagResource = { headers: headers('TagResource'), payload: params, }), + response: defaultResponse, } const TransactGetItems = { @@ -755,12 +789,12 @@ const TransactGetItems = { payload: params, } }, - response: async (response, { awsjsonUnmarshall }) => { - if (response?.Responses?.length) response.Responses = response.Responses.map(i => { + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (payload?.Responses?.length) payload.Responses = payload.Responses.map(i => { i.Item = awsjsonUnmarshall(i.Item) return i }) - return { response } + return payload }, } @@ -815,15 +849,15 @@ const TransactWriteItems = { payload: params, } }, - response: async (response, { awsjsonUnmarshall }) => { - if (Object.keys(response?.ItemCollectionMetrics || {})?.length) { - Object.entries(response.ItemCollectionMetrics).forEach(([ table, items ]) => { - response.ItemCollectionMetrics[table] = items.map(i => { + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (Object.keys(payload?.ItemCollectionMetrics || {})?.length) { + Object.entries(payload.ItemCollectionMetrics).forEach(([ table, items ]) => { + payload.ItemCollectionMetrics[table] = items.map(i => { i.ItemCollectionKey = awsjsonUnmarshall(i.ItemCollectionKey) }) }) } - return { response } + return payload }, } @@ -837,6 +871,7 @@ const UntagResource = { headers: headers('UntagResource'), payload: params, }), + response: defaultResponse, } const UpdateContinuousBackups = { @@ -862,6 +897,7 @@ const UpdateContributorInsights = { headers: headers('UpdateContributorInsights'), payload: params, }), + response: defaultResponse, } const UpdateGlobalTable = { @@ -874,6 +910,7 @@ const UpdateGlobalTable = { headers: headers('UpdateGlobalTable'), payload: params, }), + response: defaultResponse, } const UpdateGlobalTableSettings = { @@ -890,6 +927,7 @@ const UpdateGlobalTableSettings = { headers: headers('UpdateGlobalTableSettings'), payload: params, }), + response: defaultResponse, } const UpdateItem = { @@ -914,13 +952,14 @@ const UpdateItem = { headers: headers('UpdateItem'), payload: params, }), - response: async (response, { awsjsonUnmarshall }) => { - if (Object.keys(response?.ItemCollectionMetrics || {})?.length) { - Object.entries(response.ItemCollectionMetrics.ItemCollectionKey).forEach(([ key, props ]) => { - response.ItemCollectionMetrics.ItemCollectionKey[key] = awsjsonUnmarshall(props) + response: async ({ payload }, { awsjsonUnmarshall }) => { + if (Object.keys(payload?.ItemCollectionMetrics || {})?.length) { + Object.entries(payload.ItemCollectionMetrics.ItemCollectionKey).forEach(([ key, props ]) => { + payload.ItemCollectionMetrics.ItemCollectionKey[key] = awsjsonUnmarshall(props) }) } - return { awsjson: awsjsonRes, response } + payload.awsjson = awsjsonRes + return payload }, } @@ -942,6 +981,7 @@ const UpdateTable = { headers: headers('UpdateTable'), payload: params, }), + response: defaultResponse, } const UpdateTableReplicaAutoScaling = { @@ -956,6 +996,7 @@ const UpdateTableReplicaAutoScaling = { headers: headers('UpdateTableReplicaAutoScaling'), payload: params, }), + response: defaultResponse, } const UpdateTimeToLive = { @@ -968,6 +1009,7 @@ const UpdateTimeToLive = { headers: headers('UpdateTimeToLive'), payload: params, }), + response: defaultResponse, } const methods = { BatchExecuteStatement, BatchGetItem, BatchWriteItem, CreateBackup, CreateGlobalTable, CreateTable, DeleteBackup, DeleteItem, DeleteTable, DescribeBackup, DescribeContinuousBackups, DescribeContributorInsights, DescribeEndpoints, DescribeExport, DescribeGlobalTable, DescribeGlobalTableSettings, DescribeImport, DescribeKinesisStreamingDestination, DescribeLimits, DescribeTable, DescribeTableReplicaAutoScaling, DescribeTimeToLive, DisableKinesisStreamingDestination, EnableKinesisStreamingDestination, ExecuteStatement, ExecuteTransaction, ExportTableToPointInTime, GetItem, ImportTable, ListBackups, ListContributorInsights, ListExports, ListGlobalTables, ListImports, ListTables, ListTagsOfResource, PutItem, Query, RestoreTableFromBackup, RestoreTableToPointInTime, Scan, TagResource, TransactGetItems, TransactWriteItems, UntagResource, UpdateContinuousBackups, UpdateContributorInsights, UpdateGlobalTable, UpdateGlobalTableSettings, UpdateItem, UpdateTable, UpdateTableReplicaAutoScaling, UpdateTimeToLive } diff --git a/readme.md b/readme.md index 518a3fd7..485af22a 100644 --- a/readme.md +++ b/readme.md @@ -350,13 +350,13 @@ The `response()` lifecycle hook is an async function that enables mutation of se - **`headers` (object)** - HTTP response headers - **`payload` (object or string)** - - Raw non-error response from AWS service API request; if the entire payload is JSON or AWS-flavored JSON, `aws-lite` will attempt to parse it prior to executing `response()`. Responses that are primarily JSON, but with nested AWS-flavored JSON, will be parsed only as JSON and may require additional deserialization with the `awsjsonUnmarshall` utility + - Raw non-error response from AWS service API request; if the entire payload is JSON or AWS-flavored JSON, `aws-lite` will attempt to parse it prior to executing `response()`. Responses that are primarily JSON, but with nested AWS-flavored JSON, will be parsed only as JSON and may require additional deserialization with the `awsjsonUnmarshall` utility or `awsjson` property - **`utils` (object)** - [Plugin helper utilities](#plugin-utils) -The `response()` method may return: nothing (which will pass through the `response` object as-is), a mutated version of the `response` object, or any other data type (most commonly an object or string). +The `response()` method may return: nothing (which will pass through the `response` object as-is) or any data (most commonly an object or string, or mutated version of the `response` object). -Should you return an object, you may also include an `awsjson` property (that behaves the same as in [client requests](#client-requests)). The `awsjson` property will be stripped from any returned response. +Should you return an object, you may also include an `awsjson` property (that behaves the same as in [client requests](#client-requests)). The `awsjson` property is considered reserved, and will be stripped from any returned data. An example: @@ -367,8 +367,9 @@ export default { methods: { GetItem: { // Assume successful responses always have an AWS-flavored JSON `Item` property - response: async (params, utils) => { - return { awsjson: [ 'Item' ], ...params } + response: async (response, utils) => { + response.awsjson = [ 'Item' ] + return response // Returns the response (`statusCode`, `headers`, `payload`), with `payload.Item` unformatted from AWS-flavored JSON } } } From c492963c3ecb5ce2c5485a0096a640a511d68bd3 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Sun, 1 Oct 2023 18:49:36 -0700 Subject: [PATCH 17/35] Missed one --- plugins/dynamodb/src/index.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/dynamodb/src/index.mjs b/plugins/dynamodb/src/index.mjs index 993627f4..c3391c0d 100644 --- a/plugins/dynamodb/src/index.mjs +++ b/plugins/dynamodb/src/index.mjs @@ -884,6 +884,7 @@ const UpdateContinuousBackups = { headers: headers('UpdateContinuousBackups'), payload: params, }), + response: defaultResponse, } const UpdateContributorInsights = { From 09931dd4e1badf9c1b30422413df13f70235f824 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Sun, 1 Oct 2023 19:25:38 -0700 Subject: [PATCH 18/35] Parse and return more usable `@aws-lite/s3` `PutObject` responses --- plugins/s3/src/put-object.mjs | 103 ++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 41 deletions(-) diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index c9b3d49a..fb3b4520 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -4,14 +4,13 @@ import { readFile, stat } from 'node:fs/promises' import { Readable } from 'node:stream' const required = true - +const chunkBreak = `\r\n` const minSize = 1024 * 1024 * 5 const intToHexString = int => String(Number(int).toString(16)) const algo = 'sha256', utf8 = 'utf8', hex = 'hex' const hash = str => crypto.createHash(algo).update(str, utf8).digest(hex) const hmac = (key, str, enc) => crypto.createHmac(algo, key).update(str, utf8).digest(enc) -let chunkBreak = `\r\n` function payloadMetadata (chunkSize, signature) { // Don't forget: after the signature + break would normally follow the body + one more break return intToHexString(chunkSize) + `;chunk-signature=${signature}` + chunkBreak @@ -20,45 +19,66 @@ function payloadMetadata (chunkSize, signature) { // Commonly used headers const comment = header => `Sets request header: \`${header}\`` const getValidateHeaders = (...headers) => headers.reduce((acc, h) => { - if (!headerMappings?.[h]?.header) throw ReferenceError(`Header not found: ${h}`) - acc[h] = { type: 'string', comment: comment(headerMappings[h].header) } + if (!headerMappings[h]) throw ReferenceError(`Header not found: ${h}`) + acc[h] = { type: 'string', comment: comment(headerMappings[h]) } return acc }, {}) -// The !x-amz headers are documented as old school pascal-case headers; lowcasing them to be HTTP 2.0 compliant -let headerMappings = { - ACL: { header: 'x-amz-acl' }, - BucketKeyEnabled: { header: 'x-amz-server-side-encryption-bucket-key-enabled' }, - CacheControl: { header: 'cache-control' }, - ChecksumAlgorithm: { header: 'x-amz-sdk-checksum-algorithm' }, - ChecksumCRC32: { header: 'x-amz-checksum-crc32' }, - ChecksumCRC32C: { header: 'x-amz-checksum-crc32c' }, - ChecksumSHA1: { header: 'x-amz-checksum-sha1' }, - ChecksumSHA256: { header: 'x-amz-checksum-sha256' }, - ContentDisposition: { header: 'content-disposition' }, - ContentEncoding: { header: 'content-encoding' }, - ContentLanguage: { header: 'content-language' }, - ContentLength: { header: 'content-length' }, - ContentMD5: { header: 'content-md5' }, - ContentType: { header: 'content-type' }, - ExpectedBucketOwner: { header: 'x-amz-expected-bucket-owner' }, - Expires: { header: 'expires' }, - GrantFullControl: { header: 'x-amz-grant-full-control' }, - GrantRead: { header: 'x-amz-grant-read' }, - GrantReadACP: { header: 'x-amz-grant-read-acp' }, - GrantWriteACP: { header: 'x-amz-grant-write-acp' }, - ObjectLockLegalHoldStatus: { header: 'x-amz-object-lock-legal-hold' }, - ObjectLockMode: { header: 'x-amz-object-lock-mode' }, - ObjectLockRetainUntilDate: { header: 'x-amz-object-lock-retain-until-date' }, - RequestPayer: { header: 'x-amz-request-payer' }, - ServerSideEncryption: { header: 'x-amz-server-side-encryption' }, - SSECustomerAlgorithm: { header: 'x-amz-server-side-encryption-customer-algorithm' }, - SSECustomerKey: { header: 'x-amz-server-side-encryption-customer-key' }, - SSECustomerKeyMD5: { header: 'x-amz-server-side-encryption-customer-key-md5' }, - SSEKMSEncryptionContext: { header: 'x-amz-server-side-encryption-context' }, - SSEKMSKeyId: { header: 'x-amz-server-side-encryption-aws-kms-key-id' }, - StorageClass: { header: 'x-amz-storage-class' }, - Tagging: { header: 'x-amz-tagging' }, - WebsiteRedirectLocation: { header: 'x-amz-website-redirect-location' }, +// The !x-amz headers are documented by AWS as old school pascal-case headers; lowcasing them to be HTTP 2.0 compliant +const headerMappings = { + ACL: 'x-amz-acl', + BucketKeyEnabled: 'x-amz-server-side-encryption-bucket-key-enabled', + CacheControl: 'cache-control', + ChecksumAlgorithm: 'x-amz-sdk-checksum-algorithm', + ChecksumCRC32: 'x-amz-checksum-crc32', + ChecksumCRC32C: 'x-amz-checksum-crc32c', + ChecksumSHA1: 'x-amz-checksum-sha1', + ChecksumSHA256: 'x-amz-checksum-sha256', + ContentDisposition: 'content-disposition', + ContentEncoding: 'content-encoding', + ContentLanguage: 'content-language', + ContentLength: 'content-length', + ContentMD5: 'content-md5', + ContentType: 'content-type', + ETag: 'etag', + ExpectedBucketOwner: 'x-amz-expected-bucket-owner', + Expiration: 'x-amz-expiration', + Expires: 'expires', + GrantFullControl: 'x-amz-grant-full-control', + GrantRead: 'x-amz-grant-read', + GrantReadACP: 'x-amz-grant-read-acp', + GrantWriteACP: 'x-amz-grant-write-acp', + ObjectLockLegalHoldStatus: 'x-amz-object-lock-legal-hold', + ObjectLockMode: 'x-amz-object-lock-mode', + ObjectLockRetainUntilDate: 'x-amz-object-lock-retain-until-date', + RequestCharged: 'x-amz-request-charged', + RequestPayer: 'x-amz-request-payer', + ServerSideEncryption: 'x-amz-server-side-encryption', + SSECustomerAlgorithm: 'x-amz-server-side-encryption-customer-algorithm', + SSECustomerKey: 'x-amz-server-side-encryption-customer-key', + SSECustomerKeyMD5: 'x-amz-server-side-encryption-customer-key-md5', + SSEKMSEncryptionContext: 'x-amz-server-side-encryption-context', + SSEKMSKeyId: 'x-amz-server-side-encryption-aws-kms-key-id', + StorageClass: 'x-amz-storage-class', + Tagging: 'x-amz-tagging', + VersionId: 'x-amz-version-id', + WebsiteRedirectLocation: 'x-amz-website-redirect-location', +} +// Invert above for header lookups +const paramMappings = Object.fromEntries(Object.entries(headerMappings).map(([ k, v ]) => [ v, k ])) +const quoted = /^".*"$/ +const ignoreHeaders = [ 'content-length' ] +const parseHeadersToResults = ({ headers }) => { + let results = Object.entries(headers).reduce((acc, [ header, value ]) => { + const normalized = header.toLowerCase() + if (value.match(quoted)) { + value = value.substring(1, value.length - 1) + } + if (paramMappings[normalized] && !ignoreHeaders.includes(normalized)) { + acc[paramMappings[normalized]] = value + } + return acc + }, {}) + return results } const PutObject = { @@ -84,8 +104,8 @@ const PutObject = { MinChunkSize = MinChunkSize || minSize let headers = Object.keys(params).reduce((acc, param) => { - if (headerMappings[param]?.header) { - acc[headerMappings[param].header] = params[param] + if (headerMappings[param]) { + acc[headerMappings[param]] = params[param] } return acc }, {}) @@ -214,5 +234,6 @@ const PutObject = { return canonicalReq } }, + response: parseHeadersToResults, } export default PutObject From 982058eb4f288829cec3b69691510b5da999f469 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Mon, 2 Oct 2023 12:37:07 -0700 Subject: [PATCH 19/35] Add `@aws-lite/s3` `GetObject` method Move common `@aws-lite/s3` code into a lib file --- plugins/s3/src/incomplete.mjs | 2 +- plugins/s3/src/index.mjs | 58 ++++++++++++++++++++++--- plugins/s3/src/lib.mjs | 82 +++++++++++++++++++++++++++++++++++ plugins/s3/src/put-object.mjs | 76 +++----------------------------- 4 files changed, 142 insertions(+), 76 deletions(-) create mode 100644 plugins/s3/src/lib.mjs diff --git a/plugins/s3/src/incomplete.mjs b/plugins/s3/src/incomplete.mjs index c65af6b1..3423eed8 100644 --- a/plugins/s3/src/incomplete.mjs +++ b/plugins/s3/src/incomplete.mjs @@ -1,2 +1,2 @@ const x = false -export default { AbortMultipartUpload: x, CompleteMultipartUpload: x, CopyObject: x, CreateBucket: x, CreateMultipartUpload: x, DeleteBucket: x, DeleteBucketAnalyticsConfiguration: x, DeleteBucketCors: x, DeleteBucketEncryption: x, DeleteBucketIntelligentTieringConfiguration: x, DeleteBucketInventoryConfiguration: x, DeleteBucketLifecycle: x, DeleteBucketMetricsConfiguration: x, DeleteBucketOwnershipControls: x, DeleteBucketPolicy: x, DeleteBucketReplication: x, DeleteBucketTagging: x, DeleteBucketWebsite: x, DeleteObject: x, DeleteObjects: x, DeleteObjectTagging: x, DeletePublicAccessBlock: x, GetBucketAccelerateConfiguration: x, GetBucketAcl: x, GetBucketAnalyticsConfiguration: x, GetBucketCors: x, GetBucketEncryption: x, GetBucketIntelligentTieringConfiguration: x, GetBucketInventoryConfiguration: x, GetBucketLifecycle: x, GetBucketLifecycleConfiguration: x, GetBucketLocation: x, GetBucketLogging: x, GetBucketMetricsConfiguration: x, GetBucketNotification: x, GetBucketNotificationConfiguration: x, GetBucketOwnershipControls: x, GetBucketPolicy: x, GetBucketPolicyStatus: x, GetBucketReplication: x, GetBucketRequestPayment: x, GetBucketTagging: x, GetBucketVersioning: x, GetBucketWebsite: x, GetObject: x, GetObjectAcl: x, GetObjectAttributes: x, GetObjectLegalHold: x, GetObjectLockConfiguration: x, GetObjectRetention: x, GetObjectTagging: x, GetObjectTorrent: x, GetPublicAccessBlock: x, HeadBucket: x, HeadObject: x, ListBucketAnalyticsConfigurations: x, ListBucketIntelligentTieringConfigurations: x, ListBucketInventoryConfigurations: x, ListBucketMetricsConfigurations: x, ListBuckets: x, ListMultipartUploads: x, ListObjects: x, ListObjectsV2: x, ListObjectVersions: x, ListParts: x, PutBucketAccelerateConfiguration: x, PutBucketAcl: x, PutBucketAnalyticsConfiguration: x, PutBucketCors: x, PutBucketEncryption: x, PutBucketIntelligentTieringConfiguration: x, PutBucketInventoryConfiguration: x, PutBucketLifecycle: x, PutBucketLifecycleConfiguration: x, PutBucketLogging: x, PutBucketMetricsConfiguration: x, PutBucketNotification: x, PutBucketNotificationConfiguration: x, PutBucketOwnershipControls: x, PutBucketPolicy: x, PutBucketReplication: x, PutBucketRequestPayment: x, PutBucketTagging: x, PutBucketVersioning: x, PutBucketWebsite: x, PutObjectAcl: x, PutObjectLegalHold: x, PutObjectLockConfiguration: x, PutObjectRetention: x, PutObjectTagging: x, PutPublicAccessBlock: x, RestoreObject: x, SelectObjectContent: x, UploadPart: x, UploadPartCopy: x, WriteGetObjectResponse: x } +export default { AbortMultipartUpload: x, CompleteMultipartUpload: x, CopyObject: x, CreateBucket: x, CreateMultipartUpload: x, DeleteBucket: x, DeleteBucketAnalyticsConfiguration: x, DeleteBucketCors: x, DeleteBucketEncryption: x, DeleteBucketIntelligentTieringConfiguration: x, DeleteBucketInventoryConfiguration: x, DeleteBucketLifecycle: x, DeleteBucketMetricsConfiguration: x, DeleteBucketOwnershipControls: x, DeleteBucketPolicy: x, DeleteBucketReplication: x, DeleteBucketTagging: x, DeleteBucketWebsite: x, DeleteObject: x, DeleteObjects: x, DeleteObjectTagging: x, DeletePublicAccessBlock: x, GetBucketAccelerateConfiguration: x, GetBucketAcl: x, GetBucketAnalyticsConfiguration: x, GetBucketCors: x, GetBucketEncryption: x, GetBucketIntelligentTieringConfiguration: x, GetBucketInventoryConfiguration: x, GetBucketLifecycle: x, GetBucketLifecycleConfiguration: x, GetBucketLocation: x, GetBucketLogging: x, GetBucketMetricsConfiguration: x, GetBucketNotification: x, GetBucketNotificationConfiguration: x, GetBucketOwnershipControls: x, GetBucketPolicy: x, GetBucketPolicyStatus: x, GetBucketReplication: x, GetBucketRequestPayment: x, GetBucketTagging: x, GetBucketVersioning: x, GetBucketWebsite: x, GetObjectAcl: x, GetObjectAttributes: x, GetObjectLegalHold: x, GetObjectLockConfiguration: x, GetObjectRetention: x, GetObjectTagging: x, GetObjectTorrent: x, GetPublicAccessBlock: x, HeadBucket: x, HeadObject: x, ListBucketAnalyticsConfigurations: x, ListBucketIntelligentTieringConfigurations: x, ListBucketInventoryConfigurations: x, ListBucketMetricsConfigurations: x, ListBuckets: x, ListMultipartUploads: x, ListObjects: x, ListObjectsV2: x, ListObjectVersions: x, ListParts: x, PutBucketAccelerateConfiguration: x, PutBucketAcl: x, PutBucketAnalyticsConfiguration: x, PutBucketCors: x, PutBucketEncryption: x, PutBucketIntelligentTieringConfiguration: x, PutBucketInventoryConfiguration: x, PutBucketLifecycle: x, PutBucketLifecycleConfiguration: x, PutBucketLogging: x, PutBucketMetricsConfiguration: x, PutBucketNotification: x, PutBucketNotificationConfiguration: x, PutBucketOwnershipControls: x, PutBucketPolicy: x, PutBucketReplication: x, PutBucketRequestPayment: x, PutBucketTagging: x, PutBucketVersioning: x, PutBucketWebsite: x, PutObjectAcl: x, PutObjectLegalHold: x, PutObjectLockConfiguration: x, PutObjectRetention: x, PutObjectTagging: x, PutPublicAccessBlock: x, RestoreObject: x, SelectObjectContent: x, UploadPart: x, UploadPartCopy: x, WriteGetObjectResponse: x } diff --git a/plugins/s3/src/index.mjs b/plugins/s3/src/index.mjs index 0742cf66..620930aa 100644 --- a/plugins/s3/src/index.mjs +++ b/plugins/s3/src/index.mjs @@ -1,15 +1,61 @@ import incomplete from './incomplete.mjs' +import lib from './lib.mjs' +const { getValidateHeaders, headerMappings } = lib import PutObject from './put-object.mjs' const service = 's3' +const required = true /** * Plugin maintained by: @architect */ -export default { - service, - methods: { - PutObject, - ...incomplete - } + +const GetObject = { + awsDoc: 'https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html', + validate: { + Bucket: { type: 'string', required, comment: 'S3 bucket name' }, + Key: { type: 'string', required, comment: 'S3 key / file name' }, + PartNumber: { type: 'number', comment: 'Part number (between 1 - 10,000) of the object' }, + ResponseCacheControl: { type: 'string', comment: 'Sets the cache-control header of the response' }, + ResponseContentDisposition: { type: 'string', comment: 'Sets the content-disposition header of the response' }, + ResponseContentEncoding: { type: 'string', comment: 'Sets the content-encoding header of the response' }, + ResponseContentLanguage: { type: 'string', comment: 'Sets the content-language header of the response' }, + ResponseContentType: { type: 'string', comment: 'Sets the content-type header of the response' }, + ResponseExpires: { type: 'string', comment: 'Sets the expires header of the response' }, + VersionId: { type: 'string', comment: 'Reference a specific version of the object' }, + // Here come the headers + ...getValidateHeaders('IfMatch', 'IfModifiedSince', 'IfNoneMatch', 'IfUnmodifiedSince', + 'Range', 'SSECustomerAlgorithm', 'SSECustomerKey', 'SSECustomerKeyMD5', 'RequestPayer', + 'ExpectedBucketOwner', 'ChecksumMode') + }, + request: async (params) => { + let { Bucket, Key } = params + let ignoreHeaders = [ 'VersionId' ] + let headers = Object.keys(params).reduce((acc, param) => { + if (headerMappings[param] && !ignoreHeaders.includes(param)) { + acc[headerMappings[param]] = params[param] + } + return acc + }, {}) + + let query + let queryParams = [ 'PartNumber', 'ResponseCacheControl', 'ResponseContentDisposition', + 'ResponseContentEncoding', 'ResponseContentLanguage', 'ResponseContentType', + 'ResponseExpires', 'VersionId' ] + queryParams.forEach(p => { + if (params[p]) { + if (!query) query = {} + query[p] = params[p] + } + }) + return { + path: `/${Bucket}/${Key}`, + headers, + query, + } + }, + response: ({ payload }) => payload, } + +const methods = { GetObject, PutObject, ...incomplete } +export default { service, methods } diff --git a/plugins/s3/src/lib.mjs b/plugins/s3/src/lib.mjs new file mode 100644 index 00000000..98fe6943 --- /dev/null +++ b/plugins/s3/src/lib.mjs @@ -0,0 +1,82 @@ +// Generate validation for commonly used headers +const getValidateHeaders = (...headers) => headers.reduce((acc, h) => { + if (!headerMappings[h]) throw ReferenceError(`Header not found: ${h}`) + acc[h] = { type: 'string', comment: comment(headerMappings[h]) } + return acc +}, {}) +const comment = header => `Sets request header: \`${header}\`` + +// Map common AWS-named params to their respective headers +// The !x-amz headers are documented by AWS as old school pascal-case headers; lowcasing them to be HTTP 2.0 compliant +const headerMappings = { + ACL: 'x-amz-acl', + BucketKeyEnabled: 'x-amz-server-side-encryption-bucket-key-enabled', + CacheControl: 'cache-control', + ChecksumAlgorithm: 'x-amz-sdk-checksum-algorithm', + ChecksumCRC32: 'x-amz-checksum-crc32', + ChecksumCRC32C: 'x-amz-checksum-crc32c', + ChecksumMode: 'x-amz-checksum-mode', + ChecksumSHA1: 'x-amz-checksum-sha1', + ChecksumSHA256: 'x-amz-checksum-sha256', + ContentDisposition: 'content-disposition', + ContentEncoding: 'content-encoding', + ContentLanguage: 'content-language', + ContentLength: 'content-length', + ContentMD5: 'content-md5', + ContentType: 'content-type', + ETag: 'etag', + ExpectedBucketOwner: 'x-amz-expected-bucket-owner', + Expiration: 'x-amz-expiration', + Expires: 'expires', + GrantFullControl: 'x-amz-grant-full-control', + GrantRead: 'x-amz-grant-read', + GrantReadACP: 'x-amz-grant-read-acp', + GrantWriteACP: 'x-amz-grant-write-acp', + IfMatch: 'if-match', + IfModifiedSince: 'if-modified-since', + IfNoneMatch: 'if-none-match', + IfUnmodifiedSince: 'if-unmodified-since', + ObjectLockLegalHoldStatus: 'x-amz-object-lock-legal-hold', + ObjectLockMode: 'x-amz-object-lock-mode', + ObjectLockRetainUntilDate: 'x-amz-object-lock-retain-until-date', + Range: 'range', + RequestCharged: 'x-amz-request-charged', + RequestPayer: 'x-amz-request-payer', + ServerSideEncryption: 'x-amz-server-side-encryption', + SSECustomerAlgorithm: 'x-amz-server-side-encryption-customer-algorithm', + SSECustomerKey: 'x-amz-server-side-encryption-customer-key', + SSECustomerKeyMD5: 'x-amz-server-side-encryption-customer-key-md5', + SSEKMSEncryptionContext: 'x-amz-server-side-encryption-context', + SSEKMSKeyId: 'x-amz-server-side-encryption-aws-kms-key-id', + StorageClass: 'x-amz-storage-class', + Tagging: 'x-amz-tagging', + VersionId: 'x-amz-version-id', + WebsiteRedirectLocation: 'x-amz-website-redirect-location', +} +// Invert headerMappings for header-based lookups +const paramMappings = Object.fromEntries(Object.entries(headerMappings).map(([ k, v ]) => [ v, k ])) + +// Take a response, and parse its headers into the AWS-named params of headerMappings +const quoted = /^".*"$/ +const ignoreHeaders = [ 'content-length' ] +const parseHeadersToResults = ({ headers }) => { + let results = Object.entries(headers).reduce((acc, [ header, value ]) => { + const normalized = header.toLowerCase() + if (value === 'true') value = true + if (value === 'false') value = false + if (value.match(quoted)) { + value = value.substring(1, value.length - 1) + } + if (paramMappings[normalized] && !ignoreHeaders.includes(normalized)) { + acc[paramMappings[normalized]] = value + } + return acc + }, {}) + return results +} + +export default { + getValidateHeaders, + headerMappings, + parseHeadersToResults, +} diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index fb3b4520..8856cf97 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -2,6 +2,8 @@ import aws4 from 'aws4' import crypto from 'node:crypto' import { readFile, stat } from 'node:fs/promises' import { Readable } from 'node:stream' +import lib from './lib.mjs' +const { getValidateHeaders, headerMappings, parseHeadersToResults } = lib const required = true const chunkBreak = `\r\n` @@ -16,79 +18,14 @@ function payloadMetadata (chunkSize, signature) { return intToHexString(chunkSize) + `;chunk-signature=${signature}` + chunkBreak } -// Commonly used headers -const comment = header => `Sets request header: \`${header}\`` -const getValidateHeaders = (...headers) => headers.reduce((acc, h) => { - if (!headerMappings[h]) throw ReferenceError(`Header not found: ${h}`) - acc[h] = { type: 'string', comment: comment(headerMappings[h]) } - return acc -}, {}) -// The !x-amz headers are documented by AWS as old school pascal-case headers; lowcasing them to be HTTP 2.0 compliant -const headerMappings = { - ACL: 'x-amz-acl', - BucketKeyEnabled: 'x-amz-server-side-encryption-bucket-key-enabled', - CacheControl: 'cache-control', - ChecksumAlgorithm: 'x-amz-sdk-checksum-algorithm', - ChecksumCRC32: 'x-amz-checksum-crc32', - ChecksumCRC32C: 'x-amz-checksum-crc32c', - ChecksumSHA1: 'x-amz-checksum-sha1', - ChecksumSHA256: 'x-amz-checksum-sha256', - ContentDisposition: 'content-disposition', - ContentEncoding: 'content-encoding', - ContentLanguage: 'content-language', - ContentLength: 'content-length', - ContentMD5: 'content-md5', - ContentType: 'content-type', - ETag: 'etag', - ExpectedBucketOwner: 'x-amz-expected-bucket-owner', - Expiration: 'x-amz-expiration', - Expires: 'expires', - GrantFullControl: 'x-amz-grant-full-control', - GrantRead: 'x-amz-grant-read', - GrantReadACP: 'x-amz-grant-read-acp', - GrantWriteACP: 'x-amz-grant-write-acp', - ObjectLockLegalHoldStatus: 'x-amz-object-lock-legal-hold', - ObjectLockMode: 'x-amz-object-lock-mode', - ObjectLockRetainUntilDate: 'x-amz-object-lock-retain-until-date', - RequestCharged: 'x-amz-request-charged', - RequestPayer: 'x-amz-request-payer', - ServerSideEncryption: 'x-amz-server-side-encryption', - SSECustomerAlgorithm: 'x-amz-server-side-encryption-customer-algorithm', - SSECustomerKey: 'x-amz-server-side-encryption-customer-key', - SSECustomerKeyMD5: 'x-amz-server-side-encryption-customer-key-md5', - SSEKMSEncryptionContext: 'x-amz-server-side-encryption-context', - SSEKMSKeyId: 'x-amz-server-side-encryption-aws-kms-key-id', - StorageClass: 'x-amz-storage-class', - Tagging: 'x-amz-tagging', - VersionId: 'x-amz-version-id', - WebsiteRedirectLocation: 'x-amz-website-redirect-location', -} -// Invert above for header lookups -const paramMappings = Object.fromEntries(Object.entries(headerMappings).map(([ k, v ]) => [ v, k ])) -const quoted = /^".*"$/ -const ignoreHeaders = [ 'content-length' ] -const parseHeadersToResults = ({ headers }) => { - let results = Object.entries(headers).reduce((acc, [ header, value ]) => { - const normalized = header.toLowerCase() - if (value.match(quoted)) { - value = value.substring(1, value.length - 1) - } - if (paramMappings[normalized] && !ignoreHeaders.includes(normalized)) { - acc[paramMappings[normalized]] = value - } - return acc - }, {}) - return results -} - const PutObject = { awsDoc: 'https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html', // See also: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html validate: { - Bucket: { type: 'string', required, comment: 'S3 bucket name' }, - Key: { type: 'string', required, comment: 'S3 key / file name' }, - File: { type: 'string', required, comment: 'File path to be read and uploaded from the local filesystem' }, - MinChunkSize: { type: 'number', default: minSize, comment: 'Minimum size (in bytes) to utilize AWS-chunk-encoded uploads to S3' }, + Bucket: { type: 'string', required, comment: 'S3 bucket name' }, + Key: { type: 'string', required, comment: 'S3 key / file name' }, + File: { type: 'string', required, comment: 'File path to be read and uploaded from the local filesystem' }, + MinChunkSize: { type: 'number', default: minSize, comment: 'Minimum size (in bytes) to utilize AWS-chunk-encoded uploads to S3' }, // Here come the headers ...getValidateHeaders('ACL', 'BucketKeyEnabled', 'CacheControl', 'ChecksumAlgorithm', 'ChecksumCRC32', 'ChecksumCRC32C', 'ChecksumSHA1', 'ChecksumSHA256', 'ContentDisposition', 'ContentEncoding', @@ -236,4 +173,5 @@ const PutObject = { }, response: parseHeadersToResults, } + export default PutObject From ecb9b2af36df4cf23aa11fcad12524278b1f700c Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Mon, 2 Oct 2023 12:40:03 -0700 Subject: [PATCH 20/35] Generate `@aws-lite/s3` `GetObject` docs --- plugins/s3/readme.md | 50 +++++++++++++++++++++++++++++++++++++++- plugins/s3/src/index.mjs | 14 +++++------ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/plugins/s3/readme.md b/plugins/s3/readme.md index 65c1a0f8..7e2de910 100644 --- a/plugins/s3/readme.md +++ b/plugins/s3/readme.md @@ -16,6 +16,55 @@ npm i @aws-lite/s3 +### `GetObject` + +[Canonical AWS API doc](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html) + +Properties: +- **`Bucket` (string) [required]** + - S3 bucket name +- **`Key` (string) [required]** + - S3 key / file name +- **`PartNumber` (number)** + - Part number (between 1 - 10,000) of the object +- **`VersionId` (string)** + - Reference a specific version of the object +- **`IfMatch` (string)** + - Sets request header: `if-match` +- **`IfModifiedSince` (string)** + - Sets request header: `if-modified-since` +- **`IfNoneMatch` (string)** + - Sets request header: `if-none-match` +- **`IfUnmodifiedSince` (string)** + - Sets request header: `if-unmodified-since` +- **`Range` (string)** + - Sets request header: `range` +- **`SSECustomerAlgorithm` (string)** + - Sets request header: `x-amz-server-side-encryption-customer-algorithm` +- **`SSECustomerKey` (string)** + - Sets request header: `x-amz-server-side-encryption-customer-key` +- **`SSECustomerKeyMD5` (string)** + - Sets request header: `x-amz-server-side-encryption-customer-key-md5` +- **`RequestPayer` (string)** + - Sets request header: `x-amz-request-payer` +- **`ExpectedBucketOwner` (string)** + - Sets request header: `x-amz-expected-bucket-owner` +- **`ChecksumMode` (string)** + - Sets request header: `x-amz-checksum-mode` +- **`ResponseCacheControl` (string)** + - Sets response header: `cache-control` +- **`ResponseContentDisposition` (string)** + - Sets response header: `content-disposition` +- **`ResponseContentEncoding` (string)** + - Sets response header: `content-encoding` +- **`ResponseContentLanguage` (string)** + - Sets response header: `content-language` +- **`ResponseContentType` (string)** + - Sets response header: `content-type` +- **`ResponseExpires` (string)** + - Sets response header: `expires` + + ### `PutObject` [Canonical AWS API doc](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html) @@ -145,7 +194,6 @@ Properties: - `GetBucketTagging` - `GetBucketVersioning` - `GetBucketWebsite` -- `GetObject` - `GetObjectAcl` - `GetObjectAttributes` - `GetObjectLegalHold` diff --git a/plugins/s3/src/index.mjs b/plugins/s3/src/index.mjs index 620930aa..349d0dec 100644 --- a/plugins/s3/src/index.mjs +++ b/plugins/s3/src/index.mjs @@ -16,17 +16,17 @@ const GetObject = { Bucket: { type: 'string', required, comment: 'S3 bucket name' }, Key: { type: 'string', required, comment: 'S3 key / file name' }, PartNumber: { type: 'number', comment: 'Part number (between 1 - 10,000) of the object' }, - ResponseCacheControl: { type: 'string', comment: 'Sets the cache-control header of the response' }, - ResponseContentDisposition: { type: 'string', comment: 'Sets the content-disposition header of the response' }, - ResponseContentEncoding: { type: 'string', comment: 'Sets the content-encoding header of the response' }, - ResponseContentLanguage: { type: 'string', comment: 'Sets the content-language header of the response' }, - ResponseContentType: { type: 'string', comment: 'Sets the content-type header of the response' }, - ResponseExpires: { type: 'string', comment: 'Sets the expires header of the response' }, VersionId: { type: 'string', comment: 'Reference a specific version of the object' }, // Here come the headers ...getValidateHeaders('IfMatch', 'IfModifiedSince', 'IfNoneMatch', 'IfUnmodifiedSince', 'Range', 'SSECustomerAlgorithm', 'SSECustomerKey', 'SSECustomerKeyMD5', 'RequestPayer', - 'ExpectedBucketOwner', 'ChecksumMode') + 'ExpectedBucketOwner', 'ChecksumMode'), + ResponseCacheControl: { type: 'string', comment: 'Sets response header: `cache-control`' }, + ResponseContentDisposition: { type: 'string', comment: 'Sets response header: `content-disposition`' }, + ResponseContentEncoding: { type: 'string', comment: 'Sets response header: `content-encoding`' }, + ResponseContentLanguage: { type: 'string', comment: 'Sets response header: `content-language`' }, + ResponseContentType: { type: 'string', comment: 'Sets response header: `content-type`' }, + ResponseExpires: { type: 'string', comment: 'Sets response header: `expires`' }, }, request: async (params) => { let { Bucket, Key } = params From 83f78bf3f31f6c53230dc74a04a935630fa725e5 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Mon, 2 Oct 2023 12:40:40 -0700 Subject: [PATCH 21/35] Make running the generate script a bit more terse --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac2806c0..3c8d7bf0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "bugs": "https://github.com/architect/aws-lite/issues", "scripts": { - "generate": "npm run generate-plugins", + "gen": "npm run generate-plugins", "generate-plugins": "node scripts/generate-plugins/index.mjs", "publish-plugins": "node scripts/publish-plugins", "lint": "eslint --fix .", From a79898f22680f4ab637ae0f5c1e505fa7feb2773 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Mon, 2 Oct 2023 14:47:28 -0700 Subject: [PATCH 22/35] Enable binary response payloads; do not coerce all response payloads to strings Override aws4 default by using `application/octet-stream` as default `content-type` Empty API response bodies are now returned as `null` payloads Detect and stringify XML response bodies Add response debug output --- src/request.js | 54 ++++++++++++++++++++++++------ test/unit/src/index-client-test.js | 2 +- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/request.js b/src/request.js index 337b24d3..146aa6c5 100644 --- a/src/request.js +++ b/src/request.js @@ -9,6 +9,8 @@ let JSONregex = /application\/json/ let JSONContentType = ct => ct.match(JSONregex) let AwsJSONregex = /application\/x-amz-json/ let AwsJSONContentType = ct => ct.match(AwsJSONregex) +let XMLregex = /(application|text)\/xml/ +let XMLContentType = ct => ct.match(XMLregex) module.exports = function request (params, creds, region, config, metadata) { return new Promise((resolve, reject) => { @@ -70,7 +72,14 @@ module.exports = function request (params, creds, region, config, metadata) { } // Finalize headers, content-type - if (contentType) headers['content-type'] = contentType + if (contentType) { + headers['content-type'] = contentType + } + // aws4's default content-type is form-urlencoded: backfill if there's a (non-streaming) body, yet no content-type was specified + // We don't want aws4 to attempt to sign stream objects, so if we backfill this content-type on a stream, the signature breaks and auth will fail + else if (params.body) { + headers['content-type'] = 'application/octet-stream' + } params.headers = headers // Sign the payload; let aws4 handle (most) logic related to region + service instantiation @@ -114,7 +123,7 @@ module.exports = function request (params, creds, region, config, metadata) { /**/ if (isBuffer) truncatedBody = `` else if (isStream) truncatedBody = `` else truncatedBody = body?.length > 1000 ? body?.substring(0, 1000) + '...' : body - console.error('[aws-lite] Requesting:', { + console.error('[aws-lite] Request:', { service, method, url: `${protocol}//${host}${port}${path}`, @@ -130,18 +139,41 @@ module.exports = function request (params, creds, region, config, metadata) { let ok = statusCode >= 200 && statusCode < 303 res.on('data', chunk => data.push(chunk)) res.on('end', () => { - // TODO The following string coersion will definitely need be changed when we get into binary response payloads - let payload = Buffer.concat(data).toString() + let body = Buffer.concat(data), payload, rawString let contentType = headers['content-type'] || headers['Content-Type'] || '' if (JSONContentType(contentType) || AwsJSONContentType(contentType)) { - payload = JSON.parse(payload) - } - // Some services may attempt to respond with regular JSON, but an AWS JSON content-type. Sure. Ok. Anyway, try to guard against that. - if (AwsJSONContentType(contentType)) { - try { - payload = awsjson.unmarshall(payload) + payload = JSON.parse(body) + + /* istanbul ignore next */ + if (config.debug) rawString = body.toString() + + // Some services may attempt to respond with regular JSON, but an AWS JSON content-type. Sure. Ok. Anyway, try to guard against that. + if (AwsJSONContentType(contentType)) { + try { + payload = awsjson.unmarshall(payload) + } + catch { /* noop, it's already parsed */ } } - catch { /* noop, it's already parsed */ } + } + /* istanbul ignore next */ + if (XMLContentType(contentType)) { + payload = body.toString() + /* istanbul ignore next */ + if (config.debug) rawString = payload + } + /* istanbul ignore next */ // TODO remove and test + payload = payload || (body.length ? body : null) + + /* istanbul ignore next */ // TODO remove and test + if (config.debug) { + let truncatedBody + /**/ if (payload instanceof Buffer) truncatedBody = body.length ? `` : '' + else if (rawString) truncatedBody = rawString?.length > 1000 ? rawString?.substring(0, 1000) + '...' : rawString + console.error('[aws-lite] Response:', { + statusCode, + headers, + body: truncatedBody || '', + }) } if (ok) resolve({ statusCode, headers, payload }) else reject({ statusCode, headers, error: payload, metadata }) diff --git a/test/unit/src/index-client-test.js b/test/unit/src/index-client-test.js index 2633c9e4..2c10e782 100644 --- a/test/unit/src/index-client-test.js +++ b/test/unit/src/index-client-test.js @@ -29,7 +29,7 @@ test('Primary client - core functionality', async t => { t.notOk(request.body, 'Request included no body') t.equal(result.statusCode, 200, 'Client returned status code of response') t.ok(result.headers, 'Client returned response headers') - t.equal(result.payload, '', 'Client returned empty response body as empty string') + t.equal(result.payload, null, 'Client returned empty response body as null') basicRequestChecks(t, 'GET') // Basic get request with query string params From 3d47258cad6b5ccfd7600d2eef5279a690ff8355 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 05:33:42 -0700 Subject: [PATCH 23/35] Add client response documentation Add additional information about passing readable streams as payload --- readme.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++--- src/request.js | 2 +- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 485af22a..e3298e20 100644 --- a/readme.md +++ b/readme.md @@ -11,6 +11,7 @@ - [Usage](#usage) - [Configuration](#configuration) - [Client requests](#client-requests) + - [Client responses](#client-responses) - [Plugins](#plugins) - [Plugin API](#plugin-api) - [`validate`](#validate) @@ -172,10 +173,13 @@ The following parameters may be passed with individual client requests; only `se - **`headers` (object)** - Header names + values to be added to your request - By default, all headers are included in [authentication via AWS signature v4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) + - If your request includes a `payload` that cannot be automatically JSON-encoded and you do not specify a `content-type` header, the default `application/octet-stream` will be used - **`payload` (object, buffer, readable stream, string)** - Aliases: `body`, `data`, `json` - - As a convenience, any passed objects are automatically JSON-encoded (with the appropriate `content-type` header set, if not already present); buffers and strings simply pass through as-is - - Passing a Node.js readable stream is currently experimental; this initiates an HTTP data stream to the API endpoint instead of writing a normal HTTP body payload + - Payload to be used as the HTTP request body; as a convenience, any passed objects are automatically JSON-encoded (with the appropriate `content-type` header set, if not already present); buffers, streams, and strings simply pass through as-is + - Readable streams are currently experimental + - Passing a Node.js readable stream initiates an HTTP data stream to the API endpoint instead of writing a normal HTTP body + - Streams are not automatically signed like normal HTTP bodies, and may [require their own signing procedures, as in S3](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html) - **`query` (object)** - Serialize the passed object and append it to your `endpoint` as a query string in your request - **`service` (string) [required]** @@ -211,6 +215,46 @@ await awsLite({ ``` +### Client responses + +The following properties are returned with each non-error client response: + +- **`statusCode` (number)** + - HTTP status code of the response +- **`headers` (object)** + - Response header names + values +- **`payload` (object, string, null)** + - Response payload; as a convenience, JSON-encoded responses are automatically parsed; XML-encoded responses are returned as plain strings + - Responses without an HTTP body return a `null` payload + +An example: + +```js +import awsLite from '@aws-lite/client' +const aws = await awsLite() + +await awsLite({ + service: 'lambda', + endpoint: '/2015-03-31/functions/$function-name/configuration', +}) +// { +// statusCode: 200, +// headers: { +// 'content-type': 'application/json', +// 'x-amzn-requestid': 'ba3a55d2-16c2-4c2b-afe1-cf0c5523040b', +// ... +// }, +// payload: { +// FunctionName: '$function-name', +// FunctionArn: 'arn:aws:lambda:us-west-1:1234567890:function:$function-name', +// Role: 'arn:aws:iam::1234567890:role/$function-name-role', +// Runtime: 'nodejs18.x', +// ... +// } +// } +``` + + ## Plugins Out of the box, [`@aws-lite/client`](https://www.npmjs.com/package/@aws-lite/client) is a full-featured AWS API client that you can use to interact with any AWS service that makes use of [authentication via AWS signature v4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) (which should be just about all of them). @@ -369,7 +413,7 @@ export default { // Assume successful responses always have an AWS-flavored JSON `Item` property response: async (response, utils) => { response.awsjson = [ 'Item' ] - return response // Returns the response (`statusCode`, `headers`, `payload`), with `payload.Item` unformatted from AWS-flavored JSON + return response // Returns the response (`statusCode`, `headers`, `payload`), with `payload.Item` unformatted from AWS-flavored JSON, and the `awsjson` property removed } } } diff --git a/src/request.js b/src/request.js index 146aa6c5..fe8e7f50 100644 --- a/src/request.js +++ b/src/request.js @@ -46,7 +46,7 @@ module.exports = function request (params, creds, region, config, metadata) { let isBuffer = body instanceof Buffer let isStream = body instanceof Readable - // Lots of potentially weird valid json (like just a null), deal with it if / when we need to I guess + // Detecting objects leaves open the possibility of some weird valid JSON (like just a null), deal with it if / when we need to I guess if (typeof body === 'object' && !isBuffer && !isStream) { // Backfill content-type if it's just an object if (!contentType) contentType = 'application/json' From 62b88df80746b7926c093aac503fbbe012201084 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 05:45:03 -0700 Subject: [PATCH 24/35] Minor readme updates --- readme.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index e3298e20..64d75877 100644 --- a/readme.md +++ b/readme.md @@ -259,7 +259,9 @@ await awsLite({ Out of the box, [`@aws-lite/client`](https://www.npmjs.com/package/@aws-lite/client) is a full-featured AWS API client that you can use to interact with any AWS service that makes use of [authentication via AWS signature v4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) (which should be just about all of them). -`@aws-lite/client` can be extended with plugins to more easily interact with AWS services. A bit more about how plugins work: +`@aws-lite/client` can be extended with plugins to more easily interact with AWS services, or provide custom behavior or semantics. As such, plugins enable you to have significantly more control over the entire API request/response lifecycle. + +A bit more about how plugins work: - Plugins can be authored in ESM or CJS - Plugins can be dependencies downloaded from npm, or also live locally in your codebase @@ -284,7 +286,7 @@ aws.dynamodb.PutItem({ TableName: 'my-table', Key: { id: 'hello' } }) The `aws-lite` plugin API is lightweight and simple to learn. It makes use of four optional lifecycle hooks: -- [`validate`](#validate) [optional] - an object of property names and types to validate inputs with pre-request +- [`validate`](#validate) [optional] - an object of property names and types used to validate inputs pre-request - [`request()`](#request) [optional] - an async function that enables mutation of inputs to the final service API request - [`response()`](#response) [optional] - an async function that enables mutation of service API responses before they are returned - [`error()`](#error) [optional] - an async function that enables mutation of service API errors before they are returned @@ -292,7 +294,7 @@ The `aws-lite` plugin API is lightweight and simple to learn. It makes use of fo The above four lifecycle hooks must be exported as an object named `methods`, along with a valid AWS service code property named `service`, like so: ```js -// A simple plugin for validating input +// A simple plugin for validating `TableName` input on dynamodb.PutItem() calls export default { service: 'dynamodb', awsDoc: 'https://docs.aws.../API_PutItem.html', @@ -309,7 +311,7 @@ export default { aws.dynamodb.PutItem({ TableName: 12345 }) // Throws validation error ``` -Additionally, two optional (but highly recommended) metadata properties may be added that will be included in any method errors: +Additionally, two optional (but highly recommended) metadata properties that will be included in any method errors: - `awsDoc` (string) [optional] - intended to be a link to the AWS API doc pertaining to this method; should usually start with `https://docs.aws.amazon.com/...` - `readme` (string) [optional] - a link to a relevant section in your plugin's readme or docs From 63e9e2c9bdbb2c4aabeb8cc12420a6a250323630 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 06:04:01 -0700 Subject: [PATCH 25/35] Add tests for endpoints returning XML, binary data --- src/request.js | 2 -- test/unit/src/index-client-test.js | 16 +++++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/request.js b/src/request.js index fe8e7f50..b4348655 100644 --- a/src/request.js +++ b/src/request.js @@ -155,13 +155,11 @@ module.exports = function request (params, creds, region, config, metadata) { catch { /* noop, it's already parsed */ } } } - /* istanbul ignore next */ if (XMLContentType(contentType)) { payload = body.toString() /* istanbul ignore next */ if (config.debug) rawString = payload } - /* istanbul ignore next */ // TODO remove and test payload = payload || (body.length ? body : null) /* istanbul ignore next */ // TODO remove and test diff --git a/test/unit/src/index-client-test.js b/test/unit/src/index-client-test.js index 2c10e782..9a4e4835 100644 --- a/test/unit/src/index-client-test.js +++ b/test/unit/src/index-client-test.js @@ -16,7 +16,7 @@ test('Set up env', async t => { }) test('Primary client - core functionality', async t => { - t.plan(30) + t.plan(42) let request, result, body, query, responseBody, url let headers = { 'content-type': 'application/json' } @@ -83,6 +83,20 @@ test('Primary client - core functionality', async t => { request = server.getCurrentRequest() t.deepEqual(request.url, endpoint, 'Request included correct body (just a string)') reset() + + // Endpoint returns XML + responseBody = 'yo' + server.use({ responseBody, responseHeaders: { 'content-type': 'application/xml' } }) + result = await aws({ service, endpoint }) + t.deepEqual(result.payload, responseBody, 'Client returned response body as XML string') + basicRequestChecks(t, 'GET') + + // Endpoint returns a buffer + responseBody = Buffer.from('ohi') + server.use({ responseBody, responseHeaders: { 'content-type': 'application/octet-stream' } }) + result = await aws({ service, endpoint }) + t.deepEqual(result.payload, responseBody, 'Client returned response body as buffer') + basicRequestChecks(t, 'GET') }) test('Primary client - aliased params', async t => { From f42e1453abe1c83306cdbb51c3f09d353421b1f9 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 06:33:18 -0700 Subject: [PATCH 26/35] Add tests for stream requests, XML + binary responses --- src/request.js | 2 +- test/lib/index.js | 5 ++-- test/unit/src/index-client-test.js | 45 +++++++++++++++++++----------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/request.js b/src/request.js index b4348655..139b4eb4 100644 --- a/src/request.js +++ b/src/request.js @@ -189,9 +189,9 @@ module.exports = function request (params, creds, region, config, metadata) { } })) - /* istanbul ignore next */ // TODO remove and test if (isStream) { body.pipe(req) + /* istanbul ignore next */ if (config.debug) { let bytes = 0 body.on('data', chunk => { diff --git a/test/lib/index.js b/test/lib/index.js index 490a2e0d..100f2fa2 100644 --- a/test/lib/index.js +++ b/test/lib/index.js @@ -4,6 +4,7 @@ const http = require('http') const accessKeyId = 'foo' const autoloadPlugins = false const badPort = 12345 +const debug = false const host = 'localhost' const keepAlive = false const protocol = 'http' @@ -12,7 +13,7 @@ const secretAccessKey = 'bar' const service = 'lambda' const endpoint = '/an/endpoint' const port = 1111 -const config = { accessKeyId, secretAccessKey, region, protocol, autoloadPlugins, keepAlive, host, port } +const config = { accessKeyId, secretAccessKey, region, debug, protocol, autoloadPlugins, keepAlive, host, port } const defaults = { accessKeyId, autoloadPlugins, badPort, config, host, keepAlive, protocol, region, secretAccessKey, service, endpoint, port } let serverData = {} @@ -29,7 +30,7 @@ let server = { if (data.length) { body = req.headers?.['content-type']?.includes('json') ? JSON.parse(data) - : data.join() + : Buffer.concat(data) } serverData.request = { url: req.url, diff --git a/test/unit/src/index-client-test.js b/test/unit/src/index-client-test.js index 9a4e4835..d9bc77ef 100644 --- a/test/unit/src/index-client-test.js +++ b/test/unit/src/index-client-test.js @@ -1,4 +1,5 @@ let { join } = require('path') +let { Readable } = require('stream') let qs = require('querystring') let test = require('tape') let { basicRequestChecks, defaults, resetServer: reset, server } = require('../../lib') @@ -16,8 +17,8 @@ test('Set up env', async t => { }) test('Primary client - core functionality', async t => { - t.plan(42) - let request, result, body, query, responseBody, url + t.plan(48) + let request, result, payload, query, responseBody, url let headers = { 'content-type': 'application/json' } @@ -39,45 +40,55 @@ test('Primary client - core functionality', async t => { basicRequestChecks(t, 'GET', { url }) // Basic post request - body = { ok: true } + payload = { ok: true } responseBody = { aws: 'lol' } server.use({ responseBody, responseHeaders: headers }) - result = await aws({ service, endpoint, body }) + result = await aws({ service, endpoint, payload }) request = server.getCurrentRequest() - t.deepEqual(request.body, body, 'Request included correct body') + t.deepEqual(request.body, payload, 'Request included correct body') t.deepEqual(result.payload, responseBody, 'Client returned response body as parsed JSON') basicRequestChecks(t, 'POST') // Basic post with query string params - body = { ok: true } + payload = { ok: true } query = { fiz: 'buz', json: JSON.stringify({ ok: false }) } url = endpoint + '?' + qs.stringify(query) responseBody = { aws: 'lol' } server.use({ responseBody, responseHeaders: headers }) - result = await aws({ service, endpoint, body, query }) + result = await aws({ service, endpoint, payload, query }) basicRequestChecks(t, 'POST', { url }) // Publish an object while passing headers - body = { ok: true } - result = await aws({ service, endpoint, body, headers }) + payload = { ok: true } + result = await aws({ service, endpoint, payload, headers }) request = server.getCurrentRequest() - t.deepEqual(request.body, body, 'Request included correct body (pre-encoded JSON)') + t.deepEqual(request.body, payload, 'Request included correct body (pre-encoded JSON)') reset() // Publish JSON while passing headers - body = JSON.stringify({ ok: true }) - result = await aws({ service, endpoint, body, headers }) + payload = JSON.stringify({ ok: true }) + result = await aws({ service, endpoint, payload, headers }) request = server.getCurrentRequest() - t.deepEqual(request.body, JSON.parse(body), 'Request included correct body (pre-encoded JSON)') + t.deepEqual(request.body, JSON.parse(payload), 'Request included correct body (pre-encoded JSON)') reset() // Publish some other kind of non-JSON request - body = 'hi' - result = await aws({ service, endpoint, body }) + payload = 'hi' + result = await aws({ service, endpoint, payload }) request = server.getCurrentRequest() - t.deepEqual(request.body, body, 'Request included correct body (just a string)') + t.deepEqual(request.body.toString(), payload, 'Request included correct body (just a string)') reset() + // Publish a stream + payload = new Readable() + let text = 'hi\nhello\nyo' + text.split('').forEach(c => payload.push(c)) + payload.push(null) + await aws({ service, endpoint, payload, method: 'POST', headers: { 'content-length': text.length } }) + request = server.getCurrentRequest() + t.equal(request.body.toString(), text, 'Request included correct body') + basicRequestChecks(t, 'POST') + // Ensure endpoints without leading slashes are handled properly result = await aws({ service, endpoint: 'an/endpoint' }) request = server.getCurrentRequest() @@ -142,7 +153,7 @@ test('Primary client - aliased params', async t => { let string = 'hi' await aws({ service, endpoint, payload: string }) request = server.getCurrentRequest() - t.equal(request.body, string, 'Made request with correct body (plain string)') + t.equal(request.body.toString(), string, 'Made request with correct body (plain string)') reset() }) From 199ce9c85980688ea3d3e219977fdc2a59fe2a79 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 08:59:17 -0700 Subject: [PATCH 27/35] Test second validation pass --- test/mock/plugins/validation.js | 4 + test/unit/src/index-plugins-test.js | 113 +++++++++++++++------------- 2 files changed, 65 insertions(+), 52 deletions(-) diff --git a/test/mock/plugins/validation.js b/test/mock/plugins/validation.js index a97b1bc1..edc1b485 100644 --- a/test/mock/plugins/validation.js +++ b/test/mock/plugins/validation.js @@ -26,5 +26,9 @@ module.exports = { noValidation: { request, }, + pluginBreaksValidation: { + validate: { arr: { type: 'array' } }, + request: async () => ({ arr: 12345 }) + } } } diff --git a/test/unit/src/index-plugins-test.js b/test/unit/src/index-plugins-test.js index 9b070e2a..258e4a20 100644 --- a/test/unit/src/index-plugins-test.js +++ b/test/unit/src/index-plugins-test.js @@ -17,57 +17,8 @@ test('Set up env', async t => { t.ok(started, 'Started server') }) -test('Plugins - method construction, requests', async t => { - t.plan(29) - let name = 'my-lambda' - let aws, expectedEndpoint, request - - // Reads - aws = await client({ ...config, plugins: [ join(pluginDir, 'get.js') ] }) - expectedEndpoint = `/2015-03-31/functions/${name}/configuration` - - await aws.lambda.GetFunctionConfiguration({ name, host, port }) - request = server.getCurrentRequest() - t.equal(request.url, expectedEndpoint, 'Plugin requested generated endpoint') - t.equal(request.body, undefined, 'Plugin made request without body') - basicRequestChecks(t, 'GET', { url: expectedEndpoint }) - - await aws.lambda.GetFunctionConfiguration({ name, host, port, endpoint: '/foo' }) - request = server.getCurrentRequest() - t.equal(request.url, expectedEndpoint, 'Plugin can override normal client param') - basicRequestChecks(t, 'GET', { url: expectedEndpoint }) - - // Writes - aws = await client({ ...config, plugins: [ join(pluginDir, 'post.js') ] }) - expectedEndpoint = `/2015-03-31/functions/${name}/invocations` - let payload = { ok: true } - - await aws.lambda.Invoke({ name, payload, host, port }) - request = server.getCurrentRequest() - t.equal(request.url, expectedEndpoint, 'Plugin requested generated endpoint') - t.deepEqual(request.body, payload, 'Plugin made request with included payload') - basicRequestChecks(t, 'POST', { url: expectedEndpoint }) - - await aws.lambda.Invoke({ name, data: payload, host, port }) - request = server.getCurrentRequest() - t.deepEqual(request.body, payload, `Payload can be aliased to 'data'`) - - await aws.lambda.Invoke({ name, body: payload, host, port }) - request = server.getCurrentRequest() - t.deepEqual(request.body, payload, `Payload can be aliased to 'body'`) - - await aws.lambda.Invoke({ name, json: payload, host, port }) - request = server.getCurrentRequest() - t.deepEqual(request.body, payload, `Payload can be aliased to 'json'`) - - await aws.lambda.Invoke({ name, payload, host, port, endpoint: '/foo' }) - request = server.getCurrentRequest() - t.equal(request.url, expectedEndpoint, 'Plugin can override normal client param') - basicRequestChecks(t, 'POST', { url: expectedEndpoint }) -}) - -test('Plugins - input validation', async t => { - t.plan(23) +test('Plugins - validate input', async t => { + t.plan(24) let str = 'hi' let num = 123 @@ -190,6 +141,15 @@ test('Plugins - input validation', async t => { } } + // Initial validation passes, but request() output does not pass validation + try { + await aws.lambda.pluginBreaksValidation({ required: str, arr: [] }) + } + catch (err) { + console.log(err) + t.match(err.message, /Parameter 'arr' must be: array/, 'Errored on wrong param (from type array)') + } + // Type array try { await aws.lambda.testTypes({ required: str, payload: num }) @@ -219,7 +179,56 @@ test('Plugins - input validation', async t => { reset() }) -test('Plugins - error handling', async t => { +test('Plugins - method construction, request()', async t => { + t.plan(29) + let name = 'my-lambda' + let aws, expectedEndpoint, request + + // Reads + aws = await client({ ...config, plugins: [ join(pluginDir, 'get.js') ] }) + expectedEndpoint = `/2015-03-31/functions/${name}/configuration` + + await aws.lambda.GetFunctionConfiguration({ name, host, port }) + request = server.getCurrentRequest() + t.equal(request.url, expectedEndpoint, 'Plugin requested generated endpoint') + t.equal(request.body, undefined, 'Plugin made request without body') + basicRequestChecks(t, 'GET', { url: expectedEndpoint }) + + await aws.lambda.GetFunctionConfiguration({ name, host, port, endpoint: '/foo' }) + request = server.getCurrentRequest() + t.equal(request.url, expectedEndpoint, 'Plugin can override normal client param') + basicRequestChecks(t, 'GET', { url: expectedEndpoint }) + + // Writes + aws = await client({ ...config, plugins: [ join(pluginDir, 'post.js') ] }) + expectedEndpoint = `/2015-03-31/functions/${name}/invocations` + let payload = { ok: true } + + await aws.lambda.Invoke({ name, payload, host, port }) + request = server.getCurrentRequest() + t.equal(request.url, expectedEndpoint, 'Plugin requested generated endpoint') + t.deepEqual(request.body, payload, 'Plugin made request with included payload') + basicRequestChecks(t, 'POST', { url: expectedEndpoint }) + + await aws.lambda.Invoke({ name, data: payload, host, port }) + request = server.getCurrentRequest() + t.deepEqual(request.body, payload, `Payload can be aliased to 'data'`) + + await aws.lambda.Invoke({ name, body: payload, host, port }) + request = server.getCurrentRequest() + t.deepEqual(request.body, payload, `Payload can be aliased to 'body'`) + + await aws.lambda.Invoke({ name, json: payload, host, port }) + request = server.getCurrentRequest() + t.deepEqual(request.body, payload, `Payload can be aliased to 'json'`) + + await aws.lambda.Invoke({ name, payload, host, port, endpoint: '/foo' }) + request = server.getCurrentRequest() + t.equal(request.url, expectedEndpoint, 'Plugin can override normal client param') + basicRequestChecks(t, 'POST', { url: expectedEndpoint }) +}) + +test('Plugins - error(), error handling', async t => { t.plan(43) let name = 'my-lambda' let payload = { ok: true } From 4ff52adf7fea6bd1ac43f44dd0687f7cda41ca0f Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 09:02:58 -0700 Subject: [PATCH 28/35] Rename plugin mocks to be more in line with hook names --- test/mock/plugins/cjs/index.js | 2 +- test/mock/plugins/{errors.js => error.js} | 0 test/mock/plugins/{get.js => request-get.js} | 0 test/mock/plugins/{post.js => request-post.js} | 0 test/mock/plugins/{validation.js => validate.js} | 0 test/unit/src/index-plugins-test.js | 8 ++++---- 6 files changed, 5 insertions(+), 5 deletions(-) rename test/mock/plugins/{errors.js => error.js} (100%) rename test/mock/plugins/{get.js => request-get.js} (100%) rename test/mock/plugins/{post.js => request-post.js} (100%) rename test/mock/plugins/{validation.js => validate.js} (100%) diff --git a/test/mock/plugins/cjs/index.js b/test/mock/plugins/cjs/index.js index a24d5f42..6cae6dc0 100644 --- a/test/mock/plugins/cjs/index.js +++ b/test/mock/plugins/cjs/index.js @@ -1,2 +1,2 @@ // Just a passthrough -module.exports = require('../get') +module.exports = require('../request-get') diff --git a/test/mock/plugins/errors.js b/test/mock/plugins/error.js similarity index 100% rename from test/mock/plugins/errors.js rename to test/mock/plugins/error.js diff --git a/test/mock/plugins/get.js b/test/mock/plugins/request-get.js similarity index 100% rename from test/mock/plugins/get.js rename to test/mock/plugins/request-get.js diff --git a/test/mock/plugins/post.js b/test/mock/plugins/request-post.js similarity index 100% rename from test/mock/plugins/post.js rename to test/mock/plugins/request-post.js diff --git a/test/mock/plugins/validation.js b/test/mock/plugins/validate.js similarity index 100% rename from test/mock/plugins/validation.js rename to test/mock/plugins/validate.js diff --git a/test/unit/src/index-plugins-test.js b/test/unit/src/index-plugins-test.js index 258e4a20..ff81526f 100644 --- a/test/unit/src/index-plugins-test.js +++ b/test/unit/src/index-plugins-test.js @@ -22,7 +22,7 @@ test('Plugins - validate input', async t => { let str = 'hi' let num = 123 - let aws = await client({ ...config, plugins: [ join(pluginDir, 'validation.js') ] }) + let aws = await client({ ...config, plugins: [ join(pluginDir, 'validate.js') ] }) // No validation try { @@ -185,7 +185,7 @@ test('Plugins - method construction, request()', async t => { let aws, expectedEndpoint, request // Reads - aws = await client({ ...config, plugins: [ join(pluginDir, 'get.js') ] }) + aws = await client({ ...config, plugins: [ join(pluginDir, 'request-get.js') ] }) expectedEndpoint = `/2015-03-31/functions/${name}/configuration` await aws.lambda.GetFunctionConfiguration({ name, host, port }) @@ -200,7 +200,7 @@ test('Plugins - method construction, request()', async t => { basicRequestChecks(t, 'GET', { url: expectedEndpoint }) // Writes - aws = await client({ ...config, plugins: [ join(pluginDir, 'post.js') ] }) + aws = await client({ ...config, plugins: [ join(pluginDir, 'request-post.js') ] }) expectedEndpoint = `/2015-03-31/functions/${name}/invocations` let payload = { ok: true } @@ -234,7 +234,7 @@ test('Plugins - error(), error handling', async t => { let payload = { ok: true } let responseBody, responseHeaders, responseStatusCode - let errorsPlugin = join(pluginDir, 'errors.js') + let errorsPlugin = join(pluginDir, 'error.js') let aws = await client({ ...config, plugins: [ errorsPlugin ] }) // Control From a6a3a365c540f1efc97eb19cb131b13a119257e5 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 14:16:09 -0700 Subject: [PATCH 29/35] Add `response()` tests Fix `response()` payload marshalling property bug Ensure `request()` is indeed optional --- src/client-factory.js | 21 +++++---- test/mock/plugins/response.js | 40 +++++++++++++++++ test/unit/src/index-client-test.js | 18 ++++---- test/unit/src/index-plugins-test.js | 66 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 test/mock/plugins/response.js diff --git a/src/client-factory.js b/src/client-factory.js index fe5c9088..5a96a645 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -125,12 +125,14 @@ module.exports = async function clientFactory (config, creds, region) { } // Run plugin.request() - try { - var req = await method.request(input, { ...pluginUtils, region: selectedRegion }) - req = req || {} - } - catch (methodError) { - errorHandler({ error: methodError, metadata }) + if (method.request) { + try { + var req = await method.request(input, { ...pluginUtils, region: selectedRegion }) + req = req || {} + } + catch (methodError) { + errorHandler({ error: methodError, metadata }) + } } // Validate combined inputs of user + plugin @@ -157,9 +159,10 @@ module.exports = async function clientFactory (config, creds, region) { if (unmarshalling) { delete pluginRes.awsjson // If a payload property isn't included, it _is_ the payload - let payload = pluginRes.payload || pluginRes - let unmarshalled = awsjson.unmarshall(payload, unmarshalling) - response = { ...pluginRes, ...unmarshalled } + let unmarshalled = awsjson.unmarshall(pluginRes.payload || pluginRes, unmarshalling) + response = pluginRes.payload + ? { ...pluginRes, payload: unmarshalled } + : unmarshalled } else response = pluginRes } diff --git a/test/mock/plugins/response.js b/test/mock/plugins/response.js new file mode 100644 index 00000000..099846d0 --- /dev/null +++ b/test/mock/plugins/response.js @@ -0,0 +1,40 @@ +const required = true +const passthrough = params => params + +module.exports = { + service: 'lambda', + methods: { + NoResponseMethod: { + request: passthrough + }, + Passthrough: { + response: passthrough + }, + MutateProperty: { + response: params => { + params.statusCode = 234 + return params + } + }, + MutateAllProperties: { + response: params => { + params.statusCode = 234 + params.headers.foo = 'bar' + params.payload = { hi: 'there' } + return params + } + }, + OnlyPassThroughPayload: { + response: params => params.payload + }, + ReturnWhatever: { + response: params => 'yooo' + }, + ReturnAwsJsonAll: { + response: params => ({ ...params, awsjson: true }) + }, + ReturnAwsJsonKey: { + response: params => ({ ...params, awsjson: [ 'Item' ] }) + }, + } +} diff --git a/test/unit/src/index-client-test.js b/test/unit/src/index-client-test.js index d9bc77ef..5ba68287 100644 --- a/test/unit/src/index-client-test.js +++ b/test/unit/src/index-client-test.js @@ -30,7 +30,7 @@ test('Primary client - core functionality', async t => { t.notOk(request.body, 'Request included no body') t.equal(result.statusCode, 200, 'Client returned status code of response') t.ok(result.headers, 'Client returned response headers') - t.equal(result.payload, null, 'Client returned empty response body as null') + t.equal(result.payload, null, 'Client returned empty response payload as null') basicRequestChecks(t, 'GET') // Basic get request with query string params @@ -46,7 +46,7 @@ test('Primary client - core functionality', async t => { result = await aws({ service, endpoint, payload }) request = server.getCurrentRequest() t.deepEqual(request.body, payload, 'Request included correct body') - t.deepEqual(result.payload, responseBody, 'Client returned response body as parsed JSON') + t.deepEqual(result.payload, responseBody, 'Client returned response payload as parsed JSON') basicRequestChecks(t, 'POST') // Basic post with query string params @@ -99,14 +99,14 @@ test('Primary client - core functionality', async t => { responseBody = 'yo' server.use({ responseBody, responseHeaders: { 'content-type': 'application/xml' } }) result = await aws({ service, endpoint }) - t.deepEqual(result.payload, responseBody, 'Client returned response body as XML string') + t.deepEqual(result.payload, responseBody, 'Client returned response payload as XML string') basicRequestChecks(t, 'GET') // Endpoint returns a buffer responseBody = Buffer.from('ohi') server.use({ responseBody, responseHeaders: { 'content-type': 'application/octet-stream' } }) result = await aws({ service, endpoint }) - t.deepEqual(result.payload, responseBody, 'Client returned response body as buffer') + t.deepEqual(result.payload, responseBody, 'Client returned response payload as buffer') basicRequestChecks(t, 'GET') }) @@ -174,7 +174,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, headers: headersAwsJSON() }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: { BOOL: true } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response payload as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -184,7 +184,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, headers: headersAwsJSON() }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: { BOOL: false } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response payload as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -194,7 +194,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, awsjson: true }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: { BOOL: false } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response payload as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -204,7 +204,7 @@ test('Primary client - AWS JSON payloads', async t => { result = await aws({ service, endpoint, body, awsjson: [ 'fine' ] }) request = server.getCurrentRequest() t.deepEqual(request.body, { ok: true, fine: { BOOL: false } }, 'Request included correct body (raw AWS JSON)') - t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, expectedResponseBody(), 'Client returned response payload as parsed, unmarshalled JSON') basicRequestChecks(t, 'POST') reset() @@ -213,7 +213,7 @@ test('Primary client - AWS JSON payloads', async t => { server.use({ responseBody: regularJSON, responseHeaders: headersAwsJSON() }) result = await aws({ service, endpoint }) request = server.getCurrentRequest() - t.deepEqual(result.payload, regularJSON, 'Client returned response body as parsed, unmarshalled JSON') + t.deepEqual(result.payload, regularJSON, 'Client returned response payload as parsed, unmarshalled JSON') reset() }) diff --git a/test/unit/src/index-plugins-test.js b/test/unit/src/index-plugins-test.js index ff81526f..b748b98c 100644 --- a/test/unit/src/index-plugins-test.js +++ b/test/unit/src/index-plugins-test.js @@ -228,6 +228,72 @@ test('Plugins - method construction, request()', async t => { basicRequestChecks(t, 'POST', { url: expectedEndpoint }) }) +test('Plugins - response()', async t => { + t.plan(61) + let aws, payload, response, responseBody, responseHeaders + + aws = await client({ ...config, host, port, plugins: [ join(pluginDir, 'response.js') ] }) + + // Pass through by having no response() method + response = await aws.lambda.NoResponseMethod() + t.equal(response.statusCode, 200, 'Response status code passed through') + t.ok(response.headers, 'Response headers passed through') + t.notOk(response.headers.foo, 'Response headers not mutated') + t.equal(response.payload, null, 'Response payload passed through') + basicRequestChecks(t, 'GET', { url: '/' }) + + // Actively pass through + response = await aws.lambda.Passthrough() + t.equal(response.statusCode, 200, 'Response status code passed through') + t.ok(response.headers, 'Response headers passed through') + t.notOk(response.headers.foo, 'Response headers not mutated') + t.equal(response.payload, null, 'Response payload passed through') + basicRequestChecks(t, 'GET', { url: '/' }) + + // Mutate a single property, but pass the rest through + response = await aws.lambda.MutateProperty() + t.equal(response.statusCode, 234, 'Response status code mutated by plugin') + t.ok(response.headers, 'Response headers passed through') + t.notOk(response.headers.foo, 'Response headers not mutated') + t.equal(response.payload, null, 'Response payload passed through') + basicRequestChecks(t, 'GET', { url: '/' }) + + // Mutate all properties + response = await aws.lambda.MutateAllProperties() + t.equal(response.statusCode, 234, 'Response status code mutated by plugin') + t.equal(response.headers.foo, 'bar', 'Response headers mutated') + t.equal(response.payload.hi, 'there', 'Response payload mutated') + basicRequestChecks(t, 'GET', { url: '/' }) + + responseHeaders = { 'content-type': 'application/json' } + payload = { hi: 'there' } + server.use({ responseHeaders, responseBody: payload }) + response = await aws.lambda.OnlyPassThroughPayload() + t.deepEqual(response, payload, 'Response passed through just the payload') + basicRequestChecks(t, 'GET', { url: '/' }) + + response = await aws.lambda.ReturnWhatever() + t.deepEqual(response, 'yooo', 'Response passed through whatever it wants') + basicRequestChecks(t, 'GET', { url: '/' }) + + // A bit contrived since AWS JSON would normally be returned with an appropriate header, but we are just making sure we can force the entire payload to be unmarhsalled + responseHeaders = { 'content-type': 'application/json' } + responseBody = { aws: { S: 'idk' } } + server.use({ responseHeaders, responseBody }) + response = await aws.lambda.ReturnAwsJsonAll() + t.deepEqual(response.payload, { aws: 'idk' }, 'Returned response payload as parsed, unmarshalled JSON') + t.notOk(response.awsjson, 'awsjson property stripped') + basicRequestChecks(t, 'GET', { url: '/' }) + + responseHeaders = { 'content-type': 'application/x-amz-json-1.0' } + responseBody = { Item: { aws: { S: 'idk' } }, ok: true } + server.use({ responseHeaders, responseBody }) + response = await aws.lambda.ReturnAwsJsonKey() + t.deepEqual(response.payload, { Item: { aws: 'idk' }, ok: true }, 'Returned response payload as parsed, unmarshalled JSON') + t.notOk(response.awsjson, 'awsjson property stripped') + basicRequestChecks(t, 'GET', { url: '/' }) +}) + test('Plugins - error(), error handling', async t => { t.plan(43) let name = 'my-lambda' From c0cb4301b3fff8ab31223f9ca1c9e7060f6e53ad Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 15:29:32 -0700 Subject: [PATCH 30/35] Add plugin autoloading tests --- src/client-factory.js | 1 - test/unit/src/index-config-test.js | 34 +++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/client-factory.js b/src/client-factory.js index 5a96a645..792c19f4 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -29,7 +29,6 @@ module.exports = async function clientFactory (config, creds, region) { // Service API plugins let { autoloadPlugins = true, plugins = [] } = config - /* istanbul ignore next */ // TODO check once plugins are published if (autoloadPlugins) { let nodeModulesDir = join(process.cwd(), 'node_modules') let mods = await readdir(nodeModulesDir) diff --git a/test/unit/src/index-config-test.js b/test/unit/src/index-config-test.js index 47b53630..e2cc5baa 100644 --- a/test/unit/src/index-config-test.js +++ b/test/unit/src/index-config-test.js @@ -1,4 +1,5 @@ let { join } = require('path') +let mockFs = require('mock-fs') let test = require('tape') let { basicRequestChecks, defaults, resetAWSEnvVars: reset, server } = require('../../lib') let cwd = process.cwd() @@ -12,7 +13,7 @@ test('Set up env', async t => { t.ok(client, 'aws-lite client is present') }) -test('Initial configuration', async t => { +test('Configuration - basic config', async t => { t.plan(3) let aws @@ -36,7 +37,34 @@ test('Initial configuration', async t => { } }) -test('Initial configuration - per-request overrides', async t => { +test('Configuration - plugin loading', async t => { + t.plan(4) + let aws + + aws = await client({ accessKeyId, secretAccessKey, region }) + t.ok(aws.dynamodb, 'Client auto-loaded @aws-lite/dynamodb') + + aws = await client({ accessKeyId, secretAccessKey, region, autoloadPlugins: false }) + t.notOk(aws.dynamodb, 'Client did not auto-load @aws-lite/dynamodb') + + let nodeModules = join(cwd, 'node_modules') + mockFs({ [nodeModules]: {} }) + aws = await client({ accessKeyId, secretAccessKey, region }) + t.notOk(aws.dynamodb, `Client did not auto-load @aws-lite/* plugins it can't find`) + mockFs.restore() + + // A bit of a funky test, but we don't need to exercise actually loading an aws-lite-plugin-* plugin, we just need to ensure it attempts to + try { + mockFs({ [join(nodeModules, 'aws-lite-plugin-hi')]: {} }) + aws = await client({ accessKeyId, secretAccessKey, region }) + } + catch (err) { + t.match(err.message, /Cannot find module 'aws-lite-plugin-hi'/, 'Found and loaded aws-lite-plugin-*') + } + mockFs.restore() +}) + +test('Configuration - per-request overrides', async t => { t.plan(7) let started = await server.start() t.ok(started, 'Started server') @@ -67,7 +95,7 @@ test('Initial configuration - per-request overrides', async t => { t.pass('Server ended') }) -test('Initial configuration - validation', async t => { +test('Configuration - validation', async t => { t.plan(2) try { await client({ ...config, protocol: 'lolidk' }) From 92d72b38369f40c8aea22af106c6f994964ceda9 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 15:31:43 -0700 Subject: [PATCH 31/35] Update deps --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 3c8d7bf0..b99d3f68 100644 --- a/package.json +++ b/package.json @@ -30,16 +30,16 @@ "ini": "^4.1.1" }, "devDependencies": { - "@architect/eslint-config": "^2.1.1", + "@architect/eslint-config": "^2.1.2", "@aws-sdk/client-ssm": "^3.405.0", - "@aws-sdk/util-dynamodb": "^3.415.0", + "@aws-sdk/util-dynamodb": "^3.423.0", "adm-zip": "^0.5.10", "cross-env": "^7.0.3", - "eslint": "^8.48.0", + "eslint": "^8.50.0", "mock-fs": "^5.2.0", "nyc": "^15.1.0", "tap-spec": "^5.0.0", - "tape": "^5.6.6" + "tape": "^5.7.0" }, "files": [ "src" From 7becc621e05f43a031abcbae1057ff7959e4822d Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Tue, 3 Oct 2023 20:46:52 -0700 Subject: [PATCH 32/35] Isolate failing test on EOL Node.js / npm --- test/unit/src/index-config-test.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/unit/src/index-config-test.js b/test/unit/src/index-config-test.js index e2cc5baa..9ce7655b 100644 --- a/test/unit/src/index-config-test.js +++ b/test/unit/src/index-config-test.js @@ -38,11 +38,16 @@ test('Configuration - basic config', async t => { }) test('Configuration - plugin loading', async t => { - t.plan(4) + t.plan(3) let aws - aws = await client({ accessKeyId, secretAccessKey, region }) - t.ok(aws.dynamodb, 'Client auto-loaded @aws-lite/dynamodb') + // Node.js 14.x + npm 6 does funky things with npm link-ed (symlinked) modules + // That's cool, we can confidently skip this test for now, the related code path provably works! + if (!process.versions.node.startsWith('14')) { + t.plan(4) + aws = await client({ accessKeyId, secretAccessKey, region }) + t.ok(aws.dynamodb, 'Client auto-loaded @aws-lite/dynamodb') + } aws = await client({ accessKeyId, secretAccessKey, region, autoloadPlugins: false }) t.notOk(aws.dynamodb, 'Client did not auto-load @aws-lite/dynamodb') From 0075fa63087fb3849986762a86ec915779767062 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Wed, 4 Oct 2023 08:28:52 -0700 Subject: [PATCH 33/35] Add a few misc tests --- src/client-factory.js | 4 -- src/request.js | 3 +- test/mock/plugins/error.js | 7 +++ .../plugins/invalid/invalid-error-method.js | 1 - .../invalid/invalid-response-method.js | 8 +++ test/mock/plugins/misc/disabled-methods.js | 13 +++++ test/mock/plugins/response.js | 6 +++ test/unit/src/index-plugins-test.js | 54 +++++++++++++++++-- 8 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 test/mock/plugins/invalid/invalid-response-method.js create mode 100644 test/mock/plugins/misc/disabled-methods.js diff --git a/src/client-factory.js b/src/client-factory.js index 792c19f4..7041cc97 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -75,8 +75,6 @@ module.exports = async function clientFactory (config, creds, region) { if (method.request && typeof method.request !== 'function') { throw ReferenceError(`All plugin request methods must be a function: ${service}`) } - // Error + Response handlers are optional - /* istanbul ignore next */ // TODO remove as soon as plugin.response() API settles if (method.response && typeof method.response !== 'function') { throw ReferenceError(`All plugin response methods must be a function: ${service}`) } @@ -99,7 +97,6 @@ module.exports = async function clientFactory (config, creds, region) { let clientMethods = {} Object.entries(methods).forEach(([ name, method ]) => { // Allow for falsy methods to be denoted as incomplete in generated docs - /* istanbul ignore next */ // TODO remove and test if (!method || method.disabled) return // For convenient error reporting (and jic anyone wants to enumerate everything) try to ensure the AWS API method names pass through @@ -145,7 +142,6 @@ module.exports = async function clientFactory (config, creds, region) { let response = await request({ ...params, service }, creds, selectedRegion, config, metadata) // Run plugin.response() - /* istanbul ignore next */ // TODO remove as soon as plugin.response() API settles if (method.response) { try { var pluginRes = await method.response(response, { ...pluginUtils, region: selectedRegion }) diff --git a/src/request.js b/src/request.js index 139b4eb4..2c465741 100644 --- a/src/request.js +++ b/src/request.js @@ -67,7 +67,6 @@ module.exports = function request (params, creds, region, config, metadata) { } // Everything besides streams pass through for signing else { - /* istanbul ignore next */ params.body = isStream ? undefined : body } @@ -162,7 +161,7 @@ module.exports = function request (params, creds, region, config, metadata) { } payload = payload || (body.length ? body : null) - /* istanbul ignore next */ // TODO remove and test + /* istanbul ignore next */ if (config.debug) { let truncatedBody /**/ if (payload instanceof Buffer) truncatedBody = body.length ? `` : '' diff --git a/test/mock/plugins/error.js b/test/mock/plugins/error.js index f6c81f7a..ec662f78 100644 --- a/test/mock/plugins/error.js +++ b/test/mock/plugins/error.js @@ -11,6 +11,13 @@ module.exports = { return input } }, + responseMethodBlowsUp: { + awsDoc: 'https://responseMethodBlowsUp.lol', + response: async (input) => { + input.foo.bar = 'idk' + return input + } + }, noErrorMethod: { request: passthrough, }, diff --git a/test/mock/plugins/invalid/invalid-error-method.js b/test/mock/plugins/invalid/invalid-error-method.js index 28d71589..1e09a660 100644 --- a/test/mock/plugins/invalid/invalid-error-method.js +++ b/test/mock/plugins/invalid/invalid-error-method.js @@ -2,7 +2,6 @@ module.exports = { service: 'lambda', methods: { foo: { - request: async () => {}, error: true, } } diff --git a/test/mock/plugins/invalid/invalid-response-method.js b/test/mock/plugins/invalid/invalid-response-method.js new file mode 100644 index 00000000..f01e7926 --- /dev/null +++ b/test/mock/plugins/invalid/invalid-response-method.js @@ -0,0 +1,8 @@ +module.exports = { + service: 'lambda', + methods: { + foo: { + response: true, + } + } +} diff --git a/test/mock/plugins/misc/disabled-methods.js b/test/mock/plugins/misc/disabled-methods.js new file mode 100644 index 00000000..e493908e --- /dev/null +++ b/test/mock/plugins/misc/disabled-methods.js @@ -0,0 +1,13 @@ +module.exports = { + service: 'lambda', + methods: { + ok: { + request: () => {} + }, + disabledByFalsy: false, + disabledByParam: { + disabled: true, + awsDoc: 'https://arc.codes', + }, + } +} diff --git a/test/mock/plugins/response.js b/test/mock/plugins/response.js index 099846d0..287eaf27 100644 --- a/test/mock/plugins/response.js +++ b/test/mock/plugins/response.js @@ -33,8 +33,14 @@ module.exports = { ReturnAwsJsonAll: { response: params => ({ ...params, awsjson: true }) }, + ReturnAwsJsonPayload: { + response: params => ({ ...params.payload, awsjson: true }) + }, ReturnAwsJsonKey: { response: params => ({ ...params, awsjson: [ 'Item' ] }) }, + ReturnNothing: { + response: () => {} + }, } } diff --git a/test/unit/src/index-plugins-test.js b/test/unit/src/index-plugins-test.js index b748b98c..344d7371 100644 --- a/test/unit/src/index-plugins-test.js +++ b/test/unit/src/index-plugins-test.js @@ -229,7 +229,7 @@ test('Plugins - method construction, request()', async t => { }) test('Plugins - response()', async t => { - t.plan(61) + t.plan(77) let aws, payload, response, responseBody, responseHeaders aws = await client({ ...config, host, port, plugins: [ join(pluginDir, 'response.js') ] }) @@ -285,6 +285,16 @@ test('Plugins - response()', async t => { t.notOk(response.awsjson, 'awsjson property stripped') basicRequestChecks(t, 'GET', { url: '/' }) + // Unmarshall just the payload contents, leaving out headers and status code + responseHeaders = { 'content-type': 'application/json' } + responseBody = { aws: { S: 'idk' } } + server.use({ responseHeaders, responseBody }) + response = await aws.lambda.ReturnAwsJsonPayload() + t.deepEqual(response, { aws: 'idk' }, 'Returned response payload as parsed, unmarshalled JSON') + t.notOk(response.awsjson, 'awsjson property stripped') + basicRequestChecks(t, 'GET', { url: '/' }) + + // Unmarshall an individual payload key responseHeaders = { 'content-type': 'application/x-amz-json-1.0' } responseBody = { Item: { aws: { S: 'idk' } }, ok: true } server.use({ responseHeaders, responseBody }) @@ -292,10 +302,18 @@ test('Plugins - response()', async t => { t.deepEqual(response.payload, { Item: { aws: 'idk' }, ok: true }, 'Returned response payload as parsed, unmarshalled JSON') t.notOk(response.awsjson, 'awsjson property stripped') basicRequestChecks(t, 'GET', { url: '/' }) + + // Response returns nothing + response = await aws.lambda.ReturnNothing() + t.equal(response.statusCode, 200, 'Response status code passed through') + t.ok(response.headers, 'Response headers passed through') + t.notOk(response.headers.foo, 'Response headers not mutated') + t.equal(response.payload, null, 'Response payload passed through') + basicRequestChecks(t, 'GET', { url: '/' }) }) test('Plugins - error(), error handling', async t => { - t.plan(43) + t.plan(48) let name = 'my-lambda' let payload = { ok: true } let responseBody, responseHeaders, responseStatusCode @@ -329,6 +347,20 @@ test('Plugins - error(), error handling', async t => { reset() } + // Response method fails + try { + await aws.lambda.responseMethodBlowsUp({ name, host, port }) + } + catch (err) { + console.log(err) + t.match(err.message, /\@aws-lite\/client: lambda.responseMethodBlowsUp: Cannot set/, 'Error included basic method information') + t.equal(err.service, service, 'Error has service metadata') + t.equal(err.awsDoc, 'https://responseMethodBlowsUp.lol', 'Error has AWS API doc') + t.ok(err.stack.includes(errorsPlugin), 'Stack trace includes failing plugin') + t.ok(err.stack.includes(__filename), 'Stack trace includes this test') + reset() + } + // Error passed through plugin try { responseBody = { other: 'metadata' } @@ -443,8 +475,16 @@ test('Plugins - error docs (@aws-lite)', async t => { } }) +test('Plugins - disabled methods', async t => { + t.plan(3) + let aws = await client({ ...config, plugins: [ join(pluginDir, 'misc', 'disabled-methods') ] }) + t.ok(aws.lambda.ok, 'Client loaded plugin containing disabled methods') + t.notOk(aws.lambda.disabledByFalsy, 'Client did not load method disabled by boolean false') + t.notOk(aws.lambda.disabledByParam, `Client did not load method disabled by 'disabled' param`) +}) + test('Plugins - plugin validation', async t => { - t.plan(11) + t.plan(12) // CJS try { @@ -481,6 +521,14 @@ test('Plugins - plugin validation', async t => { reset() } + try { + await client({ ...config, plugins: [ join(invalidPlugins, 'invalid-response-method.js') ] }) + } + catch (err) { + t.match(err.message, /All plugin response methods must be a function/, 'Throw on invalid response method') + reset() + } + try { await client({ ...config, plugins: [ join(invalidPlugins, 'invalid-error-method.js') ] }) } From fc969065c01e48d3a282abba8921c91e5388e077 Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Wed, 4 Oct 2023 10:15:57 -0700 Subject: [PATCH 34/35] Add `@aws-lite/s3` `HeadObject` method + docs Dry up some common code in the other methods --- plugins/s3/readme.md | 38 +++++++++++++++++++++++++- plugins/s3/src/incomplete.mjs | 2 +- plugins/s3/src/index.mjs | 50 ++++++++++++++++++++++------------- plugins/s3/src/lib.mjs | 33 ++++++++++++++++++++++- plugins/s3/src/put-object.mjs | 9 ++----- 5 files changed, 104 insertions(+), 28 deletions(-) diff --git a/plugins/s3/readme.md b/plugins/s3/readme.md index 7e2de910..f4248124 100644 --- a/plugins/s3/readme.md +++ b/plugins/s3/readme.md @@ -65,6 +65,43 @@ Properties: - Sets response header: `expires` +### `HeadObject` + +[Canonical AWS API doc](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html) + +Properties: +- **`Bucket` (string) [required]** + - S3 bucket name +- **`Key` (string) [required]** + - S3 key / file name +- **`PartNumber` (number)** + - Part number (between 1 - 10,000) of the object +- **`VersionId` (string)** + - Reference a specific version of the object +- **`IfMatch` (string)** + - Sets request header: `if-match` +- **`IfModifiedSince` (string)** + - Sets request header: `if-modified-since` +- **`IfNoneMatch` (string)** + - Sets request header: `if-none-match` +- **`IfUnmodifiedSince` (string)** + - Sets request header: `if-unmodified-since` +- **`Range` (string)** + - Sets request header: `range` +- **`SSECustomerAlgorithm` (string)** + - Sets request header: `x-amz-server-side-encryption-customer-algorithm` +- **`SSECustomerKey` (string)** + - Sets request header: `x-amz-server-side-encryption-customer-key` +- **`SSECustomerKeyMD5` (string)** + - Sets request header: `x-amz-server-side-encryption-customer-key-md5` +- **`RequestPayer` (string)** + - Sets request header: `x-amz-request-payer` +- **`ExpectedBucketOwner` (string)** + - Sets request header: `x-amz-expected-bucket-owner` +- **`ChecksumMode` (string)** + - Sets request header: `x-amz-checksum-mode` + + ### `PutObject` [Canonical AWS API doc](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html) @@ -203,7 +240,6 @@ Properties: - `GetObjectTorrent` - `GetPublicAccessBlock` - `HeadBucket` -- `HeadObject` - `ListBucketAnalyticsConfigurations` - `ListBucketIntelligentTieringConfigurations` - `ListBucketInventoryConfigurations` diff --git a/plugins/s3/src/incomplete.mjs b/plugins/s3/src/incomplete.mjs index 3423eed8..9eac35d5 100644 --- a/plugins/s3/src/incomplete.mjs +++ b/plugins/s3/src/incomplete.mjs @@ -1,2 +1,2 @@ const x = false -export default { AbortMultipartUpload: x, CompleteMultipartUpload: x, CopyObject: x, CreateBucket: x, CreateMultipartUpload: x, DeleteBucket: x, DeleteBucketAnalyticsConfiguration: x, DeleteBucketCors: x, DeleteBucketEncryption: x, DeleteBucketIntelligentTieringConfiguration: x, DeleteBucketInventoryConfiguration: x, DeleteBucketLifecycle: x, DeleteBucketMetricsConfiguration: x, DeleteBucketOwnershipControls: x, DeleteBucketPolicy: x, DeleteBucketReplication: x, DeleteBucketTagging: x, DeleteBucketWebsite: x, DeleteObject: x, DeleteObjects: x, DeleteObjectTagging: x, DeletePublicAccessBlock: x, GetBucketAccelerateConfiguration: x, GetBucketAcl: x, GetBucketAnalyticsConfiguration: x, GetBucketCors: x, GetBucketEncryption: x, GetBucketIntelligentTieringConfiguration: x, GetBucketInventoryConfiguration: x, GetBucketLifecycle: x, GetBucketLifecycleConfiguration: x, GetBucketLocation: x, GetBucketLogging: x, GetBucketMetricsConfiguration: x, GetBucketNotification: x, GetBucketNotificationConfiguration: x, GetBucketOwnershipControls: x, GetBucketPolicy: x, GetBucketPolicyStatus: x, GetBucketReplication: x, GetBucketRequestPayment: x, GetBucketTagging: x, GetBucketVersioning: x, GetBucketWebsite: x, GetObjectAcl: x, GetObjectAttributes: x, GetObjectLegalHold: x, GetObjectLockConfiguration: x, GetObjectRetention: x, GetObjectTagging: x, GetObjectTorrent: x, GetPublicAccessBlock: x, HeadBucket: x, HeadObject: x, ListBucketAnalyticsConfigurations: x, ListBucketIntelligentTieringConfigurations: x, ListBucketInventoryConfigurations: x, ListBucketMetricsConfigurations: x, ListBuckets: x, ListMultipartUploads: x, ListObjects: x, ListObjectsV2: x, ListObjectVersions: x, ListParts: x, PutBucketAccelerateConfiguration: x, PutBucketAcl: x, PutBucketAnalyticsConfiguration: x, PutBucketCors: x, PutBucketEncryption: x, PutBucketIntelligentTieringConfiguration: x, PutBucketInventoryConfiguration: x, PutBucketLifecycle: x, PutBucketLifecycleConfiguration: x, PutBucketLogging: x, PutBucketMetricsConfiguration: x, PutBucketNotification: x, PutBucketNotificationConfiguration: x, PutBucketOwnershipControls: x, PutBucketPolicy: x, PutBucketReplication: x, PutBucketRequestPayment: x, PutBucketTagging: x, PutBucketVersioning: x, PutBucketWebsite: x, PutObjectAcl: x, PutObjectLegalHold: x, PutObjectLockConfiguration: x, PutObjectRetention: x, PutObjectTagging: x, PutPublicAccessBlock: x, RestoreObject: x, SelectObjectContent: x, UploadPart: x, UploadPartCopy: x, WriteGetObjectResponse: x } +export default { AbortMultipartUpload: x, CompleteMultipartUpload: x, CopyObject: x, CreateBucket: x, CreateMultipartUpload: x, DeleteBucket: x, DeleteBucketAnalyticsConfiguration: x, DeleteBucketCors: x, DeleteBucketEncryption: x, DeleteBucketIntelligentTieringConfiguration: x, DeleteBucketInventoryConfiguration: x, DeleteBucketLifecycle: x, DeleteBucketMetricsConfiguration: x, DeleteBucketOwnershipControls: x, DeleteBucketPolicy: x, DeleteBucketReplication: x, DeleteBucketTagging: x, DeleteBucketWebsite: x, DeleteObject: x, DeleteObjects: x, DeleteObjectTagging: x, DeletePublicAccessBlock: x, GetBucketAccelerateConfiguration: x, GetBucketAcl: x, GetBucketAnalyticsConfiguration: x, GetBucketCors: x, GetBucketEncryption: x, GetBucketIntelligentTieringConfiguration: x, GetBucketInventoryConfiguration: x, GetBucketLifecycle: x, GetBucketLifecycleConfiguration: x, GetBucketLocation: x, GetBucketLogging: x, GetBucketMetricsConfiguration: x, GetBucketNotification: x, GetBucketNotificationConfiguration: x, GetBucketOwnershipControls: x, GetBucketPolicy: x, GetBucketPolicyStatus: x, GetBucketReplication: x, GetBucketRequestPayment: x, GetBucketTagging: x, GetBucketVersioning: x, GetBucketWebsite: x, GetObjectAcl: x, GetObjectAttributes: x, GetObjectLegalHold: x, GetObjectLockConfiguration: x, GetObjectRetention: x, GetObjectTagging: x, GetObjectTorrent: x, GetPublicAccessBlock: x, HeadBucket: x, ListBucketAnalyticsConfigurations: x, ListBucketIntelligentTieringConfigurations: x, ListBucketInventoryConfigurations: x, ListBucketMetricsConfigurations: x, ListBuckets: x, ListMultipartUploads: x, ListObjects: x, ListObjectsV2: x, ListObjectVersions: x, ListParts: x, PutBucketAccelerateConfiguration: x, PutBucketAcl: x, PutBucketAnalyticsConfiguration: x, PutBucketCors: x, PutBucketEncryption: x, PutBucketIntelligentTieringConfiguration: x, PutBucketInventoryConfiguration: x, PutBucketLifecycle: x, PutBucketLifecycleConfiguration: x, PutBucketLogging: x, PutBucketMetricsConfiguration: x, PutBucketNotification: x, PutBucketNotificationConfiguration: x, PutBucketOwnershipControls: x, PutBucketPolicy: x, PutBucketReplication: x, PutBucketRequestPayment: x, PutBucketTagging: x, PutBucketVersioning: x, PutBucketWebsite: x, PutObjectAcl: x, PutObjectLegalHold: x, PutObjectLockConfiguration: x, PutObjectRetention: x, PutObjectTagging: x, PutPublicAccessBlock: x, RestoreObject: x, SelectObjectContent: x, UploadPart: x, UploadPartCopy: x, WriteGetObjectResponse: x } diff --git a/plugins/s3/src/index.mjs b/plugins/s3/src/index.mjs index 349d0dec..04bb7133 100644 --- a/plugins/s3/src/index.mjs +++ b/plugins/s3/src/index.mjs @@ -1,6 +1,6 @@ import incomplete from './incomplete.mjs' import lib from './lib.mjs' -const { getValidateHeaders, headerMappings } = lib +const { getValidateHeaders, getHeadersFromParams, getQueryFromParams, parseHeadersToResults } = lib import PutObject from './put-object.mjs' const service = 's3' @@ -30,26 +30,13 @@ const GetObject = { }, request: async (params) => { let { Bucket, Key } = params - let ignoreHeaders = [ 'VersionId' ] - let headers = Object.keys(params).reduce((acc, param) => { - if (headerMappings[param] && !ignoreHeaders.includes(param)) { - acc[headerMappings[param]] = params[param] - } - return acc - }, {}) - - let query let queryParams = [ 'PartNumber', 'ResponseCacheControl', 'ResponseContentDisposition', 'ResponseContentEncoding', 'ResponseContentLanguage', 'ResponseContentType', 'ResponseExpires', 'VersionId' ] - queryParams.forEach(p => { - if (params[p]) { - if (!query) query = {} - query[p] = params[p] - } - }) + let headers = getHeadersFromParams(params, queryParams) + let query = getQueryFromParams(params, queryParams) return { - path: `/${Bucket}/${Key}`, + endpoint: `/${Bucket}/${Key}`, headers, query, } @@ -57,5 +44,32 @@ const GetObject = { response: ({ payload }) => payload, } -const methods = { GetObject, PutObject, ...incomplete } +const HeadObject = { + awsDoc: 'https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html', + validate: { + Bucket: { type: 'string', required, comment: 'S3 bucket name' }, + Key: { type: 'string', required, comment: 'S3 key / file name' }, + PartNumber: { type: 'number', comment: 'Part number (between 1 - 10,000) of the object' }, + VersionId: { type: 'string', comment: 'Reference a specific version of the object' }, + // Here come the headers + ...getValidateHeaders('IfMatch', 'IfModifiedSince', 'IfNoneMatch', 'IfUnmodifiedSince', + 'Range', 'SSECustomerAlgorithm', 'SSECustomerKey', 'SSECustomerKeyMD5', 'RequestPayer', + 'ExpectedBucketOwner', 'ChecksumMode'), + }, + request: async (params) => { + let { Bucket, Key } = params + let queryParams = [ 'PartNumber', 'VersionId' ] + let headers = getHeadersFromParams(params, queryParams) + let query = getQueryFromParams(params, queryParams) + return { + endpoint: `/${Bucket}/${Key}`, + method: 'HEAD', + headers, + query, + } + }, + response: parseHeadersToResults, +} + +const methods = { GetObject, HeadObject, PutObject, ...incomplete } export default { service, methods } diff --git a/plugins/s3/src/lib.mjs b/plugins/s3/src/lib.mjs index 98fe6943..9354e696 100644 --- a/plugins/s3/src/lib.mjs +++ b/plugins/s3/src/lib.mjs @@ -6,10 +6,12 @@ const getValidateHeaders = (...headers) => headers.reduce((acc, h) => { }, {}) const comment = header => `Sets request header: \`${header}\`` -// Map common AWS-named params to their respective headers +// Map AWS-named S3 params to their respective headers // The !x-amz headers are documented by AWS as old school pascal-case headers; lowcasing them to be HTTP 2.0 compliant const headerMappings = { + AcceptRanges: 'accept-ranges', ACL: 'x-amz-acl', + ArchiveStatus: 'x-amz-archive-status', BucketKeyEnabled: 'x-amz-server-side-encryption-bucket-key-enabled', CacheControl: 'cache-control', ChecksumAlgorithm: 'x-amz-sdk-checksum-algorithm', @@ -24,6 +26,7 @@ const headerMappings = { ContentLength: 'content-length', ContentMD5: 'content-md5', ContentType: 'content-type', + DeleteMarker: 'x-amz-delete-marker', ETag: 'etag', ExpectedBucketOwner: 'x-amz-expected-bucket-owner', Expiration: 'x-amz-expiration', @@ -36,12 +39,17 @@ const headerMappings = { IfModifiedSince: 'if-modified-since', IfNoneMatch: 'if-none-match', IfUnmodifiedSince: 'if-unmodified-since', + LastModified: 'Last-Modified', + MissingMeta: 'x-amz-missing-meta', ObjectLockLegalHoldStatus: 'x-amz-object-lock-legal-hold', ObjectLockMode: 'x-amz-object-lock-mode', ObjectLockRetainUntilDate: 'x-amz-object-lock-retain-until-date', + PartsCount: 'x-amz-mp-parts-count', Range: 'range', + ReplicationStatus: 'x-amz-replication-status', RequestCharged: 'x-amz-request-charged', RequestPayer: 'x-amz-request-payer', + Restore: 'x-amz-restore', ServerSideEncryption: 'x-amz-server-side-encryption', SSECustomerAlgorithm: 'x-amz-server-side-encryption-customer-algorithm', SSECustomerKey: 'x-amz-server-side-encryption-customer-key', @@ -75,8 +83,31 @@ const parseHeadersToResults = ({ headers }) => { return results } +function getHeadersFromParams (params, ignore = []) { + let headers = Object.keys(params).reduce((acc, param) => { + if (headerMappings[param] && !ignore.includes(param)) { + acc[headerMappings[param]] = params[param] + } + return acc + }, {}) + return headers +} + +function getQueryFromParams (params, queryParams) { + let query + queryParams.forEach(p => { + if (params[p]) { + if (!query) query = {} + query[p] = params[p] + } + }) + return query +} + export default { getValidateHeaders, + getHeadersFromParams, + getQueryFromParams, headerMappings, parseHeadersToResults, } diff --git a/plugins/s3/src/put-object.mjs b/plugins/s3/src/put-object.mjs index 8856cf97..1821a168 100644 --- a/plugins/s3/src/put-object.mjs +++ b/plugins/s3/src/put-object.mjs @@ -3,7 +3,7 @@ import crypto from 'node:crypto' import { readFile, stat } from 'node:fs/promises' import { Readable } from 'node:stream' import lib from './lib.mjs' -const { getValidateHeaders, headerMappings, parseHeadersToResults } = lib +const { getHeadersFromParams, getValidateHeaders, parseHeadersToResults } = lib const required = true const chunkBreak = `\r\n` @@ -40,12 +40,7 @@ const PutObject = { let { credentials, region } = utils MinChunkSize = MinChunkSize || minSize - let headers = Object.keys(params).reduce((acc, param) => { - if (headerMappings[param]) { - acc[headerMappings[param]] = params[param] - } - return acc - }, {}) + let headers = getHeadersFromParams(params) let dataSize try { From 6b7d588aea4519979337f75b16c6cfa5f1a82a2c Mon Sep 17 00:00:00 2001 From: Ryan Block Date: Wed, 4 Oct 2023 11:19:56 -0700 Subject: [PATCH 35/35] Ignore forthcoming `@aws-lite/*-types` packages --- scripts/generate-plugins/index.mjs | 1 + src/client-factory.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/generate-plugins/index.mjs b/scripts/generate-plugins/index.mjs index 1d256f24..91cb0c23 100644 --- a/scripts/generate-plugins/index.mjs +++ b/scripts/generate-plugins/index.mjs @@ -14,6 +14,7 @@ const plugins = [ { name: 'dynamodb', service: 'DynamoDB', maintainers: [ '@architect' ] }, { name: 's3', service: 'S3', maintainers: [ '@architect' ] }, ].sort() + const pluginTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_plugin-tmpl.mjs')).toString() const readmeTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_readme-tmpl.md')).toString() const packageTmpl = readFileSync(join(cwd, 'scripts', 'generate-plugins', '_package-tmpl.json')) diff --git a/src/client-factory.js b/src/client-factory.js index 7041cc97..89a98e85 100644 --- a/src/client-factory.js +++ b/src/client-factory.js @@ -35,7 +35,7 @@ module.exports = async function clientFactory (config, creds, region) { // Find first-party plugins if (mods.includes('@aws-lite')) { let knownPlugins = await readdir(join(nodeModulesDir, '@aws-lite')) - let filtered = knownPlugins.filter(p => !ignored.includes(p)).map(p => `@aws-lite/${p}`) + let filtered = knownPlugins.filter(p => !ignored.includes(p) && !p.endsWith('-types')).map(p => `@aws-lite/${p}`) plugins.push(...filtered) } // Find correctly namespaced 3rd-party plugins