├── injector ├── example │ ├── source-map-install.js │ ├── .gitignore │ ├── src │ │ ├── company-repository.ts │ │ ├── company-service.ts │ │ └── handlers.ts │ ├── tsconfig.json │ ├── serverless.yml │ ├── webpack.config.js │ └── package.json ├── tsup.config.ts ├── tsconfig.json ├── jest.config.js ├── README.md ├── package.json └── lib │ ├── injector.ts │ └── injector.test.ts ├── docs ├── sailplane.png ├── elasticsearch_client.md ├── expiring_value.md ├── state_storage.md ├── aws_https.md ├── logger.md ├── lambda_utils.md ├── examples.md ├── license.md └── injector.md ├── .prettierignore ├── state-storage ├── lib │ ├── index.ts │ ├── state-storage-fake.ts │ ├── state-storage.ts │ └── state-storage.test.ts ├── tsup.config.ts ├── tsconfig.json ├── jest.config.js ├── README.md └── package.json ├── logger ├── lib │ ├── index.ts │ ├── structured-formatter.ts │ ├── flat-formatter.ts │ ├── context.ts │ ├── json-stringify.ts │ ├── common.ts │ └── logger.ts ├── tsup.config.ts ├── tsconfig.json ├── jest.config.js ├── README.md └── package.json ├── lambda-utils ├── lib │ ├── index.ts │ ├── logger-context.ts │ ├── resolved-promise-is-success.ts │ ├── unhandled-exception.ts │ ├── types.ts │ ├── handler-utils.ts │ └── handler-utils.test.ts ├── tsup.config.ts ├── tsconfig.json ├── jest.config.js ├── package.json └── README.md ├── aws-https ├── tsup.config.ts ├── tsconfig.json ├── jest.config.js ├── README.md ├── package.json └── lib │ ├── aws-https-with-aws.test.ts │ ├── aws-https-no-aws.test.ts │ └── aws-https.ts ├── .editorconfig ├── expiring-value ├── tsup.config.ts ├── tsconfig.json ├── jest.config.js ├── package.json ├── README.md └── lib │ ├── expiring-value.ts │ └── expiring-value.test.ts ├── elasticsearch-client ├── tsup.config.ts ├── tsconfig.json ├── jest.config.js ├── README.md ├── package.json └── lib │ ├── elasticsearch-client.ts │ └── elasticsearch-client.test.ts ├── .gitignore ├── .github └── workflows │ └── pull-request.yml ├── eslint.config.mjs ├── package.json └── README.md /injector/example/source-map-install.js: -------------------------------------------------------------------------------- 1 | require("source-map-support").install(); 2 | -------------------------------------------------------------------------------- /docs/sailplane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rackspace/sailplane/master/docs/sailplane.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .serverless 3 | .vscode 4 | coverage 5 | dist 6 | node_modules 7 | -------------------------------------------------------------------------------- /state-storage/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./state-storage"; 2 | export * from "./state-storage-fake"; 3 | -------------------------------------------------------------------------------- /logger/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./common"; 2 | export * from "./flat-formatter"; 3 | export * from "./logger"; 4 | export * from "./structured-formatter"; 5 | -------------------------------------------------------------------------------- /lambda-utils/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./handler-utils"; 2 | export * from "./logger-context"; 3 | export * from "./resolved-promise-is-success"; 4 | export * from "./types"; 5 | export * from "./unhandled-exception"; 6 | -------------------------------------------------------------------------------- /logger/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["lib/index.ts"], 5 | clean: true, 6 | sourcemap: true, 7 | format: ["cjs", "esm"], 8 | dts: true, 9 | }); 10 | -------------------------------------------------------------------------------- /lambda-utils/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["lib/index.ts"], 5 | clean: true, 6 | sourcemap: true, 7 | format: ["cjs", "esm"], 8 | dts: true, 9 | }); 10 | -------------------------------------------------------------------------------- /state-storage/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: ["lib/index.ts"], 5 | clean: true, 6 | sourcemap: true, 7 | format: ["cjs", "esm"], 8 | dts: true, 9 | }); 10 | -------------------------------------------------------------------------------- /injector/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { index: "lib/injector.ts" }, 5 | clean: true, 6 | sourcemap: true, 7 | format: ["cjs", "esm"], 8 | dts: true, 9 | }); 10 | -------------------------------------------------------------------------------- /aws-https/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { index: "lib/aws-https.ts" }, 5 | clean: true, 6 | sourcemap: true, 7 | format: ["cjs", "esm"], 8 | dts: true, 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /expiring-value/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { index: "lib/expiring-value.ts" }, 5 | clean: true, 6 | sourcemap: true, 7 | format: ["cjs", "esm"], 8 | dts: true, 9 | }); 10 | -------------------------------------------------------------------------------- /injector/example/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | package-lock.json 4 | jspm_packages 5 | 6 | # Serverless directories 7 | .serverless 8 | .serverless_plugins 9 | 10 | # Webpack directories 11 | .webpack 12 | 13 | # IDEs 14 | .idea 15 | -------------------------------------------------------------------------------- /elasticsearch-client/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { index: "lib/elasticsearch-client.ts" }, 5 | clean: true, 6 | sourcemap: true, 7 | format: ["cjs", "esm"], 8 | dts: true, 9 | }); 10 | -------------------------------------------------------------------------------- /injector/example/src/company-repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Injector } from "@sailplane/injector"; 2 | 3 | @Injectable() 4 | export class CompanyRepository { 5 | fetchAllCompanies(): Promise { 6 | return Promise.resolve([{ name: "Company name" }]); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # IDEs and editors 4 | .idea 5 | .project 6 | .classpath 7 | *.launch 8 | .settings/ 9 | *.sublime-workspace 10 | 11 | # System Files 12 | .DS_Store 13 | Thumbs.db 14 | 15 | # Project specific 16 | node_modules 17 | npm-debug.log 18 | coverage 19 | dist 20 | -------------------------------------------------------------------------------- /aws-https/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib"], 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "target": "es2020", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist", 13 | "rootDir": "lib" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /injector/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib"], 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "target": "es2020", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist", 13 | "rootDir": "lib" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /logger/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib"], 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "target": "es2020", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist", 13 | "rootDir": "lib" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /expiring-value/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib"], 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "target": "es2020", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist", 13 | "rootDir": "lib" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lambda-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib"], 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "target": "es2020", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist", 13 | "rootDir": "lib" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /state-storage/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib"], 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "target": "es2020", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist", 13 | "rootDir": "lib" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /elasticsearch-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib"], 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "target": "es2020", 6 | "lib": ["es2022"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "outDir": "dist", 13 | "rootDir": "lib" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /injector/example/src/company-service.ts: -------------------------------------------------------------------------------- 1 | import { CompanyRepository } from "./company-repository"; 2 | import { Injectable, Injector } from "@sailplane/injector"; 3 | 4 | @Injectable() 5 | export class CompanyService { 6 | constructor(private readonly companyRepo: CompanyRepository) {} 7 | 8 | listCompanies(): Promise { 9 | return this.companyRepo.fetchAllCompanies(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /injector/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | testEnvironment: "node", 4 | transform: { 5 | "^.+.ts$": ["ts-jest", {}], 6 | }, 7 | 8 | // Coverage 9 | collectCoverage: true, 10 | coverageThreshold: { 11 | "./lib": { 12 | branches: 95, 13 | functions: 90, 14 | statements: 95, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /logger/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | testEnvironment: "node", 4 | transform: { 5 | "^.+.ts$": ["ts-jest", {}], 6 | }, 7 | 8 | // Coverage 9 | collectCoverage: true, 10 | coverageThreshold: { 11 | "./lib": { 12 | branches: 90, 13 | functions: 90, 14 | statements: 90, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /aws-https/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | testEnvironment: "node", 4 | transform: { 5 | "^.+.ts$": ["ts-jest", {}], 6 | }, 7 | 8 | // Coverage 9 | collectCoverage: true, 10 | coverageThreshold: { 11 | "./lib": { 12 | branches: 90, 13 | functions: 100, 14 | statements: 95, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /expiring-value/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | testEnvironment: "node", 4 | transform: { 5 | "^.+.ts$": ["ts-jest", {}], 6 | }, 7 | 8 | // Coverage 9 | collectCoverage: true, 10 | coverageThreshold: { 11 | "./lib": { 12 | branches: 100, 13 | functions: 100, 14 | statements: 100, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /state-storage/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | testEnvironment: "node", 4 | transform: { 5 | "^.+.ts$": ["ts-jest", {}], 6 | }, 7 | 8 | // Coverage 9 | collectCoverage: true, 10 | coverageThreshold: { 11 | "./lib": { 12 | branches: 100, 13 | functions: 100, 14 | statements: 100, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /elasticsearch-client/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | testEnvironment: "node", 4 | transform: { 5 | "^.+.ts$": ["ts-jest", {}], 6 | }, 7 | 8 | // Coverage 9 | collectCoverage: true, 10 | coverageThreshold: { 11 | "./lib": { 12 | branches: 100, 13 | functions: 100, 14 | statements: 100, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /injector/example/src/handlers.ts: -------------------------------------------------------------------------------- 1 | import { CompanyService } from "./company-service"; 2 | import { Injector } from "@sailplane/injector"; 3 | import { wrapApiHandler } from "@sailplane/lambda-utils"; 4 | 5 | /** 6 | * Return a list of all company records. 7 | */ 8 | export const getCompanies = wrapApiHandler(async () => { 9 | const list = await Injector.get(CompanyService)!.listCompanies(); 10 | 11 | return { companies: list }; 12 | }); 13 | -------------------------------------------------------------------------------- /lambda-utils/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | extensionsToTreatAsEsm: [".ts"], 4 | verbose: true, 5 | preset: "ts-jest/presets/default-esm", 6 | testEnvironment: "node", 7 | transform: { 8 | "^.+\\.tsx?$": ["ts-jest", { useESM: true }], 9 | }, 10 | 11 | // Coverage 12 | collectCoverage: true, 13 | coverageThreshold: { 14 | "./lib": { 15 | branches: 90, 16 | functions: 100, 17 | statements: 100, 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | analyze: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Repository 13 | uses: actions/checkout@v4 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version-file: "package.json" 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Run Analysis 21 | run: npm run analyze 22 | -------------------------------------------------------------------------------- /injector/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "moduleResolution": "node", 6 | "baseUrl": "src", 7 | "rootDirs": ["src"], 8 | "sourceMap": true, 9 | "declaration": false, 10 | "removeComments": true, 11 | "strictNullChecks": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "target": "es2017", 15 | "typeRoots": ["node_modules/@types"], 16 | "lib": ["es2017"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /injector/example/serverless.yml: -------------------------------------------------------------------------------- 1 | service: 2 | name: sailplane-injector-example 3 | 4 | plugins: 5 | - serverless-webpack 6 | - serverless-offline 7 | 8 | provider: 9 | name: aws 10 | stage: ${opt:stage, 'dev'} 11 | region: ${opt:region, 'us-west-2'} 12 | runtime: nodejs12.x 13 | versionFunctions: false 14 | 15 | functions: 16 | getCompanies: 17 | handler: src/handlers.getCompanies 18 | events: 19 | - http: 20 | method: get 21 | path: admin/companies 22 | cors: true 23 | memorySize: 128 24 | timeout: 10 25 | -------------------------------------------------------------------------------- /injector/example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const slsw = require("serverless-webpack"); 3 | 4 | const entries = {}; 5 | 6 | Object.keys(slsw.lib.entries).forEach( 7 | (key) => (entries[key] = ["./source-map-install.js", slsw.lib.entries[key]]), 8 | ); 9 | 10 | module.exports = { 11 | entry: entries, 12 | devtool: "source-map", 13 | resolve: { 14 | extensions: [".js", ".json", ".ts"], 15 | }, 16 | target: "node", 17 | mode: "development", 18 | module: { 19 | rules: [ 20 | { 21 | test: /^(?!.*\.spec\.ts$).*\.ts$/, 22 | loader: "ts-loader", 23 | exclude: /node_modules/, 24 | }, 25 | ], 26 | }, 27 | output: { 28 | libraryTarget: "commonjs", 29 | path: path.join(__dirname, ".webpack"), 30 | filename: "[name].js", 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /injector/README.md: -------------------------------------------------------------------------------- 1 | # @sailplane/injector - Type-safe Dependency Injection 2 | 3 | ## What? 4 | 5 | Simple, light-weight, lazy-instantiating, and type-safe dependency injection in Typescript! 6 | Perfect for use in Lambdas and unit test friendly. 7 | 8 | This is part of the [sailplane](https://github.com/rackspace/sailplane) library of 9 | utilities for AWS Serverless in Node.js. 10 | 11 | # Why? 12 | 13 | It is built on top of the [BottleJS](https://www.npmjs.com/package/bottlejs), with a simple type-safe 14 | wrapper. The original _bottle_ is available for more advanced use, though. Even if you are not using Typescript, 15 | you may still prefer this simplified interface over using BottleJS directly. 16 | 17 | ## How? 18 | 19 | See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. 20 | -------------------------------------------------------------------------------- /aws-https/README.md: -------------------------------------------------------------------------------- 1 | # @sailplane/aws-http - HTTPS client with AWS Signature v4 2 | 3 | ## What? 4 | 5 | The AwsHttps class is an HTTPS (notice, _not_ HTTP) client purpose made for use in and with AWS environments. 6 | 7 | This is part of the [sailplane](https://github.com/rackspace/sailplane) library of 8 | utilities for AWS Serverless in Node.js. 9 | 10 | ## Why? 11 | 12 | - Simple Promise or async syntax 13 | - Optionally authenticates to AWS via AWS Signature v4 using [aws4](https://www.npmjs.com/package/aws4) 14 | - Familiar [options](https://nodejs.org/api/http.html#http_http_request_options_callback>) 15 | - Helper to build request options from URL object 16 | - Light-weight 17 | - Easily extended for unit testing 18 | 19 | ## How? 20 | 21 | See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. 22 | -------------------------------------------------------------------------------- /elasticsearch-client/README.md: -------------------------------------------------------------------------------- 1 | # @sailplane/elasticsearch-client - HTTPS client with AWS Signature v4 2 | 3 | ## What? 4 | 5 | An extremely light-weight client for communicating with AWS Elasticsearch. 6 | 7 | This is part of the [sailplane](https://github.com/rackspace/sailplane) library of 8 | utilities for AWS Serverless in Node.js. 9 | 10 | ## Why? 11 | 12 | Other solutions for communicating with Elasticsearch are either incompatible with AWS, 13 | very heavy weight, or both. This client gets the job done and is as simple as it gets! 14 | 15 | - Simple Promise or async syntax 16 | - Authenticates to AWS via AWS Signature v4 17 | - Light-weight 18 | 19 | Use it with Elasticsearch's [Document API](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs.html). 20 | 21 | ## How? 22 | 23 | See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. 24 | -------------------------------------------------------------------------------- /injector/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sailplane-injector-example", 3 | "version": "2.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "sls offline start --printOutput" 7 | }, 8 | "devDependencies": { 9 | "serverless-offline": "^7.0.0", 10 | "serverless-webpack": "^5.5.1", 11 | "ts-loader": "^9.2.3", 12 | "ts-node": "^10.1.0", 13 | "typescript": "^3.8.3", 14 | "webpack": "^5.44.0", 15 | "webpack-cli": "^4.7.2" 16 | }, 17 | "dependencies": { 18 | "@middy/core": "^1.5.2", 19 | "@middy/http-cors": "^1.5.2", 20 | "@middy/http-event-normalizer": "^1.5.2", 21 | "@middy/http-header-normalizer": "^1.5.2", 22 | "@middy/http-json-body-parser": "^1.5.2", 23 | "@sailplane/injector": "file:../", 24 | "@sailplane/lambda-utils": "^3.0.0", 25 | "@sailplane/logger": "file:../../logger", 26 | "aws-sdk": "^2.947.0", 27 | "bottlejs": "^1.7.2", 28 | "serverless": "^2.52.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /state-storage/README.md: -------------------------------------------------------------------------------- 1 | # @sailplane/state-storage - Serverless state and configuration storage 2 | 3 | ## What? 4 | 5 | StateStorage is a simple wrapper for SSM `getParameter` and `putParameter` functions, abstracting it into 6 | a contextual storage of small JSON objects. 7 | 8 | This is part of the [sailplane](https://github.com/rackspace/sailplane) library of 9 | utilities for AWS Serverless in Node.js. 10 | 11 | ## Why? 12 | 13 | The AWS Parameter Store (SSM) was originally designed as a place to store configuration. It turns out that 14 | it is also a pretty handy place for storing small bits of state information in between serverless executions. 15 | 16 | Why use this instead of AWS SSM API directly? 17 | 18 | - Simple Promise or async syntax 19 | - Automatic object serialization/deserialization 20 | - Logging 21 | - Consistent naming convention 22 | 23 | ## How? 24 | 25 | See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. 26 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | export default [ 7 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 8 | { files: ["**/*.js"], languageOptions: { sourceType: "script" } }, 9 | { languageOptions: { globals: globals.browser } }, 10 | pluginJs.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | { 13 | rules: { 14 | "@typescript-eslint/no-unused-vars": [ 15 | "error", 16 | { 17 | vars: "all", 18 | argsIgnorePattern: "^_", 19 | caughtErrors: "all", 20 | caughtErrorsIgnorePattern: "^_", 21 | destructuredArrayIgnorePattern: "^_", 22 | varsIgnorePattern: "^_", 23 | }, 24 | ], 25 | "@typescript-eslint/no-explicit-any": "off", // useful for generic library 26 | "@typescript-eslint/no-namespace": "off", 27 | }, 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /logger/README.md: -------------------------------------------------------------------------------- 1 | # @sailplane/logger - CloudWatch and serverless-offline friendly logger 2 | 3 | ## What? 4 | 5 | Logger adds context and when needed, timestamps and JSON formatting, to log output. 6 | 7 | This is part of the [sailplane](https://github.com/rackspace/sailplane) library of 8 | utilities for AWS Serverless in Node.js. 9 | 10 | ## Why? 11 | 12 | Sadly, `console.log` is the #1 debugging tool when writing serverless code. Logger extends it with levels, 13 | timestamps, context/category names, and object formatting. It's just a few small incremental improvements, and 14 | yet together takes logging a leap forward. It'll do until we can have a usable cloud debugger. 15 | 16 | There are far more complicated logging packages available for Javascript; 17 | but sailplane is all about simplicity, and this logger gives you all that 18 | you really need without the bulk. 19 | 20 | ## How? 21 | 22 | See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. 23 | -------------------------------------------------------------------------------- /state-storage/lib/state-storage-fake.ts: -------------------------------------------------------------------------------- 1 | import { StateStorage } from "./state-storage"; 2 | 3 | /** 4 | * Version of StateStorage to use in unit testing. 5 | * This fake will store data in instance memory, instead of the AWS Parameter Store. 6 | */ 7 | export class StateStorageFake extends StateStorage { 8 | storage: Record = {}; 9 | 10 | constructor(namePrefix: string) { 11 | super(namePrefix); 12 | } 13 | 14 | set(service: string, name: string, value: any, _options: any): Promise { 15 | const key = this.generateName(service, name); 16 | this.storage[key] = JSON.stringify(value); 17 | return Promise.resolve(); 18 | } 19 | 20 | get(service: string, name: string, _options: any): Promise { 21 | const key = this.generateName(service, name); 22 | 23 | const content = this.storage[key]; 24 | if (content) return Promise.resolve(JSON.parse(content)); 25 | else return Promise.reject(new Error("mock StateStorage.get not found: " + key)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lambda-utils/lib/logger-context.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayEventRequestContextWithAuthorizer } from "aws-lambda"; 2 | import middy from "@middy/core"; 3 | import { Logger } from "@sailplane/logger"; 4 | import { APIGatewayProxyEventAnyVersion } from "./types"; 5 | 6 | /** 7 | * Middleware for LambdaUtils to set request context in Logger 8 | */ 9 | export const loggerContextMiddleware = (): middy.MiddlewareObj => { 10 | return { 11 | before: async (request) => { 12 | Logger.setLambdaContext(request.context); 13 | 14 | const requestContext = request.event.requestContext; 15 | const claims = 16 | (requestContext as APIGatewayEventRequestContextWithAuthorizer)?.authorizer?.claims || // API v1 17 | (requestContext as any)?.authorizer?.jwt?.claims; // API v2 18 | 19 | const context = { 20 | api_request_id: requestContext?.requestId, 21 | jwt_sub: claims?.sub, 22 | }; 23 | Logger.addAttributes(context); 24 | }, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/logger", 3 | "version": "6.0.0", 4 | "description": "CloudWatch and serverless-offline friendly logger.", 5 | "keywords": [ 6 | "aws", 7 | "cloudwatch", 8 | "log", 9 | "logger", 10 | "typescript" 11 | ], 12 | "scripts": { 13 | "analyze": "npm run build && npm run lint && npm test", 14 | "clean": "rm -rf coverage dist", 15 | "build": "tsup", 16 | "dev": "tsup --watch", 17 | "lint": "eslint lib", 18 | "test": "jest" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/rackspace/sailplane.git" 23 | }, 24 | "license": "Apache-2.0", 25 | "author": "Rackspace Technology", 26 | "homepage": "https://github.com/rackspace/sailplane", 27 | "contributors": [ 28 | "Adam Fanello " 29 | ], 30 | "devDependencies": { 31 | "@types/aws-lambda": "^8.10.146" 32 | }, 33 | "main": "dist/index.js", 34 | "module": "dist/index.mjs", 35 | "types": "dist/index.d.ts", 36 | "files": [ 37 | "dist" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /expiring-value/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/expiring-value", 3 | "version": "5.0.0", 4 | "description": "Container for a value that is instantiated on-demand (lazy-loaded via factory) and cached for a limited time.", 5 | "keywords": [ 6 | "factory", 7 | "lazy", 8 | "expire", 9 | "typescript" 10 | ], 11 | "scripts": { 12 | "analyze": "npm run build && npm run lint && npm test", 13 | "clean": "rm -rf coverage dist", 14 | "build": "tsup", 15 | "dev": "tsup --watch", 16 | "lint": "eslint lib", 17 | "test": "jest" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/rackspace/sailplane.git" 22 | }, 23 | "license": "Apache-2.0", 24 | "author": "Rackspace Technology", 25 | "homepage": "https://github.com/rackspace/sailplane", 26 | "contributors": [ 27 | "Adam Fanello " 28 | ], 29 | "devDependencies": { 30 | "mockdate": "^3.0.5" 31 | }, 32 | "main": "dist/index.js", 33 | "module": "dist/index.mjs", 34 | "types": "dist/index.d.ts", 35 | "files": [ 36 | "dist" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /lambda-utils/lib/resolved-promise-is-success.ts: -------------------------------------------------------------------------------- 1 | import middy from "@middy/core"; 2 | import { APIGatewayProxyEventAnyVersion, APIGatewayProxyResultAnyVersion } from "./types"; 3 | 4 | /** 5 | * Middleware to allow an async handler to return its exact response body. 6 | * This middleware will wrap it up as an APIGatewayProxyResult. 7 | * Must be registered as the last (thus first to run) "after" middleware. 8 | */ 9 | export const resolvedPromiseIsSuccessMiddleware = (): middy.MiddlewareObj< 10 | APIGatewayProxyEventAnyVersion, 11 | APIGatewayProxyResultAnyVersion 12 | > => ({ 13 | after: async (request) => { 14 | // If response isn't a proper API result object, convert it into one. 15 | const response = request.response; 16 | if (!response || typeof response !== "object" || (!response.statusCode && !response.body)) { 17 | request.response = { 18 | statusCode: 200, 19 | body: response ? JSON.stringify(response) : "", 20 | headers: { 21 | "content-type": response 22 | ? "application/json; charset=utf-8" 23 | : "text/plain; charset=utf-8", 24 | }, 25 | }; 26 | } 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /elasticsearch-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/elasticsearch-client", 3 | "version": "4.0.0", 4 | "description": "Dead simple client for AWS managed Elasticsearch.", 5 | "keywords": [ 6 | "aws", 7 | "elasticsearch", 8 | "typescript" 9 | ], 10 | "scripts": { 11 | "analyze": "npm run build && npm run lint && npm test", 12 | "clean": "rm -rf coverage dist", 13 | "build": "tsup", 14 | "dev": "tsup --watch", 15 | "lint": "eslint lib", 16 | "test": "LOG_LEVEL=WARN jest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/rackspace/sailplane.git" 21 | }, 22 | "license": "Apache-2.0", 23 | "author": "Rackspace Technology", 24 | "homepage": "https://github.com/rackspace/sailplane", 25 | "contributors": [ 26 | "Adam Fanello " 27 | ], 28 | "devDependencies": { 29 | "@sailplane/aws-https": "file:../aws-https", 30 | "@sailplane/logger": "file:../logger", 31 | "aws4": "^1.13.2" 32 | }, 33 | "peerDependencies": { 34 | "@sailplane/aws-https": ">=4.0.0", 35 | "@sailplane/logger": ">=6.0.0" 36 | }, 37 | "main": "dist/index.js", 38 | "module": "dist/index.mjs", 39 | "types": "dist/index.d.ts", 40 | "files": [ 41 | "dist" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /state-storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/state-storage", 3 | "version": "4.0.0", 4 | "description": "Serverless state and configuration storage", 5 | "keywords": [ 6 | "aws", 7 | "ssm", 8 | "configuration", 9 | "store", 10 | "state", 11 | "typescript" 12 | ], 13 | "scripts": { 14 | "analyze": "npm run build && npm run lint && npm test", 15 | "clean": "rm -rf coverage dist", 16 | "build": "tsup", 17 | "dev": "tsup --watch", 18 | "lint": "eslint lib", 19 | "test": "LOG_LEVEL=WARN jest --no-cache --verbose" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/rackspace/sailplane.git" 24 | }, 25 | "license": "Apache-2.0", 26 | "author": "Rackspace Technology", 27 | "homepage": "https://github.com/rackspace/sailplane", 28 | "contributors": [ 29 | "Adam Fanello " 30 | ], 31 | "devDependencies": { 32 | "@aws-sdk/client-ssm": "^3.735.0", 33 | "@sailplane/logger": "file:../logger" 34 | }, 35 | "peerDependencies": { 36 | "@sailplane/logger": ">=6.0.0", 37 | "@aws-sdk/client-ssm": "3.x.x" 38 | }, 39 | "main": "dist/index.js", 40 | "module": "dist/index.mjs", 41 | "types": "dist/index.d.ts", 42 | "files": [ 43 | "dist" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /docs/elasticsearch_client.md: -------------------------------------------------------------------------------- 1 | # ElasticsearchClient 2 | 3 | Communicate with AWS Elasticsearch or Open Search. 4 | 5 | ## Overview 6 | 7 | Other solutions for communicating with Elasticsearch are either incompatible with AWS, 8 | very heavy weight, or both. This client gets the job done and is as simple as it gets! 9 | 10 | - Simple Promise or async syntax 11 | - Authenticates to AWS via AWS Signature v4 12 | - Light-weight 13 | 14 | Use it with Elasticsearch's [Document API](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs.html). 15 | 16 | `ElasticsearchClient` depends on two other utilities to work: 17 | 18 | - Sailplane [AwsHttps](aws_https.md) 19 | - Sailplane [Logger](logger.md) 20 | 21 | ## Install 22 | 23 | ```shell 24 | npm install @sailplane/elasticsearch-client @sailplane/aws-https @sailplane/logger 25 | ``` 26 | 27 | ## API Documentation 28 | 29 | [API Documentation on jsDocs.io](https://www.jsdocs.io/package/@sailplane/elasticsearch-client) 30 | 31 | ## Examples 32 | 33 | Simple example: 34 | 35 | ```ts 36 | function get(id: string): Promise { 37 | return this.es 38 | .request("GET", "/ticket/local/" + id) 39 | .then((esDoc: ElasticsearchResult) => esDoc._source as Ticket); 40 | } 41 | ``` 42 | 43 | See [examples](examples.md) for a comprehensive example. 44 | -------------------------------------------------------------------------------- /injector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/injector", 3 | "version": "5.1.0", 4 | "description": "Simple, light-weight, lazy-instantiating, and type-safe dependency injection in TypeScript.", 5 | "keywords": [ 6 | "di", 7 | "dependency injection", 8 | "typescript" 9 | ], 10 | "scripts": { 11 | "analyze": "npm run build && npm run lint && npm test", 12 | "clean": "rm -rf coverage dist", 13 | "build": "tsup", 14 | "dev": "tsup --watch", 15 | "lint": "eslint lib", 16 | "test": "LOG_LEVEL=ERROR jest" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/rackspace/sailplane.git" 21 | }, 22 | "license": "Apache-2.0", 23 | "author": "Rackspace Technology", 24 | "homepage": "https://github.com/rackspace/sailplane", 25 | "contributors": [ 26 | "Adam Fanello " 27 | ], 28 | "dependencies": { 29 | "reflect-metadata": "~0.1.14" 30 | }, 31 | "devDependencies": { 32 | "@sailplane/logger": "file:../logger", 33 | "bottlejs": "2.0.x" 34 | }, 35 | "peerDependencies": { 36 | "@sailplane/logger": "^6.0.0-rc.0", 37 | "bottlejs": "2.0.x" 38 | }, 39 | "main": "dist/index.js", 40 | "module": "dist/index.mjs", 41 | "types": "dist/index.d.ts", 42 | "files": [ 43 | "dist" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /logger/lib/structured-formatter.ts: -------------------------------------------------------------------------------- 1 | import { FormatterFn, LoggerConfig, LogLevel } from "./common"; 2 | import { jsonStringify } from "./json-stringify"; 3 | import { getContext } from "./context"; 4 | 5 | /** 6 | * Format a log line in flat format. 7 | * 8 | * @param loggerConfig configuration of Logger instance 9 | * @param globalConfig global configuration 10 | * @param level logging level 11 | * @param message text to log 12 | * @param params A list of JavaScript objects to output. 13 | * @return array to pass to a console function 14 | */ 15 | export const structuredFormatter: FormatterFn = ( 16 | loggerConfig: LoggerConfig, 17 | globalConfig: LoggerConfig, 18 | level: LogLevel, 19 | message: string, 20 | params: any[], 21 | ): any[] => { 22 | const item = { 23 | ...getContext(), 24 | ...globalConfig.attributes, 25 | ...globalConfig.attributesCallback?.(), 26 | ...loggerConfig.attributes, 27 | ...loggerConfig.attributesCallback?.(), 28 | level: LogLevel[level], 29 | module: loggerConfig.module, 30 | timestamp: new Date().toISOString(), 31 | message, 32 | }; 33 | 34 | if (params.length) { 35 | if (params.length === 1 && typeof params[0] === "object") { 36 | item.value = params[0]; 37 | } else { 38 | item.params = params; 39 | } 40 | } 41 | 42 | return [jsonStringify(item)]; 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/workspace", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "AWS Serverless Node.js Utilities in Javascript and Typescript", 6 | "author": "Rackspace Technology", 7 | "workspaces": [ 8 | "logger", 9 | "aws-https", 10 | "elasticsearch-client", 11 | "expiring-value", 12 | "injector", 13 | "lambda-utils", 14 | "state-storage" 15 | ], 16 | "engines": { 17 | "node": ">=20.11.0 <21", 18 | "npm": ">=10.3.0 <11" 19 | }, 20 | "scripts": { 21 | "analyze": "npm run analyze -ws && npm run prettier", 22 | "build": "npm run build -ws", 23 | "clean": "npm run clean -ws && rm -rf tsconfig.tsbuildinfo", 24 | "clean:all": "npm run clean && find . -name node_modules -maxdepth 2 -type d -exec rm -rf {} +", 25 | "test": "npm run test -ws", 26 | "prettier": "prettier --check . --ignore-path .prettierignore", 27 | "lint": "npm run lint --ws" 28 | }, 29 | "devDependencies": { 30 | "@eslint/js": "^9.18.0", 31 | "@types/jest": "^29.5.14", 32 | "eslint": "^9.18.0", 33 | "globals": "^15.14.0", 34 | "jest": "^29.7.0", 35 | "prettier": "^3.4.2", 36 | "ts-jest": "^29.2.5", 37 | "ts-node": "^10.9.2", 38 | "tsup": "^8.3.5", 39 | "typescript": "5.5.x", 40 | "typescript-eslint": "^8.21.0" 41 | }, 42 | "prettier": { 43 | "printWidth": 100 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /aws-https/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/aws-https", 3 | "version": "4.0.0", 4 | "description": "HTTPS client with AWS Signature v4", 5 | "keywords": [ 6 | "aws", 7 | "https", 8 | "signature", 9 | "sign", 10 | "typescript" 11 | ], 12 | "scripts": { 13 | "analyze": "npm run build && npm run lint && npm test", 14 | "clean": "rm -rf coverage dist", 15 | "build": "tsup", 16 | "dev": "tsup --watch", 17 | "lint": "eslint lib", 18 | "test": "LOG_LEVEL=NONE jest --runInBand --no-cache --verbose" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/rackspace/sailplane.git" 23 | }, 24 | "license": "Apache-2.0", 25 | "author": "Rackspace Technology", 26 | "homepage": "https://github.com/rackspace/sailplane", 27 | "contributors": [ 28 | "Adam Fanello " 29 | ], 30 | "devDependencies": { 31 | "@sailplane/logger": "file:../logger", 32 | "@types/aws4": "^1.11.6", 33 | "aws-sdk": "^2.1692.0", 34 | "nock": "^14.0.0" 35 | }, 36 | "peerDependencies": { 37 | "@sailplane/logger": ">=6.0.0", 38 | "aws4": "^1.13.2", 39 | "aws-sdk": "^2.1692.0" 40 | }, 41 | "dependencies": { 42 | "aws4": "^1.13.2" 43 | }, 44 | "main": "dist/index.js", 45 | "module": "dist/index.mjs", 46 | "types": "dist/index.d.ts", 47 | "files": [ 48 | "dist" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /expiring-value/README.md: -------------------------------------------------------------------------------- 1 | # @sailplane/expiring-value - on-demand value with timeout 2 | 3 | ## What? 4 | 5 | The `ExpringValue` generic class is a container for a value that is instantiated on-demand (lazy-loaded via factory) and cached for a limited 6 | time. Once the cache expires, the factory is used again on next demand to get a fresh version. 7 | 8 | This is part of the [sailplane](https://github.com/rackspace/sailplane) library of 9 | utilities for AWS Serverless in Node.js. 10 | 11 | ## Why? 12 | 13 | `ExpiringValue` is a container for a value that is instantiated on-demand (lazy-loaded via factory) 14 | and cached for a limited time. 15 | Once the cache expires, the factory is used again on next demand to get a fresh version. 16 | 17 | In Lambda functions, it is useful to cache some data in instance memory to avoid 18 | recomputing or fetching data on every invocation. In the early days of Lambda, instances only lasted 15 minutes 19 | and thus set an upper-limit on how stale the cached data could be. With instances now seen to last for 20 | many hours, a mechanism is needed to deal with refreshing stale content - thus `ExpiringValue` was born. 21 | 22 | `ExpiringValue` is not limited to Lambdas, though. Use it anywhere you want to cache a value for 23 | a limited time. It even works in the browser for client code. 24 | 25 | ## How? 26 | 27 | See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. 28 | -------------------------------------------------------------------------------- /logger/lib/flat-formatter.ts: -------------------------------------------------------------------------------- 1 | import { FormatterFn, LogFormat, LoggerConfig, LogLevel } from "./common"; 2 | import { jsonStringify } from "./json-stringify"; 3 | 4 | /** 5 | * Format a log line in flat or pretty format. 6 | * 7 | * @param loggerConfig configuration of Logger instance 8 | * @param globalConfig global configuration 9 | * @param level logging level 10 | * @param message text to log 11 | * @param params A list of JavaScript objects to output. 12 | * @return array to pass to a console function 13 | */ 14 | export const flatFormatter: FormatterFn = ( 15 | loggerConfig: LoggerConfig, 16 | globalConfig: LoggerConfig, 17 | level: LogLevel, 18 | message: string, 19 | params: any[], 20 | ): any[] => { 21 | const out: any[] = []; 22 | if (loggerConfig.logTimestamps) { 23 | out.push(new Date().toISOString().substring(0, 19)); 24 | } 25 | 26 | if (loggerConfig.outputLevels) { 27 | out.push(LogLevel[level]); 28 | } 29 | 30 | out.push(loggerConfig.module); 31 | 32 | out.push( 33 | ...Object.values({ 34 | ...globalConfig.attributesCallback?.(), 35 | ...loggerConfig.attributesCallback?.(), 36 | }), 37 | ); 38 | 39 | out[out.length - 1] += ":"; 40 | out.push(message); 41 | 42 | if (params.length) { 43 | const indent = loggerConfig.format === LogFormat.PRETTY ? 2 : undefined; 44 | for (const param of params) { 45 | if (typeof param === "object") { 46 | out.push(jsonStringify(param, indent)); 47 | } else { 48 | out.push(param); 49 | } 50 | } 51 | } 52 | 53 | return out; 54 | }; 55 | -------------------------------------------------------------------------------- /logger/lib/context.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from "aws-lambda"; 2 | 3 | /** 4 | * Log context based on runtime environment and Lambda context. 5 | * @see https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime 6 | * @see https://docs.aws.amazon.com/lambda/latest/dg/nodejs-context.html 7 | */ 8 | let processContext: any | undefined; 9 | let numInvocations = 0; 10 | const env = globalThis.process?.env ?? {}; 11 | 12 | function initContext(): void { 13 | const addIfExists = (name: string, value: string | undefined, notValue?: string) => { 14 | if (value && value !== notValue) { 15 | processContext[name] = value; 16 | } 17 | }; 18 | processContext = { 19 | aws_region: env.AWS_REGION || env.AWS_DEFAULT_REGION, 20 | function_name: env.AWS_LAMBDA_FUNCTION_NAME, 21 | function_memory_size: Number(env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE), 22 | }; 23 | addIfExists("function_version", env.AWS_LAMBDA_FUNCTION_VERSION, "$LATEST"); 24 | addIfExists("environment", env.ENVIRONMENT); 25 | addIfExists("stage", env.STAGE || env.SERVERLESS_STAGE); 26 | addIfExists( 27 | "xray_trace_id", 28 | /*xray enabled? */ env.AWS_XRAY_CONTEXT_MISSING ? env._X_AMZN_TRACE_ID : undefined, 29 | ); 30 | } 31 | 32 | export function getContext(): any { 33 | if (!processContext) { 34 | initContext(); 35 | } 36 | return processContext; 37 | } 38 | 39 | export function addLambdaContext(context: Context): void { 40 | if (!processContext) { 41 | initContext(); 42 | } 43 | 44 | processContext.aws_request_id = context.awsRequestId; 45 | processContext.invocation_num = ++numInvocations; 46 | } 47 | -------------------------------------------------------------------------------- /logger/lib/json-stringify.ts: -------------------------------------------------------------------------------- 1 | const stackLineExtractRegex = /\((.*):(\d+):(\d+)\)\\?$/; 2 | /** 3 | * Extract the file and line where an Error was created from its stack trace. 4 | */ 5 | function extractErrorSource(stack?: string): string { 6 | for (const stackLine of stack?.split("\n") ?? []) { 7 | const match = stackLineExtractRegex.exec(stackLine); 8 | if (Array.isArray(match)) { 9 | return `${match[1]}:${Number(match[2])}`; 10 | } 11 | } 12 | 13 | return ""; 14 | } 15 | 16 | /** 17 | * Enhanced replacer for JSON.stringify 18 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value 19 | */ 20 | const getReplacer = () => { 21 | const seen = new WeakSet(); 22 | return (key: string, value: unknown) => { 23 | if (typeof value === "object" && value !== null) { 24 | if (seen.has(value)) { 25 | return ""; 26 | } 27 | seen.add(value); 28 | } 29 | 30 | if (value instanceof Error) { 31 | const error = value as Error; 32 | value = { 33 | name: error.name, 34 | message: error.message, 35 | stack: error.stack, 36 | source: extractErrorSource(error.stack), 37 | }; 38 | } else if (typeof value === "bigint") { 39 | value = value.toString(); 40 | } 41 | 42 | return value; 43 | }; 44 | }; 45 | 46 | /** 47 | * Custom version of JSON.stringify that 48 | * - doesn't fail on cyclic objects 49 | * - formats Error objects 50 | * - indents if LogFormat is PRETTY 51 | */ 52 | export function jsonStringify(obj: any, indent?: number): string { 53 | return JSON.stringify(obj, getReplacer(), indent); 54 | } 55 | -------------------------------------------------------------------------------- /lambda-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sailplane/lambda-utils", 3 | "version": "7.0.0", 4 | "description": "Use middleware to remove redundancy in AWS Lambda handlers.", 5 | "keywords": [ 6 | "aws", 7 | "lambda", 8 | "middleware", 9 | "API gateway", 10 | "typescript", 11 | "middy" 12 | ], 13 | "scripts": { 14 | "analyze": "npm run build && npm run lint && npm test", 15 | "clean": "rm -rf coverage dist", 16 | "build": "tsup", 17 | "dev": "tsup --watch", 18 | "lint": "eslint lib", 19 | "test": "LOG_LEVEL=NONE node --experimental-vm-modules --disable-warning=ExperimentalWarning ../node_modules/.bin/jest" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/rackspace/sailplane.git" 24 | }, 25 | "license": "Apache-2.0", 26 | "author": "Rackspace Technology", 27 | "homepage": "https://github.com/rackspace/sailplane", 28 | "contributors": [ 29 | "Adam Fanello " 30 | ], 31 | "devDependencies": { 32 | "@middy/core": "^6.0.0", 33 | "@middy/http-cors": "^6.0.0", 34 | "@middy/http-event-normalizer": "^6.0.0", 35 | "@middy/http-header-normalizer": "^6.0.0", 36 | "@middy/http-json-body-parser": "^6.0.0", 37 | "@sailplane/logger": "file:../logger", 38 | "@types/aws-lambda": "^8.10.146", 39 | "@types/http-errors": "^2.0.4", 40 | "http-errors": "^2.0.0" 41 | }, 42 | "peerDependencies": { 43 | "@middy/core": "6.x.x", 44 | "@middy/http-cors": "6.x.x", 45 | "@middy/http-event-normalizer": "6.x.x", 46 | "@middy/http-header-normalizer": "6.x.x", 47 | "@middy/http-json-body-parser": "6.x.x", 48 | "@sailplane/logger": ">=6.0.0" 49 | }, 50 | "main": "dist/index.js", 51 | "module": "dist/index.mjs", 52 | "types": "dist/index.d.ts", 53 | "files": [ 54 | "dist" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /lambda-utils/lib/unhandled-exception.ts: -------------------------------------------------------------------------------- 1 | import middy from "@middy/core"; 2 | import { APIGatewayProxyEventAnyVersion, APIGatewayProxyResultAnyVersion } from "./types"; 3 | import { Logger } from "@sailplane/logger"; 4 | 5 | const logger = new Logger("lambda-utils"); 6 | 7 | /** 8 | * Middleware to handle any otherwise unhandled exception by logging it and generating 9 | * an HTTP 500 response. 10 | * 11 | * Fine-tuned to work better than the Middy version, and uses @sailplane/logger. 12 | */ 13 | export const unhandledExceptionMiddleware = (): middy.MiddlewareObj< 14 | APIGatewayProxyEventAnyVersion, 15 | APIGatewayProxyResultAnyVersion 16 | > => ({ 17 | onError: async (request) => { 18 | logger.error("Unhandled exception:", request.error); 19 | 20 | request.response = request.response || {}; 21 | /* istanbul ignore else - nominal path is for response to be brand new */ 22 | if ((request.response.statusCode || 0) < 400) { 23 | const error = findRootCause(request.error); 24 | request.response.statusCode = (error as ErrorWithStatus)?.statusCode || 500; 25 | request.response.body = error?.toString() ?? ""; 26 | request.response.headers = request.response.headers ?? {}; 27 | request.response.headers["content-type"] = "text/plain; charset=utf-8"; 28 | } 29 | 30 | logger.info("Response to API Gateway: ", request.response); 31 | }, 32 | }); 33 | 34 | type ErrorWithStatus = Error & { statusCode?: number }; 35 | 36 | function findRootCause( 37 | error: unknown | null | undefined, 38 | ): ErrorWithStatus | Error | unknown | null | undefined { 39 | const errorWithStatus = error as ErrorWithStatus; 40 | if (errorWithStatus?.statusCode && errorWithStatus.statusCode >= 400) { 41 | return error as ErrorWithStatus; 42 | } else if (errorWithStatus?.cause) { 43 | return findRootCause(errorWithStatus.cause); 44 | } else { 45 | return error; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/expiring_value.md: -------------------------------------------------------------------------------- 1 | # ExpiringValue 2 | 3 | Value that is instantiated on-demand and cached for a limited time. 4 | 5 | ## Overview 6 | 7 | `ExpiringValue` is a container for a value that is instantiated on-demand (lazy-loaded via factory) 8 | and cached for a limited time. 9 | Once the cache expires, the factory is used again on next demand to get a fresh version. 10 | 11 | In Lambda functions, it is useful to cache some data in instance memory to avoid 12 | recomputing or fetching data on every invocation. In the early days of Lambda, instances only lasted 15 minutes 13 | and thus set an upper-limit on how stale the cached data could be. With instances now seen to last for 14 | many hours, a mechanism is needed to deal with refreshing stale content - thus `ExpiringValue` was born. 15 | 16 | `ExpiringValue` is not limited to Lambdas, though. Use it anywhere you want to cache a value for 17 | a limited time. It even works in the browser for client code. 18 | 19 | A good use is with [StateStorage](state_storage.md), 20 | to load configuration and cache it, but force the refresh of that configuration periodically. 21 | 22 | ## Install 23 | 24 | ```shell 25 | npm install @sailplane/expiring-value 26 | ``` 27 | 28 | ## API Documentation 29 | 30 | [API Documentation on jsDocs.io](https://www.jsdocs.io/package/@sailplane/expiring-value) 31 | 32 | ## Example 33 | 34 | Simplistic example of using `ExpiringValue` to build an HTTP cache: 35 | 36 | ```ts 37 | const CACHE_PERIOD = 90_000; // 90 seconds 38 | const https = new AwsHttps(); 39 | const cache = {}; 40 | 41 | export function fetchWithCache(url: string): Promise { 42 | if (!cache[url]) { 43 | cache[url] = new ExpiringValue(() => loadData(url), CACHE_PERIOD); 44 | } 45 | 46 | return cache[url].get(); 47 | } 48 | 49 | function loadData(url: string): any { 50 | const req = https.buildRequest("GET", new URL(url)); 51 | return https.request(req); 52 | } 53 | ``` 54 | 55 | See [examples](examples.md) for another example. 56 | -------------------------------------------------------------------------------- /lambda-utils/README.md: -------------------------------------------------------------------------------- 1 | # @sailplane/lambda-utils - Lambda handler middleware 2 | 3 | ## What? 4 | 5 | There's a lot of boilerplate in Lambda handlers. 6 | This collection of utility functions leverage the great [Middy](https://middy.js.org/) 7 | library to add middleware functionality to Lambda handlers. 8 | You can extend it with your own middleware. 9 | 10 | This is part of the [sailplane](https://github.com/rackspace/sailplane) library of 11 | utilities for AWS Serverless in Node.js. 12 | 13 | ## Why? 14 | 15 | Middy gives you a great start as a solid middleware framework, 16 | but by itself you are still repeating the middleware registrations 17 | on each handler, its exception handler only works with errors created by the http-errors package, 18 | and you still have to format your response in the shape required by API Gateway. 19 | 20 | `LambdaUtils` takes Middy further and is extendable so that you can add your own 21 | middleware (authentication & authorization, maybe?) on top of it. 22 | 23 | Used with API Gateway, the included middlewares: 24 | 25 | - Set CORS headers 26 | - Normalize incoming headers to mixed-case 27 | - If incoming content is JSON text, replaces event.body with parsed object 28 | - Ensures that event.queryStringParameters and event.pathParameters are defined, to avoid TypeErrors 29 | - Ensures that handler response is formatted properly as a successful API Gateway result 30 | - Unique to LambdaUtils! 31 | - Simply return what you want as the body of the HTTP response 32 | - Catch http-errors exceptions into proper HTTP responses 33 | - Catch other exceptions and return as HTTP 500 34 | - Unique to LambdaUtils! 35 | - Besides providing better feedback to the client, not throwing an exception out of your handler means that your 36 | instance will not be destroyed and suffer a cold start on the next invocation 37 | - Leverages async syntax 38 | 39 | ## How? 40 | 41 | See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. 42 | -------------------------------------------------------------------------------- /expiring-value/lib/expiring-value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Container for a value that is lazy-loaded whenever needed. 3 | * Further, it expires after a given time to avoid overly-stale data. 4 | */ 5 | export class ExpiringValue { 6 | /** Cached value */ 7 | private value: Promise | undefined; 8 | 9 | /** Epoch millisecond time of when the current value expires */ 10 | private expiration: number = 0; 11 | 12 | /** 13 | * Construct a new expiring value. 14 | * 15 | * @param factoryFn factory to lazy-load the value 16 | * @param ttl milliseconds the value is good for, after which it is reloaded. 17 | * @param options optional options to change behavior 18 | * @param options.cacheError set to true to cache for TTL a Promise rejection from factoryFn. 19 | * By default, a rejection is not cached and factoryFn will be retried upon the next call. 20 | */ 21 | constructor( 22 | private factoryFn: () => Promise, 23 | private ttl: number, 24 | private options = { cacheError: false }, 25 | ) {} 26 | 27 | /** 28 | * Get value; lazy-load from factory if not yet loaded or if expired. 29 | */ 30 | get(): Promise { 31 | if (this.isExpired()) { 32 | this.value = this.factoryFn(); 33 | 34 | if (this.options.cacheError) { 35 | this.extendExpiration(); 36 | } else { 37 | // Update expiration, only upon success; no-op on error here 38 | this.value.then(() => this.extendExpiration()).catch(() => undefined); 39 | } 40 | } 41 | 42 | return this.value!; 43 | } 44 | 45 | /** 46 | * Clear/expire the value now. 47 | * Following this with a get() will reload the data from the factory. 48 | */ 49 | clear(): void { 50 | this.value = undefined; 51 | this.expiration = 0; 52 | } 53 | 54 | /** 55 | * Is the value expired (or not set) 56 | */ 57 | isExpired(): boolean { 58 | return Date.now() > this.expiration; 59 | } 60 | 61 | /** Reset the value expiration to TTL past now */ 62 | private extendExpiration(): void { 63 | this.expiration = Date.now() + this.ttl; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/state_storage.md: -------------------------------------------------------------------------------- 1 | # StateStorage 2 | 3 | Serverless state and configuration storage. 4 | 5 | ## Overview 6 | 7 | The [AWS Parameter Store (SSM)](https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html) 8 | was originally designed as a place to store configuration. It turns out that 9 | it is also a pretty handy place for storing small bits of state information in between serverless executions. 10 | 11 | `StateStorage` is a simple wrapper for SSM `getParameter` and `putParameter` functions, abstracting it into 12 | a contextual storage of small JSON objects. 13 | 14 | Why use this instead of AWS SSM API directly? 15 | 16 | - Simple Promise or async syntax 17 | - Automatic object serialization/deserialization 18 | - Logging 19 | - Consistent naming convention 20 | 21 | `StateStorage` depends on one other utility to work: 22 | 23 | - Sailplane [Logger](logger.md) 24 | 25 | ## Install 26 | 27 | ```shell 28 | npm install @sailplane/state-storage @sailplane/logger @aws-sdk/client-ssm 29 | ``` 30 | 31 | ## API Documentation 32 | 33 | [API Documentation on jsDocs.io](https://www.jsdocs.io/package/@sailplane/state-storage) 34 | 35 | ## Examples 36 | 37 | Your Lambda will need permission to access the Parameter Store. Here's an example in `serverless.yml`: 38 | 39 | ```yaml 40 | provider: 41 | name: aws 42 | 43 | environment: 44 | STATE_STORAGE_PREFIX: /${opt:stage}/myapp 45 | 46 | iamRoleStatements: 47 | - Effect: Allow 48 | Action: 49 | - ssm:GetParameter 50 | - ssm:PutParameter 51 | Resource: "arn:aws:ssm:${opt:region}:*:parameter${self:provider.environment.STATE_STORAGE_PREFIX}/*" 52 | 53 | - Effect: Allow 54 | Action: 55 | - kms:Decrypt 56 | - kms:Encrypt 57 | Resource: "arn:aws:kms:${opt:region}:*:alias/aws/ssm" 58 | Condition: 59 | StringEquals: 60 | "kms:EncryptionContext:PARAMETER_ARN": "arn:aws:ssm:${opt:region}:*:parameter${self:provider.environment.STATE_STORAGE_PREFIX}/*" 61 | ``` 62 | 63 | Note that this is the complete set of possible permissions. 64 | Not all are needed if only reading parameters or if not using the `secure` option. 65 | 66 | **Simple example storing state** 67 | 68 | ```ts 69 | import { StateStorage } from "@sailplane/state-storage"; 70 | 71 | const stateStore = new StateStorage(process.env.STATE_STORAGE_PREFIX!); 72 | 73 | export async function myHandler(event, context): Promise { 74 | let state = await stateStore.get("thing", "state"); 75 | const result = await processRequest(state, event); 76 | await stateStore.set("thing", "state", state); 77 | return result; 78 | } 79 | ``` 80 | 81 | See [examples](examples.md) for another example. 82 | 83 | ## Unit testing your services 84 | 85 | Use `StateStorageFake` to unit test your services that use `StateStorage`. The fake will 86 | store data in instance memory, instead of the AWS Parameter Store. 87 | -------------------------------------------------------------------------------- /lambda-utils/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APIGatewayProxyEvent as AWS_APIGatewayProxyEvent, 3 | APIGatewayProxyEventV2 as AWS_APIGatewayProxyEventV2, 4 | APIGatewayProxyResult, 5 | APIGatewayProxyStructuredResultV2, 6 | Callback, 7 | Context, 8 | } from "aws-lambda"; 9 | import middy from "@middy/core"; 10 | 11 | /** 12 | * Casted interface for APIGatewayProxyEvents as converted through the middleware 13 | */ 14 | export interface APIGatewayProxyEvent extends AWS_APIGatewayProxyEvent { 15 | /** 16 | * HTTP Request body, parsed from a JSON string into an object. 17 | */ 18 | body: any | null; 19 | 20 | /** 21 | * HTTP Path Parameters, always defined, never null 22 | */ 23 | pathParameters: { [name: string]: string }; 24 | 25 | /** 26 | * HTTP URL query string parameters, always defined, never null 27 | */ 28 | queryStringParameters: { [name: string]: string }; 29 | } 30 | 31 | export type APIGatewayProxyEventV1 = APIGatewayProxyEvent; 32 | 33 | /** 34 | * Casted interface for APIGatewayProxyEventsV2 as converted through the middleware 35 | */ 36 | export interface APIGatewayProxyEventV2 extends AWS_APIGatewayProxyEventV2 { 37 | /** 38 | * HTTP Request body, parsed from a JSON string into an object. 39 | */ 40 | body: any | null; 41 | 42 | /** 43 | * HTTP Path Parameters, always defined, never null 44 | */ 45 | pathParameters: { [name: string]: string }; 46 | 47 | /** 48 | * HTTP URL query string parameters, always defined, never null 49 | */ 50 | queryStringParameters: { [name: string]: string }; 51 | } 52 | 53 | export type APIGatewayProxyEventAnyVersion = 54 | | AWS_APIGatewayProxyEvent 55 | | APIGatewayProxyEvent 56 | | AWS_APIGatewayProxyEventV2 57 | | APIGatewayProxyEventV2; 58 | 59 | export type APIGatewayProxyResultAnyVersion = 60 | | APIGatewayProxyResult 61 | | APIGatewayProxyStructuredResultV2; 62 | 63 | /** LambdaUtils version of ProxyHandler for API Gateway v1 payload format */ 64 | export type AsyncProxyHandlerV1 = ( 65 | event: APIGatewayProxyEvent, 66 | context: Context, 67 | callback?: Callback, 68 | ) => Promise; 69 | /** LambdaUtils version of an API Gateway v1 payload handler wrapped with middy */ 70 | export type AsyncMiddyifedHandlerV1 = middy.MiddyfiedHandler< 71 | AWS_APIGatewayProxyEvent, 72 | APIGatewayProxyResult | object | void 73 | >; 74 | 75 | /** LambdaUtils version of ProxyHandler for API Gateway v2 payload format */ 76 | export type AsyncProxyHandlerV2 = ( 77 | event: APIGatewayProxyEventV2, 78 | context: Context, 79 | callback?: Callback, 80 | ) => Promise; 81 | /** LambdaUtils version of an API Gateway v2 payload handler wrapped with middy */ 82 | export type AsyncMiddyifedHandlerV2 = middy.MiddyfiedHandler< 83 | AWS_APIGatewayProxyEventV2, 84 | APIGatewayProxyStructuredResultV2 | object | void 85 | >; 86 | -------------------------------------------------------------------------------- /logger/lib/common.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | NONE = 1, 3 | ERROR, 4 | WARN, 5 | INFO, 6 | DEBUG, 7 | } 8 | 9 | export enum LogFormat { 10 | FLAT = 1, 11 | PRETTY, 12 | STRUCT, 13 | } 14 | 15 | export type LoggerAttributes = Record; 16 | 17 | /** 18 | * Signature of a Formatter function. 19 | * @param loggerConfig configuration of Logger instance 20 | * @param globalConfig global configuration 21 | * @param level logging level 22 | * @param message text to log 23 | * @param params A list of JavaScript objects to output. 24 | * @return array to pass to a console function 25 | */ 26 | export type FormatterFn = ( 27 | loggerConfig: LoggerConfig, 28 | globalConfig: LoggerConfig, 29 | level: LogLevel, 30 | message: string, 31 | params: any[], 32 | ) => any[]; 33 | 34 | /** 35 | * Configuration of a Logger. 36 | * See individual properties for details. 37 | * The default behavior of some vary based on runtime environment. 38 | * Some properties may be initialized via environment variables. 39 | * Configuration for all loggers may be set via the Logger.initialize(config) function. 40 | * Overrides for individual Loggers may be given in the constructor. 41 | */ 42 | export interface LoggerConfig { 43 | /** 44 | * Source module of the logger - prepended to each line. 45 | * Source file name or class name are good choices, but can be any label. 46 | */ 47 | module: string; 48 | 49 | /** 50 | * Enabled logging level. 51 | * May be initialized via LOG_LEVEL environment variable. 52 | */ 53 | level: LogLevel; 54 | 55 | /** Any additional context attributes to include with _structured_ format (only). */ 56 | attributes?: LoggerAttributes; 57 | 58 | /** 59 | * Callback on every output to get real-time attributes. 60 | * With FLAT and PRETTY log formats, only the values (not keys) are output. 61 | */ 62 | attributesCallback?: () => LoggerAttributes; 63 | 64 | /** 65 | * Include the level in log output? 66 | * Defaults to true if not streaming to CloudWatch; 67 | * always included with _structured_ format. 68 | */ 69 | outputLevels: boolean; 70 | 71 | /** 72 | * Include timestamps in log output? 73 | * Defaults to false if streaming to CloudWatch (CloudWatch provides timestamps.), 74 | * true otherwise; always included with _structured_ format. 75 | * May override by setting the LOG_TIMESTAMPS environment variable to 'true' or 'false'. 76 | */ 77 | logTimestamps: boolean; 78 | 79 | /** 80 | * Output format to use. 81 | * Defaults to FLAT if streaming to CloudWatch, PRETTY otherwise. 82 | * (Best to let CloudWatch provide its own pretty formatting.) 83 | * May initialize by setting the LOG_FORMAT environment variable to 84 | * "FLAT", "PRETTY", or "STRUCT". 85 | */ 86 | format: LogFormat; 87 | 88 | /** 89 | * Function use to format output. Set based on format property, but may 90 | * be programmatically set instead. 91 | */ 92 | formatter: FormatterFn; 93 | } 94 | -------------------------------------------------------------------------------- /elasticsearch-client/lib/elasticsearch-client.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@sailplane/logger"; 2 | import { AwsHttps, AwsHttpsOptions } from "@sailplane/aws-https"; 3 | 4 | const logger = new Logger("elasticsearch-client"); 5 | 6 | /** 7 | * All-inclusive possible properties of returned results from ElasticsearchClient 8 | */ 9 | export interface ElasticsearchResult { 10 | _shards?: { 11 | total: number; 12 | successful: number; 13 | failed: number; 14 | skipped?: number; 15 | }; 16 | _index?: string; 17 | _type?: string; 18 | _id?: string; 19 | _version?: number; 20 | result?: "created" | "deleted" | "noop"; 21 | 22 | // GET one item 23 | found?: boolean; 24 | _source?: any; 25 | 26 | // GET _search 27 | took?: number; 28 | timed_out?: boolean; 29 | hits?: { 30 | total: number; 31 | max_score: number | null; 32 | hits?: [ 33 | { 34 | _index: string; 35 | _type: string; 36 | _id: string; 37 | _score: number; 38 | _source?: any; 39 | }, 40 | ]; 41 | }; 42 | 43 | // POST _delete_by_query 44 | deleted?: number; 45 | failures?: any[]; 46 | } 47 | 48 | /** 49 | * Lightweight Elasticsearch client for AWS. 50 | * 51 | * Suggested use with Injector: 52 | * Injector.register(ElasticsearchClient, () => { 53 | * const endpoint: string = process.env.ES_ENDPOINT!; 54 | * logger.info("Connecting to Elasticsearch @ " + endpoint); 55 | * return new ElasticsearchClient(new AwsHttps(), endpoint); 56 | * }); 57 | */ 58 | export class ElasticsearchClient { 59 | /** 60 | * Construct. 61 | * @param awsHttps injection of AwsHttps object to use. 62 | * @param {string} endpoint Elasticsearch endpoint host name 63 | */ 64 | constructor( 65 | private readonly awsHttps: AwsHttps, 66 | private readonly endpoint: string, 67 | ) {} 68 | 69 | /** 70 | * Send a request to Elasticsearch. 71 | * @param {"GET" | "DELETE" | "PUT" | "POST"} method 72 | * @param {string} path per Elasticsearch Document API 73 | * @param {any?} body request content as object, if any for the API 74 | * @returns {Promise} response from Elasticsearch. An HTTP 404 75 | * response is translated into an ElasticsearchResult with found=false 76 | * @throws {Error{message,status,statusCode}} error if HTTP result is not 2xx or 404 77 | * or unable to parse response. Compatible with http-errors package. 78 | */ 79 | request( 80 | method: "DELETE" | "GET" | "PUT" | "POST", 81 | path: string, 82 | body?: any, 83 | ): Promise { 84 | const toSend: AwsHttpsOptions = { 85 | method: method, 86 | hostname: this.endpoint, 87 | path: path, 88 | headers: { 89 | accept: "application/json; charset=utf-8", 90 | "content-type": "application/json; charset=utf-8", 91 | }, 92 | timeout: 10000, //connection timeout milliseconds, 93 | body: body ? JSON.stringify(body) : undefined, 94 | awsSign: true, 95 | }; 96 | 97 | logger.info(`Elasticsearch request: ${method} ${path}`); 98 | 99 | return this.awsHttps.request(toSend).catch((err) => { 100 | if (err.statusCode === 404) { 101 | return { 102 | _shards: { total: 0, successful: 0, failed: 1 }, 103 | found: false, 104 | }; 105 | } else { 106 | throw err; 107 | } 108 | }); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /elasticsearch-client/lib/elasticsearch-client.test.ts: -------------------------------------------------------------------------------- 1 | import { ElasticsearchClient, ElasticsearchResult } from "./elasticsearch-client"; 2 | import { AwsHttps } from "@sailplane/aws-https"; 3 | 4 | describe("ElasticsearchClient", () => { 5 | test("request() with data success", async () => { 6 | // GIVEN 7 | const resultObj: ElasticsearchResult = { 8 | _shards: { total: 1, successful: 1, failed: 0 }, 9 | _index: "thing", 10 | found: true, 11 | }; 12 | const mockHttp = createMockHttp(Promise.resolve(resultObj)); 13 | const sut = new ElasticsearchClient(mockHttp, "hostdomain"); 14 | 15 | // WHEN 16 | const result = await sut.request("POST", "/thing", { s: "Hello" }); 17 | 18 | // THEN 19 | expect(result).toEqual(resultObj); 20 | expect(mockHttp.calls.length).toBe(1); 21 | expect(mockHttp.calls[0]).toEqual({ 22 | method: "POST", 23 | hostname: "hostdomain", 24 | path: "/thing", 25 | headers: { 26 | accept: "application/json; charset=utf-8", 27 | "content-type": "application/json; charset=utf-8", 28 | }, 29 | timeout: 10000, 30 | body: '{"s":"Hello"}', 31 | awsSign: true, 32 | }); 33 | }); 34 | 35 | test("request() without data success", async () => { 36 | // GIVEN 37 | const resultObj: ElasticsearchResult = { 38 | _shards: { total: 1, successful: 1, failed: 0 }, 39 | _index: "thing", 40 | found: true, 41 | }; 42 | const mockHttp = createMockHttp(Promise.resolve(resultObj)); 43 | const sut = new ElasticsearchClient(mockHttp, "hostdomain"); 44 | 45 | // WHEN 46 | const result = await sut.request("GET", "/thing"); 47 | 48 | // THEN 49 | expect(result).toEqual(resultObj); 50 | expect(mockHttp.calls.length).toBe(1); 51 | expect(mockHttp.calls[0]).toEqual({ 52 | method: "GET", 53 | hostname: "hostdomain", 54 | path: "/thing", 55 | headers: { 56 | accept: "application/json; charset=utf-8", 57 | "content-type": "application/json; charset=utf-8", 58 | }, 59 | timeout: 10000, 60 | body: undefined, 61 | awsSign: true, 62 | }); 63 | }); 64 | 65 | test("request() not found", async () => { 66 | // GIVEN 67 | const resultObj: ElasticsearchResult = { 68 | _shards: { total: 0, successful: 0, failed: 1 }, 69 | found: false, 70 | }; 71 | const mockHttp = createMockHttp(Promise.reject({ statusCode: 404 })); 72 | const sut = new ElasticsearchClient(mockHttp, "hostdomain"); 73 | 74 | // WHEN 75 | const result = await sut.request("GET", "/thing"); 76 | 77 | // THEN 78 | expect(result).toEqual(resultObj); 79 | expect(mockHttp.calls.length).toBe(1); 80 | }); 81 | 82 | test("request() server failure", async () => { 83 | // GIVEN 84 | const mockHttp = createMockHttp(Promise.reject({ statusCode: 500 })); 85 | const sut = new ElasticsearchClient(mockHttp, "hostdomain"); 86 | 87 | // WHEN 88 | const result = sut.request("GET", "/thing"); 89 | 90 | // THEN 91 | await expect(result).rejects.toEqual({ statusCode: 500 }); 92 | expect(mockHttp.calls.length).toBe(1); 93 | }); 94 | }); 95 | 96 | function createMockHttp(result: Promise): AwsHttps & { calls: Array } { 97 | const calls: Array = []; 98 | return { 99 | calls: calls, 100 | request: (options: any) => { 101 | calls.push(options); 102 | return result; 103 | }, 104 | } as any; 105 | } 106 | -------------------------------------------------------------------------------- /expiring-value/lib/expiring-value.test.ts: -------------------------------------------------------------------------------- 1 | import * as MockDate from "mockdate"; 2 | import { ExpiringValue } from "./expiring-value"; 3 | 4 | describe("ExpiringValue", () => { 5 | const baseDate = Date.now(); 6 | 7 | beforeEach(() => { 8 | MockDate.set(baseDate); 9 | }); 10 | afterEach(() => { 11 | MockDate.reset(); 12 | }); 13 | 14 | // This is all one test, because the steps build upon each other. 15 | test("progressive test", async () => { 16 | let factoryValue = "hello"; 17 | 18 | // Initialize 19 | const sut = new ExpiringValue(() => Promise.resolve(factoryValue), 1000); 20 | expect(sut["value"]).toBeUndefined(); 21 | expect(sut["expiration"]).toEqual(0); 22 | 23 | // First GET - lazy created 24 | const v1 = await sut.get(); 25 | expect(v1).toBe("hello"); 26 | await expect(sut["value"]).resolves.toBe("hello"); 27 | expect(sut["expiration"]).toEqual(baseDate + 1000); 28 | 29 | // Second GET - already have value 30 | factoryValue = "error"; // if factory is called again, we won't get expected value 31 | const v2 = await sut.get(); 32 | expect(v2).toBe("hello"); 33 | await expect(sut["value"]).resolves.toBe("hello"); 34 | expect(sut["expiration"]).toEqual(baseDate + 1000); 35 | 36 | // Passage of time... 37 | MockDate.set(baseDate + 1001); 38 | factoryValue = "world"; 39 | 40 | // Check that value is expired 41 | expect(sut.isExpired()).toBeTruthy(); 42 | 43 | // Third GET - factory called again 44 | const v3 = await sut.get(); 45 | expect(v3).toBe("world"); 46 | await expect(sut["value"]).resolves.toBe("world"); 47 | expect(sut["expiration"]).toEqual(baseDate + 1001 + 1000); 48 | 49 | // Check that value is not expired 50 | expect(sut.isExpired()).toBeFalsy(); 51 | 52 | // Clear content.. 53 | sut.clear(); 54 | expect(sut["value"]).toBeUndefined(); 55 | expect(sut["expiration"]).toEqual(0); 56 | 57 | // Fourth GET - factory called again 58 | MockDate.set(baseDate); 59 | factoryValue = "world!"; 60 | 61 | const v4 = await sut.get(); 62 | expect(v4).toBe("world!"); 63 | await expect(sut["value"]).resolves.toBe("world!"); 64 | expect(sut["expiration"]).toEqual(baseDate + 1000); 65 | }); 66 | 67 | test("doesn't cache failure", async () => { 68 | let factoryResponse: Promise = Promise.reject(new Error()); 69 | 70 | // Initialize 71 | const sut = new ExpiringValue(() => factoryResponse, 1000); 72 | expect(sut["value"]).toBeUndefined(); 73 | expect(sut["expiration"]).toEqual(0); 74 | 75 | // First GET - rejects - still expired 76 | await expect(sut.get()).rejects.toThrow(); 77 | expect(sut["expiration"]).toEqual(0); 78 | 79 | // Second GET - calls again, success this time 80 | factoryResponse = Promise.resolve("yay"); 81 | const v2 = await sut.get(); 82 | expect(v2).toBe("yay"); 83 | await expect(sut["value"]).resolves.toBe("yay"); 84 | expect(sut["expiration"]).toEqual(baseDate + 1000); 85 | }); 86 | 87 | test("does cache failure when option selected", async () => { 88 | let factoryResponse: Promise = Promise.reject(new Error()); 89 | 90 | // Initialize 91 | const sut = new ExpiringValue(() => factoryResponse, 1000, { cacheError: true }); 92 | expect(sut["value"]).toBeUndefined(); 93 | expect(sut["expiration"]).toEqual(0); 94 | 95 | // First GET - rejects - still expired 96 | await expect(sut.get()).rejects.toThrow(); 97 | expect(sut["expiration"]).toEqual(baseDate + 1000); 98 | 99 | // Second GET - calls again, uses cached failure 100 | factoryResponse = Promise.resolve("yay"); 101 | await expect(sut.get()).rejects.toThrow(); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sailplane - AWS Serverless Node.js Utilities in Javascript and Typescript 2 | 3 | ![](docs/sailplane.png) 4 | 5 | ## What is this? 6 | 7 | While developing serverless applications at Onica (now part of [Rackspace Technology](https://www.rackspace.com)), 8 | we found certain patterns being used repeatedly, and code being copied from one project to the next. 9 | These commonalities have been extracted, matured, and gathered into a reusable collection. 10 | 11 | Sailplane is the result: a collection of useful packages for use in developing code that runs in AWS. 12 | They are primarily designed for use in Lambda functions, but most are useful in other environments 13 | that use the Node.js 20+ runtime as well. `ExpiringValue` is even useful in web browsers. 14 | 15 | The Typescript source is compiled to ES2020 Javascript and distributed with both ESModule and CommonJS 16 | modules for portability, along with Typescript type definition files and map files. 17 | While the typing provides the expected benefit, these utilities may be used in plain 18 | Javascript as well. 19 | 20 | Every tool is the genesis of real world needs, and they continue to evolve. 21 | This collection is part of Rackspace Technology's commitment to give back to the open source community. 22 | Find this and other Rackspace open source repositories on [GitHub](https://github.com/rackspace). 23 | 24 | ## Content 25 | 26 | Each utility is described on its own page: 27 | 28 | - [AwsHttps - HTTPS client with AWS Signature v4](docs/aws_https.md) 29 | - [ElasticsearchClient - Communicate with AWS Elasticsearch](docs/elasticsearch_client.md) 30 | - [ExpiringValue - Value that is instantiated on-demand and cached for a limited time](docs/expiring_value.md) 31 | - [Injector - Light-weight and type-safe Dependency Injection](docs/injector.md) 32 | - [LambdaUtils - AWS Lambda handler middleware](docs/lambda_utils.md) 33 | - [Logger - CloudWatch and serverless-offline friendly logger](docs/logger.md) 34 | - [StateStorage - Serverless state and configuration storage](docs/state_storage.md) 35 | - [More Examples](docs/examples.md) 36 | - [License](docs/license.md) 37 | 38 | ## Why "Sailplane"? 39 | 40 | Onica's early OSS releases have had aviation themed names; 41 | this may or may not have something to do with the CTO being a pilot. Nobody really knows. 42 | 43 | Sailplane was selected for this _serverless_ toolset by considering that 44 | serverless is to computing without the complexities of a server, 45 | as a sailplane is to flight without the complexities of an airplane. 46 | 47 | And that's it. Also, the NPM scope was available. 48 | 49 | ## Development 50 | 51 | This is a monorepo with shared development tools at the root level. Each subdirectory is a 52 | project. Use the `npm run` scripts in each package, or from the root workspace to run the 53 | script on all packages. 54 | 55 | ### Making Changes 56 | 57 | 1. Create an [issue in Github](https://github.com/rackspace/sailplane/issues). Get approval from the community. 58 | 2. Create a branch off of `main`. The branch name should be like `issue/-brief-summary` 59 | 3. Make your change and test it thoroughly with unit tests and a project using it. 60 | 4. Run `npm run analyze` from the root workspace and resolve all errors. 61 | 5. Commit to your git branch and open a [pull request](https://github.com/rackspace/sailplane/pulls). 62 | - Do not change the version in `package.json`. 63 | 64 | ### Publish a Release 65 | 66 | This is managed from each library package, as they are individually released to NPM. 67 | 68 | 1. Run `npm run clean && npm run analyze` to confirm that all code builds and tests pass. 69 | 2. Use `npm version ` to bump the version and tag it in git. ([docs](https://docs.npmjs.com/cli/v10/commands/npm-version)) 70 | 3. Use `npm publish` to publish the change to NPM. You must have credentials. ([docs](https://docs.npmjs.com/cli/v10/commands/npm-publish)) 71 | 4. Commit & Push updates to git. 72 | -------------------------------------------------------------------------------- /docs/aws_https.md: -------------------------------------------------------------------------------- 1 | # AwsHttps 2 | 3 | HTTPS client with AWS Signature v4. 4 | 5 | ## Overview 6 | 7 | The AwsHttps class is an HTTPS (notice, _not_ HTTP) client purpose made for use in and with AWS environments. 8 | 9 | - Simple Promise or async syntax 10 | - Optionally authenticates to AWS via AWS Signature v4 using [aws4](https://www.npmjs.com/package/aws4) 11 | - Familiar [options](https://nodejs.org/api/http.html#http_http_request_options_callback) 12 | - Helper to build request options from URL object 13 | - Light-weight 14 | - Easily extended for unit testing 15 | 16 | AwsHttps is dependent on Sailplane [logger](logger.md) and [AWS4](https://github.com/mhart/aws4) for signing. 17 | 18 | ## Install 19 | 20 | ```shell 21 | npm install @sailplane/aws-https @sailplane/logger 22 | ``` 23 | 24 | ## API Documentation 25 | 26 | [API Documentation on jsDocs.io](https://www.jsdocs.io/package/@sailplane/aws-https) 27 | 28 | ## Examples 29 | 30 | Simple example to GET from URL: 31 | 32 | ```ts 33 | const url = new URL("https://www.rackspace.com/ping.json"); 34 | const http = new AwsHttps(); 35 | 36 | // Build request options from a method and URL 37 | const options = http.buildOptions("GET", url); 38 | 39 | // Make request and parse JSON response. 40 | const ping = await http.request(options); 41 | ``` 42 | 43 | Example hitting API with the container's AWS credentials: 44 | 45 | ```ts 46 | const awsHttp = new AwsHttps(); 47 | const options: AwsHttpsOptions = { 48 | // Same options as https://nodejs.org/api/http.html#http_http_request_options_callback 49 | method: "GET", 50 | hostname: apiEndpoint, 51 | path: "/cloud-help", 52 | headers: { 53 | accept: "application/json; charset=utf-8", 54 | "content-type": "application/json; charset=utf-8", 55 | }, 56 | timeout: 10000, 57 | 58 | // Additional option for POST, PUT, or PATCH: 59 | body: JSON.stringify({ website: "https://www.rackspace.com" }), 60 | 61 | // Additional option to apply AWS Signature v4 62 | awsSign: true, 63 | }; 64 | 65 | try { 66 | const responseObj = await awsHttp.request(options); 67 | process(responseObj); 68 | } catch (err) { 69 | // HTTP status response is in statusCode field 70 | if (err.statusCode === 404) { 71 | process(undefined); 72 | } else { 73 | throw err; 74 | } 75 | } 76 | ``` 77 | 78 | Example hitting API with the custom AWS credentials: 79 | 80 | ```ts 81 | // Call my helper function to get credentials with AWS.STS 82 | const roleCredentials = await this.getAssumeRoleCredentials(); 83 | 84 | const awsCredentials = { 85 | accessKey: roleCredentials.AccessKeyId, 86 | secretKey: roleCredentials.SecretAccessKey, 87 | sessionToken: roleCredentials.SessionToken, 88 | }; 89 | const http = new AwsHttps(false, awsCredentials); 90 | 91 | // Build request options from a method and URL 92 | const url = new URL("https://www.rackspace.com/ping.json"); 93 | const options = http.buildOptions("GET", url); 94 | 95 | // Make request and parse JSON response. 96 | const ping = await http.request(options); 97 | ``` 98 | 99 | The Sailplane [ElasticsearchClient](elasticsearch_client.md) package is a simple example using `AwsHttps`. 100 | 101 | ## Unit testing your services 102 | 103 | - Have your service receive `AwsHttps` in the constructor. Consider using Sailplane [Injector](injector.md). 104 | - In your service unit tests, create a new class that extends AwsHttps and returns your canned response. 105 | - Pass your fake `AwsHttps` class into the constructor of your service under test. 106 | 107 | ```ts 108 | export class AwsHttpsFake extends AwsHttps { 109 | constructor() { 110 | super(); 111 | } 112 | 113 | async request(options: AwsHttpsOptions): Promise { 114 | // Check for expected options. Example: 115 | expect(options.path).toEqual("/expected-path"); 116 | 117 | // Return canned response 118 | return Promise.resolve({ success: true }); 119 | } 120 | } 121 | ``` 122 | -------------------------------------------------------------------------------- /state-storage/lib/state-storage.ts: -------------------------------------------------------------------------------- 1 | import { SSMClient, GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm"; 2 | import { Logger } from "@sailplane/logger"; 3 | 4 | const logger = new Logger("state-storage"); 5 | 6 | interface StateStorageOptions { 7 | /** If true, do not log values. */ 8 | quiet?: boolean; 9 | /** 10 | * If true, store as encrypted or decrypt on get. Uses account default KMS key. 11 | * Implies quiet as well. 12 | */ 13 | secure?: boolean; 14 | /** 15 | * If set, set and get the value as is, not JSON. (Only works for string values.) 16 | */ 17 | isRaw?: boolean; 18 | } 19 | 20 | /** 21 | * Service for storing state of other services. 22 | * Saved state can be fetched by any other execution of code in the AWS account, region, 23 | * and environment (dev/prod). 24 | * 25 | * Suggested use with Injector: 26 | * Injector.register(StateStorage, () => new StateStorage(process.env.STATE_STORAGE_PREFIX)); 27 | */ 28 | export class StateStorage { 29 | /** 30 | * Construct 31 | * 32 | * @param namePrefix prefix string to start all parameter names with. 33 | * Should at least include the environment (dev/prod). 34 | * @param ssm the SSMClient to use 35 | */ 36 | constructor( 37 | private readonly namePrefix: string, 38 | /* istanbul ignore next - default never used when unit testing */ 39 | private readonly ssm = new SSMClient({}), 40 | ) { 41 | if (!this.namePrefix.endsWith("/")) this.namePrefix = this.namePrefix + "/"; 42 | } 43 | 44 | /** 45 | * Save state for a later run. 46 | * 47 | * @param {string} service name of the service (class name?) that owns the state 48 | * @param {string} name name of the state variable to save 49 | * @param value content to save 50 | * @param optionsOrQuiet a StateStorageOptions, or if true sets quiet option. (For backward compatibility.) 51 | * @returns {Promise} completes upon success - rejects if lacking ssm:PutParameter permission 52 | */ 53 | set( 54 | service: string, 55 | name: string, 56 | value: any, 57 | optionsOrQuiet: boolean | StateStorageOptions = {}, 58 | ): Promise { 59 | const options = 60 | optionsOrQuiet === true ? { quiet: true } : (optionsOrQuiet as StateStorageOptions); 61 | const key = this.generateName(service, name); 62 | const content = options.isRaw === true ? value : JSON.stringify(value); 63 | 64 | if (options.quiet || options.secure) { 65 | logger.info(`Saving state ${key}`); 66 | } else { 67 | logger.info(`Saving state ${key}=${content}`); 68 | } 69 | 70 | const command = new PutParameterCommand({ 71 | Name: key, 72 | Type: options.secure ? "SecureString" : "String", 73 | Value: content, 74 | Overwrite: true, 75 | }); 76 | return this.ssm.send(command).then(() => undefined); 77 | } 78 | 79 | /** 80 | * Fetch last state saved. 81 | * 82 | * @param {string} service name of the service (class name?) that owns the state 83 | * @param {string} name name of the state variable to fetch 84 | * @param optionsOrQuiet a StateStorageOptions, or if true sets quiet option. (For backward compatibility.) 85 | * @returns {Promise} completes with the saved value, or reject if not found or lacking ssm:GetParameter permission 86 | */ 87 | get( 88 | service: string, 89 | name: string, 90 | optionsOrQuiet: boolean | StateStorageOptions = {}, 91 | ): Promise { 92 | const options = 93 | optionsOrQuiet === true ? { quiet: true } : (optionsOrQuiet as StateStorageOptions); 94 | const key = this.generateName(service, name); 95 | const command = new GetParameterCommand({ 96 | Name: key, 97 | WithDecryption: options.secure, 98 | }); 99 | 100 | return this.ssm.send(command).then((result) => { 101 | const content = result && result.Parameter ? result.Parameter.Value : undefined; 102 | 103 | if (options.quiet || options.secure) { 104 | logger.info(`Loaded state ${key}`); 105 | } else { 106 | logger.info(`Loaded state ${key}=${content}`); 107 | } 108 | 109 | return options.isRaw ? content : content ? JSON.parse(content) : undefined; 110 | }); 111 | } 112 | 113 | protected generateName(service: string, name: string): string { 114 | return this.namePrefix + service + "/" + name; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /aws-https/lib/aws-https-with-aws.test.ts: -------------------------------------------------------------------------------- 1 | // Set AWS credentials early to override any set in the calling environment. 2 | process.env.AWS_SECRET_ACCESS_KEY = "abc123"; 3 | process.env.AWS_ACCESS_KEY_ID = "deadbeaf"; 4 | delete process.env.AWS_SESSION_TOKEN; 5 | 6 | import { AwsHttps } from "./aws-https"; 7 | import nock from "nock"; 8 | 9 | describe("AwsHttps with AWS", () => { 10 | const serverHostname = "example.com"; 11 | const serverUrl = "https://" + serverHostname; 12 | 13 | afterEach(() => { 14 | nock.cleanAll(); 15 | }); 16 | 17 | describe("with provided AWS credentials", () => { 18 | test("does a signed POST", async () => { 19 | // // GIVEN 20 | const awsCredentials = { 21 | accessKeyId: "ACCESS-KEY-ID", 22 | secretAccessKey: "SECRET-ACCESS-KEY", 23 | sessionToken: "SESSION-TOKEN", 24 | }; 25 | 26 | const body = JSON.stringify({ cursor: 3, map: "Irvine" }); 27 | const response = { success: true }; 28 | const scope = nock(serverUrl, { 29 | reqheaders: { 30 | "X-Amz-Date": /20......T......Z/, 31 | Authorization: 32 | /AWS4-HMAC-SHA256 Credential=ACCESS-KEY-ID.*, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-security-token, Signature=.*/, 33 | }, 34 | }) 35 | .post("/test", body) 36 | .reply(200, response); 37 | const sut = new AwsHttps(true, awsCredentials); 38 | expect(AwsHttps["credentialsInitializedPromise"]).toBeUndefined(); 39 | expect(sut["awsCredentials"]).toBe(awsCredentials); 40 | 41 | // WHEN 42 | const reply = await sut.request({ 43 | protocol: "https:", 44 | method: "POST", 45 | hostname: serverHostname, 46 | path: "/test", 47 | body: body, 48 | awsSign: true, 49 | headers: { "Content-Type": "application/json" }, 50 | }); 51 | 52 | // THEN 53 | expect(reply).toEqual(response); 54 | expect(scope.isDone()).toBeTruthy(); 55 | expect(AwsHttps["credentialsInitializedPromise"]).toBeUndefined(); 56 | expect(sut["awsCredentials"]).toBe(awsCredentials); 57 | }); 58 | }); 59 | 60 | describe("with process AWS credentials", () => { 61 | const body = JSON.stringify({ cursor: 3, map: "Irvine" }); 62 | const requestOptions = { 63 | protocol: "https:", 64 | method: "POST", 65 | hostname: serverHostname, 66 | path: "/test", 67 | body: body, 68 | awsSign: true, 69 | headers: { "Content-Type": "application/json" }, 70 | }; 71 | const response = { success: true }; 72 | let scope: nock.Scope; 73 | 74 | beforeEach(() => { 75 | scope = nock(serverUrl, { 76 | reqheaders: { 77 | "X-Amz-Date": /20......T......Z/, 78 | Authorization: 79 | /AWS4-HMAC-SHA256 Credential=deadbeaf.*, SignedHeaders=content-length;content-type;host;x-amz-date, Signature=.*/, 80 | }, 81 | }) 82 | .post("/test", body) 83 | .reply(200, response); 84 | }); 85 | 86 | test("does first signed POST", async () => { 87 | // // GIVEN 88 | const sut = new AwsHttps(true); 89 | expect(AwsHttps["credentialsInitializedPromise"]).toBeUndefined(); 90 | expect(sut["awsCredentials"]).toBeUndefined(); 91 | 92 | // WHEN 93 | const reply = await sut.request(requestOptions); 94 | 95 | // THEN 96 | expect(reply).toEqual(response); 97 | expect(scope.isDone()).toBeTruthy(); 98 | expect(AwsHttps["credentialsInitializedPromise"]).toBeTruthy(); 99 | expect(sut["awsCredentials"]).toBeDefined(); 100 | }); 101 | 102 | test("does another signed POST; reuse creds", async () => { 103 | // // GIVEN 104 | const sut = new AwsHttps(true); 105 | expect(AwsHttps["credentialsInitializedPromise"]).toBeDefined(); 106 | expect(sut["awsCredentials"]).toBeUndefined(); 107 | 108 | // WHEN 109 | const reply = await sut.request(requestOptions); 110 | 111 | // THEN 112 | expect(reply).toEqual(response); 113 | expect(scope.isDone()).toBeTruthy(); 114 | expect(AwsHttps["credentialsInitializedPromise"]).toBeTruthy(); 115 | expect(sut["awsCredentials"]).toBeDefined(); 116 | }); 117 | 118 | test("resets credentials", async () => { 119 | const sut = new AwsHttps(false, true); 120 | expect(AwsHttps["credentialsInitializedPromise"]).toBeUndefined(); 121 | expect(sut["awsCredentials"]).toBeUndefined(); 122 | }); 123 | 124 | test("after reset, again do signed POST", async () => { 125 | // // GIVEN 126 | const sut = new AwsHttps(true); 127 | expect(AwsHttps["credentialsInitializedPromise"]).toBeUndefined(); 128 | expect(sut["awsCredentials"]).toBeUndefined(); 129 | 130 | // WHEN 131 | const reply = await sut.request(requestOptions); 132 | 133 | // THEN 134 | expect(reply).toEqual(response); 135 | expect(scope.isDone()).toBeTruthy(); 136 | expect(AwsHttps["credentialsInitializedPromise"]).toBeTruthy(); 137 | expect(sut["awsCredentials"]).toBeDefined(); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /lambda-utils/lib/handler-utils.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyResult } from "aws-lambda"; 2 | import middy from "@middy/core"; 3 | import cors from "@middy/http-cors"; 4 | import httpEventNormalizer from "@middy/http-event-normalizer"; 5 | import httpHeaderNormalizer from "@middy/http-header-normalizer"; 6 | import httpJsonBodyParser from "@middy/http-json-body-parser"; 7 | import { Logger } from "@sailplane/logger"; 8 | import { 9 | AsyncMiddyifedHandlerV1, 10 | AsyncMiddyifedHandlerV2, 11 | AsyncProxyHandlerV1, 12 | AsyncProxyHandlerV2, 13 | } from "./types"; 14 | import { resolvedPromiseIsSuccessMiddleware } from "./resolved-promise-is-success"; 15 | import { unhandledExceptionMiddleware } from "./unhandled-exception"; 16 | import { loggerContextMiddleware } from "./logger-context"; 17 | 18 | const logger = new Logger("lambda-utils"); 19 | 20 | /** 21 | * Wrap an API Gateway V1 format proxy lambda function handler to add features: 22 | * - Set CORS headers. 23 | * - Normalize incoming headers to lowercase 24 | * - If incoming content is JSON text, replace event.body with parsed object. 25 | * - Ensures that event.queryStringParameters and event.pathParameters are defined, 26 | * to avoid TypeErrors. 27 | * - Ensures that handler response is formatted properly as a successful 28 | * API Gateway result. 29 | * - Catch http-errors exceptions into proper HTTP responses. 30 | * - Catch other exceptions and return as HTTP 500 31 | * - Set Lambda invocation and API request context in @sailplane/logger 32 | * 33 | * This wrapper includes commonly useful middleware. You may further wrap it 34 | * with your own function that adds additional middleware, or just use it as 35 | * an example. 36 | * 37 | * @param handler async function to wrap 38 | * @see https://middy.js.org/#:~:text=available%20middlewares 39 | * @see https://www.npmjs.com/package/http-errors 40 | */ 41 | export function wrapApiHandler(handler: AsyncProxyHandlerV1): AsyncMiddyifedHandlerV1 { 42 | return middy(handler) 43 | .use(cors({ origin: "*" })) 44 | .use(resolvedPromiseIsSuccessMiddleware()) 45 | .use(unhandledExceptionMiddleware()) 46 | .use(loggerContextMiddleware()) 47 | .use(httpEventNormalizer()) 48 | .use(httpHeaderNormalizer()) 49 | .use( 50 | // Parse JSON body, but don't throw when there is no body 51 | httpJsonBodyParser({ disableContentTypeError: true }), 52 | ) as unknown as AsyncMiddyifedHandlerV1; 53 | } 54 | export const wrapApiHandlerV1 = wrapApiHandler; 55 | 56 | /** 57 | * Wrap an API Gateway V2 format proxy lambda function handler to add features: 58 | * - Set CORS headers. 59 | * - Normalize incoming headers to lowercase 60 | * - If incoming content is JSON text, replace event.body with parsed object. 61 | * - Ensures that event.queryStringParameters and event.pathParameters are defined, 62 | * to avoid TypeErrors. 63 | * - Ensures that handler response is formatted properly as a successful 64 | * API Gateway result. 65 | * - Catch http-errors exceptions into proper HTTP responses. 66 | * - Catch other exceptions and return as HTTP 500 67 | * - Set Lambda invocation and API request context in @sailplane/logger 68 | * 69 | * This wrapper includes commonly useful middleware. You may further wrap it 70 | * with your own function that adds additional middleware, or just use it as 71 | * an example. 72 | * 73 | * @param handler async function to wrap 74 | * @see https://middy.js.org/#:~:text=available%20middlewares 75 | * @see https://www.npmjs.com/package/http-errors 76 | */ 77 | export function wrapApiHandlerV2(handler: AsyncProxyHandlerV2): AsyncMiddyifedHandlerV2 { 78 | return middy(handler) 79 | .use(cors({ origin: "*" })) 80 | .use(resolvedPromiseIsSuccessMiddleware()) 81 | .use(unhandledExceptionMiddleware()) 82 | .use(loggerContextMiddleware()) 83 | .use(httpEventNormalizer()) 84 | .use(httpHeaderNormalizer()) 85 | .use( 86 | // Parse JSON body, but don't throw when there is no body 87 | httpJsonBodyParser({ disableContentTypeError: true }), 88 | ) as unknown as AsyncMiddyifedHandlerV2; 89 | } 90 | 91 | /** 92 | * Construct the object that API Gateway payload format v1 wants back 93 | * upon a successful run. (HTTP 200 Ok) 94 | * 95 | * This normally is not needed. If the response is simply the content to return as the 96 | * body of the HTTP response, you may simply return it from the handler given to 97 | * #wrapApiHandler(handler). It will automatically transform the result. 98 | * 99 | * @param result object to serialize into JSON as the response body 100 | * @returns {APIGatewayProxyResult} 101 | */ 102 | export function apiSuccess(result?: any): APIGatewayProxyResult { 103 | return { 104 | statusCode: 200, 105 | body: result ? JSON.stringify(result) : "", 106 | headers: { 107 | "content-type": result ? "application/json; charset=utf-8" : "text/plain; charset=utf-8", 108 | }, 109 | }; 110 | } 111 | 112 | /** 113 | * Construct the object that API Gateway payload format v1 wants back upon a failed run. 114 | * 115 | * Often, it is simpler to throw a http-errors exception from your #wrapApiHandler 116 | * handler. 117 | * 118 | * @see https://www.npmjs.com/package/http-errors 119 | * @param statusCode HTTP status code, between 400 and 599. 120 | * @param message string to return in the response body 121 | * @returns {APIGatewayProxyResult} 122 | */ 123 | export function apiFailure(statusCode: number, message?: string): APIGatewayProxyResult { 124 | const response = { 125 | statusCode, 126 | body: message || "", 127 | headers: { 128 | "content-type": "text/plain; charset=utf-8", 129 | }, 130 | }; 131 | 132 | logger.warn("Response to API Gateway: ", response); 133 | return response; 134 | } 135 | -------------------------------------------------------------------------------- /aws-https/lib/aws-https-no-aws.test.ts: -------------------------------------------------------------------------------- 1 | import { AwsHttps } from "./aws-https"; 2 | import nock from "nock"; 3 | import { URL } from "url"; 4 | import * as AWS from "aws-sdk"; 5 | 6 | describe("AwsHttps-No-AWS", () => { 7 | const serverHostname = "example.com"; 8 | const serverUrl = "https://" + serverHostname; 9 | 10 | beforeAll(() => { 11 | // Don't allow actual Internet access 12 | //nock.disableNetConnect(); 13 | }); 14 | 15 | afterAll(() => { 16 | nock.cleanAll(); 17 | nock.enableNetConnect(); 18 | }); 19 | 20 | test("request(sigv4) no AWS creds", async () => { 21 | // // GIVEN 22 | const sut = new AwsHttps(false); 23 | 24 | const originalAwsGetCredentials = AWS.config.getCredentials; 25 | AWS.config.getCredentials = (callback: (err: AWS.AWSError, credentials: any) => void) => { 26 | callback(new Error("Could not load credentials from any providers") as AWS.AWSError, null); 27 | }; 28 | 29 | // WHEN 30 | let exception: Error | null = null; 31 | try { 32 | await sut.request({ 33 | protocol: "https:", 34 | method: "GET", 35 | hostname: serverHostname, 36 | awsSign: true, 37 | }); 38 | } catch (err) { 39 | exception = err as Error; 40 | } finally { 41 | AWS.config.getCredentials = originalAwsGetCredentials; 42 | } 43 | 44 | // THEN 45 | expect(exception).toBeTruthy(); 46 | expect(exception!.message).toEqual("Could not load credentials from any providers"); 47 | }); 48 | 49 | test("request(GET /test?cloud=ONICA) success", async () => { 50 | // // GIVEN 51 | const response = { success: true }; 52 | const scope = nock(serverUrl) 53 | .get("/test") 54 | .query({ cloud: "ONICA" }) 55 | .matchHeader("accept", "application/json") 56 | .reply(200, response); 57 | const sut = new AwsHttps(); 58 | 59 | // WHEN 60 | const reply = await sut.request({ 61 | protocol: "https:", 62 | method: "GET", 63 | hostname: serverHostname, 64 | path: "/test?cloud=ONICA", 65 | headers: { accept: "application/json" }, 66 | }); 67 | 68 | // THEN 69 | expect(reply).toEqual(response); 70 | expect(scope.isDone()).toBeTruthy(); 71 | }); 72 | 73 | test("request(DELETE /deadline) no response body to parse", async () => { 74 | // // GIVEN 75 | const scope = nock(serverUrl).delete("/deadline").reply(200); 76 | const sut = new AwsHttps(true); 77 | 78 | // WHEN 79 | const reply = await sut.request({ 80 | protocol: "https:", 81 | method: "DELETE", 82 | hostname: serverHostname, 83 | path: "/deadline", 84 | }); 85 | 86 | // THEN 87 | expect(reply).toEqual(null); 88 | expect(scope.isDone()).toBeTruthy(); 89 | }); 90 | 91 | test("request(GET) bad JSON parse", async () => { 92 | // // GIVEN 93 | const scope = nock(serverUrl) 94 | .get("/test") 95 | .query({ cloud: "ONICA" }) 96 | .matchHeader("accept", "application/json") 97 | .reply(200, "success: false"); // invalid JSON 98 | const sut = new AwsHttps(true); 99 | 100 | // WHEN 101 | let exception: unknown; 102 | try { 103 | await sut.request({ 104 | protocol: "https:", 105 | method: "GET", 106 | hostname: serverHostname, 107 | path: "/test?cloud=ONICA", 108 | headers: { accept: "application/json" }, 109 | }); 110 | } catch (err) { 111 | exception = err; 112 | } 113 | 114 | // THEN 115 | expect(exception).toBeInstanceOf(SyntaxError); 116 | expect(scope.isDone()).toBeTruthy(); 117 | }); 118 | 119 | test("request(GET /on-premise) not found", async () => { 120 | // // GIVEN 121 | const response = { statusCode: 404, message: "Go serverless!" }; 122 | const scope = nock(serverUrl).get("/on-premise").reply(404, response); 123 | const sut = new AwsHttps(); 124 | 125 | // WHEN 126 | let exception: Error | null = null; 127 | try { 128 | await sut.request({ 129 | protocol: "https:", 130 | method: "GET", 131 | hostname: serverHostname, 132 | path: "/on-premise", 133 | headers: { accept: "application/json" }, 134 | }); 135 | } catch (err) { 136 | exception = err as Error; 137 | } 138 | 139 | // THEN 140 | expect(exception).toEqual(new Error("Failed to load content, status code: 404")); 141 | expect((exception as any).statusCode).toEqual(404); 142 | expect(scope.isDone()).toBeTruthy(); 143 | }); 144 | 145 | test("request(timeout)", async () => { 146 | // GIVEN 147 | const scope = nock(serverUrl).get("/test").delay(100).reply(200, { s: "unreachable" }); 148 | const sut = new AwsHttps(); 149 | 150 | // WHEN 151 | try { 152 | await sut.request({ 153 | protocol: "https:", 154 | method: "GET", 155 | hostname: serverHostname, 156 | path: "/test", 157 | timeout: 10, 158 | }); 159 | fail("expected to throw"); 160 | } catch (err: any) { 161 | // THEN 162 | expect(err.code).toEqual("ECONNRESET"); 163 | expect(scope.isDone()).toBeTruthy(); 164 | } 165 | }); 166 | 167 | test("buildOptions", () => { 168 | // GIVEN 169 | const url = new URL(serverUrl + "/experts?cloud=ONICA"); 170 | const sut = new AwsHttps(); 171 | 172 | // WHEN 173 | const options = sut.buildOptions("HEAD", url, 200); 174 | 175 | // THEN 176 | expect(options.protocol).toEqual("https:"); 177 | expect(options.method).toEqual("HEAD"); 178 | expect(options.hostname).toEqual(serverHostname); 179 | expect(options.port).toEqual(443); 180 | expect(options.path).toEqual("/experts?cloud=ONICA"); 181 | expect(options.timeout).toEqual(200); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /docs/logger.md: -------------------------------------------------------------------------------- 1 | # Logger 2 | 3 | CloudWatch, web browser, and local development friendly logger with optional structured logging, 4 | while still small and easy to use. 5 | 6 | ## Overview 7 | 8 | Sadly, `console.log` is the #1 debugging tool when writing serverless code. Logger extends it with levels, 9 | timestamps, context/category names, object formatting, and optional structured logging. 10 | It's just a few incremental improvements, and yet together takes logging a leap forward. 11 | 12 | If you are transpiling, be sure to enable source maps 13 | (in [Typescript](https://www.typescriptlang.org/docs/handbook/compiler-options.html), 14 | [Babel](https://babeljs.io/docs/en/options#source-map-options)) and then enable use via the 15 | [source-map-support](https://www.npmjs.com/package/source-map-support) library or 16 | [Node.js enable-source-maps option](https://nodejs.org/dist/latest-v18.x/docs/api/cli.html#--enable-source-maps>) 17 | so that you get meaningful stack traces. 18 | 19 | ## Install 20 | 21 | ```shell 22 | npm install @sailplane/logger 23 | ``` 24 | 25 | ## API Documentation 26 | 27 | [API Documentation on jsDocs.io](https://www.jsdocs.io/package/@sailplane/logger) 28 | 29 | ## Examples 30 | 31 | ```ts 32 | import { Logger, LogLevel } from "@sailplane/logger"; 33 | const logger = new Logger("name-of-module"); 34 | 35 | logger.info("Hello World!"); 36 | // INFO name-of-module: Hello World! 37 | 38 | Logger.initialize({ level: LogLevel.INFO }); 39 | logger.debug("DEBUG < INFO."); 40 | // No output 41 | 42 | Logger.initialize({ logTimestamps: true }); 43 | logger.info("Useful local log"); 44 | // 2018-11-15T18:26:20 INFO name-of-module: Useful local log 45 | 46 | logger.warn("Exception ", { message: "oops" }); 47 | // 2018-11-15T18:29:38 INFO name-of-module: Exception {message:"oops"} 48 | 49 | Logger.initialize({ format: "PRETTY" }); 50 | logger.error("Exception ", { message: "oops" }); 51 | // 2018-11-15T18:30:49 INFO name-of-module: Exception { 52 | // message: "oops" 53 | // } 54 | 55 | Logger.initialize({ 56 | format: "STRUCT", 57 | attributes: { my_trace_id: request.id }, 58 | }); 59 | logger.error("Processing Failed", new Error("Unreachable")); 60 | // { 61 | // "aws_region": "us-east-1", 62 | // "function_name": "myDataProcessor", 63 | // "function_version": "42", 64 | // "invocation_num": 1, 65 | // "my_trace_id": "ebfb6f2f-8f2f-4e2e-a0a9-4495e90a4316", 66 | // "stage": "prod", 67 | // "level": "ERROR", 68 | // "module": "name-of-module", 69 | // "timestamp": "2022-03-03T17:32:19.830Z", 70 | // "message": "Processing Failed", 71 | // "value": { 72 | // "name": "Error", 73 | // "message": "Unreachable", 74 | // "stack": "Error: Unreachable\n at /home/adam/my-project/src/service/processor.service.ts:83\n at ..." 75 | // "source": "/home/adam/my-project/src/service/processor.service.ts:83" 76 | // } 77 | // } 78 | ``` 79 | 80 | ## Configuration / Behavior 81 | 82 | The output of Logger varies based on some global settings and whether the Lambda is executing 83 | in AWS or local (serverless-offline, SAM offline). 84 | 85 | Default behavior should work for Lambdas. If you are using Logger in another container (EC2, Fargate, ...) 86 | you likely will want to adjust these settings. 87 | 88 | ### CloudWatch detection 89 | 90 | The default behaviors of some configuration change depending on whether log output is going 91 | to CloudWatch vs local console. This is because within the AWS Lambda service, logging to 92 | stdout is automatically prefixed with the log level and timestamp. Local console does not. 93 | So Logger adds these for you when a login shell (offline mode) is detected. You can force 94 | CloudWatch logging behavior via the 95 | environment variable `export LOG_TO_CLOUDWATCH=true` or `export LOG_TO_CLOUDWATCH=false` 96 | 97 | Note: The above is ignored when using structured logging. 98 | 99 | ### Recommendations 100 | 101 | The best logging format often depends on the environment/stage. It may be selected via the `LOG_FORMAT` 102 | environment variable. 103 | 104 | For local development, the default format is `PRETTY` and this is usually the most readable in a terminal window. 105 | 106 | For deployment to AWS in lower stage environments (ex: dev), the default is `FLAT` and is recommended. 107 | When viewed in CloudWatch AWS Console, it will handle pretty printing JSON output when a line is expanded. 108 | 109 | For higher environments (ex: production), the default is still `FLAT` but `STRUCT` is suggested to allow 110 | for analysis of massive log content, when using 111 | [Amazon CloudWatch Logs Insights](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AnalyzingLogData.html) 112 | or an [ELK stack](https://aws.amazon.com/opensearch-service/the-elk-stack/). 113 | 114 | ### Structured Logging Attributes 115 | 116 | When structured logging is used, the following properties are included: 117 | 118 | - `aws_region` - the AWS region name 119 | - `aws_request_id` - the AWS generated unique identifier of the request 120 | - `xray_trace_id` - AWS X-Ray trace ID (if available) 121 | - `function_name` - name of the logging AWS Lambda 122 | - `function_memory_size` - the capacity configuration of the Lambda, in memory megabytes 123 | - `invocation_num` - count of invocations of the Lambda handler since cold start (1 = first request since cold start) 124 | - `level` - logging level 125 | - `module` - source module of the Logger instance 126 | - `timestamp` - ISO8601 date-time in UTC when the line was logged 127 | - `message` - text message of the line (first parameter to log function) 128 | - `value` - if only two parameters are given to the log function, this is the second one 129 | - `params` - when more than two parameters are given, all after the message are in this array 130 | 131 | Sailplane's [LambdaUtils](lambda_utils.md) adds additional properties. 132 | -------------------------------------------------------------------------------- /state-storage/lib/state-storage.test.ts: -------------------------------------------------------------------------------- 1 | import { StateStorage } from "./state-storage"; 2 | import { SSMClient } from "@aws-sdk/client-ssm"; 3 | 4 | describe("StateStorage", () => { 5 | const mockSSMClient = { 6 | send: jest.fn(), 7 | }; 8 | let sut: StateStorage; 9 | 10 | describe("#set", () => { 11 | beforeEach(() => { 12 | mockSSMClient.send.mockReset(); 13 | sut = new StateStorage("/prefix/", mockSSMClient as unknown as SSMClient); 14 | }); 15 | 16 | test("store something noisily", async () => { 17 | // GIVEN 18 | mockSSMClient.send.mockResolvedValue({}); 19 | 20 | // WHEN 21 | await sut.set("service", "name", { value: "hello" }); 22 | 23 | // THEN 24 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 25 | expect(mockSSMClient.send).toHaveBeenCalledWith( 26 | expect.objectContaining({ 27 | input: { 28 | Name: "/prefix/service/name", 29 | Value: '{"value":"hello"}', 30 | Type: "String", 31 | Overwrite: true, 32 | }, 33 | }), 34 | ); 35 | }); 36 | 37 | test("store something quietly", async () => { 38 | // GIVEN 39 | mockSSMClient.send.mockResolvedValue({}); 40 | 41 | // WHEN 42 | await sut.set("service", "name", { value: "hello" }, true); 43 | 44 | // THEN 45 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 46 | expect(mockSSMClient.send).toHaveBeenCalledWith( 47 | expect.objectContaining({ 48 | input: { 49 | Name: "/prefix/service/name", 50 | Value: '{"value":"hello"}', 51 | Type: "String", 52 | Overwrite: true, 53 | }, 54 | }), 55 | ); 56 | }); 57 | 58 | test("store something as raw string", async () => { 59 | // GIVEN 60 | mockSSMClient.send.mockResolvedValue({}); 61 | 62 | // WHEN 63 | await sut.set("service", "name", "Goodbye", { quiet: true, isRaw: true }); 64 | 65 | // THEN 66 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 67 | expect(mockSSMClient.send).toHaveBeenCalledWith( 68 | expect.objectContaining({ 69 | input: { 70 | Name: "/prefix/service/name", 71 | Value: "Goodbye", 72 | Type: "String", 73 | Overwrite: true, 74 | }, 75 | }), 76 | ); 77 | }); 78 | 79 | // Repeat with quiet flag in order to achieve code coverage 80 | test("store something securely", async () => { 81 | // GIVEN 82 | mockSSMClient.send.mockResolvedValue({}); 83 | 84 | // WHEN 85 | await sut.set("service", "name", { value: "hello" }, { secure: true }); 86 | 87 | // THEN 88 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 89 | expect(mockSSMClient.send).toHaveBeenCalledWith( 90 | expect.objectContaining({ 91 | input: { 92 | Name: "/prefix/service/name", 93 | Value: '{"value":"hello"}', 94 | Type: "SecureString", 95 | Overwrite: true, 96 | }, 97 | }), 98 | ); 99 | }); 100 | }); 101 | 102 | describe("#get", () => { 103 | beforeEach(() => { 104 | mockSSMClient.send.mockReset(); 105 | sut = new StateStorage("/prefix", mockSSMClient as any as SSMClient); 106 | }); 107 | 108 | test("fetch something noisily", async () => { 109 | // GIVEN 110 | mockSSMClient.send.mockResolvedValue({ 111 | Parameter: { 112 | Value: '{"value":"hello"}', 113 | }, 114 | }); 115 | 116 | // WHEN 117 | const result = await sut.get("service", "name"); 118 | 119 | // THEN 120 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 121 | expect(mockSSMClient.send).toHaveBeenCalledWith( 122 | expect.objectContaining({ 123 | input: { 124 | Name: "/prefix/service/name", 125 | }, 126 | }), 127 | ); 128 | expect(result.value).toEqual("hello"); 129 | }); 130 | 131 | test("fetch missing something quietly", async () => { 132 | // GIVEN 133 | mockSSMClient.send.mockResolvedValue({}); 134 | 135 | // WHEN 136 | const result = await sut.get("service", "name", true); 137 | 138 | // THEN 139 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 140 | expect(mockSSMClient.send).toHaveBeenCalledWith( 141 | expect.objectContaining({ 142 | input: { Name: "/prefix/service/name" }, 143 | }), 144 | ); 145 | expect(result).toBeUndefined(); 146 | }); 147 | 148 | test("fetch something as raw string", async () => { 149 | // GIVEN 150 | mockSSMClient.send.mockResolvedValue({ 151 | Parameter: { 152 | Value: '{"value":"hello"}', 153 | }, 154 | }); 155 | 156 | // WHEN 157 | const result = await sut.get("service", "name", { isRaw: true }); 158 | 159 | // THEN 160 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 161 | expect(mockSSMClient.send).toHaveBeenCalledWith( 162 | expect.objectContaining({ 163 | input: { Name: "/prefix/service/name" }, 164 | }), 165 | ); 166 | expect(result).toEqual('{"value":"hello"}'); 167 | }); 168 | 169 | test("fetch something securely", async () => { 170 | // GIVEN 171 | mockSSMClient.send.mockResolvedValue({ 172 | Parameter: { 173 | Value: '{"value":"hello"}', 174 | }, 175 | }); 176 | 177 | // WHEN 178 | const result = await sut.get("service", "name", { secure: true }); 179 | 180 | // THEN 181 | expect(mockSSMClient.send).toHaveBeenCalledTimes(1); 182 | expect(mockSSMClient.send).toHaveBeenCalledWith( 183 | expect.objectContaining({ 184 | input: { 185 | Name: "/prefix/service/name", 186 | WithDecryption: true, 187 | }, 188 | }), 189 | ); 190 | expect(result.value).toEqual("hello"); 191 | }); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /docs/lambda_utils.md: -------------------------------------------------------------------------------- 1 | # LambdaUtils 2 | 3 | Lambda handler middleware. 4 | 5 | ## Overview 6 | 7 | There's a lot of boilerplate in Lambda handlers. This collection of utility functions leverages the great 8 | [Middy](https://middy.js.org) library to add middleware functionality to Lambda handlers. 9 | You can extend it with your own middleware. 10 | 11 | Middy gives you a great start as a solid middleware framework, 12 | but by itself you are still repeating the middleware registrations 13 | on each handler, its exception handler only works with errors created by the http-errors package, 14 | its Typescript declarations are overly permissive, 15 | and you still have to format your response in the shape required by API Gateway. 16 | 17 | `LambdaUtils` takes Middy further and is extendable so that you can add your own middleware 18 | (ex: authentication & authorization) on top of it. 19 | 20 | Used with API Gateway v1 (REST API) and v2 (HTTP API), the included middlewares are: 21 | 22 | - Set CORS headers. 23 | - Normalize incoming headers to mixed-case 24 | - If incoming content is JSON text, replaces event.body with parsed object. 25 | - Ensures that event.queryStringParameters and event.pathParameters are defined, to avoid TypeErrors. 26 | - Ensures that handler response is formatted properly as a successful API Gateway result. 27 | - Unique to LambdaUtils! 28 | - Simply return what you want as the body of the HTTP response. 29 | - Catch http-errors exceptions into proper HTTP responses. 30 | - Catch other exceptions and return as HTTP 500. 31 | - Unique to LambdaUtils! 32 | - Registers Lambda context with Sailplane's [Logger](logger.md) for structured logging. (Detail below.) 33 | - Fully leverages Typescript and async syntax. 34 | 35 | See [Middy middlewares](https://middy.js.org/docs/category/middlewares) for details on those. 36 | Not all Middy middlewares are in this implementation, only common ones that are generally useful in all 37 | APIs. You may extend LambdaUtils's `wrapApiHandler()` function in your projects, 38 | or use it as an example to write your own, to add more middleware! 39 | 40 | `LambdaUtils` depends on two other utilities to work: 41 | 42 | - Sailplane [Logger](logger.md) 43 | - [Middy](https://middy.js.org) 44 | 45 | ## Install 46 | 47 | ### LambdaUtils v7.x with Middy v6.x.x (latest) 48 | 49 | **Works best with ES Modules, not CommonJS.** See [Middy Upgrade Notes](https://middy.js.org/docs/upgrade/5-6). 50 | 51 | ```shell 52 | npm install @sailplane/lambda-utils@7 @sailplane/logger @middy/core@6 @middy/http-cors@6 @middy/http-event-normalizer@6 @middy/http-header-normalizer@6 @middy/http-json-body-parser@6 53 | ``` 54 | 55 | The extra `@middy/` middleware packages are optional if you write your own wrapper function that does not use them. 56 | See below. 57 | 58 | ### LambdaUtils v6.x with Middy v4.x.x 59 | 60 | ```shell 61 | npm install @sailplane/lambda-utils@6 @sailplane/logger @middy/core@4 @middy/http-cors@4 @middy/http-event-normalizer@4 @middy/http-header-normalizer@4 @middy/http-json-body-parser@4 62 | ``` 63 | 64 | The extra `@middy/` middleware packages are optional if you write your own wrapper function that does not use them. 65 | See below. 66 | 67 | ### LambdaUtils v4.x or v5.x with Middy v2.x.x 68 | 69 | ```shell 70 | npm install @sailplane/lambda-utils@4 @sailplane/logger @middy/core@2 @middy/http-cors@2 @middy/http-event-normalizer@2 @middy/http-header-normalizer@2 @middy/http-json-body-parser@2 71 | ``` 72 | 73 | The extra @middy/ middleware packages are optional if you write your own wrapper function that does not use them. 74 | See below. 75 | 76 | ### LambdaUtils v3.x with Middy v1.x.x 77 | 78 | ```shell 79 | npm install @sailplane/lambda-utils@3 @sailplane/logger @middy/core@1 @middy/http-cors@1 @middy/http-event-normalizer@1 @middy/http-header-normalizer@1 @middy/http-json-body-parser@1 80 | ``` 81 | 82 | The extra @middy/ middleware packages are optional if you write your own wrapper function that does not use them. 83 | See below. 84 | 85 | ## Upgrading 86 | 87 | To upgrade from older versions of lambda-utils, remove the old lambda-utils and middy dependencies 88 | and then follow the installation instructions above to install the latest. See also the 89 | [Middy upgrade instructions](https://middy.js.org/docs/category/upgrade). 90 | 91 | ## Structured Logging Attributes 92 | 93 | When [structured logging](logger.md) is enabled, LambdaUtils's `wrapApiHandlerV1` and `wrapApiHandleV2` 94 | include the `loggerContextMiddleware`, which calls `Logger.setLambdaContext` for you and also 95 | adds the following properties: 96 | 97 | - `api_request_id` - the request ID from AWS API Gateway 98 | - `jwt_sub` - JWT (included by Cognito) authenticated subject of the request 99 | 100 | ## API Documentation 101 | 102 | [API Documentation on jsDocs.io](https://www.jsdocs.io/package/@sailplane/lambda-utils) 103 | 104 | ## Examples 105 | 106 | ### General use 107 | 108 | ```ts 109 | import { APIGatewayEvent } from "aws-lambda"; 110 | import * as LambdaUtils from "@sailplane/lambda-utils"; 111 | import * as createError from "http-errors"; 112 | 113 | export const hello = LambdaUtils.wrapApiHandlerV2( 114 | async (event: LambdaUtils.APIGatewayProxyEvent) => { 115 | // These event objects are now always defined, so don't need to check for undefined. 🙂 116 | const who = event.pathParameters.who; 117 | let points = Number(event.queryStringParameters.points || "0"); 118 | 119 | if (points > 0) { 120 | let message = "Hello " + who; 121 | for (; points > 0; --points) message = message + "!"; 122 | 123 | return { message }; 124 | } else { 125 | // LambdaUtils will catch and return HTTP 400 126 | throw new createError.BadRequest("Missing points parameter"); 127 | } 128 | }, 129 | ); 130 | ``` 131 | 132 | See [examples](examples.md) for another example. 133 | 134 | ### Extending LambdaUtils for your own app 135 | 136 | ```ts 137 | import { ProxyHandler } from "aws-lambda"; 138 | import middy from "@middy/core"; 139 | import * as createError from "http-errors"; 140 | import * as LambdaUtils from "@sailplane/lambda-utils"; 141 | 142 | /** ID user user authenticated in running Lambda */ 143 | let authenticatedUserId: string | undefined; 144 | 145 | export function getAuthenticatedUserId(): string | undefined { 146 | return authenticatedUserId; 147 | } 148 | 149 | /** 150 | * Middleware for LambdaUtils to automatically manage AuthService context. 151 | */ 152 | const authMiddleware = (requiredRole?: string): Required => { 153 | return { 154 | before: async (request) => { 155 | const claims = request.event.requestContext.authorizer?.claims; 156 | 157 | const role = claims["custom:role"]; 158 | if (requiredRole && role !== requiredRole) { 159 | throw new createError.Forbidden(); 160 | } 161 | 162 | authenticatedUserId = claims?.sub; 163 | if (!authenticatedUserId) { 164 | throw new createError.Unauthorized("No user authorized"); 165 | } 166 | }, 167 | after: async (_) => { 168 | authenticatedUserId = undefined; 169 | }, 170 | onError: async (_) => { 171 | authenticatedUserId = undefined; 172 | }, 173 | }; 174 | }; 175 | 176 | export interface WrapApiHandlerOptions { 177 | noUserAuth?: boolean; 178 | requiredRole?: string; 179 | } 180 | 181 | export function wrapApiHandlerWithAuth( 182 | options: WrapApiHandlerOptions, 183 | handler: LambdaUtils.AsyncProxyHandlerV2, 184 | ): LambdaUtils.AsyncMiddyifedHandlerV2 { 185 | const wrap = LambdaUtils.wrapApiHandlerV2(handler); 186 | 187 | if (!options.noUserAuth) { 188 | wrap.use(userAuthMiddleware(options.requiredRole)); 189 | } 190 | 191 | return wrap; 192 | } 193 | ``` 194 | -------------------------------------------------------------------------------- /logger/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { FormatterFn, LogFormat, LoggerAttributes, LoggerConfig, LogLevel } from "./common"; 2 | import { structuredFormatter } from "./structured-formatter"; 3 | import { flatFormatter } from "./flat-formatter"; 4 | import { Context } from "aws-lambda"; 5 | import { addLambdaContext } from "./context"; 6 | 7 | const levelConsoleFnMap: Record void> = { 8 | /* istanbul ignore next - not used but must be defined */ 9 | [LogLevel.NONE]: () => {}, 10 | [LogLevel.DEBUG]: console.debug, 11 | [LogLevel.INFO]: console.info, 12 | [LogLevel.WARN]: console.warn, 13 | [LogLevel.ERROR]: console.error, 14 | }; 15 | 16 | const formatterFnMap: Record = { 17 | [LogFormat.FLAT]: flatFormatter, 18 | [LogFormat.PRETTY]: flatFormatter, // handles pretty too 19 | [LogFormat.STRUCT]: structuredFormatter, 20 | }; 21 | 22 | const env = globalThis.process?.env ?? { IS_BROWSER: true }; 23 | // If not running in an interactive shell (such is the case for AWS Lambda environment) 24 | // or the LOG_TO_CLOUDWATCH environment is set, then format output for CloudWatch. 25 | const isMinimal = env.IS_BROWSER || (!env.SHELL && env.LOG_TO_CLOUDWATCH !== "true"); 26 | const globalFormat = 27 | (LogFormat as unknown as Record)[env.LOG_FORMAT!] || 28 | (isMinimal ? LogFormat.FLAT : LogFormat.PRETTY); 29 | const globalLoggerConfig: LoggerConfig = { 30 | module: "global", 31 | level: (LogLevel as unknown as Record)[env.LOG_LEVEL!] || LogLevel.DEBUG, 32 | outputLevels: !isMinimal, 33 | logTimestamps: env.LOG_TIMESTAMPS ? env.LOG_TIMESTAMPS === "true" : !isMinimal, 34 | format: globalFormat, 35 | formatter: formatterFnMap[globalFormat], 36 | }; 37 | 38 | /** 39 | * Custom logger class. 40 | * 41 | * Works much like console's logging, but includes levels, date/time, 42 | * and module (file) on each line, or structured formatting if configured to do so. 43 | * 44 | * Usage: 45 | * import {Logger} from "@sailplane/logger"; 46 | * const logger = new Logger('name-of-module'); 47 | * logger.info("Hello World!"); 48 | */ 49 | export class Logger { 50 | /** 51 | * Configure global defaults. Individual Logger instances may override. 52 | * @param globalConfig configuration properties to changed - undefined properties 53 | * will retain existing value 54 | */ 55 | static initialize(globalConfig: Partial): void { 56 | Object.assign(globalLoggerConfig, globalConfig); 57 | globalLoggerConfig.formatter = 58 | globalConfig.formatter ?? formatterFnMap[globalLoggerConfig.format]; 59 | } 60 | 61 | /** 62 | * Set some context attributes to the existing collection of global attributes 63 | * Use initialize({attributes: {}} to override/reset all attributes. 64 | */ 65 | static addAttributes(attributes: LoggerAttributes): void { 66 | globalLoggerConfig.attributes = { ...globalLoggerConfig.attributes, ...attributes }; 67 | } 68 | 69 | /** 70 | * Set structured logging global attributes based on Lambda Context: 71 | * 72 | * - aws_request_id: identifier of the invocation request 73 | * - invocation_num: number of invocations of this process (1 = cold start) 74 | * 75 | * Call this every time the Lambda handler begins. 76 | */ 77 | static setLambdaContext(context: Context): void { 78 | addLambdaContext(context); 79 | } 80 | 81 | private readonly config: LoggerConfig; 82 | 83 | /** 84 | * Construct. 85 | * @param ops LoggerConfig, or just module name as string 86 | */ 87 | constructor(ops: string | Partial) { 88 | if (typeof ops === "string") { 89 | this.config = { 90 | ...globalLoggerConfig, 91 | attributes: undefined, 92 | attributesCallback: undefined, 93 | module: ops, 94 | }; 95 | } else { 96 | this.config = { 97 | ...globalLoggerConfig, 98 | attributes: undefined, 99 | attributesCallback: undefined, 100 | ...ops, 101 | }; 102 | if (ops.format && !ops.formatter) { 103 | this.config.formatter = formatterFnMap[ops.format]; 104 | } 105 | } 106 | } 107 | 108 | /** 109 | * The Log Level of this Logger 110 | */ 111 | get level(): LogLevel { 112 | return this.config.level; 113 | } 114 | 115 | /** 116 | * Change the log level of this Logger 117 | */ 118 | set level(level: LogLevel) { 119 | this.config.level = level; 120 | } 121 | 122 | /** 123 | * Log an item at given level. 124 | * Usually better to use the specific function per log level instead. 125 | * 126 | * @param level log level 127 | * @param message text to log 128 | * @param params A list of JavaScript objects to output 129 | */ 130 | log(level: LogLevel, message: string, params: any[]): void { 131 | if (this.config.level >= level) { 132 | const content = this.config.formatter( 133 | this.config, 134 | globalLoggerConfig, 135 | level, 136 | message, 137 | params, 138 | ); 139 | levelConsoleFnMap[level](...content); 140 | } 141 | } 142 | 143 | /** 144 | * Log a line at DEBUG level. 145 | * 146 | * @param message text to log 147 | * @param optionalParams A list of JavaScript objects to output. 148 | */ 149 | debug(message: string, ...optionalParams: any[]): void { 150 | this.log(LogLevel.DEBUG, message, optionalParams); 151 | } 152 | 153 | /** 154 | * Log a line at INFO level. 155 | * 156 | * @param message text to log 157 | * @param optionalParams A list of JavaScript objects to output. 158 | */ 159 | info(message: string, ...optionalParams: any[]): void { 160 | this.log(LogLevel.INFO, message, optionalParams); 161 | } 162 | 163 | /** 164 | * Log a line at WARN level. 165 | * 166 | * @param message text to log 167 | * @param optionalParams A list of JavaScript objects to output. 168 | */ 169 | warn(message: string, ...optionalParams: any[]): void { 170 | this.log(LogLevel.WARN, message, optionalParams); 171 | } 172 | 173 | /** 174 | * Log a line at ERROR level. 175 | * 176 | * @param message text or Error instance 177 | * @param optionalParams A list of JavaScript objects to output. 178 | */ 179 | error(message: string | Error, ...optionalParams: any[]): void { 180 | if (typeof message === "object" && message instanceof Error) { 181 | optionalParams.push(message); 182 | message = message.toString(); 183 | } 184 | this.log(LogLevel.ERROR, message, optionalParams); 185 | } 186 | 187 | /** 188 | * Log a line at DEBUG level with a stringified object. 189 | * 190 | * @param message text to log 191 | * @param object a Javascript object to output 192 | * @deprecated #debug has the same result now 193 | */ 194 | debugObject(message: string, object: any): void { 195 | this.log(LogLevel.DEBUG, message, [object]); 196 | } 197 | 198 | /** 199 | * Log a line at INFO level with a stringified object. 200 | * 201 | * @param message text to log 202 | * @param object a Javascript object to output 203 | * @deprecated #info has the same result now 204 | */ 205 | infoObject(message: string, object: any): void { 206 | this.log(LogLevel.INFO, message, [object]); 207 | } 208 | 209 | /** 210 | * Log a line at WARN level with a stringified object. 211 | * 212 | * @param message text to log 213 | * @param object a Javascript object to output 214 | * @deprecated #warn has the same result now 215 | */ 216 | warnObject(message: string, object: any): void { 217 | this.log(LogLevel.WARN, message, [object]); 218 | } 219 | 220 | /** 221 | * Log a line at ERROR level with a stringified object. 222 | * 223 | * @param message text to log 224 | * @param object a Javascript object to output 225 | * @deprecated #error has the same result now 226 | */ 227 | errorObject(message: string, object: any): void { 228 | this.log(LogLevel.ERROR, message, [object]); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # More Examples 2 | 3 | This section includes some larger examples which use _multiple_ packages. 4 | 5 | ## Data Storage in Elasticsearch 6 | 7 | Uses: 8 | 9 | - [AwsHttps](aws_https.md) 10 | - [ElasticsearchClient](elasticsearch_client.md) 11 | - [Injector](injector.md) 12 | - [Logger](logger.md) 13 | 14 | ```ts 15 | import {AwsHttps} from "@sailplane/aws-https"; 16 | import {ElasticsearchClient} from "@sailplane/elasticsearch-client"; 17 | import {Injector, Injectable} from "@sailplane/injector"; 18 | import {Logger} from "@sailplane/logger"; 19 | import {Ticket} from "./ticket"; 20 | 21 | const logger = new Logger('ticket-storage'); 22 | const ES_TICKET_PATH_PREFIX = "/ticket/local/"; 23 | 24 | // TODO: Ideally, put this in central place so it only runs once. 25 | Injector.register(ElasticsearchClient, () => { 26 | const endpoint: string = process.env.ES_ENDPOINT!; 27 | logger.info('Connecting to Elasticsearch @ ' + endpoint); 28 | return new ElasticsearchClient(new AwsHttps(), endpoint); 29 | }); 30 | 31 | /** 32 | * Storage of service tickets in Elasticsearch on AWS. 33 | */ 34 | @Injectable() 35 | export class TicketStorage { 36 | constructor(private readonly es: ElasticsearchClient) { 37 | } 38 | 39 | /** 40 | * Fetch a previously stored ticket by its ID 41 | * @param {string} id 42 | * @returns {Promise} if not found, returns undefined 43 | */ 44 | get(id: string): Promise { 45 | return this.es.request('GET', ES_TICKET_PATH_PREFIX + id) 46 | .then((esDoc: ElasticsearchResult) => esDoc._source as Ticket); 47 | } 48 | 49 | /** 50 | * Store a ticket. Creates or replaces automatically. 51 | * 52 | * @param {Ticket} ticket 53 | * @returns {Promise} data stored (should match 'ticket') 54 | */ 55 | put(ticket: Ticket): Promise { 56 | const path = ES_TICKET_PATH_PREFIX + ticket.id; 57 | return this.es.request('PUT', path, ticket) 58 | .then(() => ticket); 59 | } 60 | 61 | /** 62 | * Query for tickets that are not closed. 63 | * 64 | * @param {string} company 65 | * @param {number} maxResults Maximum number of results to return 66 | * @returns {Promise} 67 | * @throws Forbidden if no company value provided 68 | */ 69 | queryOpen(company: string, maxResults: number): Promise { 70 | let query = { 71 | bool: { 72 | must_not: [ 73 | exists: { 74 | field: "resolution" 75 | } 76 | ] 77 | } 78 | }; 79 | 80 | return this.es.request('GET', ES_TICKET_PATH_PREFIX + '_search', { 81 | size: maxResults, 82 | query: query 83 | }) 84 | .then((esResults: ElasticsearchResult) => { 85 | if (esResults.timed_out) { 86 | throw new Error("Query of TicketStorage timed out"); 87 | } 88 | else if (esResults.hits && esResults.hits.hits && esResults.hits.total) { 89 | return esResults.hits.hits.map(esDoc => esDoc._source as Ticket); 90 | } 91 | else { 92 | return [] as Ticket[]; 93 | } 94 | }); 95 | } 96 | } 97 | ``` 98 | 99 | ## Serverless Framework Lambda 100 | 101 | This example shows how to: 102 | 103 | - Configure [Serverless Framework](https://serverless.com) for use with [StateStorage](state_storage.md). 104 | - Cache `StateStorage` result in [ExpiringValue](expiring_value.md). 105 | - Use [LambdaUtils](lambda_utils.md) to simplify the lambda handler function. 106 | - Do dependency injection with [Injector](injector.md). 107 | - Make HTTPS request with [AwsHttps](aws_https.md). No SigV4 signature required on this use. 108 | - Log status and objects via [Logger](logger.md). 109 | 110 | ```yaml 111 | # serverless.yml 112 | service: 113 | name: serverless-demo 114 | 115 | plugins: 116 | - serverless-webpack 117 | - serverless-offline 118 | - serverless-plugin-export-endpoints 119 | 120 | provider: 121 | name: aws 122 | runtime: nodejs8.10 123 | 124 | environment: 125 | STATE_STORAGE_PREFIX: /${opt:stage}/myapp 126 | 127 | iamRoleStatements: 128 | - Effect: Allow 129 | Action: 130 | - ssm:GetParameter 131 | - ssm:PutParameter 132 | Resource: "arn:aws:ssm:${opt:region}:*:parameter${self:provider.environment.STATE_STORAGE_PREFIX}/*" 133 | 134 | functions: 135 | getChatHistory: 136 | description: Retrieve some (more) history of the user's chat channel. 137 | handler: src/handlers.getChatHistory 138 | events: 139 | - http: 140 | method: get 141 | path: chat/history 142 | cors: true 143 | request: 144 | parameters: 145 | querystrings: 146 | channel: true 147 | cursor: false 148 | ``` 149 | 150 | ```ts 151 | // src/handlers.ts 152 | import "source-map-support/register"; 153 | import { APIGatewayEvent } from "aws-lambda"; 154 | import { Injector } from "@sailplane/injector"; 155 | import * as LambdaUtils from "@sailplane/lambda-utils"; 156 | import { ChatService } from "./chat-service"; 157 | import * as createHttpError from "http-errors"; 158 | 159 | Injector.register(StateStorage, () => new StateStorage(process.env.STATE_STORAGE_PREFIX)); 160 | 161 | /** 162 | * Fetch history of chat on the user's channel 163 | */ 164 | export const getChatHistory = LambdaUtils.wrapApiHandler( 165 | async (event: LambdaUtils.APIGatewayProxyEvent) => { 166 | const channel = event.queryStringParameters.channel; 167 | const cursor = event.queryStringParameters.cursor; 168 | 169 | return Injector.get(ChatService)!.getHistory(channel, cursor); 170 | }, 171 | ); 172 | ``` 173 | 174 | ```ts 175 | // src/chat-service.ts 176 | import {AwsHttps} from "@sailplane/aws-https"; 177 | import {ExpiringValue} from "@sailplane/expiring-value"; 178 | import {Injector, Injectable} from "@sailplane/injector"; 179 | import {Logger} from "@sailplane/logger"; 180 | import {URL} from "url"; 181 | import * as createHttpError from "http-errors"; 182 | 183 | const logger = new Logger('chat-service'); 184 | 185 | const CONFIG_REFRESH_PERIOD = 15*60*1000; // 15 minutes 186 | 187 | //// Define Data Structures 188 | interface ChatConfig { 189 | url: string; 190 | authToken: string; 191 | } 192 | 193 | interface ChatMessage { 194 | from: string; 195 | when: number; 196 | text: string; 197 | } 198 | 199 | interface ChatHistory { 200 | messages: ChatMessage[]; 201 | cursor: string; 202 | } 203 | 204 | /** 205 | * Service to interface with the external chat provider. 206 | */ 207 | @Injectable({ dependencies: [StateStorage] }) 208 | export class ChatService { 209 | private config = new ExpiringValue( 210 | () => this.stateStorage.get('ChatService', 'config') as ChatConfig, 211 | CONFIG_REFRESH_PERIOD); 212 | 213 | /** Construct */ 214 | constructor( 215 | private readonly stateStorage: StateStorage, 216 | private readonly awsHttps = new AwsHttps() 217 | ) { 218 | } 219 | 220 | /** 221 | * Fetch history of a chat channel. 222 | */ 223 | async getHistory(channelId: string, cursor?: string): Promise { 224 | logger.debug(`getHistory(${channelId}, ${cursor})`); 225 | const config = await this.config.get(); 226 | 227 | // Fetch history from external chat provider 228 | let options = this.awsHttp.buildOptions('POST', new URL(config.url)); 229 | options.headers = { authorization: 'TOKEN ' + config.authToken }; 230 | options.body = JSON.stringify({ 231 | channel: channelId 232 | cursor: cursor 233 | }); 234 | 235 | const response = await this.awsHttp.request(options); 236 | 237 | // Check for error 238 | if (!response.ok) { 239 | logger.info("External history request returned error: ", response); 240 | throw new createHttpError.InternalServerError(response.error); 241 | } 242 | 243 | // Prepare results 244 | const history: ChatHistory = { 245 | messages: [], 246 | cursor: response.next_cursor 247 | }; 248 | 249 | // Process each message 250 | for (let msg of response.messages) { 251 | history.messages.push({ 252 | from: msg.username, 253 | when: msg.ts 254 | text: msg.text 255 | }); 256 | } 257 | 258 | return history; 259 | } 260 | } 261 | ``` 262 | -------------------------------------------------------------------------------- /aws-https/lib/aws-https.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-expressions */ 2 | import * as AWS from "aws-sdk"; 3 | import { Credentials, CredentialsOptions } from "aws-sdk/lib/credentials"; 4 | import * as aws4 from "aws4"; 5 | import * as http from "http"; 6 | import * as https from "https"; 7 | import { Logger } from "@sailplane/logger"; 8 | import { URL } from "url"; 9 | 10 | const logger = new Logger("aws-https"); 11 | 12 | /** 13 | * Same options as https://nodejs.org/api/http.html#http_http_request_options_callback 14 | * with the addition of optional body to send with POST, PUT, or PATCH 15 | * and option to AWS Sig4 sign the request. 16 | */ 17 | export type AwsHttpsOptions = aws4.Request & { 18 | /** Body content of HTTP POST, PUT or PATCH */ 19 | body?: string; 20 | 21 | /** If true, apply AWS Signature v4 to the request */ 22 | awsSign?: boolean; 23 | }; 24 | 25 | /** 26 | * Light-weight utility for making HTTPS requests in AWS environments. 27 | */ 28 | export class AwsHttps { 29 | /** Resolves when credentials are available - shared by all instances */ 30 | private static credentialsInitializedPromise: Promise | undefined = undefined; 31 | 32 | /** Credentials to use in this instance */ 33 | private awsCredentials?: Credentials | CredentialsOptions; 34 | 35 | /** 36 | * Constructor. 37 | * @param verbose true to log everything, false for silence, 38 | * undefined (default) for normal logging. 39 | * @param credentials 40 | * If not defined, credentials will be obtained by default SDK behavior for the runtime environment. 41 | * This happens once and then is cached; good for Lambdas. 42 | * If `true`, clear cached to obtain fresh credentials from SDK. 43 | * Good for longer running containers that rotate credentials. 44 | * If an object with accessKeyId, secretAccessKey, and sessionToken, 45 | * use these credentials for this instance. 46 | */ 47 | constructor( 48 | private readonly verbose?: boolean, 49 | credentials?: boolean | Credentials | CredentialsOptions, 50 | ) { 51 | if (credentials) { 52 | AwsHttps.credentialsInitializedPromise = undefined; 53 | if (typeof credentials === "object" && credentials.accessKeyId) { 54 | this.awsCredentials = credentials; 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Perform an HTTPS request and return the JSON body of the result. 61 | * 62 | * @params options https request options, with optional body and awsSign 63 | * @returns parsed JSON content, or null if none. 64 | * @throws {Error{message,status,statusCode}} error if HTTP result is not 2xx or unable 65 | * to parse response. Compatible with http-errors package. 66 | */ 67 | async request(options: AwsHttpsOptions): Promise { 68 | let requestOptions = options; 69 | 70 | if (options.awsSign === true) { 71 | requestOptions = await this.awsSign(requestOptions); 72 | } 73 | 74 | this.verbose === true && logger.debug("HTTPS Request: ", requestOptions); 75 | 76 | return new Promise((resolve, reject) => { 77 | // eslint-disable-next-line prefer-const -- eslint is simply wrong about this 78 | let timeoutHandle: any | undefined; 79 | const request = https.request(requestOptions, (response: http.IncomingMessage) => { 80 | this.verbose !== false && logger.info("Status: " + response.statusCode); 81 | 82 | const body: Array = []; 83 | 84 | // Save each chunk of response data 85 | response.on("data", (chunk) => body.push(chunk)); 86 | 87 | // End of response - process it 88 | response.on("end", () => { 89 | clearTimeout(timeoutHandle!); 90 | const content = body.join(""); 91 | 92 | if (!response.statusCode || response.statusCode < 200 || response.statusCode > 299) { 93 | // HTTP status indicates failure. Throw http-errors compatible error. 94 | const err: any = new Error( 95 | "Failed to load content, status code: " + response.statusCode, 96 | ); 97 | err.status = err.statusCode = response.statusCode || 0; 98 | this.verbose !== false && logger.warn(err.message + " ", content); 99 | reject(err); 100 | } else if (content) { 101 | this.verbose === true && logger.debug("HTTP response content: " + content); 102 | try { 103 | resolve(JSON.parse(content)); 104 | } catch (someError) { 105 | if ( 106 | someError && 107 | typeof someError === "object" && 108 | "message" in someError && 109 | typeof someError.message === "string" 110 | ) { 111 | const err = someError as any; 112 | logger.warn(err.message, content); 113 | err.status = err.statusCode = 400; 114 | reject(err); 115 | } else { 116 | logger.warn("Unexpected error:", someError); 117 | reject(someError); 118 | } 119 | } 120 | } else { 121 | this.verbose === true && logger.debug("HTTP response " + response.statusCode); 122 | resolve(null); 123 | } 124 | }); 125 | 126 | // Theoretically this should be called based on requestOptions#timeout. 127 | // It doesn't, but leaving this code here in case in ever gets fixed by Node.js. 128 | /* istanbul ignore next */ 129 | response.on("timeout", () => { 130 | logger.warn( 131 | `Request timeout from ${options.protocol}://${options.hostname}:${options.port}`, 132 | ); 133 | request.destroy(); 134 | }); 135 | }); 136 | 137 | // Communication timeout - The HttpRequest setTimeout and requestOptions#timeout 138 | // aren't reliable across various Node.js versions, so timing out this way. 139 | timeoutHandle = setTimeout(() => { 140 | logger.warn( 141 | `Request timeout from ${options.protocol}://${options.hostname}:${options.port}`, 142 | ); 143 | request.destroy(); 144 | }, options.timeout || 120000); 145 | 146 | // Connection error 147 | request.on("error", (err) => { 148 | logger.warn( 149 | `Error response from ${options.protocol}://${options.hostname}:${options.port}: ${err.message}`, 150 | ); 151 | reject(err); 152 | }); 153 | 154 | if (options.body) request.write(options.body); 155 | 156 | request.end(); 157 | }); 158 | } 159 | 160 | /** 161 | * Helper to build a starter AwsHttpsOptions object from a URL. 162 | * 163 | * @param method an HTTP method/verb 164 | * @param url the URL to request from 165 | * @param connectTimeout (default 5000) milliseconds to wait for connection to establish 166 | * @returns an AwsHttpsOptions object, which may be further modified before use. 167 | */ 168 | buildOptions( 169 | method: "DELETE" | "GET" | "HEAD" | "OPTIONS" | "POST" | "PUT" | "PATCH", 170 | url: URL, 171 | connectTimeout = 5000, 172 | ): AwsHttpsOptions { 173 | return { 174 | protocol: url.protocol, 175 | method: method, 176 | hostname: url.hostname, 177 | port: url.port || 443, 178 | path: url.pathname + (url.search || ""), 179 | timeout: connectTimeout, 180 | }; 181 | } 182 | 183 | /** 184 | * Helper for signing AWS requests 185 | * @param request to make 186 | * @return signed version of the request. 187 | */ 188 | private async awsSign(request: AwsHttpsOptions): Promise { 189 | if (!this.awsCredentials) { 190 | if (!AwsHttps.credentialsInitializedPromise) { 191 | // Prepare process-wide AWS credentials 192 | AwsHttps.credentialsInitializedPromise = new Promise((resolve, reject) => { 193 | AWS.config.getCredentials((err) => { 194 | if (err) { 195 | logger.error("Unable to load AWS credentials", err); 196 | reject(err); 197 | } else { 198 | resolve(); 199 | } 200 | }); 201 | }); 202 | } 203 | 204 | // Wait for process-wide AWS credentials to be available 205 | await AwsHttps.credentialsInitializedPromise; 206 | this.awsCredentials = AWS.config.credentials!; 207 | } 208 | 209 | // Sign the request 210 | const signCreds = { 211 | accessKeyId: this.awsCredentials.accessKeyId, 212 | secretAccessKey: this.awsCredentials.secretAccessKey, 213 | sessionToken: this.awsCredentials.sessionToken, 214 | }; 215 | const awsRequest = aws4.sign({ ...request, host: request.host ?? undefined }, signCreds); 216 | return { 217 | ...awsRequest, 218 | body: awsRequest.body?.toString(), 219 | }; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # Apache License 2 | 3 | _Version 2.0, January 2004_ 4 | _<>_ 5 | 6 | ### Terms and Conditions for use, reproduction, and distribution 7 | 8 | #### 1. Definitions 9 | 10 | “License” shall mean the terms and conditions for use, reproduction, and 11 | distribution as defined by Sections 1 through 9 of this document. 12 | 13 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 14 | owner that is granting the License. 15 | 16 | “Legal Entity” shall mean the union of the acting entity and all other entities 17 | that control, are controlled by, or are under common control with that entity. 18 | For the purposes of this definition, “control” means **(i)** the power, direct or 19 | indirect, to cause the direction or management of such entity, whether by 20 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 21 | outstanding shares, or **(iii)** beneficial ownership of such entity. 22 | 23 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 24 | permissions granted by this License. 25 | 26 | “Source” form shall mean the preferred form for making modifications, including 27 | but not limited to software source code, documentation source, and configuration 28 | files. 29 | 30 | “Object” form shall mean any form resulting from mechanical transformation or 31 | translation of a Source form, including but not limited to compiled object code, 32 | generated documentation, and conversions to other media types. 33 | 34 | “Work” shall mean the work of authorship, whether in Source or Object form, made 35 | available under the License, as indicated by a copyright notice that is included 36 | in or attached to the work (an example is provided in the Appendix below). 37 | 38 | “Derivative Works” shall mean any work, whether in Source or Object form, that 39 | is based on (or derived from) the Work and for which the editorial revisions, 40 | annotations, elaborations, or other modifications represent, as a whole, an 41 | original work of authorship. For the purposes of this License, Derivative Works 42 | shall not include works that remain separable from, or merely link (or bind by 43 | name) to the interfaces of, the Work and Derivative Works thereof. 44 | 45 | “Contribution” shall mean any work of authorship, including the original version 46 | of the Work and any modifications or additions to that Work or Derivative Works 47 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 48 | by the copyright owner or by an individual or Legal Entity authorized to submit 49 | on behalf of the copyright owner. For the purposes of this definition, 50 | “submitted” means any form of electronic, verbal, or written communication sent 51 | to the Licensor or its representatives, including but not limited to 52 | communication on electronic mailing lists, source code control systems, and 53 | issue tracking systems that are managed by, or on behalf of, the Licensor for 54 | the purpose of discussing and improving the Work, but excluding communication 55 | that is conspicuously marked or otherwise designated in writing by the copyright 56 | owner as “Not a Contribution.” 57 | 58 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 59 | of whom a Contribution has been received by Licensor and subsequently 60 | incorporated within the Work. 61 | 62 | #### 2. Grant of Copyright License 63 | 64 | Subject to the terms and conditions of this License, each Contributor hereby 65 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 66 | irrevocable copyright license to reproduce, prepare Derivative Works of, 67 | publicly display, publicly perform, sublicense, and distribute the Work and such 68 | Derivative Works in Source or Object form. 69 | 70 | #### 3. Grant of Patent License 71 | 72 | Subject to the terms and conditions of this License, each Contributor hereby 73 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 74 | irrevocable (except as stated in this section) patent license to make, have 75 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 76 | such license applies only to those patent claims licensable by such Contributor 77 | that are necessarily infringed by their Contribution(s) alone or by combination 78 | of their Contribution(s) with the Work to which such Contribution(s) was 79 | submitted. If You institute patent litigation against any entity (including a 80 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 81 | Contribution incorporated within the Work constitutes direct or contributory 82 | patent infringement, then any patent licenses granted to You under this License 83 | for that Work shall terminate as of the date such litigation is filed. 84 | 85 | #### 4. Redistribution 86 | 87 | You may reproduce and distribute copies of the Work or Derivative Works thereof 88 | in any medium, with or without modifications, and in Source or Object form, 89 | provided that You meet the following conditions: 90 | 91 | - **(a)** You must give any other recipients of the Work or Derivative Works a copy of 92 | this License; and 93 | - **(b)** You must cause any modified files to carry prominent notices stating that You 94 | changed the files; and 95 | - **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 96 | all copyright, patent, trademark, and attribution notices from the Source form 97 | of the Work, excluding those notices that do not pertain to any part of the 98 | Derivative Works; and 99 | - **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 100 | Derivative Works that You distribute must include a readable copy of the 101 | attribution notices contained within such NOTICE file, excluding those notices 102 | that do not pertain to any part of the Derivative Works, in at least one of the 103 | following places: within a NOTICE text file distributed as part of the 104 | Derivative Works; within the Source form or documentation, if provided along 105 | with the Derivative Works; or, within a display generated by the Derivative 106 | Works, if and wherever such third-party notices normally appear. The contents of 107 | the NOTICE file are for informational purposes only and do not modify the 108 | License. You may add Your own attribution notices within Derivative Works that 109 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 110 | provided that such additional attribution notices cannot be construed as 111 | modifying the License. 112 | 113 | You may add Your own copyright statement to Your modifications and may provide 114 | additional or different license terms and conditions for use, reproduction, or 115 | distribution of Your modifications, or for any such Derivative Works as a whole, 116 | provided Your use, reproduction, and distribution of the Work otherwise complies 117 | with the conditions stated in this License. 118 | 119 | #### 5. Submission of Contributions 120 | 121 | Unless You explicitly state otherwise, any Contribution intentionally submitted 122 | for inclusion in the Work by You to the Licensor shall be under the terms and 123 | conditions of this License, without any additional terms or conditions. 124 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 125 | any separate license agreement you may have executed with Licensor regarding 126 | such Contributions. 127 | 128 | #### 6. Trademarks 129 | 130 | This License does not grant permission to use the trade names, trademarks, 131 | service marks, or product names of the Licensor, except as required for 132 | reasonable and customary use in describing the origin of the Work and 133 | reproducing the content of the NOTICE file. 134 | 135 | #### 7. Disclaimer of Warranty 136 | 137 | Unless required by applicable law or agreed to in writing, Licensor provides the 138 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 139 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 140 | including, without limitation, any warranties or conditions of TITLE, 141 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 142 | solely responsible for determining the appropriateness of using or 143 | redistributing the Work and assume any risks associated with Your exercise of 144 | permissions under this License. 145 | 146 | #### 8. Limitation of Liability 147 | 148 | In no event and under no legal theory, whether in tort (including negligence), 149 | contract, or otherwise, unless required by applicable law (such as deliberate 150 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 151 | liable to You for damages, including any direct, indirect, special, incidental, 152 | or consequential damages of any character arising as a result of this License or 153 | out of the use or inability to use the Work (including but not limited to 154 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 155 | any and all other commercial damages or losses), even if such Contributor has 156 | been advised of the possibility of such damages. 157 | 158 | #### 9. Accepting Warranty or Additional Liability 159 | 160 | While redistributing the Work or Derivative Works thereof, You may choose to 161 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 162 | other liability obligations and/or rights consistent with this License. However, 163 | in accepting such obligations, You may act only on Your own behalf and on Your 164 | sole responsibility, not on behalf of any other Contributor, and only if You 165 | agree to indemnify, defend, and hold each Contributor harmless for any liability 166 | incurred by, or claims asserted against, such Contributor by reason of your 167 | accepting any such warranty or additional liability. 168 | -------------------------------------------------------------------------------- /lambda-utils/lib/handler-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APIGatewayEventRequestContext, 3 | APIGatewayProxyEvent, 4 | APIGatewayProxyEventV2, 5 | APIGatewayProxyResult, 6 | APIGatewayProxyResultV2, 7 | APIGatewayProxyStructuredResultV2, 8 | Context, 9 | } from "aws-lambda"; 10 | import * as LambdaUtils from "./index"; 11 | import createError from "http-errors"; 12 | 13 | const mockContext: Partial = { 14 | functionName: "unitTest", 15 | functionVersion: "2", 16 | memoryLimitInMB: "128", 17 | awsRequestId: "aws-request-123", 18 | }; 19 | 20 | const givenBody = { company: "Onica", tagline: "Innovation through Cloud Transformation" }; 21 | const givenV1Event: APIGatewayProxyEvent = { 22 | body: JSON.stringify(givenBody), 23 | headers: { 24 | "content-length": "0", 25 | "CONTENT-TYPE": "application/json", 26 | }, 27 | multiValueHeaders: {}, 28 | httpMethod: "GET", 29 | isBase64Encoded: false, 30 | path: "/test", 31 | pathParameters: null, 32 | queryStringParameters: null, 33 | multiValueQueryStringParameters: null, 34 | stageVariables: null, 35 | resource: "tada", 36 | requestContext: {} as any, 37 | }; 38 | 39 | const givenV2Event: APIGatewayProxyEventV2 = { 40 | version: "2.0", 41 | routeKey: "123", 42 | body: undefined, 43 | headers: { 44 | Origin: "test-origin", 45 | }, 46 | rawPath: "/test", 47 | rawQueryString: "", 48 | isBase64Encoded: false, 49 | pathParameters: undefined, 50 | queryStringParameters: undefined, 51 | requestContext: { 52 | accountId: "123", 53 | apiId: "abc", 54 | domainName: "test", 55 | domainPrefix: "unit", 56 | http: { 57 | method: "get", 58 | path: "/test", 59 | protocol: "http", 60 | sourceIp: "1.1.1.1", 61 | userAgent: "unit/test", 62 | }, 63 | requestId: "abc", 64 | routeKey: "123", 65 | stage: "test", 66 | time: "2021-08-30T16:58:31Z", 67 | timeEpoch: 1000000, 68 | }, 69 | }; 70 | 71 | describe("LambdaUtils", () => { 72 | describe("wrapApiHandler", () => { 73 | test("wrapApiHandler apiSuccess", async () => { 74 | // GIVEN 75 | const handler = LambdaUtils.wrapApiHandler( 76 | async (event: APIGatewayProxyEvent): Promise => { 77 | // Echo the event back 78 | return LambdaUtils.apiSuccess(event); 79 | }, 80 | ); 81 | 82 | // WHEN 83 | const response = (await handler( 84 | { ...givenV1Event }, 85 | mockContext as Context, 86 | {} as any, 87 | )) as APIGatewayProxyResult; 88 | 89 | // THEN 90 | 91 | // Headers set in response 92 | expect(response.headers?.["Access-Control-Allow-Origin"]).toEqual("*"); 93 | expect(response.headers?.["content-type"]).toEqual("application/json; charset=utf-8"); 94 | 95 | const resultEvent: APIGatewayProxyEvent = JSON.parse(response.body); 96 | 97 | // body was parsed from string to JSON in request event 98 | expect(resultEvent.body).toEqual(givenBody); 99 | 100 | // Headers are normalized in request event 101 | expect(resultEvent.headers["Content-Length"]).toBeUndefined(); 102 | expect(resultEvent.headers["content-length"]).toEqual("0"); 103 | expect(resultEvent.headers["CONTENT-TYPE"]).toBeUndefined(); 104 | expect(resultEvent.headers["content-type"]).toEqual("application/json"); 105 | 106 | // pathParameters and queryStringParameters are expanded to empty objects 107 | expect(resultEvent.pathParameters).toEqual({}); 108 | expect(resultEvent.queryStringParameters).toEqual({}); 109 | }); 110 | 111 | test("wrapApiHandler v2 promise object success", async () => { 112 | // GIVEN 113 | const handler = LambdaUtils.wrapApiHandlerV2(async (): Promise => { 114 | return { message: "Hello" }; 115 | }); 116 | 117 | // WHEN 118 | const response = (await handler( 119 | { ...givenV2Event }, 120 | mockContext as Context, 121 | {} as any, 122 | )) as APIGatewayProxyResultV2; 123 | 124 | // THEN 125 | expect(response.statusCode).toEqual(200); 126 | expect(response.body).toEqual('{"message":"Hello"}'); 127 | expect(response.headers?.["Access-Control-Allow-Origin"]).toEqual("*"); 128 | expect(response.headers?.["content-type"]).toEqual("application/json; charset=utf-8"); 129 | }); 130 | 131 | test("wrapApiHandler promise empty success", async () => { 132 | // GIVEN 133 | const handler = LambdaUtils.wrapApiHandler(async (): Promise => { 134 | return; 135 | }); 136 | 137 | const givenEvent: APIGatewayProxyEvent = { 138 | body: null, 139 | headers: {}, 140 | multiValueHeaders: {}, 141 | httpMethod: "GET", 142 | isBase64Encoded: false, 143 | path: "/test", 144 | pathParameters: null, 145 | queryStringParameters: null, 146 | multiValueQueryStringParameters: null, 147 | stageVariables: null, 148 | resource: "", 149 | requestContext: { 150 | authorizer: {}, 151 | } as APIGatewayEventRequestContext, 152 | }; 153 | 154 | // WHEN 155 | const response = (await handler( 156 | givenEvent, 157 | mockContext as Context, 158 | {} as any, 159 | )) as APIGatewayProxyResult; 160 | 161 | // THEN 162 | expect(response.statusCode).toEqual(200); 163 | expect(response.body).toBeFalsy(); 164 | expect(response.headers!["Access-Control-Allow-Origin"]).toEqual("*"); 165 | expect(response.headers?.["content-type"]).toEqual("text/plain; charset=utf-8"); 166 | }); 167 | 168 | test("wrapApiHandler throw Error", async () => { 169 | // GIVEN 170 | const handler = LambdaUtils.wrapApiHandler(async (): Promise => { 171 | throw new Error("oops"); 172 | }); 173 | 174 | // WHEN 175 | const response = (await handler( 176 | { ...givenV1Event }, 177 | {} as Context, 178 | {} as any, 179 | )) as APIGatewayProxyResult; 180 | 181 | // THEN 182 | expect(response).toEqual({ 183 | statusCode: 500, 184 | body: "Error: oops", 185 | headers: { 186 | "Access-Control-Allow-Origin": "*", 187 | "content-type": "text/plain; charset=utf-8", 188 | }, 189 | }); 190 | }); 191 | 192 | test("wrapApiHandlerV2 throw http-error", async () => { 193 | // GIVEN 194 | const handler = LambdaUtils.wrapApiHandlerV2( 195 | async (): Promise => { 196 | throw new createError.NotFound(); 197 | }, 198 | ); 199 | 200 | // WHEN 201 | const response = (await handler( 202 | { ...givenV2Event }, 203 | mockContext as Context, 204 | {} as any, 205 | )) as APIGatewayProxyResult; 206 | 207 | // THEN 208 | expect(response).toEqual({ 209 | statusCode: 404, 210 | body: "NotFoundError: Not Found", 211 | headers: { 212 | "Access-Control-Allow-Origin": "*", 213 | "content-type": "text/plain; charset=utf-8", 214 | }, 215 | }); 216 | }); 217 | 218 | test("wrapApiHandlerV2 throw nested cause http-error", async () => { 219 | // GIVEN 220 | const handler = LambdaUtils.wrapApiHandlerV2( 221 | async (): Promise => { 222 | // The 'cause' option isn't gained until Node 16.9.0, but this library 223 | // targets an older version in order to be backward compatible. 224 | // So, we fake this. 225 | // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause 226 | const error: any = new Error("I'm confused"); 227 | error.cause = new createError.BadRequest(); 228 | throw error; 229 | }, 230 | ); 231 | 232 | // WHEN 233 | const response = (await handler( 234 | { ...givenV2Event }, 235 | mockContext as Context, 236 | {} as any, 237 | )) as APIGatewayProxyResult; 238 | 239 | // THEN 240 | expect(response).toEqual({ 241 | statusCode: 400, 242 | body: "BadRequestError: Bad Request", 243 | headers: { 244 | "Access-Control-Allow-Origin": "*", 245 | "content-type": "text/plain; charset=utf-8", 246 | }, 247 | }); 248 | }); 249 | }); 250 | 251 | describe("apiSuccess", () => { 252 | test("apiSuccess without body", () => { 253 | // WHEN 254 | const result = LambdaUtils.apiSuccess(); 255 | 256 | // THEN 257 | expect(result).toBeTruthy(); 258 | expect(result.statusCode).toEqual(200); 259 | expect(result.body).toEqual(""); 260 | }); 261 | 262 | test("apiSuccess with body", () => { 263 | // GIVEN 264 | const resultBody = { hello: "world" }; 265 | 266 | // WHEN 267 | const result = LambdaUtils.apiSuccess(resultBody); 268 | 269 | // THEN 270 | expect(result).toBeTruthy(); 271 | expect(result.statusCode).toEqual(200); 272 | expect(result.body).toEqual('{"hello":"world"}'); 273 | }); 274 | }); 275 | 276 | describe("apiFailure", () => { 277 | test("apiFailure without body", () => { 278 | // WHEN 279 | const result = LambdaUtils.apiFailure(501); 280 | 281 | // THEN 282 | expect(result).toBeTruthy(); 283 | expect(result.statusCode).toEqual(501); 284 | expect(result.body).toEqual(""); 285 | }); 286 | 287 | test("apiFailure with body", () => { 288 | // WHEN 289 | const result = LambdaUtils.apiFailure(418, "I'm a teapot"); 290 | 291 | // THEN 292 | expect(result).toBeTruthy(); 293 | expect(result.statusCode).toEqual(418); 294 | expect(result.body).toEqual("I'm a teapot"); 295 | }); 296 | }); 297 | }); 298 | -------------------------------------------------------------------------------- /injector/lib/injector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Dependency Injection (DI) injector. 3 | * 4 | * Using BottleJs because it is _extremely_ light weight and lazy-instantiate, 5 | * unlike any Typescript-specific solutions. 6 | * 7 | * @see https://github.com/young-steveo/bottlejs 8 | */ 9 | import "reflect-metadata"; 10 | import Bottle from "bottlejs"; 11 | import { Logger } from "@sailplane/logger"; 12 | 13 | const logger = new Logger("injector"); 14 | 15 | export type InjectableClass = { 16 | new (...args: any[]): T; 17 | $inject?: DependencyList; 18 | name: string; 19 | }; 20 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 21 | export type GettableClass = Function & { prototype: T; name: string }; 22 | export type DependencyList = (InjectableClass | string)[]; 23 | 24 | /** Convert list into array of dependency names */ 25 | function toNamedDependencies(list: DependencyList): string[] { 26 | if (list) { 27 | return list.map((dep) => (typeof dep === "string" ? dep : dep.name)); 28 | } else { 29 | return []; 30 | } 31 | } 32 | 33 | // verifies that automatically injected dependencies are not undefined 34 | (Bottle.config as any).strict = true; 35 | 36 | /** 37 | * Wraps up type-safe version of BottleJs for common uses. 38 | * 39 | * The raw bottle is also available. 40 | */ 41 | export class Injector { 42 | static readonly defaultDomain = "global"; 43 | static #bottle = Bottle.pop(Injector.defaultDomain); 44 | /** Access the underlying BottleJS instance */ 45 | static get bottle() { 46 | return Injector.#bottle; 47 | } 48 | 49 | /* istanbul ignore next */ 50 | /** 51 | * Do not use. 52 | * @deprecated 53 | */ 54 | static initialize() {} 55 | 56 | /** 57 | * Switch the Injector to a different domain. 58 | * Injector domains are entirely independent instances from each other. 59 | * 60 | * This is particularly useful to isolate production from mock dependencies for unit tests; 61 | * initialize your test by switching to a "test" domain and registering mock classes 62 | * to isolate the unit under test. 63 | * 64 | * @param domainName name of domain to switch to; 65 | * use `Injector.defaultDomain` to return to the default. 66 | * @param reset when true, the domain will be reset (cleared, emptied). 67 | */ 68 | static switchDomain(domainName: string, reset = false): void { 69 | if (reset) { 70 | Bottle.clear(domainName); 71 | } 72 | Injector.#bottle = Bottle.pop(domainName); 73 | } 74 | 75 | /** 76 | * Register a class. 77 | * 78 | * Example service that lazy instantiates with no arguments: 79 | * - Injector.register(MyServiceClass); 80 | * 81 | * Example service that lazy instantiates with other registered items as arguments to 82 | * the constructor: 83 | * - Injector.register(MyServiceClass, [DependentClass, OtherClass, 'constant-name']); 84 | * 85 | * Example service that lazy instantiates with other registered items specified 86 | * as $inject: 87 | * - class MyServiceClass { 88 | * - static readonly $inject = [OtherClass, 'constant-name']; 89 | * - constructor(other, constValue) {}; 90 | * - } 91 | * - Injector.register(MyServiceClass); 92 | * 93 | * Example service that lazy instantiates with a factory function: 94 | * - Injector.register(MyServiceClass, 95 | * - () => new MyServiceClass(Injector.get(OtherClass)!, MyArg)); 96 | * 97 | * @param clazz the class to register. Ex: MyClass. NOT an instance, the class. 98 | * @param factoryOrDependencies see above examples. Optional. 99 | * @param asName by default is clazz.name, but may specify another name to register as 100 | * @throws TypeError if duplicate or bad request 101 | */ 102 | static register( 103 | clazz: InjectableClass, 104 | factoryOrDependencies?: (() => T) | DependencyList, 105 | asName: string = clazz.name, 106 | ): void { 107 | if (!clazz || !asName) { 108 | throw new TypeError("Class to register is undefined: " + clazz.toString() + " " + asName); 109 | } else if (Injector.#bottle.container[asName] == undefined) { 110 | if (!factoryOrDependencies) { 111 | const dependencies = toNamedDependencies(clazz.$inject as DependencyList); 112 | logger.debug( 113 | `Registering class ${clazz.name} with dependencies: ` + dependencies.toString(), 114 | ); 115 | Injector.#bottle.service(asName, clazz, ...dependencies); 116 | } else if (Array.isArray(factoryOrDependencies)) { 117 | const dependencies = toNamedDependencies(factoryOrDependencies); 118 | logger.debug( 119 | `Registering class ${clazz.name} with dependencies: ` + dependencies.toString(), 120 | ); 121 | Injector.#bottle.service(asName, clazz, ...dependencies); 122 | } else if (typeof factoryOrDependencies === "function") { 123 | logger.debug(`Registering class ${clazz.name} with factory`); 124 | Injector.#bottle.factory(asName, factoryOrDependencies); 125 | } else { 126 | throw new TypeError( 127 | `Unknown type of factoryOrDependencies when registering ${clazz.name} with Injector`, 128 | ); 129 | } 130 | } else { 131 | logger.warn(`Already registered class ${clazz.name} as ${asName}`); 132 | } 133 | } 134 | 135 | /** 136 | * Register a factory by name. 137 | * 138 | * Example: 139 | * - Injector.registerFactory('MyThing', () => Things.getOne()); 140 | * 141 | * @see #getByName(name) 142 | * @param name name to give the inject. 143 | * @param factory function that returns a class. 144 | * @throws TypeError if duplicate or bad request 145 | */ 146 | static registerFactory(name: string, factory: () => T): void { 147 | if (!name) { 148 | throw new TypeError("Name to register is blank"); 149 | } else if (Injector.#bottle.container[name] == undefined) { 150 | logger.debug(`Registering ${name} with factory`); 151 | Injector.#bottle.factory(name, factory); 152 | } else { 153 | logger.warn(`Already registered factory ${name}`); 154 | } 155 | } 156 | 157 | /** 158 | * Register a named constant value. 159 | * 160 | * @see #getByName(name) 161 | * @param name name to give this constant. 162 | * @param value value to return when the name is requested. 163 | * @throws TypeError if duplicate or bad request 164 | */ 165 | static registerConstant(name: string, value: T): void { 166 | if (!name) { 167 | throw new TypeError("Constant name to register is blank"); 168 | } else if (Injector.#bottle.container[name] == undefined) { 169 | Injector.#bottle.constant(name, value); 170 | } else { 171 | logger.warn(`Already registered constant "${name}"`); 172 | } 173 | } 174 | 175 | /** 176 | * Get instance of a class. 177 | * Will instantiate on first request. 178 | * 179 | * @param clazz the class to fetch. Ex: MyClass. NOT an instance, the class. 180 | * @return the singleton instance of the requested class, undefined if not registered. 181 | */ 182 | static get(clazz: GettableClass): T | undefined { 183 | return Injector.#bottle.container[clazz.name] as T; 184 | } 185 | 186 | /** 187 | * Get a registered constant or class by name. 188 | * 189 | * @see #registerFactory(name, factory, force) 190 | * @see #registerConstant(name, value, force) 191 | * @param name 192 | * @return the singleton instance registered under the given name, 193 | * undefined if not registered. 194 | */ 195 | static getByName(name: string): T | undefined { 196 | return Injector.#bottle.container[name] as T; 197 | } 198 | 199 | /** 200 | * Is a class, factory, or constant registered? 201 | * Unlike #getByName, will not instantiate if registered but not yet lazy created. 202 | * 203 | * @param clazzOrName class or name of factory or constant 204 | */ 205 | static isRegistered( 206 | clazzOrName: InjectableClass | GettableClass | string, 207 | ): boolean { 208 | const name = typeof clazzOrName === "string" ? clazzOrName : clazzOrName.name; 209 | return name in Injector.#bottle.container; 210 | } 211 | } 212 | 213 | /** Options for Injectable decorator */ 214 | export interface InjectableOptions { 215 | /** 216 | * Register "as" this parent class or name. 217 | * A class *must* be a parent class. 218 | * The name string works for interfaces, but lacks type safety. 219 | */ 220 | as?: GettableClass | string; 221 | /** Don't auto-detect constructor dependencies - use this factory function instead */ 222 | factory?: () => T; 223 | /** Don't auto-detect constructor dependencies - use these instead */ 224 | dependencies?: DependencyList; 225 | } 226 | 227 | /** 228 | * Typescript Decorator for registering classes for injection. 229 | * 230 | * Must enable options in tsconfig.json: 231 | * { 232 | * "compilerOptions": { 233 | * "experimentalDecorators": true, 234 | * "emitDecoratorMetadata": true 235 | * } 236 | * } 237 | * 238 | * Usage: 239 | * 240 | * Like Injector.register(MyServiceClass, [Dependency1, Dependency2] 241 | * @Injectable() 242 | * class MyServiceClass { 243 | * constructor(one: Dependency1, two: Dependency2) {} 244 | * } 245 | * 246 | * Like Injector.register(MyServiceClass, [Dependency1, "registered-constant"] 247 | * @Injectable({dependencies=[Dependency1, "registered-constant"]}) 248 | * class MyServiceClass { 249 | * constructor(one: Dependency1, two: string) {} 250 | * } 251 | * 252 | * Like Injector.register(MyServiceClass, () = new MyServiceClass()) 253 | * @Injectable({factory: () = new MyServiceClass()}) 254 | * class MyServiceClass { 255 | * } 256 | * 257 | * Like Injector.register(HexagonalPort, () => new HexagonalAdaptor()) 258 | * abstract class HexagonalPort { 259 | * abstract getThing(): string; 260 | * } 261 | * @Injectable({as: HexagonalPort }) 262 | * class HexagonalAdaptor extends HexagonalPort { 263 | * getThing() { return "thing"; } 264 | * } 265 | */ 266 | export function Injectable(options?: InjectableOptions) { 267 | return function (target: InjectableClass) { 268 | let asName: string | undefined = undefined; 269 | if (typeof options?.as === "function") { 270 | // Validate that 'as' is a parent class 271 | let found = false; 272 | for ( 273 | let clazz = target; 274 | clazz; 275 | clazz = Object.getPrototypeOf(clazz) as InjectableClass 276 | ) { 277 | if (clazz?.name === options.as.name) { 278 | found = true; 279 | asName = options.as.name; 280 | break; 281 | } 282 | } 283 | if (!found) { 284 | throw new TypeError( 285 | `${options.as.name} is not a parent class of ${target.name} in @Injectable()`, 286 | ); 287 | } 288 | } else if (typeof options?.as === "string") { 289 | asName = options.as; 290 | } 291 | 292 | if (options?.factory && options.dependencies) { 293 | throw new TypeError( 294 | `Cannot specify both factory and dependencies on @Injectable() for ${target.name}`, 295 | ); 296 | } else if (options?.factory || options?.dependencies) { 297 | Injector.register(target, options.factory ?? options.dependencies, asName); 298 | } else { 299 | const metadata = Reflect.getMetadata("design:paramtypes", target) as 300 | | InjectableClass[] 301 | | undefined; 302 | const dependencies = metadata?.map((clazz) => clazz.name); 303 | Injector.register(target, dependencies, asName); 304 | } 305 | }; 306 | } 307 | -------------------------------------------------------------------------------- /docs/injector.md: -------------------------------------------------------------------------------- 1 | # Injector 2 | 3 | Light-weight and type-safe Dependency Injection. 4 | 5 | ## Overview 6 | 7 | Simple, light-weight, lazy-instantiating, and type-safe dependency injection in TypeScript! 8 | Perfect for use in Lambdas and unit test friendly. 9 | 10 | It is built on top of [BottleJS](https://www.npmjs.com/package/bottlejs), with a simple type-safe 11 | wrapper. The original `bottle` is available for more advanced use, though. Even if you are not using TypeScript, 12 | you may still prefer this simplified interface over using BottleJS directly. 13 | 14 | As of v3, Injector also supports a TypeScript decorator for registering classes. 15 | 16 | `Injector` depends on one other utility to work: 17 | 18 | - Sailplane [Logger](logger.md) 19 | 20 | ## Install 21 | 22 | ```shell 23 | npm install @sailplane/injector @sailplane/logger bottlejs@2 24 | ``` 25 | 26 | ## Configuration 27 | 28 | If you wish to use the TypeScript decorator, these options must be enabled in `tsconfig.json`: 29 | 30 | ```json 31 | { 32 | "compilerOptions": { 33 | "experimentalDecorators": true, 34 | "emitDecoratorMetadata": true 35 | } 36 | } 37 | ``` 38 | 39 | If using [esbuild](https://esbuild.github.io), a plugin such as 40 | [esbuild-decorators](https://github.com/anatine/esbuildnx/tree/main/packages/esbuild-decorators) is necessary. 41 | 42 | ## API Documentation 43 | 44 | [API Documentation on jsDocs.io](https://www.jsdocs.io/package/@sailplane/injector) 45 | 46 | ## Usage with Examples 47 | 48 | ### Register a class with no dependencies and retrieve it 49 | 50 | Use `Injector.register(className)` to register a class with the Injector. Upon the first call 51 | to `Injector.get(className)`, the singleton instance will be created and returned. 52 | 53 | Example without decorator: 54 | 55 | ```ts 56 | import { Injector } from "@sailplane/injector"; 57 | 58 | export class MyService {} 59 | Injector.register(MyService); 60 | 61 | // Elsewhere... 62 | const myService = Injector.get(MyService)!; 63 | ``` 64 | 65 | Example with decorator: 66 | 67 | ```ts 68 | import { Injector, Injectable } from "@sailplane/injector"; 69 | 70 | @Injectable() 71 | export class MyService {} 72 | 73 | // Elsewhere... 74 | const myService = Injector.get(MyService)!; 75 | ``` 76 | 77 | ### Register a class with an array of dependencies 78 | 79 | Use `Injector.register(className, dependencies: [])` to register a class with constructor 80 | dependencies with the Injector. Upon the first call to `Injector.get(className)`, the 81 | singleton instance will be created and returned. 82 | 83 | `dependencies` is an array of either class names or strings with the names of things. 84 | 85 | Example without decorator: 86 | 87 | ```ts 88 | import { Injector } from "@sailplane/injector"; 89 | 90 | export class MyHelper {} 91 | Injector.register(MyHelper); 92 | Injector.registerConstant("stage", "dev"); 93 | 94 | export class MyService { 95 | constructor( 96 | private readonly helper: MyHelper, 97 | private readonly stage: string, 98 | ) {} 99 | } 100 | Injector.register(MyService, [MyHelper, "stage"]); 101 | ``` 102 | 103 | Example with decorator: 104 | 105 | ```ts 106 | import { Injector, Injectable } from "@sailplane/injector"; 107 | 108 | @Injectable() 109 | export class MyHelper {} 110 | Injector.registerConstant("stage", "dev"); 111 | 112 | @Injectable({ dependencies: [MyHelper, "stage"] }) 113 | export class MyService { 114 | constructor( 115 | private readonly helper: MyHelper, 116 | private readonly stage: string, 117 | ) {} 118 | } 119 | ``` 120 | 121 | ### Register a class with static $inject array 122 | 123 | Define your class with a `static $inject` member as an array of either class names or strings 124 | with the names of registered dependencies, and a matching constructor that accepts the dependencies 125 | in the same order. Then use `Injector.register(className)` to register a class. 126 | Upon the first call to `Injector.get(className)`, the 127 | singleton instance will be created with the specified dependencies. 128 | 129 | This functions just like the previous use but allows you to specify the dependencies 130 | right next to the constructor instead of after the class definition; thus making it easier to 131 | keep the two lists synchronized. 132 | 133 | Example: 134 | 135 | ```ts 136 | import { Injector } from "@sailplane/injector"; 137 | 138 | export class MyHelper {} 139 | Injector.register(MyHelper); 140 | Injector.registerConstant("stage", "dev"); 141 | 142 | export class MyService { 143 | static readonly $inject = [MyHelper, "stage"]; 144 | constructor( 145 | private readonly helper: MyHelper, 146 | private readonly stage: string, 147 | ) {} 148 | } 149 | Injector.register(MyService); 150 | ``` 151 | 152 | This approach is only applicable when not using the decorator. 153 | 154 | ### Register a class with a factory 155 | 156 | If your class takes constructor parameters that are not in the dependency system, then 157 | you can register a factory function. 158 | 159 | Use `Injector.register(className, factory: ()=>T)` to register a class with your 160 | own factory function for instantiating the singleton instance. 161 | 162 | Example without decorator: 163 | 164 | ```ts 165 | import { Injector } from "@sailplane/injector"; 166 | 167 | export class MyHelper {} 168 | Injector.register(MyHelper); 169 | 170 | export class MyService { 171 | constructor( 172 | private readonly helper: MyHelper, 173 | private readonly stage: string, 174 | ) {} 175 | } 176 | Injector.register(MyService, () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!)); 177 | ``` 178 | 179 | Example with decorator: 180 | 181 | ```ts 182 | import { Injector, Injectable } from "@sailplane/injector"; 183 | 184 | @Injectable() 185 | export class MyHelper {} 186 | 187 | @Injectable({ factory: () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!) }) 188 | export class MyService { 189 | constructor( 190 | private readonly helper: MyHelper, 191 | private readonly stage: string, 192 | ) {} 193 | } 194 | ``` 195 | 196 | ### Register a child class that implements a parent 197 | 198 | A common dependency injection pattern is to invent code dependencies by having business logic 199 | define an interface it needs to talk to via an abstract class, and then elsewhere have a child 200 | class define the actual behavior. Runtime options could even choose between implementations. 201 | 202 | Here's the business logic code: 203 | 204 | ```ts 205 | abstract class SpecialDataRepository { 206 | abstract get(id: string): Promise; 207 | } 208 | 209 | class SpecialBizLogicService { 210 | constructor(dataRepo: SpecialDataRepository) {} 211 | public async calculate(id: string) { 212 | const data = await this.dataRepo.get(id); 213 | // do stuff 214 | } 215 | } 216 | Injector.register(SpecialBizLogicService); // Could use @Injectable() instead 217 | ``` 218 | 219 | Without decorators, we use `Injector.register(className, factory: ()=>T)` 220 | to register the implementing repository, which could be done conditionally: 221 | 222 | Example without decorator: 223 | 224 | ```ts 225 | import { Injector } from "@sailplane/injector"; 226 | 227 | export class LocalDataRepository extends SpecialDataRepository { 228 | async get(id: string): Promise { 229 | // implementation .... 230 | } 231 | } 232 | 233 | export class RemoteDataRepository extends SpecialDataRepository { 234 | async get(id: string): Promise { 235 | // implementation .... 236 | } 237 | } 238 | 239 | const isLocal = !!process.env.SHELL; 240 | Injector.register(MyService, () => 241 | isLocal ? new LocalDataRepository() : new RemoteDataRepository(), 242 | ); 243 | ``` 244 | 245 | Example with decorator (can't be conditional): 246 | 247 | ```ts 248 | import { Injector, Injectable } from "@sailplane/injector"; 249 | 250 | @Injectable({ as: SpecialDataRepository }) 251 | export class RemoteDataRepository extends SpecialDataRepository { 252 | async get(id: string): Promise { 253 | // implementation .... 254 | } 255 | } 256 | ``` 257 | 258 | ### Register anything with a factory and fetch it by name 259 | 260 | If you need to inject something other than a class, you can register a factory to create 261 | anything and give it a name. This is useful if you have multiple implementations 262 | of an `interface`, and one to register one of them by the interface name at runtime. Since 263 | interfaces don't exist at runtime (they don't exist in JavaScript), you must define the name 264 | yourself. (See the previous example using an abstract base class for a more type-safe approach.) 265 | 266 | Use `Injector.registerFactory(name: string, factory: ()=>T)` to register any object with your 267 | own factory function for returning the singleton instance. 268 | 269 | Example: Inject a configuration 270 | 271 | ```ts 272 | import { Injector } from "@sailplane/injector"; 273 | 274 | Injector.registerFactory("config", () => { 275 | // Note that this returns a Promise 276 | return Injector.get(StateStorage)!.get("MyService", "config"); 277 | }); 278 | 279 | // Elsewhere... 280 | const config = await Injector.getByName("config"); 281 | ``` 282 | 283 | Example: Inject an interface implementation, conditionally and no decorator 284 | 285 | ```ts 286 | import { Injector } from "@sailplane/injector"; 287 | 288 | export interface FoobarService { 289 | doSomething(): void; 290 | } 291 | 292 | export class FoobarServiceImpl implements FoobarService { 293 | constructor(private readonly stateStorage: StateStorage) {} 294 | 295 | doSomething(): void { 296 | this.stateStorage.set("foobar", "did-it", "true"); 297 | } 298 | } 299 | 300 | export class FoobarServiceDemo implements FoobarService { 301 | doSomething(): void { 302 | console.log("Nothing really"); 303 | } 304 | } 305 | 306 | Injector.registerFactory("FoobarService", () => { 307 | if (process.env.DEMO! === "true") { 308 | return new FoobarServiceDemo(); 309 | } else { 310 | return new FoobarServiceImpl(Injector.get(StateStorage)!); 311 | } 312 | }); 313 | 314 | // Elsewhere... 315 | 316 | export class MyService { 317 | static readonly $inject = ["FoobarService"]; // Note: This is a string! 318 | constructor(private readonly foobarSvc: FoobarService) {} 319 | } 320 | Injector.register(MyService); 321 | ``` 322 | 323 | Example: Inject an interface implementation with the decorator 324 | 325 | ```ts 326 | import { Injector, Injectable } from "@sailplane/injector"; 327 | 328 | export interface FoobarService { 329 | doSomething(): void; 330 | } 331 | 332 | @Injectable({ as: "FoobarService" }) 333 | export class FoobarServiceImpl implements FoobarService { 334 | constructor(private readonly stateStorage: StateStorage) {} 335 | 336 | doSomething(): void { 337 | // code 338 | } 339 | } 340 | 341 | // Elsewhere... 342 | 343 | @Injectable({ dependencies: ["FoobarService"] }) // Note: This is a string! 344 | export class MyService { 345 | constructor(private readonly foobarSvc: FoobarService) {} 346 | } 347 | ``` 348 | 349 | ### Register a constant value and fetch it by name 350 | 351 | Use `Injector.registerConstant(name: string, value: T)` to register any object as a 352 | defined value. Unlike all other registrations, the value is passed in rather than being 353 | lazy-created. 354 | 355 | Example: 356 | 357 | ```ts 358 | import { Injector } from "@sailplane/injector"; 359 | import { environment } from "environment"; 360 | 361 | Injector.registerConstant("environment-config", environment); 362 | Injector.registerConstant("promisedData", asyncFunction()); 363 | 364 | // Later... 365 | 366 | const myEnv = Injector.getByName("environment-config"); 367 | const myData = await Injector.getByName("promisedData"); 368 | ``` 369 | 370 | ### Isolate class dependency injection for Unit test 371 | 372 | Dependency that we need to mock out: `foo.repository.ts`: 373 | 374 | ```ts 375 | import { Injector, Injectable } from "@sailplane/injector"; 376 | 377 | @Injectable() 378 | export class FooRepository { 379 | get(id: string): Promise { 380 | // implementation .... 381 | } 382 | } 383 | ``` 384 | 385 | Service to unit test: `foo.service.ts`: 386 | 387 | ```ts 388 | import { Injector, Injectable } from "@sailplane/injector"; 389 | import { FoobarRepository } from "./foo.repository.js"; 390 | 391 | @Injectable() 392 | export class FooService { 393 | constructor(private readonly fooRepo: FooRepository) {} 394 | 395 | async getValue(id: string): Promise { 396 | const foo = await this.fooRepo.get(id); 397 | return foo.value; 398 | } 399 | } 400 | ``` 401 | 402 | The `import` of `FooRepository` will register it with `Injector`, but we'll just ignore that in our unit test: 403 | 404 | ```ts 405 | import { FooService } from "./foo.service.js"; 406 | 407 | describe("FooService", () => { 408 | it("will return the value of a Foo", async () => { 409 | const mockRepo = { get: (id: string) => Promise.resolve("tada") }; 410 | const uut = new FooService(mockRepo); 411 | expect(uut.getValue("test")).toEqual("tada"); 412 | }); 413 | }) 414 | ``` 415 | 416 | ### Isolate dependency get for Unit test 417 | 418 | Sometimes top-level code will need to call `Injector.get(name)` directly. To mock this out for a unit test, 419 | we can switch injector domains. This is necessary when simply importing a dependency injects the real 420 | dependency, because we cannot simply register a replacement. 421 | 422 | Continuing with `FooService` from the previous example, here's `foo.handler.ts`: 423 | 424 | ```ts 425 | import { FooService } from "./foo.service.js"; 426 | 427 | export const handler = (request) => { 428 | const fooService = Injector.get(FooService)!; 429 | return fooService.getValue(request.parameter.id); 430 | } 431 | ``` 432 | 433 | To test this, we use `Injector.switchDomain(name)`: 434 | 435 | ```ts 436 | import { Injector } from "@sailplane/injector"; 437 | import { handler } from "./foo.handler.js"; 438 | 439 | // Switch to test domain and register mock 440 | Injector.switchDomain("test"); 441 | Injector.registerConstant("FooService", { getValue: (id) => id + " value" }); 442 | 443 | it("Foo handler returns requested value", async () => { 444 | expect(handler({parameter: { id: "hello" }})).toEqual("hello value"); 445 | }); 446 | ``` 447 | 448 | ## More Examples 449 | 450 | See [examples](examples.md) for another example. 451 | 452 | ## Dependency Evaluation 453 | 454 | Dependencies are not evaluated until the class is instantiated, thus the order of registration does not matter. 455 | 456 | Cyclic constructor dependencies will fail. This probably indicates a design problem, but you can break the cycle by 457 | calling `Injector.get(className)` when needed (outside the constructor), instead of injecting into the constructor. 458 | 459 | This is a perfectly valid way of using Injector (on demand rather than upon construction). It does require 460 | that unit test mocks be registered with the Injector rather than passed into the constructor. 461 | -------------------------------------------------------------------------------- /injector/lib/injector.test.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Injector } from "./injector"; 2 | 3 | /** 4 | * Helper for calls that are expected to throw. 5 | * Not using jest.expect#toThrow because it insists on logging errors and stack traces. 6 | * @param fn function to call 7 | * @returns the thrown error 8 | * @throws error if fn doesn't throw 9 | */ 10 | function expectThrow(fn: () => any): unknown { 11 | try { 12 | fn(); 13 | } catch (err) { 14 | return err; 15 | } 16 | throw new Error("expected to have thrown"); 17 | } 18 | 19 | describe("Injector", () => { 20 | test("switchDomain()", () => { 21 | // Using default domain 22 | expect(Injector.isRegistered("Unswitched")).toBeFalsy(); 23 | Injector.registerConstant("Unswitched", "default"); 24 | expect(Injector.isRegistered("Unswitched")).toBeTruthy(); 25 | expect(Injector.isRegistered("IsSecond")).toBeFalsy(); 26 | 27 | // Switch to second domain 28 | Injector.switchDomain("second"); 29 | expect(Injector.isRegistered("Unswitched")).toBeFalsy(); 30 | expect(Injector.isRegistered("IsSecond")).toBeFalsy(); 31 | Injector.registerConstant("IsSecond", "Hello"); 32 | expect(Injector.isRegistered("Unswitched")).toBeFalsy(); 33 | expect(Injector.isRegistered("IsSecond")).toBeTruthy(); 34 | 35 | // Switch back to default, registration is still there 36 | Injector.switchDomain(Injector.defaultDomain); 37 | expect(Injector.isRegistered("Unswitched")).toBeTruthy(); 38 | expect(Injector.isRegistered("IsSecond")).toBeFalsy(); 39 | 40 | // Switch back to second with reset 41 | Injector.switchDomain("second", true); 42 | expect(Injector.isRegistered("Unswitched")).toBeFalsy(); 43 | expect(Injector.isRegistered("IsSecond")).toBeFalsy(); 44 | 45 | // Reset to default to continue unit tests 46 | Injector.switchDomain(Injector.defaultDomain); 47 | }); 48 | 49 | test("access underlying bottle", () => { 50 | expect(Injector.bottle.container).toBeTruthy(); 51 | expect(Injector.bottle.container["Unswitched"]).toEqual("default"); 52 | }); 53 | 54 | describe("register() & get()", () => { 55 | test("register/get with no dependencies", () => { 56 | // GIVEN 57 | expect(NoDependencyClass.numInstances).toBe(0); 58 | expect(Injector.isRegistered(NoDependencyClass)).toBeFalsy(); 59 | 60 | // WHEN 61 | Injector.register(NoDependencyClass); 62 | const instance = Injector.get(NoDependencyClass)!; 63 | 64 | // THEN 65 | expect(instance).toBeInstanceOf(NoDependencyClass); 66 | expect(NoDependencyClass.numInstances).toBe(1); 67 | }); 68 | 69 | test("register/get with array of dependencies", () => { 70 | // GIVEN 71 | expect(NoDependencyClass.numInstances).toBe(1); 72 | expect(OneDependencyClass.numInstances).toBe(0); 73 | expect(Injector.isRegistered(OneDependencyClass)).toBeFalsy(); 74 | 75 | Injector.register(OneDependencyClass, [NoDependencyClass]); 76 | 77 | // WHEN 78 | const instance = Injector.get(OneDependencyClass)!; 79 | 80 | // THEN 81 | expect(instance).toBeInstanceOf(OneDependencyClass); 82 | expect(instance.noDep).toBeInstanceOf(NoDependencyClass); 83 | expect(NoDependencyClass.numInstances).toBe(1); // reused! 84 | expect(OneDependencyClass.numInstances).toBe(1); 85 | }); 86 | 87 | test("register/get with static $inject of dependencies", () => { 88 | // GIVEN 89 | expect(NoDependencyClass.numInstances).toBe(1); 90 | expect(OneDependencyClass.numInstances).toBe(1); 91 | expect(TwoDependencyClass.numInstances).toBe(0); 92 | expect(Injector.isRegistered(TwoDependencyClass)).toBeFalsy(); 93 | 94 | Injector.register(TwoDependencyClass); 95 | 96 | // WHEN 97 | const instance = Injector.get(TwoDependencyClass)!; 98 | 99 | // THEN 100 | expect(instance).toBeInstanceOf(TwoDependencyClass); 101 | expect(instance.noDep).toBeInstanceOf(NoDependencyClass); 102 | expect(instance.oneDep).toBeInstanceOf(OneDependencyClass); 103 | expect(NoDependencyClass.numInstances).toBe(1); // reused! 104 | expect(OneDependencyClass.numInstances).toBe(1); // reused! 105 | expect(TwoDependencyClass.numInstances).toBe(1); 106 | }); 107 | 108 | test("register/get with factory", () => { 109 | // GIVEN 110 | expect(FactoryBuiltClass.numInstances).toBe(0); 111 | 112 | Injector.register(FactoryBuiltClass, () => new FactoryBuiltClass("one", 2)); 113 | 114 | // WHEN 115 | const instanceA = Injector.get(FactoryBuiltClass)!; 116 | const instanceB = Injector.get(FactoryBuiltClass)!; 117 | 118 | // THEN 119 | expect(instanceA).toBeInstanceOf(FactoryBuiltClass); 120 | expect(instanceA.value1).toEqual("one"); 121 | expect(instanceA.value2).toEqual(2); 122 | expect(FactoryBuiltClass.numInstances).toBe(1); 123 | expect(instanceA).toBe(instanceB); 124 | }); 125 | 126 | test("register() duplicate ignored", () => { 127 | // GIVEN 128 | expect(NoDependencyClass.numInstances).toBe(1); 129 | 130 | // WHEN 131 | Injector.register(NoDependencyClass); 132 | 133 | // THEN 134 | expect(NoDependencyClass.numInstances).toBe(1); 135 | }); 136 | 137 | test("register(undefined)", () => { 138 | // WHEN 139 | const err = expectThrow(() => Injector.register(undefined!)); 140 | 141 | // THEN 142 | expect(err).toBeInstanceOf(TypeError); 143 | }); 144 | 145 | test("register(not a class)", () => { 146 | // WHEN 147 | const err = expectThrow(() => Injector.register((() => {}) as any)); 148 | 149 | // THEN 150 | expect(err).toBeInstanceOf(TypeError); 151 | }); 152 | 153 | test("register(bad dependencies)", () => { 154 | // WHEN 155 | const err = expectThrow(() => Injector.register(Date, true as any)); 156 | 157 | // THEN 158 | expect(err).toBeInstanceOf(TypeError); 159 | }); 160 | }); 161 | 162 | describe("Injectable decorator", () => { 163 | test("register with automatic dependency detection", () => { 164 | // GIVEN 165 | expect(Injector.isRegistered(NoDependencyClass)).toBeTruthy(); 166 | expect(Injector.isRegistered(OneDependencyClass)).toBeTruthy(); 167 | expect(Injector.isRegistered("DecoratedClass")).toBeFalsy(); 168 | 169 | // WHEN 170 | @Injectable() 171 | class DecoratedClass { 172 | static numInstances = 0; 173 | 174 | constructor( 175 | public noDep: NoDependencyClass, 176 | public oneDep: OneDependencyClass, 177 | ) { 178 | DecoratedClass.numInstances++; 179 | } 180 | get instanceCount() { 181 | return DecoratedClass.numInstances; 182 | } 183 | } 184 | 185 | // THEN decorated class has registered but not yet instantiated 186 | expect(DecoratedClass.numInstances).toBe(0); 187 | expect(Injector.isRegistered(DecoratedClass)).toBeTruthy(); 188 | expect(Injector.isRegistered("DecoratedClass")).toBeTruthy(); 189 | 190 | // WHEN 191 | const instance = Injector.getByName("DecoratedClass")! as any; 192 | 193 | // THEN we can examine the instance, even though don't have access to class def anymore 194 | expect(instance).toBeDefined(); 195 | expect(instance.constructor.name).toEqual("DecoratedClass"); 196 | expect(instance.instanceCount).toBe(1); 197 | }); 198 | 199 | test("register as parent and no constructor", () => { 200 | // GIVEN 201 | abstract class HexagonalPort2 { 202 | abstract getThing(): string; 203 | } 204 | expect(Injector.isRegistered(HexagonalPort2)).toBeFalsy(); 205 | 206 | @Injectable({ as: HexagonalPort2 }) 207 | class HexagonalAdaptor2 extends HexagonalPort2 { 208 | getThing() { 209 | return "thing"; 210 | } 211 | } 212 | expect(Injector.isRegistered(HexagonalPort2)).toBeTruthy(); 213 | 214 | // WHEN 215 | const instance = Injector.get(HexagonalPort2)!; 216 | 217 | // THEN 218 | expect(instance).toBeInstanceOf(HexagonalAdaptor2); 219 | expect(instance.getThing()).toEqual("thing"); 220 | }); 221 | 222 | test("register as name", () => { 223 | // GIVEN 224 | interface HexagonalPort3 { 225 | getThing(): string; 226 | } 227 | expect(Injector.isRegistered("HexagonalPort3")).toBeFalsy(); 228 | 229 | @Injectable({ as: "HexagonalPort3" }) 230 | class HexagonalAdaptor3 implements HexagonalPort3 { 231 | constructor() {} 232 | getThing() { 233 | return "thing"; 234 | } 235 | } 236 | expect(Injector.isRegistered("HexagonalPort3")).toBeTruthy(); 237 | 238 | // WHEN 239 | const instance = Injector.getByName("HexagonalPort3")!; 240 | 241 | // THEN 242 | expect(instance).toBeInstanceOf(HexagonalAdaptor3); 243 | expect(instance.getThing()).toEqual("thing"); 244 | }); 245 | 246 | test("register with factory as parent", () => { 247 | // GIVEN 248 | abstract class HexagonalPort { 249 | abstract getThing(): string; 250 | } 251 | expect(Injector.isRegistered(HexagonalPort)).toBeFalsy(); 252 | 253 | @Injectable({ factory: () => new HexagonalAdaptor(), as: HexagonalPort }) 254 | class HexagonalAdaptor extends HexagonalPort { 255 | constructor() { 256 | super(); 257 | } 258 | getThing() { 259 | return "thing"; 260 | } 261 | } 262 | expect(Injector.isRegistered(HexagonalPort)).toBeTruthy(); 263 | 264 | // WHEN 265 | const instance = Injector.get(HexagonalPort)!; 266 | 267 | // THEN 268 | expect(instance).toBeInstanceOf(HexagonalAdaptor); 269 | expect(instance.getThing()).toEqual("thing"); 270 | }); 271 | 272 | test("register as NOT parent fails", () => { 273 | // WHEN 274 | try { 275 | @Injectable({ as: String }) 276 | // eslint-disable-next-line @typescript-eslint/no-unused-vars -- eslint fails to understand @Injectable 277 | class NotChildOfString extends NoDependencyClass {} 278 | 279 | fail("Expected to throw"); 280 | } catch (_) { 281 | // Expected 282 | } 283 | }); 284 | 285 | test("fail to register both factory and dependencies", () => { 286 | // WHEN 287 | try { 288 | @Injectable({ factory: () => new NoCanDo(), dependencies: [] }) 289 | class NoCanDo {} 290 | 291 | fail("Expected to throw"); 292 | } catch (_) { 293 | // Expected 294 | } 295 | }); 296 | }); 297 | 298 | describe("registerFactory() & getByName()", () => { 299 | test("registerFactory/getByName with factory", () => { 300 | // GIVEN 301 | const name = "factory-test"; 302 | const someObject = { api: 2, url: "https://www.onica.com" }; 303 | 304 | Injector.registerFactory(name, () => someObject); 305 | 306 | // WHEN 307 | const instanceA = Injector.getByName(name)!; 308 | const instanceB = Injector.getByName(name)!; 309 | 310 | // THEN 311 | expect(instanceA).toBe(someObject); 312 | expect(instanceA).toBe(instanceB); 313 | }); 314 | 315 | test("registerFactory with duplicate name", () => { 316 | // GIVEN 317 | const name = "factory-test"; 318 | const someObject = { api: 3 }; 319 | 320 | // WHEN 321 | Injector.registerFactory(name, () => someObject); 322 | const instance = Injector.getByName<{ api: number }>(name)!; 323 | 324 | // THEN 325 | expect(instance).toBeTruthy(); 326 | expect(instance.api).toEqual(2); // previously registered value 327 | }); 328 | 329 | test("registerFactory with blank name", () => { 330 | // WHEN 331 | const err = expectThrow(() => Injector.registerFactory("", () => "nope")); 332 | 333 | // THEN 334 | expect(err).toBeInstanceOf(TypeError); 335 | expect(Injector.isRegistered("")).toBeFalsy(); 336 | }); 337 | 338 | test("registerFactory with undefined name", () => { 339 | // WHEN 340 | const err = expectThrow(() => Injector.registerFactory(undefined!, () => "nope")); 341 | 342 | // THEN 343 | expect(err).toBeInstanceOf(TypeError); 344 | expect(Injector.getByName(undefined!)).toBeUndefined(); 345 | }); 346 | }); 347 | 348 | describe("registerConstant() & getByName()", () => { 349 | test("registerConstant/getByName with object", () => { 350 | // GIVEN 351 | const name = "const-test"; 352 | const someObject = { api: 2, url: "https://www.onica.com" }; 353 | 354 | Injector.registerConstant(name, someObject); 355 | 356 | // WHEN 357 | const instanceA = Injector.getByName(name)!; 358 | const instanceB = Injector.getByName(name)!; 359 | 360 | // THEN 361 | expect(instanceA).toBe(someObject); 362 | expect(instanceA).toBe(instanceB); 363 | }); 364 | 365 | test("registerConstant/getByName with string", () => { 366 | // GIVEN 367 | const name = "str-test"; 368 | const someValue = "Hello World!"; 369 | 370 | Injector.registerConstant(name, someValue); 371 | 372 | // WHEN 373 | const instanceA = Injector.getByName(name)!; 374 | const instanceB = Injector.getByName(name)!; 375 | 376 | // THEN 377 | expect(instanceA).toBe(someValue); 378 | expect(instanceA).toBe(instanceB); 379 | }); 380 | 381 | test("registerConstant with duplicate name", () => { 382 | // GIVEN 383 | const name = "const-test"; 384 | const someObject = "hello"; 385 | 386 | // WHEN 387 | Injector.registerConstant(name, someObject); 388 | const instance = Injector.getByName<{ api: number }>(name)!; 389 | 390 | // THEN 391 | expect(instance).toBeTruthy(); 392 | expect(instance.api).toEqual(2); // previously registered value 393 | }); 394 | 395 | test("registerConstant with blank name", () => { 396 | // WHEN 397 | const err = expectThrow(() => Injector.registerConstant("", "nope")); 398 | 399 | // THEN 400 | expect(err).toBeInstanceOf(TypeError); 401 | expect(Injector.isRegistered("")).toBeFalsy(); 402 | }); 403 | 404 | test("registerConstant with null name", () => { 405 | // WHEN 406 | const err = expectThrow(() => Injector.registerConstant(null!, "nope")); 407 | 408 | // THEN 409 | expect(err).toBeInstanceOf(TypeError); 410 | expect(Injector.getByName(null!)).toBeUndefined(); 411 | }); 412 | }); 413 | }); 414 | 415 | class NoDependencyClass { 416 | static numInstances = 0; 417 | 418 | constructor() { 419 | NoDependencyClass.numInstances++; 420 | } 421 | } 422 | 423 | class OneDependencyClass { 424 | static numInstances = 0; 425 | 426 | constructor(public noDep: NoDependencyClass) { 427 | OneDependencyClass.numInstances++; 428 | } 429 | } 430 | 431 | class TwoDependencyClass { 432 | static numInstances = 0; 433 | 434 | // Can specify dependency by class or name. 435 | static $inject = [NoDependencyClass, "OneDependencyClass"]; 436 | constructor( 437 | public noDep: NoDependencyClass, 438 | public oneDep: OneDependencyClass, 439 | ) { 440 | TwoDependencyClass.numInstances++; 441 | } 442 | } 443 | 444 | class FactoryBuiltClass { 445 | static numInstances = 0; 446 | 447 | constructor( 448 | public value1: any, 449 | public value2: any, 450 | ) { 451 | FactoryBuiltClass.numInstances++; 452 | } 453 | } 454 | --------------------------------------------------------------------------------