From b89581adc53257d8e4d949735402dc8e3c18a7e8 Mon Sep 17 00:00:00 2001 From: Imed Jaberi Date: Sun, 25 Jun 2023 02:22:50 +0100 Subject: [PATCH] feat: Re-create the module with TypeScript (#152) * refactor: imporve the return type for shouldParseBodyAs method * test: coverage 100% * feat: support the already parsed body * feat: prevent runtime problems * feat: add patchNode option to support patching ctx.req.body * feat: add parsedMethods option to parse only the passed ones * feat: support extra value under the content-type + use async/await over done in testing --- .github/workflows/ci.yml | 25 + .github/workflows/nodejs.yml | 15 - .gitignore | 4 + .npmrc | 1 - .xo-config.json | 22 + README.md | 111 ++-- example.js => examples/cjs/index.example.cjs | 8 +- examples/esm/index.example.mjs | 14 + examples/typescript/index.example.ts | 16 + index.js | 136 ----- jest.config.ts | 9 + package.json | 97 ++-- src/body-parser.ts | 113 ++++ src/body-parser.types.ts | 82 +++ src/body-parser.utils.ts | 96 ++++ src/index.ts | 5 + test/middleware.spec.js | 425 -------------- test/middleware.test.ts | 561 +++++++++++++++++++ test/test-utils.ts | 23 + tsconfig.json | 40 ++ tsup.config.ts | 15 + 21 files changed, 1146 insertions(+), 672 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/nodejs.yml delete mode 100644 .npmrc create mode 100644 .xo-config.json rename example.js => examples/cjs/index.example.cjs (71%) create mode 100644 examples/esm/index.example.mjs create mode 100644 examples/typescript/index.example.ts delete mode 100644 index.js create mode 100644 jest.config.ts create mode 100644 src/body-parser.ts create mode 100644 src/body-parser.types.ts create mode 100644 src/body-parser.utils.ts create mode 100644 src/index.ts delete mode 100644 test/middleware.spec.js create mode 100644 test/middleware.test.ts create mode 100644 test/test-utils.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39bec85 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: Continuous Integration + +on: + - push + - pull_request + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: + - 16 + - 18 + - 20 + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + - name: Install dependencies + run: yarn install + - name: Check linter + run: yarn lint + - name: Run tests + run: yarn test-ci diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml deleted file mode 100644 index e22e1d1..0000000 --- a/.github/workflows/nodejs.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: CI - -on: - push: - branches: [ master ] - - pull_request: - branches: [ master ] - -jobs: - Job: - name: Node.js - uses: node-modules/github-actions/.github/workflows/node-test.yml@master - with: - version: '12, 14, 16, 18, 20' diff --git a/.gitignore b/.gitignore index 022e927..1e39d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ npm-debug.log yarn-debug.log yarn-error.log +# Build # +################### +dist +build # NYC # ################### diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 9cf9495..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -package-lock=false \ No newline at end of file diff --git a/.xo-config.json b/.xo-config.json new file mode 100644 index 0000000..d78ad10 --- /dev/null +++ b/.xo-config.json @@ -0,0 +1,22 @@ +{ + "prettier": true, + "space": true, + "extends": [ + "xo-lass" + ], + "rules": { + "node/no-deprecated-api": "off", + "no-unused-vars": "off", + "no-prototype-builtins": "off", + "prefer-rest-params": "off", + "n/prefer-global/process": "off", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/naming-convention": "off", + "@typescript-eslint/prefer-nullish-coalescing": "off", + "unicorn/no-array-reduce": "off" + }, + "ignores": [ + "test/**", + "examples/**" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index d8774b9..fe1c4be 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,42 @@ -# [**koa-bodyparser**](https://github.com/koajs/bodyparser) - +# [**@koa/bodyparser**](https://github.com/koajs/bodyparser) [![NPM version][npm-image]][npm-url] -[![build status][travis-image]][travis-url] +![build status][github-action-image] [![Coveralls][coveralls-image]][coveralls-url] -[![David deps][david-image]][david-url] [![node version][node-image]][node-url] -[npm-image]: https://img.shields.io/npm/v/koa-bodyparser.svg?style=flat-square -[npm-url]: https://npmjs.com/package/koa-bodyparser -[travis-image]: https://img.shields.io/travis/koajs/bodyparser.svg?style=flat-square -[travis-url]: https://travis-ci.org/koajs/bodyparser +[npm-image]: https://img.shields.io/npm/v/@koa/bodyparser.svg?style=flat-square +[npm-url]: https://www.npmjs.com/package/@koa/router +[github-action-image]: https://github.com/koajs/bodyparser/actions/workflows/ci.yml/badge.svg?style=flat-square [coveralls-image]: https://img.shields.io/coveralls/koajs/bodyparser.svg?style=flat-square [coveralls-url]: https://coveralls.io/r/koajs/bodyparser?branch=master -[david-image]: https://img.shields.io/david/koajs/bodyparser.svg?style=flat-square -[david-url]: https://david-dm.org/koajs/bodyparser -[node-image]: https://img.shields.io/badge/node.js-%3E=_8-green.svg?style=flat-square +[node-image]: https://img.shields.io/badge/node.js-%3E=_14-green.svg?style=flat-square [node-url]: http://nodejs.org/download/ -A body parser for koa, based on [co-body](https://github.com/tj/co-body). support `json`, `form` and `text` type body. +Koa body parsing middleware, based on [co-body](https://github.com/tj/co-body). support `json`, `form` and `text` type body. + +Parse incoming request bodies in a middleware before your handlers, available under the `ctx.request.body` property. -> Notice: this module doesn't support parsing multipart format data, please use [`@koa/multer`](https://github.com/koajs/multer) to parse multipart format data. +> ⚠ Notice: **This module doesn't support parsing multipart format data**, please use [`@koa/multer`](https://github.com/koajs/multer) to parse multipart format data. ## Install -[![NPM](https://nodei.co/npm/koa-bodyparser.png?downloads=true)](https://nodei.co/npm/koa-bodyparser/) +[![NPM](https://nodei.co/npm/@koa/bodyparser.png?downloads=true)](https://nodei.co/npm/@koa/bodyparser) + +```bash +$ npm i @koa/bodyparser +``` ## Usage ```js -const Koa = require('koa'); -const bodyParser = require('koa-bodyparser'); +const Koa = require("koa"); +const bodyParser = require("@koa/bodyparser"); const app = new Koa(); app.use(bodyParser()); -app.use(async ctx => { +app.use((ctx) => { // the parsed body will store in ctx.request.body // if nothing was parsed, body will be an empty object {} ctx.body = ctx.request.body; @@ -44,49 +45,61 @@ app.use(async ctx => { ## Options -* **enableTypes**: parser will only parse when request type hits enableTypes, support `json/form/text/xml`, default is `['json', 'form']`. -* **encoding**: requested encoding. Default is `utf-8` by `co-body`. -* **formLimit**: limit of the `urlencoded` body. If the body ends up being larger than this limit, a 413 error code is returned. Default is `56kb`. -* **jsonLimit**: limit of the `json` body. Default is `1mb`. -* **textLimit**: limit of the `text` body. Default is `1mb`. -* **xmlLimit**: limit of the `xml` body. Default is `1mb`. -* **strict**: when set to true, JSON parser will only accept arrays and objects. Default is `true`. See [strict mode](https://github.com/cojs/co-body#options) in `co-body`. In strict mode, `ctx.request.body` will always be an object(or array), this avoid lots of type judging. But text body will always return string type. -* **detectJSON**: custom json request detect function. Default is `null`. +- **patchNode**: patch request body to Node's `ctx.req`, default is `false`. +- **enableTypes**: parser will only parse when request type hits enableTypes, support `json/form/text/xml`, default is `['json', 'form']`. +- **encoding**: requested encoding. Default is `utf-8` by `co-body`. +- **formLimit**: limit of the `urlencoded` body. If the body ends up being larger than this limit, a 413 error code is returned. Default is `56kb`. +- **jsonLimit**: limit of the `json` body. Default is `1mb`. +- **textLimit**: limit of the `text` body. Default is `1mb`. +- **xmlLimit**: limit of the `xml` body. Default is `1mb`. +- **jsonStrict**: when set to true, JSON parser will only accept arrays and objects. Default is `true`. See [strict mode](https://github.com/cojs/co-body#options) in `co-body`. In strict mode, `ctx.request.body` will always be an object(or array), this avoid lots of type judging. But text body will always return string type. +- **detectJSON**: custom json request detect function. Default is `null`. ```js - app.use(bodyParser({ - detectJSON: function (ctx) { - return /\.json$/i.test(ctx.path); - } - })); + app.use( + bodyParser({ + detectJSON(ctx) { + return /\.json$/i.test(ctx.path); + }, + }) + ); ``` -* **extendTypes**: support extend types: +- **extendTypes**: support extend types: ```js - app.use(bodyParser({ - extendTypes: { - json: ['application/x-javascript'] // will parse application/x-javascript type body as a JSON string - } - })); + app.use( + bodyParser({ + extendTypes: { + // will parse application/x-javascript type body as a JSON string + json: ["application/x-javascript"], + }, + }) + ); ``` -* **onerror**: support custom error handle, if `koa-bodyparser` throw an error, you can customize the response like: +- **onError**: support custom error handle, if `koa-bodyparser` throw an error, you can customize the response like: ```js - app.use(bodyParser({ - onerror: function (err, ctx) { - ctx.throw(422, 'body parse error'); - } - })); + app.use( + bodyParser({ + onError(err, ctx) { + ctx.throw(422, "body parse error"); + }, + }) + ); ``` -* **disableBodyParser**: you can dynamic disable body parser by set `ctx.disableBodyParser = true`. +- **enableRawChecking**: support the already parsed body on the raw request by override and prioritize the parsed value over the sended payload. (default is `false`) + +- **parsedMethods**: declares the HTTP methods where bodies will be parsed, default `['POST', 'PUT', 'PATCH']`. + +- **disableBodyParser**: you can dynamic disable body parser by set `ctx.disableBodyParser = true`. ```js - app.use(async (ctx, next) => { - if (ctx.path === '/disable') ctx.disableBodyParser = true; - await next(); + app.use((ctx, next) => { + if (ctx.path === "/disable") ctx.disableBodyParser = true; + return next(); }); app.use(bodyParser()); ``` @@ -98,14 +111,16 @@ You can access raw request body by `ctx.request.rawBody` after `koa-bodyparser` 1. `koa-bodyparser` parsed the request body. 2. `ctx.request.rawBody` is not present before `koa-bodyparser`. -## Koa 1 Support +## Koa v1.x.x Support -To use `koa-bodyparser` with koa@1, please use [bodyparser 2.x](https://github.com/koajs/bodyparser/tree/2.x). +To use `koa-bodyparser` with koa@1.x.x, please use [bodyparser 2.x](https://github.com/koajs/bodyparser/tree/2.x). ```bash npm install koa-bodyparser@2 --save ``` #### Licences + --- + [MIT](LICENSE) diff --git a/example.js b/examples/cjs/index.example.cjs similarity index 71% rename from example.js rename to examples/cjs/index.example.cjs index afeda96..1fc33df 100644 --- a/example.js +++ b/examples/cjs/index.example.cjs @@ -1,16 +1,16 @@ const Koa = require('koa'); -const bodyParser = require('.'); +const bodyParser = require('../../dist').default; const app = new Koa(); app.use(bodyParser()); -app.use(async function() { +app.use((ctx) => { // the parsed body will store in this.request.body - this.body = this.request.body; + ctx.body = ctx.request.body; }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => - console.log(`Server ready at http://localhost:${PORT} 🚀 ..`) + console.log(`Server ready at http://localhost:${PORT} 🚀 ..`), ); diff --git a/examples/esm/index.example.mjs b/examples/esm/index.example.mjs new file mode 100644 index 0000000..e60550b --- /dev/null +++ b/examples/esm/index.example.mjs @@ -0,0 +1,14 @@ +import Koa from 'koa'; +import bodyParser from '../../dist/index.mjs'; + +const app = new Koa(); +app.use(bodyParser()); + +app.use((ctx) => { + // the parsed body will store in this.request.body + ctx.body = ctx.request.body; +}); + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => console.log(`Server ready at http://localhost:${PORT} 🚀 ..`)); diff --git a/examples/typescript/index.example.ts b/examples/typescript/index.example.ts new file mode 100644 index 0000000..86a863b --- /dev/null +++ b/examples/typescript/index.example.ts @@ -0,0 +1,16 @@ +import Koa from 'koa'; +import {bodyParser} from '../../src'; + +const app = new Koa(); +app.use(bodyParser()); + +app.use((ctx) => { + // the parsed body will store in this.request.body + ctx.body = ctx.request.body; // eslint-disable-line @typescript-eslint/no-unsafe-assignment +}); + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => { + console.log(`Server ready at http://localhost:${PORT} 🚀 ..`); +}); diff --git a/index.js b/index.js deleted file mode 100644 index 0d01853..0000000 --- a/index.js +++ /dev/null @@ -1,136 +0,0 @@ -'use strict'; - -const parse = require('co-body'); -const copy = require('copy-to'); -const typeis = require('type-is'); - -/** - * @param [Object] opts - * - {String} jsonLimit default '1mb' - * - {String} formLimit default '56kb' - * - {string} encoding default 'utf-8' - * - {Object} extendTypes - */ - -module.exports = function(opts) { - opts = opts || {}; - const {detectJSON} = opts; - const {onerror} = opts; - - const enableTypes = opts.enableTypes || ['json', 'form']; - const enableForm = checkEnable(enableTypes, 'form'); - const enableJson = checkEnable(enableTypes, 'json'); - const enableText = checkEnable(enableTypes, 'text'); - const enableXml = checkEnable(enableTypes, 'xml'); - - opts.detectJSON = undefined; - opts.onerror = undefined; // eslint-disable-line unicorn/prefer-add-event-listener - - // force co-body return raw body - opts.returnRawBody = true; - - // default json types - const jsonTypes = [ - 'application/json', - 'application/json-patch+json', - 'application/vnd.api+json', - 'application/csp-report', - 'application/scim+json' - ]; - - // default form types - const formTypes = ['application/x-www-form-urlencoded']; - - // default text types - const textTypes = ['text/plain']; - - // default xml types - const xmlTypes = ['text/xml', 'application/xml']; - - const jsonOpts = formatOptions(opts, 'json'); - const formOpts = formatOptions(opts, 'form'); - const textOpts = formatOptions(opts, 'text'); - const xmlOpts = formatOptions(opts, 'xml'); - - const extendTypes = opts.extendTypes || {}; - - extendType(jsonTypes, extendTypes.json); - extendType(formTypes, extendTypes.form); - extendType(textTypes, extendTypes.text); - extendType(xmlTypes, extendTypes.xml); - - // eslint-disable-next-line func-names - return async function bodyParser(ctx, next) { - if (ctx.request.body !== undefined || ctx.disableBodyParser) - return await next(); // eslint-disable-line no-return-await - try { - const res = await parseBody(ctx); - ctx.request.body = 'parsed' in res ? res.parsed : {}; - if (ctx.request.rawBody === undefined) ctx.request.rawBody = res.raw; - } catch (err) { - if (onerror) { - onerror(err, ctx); - } else { - throw err; - } - } - - await next(); - }; - - async function parseBody(ctx) { - if ( - enableJson && - ((detectJSON && detectJSON(ctx)) || - isTypes(ctx.request.get('content-type'), jsonTypes)) - ) { - return await parse.json(ctx, jsonOpts); // eslint-disable-line no-return-await - } - - if (enableForm && ctx.request.is(formTypes)) { - return await parse.form(ctx, formOpts); // eslint-disable-line no-return-await - } - - if (enableText && ctx.request.is(textTypes)) { - return (await parse.text(ctx, textOpts)) || ''; - } - - if (enableXml && ctx.request.is(xmlTypes)) { - return (await parse.text(ctx, xmlOpts)) || ''; - } - - return {}; - } -}; - -function formatOptions(opts, type) { - const res = {}; - copy(opts).to(res); - res.limit = opts[type + 'Limit']; - return res; -} - -function extendType(original, extend) { - if (extend) { - if (!Array.isArray(extend)) { - extend = [extend]; - } - - extend.forEach(function(extend) { - original.push(extend); - }); - } -} - -function checkEnable(types, type) { - return types.includes(type); -} - -function isTypes(contentTypeValue, types) { - if (typeof contentTypeValue === 'string') { - // trim extra semicolon - contentTypeValue = contentTypeValue.replace(/;$/, ''); - } - - return typeis.is(contentTypeValue, types); -} diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..a73df28 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,9 @@ +import type {Config} from 'jest'; + +const jestConfig: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], +}; + +export default jestConfig; diff --git a/package.json b/package.json index 3cf98cc..fff0c69 100644 --- a/package.json +++ b/package.json @@ -1,68 +1,79 @@ { - "name": "koa-bodyparser", - "version": "4.4.1", - "description": "a body parser for Koa", - "main": "index.js", + "name": "@koa/bodyparser", + "version": "5.0.0", + "description": "Koa body parsing middleware", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], "scripts": { + "build": "tsup", "lint": "xo", "lint:fix": "xo --fix", - "test": "mocha --require should test/*.spec.js --exit", - "coverage": "nyc npm run test --reporter=lcov", - "ci": "npm run lint && npm run coverage" + "test": "jest --detectOpenHandles", + "test-ci": "npm run test -- --coverage" }, - "repository": { - "type": "git", - "url": "git://github.com/koajs/bodyparser.git" - }, - "files": [ - "index.js" - ], "keywords": [ + "koa", + "body", + "request-body", "bodyParser", "json", "urlencoded", - "koa", - "body" + "text", + "xml" ], "author": { "name": "dead_horse", "email": "dead_horse@qq.com", "url": " http://deadhorse.me" }, + "contributors": [ + { + "name": "Imed Jaberi", + "email": "imed_jebari@hotmail.fr" + } + ], "license": "MIT", "devDependencies": { - "eslint-config-xo-lass": "^1.0.3", - "husky": "^4.2.5", - "koa": "^2", - "mocha": "^10.2.0", - "nyc": "^15.0.1", - "should": "^13.2.3", - "supertest": "^4.0.2", - "xo": "0.25.4" + "@types/co-body": "^6.1.0", + "@types/jest": "^29.5.0", + "@types/koa": "^2.13.6", + "@types/lodash.merge": "^4.6.7", + "@types/node": "^18.15.11", + "@types/supertest": "^2.0.12", + "@types/type-is": "^1.6.3", + "eslint-config-xo-lass": "^2.0.1", + "husky": "^8.0.3", + "jest": "^29.5.0", + "koa": "^2.14.1", + "supertest": "^6.3.3", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.1", + "tsup": "^6.7.0", + "typescript": "^5.0.3", + "xo": "^0.54.2" }, "dependencies": { - "co-body": "^6.0.0", - "copy-to": "^2.0.1", + "co-body": "^6.1.0", + "lodash.merge": "^4.6.2", "type-is": "^1.6.18" }, - "xo": { - "prettier": true, - "space": true, - "extends": [ - "xo-lass" - ], - "rules": { - "node/no-deprecated-api": "off", - "no-unused-vars": "off", - "no-prototype-builtins": "off", - "prefer-rest-params": "off" - }, - "ignores": [ - "test/**" - ] - }, "engines": { - "node": ">=8.0.0" + "node": ">= 16" + }, + "repository": { + "type": "git", + "url": "git://github.com/koajs/bodyparser.git" }, "bugs": { "url": "https://github.com/koajs/body-parser/issues" diff --git a/src/body-parser.ts b/src/body-parser.ts new file mode 100644 index 0000000..3b9993c --- /dev/null +++ b/src/body-parser.ts @@ -0,0 +1,113 @@ +import parser from 'co-body'; +import type * as Koa from 'koa'; +import type {BodyParserOptions, BodyType} from './body-parser.types'; +import {getIsEnabledBodyAs, getMimeTypes, isTypes} from './body-parser.utils'; + +/** + * Global declaration for the added properties to the 'ctx.request' + */ +declare module 'koa' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Request { + body?: any; + rawBody: string; + } +} + +declare module 'http' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface IncomingMessage { + body?: any; + rawBody: string; + } +} +/** + * Middleware wrapper which delegate options to the core code + */ +export function bodyParserWrapper(opts: BodyParserOptions = {}) { + const { + patchNode = false, + parsedMethods = ['POST', 'PUT', 'PATCH'], + detectJSON, + onError, + enableTypes = ['json', 'form'], + extendTypes = {} as NonNullable, + enableRawChecking = false, + ...restOpts + } = opts; + const isEnabledBodyAs = getIsEnabledBodyAs(enableTypes); + const mimeTypes = getMimeTypes(extendTypes); + + /** + * Handler to parse the request coming data + */ + async function parseBody(ctx: Koa.Context) { + const shouldParseBodyAs = (type: BodyType) => { + return Boolean( + isEnabledBodyAs[type] && + isTypes(ctx.request.get('content-type'), mimeTypes[type]), + ); + }; + + const bodyType = + detectJSON?.(ctx) || shouldParseBodyAs('json') + ? 'json' + : shouldParseBodyAs('form') + ? 'form' + : shouldParseBodyAs('text') || shouldParseBodyAs('xml') + ? 'text' + : null; + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + if (!bodyType) return {} as Record; + const parserOptions = { + // force co-body return raw body + returnRawBody: true, + strict: bodyType === 'json' ? restOpts.jsonStrict : undefined, + [`${bodyType}Types`]: mimeTypes[bodyType], + limit: restOpts[`${shouldParseBodyAs('xml') ? 'xml' : bodyType}Limit`], + }; + + return parser[bodyType](ctx, parserOptions) as Promise< + Record + >; + } + + return async function (ctx: Koa.Context, next: Koa.Next) { + if ( + // method souldn't be parsed + !parsedMethods.includes(ctx.method.toUpperCase()) || + // patchNode enabled and raw request already parsed + (patchNode && ctx.req.body !== undefined) || + // koa request body already parsed + ctx.request.body !== undefined || + // bodyparser disabled + ctx.disableBodyParser + ) + return next(); + // raw request parsed and contain 'body' values and it's enabled to override the koa request + if (enableRawChecking && ctx.req.body !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + ctx.request.body = ctx.req.body; + return next(); + } + + try { + const response = await parseBody(ctx); + // patch node + if (patchNode) { + ctx.req.body = 'parsed' in response ? response.parsed : {}; + if (ctx.req.rawBody === undefined) ctx.req.rawBody = response.raw; + } + + // patch koa + ctx.request.body = 'parsed' in response ? response.parsed : {}; + if (ctx.request.rawBody === undefined) ctx.request.rawBody = response.raw; + } catch (err: unknown) { + if (!onError) throw err; + onError(err as Error, ctx); + } + + return next(); + }; +} diff --git a/src/body-parser.types.ts b/src/body-parser.types.ts new file mode 100644 index 0000000..5d3226b --- /dev/null +++ b/src/body-parser.types.ts @@ -0,0 +1,82 @@ +import type {Options as CoBodyOptions} from 'co-body'; +import type * as Koa from 'koa'; + +/** + * List of supported body types + */ +export const supportedBodyTypes = ['json', 'form', 'text', 'xml'] as const; +export type BodyType = (typeof supportedBodyTypes)[number]; + +/** + * BodyParser Options + */ +export type BodyParserOptions = { + /** + * declares the HTTP methods where bodies will be parsed. + * @default ['POST', 'PUT', 'PATCH'] + */ + parsedMethods?: string[]; + /** + * patch request body to Node's 'ctx.req' + * @default false + */ + patchNode?: boolean; + /** + * json detector function, can help to detect request json type based on custom logic + */ + detectJSON?: (ctx: Koa.Context) => boolean; + /** + * error handler, can help to customize the response on error case + */ + onError?: (error: Error, ctx: Koa.Context) => void; + /** + * false to disable the raw request body checking to prevent koa request override + * @default false + */ + enableRawChecking?: boolean; + /** + * co-body parser will only parse when request type hits enableTypes + * @default ['json', 'form'] + */ + enableTypes?: BodyType[]; + /** + * extend parser types, can help to enhance the base mime types with custom types + */ + extendTypes?: { + [K in BodyType]?: string[]; + }; + /** + * When set to true, JSON parser will only accept arrays and objects. + * When false will accept anything JSON.parse accepts. + * + * @default true + */ + jsonStrict?: CoBodyOptions['strict']; + /** + * limit of the `json` body + * @default '1mb' + */ + jsonLimit?: CoBodyOptions['limit']; + /** + * limit of the `urlencoded` body + * @default '56kb' + */ + formLimit?: CoBodyOptions['limit']; + /** + * limit of the `text` body + * @default '1mb' + */ + textLimit?: CoBodyOptions['limit']; + /** + * limit of the `xml` body + * @default '1mb' + */ + xmlLimit?: CoBodyOptions['limit']; +} & Pick< + CoBodyOptions, + /** + * requested encoding. + * @default 'utf-8' by 'co-body'. + */ + 'encoding' +>; diff --git a/src/body-parser.utils.ts b/src/body-parser.utils.ts new file mode 100644 index 0000000..b65d94c --- /dev/null +++ b/src/body-parser.utils.ts @@ -0,0 +1,96 @@ +import deepMerge from 'lodash.merge'; +import typeis from 'type-is'; +import { + type BodyParserOptions, + supportedBodyTypes, + type BodyType, +} from './body-parser.types'; + +/** + * UnsupportedBodyTypeError + */ +export class UnsupportedBodyTypeError extends Error { + constructor(wrongType: string) { + super(); + this.name = 'UnsupportedBodyTypeError'; + this.message = + `Invalid enabled type '${wrongType}'.` + + ` make sure to pass an array contains ` + + `supported types ([${supportedBodyTypes}]).`; + } +} + +/** + * Utility which help us to check if the body type enabled + */ +export function getIsEnabledBodyAs(enableTypes: BodyType[]) { + for (const enabledType of enableTypes) { + if (!supportedBodyTypes.includes(enabledType)) { + throw new UnsupportedBodyTypeError(enabledType); + } + } + + const isEnabledBodyAs = supportedBodyTypes.reduce( + (prevResult, currentType) => ({ + ...prevResult, + [currentType]: enableTypes.includes(currentType), + }), + {} as NonNullable, + ); + + return isEnabledBodyAs; +} + +/** + * Utility which help us to merge the extended mime types with our base + */ +export function getMimeTypes( + extendTypes: NonNullable, +) { + for (const extendedTypeKey of Object.keys(extendTypes) as BodyType[]) { + const extendedType = extendTypes[extendedTypeKey]; + + if ( + !supportedBodyTypes.includes(extendedTypeKey) || + !Array.isArray(extendedType) + ) { + throw new UnsupportedBodyTypeError(extendedTypeKey); + } + } + + const defaultMimeTypes = { + // default json mime types + json: [ + 'application/json', + 'application/json-patch+json', + 'application/vnd.api+json', + 'application/csp-report', + 'application/reports+json', + 'application/scim+json', + ], + // default form mime types + form: ['application/x-www-form-urlencoded'], + // default text mime types + text: ['text/plain'], + // default xml mime types + xml: ['text/xml', 'application/xml'], + }; + const mimeTypes = deepMerge(defaultMimeTypes, extendTypes); + + return mimeTypes; +} + +/** + * Check if the incoming request contains the "Content-Type" header + * field, and it contains any of the give mime types. If there + * is no request body, null is returned. If there is no content type, + * false is returned. Otherwise, it returns the first type that matches. + */ +export function isTypes(contentTypeValue: string, types: string[]) { + if (typeof contentTypeValue === 'string') { + // trim extra semicolon + contentTypeValue = contentTypeValue.replace(/;$/, ''); + } + + return typeis.is(contentTypeValue, types); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6f0ae89 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export { + /* istanbul ignore next */ + bodyParserWrapper as bodyParser, + bodyParserWrapper as default, +} from './body-parser'; diff --git a/test/middleware.spec.js b/test/middleware.spec.js deleted file mode 100644 index 70b0bc9..0000000 --- a/test/middleware.spec.js +++ /dev/null @@ -1,425 +0,0 @@ -const path = require('path'); -const request = require('supertest'); -const Koa = require('koa'); -const bodyParser = require('..'); - -const fixtures = path.join(__dirname, 'fixtures'); - -describe('test/middleware.test.js', function() { - describe('json body', function() { - let app; - beforeEach(function() { - app = App(); - }); - - it('should parse json body ok', function(done) { - // should work when use body parser again - app.use(bodyParser()); - - app.use(async ctx => { - ctx.request.body.should.eql({foo: 'bar'}); - ctx.request.rawBody.should.equal('{"foo":"bar"}'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .send({foo: 'bar'}) - .expect({foo: 'bar'}, done); - }); - - it('should parse json body with json-api headers ok', function(done) { - // should work when use body parser again - app.use(bodyParser()); - - app.use(async ctx => { - ctx.request.body.should.eql({foo: 'bar'}); - ctx.request.rawBody.should.equal('{"foo": "bar"}'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .set('Accept', 'application/vnd.api+json') - .set('Content-type', 'application/vnd.api+json') - .send('{"foo": "bar"}') - .expect({foo: 'bar'}, done); - }); - - it('should parse json body with `content-type: application/json;charset=utf-8;` headers ok', async () => { - app.use(bodyParser()); - - app.use(async ctx => { - ctx.request.body.should.eql({foo: 'bar'}); - ctx.request.rawBody.should.equal('{"foo": "bar"}'); - ctx.body = ctx.request.body; - }); - await request(app.listen()) - .post('/') - .set('Content-type', 'application/json;charset=utf-8;') - .send('{"foo": "bar"}') - .expect({foo: 'bar'}); - }); - - it('should parse json patch', function(done) { - const app = App(); - app.use(async ctx => { - ctx.request.body.should.eql([{op: 'add', path: '/foo', value: 'bar'}]); - ctx.request.rawBody.should.equal( - '[{"op": "add", "path": "/foo", "value": "bar"}]' - ); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .patch('/') - .set('Content-type', 'application/json-patch+json') - .send('[{"op": "add", "path": "/foo", "value": "bar"}]') - .expect([{op: 'add', path: '/foo', value: 'bar'}], done); - }); - - it('should json body reach the limit size', function(done) { - const app = App({jsonLimit: 100}); - app.use(async ctx => { - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .send(require(path.join(fixtures, 'raw.json'))) - .expect(413, done); - }); - - it('should json body error with string in strict mode', function(done) { - const app = App({jsonLimit: 100}); - app.use(async ctx => { - ctx.request.rawBody.should.equal('"invalid"'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .set('Content-type', 'application/json') - .send('"invalid"') - .expect(400, done); - }); - - it('should json body ok with string not in strict mode', function(done) { - const app = App({jsonLimit: 100, strict: false}); - app.use(async ctx => { - ctx.request.rawBody.should.equal('"valid"'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .set('Content-type', 'application/json') - .send('"valid"') - .expect(200) - .expect('valid', done); - }); - - describe('opts.detectJSON', function() { - it('should parse json body on /foo.json request', function(done) { - const app = App({ - detectJSON(ctx) { - return /\.json/i.test(ctx.path); - } - }); - - app.use(async ctx => { - ctx.request.body.should.eql({foo: 'bar'}); - ctx.request.rawBody.should.equal('{"foo":"bar"}'); - ctx.body = ctx.request.body; - }); - - request(app.listen()) - .post('/foo.json') - .send(JSON.stringify({foo: 'bar'})) - .expect({foo: 'bar'}, done); - }); - - it('should not parse json body on /foo request', function(done) { - const app = App({ - detectJSON(ctx) { - return /\.json/i.test(ctx.path); - } - }); - - app.use(async ctx => { - ctx.request.rawBody.should.equal('{"foo":"bar"}'); - ctx.body = ctx.request.body; - }); - - request(app.listen()) - .post('/foo') - .send(JSON.stringify({foo: 'bar'})) - .expect({'{"foo":"bar"}': ''}, done); - }); - }); - }); - - describe('form body', function() { - const app = App(); - - it('should parse form body ok', function(done) { - app.use(async ctx => { - ctx.request.body.should.eql({foo: {bar: 'baz'}}); - ctx.request.rawBody.should.equal('foo%5Bbar%5D=baz'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('form') - .send({foo: {bar: 'baz'}}) - .expect({foo: {bar: 'baz'}}, done); - }); - - it('should parse form body reach the limit size', function(done) { - const app = App({formLimit: 10}); - request(app.listen()) - .post('/') - .type('form') - .send({foo: {bar: 'bazzzzzzz'}}) - .expect(413, done); - }); - }); - - describe('text body', function() { - it('should parse text body ok', function(done) { - const app = App({ - enableTypes: ['text', 'json'] - }); - app.use(async ctx => { - ctx.request.body.should.equal('body'); - ctx.request.rawBody.should.equal('body'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('text') - .send('body') - .expect('body', done); - }); - - it('should not parse text body when disable', function(done) { - const app = App(); - app.use(async ctx => { - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('text') - .send('body') - .expect({}, done); - }); - }); - - describe('xml body', function() { - it('should parse xml body ok', function(done) { - const app = App({ - enableTypes: ['xml'] - }); - app.use(async ctx => { - ctx.headers['content-type'].should.equal('application/xml'); - ctx.request.body.should.equal('abc'); - ctx.request.rawBody.should.equal('abc'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('xml') - .send('abc') - .expect('abc', done); - }); - - it('should not parse text body when disable', function(done) { - const app = App(); - app.use(async ctx => { - ctx.headers['content-type'].should.equal('application/xml'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('xml') - .send('abc') - .expect({}, done); - }); - - it('should xml body reach the limit size', function(done) { - const app = App({ - enableTypes: ['xml'], - xmlLimit: 10 - }); - app.use(async ctx => { - ctx.headers['content-type'].should.equal('application/xml'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('xml') - .send('abcdefghijklmn') - .expect(413, done); - }); - }); - - describe('html body by text parser', function () { - it('should parse html body ok', function (done) { - const app = App({ - extendTypes: { - text: ['text/html'], - }, - enableTypes: ['text'], - }); - app.use(async (ctx) => { - console.log(ctx.request.body); - ctx.headers['content-type'].should.equal('text/html'); - ctx.request.body.should.equal('

abc

'); - ctx.request.rawBody.should.equal('

abc

'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('html') - .send('

abc

') - .expect('

abc

', done); - }); - - it('should not parse html body when disable', function (done) { - const app = App(); - app.use(async (ctx) => { - ctx.headers['content-type'].should.equal('text/html'); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('html') - .send('

abc

') - .expect({}, done); - }); - }); - - describe('extend type', function() { - it('should extend json ok', function(done) { - const app = App({ - extendTypes: { - json: 'application/x-javascript' - } - }); - app.use(async ctx => { - ctx.body = ctx.request.body; - }); - - request(app.listen()) - .post('/') - .type('application/x-javascript') - .send(JSON.stringify({foo: 'bar'})) - .expect({foo: 'bar'}, done); - }); - - it('should extend json with array ok', function(done) { - const app = App({ - extendTypes: { - json: ['application/x-javascript', 'application/y-javascript'] - } - }); - app.use(async ctx => { - ctx.body = ctx.request.body; - }); - - request(app.listen()) - .post('/') - .type('application/x-javascript') - .send(JSON.stringify({foo: 'bar'})) - .expect({foo: 'bar'}, done); - }); - - it('should extend xml ok', function(done) { - const app = App({ - enableTypes: ['xml'], - extendTypes: { - xml: 'application/xml-custom' - } - }); - app.use(async ctx => { - ctx.body = ctx.request.body; - }); - - request(app.listen()) - .post('/') - .type('application/xml-custom') - .send('abc') - .expect('abc', done); - }); - }); - - describe('enableTypes', function() { - it('should disable json success', function(done) { - const app = App({ - enableTypes: ['form'] - }); - - app.use(async ctx => { - ctx.body = ctx.request.body; - }); - request(app.listen()) - .post('/') - .type('json') - .send({foo: 'bar'}) - .expect({}, done); - }); - }); - - describe('other type', function() { - const app = App(); - - it('should get body null', function(done) { - app.use(async ctx => { - ctx.request.body.should.eql({}); - ctx.body = ctx.request.body; - }); - request(app.listen()) - .get('/') - .expect({}, done); - }); - }); - - describe('onerror', function() { - const app = App({ - onerror(err, ctx) { - ctx.throw('custom parse error', 422); - } - }); - - it('should get custom error message', function(done) { - app.use(async ctx => {}); - request(app.listen()) - .post('/') - .send('test') - .set('content-type', 'application/json') - .expect(422) - .expect('custom parse error', done); - }); - }); - - describe('disableBodyParser', () => { - it('should not parse body when disableBodyParser set to true', function(done) { - const app = new Koa(); - app.use(async (ctx, next) => { - ctx.disableBodyParser = true; - await next(); - }); - app.use(bodyParser()); - app.use(async ctx => { - (undefined === ctx.request.rawBody).should.equal(true); - ctx.body = ctx.request.body ? 'parsed' : 'empty'; - }); - request(app.listen()) - .post('/') - .send({foo: 'bar'}) - .set('content-type', 'application/json') - .expect(200) - .expect('empty', done); - }); - }); -}); - -function App(options) { - const app = new Koa(); - app.use(bodyParser(options)); - return app; -} diff --git a/test/middleware.test.ts b/test/middleware.test.ts new file mode 100644 index 0000000..49103de --- /dev/null +++ b/test/middleware.test.ts @@ -0,0 +1,561 @@ +import path from "path"; +import request from "supertest"; +import Koa from "koa"; + +import bodyParser from "../src"; +import { UnsupportedBodyTypeError } from "../src/body-parser.utils"; + +import { createApp, fixtures } from "./test-utils"; + +describe("test/body-parser.test.ts", () => { + let server: ReturnType["listen"]>; + + afterEach(() => { + if (server?.listening) server.close(); + }); + + describe("json body", () => { + let app: ReturnType; + beforeEach(() => { + app = createApp(); + }); + + it("should parse json body ok", async () => { + // should work when use body parser again + app.use(bodyParser()); + + app.use(async (ctx) => { + expect(ctx.request.body).toEqual({ foo: "bar" }); + expect(ctx.request.rawBody).toEqual('{"foo":"bar"}'); + ctx.body = ctx.request.body; + }); + + server = app.listen(); + + await request(server) + .post("/") + .send({ foo: "bar" }) + .expect({ foo: "bar" }); + }); + + it("should parse json body with json-api headers ok", async () => { + // should work when use body parser again + app.use(bodyParser()); + + app.use(async (ctx) => { + expect(ctx.request.body).toEqual({ foo: "bar" }); + expect(ctx.request.rawBody).toEqual('{"foo": "bar"}'); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .set("Accept", "application/vnd.api+json") + .set("Content-type", "application/vnd.api+json") + .send('{"foo": "bar"}') + .expect({ foo: "bar" }); + }); + + it.only("should parse json body with `content-type: application/json;charset=utf-8;` headers ok", async () => { + app.use(bodyParser()); + + app.use(async (ctx) => { + expect(ctx.request.body).toEqual({ foo: "bar" }); + expect(ctx.request.rawBody).toEqual('{"foo": "bar"}'); + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/") + .set("Content-type", "application/json;charset=utf-8;") + .send('{"foo": "bar"}') + .expect({ foo: "bar" }); + }); + + it("should parse json patch", async () => { + const app = createApp(); + app.use(async (ctx) => { + expect(ctx.request.body).toEqual([ + { op: "add", path: "/foo", value: "bar" }, + ]); + expect(ctx.request.rawBody).toEqual( + '[{"op": "add", "path": "/foo", "value": "bar"}]' + ); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .patch("/") + .set("Content-type", "application/json-patch+json") + .send('[{"op": "add", "path": "/foo", "value": "bar"}]') + .expect([{ op: "add", path: "/foo", value: "bar" }]); + }); + + it("should json body reach the limit size", async () => { + const app = createApp({ jsonLimit: 100 }); + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .send(require(path.join(fixtures, "raw.json"))) + .expect(413); + }); + + it("should json body error with string in strict mode", async () => { + const app = createApp({ jsonLimit: 100 }); + app.use(async (ctx) => { + expect(ctx.request.rawBody).toEqual('"invalid"'); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .set("Content-type", "application/json") + .send('"invalid"') + .expect(400); + }); + + it("should json body ok with string not in strict mode", async () => { + const app = createApp({ jsonLimit: 100, jsonStrict: false }); + app.use(async (ctx) => { + expect(ctx.request.rawBody).toEqual('"valid"'); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .set("Content-type", "application/json") + .send('"valid"') + .expect(200) + .expect("valid"); + }); + + describe("opts.detectJSON", () => { + it("should parse json body on /foo.json request", async () => { + const app = createApp({ + detectJSON(ctx) { + return /\.json/i.test(ctx.path); + }, + }); + + app.use(async (ctx) => { + expect(ctx.request.body).toEqual({ foo: "bar" }); + expect(ctx.request.rawBody).toEqual('{"foo":"bar"}'); + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/foo.json") + .send(JSON.stringify({ foo: "bar" })) + .expect({ foo: "bar" }); + }); + + it("should not parse json body on /foo request", async () => { + const app = createApp({ + detectJSON(ctx) { + return /\.json/i.test(ctx.path); + }, + }); + + app.use(async (ctx) => { + expect(ctx.request.rawBody).toEqual('{"foo":"bar"}'); + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/foo") + .send(JSON.stringify({ foo: "bar" })) + .expect({ '{"foo":"bar"}': "" }); + }); + }); + }); + + describe("form body", () => { + const app = createApp(); + + it("should parse form body ok", async () => { + app.use(async (ctx) => { + expect(ctx.request.body).toEqual({ foo: { bar: "baz" } }); + expect(ctx.request.rawBody).toEqual("foo%5Bbar%5D=baz"); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("form") + .send({ foo: { bar: "baz" } }) + .expect({ foo: { bar: "baz" } }); + }); + + it("should parse form body reach the limit size", async () => { + const app = createApp({ formLimit: 10 }); + server = app.listen(); + await request(server) + .post("/") + .type("form") + .send({ foo: { bar: "bazzzzzzz" } }) + .expect(413); + }); + }); + + describe("text body", () => { + it("should parse text body ok", async () => { + const app = createApp({ + enableTypes: ["text", "json"], + }); + app.use(async (ctx) => { + expect(ctx.request.body).toEqual("body"); + expect(ctx.request.rawBody).toEqual("body"); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server).post("/").type("text").send("body").expect("body"); + }); + + it("should not parse text body when disable", async () => { + const app = createApp(); + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server).post("/").type("text").send("body").expect({}); + }); + }); + + describe("xml body", () => { + it("should parse xml body ok", async () => { + const app = createApp({ + enableTypes: ["xml"], + }); + app.use(async (ctx) => { + expect(ctx.headers["content-type"]).toEqual("application/xml"); + expect(ctx.request.body).toEqual("abc"); + expect(ctx.request.rawBody).toEqual("abc"); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("xml") + .send("abc") + .expect("abc"); + }); + + it("should not parse text body when disable", async () => { + const app = createApp(); + app.use(async (ctx) => { + expect(ctx.headers["content-type"]).toEqual("application/xml"); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("xml") + .send("abc") + .expect({}); + }); + + it("should xml body reach the limit size", async () => { + const app = createApp({ + enableTypes: ["xml"], + xmlLimit: 10, + }); + app.use(async (ctx) => { + expect(ctx.headers["content-type"]).toEqual("application/xml"); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("xml") + .send("abcdefghijklmn") + .expect(413); + }); + }); + + describe("html body by text parser", () => { + it("should parse html body ok", async () => { + const app = createApp({ + extendTypes: { + text: ["text/html"], + }, + enableTypes: ["text"], + }); + app.use(async (ctx) => { + expect(ctx.headers["content-type"]).toEqual("text/html"); + expect(ctx.request.body).toEqual("

abc

"); + expect(ctx.request.rawBody).toEqual("

abc

"); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("html") + .send("

abc

") + .expect("

abc

"); + }); + + it("should not parse html body when disable", async () => { + const app = createApp(); + app.use(async (ctx) => { + expect(ctx.headers["content-type"]).toEqual("text/html"); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("html") + .send("

abc

") + .expect({}); + }); + }); + + describe("patchNode", () => { + it("should patch Node raw request with supported type", async () => { + const app = createApp({ patchNode: true }); + + app.use(async (ctx) => { + expect(ctx.request.body).toEqual({ foo: "bar" }); + expect(ctx.request.rawBody).toEqual('{"foo":"bar"}'); + expect(ctx.req.body).toEqual({ foo: "bar" }); + expect(ctx.req.rawBody).toEqual('{"foo":"bar"}'); + + ctx.body = ctx.req.body; + }); + server = app.listen(); + await request(server) + .post("/") + .send({ foo: "bar" }) + .expect({ foo: "bar" }); + }); + + it("should patch Node raw request with unsupported type", async () => { + const app = createApp({ patchNode: true }); + + app.use(async (ctx) => { + expect(ctx.request.body).toEqual({}); + expect(ctx.request.rawBody).toEqual(undefined); + expect(ctx.req.body).toEqual({}); + expect(ctx.req.rawBody).toEqual(undefined); + + ctx.body = ctx.req.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("application/x-unsupported-type") + .send("x-unsupported-type") + .expect({}); + }); + }); + + describe("extend type", () => { + it("should extend json ok", async () => { + const app = createApp({ + extendTypes: { + json: ["application/x-javascript"], + }, + }); + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/") + .type("application/x-javascript") + .send(JSON.stringify({ foo: "bar" })) + .expect({ foo: "bar" }); + }); + + it("should extend json with array ok", async () => { + const app = createApp({ + extendTypes: { + json: ["application/x-javascript", "application/y-javascript"], + }, + }); + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/") + .type("application/x-javascript") + .send(JSON.stringify({ foo: "bar" })) + .expect({ foo: "bar" }); + }); + + it("should extend xml ok", async () => { + const app = createApp({ + enableTypes: ["xml"], + extendTypes: { + xml: ["application/xml-custom"], + }, + }); + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/") + .type("application/xml-custom") + .send("abc") + .expect("abc"); + }); + + it("should throw when pass unsupported types", () => { + try { + createApp({ + extendTypes: { + "any-other-type": ["application/any-other-type"], + } as any, + }); + } catch (error) { + expect(error instanceof UnsupportedBodyTypeError).toBe(true); + } + }); + + it("should throw when pass supported types with string value instead of array", () => { + try { + createApp({ + extendTypes: { + "any-other-type": "application/any-other-type", + } as any, + }); + } catch (error) { + expect(error instanceof UnsupportedBodyTypeError).toBe(true); + } + }); + + it("should throw when pass supported types with array contain falsy values", () => { + try { + createApp({ + extendTypes: { + json: ["", 0, false, null, undefined], + } as any, + }); + } catch (error) { + expect(error instanceof UnsupportedBodyTypeError).toBe(true); + } + }); + }); + + describe("enableTypes", () => { + it("should disable json success", async () => { + const app = createApp({ + enableTypes: ["form"], + }); + + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server) + .post("/") + .type("json") + .send({ foo: "bar" }) + .expect({}); + }); + + it("should throw when pass unsupported types", () => { + try { + createApp({ + enableTypes: ["any-other-type" as any], + }); + } catch (error) { + expect(error instanceof UnsupportedBodyTypeError).toBe(true); + } + }); + }); + + describe("other type", () => { + const app = createApp(); + + it("should get body null", async () => { + app.use(async (ctx) => { + expect(ctx.request.body).toBeUndefined(); + ctx.body = ctx.request.body; + }); + server = app.listen(); + await request(server).get("/").expect({}); + }); + }); + + describe("onError", () => { + const app = createApp({ + onError({}, ctx) { + ctx.throw("custom parse error", 422); + }, + }); + + it("should get custom error message", async () => { + app.use(async () => {}); + server = app.listen(); + await request(server) + .post("/") + .send("test") + .set("content-type", "application/json") + .expect(422) + .expect("custom parse error"); + }); + }); + + describe("disableBodyParser", () => { + it("should not parse body when disableBodyParser set to true", async () => { + const app = new Koa(); + app.use(async (ctx, next) => { + ctx.disableBodyParser = true; + await next(); + }); + app.use(bodyParser()); + app.use(async (ctx) => { + expect(undefined === ctx.request.rawBody).toEqual(true); + ctx.body = ctx.request.body ? "parsed" : "empty"; + }); + server = app.listen(); + await request(server) + .post("/") + .send({ foo: "bar" }) + .set("content-type", "application/json") + .expect(200) + .expect("empty"); + }); + }); + + describe("enableRawChecking", () => { + it("should override koa request with raw request body if exist and enableRawChecking is truthy", async () => { + const rawParsedBody = { rawFoo: "rawBar" }; + const app = createApp({ rawParsedBody, enableRawChecking: true }); + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/") + .send({ foo: "bar" }) + .expect(rawParsedBody); + }); + + it("shouldn't override koa request with raw request body if not exist and enableRawChecking is truthy", async () => { + const rawParsedBody = undefined; + const app = createApp({ rawParsedBody, enableRawChecking: true }); + app.use(async (ctx) => { + ctx.body = ctx.request.body; + }); + + server = app.listen(); + await request(server) + .post("/") + .send({ foo: "bar" }) + .expect({ foo: "bar" }); + }); + }); +}); diff --git a/test/test-utils.ts b/test/test-utils.ts new file mode 100644 index 0000000..fc0b766 --- /dev/null +++ b/test/test-utils.ts @@ -0,0 +1,23 @@ +import path from "path"; +import Koa from "koa"; + +import bodyParser from "../src"; +import type { BodyParserOptions } from "../src/body-parser.types"; + +export const fixtures = path.join(__dirname, "fixtures"); +type CreateAppConfig = BodyParserOptions & { + rawParsedBody?: Record; +}; + +export const createApp = (config: CreateAppConfig = {}) => { + const { rawParsedBody, ...options } = config; + const app = new Koa(); + rawParsedBody && + app.use((ctx, next) => { + ctx.req.body = rawParsedBody; + return next(); + }); + + app.use(bodyParser(options)); + return app; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9cf41b3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "lib": [ + "dom", + "es6", + "es2017", + "esnext.asynciterable" + ], + "skipLibCheck": true, + "sourceMap": true, + "outDir": "./dist", + "moduleResolution": "node", + "removeComments": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "baseUrl": ".", + "types": ["jest", "node"] + }, + "include": [ + "./src/**/*.ts" + ], + "exclude": [ + "dist", + "node_modules", + "src/**/*.test.tsx" + ] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..b06acd7 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,15 @@ +import {defineConfig} from 'tsup'; + +const tsupConfig = defineConfig({ + name: '@koa/body-parser', + entry: ['src/*.ts'], + target: 'esnext', + format: ['cjs', 'esm'], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + platform: 'node', +}); + +export default tsupConfig;