Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Haste 1.0.0 #8

Merged
merged 13 commits into from
Feb 1, 2024
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,6 @@ jobs:
working-directory: docs/examples/simple
run: yarn install --frozen-lockfile

- name: "Test 🧪"
- name: "Test simple example 🧪"
working-directory: docs/examples/simple
run: yarn test:ci
79 changes: 47 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,28 @@ A simple validated request in an app, looks as follows;
import express, { json } from 'express';
import { document } from 'express-haste';
import cookieParser from 'cookie-parser';
import { requiresMany } from "./requiresMany";
import { requires, body, query, header } from "./index";
import { requiresMany } from './requiresMany';
import { requires, body, query, header } from './index';
import { z } from 'zod';


const app = express();

app.post('/test', requires(
body(z.object({})),
query('someParam', z.string().default('somevalue')),
query('manyparam', z.string().array()),
header('x-my-header', z.string().uuid())
), handler);
app.post('/test', requires()
.body(z.object({}))
.query('someParam', z.string().default('somevalue'))
.query('manyparam', z.string().array())
.header('x-my-header', z.string().uuid())
, handler);
```

#### `body(schema)`

`requires().body(z.string())`

Given a zod schema, validates the req.body matches that schema and will make it the required body for that request
in the documentation. You may only provide one body, more than one will result in undefined behaviour for now.
in the documentation. You may only provide one body, when providing many .body(), the last to be defined will be taken.


#### `header(key, schema)`

Expand All @@ -73,28 +76,39 @@ Given a key and a ZodSchema, validate the header passes schema validation.
*You should always start with z.string() here but you can use [.transform](https://zod.dev/?id=transform)*
*and [.refine](https://zod.dev/?id=refine) to abstract validation logic.*

`requires().header('x-id', z.string())`

#### `cookie(key, schema)`

`requires().cookie('cookie', z.string())`

Given a key and a ZodSchema, validate the cookie passes schema validation, you will need
the [cookie-parser middleware](https://expressjs.com/en/resources/middleware/cookie-parser.html)
or similar for this to work.

*You should always start with z.string() here but you can use [.transform](https://zod.dev/?id=transform)*
*and [.refine](https://zod.dev/?id=refine) to abstract validation logic.*


#### `query(key, schema)`

`requires().query('page', z.string().array())`

Given a key and a Schema, validate a search/query parameter meets that validation, valid starting types are

- `z.string()` For single values
- `z.string().array()` For multiple values, ie; `?a=1&a=2` -> `[{a: [1,2]}]`

#### `path(key, schema)`

`requires().path('id', z.string())`

Given a key and a Schema, validate a path parameter listed in your path, key should take the exact same name as the
`:name` given to the parameter

#### `response(status, schema, {description})`
#### `response(status, schema, {description, contentType})`

`requires().response('200', z.object({message: z.string()}))`

Given a string status code, zod schema, and optionally a description, add this response to the documentation.
This validator will **NOT** validate the response, but will provide type checking if using `HasteRequestHandler`.
Expand Down Expand Up @@ -146,25 +160,25 @@ Who doesn't love having a typed application, you can add typing to your request
here's an example;

```typescript
import express, { json } from "express";
import { document } from "express-haste";
import cookieParser from "cookie-parser";
import { requiresMany } from "./requiresMany";
import { requires, body, query, header, HasteRequestHandler } from "./index";
import { z } from "zod";
import express, { json } from 'express';
import { document } from 'express-haste';
import cookieParser from 'cookie-parser';
import { requiresMany } from './requiresMany';
import { requires, body, query, header, HasteRequestHandler } from './index';
import { z } from 'zod';


const app = express();

const testRequirements = requires(
body(z.object({ param: z.string().optional() })),
query("someParam", z.string().default("somevalue")),
query("manyparam", z.string().array()),
path("pathid", z.string().transform(z.number().parse)),
response(200, z.object({
returnValue: z.number()
}))
)
const testRequirements = requires()
.body(z.object({ param: z.string().optional() }))
.query('someParam', z.string().default('somevalue'))
.query('manyparam', z.string().array())
.path('pathid', z.string().transform(z.number().parse))
.response(200, z.object({
returnValue: z.number(),
}),
);

const handler: HasteRequestHandler<typeof testRequirements> = (req, res) => {
req.body; // Will be {param?: string}
Expand All @@ -176,7 +190,7 @@ const handler: HasteRequestHandler<typeof testRequirements> = (req, res) => {
});
};

app.post("/test/:pathid", testRequirements, handler);
app.post('/test/:pathid', testRequirements, handler);
```
### Documenting

Expand All @@ -187,7 +201,7 @@ You can then feed this into the openapi provider of your choice to generate docu

import { document } from 'express-haste';
//... All your routing, it's important these have been finalised before you call document.
const spec = document(app, {
const spec = document(app).info({
appTitle: 'My First App',
appVersion: '1.0.0'
})
Expand All @@ -207,12 +221,13 @@ full end-to-end examples of how express-haste works.
### Roadmap

* [X] Request Handler typing.
* [ ] Improve test coverage.
* [X] Improve test coverage (97% coverage).
* [X] Lint and Test checking in github actions.
* [ ] Tests for typing (it's very fragile and hard to catch all edge cases manually).
* [ ] Explore whether typing can be made less complicated.
* [X] Explore whether typing can be made less complicated.
* [ ] Ability to pass many parameters into one query, header, etc function call.
ie; `query({q1: z.string(), q2: z.string()})`.
* [ ] Ability to customize error response when the request fails.
* [ ] Define behaviour for when many of the same body validation schemas are provided.
* [ ] Response validation and/or warning.
* This is now unblocked
* [X] Ability to customize error response when the request fails.
* [X] Define behaviour for when many of the same body validation schemas are provided.
~~* [ ] Response validation and/or warning.~~
37 changes: 37 additions & 0 deletions docs/examples/customErrorHandlingAndAuth/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { requires } from '../../../src';
import * as jwt from 'jsonwebtoken';
import { z } from 'zod';
import { HasteRequestHandler } from 'express-haste';

export const requireAuth = requires()
.auth('jwt', {
type: 'apiKey',
scheme: 'Bearer',
})
.response('401', z.object({ message: z.literal('Unauthorized') }));

const splitBearer = z.tuple([z.literal('Bearer'), z.string()]);
const tokenHeaderSchema = z
.string()
.transform((value) => splitBearer.parse(value.split(' ', 1))[1]);
/**
* This is NOT intended to be a reference for implementing secure jwt validation.
* This example is vastly oversimplified and inherently insecure,
* for details on a proper jwt implementation see https://www.npmjs.com/package/jsonwebtoken
*/
export const authValidator: HasteRequestHandler<typeof requireAuth> = (req, res, next) => {
try {
const rawToken = req.headers['authorization'];
const probablyToken = tokenHeaderSchema.parse(rawToken);
// IMPORTANT: this is being verified with a symmetric key, do not use in a real application
const token = jwt.verify(probablyToken, 'totally very secret');
if (token) {
req.app.set('user', token)
next();
}
} catch (e) {
res.status(401).json({
message: 'Unauthorized',
});
}
};
11 changes: 11 additions & 0 deletions docs/examples/customErrorHandlingAndAuth/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as H from '../../../src';
import 'express-haste';

jest.mock('express-haste');


describe('customErrorHandlingAndAuth', () => {
it('should pass', () => {
/** End-to-End Tests coming soon TM **/
})
});
47 changes: 47 additions & 0 deletions docs/examples/customErrorHandlingAndAuth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import express, { json, Router } from 'express';
import { HasteRequestHandler } from 'express-haste';
import cookieParser from 'cookie-parser';
import { HasteCustomErrorHandler, requires } from '../../../src';
import { z } from 'zod';
import { authValidator, requireAuth } from './auth';

const app: express.Express = express();

app.use(json());
app.use(cookieParser());
/**
* In this example we have 2 routes
* /public -> returns {message: "hello world"}
* /user -> accepts a jwt and returns the user object
* /docs -> view the documentation (no authentication)
*/

const docRouter = Router();
app.use('/docs', docRouter);

const customErrorFunction: HasteCustomErrorHandler = (e, res) =>
res.send({
message: e.issues.map((i) => i.message).join(' and '),
});

const r = () => requires({ errorHandler: customErrorFunction });

// Get one pet is exempt from needing a header for demo reasons.
app.get('/public', r().response('200', z.object({ message: z.string() })), (_req, res) =>
res.json({
message: 'hello world',
})
);

// Require an authorization header for requests not public
app.use(requireAuth, authValidator);

app.get('/user', r().response('200', z.object({}).passthrough().describe('Anything in the JWT')));
app.post('/user');

const updateRequirements = requires({ errorHandler: customErrorFunction })
.body(z.object({ email: z.string() }).describe('Update the email of this user'))
.response('202', z.object({ message: z.literal('Accepted') }));
const updateUser: HasteRequestHandler<typeof updateRequirements> = (_req, res) => res.status(202);

export default app;
6 changes: 6 additions & 0 deletions docs/examples/customErrorHandlingAndAuth/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
modulePathIgnorePatterns: ['/node_modules/', '/dist/'],
};
26 changes: 26 additions & 0 deletions docs/examples/customErrorHandlingAndAuth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "simple-haste-example",
"version": "1.0.0",
"main": "index.ts",
"license": "MIT",
"scripts": {
"start": "ts-node ./serve.ts",
"test:ci": "jest"
},
"dependencies": {
"@types/jsonwebtoken": "^9.0.5",
"body-parser": "^1.20.2",
"cookie-parser": "^1.4.6",
"express": "^4.18.2",
"express-haste": "1.0.0-canary.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.22.4"
},
"devDependencies": {
"jest": "^29.7.0",
"ts-node": "^10.9.2"
},
"overrides": {
"@hookform/resolvers": "^3.3.1"
}
}
28 changes: 28 additions & 0 deletions docs/examples/customErrorHandlingAndAuth/redoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const getRedocHtml = ({ apiPath }: { apiPath: string }) =>
`
<!DOCTYPE html>
<html lang="en">
<head>
<title>Example Pet App docs</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">

<!--
Redoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='{{apiPath}}'></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
</body>
</html>
`
.replace('{{apiPath}}', apiPath)
5 changes: 5 additions & 0 deletions docs/examples/customErrorHandlingAndAuth/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import app from "./index";

app.listen(3000, () => {
console.log('Check out http://localhost:3000/docs')
})
Loading
Loading