diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5e6cc6c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +.DS_Store +.serverless +.vscode +coverage +dist +node_modules diff --git a/README.md b/README.md index 7ffa773..6ced24a 100644 --- a/README.md +++ b/README.md @@ -20,27 +20,26 @@ Every tool is the genesis of real world needs, and they continue to evolve. This collection is part of Rackspace Technology's commitment to give back to the open source community. Find this and other Rackspace open source repositories on [GitHub](https://github.com/rackspace). - ## Content Each utility is described on its own page: -* [AwsHttps - HTTPS client with AWS Signature v4](docs/aws_https.md) -* [ElasticsearchClient - Communicate with AWS Elasticsearch](docs/elasticsearch_client.md) -* [ExpiringValue - Value that is instantiated on-demand and cached for a limited time](docs/expiring_value.md) -* [Injector - Light-weight and type-safe Dependency Injection](docs/injector.md) -* [LambdaUtils - AWS Lambda handler middleware](docs/lambda_utils.md) -* [Logger - CloudWatch and serverless-offline friendly logger](docs/logger.md) -* [StateStorage - Serverless state and configuration storage](docs/state_storage.md) -* [More Examples](docs/examples.md) -* [License](docs/license.md) +- [AwsHttps - HTTPS client with AWS Signature v4](docs/aws_https.md) +- [ElasticsearchClient - Communicate with AWS Elasticsearch](docs/elasticsearch_client.md) +- [ExpiringValue - Value that is instantiated on-demand and cached for a limited time](docs/expiring_value.md) +- [Injector - Light-weight and type-safe Dependency Injection](docs/injector.md) +- [LambdaUtils - AWS Lambda handler middleware](docs/lambda_utils.md) +- [Logger - CloudWatch and serverless-offline friendly logger](docs/logger.md) +- [StateStorage - Serverless state and configuration storage](docs/state_storage.md) +- [More Examples](docs/examples.md) +- [License](docs/license.md) ## Why "Sailplane"? Onica's early OSS releases have had aviation themed names; this may or may not have something to do with the CTO being a pilot. Nobody really knows. -Sailplane was selected for this *serverless* toolset by considering that +Sailplane was selected for this _serverless_ toolset by considering that serverless is to computing without the complexities of a server, as a sailplane is to flight without the complexities of an airplane. diff --git a/aws-https/README.md b/aws-https/README.md index dacf8f2..9df1aa0 100644 --- a/aws-https/README.md +++ b/aws-https/README.md @@ -1,12 +1,14 @@ # @sailplane/aws-http - HTTPS client with AWS Signature v4 ## What? -The AwsHttps class is an HTTPS (notice, *not* HTTP) client purpose made for use in and with AWS environments. + +The AwsHttps class is an HTTPS (notice, _not_ HTTP) client purpose made for use in and with AWS environments. This is part of the [sailplane](https://github.com/rackspace/sailplane) library of utilities for AWS Serverless in Node.js. ## Why? + - Simple Promise or async syntax - Optionally authenticates to AWS via AWS Signature v4 using [aws4](https://www.npmjs.com/package/aws4) - Familiar [options](https://nodejs.org/api/http.html#http_http_request_options_callback>) @@ -15,4 +17,5 @@ utilities for AWS Serverless in Node.js. - Easily extended for unit testing ## How? + See the [docs](https://github.com/rackspace/sailplane/blob/master/README.md) for usage and examples. diff --git a/docs/aws_https.md b/docs/aws_https.md index 33231ec..856c333 100644 --- a/docs/aws_https.md +++ b/docs/aws_https.md @@ -4,7 +4,7 @@ HTTPS client with AWS Signature v4. ## Overview -The AwsHttps class is an HTTPS (notice, *not* HTTP) client purpose made for use in and with AWS environments. +The AwsHttps class is an HTTPS (notice, _not_ HTTP) client purpose made for use in and with AWS environments. - Simple Promise or async syntax - Optionally authenticates to AWS via AWS Signature v4 using [aws4](https://www.npmjs.com/package/aws4) @@ -30,11 +30,11 @@ npm install @sailplane/aws-https @sailplane/logger Simple example to GET from URL: ```ts -const url = new URL('https://www.rackspace.com/ping.json'); +const url = new URL("https://www.rackspace.com/ping.json"); const http = new AwsHttps(); // Build request options from a method and URL -const options = http.buildOptions('GET', url); +const options = http.buildOptions("GET", url); // Make request and parse JSON response. const ping = await http.request(options); @@ -45,34 +45,33 @@ Example hitting API with the container's AWS credentials: ```ts const awsHttp = new AwsHttps(); const options: AwsHttpsOptions = { - // Same options as https://nodejs.org/api/http.html#http_http_request_options_callback - method: 'GET', - hostname: apiEndpoint, - path: '/cloud-help', - headers: { - 'accept': 'application/json; charset=utf-8', - 'content-type': 'application/json; charset=utf-8' - }, - timeout: 10000, - - // Additional option for POST, PUT, or PATCH: - body: JSON.stringify({ website: "https://www.rackspace.com" }), - - // Additional option to apply AWS Signature v4 - awsSign: true + // Same options as https://nodejs.org/api/http.html#http_http_request_options_callback + method: "GET", + hostname: apiEndpoint, + path: "/cloud-help", + headers: { + accept: "application/json; charset=utf-8", + "content-type": "application/json; charset=utf-8", + }, + timeout: 10000, + + // Additional option for POST, PUT, or PATCH: + body: JSON.stringify({ website: "https://www.rackspace.com" }), + + // Additional option to apply AWS Signature v4 + awsSign: true, }; try { - const responseObj = await awsHttp.request(options); - process(responseObj); + const responseObj = await awsHttp.request(options); + process(responseObj); } catch (err) { - // HTTP status response is in statusCode field - if (err.statusCode === 404) { - process(undefined); - } - else { - throw err; - } + // HTTP status response is in statusCode field + if (err.statusCode === 404) { + process(undefined); + } else { + throw err; + } } ``` @@ -83,15 +82,15 @@ Example hitting API with the custom AWS credentials: const roleCredentials = await this.getAssumeRoleCredentials(); const awsCredentials = { - accessKey: roleCredentials.AccessKeyId, - secretKey: roleCredentials.SecretAccessKey, - sessionToken: roleCredentials.SessionToken, + accessKey: roleCredentials.AccessKeyId, + secretKey: roleCredentials.SecretAccessKey, + sessionToken: roleCredentials.SessionToken, }; const http = new AwsHttps(false, awsCredentials); // Build request options from a method and URL -const url = new URL('https://www.rackspace.com/ping.json'); -const options = http.buildOptions('GET', url); +const url = new URL("https://www.rackspace.com/ping.json"); +const options = http.buildOptions("GET", url); // Make request and parse JSON response. const ping = await http.request(options); @@ -107,16 +106,16 @@ The Sailplane [ElasticsearchClient](elasticsearch_client.md) package is a simple ```ts export class AwsHttpsFake extends AwsHttps { - constructor() { - super(); - } + constructor() { + super(); + } - async request(options: AwsHttpsOptions): Promise { - // Check for expected options. Example: - expect(options.path).toEqual('/expected-path'); + async request(options: AwsHttpsOptions): Promise { + // Check for expected options. Example: + expect(options.path).toEqual("/expected-path"); - // Return canned response - return Promise.resolve({ success: true }); - } + // Return canned response + return Promise.resolve({ success: true }); + } } ``` diff --git a/docs/elasticsearch_client.md b/docs/elasticsearch_client.md index b1dab9b..ea4dd10 100644 --- a/docs/elasticsearch_client.md +++ b/docs/elasticsearch_client.md @@ -34,8 +34,9 @@ Simple example: ```ts function get(id: string): Promise { - return this.es.request('GET', '/ticket/local/' + id) - .then((esDoc: ElasticsearchResult) => esDoc._source as Ticket); + return this.es + .request("GET", "/ticket/local/" + id) + .then((esDoc: ElasticsearchResult) => esDoc._source as Ticket); } ``` diff --git a/docs/examples.md b/docs/examples.md index 728c5ef..e20ae57 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,10 +6,10 @@ This section includes some larger examples which use _multiple_ packages. Uses: -* [AwsHttps](aws_https.md) -* [ElasticsearchClient](elasticsearch_client.md) -* [Injector](injector.md) -* [Logger](logger.md) +- [AwsHttps](aws_https.md) +- [ElasticsearchClient](elasticsearch_client.md) +- [Injector](injector.md) +- [Logger](logger.md) ```ts import {AwsHttps} from "@sailplane/aws-https"; @@ -149,11 +149,11 @@ functions: ```ts // src/handlers.ts -import 'source-map-support/register'; -import {APIGatewayEvent} from 'aws-lambda'; -import {Injector} from "@sailplane/injector"; +import "source-map-support/register"; +import { APIGatewayEvent } from "aws-lambda"; +import { Injector } from "@sailplane/injector"; import * as LambdaUtils from "@sailplane/lambda-utils"; -import {ChatService} from "./chat-service"; +import { ChatService } from "./chat-service"; import * as createHttpError from "http-errors"; Injector.register(StateStorage, () => new StateStorage(process.env.STATE_STORAGE_PREFIX)); @@ -161,12 +161,14 @@ Injector.register(StateStorage, () => new StateStorage(process.env.STATE_STORAGE /** * Fetch history of chat on the user's channel */ -export const getChatHistory = LambdaUtils.wrapApiHandler(async (event: LambdaUtils.APIGatewayProxyEvent) => { +export const getChatHistory = LambdaUtils.wrapApiHandler( + async (event: LambdaUtils.APIGatewayProxyEvent) => { const channel = event.queryStringParameters.channel; const cursor = event.queryStringParameters.cursor; return Injector.get(ChatService)!.getHistory(channel, cursor); -}); + }, +); ``` ```ts diff --git a/docs/expiring_value.md b/docs/expiring_value.md index f0b51d1..1e54112 100644 --- a/docs/expiring_value.md +++ b/docs/expiring_value.md @@ -39,16 +39,16 @@ const https = new AwsHttps(); const cache = {}; export function fetchWithCache(url: string): Promise { - if (!cache[url]) { - cache[url] = new ExpiringValue(() => loadData(url), CACHE_PERIOD); - } + if (!cache[url]) { + cache[url] = new ExpiringValue(() => loadData(url), CACHE_PERIOD); + } - return cache[url].get(); + return cache[url].get(); } function loadData(url: string): any { - const req = https.buildRequest('GET', new URL(url)); - return https.request(req); + const req = https.buildRequest("GET", new URL(url)); + return https.request(req); } ``` diff --git a/docs/injector.md b/docs/injector.md index 91bc07a..fd90e3c 100644 --- a/docs/injector.md +++ b/docs/injector.md @@ -53,10 +53,9 @@ to `Injector.get(className)`, the singleton instance will be created and returne Example without decorator: ```ts -import {Injector} from "@sailplane/injector"; +import { Injector } from "@sailplane/injector"; -export class MyService { -} +export class MyService {} Injector.register(MyService); // Elsewhere... @@ -66,11 +65,10 @@ const myService = Injector.get(MyService)!; Example with decorator: ```ts -import {Injector, Injectable} from "@sailplane/injector"; +import { Injector, Injectable } from "@sailplane/injector"; @Injectable() -export class MyService { -} +export class MyService {} // Elsewhere... const myService = Injector.get(MyService)!; @@ -87,36 +85,36 @@ singleton instance will be created and returned. Example without decorator: ```ts -import {Injector} from "@sailplane/injector"; +import { Injector } from "@sailplane/injector"; -export class MyHelper { -} +export class MyHelper {} Injector.register(MyHelper); -Injector.registerConstant('stage', 'dev'); +Injector.registerConstant("stage", "dev"); export class MyService { - constructor(private readonly helper: MyHelper, - private readonly stage: string) { - } + constructor( + private readonly helper: MyHelper, + private readonly stage: string, + ) {} } -Injector.register(MyService, [MyHelper, 'stage']); +Injector.register(MyService, [MyHelper, "stage"]); ``` Example with decorator: ```ts -import {Injector, Injectable} from "@sailplane/injector"; +import { Injector, Injectable } from "@sailplane/injector"; @Injectable() -export class MyHelper { -} -Injector.registerConstant('stage', 'dev'); +export class MyHelper {} +Injector.registerConstant("stage", "dev"); -@Injectable({ dependencies: [MyHelper, 'stage'] }) +@Injectable({ dependencies: [MyHelper, "stage"] }) export class MyService { - constructor(private readonly helper: MyHelper, - private readonly stage: string) { - } + constructor( + private readonly helper: MyHelper, + private readonly stage: string, + ) {} } ``` @@ -135,18 +133,18 @@ keep the two lists synchronized. Example: ```ts -import {Injector} from "@sailplane/injector"; +import { Injector } from "@sailplane/injector"; -export class MyHelper { -} +export class MyHelper {} Injector.register(MyHelper); -Injector.registerConstant('stage', 'dev'); +Injector.registerConstant("stage", "dev"); export class MyService { - static readonly $inject = [MyHelper, 'stage']; - constructor(private readonly helper: MyHelper, - private readonly stage: string) { - } + static readonly $inject = [MyHelper, "stage"]; + constructor( + private readonly helper: MyHelper, + private readonly stage: string, + ) {} } Injector.register(MyService); ``` @@ -164,35 +162,34 @@ own factory function for instantiating the singleton instance. Example without decorator: ```ts -import {Injector} from "@sailplane/injector"; +import { Injector } from "@sailplane/injector"; -export class MyHelper { -} +export class MyHelper {} Injector.register(MyHelper); export class MyService { - constructor(private readonly helper: MyHelper, - private readonly stage: string) { - } + constructor( + private readonly helper: MyHelper, + private readonly stage: string, + ) {} } -Injector.register(MyService, - () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!)); +Injector.register(MyService, () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!)); ``` Example with decorator: ```ts -import {Injector, Injectable} from "@sailplane/injector"; +import { Injector, Injectable } from "@sailplane/injector"; @Injectable() -export class MyHelper { -} +export class MyHelper {} -@Injectable({factory: () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!)}) +@Injectable({ factory: () => new MyService(Injector.get(MyHelper)!, process.env.STAGE!) }) export class MyService { - constructor(private readonly helper: MyHelper, - private readonly stage: string) { - } + constructor( + private readonly helper: MyHelper, + private readonly stage: string, + ) {} } ``` @@ -206,15 +203,15 @@ Here's the business logic code: ```ts abstract class SpecialDataRepository { -abstract get(id: string): Promise; + abstract get(id: string): Promise; } class SpecialBizLogicService { -constructor(dataRepo: SpecialDataRepository) {} -public async calculate(id: string) { - const data = await this.dataRepo.get(id); - // do stuff -} + constructor(dataRepo: SpecialDataRepository) {} + public async calculate(id: string) { + const data = await this.dataRepo.get(id); + // do stuff + } } Injector.register(SpecialBizLogicService); // Could use @Injectable() instead ``` @@ -225,7 +222,7 @@ to register the implementing repository, which could be done conditionally: Example without decorator: ```ts -import {Injector} from "@sailplane/injector"; +import { Injector } from "@sailplane/injector"; export class LocalDataRepository extends SpecialDataRepository { async get(id: string): Promise { @@ -240,18 +237,17 @@ export class RemoteDataRepository extends SpecialDataRepository { } const isLocal = !!process.env.SHELL; -Injector.register( - MyService, - () => isLocal ? new LocalDataRepository() : new RemoteDataRepository() +Injector.register(MyService, () => + isLocal ? new LocalDataRepository() : new RemoteDataRepository(), ); ``` Example with decorator (can't be conditional): ```ts -import {Injector, Injectable} from "@sailplane/injector"; +import { Injector, Injectable } from "@sailplane/injector"; -@Injectable({as: SpecialDataRepository}) +@Injectable({ as: SpecialDataRepository }) export class RemoteDataRepository extends SpecialDataRepository { async get(id: string): Promise { // implementation .... @@ -273,55 +269,53 @@ own factory function for returning the singleton instance. Example: Inject a configuration ```ts -import {Injector} from "@sailplane/injector"; +import { Injector } from "@sailplane/injector"; -Injector.registerFactory('config', () => { - // Note that this returns a Promise - return Injector.get(StateStorage)!.get('MyService', 'config'); +Injector.registerFactory("config", () => { + // Note that this returns a Promise + return Injector.get(StateStorage)!.get("MyService", "config"); }); // Elsewhere... -const config = await Injector.getByName('config'); +const config = await Injector.getByName("config"); ``` Example: Inject an interface implementation, conditionally and no decorator ```ts -import {Injector} from "@sailplane/injector"; +import { Injector } from "@sailplane/injector"; export interface FoobarService { - doSomething(): void; + doSomething(): void; } export class FoobarServiceImpl implements FoobarService { - constructor(private readonly stateStorage: StateStorage) {} + constructor(private readonly stateStorage: StateStorage) {} - doSomething(): void { - this.stateStorage.set('foobar', 'did-it', 'true'); - } + doSomething(): void { + this.stateStorage.set("foobar", "did-it", "true"); + } } export class FoobarServiceDemo implements FoobarService { - doSomething(): void { - console.log("Nothing really"); - } + doSomething(): void { + console.log("Nothing really"); + } } -Injector.registerFactory('FoobarService', () => { - if (process.env.DEMO! === 'true') { - return new FoobarServiceDemo(); - } - else { - return new FoobarServiceImpl(Injector.get(StateStorage)!); - } +Injector.registerFactory("FoobarService", () => { + if (process.env.DEMO! === "true") { + return new FoobarServiceDemo(); + } else { + return new FoobarServiceImpl(Injector.get(StateStorage)!); + } }); // Elsewhere... export class MyService { - static readonly $inject = ['FoobarService']; // Note: This is a string! - constructor(private readonly foobarSvc: FoobarService) { - } + static readonly $inject = ["FoobarService"]; // Note: This is a string! + constructor(private readonly foobarSvc: FoobarService) {} } Injector.register(MyService); ``` @@ -329,27 +323,26 @@ Injector.register(MyService); Example: Inject an interface implementation with the decorator ```ts -import {Injector, Injectable} from "@sailplane/injector"; +import { Injector, Injectable } from "@sailplane/injector"; export interface FoobarService { - doSomething(): void; + doSomething(): void; } @Injectable({ as: "FoobarService" }) export class FoobarServiceImpl implements FoobarService { - constructor(private readonly stateStorage: StateStorage) {} + constructor(private readonly stateStorage: StateStorage) {} - doSomething(): void { - // code - } + doSomething(): void { + // code + } } // Elsewhere... -@Injectable({dependencies: ['FoobarService']}) // Note: This is a string! +@Injectable({ dependencies: ["FoobarService"] }) // Note: This is a string! export class MyService { - constructor(private readonly foobarSvc: FoobarService) { - } + constructor(private readonly foobarSvc: FoobarService) {} } Injector.register(MyService); ``` @@ -363,19 +356,18 @@ lazy-created. Example: ```ts -import {Injector} from "@sailplane/injector"; -import {environment} from "environment"; +import { Injector } from "@sailplane/injector"; +import { environment } from "environment"; -Injector.registerConstant('environment-config', environment); -Injector.registerConstant('promisedData', asyncFunction()); +Injector.registerConstant("environment-config", environment); +Injector.registerConstant("promisedData", asyncFunction()); // Later... -const myEnv = Injector.getByName('environment-config'); -const myData = await Injector.getByName('promisedData'); +const myEnv = Injector.getByName("environment-config"); +const myData = await Injector.getByName("promisedData"); ``` - ## More Examples See [examples](examples.md) for another example. diff --git a/docs/lambda_utils.md b/docs/lambda_utils.md index c5c7e1c..5a312bd 100644 --- a/docs/lambda_utils.md +++ b/docs/lambda_utils.md @@ -24,11 +24,11 @@ Used with API Gateway v1 (REST API) and v2 (HTTP API), the included middlewares - If incoming content is JSON text, replaces event.body with parsed object. - Ensures that event.queryStringParameters and event.pathParameters are defined, to avoid TypeErrors. - Ensures that handler response is formatted properly as a successful API Gateway result. - - Unique to LambdaUtils! - - Simply return what you want as the body of the HTTP response. + - Unique to LambdaUtils! + - Simply return what you want as the body of the HTTP response. - Catch http-errors exceptions into proper HTTP responses. - Catch other exceptions and return as HTTP 500. - - Unique to LambdaUtils! + - Unique to LambdaUtils! - Registers Lambda context with Sailplane's [Logger](logger.md) for structured logging. (Detail below.) - Fully leverages Typescript and async syntax. @@ -101,27 +101,27 @@ adds the following properties: ### General use ```ts -import {APIGatewayEvent} from 'aws-lambda'; +import { APIGatewayEvent } from "aws-lambda"; import * as LambdaUtils from "@sailplane/lambda-utils"; import * as createError from "http-errors"; -export const hello = LambdaUtils.wrapApiHandlerV2(async (event: LambdaUtils.APIGatewayProxyEvent) => { +export const hello = LambdaUtils.wrapApiHandlerV2( + async (event: LambdaUtils.APIGatewayProxyEvent) => { // These event objects are now always defined, so don't need to check for undefined. 🙂 const who = event.pathParameters.who; - let points = Number(event.queryStringParameters.points || '0'); + let points = Number(event.queryStringParameters.points || "0"); if (points > 0) { - let message = 'Hello ' + who; - for (; points > 0; --points) - message = message + '!'; + let message = "Hello " + who; + for (; points > 0; --points) message = message + "!"; - return {message}; + return { message }; + } else { + // LambdaUtils will catch and return HTTP 400 + throw new createError.BadRequest("Missing points parameter"); } - else { - // LambdaUtils will catch and return HTTP 400 - throw new createError.BadRequest('Missing points parameter'); - } -}); + }, +); ``` See [examples](examples.md) for another example. @@ -129,15 +129,15 @@ See [examples](examples.md) for another example. ### Extending LambdaUtils for your own app ```ts -import {ProxyHandler} from "aws-lambda"; +import { ProxyHandler } from "aws-lambda"; import middy from "@middy/core"; import * as createError from "http-errors"; import * as LambdaUtils from "@sailplane/lambda-utils"; /** ID user user authenticated in running Lambda */ -let authenticatedUserId: string|undefined; +let authenticatedUserId: string | undefined; -export function getAuthenticatedUserId(): string|undefined { +export function getAuthenticatedUserId(): string | undefined { return authenticatedUserId; } @@ -149,14 +149,14 @@ const authMiddleware = (requiredRole?: string): Required => before: async (request) => { const claims = request.event.requestContext.authorizer?.claims; - const role = claims['custom:role']; + const role = claims["custom:role"]; if (requiredRole && role !== requiredRole) { - throw new createError.Forbidden(); + throw new createError.Forbidden(); } authenticatedUserId = claims?.sub; if (!authenticatedUserId) { - throw new createError.Unauthorized("No user authorized"); + throw new createError.Unauthorized("No user authorized"); } }, after: async (_) => { @@ -164,25 +164,25 @@ const authMiddleware = (requiredRole?: string): Required => }, onError: async (_) => { authenticatedUserId = undefined; - } + }, }; -} +}; export interface WrapApiHandlerOptions { - noUserAuth?: boolean; - requiredRole?: string; + noUserAuth?: boolean; + requiredRole?: string; } export function wrapApiHandlerWithAuth( - options: WrapApiHandlerOptions, - handler: LambdaUtils.AsyncProxyHandlerV2 + options: WrapApiHandlerOptions, + handler: LambdaUtils.AsyncProxyHandlerV2, ): LambdaUtils.AsyncMiddyifedHandlerV2 { - const wrap = LambdaUtils.wrapApiHandlerV2(handler); + const wrap = LambdaUtils.wrapApiHandlerV2(handler); - if (!options.noUserAuth) { - wrap.use(userAuthMiddleware(options.requiredRole)); - } + if (!options.noUserAuth) { + wrap.use(userAuthMiddleware(options.requiredRole)); + } - return wrap; + return wrap; } ``` diff --git a/docs/license.md b/docs/license.md index d67f435..ff81728 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,5 +1,4 @@ -Apache License -============== +# Apache License _Version 2.0, January 2004_ _<>_ @@ -89,15 +88,15 @@ You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: -* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +- **(a)** You must give any other recipients of the Work or Derivative Works a copy of this License; and -* **(b)** You must cause any modified files to carry prominent notices stating that You +- **(b)** You must cause any modified files to carry prominent notices stating that You changed the files; and -* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +- **(c)** You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and -* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the diff --git a/docs/logger.md b/docs/logger.md index bb69c6a..85775c2 100644 --- a/docs/logger.md +++ b/docs/logger.md @@ -29,32 +29,32 @@ npm install @sailplane/logger ## Examples ```ts -import {Logger, LogLevel} from "@sailplane/logger"; -const logger = new Logger('name-of-module'); +import { Logger, LogLevel } from "@sailplane/logger"; +const logger = new Logger("name-of-module"); logger.info("Hello World!"); // INFO name-of-module: Hello World! -Logger.initialize({level: LogLevel.INFO}); +Logger.initialize({ level: LogLevel.INFO }); logger.debug("DEBUG < INFO."); // No output -Logger.initialize({logTimestamps: true}); +Logger.initialize({ logTimestamps: true }); logger.info("Useful local log"); // 2018-11-15T18:26:20 INFO name-of-module: Useful local log -logger.warn("Exception ", {message: "oops"}); +logger.warn("Exception ", { message: "oops" }); // 2018-11-15T18:29:38 INFO name-of-module: Exception {message:"oops"} -Logger.initialize({format: "PRETTY"}); -logger.error("Exception ", {message: "oops"}); +Logger.initialize({ format: "PRETTY" }); +logger.error("Exception ", { message: "oops" }); // 2018-11-15T18:30:49 INFO name-of-module: Exception { // message: "oops" // } Logger.initialize({ format: "STRUCT", - attributes: { my_trace_id: request.id } + attributes: { my_trace_id: request.id }, }); logger.error("Processing Failed", new Error("Unreachable")); // { diff --git a/docs/state_storage.md b/docs/state_storage.md index a5f2c53..96c413a 100644 --- a/docs/state_storage.md +++ b/docs/state_storage.md @@ -66,21 +66,20 @@ Not all are needed if only reading parameters or if not using the `secure` optio **Simple example storing state** ```ts -import {StateStorage} from "@sailplane/state-storage"; +import { StateStorage } from "@sailplane/state-storage"; const stateStore = new StateStorage(process.env.STATE_STORAGE_PREFIX!); export async function myHandler(event, context): Promise { - let state = await stateStore.get('thing', 'state'); + let state = await stateStore.get("thing", "state"); const result = await processRequest(state, event); - await stateStore.set('thing', 'state', state); + await stateStore.set("thing", "state", state); return result; } ``` See [examples](examples.md) for another example. - ## Unit testing your services Use `StateStorageFake` to unit test your services that use `StateStorage`. The fake will diff --git a/elasticsearch-client/lib/elasticsearch-client.test.ts b/elasticsearch-client/lib/elasticsearch-client.test.ts index b6c801e..bd94c7e 100644 --- a/elasticsearch-client/lib/elasticsearch-client.test.ts +++ b/elasticsearch-client/lib/elasticsearch-client.test.ts @@ -1,106 +1,105 @@ -import {ElasticsearchClient, ElasticsearchResult} from "./elasticsearch-client"; -import {AwsHttps} from "@sailplane/aws-https"; +import { ElasticsearchClient, ElasticsearchResult } from "./elasticsearch-client"; +import { AwsHttps } from "@sailplane/aws-https"; -describe("ElasticsearchClient",() => { +describe("ElasticsearchClient", () => { + test("request() with data success", async () => { + // GIVEN + const resultObj: ElasticsearchResult = { + _shards: { total: 1, successful: 1, failed: 0 }, + _index: "thing", + found: true, + }; + const mockHttp = createMockHttp(Promise.resolve(resultObj)); + const sut = new ElasticsearchClient(mockHttp, "hostdomain"); - test('request() with data success', async () => { - // GIVEN - const resultObj: ElasticsearchResult = { - _shards: { total: 1, successful: 1, failed: 0}, - _index: 'thing', - found: true - }; - const mockHttp = createMockHttp(Promise.resolve(resultObj)); - const sut = new ElasticsearchClient(mockHttp, 'hostdomain'); + // WHEN + const result = await sut.request("POST", "/thing", { s: "Hello" }); - // WHEN - const result = await sut.request('POST', '/thing', { s: "Hello" }); - - // THEN - expect(result).toEqual(resultObj); - expect(mockHttp.calls.length).toBe(1); - expect(mockHttp.calls[0]).toEqual({ - method: 'POST', - hostname: 'hostdomain', - path: '/thing', - headers: { - 'accept': 'application/json; charset=utf-8', - 'content-type': 'application/json; charset=utf-8', - }, - timeout: 10000, - body: "{\"s\":\"Hello\"}", - awsSign: true - }); + // THEN + expect(result).toEqual(resultObj); + expect(mockHttp.calls.length).toBe(1); + expect(mockHttp.calls[0]).toEqual({ + method: "POST", + hostname: "hostdomain", + path: "/thing", + headers: { + accept: "application/json; charset=utf-8", + "content-type": "application/json; charset=utf-8", + }, + timeout: 10000, + body: '{"s":"Hello"}', + awsSign: true, }); + }); - test('request() without data success', async () => { - // GIVEN - const resultObj: ElasticsearchResult = { - _shards: { total: 1, successful: 1, failed: 0}, - _index: 'thing', - found: true - }; - const mockHttp = createMockHttp(Promise.resolve(resultObj)); - const sut = new ElasticsearchClient(mockHttp, 'hostdomain'); + test("request() without data success", async () => { + // GIVEN + const resultObj: ElasticsearchResult = { + _shards: { total: 1, successful: 1, failed: 0 }, + _index: "thing", + found: true, + }; + const mockHttp = createMockHttp(Promise.resolve(resultObj)); + const sut = new ElasticsearchClient(mockHttp, "hostdomain"); - // WHEN - const result = await sut.request('GET', '/thing'); + // WHEN + const result = await sut.request("GET", "/thing"); - // THEN - expect(result).toEqual(resultObj); - expect(mockHttp.calls.length).toBe(1); - expect(mockHttp.calls[0]).toEqual({ - method: 'GET', - hostname: 'hostdomain', - path: '/thing', - headers: { - 'accept': 'application/json; charset=utf-8', - 'content-type': 'application/json; charset=utf-8', - }, - timeout: 10000, - body: undefined, - awsSign: true - }); + // THEN + expect(result).toEqual(resultObj); + expect(mockHttp.calls.length).toBe(1); + expect(mockHttp.calls[0]).toEqual({ + method: "GET", + hostname: "hostdomain", + path: "/thing", + headers: { + accept: "application/json; charset=utf-8", + "content-type": "application/json; charset=utf-8", + }, + timeout: 10000, + body: undefined, + awsSign: true, }); + }); - test('request() not found', async () => { - // GIVEN - const resultObj: ElasticsearchResult = { - _shards: { total: 0, successful: 0, failed: 1}, - found: false - }; - const mockHttp = createMockHttp(Promise.reject({ statusCode: 404 })); - const sut = new ElasticsearchClient(mockHttp, 'hostdomain'); + test("request() not found", async () => { + // GIVEN + const resultObj: ElasticsearchResult = { + _shards: { total: 0, successful: 0, failed: 1 }, + found: false, + }; + const mockHttp = createMockHttp(Promise.reject({ statusCode: 404 })); + const sut = new ElasticsearchClient(mockHttp, "hostdomain"); - // WHEN - const result = await sut.request('GET', '/thing'); + // WHEN + const result = await sut.request("GET", "/thing"); - // THEN - expect(result).toEqual(resultObj); - expect(mockHttp.calls.length).toBe(1); - }); + // THEN + expect(result).toEqual(resultObj); + expect(mockHttp.calls.length).toBe(1); + }); - test('request() server failure', async () => { - // GIVEN - const mockHttp = createMockHttp(Promise.reject({ statusCode: 500 })); - const sut = new ElasticsearchClient(mockHttp, 'hostdomain'); + test("request() server failure", async () => { + // GIVEN + const mockHttp = createMockHttp(Promise.reject({ statusCode: 500 })); + const sut = new ElasticsearchClient(mockHttp, "hostdomain"); - // WHEN - const result = sut.request('GET', '/thing'); + // WHEN + const result = sut.request("GET", "/thing"); - // THEN - await expect(result).rejects.toEqual({ statusCode: 500 }); - expect(mockHttp.calls.length).toBe(1); - }); + // THEN + await expect(result).rejects.toEqual({ statusCode: 500 }); + expect(mockHttp.calls.length).toBe(1); + }); }); -function createMockHttp(result: Promise): AwsHttps & {calls: Array} { - const calls: Array = []; - return { - calls: calls, - request: (options: any) => { - calls.push(options); - return result; - } - } as any; +function createMockHttp(result: Promise): AwsHttps & { calls: Array } { + const calls: Array = []; + return { + calls: calls, + request: (options: any) => { + calls.push(options); + return result; + }, + } as any; } diff --git a/elasticsearch-client/lib/elasticsearch-client.ts b/elasticsearch-client/lib/elasticsearch-client.ts index 4c91933..f3168b6 100644 --- a/elasticsearch-client/lib/elasticsearch-client.ts +++ b/elasticsearch-client/lib/elasticsearch-client.ts @@ -1,49 +1,50 @@ -import {Logger} from "@sailplane/logger"; -import {AwsHttps, AwsHttpsOptions} from "@sailplane/aws-https"; +import { Logger } from "@sailplane/logger"; +import { AwsHttps, AwsHttpsOptions } from "@sailplane/aws-https"; -const logger = new Logger('elasticsearch-client'); +const logger = new Logger("elasticsearch-client"); /** * All-inclusive possible properties of returned results from ElasticsearchClient */ export interface ElasticsearchResult { - _shards?: { - total: number, - successful: number, - failed: number, - skipped?: number - }; - _index?: string; - _type?: string; - _id?: string; - _version?: number; - result?: "created" | "deleted" | "noop"; + _shards?: { + total: number; + successful: number; + failed: number; + skipped?: number; + }; + _index?: string; + _type?: string; + _id?: string; + _version?: number; + result?: "created" | "deleted" | "noop"; - // GET one item - found?: boolean; - _source?: any; + // GET one item + found?: boolean; + _source?: any; - // GET _search - took?: number; - timed_out?: boolean; - hits?: { - total: number; - max_score: number|null; - hits?: [{ - _index: string; - _type: string; - _id: string; - _score: number; - _source?: any; - }] - } + // GET _search + took?: number; + timed_out?: boolean; + hits?: { + total: number; + max_score: number | null; + hits?: [ + { + _index: string; + _type: string; + _id: string; + _score: number; + _source?: any; + }, + ]; + }; - // POST _delete_by_query - deleted?: number; - failures?: any[]; + // POST _delete_by_query + deleted?: number; + failures?: any[]; } - /** * Lightweight Elasticsearch client for AWS. * @@ -55,54 +56,55 @@ export interface ElasticsearchResult { * }); */ export class ElasticsearchClient { + /** + * Construct. + * @param awsHttps injection of AwsHttps object to use. + * @param {string} endpoint Elasticsearch endpoint host name + */ + constructor( + private readonly awsHttps: AwsHttps, + private readonly endpoint: string, + ) {} - /** - * Construct. - * @param awsHttps injection of AwsHttps object to use. - * @param {string} endpoint Elasticsearch endpoint host name - */ - constructor(private readonly awsHttps: AwsHttps, private readonly endpoint: string) { - } + /** + * Send a request to Elasticsearch. + * @param {"GET" | "DELETE" | "PUT" | "POST"} method + * @param {string} path per Elasticsearch Document API + * @param {any?} body request content as object, if any for the API + * @returns {Promise} response from Elasticsearch. An HTTP 404 + * response is translated into an ElasticsearchResult with found=false + * @throws {Error{message,status,statusCode}} error if HTTP result is not 2xx or 404 + * or unable to parse response. Compatible with http-errors package. + */ + request( + method: "DELETE" | "GET" | "PUT" | "POST", + path: string, + body?: any, + ): Promise { + const toSend: AwsHttpsOptions = { + method: method, + hostname: this.endpoint, + path: path, + headers: { + accept: "application/json; charset=utf-8", + "content-type": "application/json; charset=utf-8", + }, + timeout: 10000, //connection timeout milliseconds, + body: body ? JSON.stringify(body) : undefined, + awsSign: true, + }; - /** - * Send a request to Elasticsearch. - * @param {"GET" | "DELETE" | "PUT" | "POST"} method - * @param {string} path per Elasticsearch Document API - * @param {any?} body request content as object, if any for the API - * @returns {Promise} response from Elasticsearch. An HTTP 404 - * response is translated into an ElasticsearchResult with found=false - * @throws {Error{message,status,statusCode}} error if HTTP result is not 2xx or 404 - * or unable to parse response. Compatible with http-errors package. - */ - request(method: 'DELETE'|'GET'|'PUT'|'POST', - path: string, body?: any): Promise { + logger.info(`Elasticsearch request: ${method} ${path}`); - const toSend: AwsHttpsOptions = { - method: method, - hostname: this.endpoint, - path: path, - headers: { - 'accept': 'application/json; charset=utf-8', - 'content-type': 'application/json; charset=utf-8', - }, - timeout: 10000, //connection timeout milliseconds, - body: body ? JSON.stringify(body) : undefined, - awsSign: true + return this.awsHttps.request(toSend).catch((err) => { + if (err.statusCode === 404) { + return { + _shards: { total: 0, successful: 0, failed: 1 }, + found: false, }; - - logger.info(`Elasticsearch request: ${method} ${path}`); - - return this.awsHttps.request(toSend) - .catch(err => { - if (err.statusCode === 404) { - return { - _shards: { total: 0, successful: 0, failed: 1}, - found: false - }; - } - else { - throw err; - } - }); - } + } else { + throw err; + } + }); + } } diff --git a/expiring-value/jest.config.js b/expiring-value/jest.config.js index ea9105b..17348e1 100644 --- a/expiring-value/jest.config.js +++ b/expiring-value/jest.config.js @@ -1,17 +1,17 @@ /** @type {import('ts-jest').JestConfigWithTsJest} **/ module.exports = { - testEnvironment: "node", - transform: { - "^.+.ts$": ["ts-jest", {}], - }, + testEnvironment: "node", + transform: { + "^.+.ts$": ["ts-jest", {}], + }, - // Coverage - collectCoverage: true, - coverageThreshold: { - "./lib": { - branches: 100, - functions: 100, - statements: 100, - }, + // Coverage + collectCoverage: true, + coverageThreshold: { + "./lib": { + branches: 100, + functions: 100, + statements: 100, }, + }, }; diff --git a/expiring-value/lib/expiring-value.ts b/expiring-value/lib/expiring-value.ts index 69c6329..a2febbd 100644 --- a/expiring-value/lib/expiring-value.ts +++ b/expiring-value/lib/expiring-value.ts @@ -3,63 +3,63 @@ * Further, it expires after a given time to avoid overly-stale data. */ export class ExpiringValue { - /** Cached value */ - private value: Promise | undefined; + /** Cached value */ + private value: Promise | undefined; - /** Epoch millisecond time of when the current value expires */ - private expiration: number = 0; + /** Epoch millisecond time of when the current value expires */ + private expiration: number = 0; - /** - * Construct a new expiring value. - * - * @param factoryFn factory to lazy-load the value - * @param ttl milliseconds the value is good for, after which it is reloaded. - * @param options optional options to change behavior - * @param options.cacheError set to true to cache for TTL a Promise rejection from factoryFn. - * By default, a rejection is not cached and factoryFn will be retried upon the next call. - */ - constructor( - private factoryFn: () => Promise, - private ttl: number, - private options = { cacheError: false }, - ) {} + /** + * Construct a new expiring value. + * + * @param factoryFn factory to lazy-load the value + * @param ttl milliseconds the value is good for, after which it is reloaded. + * @param options optional options to change behavior + * @param options.cacheError set to true to cache for TTL a Promise rejection from factoryFn. + * By default, a rejection is not cached and factoryFn will be retried upon the next call. + */ + constructor( + private factoryFn: () => Promise, + private ttl: number, + private options = { cacheError: false }, + ) {} - /** - * Get value; lazy-load from factory if not yet loaded or if expired. - */ - get(): Promise { - if (this.isExpired()) { - this.value = this.factoryFn(); + /** + * Get value; lazy-load from factory if not yet loaded or if expired. + */ + get(): Promise { + if (this.isExpired()) { + this.value = this.factoryFn(); - if (this.options.cacheError) { - this.extendExpiration(); - } else { - // Update expiration, only upon success; no-op on error here - this.value.then(() => this.extendExpiration()).catch(() => undefined); - } - } - - return this.value!; + if (this.options.cacheError) { + this.extendExpiration(); + } else { + // Update expiration, only upon success; no-op on error here + this.value.then(() => this.extendExpiration()).catch(() => undefined); + } } - /** - * Clear/expire the value now. - * Following this with a get() will reload the data from the factory. - */ - clear(): void { - this.value = undefined; - this.expiration = 0; - } + return this.value!; + } - /** - * Is the value expired (or not set) - */ - isExpired(): boolean { - return Date.now() > this.expiration; - } + /** + * Clear/expire the value now. + * Following this with a get() will reload the data from the factory. + */ + clear(): void { + this.value = undefined; + this.expiration = 0; + } - /** Reset the value expiration to TTL past now */ - private extendExpiration(): void { - this.expiration = Date.now() + this.ttl; - } + /** + * Is the value expired (or not set) + */ + isExpired(): boolean { + return Date.now() > this.expiration; + } + + /** Reset the value expiration to TTL past now */ + private extendExpiration(): void { + this.expiration = Date.now() + this.ttl; + } } diff --git a/injector/example/source-map-install.js b/injector/example/source-map-install.js index ef7457f..88f7432 100644 --- a/injector/example/source-map-install.js +++ b/injector/example/source-map-install.js @@ -1 +1 @@ -require('source-map-support').install(); +require("source-map-support").install(); diff --git a/injector/example/src/company-repository.ts b/injector/example/src/company-repository.ts index 4eb51a2..f0852ce 100644 --- a/injector/example/src/company-repository.ts +++ b/injector/example/src/company-repository.ts @@ -2,7 +2,7 @@ import { Injectable, Injector } from "@sailplane/injector"; @Injectable() export class CompanyRepository { - fetchAllCompanies(): Promise { - return Promise.resolve([{ name: 'Company name' }]); - } + fetchAllCompanies(): Promise { + return Promise.resolve([{ name: "Company name" }]); + } } diff --git a/injector/example/src/company-service.ts b/injector/example/src/company-service.ts index 5afe2ab..0954ca3 100644 --- a/injector/example/src/company-service.ts +++ b/injector/example/src/company-service.ts @@ -1,13 +1,11 @@ -import {CompanyRepository} from "./company-repository"; +import { CompanyRepository } from "./company-repository"; import { Injectable, Injector } from "@sailplane/injector"; @Injectable() export class CompanyService { + constructor(private readonly companyRepo: CompanyRepository) {} - constructor(private readonly companyRepo: CompanyRepository) { - } - - listCompanies(): Promise { - return this.companyRepo.fetchAllCompanies(); - } + listCompanies(): Promise { + return this.companyRepo.fetchAllCompanies(); + } } diff --git a/injector/example/src/handlers.ts b/injector/example/src/handlers.ts index c844f72..276a6b4 100644 --- a/injector/example/src/handlers.ts +++ b/injector/example/src/handlers.ts @@ -1,12 +1,12 @@ -import {CompanyService} from "./company-service"; -import {Injector} from "@sailplane/injector"; -import {wrapApiHandler} from "@sailplane/lambda-utils"; +import { CompanyService } from "./company-service"; +import { Injector } from "@sailplane/injector"; +import { wrapApiHandler } from "@sailplane/lambda-utils"; /** * Return a list of all company records. */ export const getCompanies = wrapApiHandler(async () => { - const list = await Injector.get(CompanyService)!.listCompanies(); + const list = await Injector.get(CompanyService)!.listCompanies(); - return {companies: list}; + return { companies: list }; }); diff --git a/injector/example/tsconfig.json b/injector/example/tsconfig.json index b3f75dd..ac11c1a 100644 --- a/injector/example/tsconfig.json +++ b/injector/example/tsconfig.json @@ -4,9 +4,7 @@ "allowSyntheticDefaultImports": true, "moduleResolution": "node", "baseUrl": "src", - "rootDirs": [ - "src" - ], + "rootDirs": ["src"], "sourceMap": true, "declaration": false, "removeComments": true, @@ -14,11 +12,7 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "target": "es2017", - "typeRoots": [ - "node_modules/@types" - ], - "lib": [ - "es2017" - ] + "typeRoots": ["node_modules/@types"], + "lib": ["es2017"] } } diff --git a/injector/example/webpack.config.js b/injector/example/webpack.config.js index 124de71..cddaae5 100644 --- a/injector/example/webpack.config.js +++ b/injector/example/webpack.config.js @@ -1,36 +1,32 @@ -const path = require('path'); -const slsw = require('serverless-webpack'); +const path = require("path"); +const slsw = require("serverless-webpack"); const entries = {}; -Object.keys(slsw.lib.entries).forEach(key => ( - entries[key] = ['./source-map-install.js', slsw.lib.entries[key]] -)); +Object.keys(slsw.lib.entries).forEach( + (key) => (entries[key] = ["./source-map-install.js", slsw.lib.entries[key]]), +); module.exports = { - entry: entries, - devtool: 'source-map', - resolve: { - extensions: [ - '.js', - '.json', - '.ts' - ] - }, - target: 'node', - mode: 'development', - module: { - rules: [ - { - test: /^(?!.*\.spec\.ts$).*\.ts$/, - loader: 'ts-loader', - exclude: /node_modules/ - } - ], - }, - output: { - libraryTarget: 'commonjs', - path: path.join(__dirname, '.webpack'), - filename: '[name].js', - } + entry: entries, + devtool: "source-map", + resolve: { + extensions: [".js", ".json", ".ts"], + }, + target: "node", + mode: "development", + module: { + rules: [ + { + test: /^(?!.*\.spec\.ts$).*\.ts$/, + loader: "ts-loader", + exclude: /node_modules/, + }, + ], + }, + output: { + libraryTarget: "commonjs", + path: path.join(__dirname, ".webpack"), + filename: "[name].js", + }, }; diff --git a/lambda-utils/README.md b/lambda-utils/README.md index c3ee0c8..57b01c6 100644 --- a/lambda-utils/README.md +++ b/lambda-utils/README.md @@ -2,9 +2,9 @@ ## What? -There's a lot of boilerplate in Lambda handlers. +There's a lot of boilerplate in Lambda handlers. This collection of utility functions leverage the great [Middy](https://middy.js.org/) -library to add middleware functionality to Lambda handlers. +library to add middleware functionality to Lambda handlers. You can extend it with your own middleware. This is part of the [sailplane](https://github.com/rackspace/sailplane) library of @@ -27,13 +27,13 @@ Used with API Gateway, the included middlewares: - If incoming content is JSON text, replaces event.body with parsed object - Ensures that event.queryStringParameters and event.pathParameters are defined, to avoid TypeErrors - Ensures that handler response is formatted properly as a successful API Gateway result - - Unique to LambdaUtils! - - Simply return what you want as the body of the HTTP response + - Unique to LambdaUtils! + - Simply return what you want as the body of the HTTP response - Catch http-errors exceptions into proper HTTP responses - Catch other exceptions and return as HTTP 500 - - Unique to LambdaUtils! - - Besides providing better feedback to the client, not throwing an exception out of your handler means that your - instance will not be destroyed and suffer a cold start on the next invocation + - Unique to LambdaUtils! + - Besides providing better feedback to the client, not throwing an exception out of your handler means that your + instance will not be destroyed and suffer a cold start on the next invocation - Leverages async syntax ## How? diff --git a/lambda-utils/lib/logger-context.ts b/lambda-utils/lib/logger-context.ts index 2c732fc..6f6bb76 100644 --- a/lambda-utils/lib/logger-context.ts +++ b/lambda-utils/lib/logger-context.ts @@ -7,20 +7,20 @@ import { APIGatewayProxyEventAnyVersion } from "./types"; * Middleware for LambdaUtils to set request context in Logger */ export const loggerContextMiddleware = (): middy.MiddlewareObj => { - return { - before: async (request) => { - Logger.setLambdaContext(request.context); + return { + before: async (request) => { + Logger.setLambdaContext(request.context); - const requestContext = request.event.requestContext; - const claims = - (requestContext as APIGatewayEventRequestContextWithAuthorizer)?.authorizer?.claims // API v1 - || ((requestContext as any)?.authorizer?.jwt?.claims); // API v2 + const requestContext = request.event.requestContext; + const claims = + (requestContext as APIGatewayEventRequestContextWithAuthorizer)?.authorizer?.claims || // API v1 + (requestContext as any)?.authorizer?.jwt?.claims; // API v2 - const context = { - api_request_id: requestContext?.requestId, - jwt_sub: claims?.sub, - }; - Logger.addAttributes(context); - }, - }; + const context = { + api_request_id: requestContext?.requestId, + jwt_sub: claims?.sub, + }; + Logger.addAttributes(context); + }, + }; }; diff --git a/lambda-utils/lib/resolved-promise-is-success.ts b/lambda-utils/lib/resolved-promise-is-success.ts index 4af96e0..62baa28 100644 --- a/lambda-utils/lib/resolved-promise-is-success.ts +++ b/lambda-utils/lib/resolved-promise-is-success.ts @@ -1,23 +1,28 @@ import middy from "@middy/core"; -import {APIGatewayProxyEventAnyVersion, APIGatewayProxyResultAnyVersion} from "./types"; +import { APIGatewayProxyEventAnyVersion, APIGatewayProxyResultAnyVersion } from "./types"; /** * Middleware to allow an async handler to return its exact response body. * This middleware will wrap it up as an APIGatewayProxyResult. * Must be registered as the last (thus first to run) "after" middleware. */ -export const resolvedPromiseIsSuccessMiddleware = (): middy.MiddlewareObj => ({ - after: async (request) => { - // If response isn't a proper API result object, convert it into one. - const response = request.response; - if (!response || typeof response !== 'object' || (!response.statusCode && !response.body)) { - request.response = { - statusCode: 200, - body: response ? JSON.stringify(response) : '', - headers: { - "content-type": response ? "application/json; charset=utf-8" : "text/plain; charset=utf-8" - } - }; - } +export const resolvedPromiseIsSuccessMiddleware = (): middy.MiddlewareObj< + APIGatewayProxyEventAnyVersion, + APIGatewayProxyResultAnyVersion +> => ({ + after: async (request) => { + // If response isn't a proper API result object, convert it into one. + const response = request.response; + if (!response || typeof response !== "object" || (!response.statusCode && !response.body)) { + request.response = { + statusCode: 200, + body: response ? JSON.stringify(response) : "", + headers: { + "content-type": response + ? "application/json; charset=utf-8" + : "text/plain; charset=utf-8", + }, + }; } + }, }); diff --git a/lambda-utils/lib/unhandled-exception.ts b/lambda-utils/lib/unhandled-exception.ts index c3c15d7..ac712c8 100644 --- a/lambda-utils/lib/unhandled-exception.ts +++ b/lambda-utils/lib/unhandled-exception.ts @@ -1,8 +1,8 @@ import middy from "@middy/core"; -import {APIGatewayProxyEventAnyVersion, APIGatewayProxyResultAnyVersion} from "./types"; -import {Logger} from "@sailplane/logger"; +import { APIGatewayProxyEventAnyVersion, APIGatewayProxyResultAnyVersion } from "./types"; +import { Logger } from "@sailplane/logger"; -const logger = new Logger('lambda-utils'); +const logger = new Logger("lambda-utils"); /** * Middleware to handle any otherwise unhandled exception by logging it and generating @@ -10,33 +10,38 @@ const logger = new Logger('lambda-utils'); * * Fine-tuned to work better than the Middy version, and uses @sailplane/logger. */ -export const unhandledExceptionMiddleware = (): middy.MiddlewareObj => ({ - onError: async (request) => { - logger.error('Unhandled exception:', request.error); +export const unhandledExceptionMiddleware = (): middy.MiddlewareObj< + APIGatewayProxyEventAnyVersion, + APIGatewayProxyResultAnyVersion +> => ({ + onError: async (request) => { + logger.error("Unhandled exception:", request.error); - request.response = request.response || {}; - /* istanbul ignore else - nominal path is for response to be brand new */ - if ((request.response.statusCode || 0) < 400) { - const error = findRootCause(request.error); - request.response.statusCode = (error as ErrorWithStatus)?.statusCode || 500; - request.response.body = error?.toString() ?? ''; - request.response.headers = request.response.headers ?? {}; - request.response.headers["content-type"] = "text/plain; charset=utf-8"; - } - - logger.info("Response to API Gateway: ", request.response); + request.response = request.response || {}; + /* istanbul ignore else - nominal path is for response to be brand new */ + if ((request.response.statusCode || 0) < 400) { + const error = findRootCause(request.error); + request.response.statusCode = (error as ErrorWithStatus)?.statusCode || 500; + request.response.body = error?.toString() ?? ""; + request.response.headers = request.response.headers ?? {}; + request.response.headers["content-type"] = "text/plain; charset=utf-8"; } + + logger.info("Response to API Gateway: ", request.response); + }, }); type ErrorWithStatus = Error & { statusCode?: number }; -function findRootCause(error: unknown | null | undefined): ErrorWithStatus | Error | unknown | null | undefined { - const errorWithStatus = error as ErrorWithStatus; - if (errorWithStatus?.statusCode && errorWithStatus.statusCode >= 400) { - return error as ErrorWithStatus; - } else if (errorWithStatus?.cause) { - return findRootCause(errorWithStatus.cause); - } else { - return error; - } +function findRootCause( + error: unknown | null | undefined, +): ErrorWithStatus | Error | unknown | null | undefined { + const errorWithStatus = error as ErrorWithStatus; + if (errorWithStatus?.statusCode && errorWithStatus.statusCode >= 400) { + return error as ErrorWithStatus; + } else if (errorWithStatus?.cause) { + return findRootCause(errorWithStatus.cause); + } else { + return error; + } } diff --git a/logger/README.md b/logger/README.md index 4069253..d566789 100644 --- a/logger/README.md +++ b/logger/README.md @@ -11,7 +11,7 @@ utilities for AWS Serverless in Node.js. Sadly, `console.log` is the #1 debugging tool when writing serverless code. Logger extends it with levels, timestamps, context/category names, and object formatting. It's just a few small incremental improvements, and -yet together takes logging a leap forward. It'll do until we can have a usable cloud debugger. +yet together takes logging a leap forward. It'll do until we can have a usable cloud debugger. There are far more complicated logging packages available for Javascript; but sailplane is all about simplicity, and this logger gives you all that diff --git a/logger/lib/flat-formatter.ts b/logger/lib/flat-formatter.ts index 6a5dff8..0c28ed5 100644 --- a/logger/lib/flat-formatter.ts +++ b/logger/lib/flat-formatter.ts @@ -1,5 +1,5 @@ -import {FormatterFn, LogFormat, LoggerConfig, LogLevel} from "./common"; -import {jsonStringify} from "./json-stringify"; +import { FormatterFn, LogFormat, LoggerConfig, LogLevel } from "./common"; +import { jsonStringify } from "./json-stringify"; /** * Format a log line in flat or pretty format. @@ -12,41 +12,43 @@ import {jsonStringify} from "./json-stringify"; * @return array to pass to a console function */ export const flatFormatter: FormatterFn = ( - loggerConfig: LoggerConfig, - globalConfig: LoggerConfig, - level: LogLevel, - message: string, - params: any[] + loggerConfig: LoggerConfig, + globalConfig: LoggerConfig, + level: LogLevel, + message: string, + params: any[], ): any[] => { - const out: any[] = []; - if (loggerConfig.logTimestamps) { - out.push(new Date().toISOString().substring(0, 19)); - } + const out: any[] = []; + if (loggerConfig.logTimestamps) { + out.push(new Date().toISOString().substring(0, 19)); + } - if (loggerConfig.outputLevels) { - out.push(LogLevel[level]); - } + if (loggerConfig.outputLevels) { + out.push(LogLevel[level]); + } - out.push(loggerConfig.module); + out.push(loggerConfig.module); - out.push(...Object.values({ - ...(globalConfig.attributesCallback?.()), - ...(loggerConfig.attributesCallback?.()), - })); + out.push( + ...Object.values({ + ...globalConfig.attributesCallback?.(), + ...loggerConfig.attributesCallback?.(), + }), + ); - out[out.length - 1] += ":"; - out.push(message); + out[out.length - 1] += ":"; + out.push(message); - if (params.length) { - const indent = loggerConfig.format === LogFormat.PRETTY ? 2 : undefined; - for (const param of params) { - if (typeof param === 'object') { - out.push(jsonStringify(param, indent)); - } else { - out.push(param); - } - } + if (params.length) { + const indent = loggerConfig.format === LogFormat.PRETTY ? 2 : undefined; + for (const param of params) { + if (typeof param === "object") { + out.push(jsonStringify(param, indent)); + } else { + out.push(param); + } } + } - return out; -} + return out; +}; diff --git a/logger/lib/structured-formatter.ts b/logger/lib/structured-formatter.ts index 83a09ac..4be70d1 100644 --- a/logger/lib/structured-formatter.ts +++ b/logger/lib/structured-formatter.ts @@ -13,31 +13,31 @@ import { getContext } from "./context"; * @return array to pass to a console function */ export const structuredFormatter: FormatterFn = ( - loggerConfig: LoggerConfig, - globalConfig: LoggerConfig, - level: LogLevel, - message: string, - params: any[] + loggerConfig: LoggerConfig, + globalConfig: LoggerConfig, + level: LogLevel, + message: string, + params: any[], ): any[] => { - const item = { - ...getContext(), - ...globalConfig.attributes, - ...(globalConfig.attributesCallback?.()), - ...loggerConfig.attributes, - ...(loggerConfig.attributesCallback?.()), - level: LogLevel[level], - module: loggerConfig.module, - timestamp: new Date().toISOString(), - message, - }; + const item = { + ...getContext(), + ...globalConfig.attributes, + ...globalConfig.attributesCallback?.(), + ...loggerConfig.attributes, + ...loggerConfig.attributesCallback?.(), + level: LogLevel[level], + module: loggerConfig.module, + timestamp: new Date().toISOString(), + message, + }; - if (params.length) { - if (params.length === 1 && typeof params[0] === 'object') { - item.value = params[0]; - } else { - item.params = params; - } + if (params.length) { + if (params.length === 1 && typeof params[0] === "object") { + item.value = params[0]; + } else { + item.params = params; } + } - return [jsonStringify(item)]; + return [jsonStringify(item)]; }; diff --git a/state-storage/README.md b/state-storage/README.md index 1dc7658..aef82f4 100644 --- a/state-storage/README.md +++ b/state-storage/README.md @@ -9,6 +9,7 @@ This is part of the [sailplane](https://github.com/rackspace/sailplane) library utilities for AWS Serverless in Node.js. ## Why? + The AWS Parameter Store (SSM) was originally designed as a place to store configuration. It turns out that it is also a pretty handy place for storing small bits of state information in between serverless executions. diff --git a/state-storage/lib/state-storage.test.ts b/state-storage/lib/state-storage.test.ts index f5ed956..d124727 100644 --- a/state-storage/lib/state-storage.test.ts +++ b/state-storage/lib/state-storage.test.ts @@ -1,177 +1,193 @@ -import {StateStorage} from "./state-storage"; -import {SSMClient} from "@aws-sdk/client-ssm"; - -describe('StateStorage', () => { - const mockSSMClient = { - send: jest.fn() - }; - let sut: StateStorage; - - describe('#set', () => { - beforeEach(() => { - mockSSMClient.send.mockReset(); - sut = new StateStorage('/prefix/', mockSSMClient as unknown as SSMClient); - }); - - test('store something noisily', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({}); - - // WHEN - await sut.set('service', 'name', {value: 'hello'}); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: { - Name: '/prefix/service/name', - Value: '{"value":"hello"}', - Type: 'String', - Overwrite: true - } - })); - }); - - test('store something quietly', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({}); - - // WHEN - await sut.set('service', 'name', {value: 'hello'}, true); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: { - Name: '/prefix/service/name', - Value: '{"value":"hello"}', - Type: 'String', - Overwrite: true - } - })); - }); - - test('store something as raw string', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({}); - - // WHEN - await sut.set('service', 'name', "Goodbye", {quiet: true, isRaw: true}); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: { - Name: '/prefix/service/name', - Value: 'Goodbye', - Type: 'String', - Overwrite: true - } - })); - }); - - // Repeat with quiet flag in order to achieve code coverage - test('store something securely', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({}); - - // WHEN - await sut.set('service', 'name', {value: 'hello'}, {secure: true}); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: { - Name: '/prefix/service/name', - Value: '{"value":"hello"}', - Type: 'SecureString', - Overwrite: true - } - })); - }); +import { StateStorage } from "./state-storage"; +import { SSMClient } from "@aws-sdk/client-ssm"; + +describe("StateStorage", () => { + const mockSSMClient = { + send: jest.fn(), + }; + let sut: StateStorage; + + describe("#set", () => { + beforeEach(() => { + mockSSMClient.send.mockReset(); + sut = new StateStorage("/prefix/", mockSSMClient as unknown as SSMClient); }); - describe('#get', () => { - beforeEach(() => { - mockSSMClient.send.mockReset(); - sut = new StateStorage('/prefix', mockSSMClient as any as SSMClient); - }); - - test('fetch something noisily', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({ - Parameter: { - Value: '{"value":"hello"}' - } - }); - - // WHEN - const result = await sut.get('service', 'name'); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: { - Name: '/prefix/service/name' - } - })); - expect(result.value).toEqual('hello'); - }); - - test('fetch missing something quietly', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({}); - - // WHEN - const result = await sut.get('service', 'name', true); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: {Name: '/prefix/service/name'} - })); - expect(result).toBeUndefined(); - }); - - test('fetch something as raw string', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({ - Parameter: { - Value: '{"value":"hello"}' - } - }); - - // WHEN - const result = await sut.get('service', 'name', {isRaw: true}); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: {Name: '/prefix/service/name'} - })); - expect(result).toEqual('{"value":"hello"}'); - }); - - test('fetch something securely', async () => { - // GIVEN - mockSSMClient.send.mockResolvedValue({ - Parameter: { - Value: '{"value":"hello"}' - } - }); - - // WHEN - const result = await sut.get('service', 'name', {secure: true}); - - // THEN - expect(mockSSMClient.send).toHaveBeenCalledTimes(1); - expect(mockSSMClient.send).toHaveBeenCalledWith(expect.objectContaining({ - input: { - Name: '/prefix/service/name', - WithDecryption: true - } - })); - expect(result.value).toEqual('hello'); - }); + test("store something noisily", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({}); + + // WHEN + await sut.set("service", "name", { value: "hello" }); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + Name: "/prefix/service/name", + Value: '{"value":"hello"}', + Type: "String", + Overwrite: true, + }, + }), + ); }); + + test("store something quietly", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({}); + + // WHEN + await sut.set("service", "name", { value: "hello" }, true); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + Name: "/prefix/service/name", + Value: '{"value":"hello"}', + Type: "String", + Overwrite: true, + }, + }), + ); + }); + + test("store something as raw string", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({}); + + // WHEN + await sut.set("service", "name", "Goodbye", { quiet: true, isRaw: true }); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + Name: "/prefix/service/name", + Value: "Goodbye", + Type: "String", + Overwrite: true, + }, + }), + ); + }); + + // Repeat with quiet flag in order to achieve code coverage + test("store something securely", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({}); + + // WHEN + await sut.set("service", "name", { value: "hello" }, { secure: true }); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + Name: "/prefix/service/name", + Value: '{"value":"hello"}', + Type: "SecureString", + Overwrite: true, + }, + }), + ); + }); + }); + + describe("#get", () => { + beforeEach(() => { + mockSSMClient.send.mockReset(); + sut = new StateStorage("/prefix", mockSSMClient as any as SSMClient); + }); + + test("fetch something noisily", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({ + Parameter: { + Value: '{"value":"hello"}', + }, + }); + + // WHEN + const result = await sut.get("service", "name"); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + Name: "/prefix/service/name", + }, + }), + ); + expect(result.value).toEqual("hello"); + }); + + test("fetch missing something quietly", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({}); + + // WHEN + const result = await sut.get("service", "name", true); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { Name: "/prefix/service/name" }, + }), + ); + expect(result).toBeUndefined(); + }); + + test("fetch something as raw string", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({ + Parameter: { + Value: '{"value":"hello"}', + }, + }); + + // WHEN + const result = await sut.get("service", "name", { isRaw: true }); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { Name: "/prefix/service/name" }, + }), + ); + expect(result).toEqual('{"value":"hello"}'); + }); + + test("fetch something securely", async () => { + // GIVEN + mockSSMClient.send.mockResolvedValue({ + Parameter: { + Value: '{"value":"hello"}', + }, + }); + + // WHEN + const result = await sut.get("service", "name", { secure: true }); + + // THEN + expect(mockSSMClient.send).toHaveBeenCalledTimes(1); + expect(mockSSMClient.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + Name: "/prefix/service/name", + WithDecryption: true, + }, + }), + ); + expect(result.value).toEqual("hello"); + }); + }); });