diff --git a/packages/fetch-router/CHANGELOG.md b/packages/fetch-router/CHANGELOG.md index e48430aa2fc..24347f1214a 100644 --- a/packages/fetch-router/CHANGELOG.md +++ b/packages/fetch-router/CHANGELOG.md @@ -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) diff --git a/packages/fetch-router/src/index.ts b/packages/fetch-router/src/index.ts index cc757b32b67..5a9977391b3 100644 --- a/packages/fetch-router/src/index.ts +++ b/packages/fetch-router/src/index.ts @@ -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, diff --git a/packages/fetch-router/src/lib/route-helpers.test.ts b/packages/fetch-router/src/lib/route-helpers.test.ts new file mode 100644 index 00000000000..62f73ad8b95 --- /dev/null +++ b/packages/fetch-router/src/lib/route-helpers.test.ts @@ -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>>, + Assert>>, + Assert>>, + Assert>>, + Assert>>, + Assert>>, + Assert>>, + Assert>>, + Assert>>, +] diff --git a/packages/fetch-router/src/lib/route-helpers.ts b/packages/fetch-router/src/lib/route-helpers.ts new file mode 100644 index 00000000000..7114b1ab207 --- /dev/null +++ b/packages/fetch-router/src/lib/route-helpers.ts @@ -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(pattern: T) { + return { method: 'DELETE', pattern: pattern } as BuildRouteObject<'DELETE', T> +} + +/** + * Shorthand for a GET route. + */ +export function createGet(pattern: T) { + return { method: 'GET', pattern: pattern } as BuildRouteObject<'GET', T> +} + +/** + * Shorthand for a HEAD route. + */ +export function createHead(pattern: T) { + return { method: 'HEAD', pattern: pattern } as BuildRouteObject<'HEAD', T> +} + +/** + * Shorthand for a OPTIONS route. + */ +export function createOptions(pattern: T) { + return { method: 'OPTIONS', pattern: pattern } as BuildRouteObject<'OPTIONS', T> +} + +/** + * Shorthand for a PATCH route. + */ +export function createPatch(pattern: T) { + return { method: 'PATCH', pattern: pattern } as BuildRouteObject<'PATCH', T> +} + +/** + * Shorthand for a POST route. + */ +export function createPost(pattern: T) { + return { method: 'POST', pattern: pattern } as BuildRouteObject<'POST', T> +} + +/** + * Shorthand for a PUT route. + */ +export function createPut(pattern: T) { + return { method: 'PUT', pattern: pattern } as BuildRouteObject<'PUT', T> +} + +// prettier-ignore +type BuildRouteObject = + T extends string ? {method: M, pattern: T} : + T extends RoutePattern ? {method: M, pattern: RoutePattern

} : + never;