├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── client ├── axios.ts ├── fetch.ts ├── index.ts ├── node.ts ├── request.ts ├── supertest.ts └── xhr.ts ├── example ├── README.md ├── backend │ ├── app.ts │ ├── database.ts │ └── index.ts ├── common │ ├── endpoints.ts │ └── types.ts ├── frontend │ ├── app.tsx │ ├── index.html │ └── index.tsx ├── package.json ├── testing │ ├── backend.test.ts │ ├── frontend.test.tsx │ ├── integration.test.tsx │ └── setup.ts ├── tsconfig.json └── webpack.config.ts ├── index.ts ├── package.json ├── source ├── callable.ts ├── endpoint.ts └── group.ts ├── test ├── callable.ts ├── client │ ├── axios.ts │ ├── fetch.ts │ └── supertest.ts ├── endpoint.ts └── group.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # parcel.js build output 64 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example/ 2 | node_modules/ 3 | *.ts 4 | !*.d.ts 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "10" 5 | 6 | cache: 7 | directories: 8 | - "node_modules" 9 | 10 | notifications: 11 | email: false 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 4.1.0 2 | 3 | Use parsed body from `req.body` when it exists. 4 | 5 | # 4.0.2 6 | 7 | Correctly forward errors during response serialization. 8 | 9 | # 4.0.1 10 | 11 | Remove typescript source from published bundle. 12 | 13 | # 4.0.0 14 | 15 | Make Endpoints require a client in their configuration. 16 | 17 | Multiple clients are implemented for different use cases in [`client/...`](./client). A default client is also exported from the package (currently [`FetchClient`](./client/fetch.ts)). 18 | 19 | The only requirement for a client is that it satisfies the `Client` interface available in [`rickety/client`](./client/index.ts). 20 | 21 | ```typescript 22 | const endpoint = new Endpoint({ 23 | client: myAPI, 24 | // ... 25 | }); 26 | ``` 27 | 28 | ## 29 | 30 | Remove headers arguments from `Endpoint.call`. Headers can still be modified using a custom client. 31 | 32 | ```typescript 33 | class CustomClient extends DefaultClient implements Client { 34 | public send(request: ClientRequest) { 35 | request.headers["Custom-Header"] = "abc"; 36 | return super.send(request); 37 | } 38 | } 39 | 40 | const myAPI = new CustomClient(); 41 | 42 | const endpoint = new Endpoint({ 43 | client: myAPI, 44 | // ... 45 | }); 46 | ``` 47 | 48 | ## 49 | 50 | Remove `base` option from endpoint configuration. The url can still be modified using a custom client. 51 | 52 | ```typescript 53 | class CustomClient extends DefaultClient implements Client { 54 | public send(request: ClientRequest) { 55 | request.url = "https://example.com/base/" + request.url 56 | return super.send(request); 57 | } 58 | } 59 | 60 | const myAPI = new CustomClient(); 61 | 62 | const endpoint = new Endpoint({ 63 | client: myAPI, 64 | // ... 65 | }); 66 | ``` 67 | 68 | ## 69 | 70 | Removed exports from entry module. Internal types are still exported, but from other files. For example, endpoint `Config` can be accessed from "rickety/source/endpoint" and `Client` from "rickety/client". 71 | 72 | The custom types for `Status` and `Method` were also removed in favor of `number` and `string` respectively. 73 | 74 | ## 75 | 76 | Make JSON marshalling/un-marshalling more lenient for raw strings. This issue comes up if a server is returning a plain message `str`. Since it is not valid JSON it cannot be parsed without extra steps. The correct format for a JSON string surrounds it with double quotes `"str"`. 77 | 78 | This behavior can be disabled by enabling `strict` mode in an endpoint's config. 79 | 80 | ```typescript 81 | const endpoint = new Endpoint({ 82 | // ... 83 | strict: true, 84 | }); 85 | ``` 86 | 87 | ## 88 | 89 | Add `Group` construct to combine endpoints into single callable entity. Requests and responses remain strictly typed and have the same "shape" as the group's template. 90 | 91 | ```typescript 92 | const endpoint = new Endpoint( ... ); 93 | 94 | const group = new Group({ 95 | very: { 96 | nested: { 97 | example: endpoint, 98 | }, 99 | }, 100 | }); 101 | 102 | const response = await group.call({ 103 | very: { 104 | nested: { 105 | example: "abc", 106 | }, 107 | }, 108 | }) 109 | 110 | // response { 111 | // very: { 112 | // nested: { 113 | // example: 123, 114 | // } 115 | // } 116 | // } 117 | ``` 118 | 119 | ## 120 | 121 | Add optional type checking options to endpoint config. These are used both when marshalling and un-marshalling data. 122 | 123 | ```typescript 124 | const endpoint = new Endpoint({ 125 | // ... 126 | isRequest: (req) => { 127 | if (!req) return false; 128 | if (req.value === undefined) return false; 129 | return true; 130 | }, 131 | isResponse: (res) => { 132 | // ... 133 | }, 134 | }); 135 | ``` 136 | 137 | ## 138 | 139 | Make endpoint (and group) request and response types available as `$req` and `$res`. 140 | 141 | ```typescript 142 | const endpointRequest: typeof endpoint.$req; 143 | const groupResponse: typeof group.$res; 144 | ``` 145 | 146 | _Using these members by value with produce an error._ 147 | 148 | # 3.0.3 149 | 150 | Make handler path comparing use `req.originalUrl` instead of `req.path`. 151 | 152 | ```typescript 153 | // v3.0.2 match with "/api/api/test" 154 | // v3.0.3 match with "/api/test" 155 | app.use("/api", endpoint.handler( ... )); 156 | ``` 157 | 158 | ## 159 | 160 | Make requests where `req.base` equals endpoint's base and `req.path` equals endpoint's path match with handler. This change is primarily meant to preserve the possibility of using a base in both the endpoint and express. 161 | 162 | ```typescript 163 | const endpoint = new Endpoint({ 164 | base: "/api", 165 | path: "/test", 166 | }); 167 | 168 | // v3.0.2 match with "/api/test" and "/api2/test" 169 | // v3.0.3 match with "/api/test" 170 | app.use("/api(2?)", endpoint.handler( ... )); 171 | ``` 172 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Gabriel Harel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | # :scroll: rickety 9 | 10 | [![](https://img.shields.io/npm/v/rickety.svg)](https://www.npmjs.com/package/rickety) 11 | [![](https://travis-ci.org/g-harel/rickety.svg?branch=master)](https://travis-ci.org/g-harel/rickety) 12 | [![](https://img.shields.io/npm/types/rickety.svg)](https://github.com/g-harel/rickety) 13 | 14 | > minimal typescript rpc library 15 | 16 | * [Strongly typed endpoints](#usage) 17 | * [Groupable queries](#group) 18 | * [Convenient testing](#testing) 19 | * [Optional type checks](#config) 20 | * [Extensible clients](#client) 21 | * [No runtime dependencies](/package.json) 22 | 23 | ## 24 | 25 | [Try out the example project to experiment with a working setup (including tests)](/example) 26 | 27 | ## Install 28 | 29 | ```shell 30 | $ npm install rickety 31 | ``` 32 | 33 | ## Usage 34 | 35 | ``` typescript 36 | import {DefaultClient, Endpoint} from "rickety"; 37 | ``` 38 | 39 | ```typescript 40 | const myAPI = new DefaultClient(); 41 | 42 | const userByID = new Endpoint({ 43 | client: myAPI, 44 | path: "/api/v1/...", 45 | }); 46 | ``` 47 | 48 | ```typescript 49 | app.use( 50 | userByID.handler(async (id) => { 51 | // ... 52 | return user; 53 | }); 54 | ); 55 | ``` 56 | 57 | ```typescript 58 | const user = await userByID.call(id); 59 | ``` 60 | 61 | ## Endpoint 62 | 63 | An endpoint's call function sends requests using the configured options. It returns a promise which may be rejected if there is an issue with the request process or if the status is unexpected. 64 | 65 | ```typescript 66 | const response = await endpoint.call(request); 67 | ``` 68 | 69 | Request handlers contain the server code that transforms requests into responses. Both express' `req` and `res` objects are passed to the function which makes it possible to implement custom behavior like accessing and writing headers. 70 | 71 | ```typescript 72 | app.use( 73 | endpoint.handler(async (request, req, res) => { 74 | // ... 75 | return response; 76 | }); 77 | ); 78 | ``` 79 | 80 | Endpoints expose their configuration through readonly public values which can be accessed from the instance. 81 | 82 | ```typescript 83 | const method = endpoint.method; // POST 84 | ``` 85 | 86 | The endpoint's request and response types can also be accessed using `typeof` on two special members. Using them by value with produce an error. 87 | 88 | ```typescript 89 | type Request = typeof endpoint.$req; 90 | type Response = typeof endpoint.$res; 91 | ``` 92 | 93 | #### Config 94 | 95 | ```typescript 96 | const endpoint = new Endpoint({ 97 | client: Client; 98 | path: string; 99 | method?: string; 100 | expect?: number | number[]; 101 | isRequest?: (req: any) => boolean; 102 | isResponse?: (res: any) => boolean; 103 | strict?: boolean; 104 | }); 105 | ``` 106 | 107 | | | | 108 | | -- | -- | 109 | | `client` | Client is used to send the requests and can be shared by multiple endpoints. More info [here](#client). | 110 | | `method` | HTTP method used when handling and making requests. Defaults to `POST` if not configured. | 111 | | `path` | Required URL path at which the handler will be registered and the requests will be sent. | 112 | | `expect` | Expected returned status code(s). By default, anything but `200` is considered an error. This value is only used for making requests and has no influence on the handler (which will return `200` by default). | 113 | | `isRequest` `isResponse` | Type checking functions run before and after serializing the objects in both client and server. By default any value will be considered correct. | 114 | | `strict` | Flag to enable strict JSON marshalling/un-marshalling. By default "raw" strings are detected and handled correctly. In strict mode, they would cause a parsing error. This issue comes up if a server is returning a plain message `str`. Since it is not valid JSON it cannot be parsed without extra steps. The correct format for a JSON string surrounds it with double quotes `"str"`. | 115 | 116 | ## Client 117 | 118 | Clients are responsible for sending requests and receiving responses. 119 | 120 | Rickety is released with a few included clients which can be imported using the [`rickety/client/...`](./client) path pattern. 121 | 122 | | [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) | [`xhr`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) | [`node`](https://nodejs.org/api/https.html) | [`request`](https://github.com/request/request) | [`axios`](https://github.com/axios/axios) | 123 | | -- | -- | -- | -- | -- | 124 | 125 | _The `fetch` client is used as `DefaultClient`._ 126 | 127 | Clients can be extended or re-implemented to better address project requirements. For example, a client can enable caching, modify headers or append a path prefix or a domain to endpoint URLs. The only requirement for a client is that it satisfies the `Client` interface available in [`rickety/client`](./client/index.ts). 128 | 129 | The [`supertest`](./client/supertest.ts) client also enables easy integration tests, as detailed in the [testing](#testing) section. 130 | 131 | ## Group 132 | 133 | Groups allow multiple endpoints to be treated as a single construct while preserving type information. 134 | 135 | ```typescript 136 | const userByID = new Endpoint( ... ); 137 | const promotedByUserID = new Endpoint( ... ); 138 | const allProducts = new Endpoint( ... ); 139 | 140 | const listingPage = new Group({ 141 | user: userByID, 142 | listing: { 143 | promoted: promotedByUserID, 144 | all: allProducts, 145 | }, 146 | }); 147 | ``` 148 | 149 | _Groups can be used inside other groups._ 150 | 151 | Groups are called using a request object with the same "shape" as its definition, but with the correct request data type in the place of the endpoints. Similarly, the response is also strictly typed and shares the same "shape" as the definition, but with response data in the place of the endpoints. 152 | 153 | Here is an example request and response objects for the above group. 154 | 155 | ```typescript 156 | const pageData = await listingPage.call({ 157 | user: "abc-123-xyz", 158 | listing: { 159 | promoted: "abc-123-xyz", 160 | all: { 161 | page: 3 162 | }, 163 | }, 164 | }); 165 | 166 | // pageData { 167 | // user: {...} 168 | // listing: { 169 | // promoted: [...], 170 | // all: [...], 171 | // }, 172 | // } 173 | ``` 174 | 175 | The group's dynamic request and response types can also be accessed using `typeof` on two special members. Using them by value with produce an error. 176 | 177 | ```typescript 178 | type Request = typeof group.$req; 179 | type Response = typeof group.$res; 180 | ``` 181 | 182 | ## Testing 183 | 184 | An endpoint/group's `call` function can be spied on to test behavior with mocked return values or assert on how it is being called. 185 | 186 | ```tsx 187 | import {getUserData} from "../endpoints"; 188 | import {Homepage} from "../frontend/components"; 189 | 190 | test("homepage fetches correct user data", () => { 191 | const spy = jest.spyOn(getUserData, "call"); 192 | spy.mockReturnValue({ ... }); 193 | 194 | mount(); 195 | 196 | expect(spy).toHaveBeenCalledWith( ... ); 197 | }); 198 | ``` 199 | 200 | The express app instance can be "linked" to test handler behavior. 201 | 202 | ```tsx 203 | import {SupertestClient} from "rickety/client/supertest"; 204 | 205 | import {app} from "../backend/app"; 206 | import {database} from "../backend/database"; 207 | import {client} from "../client"; 208 | import {createUserByEmail} from "../endpoints"; 209 | 210 | SupertestClient.override(client, app); 211 | 212 | test("new user is created in the database", async () => { 213 | const spy = jest.spyOn(database, "createUser"); 214 | spy.mockReturnValue({ ... }); 215 | 216 | await createUserByEmail( ... ); 217 | 218 | expect(spy).toHaveBeenCalledWith( ... ); 219 | }); 220 | ``` 221 | 222 | This pattern also enables integration tests which involve both client and server code. 223 | 224 | ```tsx 225 | import {SupertestClient} from "rickety/client/supertest"; 226 | import {mount} from "enzyme"; 227 | 228 | import {app} from "../backend/app"; 229 | import {database} from "../backend/database"; 230 | import {client} from "../client"; 231 | import {SignUp} from "../frontend/components"; 232 | 233 | SupertestClient.override(client, app); 234 | 235 | test("should refuse duplicate email addresses", async () => { 236 | const spy = jest.spyOn(database, "createUser"); 237 | spy.mockReturnValue({ ... }); 238 | 239 | const wrapper = mount(); 240 | const submit = wrapper.find('button'); 241 | submit.simulate('click'); 242 | 243 | expect(wrapper.find(".error")).toContain("..."); 244 | }); 245 | ``` 246 | 247 | ## License 248 | 249 | [MIT](./LICENSE) 250 | -------------------------------------------------------------------------------- /client/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | import {Client, ClientRequest} from "."; 4 | 5 | // AxiosClient uses the "request" package to send requests. 6 | export class AxiosClient implements Client { 7 | public async send(request: ClientRequest) { 8 | const res = await axios({ 9 | method: request.method, 10 | url: request.url, 11 | headers: request.headers, 12 | body: request.body, 13 | } as any); 14 | 15 | return {body: res.data, status: res.status}; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/fetch.ts: -------------------------------------------------------------------------------- 1 | import {Client, ClientRequest} from "."; 2 | 3 | // FetchClient uses the "fetch" global to send requests. It 4 | // will fail if the user's browser does not support that api 5 | // and it is not being polyfilled. 6 | export class FetchClient implements Client { 7 | public async send(request: ClientRequest) { 8 | const response = await fetch(request.url, { 9 | method: request.method, 10 | headers: request.headers, 11 | body: request.body, 12 | credentials: "same-origin", 13 | }); 14 | 15 | const body = await response.text(); 16 | const status = response.status; 17 | return {body, status}; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/index.ts: -------------------------------------------------------------------------------- 1 | export interface ClientRequest { 2 | method: string; 3 | url: string; 4 | headers: Record; 5 | body: string; 6 | } 7 | 8 | export interface ClientResponse { 9 | status: number; 10 | body: string; 11 | } 12 | 13 | export interface Client { 14 | send: (request: ClientRequest) => Promise; 15 | } 16 | -------------------------------------------------------------------------------- /client/node.ts: -------------------------------------------------------------------------------- 1 | import {Client, ClientRequest, ClientResponse} from "."; 2 | 3 | import https from "https"; 4 | 5 | export class NodeClient implements Client { 6 | public async send(request: ClientRequest) { 7 | return new Promise((resolve, reject) => { 8 | const req = https.request( 9 | { 10 | method: request.method, 11 | path: request.url, 12 | headers: request.headers, 13 | }, 14 | (res) => { 15 | let data = ""; 16 | res.on("data", (chunk) => (data += chunk)); 17 | res.on("error", reject); 18 | res.on("end", () => 19 | resolve({ 20 | body: data, 21 | status: res.statusCode as number, 22 | }), 23 | ); 24 | }, 25 | ); 26 | 27 | req.on("error", reject); 28 | 29 | req.write(request.body); 30 | req.end(); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/request.ts: -------------------------------------------------------------------------------- 1 | import request from "request"; 2 | 3 | import {Client, ClientRequest, ClientResponse} from "."; 4 | 5 | // RequestClient uses the "request" package to send requests. 6 | export class RequestClient implements Client { 7 | public async send(req: ClientRequest) { 8 | return new Promise((resolve, reject) => { 9 | request({ 10 | method: req.method, 11 | url: req.url, 12 | headers: req.headers, 13 | body: req.body, 14 | callback: (error, res, body) => { 15 | if (error) reject(error); 16 | resolve({body, status: res.statusCode}); 17 | }, 18 | }); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/supertest.ts: -------------------------------------------------------------------------------- 1 | import supertest from "supertest"; 2 | 3 | import {Client, ClientRequest, ClientResponse} from "."; 4 | 5 | // SupertestClient uses the "supertest" package to directly 6 | // query the application instead of going through the network. 7 | // It is intended to be used for integration tests. 8 | export class SupertestClient implements Client { 9 | private app: Express.Application; 10 | 11 | constructor(app: Express.Application) { 12 | this.app = app; 13 | } 14 | 15 | public async send(request: ClientRequest) { 16 | const method = request.method.toLowerCase(); 17 | let req: supertest.Test = (supertest(this.app) as any)[method](request.url); 18 | 19 | Object.keys(request.headers).forEach((name) => { 20 | req = req.set(name, request.headers[name]); 21 | }); 22 | 23 | req.send(request.body); 24 | return new Promise((resolve, reject) => { 25 | req.end((err, response) => { 26 | if (err) reject(err); 27 | resolve({ 28 | status: response.status as any, 29 | body: JSON.stringify(response.body), 30 | }); 31 | }); 32 | }); 33 | } 34 | 35 | // Testing helper to replace a client's functionality 36 | // with that of a new SupertestClient. 37 | public static override(client: Client, app: Express.Application): Client { 38 | const replacement = new SupertestClient(app); 39 | (client as any).send = (req: any) => { 40 | return replacement.send(req); 41 | }; 42 | return replacement; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/xhr.ts: -------------------------------------------------------------------------------- 1 | import {Client, ClientRequest, ClientResponse} from "."; 2 | 3 | // XHRClient uses the browser's XHR api to send requests. 4 | export class XHRClient implements Client { 5 | public async send(request: ClientRequest) { 6 | const req = new XMLHttpRequest(); 7 | req.open(request.method, request.url, true); 8 | 9 | Object.keys(request.headers).forEach((name) => { 10 | req.setRequestHeader(name, request.headers[name]); 11 | }); 12 | 13 | req.send(); 14 | return new Promise((resolve) => { 15 | req.onreadystatechange = () => { 16 | if (req.readyState === 4) { 17 | resolve({ 18 | body: req.responseText, 19 | status: req.status, 20 | }); 21 | } 22 | }; 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | example 3 | ├─ backend Server-side code 4 | │ ├─ app.ts Express app configuration, registers endpoint handlers 5 | │ ├─ database.ts Pretend async database access 6 | │ └─ index.ts Backend entry point, makes the app start listening 7 | ├─ common Shared code between both frontend and backend 8 | │ ├─ endpoints.ts Endpoint definitions 9 | │ └─ types.ts Shared endpoint request and response types 10 | ├─ frontend Client-side code 11 | │ ├─ app.tsx Sample React component with onClick handlers to call endpoints 12 | │ ├─ index.html HTML page template 13 | │ └─ index.tsx Frontend entry point, renders the app into the root 14 | └─ testing Tests and test setup code 15 | ├─ backend.test.ts Endpoint handler test using express link 16 | ├─ frontend.test.tsx Component test spying on endpoint calls 17 | ├─ integration.test.tsx Component test using express link and mocked database module 18 | └─ setup.ts Testing environment setup 19 | ``` 20 | 21 |   22 | 23 | ## Setup 24 | 25 | ``` 26 | $ npm install 27 | ``` 28 | 29 | ## Running 30 | 31 | ``` 32 | $ npm start 33 | ``` 34 | 35 | ## Testing 36 | 37 | ``` 38 | $ npm test 39 | ``` 40 | -------------------------------------------------------------------------------- /example/backend/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import {userByID, userByName} from "../common/endpoints"; 4 | import {readUser} from "./database"; 5 | 6 | export const app = express(); 7 | 8 | app.use(express.static(__dirname + "/../dist")); 9 | 10 | app.use( 11 | userByID.handler(async (userID) => { 12 | const user = await readUser(); 13 | user.id = userID; 14 | 15 | return user; 16 | }), 17 | ); 18 | 19 | app.use( 20 | userByName.handler(async (data) => { 21 | const name = data.name.replace(/\s/g, ".").toLowerCase(); 22 | 23 | const user = await readUser(); 24 | user.name = data.name; 25 | user.account.email = name + "@example.com"; 26 | user.account.username = name; 27 | 28 | return user; 29 | }), 30 | ); 31 | 32 | const errHandler: express.ErrorRequestHandler = (err, req, res, next) => { 33 | console.error(err); 34 | res.sendStatus(418); 35 | }; 36 | app.use(errHandler); 37 | -------------------------------------------------------------------------------- /example/backend/database.ts: -------------------------------------------------------------------------------- 1 | import {User} from "../common/types"; 2 | 3 | export const readUser = async (): Promise => ({ 4 | id: 1234, 5 | name: "David Smith", 6 | account: { 7 | email: "david.smith@example.com", 8 | username: null, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /example/backend/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | import {app} from "./app"; 4 | 5 | const port = process.env.PORT || 3000; 6 | 7 | app.listen(port, async () => { 8 | console.log(chalk.bold.magentaBright(`http://localhost:${port}\n`)); 9 | }); 10 | -------------------------------------------------------------------------------- /example/common/endpoints.ts: -------------------------------------------------------------------------------- 1 | import {DefaultClient, Endpoint} from "rickety"; 2 | 3 | import {ID, Name, User} from "./types"; 4 | 5 | export const client = new DefaultClient(); 6 | 7 | export const userByID = new Endpoint({ 8 | client: client, 9 | path: "/api/v1/userByID", 10 | }); 11 | 12 | export const userByName = new Endpoint({ 13 | client: client, 14 | path: "/api/v1/userByName", 15 | }); 16 | -------------------------------------------------------------------------------- /example/common/types.ts: -------------------------------------------------------------------------------- 1 | export type ID = number; 2 | 3 | export type Name = { 4 | name: string; 5 | }; 6 | 7 | export type User = { 8 | id: number; 9 | name: string; 10 | account: { 11 | email: string; 12 | username: string | null; 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /example/frontend/app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import {userByID, userByName} from "../common/endpoints"; 4 | import {User} from "../common/types"; 5 | 6 | interface State { 7 | user: User | null; 8 | } 9 | 10 | export class App extends React.Component { 11 | constructor(props: any) { 12 | super(props); 13 | this.state = {user: null}; 14 | } 15 | 16 | async loadUserByID(id: number) { 17 | const user = await userByID.call(id); 18 | this.setState({user}); 19 | } 20 | 21 | async loadUserByName(name: string) { 22 | const user = await userByName.call({name}); 23 | this.setState({user}); 24 | } 25 | 26 | loadNullUser() { 27 | this.setState({user: null}); 28 | } 29 | 30 | render() { 31 | return ( 32 |
33 | 36 |
37 | 40 |
41 | 44 |
45 |
{JSON.stringify(this.state.user, null, 2)}
46 |
47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /example/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | rickety 5 | 6 | 7 |
8 | 9 | 10 | -------------------------------------------------------------------------------- /example/frontend/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {render} from "react-dom"; 3 | 4 | import {App} from "./app"; 5 | 6 | render(, document.getElementById("root")); 7 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "run-s build start:backend", 4 | "start:backend": "ts-node --files backend", 5 | "build": "webpack --mode=development --display=minimal", 6 | "test": "jest" 7 | }, 8 | "dependencies": { 9 | "@types/express": "^4.16.0", 10 | "chalk": "^2.4.1", 11 | "express": "^4.16.4", 12 | "rickety": "^4.0.1", 13 | "ts-node": "^7.0.1", 14 | "typescript": "^3.1.4" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.1.2", 18 | "@babel/plugin-transform-typescript": "^7.1.0", 19 | "@babel/preset-env": "^7.1.0", 20 | "@types/enzyme": "^3.1.14", 21 | "@types/enzyme-adapter-react-16": "^1.0.3", 22 | "@types/html-webpack-plugin": "^3.2.0", 23 | "@types/react": "^16.4.18", 24 | "@types/react-dom": "^16.0.9", 25 | "@types/webpack": "^4.4.16", 26 | "babel-loader": "^8.0.4", 27 | "enzyme": "^3.7.0", 28 | "enzyme-adapter-react-16": "^1.6.0", 29 | "html-webpack-plugin": "^3.2.0", 30 | "jest": "^23.6.0", 31 | "npm-run-all": "^4.1.3", 32 | "react": "^16.6.0", 33 | "react-dom": "^16.6.0", 34 | "ts-jest": "^23.10.4", 35 | "ts-loader": "^5.2.2", 36 | "webpack": "^4.23.1", 37 | "webpack-cli": "^3.1.2" 38 | }, 39 | "jest": { 40 | "setupFiles": [ 41 | "./testing/setup.ts" 42 | ], 43 | "moduleFileExtensions": [ 44 | "js", 45 | "ts", 46 | "tsx" 47 | ], 48 | "transform": { 49 | "^.+\\.tsx?$": "ts-jest" 50 | }, 51 | "testMatch": [ 52 | "**/testing/*.test.(ts|tsx)" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /example/testing/backend.test.ts: -------------------------------------------------------------------------------- 1 | import {SupertestClient} from "rickety/client/supertest"; 2 | 3 | import {client, userByID} from "../common/endpoints"; 4 | import {app} from "../backend/app"; 5 | 6 | SupertestClient.override(client, app); 7 | 8 | test("userByID endpoint returns a user with a correct ID", async () => { 9 | const id = Math.random(); 10 | 11 | const res = await userByID.call(id); 12 | 13 | expect(res.id).toBe(id); 14 | }); 15 | -------------------------------------------------------------------------------- /example/testing/frontend.test.tsx: -------------------------------------------------------------------------------- 1 | import {mount} from "enzyme"; 2 | import React from "react"; 3 | 4 | import {userByName} from "../common/endpoints"; 5 | import {App} from "../frontend/app"; 6 | 7 | test("button clicks call endpoint correctly", async () => { 8 | const spy = jest.spyOn(userByName, "call"); 9 | spy.mockReturnValue({}); 10 | 11 | const wrapper = mount(); 12 | const button = wrapper.find("#byName"); 13 | button.simulate('click'); 14 | 15 | expect(spy).toHaveBeenCalled(); 16 | }); 17 | -------------------------------------------------------------------------------- /example/testing/integration.test.tsx: -------------------------------------------------------------------------------- 1 | import {mount} from "enzyme"; 2 | import React from "react"; 3 | import {SupertestClient} from "rickety/client/supertest"; 4 | 5 | import {App} from "../frontend/app"; 6 | import {app} from "../backend/app"; 7 | import * as database from "../backend/database"; 8 | import {client} from "../common/endpoints"; 9 | import {User} from "../common/types"; 10 | 11 | SupertestClient.override(client, app); 12 | 13 | const readUser = jest.spyOn(database, "readUser"); 14 | 15 | test("button click displays data from database", async () => { 16 | const user: User = { 17 | id: Math.random(), 18 | name: "Jane Doe", 19 | account: { 20 | email: "jane.doe@example.com", 21 | username: "jane.doe", 22 | } 23 | }; 24 | readUser.mockClear(); 25 | readUser.mockReturnValueOnce(user); 26 | 27 | const wrapper = mount(); 28 | const button = wrapper.find("#byName"); 29 | button.simulate('click'); 30 | 31 | // Wait for component re-render after async "fetch". 32 | // https://github.com/airbnb/enzyme/issues/823 33 | await new Promise(r => setTimeout(r, 100)); 34 | 35 | expect(readUser).toHaveBeenCalled(); 36 | expect(wrapper.text()).toMatch(String(user.id)); 37 | }); 38 | -------------------------------------------------------------------------------- /example/testing/setup.ts: -------------------------------------------------------------------------------- 1 | import {configure} from "enzyme"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | 4 | configure({adapter: new Adapter()}); 5 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es2018", "dom"], 6 | "sourceMap": true, 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "allowSyntheticDefaultImports": true, 16 | "esModuleInterop": true, 17 | "experimentalDecorators": true, 18 | "jsx": "react" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import webpack from "webpack"; 4 | import HtmlWebpackPlugin from "html-webpack-plugin"; 5 | 6 | const config: webpack.Configuration = { 7 | entry: ["./frontend/index.tsx"], 8 | output: { 9 | path: path.resolve("dist"), 10 | }, 11 | devtool: "cheap-module-source-map", 12 | resolve: { 13 | extensions: [".js", ".ts", ".jsx", ".tsx"], 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.tsx?$/, 19 | loader: "ts-loader", 20 | }, 21 | { 22 | test: /\.jsx?$/, 23 | use: { 24 | loader: "babel-loader", 25 | options: { 26 | presets: ["@babel/preset-env"], 27 | }, 28 | }, 29 | }, 30 | ], 31 | }, 32 | plugins: [ 33 | new HtmlWebpackPlugin({ 34 | template: path.resolve("frontend", "index.html"), 35 | }), 36 | ], 37 | }; 38 | 39 | export default config; 40 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export {FetchClient as DefaultClient} from "./client/fetch"; 2 | export {Endpoint} from "./source/endpoint"; 3 | export {Group} from "./source/group"; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rickety", 3 | "version": "4.1.0", 4 | "description": "minimal typescript rpc framework", 5 | "author": "g-harel", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "types": "index.d.ts", 9 | "scripts": { 10 | "test": "jest", 11 | "prepack": "npm run build", 12 | "postpublish": "npm run clean", 13 | "build": "npm run clean && tsc", 14 | "clean": "trash '**/*.js' '**/*.js.map' '**/*.d.ts' '!**/node_modules/**/*'", 15 | "fmt": "prettier --list-different --write --ignore-path .gitignore '**/*.{js,ts}'" 16 | }, 17 | "peerDependencies": { 18 | "@types/express": "4.*.*" 19 | }, 20 | "devDependencies": { 21 | "@types/express": "^4.16.0", 22 | "@types/jest": "^23.3.5", 23 | "@types/request": "^2.48.1", 24 | "@types/supertest": "^2.0.6", 25 | "axios": "^0.18.0", 26 | "express": "^4.16.3", 27 | "jest": "^23.6.0", 28 | "prettier": "^1.14.2", 29 | "request": "^2.88.0", 30 | "supertest": "^3.3.0", 31 | "trash-cli": "^1.4.0", 32 | "ts-jest": "^23.10.3", 33 | "typescript": "^3.1.3" 34 | }, 35 | "homepage": "https://github.com/g-harel/rickety#readme", 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/g-harel/rickety" 39 | }, 40 | "bugs": { 41 | "url": "https://github.com/g-harel/rickety/issues" 42 | }, 43 | "keywords": [ 44 | "typescript", 45 | "ts", 46 | "rpc" 47 | ], 48 | "jest": { 49 | "moduleFileExtensions": [ 50 | "js", 51 | "ts" 52 | ], 53 | "transform": { 54 | "^.+\\.ts$": "ts-jest" 55 | }, 56 | "testMatch": [ 57 | "**/test/**" 58 | ] 59 | }, 60 | "prettier": { 61 | "tabWidth": 4, 62 | "printWidth": 85, 63 | "trailingComma": "all", 64 | "bracketSpacing": false, 65 | "arrowParens": "always" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /source/callable.ts: -------------------------------------------------------------------------------- 1 | export abstract class Callable { 2 | // Exposes request and response types to make them easier 3 | // to access when interacting with a callable object. 4 | // ex. `const req: typeof Callable.$req = {...}` 5 | public get $req(): RQ { 6 | throw new Error(`rickety: Request cannot be accessed as value`); 7 | } 8 | public get $res(): RS { 9 | throw new Error(`rickety: Response cannot be accessed as value`); 10 | } 11 | 12 | abstract call(requestData: RQ): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /source/endpoint.ts: -------------------------------------------------------------------------------- 1 | import {Request, Response} from "express"; 2 | 3 | import {Client, ClientResponse} from "../client"; 4 | import {Callable} from "./callable"; 5 | 6 | // Config object influences the behavior of both the request 7 | // sending and handling logic. It attempts to be flexible 8 | // enough to accommodate arbitrary endpoints (not managed 9 | // by this package). 10 | export interface Config { 11 | // Client is used to send the requests and can be shared 12 | // by multiple endpoints. 13 | client: Client; 14 | 15 | // HTTP method used when handling and making requests. 16 | // Defaults to "POST" if not configured. 17 | method?: string; 18 | 19 | // URL path at which the handler will be registered and 20 | // the requests will be sent. This setting is required. 21 | path: string; 22 | 23 | // Expected returned status code(s). By default, anything 24 | // but a `200` is considered an error. This value is only 25 | // used for making requests and has no influence on the 26 | // handler (which will return `200` by default). 27 | expect?: number | number[]; 28 | 29 | // Type checking functions run before and after 30 | // serializing the objects in both client and server. 31 | // By default any value will be considered correct. 32 | isRequest?: (req: any) => boolean; 33 | isResponse?: (res: any) => boolean; 34 | 35 | // Flag to enable strict JSON marshalling/un-marshalling. 36 | // By default, "raw" strings are detected and handled 37 | // as non-JSON. In strict mode, this would throw a parsing 38 | // error. The issue is relevant if a server is returning 39 | // a plain message `str`. This is not valid JSON and cannot 40 | // be parsed without extra steps. The correct format for 41 | // a JSON string has double quotes `"str"`. 42 | strict?: boolean; 43 | } 44 | 45 | // Request handlers contain the server code that transforms 46 | // typed requests into typed responses. Both express' request 47 | // and response objects are passed to the function to make it 48 | // possible to implement custom behavior like accessing and 49 | // writing headers when necessary. 50 | export interface Handler { 51 | (data: RQ, req: Request, res: Response): Promise | RS; 52 | } 53 | 54 | // Helper to create formatted errors with added information 55 | // about the endpoint instance. 56 | export const err = (endpoint: Endpoint, ...messages: any[]): Error => { 57 | const {method, path} = endpoint; 58 | messages.unshift(`EndpointError (${method} ${path})`); 59 | messages = messages.map((message, i) => { 60 | return " ".repeat(i) + message.toString(); 61 | }); 62 | const e = new Error(messages.join("\n")); 63 | return e; 64 | }; 65 | 66 | // JSON parsing helper which tries a bit harder than the 67 | // default parsing to produce a usable value. 68 | export const parse = (str: string, strict: boolean = false): any => { 69 | try { 70 | return JSON.parse(str); 71 | } catch (e) { 72 | // Strict parsing will not attempt to recover from 73 | // an error. 74 | if (strict) { 75 | throw e; 76 | } 77 | 78 | // Only throw error if input's first non-space char 79 | // is highly likely to be malformed JSON. 80 | const firstChar = str.trimLeft()[0]; 81 | if (firstChar === "{" || firstChar === "[") { 82 | throw e; 83 | } 84 | 85 | return str; 86 | } 87 | }; 88 | 89 | // JSON serialization helper that doesn't put quotes around 90 | // top level strings (unless in strict marshalling mode). 91 | export const stringify = (obj: any, strict: boolean = false): string => { 92 | if (typeof obj === "string" && !strict) { 93 | return obj; 94 | } 95 | return JSON.stringify(obj); 96 | }; 97 | 98 | // An endpoint contains its configuration as well as the types 99 | // of the request and response values. 100 | export class Endpoint extends Callable implements Config { 101 | public readonly client: Client; 102 | public readonly method: string; 103 | public readonly path: string; 104 | public readonly expect: number[]; 105 | public readonly isRequest: (req: any) => boolean; 106 | public readonly isResponse: (res: any) => boolean; 107 | public readonly strict: boolean; 108 | 109 | constructor(config: Config) { 110 | super(); 111 | this.client = config.client; 112 | this.method = config.method || "POST"; 113 | this.path = config.path; 114 | this.expect = [].concat((config.expect as any) || 200); 115 | this.isRequest = config.isRequest || ((() => true) as any); 116 | this.isResponse = config.isResponse || ((() => true) as any); 117 | this.strict = !!config.strict; 118 | } 119 | 120 | // The call function sends requests using the configured 121 | // options. It returns a promise which may throw errors if 122 | // there is an issue with the request process or if the 123 | // status is unexpected. 124 | public async call(requestData: RQ): Promise { 125 | if (!this.isRequest(requestData)) { 126 | throw err(this, "Request type check failed", requestData); 127 | } 128 | 129 | let body: string; 130 | try { 131 | body = stringify(requestData, this.strict); 132 | } catch (e) { 133 | throw err(this, "Could not stringify request data", e); 134 | } 135 | 136 | const url = this.path; 137 | const method = this.method; 138 | const headers = { 139 | "Content-Type": "application/json", 140 | "Content-Length": body.length + "", 141 | }; 142 | 143 | let res: ClientResponse; 144 | try { 145 | res = await this.client.send({method, url, body, headers}); 146 | } catch (e) { 147 | throw err(this, "Request sending failed", e); 148 | } 149 | if ((this.expect as any).indexOf(res.status as any) < 0) { 150 | throw err(this, `Unexpected status: ${res.status}`, res.body); 151 | } 152 | 153 | let responseData: RS; 154 | try { 155 | responseData = parse(res.body, this.strict); 156 | } catch (e) { 157 | throw err(this, "Could not parse response data", e, res.body); 158 | } 159 | 160 | if (!this.isResponse(responseData)) { 161 | throw err(this, "Response type check failed", responseData); 162 | } 163 | 164 | return responseData; 165 | } 166 | 167 | // Handler generator returning an express request handler 168 | // from a config and a request handling function. 169 | public handler(handler: Handler): any { 170 | return async (req: Request, res: Response, next: (err?: any) => void) => { 171 | // Only requests with the correct method are handled. 172 | if (req.method !== this.method) { 173 | return next(); 174 | } 175 | 176 | // Requests with the correct full path are handled. 177 | // If the endpoint's base path is also defined, 178 | // requests with the correct base and path part 179 | // are also handled. 180 | if (req.originalUrl !== this.path) { 181 | return next(); 182 | } 183 | 184 | // Handler is not invoked if a different handler 185 | // has already answered the request. This situation 186 | // is considered an error since the handler should 187 | // have been used. 188 | if (res.headersSent) { 189 | return next(err(this, "Response has already been sent.")); 190 | } 191 | 192 | // Use parsed version if already present. 193 | // This allows for "body-parser" middleware and GCP Cloud Functions. 194 | let requestData: RQ; 195 | if (req.body !== undefined) { 196 | requestData = req.body; 197 | } else { 198 | // Request body is streamed into a string to be parsed. 199 | const rawRequestData = await new Promise((resolve) => { 200 | let data = ""; 201 | req.setEncoding("utf8"); 202 | req.on("data", (chunk) => (data += chunk)); 203 | req.on("end", () => resolve(data)); 204 | }); 205 | 206 | try { 207 | requestData = parse(rawRequestData, this.strict); 208 | } catch (e) { 209 | const msg = "Could not parse request data"; 210 | return next(err(this, msg, e, rawRequestData)); 211 | } 212 | } 213 | 214 | if (!this.isRequest(requestData)) { 215 | return next(err(this, "Request type check failed", requestData)); 216 | } 217 | 218 | let responseData: RS; 219 | try { 220 | responseData = await handler(requestData, req, res); 221 | } catch (e) { 222 | return next(err(this, "Handler error", e)); 223 | } 224 | 225 | if (!this.isResponse(responseData)) { 226 | return next(err(this, "Response type check failed", responseData)); 227 | } 228 | 229 | // Although the handler is given access to the express 230 | // response object, it should not send the data itself. 231 | if (res.headersSent) { 232 | return next(err(this, "Response was sent by handler.")); 233 | } 234 | 235 | let rawResponseData: string = ""; 236 | try { 237 | rawResponseData = stringify(responseData, this.strict); 238 | } catch (e) { 239 | return next(err(this, "Could not stringify response data", e)); 240 | } 241 | 242 | res.status(200); 243 | res.set("Content-Type", "application/json"); 244 | res.send(rawResponseData); 245 | }; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /source/group.ts: -------------------------------------------------------------------------------- 1 | import {Callable} from "./callable"; 2 | 3 | // Plain object containing other plain objects or Callable 4 | // entities. This is the type of the input argument when 5 | // creating a new Group. 6 | export interface GroupTemplate { 7 | [name: string]: GroupTemplate | Callable; 8 | } 9 | 10 | // Transforms the GroupTemplate into a type with the same 11 | // shape, but where the Callable entities are replaced with 12 | // their request types. This type is used when calling an 13 | // instanciated group. 14 | // prettier-ignore 15 | export type GroupRequest = { 16 | [N in keyof G]: 17 | G[N] extends GroupTemplate ? GroupRequest : 18 | G[N] extends Callable ? RQ : 19 | never 20 | }; 21 | 22 | // Transforms the GroupTemplate into a type with the same 23 | // shape, but where the Callable entities are replaced with 24 | // their response types. This type is the return value of 25 | // calling a group. 26 | // prettier-ignore 27 | export type GroupResponse = { 28 | [N in keyof G]: 29 | G[N] extends GroupTemplate ? GroupResponse : 30 | G[N] extends Callable ? RS : 31 | never 32 | }; 33 | 34 | // Helper to read an object's property at the given address. 35 | // The `undefined` value is not treated as incorrect if it 36 | // is found at the final destination. 37 | export const read = (obj: any, addr: string[]): any => { 38 | let current = obj; 39 | for (let i = 0; i < addr.length; i++) { 40 | const key = addr[i]; 41 | 42 | if (i === addr.length - 1) { 43 | return current[key]; 44 | } 45 | 46 | if (current[key] === undefined) { 47 | const lines = [ 48 | `GroupError (invalid request data)`, 49 | ` missing value at [${addr}] in`, 50 | ` ${JSON.stringify(obj, null, 2)}`, 51 | ]; 52 | throw new Error(lines.join("\n")); 53 | } 54 | current = current[key]; 55 | } 56 | }; 57 | 58 | // Helper to write a value in an object at a given address. 59 | // If the address contains missing objects, they are all 60 | // added until the final destination is written to. 61 | export const write = (obj: any, addr: string[], value: any) => { 62 | let current = obj; 63 | for (let i = 0; i < addr.length; i++) { 64 | const key = addr[i]; 65 | 66 | if (i === addr.length - 1) { 67 | current[key] = value; 68 | return; 69 | } 70 | 71 | if (!current[key]) { 72 | current[key] = {}; 73 | } 74 | current = current[key]; 75 | } 76 | }; 77 | 78 | // Groups combine multiple Callable entities into a single one 79 | // which preserves the strongly typed requests and responses. 80 | // Groups can be used inside other groups. 81 | export class Group extends Callable< 82 | GroupRequest, 83 | GroupResponse 84 | > { 85 | private group: G; 86 | 87 | constructor(group: G) { 88 | super(); 89 | this.group = group; 90 | } 91 | 92 | // Recursively travels the template to call all of its 93 | // members with the given request data. The responses are 94 | // collected, formed into the correct type and returned. 95 | public call = async (request: GroupRequest): Promise> => { 96 | // The pending responses and their position in the 97 | // response object is tracked. 98 | const pending: Array> = []; 99 | const position: string[][] = []; 100 | 101 | // Recursively travels the group's template, calling 102 | // the Callable leaf nodes and storing the response 103 | // promise and the address in the template. 104 | const call = (obj: any, addr: string[]): void => { 105 | Object.keys(obj).forEach((key) => { 106 | const current = obj[key]; 107 | const currentAddr = [...addr, key]; 108 | if (current instanceof Callable) { 109 | pending.push(current.call(read(request, currentAddr))); 110 | position.push(currentAddr); 111 | return; 112 | } 113 | call(current, currentAddr); 114 | }); 115 | }; 116 | call(this.group, []); 117 | 118 | // Response object is rebuilt from the results and 119 | // the stored positions. 120 | const response = {}; 121 | const results = await Promise.all(pending); 122 | results.forEach((result, i) => { 123 | write(response, position[i], result); 124 | }); 125 | return response as any; 126 | }; 127 | } 128 | -------------------------------------------------------------------------------- /test/callable.ts: -------------------------------------------------------------------------------- 1 | import {Callable} from "../source/callable"; 2 | 3 | class ConcreteCallable extends Callable { 4 | call = async () => { 5 | return (null as any) as RS; 6 | }; 7 | } 8 | 9 | it("should expose the request type", () => { 10 | const c = new ConcreteCallable(); 11 | let n: typeof c.$req = 0; 12 | n = n; 13 | }); 14 | 15 | it("should expose the response type", () => { 16 | const c = new ConcreteCallable(); 17 | let s: typeof c.$res = ""; 18 | s = s; 19 | }); 20 | 21 | it("should not allow access to $Request member", () => { 22 | const c = new ConcreteCallable(); 23 | expect(() => c.$req).toThrow(/rickety/); 24 | }); 25 | 26 | it("should not allow access to $Response member", () => { 27 | const c = new ConcreteCallable(); 28 | expect(() => c.$res).toThrow(/rickety/); 29 | }); 30 | -------------------------------------------------------------------------------- /test/client/axios.ts: -------------------------------------------------------------------------------- 1 | import * as axios from "axios"; 2 | 3 | import {AxiosClient} from "../../client/axios"; 4 | import {ClientRequest} from "../../client"; 5 | 6 | jest.mock("axios"); 7 | 8 | it("should correctly translate the request", async () => { 9 | const request: ClientRequest = { 10 | body: "test", 11 | headers: { 12 | "Test-Name": "test_value", 13 | }, 14 | method: "DELETE", 15 | url: "/test/path", 16 | }; 17 | 18 | ((axios as any) as jest.Mock).mockResolvedValueOnce({ 19 | text: () => "", 20 | status: 200, 21 | }); 22 | 23 | const client = new AxiosClient(); 24 | await client.send(request); 25 | 26 | expect(axios).toHaveBeenCalledWith( 27 | expect.objectContaining({ 28 | method: request.method, 29 | headers: request.headers, 30 | body: request.body, 31 | }), 32 | ); 33 | }); 34 | 35 | it("should correctly translate the response", async () => { 36 | const body = "test123"; 37 | const response = { 38 | data: body, 39 | status: 301, 40 | }; 41 | 42 | ((axios as any) as jest.Mock).mockResolvedValueOnce(response); 43 | 44 | const client = new AxiosClient(); 45 | const res = await client.send({ 46 | body: "", 47 | headers: {}, 48 | method: "", 49 | url: "", 50 | }); 51 | 52 | expect(res).toMatchObject({ 53 | body, 54 | status: response.status, 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/client/fetch.ts: -------------------------------------------------------------------------------- 1 | import {DefaultClient} from "../.."; 2 | import {FetchClient} from "../../client/fetch"; 3 | import {ClientRequest} from "../../client"; 4 | 5 | const fetch = jest.fn(); 6 | (global as any).fetch = fetch; 7 | 8 | it("should be the default client", () => { 9 | expect(DefaultClient).toBe(FetchClient); 10 | }); 11 | 12 | it("should correctly translate the request", async () => { 13 | const request: ClientRequest = { 14 | body: "test", 15 | headers: { 16 | "Test-Name": "test_value", 17 | }, 18 | method: "DELETE", 19 | url: "/test/path", 20 | }; 21 | 22 | fetch.mockResolvedValueOnce({ 23 | text: () => "", 24 | status: 200, 25 | }); 26 | 27 | const client = new FetchClient(); 28 | await client.send(request); 29 | 30 | expect(fetch).toHaveBeenCalledWith( 31 | request.url, 32 | expect.objectContaining({ 33 | method: request.method, 34 | headers: request.headers, 35 | body: request.body, 36 | }), 37 | ); 38 | }); 39 | 40 | it("should correctly translate the response", async () => { 41 | const body = "test123"; 42 | const response = { 43 | text: () => body, 44 | status: 301, 45 | }; 46 | 47 | fetch.mockResolvedValueOnce(response); 48 | 49 | const client = new FetchClient(); 50 | const res = await client.send({ 51 | body: "", 52 | headers: {}, 53 | method: "", 54 | url: "", 55 | }); 56 | 57 | expect(res).toMatchObject({ 58 | body, 59 | status: response.status, 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/client/supertest.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import {Client} from "../../client"; 4 | import {Endpoint} from "../.."; 5 | import {SupertestClient} from "../../client/supertest"; 6 | 7 | let app: express.Express; 8 | let client: Client; 9 | let endpoint: Endpoint; 10 | 11 | beforeEach(() => { 12 | app = express(); 13 | client = new SupertestClient(app); 14 | endpoint = new Endpoint({client, path: "/" + Math.random()}); 15 | }); 16 | 17 | it("should send requests to the handler", async () => { 18 | const test = jest.fn(); 19 | 20 | app.use(endpoint.handler(test)); 21 | await endpoint.call({}); 22 | 23 | expect(test).toHaveBeenCalled(); 24 | }); 25 | 26 | it("should pass along request headers", async () => { 27 | const headerName = "Test-Name"; 28 | const headerValue = "12345"; 29 | const test = jest.fn(); 30 | 31 | app.use( 32 | endpoint.handler((_, req) => { 33 | test(req.header(headerName)); 34 | return {}; 35 | }), 36 | ); 37 | 38 | const spy = jest.spyOn(client, "send"); 39 | spy.mockImplementation((request) => { 40 | request.headers[headerName] = headerValue; 41 | return new SupertestClient(app).send(request); 42 | }); 43 | 44 | await endpoint.call({}); 45 | 46 | expect(test).toHaveBeenCalledWith(headerValue); 47 | }); 48 | 49 | it("should pass along the request body", async () => { 50 | const payload = {test: true, arr: [0, ""]}; 51 | const test = jest.fn(); 52 | 53 | app.use( 54 | endpoint.handler((data) => { 55 | test(data); 56 | return {}; 57 | }), 58 | ); 59 | await endpoint.call(payload); 60 | 61 | expect(test).toHaveBeenCalledWith(payload); 62 | }); 63 | 64 | it("should return the response data", async () => { 65 | const payload = {test: true, arr: [0, ""]}; 66 | 67 | app.use(endpoint.handler(() => payload)); 68 | const res = await endpoint.call({}); 69 | 70 | expect(res).toEqual(payload); 71 | }); 72 | -------------------------------------------------------------------------------- /test/endpoint.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import supertest from "supertest"; 3 | 4 | import {DefaultClient, Endpoint} from ".."; 5 | import {ClientRequest} from "../client"; 6 | import {Config} from "../source/endpoint"; 7 | 8 | describe("call", () => { 9 | let spy: jest.SpyInstance; 10 | let lastSent: ClientRequest; 11 | let client: DefaultClient; 12 | 13 | beforeEach(() => { 14 | client = new DefaultClient(); 15 | spy = jest.spyOn(client, "send"); 16 | spy.mockImplementation(async (request) => { 17 | lastSent = request; 18 | return {status: 200, body: "{}"}; 19 | }); 20 | }); 21 | 22 | it("should pass along request data to the client", async () => { 23 | const path = "/path123"; 24 | const request = { 25 | test: true, 26 | list: [0, "test", null], 27 | }; 28 | await new Endpoint({client, path}).call(request); 29 | expect(lastSent.headers["Content-Type"]).toContain("application/json"); 30 | expect(lastSent.body).toBe(JSON.stringify(request)); 31 | }); 32 | 33 | it("should use the configured http method", async () => { 34 | const config: Config = { 35 | client, 36 | method: "HEAD", 37 | path: "/path123", 38 | }; 39 | await new Endpoint(config).call({}); 40 | expect(lastSent.method).toBe(config.method); 41 | }); 42 | 43 | it("should throw an error if the status code not expected", async () => { 44 | const config: Config = { 45 | client, 46 | path: "/path123", 47 | expect: [301, 302], 48 | }; 49 | spy.mockReturnValueOnce({ 50 | status: 400, 51 | body: "{}", 52 | }); 53 | const endpoint = new Endpoint(config); 54 | await expect(endpoint.call({})).rejects.toThrow(/status.*400/); 55 | }); 56 | 57 | it("should throw an error if the request has the wrong type", async () => { 58 | const test = jest.fn(() => false); 59 | const config: Config = { 60 | client, 61 | path: "/path123", 62 | isRequest: test, 63 | }; 64 | const endpoint = new Endpoint(config); 65 | const request = {test: true}; 66 | await expect(endpoint.call(request)).rejects.toThrow(/request type check/i); 67 | expect(test).toHaveBeenCalledWith(request); 68 | }); 69 | 70 | it("should throw an error if the response has the wrong type", async () => { 71 | const test = jest.fn(() => false); 72 | const config: Config = { 73 | client, 74 | path: "/path123", 75 | isResponse: test, 76 | }; 77 | const endpoint = new Endpoint(config); 78 | const response = {test: true}; 79 | spy.mockReturnValueOnce({ 80 | status: 200, 81 | body: JSON.stringify(response), 82 | }); 83 | await expect(endpoint.call({})).rejects.toThrow(/response type check/i); 84 | expect(test).toHaveBeenCalledWith(response); 85 | }); 86 | 87 | it("should handle raw string responses correctly", async () => { 88 | const endpoint = new Endpoint({client, path: "/"}); 89 | const body = "test123"; 90 | 91 | spy.mockResolvedValueOnce({body, status: 200}); 92 | await expect(endpoint.call({})).resolves.toBe(body); 93 | 94 | spy.mockResolvedValueOnce({body: "{bad}", status: 200}); 95 | await expect(endpoint.call({})).rejects.toThrow("parse"); 96 | }); 97 | 98 | it("should handle raw string responses correctly in strict mode", async () => { 99 | const endpoint = new Endpoint({client, path: "/", strict: true}); 100 | const body = "test123"; 101 | 102 | spy.mockResolvedValueOnce({body: `"${body}"`, status: 200}); 103 | await expect(endpoint.call({})).resolves.toBe(body); 104 | }); 105 | 106 | it("should correctly send raw string requests", async () => { 107 | const endpoint = new Endpoint({client, path: "/"}); 108 | const request = "test456"; 109 | 110 | spy.mockClear(); 111 | await endpoint.call(request); 112 | 113 | await expect(spy).toHaveBeenCalledWith( 114 | expect.objectContaining({ 115 | body: request, 116 | }), 117 | ); 118 | }); 119 | 120 | it("should correctly send raw string requests in strict mode", async () => { 121 | const endpoint = new Endpoint({client, path: "/", strict: true}); 122 | const request = "test456"; 123 | 124 | spy.mockClear(); 125 | await endpoint.call(request); 126 | 127 | await expect(spy).toHaveBeenCalledWith( 128 | expect.objectContaining({ 129 | body: `"${request}"`, 130 | }), 131 | ); 132 | }); 133 | }); 134 | 135 | describe("handler", () => { 136 | let app: express.Express; 137 | let client: DefaultClient; 138 | 139 | beforeEach(() => { 140 | app = express(); 141 | client = new DefaultClient(); 142 | }); 143 | 144 | it("should run handler when endpoint is called", async () => { 145 | const path = "/path123"; 146 | const test = jest.fn(() => {}); 147 | const endpoint = new Endpoint({client, path}); 148 | app.use(endpoint.handler(test)); 149 | 150 | await supertest(app) 151 | .post(path) 152 | .send("{}"); 153 | expect(test).toHaveBeenCalled(); 154 | }); 155 | 156 | it("should match with the full request path when handling on a base", async () => { 157 | const base = "/very/nested"; 158 | const path = base + "/path"; 159 | const test = jest.fn(() => {}); 160 | const endpoint = new Endpoint({client, path}); 161 | app.use(base, endpoint.handler(test)); 162 | 163 | await supertest(app) 164 | .post(path) 165 | .send("{}"); 166 | expect(test).toHaveBeenCalled(); 167 | }); 168 | 169 | it("should only run handler when endpoint is matched", async () => { 170 | const path1 = "/path1"; 171 | const test1 = jest.fn(() => {}); 172 | const endpoint1 = new Endpoint({ 173 | client, 174 | method: "PUT", 175 | path: path1, 176 | }); 177 | app.use(endpoint1.handler(test1)); 178 | 179 | const test2 = jest.fn(() => {}); 180 | const endpoint2 = new Endpoint({client, path: path1}); 181 | app.use(endpoint2.handler(test2)); 182 | 183 | const path3 = "/path3"; 184 | const test3 = jest.fn(() => {}); 185 | const endpoint3 = new Endpoint({client, path: path3}); 186 | app.use(endpoint3.handler(test3)); 187 | 188 | // Test non-matching path. 189 | await supertest(app) 190 | .post(path3) 191 | .send("{}"); 192 | expect(test1).not.toHaveBeenCalled(); 193 | expect(test2).not.toHaveBeenCalled(); 194 | expect(test3).toHaveBeenCalled(); 195 | 196 | // Test non-matching method. 197 | await supertest(app) 198 | .post(path1) 199 | .send("{}"); 200 | expect(test1).not.toHaveBeenCalled(); 201 | expect(test2).toHaveBeenCalled(); 202 | }); 203 | 204 | it("should call handler with the parsed request payload", async () => { 205 | const path = "/path"; 206 | const test = jest.fn(); 207 | const payload = {test: true, arr: [0, ""]}; 208 | const endpoint = new Endpoint({client, path}); 209 | app.use(endpoint.handler(test)); 210 | 211 | await supertest(app) 212 | .post(path) 213 | .send(JSON.stringify(payload)); 214 | expect(test.mock.calls[0][0]).toEqual(payload); 215 | }); 216 | 217 | it("should respond with stringified response from handler", async () => { 218 | const path = "/path"; 219 | const payload = {test: true, arr: [0, ""]}; 220 | const endpoint = new Endpoint({client, path}); 221 | app.use(endpoint.handler(() => payload)); 222 | 223 | const response = await supertest(app) 224 | .post(path) 225 | .send("{}"); 226 | expect(response.header["content-type"]).toContain("application/json"); 227 | expect(response.body).toEqual(payload); 228 | }); 229 | 230 | it("should use req.body if provided", async () => { 231 | const path = "/path"; 232 | const payload = {test: true, arr: [0, ""]}; 233 | const endpoint = new Endpoint({client, path}); 234 | app.use((req, _, next) => { 235 | req.body = payload; 236 | next(); 237 | }); 238 | app.use(endpoint.handler((req) => req)); 239 | 240 | const response = await supertest(app) 241 | .post(path) 242 | .send(""); 243 | expect(response.body).toEqual(payload); 244 | }); 245 | 246 | it("should defer errors to express", async () => { 247 | const path = "/path"; 248 | const test = jest.fn(); 249 | const error = new Error("test"); 250 | const endpoint = new Endpoint({client, path}); 251 | app.use( 252 | endpoint.handler(() => { 253 | throw error; 254 | }), 255 | ); 256 | app.use((err: any, _0: any, res: any, _1: any) => { 257 | test(err); 258 | res.sendStatus(200); 259 | return; 260 | }); 261 | 262 | await supertest(app) 263 | .post(path) 264 | .send("{}"); 265 | expect(test.mock.calls[0][0].toString()).toMatch(error.toString()); 266 | }); 267 | 268 | it("should throw an error if the request has the wrong type", async () => { 269 | const path = "/path"; 270 | const test = jest.fn(() => false); 271 | const request = {test: true}; 272 | const endpoint = new Endpoint({ 273 | client, 274 | path, 275 | isRequest: test, 276 | }); 277 | 278 | app.use(endpoint.handler(() => null as any)); 279 | const res = await supertest(app) 280 | .post(path) 281 | .send(JSON.stringify(request)); 282 | 283 | expect(res.status).toBe(500); 284 | expect(res.text).toMatch(/request type check/i); 285 | expect(test).toHaveBeenCalledWith(request); 286 | }); 287 | 288 | it("should throw an error if the response has the wrong type", async () => { 289 | const path = "/path"; 290 | const test = jest.fn(() => false); 291 | const response = {test: true}; 292 | const endpoint = new Endpoint({ 293 | client, 294 | path, 295 | isResponse: test, 296 | }); 297 | 298 | app.use(endpoint.handler(() => response)); 299 | 300 | const res = await supertest(app) 301 | .post(path) 302 | .send("{}"); 303 | 304 | expect(res.status).toBe(500); 305 | expect(res.text).toMatch(/response type check/i); 306 | expect(test).toHaveBeenCalledWith(response); 307 | }); 308 | 309 | it("should throw an error if the response is unserializable", async () => { 310 | const path = "/path"; 311 | const response: any = {}; 312 | response.loop = response; 313 | const endpoint = new Endpoint({ 314 | client, 315 | path, 316 | }); 317 | 318 | app.use(endpoint.handler(() => response)); 319 | 320 | const res = await supertest(app) 321 | .post(path) 322 | .send("{}"); 323 | 324 | expect(res.status).toBe(500); 325 | expect(res.text).toMatch(/stringify response data/i); 326 | }); 327 | }); 328 | -------------------------------------------------------------------------------- /test/group.ts: -------------------------------------------------------------------------------- 1 | import {Endpoint, Group} from ".."; 2 | 3 | // Helper to generate endpoints with mocked clients. 4 | const genMockedEndpoint = () => { 5 | return new Endpoint({ 6 | client: { 7 | send: jest.fn(async () => ({ 8 | body: "{}", 9 | status: 200, 10 | })), 11 | }, 12 | path: "/" + Math.random(), 13 | }); 14 | }; 15 | 16 | // Helper to generate endpoints with a client that mirrors 17 | // request data in the response. 18 | const genMirrorEndpoint = () => { 19 | return new Endpoint({ 20 | client: { 21 | send: async (req) => ({ 22 | body: req.body, 23 | status: 200, 24 | }), 25 | }, 26 | path: "/" + Math.random(), 27 | }); 28 | }; 29 | 30 | // 31 | 32 | it("should call all endpoints with the correct data", async () => { 33 | const a = genMockedEndpoint(); 34 | const b = genMockedEndpoint(); 35 | const request = {a: 0, b: 1}; 36 | 37 | const group = new Group({a, b}); 38 | await group.call(request); 39 | 40 | expect(a.client.send).toHaveBeenCalledWith( 41 | expect.objectContaining({ 42 | body: JSON.stringify(request.a), 43 | }), 44 | ); 45 | expect(b.client.send).toHaveBeenCalledWith( 46 | expect.objectContaining({ 47 | body: JSON.stringify(request.b), 48 | }), 49 | ); 50 | }); 51 | 52 | it("should return the correct response data", async () => { 53 | const a = genMockedEndpoint(); 54 | const b = genMockedEndpoint(); 55 | const response = {a: 0, b: 1}; 56 | 57 | (a.client.send as jest.Mock).mockReturnValue({ 58 | body: response.a.toString(), 59 | status: 200, 60 | }); 61 | (b.client.send as jest.Mock).mockReturnValue({ 62 | body: response.b.toString(), 63 | status: 200, 64 | }); 65 | 66 | const group = new Group({a, b}); 67 | const res = await group.call({a: {}, b: {}}); 68 | 69 | expect(res).toEqual(response); 70 | }); 71 | 72 | it("should reject entire response if one call fails", async () => { 73 | const a = genMockedEndpoint(); 74 | const b = genMockedEndpoint(); 75 | const group = new Group({a, b}); 76 | const message = String(Math.random()); 77 | 78 | (b.client.send as jest.Mock).mockImplementationOnce(() => { 79 | throw new Error(message); 80 | }); 81 | 82 | await expect(group.call({a: {}, b: {}})).rejects.toThrow(message); 83 | }); 84 | 85 | it("should correctly handle deeply nested data", async () => { 86 | const group = new Group({ 87 | a: { 88 | a: { 89 | a: { 90 | a: genMirrorEndpoint(), 91 | b: genMirrorEndpoint(), 92 | }, 93 | }, 94 | b: genMirrorEndpoint(), 95 | c: genMirrorEndpoint(), 96 | }, 97 | b: genMirrorEndpoint(), 98 | }); 99 | 100 | const obj: typeof group.$req = { 101 | a: { 102 | a: { 103 | a: { 104 | a: Math.random(), 105 | b: Math.random(), 106 | }, 107 | }, 108 | b: Math.random(), 109 | c: Math.random(), 110 | }, 111 | b: Math.random(), 112 | }; 113 | 114 | await expect(group.call(obj)).resolves.toEqual(obj); 115 | }); 116 | 117 | it("should support nested groups", async () => { 118 | const inner = new Group({ 119 | test: genMirrorEndpoint(), 120 | inside: { 121 | a: genMirrorEndpoint(), 122 | }, 123 | }); 124 | const group = new Group({ 125 | test1: genMirrorEndpoint(), 126 | test2: inner, 127 | test3: { 128 | b: { 129 | c: inner, 130 | }, 131 | }, 132 | }); 133 | 134 | const obj: typeof group.$req = { 135 | test1: Math.random(), 136 | test2: { 137 | test: Math.random(), 138 | inside: { 139 | a: Math.random(), 140 | }, 141 | }, 142 | test3: { 143 | b: { 144 | c: { 145 | test: Math.random(), 146 | inside: { 147 | a: Math.random(), 148 | }, 149 | }, 150 | }, 151 | }, 152 | }; 153 | 154 | await expect(group.call(obj)).resolves.toEqual(obj); 155 | }); 156 | 157 | it("should correctly handle missing request data", async () => { 158 | const group = new Group({ 159 | test: { 160 | test: genMockedEndpoint(), 161 | }, 162 | }); 163 | 164 | await expect(group.call({} as any)).rejects.toThrow("request data"); 165 | }); 166 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "example", 4 | "test", 5 | "node_modules" 6 | ], 7 | "compilerOptions": { 8 | "target": "es5", 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "lib": [ 13 | "es2018", "dom" 14 | ], 15 | "sourceMap": true, 16 | "removeComments": true, 17 | "declaration": true, 18 | "strict": true, 19 | "noImplicitAny": true, 20 | "strictNullChecks": true, 21 | "strictFunctionTypes": true, 22 | "strictPropertyInitialization": true, 23 | "alwaysStrict": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noImplicitReturns": true, 27 | "noFallthroughCasesInSwitch": true, 28 | "allowSyntheticDefaultImports": true, 29 | "stripInternal": true 30 | } 31 | } --------------------------------------------------------------------------------