├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── .travis.yml ├── .vscode └── launch.json ├── README.md ├── bin └── deploy-npm.sh ├── coverage.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── app.ts ├── aws-lambda │ ├── app.test.ts │ ├── app.ts │ ├── credentials │ └── index.ts ├── config.ts ├── examples │ ├── file-server.ts │ └── http-server.ts ├── gcf │ ├── app.test.ts │ ├── app.ts │ └── index.ts ├── handler.ts ├── handlers │ ├── file-server.test.ts │ ├── file-server.ts │ ├── index.ts │ ├── not-found.test.ts │ ├── not-found.ts │ ├── open-api.test.ts │ ├── open-api.ts │ ├── redirect-to.test.ts │ ├── redirect-to.ts │ ├── statuscode-200.test.ts │ └── statuscode-200.ts ├── http │ ├── app.test.ts │ ├── app.ts │ └── index.ts ├── index.ts ├── interfaces.ts ├── metadata.test.ts ├── metadata.ts ├── middleware.ts ├── middlewares │ ├── binary-typer.test.ts │ ├── binary-typer.ts │ ├── body-parser.test.ts │ ├── body-parser.ts │ ├── compressor.test.ts │ ├── compressor.ts │ ├── cors.test.ts │ ├── cors.ts │ ├── index.ts │ ├── normalizer.test.ts │ ├── normalizer.ts │ ├── request-logger.test.ts │ ├── request-logger.ts │ ├── timer.test.ts │ └── timer.ts ├── ported │ ├── cors.ts │ └── vary.ts ├── router.test.ts ├── router.ts ├── service.ts ├── services │ ├── index.ts │ ├── log-provider.test.ts │ └── log-provider.ts ├── utils.test.ts └── utils.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint-config-mflorence99", "eslint-config-prettier"], 3 | "parserOptions": { 4 | "project": ["./tsconfig.json"] 5 | }, 6 | "root": true 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /coverage 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/dist/ 3 | /dist/**/*.test.d.ts 4 | /dist/**/*.test.js 5 | /dist/examples/*.* 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /coverage 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | endOfLine: 'lf', 5 | printWidth: 80, 6 | proseWrap: 'never', 7 | quoteProps: 'consistent', 8 | semi: true, 9 | singleQuote: true, 10 | tabWidth: 2, 11 | trailingComma: 'none', 12 | useTabs: false 13 | }; 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | install: 5 | - npm install 6 | cache: 7 | directories: 8 | - 'node_modules' 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "All Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/.bin/jest", 11 | "--runInBand", 12 | "--coverage", 13 | "false" 14 | ], 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen" 17 | }, 18 | 19 | { 20 | "name": "Current Jest File", 21 | "type": "node", 22 | "request": "launch", 23 | "program": "${workspaceFolder}/node_modules/.bin/jest", 24 | "args": ["${relativeFile}"], 25 | "console": "integratedTerminal", 26 | "internalConsoleOptions": "neverOpen" 27 | }, 28 | 29 | { 30 | "name": "Current TS File", 31 | "type": "node", 32 | "request": "launch", 33 | "args": ["${relativeFile}"], 34 | "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], 35 | "sourceMaps": true, 36 | "cwd": "${workspaceRoot}", 37 | "protocol": "inspector" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ServeRX-ts 2 | 3 | [![Build Status](https://travis-ci.org/mflorence99/serverx-ts.svg?branch=master)](https://travis-ci.org/mflorence99/serverx-ts) [![Jest Coverage](./coverage.svg)]() [![npm](https://img.shields.io/npm/v/serverx-ts.svg)]() [![node](https://img.shields.io/badge/node-8.10-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | 5 | [![NPM](https://nodei.co/npm/serverx-ts.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/serverx-ts/) 6 | 7 | Experimental [Node.js](https://nodejs.org) HTTP framework using [RxJS](https://rxjs.dev), built with [TypeScript](https://www.typescriptlang.org/) and optimized for serverless deployments. Heavily inspired by [Marble.js](https://github.com/marblejs/marble) and [NestJS](https://nestjs.com/). 8 | 9 | 10 | 11 | - [Rationale](#rationale) 12 | - [Design Objectives](#design-objectives) 13 | - [Design Non-Objectives](#design-non-objectives) 14 | - [Some Bookmarks for Future Work](#some-bookmarks-for-future-work) 15 | - [Key Concepts](#key-concepts) 16 | - [Sample Application](#sample-application) 17 | - [Primer](#primer) 18 | - [Serverless Support](#serverless-support) 19 | - [AWS Lambda Considerations](#aws-lambda-considerations) 20 | - [Google Cloud Functions Considerations](#google-cloud-functions-considerations) 21 | - [Messages](#messages) 22 | - [Handlers](#handlers) 23 | - [Middleware](#middleware) 24 | - [Immediate Response](#immediate-response) 25 | - [Built-in Middleware](#built-in-middleware) 26 | - [Available Middleware](#available-middleware) 27 | - [Services](#services) 28 | - [Routing](#routing) 29 | - [Inheritance](#inheritance) 30 | - [Path Parameters](#path-parameters) 31 | - [Redirect](#redirect) 32 | - [Route Data](#route-data) 33 | - [File Server](#file-server) 34 | - [OpenAPI](#openapi) 35 | - [Informational Annotations](#informational-annotations) 36 | - [Metadata Annotations](#metadata-annotations) 37 | 38 | 39 | 40 | > See [ServeRX-serverless](https://github.com/mflorence99/serverx-serverless) for a sample app operating in a serverless environment. 41 | 42 | > See [ServeRX-angular](https://github.com/mflorence99/serverx-angular) to see ServeRX-ts put to the test of deploying and hosting any Angular app without change and with no dependencies.. 43 | 44 | ## Rationale 45 | 46 | > ServeRX-ts is an experimental project only. It doesn't advocate replacing any other framework and certainly not those from which it has drawn extensively. 47 | 48 | ### Design Objectives 49 | 50 | - _Declarative routes_ like [Angular](https://angular.io/guide/router) 51 | 52 | - _Functional reactive programming_ using [RxJS](https://rxjs.dev) like [Marble.js](https://github.com/marblejs/marble) 53 | 54 | - _Dependency injection_ like [Angular](https://v4.angular.io/guide/dependency-injection) and [NestJS](https://nestjs.com/) 55 | 56 | - _Serverless support_ out-of-the-box for [AWS Lambda](https://aws.amazon.com/lambda/) with functionality similar to [AWS Serverless Express](https://github.com/awslabs/aws-serverless-express) but without the overhead 57 | 58 | - _Serverless support_ out-of-the-box for [Google Cloud HTTP Functions](https://cloud.google.com/functions/docs/writing/http) 59 | 60 | - _Low cold-start latency_ as needed in serverless deployments, where in theory every request can trigger a cold start 61 | 62 | - _Optimized for microservices_ in particular those that send `application/json` responses and typically deployed in serverless environments 63 | 64 | - _OpenAPI support_ out-of-the-box to support the automated discovery and activation of the microservices for which ServeRX-ts is intended via the standard [OpenAPI](https://swagger.io/docs/specification/about/) specification 65 | 66 | - _Full type safety_ by using [TypeScript](https://www.typescriptlang.org/) exclusively 67 | 68 | - _Maximal test coverage_ using [Jest](https://jestjs.io/) 69 | 70 | ### Design Non-Objectives 71 | 72 | - _Deployment of static resources_ which can be commoditized via, for example, a CDN. However, ServeRX-ts supplies a simple but effective [FileServer](#file-server) handler that has just enough capability to deploy (say) an [Angular app](https://angular.io/docs). 73 | 74 | - _FRP religion_ ServeRX-ts believes in using functions where appropriate and classes and class inheritance where they are appropriate 75 | 76 | ### Some Bookmarks for Future Work 77 | 78 | - _Emulator for Express middleware_ (but that's hard and definitely back-burner!) 79 | 80 | ## Key Concepts 81 | 82 | Like [Marble.js](https://github.com/marblejs/marble), linear request/response logic is not used to process HTTP traffic. Instead, application code operates on an observable stream. ServeRX-ts does not provide any abstractions for server creation. Either standard [Node.js](https://nodejs.org) APIs are used or appropriate serverless functions. 83 | 84 | ServeRX-ts _does_ however abstract requests and responses (whatever their source or destination) and bundles them into a stream of `messages`. 85 | 86 | A `Handler` is application code that observes this stream, mapping requests into responses. 87 | 88 | Similarly, `middleware` is code that maps requests into new requests and/or responses into new responses. For example, CORS middleware takes note of request headers and injects appropriate response headers. 89 | 90 | `Services` can be injected into both handlers and middleware. ServeRX-ts uses the [injection-js](https://github.com/mgechev/injection-js) dependency injection library, which itself provides the same capabilities as in [Angular 4](https://v4.angular.io/guide/dependency-injection). In Angular, services are often used to provide common state, which makes less sense server-side. However in ServeRX-ts, services are a good means of isolating common functionality into testable, extensible and mockable units. 91 | 92 | `DI` is also often used in ServeRX-ts to inject configuration parameters into handlers, middleware and services. 93 | 94 | `Routes` are the backbone of a ServeRX-ts application. A route binds an HTTP method and path to a handler, middleware and services. Routes can be nested arbitrarily in parent/child relationships, just like [Angular](https://angular.io/guide/router). Middleware and services (and other route attributes) are inherited from a parent. 95 | 96 | Routes can be annotated with [OpenAPI](https://swagger.io/docs/specification/about/) metadata to enable the automated deployment of an OpenAPI YAML file that completely describes the API that the ServeRX-ts application implements. 97 | 98 | ## Sample Application 99 | 100 | ```ts 101 | import 'reflect-metadata'; 102 | 103 | import { AWSLambdaApp } from 'serverx-ts'; 104 | import { Compressor } from 'serverx-ts'; 105 | import { CORS } from 'serverx-ts'; 106 | import { GCFApp } from 'serverx-ts'; 107 | import { Handler } from 'serverx-ts'; 108 | import { HttpApp } from 'serverx-ts'; 109 | import { Injectable } from 'injection-js'; 110 | import { Message } from 'serverx-ts'; 111 | import { Observable } from 'rxjs'; 112 | import { RequestLogger } from 'serverx-ts'; 113 | import { Route } from 'serverx-ts'; 114 | 115 | import { createServer } from 'http'; 116 | import { tap } from 'rxjs/operators'; 117 | 118 | @Injectable() 119 | class HelloWorld extends Handler { 120 | handle(message$: Observable): Observable { 121 | return message$.pipe( 122 | tap(({ response }) => { 123 | response.body = 'Hello, world!'; 124 | }) 125 | ); 126 | } 127 | } 128 | 129 | const routes: Route[] = [ 130 | { 131 | path: '', 132 | methods: ['GET'], 133 | middlewares: [RequestLogger, Compressor, CORS], 134 | children: [ 135 | { 136 | path: '/hello', 137 | handler: HelloWorld 138 | }, 139 | { 140 | // NOTE: default handler sends 200 141 | // for example: useful in load balancers 142 | path: '/isalive' 143 | }, 144 | { 145 | path: '/not-here', 146 | redirectTo: 'http://over-there.com' 147 | } 148 | ] 149 | } 150 | ]; 151 | 152 | // local HTTP server 153 | const httpApp = new HttpApp(routes); 154 | createServer(httpApp.listen()).listen(4200); 155 | 156 | // AWS Lambda function 157 | const lambdaApp = new AWSLambdaApp(routes); 158 | export function handler(event, context) { 159 | return lambdaApp.handle(event, context); 160 | } 161 | 162 | // Google Cloud HTTP Function 163 | const gcfApp = new GCFApp(routes); 164 | export function handler(req, res) { 165 | gcfApp.handle(req, res); 166 | } 167 | ``` 168 | 169 | > Be sure to include the following options in `tsconfig.json` when you build ServeRX-ts applications: 170 | 171 | ```json 172 | { 173 | "compilerOptions": { 174 | "emitDecoratorMetadata": true, 175 | "experimentalDecorators": true 176 | } 177 | } 178 | ``` 179 | 180 | > See [ServeRX-serverless](https://github.com/mflorence99/serverx-serverless) for a sample app operating in a serverless environment. 181 | 182 | ## Primer 183 | 184 | > There's just enough information here to understand the principles behind ServeRX-ts. Much detail can be learned from [interfaces.ts](https://github.com/mflorence99/serverx-ts/blob/master/src/interfaces.ts) and from the [Jest](https://jestjs.io/) test cases, which are in-line with the body of the code. 185 | 186 | ### Serverless Support 187 | 188 | [AWS Serverless Express](https://github.com/awslabs/aws-serverless-express) connects AWS Lambda to an [Express](https://expressjs.com/) application by creating a proxy server and routing lambda calls through it so that they appear to [Express](https://expressjs.com/) code as regular HTTP requests and responses. That's a lot of overhead for a cold start, bearing in mind that in theory every serverless request could require a cold start. 189 | 190 | [Google Cloud Functions](https://cloud.google.com/functions/docs/writing/http) take a different approach and fabricate [Express](https://expressjs.com/) [request](https://expressjs.com/en/api.html#req) and [response](https://expressjs.com/en/api.html#res) objects. 191 | 192 | ServeRX-ts attempts to minimize overhead by injecting serverless calls right into its application code. This approach led a number of design decisions, notably `messages`, discussed next. 193 | 194 | ServeRX-ts recommends using the excellent [serverless](https://serverless.com/framework/docs/) framework to deploy to serverless environments. 195 | 196 | #### AWS Lambda Considerations 197 | 198 | > TODO: discuss how to control binary types and recommended `serverless.yml`. 199 | 200 | #### Google Cloud Functions Considerations 201 | 202 | > TODO: ??? and recommended `serverless.yml`. 203 | 204 | ### Messages 205 | 206 | ServeRX-ts creates `messages` from inbound requests (either HTTP or serverless) and represents the request and response as simple inner objects. 207 | 208 | | `message.context` | `message.request` | `message.response` | 209 | | ------------------ | ------------------------ | -------------------- | 210 | | `info: InfoObject` | `body: any` | `body: any` | 211 | | `router: Router` | `headers: any` | `headers: any` | 212 | |   | `httpVersion: string` | `statusCode: number` | 213 | |   | `method: string` | 214 | |   | `params: any` | 215 | |   | `path: string` | 216 | |   | `query: URLSearchParams` | 217 | |   | `remoteAddr: string` | 218 | |   | `route: Route` | 219 | |   | `timestamp: number` | 220 | 221 | `messages` are strictly mutable, meaning that application code cannot create new ones. Similarly, inner `request` and `response` should be mutated. A common mutation, for example, is to add or remove `request` or `response` `headers`. 222 | 223 | ### Handlers 224 | 225 | The job of a ServeRX-ts `handler` is to populate `message.response`, perhaps by analyzing the data in `message.request`. 226 | 227 | If `response.headers['Content-Type']` is not set, then ServeRX-ts sets it to `application/json`. If `response.statusCode` is not set, then ServeRX-ts sets it to `200`. 228 | 229 | All handlers must implement a `handle` method to process a stream of `messages`. Typically: 230 | 231 | ```ts 232 | @Injectable() class MyHandler extends Handler { 233 | handle(message$: Observable): Observable { 234 | return message$.pipe( ... ); 235 | } 236 | } 237 | ``` 238 | 239 | ### Middleware 240 | 241 | The job of ServeRX-ts `middleware` is to prepare and/or post-process streams of `messages`. In a framework like [Express](https://expressjs.com/), programmers can control when `middleware` is executed by the appropriate placement of `app.use()` calls. Because routing in ServeRX-ts is declarative, it uses a different approach. 242 | 243 | All ServeRX-ts `middleware` must implement either a `prehandle` or a `posthandle` method, or in special circumstances, both. All `prehandle` methods are executed before a `handler` gains control and all `posthandle` methods afterwards. Otherwise the shape of `middleware` is very similar to that of a `handler`: 244 | 245 | ```ts 246 | @Injectable() class MyMiddleware extends Middleware { 247 | prehandle(message$: Observable): Observable { 248 | return message$.pipe( ... ); 249 | } 250 | } 251 | ``` 252 | 253 | A third entrypoint exists: the `postcatch` method is invoked after all `posthandle` methods and even after an error has been thrown. The built-in [RequestLogger](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/request-logger.ts) `middleware` uses this entrypoint to make sure that _all_ requests are logged, even those that end in a failure. 254 | 255 | > The `postcatch` method cannot itself cause or throw an error. 256 | 257 | #### Immediate Response 258 | 259 | `middleware` code can trigger an immediate response, bypassing downstream `middleware` and any `handler` by simply throwing an error. A good example might be authentication `middleware` that rejects a request by throwing a 401 error. 260 | 261 | > A `handler` can do this too, but errors are more commonly thrown by `middleware`. 262 | 263 | ```ts 264 | import { Exception } from 'serverx-ts'; 265 | // more imports 266 | @Injectable() 267 | class Authenticator extends Middleware { 268 | prehandle(message$: Observable): Observable { 269 | return message$.pipe( 270 | // more pipeline functions 271 | mergeMap((message: Message): Observable => { 272 | return iif( 273 | () => !isAuthenticated, 274 | // NOTE: the format of an Exception is the same as a Response 275 | throwError(new Exception({ statusCode: 401 })), 276 | of(message) 277 | ); 278 | }) 279 | ); 280 | } 281 | } 282 | ``` 283 | 284 | #### Built-in Middleware 285 | 286 | - The [Normalizer](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/normalizer.ts) `middleware` is automatically provided for all routes and is guaranteed to run after all other `posthandler`s. It makes sure that `response.headers['Content-Length']`, `response.headers['Content-Type']` and `response.statusCode` are set correctly. 287 | 288 | - The [BodyParser](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/body-parser.ts) `middleware` is automatically provided, except in serverless environments, where body parsing is automatically performed. 289 | 290 | #### Available Middleware 291 | 292 | - The [Compressor](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/compressor.ts) `middleware` performs `request.body` `gzip` or `deflate` compression, if accepted by the client. See the [compressor tests](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/compressor.test.ts) for an illustration of how it is used and configured. 293 | 294 | - The [CORS](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/cors.ts) `middleware` is a wrapper around the robust [Express CORS middleware](https://expressjs.com/en/resources/middleware/cors.html). See the [CORS tests](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/cors.test.ts) for an illustration of how it is used and configured. 295 | 296 | - The [RequestLogger](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/request-logger.ts) `middleware` is a gross simplification of the [Express Morgan middleware](https://github.com/expressjs/morgan). See the [request logger tests](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/request-logger.test.ts) for an illustration of how it is used and configured. 297 | 298 | - The [Timer](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/timer.ts) `middleware` injects timing information into `response.header`. See the [timer tests](https://github.com/mflorence99/serverx-ts/blob/master/src/middlewares/timer.test.ts) for an illustration of how it is used. 299 | 300 | ### Services 301 | 302 | > TODO: discuss default [LogProvider](https://github.com/mflorence99/serverx-ts/blob/master/src/services/log-provider.ts) and possible [Loggly](https://www.loggly.com/docs/node-js-logs-2/) log provider. 303 | 304 | ### Routing 305 | 306 | ServeRX's routes follow the pattern set by [Angular](https://angular.io/guide/router): they are declarative and hierarchical. For example, the following defines two routes, `GET /foo/bar` and `PUT /foo/baz`: 307 | 308 | ```ts 309 | const routes: Route[] = { 310 | { 311 | path: '/foo', 312 | children: [ 313 | { 314 | methods: ['GET'], 315 | path: '/bar', 316 | Handler: FooBar 317 | }, 318 | { 319 | methods: ['PUT'], 320 | path: '/baz', 321 | Handler: FooBaz 322 | } 323 | ] 324 | } 325 | }; 326 | ``` 327 | 328 | > Notice how path components are inherited from parent to child. Parent/child relationships can be arbitrarily deep. 329 | 330 | #### Inheritance 331 | 332 | Paths are not the only route attribute that is inherited; `methods`, `middleware` and `services` are too. Consider this example: 333 | 334 | ```ts 335 | const routes: Route[] = [ 336 | { 337 | path: '', 338 | methods: ['GET'], 339 | middlewares: [RequestLogger, CORS], 340 | services: [{ provide: REQUEST_LOGGER_OPTS, useValue: { colorize: true } }] 341 | children: [ 342 | { 343 | path: '/bar', 344 | Handler: FooBar 345 | }, 346 | { 347 | path: '/baz', 348 | services: [{ provide: LogProvider, useClass: MyLogProvider }] 349 | Handler: FooBaz 350 | } 351 | ] 352 | } 353 | ]; 354 | ``` 355 | 356 | > Notice how an empty `path` component propagates inheritance but doesn't affect the computed path. 357 | 358 | Routes for `GET /bar` and `GET /baz` are defined. Both share the `RequestLogger` and `CORS` `middleware`, the former nominally configured to colorize its output. However, `GET /baz` uses its own custom `LogProvider`. 359 | 360 | ServeRX-ts leverages inheritance to inject its standard `middleware` and `services` without special code. The Router takes the supplied routes and wraps them like this: 361 | 362 | ```ts 363 | { 364 | path: '', 365 | middlewares: [BodyParser /* HTTP only */, Normalizer], 366 | services: [LogProvider], 367 | children: [ /* supplied routes */ ] 368 | } 369 | ``` 370 | 371 | #### Path Parameters 372 | 373 | Path parameters are coded using [OpenAPI notation](https://swagger.io/specification/): 374 | 375 | ```ts 376 | { 377 | methods: ['GET'], 378 | path: '/foo/{this}', 379 | handler: Foo 380 | }, 381 | { 382 | methods: ['GET'], 383 | path: '/foo/{this}/{that}', 384 | handler: Foo 385 | } 386 | ``` 387 | 388 | > Notice how optional parameters are coded by routing variants to the same `handler`. 389 | 390 | Path parameters are available to `handlers` in `message.request.params`. 391 | 392 | #### Redirect 393 | 394 | A redirect can be coded directly into a route: 395 | 396 | ```ts 397 | { 398 | methods: ['GET', 'PUT', 'POST'], 399 | path: '/not-here', 400 | redirectTo: 'http://over-there.com', 401 | redirectAs: 307 402 | } 403 | ``` 404 | 405 | > If `redirectAs` is not coded, `301` is assumed. 406 | 407 | #### Route Data 408 | 409 | An arbitrary `data` object can be attached to a route: 410 | 411 | ```ts 412 | { 413 | data: { db: process.env['DB'] }, 414 | methods: ['GET'], 415 | path: '/foo/bar/baz', 416 | handler: FooBarBaz 417 | } 418 | ``` 419 | 420 | > Route `data` can be accessed by both `middleware` and a `handler` via `message.request.route.data`. 421 | 422 | ### File Server 423 | 424 | ServeRX-ts supplies a simple but effective [FileServer](https://github.com/mflorence99/serverx-ts/blob/master/src/handlers/file-server.ts) handler that has just enough capability to deploy (say) an [Angular app](https://angular.io/docs). It can be used in any route, for example: 425 | 426 | ```ts 427 | const routes: Route[] = [ 428 | { 429 | path: '', 430 | children: [ 431 | { 432 | methods: ['GET'], 433 | path: '/public', 434 | handler: FileServer, 435 | provide: [ 436 | { provide: FILE_SERVER_OPTS, useValue: { maxAge: 999, root: '/tmp' } } 437 | ] 438 | } 439 | // other routes 440 | ] 441 | } 442 | ]; 443 | ``` 444 | 445 | By default, it serves files starting from the user's home directory, although that can be customized as shown above. So in that example, `GET /public/x/y/z.js` would attempt to serve `/tmp/x/y/z.js`. 446 | 447 | ServeRX-ts forces `must-revalidate` caching and sets `max-age` as customized or one year by default. The file's modification timestamp is used as an `Etag` to control caching via `If-None-Match`. 448 | 449 | ### OpenAPI 450 | 451 | ServeRX-ts supplies an [OpenAPI](https://github.com/mflorence99/serverx-ts/blob/master/src/handlers/open-api.ts) `handler` that can be used in any route, although by convention: 452 | 453 | ```ts 454 | const routes: Route[] = [ 455 | { 456 | path: '', 457 | children: [ 458 | { 459 | path: 'openapi.yml', 460 | handler: OpenAPI 461 | } 462 | // other routes 463 | ] 464 | } 465 | ]; 466 | 467 | const app = new HttpApp(routes, { title: 'http-server', version: '1.0' }); 468 | ``` 469 | 470 | The `OpenAPI` `handler` creates a `YAML` response that describes the entire ServeRX-ts application. 471 | 472 | > Notice how an `InfoObject` can be passed to `HttpApp`, `AWSLambdaApp` and so on to fulfill the [OpenAPI specification](https://swagger.io/specification/). The excellent [OpenApi3-TS](https://github.com/metadevpro/openapi3-ts) package is a ServeRX-ts dependency and its model definitions can be imported for type-safety. 473 | 474 | #### Informational Annotations 475 | 476 | Routes can be annotated with `summary` and `description` information: 477 | 478 | ```ts 479 | const routes: Route[] = [ 480 | { 481 | path: '', 482 | methods: ['GET'], 483 | summary: 'A family of blah blah endpoints', 484 | children: [ 485 | { 486 | description: 'Get some bar-type data', 487 | path: '/bar', 488 | Handler: FooBar 489 | }, 490 | { 491 | description: 'Get some baz-type data', 492 | path: '/baz', 493 | Handler: FooBaz 494 | } 495 | ] 496 | } 497 | ]; 498 | ``` 499 | 500 | > Both `summary` and `description` are inherited. 501 | 502 | > Because ServeRX-ts is biased toward microservices, ServeRX-ts does not currently support the many other informational annotations that the full [OpenAPI specification](https://swagger.io/specification/) does. 503 | 504 | #### Metadata Annotations 505 | 506 | Routes can also be annotated with `request` and `responses` metadata. The idea is to provide `OpenAPI` with decorated classes that describe the format of headers, parameters and request/response body. These classes are the same classes that would be used in `middleware` and `handlers` for type-safety. The `request` and `responses` annotations are inherited. 507 | 508 | Consider the following classes: 509 | 510 | ```ts 511 | class CommonHeader { 512 | @Attr({ required: true }) x: string; 513 | @Attr() y: boolean; 514 | @Attr() z: number; 515 | } 516 | 517 | class FooBodyInner { 518 | @Attr() a: number; 519 | @Attr() b: string; 520 | @Attr() c: boolean; 521 | } 522 | 523 | class FooBody { 524 | @Attr() p: string; 525 | @Attr() q: boolean; 526 | @Attr() r: number; 527 | // NOTE: _class is only necessary because TypeScript's design:type tells us 528 | // that a field is an array, but not of what type -- when it can we'll deprecate 529 | @Attr({ _class: FooBodyInner }) t[]: FooBodyInner; 530 | } 531 | 532 | class FooPath { 533 | @Attr() k: boolean; 534 | } 535 | 536 | class FooQuery { 537 | @Attr({ required: true }) k: number; 538 | } 539 | ``` 540 | 541 | They could be used in the following routes: 542 | 543 | ```ts 544 | const routes: Route[] = [ 545 | { 546 | path: '', 547 | request: { 548 | header: CommonHeader 549 | }, 550 | children: [ 551 | { 552 | methods: ['GET'], 553 | path: '/foo', 554 | request: { 555 | path: FooPath, 556 | query: FooQuery 557 | } 558 | }, 559 | { 560 | methods: ['PUT'], 561 | path: '/foo', 562 | request: { 563 | body: { 564 | 'application/x-www-form-urlencoded': FooBody, 565 | 'application/json': FooBody 566 | } 567 | } 568 | }, 569 | { 570 | methods: ['POST'], 571 | path: '/bar', 572 | responses: { 573 | '200': { 574 | 'application/json': BarBody 575 | } 576 | } 577 | } 578 | ] 579 | } 580 | ]; 581 | ``` 582 | 583 | > Notice how `request` and `responses` are inherited cumulatively. 584 | 585 | When ServeRX-ts wraps supplied routes, it automatically adds metadata about the `500` response it handles itself, as if this were coded: 586 | 587 | ```ts 588 | { 589 | path: '', 590 | middlewares: [BodyParser /* HTTP only */, Normalizer], 591 | services: [LogProvider], 592 | responses: { 593 | '500': { 594 | 'application/json': Response500 595 | } 596 | }, 597 | children: [ /* supplied routes */ ] 598 | } 599 | ``` 600 | -------------------------------------------------------------------------------- /bin/deploy-npm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | version=${1:-patch} 4 | message=${2:-"Prepare new version for release"} 5 | 6 | npm run build 7 | 8 | git add . * 9 | git commit -m "$message" 10 | 11 | npm version $version 12 | 13 | git push origin master 14 | 15 | npm publish 16 | -------------------------------------------------------------------------------- /coverage.svg: -------------------------------------------------------------------------------- 1 | Coverage: 95.34%Coverage95.34% -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | collectCoverage: true, 8 | coverageReporters: ['json-summary'], 9 | coveragePathIgnorePatterns: ['/node_modules/', '/src/ported/'], 10 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 11 | setupFiles: ['./node_modules/reflect-metadata/Reflect.js'], 12 | testEnvironment: 'node' 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverx-ts", 3 | "version": "0.0.41", 4 | "description": "Experimental Node.js HTTP framework using RxJS, built with TypeScript and optimized for serverless deployments", 5 | "license": "MIT", 6 | "author": "Mark Florence", 7 | "main": "./dist/index.js", 8 | "engines": { 9 | "node": ">= 8.10" 10 | }, 11 | "scripts": { 12 | "build": "npm run lint && npm run test && rimraf dist && tsc", 13 | "clean:deps": "rm -rf node_modules && rm -f package-lock.json && npm install", 14 | "compile": "npm run lint && rimraf dist && tsc", 15 | "deploy:npm": "./bin/deploy-npm.sh", 16 | "file-server": "rimraf dist && tsc && node dist/examples/file-server.js", 17 | "http-server": "rimraf dist && tsc && node dist/examples/http-server.js", 18 | "lint": "eslint ./src/**/*.ts", 19 | "postinstall": "rimraf node_modules/@types/mime", 20 | "prettier": "prettier --write .", 21 | "test": "jest --runInBand --coverage && make-coverage-badge && mv ./coverage/badge.svg ./coverage.svg", 22 | "test:only": "jest --coverage=false -- %1", 23 | "toc": "markdown-toc -i README.md" 24 | }, 25 | "dependencies": { 26 | "chalk": "^4", 27 | "file-type": "^10", 28 | "injection-js": "^2", 29 | "js-yaml": "^4", 30 | "md5-file": "^5.0.0", 31 | "mime": "^2", 32 | "openapi3-ts": "^2", 33 | "reflect-metadata": "^0.1.13", 34 | "rxjs": "^7", 35 | "table": "^6" 36 | }, 37 | "devDependencies": { 38 | "@types/aws-lambda": "^8", 39 | "@types/express": "^4", 40 | "@types/jest": "^27", 41 | "@types/md5-file": "^5.0.0", 42 | "@types/node": "^16", 43 | "@typescript-eslint/eslint-plugin": "^4", 44 | "@typescript-eslint/parser": "^4", 45 | "axios": "^0.21.1", 46 | "eslint": "^7", 47 | "eslint-config-mflorence99": "^1", 48 | "eslint-config-prettier": "^8", 49 | "eslint-plugin-import": "^2", 50 | "eslint-plugin-import-splitnsort": "^1", 51 | "eslint-plugin-jsdoc": "^36", 52 | "eslint-plugin-prefer-arrow": "^1", 53 | "jest": "^27", 54 | "lambda-local": "^2", 55 | "make-coverage-badge": "^1", 56 | "markdown-toc": "^1", 57 | "prettier": "^2", 58 | "prettier-plugin-package": "^1", 59 | "rimraf": "^3", 60 | "string-to-stream": "^1", 61 | "ts-jest": "^27", 62 | "ts-node": "^10", 63 | "typescript": "^4" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Class } from './interfaces'; 2 | import { Exception } from './interfaces'; 3 | import { Handler } from './handler'; 4 | import { Message } from './interfaces'; 5 | import { Middleware } from './middleware'; 6 | import { MiddlewareMethod } from './middleware'; 7 | import { Response } from './interfaces'; 8 | import { Response500 } from './interfaces'; 9 | import { Route } from './interfaces'; 10 | import { Router } from './router'; 11 | 12 | import { caseInsensitiveObject } from './utils'; 13 | 14 | import { InfoObject } from 'openapi3-ts'; 15 | import { Observable } from 'rxjs'; 16 | 17 | import { catchError } from 'rxjs/operators'; 18 | import { combineLatest } from 'rxjs'; 19 | import { map } from 'rxjs/operators'; 20 | import { mergeMap } from 'rxjs/operators'; 21 | import { of } from 'rxjs'; 22 | import { pipe } from 'rxjs'; 23 | 24 | import querystring from 'querystring'; 25 | 26 | /** 27 | * Base app class 28 | */ 29 | 30 | export abstract class App { 31 | info: InfoObject; 32 | router: Router; 33 | 34 | /** ctor */ 35 | constructor( 36 | routes: Route[], 37 | required: Class[] = [], 38 | info: InfoObject = null 39 | ) { 40 | this.router = new Router(routes, required); 41 | this.info = info || { title: this.constructor.name, version: '0.0.0' }; 42 | } 43 | 44 | // protected methods 45 | 46 | protected makePipeline(message: Message): any { 47 | const { request } = message; 48 | return pipe( 49 | // run pre-handle middleware 50 | mergeMap((message: Message): Observable => { 51 | const middlewares$ = this.makeMiddlewares$( 52 | request.route, 53 | message, 54 | 'prehandle' 55 | ); 56 | return combineLatest(middlewares$); 57 | }), 58 | // NOTE: because of mutability, they're all the same message 59 | map((messages: Message[]): Message => messages[0]), 60 | // run the handler 61 | mergeMap((message: Message): Observable => { 62 | return this.makeHandler$(request.route, message); 63 | }), 64 | // run post-handle middleware 65 | // NOTE: in reverse order 66 | mergeMap((message: Message): Observable => { 67 | const middlewares$ = this.makeMiddlewares$( 68 | request.route, 69 | message, 70 | 'posthandle' 71 | ); 72 | return combineLatest(middlewares$.reverse()); 73 | }), 74 | // NOTE: because of mutability, they're all the same message 75 | map((messages: Message[]): Message => messages[0]), 76 | // turn any error into a message 77 | catchError((error: any): Observable => { 78 | return this.catchError$(error, message); 79 | }), 80 | // run post-catch middleware 81 | // NOTE: in reverse order 82 | mergeMap((message: Message): Observable => { 83 | const middlewares$ = this.makeMiddlewares$( 84 | request.route, 85 | message, 86 | 'postcatch' 87 | ); 88 | return combineLatest(middlewares$.reverse()); 89 | }), 90 | // NOTE: because of mutability, they're all the same message 91 | map((messages: Message[]): Message => messages[0]) 92 | ); 93 | } 94 | 95 | protected normalizePath(path: string): string { 96 | return !path || path === '/' ? '/index.html' : querystring.unescape(path); 97 | } 98 | 99 | // private methods 100 | 101 | private catchError$(error: any, message: Message): Observable { 102 | const { context, request } = message; 103 | if (error instanceof Exception) { 104 | // NOTE: make sure there are at least empty headers 105 | const response = error.exception; 106 | if (!response.headers) response.headers = caseInsensitiveObject({}); 107 | return of({ context, request, response }); 108 | } else { 109 | const response: Response = { 110 | // NOTE: we have to stringify manually because we are now past the Normalizer 111 | body: JSON.stringify({ 112 | error: error.toString(), 113 | stack: error.stack 114 | } as Response500), 115 | // eslint-disable-next-line @typescript-eslint/naming-convention 116 | headers: caseInsensitiveObject({ 'Content-Type': 'application/json' }), 117 | statusCode: 500 118 | }; 119 | return of({ context, request, response }); 120 | } 121 | } 122 | 123 | private makeHandler$(route: Route, message: Message): Observable { 124 | const handler = Handler.makeInstance(route); 125 | return handler ? handler.handle(of(message)) : of(message); 126 | } 127 | 128 | private makeMiddlewares$( 129 | route: Route, 130 | message: Message, 131 | method: MiddlewareMethod 132 | ): Observable[] { 133 | const middlewares = []; 134 | // we find all the middlewares up the route tree 135 | while (route) { 136 | middlewares.push(...Middleware.makeInstances(route)); 137 | route = route.parent; 138 | } 139 | return middlewares.length === 0 140 | ? [of(message)] 141 | : middlewares.map((middleware) => middleware[method](of(message))); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/aws-lambda/app.test.ts: -------------------------------------------------------------------------------- 1 | import { AWSLambdaApp } from './app'; 2 | import { Handler } from '../handler'; 3 | import { Message } from '../interfaces'; 4 | import { Middleware } from '../middleware'; 5 | import { Response } from '../interfaces'; 6 | import { Route } from '../interfaces'; 7 | 8 | import * as lambdaLocal from 'lambda-local'; 9 | import * as path from 'path'; 10 | 11 | import { APIGatewayProxyEvent } from 'aws-lambda'; 12 | import { Context } from 'aws-lambda'; 13 | import { Injectable } from 'injection-js'; 14 | import { Observable } from 'rxjs'; 15 | 16 | import { tap } from 'rxjs/operators'; 17 | 18 | @Injectable() 19 | class Hello extends Handler { 20 | handle(message$: Observable): Observable { 21 | return message$.pipe( 22 | tap(({ request, response }) => { 23 | response.body = `Hello, ${request.query.get('bizz')}`; 24 | }) 25 | ); 26 | } 27 | } 28 | 29 | @Injectable() 30 | class Goodbye extends Handler { 31 | handle(message$: Observable): Observable { 32 | return message$.pipe( 33 | tap(({ request, response }) => { 34 | response.body = `Goodbye, ${request.query.get('buzz')}`; 35 | }) 36 | ); 37 | } 38 | } 39 | 40 | @Injectable() 41 | class Explode extends Handler { 42 | handle(message$: Observable): Observable { 43 | return message$.pipe( 44 | tap(({ response }) => { 45 | response['x']['y'] = 'z'; 46 | }) 47 | ); 48 | } 49 | } 50 | 51 | @Injectable() 52 | class Middleware1 extends Middleware { 53 | prehandle(message$: Observable): Observable { 54 | return message$.pipe( 55 | tap(({ response }) => { 56 | response.headers['X-this'] = 'that'; 57 | }) 58 | ); 59 | } 60 | } 61 | 62 | @Injectable() 63 | class Middleware2 extends Middleware { 64 | prehandle(message$: Observable): Observable { 65 | return message$.pipe( 66 | tap(({ request, response }) => { 67 | response.headers['X-that'] = 'this'; 68 | Object.keys(request.body).forEach( 69 | (k) => (response.headers[k] = request.body[k]) 70 | ); 71 | }) 72 | ); 73 | } 74 | } 75 | 76 | const event = { 77 | body: JSON.stringify({ a: 'b', c: 'd' }), 78 | headers: { 79 | this: 'that' 80 | }, 81 | httpMethod: 'GET', 82 | isBase64Encoded: false, 83 | multiValueHeaders: null, 84 | multiValueQueryStringParameters: null, 85 | path: '/foo/bar', 86 | pathParameters: null, 87 | queryStringParameters: { 88 | bizz: 'bazz', 89 | buzz: 'bozz' 90 | }, 91 | requestContext: null, 92 | resource: null, 93 | stageVariables: null 94 | }; 95 | 96 | const context = { 97 | awsRequestId: '0' 98 | }; 99 | 100 | const routes: Route[] = [ 101 | { 102 | path: '', 103 | children: [ 104 | { 105 | methods: ['GET'], 106 | path: '/foo/bar', 107 | handler: Hello, 108 | middlewares: [Middleware1, Middleware2] 109 | }, 110 | 111 | { 112 | methods: ['PUT'], 113 | path: '/foo/bar', 114 | handler: Goodbye, 115 | middlewares: [Middleware1] 116 | }, 117 | 118 | { 119 | methods: ['GET'], 120 | path: '/explode', 121 | handler: Explode 122 | }, 123 | 124 | { 125 | methods: ['GET'], 126 | path: '/not-here', 127 | redirectTo: 'http://over-there.com' 128 | } 129 | ] 130 | } 131 | ]; 132 | 133 | const app = new AWSLambdaApp(routes); 134 | 135 | describe('AWSLambdaApp unit tests', () => { 136 | test('smoke test #1', () => { 137 | return app 138 | .handle({ ...event, httpMethod: 'GET' }, context) 139 | .then((response: Response) => { 140 | expect(response.body).toEqual('"Hello, bazz"'); 141 | expect(response.headers['X-this']).toEqual('that'); 142 | expect(response.headers['X-that']).toEqual('this'); 143 | expect(response.headers['a']).toEqual('b'); 144 | expect(response.headers['c']).toEqual('d'); 145 | expect(response.statusCode).toEqual(200); 146 | }); 147 | }); 148 | 149 | test('smoke test #2', () => { 150 | return app 151 | .handle({ ...event, httpMethod: 'PUT' }, context) 152 | .then((response: Response) => { 153 | expect(response.body).toEqual('"Goodbye, bozz"'); 154 | expect(response.headers['X-this']).toEqual('that'); 155 | expect(response.headers['X-that']).toBeUndefined(); 156 | expect(response.statusCode).toEqual(200); 157 | }); 158 | }); 159 | 160 | test('smoke test #3', () => { 161 | return app 162 | .handle({ ...event, httpMethod: 'PUT', path: '/xxx' }, context) 163 | .then((response: Response) => { 164 | expect(response.statusCode).toEqual(404); 165 | }); 166 | }); 167 | 168 | test('smoke test #4', () => { 169 | return app 170 | .handle({ ...event, httpMethod: 'GET', path: '/not-here' }, context) 171 | .then((response: Response) => { 172 | expect(response.headers['Location']).toEqual('http://over-there.com'); 173 | expect(response.statusCode).toEqual(301); 174 | }); 175 | }); 176 | 177 | test('error 500', () => { 178 | return app 179 | .handle({ ...event, httpMethod: 'GET', path: '/explode' }, context) 180 | .then((response: Response) => { 181 | expect(response.body).toContain( 182 | `TypeError: Cannot set property 'y' of undefined` 183 | ); 184 | expect(response.statusCode).toEqual(500); 185 | }); 186 | }); 187 | 188 | test('lambda local 200', () => { 189 | const apiGatewayEvent = require('lambda-local/examples/event_apigateway'); 190 | return lambdaLocal 191 | .execute({ 192 | event: { ...apiGatewayEvent, path: '/foo/bar' }, 193 | lambdaFunc: { handler: (event, context) => app.handle(event, context) }, 194 | lambdaHandler: 'handler', 195 | profilePath: path.join(__dirname, 'credentials'), 196 | profileName: 'default', 197 | verboseLevel: 0 198 | }) 199 | .then((response: Response) => { 200 | expect(response.body).toEqual('"Hello, null"'); 201 | expect(response.statusCode).toEqual(200); 202 | }); 203 | }); 204 | 205 | test('lambda local 404', () => { 206 | const apiGatewayEvent = require('lambda-local/examples/event_apigateway'); 207 | return lambdaLocal 208 | .execute({ 209 | event: { ...apiGatewayEvent, path: '/xxx' }, 210 | lambdaFunc: { handler: (event, context) => app.handle(event, context) }, 211 | lambdaHandler: 'handler', 212 | profilePath: path.join(__dirname, 'credentials'), 213 | profileName: 'default', 214 | verboseLevel: 0 215 | }) 216 | .then((response: Response) => { 217 | expect(response.statusCode).toEqual(404); 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /src/aws-lambda/app.ts: -------------------------------------------------------------------------------- 1 | import { App } from '../app'; 2 | import { Message } from '../interfaces'; 3 | import { Method } from '../interfaces'; 4 | import { Normalizer } from '../middlewares/normalizer'; 5 | import { Response } from '../interfaces'; 6 | import { Route } from '../interfaces'; 7 | 8 | import { caseInsensitiveObject } from '../utils'; 9 | 10 | import { APIGatewayProxyEvent } from 'aws-lambda'; 11 | import { Context } from 'aws-lambda'; 12 | import { InfoObject } from 'openapi3-ts'; 13 | import { URLSearchParams } from 'url'; 14 | 15 | import { map } from 'rxjs/operators'; 16 | import { of } from 'rxjs'; 17 | 18 | // NOTE: this middleware is required 19 | const MIDDLEWARES = [Normalizer]; 20 | 21 | /** 22 | * AWS Lambda application 23 | */ 24 | 25 | export class AWSLambdaApp extends App { 26 | /** ctor */ 27 | constructor(routes: Route[], info: InfoObject = null) { 28 | super(routes, MIDDLEWARES, info); 29 | } 30 | 31 | /** AWS Lambda handler method */ 32 | handle(event: APIGatewayProxyEvent, _context: Context): Promise { 33 | // synthesize Message from Lambda event and context 34 | const message: Message = { 35 | context: { 36 | info: this.info, 37 | router: this.router 38 | }, 39 | request: { 40 | // @see https://stackoverflow.com/questions/41648467 41 | body: event.body != null ? JSON.parse(event.body) : {}, 42 | headers: caseInsensitiveObject(event.headers || {}), 43 | httpVersion: '1.1', 44 | method: event.httpMethod, 45 | params: {}, 46 | path: this.normalizePath(event.path), 47 | query: this.makeSearchParamsFromEvent(event), 48 | remoteAddr: null, 49 | route: null, 50 | stream$: null, 51 | timestamp: Date.now() 52 | }, 53 | response: { 54 | body: null, 55 | headers: caseInsensitiveObject({}), 56 | statusCode: null 57 | } 58 | }; 59 | return of(message) 60 | .pipe(map((message: Message): Message => this.router.route(message))) 61 | .pipe(this.makePipeline(message)) 62 | .pipe( 63 | map((message: Message): Response => message.response), 64 | // NOTE: properly encode binary responses as base64 65 | // @see https://techblog.commercetools.com/gzip-on-aws-lambda-and-api-gateway-5170bb02b543 66 | map((response: Response): Response => { 67 | // @see https://stackoverflow.com/questions/21858138 68 | if (response.body instanceof Buffer) { 69 | const encoding = response.isBase64Encoded ? 'base64' : 'utf8'; 70 | response.body = response.body.toString(encoding); 71 | } 72 | return response; 73 | }) 74 | ) 75 | .toPromise(); 76 | } 77 | 78 | // private methods 79 | 80 | private makeSearchParamsFromEvent( 81 | event: APIGatewayProxyEvent 82 | ): URLSearchParams { 83 | const params = new URLSearchParams(); 84 | if (event.queryStringParameters) { 85 | Object.entries(event.queryStringParameters).forEach(([k, v]) => { 86 | params.append(k, v); 87 | }); 88 | } 89 | if (event.multiValueQueryStringParameters) { 90 | Object.entries(event.multiValueQueryStringParameters).forEach( 91 | ([k, vs]) => { 92 | if (!params.has(k)) vs.forEach((v) => params.append(k, v)); 93 | } 94 | ); 95 | } 96 | return params; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/aws-lambda/credentials: -------------------------------------------------------------------------------- 1 | [default] 2 | aws_access_key_id=redacted 3 | aws_secret_access_key=redacted 4 | 5 | -------------------------------------------------------------------------------- /src/aws-lambda/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app'; 2 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Common configuration settings 3 | */ 4 | 5 | export class Config {} 6 | 7 | export const config = new Config(); 8 | -------------------------------------------------------------------------------- /src/examples/file-server.ts: -------------------------------------------------------------------------------- 1 | import { Compressor } from '../middlewares/compressor'; 2 | import { CORS } from '../middlewares/cors'; 3 | import { FileServer } from '../handlers/file-server'; 4 | import { HttpApp } from '../http/app'; 5 | import { REQUEST_LOGGER_OPTS } from '../middlewares/request-logger'; 6 | import { RequestLogger } from '../middlewares/request-logger'; 7 | import { Route } from '../interfaces'; 8 | 9 | import * as os from 'os'; 10 | 11 | import { createServer } from 'http'; 12 | 13 | import chalk from 'chalk'; 14 | 15 | const routes: Route[] = [ 16 | { 17 | path: '/', 18 | handler: FileServer, 19 | middlewares: [Compressor, CORS, RequestLogger], 20 | services: [{ provide: REQUEST_LOGGER_OPTS, useValue: { format: 'tiny' } }] 21 | } 22 | ]; 23 | 24 | const app = new HttpApp(routes); 25 | 26 | const listener = app.listen(); 27 | const server = createServer(listener).on('listening', () => { 28 | console.log( 29 | chalk.cyanBright( 30 | `Examples: file-server listening on port 4200 deploying from ${os.homedir()}` 31 | ) 32 | ); 33 | }); 34 | 35 | server.listen(4200); 36 | -------------------------------------------------------------------------------- /src/examples/http-server.ts: -------------------------------------------------------------------------------- 1 | import { Attr } from '../metadata'; 2 | import { Compressor } from '../middlewares/compressor'; 3 | import { COMPRESSOR_OPTS } from '../middlewares/compressor'; 4 | import { CORS } from '../middlewares/cors'; 5 | import { FileServer } from '../handlers/file-server'; 6 | import { Handler } from '../handler'; 7 | import { HttpApp } from '../http/app'; 8 | import { Message } from '../interfaces'; 9 | import { OpenAPI } from '../handlers/open-api'; 10 | import { REQUEST_LOGGER_OPTS } from '../middlewares/request-logger'; 11 | import { RequestLogger } from '../middlewares/request-logger'; 12 | import { Route } from '../interfaces'; 13 | 14 | import { Injectable } from 'injection-js'; 15 | import { Observable } from 'rxjs'; 16 | 17 | import { createServer } from 'http'; 18 | import { table } from 'table'; 19 | import { tap } from 'rxjs/operators'; 20 | 21 | import chalk from 'chalk'; 22 | 23 | @Injectable() 24 | class Explode extends Handler { 25 | handle(message$: Observable): Observable { 26 | return message$.pipe( 27 | tap(({ response }) => { 28 | response['x']['y'] = 'z'; 29 | }) 30 | ); 31 | } 32 | } 33 | 34 | @Injectable() 35 | class Hello extends Handler { 36 | handle(message$: Observable): Observable { 37 | return message$.pipe( 38 | tap(({ response }) => { 39 | response.body = 'Hello, http!'; 40 | }) 41 | ); 42 | } 43 | } 44 | 45 | @Injectable() 46 | class Goodbye extends Handler { 47 | handle(message$: Observable): Observable { 48 | return message$.pipe( 49 | tap(({ response }) => { 50 | response.body = 'Goodbye, http!'; 51 | }) 52 | ); 53 | } 54 | } 55 | 56 | class FooBodyInner { 57 | @Attr() a: number; 58 | @Attr() b: string; 59 | @Attr() c: boolean; 60 | } 61 | 62 | class FooBody { 63 | @Attr() p: string; 64 | @Attr() q: boolean; 65 | @Attr() r: number; 66 | // eslint-disable-next-line @typescript-eslint/naming-convention 67 | @Attr({ _class: FooBodyInner }) t: FooBodyInner[]; 68 | } 69 | 70 | class FooBarParams { 71 | @Attr() id: string; 72 | } 73 | 74 | const routes: Route[] = [ 75 | { 76 | path: '', 77 | methods: ['GET'], 78 | middlewares: [RequestLogger, Compressor, CORS], 79 | services: [ 80 | { provide: REQUEST_LOGGER_OPTS, useValue: { colorize: true } }, 81 | { provide: COMPRESSOR_OPTS, useValue: { threshold: 0 } } 82 | ], 83 | summary: 'A family of test endpoints', 84 | children: [ 85 | { 86 | description: 'Develop OpenAPI representation of this server', 87 | path: 'openapi.yml', 88 | handler: OpenAPI 89 | }, 90 | 91 | { 92 | description: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`, 93 | path: '/foo', 94 | children: [ 95 | { 96 | path: '/bar/{id}', 97 | request: { 98 | body: { 99 | 'application/x-www-form-urlencoded': FooBody, 100 | 'application/json': FooBody 101 | }, 102 | path: FooBarParams 103 | } 104 | }, 105 | 106 | { 107 | path: '/baz' 108 | } 109 | ] 110 | }, 111 | 112 | { 113 | path: '/hello', 114 | handler: Hello 115 | }, 116 | 117 | { 118 | path: '/goodbye', 119 | handler: Goodbye 120 | }, 121 | 122 | { 123 | path: '/isalive' 124 | }, 125 | 126 | { 127 | path: '/public', 128 | handler: FileServer 129 | }, 130 | 131 | { 132 | description: 'This one will blow your mind!', 133 | methods: ['GET'], 134 | path: '/explode', 135 | handler: Explode 136 | }, 137 | 138 | { 139 | methods: ['GET'], 140 | path: '/not-here', 141 | redirectTo: 'http://over-there.com' 142 | } 143 | ] 144 | } 145 | ]; 146 | 147 | const app = new HttpApp(routes, { title: 'http-server', version: '1.0' }); 148 | 149 | const flattened = app.router 150 | .flatten() 151 | .map((route: Route) => [route.methods.join(','), route.path, route.summary]); 152 | console.log(table(flattened)); 153 | 154 | const listener = app.listen(); 155 | const server = createServer(listener).on('listening', () => { 156 | console.log(chalk.cyanBright('Examples: http-server listening on port 4200')); 157 | }); 158 | 159 | server.listen(4200); 160 | -------------------------------------------------------------------------------- /src/gcf/app.test.ts: -------------------------------------------------------------------------------- 1 | import { GCFApp } from './app'; 2 | import { Handler } from '../handler'; 3 | import { Message } from '../interfaces'; 4 | import { Middleware } from '../middleware'; 5 | import { Route } from '../interfaces'; 6 | 7 | import { Injectable } from 'injection-js'; 8 | import { Observable } from 'rxjs'; 9 | 10 | import { tap } from 'rxjs/operators'; 11 | 12 | @Injectable() 13 | class Hello extends Handler { 14 | handle(message$: Observable): Observable { 15 | return message$.pipe( 16 | tap(({ request, response }) => { 17 | response.body = `Hello, ${request.query.get('bizz')}`; 18 | }) 19 | ); 20 | } 21 | } 22 | 23 | @Injectable() 24 | class Goodbye extends Handler { 25 | handle(message$: Observable): Observable { 26 | return message$.pipe( 27 | tap(({ request, response }) => { 28 | response.body = `Goodbye, ${request.query.get('buzz')}`; 29 | }) 30 | ); 31 | } 32 | } 33 | 34 | @Injectable() 35 | class Explode extends Handler { 36 | handle(message$: Observable): Observable { 37 | return message$.pipe( 38 | tap(({ response }) => { 39 | response['x']['y'] = 'z'; 40 | }) 41 | ); 42 | } 43 | } 44 | 45 | @Injectable() 46 | class Middleware1 extends Middleware { 47 | prehandle(message$: Observable): Observable { 48 | return message$.pipe( 49 | tap(({ response }) => { 50 | response.headers['X-this'] = 'that'; 51 | }) 52 | ); 53 | } 54 | } 55 | 56 | @Injectable() 57 | class Middleware2 extends Middleware { 58 | prehandle(message$: Observable): Observable { 59 | return message$.pipe( 60 | tap(({ request, response }) => { 61 | response.headers['X-that'] = 'this'; 62 | Object.keys(request.body).forEach( 63 | (k) => (response.headers[k] = request.body[k]) 64 | ); 65 | }) 66 | ); 67 | } 68 | } 69 | const req: any = { 70 | body: { a: 'b', c: 'd' }, 71 | headers: { 72 | this: 'that' 73 | }, 74 | method: 'GET', 75 | url: '/foo/bar?bizz=bazz&buzz=bozz' 76 | }; 77 | 78 | const res: any = {}; 79 | 80 | const routes: Route[] = [ 81 | { 82 | path: '', 83 | children: [ 84 | { 85 | methods: ['GET'], 86 | path: '/foo/bar', 87 | handler: Hello, 88 | middlewares: [Middleware1, Middleware2] 89 | }, 90 | 91 | { 92 | methods: ['PUT'], 93 | path: '/foo/bar', 94 | handler: Goodbye, 95 | middlewares: [Middleware1] 96 | }, 97 | 98 | { 99 | methods: ['GET'], 100 | path: '/explode', 101 | handler: Explode 102 | }, 103 | 104 | { 105 | methods: ['GET'], 106 | path: '/not-here', 107 | redirectTo: 'http://over-there.com' 108 | } 109 | ] 110 | } 111 | ]; 112 | 113 | const app = new GCFApp(routes); 114 | 115 | describe('GCFApp unit tests', () => { 116 | test('smoke test #1', async () => { 117 | const response = await app.handle({ ...req, method: 'GET' }, res); 118 | expect(response.body).toEqual('"Hello, bazz"'); 119 | expect(response.headers['X-this']).toEqual('that'); 120 | expect(response.headers['X-that']).toEqual('this'); 121 | expect(response.headers['a']).toEqual('b'); 122 | expect(response.headers['c']).toEqual('d'); 123 | expect(response.statusCode).toEqual(200); 124 | }); 125 | 126 | test('smoke test #2', async () => { 127 | const response = await app.handle({ ...req, method: 'PUT' }, res); 128 | expect(response.body).toEqual('"Goodbye, bozz"'); 129 | expect(response.headers['X-this']).toEqual('that'); 130 | expect(response.headers['X-that']).toBeUndefined(); 131 | expect(response.statusCode).toEqual(200); 132 | }); 133 | 134 | test('smoke test #3', async () => { 135 | const response = await app.handle( 136 | { ...req, method: 'PUT', url: '/xxx' }, 137 | res 138 | ); 139 | expect(response.statusCode).toEqual(404); 140 | }); 141 | 142 | test('smoke test #4', async () => { 143 | const response = await app.handle( 144 | { ...req, method: 'GET', url: '/not-here' }, 145 | res 146 | ); 147 | expect(response.headers['Location']).toEqual('http://over-there.com'); 148 | expect(response.statusCode).toEqual(301); 149 | }); 150 | 151 | test('error 500', async () => { 152 | const response = await app.handle( 153 | { ...req, method: 'GET', url: '/explode' }, 154 | res 155 | ); 156 | expect(response.body).toContain( 157 | `TypeError: Cannot set property 'y' of undefined` 158 | ); 159 | expect(response.statusCode).toEqual(500); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /src/gcf/app.ts: -------------------------------------------------------------------------------- 1 | import { App } from '../app'; 2 | import { Message } from '../interfaces'; 3 | import { Method } from '../interfaces'; 4 | import { Normalizer } from '../middlewares/normalizer'; 5 | import { Response } from '../interfaces'; 6 | import { Route } from '../interfaces'; 7 | 8 | import { caseInsensitiveObject } from '../utils'; 9 | 10 | import * as express from 'express'; 11 | import * as url from 'url'; 12 | 13 | import { InfoObject } from 'openapi3-ts'; 14 | import { URLSearchParams } from 'url'; 15 | 16 | import { map } from 'rxjs/operators'; 17 | import { of } from 'rxjs'; 18 | import { tap } from 'rxjs/operators'; 19 | 20 | // NOTE: this middleware is required 21 | const MIDDLEWARES = [Normalizer]; 22 | 23 | /** 24 | * Google Cloud Function application 25 | */ 26 | 27 | export class GCFApp extends App { 28 | /** ctor */ 29 | constructor(routes: Route[], info: InfoObject = null) { 30 | super(routes, MIDDLEWARES, info); 31 | } 32 | 33 | /** AWS Lambda handler method */ 34 | handle(req: express.Request, res: express.Response): Promise { 35 | // synthesize Message from GCF Express-simulated req and res 36 | // NOTE: req is augmented IncomingMessage 37 | const parsed = url.parse(req.url); 38 | const message: Message = { 39 | context: { 40 | info: this.info, 41 | router: this.router 42 | }, 43 | request: { 44 | // NOTE: body is pre-parsed by Google Cloud 45 | body: req.body, 46 | headers: caseInsensitiveObject(req.headers || {}), 47 | httpVersion: req.httpVersion, 48 | method: req.method, 49 | params: {}, 50 | path: this.normalizePath(parsed.pathname), 51 | query: new URLSearchParams(parsed.search), 52 | remoteAddr: req.connection ? req.connection.remoteAddress : null, 53 | route: null, 54 | stream$: null, 55 | timestamp: Date.now() 56 | }, 57 | response: { 58 | body: null, 59 | headers: caseInsensitiveObject({}), 60 | statusCode: null 61 | } 62 | }; 63 | return of(message) 64 | .pipe(map((message: Message): Message => this.router.route(message))) 65 | .pipe(this.makePipeline(message)) 66 | .pipe( 67 | map((message: Message): Response => message.response), 68 | tap((response: Response) => { 69 | if (res.send) { 70 | Object.entries(response.headers).forEach(([k, v]) => 71 | res.set(k, v) 72 | ); 73 | res.status(response.statusCode).send(response.body).end(); 74 | } 75 | }) 76 | ) 77 | .toPromise(); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/gcf/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app'; 2 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './interfaces'; 2 | import { Route } from './interfaces'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | /** 7 | * Handler definition 8 | */ 9 | 10 | export class Handler { 11 | /** Instantiate a Handler from a Route */ 12 | static makeInstance(route: Route): T { 13 | return route.handler ? route.injector.get(route.handler) : null; 14 | } 15 | 16 | /** Handle a message */ 17 | handle(message$: Observable): Observable { 18 | return message$; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/handlers/file-server.test.ts: -------------------------------------------------------------------------------- 1 | import { FileServer } from './file-server'; 2 | import { Message } from '../interfaces'; 3 | import { Route } from '../interfaces'; 4 | import { Router } from '../router'; 5 | 6 | import * as path from 'path'; 7 | 8 | import { of } from 'rxjs'; 9 | 10 | import md5File from 'md5-file'; 11 | 12 | const routes: Route[] = [ 13 | { 14 | path: '/public', 15 | handler: FileServer 16 | } 17 | ]; 18 | 19 | const info = { title: 'dummy', version: 'dummy' }; 20 | const router = new Router(routes); 21 | const fileServer = new FileServer({ root: __dirname }); 22 | 23 | describe('FileServer unit tests', () => { 24 | test('sets statusCode, body and headers if found', (done) => { 25 | const hash = md5File.sync(path.join(__dirname, 'file-server.test.ts')); 26 | const message: Message = { 27 | context: { info, router }, 28 | request: { 29 | path: '/public/file-server.test.ts', 30 | method: 'GET', 31 | headers: {}, 32 | route: routes[0] 33 | }, 34 | response: { headers: {} } 35 | }; 36 | fileServer.handle(of(message)).subscribe(({ response }) => { 37 | expect(response.body.toString()).toMatch(/^import /); 38 | expect(response.headers['Cache-Control']).toEqual('max-age=600'); 39 | expect(response.headers['Etag']).toEqual(hash); 40 | expect(response.statusCode).toEqual(200); 41 | done(); 42 | }); 43 | }); 44 | 45 | test('sets statusCode and headers if found but cached', (done) => { 46 | const hash = md5File.sync(path.join(__dirname, 'file-server.test.ts')); 47 | const message: Message = { 48 | context: { info, router }, 49 | request: { 50 | path: '/public/file-server.test.ts', 51 | method: 'GET', 52 | // eslint-disable-next-line @typescript-eslint/naming-convention 53 | headers: { 'If-None-Match': hash }, 54 | route: routes[0] 55 | }, 56 | response: { headers: {} } 57 | }; 58 | fileServer.handle(of(message)).subscribe(({ response }) => { 59 | expect(response.body).toBeUndefined(); 60 | expect(response.headers['Cache-Control']).toEqual('max-age=600'); 61 | expect(response.headers['Etag']).toEqual(hash); 62 | expect(response.statusCode).toEqual(304); 63 | done(); 64 | }); 65 | }); 66 | 67 | test('sets statusCode if not found', (done) => { 68 | const message: Message = { 69 | context: { info, router }, 70 | request: { 71 | path: '/public/x/y/z.html', 72 | method: 'GET', 73 | headers: {}, 74 | route: routes[0] 75 | }, 76 | response: { headers: {} } 77 | }; 78 | fileServer.handle(of(message)).subscribe({ 79 | error: ({ exception }) => { 80 | expect(exception.statusCode).toEqual(404); 81 | done(); 82 | } 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/handlers/file-server.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from '../interfaces'; 2 | import { Handler } from '../handler'; 3 | import { Message } from '../interfaces'; 4 | 5 | import { fromReadableStream } from '../utils'; 6 | 7 | import * as fs from 'fs'; 8 | import * as os from 'os'; 9 | import * as path from 'path'; 10 | 11 | import { Inject } from 'injection-js'; 12 | import { Injectable } from 'injection-js'; 13 | import { InjectionToken } from 'injection-js'; 14 | import { Observable } from 'rxjs'; 15 | import { Optional } from 'injection-js'; 16 | 17 | import { catchError } from 'rxjs/operators'; 18 | import { from } from 'rxjs'; 19 | import { mapTo } from 'rxjs/operators'; 20 | import { mergeMap } from 'rxjs/operators'; 21 | import { of } from 'rxjs'; 22 | import { tap } from 'rxjs/operators'; 23 | import { throwError } from 'rxjs'; 24 | 25 | import md5File from 'md5-file'; 26 | 27 | /** 28 | * File server options 29 | */ 30 | 31 | export interface FileServerOpts { 32 | maxAge?: number; 33 | root?: string; 34 | } 35 | 36 | export const FILE_SERVER_OPTS = new InjectionToken( 37 | 'FILE_SERVER_OPTS' 38 | ); 39 | 40 | export const FILE_SERVER_DEFAULT_OPTS: FileServerOpts = { 41 | maxAge: 600, 42 | root: os.homedir() 43 | }; 44 | 45 | /** 46 | * File server 47 | */ 48 | 49 | @Injectable() 50 | export class FileServer extends Handler { 51 | private opts: FileServerOpts; 52 | 53 | constructor(@Optional() @Inject(FILE_SERVER_OPTS) opts: FileServerOpts) { 54 | super(); 55 | this.opts = opts 56 | ? { ...FILE_SERVER_DEFAULT_OPTS, ...opts } 57 | : FILE_SERVER_DEFAULT_OPTS; 58 | } 59 | 60 | handle(message$: Observable): Observable { 61 | return message$.pipe( 62 | mergeMap((message: Message): Observable => { 63 | const { request, response } = message; 64 | const fpath = this.makeFPath(message); 65 | // Etag is the fie hash 66 | const etag = request.headers['If-None-Match']; 67 | return of(message).pipe( 68 | // NOTE: exception thrown if not found 69 | mergeMap( 70 | (_message: Message): Observable => from(md5File(fpath)) 71 | ), 72 | // set the response headers 73 | tap((hash: string) => { 74 | response.headers['Cache-Control'] = `max-age=${this.opts.maxAge}`; 75 | response.headers['Etag'] = hash; 76 | }), 77 | // flip to cached/not cached pipes 78 | mergeMap((hash: string): Observable => { 79 | const cached = etag === hash; 80 | // cached pipe 81 | const cached$ = of(hash).pipe( 82 | tap(() => (response.statusCode = 304)), 83 | mapTo(message) 84 | ); 85 | // not cached pipe 86 | const notCached$ = of(hash).pipe( 87 | mergeMap( 88 | (): Observable => 89 | fromReadableStream(fs.createReadStream(fpath)) 90 | ), 91 | tap((buffer: Buffer) => { 92 | response.body = buffer; 93 | response.statusCode = 200; 94 | }), 95 | mapTo(message) 96 | ); 97 | return cached ? cached$ : notCached$; 98 | }), 99 | catchError(() => throwError(new Exception({ statusCode: 404 }))) 100 | ); 101 | }) 102 | ); 103 | } 104 | 105 | // private methods 106 | 107 | private makeFPath(message: Message): string { 108 | const { context, request, response } = message; 109 | const router = context.router; 110 | // NOTE: we never allow dot files and router.validate takes care of that 111 | let tail = router.tailOf(router.validate(request.path), request.route); 112 | // TODO: hack if this is a client-side route and not a path, deploy default 113 | if (!tail.includes('.')) { 114 | tail = 'index.html'; 115 | response.headers['Content-Type'] = 'text/html'; 116 | } 117 | return path.join(this.opts.root, tail); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './file-server'; 2 | export * from './not-found'; 3 | export * from './open-api'; 4 | export * from './redirect-to'; 5 | export * from './statuscode-200'; 6 | -------------------------------------------------------------------------------- /src/handlers/not-found.test.ts: -------------------------------------------------------------------------------- 1 | import { ALL_METHODS } from '../interfaces'; 2 | import { Message } from '../interfaces'; 3 | import { NotFound } from './not-found'; 4 | 5 | import { of } from 'rxjs'; 6 | 7 | describe('NotFound unit tests', () => { 8 | test('sets statusCode', (done) => { 9 | const notFound = new NotFound(); 10 | const message: Message = { 11 | request: { path: '/foo/bar', method: 'GET' }, 12 | response: {} 13 | }; 14 | notFound.handle(of(message)).subscribe(({ response }) => { 15 | expect(response.statusCode).toEqual(404); 16 | done(); 17 | }); 18 | }); 19 | 20 | test('handles pre-flight OPTIONS', (done) => { 21 | const notFound = new NotFound(); 22 | const message: Message = { 23 | request: { path: '/foo/bar', method: 'OPTIONS' }, 24 | response: { headers: {} } 25 | }; 26 | notFound.handle(of(message)).subscribe(({ response }) => { 27 | expect(response.headers['Allow']).toEqual(ALL_METHODS.join(',')); 28 | expect(response.statusCode).toEqual(200); 29 | done(); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/handlers/not-found.ts: -------------------------------------------------------------------------------- 1 | import { ALL_METHODS } from '../interfaces'; 2 | import { Handler } from '../handler'; 3 | import { Message } from '../interfaces'; 4 | 5 | import { Injectable } from 'injection-js'; 6 | import { Observable } from 'rxjs'; 7 | 8 | import { tap } from 'rxjs/operators'; 9 | 10 | /** 11 | * Catch all "not found" handler 12 | */ 13 | 14 | @Injectable() 15 | export class NotFound extends Handler { 16 | handle(message$: Observable): Observable { 17 | return message$.pipe( 18 | tap(({ request, response }) => { 19 | if (request.method === 'OPTIONS') { 20 | response.headers['Allow'] = ALL_METHODS.join(','); 21 | response.statusCode = 200; 22 | } else response.statusCode = response.statusCode || 404; 23 | }) 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/handlers/open-api.test.ts: -------------------------------------------------------------------------------- 1 | import { Attr } from '../metadata'; 2 | import { OpenAPI } from './open-api'; 3 | import { Route } from '../interfaces'; 4 | import { Router } from '../router'; 5 | 6 | import { InfoObject } from 'openapi3-ts'; 7 | import { OperationObject } from 'openapi3-ts'; 8 | import { SchemaObject } from 'openapi3-ts'; 9 | 10 | const info: InfoObject = { 11 | title: 'open-api.test', 12 | version: '0.0.0' 13 | }; 14 | 15 | class BarBody { 16 | @Attr() p: string; 17 | @Attr() q: boolean; 18 | @Attr() r: number; 19 | } 20 | 21 | class CommonHeader { 22 | @Attr({ required: true }) x: string; 23 | @Attr() y: boolean; 24 | @Attr() z: number; 25 | } 26 | 27 | class FooBodyInner { 28 | @Attr() a: number; 29 | @Attr() b: string; 30 | @Attr() c: boolean; 31 | } 32 | 33 | class FooBody { 34 | @Attr() p: string; 35 | @Attr() q: boolean; 36 | @Attr() r: number; 37 | @Attr() t: FooBodyInner; 38 | } 39 | 40 | class FooPath { 41 | @Attr() k: boolean; 42 | } 43 | 44 | class FooQuery { 45 | @Attr({ required: true }) k: number; 46 | } 47 | 48 | const routes: Route[] = [ 49 | { 50 | path: '', 51 | request: { 52 | header: CommonHeader 53 | }, 54 | summary: 'Top level', 55 | children: [ 56 | { 57 | description: 'GET /foo', 58 | methods: ['GET'], 59 | path: '/foo', 60 | request: { 61 | path: FooPath, 62 | query: FooQuery 63 | } 64 | }, 65 | 66 | { 67 | description: 'PUT /foo', 68 | methods: ['PUT'], 69 | path: '/foo', 70 | request: { 71 | body: { 72 | 'application/x-www-form-urlencoded': FooBody, 73 | 'application/json': FooBody 74 | } 75 | } 76 | }, 77 | 78 | { 79 | summary: 'Lower level', 80 | description: 'POST /bar', 81 | methods: ['POST'], 82 | path: '/bar', 83 | responses: { 84 | '200': { 85 | 'application/json': BarBody 86 | } 87 | } 88 | }, 89 | 90 | { 91 | path: '/not-here', 92 | methods: ['GET'], 93 | redirectAs: 304, 94 | redirectTo: '/over-there' 95 | } 96 | ] 97 | } 98 | ]; 99 | 100 | const router = new Router(routes); 101 | const flattened = router.flatten(); 102 | const openAPI = OpenAPI.fromRoutes(info, flattened).getSpec(); 103 | 104 | describe('OpenAPI unit tests', () => { 105 | test('object types', () => { 106 | class X { 107 | @Attr() t: number; 108 | @Attr({ float: true }) u: number; 109 | } 110 | class Y { 111 | @Attr() b: X; 112 | @Attr() c: string; 113 | } 114 | class Z { 115 | @Attr() a: Y; 116 | } 117 | const schema: SchemaObject = OpenAPI.makeSchemaObject(Z); 118 | expect(schema.properties['a']['type']).toEqual('object'); 119 | expect(schema.properties['a']['properties']['b']['type']).toEqual('object'); 120 | expect( 121 | schema.properties['a']['properties']['b']['properties']['t']['type'] 122 | ).toEqual('integer'); 123 | expect( 124 | schema.properties['a']['properties']['b']['properties']['u']['type'] 125 | ).toEqual('number'); 126 | expect(schema.properties['a']['properties']['c']['type']).toEqual('string'); 127 | }); 128 | 129 | test('array types', () => { 130 | class X { 131 | @Attr() p: string; 132 | @Attr() q: number; 133 | } 134 | class Y { 135 | // eslint-disable-next-line @typescript-eslint/naming-convention 136 | @Attr({ _class: X }) t: X[]; 137 | } 138 | const schema: SchemaObject = OpenAPI.makeSchemaObject(Y); 139 | expect(schema.properties['t']['type']).toEqual('array'); 140 | expect(schema.properties['t']['items']['type']).toEqual('object'); 141 | expect(schema.properties['t']['items']['properties']['p']['type']).toEqual( 142 | 'string' 143 | ); 144 | expect(schema.properties['t']['items']['properties']['q']['type']).toEqual( 145 | 'integer' 146 | ); 147 | }); 148 | 149 | test('basic smoke test', () => { 150 | expect(openAPI.info.title).toEqual('open-api.test'); 151 | expect(openAPI.info.version).toEqual('0.0.0'); 152 | }); 153 | 154 | test('paths and methods are mapped correctly', () => { 155 | const pathNames = Object.keys(openAPI.paths); 156 | expect(pathNames.length).toEqual(3); 157 | // NOTE: flattened paths are alpha-sorted 158 | expect(pathNames[0]).toEqual('/bar'); 159 | expect(pathNames[1]).toEqual('/foo'); 160 | expect(pathNames[2]).toEqual('/not-here'); 161 | expect(openAPI.paths['/foo'].get).toBeDefined(); 162 | expect(openAPI.paths['/foo'].put).toBeDefined(); 163 | expect(openAPI.paths['/foo'].post).not.toBeDefined(); 164 | expect(openAPI.paths['/bar'].get).not.toBeDefined(); 165 | expect(openAPI.paths['/bar'].post).toBeDefined(); 166 | expect(openAPI.paths['/not-here'].get).toBeDefined(); 167 | }); 168 | 169 | test('basic summary and description are accumulated', () => { 170 | expect(openAPI.paths['/foo'].get.summary).toEqual('Top level'); 171 | expect(openAPI.paths['/foo'].get.description).toEqual('GET /foo'); 172 | expect(openAPI.paths['/foo'].put.summary).toEqual('Top level'); 173 | expect(openAPI.paths['/foo'].put.description).toEqual('PUT /foo'); 174 | expect(openAPI.paths['/bar'].post.summary).toEqual('Lower level'); 175 | expect(openAPI.paths['/bar'].post.description).toEqual('POST /bar'); 176 | }); 177 | 178 | test('request parameter metadata is recorded', () => { 179 | const op: OperationObject = openAPI.paths['/foo'].get; 180 | expect(op.parameters).toContainEqual({ 181 | name: 'x', 182 | in: 'header', 183 | required: true, 184 | schema: { type: 'string' } 185 | }); 186 | expect(op.parameters).toContainEqual({ 187 | name: 'y', 188 | in: 'header', 189 | required: false, 190 | schema: { type: 'boolean' } 191 | }); 192 | expect(op.parameters).toContainEqual({ 193 | name: 'z', 194 | in: 'header', 195 | required: false, 196 | schema: { type: 'integer' } 197 | }); 198 | expect(op.parameters).toContainEqual({ 199 | name: 'k', 200 | in: 'path', 201 | required: true, 202 | schema: { type: 'boolean' } 203 | }); 204 | expect(op.parameters).toContainEqual({ 205 | name: 'k', 206 | in: 'query', 207 | required: true, 208 | schema: { type: 'integer' } 209 | }); 210 | }); 211 | 212 | test('request body metadata is recorded', () => { 213 | const schema: SchemaObject = 214 | openAPI.paths['/foo'].put.requestBody.content['application/json'].schema; 215 | expect(schema.properties['p']['type']).toEqual('string'); 216 | expect(schema.properties['q']['type']).toEqual('boolean'); 217 | expect(schema.properties['r']['type']).toEqual('integer'); 218 | expect(schema.properties['t']['type']).toEqual('object'); 219 | expect(schema.properties['t']['properties']['a']['type']).toEqual( 220 | 'integer' 221 | ); 222 | expect(schema.properties['t']['properties']['b']['type']).toEqual('string'); 223 | expect(schema.properties['t']['properties']['c']['type']).toEqual( 224 | 'boolean' 225 | ); 226 | }); 227 | 228 | test('response 500 is baked in', () => { 229 | const schema: SchemaObject = 230 | openAPI.paths['/bar'].post.responses['500'].content['application/json'] 231 | .schema; 232 | expect(schema.properties['error']['type']).toEqual('string'); 233 | expect(schema.properties['stack']['type']).toEqual('string'); 234 | }); 235 | 236 | test('responses can be specified at any level', () => { 237 | const schema: SchemaObject = 238 | openAPI.paths['/bar'].post.responses['200'].content['application/json'] 239 | .schema; 240 | expect(schema.properties['p']['type']).toEqual('string'); 241 | expect(schema.properties['q']['type']).toEqual('boolean'); 242 | expect(schema.properties['r']['type']).toEqual('integer'); 243 | }); 244 | 245 | test('redirect automatically generates a response', () => { 246 | const headers = openAPI.paths['/not-here'].get.responses['304'].headers; 247 | expect(headers['Location']).toBeDefined(); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /src/handlers/open-api.ts: -------------------------------------------------------------------------------- 1 | import { Class } from '../interfaces'; 2 | import { Handler } from '../handler'; 3 | import { Message } from '../interfaces'; 4 | import { Metadata } from '../interfaces'; 5 | import { Route } from '../interfaces'; 6 | 7 | import { getMetadata } from '../metadata'; 8 | import { resolveMetadata } from '../metadata'; 9 | 10 | import { ContentObject } from 'openapi3-ts'; 11 | import { InfoObject } from 'openapi3-ts'; 12 | import { Injectable } from 'injection-js'; 13 | import { Observable } from 'rxjs'; 14 | import { OpenApiBuilder } from 'openapi3-ts'; 15 | import { OperationObject } from 'openapi3-ts'; 16 | import { ParameterLocation } from 'openapi3-ts'; 17 | import { PathItemObject } from 'openapi3-ts'; 18 | import { PathsObject } from 'openapi3-ts'; 19 | import { SchemaObject } from 'openapi3-ts'; 20 | 21 | import { tap } from 'rxjs/operators'; 22 | 23 | /** 24 | * OpenAPI YML generator 25 | */ 26 | 27 | @Injectable() 28 | export class OpenAPI extends Handler { 29 | /** 30 | * Generate OpenAPI from routes 31 | * 32 | * TODO: refactor if it gets any bigger 33 | */ 34 | 35 | static fromRoutes(info: InfoObject, flattened: Route[]): OpenApiBuilder { 36 | const openAPI = new OpenApiBuilder().addInfo(info); 37 | // create each path from a corresponding route 38 | const paths = flattened.reduce((acc, route) => { 39 | const item: PathItemObject = acc[route.path] || {}; 40 | // skeleton operation object 41 | const operation: OperationObject = { 42 | description: route.description || '', 43 | responses: {}, 44 | summary: route.summary || '', 45 | parameters: [] 46 | }; 47 | // handle request body 48 | if (route.request && route.request.body) { 49 | const content: ContentObject = Object.entries(route.request.body) 50 | .map(([contentType, clazz]) => ({ 51 | contentType, 52 | schema: OpenAPI.makeSchemaObject(clazz) 53 | })) 54 | .reduce((acc, { contentType, schema }) => { 55 | acc[contentType] = { schema }; 56 | return acc; 57 | }, {}); 58 | operation.requestBody = { content }; 59 | } 60 | // handle request parameters 61 | if (route.request) { 62 | ['header', 'path', 'query'] 63 | .map((type) => ({ type, clazz: route.request[type] })) 64 | .filter(({ clazz }) => !!clazz) 65 | .map(({ type, clazz }) => ({ type, metadata: getMetadata(clazz) })) 66 | .forEach(({ type, metadata }) => { 67 | metadata.forEach((metadatum) => { 68 | operation.parameters.push({ 69 | name: metadatum.name, 70 | in: type as ParameterLocation, 71 | // NOTE: path params are always required 72 | required: metadatum.opts.required || type === 'path', 73 | schema: { type: OpenAPI.typeFor(metadatum) as any } 74 | }); 75 | }); 76 | }); 77 | } 78 | // handle responses 79 | if (route.responses) { 80 | Object.keys(route.responses).forEach((statusCode) => { 81 | const content: ContentObject = Object.entries( 82 | route.responses[statusCode] 83 | ) 84 | .map(([contentType, clazz]) => ({ 85 | contentType, 86 | schema: OpenAPI.makeSchemaObject(clazz) 87 | })) 88 | .reduce((acc, { contentType, schema }) => { 89 | acc[contentType] = { schema }; 90 | return acc; 91 | }, {}); 92 | operation.responses[statusCode] = { content }; 93 | }); 94 | } 95 | // redirect gets a special response 96 | if (route.redirectTo) { 97 | const statusCode = route.redirectAs || 301; 98 | operation.responses[String(statusCode)] = { 99 | // eslint-disable-next-line @typescript-eslint/naming-convention 100 | headers: { Location: { schema: { type: 'string' } } } 101 | }; 102 | } 103 | // NOTE: we allow multiple methods to alias to the same "operation" 104 | // while OpenAPI does not directly, so this looks a little weird 105 | route.methods.forEach( 106 | (method) => (item[method.toLowerCase()] = operation) 107 | ); 108 | acc[route.path] = item; 109 | return acc; 110 | }, {} as PathsObject); 111 | // add the paths back into the OpenAPI object 112 | Object.entries(paths).forEach(([pathName, path]) => 113 | openAPI.addPath(pathName, path) 114 | ); 115 | return openAPI; 116 | } 117 | 118 | /** 119 | * Make an OpenAPI schema from a class's metadata 120 | * 121 | * @see https://swagger.io/docs/specification/data-models/data-types 122 | */ 123 | 124 | static makeSchemaObject(tgt: Class): SchemaObject { 125 | const metadata = resolveMetadata(getMetadata(tgt)); 126 | const schema: SchemaObject = { 127 | properties: {}, 128 | required: [], 129 | type: 'object' 130 | }; 131 | return OpenAPI.makeSchemaObjectImpl(metadata, schema); 132 | } 133 | 134 | private static makeSchemaObjectImpl( 135 | metadata: Metadata[], 136 | schema: SchemaObject 137 | ): SchemaObject { 138 | metadata.forEach((metadatum) => { 139 | // this is the normal case - we keep coded properties to a minimum 140 | const subschema: SchemaObject = { 141 | type: OpenAPI.typeFor(metadatum) as any 142 | }; 143 | // for arrays 144 | if (metadatum.isArray) { 145 | subschema.items = { type: OpenAPI.typeFor(metadatum) as any }; 146 | subschema.type = 'array'; 147 | // for arrays of objects 148 | if (metadatum.metadata.length > 0) { 149 | subschema.items.properties = {}; 150 | subschema.items.required = []; 151 | subschema.items.type = 'object'; 152 | OpenAPI.makeSchemaObjectImpl(metadatum.metadata, subschema.items); 153 | } 154 | } 155 | // for objects 156 | else if (metadatum.metadata.length > 0) { 157 | subschema.properties = {}; 158 | subschema.required = []; 159 | subschema.type = 'object'; 160 | OpenAPI.makeSchemaObjectImpl(metadatum.metadata, subschema); 161 | } 162 | schema.properties[metadatum.name] = subschema; 163 | if (metadatum.opts.required) schema.required.push(metadatum.name); 164 | }); 165 | return schema; 166 | } 167 | 168 | private static typeFor(metadata: Metadata): string { 169 | if (metadata.type === 'Number') 170 | return metadata.opts.float ? 'number' : 'integer'; 171 | else return metadata.type.toLowerCase(); 172 | } 173 | 174 | handle(message$: Observable): Observable { 175 | return message$.pipe( 176 | tap(({ context, response }) => { 177 | const flattened = context.router.flatten(); 178 | response.body = OpenAPI.fromRoutes(context.info, flattened).getSpec(); 179 | }) 180 | ); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/handlers/redirect-to.test.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../interfaces'; 2 | import { RedirectTo } from './redirect-to'; 3 | 4 | import { of } from 'rxjs'; 5 | 6 | describe('RedirectTo unit tests', () => { 7 | test('sets statusCode and headers', (done) => { 8 | const redirectTo = new RedirectTo(); 9 | const message: Message = { 10 | request: { 11 | path: '/foo/bar', 12 | method: 'GET', 13 | route: { path: 'x', redirectTo: 'y' } 14 | }, 15 | response: { headers: {} } 16 | }; 17 | redirectTo.handle(of(message)).subscribe(({ response }) => { 18 | expect(response.headers['Location']).toEqual('y'); 19 | expect(response.statusCode).toEqual(301); 20 | done(); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/handlers/redirect-to.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from '../handler'; 2 | import { Message } from '../interfaces'; 3 | 4 | import { Injectable } from 'injection-js'; 5 | import { Observable } from 'rxjs'; 6 | 7 | import { tap } from 'rxjs/operators'; 8 | 9 | /** 10 | * Redirect handler 11 | */ 12 | 13 | @Injectable() 14 | export class RedirectTo extends Handler { 15 | handle(message$: Observable): Observable { 16 | return message$.pipe( 17 | tap(({ request, response }) => { 18 | response.headers['Location'] = request.route.redirectTo; 19 | response.statusCode = request.route.redirectAs || 301; 20 | }) 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/handlers/statuscode-200.test.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../interfaces'; 2 | import { StatusCode200 } from './statuscode-200'; 3 | 4 | import { of } from 'rxjs'; 5 | 6 | describe('StatusCode200 unit tests', () => { 7 | test('sets statusCode', (done) => { 8 | const statusCode200 = new StatusCode200(); 9 | const message: Message = { 10 | request: { path: '/foo/bar', method: 'GET' }, 11 | response: {} 12 | }; 13 | statusCode200.handle(of(message)).subscribe(({ response }) => { 14 | expect(response.statusCode).toEqual(200); 15 | done(); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/handlers/statuscode-200.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from '../handler'; 2 | import { Message } from '../interfaces'; 3 | 4 | import { Injectable } from 'injection-js'; 5 | import { Observable } from 'rxjs'; 6 | 7 | import { tap } from 'rxjs/operators'; 8 | 9 | /** 10 | * StatusCode 200 handler 11 | */ 12 | 13 | @Injectable() 14 | export class StatusCode200 extends Handler { 15 | handle(message$: Observable): Observable { 16 | return message$.pipe( 17 | tap(({ response }) => { 18 | response.statusCode = 200; 19 | }) 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/http/app.test.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from '../handler'; 2 | import { HttpApp } from './app'; 3 | import { Message } from '../interfaces'; 4 | import { Middleware } from '../middleware'; 5 | import { Route } from '../interfaces'; 6 | 7 | import { IncomingMessage } from 'http'; 8 | import { Injectable } from 'injection-js'; 9 | import { Observable } from 'rxjs'; 10 | import { OutgoingMessage } from 'http'; 11 | 12 | import { createServer } from 'http'; 13 | import { tap } from 'rxjs/operators'; 14 | 15 | import axios from 'axios'; 16 | 17 | @Injectable() 18 | class Hello extends Handler { 19 | handle(message$: Observable): Observable { 20 | return message$.pipe( 21 | tap(({ response }) => { 22 | response.body = 'Hello, http!'; 23 | }) 24 | ); 25 | } 26 | } 27 | 28 | @Injectable() 29 | class Goodbye extends Handler { 30 | handle(message$: Observable): Observable { 31 | return message$.pipe( 32 | tap(({ request, response }) => { 33 | response.body = `Goodbye, ${request.body.name || 'http'}!`; 34 | }) 35 | ); 36 | } 37 | } 38 | 39 | @Injectable() 40 | class CORS extends Middleware { 41 | prehandle(message$: Observable): Observable { 42 | return message$.pipe( 43 | tap(({ response }) => { 44 | // NOTE: just the minimum CORS necessary for test case 45 | response.headers['Access-Control-Allow-Origin'] = '*'; 46 | }) 47 | ); 48 | } 49 | } 50 | 51 | @Injectable() 52 | class Middleware1 extends Middleware { 53 | prehandle(message$: Observable): Observable { 54 | return message$.pipe( 55 | tap(({ response }) => { 56 | response.headers['X-this'] = 'that'; 57 | }) 58 | ); 59 | } 60 | } 61 | 62 | @Injectable() 63 | class Middleware2 extends Middleware { 64 | prehandle(message$: Observable): Observable { 65 | return message$.pipe( 66 | tap(({ response }) => { 67 | response.headers['X-that'] = 'this'; 68 | }) 69 | ); 70 | } 71 | } 72 | 73 | const routes: Route[] = [ 74 | { 75 | path: '', 76 | middlewares: [CORS], 77 | children: [ 78 | { 79 | methods: ['GET'], 80 | path: '/foo/bar', 81 | handler: Hello, 82 | middlewares: [Middleware1, Middleware2] 83 | }, 84 | 85 | { 86 | methods: ['PUT'], 87 | path: '/foo/bar', 88 | handler: Goodbye, 89 | middlewares: [Middleware1] 90 | }, 91 | 92 | { 93 | path: '/fizz', 94 | children: [ 95 | { 96 | path: '/bazz' 97 | }, 98 | 99 | { 100 | path: '/buzz' 101 | } 102 | ] 103 | } 104 | ] 105 | } 106 | ]; 107 | 108 | // @see https://angularfirebase.com/snippets/testing-rxjs-observables-with-jest/ 109 | 110 | describe('HttpApp unit tests', () => { 111 | test('smoke test #1', (done) => { 112 | const app = new HttpApp(routes); 113 | const listener = app.listen(); 114 | app['response$'].subscribe((response) => { 115 | expect(response.body).toEqual('"Hello, http!"'); 116 | expect(response.headers['X-this']).toEqual('that'); 117 | expect(response.headers['X-that']).toEqual('this'); 118 | // NOTE: testing case-insensitivity of headers 119 | expect(response.headers['X-THIS']).toEqual('that'); 120 | expect(response.headers['X-THAT']).toEqual('this'); 121 | expect(response.statusCode).toEqual(200); 122 | done(); 123 | }); 124 | listener( 125 | { method: 'GET', url: '/foo/bar' } as IncomingMessage, 126 | {} as OutgoingMessage 127 | ); 128 | }); 129 | 130 | test('smoke test #2', (done) => { 131 | const app = new HttpApp(routes); 132 | const listener = app.listen(); 133 | app['response$'].subscribe((response) => { 134 | expect(response.body).toEqual('"Goodbye, http!"'); 135 | expect(response.headers['X-this']).toEqual('that'); 136 | expect(response.headers['X-that']).toBeUndefined(); 137 | expect(response.statusCode).toEqual(200); 138 | done(); 139 | }); 140 | listener( 141 | { method: 'PUT', url: '/foo/bar' } as IncomingMessage, 142 | {} as OutgoingMessage 143 | ); 144 | }); 145 | 146 | test('smoke test #3', (done) => { 147 | const app = new HttpApp(routes); 148 | const listener = app.listen(); 149 | app['response$'].subscribe((response) => { 150 | expect(response.statusCode).toEqual(404); 151 | done(); 152 | }); 153 | listener( 154 | { method: 'PUT', url: '/xxx' } as IncomingMessage, 155 | {} as OutgoingMessage 156 | ); 157 | }); 158 | 159 | test('local 200/404 and body parse', async () => { 160 | const app = new HttpApp(routes); 161 | const listener = app.listen(); 162 | const server = createServer(listener).listen(8080); 163 | let response = await axios.request({ 164 | url: 'http://localhost:8080/foo/bar', 165 | method: 'GET' 166 | }); 167 | expect(response.data).toEqual('Hello, http!'); 168 | expect(response.status).toEqual(200); 169 | response = await axios.request({ 170 | url: 'http://localhost:8080/fizz/bazz', 171 | method: 'GET' 172 | }); 173 | expect(response.status).toEqual(200); 174 | response = await axios.request({ 175 | url: 'http://localhost:8080/foo/bar', 176 | method: 'PUT', 177 | data: { name: 'Marco' }, 178 | // eslint-disable-next-line @typescript-eslint/naming-convention 179 | headers: { 'Content-Type': 'application/json' } 180 | }); 181 | expect(response.data).toEqual('Goodbye, Marco!'); 182 | try { 183 | await axios.get('http://localhost:8080/xxx'); 184 | } catch (error) { 185 | expect(error.response.status).toEqual(404); 186 | } 187 | server.close(); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/http/app.ts: -------------------------------------------------------------------------------- 1 | import { App } from '../app'; 2 | import { BodyParser } from '../middlewares/body-parser'; 3 | import { Message } from '../interfaces'; 4 | import { Method } from '../interfaces'; 5 | import { Normalizer } from '../middlewares/normalizer'; 6 | import { Response } from '../interfaces'; 7 | import { Route } from '../interfaces'; 8 | 9 | import { caseInsensitiveObject } from '../utils'; 10 | import { fromReadableStream } from '../utils'; 11 | 12 | import * as url from 'url'; 13 | 14 | import { IncomingMessage } from 'http'; 15 | import { InfoObject } from 'openapi3-ts'; 16 | import { Observable } from 'rxjs'; 17 | import { OutgoingMessage } from 'http'; 18 | import { ServerResponse } from 'http'; 19 | import { Subject } from 'rxjs'; 20 | import { Subscription } from 'rxjs'; 21 | import { URLSearchParams } from 'url'; 22 | 23 | import { map } from 'rxjs/operators'; 24 | import { mergeMap } from 'rxjs/operators'; 25 | import { of } from 'rxjs'; 26 | import { tap } from 'rxjs/operators'; 27 | 28 | // NOTE: this middleware is required 29 | const MIDDLEWARES = [BodyParser, Normalizer]; 30 | 31 | /** 32 | * Http application 33 | */ 34 | 35 | export class HttpApp extends App { 36 | private message$ = new Subject(); 37 | private response$ = new Subject(); 38 | private subToMessages: Subscription; 39 | 40 | /** ctor */ 41 | constructor(routes: Route[], info: InfoObject = null) { 42 | super(routes, MIDDLEWARES, info); 43 | } 44 | 45 | /** Create a listener */ 46 | listen(): (req: IncomingMessage, res: OutgoingMessage) => void { 47 | this.startListening(); 48 | return (req: IncomingMessage, res: OutgoingMessage): void => { 49 | // synthesize Message from Http req/res 50 | const parsed = url.parse(req.url); 51 | const message: Message = { 52 | context: { 53 | // eslint-disable-next-line @typescript-eslint/naming-convention 54 | _internal: { res }, 55 | info: this.info, 56 | router: this.router 57 | }, 58 | request: { 59 | body: {}, 60 | headers: caseInsensitiveObject(req.headers || {}), 61 | httpVersion: req.httpVersion, 62 | method: req.method, 63 | params: {}, 64 | path: this.normalizePath(parsed.pathname), 65 | query: new URLSearchParams(parsed.search), 66 | remoteAddr: req.connection ? req.connection.remoteAddress : null, 67 | route: null, 68 | stream$: req.on ? fromReadableStream(req) : null, 69 | timestamp: Date.now() 70 | }, 71 | response: { 72 | body: null, 73 | headers: caseInsensitiveObject({}), 74 | statusCode: null 75 | } 76 | }; 77 | this.message$.next(message); 78 | }; 79 | } 80 | 81 | /** Create a listener */ 82 | unlisten(): void { 83 | if (this.subToMessages) this.subToMessages.unsubscribe(); 84 | } 85 | 86 | // private methods 87 | 88 | private handleResponse(res: ServerResponse, response: Response): void { 89 | if (res.end) { 90 | res.writeHead(response.statusCode, response.headers); 91 | res.end(response.body); 92 | } 93 | // NOTE: back-door for tests 94 | else this.response$.next(response); 95 | } 96 | 97 | private startListening(): void { 98 | this.subToMessages = this.message$ 99 | .pipe( 100 | // route the message 101 | map((message: Message): Message => this.router.route(message)), 102 | mergeMap((message: Message): Observable => { 103 | return of(message) 104 | .pipe(this.makePipeline(message)) 105 | .pipe( 106 | tap(({ context, response }) => 107 | this.handleResponse(context._internal.res, response) 108 | ) 109 | ); 110 | }) 111 | ) 112 | .subscribe(); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './aws-lambda'; 2 | export * from './gcf'; 3 | export * from './handler'; 4 | export * from './handlers'; 5 | export * from './http'; 6 | export * from './metadata'; 7 | export * from './middleware'; 8 | export * from './middlewares'; 9 | export * from './router'; 10 | export * from './interfaces'; 11 | export * from './service'; 12 | export * from './services'; 13 | export * from './utils'; 14 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Attr } from './metadata'; 2 | import { Handler } from './handler'; 3 | import { Middleware } from './middleware'; 4 | import { Router } from './router'; 5 | 6 | import { IncomingHttpHeaders } from 'http'; 7 | import { InfoObject } from 'openapi3-ts'; 8 | import { Observable } from 'rxjs'; 9 | import { OutgoingHttpHeaders } from 'http'; 10 | import { Provider } from 'injection-js'; 11 | import { ReflectiveInjector } from 'injection-js'; 12 | import { URLSearchParams } from 'url'; 13 | 14 | /** 15 | * @see https://stackoverflow.com/questions/39392853 16 | */ 17 | 18 | export interface Class { 19 | new (...args: any[]): T; 20 | } 21 | 22 | /** 23 | * Content metadata 24 | */ 25 | 26 | export interface ContentMetadata { 27 | [contentType: string]: Class; 28 | } 29 | 30 | /** 31 | * Application context 32 | */ 33 | 34 | export interface Context { 35 | // eslint-disable-next-line @typescript-eslint/naming-convention 36 | _internal?: any; 37 | info: InfoObject; 38 | router: Router; 39 | } 40 | 41 | /** 42 | * Exception definition 43 | */ 44 | 45 | export class Exception { 46 | constructor(public exception: T) {} 47 | } 48 | 49 | /** 50 | * KV pairs eg: request headers 51 | */ 52 | 53 | export interface Map { 54 | [k: string]: T; 55 | } 56 | 57 | /** 58 | * Unified message definition 59 | */ 60 | 61 | export interface Message { 62 | context?: Context; 63 | request?: TRequest; 64 | response?: TResponse; 65 | } 66 | 67 | /** 68 | * Metadata definition 69 | */ 70 | 71 | export interface Metadata { 72 | // eslint-disable-next-line @typescript-eslint/naming-convention 73 | _class: Class; 74 | isArray: boolean; 75 | metadata: Metadata[]; 76 | name: string; 77 | opts?: MetadataOpts; 78 | type: string; 79 | } 80 | 81 | export interface MetadataOpts { 82 | // NOTE: _class is only necessary because TypeScript's design:type tells us 83 | // that a field is an array, but not of what type -- when it can we'll deprecate 84 | // @see https://github.com/Microsoft/TypeScript/issues/7169 85 | // eslint-disable-next-line @typescript-eslint/naming-convention 86 | _class?: Class; 87 | // NOTE: float: false (the default) indicates that a Number is really an integer 88 | float?: boolean; 89 | required?: boolean; 90 | } 91 | 92 | /** 93 | * Method definition 94 | */ 95 | 96 | export type Method = 97 | | 'CONNECT' 98 | | 'DELETE' 99 | | 'GET' 100 | | 'HEAD' 101 | | 'OPTIONS' 102 | | 'PATCH' 103 | | 'POST' 104 | | 'PUT' 105 | | 'TRACE'; 106 | 107 | export const ALL_METHODS: Method[] = [ 108 | 'CONNECT', 109 | 'DELETE', 110 | 'GET', 111 | 'HEAD', 112 | 'OPTIONS', 113 | 'PATCH', 114 | 'POST', 115 | 'PUT', 116 | 'TRACE' 117 | ]; 118 | 119 | /** 120 | * Request definition 121 | */ 122 | 123 | export interface Request< 124 | TBody = any, 125 | THeaders = IncomingHttpHeaders, 126 | TParams = Map, 127 | TQuery = URLSearchParams 128 | > { 129 | body?: TBody; 130 | headers?: THeaders; 131 | httpVersion?: string; 132 | method: Method; 133 | params?: TParams; 134 | path: string; 135 | query?: TQuery; 136 | remoteAddr?: string; 137 | route?: Route; 138 | stream$?: Observable; 139 | timestamp?: number; 140 | } 141 | 142 | /** 143 | * Request metadata 144 | */ 145 | 146 | export interface RequestMetadata { 147 | body?: ContentMetadata; 148 | header?: Class; 149 | path?: Class; 150 | query?: Class; 151 | } 152 | 153 | /** 154 | * Response definition 155 | */ 156 | 157 | export interface Response { 158 | body?: TBody; 159 | headers?: THeaders; 160 | isBase64Encoded?: boolean; 161 | statusCode?: number; 162 | } 163 | 164 | /** 165 | * Response metadata 166 | */ 167 | 168 | export interface ResponseMetadata { 169 | [statusCode: string]: ContentMetadata; 170 | } 171 | 172 | /** 173 | * 500 response 174 | */ 175 | 176 | export class Response500 { 177 | @Attr() error: string; 178 | @Attr() stack: string; 179 | } 180 | 181 | /** 182 | * Route definition 183 | */ 184 | 185 | export interface Route { 186 | children?: Route[]; 187 | data?: any; 188 | description?: string; 189 | handler?: Class; 190 | injector?: ReflectiveInjector; 191 | methods?: Method[]; 192 | middlewares?: Class[]; 193 | parent?: Route; 194 | path: string; 195 | pathMatch?: 'full' | 'prefix'; 196 | phantom?: boolean; 197 | redirectAs?: number; 198 | redirectTo?: string; 199 | request?: RequestMetadata; 200 | responses?: ResponseMetadata; 201 | services?: Provider[]; 202 | summary?: string; 203 | } 204 | -------------------------------------------------------------------------------- /src/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { Attr } from './metadata'; 2 | import { Metadata } from './interfaces'; 3 | 4 | import { getMetadata } from './metadata'; 5 | import { resolveMetadata } from './metadata'; 6 | 7 | describe('Decorator unit tests', () => { 8 | test('primitive types', () => { 9 | class Y { 10 | @Attr() a: number; 11 | @Attr() b: string; 12 | @Attr() c: boolean; 13 | } 14 | const metadata: Metadata[] = getMetadata(Y); 15 | expect(metadata.length).toEqual(3); 16 | expect(metadata[0].name).toEqual('a'); 17 | expect(metadata[0].type).toEqual('Number'); 18 | expect(metadata[1].name).toEqual('b'); 19 | expect(metadata[1].type).toEqual('String'); 20 | expect(metadata[2].name).toEqual('c'); 21 | expect(metadata[2].type).toEqual('Boolean'); 22 | }); 23 | 24 | test('object types', () => { 25 | class X { 26 | @Attr() t: number; 27 | } 28 | class Y { 29 | @Attr() b: X; 30 | @Attr() c: string; 31 | } 32 | class Z { 33 | @Attr() a: Y; 34 | } 35 | let metadata: Metadata[] = getMetadata(Z); 36 | resolveMetadata(metadata); 37 | expect(metadata.length).toEqual(1); 38 | expect(metadata[0].name).toEqual('a'); 39 | expect(metadata[0].type).toEqual('Y'); 40 | metadata = metadata[0].metadata; 41 | expect(metadata.length).toEqual(2); 42 | expect(metadata[0].name).toEqual('b'); 43 | expect(metadata[0].type).toEqual('X'); 44 | expect(metadata[1].name).toEqual('c'); 45 | expect(metadata[1].type).toEqual('String'); 46 | metadata = metadata[0].metadata; 47 | expect(metadata.length).toEqual(1); 48 | expect(metadata[0].name).toEqual('t'); 49 | expect(metadata[0].type).toEqual('Number'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | import { Class } from './interfaces'; 2 | import { Metadata } from './interfaces'; 3 | import { MetadataOpts } from './interfaces'; 4 | 5 | import 'reflect-metadata'; 6 | 7 | /** 8 | * Decorators for OpenAPI annotation 9 | * 10 | * @see https://blog.wizardsoftheweb.pro/typescript-decorators-property-decorators/ 11 | */ 12 | 13 | const METADATA = Symbol('METADATA'); 14 | 15 | const DEFAULT_OPTS: MetadataOpts = { 16 | float: false, 17 | required: false 18 | }; 19 | 20 | /** 21 | * Define an attribute (parameter, body etc) 22 | */ 23 | 24 | // eslint-disable-next-line @typescript-eslint/naming-convention 25 | export function Attr(opts: MetadataOpts = DEFAULT_OPTS): any { 26 | return function (tgt: any, name: string): void { 27 | // grab the metadata to date for this class 28 | const attrs: Metadata[] = 29 | Reflect.getMetadata(METADATA, tgt.constructor) || []; 30 | // what type does TypeScript say this property is? 31 | // eslint-disable-next-line @typescript-eslint/naming-convention 32 | let _class = Reflect.getMetadata('design:type', tgt, name); 33 | let type = _class.name; 34 | // NOTE: _class is only necessary because TypeScript's design:type tells us 35 | // that a field is an array, but not of what type -- when it can we'll deprecate 36 | // @see https://github.com/Microsoft/TypeScript/issues/7169 37 | const isArray = type === 'Array'; 38 | if (opts._class) { 39 | _class = opts._class; 40 | type = _class.name; 41 | } 42 | // update the record of metadata by class 43 | // eslint-disable-next-line @typescript-eslint/naming-convention 44 | attrs.push({ _class, isArray, metadata: [], name, type, opts }); 45 | Reflect.defineMetadata(METADATA, attrs, tgt.constructor); 46 | }; 47 | } 48 | 49 | /** 50 | * Get all metadata for a class 51 | */ 52 | 53 | export function getMetadata(tgt: Class): Metadata[] { 54 | return [...Reflect.getMetadata(METADATA, tgt)]; 55 | } 56 | 57 | /** 58 | * Recursively resolve metdata within metadata 59 | */ 60 | 61 | export function resolveMetadata(metadata: Metadata[]): Metadata[] { 62 | metadata.forEach((metadatum) => { 63 | // NOTE: see above -- we won't see Array if opts._class is provided 64 | if (!['Array', 'Boolean', 'Number', 'String'].includes(metadatum.type)) 65 | metadatum.metadata = resolveMetadata(getMetadata(metadatum._class)); 66 | }); 67 | return metadata; 68 | } 69 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './interfaces'; 2 | import { Route } from './interfaces'; 3 | 4 | import { Observable } from 'rxjs'; 5 | 6 | /** 7 | * Middleware definition 8 | */ 9 | 10 | export type MiddlewareMethod = 'prehandle' | 'posthandle' | 'postcatch'; 11 | 12 | export class Middleware { 13 | /** Instantiate a set of Middlewares from a Route */ 14 | static makeInstances(route: Route): Middleware[] { 15 | return (route.middlewares || []).map( 16 | (middleware) => route.injector.get(middleware) 17 | ); 18 | } 19 | 20 | /** Process a message AFTER catcher */ 21 | postcatch(message$: Observable): Observable { 22 | return message$; 23 | } 24 | 25 | /** Process a message AFTER handler */ 26 | posthandle(message$: Observable): Observable { 27 | return message$; 28 | } 29 | 30 | /** Process a message BEFORE handler */ 31 | prehandle(message$: Observable): Observable { 32 | return message$; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/middlewares/binary-typer.test.ts: -------------------------------------------------------------------------------- 1 | import { BinaryTyper } from './binary-typer'; 2 | import { Message } from '../interfaces'; 3 | 4 | import { of } from 'rxjs'; 5 | 6 | describe('BinaryTyper unit tests', () => { 7 | test('response with Content-Encoding is always binary', (done) => { 8 | const binaryTyper = new BinaryTyper(null); 9 | const message: Message = { 10 | request: { path: '/', method: 'GET' }, 11 | response: { 12 | body: Buffer.from('a'), 13 | // eslint-disable-next-line @typescript-eslint/naming-convention 14 | headers: { 'Content-Encoding': 'gzip' } 15 | } 16 | }; 17 | binaryTyper.postcatch(of(message)).subscribe(({ response }) => { 18 | expect(response.isBase64Encoded).toBeTruthy(); 19 | done(); 20 | }); 21 | }); 22 | 23 | test('default binary types match any Content-Type', (done) => { 24 | const binaryTyper = new BinaryTyper(null); 25 | const message: Message = { 26 | request: { path: '/', method: 'GET' }, 27 | response: { 28 | body: Buffer.from('a'), 29 | // eslint-disable-next-line @typescript-eslint/naming-convention 30 | headers: { 'Content-Type': 'text/html' } 31 | } 32 | }; 33 | binaryTyper.postcatch(of(message)).subscribe(({ response }) => { 34 | expect(response.isBase64Encoded).toBeTruthy(); 35 | done(); 36 | }); 37 | }); 38 | 39 | test('wildcard binary types match Content-Type', (done) => { 40 | const binaryTyper = new BinaryTyper([ 41 | 'application/*', 42 | 'this/that', 43 | '*/binary' 44 | ]); 45 | const message: Message = { 46 | request: { path: '/', method: 'GET' }, 47 | response: { 48 | body: Buffer.from('a'), 49 | // eslint-disable-next-line @typescript-eslint/naming-convention 50 | headers: { 'Content-Type': 'text/binary' } 51 | } 52 | }; 53 | binaryTyper.postcatch(of(message)).subscribe(({ response }) => { 54 | expect(response.isBase64Encoded).toBeTruthy(); 55 | done(); 56 | }); 57 | }); 58 | 59 | test('explicit binary types match Content-Type', (done) => { 60 | const binaryTyper = new BinaryTyper([ 61 | 'application/*', 62 | 'this/that', 63 | '*/binary' 64 | ]); 65 | const message: Message = { 66 | request: { path: '/', method: 'GET' }, 67 | response: { 68 | body: Buffer.from('a'), 69 | // eslint-disable-next-line @typescript-eslint/naming-convention 70 | headers: { 'Content-Type': 'this/that' } 71 | } 72 | }; 73 | binaryTyper.postcatch(of(message)).subscribe(({ response }) => { 74 | expect(response.isBase64Encoded).toBeTruthy(); 75 | done(); 76 | }); 77 | }); 78 | 79 | test('no binary types match Content-Type is never binary', (done) => { 80 | const binaryTyper = new BinaryTyper([ 81 | 'application/*', 82 | 'this/that', 83 | '*/binary' 84 | ]); 85 | const message: Message = { 86 | request: { path: '/', method: 'GET' }, 87 | response: { 88 | body: Buffer.from('a'), 89 | // eslint-disable-next-line @typescript-eslint/naming-convention 90 | headers: { 'Content-Type': 'text/that' } 91 | } 92 | }; 93 | binaryTyper.postcatch(of(message)).subscribe(({ response }) => { 94 | expect(response.isBase64Encoded).toBeFalsy(); 95 | done(); 96 | }); 97 | }); 98 | 99 | test('empty binary types is never binary', (done) => { 100 | const binaryTyper = new BinaryTyper([]); 101 | const message: Message = { 102 | request: { path: '/', method: 'GET' }, 103 | response: { 104 | body: Buffer.from('a'), 105 | // eslint-disable-next-line @typescript-eslint/naming-convention 106 | headers: { 'Content-Type': 'text/that' } 107 | } 108 | }; 109 | binaryTyper.postcatch(of(message)).subscribe(({ response }) => { 110 | expect(response.isBase64Encoded).toBeFalsy(); 111 | done(); 112 | }); 113 | }); 114 | 115 | test('response body not-a-Buffer is analyzed as if a buffer', (done) => { 116 | const binaryTyper = new BinaryTyper(null); 117 | const message: Message = { 118 | request: { path: '/', method: 'GET' }, 119 | // eslint-disable-next-line @typescript-eslint/naming-convention 120 | response: { body: 'a', headers: { 'Content-Encoding': 'gzip' } } 121 | }; 122 | binaryTyper.postcatch(of(message)).subscribe(({ response }) => { 123 | expect(response.body instanceof Buffer).toBeTruthy(); 124 | expect(response.isBase64Encoded).toBeTruthy(); 125 | done(); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/middlewares/binary-typer.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../interfaces'; 2 | import { Middleware } from '../middleware'; 3 | import { Response } from '../interfaces'; 4 | 5 | import { Inject } from 'injection-js'; 6 | import { Injectable } from 'injection-js'; 7 | import { InjectionToken } from 'injection-js'; 8 | import { Observable } from 'rxjs'; 9 | import { Optional } from 'injection-js'; 10 | 11 | import { defaultIfEmpty } from 'rxjs/operators'; 12 | import { filter } from 'rxjs/operators'; 13 | import { mergeMap } from 'rxjs/operators'; 14 | import { of } from 'rxjs'; 15 | import { tap } from 'rxjs/operators'; 16 | 17 | /** 18 | * Binary types 19 | */ 20 | 21 | export const BINARY_TYPES = new InjectionToken('BINARY_TYPES'); 22 | 23 | export const BINARY_TYPES_DEFAULT: string[] = ['*/*']; 24 | 25 | /** 26 | * Binary typer for AWS Lambda 27 | * 28 | * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html 29 | */ 30 | 31 | @Injectable() 32 | export class BinaryTyper extends Middleware { 33 | private binaryTypes: string[]; 34 | 35 | constructor(@Optional() @Inject(BINARY_TYPES) binaryTypes: string[]) { 36 | super(); 37 | this.binaryTypes = binaryTypes || BINARY_TYPES_DEFAULT; 38 | } 39 | 40 | postcatch(message$: Observable): Observable { 41 | return message$.pipe( 42 | mergeMap((message: Message): Observable => { 43 | return of(message).pipe( 44 | filter(({ response }) => !!response.body), 45 | tap(({ response }) => (response.body = Buffer.from(response.body))), 46 | filter(({ response }) => this.isBinaryType(response)), 47 | tap(({ response }) => (response.isBase64Encoded = true)), 48 | defaultIfEmpty(message) 49 | ); 50 | }) 51 | ); 52 | } 53 | 54 | // private methods 55 | 56 | private isBinaryType(response: Response): boolean { 57 | if (response.headers['Content-Encoding']) return true; 58 | else if (response.headers['Content-Type']) { 59 | const contentType = response.headers['Content-Type']; 60 | const parts = contentType.split('/'); 61 | return this.binaryTypes.some((binaryType) => { 62 | const matches = binaryType.split('/'); 63 | return ( 64 | parts.length === matches.length && 65 | parts.length === 2 && 66 | (parts[0] === matches[0] || matches[0] === '*') && 67 | (parts[1] === matches[1] || matches[1] === '*') 68 | ); 69 | }); 70 | } else return false; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/middlewares/body-parser.test.ts: -------------------------------------------------------------------------------- 1 | import { BodyParser } from './body-parser'; 2 | import { Message } from '../interfaces'; 3 | 4 | import { fromReadableStream } from '../utils'; 5 | 6 | import { of } from 'rxjs'; 7 | 8 | import str = require('string-to-stream'); 9 | 10 | describe('BodyParser unit tests', () => { 11 | test('parses JSON', (done) => { 12 | const bodyParser = new BodyParser(null); 13 | const body = JSON.stringify({ x: 'y' }); 14 | const message: Message = { 15 | request: { 16 | path: '/foo/bar', 17 | method: 'POST', 18 | headers: { 'content-type': 'application/json' }, 19 | stream$: fromReadableStream(str(body)) 20 | } 21 | }; 22 | bodyParser.prehandle(of(message)).subscribe(({ request }) => { 23 | expect(request.body.x).toEqual('y'); 24 | done(); 25 | }); 26 | }); 27 | 28 | test('parses form encoded', (done) => { 29 | const bodyParser = new BodyParser(null); 30 | const body = encodeURIComponent('a=b&x=y'); 31 | const message: Message = { 32 | request: { 33 | path: '/foo/bar', 34 | method: 'POST', 35 | headers: { 'content-type': 'x-www-form-urlencoded' }, 36 | stream$: fromReadableStream(str(body)) 37 | } 38 | }; 39 | bodyParser.prehandle(of(message)).subscribe(({ request }) => { 40 | expect(request.body.a).toEqual('b'); 41 | expect(request.body.x).toEqual('y'); 42 | done(); 43 | }); 44 | }); 45 | 46 | test('nothing to parse', (done) => { 47 | const bodyParser = new BodyParser(null); 48 | const message: Message = { 49 | request: { 50 | path: '/foo/bar', 51 | method: 'POST', 52 | headers: { 'content-type': 'x-www-form-urlencoded' }, 53 | stream$: null 54 | } 55 | }; 56 | bodyParser.prehandle(of(message)).subscribe(({ request }) => { 57 | expect(request.body).toBeUndefined(); 58 | done(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/middlewares/body-parser.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from '../interfaces'; 2 | import { Message } from '../interfaces'; 3 | import { Method } from '../interfaces'; 4 | import { Middleware } from '../middleware'; 5 | 6 | import { Inject } from 'injection-js'; 7 | import { Injectable } from 'injection-js'; 8 | import { InjectionToken } from 'injection-js'; 9 | import { Observable } from 'rxjs'; 10 | import { Optional } from 'injection-js'; 11 | 12 | import { catchError } from 'rxjs/operators'; 13 | import { defaultIfEmpty } from 'rxjs/operators'; 14 | import { filter } from 'rxjs/operators'; 15 | import { map } from 'rxjs/operators'; 16 | import { mapTo } from 'rxjs/operators'; 17 | import { mergeMap } from 'rxjs/operators'; 18 | import { of } from 'rxjs'; 19 | import { tap } from 'rxjs/operators'; 20 | import { throwError } from 'rxjs'; 21 | import { toArray } from 'rxjs/operators'; 22 | 23 | /** 24 | * Body parser options 25 | */ 26 | 27 | export interface BodyParserOpts { 28 | methods?: Method[]; 29 | } 30 | 31 | export const BODY_PARSER_OPTS = new InjectionToken( 32 | 'BODY_PARSER_OPTS' 33 | ); 34 | 35 | export const BODY_PARSER_DEFAULT_OPTS: BodyParserOpts = { 36 | methods: ['POST', 'PUT', 'PATCH'] 37 | }; 38 | 39 | /** 40 | * Body parser 41 | * 42 | * @see https://github.com/marblejs/marble/blob/master/packages/middleware-body/src/index.ts 43 | */ 44 | 45 | @Injectable() 46 | export class BodyParser extends Middleware { 47 | private opts: BodyParserOpts; 48 | 49 | constructor(@Optional() @Inject(BODY_PARSER_OPTS) opts: BodyParserOpts) { 50 | super(); 51 | this.opts = opts 52 | ? { ...BODY_PARSER_DEFAULT_OPTS, ...opts } 53 | : BODY_PARSER_DEFAULT_OPTS; 54 | } 55 | 56 | prehandle(message$: Observable): Observable { 57 | return message$.pipe( 58 | mergeMap((message: Message): Observable => { 59 | return of(message).pipe( 60 | filter( 61 | ({ request }) => 62 | !!request.stream$ && 63 | (!this.opts.methods || this.opts.methods.includes(request.method)) 64 | ), 65 | // read the stream into a string, then into encoded form 66 | mergeMap(({ request }): Observable => { 67 | return request.stream$.pipe( 68 | toArray(), 69 | map((chunks: any[]): Buffer => Buffer.concat(chunks)), 70 | map((buffer: Buffer): string => buffer.toString()), 71 | map((data: string): any => { 72 | switch (request.headers['content-type']) { 73 | case 'application/json': 74 | return JSON.parse(data); 75 | case 'x-www-form-urlencoded': 76 | return decodeURIComponent(data) 77 | .split('&') 78 | .map((kv) => kv.split('=')) 79 | .reduce((acc, [k, v]) => { 80 | acc[k] = isNaN(+v) ? v : +v; 81 | return acc; 82 | }, {}); 83 | default: 84 | return data; 85 | } 86 | }) 87 | ); 88 | }), 89 | // map the encoded body object back to the original message 90 | tap((body: any) => (message.request.body = body)), 91 | mapTo(message), 92 | catchError(() => throwError(new Exception({ statusCode: 400 }))), 93 | defaultIfEmpty(message) 94 | ); 95 | }) 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/middlewares/compressor.test.ts: -------------------------------------------------------------------------------- 1 | import { Compressor } from './compressor'; 2 | import { COMPRESSOR_OPTS } from './compressor'; 3 | import { Handler } from '../handler'; 4 | import { HttpApp } from '../http/app'; 5 | import { Message } from '../interfaces'; 6 | import { Route } from '../interfaces'; 7 | 8 | import * as zlib from 'zlib'; 9 | 10 | import { Injectable } from 'injection-js'; 11 | import { Observable } from 'rxjs'; 12 | 13 | import { tap } from 'rxjs/operators'; 14 | 15 | @Injectable() 16 | class Hello extends Handler { 17 | handle(message$: Observable): Observable { 18 | return message$.pipe( 19 | tap(({ response }) => { 20 | response.body = 'Hello, http!'; 21 | }) 22 | ); 23 | } 24 | } 25 | 26 | const routes: Route[] = [ 27 | { 28 | path: '', 29 | middlewares: [Compressor], 30 | services: [{ provide: COMPRESSOR_OPTS, useValue: { threshold: 0 } }], 31 | children: [ 32 | { 33 | methods: ['GET'], 34 | path: '/foo/bar', 35 | handler: Hello 36 | } 37 | ] 38 | } 39 | ]; 40 | 41 | describe('Compressor unit tests', () => { 42 | test('performs gzip compression', (done) => { 43 | const app = new HttpApp(routes); 44 | const listener = app.listen(); 45 | app['response$'].subscribe((response) => { 46 | expect(response.headers['Content-Encoding']).toEqual('gzip'); 47 | expect(zlib.unzipSync(response.body).toString()).toEqual( 48 | '"Hello, http!"' 49 | ); 50 | expect(response.statusCode).toEqual(200); 51 | done(); 52 | }); 53 | listener( 54 | { 55 | method: 'GET', 56 | url: '/foo/bar', 57 | // eslint-disable-next-line @typescript-eslint/naming-convention 58 | headers: { 'Accept-Encoding': 'gzip, deflate' } 59 | } as any, 60 | {} as any 61 | ); 62 | }); 63 | 64 | test('performs deflate compression', (done) => { 65 | const app = new HttpApp(routes); 66 | const listener = app.listen(); 67 | app['response$'].subscribe((response) => { 68 | expect(response.headers['Content-Encoding']).toEqual('deflate'); 69 | expect(zlib.inflateSync(response.body).toString()).toEqual( 70 | '"Hello, http!"' 71 | ); 72 | expect(response.statusCode).toEqual(200); 73 | done(); 74 | }); 75 | listener( 76 | { 77 | method: 'GET', 78 | url: '/foo/bar', 79 | // eslint-disable-next-line @typescript-eslint/naming-convention 80 | headers: { 'Accept-Encoding': 'deflate' } 81 | } as any, 82 | {} as any 83 | ); 84 | }); 85 | 86 | test('performs no compression', (done) => { 87 | const app = new HttpApp(routes); 88 | const listener = app.listen(); 89 | app['response$'].subscribe((response) => { 90 | expect(response.headers['Content-Encoding']).toBeUndefined(); 91 | expect(response.body).toEqual('"Hello, http!"'); 92 | expect(response.statusCode).toEqual(200); 93 | done(); 94 | }); 95 | listener({ method: 'GET', url: '/foo/bar', headers: {} } as any, {} as any); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/middlewares/compressor.ts: -------------------------------------------------------------------------------- 1 | import { ALL_METHODS } from '../interfaces'; 2 | import { Exception } from '../interfaces'; 3 | import { Message } from '../interfaces'; 4 | import { Method } from '../interfaces'; 5 | import { Middleware } from '../middleware'; 6 | 7 | import { vary } from '../ported/vary'; 8 | 9 | import * as zlib from 'zlib'; 10 | 11 | import { Inject } from 'injection-js'; 12 | import { Injectable } from 'injection-js'; 13 | import { InjectionToken } from 'injection-js'; 14 | import { Observable } from 'rxjs'; 15 | import { Optional } from 'injection-js'; 16 | 17 | import { catchError } from 'rxjs/operators'; 18 | import { defaultIfEmpty } from 'rxjs/operators'; 19 | import { filter } from 'rxjs/operators'; 20 | import { map } from 'rxjs/operators'; 21 | import { mapTo } from 'rxjs/operators'; 22 | import { mergeMap } from 'rxjs/operators'; 23 | import { of } from 'rxjs'; 24 | import { tap } from 'rxjs/operators'; 25 | import { throwError } from 'rxjs'; 26 | 27 | /** 28 | * Compressor options 29 | * 30 | * @see https://nodejs.org/api/zlib.html#zlib_class_options 31 | * @see http://zlib.net/manual.html#Constants 32 | */ 33 | 34 | /* eslint-disable @typescript-eslint/naming-convention */ 35 | 36 | export enum CompressorLevel { 37 | NO_COMPRESSION = 0, 38 | BEST_SPEED = 1, 39 | BEST_COMPRESSION = 2, 40 | DEFAULT_COMPRESSION = -1 41 | } 42 | 43 | export enum CompressorStrategy { 44 | FILTERED = 1, 45 | HUFFMAN_ONLY = 2, 46 | RLE = 3, 47 | FIXED = 4, 48 | DEFAULT_STRATEGY = 0 49 | } 50 | 51 | /* eslint-enable @typescript-eslint/naming-convention */ 52 | 53 | export interface CompressorOpts { 54 | level?: CompressorLevel; 55 | methods?: Method[]; 56 | strategy?: CompressorStrategy; 57 | threshold?: number; 58 | } 59 | 60 | export const COMPRESSOR_OPTS = new InjectionToken( 61 | 'COMPRESSOR_OPTS' 62 | ); 63 | 64 | export const COMPRESSOR_DEFAULT_OPTS: CompressorOpts = { 65 | level: CompressorLevel.DEFAULT_COMPRESSION, 66 | methods: ALL_METHODS, 67 | strategy: CompressorStrategy.DEFAULT_STRATEGY, 68 | threshold: 1024 69 | }; 70 | 71 | /** 72 | * Request logger 73 | */ 74 | 75 | @Injectable() 76 | export class Compressor extends Middleware { 77 | private opts: CompressorOpts; 78 | 79 | constructor(@Optional() @Inject(COMPRESSOR_OPTS) opts: CompressorOpts) { 80 | super(); 81 | this.opts = opts 82 | ? { ...COMPRESSOR_DEFAULT_OPTS, ...opts } 83 | : COMPRESSOR_DEFAULT_OPTS; 84 | } 85 | 86 | posthandle(message$: Observable): Observable { 87 | return message$.pipe( 88 | mergeMap((message: Message): Observable => { 89 | const { request, response } = message; 90 | return of(message).pipe( 91 | tap(({ response }) => vary(response, 'Accept-Encoding')), 92 | map((_message: Message) => { 93 | const accepts = ( 94 | String(request.headers['Accept-Encoding']) || '' 95 | ).split(/, /); 96 | const willDeflate = accepts.includes('deflate'); 97 | const willGZIP = accepts.includes('gzip'); 98 | return { willDeflate, willGZIP }; 99 | }), 100 | filter(({ willDeflate, willGZIP }) => { 101 | const alreadyEncoded = !!request.headers['Content-Encoding']; 102 | const size = Number(response.headers['Content-Length'] || '0'); 103 | return ( 104 | (!this.opts.methods || 105 | this.opts.methods.includes(request.method)) && 106 | response.body && 107 | !alreadyEncoded && 108 | (willDeflate || willGZIP) && 109 | size >= this.opts.threshold 110 | ); 111 | }), 112 | tap(({ willGZIP }) => { 113 | // NOTE: prefer gzip to deflate 114 | const type = willGZIP ? 'gzip' : 'deflate'; 115 | response.body = zlib[`${type}Sync`](response.body, this.opts); 116 | response.headers['Content-Encoding'] = type; 117 | response.headers['Content-Length'] = Buffer.byteLength( 118 | response.body 119 | ); 120 | }), 121 | mapTo(message), 122 | catchError(() => throwError(new Exception({ statusCode: 400 }))), 123 | defaultIfEmpty(message) 124 | ); 125 | }) 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/middlewares/cors.test.ts: -------------------------------------------------------------------------------- 1 | import { CORS } from './cors'; 2 | import { Message } from '../interfaces'; 3 | 4 | import { caseInsensitiveObject } from '../utils'; 5 | 6 | import { of } from 'rxjs'; 7 | 8 | describe('CORS unit tests', () => { 9 | test('smoke test', (done) => { 10 | const cors = new CORS(null); 11 | const message: Message = { 12 | request: { path: '', method: 'GET', headers: caseInsensitiveObject({}) }, 13 | response: { headers: caseInsensitiveObject({}) } 14 | }; 15 | cors.prehandle(of(message)).subscribe(({ response }) => { 16 | expect(response.headers['Access-Control-Allow-Origin']).toEqual('*'); 17 | done(); 18 | }); 19 | }); 20 | 21 | test('test preflightContinue', (done) => { 22 | const cors = new CORS({ origin: 'o', preflightContinue: false }); 23 | const message: Message = { 24 | request: { 25 | path: '', 26 | method: 'OPTIONS', 27 | headers: caseInsensitiveObject({}) 28 | }, 29 | response: { headers: caseInsensitiveObject({}) } 30 | }; 31 | cors.prehandle(of(message)).subscribe({ 32 | next: () => {}, 33 | error: ({ exception }) => { 34 | expect(exception.headers['Access-Control-Allow-Origin']).toEqual('o'); 35 | expect(exception.headers['Content-Length']).toEqual('0'); 36 | expect(exception.headers['Vary']).toEqual( 37 | 'Origin, Access-Control-Request-Headers' 38 | ); 39 | expect(exception.statusCode).toEqual(204); 40 | done(); 41 | } 42 | }); 43 | }); 44 | 45 | test('test origin', (done) => { 46 | const cors = new CORS({ origin: 'o', preflightContinue: true }); 47 | const message: Message = { 48 | request: { path: '', method: 'GET', headers: caseInsensitiveObject({}) }, 49 | response: { headers: caseInsensitiveObject({}) } 50 | }; 51 | cors.prehandle(of(message)).subscribe(({ response }) => { 52 | expect(response.headers['Access-Control-Allow-Origin']).toEqual('o'); 53 | expect(response.headers['Vary']).toEqual('Origin'); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/middlewares/cors.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from '../interfaces'; 2 | import { Message } from '../interfaces'; 3 | import { Method } from '../interfaces'; 4 | import { Middleware } from '../middleware'; 5 | 6 | import { cors } from '../ported/cors'; 7 | 8 | import { Inject } from 'injection-js'; 9 | import { Injectable } from 'injection-js'; 10 | import { InjectionToken } from 'injection-js'; 11 | import { Observable } from 'rxjs'; 12 | import { Optional } from 'injection-js'; 13 | 14 | import { iif } from 'rxjs'; 15 | import { mergeMap } from 'rxjs/operators'; 16 | import { of } from 'rxjs'; 17 | import { tap } from 'rxjs/operators'; 18 | import { throwError } from 'rxjs'; 19 | 20 | /** 21 | * CORS options 22 | */ 23 | 24 | export interface CORSOpts { 25 | allowedHeaders?: string[]; 26 | credentials?: boolean; 27 | exposedHeaders?: string[]; 28 | headers?: string[]; 29 | maxAge?: number; 30 | methods?: Method[]; 31 | optionsSuccessStatus?: number; 32 | origin?: string; 33 | preflightContinue?: boolean; 34 | } 35 | 36 | export const CORS_OPTS = new InjectionToken('CORS_OPTS'); 37 | 38 | export const CORS_DEFAULT_OPTS: CORSOpts = { 39 | methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], 40 | preflightContinue: true, 41 | optionsSuccessStatus: 204, 42 | origin: '' 43 | }; 44 | 45 | /** 46 | * CORS 47 | * 48 | * @see https://expressjs.com/en/resources/middleware/cors.html 49 | */ 50 | 51 | @Injectable() 52 | export class CORS extends Middleware { 53 | private opts: CORSOpts; 54 | 55 | constructor(@Optional() @Inject(CORS_OPTS) opts: CORSOpts) { 56 | super(); 57 | this.opts = opts ? { ...CORS_DEFAULT_OPTS, ...opts } : CORS_DEFAULT_OPTS; 58 | } 59 | 60 | prehandle(message$: Observable): Observable { 61 | const next = (): void => {}; 62 | return message$.pipe( 63 | tap(({ request, response }) => cors(this.opts, request, response, next)), 64 | mergeMap((message: Message): Observable => { 65 | const { request, response } = message; 66 | return iif( 67 | () => !this.opts.preflightContinue && request.method === 'OPTIONS', 68 | throwError(new Exception(response)), 69 | of(message) 70 | ); 71 | }) 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './binary-typer'; 2 | export * from './body-parser'; 3 | export * from './compressor'; 4 | export * from './cors'; 5 | export * from './normalizer'; 6 | export * from './request-logger'; 7 | export * from './timer'; 8 | -------------------------------------------------------------------------------- /src/middlewares/normalizer.test.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../interfaces'; 2 | import { Normalizer } from './normalizer'; 3 | 4 | import { of } from 'rxjs'; 5 | 6 | describe('Normalizer unit tests', () => { 7 | test('sets statusCode 200', (done) => { 8 | const normalizer = new Normalizer(); 9 | const message: Message = { 10 | request: { path: '/foo/bar', method: 'GET' }, 11 | response: { body: 'hello', headers: {} } 12 | }; 13 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 14 | expect(response.statusCode).toEqual(200); 15 | done(); 16 | }); 17 | }); 18 | 19 | test('sets statusCode 204', (done) => { 20 | const normalizer = new Normalizer(); 21 | const message: Message = { 22 | request: { path: '/foo/bar', method: 'GET' }, 23 | response: { headers: {} } 24 | }; 25 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 26 | expect(response.statusCode).toEqual(204); 27 | done(); 28 | }); 29 | }); 30 | 31 | test('leaves statusCode alone', (done) => { 32 | const normalizer = new Normalizer(); 33 | const message: Message = { 34 | request: { path: '/foo/bar', method: 'GET' }, 35 | response: { headers: {}, statusCode: 500 } 36 | }; 37 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 38 | expect(response.statusCode).toEqual(500); 39 | done(); 40 | }); 41 | }); 42 | 43 | test('deduces Content-Type from path', (done) => { 44 | const html = '

Hellow, world!

'; 45 | const normalizer = new Normalizer(); 46 | const message: Message = { 47 | request: { path: '/foo/bar.html', method: 'GET' }, 48 | response: { body: Buffer.from(html), headers: {} } 49 | }; 50 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 51 | expect(response.headers['Content-Type']).toEqual('text/html'); 52 | done(); 53 | }); 54 | }); 55 | 56 | test('deduces Content-Type correctly for SVG files', (done) => { 57 | const normalizer = new Normalizer(); 58 | const svg = 59 | ''; 60 | const message: Message = { 61 | request: { path: '/foo/bar.svg', method: 'GET' }, 62 | response: { body: Buffer.from(svg), headers: {} } 63 | }; 64 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 65 | expect(response.headers['Content-Type']).toEqual('image/svg+xml'); 66 | done(); 67 | }); 68 | }); 69 | 70 | test('sets default Content-Type to JSON', (done) => { 71 | const normalizer = new Normalizer(); 72 | const message: Message = { 73 | request: { path: '/foo/bar', method: 'GET' }, 74 | response: { body: { x: 'y' }, headers: {} } 75 | }; 76 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 77 | expect(response.headers['Content-Type']).toEqual('application/json'); 78 | expect(response.body).toEqual(JSON.stringify({ x: 'y' })); 79 | done(); 80 | }); 81 | }); 82 | 83 | test('sets default Content-Length', (done) => { 84 | const normalizer = new Normalizer(); 85 | const message: Message = { 86 | request: { path: '/foo/bar', method: 'GET' }, 87 | response: { body: 'xyz', headers: {} } 88 | }; 89 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 90 | expect(response.headers['Content-Length']).toEqual(5); 91 | done(); 92 | }); 93 | }); 94 | 95 | test('sets default Cache-Control', (done) => { 96 | const normalizer = new Normalizer(); 97 | const message: Message = { 98 | request: { path: '/foo/bar', method: 'GET' }, 99 | response: { body: 'xyz', headers: {} } 100 | }; 101 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 102 | expect(response.headers['Cache-Control']).toEqual( 103 | 'no-cache, no-store, must-revalidate' 104 | ); 105 | done(); 106 | }); 107 | }); 108 | 109 | test('leaves Cache-Control alone if already set', (done) => { 110 | const normalizer = new Normalizer(); 111 | const message: Message = { 112 | request: { path: '/foo/bar', method: 'GET' }, 113 | // eslint-disable-next-line @typescript-eslint/naming-convention 114 | response: { body: 'xyz', headers: { 'Cache-Control': 'public' } } 115 | }; 116 | normalizer.posthandle(of(message)).subscribe(({ response }) => { 117 | expect(response.headers['Cache-Control']).toEqual('public'); 118 | done(); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/middlewares/normalizer.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../interfaces'; 2 | import { Middleware } from '../middleware'; 3 | 4 | import * as mime from 'mime'; 5 | import * as yaml from 'js-yaml'; 6 | 7 | import { Injectable } from 'injection-js'; 8 | import { Observable } from 'rxjs'; 9 | 10 | import { map } from 'rxjs/operators'; 11 | import { mapTo } from 'rxjs/operators'; 12 | import { mergeMap } from 'rxjs/operators'; 13 | import { of } from 'rxjs'; 14 | import { tap } from 'rxjs/operators'; 15 | 16 | import fileType from 'file-type'; 17 | 18 | /** 19 | * Response normalizer 20 | * 21 | * NOTE: internal use only 22 | */ 23 | 24 | @Injectable() 25 | export class Normalizer extends Middleware { 26 | posthandle(message$: Observable): Observable { 27 | return message$.pipe( 28 | mergeMap((message: Message): Observable => { 29 | return of(message).pipe( 30 | map(({ request, response }) => ({ 31 | body: response.body, 32 | headers: response.headers, 33 | path: request.path, 34 | statusCode: response.statusCode 35 | })), 36 | map(({ body, headers, path, statusCode }) => ({ 37 | body, 38 | headers, 39 | path, 40 | statusCode: statusCode || (body ? 200 : 204) 41 | })), 42 | tap(({ headers }) => { 43 | if (!headers['Cache-Control']) 44 | headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'; 45 | }), 46 | tap(({ body, headers, path }) => { 47 | if (!headers['Content-Type']) { 48 | const fromBuffer = body instanceof Buffer && fileType(body); 49 | let contentType = fromBuffer 50 | ? fromBuffer.mime 51 | : mime.getType(path); 52 | // 👇 fileType incorrectly identifies SVG as XML 53 | if (path.endsWith('.svg')) contentType = 'image/svg+xml'; 54 | // NOTE: JSON is the default 55 | headers['Content-Type'] = contentType || 'application/json'; 56 | } 57 | }), 58 | map(({ body, headers, statusCode }) => { 59 | if (body && !(body instanceof Buffer)) { 60 | switch (headers['Content-Type']) { 61 | case 'application/json': 62 | body = JSON.stringify(body); 63 | break; 64 | case 'text/yaml': 65 | body = yaml.dump(body); 66 | break; 67 | } 68 | } 69 | return { body, headers, statusCode }; 70 | }), 71 | tap(({ body, headers, statusCode }) => { 72 | // NOTE: Safari (and potentially other browsers) need content-length 0, 73 | // for 204 or they just hang waiting for a body 74 | if (!body || statusCode === 204) headers['Content-Length'] = '0'; 75 | else if (body) headers['Content-Length'] = Buffer.byteLength(body); 76 | }), 77 | tap( 78 | ({ body, headers, statusCode }) => 79 | (message.response = { body, headers, statusCode }) 80 | ), 81 | mapTo(message) 82 | ); 83 | }) 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/middlewares/request-logger.test.ts: -------------------------------------------------------------------------------- 1 | import { LogProvider } from '../services/log-provider'; 2 | import { Request } from '../interfaces'; 3 | import { RequestLogger } from './request-logger'; 4 | import { RequestLoggerOpts } from './request-logger'; 5 | import { Response } from '../interfaces'; 6 | 7 | import { of } from 'rxjs'; 8 | 9 | const request: Request = { 10 | path: '/foo/bar', 11 | method: 'GET', 12 | remoteAddr: '::1', 13 | httpVersion: '1.2', 14 | timestamp: Date.now() 15 | }; 16 | 17 | const response: Response = { 18 | // eslint-disable-next-line @typescript-eslint/naming-convention 19 | headers: { 'Content-Length': '20' }, 20 | statusCode: 301 21 | }; 22 | 23 | const error: Response = { 24 | body: JSON.stringify({ 25 | error: 'xxx', 26 | stack: 'y' 27 | }), 28 | // eslint-disable-next-line @typescript-eslint/naming-convention 29 | headers: { 'Content-Length': '20' }, 30 | statusCode: 500 31 | }; 32 | 33 | describe('RequestLogger unit tests', () => { 34 | test('logs errors to console', (done) => { 35 | const log: LogProvider = { 36 | canColorize: () => false, 37 | log: (_stuff) => {}, 38 | info: (_stuff) => {}, 39 | warn: (_stuff) => {}, 40 | error: (logLine) => { 41 | expect(logLine).toContain('xxx'); 42 | done(); 43 | } 44 | }; 45 | const opts: RequestLoggerOpts = { colorize: false, format: 'common' }; 46 | const logger = new RequestLogger(log, opts); 47 | logger.postcatch(of({ request, response: error })).subscribe(); 48 | }); 49 | 50 | test('silent mode logs nothing', (done) => { 51 | const log: LogProvider = { 52 | canColorize: () => false, 53 | log: (_stuff) => {}, 54 | info: (_stuff) => {}, 55 | warn: (_stuff) => {}, 56 | error: (_logLine) => {} 57 | }; 58 | jest.spyOn(log, 'info'); 59 | const opts: RequestLoggerOpts = { silent: true }; 60 | const logger = new RequestLogger(log, opts); 61 | logger.postcatch(of({ request, response })).subscribe(() => { 62 | // eslint-disable-next-line @typescript-eslint/unbound-method 63 | expect(log.info).not.toBeCalled(); 64 | done(); 65 | }); 66 | }); 67 | 68 | test('"common" format correctly logs messages', (done) => { 69 | const log: LogProvider = { 70 | canColorize: () => false, 71 | log: (_stuff) => {}, 72 | info: (logLine) => { 73 | expect(logLine).toMatch( 74 | /^::1 - - \[.*\] "GET \/foo\/bar HTTP\/1.2" 301 20$/ 75 | ); 76 | done(); 77 | }, 78 | warn: (_stuff) => {}, 79 | error: (_stuff) => {} 80 | }; 81 | const opts: RequestLoggerOpts = { colorize: false, format: 'common' }; 82 | const logger = new RequestLogger(log, opts); 83 | logger.postcatch(of({ request, response })).subscribe(); 84 | }); 85 | 86 | test('"dev" format correctly logs messages', (done) => { 87 | const log: LogProvider = { 88 | canColorize: () => false, 89 | log: (_stuff) => {}, 90 | info: (logLine) => { 91 | expect(logLine).toMatch(/^GET \/foo\/bar 301 [0-9]+ms - 20$/); 92 | done(); 93 | }, 94 | warn: (_stuff) => {}, 95 | error: (_stuff) => {} 96 | }; 97 | const opts: RequestLoggerOpts = { colorize: false, format: 'dev' }; 98 | const logger = new RequestLogger(log, opts); 99 | logger.postcatch(of({ request, response })).subscribe(); 100 | }); 101 | 102 | test('"short" format correctly logs messages', (done) => { 103 | const log: LogProvider = { 104 | canColorize: () => false, 105 | log: (_stuff) => {}, 106 | info: (logLine) => { 107 | expect(logLine).toMatch( 108 | /^::1 - GET \/foo\/bar HTTP\/1.2 301 20 - [0-9]+ms$/ 109 | ); 110 | done(); 111 | }, 112 | warn: (_stuff) => {}, 113 | error: (_stuff) => {} 114 | }; 115 | const opts: RequestLoggerOpts = { colorize: false, format: 'short' }; 116 | const logger = new RequestLogger(log, opts); 117 | logger.postcatch(of({ request, response })).subscribe(); 118 | }); 119 | 120 | test('"tiny" format correctly logs messages', (done) => { 121 | const log: LogProvider = { 122 | canColorize: () => false, 123 | log: (_stuff) => {}, 124 | info: (logLine) => { 125 | expect(logLine).toMatch(/^GET \/foo\/bar 301 20 - [0-9]+ms$/); 126 | done(); 127 | }, 128 | warn: (_stuff) => {}, 129 | error: (_stuff) => {} 130 | }; 131 | const opts: RequestLoggerOpts = { colorize: false, format: 'tiny' }; 132 | const logger = new RequestLogger(log, opts); 133 | logger.postcatch(of({ request, response })).subscribe(); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/middlewares/request-logger.ts: -------------------------------------------------------------------------------- 1 | import { LogProvider } from '../services/log-provider'; 2 | import { Message } from '../interfaces'; 3 | import { Middleware } from '../middleware'; 4 | 5 | import { Inject } from 'injection-js'; 6 | import { Injectable } from 'injection-js'; 7 | import { InjectionToken } from 'injection-js'; 8 | import { Observable } from 'rxjs'; 9 | import { Optional } from 'injection-js'; 10 | 11 | import { defaultIfEmpty } from 'rxjs/operators'; 12 | import { filter } from 'rxjs/operators'; 13 | import { mergeMap } from 'rxjs/operators'; 14 | import { of } from 'rxjs'; 15 | import { tap } from 'rxjs/operators'; 16 | 17 | import chalk from 'chalk'; 18 | 19 | /** 20 | * Request logger options 21 | */ 22 | 23 | export interface RequestLoggerOpts { 24 | colorize?: boolean; 25 | // @see https://github.com/expressjs/morgan 26 | format?: 'common' | 'dev' | 'short' | 'tiny'; 27 | silent?: boolean; 28 | } 29 | 30 | export const REQUEST_LOGGER_OPTS = new InjectionToken( 31 | 'REQUEST_LOGGER_OPTS' 32 | ); 33 | 34 | export const REQUEST_LOGGER_DEFAULT_OPTS: RequestLoggerOpts = { 35 | colorize: true, 36 | format: 'common', 37 | silent: false 38 | }; 39 | 40 | /** 41 | * Request logger 42 | */ 43 | 44 | @Injectable() 45 | export class RequestLogger extends Middleware { 46 | private opts: RequestLoggerOpts; 47 | 48 | constructor( 49 | private log: LogProvider, 50 | @Optional() @Inject(REQUEST_LOGGER_OPTS) opts: RequestLoggerOpts 51 | ) { 52 | super(); 53 | this.opts = opts 54 | ? { ...REQUEST_LOGGER_DEFAULT_OPTS, ...opts } 55 | : REQUEST_LOGGER_DEFAULT_OPTS; 56 | } 57 | 58 | postcatch(message$: Observable): Observable { 59 | return message$.pipe( 60 | mergeMap((message: Message): Observable => { 61 | return of(message).pipe( 62 | filter((_message: Message) => !this.opts.silent), 63 | tap(({ response }) => { 64 | if (response.statusCode === 500) this.logError(message); 65 | this.logMessage(message); 66 | }), 67 | defaultIfEmpty(message) 68 | ); 69 | }) 70 | ); 71 | } 72 | 73 | // private methods 74 | 75 | private get(str: any, color: string = null): string { 76 | str = str ? String(str) : '-'; 77 | return this.log.canColorize() && this.opts.colorize && color 78 | ? chalk[color](str) 79 | : str; 80 | } 81 | 82 | private logError(message: Message): void { 83 | try { 84 | // NOTE: we have to reparse because the error got generated after the Normalizer 85 | const body = JSON.parse(message.response.body); 86 | this.log.error(this.get(body.error, 'red')); 87 | this.log.error(body.stack); 88 | } catch (ignored) {} 89 | } 90 | 91 | private logMessage(message: Message): void { 92 | const { request, response } = message; 93 | // response time 94 | const ms = `${Date.now() - request.timestamp}ms`; 95 | // status code color 96 | let color = 'green'; 97 | if (response.statusCode >= 500) color = 'red'; 98 | else if (response.statusCode >= 400) color = 'yellow'; 99 | else if (response.statusCode >= 300) color = 'cyan'; 100 | // develop parts of message 101 | let parts = []; 102 | switch (this.opts.format) { 103 | // @see https://github.com/expressjs/morgan 104 | case 'dev': 105 | parts = [ 106 | this.get(request.method), 107 | this.get(request.path), 108 | this.get(response.statusCode, color), 109 | ms, 110 | '-', 111 | this.get(response.headers['Content-Length']) 112 | ]; 113 | break; 114 | // @see https://github.com/expressjs/morgan 115 | case 'short': 116 | parts = [ 117 | this.get(request.remoteAddr), 118 | '-', 119 | this.get(request.method), 120 | this.get(request.path), 121 | this.get(`HTTP/${request.httpVersion}`), 122 | this.get(response.statusCode, color), 123 | this.get(response.headers['Content-Length']), 124 | '-', 125 | ms 126 | ]; 127 | break; 128 | // @see https://github.com/expressjs/morgan 129 | case 'tiny': 130 | parts = [ 131 | this.get(request.method), 132 | this.get(request.path), 133 | this.get(response.statusCode, color), 134 | this.get(response.headers['Content-Length']), 135 | '-', 136 | ms 137 | ]; 138 | break; 139 | // @see https://github.com/expressjs/morgan 140 | default: 141 | parts = [ 142 | this.get(request.remoteAddr), 143 | '-', 144 | '-', 145 | `[${new Date(request.timestamp).toISOString()}]`, 146 | this.get(`"${request.method}`), 147 | this.get(request.path), 148 | this.get(`HTTP/${request.httpVersion}"`), 149 | this.get(response.statusCode, color), 150 | this.get(response.headers['Content-Length']) 151 | ]; 152 | break; 153 | } 154 | // now all we have to do is print it! 155 | this.log.info(parts.join(' ')); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/middlewares/timer.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpApp } from '../http/app'; 2 | import { Route } from '../interfaces'; 3 | import { Timer } from './timer'; 4 | 5 | import { IncomingMessage } from 'http'; 6 | import { OutgoingMessage } from 'http'; 7 | 8 | const routes: Route[] = [ 9 | { 10 | path: '', 11 | middlewares: [Timer], 12 | children: [ 13 | { 14 | methods: ['GET'], 15 | path: '/foo/bar' 16 | } 17 | ] 18 | } 19 | ]; 20 | 21 | describe('Timer unit tests', () => { 22 | test('sets appropriate headers', (done) => { 23 | const app = new HttpApp(routes); 24 | const listener = app.listen(); 25 | const now = Date.now(); 26 | app['response$'].subscribe((response) => { 27 | expect( 28 | Number(response.headers['X-Request-Timein']) 29 | ).toBeGreaterThanOrEqual(now); 30 | expect( 31 | Number(response.headers['X-Request-Timeout']) 32 | ).toBeGreaterThanOrEqual(now); 33 | expect( 34 | Number(response.headers['X-Response-Time']) 35 | ).toBeGreaterThanOrEqual(0); 36 | expect(response.statusCode).toEqual(200); 37 | done(); 38 | }); 39 | listener( 40 | { method: 'GET', url: '/foo/bar' } as IncomingMessage, 41 | {} as OutgoingMessage 42 | ); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/middlewares/timer.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../interfaces'; 2 | import { Middleware } from '../middleware'; 3 | 4 | import { Injectable } from 'injection-js'; 5 | import { Observable } from 'rxjs'; 6 | 7 | import { tap } from 'rxjs/operators'; 8 | 9 | /** 10 | * Response timer 11 | * 12 | * X-Request-Timein 13 | * X-Request-Timeout 14 | * X-Response-Time 15 | */ 16 | 17 | @Injectable() 18 | export class Timer extends Middleware { 19 | posthandle(message$: Observable): Observable { 20 | return message$.pipe( 21 | tap(({ response }) => { 22 | response.headers['X-Request-Timeout'] = Date.now(); 23 | const timein = Number(response.headers['X-Request-Timein']); 24 | const timeout = Number(response.headers['X-Request-Timeout']); 25 | response.headers['X-Response-Time'] = String(timeout - timein); 26 | }) 27 | ); 28 | } 29 | 30 | prehandle(message$: Observable): Observable { 31 | return message$.pipe( 32 | tap(({ response }) => { 33 | response.headers['X-Request-Timein'] = Date.now(); 34 | }) 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ported/cors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | 'use strict'; 4 | 5 | // var assign = require('object-assign'); 6 | // var vary = require('vary'); 7 | 8 | import { vary } from './vary'; 9 | 10 | // var defaults = { 11 | // origin: '*', 12 | // methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', 13 | // preflightContinue: false, 14 | // optionsSuccessStatus: 204 15 | // }; 16 | 17 | function isString(s) { 18 | return typeof s === 'string' || s instanceof String; 19 | } 20 | 21 | function isOriginAllowed(origin, allowedOrigin) { 22 | if (Array.isArray(allowedOrigin)) { 23 | for (var i = 0; i < allowedOrigin.length; ++i) { 24 | if (isOriginAllowed(origin, allowedOrigin[i])) { 25 | return true; 26 | } 27 | } 28 | return false; 29 | } else if (isString(allowedOrigin)) { 30 | return origin === allowedOrigin; 31 | } else if (allowedOrigin instanceof RegExp) { 32 | return allowedOrigin.test(origin); 33 | } else { 34 | return !!allowedOrigin; 35 | } 36 | } 37 | 38 | function configureOrigin(options, req) { 39 | var requestOrigin = req.headers.origin, 40 | headers = [], 41 | isAllowed; 42 | 43 | if (!options.origin || options.origin === '*') { 44 | // allow any origin 45 | headers.push([ 46 | { 47 | key: 'Access-Control-Allow-Origin', 48 | value: '*' 49 | } 50 | ]); 51 | } else if (isString(options.origin)) { 52 | // fixed origin 53 | headers.push([ 54 | { 55 | key: 'Access-Control-Allow-Origin', 56 | value: options.origin 57 | } 58 | ]); 59 | headers.push([ 60 | { 61 | key: 'Vary', 62 | value: 'Origin' 63 | } 64 | ]); 65 | } else { 66 | isAllowed = isOriginAllowed(requestOrigin, options.origin); 67 | // reflect origin 68 | headers.push([ 69 | { 70 | key: 'Access-Control-Allow-Origin', 71 | value: isAllowed ? requestOrigin : false 72 | } 73 | ]); 74 | headers.push([ 75 | { 76 | key: 'Vary', 77 | value: 'Origin' 78 | } 79 | ]); 80 | } 81 | 82 | return headers; 83 | } 84 | 85 | function configureMethods(options, req) { 86 | var methods = options.methods; 87 | if (methods.join) { 88 | methods = options.methods.join(','); // .methods is an array, so turn it into a string 89 | } 90 | return { 91 | key: 'Access-Control-Allow-Methods', 92 | value: methods 93 | }; 94 | } 95 | 96 | function configureCredentials(options, req) { 97 | if (options.credentials === true) { 98 | return { 99 | key: 'Access-Control-Allow-Credentials', 100 | value: 'true' 101 | }; 102 | } 103 | return null; 104 | } 105 | 106 | function configureAllowedHeaders(options, req) { 107 | var allowedHeaders = options.allowedHeaders || options.headers; 108 | var headers = []; 109 | 110 | if (!allowedHeaders) { 111 | allowedHeaders = req.headers['access-control-request-headers']; // .headers wasn't specified, so reflect the request headers 112 | headers.push([ 113 | { 114 | key: 'Vary', 115 | value: 'Access-Control-Request-Headers' 116 | } 117 | ]); 118 | } else if (allowedHeaders.join) { 119 | allowedHeaders = allowedHeaders.join(','); // .headers is an array, so turn it into a string 120 | } 121 | if (allowedHeaders && allowedHeaders.length) { 122 | headers.push([ 123 | { 124 | key: 'Access-Control-Allow-Headers', 125 | value: allowedHeaders 126 | } 127 | ]); 128 | } 129 | 130 | return headers; 131 | } 132 | 133 | function configureExposedHeaders(options, req) { 134 | var headers = options.exposedHeaders; 135 | if (!headers) { 136 | return null; 137 | } else if (headers.join) { 138 | headers = headers.join(','); // .headers is an array, so turn it into a string 139 | } 140 | if (headers && headers.length) { 141 | return { 142 | key: 'Access-Control-Expose-Headers', 143 | value: headers 144 | }; 145 | } 146 | return null; 147 | } 148 | 149 | function configureMaxAge(options, req) { 150 | var maxAge = 151 | (typeof options.maxAge === 'number' || options.maxAge) && 152 | options.maxAge.toString(); 153 | if (maxAge && maxAge.length) { 154 | return { 155 | key: 'Access-Control-Max-Age', 156 | value: maxAge 157 | }; 158 | } 159 | return null; 160 | } 161 | 162 | function applyHeaders(headers, res) { 163 | for (var i = 0, n = headers.length; i < n; i++) { 164 | var header = headers[i]; 165 | if (header) { 166 | if (Array.isArray(header)) { 167 | applyHeaders(header, res); 168 | } else if (header.key === 'Vary' && header.value) { 169 | vary(res, header.value); 170 | } else if (header.value) { 171 | res.headers[header.key] = header.value; 172 | } 173 | } 174 | } 175 | } 176 | 177 | export function cors(options, req, res, next) { 178 | var headers = [], 179 | method = req.method && req.method.toUpperCase && req.method.toUpperCase(); 180 | 181 | if (method === 'OPTIONS') { 182 | // preflight 183 | headers.push(configureOrigin(options, req)); 184 | headers.push(configureCredentials(options, req)); 185 | headers.push(configureMethods(options, req)); 186 | headers.push(configureAllowedHeaders(options, req)); 187 | headers.push(configureMaxAge(options, req)); 188 | headers.push(configureExposedHeaders(options, req)); 189 | applyHeaders(headers, res); 190 | 191 | if (options.preflightContinue) { 192 | next(); 193 | } else { 194 | // Safari (and potentially other browsers) need content-length 0, 195 | // for 204 or they just hang waiting for a body 196 | res.statusCode = options.optionsSuccessStatus; 197 | res.headers['Content-Length'] = '0'; 198 | // res.end(); 199 | } 200 | } else { 201 | // actual response 202 | headers.push(configureOrigin(options, req)); 203 | headers.push(configureCredentials(options, req)); 204 | headers.push(configureExposedHeaders(options, req)); 205 | applyHeaders(headers, res); 206 | next(); 207 | } 208 | } 209 | 210 | // function middlewareWrapper(o) { 211 | // // if options are static (either via defaults or custom options passed in), wrap in a function 212 | // var optionsCallback = null; 213 | // if (typeof o === 'function') { 214 | // optionsCallback = o; 215 | // } else { 216 | // optionsCallback = function (req, cb) { 217 | // cb(null, o); 218 | // }; 219 | // } 220 | 221 | // return function corsMiddleware(req, res, next) { 222 | // optionsCallback(req, function (err, options) { 223 | // if (err) { 224 | // next(err); 225 | // } else { 226 | // var corsOptions = assign({}, defaults, options); 227 | // var originCallback = null; 228 | // if (corsOptions.origin && typeof corsOptions.origin === 'function') { 229 | // originCallback = corsOptions.origin; 230 | // } else if (corsOptions.origin) { 231 | // originCallback = function (origin, cb) { 232 | // cb(null, corsOptions.origin); 233 | // }; 234 | // } 235 | 236 | // if (originCallback) { 237 | // originCallback(req.headers.origin, function (err2, origin) { 238 | // if (err2 || !origin) { 239 | // next(err2); 240 | // } else { 241 | // corsOptions.origin = origin; 242 | // cors(corsOptions, req, res, next); 243 | // } 244 | // }); 245 | // } else { 246 | // next(); 247 | // } 248 | // } 249 | // }); 250 | // }; 251 | // } 252 | 253 | // // can pass either an options hash, an options delegate, or nothing 254 | // module.exports = middlewareWrapper; 255 | 256 | // }()); 257 | -------------------------------------------------------------------------------- /src/ported/vary.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | 'use strict'; 4 | 5 | /*! 6 | * vary 7 | * Copyright(c) 2014-2017 Douglas Christopher Wilson 8 | * MIT Licensed 9 | */ 10 | 11 | /** 12 | * Module exports. 13 | */ 14 | 15 | // NOTE: use export and fake append 16 | 17 | // module.exports = vary 18 | // module.exports.append = append 19 | vary.append = append; 20 | 21 | /** 22 | * RegExp to match field-name in RFC 7230 sec 3.2 23 | * 24 | * field-name = token 25 | * token = 1*tchar 26 | * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" 27 | * / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" 28 | * / DIGIT / ALPHA 29 | * ; any VCHAR, except delimiters 30 | */ 31 | 32 | var FIELD_NAME_REGEXP = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; 33 | 34 | /** 35 | * Append a field to a vary header. 36 | * 37 | * @param {String} header 38 | * @param {String|Array} field 39 | * @return {String} 40 | * @public 41 | */ 42 | 43 | function append(header, field) { 44 | if (typeof header !== 'string') { 45 | throw new TypeError('header argument is required'); 46 | } 47 | 48 | if (!field) { 49 | throw new TypeError('field argument is required'); 50 | } 51 | 52 | // get fields array 53 | var fields = !Array.isArray(field) ? parse(String(field)) : field; 54 | 55 | // assert on invalid field names 56 | for (var j = 0; j < fields.length; j++) { 57 | if (!FIELD_NAME_REGEXP.test(fields[j])) { 58 | throw new TypeError('field argument contains an invalid header name'); 59 | } 60 | } 61 | 62 | // existing, unspecified vary 63 | if (header === '*') { 64 | return header; 65 | } 66 | 67 | // enumerate current values 68 | var val = header; 69 | var vals = parse(header.toLowerCase()); 70 | 71 | // unspecified vary 72 | if (fields.indexOf('*') !== -1 || vals.indexOf('*') !== -1) { 73 | return '*'; 74 | } 75 | 76 | for (var i = 0; i < fields.length; i++) { 77 | var fld = fields[i].toLowerCase(); 78 | 79 | // append value (case-preserving) 80 | if (vals.indexOf(fld) === -1) { 81 | vals.push(fld); 82 | val = val ? val + ', ' + fields[i] : fields[i]; 83 | } 84 | } 85 | 86 | return val; 87 | } 88 | 89 | /** 90 | * Parse a vary header into an array. 91 | * 92 | * @param {String} header 93 | * @return {Array} 94 | * @private 95 | */ 96 | 97 | function parse(header) { 98 | var end = 0; 99 | var list = []; 100 | var start = 0; 101 | 102 | // gather tokens 103 | for (var i = 0, len = header.length; i < len; i++) { 104 | switch (header.charCodeAt(i)) { 105 | case 0x20 /* */: 106 | if (start === end) { 107 | start = end = i + 1; 108 | } 109 | break; 110 | case 0x2c /* , */: 111 | list.push(header.substring(start, end)); 112 | start = end = i + 1; 113 | break; 114 | default: 115 | end = i + 1; 116 | break; 117 | } 118 | } 119 | 120 | // final token 121 | list.push(header.substring(start, end)); 122 | 123 | return list; 124 | } 125 | 126 | /** 127 | * Mark that a request is varied on a header field. 128 | * 129 | * @param {Object} res 130 | * @param {String|Array} field 131 | * @public 132 | */ 133 | 134 | // NOTE: use ServeRX headers 135 | 136 | export function vary(res, field) { 137 | if (!res || !res.headers || !res.headers) { 138 | // quack quack 139 | throw new TypeError('res argument is required'); 140 | } 141 | 142 | // get existing header 143 | var val = res.headers['Vary'] || ''; 144 | var header = Array.isArray(val) ? val.join(', ') : String(val); 145 | 146 | // set new header 147 | if ((val = append(header, field))) { 148 | res.headers['Vary'] = val; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/router.test.ts: -------------------------------------------------------------------------------- 1 | import { Exception } from './interfaces'; 2 | import { FILE_SERVER_OPTS } from './handlers/file-server'; 3 | import { FileServer } from './handlers/file-server'; 4 | import { Handler } from './handler'; 5 | import { Message } from './interfaces'; 6 | import { Middleware } from './middleware'; 7 | import { NotFound } from './handlers/not-found'; 8 | import { Route } from './interfaces'; 9 | import { Router } from './router'; 10 | import { StatusCode200 } from './handlers/statuscode-200'; 11 | 12 | import { Injectable } from 'injection-js'; 13 | import { Observable } from 'rxjs'; 14 | 15 | import { mergeMap } from 'rxjs/operators'; 16 | import { throwError } from 'rxjs'; 17 | 18 | @Injectable() 19 | class Service2 {} 20 | 21 | @Injectable() 22 | class Service1 { 23 | constructor(public service: Service2) {} 24 | } 25 | 26 | @Injectable() 27 | class Handler1 extends Handler { 28 | constructor(public service: Service1) { 29 | super(); 30 | } 31 | } 32 | 33 | @Injectable() 34 | class NoMatch extends Handler { 35 | handle(message$: Observable): Observable { 36 | return message$.pipe( 37 | mergeMap(() => throwError(new Exception({ statusCode: 404 }))) 38 | ); 39 | } 40 | } 41 | 42 | @Injectable() 43 | class Middleware1 extends Middleware {} 44 | 45 | @Injectable() 46 | class Middleware2 extends Middleware {} 47 | 48 | @Injectable() 49 | class Middleware3 extends Middleware { 50 | constructor(public service: Service1) { 51 | super(); 52 | } 53 | } 54 | 55 | const routes: Route[] = [ 56 | { 57 | path: '', 58 | middlewares: [Middleware1, Middleware2], 59 | services: [ 60 | Service2, 61 | { provide: FILE_SERVER_OPTS, useValue: { root: '/tmp' } } 62 | ], 63 | children: [ 64 | { 65 | path: '/foo', 66 | children: [ 67 | { 68 | methods: ['GET'], 69 | path: '/bar', 70 | data: '/foo/bar', 71 | children: [ 72 | { 73 | path: '/public', 74 | handler: FileServer, 75 | data: '/foo/bar/public' 76 | }, 77 | 78 | { 79 | path: '/this/{id}/{user}', 80 | services: [Service1], 81 | handler: Handler1, 82 | data: '/foo/bar/this{id}/{user}' 83 | }, 84 | 85 | { 86 | path: '/that/{partner}', 87 | services: [Service1], 88 | handler: Handler1, 89 | data: '/foo/bar/that/{partner}' 90 | }, 91 | 92 | { 93 | path: '**', 94 | handler: NoMatch, 95 | data: '/foo/bar/this no match' 96 | } 97 | ] 98 | }, 99 | 100 | { 101 | path: null, 102 | methods: ['GET'], 103 | services: [Service1], 104 | children: [ 105 | { 106 | path: '/fizz/baz', 107 | middlewares: [Middleware3], 108 | data: '/foo/fizz/baz' 109 | } 110 | ] 111 | }, 112 | 113 | { 114 | path: '/', 115 | methods: ['POST'], 116 | children: [ 117 | { 118 | path: '/fizz/baz/buzz', 119 | pathMatch: 'prefix', 120 | data: '/foo/fizz/baz/buzz' 121 | }, 122 | 123 | { 124 | path: '/fizz/baz', 125 | pathMatch: 'full' 126 | }, 127 | 128 | { 129 | path: '**', 130 | handler: NoMatch, 131 | data: '/foo/fizz/baz/full no match' 132 | } 133 | ] 134 | } 135 | ] 136 | } 137 | ] 138 | } 139 | ]; 140 | 141 | const router = new Router(routes); 142 | 143 | describe('Router unit tests', () => { 144 | test('routes can be flattened', () => { 145 | const flattened = router.flatten(); 146 | expect(flattened.length).toEqual(6); 147 | expect(flattened[0].path).toEqual('/foo/bar/public'); 148 | }); 149 | 150 | test('GET /foo no match', () => { 151 | const message: Message = { request: { method: 'GET', path: '/foo' } }; 152 | const route = router.route(message).request.route; 153 | const handler = Handler.makeInstance(route); 154 | expect(handler instanceof NotFound).toBeTruthy(); 155 | }); 156 | 157 | test('GET /fizz no match', () => { 158 | const message: Message = { request: { method: 'GET', path: '/fizz' } }; 159 | expect(router.route(message).request.route.phantom).toBeTruthy(); 160 | }); 161 | 162 | test('GET /foo/bar matches', () => { 163 | const message: Message = { request: { method: 'GET', path: '/foo/bar' } }; 164 | const route = router.route(message).request.route; 165 | expect(route.data).toEqual('/foo/bar'); 166 | const middlewares = Middleware.makeInstances(route); 167 | expect(middlewares.length).toEqual(0); 168 | const handler = Handler.makeInstance(route); 169 | expect(handler instanceof NotFound).toBeTruthy(); 170 | }); 171 | 172 | test('GET /foo/bar/this no match', () => { 173 | const message: Message = { 174 | request: { method: 'GET', path: '/foo/bar/this' } 175 | }; 176 | const route = router.route(message).request.route; 177 | expect(route.data).toEqual('/foo/bar/this no match'); 178 | const handler = Handler.makeInstance(route); 179 | expect(handler instanceof NoMatch).toBeTruthy(); 180 | }); 181 | 182 | test('GET /foo/bar/this/10/mark matches', () => { 183 | const message: Message = { 184 | request: { method: 'GET', path: '/foo/bar/this/10/mark' } 185 | }; 186 | const request = router.route(message).request; 187 | expect(request.route.data).toEqual('/foo/bar/this{id}/{user}'); 188 | const handler = Handler.makeInstance(request.route); 189 | expect(handler instanceof Handler1).toBeTruthy(); 190 | expect(handler.service instanceof Service1).toBeTruthy(); 191 | expect(handler.service.service instanceof Service2).toBeTruthy(); 192 | expect(request.params).toEqual({ id: '10', user: 'mark' }); 193 | }); 194 | 195 | test('GET /foo/bar/that/company matches', () => { 196 | const message: Message = { 197 | request: { method: 'GET', path: '/foo/bar/that/company' } 198 | }; 199 | const request = router.route(message).request; 200 | expect(request.route.data).toEqual('/foo/bar/that/{partner}'); 201 | expect(request.params).toEqual({ partner: 'company' }); 202 | }); 203 | 204 | test('GET /foo/fizz/baz matches', () => { 205 | const message: Message = { 206 | request: { method: 'GET', path: '/foo/fizz/baz/' } 207 | }; 208 | const route = router.route(message).request.route; 209 | expect(route.data).toEqual('/foo/fizz/baz'); 210 | const middlewares = Middleware.makeInstances(route); 211 | expect(middlewares.length).toEqual(1); 212 | expect(middlewares[0] instanceof Middleware3).toBeTruthy(); 213 | const handler = Handler.makeInstance(route); 214 | expect(handler instanceof StatusCode200).toBeTruthy(); 215 | }); 216 | 217 | test('POST /foo/fizz/baz/full no match', () => { 218 | const message: Message = { 219 | request: { method: 'POST', path: '/foo/fizz/baz/full' } 220 | }; 221 | const request = router.route(message).request; 222 | expect(request.route.data).toEqual('/foo/fizz/baz/full no match'); 223 | const handler = Handler.makeInstance(request.route); 224 | expect(handler instanceof NoMatch).toBeTruthy(); 225 | }); 226 | 227 | test('POST /foo/fizz/baz/buzz matches', () => { 228 | const message: Message = { 229 | request: { method: 'POST', path: '/foo/fizz/baz/buzz' } 230 | }; 231 | expect(router.route(message).request.route.data).toEqual( 232 | '/foo/fizz/baz/buzz' 233 | ); 234 | }); 235 | 236 | test('GET /foo/bar/public honors tailOf API', () => { 237 | const message: Message = { 238 | request: { method: 'GET', path: '/foo/bar/public/x/y/z.html' } 239 | }; 240 | const request = router.route(message).request; 241 | expect(request.route.data).toEqual('/foo/bar/public'); 242 | expect(router.tailOf(request.path, request.route)).toEqual('/x/y/z.html'); 243 | }); 244 | 245 | test('GET /foo/bar/public resolves to root dir from DI', () => { 246 | const message: Message = { 247 | request: { method: 'GET', path: '/foo/bar/public/x/y/z.html' } 248 | }; 249 | const request = router.route(message).request; 250 | expect(request.route.data).toEqual('/foo/bar/public'); 251 | const handler = Handler.makeInstance(request.route); 252 | expect(handler instanceof FileServer).toBeTruthy(); 253 | expect(handler['opts']['root']).toEqual('/tmp'); 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { ALL_METHODS } from './interfaces'; 2 | import { Class } from './interfaces'; 3 | import { LogProvider } from './services/log-provider'; 4 | import { Map } from './interfaces'; 5 | import { Message } from './interfaces'; 6 | import { Method } from './interfaces'; 7 | import { NotFound } from './handlers/not-found'; 8 | import { RedirectTo } from './handlers/redirect-to'; 9 | import { RequestMetadata } from './interfaces'; 10 | import { Response500 } from './interfaces'; 11 | import { ResponseMetadata } from './interfaces'; 12 | import { Route } from './interfaces'; 13 | import { StatusCode200 } from './handlers/statuscode-200'; 14 | 15 | import { ReflectiveInjector } from 'injection-js'; 16 | 17 | /** 18 | * Router implementation 19 | */ 20 | 21 | export class Router { 22 | routes: Route[]; 23 | 24 | /** ctor */ 25 | constructor(routes: Route[], middlewares: Class[] = []) { 26 | this.routes = [ 27 | { 28 | children: routes, 29 | middlewares: middlewares, 30 | path: '', 31 | phantom: true, 32 | responses: { 33 | '500': { 34 | 'application/json': Response500 35 | } 36 | }, 37 | services: [LogProvider] 38 | } 39 | ]; 40 | } 41 | 42 | /** Flatten all handled routes */ 43 | flatten(): Route[] { 44 | const flattened: Route[] = []; 45 | this.flattenImpl(flattened, this.routes); 46 | return flattened.sort((a: Route, b: Route) => a.path.localeCompare(b.path)); 47 | } 48 | 49 | /** Route a message */ 50 | route(message: Message): Message { 51 | const { request } = message; 52 | const params = {}; 53 | const paths = this.split(request.path); 54 | const route = this.match(paths, request.method, null, this.routes, params); 55 | request.params = params; 56 | request.route = route; 57 | return message; 58 | } 59 | 60 | /** Find the tail of a path, relative to its route */ 61 | tailOf(path: string, route: Route): string { 62 | const rpaths = []; 63 | while (route) { 64 | const normalized = this.normalize(route.path); 65 | if (normalized) rpaths.push(normalized); 66 | // now look at parent 67 | route = route.parent; 68 | } 69 | const rpath = '/' + rpaths.reverse().join('/'); 70 | return path.substring(rpath.length); 71 | } 72 | 73 | /** Validate a path */ 74 | validate(path: string): string { 75 | path.split('/').forEach((part) => { 76 | if (part[0] === '.') throw new Error('TODO: dot files not allowed'); 77 | }); 78 | return path; 79 | } 80 | 81 | // private methods 82 | 83 | private flattenImpl( 84 | flattened: Route[], 85 | routes: Route[], 86 | parent: Route = null 87 | ): void { 88 | routes.forEach((route: Route) => { 89 | route.parent = parent; 90 | const matches = 91 | !route.phantom && 92 | !['', '**'].includes(route.path) && 93 | (route.handler || !route.children); 94 | if (matches) flattened.push(this.harmonize(route)); 95 | if (route.children) this.flattenImpl(flattened, route.children, route); 96 | }); 97 | } 98 | 99 | private getPathParam(path: string): string { 100 | return path.substring(1, path.length - 1); 101 | } 102 | 103 | private harmonize(route: Route): Route { 104 | let description: string; 105 | let methods: Method[]; 106 | const paths: string[] = []; 107 | const redirectAs = route.redirectAs; 108 | const redirectTo = route.redirectTo; 109 | const request: RequestMetadata = {}; 110 | const responses: ResponseMetadata = {}; 111 | let summary: string; 112 | // accumulate route components 113 | while (route) { 114 | description = description || route.description; 115 | methods = methods || route.methods; 116 | paths.push(...this.split(route.path).reverse()); 117 | if (route.request) { 118 | request.body = request.body || route.request.body; 119 | request.header = request.header || route.request.header; 120 | request.path = request.path || route.request.path; 121 | request.query = request.query || route.request.query; 122 | } 123 | if (route.responses) { 124 | Object.keys(route.responses).forEach((statusCode) => { 125 | responses[statusCode] = 126 | responses[statusCode] || route.responses[statusCode]; 127 | }); 128 | } 129 | summary = summary || route.summary; 130 | // now look at parent 131 | route = route.parent; 132 | } 133 | // cleanup and return the business 134 | methods = methods || ALL_METHODS; 135 | const path = '/' + paths.reverse().join('/'); 136 | const harmonized: Route = { 137 | description, 138 | methods, 139 | path, 140 | request, 141 | responses, 142 | summary 143 | }; 144 | // NOTE: redirect isn't inherited 145 | if (redirectTo) { 146 | harmonized.redirectAs = redirectAs; 147 | harmonized.redirectTo = redirectTo; 148 | } 149 | return harmonized; 150 | } 151 | 152 | private isPathParam(path: string): boolean { 153 | return path.startsWith('{') && path.endsWith('}'); 154 | } 155 | 156 | private match( 157 | paths: string[], 158 | method: Method, 159 | parent: Route, 160 | routes: Route[], 161 | params: Map 162 | ): Route | undefined { 163 | const rpaths = []; 164 | // find matching route, fabricating if necessary 165 | let route = this.matchImpl(paths, rpaths, method, parent, routes, params); 166 | // we always need a handler 167 | if (!route.handler) { 168 | if (route.children) route.handler = NotFound; 169 | else route.handler = route.redirectTo ? RedirectTo : StatusCode200; 170 | } 171 | // create an injector 172 | if (!route.injector) { 173 | const providers = (route.services || []).concat(route.middlewares || []); 174 | providers.push(route.handler); 175 | const resolved = ReflectiveInjector.resolve(providers); 176 | if (parent) 177 | route.injector = parent.injector.createChildFromResolved(resolved); 178 | else route.injector = ReflectiveInjector.fromResolvedProviders(resolved); 179 | } 180 | // look to the children 181 | if (route.children && paths.length > rpaths.length) 182 | route = this.match( 183 | paths.slice(rpaths.length), 184 | method, 185 | route, 186 | route.children, 187 | params 188 | ); 189 | return route; 190 | } 191 | 192 | private matchImpl( 193 | paths: string[], 194 | rpaths: string[], 195 | method: Method, 196 | parent: Route, 197 | routes: Route[], 198 | params: Map 199 | ): Route { 200 | let route = routes.find((route: Route) => { 201 | route.parent = parent; 202 | if (route.methods && !route.methods.includes(method)) return false; 203 | if (route.path === '**') return true; 204 | rpaths.splice(0, rpaths.length, ...this.split(route.path)); 205 | if (rpaths.length === 0) return true; 206 | if ( 207 | rpaths.length > paths.length || 208 | (route.pathMatch === 'full' && rpaths.length !== paths.length) 209 | ) 210 | return false; 211 | return rpaths.every( 212 | (rpath, ix) => this.isPathParam(rpath) || rpath === paths[ix] 213 | ); 214 | }); 215 | // if no route, fabricate a catch all 216 | // NOTE: we only want to do this once per not found 217 | if (!route) { 218 | route = { handler: NotFound, parent, path: '**', phantom: true }; 219 | routes.push(route); 220 | } 221 | // accumulate path parameters 222 | rpaths.forEach((rpath, ix) => { 223 | if (this.isPathParam(rpath)) params[this.getPathParam(rpath)] = paths[ix]; 224 | }); 225 | return route; 226 | } 227 | 228 | private normalize(path: string): string { 229 | // NOTE: trim out any leading or trailing / 230 | return path && path !== '/' ? path.replace(/^\/|\/$/g, '') : ''; 231 | } 232 | 233 | private split(path: string): string[] { 234 | const normalized = this.normalize(path); 235 | return normalized ? normalized.split('/') : []; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service definition 3 | */ 4 | 5 | export abstract class Service {} 6 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './log-provider'; 2 | -------------------------------------------------------------------------------- /src/services/log-provider.test.ts: -------------------------------------------------------------------------------- 1 | import { LogProvider } from './log-provider'; 2 | 3 | describe('LogProvider unit tests', () => { 4 | test('console.log proxy', (done) => { 5 | const logProvider = new LogProvider(); 6 | jest.spyOn(global.console, 'log').mockImplementation(() => {}); 7 | logProvider.log('xxx'); 8 | expect(console.log).toBeCalled(); 9 | done(); 10 | }); 11 | 12 | test('console.info proxy', (done) => { 13 | const logProvider = new LogProvider(); 14 | jest.spyOn(global.console, 'info').mockImplementation(() => {}); 15 | logProvider.info('xxx'); 16 | expect(console.info).toBeCalled(); 17 | done(); 18 | }); 19 | 20 | test('console.warn proxy', (done) => { 21 | const logProvider = new LogProvider(); 22 | jest.spyOn(global.console, 'warn').mockImplementation(() => {}); 23 | logProvider.warn('xxx'); 24 | expect(console.warn).toBeCalled(); 25 | done(); 26 | }); 27 | 28 | test('console.error proxy', (done) => { 29 | const logProvider = new LogProvider(); 30 | jest.spyOn(global.console, 'error').mockImplementation(() => {}); 31 | logProvider.error('xxx'); 32 | expect(console.error).toBeCalled(); 33 | done(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/services/log-provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from 'injection-js'; 2 | 3 | /** 4 | * Base log provider 5 | * 6 | * NOTE: uses console.log 7 | */ 8 | 9 | @Injectable() 10 | export class LogProvider { 11 | /** Can this provider colorize? */ 12 | canColorize(): boolean { 13 | return true; 14 | } 15 | 16 | /** console proxies */ 17 | 18 | error(stuff: any): void { 19 | console.error(stuff); 20 | } 21 | 22 | info(stuff: any): void { 23 | console.info(stuff); 24 | } 25 | 26 | log(stuff: any): void { 27 | console.log(stuff); 28 | } 29 | 30 | warn(stuff: any): void { 31 | console.warn(stuff); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { caseInsensitiveObject } from './utils'; 2 | import { deepCopy } from './utils'; 3 | import { fromReadableStream } from './utils'; 4 | 5 | import * as fs from 'fs'; 6 | import * as path from 'path'; 7 | 8 | import { map } from 'rxjs/operators'; 9 | 10 | import str = require('string-to-stream'); 11 | 12 | describe('caseInsensitiveObject unit tests', () => { 13 | test('HTTP header case is honored', () => { 14 | const headers = caseInsensitiveObject({}); 15 | headers['content-TYPE'] = 'application/json'; 16 | headers['cONtent-lENGTH'] = '0'; 17 | expect(Object.keys(headers)).toEqual(['Content-Type', 'Content-Length']); 18 | expect(headers['Content-Type']).toEqual('application/json'); 19 | expect(headers['Content-Length']).toEqual('0'); 20 | }); 21 | 22 | test('HTTP aliases re discarded', () => { 23 | const headers = caseInsensitiveObject({}); 24 | headers['content-length'] = '10'; 25 | headers['cONtent-lENGTH'] = '0'; 26 | expect(Object.keys(headers)).toEqual(['Content-Length']); 27 | expect(headers['Content-Length']).toEqual('0'); 28 | }); 29 | 30 | test('HTTP Referrer header is properly aliased', () => { 31 | const headers = caseInsensitiveObject({}); 32 | headers['reFeRRer'] = 'p'; 33 | headers['Referer'] = 'q'; 34 | expect(Object.keys(headers)).toEqual(['Referrer']); 35 | expect(headers['Referrer']).toEqual('q'); 36 | }); 37 | 38 | test('keys can be deleted', () => { 39 | const headers = caseInsensitiveObject({}); 40 | headers['abc-def'] = 'p'; 41 | headers['ABC-DEF'] = 'q'; 42 | headers['pQr-StU'] = 'q'; 43 | delete headers['PQR-stu']; 44 | expect(Object.keys(headers)).toEqual(['Abc-Def']); 45 | expect(headers['Abc-Def']).toEqual('q'); 46 | }); 47 | }); 48 | 49 | describe('deepCopy unit tests', () => { 50 | test('an object can be copied', () => { 51 | const a = { p: { q: { r: [1, 2, 3] } } }; 52 | const b = deepCopy(a); 53 | expect(b.p.q.r).toEqual([1, 2, 3]); 54 | }); 55 | }); 56 | 57 | describe('fromReadableStream unit tests', () => { 58 | test('a string can be read as an observable stream', (done) => { 59 | const test = 'This is only a test. There is no cause for alarm!'; 60 | const stream$ = fromReadableStream(str(test)); 61 | stream$ 62 | .pipe(map((result: Buffer): string => result.toString())) 63 | .subscribe((result: string) => { 64 | expect(result).toEqual(test); 65 | done(); 66 | }); 67 | }); 68 | 69 | test('this file can be read as an observable stream', (done) => { 70 | const test = path.join(__dirname, 'utils.test.ts'); 71 | const stream$ = fromReadableStream(fs.createReadStream(test)); 72 | stream$ 73 | .pipe(map((result: Buffer): string => result.toString())) 74 | .subscribe((result: string) => { 75 | expect(result).toMatch(/^import /); 76 | expect(result).toMatch(/\}\);\n$/); 77 | done(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Readable } from 'stream'; 3 | 4 | /** 5 | * Create a case-insensitive object 6 | * 7 | * NOTE: biased to HTTP headers 8 | */ 9 | 10 | export function caseInsensitiveObject(obj: any): any { 11 | const proxy = {}; 12 | Object.entries(obj).forEach(([k, v]) => (proxy[normalize(k)] = v)); 13 | return new Proxy(proxy, { 14 | deleteProperty: (tgt: any, k: string): boolean => delete tgt[normalize(k)], 15 | get: (tgt: any, k: string): any => tgt[normalize(k)], 16 | set: (tgt: any, k: string, v: any): boolean => { 17 | tgt[normalize(k)] = v; 18 | return true; 19 | } 20 | }); 21 | } 22 | 23 | function normalize(k: string): string { 24 | if (k.split) { 25 | const words = k 26 | .split('-') 27 | // @see https://en.wikipedia.org/wiki/HTTP_referer 28 | .map((word) => (word.toLowerCase() === 'referer' ? 'referrer' : word)) 29 | .map( 30 | (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() 31 | ); 32 | return words.join('-'); 33 | } else return k; 34 | } 35 | 36 | /** 37 | * Deep-copy an object 38 | * 39 | * TODO: we can do better than this! 40 | */ 41 | 42 | export function deepCopy(obj: T): T { 43 | return JSON.parse(JSON.stringify(obj)); 44 | } 45 | 46 | /** 47 | * Make an observer from a readable stream 48 | */ 49 | 50 | export function fromReadableStream(stream: Readable): Observable { 51 | stream.pause(); 52 | const buffer = []; 53 | return new Observable((observer) => { 54 | const next = (chunk): number => buffer.push(chunk); 55 | const error = (err): void => observer.error(err); 56 | const complete = (): void => { 57 | observer.next(Buffer.concat(buffer)); 58 | observer.complete(); 59 | }; 60 | stream.on('data', next).on('error', error).on('end', complete).resume(); 61 | return (): void => { 62 | stream.removeListener('data', next); 63 | stream.removeListener('error', error); 64 | stream.removeListener('end', complete); 65 | }; 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "emitDecoratorMetadata": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "lib": ["dom", "es2017"], 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "noEmitOnError": true, 14 | "noImplicitReturns": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": false, 17 | "outDir": "./dist", 18 | "pretty": true, 19 | "rootDir": "./src", 20 | "suppressImplicitAnyIndexErrors": true, 21 | "target": "es2017", 22 | "typeRoots": ["node_modules/@types"] 23 | }, 24 | "include": ["./src/**/*.ts"] 25 | } 26 | --------------------------------------------------------------------------------