Skip to content

Commit 0719979

Browse files
authored
feat(server): add MethodOverrideHandlerPlugin (#1805)
Adds `MethodOverrideHandlerPlugin` to `@orpc/server/plugins`. A POST request carrying a `method` query parameter (e.g. `POST /todos/1?method=DELETE`) is routed and executed as that method, so HTML forms, which only support GET and POST, can invoke procedures routed as PUT, PATCH, or DELETE. ## Behavior - The override only applies to POST requests; the param name (`method`) and allowed targets (`PUT`, `PATCH`, `DELETE`) are configurable. - The parameter is stripped from the URL before input decoding, so it never leaks into query-decoded input (including `inputStructure: 'detailed'`). - Values outside the allowed list are silently ignored and the request proceeds as a regular POST. - GET and HEAD are excluded by default: they would switch input decoding from body to query and widen the CSRF surface. - Batch sub-requests are unaffected (`after = ['~batch']`). ## Notes for reviewers - Works with both `RPCHandler` and `OpenAPIHandler`; it is most useful with the latter, where the method decides route matching. - Docs page warns the plugin is incompatible with `SimpleCsrfProtectionHandlerPlugin`, which blocks the `navigate` fetch mode real form submissions use. ## Testing - Unit tests plus an `RPCHandler` integration test in `packages/server`, and `OpenAPIHandler` integration tests in `tests/openapi` (form-encoded POST hits a DELETE route with clean input, plain POST stays unmatched, no param leak into detailed `query`). - `pnpm type:check`, `pnpm lint`, and `pnpm docs:validate` pass.
1 parent de68509 commit 0719979

6 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: "Method Override Plugin"
3+
description: "Use MethodOverrideHandlerPlugin to override the HTTP method of a POST request with a query parameter, so HTML forms can invoke PUT, PATCH, and DELETE procedures."
4+
sidebar:
5+
label: "Method Override"
6+
---
7+
8+
## How It Works
9+
10+
HTML forms only support the GET and POST methods. When a POST request carries a `method` query parameter with an allowed value, the plugin routes the request as if it used that method and removes the parameter before input decoding. Values outside the allowed list are ignored and the request is processed as a regular POST.
11+
12+
```html
13+
<form method="post" action="/api/todos/1?method=DELETE">
14+
<button>Delete todo</button>
15+
</form>
16+
```
17+
18+
## Setup
19+
20+
```ts
21+
import { OpenAPIHandler } from '@orpc/openapi/fetch'
22+
import { MethodOverrideHandlerPlugin } from '@orpc/server/plugins'
23+
24+
const handler = new OpenAPIHandler(router, {
25+
plugins: [
26+
new MethodOverrideHandlerPlugin({
27+
/**
28+
* The query parameter carrying the override method.
29+
*
30+
* @default 'method'
31+
*/
32+
param: 'method',
33+
34+
/**
35+
* The methods a POST request may be overridden to.
36+
*
37+
* GET and HEAD are excluded by default because they switch input decoding
38+
* from the request body to the query string and widen the CSRF surface.
39+
*
40+
* @default ['PUT', 'PATCH', 'DELETE']
41+
*/
42+
methods: ['PUT', 'PATCH', 'DELETE'],
43+
}),
44+
],
45+
})
46+
```
47+
48+
:::info
49+
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. The plugin is most useful with [OpenAPIHandler](/docs/openapi/handler), where the HTTP method decides which procedure a request matches.
50+
:::
51+
52+
:::warning
53+
This plugin is incompatible with the [Simple CSRF Protection Plugin](/docs/plugins/simple-csrf-protection), which blocks the `navigate` fetch mode used by regular HTML form submissions.
54+
:::
55+
56+
## Learn More
57+
58+
For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/method-override.ts).

packages/server/src/plugins/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ it('exports plugins', async () => {
55
RequestHeadersHandlerPlugin: expect.any(Function),
66
ResponseHeadersHandlerPlugin: expect.any(Function),
77
SimpleCsrfProtectionHandlerPlugin: expect.any(Function),
8+
MethodOverrideHandlerPlugin: expect.any(Function),
89
RethrowHandlerPlugin: expect.any(Function),
910
RequestCompressionHandlerPlugin: expect.any(Function),
1011
RequestLimitHandlerPlugin: expect.any(Function),

packages/server/src/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
*/
77
CORSHandlerPlugin as CORSPlugin,
88
} from './cors'
9+
export * from './method-override'
910
export * from './request-compression'
1011
export * from './request-headers'
1112
export {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { RPCHandler } from '../adapters/fetch'
2+
import { os } from '../builder'
3+
import { MethodOverrideHandlerPlugin } from './method-override'
4+
5+
function getInterceptor(options?: ConstructorParameters<typeof MethodOverrideHandlerPlugin>[0]) {
6+
const existingInterceptor = vi.fn()
7+
8+
const handlerOptions = new MethodOverrideHandlerPlugin<any>(options).init({
9+
routingInterceptors: [existingInterceptor],
10+
} as any)
11+
12+
return {
13+
interceptor: handlerOptions.routingInterceptors![0]!,
14+
existingInterceptor,
15+
routingInterceptors: handlerOptions.routingInterceptors!,
16+
}
17+
}
18+
19+
async function invokeInterceptor(
20+
request: { method: string, url: string },
21+
options?: ConstructorParameters<typeof MethodOverrideHandlerPlugin>[0],
22+
) {
23+
const nextResult = { matched: true, response: 'ok' } as any
24+
const next = vi.fn().mockResolvedValue(nextResult)
25+
const { interceptor } = getInterceptor(options)
26+
27+
const result = await interceptor({
28+
context: {},
29+
request,
30+
next,
31+
} as any)
32+
33+
return { result, next, nextResult }
34+
}
35+
36+
describe('methodOverrideHandlerPlugin', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
})
40+
41+
it('prepends its routing interceptor before existing ones', () => {
42+
const { routingInterceptors, existingInterceptor } = getInterceptor()
43+
44+
expect(routingInterceptors).toHaveLength(2)
45+
expect(routingInterceptors[0]).not.toBe(existingInterceptor)
46+
expect(routingInterceptors[1]).toBe(existingInterceptor)
47+
})
48+
49+
it('ignores non-POST requests', async () => {
50+
const { result, next, nextResult } = await invokeInterceptor({ method: 'GET', url: '/todos/1?method=DELETE' })
51+
52+
expect(next).toHaveBeenCalledExactlyOnceWith()
53+
expect(result).toBe(nextResult)
54+
})
55+
56+
it('ignores POST requests without the override param', async () => {
57+
const { result, next, nextResult } = await invokeInterceptor({ method: 'POST', url: '/todos/1?x=1' })
58+
59+
expect(next).toHaveBeenCalledExactlyOnceWith()
60+
expect(result).toBe(nextResult)
61+
})
62+
63+
it('overrides the method and strips the param', async () => {
64+
const { result, next, nextResult } = await invokeInterceptor({ method: 'POST', url: '/todos/1?method=DELETE' })
65+
66+
expect(next).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
67+
request: expect.objectContaining({ method: 'DELETE', url: '/todos/1' }),
68+
}))
69+
expect(result).toBe(nextResult)
70+
})
71+
72+
it('matches the override value case-insensitively', async () => {
73+
const { next } = await invokeInterceptor({ method: 'POST', url: '/todos/1?method=delete' })
74+
75+
expect(next).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
76+
request: expect.objectContaining({ method: 'DELETE', url: '/todos/1' }),
77+
}))
78+
})
79+
80+
it('uses the last occurrence when the param is repeated and strips all of them', async () => {
81+
const { next } = await invokeInterceptor({ method: 'POST', url: '/todos/1?method=PUT&method=DELETE' })
82+
83+
expect(next).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
84+
request: expect.objectContaining({ method: 'DELETE', url: '/todos/1' }),
85+
}))
86+
})
87+
88+
it('preserves other query params and the hash', async () => {
89+
const { next } = await invokeInterceptor({ method: 'POST', url: '/a?x=1&method=DELETE&y=2#h' })
90+
91+
expect(next).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
92+
request: expect.objectContaining({ method: 'DELETE', url: '/a?x=1&y=2#h' }),
93+
}))
94+
})
95+
96+
it.each(['FOO', 'GET', 'HEAD', 'POST'])('silently ignores an override value that is not allowed: %s', async (method) => {
97+
const { result, next, nextResult } = await invokeInterceptor({ method: 'POST', url: `/todos/1?method=${method}` })
98+
99+
expect(next).toHaveBeenCalledExactlyOnceWith()
100+
expect(result).toBe(nextResult)
101+
})
102+
103+
it('honors a custom param name', async () => {
104+
const { next } = await invokeInterceptor({ method: 'POST', url: '/todos/1?_method=DELETE&method=PUT' }, { param: '_method' })
105+
106+
expect(next).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
107+
request: expect.objectContaining({ method: 'DELETE', url: '/todos/1?method=PUT' }),
108+
}))
109+
})
110+
111+
it('honors custom allowed methods', async () => {
112+
const { next } = await invokeInterceptor({ method: 'POST', url: '/todos/1?method=GET' }, { methods: ['get'] })
113+
114+
expect(next).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
115+
request: expect.objectContaining({ method: 'GET', url: '/todos/1' }),
116+
}))
117+
118+
const { next: next2 } = await invokeInterceptor({ method: 'POST', url: '/todos/1?method=DELETE' }, { methods: ['GET'] })
119+
120+
expect(next2).toHaveBeenCalledExactlyOnceWith()
121+
})
122+
123+
it('works with RPCHandler', async () => {
124+
const procedureHandler = vi.fn(() => 'pong')
125+
const handler = new RPCHandler(
126+
{
127+
ping: os.handler(procedureHandler),
128+
},
129+
{
130+
plugins: [new MethodOverrideHandlerPlugin()],
131+
},
132+
)
133+
134+
const { matched, response } = await handler.handle(new Request('https://example.com/ping?method=DELETE', {
135+
method: 'POST',
136+
headers: {
137+
'content-type': 'application/json',
138+
},
139+
body: JSON.stringify({ json: 'input' }),
140+
}))
141+
142+
expect(matched).toBe(true)
143+
expect(response!.status).toBe(200)
144+
await expect(response!.text()).resolves.toContain('pong')
145+
expect(procedureHandler).toHaveBeenCalledWith(expect.any(Object), 'input')
146+
})
147+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { StandardMethod, StandardUrl } from '@standardserver/core'
2+
import type { StandardHandlerOptions, StandardHandlerPlugin, StandardHandlerRoutingInterceptor } from '../adapters/standard'
3+
import type { Context } from '../context'
4+
import { toArray } from '@orpc/shared'
5+
import { parseStandardUrl } from '@standardserver/core'
6+
7+
export interface MethodOverrideHandlerPluginOptions {
8+
/**
9+
* The query parameter carrying the override method.
10+
*
11+
* @default 'method'
12+
*/
13+
param?: string
14+
15+
/**
16+
* The methods a POST request may be overridden to.
17+
*
18+
* GET and HEAD are excluded by default because they switch input decoding
19+
* from the request body to the query string and widen the CSRF surface.
20+
*
21+
* @default ['PUT', 'PATCH', 'DELETE']
22+
*/
23+
methods?: readonly StandardMethod[]
24+
}
25+
26+
/**
27+
* Overrides the HTTP method of a POST request based on a query parameter,
28+
* so HTML forms (which only support GET and POST) can invoke procedures
29+
* routed as PUT, PATCH, or DELETE.
30+
*
31+
* @see {@link https://orpc.dev/docs/plugins/method-override | Method Override Plugin}
32+
*/
33+
export class MethodOverrideHandlerPlugin<T extends Context> implements StandardHandlerPlugin<T> {
34+
name = '~method-override'
35+
36+
/**
37+
* Should override batch sub-request methods, not the original batch request.
38+
*/
39+
before = ['~batch']
40+
41+
private readonly param: string
42+
private readonly methods: ReadonlySet<string>
43+
44+
constructor(options: MethodOverrideHandlerPluginOptions = {}) {
45+
this.param = options.param ?? 'method'
46+
this.methods = new Set((options.methods ?? ['PUT', 'PATCH', 'DELETE']).map(method => method.toUpperCase()))
47+
}
48+
49+
init(options: StandardHandlerOptions<T>): StandardHandlerOptions<T> {
50+
const routingInterceptor: StandardHandlerRoutingInterceptor<T> = async ({ next, ...interceptorOptions }) => {
51+
const { request } = interceptorOptions
52+
53+
if (request.method !== 'POST') {
54+
return next()
55+
}
56+
57+
const [pathname, search, hash] = parseStandardUrl(request.url)
58+
const params = new URLSearchParams(search)
59+
const raw = params.getAll(this.param).at(-1)
60+
61+
if (raw === undefined) {
62+
return next()
63+
}
64+
65+
const method = raw.toUpperCase()
66+
67+
if (!this.methods.has(method)) {
68+
return next()
69+
}
70+
71+
params.delete(this.param)
72+
const url = `${pathname}${params.size ? `?${params}` : ''}${hash ?? ''}` as StandardUrl
73+
74+
return next({
75+
...interceptorOptions,
76+
request: { ...request, method, url },
77+
})
78+
}
79+
80+
return {
81+
...options,
82+
routingInterceptors: [routingInterceptor, ...toArray(options.routingInterceptors)],
83+
}
84+
}
85+
}

0 commit comments

Comments
 (0)