@middy/ssm
Version:
SSM (EC2 Systems Manager) parameters middleware for the middy framework
237 lines (212 loc) • 6.53 kB
JavaScript
// Copyright 2017 - 2026 will Farrell, Luciano Mammino, and Middy contributors.
// SPDX-License-Identifier: MIT
import {
GetParametersByPathCommand,
GetParametersCommand,
SSMClient,
} from "@aws-sdk/client-ssm";
import {
assignSetToContext,
buildSetToContextSpec,
canPrefetch,
catchInvalidSignatureException,
createClient,
createPrefetchClient,
getCache,
jsonSafeParse,
modifyCache,
processCache,
sanitizeKey,
validateOptions,
} from "@middy/util";
const name = "ssm";
const pkg = `@middy/${name}`;
const defaults = {
AwsClient: SSMClient, // Allow for XRay
awsClientOptions: {},
awsClientAssumeRole: undefined,
awsClientCapture: undefined,
fetchData: {}, // { contextKey: fetchKey, contextPrefix: fetchPath/ }
disablePrefetch: false,
cacheKey: pkg,
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false,
awsRequestLimit: 10,
};
const optionSchema = {
type: "object",
properties: {
AwsClient: { instanceof: "Function" },
awsClientOptions: { type: "object" },
awsClientAssumeRole: { type: "string" },
awsClientCapture: { instanceof: "Function" },
fetchData: {
type: "object",
additionalProperties: { type: "string" },
},
disablePrefetch: { type: "boolean" },
cacheKey: { type: "string" },
cacheKeyExpiry: {
type: "object",
additionalProperties: { type: "number", minimum: -1 },
},
cacheExpiry: { type: "number", minimum: -1 },
setToContext: { type: "boolean" },
awsRequestLimit: { type: "integer", minimum: 1, maximum: 10 },
},
additionalProperties: false,
};
export const ssmValidateOptions = (options) =>
validateOptions(pkg, optionSchema, options);
const ssmMiddleware = (opts = {}) => {
const options = { ...defaults, ...opts };
const fetchDataKeys = Object.keys(options.fetchData);
const fetchDataValues = Object.values(options.fetchData);
const contextSpec = buildSetToContextSpec(options);
const fetchRequest = (request, cachedValues) => {
const single = fetchSingleRequest(request, cachedValues);
const path = fetchByPathRequest(request, cachedValues);
return Object.assign(single, path);
};
const fetchSingleRequest = (request, cachedValues = {}) => {
const values = {};
let batchReq = null;
const batchKeys = new Map();
const namedKeys = [];
const internalKeys = fetchDataKeys;
const fetchKeys = fetchDataValues;
for (const internalKey of internalKeys) {
if (cachedValues[internalKey]) continue;
if (options.fetchData[internalKey].endsWith("/")) continue; // Skip path passed in
namedKeys.push(internalKey);
}
for (const [idx, internalKey] of namedKeys.entries()) {
const fetchKey = options.fetchData[internalKey];
batchKeys.set(internalKey, fetchKey);
// from the first to the batch size skip, unless it's the last entry
if (
(!idx || (idx + 1) % options.awsRequestLimit !== 0) &&
!(idx + 1 === namedKeys.length)
) {
continue;
}
const command = new GetParametersCommand({
Names: Array.from(batchKeys.values()),
WithDecryption: true,
});
const currentBatchInternalKeys = Array.from(batchKeys.keys());
batchReq = client
.send(command)
.catch((e) => catchInvalidSignatureException(e, client, command))
.then((resp) => {
// Don't sanitize key, mapped to set value in options
const result = {};
for (const fetchKey of resp.InvalidParameters ?? []) {
const internalKey = internalKeys[fetchKeys.indexOf(fetchKey)];
const value = getCache(options.cacheKey).value ?? {};
value[internalKey] = undefined;
modifyCache(options.cacheKey, value);
result[fetchKey] = Promise.reject(
new Error(`InvalidParameter ${fetchKey}`, {
cause: { package: pkg },
}),
);
}
for (const param of resp.Parameters ?? []) {
result[param.Name] = parseValue(param);
}
return result;
})
.catch((e) => {
const value = getCache(options.cacheKey).value ?? {};
for (const key of currentBatchInternalKeys) {
value[key] = undefined;
}
modifyCache(options.cacheKey, value);
throw e;
});
for (const [internalKey, fetchKey] of batchKeys.entries()) {
values[internalKey] = batchReq.then((params) => {
if (fetchKey.startsWith("arn:aws:ssm:")) {
const matchingParamName = Object.keys(params).find((key) =>
fetchKey.endsWith(`:parameter${key}`),
);
return params[matchingParamName];
}
return params[options.fetchData[internalKey]];
});
}
batchKeys.clear();
batchReq = null;
}
return values;
};
const fetchByPathRequest = (request, cachedValues = {}) => {
const values = {};
for (const internalKey of fetchDataKeys) {
if (cachedValues[internalKey]) continue;
const fetchKey = options.fetchData[internalKey];
if (!fetchKey.endsWith("/")) continue; // Skip not path passed in
values[internalKey] = fetchPathRequest(fetchKey).catch((e) => {
const value = getCache(options.cacheKey).value ?? {};
value[internalKey] = undefined;
modifyCache(options.cacheKey, value);
throw e;
});
}
return values;
};
const fetchPathRequest = (path, nextToken, values = {}) => {
const command = new GetParametersByPathCommand({
Path: path,
NextToken: nextToken,
Recursive: true,
WithDecryption: true,
});
return client
.send(command)
.catch((e) => catchInvalidSignatureException(e, client, command))
.then((resp) => {
for (const param of resp.Parameters ?? []) {
values[sanitizeKey(param.Name.replace(path, ""))] = parseValue(param);
}
if (resp.NextToken) {
return fetchPathRequest(path, resp.NextToken, values);
}
return values;
});
};
const parseValue = (param) => {
if (param.Type === "StringList") {
return param.Value.split(",");
}
return jsonSafeParse(param.Value);
};
let client;
let clientInit;
if (canPrefetch(options)) {
client = createPrefetchClient(options);
processCache(options, fetchRequest);
}
const ssmMiddlewareBefore = async (request) => {
if (!client) {
clientInit ??= createClient(options, request);
client = await clientInit;
}
const { value } = processCache(options, fetchRequest, request);
Object.assign(request.internal, value);
if (contextSpec) {
const pending = assignSetToContext(contextSpec, value, request);
if (pending) await pending;
}
};
return {
before: ssmMiddlewareBefore,
};
};
export default ssmMiddleware;
// used for TS type inference (see index.d.ts)
export function ssmParam(name) {
return name;
}