diff --git a/.changeset/violet-moose-brake.md b/.changeset/violet-moose-brake.md new file mode 100644 index 0000000..ca83968 --- /dev/null +++ b/.changeset/violet-moose-brake.md @@ -0,0 +1,5 @@ +--- +"@effect-aws/ssm": major +--- + +Implement `fromParameterStore` ConfigProvider constructor diff --git a/.projenrc.ts b/.projenrc.ts index 76442d8..0f81e91 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -139,7 +139,7 @@ new TypeScriptLibProject({ peerDeps: commonPeerDeps, }); -new TypeScriptLibProject({ +const ssmClient = new TypeScriptLibProject({ parent: project, name: "client-ssm", deps: [...commonDeps, "@aws-sdk/client-ssm@^3"], @@ -193,8 +193,16 @@ const secretsManager = new TypeScriptLibProject({ peerDeps: [...commonPeerDeps, secretsManagerClient.package.packageName], }); +const ssm = new TypeScriptLibProject({ + parent: project, + name: "ssm", + devDeps: ["@aws-sdk/client-ssm@^3", "@fluffy-spoon/substitute"], + peerDeps: [...commonPeerDeps, ssmClient.package.packageName], +}); + project.addImplicitDependency(dynamodbLib, dynamodbClient); project.addImplicitDependency(secretsManager, secretsManagerClient); +project.addImplicitDependency(ssm, ssmClient); project.addGitIgnore(".direnv/"); // flake environment creates .direnv folder project.addGitIgnore("docs/"); // docs are generated diff --git a/package.json b/package.json index 874c2ce..d6da476 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,8 @@ "packages/lambda", "packages/lib-dynamodb", "packages/powertools-logger", - "packages/secrets-manager" + "packages/secrets-manager", + "packages/ssm" ] }, "//": "~~ Generated by projen. To modify, edit .projenrc.ts and run \"pnpm exec projen\"." diff --git a/packages/ssm/.eslintrc.json b/packages/ssm/.eslintrc.json new file mode 100644 index 0000000..e80ef8f --- /dev/null +++ b/packages/ssm/.eslintrc.json @@ -0,0 +1,126 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "env": { + "jest": true, + "node": true + }, + "root": true, + "plugins": [ + "@typescript-eslint", + "import" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module", + "project": "./tsconfig.dev.json" + }, + "extends": [ + "plugin:import/typescript", + "plugin:prettier/recommended" + ], + "settings": { + "import/parsers": { + "@typescript-eslint/parser": [ + ".ts", + ".tsx" + ] + }, + "import/resolver": { + "node": {}, + "typescript": { + "project": "./tsconfig.dev.json", + "alwaysTryTypes": true + } + } + }, + "ignorePatterns": [ + "*.js", + "*.d.ts", + "node_modules/", + "*.generated.ts", + "coverage" + ], + "rules": { + "@typescript-eslint/no-require-imports": [ + "error" + ], + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": [ + "**/test/**", + "**/build-tools/**" + ], + "optionalDependencies": false, + "peerDependencies": true + } + ], + "import/no-unresolved": [ + "error" + ], + "import/order": [ + "warn", + { + "groups": [ + "builtin", + "external" + ], + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ], + "import/no-duplicates": [ + "error" + ], + "no-shadow": [ + "off" + ], + "@typescript-eslint/no-shadow": [ + "error" + ], + "key-spacing": [ + "error" + ], + "no-multiple-empty-lines": [ + "error" + ], + "@typescript-eslint/no-floating-promises": [ + "error" + ], + "no-return-await": [ + "off" + ], + "@typescript-eslint/return-await": [ + "error" + ], + "no-trailing-spaces": [ + "error" + ], + "dot-notation": [ + "error" + ], + "no-bitwise": [ + "error" + ], + "@typescript-eslint/member-ordering": [ + "error", + { + "default": [ + "public-static-field", + "public-static-method", + "protected-static-field", + "protected-static-method", + "private-static-field", + "private-static-method", + "field", + "constructor", + "method" + ] + } + ] + }, + "overrides": [] +} diff --git a/packages/ssm/.gitattributes b/packages/ssm/.gitattributes new file mode 100644 index 0000000..1c2be5a --- /dev/null +++ b/packages/ssm/.gitattributes @@ -0,0 +1,21 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +/./tsconfig.esm.json linguist-generated +/.eslintrc.json linguist-generated +/.gitattributes linguist-generated +/.gitignore linguist-generated +/.npmignore linguist-generated +/.npmrc linguist-generated +/.prettierignore linguist-generated +/.prettierrc.json linguist-generated +/.projen/** linguist-generated +/.projen/deps.json linguist-generated +/.projen/files.json linguist-generated +/.projen/tasks.json linguist-generated +/docgen.json linguist-generated +/LICENSE linguist-generated +/package.json linguist-generated +/pnpm-lock.yaml linguist-generated +/project.json linguist-generated +/tsconfig.dev.json linguist-generated +/tsconfig.json linguist-generated \ No newline at end of file diff --git a/packages/ssm/.gitignore b/packages/ssm/.gitignore new file mode 100644 index 0000000..e4fdd83 --- /dev/null +++ b/packages/ssm/.gitignore @@ -0,0 +1,45 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +!/.gitattributes +!/.projen/tasks.json +!/.projen/deps.json +!/.projen/files.json +!/package.json +!/LICENSE +!/.npmignore +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +pids +*.pid +*.seed +*.pid.lock +lib-cov +coverage +*.lcov +.nyc_output +build/Release +node_modules/ +jspm_packages/ +*.tsbuildinfo +.eslintcache +*.tgz +.yarn-integrity +.cache +!/.prettierignore +!/.prettierrc.json +!/.npmrc +!/test/ +!/tsconfig.json +!/tsconfig.dev.json +!/src/ +/lib +/dist/ +!/.eslintrc.json +!/tsconfig.esm.json +!/project.json +!/docgen.json +docs/ diff --git a/packages/ssm/.npmignore b/packages/ssm/.npmignore new file mode 100644 index 0000000..277d62e --- /dev/null +++ b/packages/ssm/.npmignore @@ -0,0 +1,20 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +/.projen/ +/.prettierignore +/.prettierrc.json +/test/ +/tsconfig.dev.json +/src/ +!/lib/ +!/lib/**/*.js +!/lib/**/*.d.ts +dist +/tsconfig.json +/.github/ +/.vscode/ +/.idea/ +/.projenrc.js +tsconfig.tsbuildinfo +/.eslintrc.json +/tsconfig.esm.json +/.gitattributes diff --git a/packages/ssm/.prettierignore b/packages/ssm/.prettierignore new file mode 100644 index 0000000..46704c7 --- /dev/null +++ b/packages/ssm/.prettierignore @@ -0,0 +1 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". diff --git a/packages/ssm/.prettierrc.json b/packages/ssm/.prettierrc.json new file mode 100644 index 0000000..84c85a3 --- /dev/null +++ b/packages/ssm/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "overrides": [] +} diff --git a/packages/ssm/.projen/deps.json b/packages/ssm/.projen/deps.json new file mode 100644 index 0000000..837afdb --- /dev/null +++ b/packages/ssm/.projen/deps.json @@ -0,0 +1,72 @@ +{ + "dependencies": [ + { + "name": "@aws-sdk/client-ssm", + "version": "^3", + "type": "build" + }, + { + "name": "@fluffy-spoon/substitute", + "type": "build" + }, + { + "name": "@types/node", + "version": "^18", + "type": "build" + }, + { + "name": "@typescript-eslint/eslint-plugin", + "version": "^7", + "type": "build" + }, + { + "name": "@typescript-eslint/parser", + "version": "^7", + "type": "build" + }, + { + "name": "eslint-config-prettier", + "type": "build" + }, + { + "name": "eslint-import-resolver-typescript", + "type": "build" + }, + { + "name": "eslint-plugin-import", + "type": "build" + }, + { + "name": "eslint-plugin-prettier", + "type": "build" + }, + { + "name": "eslint", + "version": "^8", + "type": "build" + }, + { + "name": "prettier", + "type": "build" + }, + { + "name": "typescript", + "version": "^5.4.2", + "type": "build" + }, + { + "name": "vitest", + "type": "build" + }, + { + "name": "@effect-aws/client-ssm", + "type": "peer" + }, + { + "name": "effect", + "version": ">=3.0.0 <4.0.0", + "type": "peer" + } + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/ssm/.projen/files.json b/packages/ssm/.projen/files.json new file mode 100644 index 0000000..c38792b --- /dev/null +++ b/packages/ssm/.projen/files.json @@ -0,0 +1,20 @@ +{ + "files": [ + ".eslintrc.json", + ".gitattributes", + ".gitignore", + ".npmignore", + ".prettierignore", + ".prettierrc.json", + ".projen/deps.json", + ".projen/files.json", + ".projen/tasks.json", + "docgen.json", + "LICENSE", + "project.json", + "tsconfig.dev.json", + "tsconfig.esm.json", + "tsconfig.json" + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/ssm/.projen/tasks.json b/packages/ssm/.projen/tasks.json new file mode 100644 index 0000000..6ecb00a --- /dev/null +++ b/packages/ssm/.projen/tasks.json @@ -0,0 +1,113 @@ +{ + "tasks": { + "build": { + "name": "build", + "description": "Full release build", + "steps": [ + { + "spawn": "pre-compile" + }, + { + "spawn": "compile" + }, + { + "spawn": "post-compile" + }, + { + "spawn": "test" + }, + { + "spawn": "package" + } + ] + }, + "compile": { + "name": "compile", + "description": "Only compile", + "steps": [ + { + "exec": "tsc -b ./tsconfig.json ./tsconfig.esm.json" + } + ] + }, + "default": { + "name": "default", + "description": "Synthesize project files" + }, + "eslint": { + "name": "eslint", + "description": "Runs eslint against the codebase", + "steps": [ + { + "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern $@ src test build-tools", + "receiveArgs": true + } + ] + }, + "install": { + "name": "install", + "description": "Install project dependencies and update lockfile (non-frozen)", + "steps": [ + { + "exec": "pnpm i --no-frozen-lockfile" + } + ] + }, + "install:ci": { + "name": "install:ci", + "description": "Install project dependencies using frozen lockfile", + "steps": [ + { + "exec": "pnpm i --frozen-lockfile" + } + ] + }, + "package": { + "name": "package", + "description": "Creates the distribution package" + }, + "post-compile": { + "name": "post-compile", + "description": "Runs after successful compilation" + }, + "pre-compile": { + "name": "pre-compile", + "description": "Prepare the project for compilation" + }, + "test": { + "name": "test", + "description": "Run tests", + "steps": [ + { + "exec": "vitest run --globals --reporter verbose", + "receiveArgs": true + }, + { + "spawn": "eslint" + } + ] + }, + "test:watch": { + "name": "test:watch", + "description": "Run tests in watch mode", + "steps": [ + { + "exec": "vitest --globals --reporter verbose" + } + ] + }, + "watch": { + "name": "watch", + "description": "Watch & compile in the background", + "steps": [ + { + "exec": "tsc --build -w" + } + ] + } + }, + "env": { + "PATH": "$(pnpm -c exec \"node --print process.env.PATH\")" + }, + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/ssm/LICENSE b/packages/ssm/LICENSE new file mode 100644 index 0000000..7afce46 --- /dev/null +++ b/packages/ssm/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Victor Korzunin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ssm/README.md b/packages/ssm/README.md new file mode 100644 index 0000000..a71dd2e --- /dev/null +++ b/packages/ssm/README.md @@ -0,0 +1,60 @@ +# @effect-aws/ssm + +This package provides a `fromParameterStore` [ConfigProvider](https://effect.website/docs/guides/configuration) constructor function. +You can use it to read parameters from AWS Systems Manager Parameter Store as a `Config` schema. + +The config primitive name must match the parameter Name or ARN in Parameter Store. +For example, `Config.string("my_parameter_name")` or `Config.string("arn:aws:ssm:eu-central-1:123456789012:parameter/my_parameter_name")`. + +## Installation + +```bash +npm install --save @effect-aws/ssm @effect-aws/client-ssm +``` + +## Usage + +With default service layer: + +```typescript +import { Effect, Config, Console } from "effect"; +import { fromParameterStore } from "@effect-aws/ssm"; + +const program = Effect.gen(function* () { + const param: string = yield* Config.string("my_parameter_name"); + + yield* Console.log("Parameter from Parameter Store: ", param); +}); + +program.pipe( + Effect.provide(Layer.setConfigProvider(fromParameterStore())), + Effect.runPromise, +); +``` + +With custom service layer: + +```typescript +import { Effect, Config, Console, Layer } from "effect"; +import { SSMClient } from "@aws-sdk/client-ssm"; +import { BaseSSMServiceLayer, SSMClientInstance } from "@effect-aws/client-ssm"; +import { fromParameterStore } from "@effect-aws/ssm"; + +const program = Effect.gen(function* () { + const param: string = yield* Config.string("my_parameter_name"); + + yield* Console.log("Parameter from Parameter Store: ", param); +}); + +const SSMClientInstanceLayer = Layer.succeed( + SSMClientInstance, + new SSMClient({ region: "eu-central-1" }), +); + +const serviceLayer = Layer.provide(BaseSSMServiceLayer, SSMClientInstanceLayer); + +program.pipe( + Effect.withConfigProvider(fromParameterStore({ serviceLayer })), + Effect.runPromise, +); +``` diff --git a/packages/ssm/docgen.json b/packages/ssm/docgen.json new file mode 100644 index 0000000..b359fc7 --- /dev/null +++ b/packages/ssm/docgen.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/@effect/docgen/schema.json", + "exclude": [ + "src/index.ts", + "src/Errors.ts" + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/ssm/package.json b/packages/ssm/package.json new file mode 100644 index 0000000..51103ae --- /dev/null +++ b/packages/ssm/package.json @@ -0,0 +1,53 @@ +{ + "name": "@effect-aws/ssm", + "scripts": { + "build": "npx projen build", + "compile": "npx projen compile", + "default": "npx projen default", + "eslint": "npx projen eslint", + "package": "npx projen package", + "post-compile": "npx projen post-compile", + "pre-compile": "npx projen pre-compile", + "test": "npx projen test", + "test:watch": "npx projen test:watch", + "watch": "npx projen watch", + "docgen": "docgen" + }, + "author": { + "name": "Victor Korzunin", + "email": "ifloydrose@gmail.com", + "organization": false + }, + "devDependencies": { + "@aws-sdk/client-ssm": "^3", + "@effect-aws/client-ssm": "0.0.0", + "@fluffy-spoon/substitute": "^1.208.0", + "@types/node": "^18", + "@typescript-eslint/eslint-plugin": "^7", + "@typescript-eslint/parser": "^7", + "effect": "3.0.0", + "eslint": "^8", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.2.5", + "typescript": "^5.4.2", + "vitest": "^2.0.5" + }, + "peerDependencies": { + "@effect-aws/client-ssm": "^0.0.0", + "effect": ">=3.0.0 <4.0.0" + }, + "main": "lib/index.js", + "license": "MIT", + "homepage": "https://floydspace.github.io/effect-aws", + "publishConfig": { + "access": "public" + }, + "version": "0.0.0", + "types": "lib/index.d.ts", + "module": "lib/esm/index.js", + "sideEffects": [], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/ssm/project.json b/packages/ssm/project.json new file mode 100644 index 0000000..00714a7 --- /dev/null +++ b/packages/ssm/project.json @@ -0,0 +1,80 @@ +{ + "name": "@effect-aws/ssm", + "root": "packages/ssm", + "targets": { + "default": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen default", + "cwd": "packages/ssm" + } + }, + "pre-compile": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen pre-compile", + "cwd": "packages/ssm" + } + }, + "compile": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen compile", + "cwd": "packages/ssm" + } + }, + "post-compile": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen post-compile", + "cwd": "packages/ssm" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen test", + "cwd": "packages/ssm" + } + }, + "package": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen package", + "cwd": "packages/ssm" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen build", + "cwd": "packages/ssm" + } + }, + "watch": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen watch", + "cwd": "packages/ssm" + } + }, + "eslint": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen eslint", + "cwd": "packages/ssm" + } + }, + "test:watch": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm exec projen test:watch", + "cwd": "packages/ssm" + } + } + }, + "implicitDependencies": [ + "@effect-aws/client-ssm" + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/ssm/src/ConfigProvider.ts b/packages/ssm/src/ConfigProvider.ts new file mode 100644 index 0000000..aae9f50 --- /dev/null +++ b/packages/ssm/src/ConfigProvider.ts @@ -0,0 +1,139 @@ +/** + * @since 1.0.0 + */ +import { DefaultSSMServiceLayer, SSMService } from "@effect-aws/client-ssm"; +import { + Array, + Cause, + Config, + ConfigError, + ConfigProvider, + ConfigProviderPathPatch, + Effect, + HashSet, + Layer, + Option, + pipe, +} from "effect"; + +/** + * A config provider that loads configuration from AWS Systems Manager Parameter Store. + * + * @since 1.0.0 + * @category constructors + */ +export const fromParameterStore = (config?: { + pathDelim?: string; + serviceLayer?: Layer.Layer; +}): ConfigProvider.ConfigProvider => { + const { pathDelim, serviceLayer } = Object.assign( + {}, + { pathDelim: "_", serviceLayer: DefaultSSMServiceLayer }, + config, + ); + const makePathString = (path: ReadonlyArray): string => + pipe(path, Array.join(pathDelim)); + const unmakePathString = (pathString: string): ReadonlyArray => + pathString.split(pathDelim); + + const load = ( + path: ReadonlyArray, + primitive: Config.Config.Primitive, + ): Effect.Effect, ConfigError.ConfigError> => { + const pathString = makePathString(path); + return SSMService.getParameter({ Name: pathString }).pipe( + Effect.flatMap((value) => Option.fromNullable(value.Parameter?.Value)), + Effect.catchTag("ParameterNotFound", () => + Effect.fail( + ConfigError.MissingData( + path as Array, + `Expected ${pathString} parameter to exist in AWS Systems Manager Parameter Store`, + ), + ), + ), + Effect.catchTag("ParameterVersionNotFound", () => + Effect.fail( + ConfigError.MissingData( + path as Array, + `Expected ${pathString} parameter version to exist in AWS Systems Manager Parameter Store`, + ), + ), + ), + Effect.catchTag("NoSuchElementException", () => + Effect.fail( + ConfigError.MissingData( + path as Array, + `Expected ${pathString} to exist in AWS Systems Manager Parameter Store`, + ), + ), + ), + Effect.catchTag("InvalidKeyId", () => + Effect.fail( + ConfigError.InvalidData( + path as Array, + "Invalid key ID when retrieving configuration from AWS Systems Manager Parameter Store", + ), + ), + ), + Effect.catchAllCause((cause) => + Cause.isFailType(cause) && ConfigError.isConfigError(cause.error) + ? Effect.fail(cause.error) + : Effect.fail( + ConfigError.SourceUnavailable( + path as Array, + "Failed to load configuration from AWS Systems Manager Parameter Store", + cause, + ), + ), + ), + Effect.flatMap((value) => + pipe( + primitive.parse(value), + Effect.mapBoth({ + onFailure: ConfigError.prefixed(path as Array), + onSuccess: Array.of, + }), + ), + ), + Effect.provide(serviceLayer), + ); + }; + + const enumerateChildren = ( + path: ReadonlyArray, + ): Effect.Effect, ConfigError.ConfigError> => + SSMService.describeParameters({}).pipe( + Effect.flatMap((params) => Option.fromNullable(params.Parameters)), + Effect.map(Array.map((param) => Option.fromNullable(param.Name))), + Effect.flatMap(Option.all), + Effect.orDie, + Effect.map((keys) => { + const keyPaths = keys.map(unmakePathString); + const filteredKeyPaths = keyPaths + .filter((keyPath) => { + for (let i = 0; i < path.length; i++) { + const pathComponent = pipe(path, Array.unsafeGet(i)); + const currentElement = keyPath[i]; + if ( + currentElement === undefined || + pathComponent !== currentElement + ) { + return false; + } + } + return true; + }) + .flatMap((keyPath) => keyPath.slice(path.length, path.length + 1)); + return HashSet.fromIterable(filteredKeyPaths); + }), + Effect.provide(serviceLayer), + ); + + return ConfigProvider.fromFlat( + ConfigProvider.makeFlat({ + load, + enumerateChildren, + patch: ConfigProviderPathPatch.empty, + }), + ); +}; diff --git a/packages/ssm/src/index.ts b/packages/ssm/src/index.ts new file mode 100644 index 0000000..6db5f05 --- /dev/null +++ b/packages/ssm/src/index.ts @@ -0,0 +1 @@ +export * from "./ConfigProvider"; diff --git a/packages/ssm/test/ConfigProvider.test.ts b/packages/ssm/test/ConfigProvider.test.ts new file mode 100644 index 0000000..2f569c9 --- /dev/null +++ b/packages/ssm/test/ConfigProvider.test.ts @@ -0,0 +1,165 @@ +import { InvalidKeyId, ParameterNotFound } from "@aws-sdk/client-ssm"; +import { BaseSSMServiceLayer, SSMClientInstance } from "@effect-aws/client-ssm"; +import { Arg } from "@fluffy-spoon/substitute"; +import { Config, ConfigError, Effect, Exit, Layer, Secret } from "effect"; +import { describe, expect, it } from "vitest"; +import { SubstituteBuilder } from "./utils"; +import { fromParameterStore } from "../src/ConfigProvider"; + +describe("fromParameterStore", () => { + it("should load configuration from AWS Systems Manager Parameter Store", async () => { + const clientSubstitute = SubstituteBuilder.forSSM() + .mockGetParameter() + .withParameterValue("mocked-parameter") + .succeeds(); + + const clientInstanceLayer = Layer.succeed( + SSMClientInstance, + clientSubstitute, + ); + const serviceLayer = Layer.provide( + BaseSSMServiceLayer, + clientInstanceLayer, + ); + + const result = await Config.string("test").pipe( + Effect.withConfigProvider(fromParameterStore({ serviceLayer })), + Effect.runPromiseExit, + ); + + expect(result).toEqual(Exit.succeed("mocked-parameter")); + clientSubstitute.received(1).send(Arg.any(), {}); + }); + + it("should load default value if the parameter does not exist", async () => { + const clientSubstitute = SubstituteBuilder.forSSM() + .mockGetParameter() + .failsWith( + new ParameterNotFound({ + $metadata: {}, + message: "mocked-error", + }), + ); + + const clientInstanceLayer = Layer.succeed( + SSMClientInstance, + clientSubstitute, + ); + const serviceLayer = Layer.provide( + BaseSSMServiceLayer, + clientInstanceLayer, + ); + + const result = await Config.secret("my-param-that-doesnt-exist").pipe( + Config.withDefault(Secret.fromString("mocked-default-value")), + Effect.withConfigProvider(fromParameterStore({ serviceLayer })), + Effect.map(Secret.value), + Effect.runPromiseExit, + ); + + expect(result).toEqual(Exit.succeed("mocked-default-value")); + clientSubstitute.received(1).send(Arg.any(), {}); + }); + + it("should fail if request is invalid", async () => { + const clientSubstitute = SubstituteBuilder.forSSM() + .mockGetParameter() + .failsWith( + new InvalidKeyId({ + $metadata: {}, + message: "mocked-error", + }), + ); + + const clientInstanceLayer = Layer.succeed( + SSMClientInstance, + clientSubstitute, + ); + const serviceLayer = Layer.provide( + BaseSSMServiceLayer, + clientInstanceLayer, + ); + + const result = await Config.secret("test").pipe( + Config.withDefault(Secret.fromString("mocked-default-value")), + Effect.withConfigProvider(fromParameterStore({ serviceLayer })), + Effect.map(Secret.value), + Effect.runPromiseExit, + ); + + expect(result).toEqual( + Exit.fail( + ConfigError.InvalidData( + ["test"], + "Invalid key ID when retrieving configuration from AWS Systems Manager Parameter Store", + ), + ), + ); + clientSubstitute.received(1).send(Arg.any(), {}); + }); + + it("should fail if the parameter does not exist", async () => { + const clientSubstitute = SubstituteBuilder.forSSM() + .mockGetParameter() + .failsWith( + new ParameterNotFound({ + $metadata: {}, + message: "mocked-error", + }), + ); + + const clientInstanceLayer = Layer.succeed( + SSMClientInstance, + clientSubstitute, + ); + const serviceLayer = Layer.provide( + BaseSSMServiceLayer, + clientInstanceLayer, + ); + + const result = await Config.string("test").pipe( + Effect.withConfigProvider(fromParameterStore({ serviceLayer })), + Effect.runPromiseExit, + ); + + expect(result).toEqual( + Exit.fail( + ConfigError.MissingData( + ["test"], + "Expected test parameter to exist in AWS Systems Manager Parameter Store", + ), + ), + ); + clientSubstitute.received(1).send(Arg.any(), {}); + }); + + it("should fail if the parameter is empty", async () => { + const clientSubstitute = SubstituteBuilder.forSSM() + .mockGetParameter() + .succeeds(); + + const clientInstanceLayer = Layer.succeed( + SSMClientInstance, + clientSubstitute, + ); + const serviceLayer = Layer.provide( + BaseSSMServiceLayer, + clientInstanceLayer, + ); + + const result = await Config.string("test").pipe( + Effect.withConfigProvider(fromParameterStore({ serviceLayer })), + Effect.runPromiseExit, + ); + + expect(result).toEqual( + Exit.fail( + ConfigError.MissingData( + ["test"], + "Expected test to exist in AWS Systems Manager Parameter Store", + ), + ), + ); + clientSubstitute.received(1).send(Arg.any(), {}); + }); +}); diff --git a/packages/ssm/test/utils/index.ts b/packages/ssm/test/utils/index.ts new file mode 100644 index 0000000..eaaaa6f --- /dev/null +++ b/packages/ssm/test/utils/index.ts @@ -0,0 +1 @@ +export * from "./substituteBuilder"; diff --git a/packages/ssm/test/utils/substituteBuilder.ts b/packages/ssm/test/utils/substituteBuilder.ts new file mode 100644 index 0000000..f3e4dbe --- /dev/null +++ b/packages/ssm/test/utils/substituteBuilder.ts @@ -0,0 +1,48 @@ +import { Parameter, SSMClient } from "@aws-sdk/client-ssm"; +import Substitute, { Arg, SubstituteOf } from "@fluffy-spoon/substitute"; + +export class SubstituteBuilder { + static forSSM(): SSMSubstituteBuilder { + return new SSMSubstituteBuilder(); + } +} + +class SSMSubstituteBuilder { + public substitute: SubstituteOf; + + constructor() { + this.substitute = Substitute.for(); + } + + mockGetParameter() { + return new GetParameterSubstituteBuilder(this.substitute); + } +} + +class GetParameterSubstituteBuilder { + private paramValue?: string; + + constructor(private readonly substitute: SubstituteOf) {} + + withParameterValue(value: string) { + this.paramValue = value; + return this; + } + + failsWith(error: Error) { + this.substitute.send(Arg.all()).rejects(error); + return this.substitute; + } + + succeeds() { + const substituteResponse = Substitute.for(); + substituteResponse.Value?.returns?.(this.paramValue); + + this.substitute.send(Arg.all()).resolves({ + $metadata: {}, + Parameter: substituteResponse, + }); + + return this.substitute; + } +} diff --git a/packages/ssm/tsconfig.dev.json b/packages/ssm/tsconfig.dev.json new file mode 100644 index 0000000..278f027 --- /dev/null +++ b/packages/ssm/tsconfig.dev.json @@ -0,0 +1,37 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "compilerOptions": { + "alwaysStrict": true, + "declaration": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "inlineSourceMap": true, + "inlineSources": true, + "lib": [ + "es2019", + "dom" + ], + "module": "CommonJS", + "noEmitOnError": false, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "stripInternal": true, + "target": "ES2019", + "moduleResolution": "node" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/ssm/tsconfig.esm.json b/packages/ssm/tsconfig.esm.json new file mode 100644 index 0000000..1b04668 --- /dev/null +++ b/packages/ssm/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./lib/esm", + "module": "es6", + "resolveJsonModule": false, + "declaration": false + }, + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/ssm/tsconfig.json b/packages/ssm/tsconfig.json new file mode 100644 index 0000000..576047c --- /dev/null +++ b/packages/ssm/tsconfig.json @@ -0,0 +1,36 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "alwaysStrict": true, + "declaration": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "inlineSourceMap": true, + "inlineSources": true, + "lib": [ + "es2019", + "dom" + ], + "module": "CommonJS", + "noEmitOnError": false, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "stripInternal": true, + "target": "ES2019", + "moduleResolution": "node" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c34bc6c..344b32c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1020,6 +1020,54 @@ importers: specifier: ^2.0.5 version: 2.0.5(@types/node@18.19.14) + packages/ssm: + devDependencies: + '@aws-sdk/client-ssm': + specifier: ^3 + version: 3.645.0 + '@effect-aws/client-ssm': + specifier: 0.0.0 + version: link:../client-ssm + '@fluffy-spoon/substitute': + specifier: ^1.208.0 + version: 1.208.0 + '@types/node': + specifier: ^18 + version: 18.19.14 + '@typescript-eslint/eslint-plugin': + specifier: ^7 + version: 7.18.0(@typescript-eslint/parser@7.18.0)(eslint@8.57.0)(typescript@5.4.5) + '@typescript-eslint/parser': + specifier: ^7 + version: 7.18.0(eslint@8.57.0)(typescript@5.4.5) + effect: + specifier: 3.0.0 + version: 3.0.0 + eslint: + specifier: ^8 + version: 8.57.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.0) + eslint-import-resolver-typescript: + specifier: ^3.6.1 + version: 3.6.1(@typescript-eslint/parser@7.18.0)(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-plugin-import: + specifier: ^2.29.1 + version: 2.29.1(@typescript-eslint/parser@7.18.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.2.1(eslint-config-prettier@9.1.0)(eslint@8.57.0)(prettier@3.2.5) + prettier: + specifier: ^3.2.5 + version: 3.2.5 + typescript: + specifier: ^5.4.2 + version: 5.4.5 + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@18.19.14) + packages: /@ampproject/remapping@2.3.0: @@ -1840,7 +1888,6 @@ packages: uuid: 9.0.1 transitivePeerDependencies: - aws-crt - dev: false /@aws-sdk/client-sso-oidc@3.645.0(@aws-sdk/client-sts@3.645.0): resolution: {integrity: sha512-X9ULtdk3cO+1ysurEkJ1MSnu6U00qodXx+IVual+1jXX4RYY1WmQmfo7uDKf6FFkz7wW1DAqU+GJIBNQr0YH8A==} @@ -3881,7 +3928,6 @@ packages: '@smithy/abort-controller': 3.1.1 '@smithy/types': 3.3.0 tslib: 2.6.2 - dev: false /@tsconfig/node10@1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c5ba59..a4c537a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,3 +19,4 @@ packages: - packages/lib-dynamodb - packages/powertools-logger - packages/secrets-manager + - packages/ssm