├── .editorconfig ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── LICENSE ├── README.md ├── dist ├── cacheControl.d.ts ├── cacheControl.js ├── cacheControl.js.map ├── errors.d.ts ├── errors.js ├── errors.js.map ├── formatResults.d.ts ├── formatResults.js ├── formatResults.js.map ├── getMeta.d.ts ├── getMeta.js ├── getMeta.js.map ├── getPath.d.ts ├── getPath.js ├── getPath.js.map ├── getRequestHandler.d.ts ├── getRequestHandler.js ├── getRequestHandler.js.map ├── getSwagger.d.ts ├── getSwagger.js ├── getSwagger.js.map ├── index.d.ts ├── index.js ├── index.js.map ├── methods.d.ts ├── methods.js ├── methods.js.map ├── rewriteLarge.d.ts ├── rewriteLarge.js ├── rewriteLarge.js.map ├── types.d.ts ├── types.js ├── types.js.map ├── walkResources.d.ts ├── walkResources.js └── walkResources.js.map ├── package.json ├── src ├── cacheControl.ts ├── errors.ts ├── formatResults.ts ├── getMeta.ts ├── getPath.ts ├── getRequestHandler.ts ├── getSwagger.ts ├── global.d.ts ├── index.ts ├── methods.ts ├── rewriteLarge.ts ├── types.ts └── walkResources.ts ├── test └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # A special property that should be specified at the top of the file outside of 4 | # any sections. Set to true to stop .editor config file search on current file 5 | root = true 6 | 7 | [*] 8 | # Indentation style 9 | # Possible values - tab, space 10 | indent_style = space 11 | 12 | # Indentation size in single-spaced characters 13 | # Possible values - an integer, tab 14 | indent_size = 2 15 | 16 | # Line ending file format 17 | # Possible values - lf, crlf, cr 18 | end_of_line = lf 19 | 20 | # File character encoding 21 | # Possible values - latin1, utf-8, utf-16be, utf-16le 22 | charset = utf-8 23 | 24 | # Denotes whether to trim whitespace at the end of lines 25 | # Possible values - true, false 26 | trim_trailing_whitespace = true 27 | 28 | # Denotes whether file should end with a newline 29 | # Possible values - true, false 30 | insert_final_newline = true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build 3 | lib-cov 4 | *.seed 5 | *.log 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | _book 11 | 12 | pids 13 | logs 14 | results 15 | 16 | yarn.lock 17 | npm-shrinkwrap.json 18 | npm-debug.log 19 | node_modules 20 | *.sublime* 21 | *.node 22 | coverage 23 | *.orig 24 | .idea 25 | sandbox 26 | docs 27 | package-lock.json 28 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '14' 4 | - '15' 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Contra 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

Build next-generation realtime APIs simply and easily

4 |

5 | 6 | ## Install 7 | 8 | One command and you're ready to make some killer APIs: 9 | 10 | ``` 11 | npm install sutro --save 12 | ``` 13 | 14 | **Now**, check out the [documentation](https://github.com/contra/sutro/tree/master/docs) to get started! 15 | 16 | ## Examples 17 | 18 | ### 10-LOC ES7 API 19 | 20 | ```js 21 | const api = { 22 | user: { 23 | create: async ({ data }) => User.create(data), 24 | find: async ({ options }) => User.findAll(options), 25 | findById: async ({ userId }) => User.findById(userId), 26 | updateById: async ({ userId, data }) => User.updateById(userId, data), 27 | replaceById: async ({ userId, data }) => User.replaceById(userId, data), 28 | deleteById: async ({ userId }) => User.deleteById(userId) 29 | } 30 | } 31 | ``` 32 | 33 | Yields: 34 | 35 | ``` 36 | GET /swagger.json 37 | GET /users 38 | POST /users 39 | GET /users/:userId 40 | PATCH /users/:userId 41 | PUT /users/:userId 42 | DELETE /users/:userId 43 | ``` 44 | -------------------------------------------------------------------------------- /dist/cacheControl.d.ts: -------------------------------------------------------------------------------- 1 | import { CacheOptions } from './types'; 2 | declare const _default: (opt: string | CacheOptions) => string; 3 | export default _default; 4 | -------------------------------------------------------------------------------- /dist/cacheControl.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const parse_duration_1 = __importDefault(require("parse-duration")); 7 | const parseNumber = (v) => { 8 | const n = (typeof v === 'number' ? v : parse_duration_1.default(v)); 9 | if (isNaN(n)) 10 | throw new Error(`Invalid number: ${v}`); 11 | return n / 1000; 12 | }; 13 | exports.default = (opt) => { 14 | if (typeof opt === 'string') 15 | return opt; // already formatted 16 | const stack = []; 17 | if (opt.private) 18 | stack.push('private'); 19 | if (opt.public) 20 | stack.push('public'); 21 | if (opt.noStore) 22 | stack.push('no-store'); 23 | if (opt.noCache) 24 | stack.push('no-cache'); 25 | if (opt.noTransform) 26 | stack.push('no-transform'); 27 | if (opt.proxyRevalidate) 28 | stack.push('proxy-revalidate'); 29 | if (opt.mustRevalidate) 30 | stack.push('proxy-revalidate'); 31 | if (opt.staleIfError) 32 | stack.push(`stale-if-error=${parseNumber(opt.staleIfError)}`); 33 | if (opt.staleWhileRevalidate) 34 | stack.push(`stale-while-revalidate=${parseNumber(opt.staleWhileRevalidate)}`); 35 | if (opt.maxAge) 36 | stack.push(`max-age=${parseNumber(opt.maxAge)}`); 37 | if (opt.sMaxAge) 38 | stack.push(`s-maxage=${parseNumber(opt.sMaxAge)}`); 39 | return stack.join(', '); 40 | }; 41 | //# sourceMappingURL=cacheControl.js.map -------------------------------------------------------------------------------- /dist/cacheControl.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"cacheControl.js","sourceRoot":"","sources":["../src/cacheControl.ts"],"names":[],"mappings":";;;;;AAAA,oEAA0C;AAG1C,MAAM,WAAW,GAAG,CAAC,CAAkB,EAAU,EAAE;IACjD,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,wBAAa,CAAC,CAAC,CAAC,CAAW,CAAA;IAClE,IAAI,KAAK,CAAC,CAAC,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,mBAAmB,CAAC,EAAE,CAAC,CAAA;IACrD,OAAO,CAAC,GAAG,IAAI,CAAA;AACjB,CAAC,CAAA;AAED,kBAAe,CAAC,GAA0B,EAAE,EAAE;IAC5C,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,GAAG,CAAA,CAAC,oBAAoB;IAC5D,MAAM,KAAK,GAAG,EAAE,CAAA;IAEhB,IAAI,GAAG,CAAC,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;IACtC,IAAI,GAAG,CAAC,MAAM;QAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACpC,IAAI,GAAG,CAAC,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;IACvC,IAAI,GAAG,CAAC,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAA;IACvC,IAAI,GAAG,CAAC,WAAW;QAAE,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,CAAA;IAC/C,IAAI,GAAG,CAAC,eAAe;QAAE,KAAK,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAA;IACvD,IAAI,GAAG,CAAC,cAAc;QAAE,KAAK,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAA;IACtD,IAAI,GAAG,CAAC,YAAY;QAClB,KAAK,CAAC,IAAI,CAAC,kBAAkB,WAAW,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,CAAC,CAAA;IAC/D,IAAI,GAAG,CAAC,oBAAoB;QAC1B,KAAK,CAAC,IAAI,CACR,0BAA0B,WAAW,CAAC,GAAG,CAAC,oBAAoB,CAAC,EAAE,CAClE,CAAA;IACH,IAAI,GAAG,CAAC,MAAM;QAAE,KAAK,CAAC,IAAI,CAAC,WAAW,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IAChE,IAAI,GAAG,CAAC,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,YAAY,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IACnE,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACzB,CAAC,CAAA"} -------------------------------------------------------------------------------- /dist/errors.d.ts: -------------------------------------------------------------------------------- 1 | export declare const codes: { 2 | badRequest: number; 3 | unauthorized: number; 4 | forbidden: number; 5 | notFound: number; 6 | serverError: number; 7 | }; 8 | export declare class UnauthorizedError extends Error { 9 | message: string; 10 | status: number; 11 | constructor(message?: string, status?: number); 12 | toString: () => string; 13 | } 14 | export declare class BadRequestError extends Error { 15 | message: string; 16 | status: number; 17 | constructor(message?: string, status?: number); 18 | toString(): string; 19 | } 20 | export declare class ValidationError extends BadRequestError { 21 | fields?: any[]; 22 | constructor(fields?: any[]); 23 | toString(): string; 24 | } 25 | export declare class NotFoundError extends Error { 26 | message: string; 27 | status: number; 28 | constructor(message?: string, status?: number); 29 | toString: () => string; 30 | } 31 | -------------------------------------------------------------------------------- /dist/errors.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.NotFoundError = exports.ValidationError = exports.BadRequestError = exports.UnauthorizedError = exports.codes = void 0; 4 | const util_1 = require("util"); 5 | const inspectOptions = { 6 | depth: 100, 7 | breakLength: Infinity 8 | }; 9 | const serializeIssues = (fields) => fields.map((f) => `\n - ${util_1.inspect(f, inspectOptions)}`); 10 | exports.codes = { 11 | badRequest: 400, 12 | unauthorized: 401, 13 | forbidden: 403, 14 | notFound: 404, 15 | serverError: 500 16 | }; 17 | class UnauthorizedError extends Error { 18 | constructor(message = 'Unauthorized', status = exports.codes.unauthorized) { 19 | super(message); 20 | this.toString = () => `${super.toString()} (HTTP ${this.status})`; 21 | this.message = message; 22 | this.status = status; 23 | Error.captureStackTrace(this, UnauthorizedError); 24 | } 25 | } 26 | exports.UnauthorizedError = UnauthorizedError; 27 | class BadRequestError extends Error { 28 | constructor(message = 'Bad Request', status = exports.codes.badRequest) { 29 | super(message); 30 | this.message = message; 31 | this.status = status; 32 | Error.captureStackTrace(this, BadRequestError); 33 | } 34 | toString() { 35 | return `${super.toString()} (HTTP ${this.status})`; 36 | } 37 | } 38 | exports.BadRequestError = BadRequestError; 39 | class ValidationError extends BadRequestError { 40 | constructor(fields) { 41 | super(); 42 | this.fields = fields; 43 | Error.captureStackTrace(this, ValidationError); 44 | } 45 | toString() { 46 | const original = super.toString(); 47 | if (!this.fields) 48 | return original; // no custom validation 49 | if (Array.isArray(this.fields)) { 50 | return `${original}\nIssues:${serializeIssues(this.fields)}`; 51 | } 52 | return this.fields; 53 | } 54 | } 55 | exports.ValidationError = ValidationError; 56 | class NotFoundError extends Error { 57 | constructor(message = 'Not Found', status = exports.codes.notFound) { 58 | super(message); 59 | this.toString = () => `${super.toString()} (HTTP ${this.status})`; 60 | this.message = message; 61 | this.status = status; 62 | Error.captureStackTrace(this, NotFoundError); 63 | } 64 | } 65 | exports.NotFoundError = NotFoundError; 66 | //# sourceMappingURL=errors.js.map -------------------------------------------------------------------------------- /dist/errors.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":";;;AAAA,+BAA8B;AAE9B,MAAM,cAAc,GAAG;IACrB,KAAK,EAAE,GAAG;IACV,WAAW,EAAE,QAAQ;CACtB,CAAA;AAED,MAAM,eAAe,GAAG,CAAC,MAAa,EAAE,EAAE,CACxC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,cAAO,CAAC,CAAC,EAAE,cAAc,CAAC,EAAE,CAAC,CAAA;AAE5C,QAAA,KAAK,GAAG;IACnB,UAAU,EAAE,GAAG;IACf,YAAY,EAAE,GAAG;IACjB,SAAS,EAAE,GAAG;IACd,QAAQ,EAAE,GAAG;IACb,WAAW,EAAE,GAAG;CACjB,CAAA;AAED,MAAa,iBAAkB,SAAQ,KAAK;IAI1C,YAAY,OAAO,GAAG,cAAc,EAAE,MAAM,GAAG,aAAK,CAAC,YAAY;QAC/D,KAAK,CAAC,OAAO,CAAC,CAAA;QAKhB,aAAQ,GAAG,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC,QAAQ,EAAE,UAAU,IAAI,CAAC,MAAM,GAAG,CAAA;QAJ1D,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;QACpB,KAAK,CAAC,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,CAAC,CAAA;IAClD,CAAC;CAEF;AAXD,8CAWC;AAED,MAAa,eAAgB,SAAQ,KAAK;IAIxC,YAAY,OAAO,GAAG,aAAa,EAAE,MAAM,GAAG,aAAK,CAAC,UAAU;QAC5D,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;QACpB,KAAK,CAAC,iBAAiB,CAAC,IAAI,EAAE,eAAe,CAAC,CAAA;IAChD,CAAC;IACD,QAAQ;QACN,OAAO,GAAG,KAAK,CAAC,QAAQ,EAAE,UAAU,IAAI,CAAC,MAAM,GAAG,CAAA;IACpD,CAAC;CACF;AAbD,0CAaC;AAED,MAAa,eAAgB,SAAQ,eAAe;IAGlD,YAAY,MAAc;QACxB,KAAK,EAAE,CAAA;QACP,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;QACpB,KAAK,CAAC,iBAAiB,CAAC,IAAI,EAAE,eAAe,CAAC,CAAA;IAChD,CAAC;IACD,QAAQ;QACN,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,EAAE,CAAA;QACjC,IAAI,CAAC,IAAI,CAAC,MAAM;YAAE,OAAO,QAAQ,CAAA,CAAC,uBAAuB;QACzD,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE;YAC9B,OAAO,GAAG,QAAQ,YAAY,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAA;SAC7D;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAhBD,0CAgBC;AAED,MAAa,aAAc,SAAQ,KAAK;IAItC,YAAY,OAAO,GAAG,WAAW,EAAE,MAAM,GAAG,aAAK,CAAC,QAAQ;QACxD,KAAK,CAAC,OAAO,CAAC,CAAA;QAKhB,aAAQ,GAAG,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC,QAAQ,EAAE,UAAU,IAAI,CAAC,MAAM,GAAG,CAAA;QAJ1D,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;QACtB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;QACpB,KAAK,CAAC,iBAAiB,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IAC9C,CAAC;CAEF;AAXD,sCAWC"} -------------------------------------------------------------------------------- /dist/formatResults.d.ts: -------------------------------------------------------------------------------- 1 | export declare const format: (inp?: any, meta?: object) => { 2 | results: any[]; 3 | meta: { 4 | results: number; 5 | total: number; 6 | }; 7 | }; 8 | export declare const stream: { 9 | (counter?: Promise, meta?: object): any; 10 | contentType: string; 11 | }; 12 | -------------------------------------------------------------------------------- /dist/formatResults.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.stream = exports.format = void 0; 7 | const jsonstream_next_1 = __importDefault(require("jsonstream-next")); 8 | const isTypeORM = (inp) => Array.isArray(inp) && 9 | inp.length === 2 && 10 | Array.isArray(inp[0]) && 11 | typeof inp[1] === 'number'; 12 | const isSequelize = (inp) => inp.rows && typeof inp.count !== 'undefined'; 13 | const format = (inp = [], meta) => { 14 | let rows; 15 | let count; 16 | if (isSequelize(inp)) { 17 | rows = inp.rows; 18 | count = inp.count; 19 | } 20 | else if (isTypeORM(inp)) { 21 | rows = inp[0]; 22 | count = inp[1]; 23 | } 24 | else if (Array.isArray(inp)) { 25 | rows = inp; 26 | } 27 | else { 28 | throw new Error('Invalid response! Could not format.'); 29 | } 30 | return { 31 | results: rows, 32 | meta: { 33 | results: rows.length, 34 | total: typeof count === 'undefined' 35 | ? rows.length 36 | : Math.max(rows.length, count), 37 | ...meta 38 | } 39 | }; 40 | }; 41 | exports.format = format; 42 | const stream = (counter, meta) => { 43 | let results = 0; 44 | const tail = jsonstream_next_1.default.stringify('{"results":[', ',', (cb) => { 45 | const fin = (res, total) => { 46 | const outMeta = { 47 | results: res, 48 | total, 49 | ...meta 50 | }; 51 | cb(null, `],"meta":${JSON.stringify(outMeta)}}`); 52 | }; 53 | if (!counter) 54 | return fin(results, results); 55 | counter 56 | .then((total) => { 57 | const totalConstrained = Math.max(results, total); // count should never be below results 58 | fin(results, totalConstrained); 59 | }) 60 | .catch((err) => cb(err)); 61 | }); 62 | const origWrite = tail.write; 63 | tail.write = (...a) => { 64 | ++results; 65 | return origWrite.call(tail, ...a); 66 | }; 67 | return tail; 68 | }; 69 | exports.stream = stream; 70 | exports.stream.contentType = 'application/json'; 71 | //# sourceMappingURL=formatResults.js.map -------------------------------------------------------------------------------- /dist/formatResults.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"formatResults.js","sourceRoot":"","sources":["../src/formatResults.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /dist/getMeta.d.ts: -------------------------------------------------------------------------------- 1 | import { Resources, Meta } from './types'; 2 | declare const _default: ({ base, resources }: { 3 | base?: string; 4 | resources: Resources; 5 | }) => Meta; 6 | export default _default; 7 | -------------------------------------------------------------------------------- /dist/getMeta.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const url_join_1 = __importDefault(require("url-join")); 7 | const dot_prop_1 = __importDefault(require("dot-prop")); 8 | const walkResources_1 = __importDefault(require("./walkResources")); 9 | exports.default = ({ base, resources }) => { 10 | const paths = {}; 11 | walkResources_1.default(resources, ({ hierarchy, path, method, instance, endpoint }) => { 12 | if (endpoint?.hidden) 13 | return; // skip 14 | const descriptor = { 15 | path: base ? url_join_1.default(base, path) : path, 16 | method, 17 | instance 18 | }; 19 | dot_prop_1.default.set(paths, hierarchy, descriptor); 20 | }); 21 | return paths; 22 | }; 23 | //# sourceMappingURL=getMeta.js.map -------------------------------------------------------------------------------- /dist/getMeta.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"getMeta.js","sourceRoot":"","sources":["../src/getMeta.ts"],"names":[],"mappings":";;;;;AAAA,wDAA2B;AAC3B,wDAAyB;AACzB,oEAA2C;AAG3C,kBAAe,CAAC,EACd,IAAI,EACJ,SAAS,EAIV,EAAQ,EAAE;IACT,MAAM,KAAK,GAAG,EAAE,CAAA;IAChB,uBAAa,CACX,SAAS,EACT,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE;QAClD,IAAI,QAAQ,EAAE,MAAM;YAAE,OAAM,CAAC,OAAO;QACpC,MAAM,UAAU,GAAG;YACjB,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,kBAAI,CAAC,IAAI,EAAE,IAAc,CAAC,CAAC,CAAC,CAAC,IAAI;YAC9C,MAAM;YACN,QAAQ;SACT,CAAA;QACD,kBAAE,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,UAAU,CAAC,CAAA;IACtC,CAAC,CACF,CAAA;IACD,OAAO,KAAK,CAAA;AACd,CAAC,CAAA"} -------------------------------------------------------------------------------- /dist/getPath.d.ts: -------------------------------------------------------------------------------- 1 | import { getPathArgs } from './types'; 2 | declare const _default: ({ resource, endpoint, instance }: getPathArgs) => string; 3 | export default _default; 4 | -------------------------------------------------------------------------------- /dist/getPath.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const pluralize_1 = __importDefault(require("pluralize")); 7 | const methods_1 = __importDefault(require("./methods")); 8 | exports.default = ({ resource, endpoint, instance }) => { 9 | let path = ''; 10 | if (resource) 11 | path += `/${pluralize_1.default(resource)}`; 12 | if (resource && instance) 13 | path += `/:${resource}Id`; 14 | if (endpoint && !methods_1.default[endpoint]) 15 | path += `/${endpoint}`; 16 | return path; 17 | }; 18 | //# sourceMappingURL=getPath.js.map -------------------------------------------------------------------------------- /dist/getPath.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"getPath.js","sourceRoot":"","sources":["../src/getPath.ts"],"names":[],"mappings":";;;;;AAAA,0DAA8B;AAC9B,wDAA+B;AAG/B,kBAAe,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAe,EAAE,EAAE;IAC/D,IAAI,IAAI,GAAG,EAAE,CAAA;IACb,IAAI,QAAQ;QAAE,IAAI,IAAI,IAAI,mBAAM,CAAC,QAAQ,CAAC,EAAE,CAAA;IAC5C,IAAI,QAAQ,IAAI,QAAQ;QAAE,IAAI,IAAI,KAAK,QAAQ,IAAI,CAAA;IACnD,IAAI,QAAQ,IAAI,CAAC,iBAAO,CAAC,QAAsB,CAAC;QAAE,IAAI,IAAI,IAAI,QAAQ,EAAE,CAAA;IACxE,OAAO,IAAI,CAAA;AACb,CAAC,CAAA"} -------------------------------------------------------------------------------- /dist/getRequestHandler.d.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express'; 2 | import { Trace, ExpressRequest, ResourceRoot, SutroArgs } from './types'; 3 | declare type Args = { 4 | trace?: Trace; 5 | formatResults: SutroArgs['formatResults']; 6 | augmentContext?: SutroArgs['augmentContext']; 7 | }; 8 | declare const _default: (resource: ResourceRoot, { trace, augmentContext, formatResults }: Args) => (req: ExpressRequest, res: Response, next: NextFunction) => Promise; 9 | export default _default; 10 | -------------------------------------------------------------------------------- /dist/getRequestHandler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const handle_async_1 = require("handle-async"); 7 | const stream_1 = require("stream"); 8 | const errors_1 = require("./errors"); 9 | const cacheControl_1 = __importDefault(require("./cacheControl")); 10 | const defaultCacheHeaders = { 11 | private: true, 12 | noCache: true 13 | }; 14 | const responded = (req, res) => res.headersSent || req.timedout; 15 | const traceAsync = async (trace, name, promise) => { 16 | if (!trace) 17 | return promise; // no tracing, just return 18 | const ourTrace = trace.start(name); 19 | try { 20 | const res = await promise; 21 | ourTrace.end(); 22 | return res; 23 | } 24 | catch (err) { 25 | ourTrace.end(); 26 | throw err; 27 | } 28 | }; 29 | const streamResponse = async (stream, req, res, codes, cacheStream) => { 30 | let hasFirstChunk = false; 31 | return new Promise((resolve, reject) => { 32 | let finished = false; 33 | const ourStream = stream_1.pipeline(stream, new stream_1.Transform({ 34 | transform(chunk, _, cb) { 35 | // wait until we get a chunk without an error before writing the headers 36 | if (hasFirstChunk) 37 | return cb(null, chunk); 38 | hasFirstChunk = true; 39 | if (stream.contentType) 40 | res.type(stream.contentType); 41 | res.status(codes.success); 42 | cb(null, chunk); 43 | } 44 | }), (err) => { 45 | finished = true; 46 | if (!err || req.timedout) 47 | return resolve(undefined); // timed out, no point throwing a duplicate error 48 | reject(err); 49 | }); 50 | // make sure we don't keep working if the response closed! 51 | res.once('close', () => { 52 | if (finished) 53 | return; // no need to blow up 54 | ourStream.destroy(new Error('Socket closed before response finished')); 55 | }); 56 | if (cacheStream) { 57 | ourStream.pipe(cacheStream); 58 | ourStream.pause(); 59 | } 60 | // just use a regular pipe to res, since pipeline would close it on error 61 | // which would make us unable to send an error back out 62 | ourStream.pipe(res); 63 | }); 64 | }; 65 | const sendBufferResponse = (resultData, _req, res, codes) => { 66 | res.status(codes.success); 67 | res.type('json'); 68 | if (Buffer.isBuffer(resultData)) { 69 | res.send(resultData); 70 | } 71 | else if (typeof resultData === 'string') { 72 | res.send(Buffer.from(resultData)); 73 | } 74 | else { 75 | res.json(resultData); 76 | } 77 | res.end(); 78 | }; 79 | const sendResponse = async (opt, successCode, resultData, writeCache) => { 80 | const { _res, _req, method, noResponse } = opt; 81 | const codes = { 82 | noResponse: successCode || 204, 83 | success: successCode || 200 84 | }; 85 | // no response 86 | if (resultData == null) { 87 | if (method === 'GET') 88 | throw new errors_1.NotFoundError(); 89 | return _res.status(codes.noResponse).end(); 90 | } 91 | // user asked for no body (save bandwidth) 92 | if (noResponse) { 93 | return _res.status(codes.noResponse).end(); 94 | } 95 | // stream response 96 | if (resultData.pipe && resultData.on) { 97 | const cacheStream = await writeCache(resultData); 98 | await streamResponse(resultData, _req, _res, codes, cacheStream); 99 | return; 100 | } 101 | // json obj response 102 | sendBufferResponse(resultData, _req, _res, codes); 103 | await writeCache(resultData); 104 | }; 105 | const exec = async (req, res, resource, { trace, augmentContext, formatResults }) => { 106 | if (responded(req, res)) 107 | return; 108 | const { endpoint, successCode } = resource; 109 | let opt = { 110 | ...req.params, 111 | ip: req.ip, 112 | url: req.url, 113 | protocol: req.protocol, 114 | method: req.method, 115 | subdomains: req.subdomains, 116 | path: req.path, 117 | headers: req.headers, 118 | cookies: req.cookies, 119 | user: req.user, 120 | data: req.body, 121 | options: req.query, 122 | session: req.session, 123 | noResponse: req.query.response === 'false', 124 | onFinish: (fn) => { 125 | res.once('finish', fn.bind(null, req, res)); 126 | }, 127 | withRaw: (fn) => { 128 | fn(req, res); 129 | }, 130 | _req: req, 131 | _res: res 132 | }; 133 | if (augmentContext) { 134 | opt = await traceAsync(trace, 'sutro/augmentContext', handle_async_1.promisify(augmentContext.bind(null, opt, req, resource))); 135 | } 136 | if (responded(req, res)) 137 | return; 138 | // check isAuthorized 139 | const authorized = !endpoint?.isAuthorized || 140 | (await traceAsync(trace, 'sutro/isAuthorized', handle_async_1.promisify(endpoint.isAuthorized.bind(null, opt)))); 141 | if (authorized !== true) 142 | throw new errors_1.UnauthorizedError(); 143 | if (responded(req, res)) 144 | return; 145 | let resultData; 146 | // check cache 147 | const cacheKey = endpoint?.cache && 148 | endpoint.cache.key && 149 | (await traceAsync(trace, 'sutro/cache.key', handle_async_1.promisify(endpoint.cache.key.bind(null, opt)))); 150 | if (responded(req, res)) 151 | return; 152 | const cachedData = endpoint?.cache && 153 | endpoint.cache.get && 154 | (await traceAsync(trace, 'sutro/cache.get', handle_async_1.promisify(endpoint.cache.get.bind(null, opt, cacheKey)))); 155 | if (responded(req, res)) 156 | return; 157 | // call execute 158 | if (!cachedData) { 159 | const executeFn = typeof endpoint === 'function' ? endpoint : endpoint?.execute; 160 | const rawData = typeof executeFn === 'function' 161 | ? await traceAsync(trace, 'sutro/execute', handle_async_1.promisify(executeFn.bind(null, opt))) 162 | : executeFn || null; 163 | if (responded(req, res)) 164 | return; 165 | // call format on execute result 166 | resultData = endpoint?.format 167 | ? await traceAsync(trace, 'sutro/format', handle_async_1.promisify(endpoint.format.bind(null, opt, rawData))) 168 | : rawData; 169 | if (responded(req, res)) 170 | return; 171 | // call serialize on final result 172 | resultData = formatResults 173 | ? await traceAsync(trace, 'sutro/formatResults', handle_async_1.promisify(formatResults.bind(null, opt, req, endpoint, rawData))) 174 | : resultData; 175 | if (responded(req, res)) 176 | return; 177 | } 178 | else { 179 | resultData = cachedData; 180 | } 181 | // call cacheControl 182 | const cacheHeaders = endpoint?.cache && endpoint.cache.header 183 | ? typeof endpoint.cache.header === 'function' 184 | ? await traceAsync(trace, 'sutro/cache.header', handle_async_1.promisify(endpoint.cache.header.bind(null, opt, resultData))) 185 | : endpoint.cache.header 186 | : defaultCacheHeaders; 187 | if (responded(req, res)) 188 | return; 189 | if (cacheHeaders) 190 | res.set('Cache-Control', cacheControl_1.default(cacheHeaders)); 191 | const writeCache = async (data) => { 192 | if (cachedData || !endpoint?.cache?.set) 193 | return; 194 | return traceAsync(trace, 'sutro/cache.set', handle_async_1.promisify(endpoint.cache.set.bind(null, opt, data, cacheKey))); 195 | }; 196 | await sendResponse(opt, successCode, resultData, writeCache); 197 | }; 198 | exports.default = (resource, { trace, augmentContext, formatResults }) => { 199 | // wrap it so it has a name 200 | const handleAPIRequest = async (req, res, next) => { 201 | if (req.timedout) 202 | return; 203 | try { 204 | await traceAsync(trace, 'sutro/handleAPIRequest', exec(req, res, resource, { trace, augmentContext, formatResults })); 205 | } 206 | catch (err) { 207 | return next(err); 208 | } 209 | }; 210 | return handleAPIRequest; 211 | }; 212 | //# sourceMappingURL=getRequestHandler.js.map -------------------------------------------------------------------------------- /dist/getRequestHandler.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"getRequestHandler.js","sourceRoot":"","sources":["../src/getRequestHandler.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /dist/getSwagger.d.ts: -------------------------------------------------------------------------------- 1 | import { getSwaggerArgs, Swagger } from './types'; 2 | declare const _default: ({ swagger, base, resources }: getSwaggerArgs) => Swagger; 3 | export default _default; 4 | -------------------------------------------------------------------------------- /dist/getSwagger.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const lodash_omit_1 = __importDefault(require("lodash.omit")); 7 | const walkResources_1 = __importDefault(require("./walkResources")); 8 | const param = /:(\w+)/gi; 9 | const getResponses = (method, endpoint) => { 10 | const out = { 11 | 404: { 12 | description: 'Not found' 13 | }, 14 | 500: { 15 | description: 'Server error' 16 | }, 17 | default: { 18 | description: 'Unexpected error' 19 | } 20 | }; 21 | if (method === 'post') { 22 | out['201'] = { 23 | description: 'Success, created' 24 | }; 25 | } 26 | else { 27 | out['200'] = { 28 | description: 'Success' 29 | }; 30 | out['204'] = { 31 | description: 'Success, no data return necessary' 32 | }; 33 | } 34 | if (endpoint.isAuthorized) { 35 | out['401'] = { 36 | description: 'Unauthorized' 37 | }; 38 | } 39 | return out; 40 | }; 41 | const flattenConfig = (base, override) => { 42 | const filtered = lodash_omit_1.default(override, [ 43 | 'consumes', 44 | 'produces', 45 | 'responses', 46 | 'parameters' 47 | ]); 48 | return { 49 | consumes: override.consumes || base.consumes, 50 | produces: override.produces || base.produces, 51 | responses: override.responses 52 | ? { 53 | ...base.responses, 54 | ...override.responses 55 | } 56 | : base.responses, 57 | parameters: override.parameters 58 | ? [...(base.parameters || []), ...override.parameters] 59 | : base.parameters, 60 | ...filtered 61 | }; 62 | }; 63 | const getPaths = (resources) => { 64 | const paths = {}; 65 | walkResources_1.default(resources, ({ path, method, endpoint }) => { 66 | if (endpoint?.hidden || endpoint?.swagger === false) 67 | return; // skip 68 | const params = path?.match(param); 69 | const base = { 70 | consumes: (method !== 'get' && ['application/json']) || undefined, 71 | produces: ['application/json'], 72 | parameters: (params && 73 | params.map((name) => ({ 74 | name: name.slice(1), 75 | in: 'path', 76 | required: true, 77 | type: 'string' 78 | }))) || 79 | undefined, 80 | responses: getResponses(method, endpoint) 81 | }; 82 | const fixedPath = path.replace(param, '{$1}'); 83 | if (!paths[fixedPath]) 84 | paths[fixedPath] = {}; 85 | paths[fixedPath][method] = endpoint?.swagger 86 | ? flattenConfig(base, endpoint?.swagger) 87 | : base; 88 | }); 89 | return paths; 90 | }; 91 | exports.default = ({ swagger = {}, base, resources }) => { 92 | const out = { 93 | swagger: '2.0', 94 | info: { 95 | title: 'Sutro API', 96 | version: '1.0.0' 97 | }, 98 | basePath: base, 99 | schemes: ['http'], 100 | paths: getPaths(resources), 101 | ...swagger 102 | }; 103 | return out; 104 | }; 105 | //# sourceMappingURL=getSwagger.js.map -------------------------------------------------------------------------------- /dist/getSwagger.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"getSwagger.js","sourceRoot":"","sources":["../src/getSwagger.ts"],"names":[],"mappings":";;;;;AAAA,8DAA8B;AAC9B,oEAA2C;AAY3C,MAAM,KAAK,GAAG,UAAU,CAAA;AAExB,MAAM,YAAY,GAAG,CAAC,MAAmB,EAAE,QAAsB,EAAE,EAAE;IACnE,MAAM,GAAG,GAAc;QACrB,GAAG,EAAE;YACH,WAAW,EAAE,WAAW;SACzB;QACD,GAAG,EAAE;YACH,WAAW,EAAE,cAAc;SAC5B;QACD,OAAO,EAAE;YACP,WAAW,EAAE,kBAAkB;SAChC;KACF,CAAA;IAED,IAAI,MAAM,KAAK,MAAM,EAAE;QACrB,GAAG,CAAC,KAAK,CAAC,GAAG;YACX,WAAW,EAAE,kBAAkB;SAChC,CAAA;KACF;SAAM;QACL,GAAG,CAAC,KAAK,CAAC,GAAG;YACX,WAAW,EAAE,SAAS;SACvB,CAAA;QACD,GAAG,CAAC,KAAK,CAAC,GAAG;YACX,WAAW,EAAE,mCAAmC;SACjD,CAAA;KACF;IAED,IAAI,QAAQ,CAAC,YAAY,EAAE;QACzB,GAAG,CAAC,KAAK,CAAC,GAAG;YACX,WAAW,EAAE,cAAc;SAC5B,CAAA;KACF;IACD,OAAO,GAAG,CAAA;AACZ,CAAC,CAAA;AAED,MAAM,aAAa,GAAG,CACpB,IAAmB,EACnB,QAAuB,EACR,EAAE;IACjB,MAAM,QAAQ,GAAG,qBAAI,CAAC,QAAQ,EAAE;QAC9B,UAAU;QACV,UAAU;QACV,WAAW;QACX,YAAY;KACb,CAAC,CAAA;IACF,OAAO;QACL,QAAQ,EAAE,QAAQ,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ;QAC5C,QAAQ,EAAE,QAAQ,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ;QAC5C,SAAS,EAAE,QAAQ,CAAC,SAAS;YAC3B,CAAC,CAAC;gBACE,GAAG,IAAI,CAAC,SAAS;gBACjB,GAAG,QAAQ,CAAC,SAAS;aACtB;YACH,CAAC,CAAC,IAAI,CAAC,SAAS;QAClB,UAAU,EAAE,QAAQ,CAAC,UAAU;YAC7B,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,IAAI,EAAE,CAAC,EAAE,GAAG,QAAQ,CAAC,UAAU,CAAC;YACtD,CAAC,CAAC,IAAI,CAAC,UAAU;QACnB,GAAG,QAAQ;KACZ,CAAA;AACH,CAAC,CAAA;AAED,MAAM,QAAQ,GAAG,CAAC,SAAoB,EAAS,EAAE;IAC/C,MAAM,KAAK,GAAU,EAAE,CAAA;IACvB,uBAAa,CAAC,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,EAAE;QACtD,IAAI,QAAQ,EAAE,MAAM,IAAI,QAAQ,EAAE,OAAO,KAAK,KAAK;YAAE,OAAM,CAAC,OAAO;QACnE,MAAM,MAAM,GAAI,IAAe,EAAE,KAAK,CAAC,KAAK,CAAC,CAAA;QAC7C,MAAM,IAAI,GAAkB;YAC1B,QAAQ,EAAE,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,kBAAkB,CAAC,CAAC,IAAI,SAAS;YACjE,QAAQ,EAAE,CAAC,kBAAkB,CAAC;YAC9B,UAAU,EACR,CAAC,MAAM;gBACL,MAAM,CAAC,GAAG,CAAC,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC;oBAC5B,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;oBACnB,EAAE,EAAE,MAAM;oBACV,QAAQ,EAAE,IAAI;oBACd,IAAI,EAAE,QAAQ;iBACf,CAAC,CAAC,CAAC;gBACN,SAAS;YACX,SAAS,EAAE,YAAY,CAAC,MAAqB,EAAE,QAAwB,CAAC;SACzE,CAAA;QACD,MAAM,SAAS,GAAI,IAAe,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;QACzD,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC;YAAE,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE,CAAA;QAC5C,KAAK,CAAC,SAAS,CAAC,CAAC,MAAqB,CAAC,GAAG,QAAQ,EAAE,OAAO;YACzD,CAAC,CAAC,aAAa,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,CAAC;YACxC,CAAC,CAAC,IAAI,CAAA;IACV,CAAC,CAAC,CAAA;IACF,OAAO,KAAK,CAAA;AACd,CAAC,CAAA;AAED,kBAAe,CAAC,EAAE,OAAO,GAAG,EAAE,EAAE,IAAI,EAAE,SAAS,EAAkB,EAAW,EAAE;IAC5E,MAAM,GAAG,GAAG;QACV,OAAO,EAAE,KAAK;QACd,IAAI,EAAE;YACJ,KAAK,EAAE,WAAW;YAClB,OAAO,EAAE,OAAO;SACjB;QACD,QAAQ,EAAE,IAAI;QACd,OAAO,EAAE,CAAC,MAAM,CAAC;QACjB,KAAK,EAAE,QAAQ,CAAC,SAAS,CAAC;QAC1B,GAAG,OAAO;KACX,CAAA;IACD,OAAO,GAAG,CAAA;AACZ,CAAC,CAAA"} -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import { format, stream } from './formatResults'; 4 | import cacheControl from './cacheControl'; 5 | import { SutroArgs, SutroRouter, EndpointIsAuthorized, EndpointExecute, EndpointFormat, EndpointCache, EndpointHTTP, SutroRequest, SutroStream } from './types'; 6 | export declare const rewriteLargeRequests: (req: import("express").Request>, res: import("express").Response>, next: import("express").NextFunction) => void; 7 | export type { EndpointIsAuthorized, EndpointExecute, EndpointFormat, EndpointCache, EndpointHTTP, SutroRequest, SutroStream }; 8 | export * from './errors'; 9 | export { format as formatResults, stream as formatResultsStream }; 10 | export { cacheControl }; 11 | declare const _default: ({ swagger, base, resources, pre, post, augmentContext, formatResults, trace }: SutroArgs) => SutroRouter; 12 | export default _default; 13 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 3 | if (k2 === undefined) k2 = k; 4 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 5 | }) : (function(o, m, k, k2) { 6 | if (k2 === undefined) k2 = k; 7 | o[k2] = m[k]; 8 | })); 9 | var __exportStar = (this && this.__exportStar) || function(m, exports) { 10 | for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); 11 | }; 12 | var __importDefault = (this && this.__importDefault) || function (mod) { 13 | return (mod && mod.__esModule) ? mod : { "default": mod }; 14 | }; 15 | Object.defineProperty(exports, "__esModule", { value: true }); 16 | exports.cacheControl = exports.formatResultsStream = exports.formatResults = exports.rewriteLargeRequests = void 0; 17 | const express_1 = require("express"); 18 | const handle_async_1 = require("handle-async"); 19 | const stream_1 = require("stream"); 20 | const formatResults_1 = require("./formatResults"); 21 | Object.defineProperty(exports, "formatResults", { enumerable: true, get: function () { return formatResults_1.format; } }); 22 | Object.defineProperty(exports, "formatResultsStream", { enumerable: true, get: function () { return formatResults_1.stream; } }); 23 | const errors_1 = require("./errors"); 24 | const cacheControl_1 = __importDefault(require("./cacheControl")); 25 | exports.cacheControl = cacheControl_1.default; 26 | const getRequestHandler_1 = __importDefault(require("./getRequestHandler")); 27 | const getSwagger_1 = __importDefault(require("./getSwagger")); 28 | const getMeta_1 = __importDefault(require("./getMeta")); 29 | const walkResources_1 = __importDefault(require("./walkResources")); 30 | const rewriteLarge_1 = __importDefault(require("./rewriteLarge")); 31 | // other exports 32 | exports.rewriteLargeRequests = rewriteLarge_1.default; 33 | __exportStar(require("./errors"), exports); 34 | exports.default = ({ swagger, base, resources, pre, post, augmentContext, formatResults, trace }) => { 35 | if (!resources) 36 | throw new Error('Missing resources option'); 37 | const router = express_1.Router({ mergeParams: true }); 38 | router.swagger = getSwagger_1.default({ swagger, base, resources }); 39 | router.meta = getMeta_1.default({ base, resources }); 40 | router.base = base; 41 | router.get('/', (_req, res) => res.status(200).json(router.meta).end()); 42 | router.get('/swagger', (_req, res) => res.status(200).json(router.swagger).end()); 43 | walkResources_1.default(resources, (resource) => { 44 | const handlers = [ 45 | getRequestHandler_1.default(resource, { augmentContext, formatResults, trace }) 46 | ]; 47 | if (pre) { 48 | handlers.unshift(async (req, res, next) => { 49 | const ourTrace = trace && trace.start('sutro/pre'); 50 | try { 51 | await handle_async_1.promisify(pre.bind(null, resource, req, res)); 52 | } 53 | catch (err) { 54 | if (ourTrace) 55 | ourTrace.end(); 56 | return next(err); 57 | } 58 | if (ourTrace) 59 | ourTrace.end(); 60 | next(); 61 | }); 62 | } 63 | if (post) { 64 | handlers.unshift(async (req, res, next) => { 65 | stream_1.finished(res, async (err) => { 66 | const ourTrace = trace && trace.start('sutro/post'); 67 | try { 68 | await handle_async_1.promisify(post.bind(null, resource, req, res, err)); 69 | } 70 | catch (err) { 71 | if (ourTrace) 72 | ourTrace.end(); 73 | } 74 | if (ourTrace) 75 | ourTrace.end(); 76 | }); 77 | next(); 78 | }); 79 | } 80 | router[resource.method](resource.path, ...handlers); 81 | }); 82 | // handle 404s 83 | router.use((_req, _res, next) => next(new errors_1.NotFoundError())); 84 | return router; 85 | }; 86 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} -------------------------------------------------------------------------------- /dist/methods.d.ts: -------------------------------------------------------------------------------- 1 | import { Methods } from './types'; 2 | declare const methods: Methods; 3 | export default methods; 4 | -------------------------------------------------------------------------------- /dist/methods.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const methods = { 4 | find: { 5 | method: 'get', 6 | instance: false 7 | }, 8 | create: { 9 | method: 'post', 10 | instance: false, 11 | successCode: 201 12 | }, 13 | findById: { 14 | method: 'get', 15 | instance: true 16 | }, 17 | replaceById: { 18 | method: 'put', 19 | instance: true 20 | }, 21 | updateById: { 22 | method: 'patch', 23 | instance: true 24 | }, 25 | deleteById: { 26 | method: 'delete', 27 | instance: true 28 | } 29 | }; 30 | exports.default = methods; 31 | //# sourceMappingURL=methods.js.map -------------------------------------------------------------------------------- /dist/methods.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"methods.js","sourceRoot":"","sources":["../src/methods.ts"],"names":[],"mappings":";;AAEA,MAAM,OAAO,GAAY;IACvB,IAAI,EAAE;QACJ,MAAM,EAAE,KAAK;QACb,QAAQ,EAAE,KAAK;KAChB;IACD,MAAM,EAAE;QACN,MAAM,EAAE,MAAM;QACd,QAAQ,EAAE,KAAK;QACf,WAAW,EAAE,GAAG;KACjB;IACD,QAAQ,EAAE;QACR,MAAM,EAAE,KAAK;QACb,QAAQ,EAAE,IAAI;KACf;IACD,WAAW,EAAE;QACX,MAAM,EAAE,KAAK;QACb,QAAQ,EAAE,IAAI;KACf;IACD,UAAU,EAAE;QACV,MAAM,EAAE,OAAO;QACf,QAAQ,EAAE,IAAI;KACf;IACD,UAAU,EAAE;QACV,MAAM,EAAE,QAAQ;QAChB,QAAQ,EAAE,IAAI;KACf;CACF,CAAA;AAED,kBAAe,OAAO,CAAA"} -------------------------------------------------------------------------------- /dist/rewriteLarge.d.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | declare const _default: (req: Request, res: Response, next: NextFunction) => void; 3 | export default _default; 4 | -------------------------------------------------------------------------------- /dist/rewriteLarge.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | // allow people to send a POST and alias it into a GET 4 | // this is a work-around for really large queries 5 | // this is similar to how method-override works but more opinionated 6 | exports.default = (req, res, next) => { 7 | if (req.method.toLowerCase() !== 'post') 8 | return next(); 9 | const override = req.get('x-http-method-override'); 10 | if (!override || override.toLowerCase() !== 'get') 11 | return next(); 12 | // work 13 | req.method = 'GET'; 14 | req.query = { 15 | ...req.query, 16 | ...req.body 17 | }; 18 | delete req.body; 19 | next(); 20 | }; 21 | //# sourceMappingURL=rewriteLarge.js.map -------------------------------------------------------------------------------- /dist/rewriteLarge.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"rewriteLarge.js","sourceRoot":"","sources":["../src/rewriteLarge.ts"],"names":[],"mappings":";;AAGA,sDAAsD;AACtD,iDAAiD;AACjD,oEAAoE;AACpE,kBAAe,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAE,EAAE;IACjE,IAAI,GAAG,CAAC,MAAM,CAAC,WAAW,EAAE,KAAK,MAAM;QAAE,OAAO,IAAI,EAAE,CAAA;IACtD,MAAM,QAAQ,GAAG,GAAG,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAA;IAClD,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,WAAW,EAAE,KAAK,KAAK;QAAE,OAAO,IAAI,EAAE,CAAA;IAEhE,OAAO;IACP,GAAG,CAAC,MAAM,GAAG,KAAK,CAAA;IAClB,GAAG,CAAC,KAAK,GAAG;QACV,GAAG,GAAG,CAAC,KAAK;QACZ,GAAG,GAAG,CAAC,IAAI;KACZ,CAAA;IACD,OAAO,GAAG,CAAC,IAAI,CAAA;IACf,IAAI,EAAE,CAAA;AACR,CAAC,CAAA"} -------------------------------------------------------------------------------- /dist/types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { IRouter, Request, Response } from 'express'; 3 | import { Readable } from 'stream'; 4 | export declare type PathParams = string | RegExp | Array; 5 | export declare type CacheOptions = { 6 | private?: boolean; 7 | public?: boolean; 8 | noStore?: boolean; 9 | noCache?: boolean; 10 | noTransform?: boolean; 11 | proxyRevalidate?: boolean; 12 | mustRevalidate?: boolean; 13 | staleIfError?: number | string; 14 | staleWhileRevalidate?: number | string; 15 | maxAge?: number | string; 16 | sMaxAge?: number | string; 17 | }; 18 | export declare type MethodKeys = 'find' | 'create' | 'findById' | 'replaceById' | 'updateById' | 'deleteById'; 19 | export declare type MethodVerbs = 'get' | 'post' | 'put' | 'patch' | 'delete'; 20 | export declare type Methods = { 21 | [key: string]: { 22 | method: MethodVerbs; 23 | instance: boolean; 24 | successCode?: number; 25 | }; 26 | }; 27 | export declare type ResourceRoot = { 28 | hidden?: boolean; 29 | path: PathParams; 30 | method: string; 31 | instance: boolean; 32 | swagger?: SwaggerConfig; 33 | isAuthorized?: EndpointIsAuthorized; 34 | execute: EndpointExecute; 35 | format?: EndpointFormat; 36 | cache?: EndpointCache; 37 | http: EndpointHTTP; 38 | endpoint: ResourceRoot; 39 | successCode?: number; 40 | hierarchy: string; 41 | }; 42 | export declare type Resource = { 43 | [key: string]: ResourceRoot; 44 | }; 45 | export declare type Resources = { 46 | [key: string]: any; 47 | }; 48 | export declare type Handler = (args: ResourceRoot) => void; 49 | export declare type walkResourceArgs = { 50 | base?: string; 51 | name: string; 52 | resource: ResourceRoot; 53 | hierarchy?: string; 54 | handler: Handler; 55 | }; 56 | export declare type getPathArgs = { 57 | resource: string; 58 | endpoint?: string; 59 | instance: boolean; 60 | }; 61 | export declare type Paths = { 62 | [key: string]: { 63 | [key in MethodVerbs]?: SwaggerConfig; 64 | }; 65 | }; 66 | export declare type MetaRoot = { 67 | path?: PathParams; 68 | method?: MethodVerbs; 69 | instance?: boolean; 70 | [Key: string]: any; 71 | }; 72 | export declare type Meta = { 73 | [key: string]: Meta | MetaRoot; 74 | }; 75 | export declare type getSwaggerArgs = { 76 | swagger: Swagger; 77 | base?: string; 78 | resources: Resources; 79 | }; 80 | export declare type Swagger = { 81 | swagger?: string; 82 | info?: { 83 | title?: string; 84 | version?: string; 85 | termsOfService?: string; 86 | contact?: { 87 | name?: string; 88 | url?: string; 89 | }; 90 | description?: string; 91 | }; 92 | basePath?: string; 93 | schemes?: string[]; 94 | paths?: { 95 | [key: string]: { 96 | [key: string]: SwaggerConfig; 97 | }; 98 | }; 99 | }; 100 | export declare type SwaggerConfig = { 101 | consumes?: string[]; 102 | produces?: string[]; 103 | parameters?: { 104 | name: string; 105 | in: string; 106 | required: boolean; 107 | type: string; 108 | }[] | undefined; 109 | responses?: Responses; 110 | operationId?: string; 111 | summary?: string; 112 | description?: string; 113 | }; 114 | export declare type Trace = { 115 | start: (name: string) => Trace; 116 | end: () => Trace; 117 | }; 118 | export declare type SutroArgs = { 119 | base?: string; 120 | resources: Resources; 121 | swagger?: Swagger; 122 | pre?: (resource: ResourceRoot, req: Request, res: Response) => void; 123 | post?: (resource: ResourceRoot, req: Request, res: Response, err?: any) => void; 124 | augmentContext?: (context: SutroRequest, req: Request, resource: ResourceRoot) => Promise | SutroRequest; 125 | formatResults?: (context: SutroRequest, req: Request, resource: ResourceRoot, rawData: any) => void; 126 | trace?: Trace; 127 | }; 128 | export interface SutroRouter extends IRouter { 129 | swagger?: Swagger; 130 | meta?: Meta; 131 | base?: string; 132 | } 133 | export declare type ResponseStatusKeys = 'default' | '200' | '201' | '204' | '401' | '404' | '500'; 134 | export declare type Responses = { 135 | [key in ResponseStatusKeys]?: { 136 | description: string; 137 | }; 138 | }; 139 | export interface ExpressRequest extends Request { 140 | timedout: boolean; 141 | user?: any; 142 | session?: any; 143 | } 144 | export interface SutroRequest { 145 | ip: Request['ip']; 146 | url: Request['url']; 147 | protocol: Request['protocol']; 148 | method: Request['method']; 149 | subdomains: Request['subdomains']; 150 | path: Request['path']; 151 | headers: Request['headers']; 152 | cookies: Request['cookies']; 153 | data?: any; 154 | options: Request['query']; 155 | session?: any; 156 | noResponse?: boolean; 157 | onFinish?: (fn: (req: Request, res: Response) => void) => void; 158 | withRaw?: (fn: (req: Request, res: Response) => void) => void; 159 | _req: ExpressRequest; 160 | _res: Response; 161 | [key: string]: any; 162 | } 163 | export interface SutroStream extends Readable { 164 | contentType?: string; 165 | } 166 | export declare type EndpointIsAuthorized = (opt: SutroRequest) => Promise | boolean; 167 | export declare type EndpointExecute = (opt: SutroRequest) => Promise | any; 168 | export declare type EndpointFormat = (opt: SutroRequest, rawData: any) => Promise | any; 169 | export declare type EndpointCache = { 170 | header?: CacheOptions | (() => CacheOptions); 171 | key?: () => string; 172 | get?: (opt: SutroRequest | string, key: string) => Promise | any; 173 | set?: (opt: SutroRequest | string, data: any, key: string) => Promise | any; 174 | }; 175 | export declare type EndpointHTTP = { 176 | method: MethodVerbs; 177 | instance: boolean; 178 | }; 179 | -------------------------------------------------------------------------------- /dist/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | //# sourceMappingURL=types.js.map -------------------------------------------------------------------------------- /dist/types.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /dist/walkResources.d.ts: -------------------------------------------------------------------------------- 1 | import { Resources, Handler } from './types'; 2 | declare const _default: (resources: Resources, handler: Handler) => void; 3 | export default _default; 4 | -------------------------------------------------------------------------------- /dist/walkResources.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | const url_join_1 = __importDefault(require("url-join")); 7 | const getPath_1 = __importDefault(require("./getPath")); 8 | const methods_1 = __importDefault(require("./methods")); 9 | const idxd = (o) => o.index || o; 10 | const walkResource = ({ base, name, resource, hierarchy, handler }) => { 11 | const res = idxd(resource); 12 | // sort custom stuff first 13 | const endpointNames = []; 14 | Object.keys(res).forEach((k) => methods_1.default[k] ? endpointNames.push(k) : endpointNames.unshift(k)); 15 | endpointNames.forEach((endpointName) => { 16 | const endpoint = res[endpointName]; 17 | const methodInfo = endpoint.http || methods_1.default[endpointName]; 18 | if (!methodInfo) { 19 | // TODO: error if still nothing found 20 | const newBase = getPath_1.default({ resource: name, instance: true }); 21 | walkResource({ 22 | base: base ? url_join_1.default(base, newBase) : newBase, 23 | name: endpointName, 24 | resource: endpoint, 25 | hierarchy: hierarchy ? `${hierarchy}.${name}` : name, 26 | handler 27 | }); 28 | return; 29 | } 30 | const path = endpoint.path || 31 | getPath_1.default({ 32 | resource: name, 33 | endpoint: endpointName, 34 | instance: methodInfo.instance 35 | }); 36 | const fullPath = base ? url_join_1.default(base, path) : path; 37 | handler({ 38 | hierarchy: hierarchy 39 | ? `${hierarchy}.${name}.${endpointName}` 40 | : `${name}.${endpointName}`, 41 | path: fullPath, 42 | endpoint, 43 | ...methodInfo 44 | }); 45 | }); 46 | }; 47 | exports.default = (resources, handler) => { 48 | Object.keys(idxd(resources)).forEach((resourceName) => { 49 | walkResource({ 50 | name: resourceName, 51 | resource: resources[resourceName], 52 | handler 53 | }); 54 | }); 55 | }; 56 | //# sourceMappingURL=walkResources.js.map -------------------------------------------------------------------------------- /dist/walkResources.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"walkResources.js","sourceRoot":"","sources":["../src/walkResources.ts"],"names":[],"mappings":";;;;;AAAA,wDAA2B;AAC3B,wDAA+B;AAC/B,wDAA+B;AAG/B,MAAM,IAAI,GAAG,CAAC,CAAY,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAA;AAE3C,MAAM,YAAY,GAAG,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,OAAO,EACU,EAAE,EAAE;IACrB,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAA;IAE1B,0BAA0B;IAC1B,MAAM,aAAa,GAAa,EAAE,CAAA;IAClC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAC7B,iBAAO,CAAC,CAAe,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC,CAC5E,CAAA;IAED,aAAa,CAAC,OAAO,CAAC,CAAC,YAAY,EAAE,EAAE;QACrC,MAAM,QAAQ,GAAG,GAAG,CAAC,YAAY,CAAC,CAAA;QAClC,MAAM,UAAU,GAAG,QAAQ,CAAC,IAAI,IAAI,iBAAO,CAAC,YAA0B,CAAC,CAAA;QACvE,IAAI,CAAC,UAAU,EAAE;YACf,qCAAqC;YACrC,MAAM,OAAO,GAAG,iBAAO,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;YAC3D,YAAY,CAAC;gBACX,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,kBAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO;gBAC1C,IAAI,EAAE,YAAY;gBAClB,QAAQ,EAAE,QAAQ;gBAClB,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,SAAS,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI;gBACpD,OAAO;aACR,CAAC,CAAA;YACF,OAAM;SACP;QACD,MAAM,IAAI,GACR,QAAQ,CAAC,IAAI;YACb,iBAAO,CAAC;gBACN,QAAQ,EAAE,IAAI;gBACd,QAAQ,EAAE,YAAY;gBACtB,QAAQ,EAAE,UAAU,CAAC,QAAQ;aAC9B,CAAC,CAAA;QACJ,MAAM,QAAQ,GAAG,IAAI,CAAC,CAAC,CAAC,kBAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;QAC/C,OAAO,CAAC;YACN,SAAS,EAAE,SAAS;gBAClB,CAAC,CAAC,GAAG,SAAS,IAAI,IAAI,IAAI,YAAY,EAAE;gBACxC,CAAC,CAAC,GAAG,IAAI,IAAI,YAAY,EAAE;YAC7B,IAAI,EAAE,QAAQ;YACd,QAAQ;YACR,GAAG,UAAU;SACd,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC,CAAA;AAED,kBAAe,CAAC,SAAoB,EAAE,OAAgB,EAAE,EAAE;IACxD,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,YAAY,EAAE,EAAE;QACpD,YAAY,CAAC;YACX,IAAI,EAAE,YAAY;YAClB,QAAQ,EAAE,SAAS,CAAC,YAAY,CAAC;YACjC,OAAO;SACR,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC,CAAA"} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sutro", 3 | "version": "7.2.7", 4 | "description": "API Resource Framework", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "keywords": [ 8 | "api", 9 | "realtime", 10 | "rest", 11 | "http", 12 | "express", 13 | "middleware", 14 | "streaming" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/contra/sutro.git" 19 | }, 20 | "author": "Contra (https://contra.io)", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/contra/sutro/issues" 24 | }, 25 | "homepage": "https://github.com/contra/sutro#readme", 26 | "files": [ 27 | "dist" 28 | ], 29 | "engines": { 30 | "node": ">=12" 31 | }, 32 | "scripts": { 33 | "docs": "typedoc src/index.ts --theme minimal && gh-pages -d docs", 34 | "lint": "prettier --write .", 35 | "build": "npm run clean && tsc -b", 36 | "clean": "rimraf dist", 37 | "test": "mocha --require ts-node/register --recursive --reporter spec test/*.ts" 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "pretty-quick --staged" 42 | } 43 | }, 44 | "devDependencies": { 45 | "@types/express": "^4.17.11", 46 | "@types/lodash.omit": "^4.5.6", 47 | "@types/mocha": "^9.0.0", 48 | "@types/node": "^16.0.0", 49 | "@types/pluralize": "^0.0.29", 50 | "@types/url-join": "^4.0.0", 51 | "gh-pages": "^3.1.0", 52 | "husky": "^4.3.8", 53 | "lodash.pick": "^4.4.0", 54 | "mocha": "^9.0.0", 55 | "prettier": "^2.2.1", 56 | "pretty-quick": "^3.1.0", 57 | "rimraf": "^3.0.2", 58 | "should": "^13.0.0", 59 | "supertest": "^6.0.0", 60 | "swagger-parser": "^6.0.0", 61 | "ts-node": "^10.0.0", 62 | "typedoc": "^0.21.5", 63 | "typescript": "^4.2.3" 64 | }, 65 | "dependencies": { 66 | "dot-prop": "^6.0.0", 67 | "express": "^4.16.3", 68 | "handle-async": "^1.0.1", 69 | "jsonstream-next": "^3.0.0", 70 | "lodash.omit": "^4.5.0", 71 | "parse-duration": "^1.0.0", 72 | "pluralize": "^8.0.0", 73 | "url-join": "^4.0.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/cacheControl.ts: -------------------------------------------------------------------------------- 1 | import parseDuration from 'parse-duration' 2 | import { CacheOptions } from './types' 3 | 4 | const parseNumber = (v: number | string): number => { 5 | const n = (typeof v === 'number' ? v : parseDuration(v)) as number 6 | if (isNaN(n)) throw new Error(`Invalid number: ${v}`) 7 | return n / 1000 8 | } 9 | 10 | export default (opt: string | CacheOptions) => { 11 | if (typeof opt === 'string') return opt // already formatted 12 | const stack = [] 13 | 14 | if (opt.private) stack.push('private') 15 | if (opt.public) stack.push('public') 16 | if (opt.noStore) stack.push('no-store') 17 | if (opt.noCache) stack.push('no-cache') 18 | if (opt.noTransform) stack.push('no-transform') 19 | if (opt.proxyRevalidate) stack.push('proxy-revalidate') 20 | if (opt.mustRevalidate) stack.push('proxy-revalidate') 21 | if (opt.staleIfError) 22 | stack.push(`stale-if-error=${parseNumber(opt.staleIfError)}`) 23 | if (opt.staleWhileRevalidate) 24 | stack.push( 25 | `stale-while-revalidate=${parseNumber(opt.staleWhileRevalidate)}` 26 | ) 27 | if (opt.maxAge) stack.push(`max-age=${parseNumber(opt.maxAge)}`) 28 | if (opt.sMaxAge) stack.push(`s-maxage=${parseNumber(opt.sMaxAge)}`) 29 | return stack.join(', ') 30 | } 31 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | const inspectOptions = { 4 | depth: 100, 5 | breakLength: Infinity 6 | } 7 | 8 | const serializeIssues = (fields: any[]) => 9 | fields.map((f) => `\n - ${inspect(f, inspectOptions)}`) 10 | 11 | export const codes = { 12 | badRequest: 400, 13 | unauthorized: 401, 14 | forbidden: 403, 15 | notFound: 404, 16 | serverError: 500 17 | } 18 | 19 | export class UnauthorizedError extends Error { 20 | message: string 21 | status: number 22 | 23 | constructor(message = 'Unauthorized', status = codes.unauthorized) { 24 | super(message) 25 | this.message = message 26 | this.status = status 27 | Error.captureStackTrace(this, UnauthorizedError) 28 | } 29 | toString = () => `${super.toString()} (HTTP ${this.status})` 30 | } 31 | 32 | export class BadRequestError extends Error { 33 | message: string 34 | status: number 35 | 36 | constructor(message = 'Bad Request', status = codes.badRequest) { 37 | super(message) 38 | this.message = message 39 | this.status = status 40 | Error.captureStackTrace(this, BadRequestError) 41 | } 42 | toString() { 43 | return `${super.toString()} (HTTP ${this.status})` 44 | } 45 | } 46 | 47 | export class ValidationError extends BadRequestError { 48 | fields?: any[] 49 | 50 | constructor(fields?: any[]) { 51 | super() 52 | this.fields = fields 53 | Error.captureStackTrace(this, ValidationError) 54 | } 55 | toString() { 56 | const original = super.toString() 57 | if (!this.fields) return original // no custom validation 58 | if (Array.isArray(this.fields)) { 59 | return `${original}\nIssues:${serializeIssues(this.fields)}` 60 | } 61 | return this.fields 62 | } 63 | } 64 | 65 | export class NotFoundError extends Error { 66 | message: string 67 | status: number 68 | 69 | constructor(message = 'Not Found', status = codes.notFound) { 70 | super(message) 71 | this.message = message 72 | this.status = status 73 | Error.captureStackTrace(this, NotFoundError) 74 | } 75 | toString = () => `${super.toString()} (HTTP ${this.status})` 76 | } 77 | -------------------------------------------------------------------------------- /src/formatResults.ts: -------------------------------------------------------------------------------- 1 | import JSONStream from 'jsonstream-next' 2 | 3 | const isTypeORM = (inp: any) => 4 | Array.isArray(inp) && 5 | inp.length === 2 && 6 | Array.isArray(inp[0]) && 7 | typeof inp[1] === 'number' 8 | const isSequelize = (inp: any) => inp.rows && typeof inp.count !== 'undefined' 9 | 10 | export const format = (inp: any = [], meta?: object) => { 11 | let rows: any[] 12 | let count: number 13 | if (isSequelize(inp)) { 14 | rows = inp.rows 15 | count = inp.count 16 | } else if (isTypeORM(inp)) { 17 | rows = inp[0] 18 | count = inp[1] 19 | } else if (Array.isArray(inp)) { 20 | rows = inp 21 | } else { 22 | throw new Error('Invalid response! Could not format.') 23 | } 24 | return { 25 | results: rows, 26 | meta: { 27 | results: rows.length, 28 | total: 29 | typeof count === 'undefined' 30 | ? rows.length 31 | : Math.max(rows.length, count), // count should never be below results 32 | ...meta 33 | } 34 | } 35 | } 36 | 37 | export const stream = (counter?: Promise, meta?: object) => { 38 | let results = 0 39 | const tail = JSONStream.stringify('{"results":[', ',', (cb) => { 40 | const fin = (res: number, total: number) => { 41 | const outMeta = { 42 | results: res, 43 | total, 44 | ...meta 45 | } 46 | cb(null, `],"meta":${JSON.stringify(outMeta)}}`) 47 | } 48 | if (!counter) return fin(results, results) 49 | counter 50 | .then((total) => { 51 | const totalConstrained = Math.max(results, total) // count should never be below results 52 | fin(results, totalConstrained) 53 | }) 54 | .catch((err) => cb(err)) 55 | }) 56 | const origWrite = tail.write 57 | tail.write = (...a) => { 58 | ++results 59 | return origWrite.call(tail, ...a) 60 | } 61 | return tail 62 | } 63 | stream.contentType = 'application/json' 64 | -------------------------------------------------------------------------------- /src/getMeta.ts: -------------------------------------------------------------------------------- 1 | import join from 'url-join' 2 | import dp from 'dot-prop' 3 | import walkResources from './walkResources' 4 | import { Resources, Meta } from './types' 5 | 6 | export default ({ 7 | base, 8 | resources 9 | }: { 10 | base?: string 11 | resources: Resources 12 | }): Meta => { 13 | const paths = {} 14 | walkResources( 15 | resources, 16 | ({ hierarchy, path, method, instance, endpoint }) => { 17 | if (endpoint?.hidden) return // skip 18 | const descriptor = { 19 | path: base ? join(base, path as string) : path, 20 | method, 21 | instance 22 | } 23 | dp.set(paths, hierarchy, descriptor) 24 | } 25 | ) 26 | return paths 27 | } 28 | -------------------------------------------------------------------------------- /src/getPath.ts: -------------------------------------------------------------------------------- 1 | import plural from 'pluralize' 2 | import methods from './methods' 3 | import { getPathArgs, MethodKeys } from './types' 4 | 5 | export default ({ resource, endpoint, instance }: getPathArgs) => { 6 | let path = '' 7 | if (resource) path += `/${plural(resource)}` 8 | if (resource && instance) path += `/:${resource}Id` 9 | if (endpoint && !methods[endpoint as MethodKeys]) path += `/${endpoint}` 10 | return path 11 | } 12 | -------------------------------------------------------------------------------- /src/getRequestHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Response } from 'express' 2 | import { promisify } from 'handle-async' 3 | import { pipeline, Transform, Writable } from 'stream' 4 | import { NotFoundError, UnauthorizedError } from './errors' 5 | import cacheControl from './cacheControl' 6 | import { 7 | Trace, 8 | ExpressRequest, 9 | SutroRequest, 10 | SutroStream, 11 | ResourceRoot, 12 | CacheOptions, 13 | SutroArgs 14 | } from './types' 15 | 16 | type Args = { 17 | trace?: Trace 18 | formatResults: SutroArgs['formatResults'] 19 | augmentContext?: SutroArgs['augmentContext'] 20 | } 21 | 22 | const defaultCacheHeaders = { 23 | private: true, 24 | noCache: true 25 | } 26 | const responded = (req, res) => res.headersSent || req.timedout 27 | 28 | const traceAsync = async ( 29 | trace: Trace, 30 | name: string, 31 | promise: Promise 32 | ) => { 33 | if (!trace) return promise // no tracing, just return 34 | const ourTrace = trace.start(name) 35 | try { 36 | const res = await promise 37 | ourTrace.end() 38 | return res 39 | } catch (err) { 40 | ourTrace.end() 41 | throw err 42 | } 43 | } 44 | 45 | const streamResponse = async ( 46 | stream: SutroStream, 47 | req: ExpressRequest, 48 | res: Response, 49 | codes: { [key in 'noResponse' | 'success']: number }, 50 | cacheStream?: Writable 51 | ) => { 52 | let hasFirstChunk = false 53 | return new Promise((resolve, reject) => { 54 | let finished = false 55 | const ourStream = pipeline( 56 | stream, 57 | new Transform({ 58 | transform(chunk, _, cb) { 59 | // wait until we get a chunk without an error before writing the headers 60 | if (hasFirstChunk) return cb(null, chunk) 61 | hasFirstChunk = true 62 | if (stream.contentType) res.type(stream.contentType) 63 | res.status(codes.success) 64 | cb(null, chunk) 65 | } 66 | }), 67 | (err) => { 68 | finished = true 69 | if (!err || req.timedout) return resolve(undefined) // timed out, no point throwing a duplicate error 70 | reject(err) 71 | } 72 | ) 73 | 74 | // make sure we don't keep working if the response closed! 75 | res.once('close', () => { 76 | if (finished) return // no need to blow up 77 | ourStream.destroy(new Error('Socket closed before response finished')) 78 | }) 79 | 80 | if (cacheStream) { 81 | ourStream.pipe(cacheStream) 82 | ourStream.pause() 83 | } 84 | 85 | // just use a regular pipe to res, since pipeline would close it on error 86 | // which would make us unable to send an error back out 87 | ourStream.pipe(res) 88 | }) 89 | } 90 | 91 | const sendBufferResponse = ( 92 | resultData: any, 93 | _req: ExpressRequest, 94 | res: Response, 95 | codes: { [key in 'noResponse' | 'success']: number } 96 | ) => { 97 | res.status(codes.success) 98 | res.type('json') 99 | if (Buffer.isBuffer(resultData)) { 100 | res.send(resultData) 101 | } else if (typeof resultData === 'string') { 102 | res.send(Buffer.from(resultData)) 103 | } else { 104 | res.json(resultData) 105 | } 106 | 107 | res.end() 108 | } 109 | 110 | const sendResponse = async ( 111 | opt: SutroRequest, 112 | successCode: number | undefined, 113 | resultData: any, 114 | writeCache: ( 115 | data: any 116 | ) => Promise | Writable | undefined 117 | ) => { 118 | const { _res, _req, method, noResponse } = opt 119 | const codes = { 120 | noResponse: successCode || 204, 121 | success: successCode || 200 122 | } 123 | 124 | // no response 125 | if (resultData == null) { 126 | if (method === 'GET') throw new NotFoundError() 127 | return _res.status(codes.noResponse).end() 128 | } 129 | 130 | // user asked for no body (save bandwidth) 131 | if (noResponse) { 132 | return _res.status(codes.noResponse).end() 133 | } 134 | 135 | // stream response 136 | if (resultData.pipe && resultData.on) { 137 | const cacheStream = await writeCache(resultData) 138 | await streamResponse(resultData, _req, _res, codes, cacheStream) 139 | return 140 | } 141 | 142 | // json obj response 143 | sendBufferResponse(resultData, _req, _res, codes) 144 | await writeCache(resultData) 145 | } 146 | 147 | const exec = async ( 148 | req: ExpressRequest, 149 | res: Response, 150 | resource: ResourceRoot, 151 | { trace, augmentContext, formatResults }: Args 152 | ) => { 153 | if (responded(req, res)) return 154 | 155 | const { endpoint, successCode } = resource 156 | let opt: SutroRequest = { 157 | ...req.params, 158 | ip: req.ip, 159 | url: req.url, 160 | protocol: req.protocol, 161 | method: req.method, 162 | subdomains: req.subdomains, 163 | path: req.path, 164 | headers: req.headers, 165 | cookies: req.cookies, 166 | user: req.user, 167 | data: req.body, 168 | options: req.query, 169 | session: req.session, 170 | noResponse: req.query.response === 'false', 171 | onFinish: (fn: (req: ExpressRequest, res: Response) => void) => { 172 | res.once('finish', fn.bind(null, req, res)) 173 | }, 174 | withRaw: (fn: (req: ExpressRequest, res: Response) => void) => { 175 | fn(req, res) 176 | }, 177 | _req: req, 178 | _res: res 179 | } 180 | if (augmentContext) { 181 | opt = await traceAsync( 182 | trace, 183 | 'sutro/augmentContext', 184 | promisify(augmentContext.bind(null, opt, req, resource)) 185 | ) 186 | } 187 | 188 | if (responded(req, res)) return 189 | 190 | // check isAuthorized 191 | const authorized = 192 | !endpoint?.isAuthorized || 193 | (await traceAsync( 194 | trace, 195 | 'sutro/isAuthorized', 196 | promisify(endpoint.isAuthorized.bind(null, opt)) 197 | )) 198 | if (authorized !== true) throw new UnauthorizedError() 199 | if (responded(req, res)) return 200 | 201 | let resultData 202 | 203 | // check cache 204 | const cacheKey = 205 | endpoint?.cache && 206 | endpoint.cache.key && 207 | (await traceAsync( 208 | trace, 209 | 'sutro/cache.key', 210 | promisify(endpoint.cache.key.bind(null, opt)) 211 | )) 212 | if (responded(req, res)) return 213 | 214 | const cachedData = 215 | endpoint?.cache && 216 | endpoint.cache.get && 217 | (await traceAsync( 218 | trace, 219 | 'sutro/cache.get', 220 | promisify(endpoint.cache.get.bind(null, opt, cacheKey as string)) 221 | )) 222 | if (responded(req, res)) return 223 | 224 | // call execute 225 | if (!cachedData) { 226 | const executeFn = 227 | typeof endpoint === 'function' ? endpoint : endpoint?.execute 228 | const rawData = 229 | typeof executeFn === 'function' 230 | ? await traceAsync( 231 | trace, 232 | 'sutro/execute', 233 | promisify(executeFn.bind(null, opt)) 234 | ) 235 | : executeFn || null 236 | if (responded(req, res)) return 237 | 238 | // call format on execute result 239 | resultData = endpoint?.format 240 | ? await traceAsync( 241 | trace, 242 | 'sutro/format', 243 | promisify(endpoint.format.bind(null, opt, rawData)) 244 | ) 245 | : rawData 246 | if (responded(req, res)) return 247 | 248 | // call serialize on final result 249 | resultData = formatResults 250 | ? await traceAsync( 251 | trace, 252 | 'sutro/formatResults', 253 | promisify(formatResults.bind(null, opt, req, endpoint, rawData)) 254 | ) 255 | : resultData 256 | if (responded(req, res)) return 257 | } else { 258 | resultData = cachedData 259 | } 260 | 261 | // call cacheControl 262 | const cacheHeaders: string | CacheOptions = 263 | endpoint?.cache && endpoint.cache.header 264 | ? typeof endpoint.cache.header === 'function' 265 | ? await traceAsync( 266 | trace, 267 | 'sutro/cache.header', 268 | promisify(endpoint.cache.header.bind(null, opt, resultData)) 269 | ) 270 | : endpoint.cache.header 271 | : defaultCacheHeaders 272 | if (responded(req, res)) return 273 | if (cacheHeaders) res.set('Cache-Control', cacheControl(cacheHeaders)) 274 | 275 | const writeCache = async (data: any) => { 276 | if (cachedData || !endpoint?.cache?.set) return 277 | return traceAsync( 278 | trace, 279 | 'sutro/cache.set', 280 | promisify(endpoint.cache.set.bind(null, opt, data, cacheKey as string)) 281 | ) as Promise 282 | } 283 | await sendResponse(opt, successCode, resultData, writeCache) 284 | } 285 | 286 | export default ( 287 | resource: ResourceRoot, 288 | { trace, augmentContext, formatResults }: Args 289 | ) => { 290 | // wrap it so it has a name 291 | const handleAPIRequest = async ( 292 | req: ExpressRequest, 293 | res: Response, 294 | next: NextFunction 295 | ) => { 296 | if (req.timedout) return 297 | try { 298 | await traceAsync( 299 | trace, 300 | 'sutro/handleAPIRequest', 301 | exec(req, res, resource, { trace, augmentContext, formatResults }) 302 | ) 303 | } catch (err) { 304 | return next(err) 305 | } 306 | } 307 | return handleAPIRequest 308 | } 309 | -------------------------------------------------------------------------------- /src/getSwagger.ts: -------------------------------------------------------------------------------- 1 | import omit from 'lodash.omit' 2 | import walkResources from './walkResources' 3 | import { 4 | getSwaggerArgs, 5 | MethodVerbs, 6 | Resources, 7 | Responses, 8 | Swagger, 9 | ResourceRoot, 10 | SwaggerConfig, 11 | Paths 12 | } from './types' 13 | 14 | const param = /:(\w+)/gi 15 | 16 | const getResponses = (method: MethodVerbs, endpoint: ResourceRoot) => { 17 | const out: Responses = { 18 | 404: { 19 | description: 'Not found' 20 | }, 21 | 500: { 22 | description: 'Server error' 23 | }, 24 | default: { 25 | description: 'Unexpected error' 26 | } 27 | } 28 | 29 | if (method === 'post') { 30 | out['201'] = { 31 | description: 'Success, created' 32 | } 33 | } else { 34 | out['200'] = { 35 | description: 'Success' 36 | } 37 | out['204'] = { 38 | description: 'Success, no data return necessary' 39 | } 40 | } 41 | 42 | if (endpoint.isAuthorized) { 43 | out['401'] = { 44 | description: 'Unauthorized' 45 | } 46 | } 47 | return out 48 | } 49 | 50 | const flattenConfig = ( 51 | base: SwaggerConfig, 52 | override: SwaggerConfig 53 | ): SwaggerConfig => { 54 | const filtered = omit(override, [ 55 | 'consumes', 56 | 'produces', 57 | 'responses', 58 | 'parameters' 59 | ]) 60 | return { 61 | consumes: override.consumes || base.consumes, 62 | produces: override.produces || base.produces, 63 | responses: override.responses 64 | ? { 65 | ...base.responses, 66 | ...override.responses 67 | } 68 | : base.responses, 69 | parameters: override.parameters 70 | ? [...(base.parameters || []), ...override.parameters] 71 | : base.parameters, 72 | ...filtered 73 | } 74 | } 75 | 76 | const getPaths = (resources: Resources): Paths => { 77 | const paths: Paths = {} 78 | walkResources(resources, ({ path, method, endpoint }) => { 79 | if (endpoint?.hidden || endpoint?.swagger === false) return // skip 80 | const params = (path as string)?.match(param) 81 | const base: SwaggerConfig = { 82 | consumes: (method !== 'get' && ['application/json']) || undefined, 83 | produces: ['application/json'], 84 | parameters: 85 | (params && 86 | params.map((name: string) => ({ 87 | name: name.slice(1), 88 | in: 'path', 89 | required: true, 90 | type: 'string' 91 | }))) || 92 | undefined, 93 | responses: getResponses(method as MethodVerbs, endpoint as ResourceRoot) 94 | } 95 | const fixedPath = (path as string).replace(param, '{$1}') 96 | if (!paths[fixedPath]) paths[fixedPath] = {} 97 | paths[fixedPath][method as MethodVerbs] = endpoint?.swagger 98 | ? flattenConfig(base, endpoint?.swagger) 99 | : base 100 | }) 101 | return paths 102 | } 103 | 104 | export default ({ swagger = {}, base, resources }: getSwaggerArgs): Swagger => { 105 | const out = { 106 | swagger: '2.0', 107 | info: { 108 | title: 'Sutro API', 109 | version: '1.0.0' 110 | }, 111 | basePath: base, 112 | schemes: ['http'], 113 | paths: getPaths(resources), 114 | ...swagger 115 | } 116 | return out 117 | } 118 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'handle-async' 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { promisify } from 'handle-async' 3 | import { finished } from 'stream' 4 | import { format, stream } from './formatResults' 5 | import { NotFoundError } from './errors' 6 | import cacheControl from './cacheControl' 7 | import getRequestHandler from './getRequestHandler' 8 | import getSwagger from './getSwagger' 9 | import getMeta from './getMeta' 10 | import walkResources from './walkResources' 11 | import rewriteLarge from './rewriteLarge' 12 | import { 13 | MethodVerbs, 14 | SutroArgs, 15 | SutroRouter, 16 | PathParams, 17 | EndpointIsAuthorized, 18 | EndpointExecute, 19 | EndpointFormat, 20 | EndpointCache, 21 | EndpointHTTP, 22 | SutroRequest, 23 | SutroStream 24 | } from './types' 25 | 26 | // other exports 27 | export const rewriteLargeRequests = rewriteLarge 28 | export type { 29 | EndpointIsAuthorized, 30 | EndpointExecute, 31 | EndpointFormat, 32 | EndpointCache, 33 | EndpointHTTP, 34 | SutroRequest, 35 | SutroStream 36 | } 37 | export * from './errors' 38 | export { format as formatResults, stream as formatResultsStream } 39 | export { cacheControl } 40 | 41 | export default ({ 42 | swagger, 43 | base, 44 | resources, 45 | pre, 46 | post, 47 | augmentContext, 48 | formatResults, 49 | trace 50 | }: SutroArgs) => { 51 | if (!resources) throw new Error('Missing resources option') 52 | const router: SutroRouter = Router({ mergeParams: true }) 53 | router.swagger = getSwagger({ swagger, base, resources }) 54 | router.meta = getMeta({ base, resources }) 55 | router.base = base 56 | 57 | router.get('/', (_req, res) => res.status(200).json(router.meta).end()) 58 | router.get('/swagger', (_req, res) => 59 | res.status(200).json(router.swagger).end() 60 | ) 61 | 62 | walkResources(resources, (resource) => { 63 | const handlers = [ 64 | getRequestHandler(resource, { augmentContext, formatResults, trace }) 65 | ] 66 | if (pre) { 67 | handlers.unshift(async (req, res, next) => { 68 | const ourTrace = trace && trace.start('sutro/pre') 69 | try { 70 | await promisify(pre.bind(null, resource, req, res)) 71 | } catch (err) { 72 | if (ourTrace) ourTrace.end() 73 | return next(err) 74 | } 75 | if (ourTrace) ourTrace.end() 76 | next() 77 | }) 78 | } 79 | if (post) { 80 | handlers.unshift(async (req, res, next) => { 81 | finished(res, async (err) => { 82 | const ourTrace = trace && trace.start('sutro/post') 83 | try { 84 | await promisify(post.bind(null, resource, req, res, err)) 85 | } catch (err) { 86 | if (ourTrace) ourTrace.end() 87 | } 88 | if (ourTrace) ourTrace.end() 89 | }) 90 | next() 91 | }) 92 | } 93 | router[resource.method as MethodVerbs]( 94 | resource.path as PathParams, 95 | ...handlers 96 | ) 97 | }) 98 | 99 | // handle 404s 100 | router.use((_req, _res, next) => next(new NotFoundError())) 101 | return router 102 | } 103 | -------------------------------------------------------------------------------- /src/methods.ts: -------------------------------------------------------------------------------- 1 | import { Methods } from './types' 2 | 3 | const methods: Methods = { 4 | find: { 5 | method: 'get', 6 | instance: false 7 | }, 8 | create: { 9 | method: 'post', 10 | instance: false, 11 | successCode: 201 12 | }, 13 | findById: { 14 | method: 'get', 15 | instance: true 16 | }, 17 | replaceById: { 18 | method: 'put', 19 | instance: true 20 | }, 21 | updateById: { 22 | method: 'patch', 23 | instance: true 24 | }, 25 | deleteById: { 26 | method: 'delete', 27 | instance: true 28 | } 29 | } 30 | 31 | export default methods 32 | -------------------------------------------------------------------------------- /src/rewriteLarge.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { MethodVerbs } from './types' 3 | 4 | // allow people to send a POST and alias it into a GET 5 | // this is a work-around for really large queries 6 | // this is similar to how method-override works but more opinionated 7 | export default (req: Request, res: Response, next: NextFunction) => { 8 | if (req.method.toLowerCase() !== 'post') return next() 9 | const override = req.get('x-http-method-override') 10 | if (!override || override.toLowerCase() !== 'get') return next() 11 | 12 | // work 13 | req.method = 'GET' 14 | req.query = { 15 | ...req.query, 16 | ...req.body 17 | } 18 | delete req.body 19 | next() 20 | } 21 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { IRouter, Request, Response } from 'express' 2 | import { Readable } from 'stream' 3 | 4 | export type PathParams = string | RegExp | Array 5 | 6 | export type CacheOptions = { 7 | private?: boolean 8 | public?: boolean 9 | noStore?: boolean 10 | noCache?: boolean 11 | noTransform?: boolean 12 | proxyRevalidate?: boolean 13 | mustRevalidate?: boolean 14 | staleIfError?: number | string 15 | staleWhileRevalidate?: number | string 16 | maxAge?: number | string 17 | sMaxAge?: number | string 18 | } 19 | 20 | export type MethodKeys = 21 | | 'find' 22 | | 'create' 23 | | 'findById' 24 | | 'replaceById' 25 | | 'updateById' 26 | | 'deleteById' 27 | 28 | export type MethodVerbs = 'get' | 'post' | 'put' | 'patch' | 'delete' 29 | 30 | export type Methods = { 31 | [key: string]: { 32 | method: MethodVerbs 33 | instance: boolean 34 | successCode?: number 35 | } 36 | } 37 | 38 | export type ResourceRoot = { 39 | hidden?: boolean 40 | path: PathParams 41 | method: string 42 | instance: boolean 43 | swagger?: SwaggerConfig 44 | isAuthorized?: EndpointIsAuthorized 45 | execute: EndpointExecute 46 | format?: EndpointFormat 47 | cache?: EndpointCache 48 | http: EndpointHTTP 49 | endpoint: ResourceRoot 50 | successCode?: number 51 | hierarchy: string 52 | } 53 | 54 | export type Resource = { 55 | [key: string]: ResourceRoot 56 | } 57 | 58 | export type Resources = { 59 | [key: string]: any // this type is recursive and not possible ATM 60 | } 61 | 62 | export type Handler = (args: ResourceRoot) => void 63 | 64 | export type walkResourceArgs = { 65 | base?: string 66 | name: string 67 | resource: ResourceRoot 68 | hierarchy?: string 69 | handler: Handler 70 | } 71 | 72 | export type getPathArgs = { 73 | resource: string 74 | endpoint?: string 75 | instance: boolean 76 | } 77 | 78 | export type Paths = { 79 | [key: string]: { 80 | [key in MethodVerbs]?: SwaggerConfig 81 | } 82 | } 83 | 84 | export type MetaRoot = { 85 | path?: PathParams 86 | method?: MethodVerbs 87 | instance?: boolean 88 | [Key: string]: any 89 | } 90 | 91 | export type Meta = { 92 | [key: string]: Meta | MetaRoot 93 | } 94 | 95 | export type getSwaggerArgs = { 96 | swagger: Swagger 97 | base?: string 98 | resources: Resources 99 | } 100 | 101 | export type Swagger = { 102 | swagger?: string 103 | info?: { 104 | title?: string 105 | version?: string 106 | termsOfService?: string 107 | contact?: { 108 | name?: string 109 | url?: string 110 | } 111 | description?: string 112 | } 113 | basePath?: string 114 | schemes?: string[] 115 | paths?: { 116 | [key: string]: { 117 | [key: string]: SwaggerConfig 118 | } 119 | } 120 | } 121 | 122 | export type SwaggerConfig = { 123 | consumes?: string[] 124 | produces?: string[] 125 | parameters?: 126 | | { 127 | name: string 128 | in: string 129 | required: boolean 130 | type: string 131 | }[] 132 | | undefined 133 | responses?: Responses 134 | operationId?: string 135 | summary?: string 136 | description?: string 137 | } 138 | 139 | export type Trace = { 140 | start: (name: string) => Trace 141 | end: () => Trace 142 | } 143 | 144 | export type SutroArgs = { 145 | base?: string 146 | resources: Resources 147 | swagger?: Swagger 148 | pre?: (resource: ResourceRoot, req: Request, res: Response) => void 149 | post?: ( 150 | resource: ResourceRoot, 151 | req: Request, 152 | res: Response, 153 | err?: any 154 | ) => void 155 | augmentContext?: ( 156 | context: SutroRequest, 157 | req: Request, 158 | resource: ResourceRoot 159 | ) => Promise | SutroRequest 160 | formatResults?: ( 161 | context: SutroRequest, 162 | req: Request, 163 | resource: ResourceRoot, 164 | rawData: any 165 | ) => void 166 | trace?: Trace 167 | } 168 | 169 | export interface SutroRouter extends IRouter { 170 | swagger?: Swagger 171 | meta?: Meta 172 | base?: string 173 | } 174 | 175 | export type ResponseStatusKeys = 176 | | 'default' 177 | | '200' 178 | | '201' 179 | | '204' 180 | | '401' 181 | | '404' 182 | | '500' 183 | 184 | export type Responses = { 185 | [key in ResponseStatusKeys]?: { 186 | description: string 187 | } 188 | } 189 | 190 | // our custom express overrides 191 | export interface ExpressRequest extends Request { 192 | timedout: boolean 193 | user?: any 194 | session?: any 195 | } 196 | 197 | // our core primitives 198 | export interface SutroRequest { 199 | ip: Request['ip'] 200 | url: Request['url'] 201 | protocol: Request['protocol'] 202 | method: Request['method'] 203 | subdomains: Request['subdomains'] 204 | path: Request['path'] 205 | headers: Request['headers'] 206 | cookies: Request['cookies'] 207 | data?: any 208 | options: Request['query'] 209 | session?: any 210 | noResponse?: boolean 211 | onFinish?: (fn: (req: Request, res: Response) => void) => void 212 | withRaw?: (fn: (req: Request, res: Response) => void) => void 213 | _req: ExpressRequest 214 | _res: Response 215 | [key: string]: any 216 | } 217 | 218 | export interface SutroStream extends Readable { 219 | contentType?: string 220 | } 221 | 222 | export type EndpointIsAuthorized = ( 223 | opt: SutroRequest 224 | ) => Promise | boolean 225 | export type EndpointExecute = (opt: SutroRequest) => Promise | any 226 | export type EndpointFormat = ( 227 | opt: SutroRequest, 228 | rawData: any 229 | ) => Promise | any 230 | export type EndpointCache = { 231 | header?: CacheOptions | (() => CacheOptions) 232 | key?: () => string 233 | get?: (opt: SutroRequest | string, key: string) => Promise | any 234 | set?: ( 235 | opt: SutroRequest | string, 236 | data: any, 237 | key: string 238 | ) => Promise | any 239 | } 240 | export type EndpointHTTP = { 241 | method: MethodVerbs 242 | instance: boolean 243 | } 244 | -------------------------------------------------------------------------------- /src/walkResources.ts: -------------------------------------------------------------------------------- 1 | import join from 'url-join' 2 | import getPath from './getPath' 3 | import methods from './methods' 4 | import { Resources, walkResourceArgs, Handler, MethodKeys } from './types' 5 | 6 | const idxd = (o: Resources) => o.index || o 7 | 8 | const walkResource = ({ 9 | base, 10 | name, 11 | resource, 12 | hierarchy, 13 | handler 14 | }: walkResourceArgs) => { 15 | const res = idxd(resource) 16 | 17 | // sort custom stuff first 18 | const endpointNames: string[] = [] 19 | Object.keys(res).forEach((k) => 20 | methods[k as MethodKeys] ? endpointNames.push(k) : endpointNames.unshift(k) 21 | ) 22 | 23 | endpointNames.forEach((endpointName) => { 24 | const endpoint = res[endpointName] 25 | const methodInfo = endpoint.http || methods[endpointName as MethodKeys] 26 | if (!methodInfo) { 27 | // TODO: error if still nothing found 28 | const newBase = getPath({ resource: name, instance: true }) 29 | walkResource({ 30 | base: base ? join(base, newBase) : newBase, 31 | name: endpointName, 32 | resource: endpoint, 33 | hierarchy: hierarchy ? `${hierarchy}.${name}` : name, 34 | handler 35 | }) 36 | return 37 | } 38 | const path = 39 | endpoint.path || 40 | getPath({ 41 | resource: name, 42 | endpoint: endpointName, 43 | instance: methodInfo.instance 44 | }) 45 | const fullPath = base ? join(base, path) : path 46 | handler({ 47 | hierarchy: hierarchy 48 | ? `${hierarchy}.${name}.${endpointName}` 49 | : `${name}.${endpointName}`, 50 | path: fullPath, 51 | endpoint, 52 | ...methodInfo 53 | }) 54 | }) 55 | } 56 | 57 | export default (resources: Resources, handler: Handler) => { 58 | Object.keys(idxd(resources)).forEach((resourceName) => { 59 | walkResource({ 60 | name: resourceName, 61 | resource: resources[resourceName], 62 | handler 63 | }) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | /*eslint no-console: 0*/ 2 | import should from 'should' 3 | import sutro, { rewriteLargeRequests } from '../src' 4 | import request from 'supertest' 5 | import express from 'express' 6 | import Parser from 'swagger-parser' 7 | import JSONStream from 'jsonstream-next' 8 | import { SutroArgs } from '../src/types' 9 | 10 | const parser = new Parser() 11 | const users = [ 12 | { id: 0, name: 'foo' }, 13 | { id: 1, name: 'bar' }, 14 | { id: 2, name: 'baz' } 15 | ] 16 | 17 | const passengers = [{ name: 'todd' }, { name: 'rob' }] 18 | const cars = [ 19 | [ 20 | { id: 0, name: 'foo', passengers }, 21 | { id: 1, name: 'bar', passengers }, 22 | { id: 2, name: 'baz', passengers } 23 | ], 24 | [ 25 | { id: 0, name: 'foo', passengers }, 26 | { id: 1, name: 'bar', passengers }, 27 | { id: 2, name: 'baz', passengers } 28 | ], 29 | [ 30 | { id: 0, name: 'foo', passengers }, 31 | { id: 1, name: 'bar', passengers }, 32 | { id: 2, name: 'baz', passengers } 33 | ] 34 | ] 35 | 36 | describe('sutro', () => { 37 | it('should export a function', () => { 38 | should.exist(sutro) 39 | should(typeof sutro).eql('function') 40 | }) 41 | it('should return a router', () => { 42 | const router = sutro({ resources: {} }) 43 | should.exist(router) 44 | should(typeof router).eql('function') 45 | }) 46 | it('should error if missing resources', () => { 47 | sutro.should.throw() 48 | sutro.bind(null, {}).should.throw() 49 | }) 50 | }) 51 | 52 | describe('sutro - function handlers', () => { 53 | const config: SutroArgs = { 54 | pre: async (o, req, res) => { 55 | should.exist(o) 56 | should.exist(req) 57 | should.exist(res) 58 | }, 59 | resources: { 60 | user: { 61 | create: (opts, cb) => cb(null, { created: true }), 62 | find: (opts, cb) => cb(null, users), 63 | findById: (opts, cb) => cb(null, users[opts.userId]), 64 | deleteById: (opts, cb) => cb(null, { deleted: true }), 65 | updateById: (opts, cb) => cb(null, { updated: true }), 66 | replaceById: (opts, cb) => cb(null, { replaced: true }), 67 | car: { 68 | create: (opts, cb) => cb(null, { created: true }), 69 | find: (opts, cb) => cb(null, cars[opts.userId]), 70 | findById: (opts, cb) => cb(null, cars[opts.userId][opts.carId]), 71 | deleteById: (opts, cb) => cb(null, { deleted: true }), 72 | updateById: (opts, cb) => cb(null, { updated: true }), 73 | replaceById: (opts, cb) => cb(null, { replaced: true }), 74 | 75 | passenger: { 76 | create: (opts, cb) => cb(null, { created: true }), 77 | find: (opts, cb) => 78 | cb(null, cars[opts.userId][opts.carId].passengers), 79 | findById: (opts, cb) => 80 | cb( 81 | null, 82 | cars[opts.userId][opts.carId].passengers[opts.passengerId] 83 | ), 84 | deleteById: (opts, cb) => cb(null, { deleted: true }), 85 | updateById: (opts, cb) => cb(null, { updated: true }), 86 | replaceById: (opts, cb) => cb(null, { replaced: true }) 87 | } 88 | }, 89 | me: { 90 | execute: (opts, cb) => cb(null, { me: true }), 91 | http: { 92 | method: 'get', 93 | instance: false 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | const app = express().use(sutro(config)) 101 | 102 | it('should register a resource find endpoint', async () => 103 | request(app) 104 | .get('/users') 105 | .set('Accept', 'application/json') 106 | .expect('Content-Type', /json/) 107 | .expect(200, users)) 108 | 109 | it('should register a resource findById endpoint', async () => 110 | request(app) 111 | .get('/users/1') 112 | .set('Accept', 'application/json') 113 | .expect('Content-Type', /json/) 114 | .expect(200, users[1])) 115 | 116 | it('should register a resource create endpoint', async () => 117 | request(app) 118 | .post('/users') 119 | .set('Accept', 'application/json') 120 | .expect('Content-Type', /json/) 121 | .expect(201, { created: true })) 122 | 123 | it('should register a resource create endpoint that works with response=false', async () => 124 | request(app) 125 | .post('/users') 126 | .set('Accept', 'application/json') 127 | .query({ response: false }) 128 | .expect(201) 129 | .expect(({ body }) => !body)) 130 | 131 | it('should register a resource delete endpoint', async () => 132 | request(app) 133 | .delete('/users/1') 134 | .set('Accept', 'application/json') 135 | .expect('Content-Type', /json/) 136 | .expect(200, { deleted: true })) 137 | 138 | it('should register a resource replace endpoint', async () => 139 | request(app) 140 | .put('/users/1') 141 | .set('Accept', 'application/json') 142 | .expect('Content-Type', /json/) 143 | .expect(200, { replaced: true })) 144 | 145 | it('should register a resource update endpoint', async () => 146 | request(app) 147 | .patch('/users/1') 148 | .set('Accept', 'application/json') 149 | .expect('Content-Type', /json/) 150 | .expect(200, { updated: true })) 151 | 152 | it('should register a custom resource', async () => 153 | request(app) 154 | .get('/users/me') 155 | .set('Accept', 'application/json') 156 | .expect('Content-Type', /json/) 157 | .expect(200, { me: true })) 158 | 159 | it('should register a nested resource find endpoint', async () => 160 | request(app) 161 | .get('/users/1/cars') 162 | .set('Accept', 'application/json') 163 | .expect('Content-Type', /json/) 164 | .expect(200, cars[1])) 165 | 166 | it('should register a nested resource findById endpoint', async () => 167 | request(app) 168 | .get('/users/1/cars/1') 169 | .set('Accept', 'application/json') 170 | .expect('Content-Type', /json/) 171 | .expect(200, cars[1][1])) 172 | 173 | it('should register a nested resource create endpoint', async () => 174 | request(app) 175 | .post('/users/1/cars') 176 | .set('Accept', 'application/json') 177 | .expect('Content-Type', /json/) 178 | .expect(201, { created: true })) 179 | 180 | it('should register a nested resource delete endpoint', async () => 181 | request(app) 182 | .delete('/users/1/cars/1') 183 | .set('Accept', 'application/json') 184 | .expect('Content-Type', /json/) 185 | .expect(200, { deleted: true })) 186 | 187 | it('should register a nested resource replace endpoint', async () => 188 | request(app) 189 | .put('/users/1/cars/1') 190 | .set('Accept', 'application/json') 191 | .expect('Content-Type', /json/) 192 | .expect(200, { replaced: true })) 193 | 194 | it('should register a nested resource update endpoint', async () => 195 | request(app) 196 | .patch('/users/1/cars/1') 197 | .set('Accept', 'application/json') 198 | .expect('Content-Type', /json/) 199 | .expect(200, { updated: true })) 200 | it('should register a double nested resource find endpoint', async () => 201 | request(app) 202 | .get('/users/1/cars/1/passengers') 203 | .set('Accept', 'application/json') 204 | .expect('Content-Type', /json/) 205 | .expect(200, cars[1][1].passengers)) 206 | 207 | it('should register a double nested resource findById endpoint', async () => 208 | request(app) 209 | .get('/users/1/cars/1/passengers/0') 210 | .set('Accept', 'application/json') 211 | .expect('Content-Type', /json/) 212 | .expect(200, cars[1][1].passengers[0])) 213 | 214 | it('should register a double nested resource create endpoint', async () => 215 | request(app) 216 | .post('/users/1/cars/1/passengers') 217 | .set('Accept', 'application/json') 218 | .expect('Content-Type', /json/) 219 | .expect(201, { created: true })) 220 | 221 | it('should register a double nested resource delete endpoint', async () => 222 | request(app) 223 | .delete('/users/1/cars/1/passengers/1') 224 | .set('Accept', 'application/json') 225 | .expect('Content-Type', /json/) 226 | .expect(200, { deleted: true })) 227 | 228 | it('should register a double nested resource replace endpoint', async () => 229 | request(app) 230 | .put('/users/1/cars/1/passengers/1') 231 | .set('Accept', 'application/json') 232 | .expect('Content-Type', /json/) 233 | .expect(200, { replaced: true })) 234 | 235 | it('should register a double nested resource update endpoint', async () => 236 | request(app) 237 | .patch('/users/1/cars/1/passengers/1') 238 | .set('Accept', 'application/json') 239 | .expect('Content-Type', /json/) 240 | .expect(200, { updated: true })) 241 | 242 | it('should have a valid swagger file', async () => { 243 | const { body } = await request(app) 244 | .get('/swagger') 245 | .set('Accept', 'application/json') 246 | .expect('Content-Type', /json/) 247 | .expect(200) 248 | 249 | await parser.validate(body) 250 | }) 251 | 252 | it('should have a meta index', async () => { 253 | const { body } = await request(app) 254 | .get('/') 255 | .set('Accept', 'application/json') 256 | .expect('Content-Type', /json/) 257 | .expect(200) 258 | 259 | should(body).eql({ 260 | user: { 261 | me: { 262 | path: '/users/me', 263 | method: 'get', 264 | instance: false 265 | }, 266 | car: { 267 | passenger: { 268 | create: { 269 | path: '/users/:userId/cars/:carId/passengers', 270 | method: 'post', 271 | instance: false 272 | }, 273 | find: { 274 | path: '/users/:userId/cars/:carId/passengers', 275 | method: 'get', 276 | instance: false 277 | }, 278 | findById: { 279 | path: '/users/:userId/cars/:carId/passengers/:passengerId', 280 | method: 'get', 281 | instance: true 282 | }, 283 | deleteById: { 284 | path: '/users/:userId/cars/:carId/passengers/:passengerId', 285 | method: 'delete', 286 | instance: true 287 | }, 288 | updateById: { 289 | path: '/users/:userId/cars/:carId/passengers/:passengerId', 290 | method: 'patch', 291 | instance: true 292 | }, 293 | replaceById: { 294 | path: '/users/:userId/cars/:carId/passengers/:passengerId', 295 | method: 'put', 296 | instance: true 297 | } 298 | }, 299 | create: { 300 | path: '/users/:userId/cars', 301 | method: 'post', 302 | instance: false 303 | }, 304 | find: { 305 | path: '/users/:userId/cars', 306 | method: 'get', 307 | instance: false 308 | }, 309 | findById: { 310 | path: '/users/:userId/cars/:carId', 311 | method: 'get', 312 | instance: true 313 | }, 314 | deleteById: { 315 | path: '/users/:userId/cars/:carId', 316 | method: 'delete', 317 | instance: true 318 | }, 319 | updateById: { 320 | path: '/users/:userId/cars/:carId', 321 | method: 'patch', 322 | instance: true 323 | }, 324 | replaceById: { 325 | path: '/users/:userId/cars/:carId', 326 | method: 'put', 327 | instance: true 328 | } 329 | }, 330 | create: { 331 | path: '/users', 332 | method: 'post', 333 | instance: false 334 | }, 335 | find: { 336 | path: '/users', 337 | method: 'get', 338 | instance: false 339 | }, 340 | findById: { 341 | path: '/users/:userId', 342 | method: 'get', 343 | instance: true 344 | }, 345 | deleteById: { 346 | path: '/users/:userId', 347 | method: 'delete', 348 | instance: true 349 | }, 350 | updateById: { 351 | path: '/users/:userId', 352 | method: 'patch', 353 | instance: true 354 | }, 355 | replaceById: { 356 | path: '/users/:userId', 357 | method: 'put', 358 | instance: true 359 | } 360 | } 361 | }) 362 | }) 363 | }) 364 | 365 | describe('sutro - async function handlers', () => { 366 | const config: SutroArgs = { 367 | resources: { 368 | user: { 369 | create: async () => ({ created: true }), 370 | find: async () => users, 371 | findById: async (opts) => users[opts.userId], 372 | deleteById: async () => ({ deleted: true }), 373 | updateById: async () => ({ updated: true }), 374 | replaceById: async () => ({ replaced: true }), 375 | me: { 376 | execute: async () => ({ me: true }), 377 | http: { 378 | method: 'get', 379 | instance: false 380 | } 381 | } 382 | } 383 | } 384 | } 385 | 386 | const app = express().use(sutro(config)) 387 | 388 | it('should register a resource find endpoint', async () => 389 | request(app) 390 | .get('/users') 391 | .set('Accept', 'application/json') 392 | .expect('Content-Type', /json/) 393 | .expect(200, users)) 394 | 395 | it('should register a resource findById endpoint', async () => 396 | request(app) 397 | .get('/users/1') 398 | .set('Accept', 'application/json') 399 | .expect('Content-Type', /json/) 400 | .expect(200, users[1])) 401 | 402 | it('should register a resource create endpoint', async () => 403 | request(app) 404 | .post('/users') 405 | .set('Accept', 'application/json') 406 | .expect('Content-Type', /json/) 407 | .expect(201, { created: true })) 408 | 409 | it('should register a resource delete endpoint', async () => 410 | request(app) 411 | .delete('/users/1') 412 | .set('Accept', 'application/json') 413 | .expect('Content-Type', /json/) 414 | .expect(200, { deleted: true })) 415 | 416 | it('should register a resource replace endpoint', async () => 417 | request(app) 418 | .put('/users/1') 419 | .set('Accept', 'application/json') 420 | .expect('Content-Type', /json/) 421 | .expect(200, { replaced: true })) 422 | 423 | it('should register a resource update endpoint', async () => 424 | request(app) 425 | .patch('/users/1') 426 | .set('Accept', 'application/json') 427 | .expect('Content-Type', /json/) 428 | .expect(200, { updated: true })) 429 | 430 | it('should register a custom resource', async () => 431 | request(app) 432 | .get('/users/me') 433 | .set('Accept', 'application/json') 434 | .expect('Content-Type', /json/) 435 | .expect(200, { me: true })) 436 | }) 437 | 438 | describe('sutro - flat value handlers', () => { 439 | const config: SutroArgs = { 440 | pre: async (o, req, res) => { 441 | should.exist(o) 442 | should.exist(req) 443 | should.exist(res) 444 | }, 445 | post: async (o, req, res, err) => { 446 | should.exist(o) 447 | should.exist(req) 448 | should.exist(res) 449 | should.not.exist(err) 450 | }, 451 | resources: { 452 | user: { 453 | create: () => ({ created: true }), 454 | find: () => users, 455 | findById: (opts) => users[opts.userId], 456 | deleteById: () => ({ deleted: true }), 457 | updateById: () => ({ updated: true }), 458 | replaceById: () => ({ replaced: true }), 459 | me: { 460 | execute: () => ({ me: true }), 461 | http: { 462 | method: 'get', 463 | instance: false 464 | } 465 | }, 466 | isCool: { 467 | execute: () => false, 468 | http: { 469 | method: 'get', 470 | instance: true 471 | } 472 | }, 473 | nulled: { 474 | execute: () => null, 475 | http: { 476 | method: 'get', 477 | instance: false 478 | } 479 | } 480 | } 481 | } 482 | } 483 | 484 | const app = express().use(sutro(config)) 485 | 486 | it('should register a resource find endpoint', async () => 487 | request(app) 488 | .get('/users') 489 | .set('Accept', 'application/json') 490 | .expect('Content-Type', /json/) 491 | .expect(200, users)) 492 | 493 | it('should register a resource findById endpoint', async () => 494 | request(app) 495 | .get('/users/1') 496 | .set('Accept', 'application/json') 497 | .expect('Content-Type', /json/) 498 | .expect(200, users[1])) 499 | 500 | it('should register a resource create endpoint', async () => 501 | request(app) 502 | .post('/users') 503 | .set('Accept', 'application/json') 504 | .expect('Content-Type', /json/) 505 | .expect(201, { created: true })) 506 | 507 | it('should register a resource delete endpoint', async () => 508 | request(app) 509 | .delete('/users/1') 510 | .set('Accept', 'application/json') 511 | .expect('Content-Type', /json/) 512 | .expect(200, { deleted: true })) 513 | 514 | it('should register a resource replace endpoint', async () => 515 | request(app) 516 | .put('/users/1') 517 | .set('Accept', 'application/json') 518 | .expect('Content-Type', /json/) 519 | .expect(200, { replaced: true })) 520 | 521 | it('should register a resource update endpoint', async () => 522 | request(app) 523 | .patch('/users/1') 524 | .set('Accept', 'application/json') 525 | .expect('Content-Type', /json/) 526 | .expect(200, { updated: true })) 527 | 528 | it('should register a custom resource', async () => 529 | request(app) 530 | .get('/users/me') 531 | .set('Accept', 'application/json') 532 | .expect('Content-Type', /json/) 533 | .expect(200, { me: true })) 534 | 535 | it('should return 200 with data from a custom falsey resource', async () => 536 | request(app) 537 | .get('/users/123/isCool') 538 | .set('Accept', 'application/json') 539 | .expect('Content-Type', /json/) 540 | .expect(200, 'false')) 541 | 542 | it('should return 404 from a custom null resource', async () => 543 | request(app) 544 | .get('/users/nulled') 545 | .set('Accept', 'application/json') 546 | .expect(404)) 547 | }) 548 | 549 | describe('sutro - caching', () => { 550 | let meCache 551 | const findByIdCache = {} 552 | const keyedCache: { yo?: any } = {} 553 | const config: SutroArgs = { 554 | resources: { 555 | user: { 556 | find: { 557 | execute: async () => users, 558 | cache: { 559 | header: () => ({ public: true, maxAge: '1hr' }), 560 | key: () => 'yo', 561 | get: async (opt, key) => keyedCache[key], 562 | set: async (opt, data, key) => (keyedCache[key] = data) 563 | } 564 | }, 565 | findById: { 566 | execute: async (opt) => users[opt.userId], 567 | cache: { 568 | header: () => ({ public: true }), 569 | get: async (opt) => findByIdCache[opt.userId], 570 | set: async (opt, data) => (findByIdCache[opt.userId] = data) 571 | } 572 | }, 573 | me: { 574 | execute: async () => ({ me: true }), 575 | cache: { 576 | header: { private: true }, 577 | get: async () => meCache, 578 | set: async (opt, data) => (meCache = data) 579 | }, 580 | http: { 581 | method: 'get', 582 | instance: false 583 | } 584 | } 585 | } 586 | } 587 | } 588 | 589 | const app = express().use(sutro(config)) 590 | 591 | it('should cache a findById endpoint', async () => { 592 | await request(app) 593 | .get('/users/1') 594 | .set('Accept', 'application/json') 595 | .expect('Content-Type', /json/) 596 | .expect('Cache-Control', 'public') 597 | .expect(200, users[1]) 598 | 599 | await request(app) 600 | .get('/users/2') 601 | .set('Accept', 'application/json') 602 | .expect('Content-Type', /json/) 603 | .expect('Cache-Control', 'public') 604 | .expect(200, users[2]) 605 | 606 | await request(app) 607 | .get('/users/1') 608 | .set('Accept', 'application/json') 609 | .expect('Content-Type', /json/) 610 | .expect('Cache-Control', 'public') 611 | .expect(200, users[1]) 612 | 613 | await request(app) 614 | .get('/users/2') 615 | .set('Accept', 'application/json') 616 | .expect('Content-Type', /json/) 617 | .expect('Cache-Control', 'public') 618 | .expect(200, users[2]) 619 | 620 | should.exist(findByIdCache) 621 | findByIdCache.should.eql({ 622 | 1: users[1], 623 | 2: users[2] 624 | }) 625 | }) 626 | it('should cache a custom resource', async () => { 627 | await request(app) 628 | .get('/users/me') 629 | .set('Accept', 'application/json') 630 | .expect('Content-Type', /json/) 631 | .expect('Cache-Control', 'private') 632 | .expect(200, { me: true }) 633 | 634 | should.exist(meCache) 635 | meCache.should.eql({ me: true }) 636 | }) 637 | it('should cache with a key function', async () => { 638 | await request(app) 639 | .get('/users') 640 | .set('Accept', 'application/json') 641 | .expect('Content-Type', /json/) 642 | .expect('Cache-Control', 'public, max-age=3600') 643 | .expect(200, users) 644 | 645 | should.exist(keyedCache.yo) 646 | keyedCache.yo.should.eql(users) 647 | }) 648 | }) 649 | 650 | describe('sutro - rewriting requests', () => { 651 | const config: SutroArgs = { 652 | resources: { 653 | user: { 654 | find: () => [{ a: 1 }], 655 | findById: () => ({ a: 1 }) 656 | } 657 | } 658 | } 659 | const app = express().use(rewriteLargeRequests).use(sutro(config)) 660 | 661 | it('should rewrite a post for a resource find endpoint', async () => 662 | request(app) 663 | .post('/users') 664 | .set('Accept', 'application/json') 665 | .set('X-HTTP-Method-Override', 'GET') 666 | .expect('Content-Type', /json/) 667 | .expect(200, config.resources.user.find())) 668 | 669 | it('should rewrite a post for a resource findById endpoint', async () => 670 | request(app) 671 | .post('/users/1') 672 | .set('Accept', 'application/json') 673 | .set('X-HTTP-Method-Override', 'GET') 674 | .expect('Content-Type', /json/) 675 | .expect(200, config.resources.user.findById())) 676 | }) 677 | 678 | describe('sutro - streaming requests', () => { 679 | const config: SutroArgs = { 680 | resources: { 681 | user: { 682 | find: ({ options }) => { 683 | const out = JSONStream.stringify() 684 | out.contentType = 'application/json' 685 | if (options.asyncError) { 686 | setTimeout(() => { 687 | out.emit('error', new Error('Bad news!')) 688 | }, 100) 689 | } 690 | if (options.error) { 691 | out.emit('error', new Error('Bad news!')) 692 | } 693 | setTimeout(() => { 694 | out.end({ a: 1 }) 695 | }, 200) 696 | return out 697 | } 698 | } 699 | } 700 | } 701 | const app = express() 702 | .use(sutro(config)) 703 | .use((err, req, res, _next) => { 704 | res.status(500).send({ error: err.message }) 705 | }) 706 | 707 | it('should stream a resource find endpoint', async () => 708 | request(app) 709 | .get('/users') 710 | .set('Accept', 'application/json') 711 | .expect('Content-Type', /json/) 712 | .expect(200, [{ a: 1 }])) 713 | it('should handle async stream errors correctly', async () => 714 | request(app) 715 | .get('/users') 716 | .query({ asyncError: true }) 717 | .set('Accept', 'application/json') 718 | .expect('Content-Type', /json/) 719 | .expect(500, { error: 'Bad news!' })) 720 | it('should handle instant stream errors correctly', async () => 721 | request(app) 722 | .get('/users') 723 | .query({ error: true }) 724 | .set('Accept', 'application/json') 725 | .expect('Content-Type', /json/) 726 | .expect(500, { error: 'Bad news!' })) 727 | }) 728 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2020"], 4 | "target": "ES2020", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "strict": false, 8 | "rootDir": "src", 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "outDir": "dist" 13 | }, 14 | "exclude": ["test"] 15 | } 16 | --------------------------------------------------------------------------------