Skip to content

Commit

Permalink
feat(data-cleaner-koa): initial monorepo release
Browse files Browse the repository at this point in the history
  • Loading branch information
IlyaSemenov committed Aug 9, 2022
1 parent a02f652 commit c9faaef
Show file tree
Hide file tree
Showing 7 changed files with 745 additions and 19 deletions.
1 change: 1 addition & 0 deletions packages/data-cleaner-koa/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/dist/
129 changes: 129 additions & 0 deletions packages/data-cleaner-koa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# data-cleaner-koa

This is a plugin for [node-data-cleaner](https://github.com/IlyaSemenov/node-data-cleaner) for _cleaning_ Koa.js requests (or more precisely, for `koa-body@4` requests).

It will return cleaned `body` and `files`, and throw a Koa HTTP error response with `{ "errors": ... }` if there are validation errors.

## Schema options

The cleaner creator accepts the following schema options:

- `body`: body cleaner (optional)
- `files`: files cleaner (optional)
- `errorCode`: HTTP status code for the failed validation response (default: 200)

## Example

Consider a registration form:

```vue
<template>
<form ref="form" @submit.prevent="submit">
Login: <input name="username" required /> Password:
<input name="password" type="password" required /> Company name:
<input name="company.name" required /> Domain:
<input name="company.domain" required />.mysaas.com
<button>Register</button>
errors = {{ errors }}
</form>
</template>
<script>
export default {
data() {
return {
errors: null,
}
},
methods: {
async submit() {
this.errors = null
const { data } = await this.$axios.post(
'/register',
new FormData(this.$refs.form),
)
if (data.errors) {
this.errors = data.errors
} else {
await this.$store.dispatch('login', data.user)
}
},
},
}
</script>
```

and the corresponding backend:

```js
import * as clean from 'data-cleaner'
import 'data-cleaner-koa' // injects into data-cleaner

const cleanRegister = clean.koa({
body: clean.object({
parseKeys: true,
fields: {
username: clean.string({
async clean (username: string) {
const user = await User.query().select('id').where('username', username).first()
if (user) {
throw new clean.ValidationError('This username is not available.')
}
return username
},
}),
password: clean.string(),
company: clean.object({
fields: {
name: clean.string(),
domain: clean.string({
async clean (domain: string) {
domain = domain.toLowerCase()
if (
domain.match(/^(www|mail|admin)/) ||
await Company.query().select('id').where('domain', domain).first()
) {
throw new clean.ValidationError('This domain is not available.')
}
return domain
},
}),
},
}),
},
}),
})

router.post('/register', async ctx => {
const { body } = await cleanRegister(ctx)
const user = await User.query().upsertGraphAndFetch({
username: body.username, // will be unique (*)
password: body.password,
company: {
name: body.company.name,
domain: body.company.domain, // will be lowercase
},
}),
ctx.body = user
})
```

- _NOTE: There is a race condition during unique username check, it's not handled for simplicity. For production use, wrap everything into a database transaction._

## Typescript

In the example above, `cleanKoa` will accept optional return value interface for body fields:

```ts
interface RegisterFields extends Pick<IUser, 'username' | 'password'> {
company: Pick<ICompany, 'name' | 'domain'>
}

const cleanRegister = clean.koa<RegisterFields>({
...
})

const { body } = await cleanRegister(ctx) // body is a RegisterFields object
```

The `files` are currently untyped.
38 changes: 38 additions & 0 deletions packages/data-cleaner-koa/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "data-cleaner-koa",
"version": "0.0.0-development",
"description": "data-cleaner plugin for Koa.js requests",
"repository": {
"type": "git",
"url": "https://github.com/IlyaSemenov/node-data-cleaner.git"
},
"author": "Ilya Semenov",
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"source": "src/index.ts",
"files": [
"dist/**",
"src/**"
],
"scripts": {
"build": "tsup",
"prepack": "npm run build",
"test": "echo No tests so far."
},
"dependencies": {
"http-errors": "^1.7.2"
},
"peerDependencies": {
"data-cleaner": "^3 | ^4"
},
"devDependencies": {
"@types/formidable": "~2.0.5",
"@types/http-errors": "^1.8.2",
"@types/koa": "^2.0.48",
"data-cleaner": "workspace:*",
"koa-body": "^4.1.0",
"tsup": "5.11.13"
}
}
69 changes: 69 additions & 0 deletions packages/data-cleaner-koa/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as clean from 'data-cleaner'
import { Cleaner } from 'data-cleaner'
import { Files } from 'formidable'
import createError from 'http-errors'
import Koa from 'koa'

export interface KoaSchema<BodyT, ReturnT> {
body?: Cleaner<BodyT, any> // TODO: any -> type of ctx.request.body
files?: Cleaner<Files>
clean?: Cleaner<ReturnT, CleanKoaRequest<BodyT>>
errorCode?: number
}

export interface CleanKoaRequest<BodyT> {
body: BodyT
files: Files // TODO: allow this to be optional
}

export default function clean_koa<
BodyT = any,
StateT = any,
ContextT extends Koa.ParameterizedContext<StateT> = Koa.ParameterizedContext<StateT>,
ReturnT = CleanKoaRequest<BodyT>,
>(schema: KoaSchema<BodyT, ReturnT>): Cleaner<ReturnT, ContextT> {
return clean.any<ReturnT, ContextT>({
async clean(ctx, opts) {
try {
let res: any = {}
if (schema.body) {
res.body = await schema.body(ctx.request.body, opts)
}
if (schema.files) {
res.files = await schema.files(ctx.request.files, opts)
}
if (schema.clean) {
res = await schema.clean(res, opts)
}
return res
} catch (err) {
if (err instanceof clean.ValidationError) {
const http_error = createError<number>(
400,
JSON.stringify({ errors: err.messages || err.errors }),
{
headers: { 'Content-Type': 'application/json' },
},
)
// avoid createError deprecation warning for status < 400
http_error.status = schema.errorCode || 200
// prevent Koa from resetting content-type, see https://github.com/koajs/koa/issues/787
Object.defineProperty(ctx.response, 'type', {
set() {
// do nothing.
},
})
throw http_error
}
throw err
}
},
})
}

declare module 'data-cleaner' {
export const koa: typeof clean_koa
}

// eslint-disable-next-line @typescript-eslint/no-var-requires
require('data-cleaner').koa = clean_koa
7 changes: 7 additions & 0 deletions packages/data-cleaner-koa/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"include": ["src"],
"compilerOptions": {
"types": ["koa-body"]
}
}
10 changes: 10 additions & 0 deletions packages/data-cleaner-koa/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from 'tsup'

export default defineConfig({
clean: true,
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
sourcemap: true,
dts: true,
external: ['data-cleaner', 'formidable', 'http-errors', 'koa', 'koa-body'],
})
Loading

0 comments on commit c9faaef

Please sign in to comment.