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

feat: add router support #64

Merged
merged 5 commits into from
Mar 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

✔️  **Extendable:** Ships with a set of composable utilities but can be extended

✔️  **Router:** Super fast route matching using [unjs/radix3](https://github.com/unjs/radix3)

## Install

```bash
Expand Down Expand Up @@ -57,7 +59,29 @@ listen(app)
```
</details>

## Examples
## Router

The `app` instance created by `h3` uses a middleware stack (see [how it works](#how-it-works)) with the ability to match route prefix and apply matched middleware.

To opt-in using a more advanced and convenient routing system, we can create a router instance and register it to app instance.

```ts
import { createApp, createRouter } from 'h3'

const app = createApp()

const router = createRouter()
.get('/', () => 'Hello World!')
.get('/hello/:name', req => `Hello ${req.params.name}!`)

app.use(router)
```

**Tip:** We can register same route more than once with different methods.

Routes are internally stored in a [Radix Tree](https://en.wikipedia.org/wiki/Radix_tree) and matched using [unjs/radix3](https://github.com/unjs/radix3).

## More usage examples

```js
// Handle can directly return object or Promise<object> for JSON response
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"cookie": "^0.4.2",
"destr": "^1.1.0",
"radix3": "^0.1.1",
"ufo": "^0.7.11"
},
"devDependencies": {
Expand Down
12 changes: 6 additions & 6 deletions playground/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { listen } from 'listhen'
import { createApp } from '../src'
import { createApp, createRouter } from '../src'

const app = createApp({ debug: true })
const app = createApp()
const router = createRouter()
.get('/', () => 'Hello World!')
.get('/hello/:name', req => `Hello ${req.params.name}!`)

app.use('/', () => {
// throw new Error('Foo bar')
return 'Hi!'
})
app.use(router)

listen(app)
2 changes: 1 addition & 1 deletion src/handle.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { withoutTrailingSlash, withoutBase } from 'ufo'
import type { IncomingMessage, ServerResponse } from './types/node'

export type Handle<T = any> = (req: IncomingMessage, res: ServerResponse) => T
export type Handle<T = any, ReqT={}> = (req: IncomingMessage & ReqT, res: ServerResponse) => T
export type PHandle = Handle<Promise<any>>
export type Middleware = (req: IncomingMessage, res: ServerResponse, next: (err?: Error) => any) => any
export type LazyHandle = () => Handle | Promise<Handle>
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './app'
export * from './error'
export * from './handle'
export * from './utils'
export * from './router'
76 changes: 76 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createRouter as _createRouter } from 'radix3'
import type { Handle } from './handle'
import type { HTTPMethod } from './types/http'
import { createError } from './error'

export type RouterMethod = Lowercase<HTTPMethod>
const RouterMethods: Lowercase<RouterMethod>[] = ['connect', 'delete', 'get', 'head', 'options', 'post', 'put', 'trace']

export type HandleWithParams = Handle<any, { params: Record<string, string> }>

export type AddWithMethod = (path: string, handle: HandleWithParams) => Router
export type AddRouteShortcuts = Record<Lowercase<HTTPMethod>, AddWithMethod>

export interface Router extends AddRouteShortcuts {
add: (path: string, handle: HandleWithParams, method?: RouterMethod | 'all') => Router
handle: Handle
}

interface RouteNode {
handlers: Partial<Record<RouterMethod| 'all', HandleWithParams>>
}

export function createRouter (): Router {
const _router = _createRouter<RouteNode>({})
const routes: Record<string, RouteNode> = {}

const router: Router = {} as Router

// Utilities to add a new route
router.add = (path, handle, method = 'all') => {
let route = routes[path]
if (!route) {
routes[path] = route = { handlers: {} }
_router.insert(path, route)
}
route.handlers[method] = handle
return router
}
for (const method of RouterMethods) {
router[method] = (path, handle) => router.add(path, handle, method)
}

// Main handle
router.handle = (req, res) => {
// Match route
const matched = _router.lookup(req.url || '/')
if (!matched) {
throw createError({
statusCode: 404,
name: 'Not Found',
statusMessage: `Cannot find any route matching ${req.url || '/'}.`
})
}

// Match method
const method = (req.method || 'get').toLowerCase() as RouterMethod
const handler: HandleWithParams | undefined = matched.handlers[method] || matched.handlers.all
if (!handler) {
throw createError({
statusCode: 405,
name: 'Method Not Allowed',
statusMessage: `Method ${method} is not allowed on this route.`
})
}

// Add params
// @ts-ignore
req.params = matched.params || {}

// Call handler
// @ts-ignore
return handler(req, res)
}

return router
}
2 changes: 2 additions & 0 deletions src/types/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// https://www.rfc-editor.org/rfc/rfc7231#section-4.1
export type HTTPMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE'
3 changes: 2 additions & 1 deletion src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { IncomingMessage } from 'http'
import destr from 'destr'
import type { Encoding } from '../types/node'
import { HTTPMethod, assertMethod } from './request'
import type { HTTPMethod } from '../types/http'
import { assertMethod } from './request'

const RawBodySymbol = Symbol('h3RawBody')
const ParsedBodySymbol = Symbol('h3RawBody')
Expand Down
4 changes: 1 addition & 3 deletions src/utils/request.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import type { IncomingMessage } from 'http'
import { getQuery } from 'ufo'
import { createError } from '../error'
import type { HTTPMethod } from '../types/http'

export function useQuery (req: IncomingMessage) {
return getQuery(req.url || '')
}

// https://www.rfc-editor.org/rfc/rfc7231#section-4.1
export type HTTPMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE'

export function useMethod (req: IncomingMessage, defaultMethod: HTTPMethod = 'GET'): HTTPMethod {
return (req.method || defaultMethod).toUpperCase() as HTTPMethod
}
Expand Down
42 changes: 42 additions & 0 deletions test/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import supertest, { SuperTest, Test } from 'supertest'
import { describe, it, expect, beforeEach } from 'vitest'
import { createApp, createRouter, App, Router } from '../src'

describe('router', () => {
let app: App
let router: Router
let request: SuperTest<Test>

beforeEach(() => {
app = createApp({ debug: false })
router = createRouter()
.add('/', () => 'Hello')
.get('/test', () => 'Test (GET)')
.post('/test', () => 'Test (POST)')

app.use(router)
request = supertest(app)
})

it('Handle route', async () => {
const res = await request.get('/')
expect(res.text).toEqual('Hello')
})

it('Handle different methods', async () => {
const res1 = await request.get('/test')
expect(res1.text).toEqual('Test (GET)')
const res2 = await request.post('/test')
expect(res2.text).toEqual('Test (POST)')
})

it('Not matching route', async () => {
const res = await request.get('/404')
expect(res.status).toEqual(404)
})

it('Not matching route method', async () => {
const res = await request.head('/test')
expect(res.status).toEqual(405)
})
})
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4689,6 +4689,11 @@ quick-lru@^4.0.1:
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==

radix3@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/radix3/-/radix3-0.1.1.tgz#33f783184fc6f2ae2b1d15427ae63c17d44e43cb"
integrity sha512-9Np01fn+penHvC05A9EkRpyObPMS0ht3t1UP6KlnQPCfTNzArmEZW/+t2SLsDtPcZyXPDbYCGWA8dSQqWaVwQQ==

randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
Expand Down