Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/fetch-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ This is the changelog for [`fetch-router`](https://github.com/remix-run/remix/tr
- Add support for `request.signal` abort, which now short-circuits the middleware chain. `router.fetch()` will now throw `DOMException` with `error.name === 'AbortError'` when a request is aborted
- Fix an issue where `Router`'s `fetch` wasn't spec-compliant
- Provide empty `context.formData` to `POST`/`PUT`/etc handlers when `parseFormData: false`
- Add functional aliases for routes that respond to a single HTTP verb

## v0.6.0 (2025-10-10)

Expand Down
17 changes: 17 additions & 0 deletions packages/fetch-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ export type {

export type { RouteHandlers, InferRouteHandler, RouteHandler } from './lib/route-handlers.ts'

export {
createDestroy,
createDestroy as destroy, // shorthand
createGet,
createGet as get, // shorthand
createHead,
createHead as head, // shorthand
createOptions,
createOptions as options, // shorthand
createPatch,
createPatch as patch, // shorthand
createPost,
createPost as post, // shorthand
createPut,
createPut as put, // shorthand
} from './lib/route-helpers.ts'

export {
Route,
createRoutes,
Expand Down
131 changes: 131 additions & 0 deletions packages/fetch-router/src/lib/route-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import * as assert from 'node:assert/strict'
import { describe, it } from 'node:test'

import type { Assert, IsEqual } from './type-utils.ts'
import { Route, createRoutes as route } from './route-map.ts'
import { destroy, get, head, options, patch, post, put } from '../index.ts'
import { RoutePattern } from '@remix-run/route-pattern'

describe('route helpers composition', () => {
it('composes route helpers in a route map', () => {
let routes = route({
home: '/',
posts: {
index: get('/posts'),
create: post('/posts'),
show: get('/posts/:id'),
update: put('/posts/:id'),
patch: patch('/posts/:id'),
destroy: destroy('/posts/:id'),
},
api: {
health: head('/api/health'),
options: options('/api/settings'),
},
})

assert.deepEqual(routes.home, new Route('ANY', '/'))
assert.deepEqual(routes.posts.index, new Route('GET', '/posts'))
assert.deepEqual(routes.posts.create, new Route('POST', '/posts'))
assert.deepEqual(routes.posts.show, new Route('GET', '/posts/:id'))
assert.deepEqual(routes.posts.update, new Route('PUT', '/posts/:id'))
assert.deepEqual(routes.posts.patch, new Route('PATCH', '/posts/:id'))
assert.deepEqual(routes.posts.destroy, new Route('DELETE', '/posts/:id'))
assert.deepEqual(routes.api.health, new Route('HEAD', '/api/health'))
assert.deepEqual(routes.api.options, new Route('OPTIONS', '/api/settings'))
})

it('composes route helpers with base paths', () => {
let apiRoutes = route('api/v1', {
users: {
index: get('/'),
create: post('/'),
show: get('/:id'),
update: put('/:id'),
destroy: destroy('/:id'),
},
})

let routes = route({
home: '/',
api: apiRoutes,
})

assert.deepEqual(routes.api.users.index, new Route('GET', '/api/v1'))
assert.deepEqual(routes.api.users.create, new Route('POST', '/api/v1'))
assert.deepEqual(routes.api.users.show, new Route('GET', '/api/v1/:id'))
assert.deepEqual(routes.api.users.update, new Route('PUT', '/api/v1/:id'))
assert.deepEqual(routes.api.users.destroy, new Route('DELETE', '/api/v1/:id'))
})

it('mixes helper methods with string patterns', () => {
let routes = route({
home: '/',
about: '/about',
contact: get('/contact'),
login: post('/auth/login'),
logout: destroy('/auth/logout'),
profile: {
show: '/profile',
edit: get('/profile/edit'),
update: patch('/profile'),
},
})

assert.deepEqual(routes.home, new Route('ANY', '/'))
assert.deepEqual(routes.about, new Route('ANY', '/about'))
assert.deepEqual(routes.contact, new Route('GET', '/contact'))
assert.deepEqual(routes.login, new Route('POST', '/auth/login'))
assert.deepEqual(routes.logout, new Route('DELETE', '/auth/logout'))
assert.deepEqual(routes.profile.show, new Route('ANY', '/profile'))
assert.deepEqual(routes.profile.edit, new Route('GET', '/profile/edit'))
assert.deepEqual(routes.profile.update, new Route('PATCH', '/profile'))
})

it('uses helper methods with complex patterns', () => {
let routes = route({
api: {
posts: get('/api/posts(/:lang)'),
createPost: post('/api/posts'),
updatePost: put('/api/posts/:id'),
deletePost: destroy('/api/posts/:id'),
},
healthCheck: head('/health'),
})

assert.deepEqual(routes.api.posts, new Route('GET', '/api/posts(/:lang)'))
assert.deepEqual(routes.api.createPost, new Route('POST', '/api/posts'))
assert.deepEqual(routes.api.updatePost, new Route('PUT', '/api/posts/:id'))
assert.deepEqual(routes.api.deletePost, new Route('DELETE', '/api/posts/:id'))
assert.deepEqual(routes.healthCheck, new Route('HEAD', '/health'))
})
})

let composedRoutes = route({
home: '/',
...route('posts', {
posts: get('/'),
createPost: post('/'),
showPost: get(':id'),
updatePost: put(':id'),
deletePost: destroy(':id'),
}),
api: {
health: head('/api/health'),
options: options('/api/settings'),
},
patch: patch(new RoutePattern('/patch')),
put: put(new RoutePattern('/misc/put')),
})

type Tests = [
Assert<IsEqual<typeof composedRoutes.posts, Route<'GET', '/posts'>>>,
Assert<IsEqual<typeof composedRoutes.createPost, Route<'POST', '/posts'>>>,
Assert<IsEqual<typeof composedRoutes.showPost, Route<'GET', '/posts/:id'>>>,
Assert<IsEqual<typeof composedRoutes.updatePost, Route<'PUT', '/posts/:id'>>>,
Assert<IsEqual<typeof composedRoutes.deletePost, Route<'DELETE', '/posts/:id'>>>,
Assert<IsEqual<typeof composedRoutes.api.health, Route<'HEAD', '/api/health'>>>,
Assert<IsEqual<typeof composedRoutes.api.options, Route<'OPTIONS', '/api/settings'>>>,
Assert<IsEqual<typeof composedRoutes.patch, Route<'PATCH', '/patch'>>>,
Assert<IsEqual<typeof composedRoutes.put, Route<'PUT', '/misc/put'>>>,
]
58 changes: 58 additions & 0 deletions packages/fetch-router/src/lib/route-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { RoutePattern } from '@remix-run/route-pattern'

import type { RequestMethod } from './request-methods'

/**
* Shorthand for a DELETE route.
*/
export function createDestroy<T extends string | RoutePattern>(pattern: T) {
return { method: 'DELETE', pattern: pattern } as BuildRouteObject<'DELETE', T>
}

/**
* Shorthand for a GET route.
*/
export function createGet<T extends string | RoutePattern>(pattern: T) {
return { method: 'GET', pattern: pattern } as BuildRouteObject<'GET', T>
}

/**
* Shorthand for a HEAD route.
*/
export function createHead<T extends string | RoutePattern>(pattern: T) {
return { method: 'HEAD', pattern: pattern } as BuildRouteObject<'HEAD', T>
}

/**
* Shorthand for a OPTIONS route.
*/
export function createOptions<T extends string | RoutePattern>(pattern: T) {
return { method: 'OPTIONS', pattern: pattern } as BuildRouteObject<'OPTIONS', T>
}

/**
* Shorthand for a PATCH route.
*/
export function createPatch<T extends string | RoutePattern>(pattern: T) {
return { method: 'PATCH', pattern: pattern } as BuildRouteObject<'PATCH', T>
}

/**
* Shorthand for a POST route.
*/
export function createPost<T extends string | RoutePattern>(pattern: T) {
return { method: 'POST', pattern: pattern } as BuildRouteObject<'POST', T>
}

/**
* Shorthand for a PUT route.
*/
export function createPut<T extends string | RoutePattern>(pattern: T) {
return { method: 'PUT', pattern: pattern } as BuildRouteObject<'PUT', T>
}

// prettier-ignore
type BuildRouteObject<M extends RequestMethod, T> =
T extends string ? {method: M, pattern: T} :
T extends RoutePattern<infer P extends string> ? {method: M, pattern: RoutePattern<P>} :
never;