Skip to content

Commit

Permalink
Merge pull request #6 from Bessonov/addCorsMiddleware
Browse files Browse the repository at this point in the history
add cors middleware
  • Loading branch information
Bessonov committed Apr 22, 2022
2 parents c50b03e + 523e6d3 commit 419d201
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 38 deletions.
96 changes: 64 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ See [full example](src/examples/micro.ts).

### Matchers

In the core, matchers are responsible to decide if particular handler should be called or not. There is no magic: matchers are interated on every request and first positive "match" calls defined handler.
In the core, matchers are responsible to decide if particular handler should be called or not. There is no magic: matchers are iterated on every request and first positive "match" calls defined handler.

#### MethodMatcher ([source](./src/matchers/MethodMatcher.ts))

Expand Down Expand Up @@ -146,18 +146,49 @@ router.addRoute({
})
```

### Middleware
### Middlewares

**This section is highly experimental!**

Currently, there is no built-in API for middlewares. It seems like there is no aproach to provide centralized and typesafe way for middlewares. And it need some conceptual work, before it will be added. Open an issue, if you have a great idea!

But well, handler can be wrapped like:
#### CorsMiddleware ([source](./src/middlewares/CorsMiddleware.ts))

Example of CorsMiddleware usage:

```typescript
const corsMiddleware = CorsMiddleware({
origins: corsOrigins,
})
```

Available options:

```typescript
interface CorsMiddlewareOptions {
// exact origins like 'http://0.0.0.0:8080' or '*'
origins: string[],
// methods like 'POST', 'GET' etc.
allowMethods?: HttpMethod[]
// headers like 'Authorization' or 'X-Requested-With'
allowHeaders?: string[]
// allows cookies in CORS scenario
allowCredentials?: boolean
// max age in seconds
maxAge?: number
}
```

See source file for defaults.

#### Create own middleware

```typescript
// example of a generic middleware, not a cors middleware!
function corsMiddleware(origin: string) {
return function corsWrapper<T extends MatchResult>(
wrappedHandler: Handler<T>,
): Handler<T> {
function CorsMiddleware(origin: string) {
return function corsWrapper<T extends MatchResult, D extends Matched<T>>(
wrappedHandler: Handler<T, D>,
): Handler<T, D> {
return async function corsHandler(req, res, ...args) {
// -> executed before handler
// it's even possible to skip the handler at all
Expand All @@ -170,7 +201,7 @@ function corsMiddleware(origin: string) {
}

// create a configured instance of middleware
const cors = corsMiddleware('http://0.0.0.0:8080')
const cors = CorsMiddleware('http://0.0.0.0:8080')

router.addRoute({
matcher: new MethodMatcher(['OPTIONS', 'POST']),
Expand All @@ -179,32 +210,10 @@ router.addRoute({
})
```

Of course you can create a `middlewares` wrapper and put all middlewares inside it:
```typescript
type Middleware<T extends (handler: Handler<MatchResult>) => Handler<MatchResult>> = Parameters<Parameters<T>[0]>[2]

function middlewares<T extends MatchResult>(
handler: Handler<T, Matched<T>
& Middleware<typeof session>
& Middleware<typeof cors>>,
): Handler<T> {
return function middlewaresHandler(...args) {
// @ts-expect-error
return cors(session(handler(...args)))
}
}

router.addRoute({
matcher,
// use it
handler: middlewares((req, res, { csrftoken }) => `Token: ${csrftoken}`),
})
```

Apropos typesafety. You can modify types in middleware:

```typescript
function valueMiddleware(myValue: string) {
function ValueMiddleware(myValue: string) {
return function valueWrapper<T extends MatchResult>(
handler: Handler<T, Matched<T> & {
// add additional type
Expand All @@ -221,14 +230,37 @@ function valueMiddleware(myValue: string) {
}
}

const value = valueMiddleware('world')
const value = ValueMiddleware('world')

router.addRoute({
matcher: new MethodMatcher(['GET']),
handler: value((req, res, { myValue }) => `Hello ${myValue}`),
})
```

#### DRY approach

Of course you can create a `middlewares` wrapper and put all middlewares inside it:
```typescript
type Middleware<T extends (handler: Handler<MatchResult>) => Handler<MatchResult>> = Parameters<Parameters<T>[0]>[2]

function middlewares<T extends MatchResult>(
handler: Handler<T, Matched<T>
& Middleware<typeof session>
& Middleware<typeof cors>>,
): Handler<T> {
return function middlewaresHandler(...args) {
return cors(session(handler))(...args)
}
}

router.addRoute({
matcher,
// use it
handler: middlewares((req, res, { csrftoken }) => `Token: ${csrftoken}`),
})
```

## License

MIT License
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
testRegex: '(/__tests__/.*|(\\.|/)test)\\.tsx?$',
testPathIgnorePatterns: ['/node_modules/'],
moduleFileExtensions: [...defaults.moduleFileExtensions, 'ts', 'tsx'],
coverageThreshold: {
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bessonovs/node-http-router",
"version": "0.0.9",
"version": "1.0.0",
"description": "Extensible http router for node and micro",
"keywords": [
"router",
Expand Down Expand Up @@ -41,11 +41,11 @@
"@types/express": "4.17.13",
"@types/jest": "27.4.1",
"@types/node": "16.11.7",
"@typescript-eslint/eslint-plugin": "5.17.0",
"@typescript-eslint/parser": "5.17.0",
"eslint": "8.12.0",
"@typescript-eslint/eslint-plugin": "5.20.0",
"@typescript-eslint/parser": "5.20.0",
"eslint": "8.13.0",
"eslint-config-airbnb": "19.0.4",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-jsx-a11y": "6.5.1",
"eslint-plugin-react": "7.29.4",
"jest": "27.5.1",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './matchers'
export * from './middlewares'
export type {
Handler,
Route,
Expand Down
96 changes: 96 additions & 0 deletions src/middlewares/CorsMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
MatchResult,
Matched,
} from '../matchers/MatchResult'
import {
Handler,
} from '../router'

type HttpMethod =
| 'POST'
| 'GET'
| 'PUT'
| 'PATCH'
| 'DELETE'
| 'OPTIONS'

const DEFAULT_ALLOWED_METHODS: HttpMethod[] = [
'POST',
'GET',
'PUT',
'PATCH',
'DELETE',
'OPTIONS',
]

const DEFAULT_ALLOWED_HEADERS = [
'X-Requested-With',
'Access-Control-Allow-Origin',
'Content-Type',
'Authorization',
'Accept',
]

const DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 // 24 hours

interface CorsMiddlewareOptions {
// exact origins like 'http://0.0.0.0:8080' or '*'
origins: string[]
// methods like 'POST', 'GET' etc.
allowMethods?: HttpMethod[]
// headers like 'Authorization' or 'X-Requested-With'
allowHeaders?: string[]
// allows cookies in CORS scenario
allowCredentials?: boolean
// max age in seconds
maxAge?: number
}

export function CorsMiddleware({
origins: originConfig,
allowMethods = DEFAULT_ALLOWED_METHODS,
allowHeaders = DEFAULT_ALLOWED_HEADERS,
allowCredentials = true,
maxAge = DEFAULT_MAX_AGE_SECONDS,
}: CorsMiddlewareOptions) {
return function corsWrapper<T extends MatchResult, D extends Matched<T>>(
handler: Handler<T, D>,
): Handler<T, D> {
return async function corsHandler(req, res, ...args) {
// avoid "Cannot set headers after they are sent to the client"
if (res.writableEnded) {
// TODO: not sure if handler should be called
return handler(req, res, ...args)
}

const origin = req.headers.origin ?? ''
if (originConfig.includes(origin) || originConfig.includes('*')) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
if (allowCredentials) {
res.setHeader('Access-Control-Allow-Credentials', 'true')
}
}

if (req.method === 'OPTIONS') {
if (allowMethods.length) {
res.setHeader('Access-Control-Allow-Methods', allowMethods.join(','))
}
if (allowHeaders.length) {
res.setHeader('Access-Control-Allow-Headers', allowHeaders.join(','))
}
if (maxAge) {
res.setHeader('Access-Control-Max-Age', String(maxAge))
}
// no further processing of preflight requests
res.statusCode = 200
res.end()
// eslint-disable-next-line consistent-return
return
}

const result = await handler(req, res, ...args)
return result
}
}
}
101 changes: 101 additions & 0 deletions src/middlewares/__tests__/CorsMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
createRequest,
createResponse,
} from 'node-mocks-http'
import {
CorsMiddleware,
} from '../CorsMiddleware'

describe('simple configuration', () => {
beforeEach(() => {
jest.resetAllMocks()
})

const innerHandler = jest.fn()
const handler = CorsMiddleware({
origins: ['http://0.0.0.0:8000'],
})(innerHandler)

it('no action', async () => {
const req = createRequest({
method: 'GET',
headers: {
origin: 'http://0.0.0.0:8000',
},
})
const res = createResponse()
await handler(req, res, { matched: true })
expect(innerHandler).toBeCalledTimes(1)
expect(res.getHeader('Access-Control-Allow-Methods')).toBeUndefined()
expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http://0.0.0.0:8000')
})

it('cors request', async () => {
const req = createRequest({
method: 'OPTIONS',
headers: {
origin: 'http://0.0.0.0:8000',
},
})
const res = createResponse()
await handler(req, res, { matched: true })
expect(innerHandler).toBeCalledTimes(0)
expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,GET,PUT,PATCH,DELETE,OPTIONS')
expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http://0.0.0.0:8000')
expect(res.getHeader('Access-Control-Allow-Credentials')).toBe('true')
})

it('without origin', async () => {
const req = createRequest({
method: 'OPTIONS',
})
const res = createResponse()
await handler(req, res, { matched: true })
expect(innerHandler).toBeCalledTimes(0)
expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,GET,PUT,PATCH,DELETE,OPTIONS')
expect(res.getHeader('Access-Control-Allow-Origin')).toBeUndefined()
})

it('request was ended before', async () => {
const req = createRequest({
method: 'GET',
})
const res = createResponse()
res.end()
await handler(req, res, { matched: true })
expect(innerHandler).toBeCalledTimes(1)
expect(res.getHeader('Access-Control-Allow-Methods')).toBeUndefined()
expect(res.getHeader('Access-Control-Allow-Origin')).toBeUndefined()
})
})

describe('changed defaults', () => {
beforeEach(() => {
jest.resetAllMocks()
})

const innerHandler = jest.fn()
const handler = CorsMiddleware({
origins: ['*'],
allowMethods: ['POST', 'DELETE'],
allowHeaders: ['Authorization'],
allowCredentials: false,
maxAge: 360,
})(innerHandler)

it('no action', async () => {
const req = createRequest({
method: 'OPTIONS',
headers: {
origin: 'http:/idontcare:80',
},
})
const res = createResponse()
await handler(req, res, { matched: true })
expect(innerHandler).toBeCalledTimes(0)
expect(res.getHeader('Access-Control-Allow-Methods')).toBe('POST,DELETE')
expect(res.getHeader('Access-Control-Allow-Origin')).toBe('http:/idontcare:80')
expect(res.getHeader('Access-Control-Max-Age')).toBe('360')
expect(res.getHeader('Access-Control-Allow-Credentials')).toBeUndefined()
})
})
3 changes: 3 additions & 0 deletions src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {
CorsMiddleware,
} from './CorsMiddleware'

0 comments on commit 419d201

Please sign in to comment.