Skip to content

Commit 5252335

Browse files
committed
Add functionality to override http methods (issue sveltejs#1046)
See: sveltejs#1046
1 parent 083491f commit 5252335

File tree

19 files changed

+502
-11
lines changed

19 files changed

+502
-11
lines changed

.changeset/chilly-moose-provide.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sveltejs/kit': patch
3+
'create-svelte': patch
4+
---
5+
6+
[feat] add functionality to override http methods (issue #1046)

documentation/docs/01-routing.md

+39
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,45 @@ The `body` property of the request object will be provided in the case of POST r
161161
- Form data (with content-type `application/x-www-form-urlencoded` or `multipart/form-data`) will be parsed to a read-only version of the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object.
162162
- All other data will be provided as a `Uint8Array`
163163

164+
#### HTTP Method Overrides
165+
166+
Given that the only valid `<form>` methods are GET and POST, functionality is provided to override this limitation and allow the use of other HTTP verbs. This is particularly helpful when ensuring your application works even when javascript fails or is disabled.
167+
168+
- Disabled by default to prevent unintended behavior for those who don't need it
169+
- For security purposes, the original method on the form must be `POST` and cannot be overridden with `GET`
170+
- There are 2 different ways to specify a method override strategy
171+
- `url_parameter`: Pass the key and desired method as a query string
172+
- `form_data`: Pass as a field within the form where the field name is the key and the field value is the desired method
173+
- `strategy: 'both'` will enable both methods (`url_parameter` takes precendence in the event that both strategies are used within the same form)
174+
175+
```js
176+
// svelte.config.js
177+
export default {
178+
kit: {
179+
methodOverride: {
180+
enabled: true,
181+
key: '_method',
182+
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
183+
strategy: 'both'
184+
},
185+
}
186+
};
187+
```
188+
189+
```html
190+
<form method="post" action="/todos/{id}?_method=put">
191+
<!-- form elements -->
192+
</form>
193+
```
194+
195+
OR
196+
197+
```html
198+
<form method="post" action="/todos/{id}">
199+
<input type="hidden" name="_method" value="PUT">
200+
</form>
201+
```
202+
164203
### Private modules
165204

166205
A filename that has a segment with a leading underscore, such as `src/routes/foo/_Private.svelte` or `src/routes/bar/_utils/cool-util.js`, is hidden from the router, but can be imported by files that are not.

documentation/docs/14-configuration.md

+19
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ const config = {
2929
host: null,
3030
hostHeader: null,
3131
hydrate: true,
32+
methodOverride: {
33+
enabled: false,
34+
key: '_method',
35+
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
36+
strategy: 'both'
37+
},
3238
package: {
3339
dir: 'package',
3440
emitTypes: true,
@@ -124,6 +130,19 @@ export default {
124130

125131
Whether to [hydrate](#ssr-and-javascript-hydrate) the server-rendered HTML with a client-side app. (It's rare that you would set this to `false` on an app-wide basis.)
126132

133+
### methodOverride
134+
135+
See [HTTP Method Overrides](#routing-endpoints-http-method-overrides). An object containing zero or more of the following:
136+
137+
- `enabled` — set to `true` to enable method overriding
138+
- `key` — query parameter name/field name to use for passing the intended method value
139+
- `allowedMethods` - array of HTTP methods that can be used when overriding the original request method
140+
- `strategy`
141+
142+
- `'both'` — (default) will look for the override key in both the list of query parameters and the form fields
143+
- `'url_parameter'` — only allow overriding via a query parameter
144+
- `form_data` — only allow overriding via a form field
145+
127146
### package
128147

129148
Options related to [creating a package](#packaging).

packages/create-svelte/templates/default/src/hooks.ts

-5
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,6 @@ export const handle: Handle = async ({ request, resolve }) => {
66
const cookies = cookie.parse(request.headers.cookie || '');
77
request.locals.userid = cookies.userid || uuid();
88

9-
// TODO https://github.com/sveltejs/kit/issues/1046
10-
if (request.query.has('_method')) {
11-
request.method = request.query.get('_method').toUpperCase();
12-
}
13-
149
const response = await resolve(request);
1510

1611
if (!cookies.userid) {

packages/create-svelte/templates/default/svelte.config.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ const config = {
1111
adapter: adapter(),
1212

1313
// hydrate the <div id="svelte"> element in src/app.html
14-
target: '#svelte'
14+
target: '#svelte',
15+
16+
// Override http methods in the Todo forms
17+
methodOverride: {
18+
enabled: true
19+
}
1520
}
1621
};
1722

packages/kit/src/core/build/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,12 @@ async function build_server(
334334
initiator: undefined,
335335
load_component,
336336
manifest,
337+
methodOverride: {
338+
enabled: ${config.kit.methodOverride.enabled},
339+
key: ${s(config.kit.methodOverride.key)},
340+
allowedMethods: [${config.kit.methodOverride.allowedMethods.map(method => s(method))}],
341+
strategy: ${s(config.kit.methodOverride.strategy)}
342+
},
337343
paths: settings.paths,
338344
prerender: ${config.kit.prerender.enabled},
339345
read: settings.read,

packages/kit/src/core/config/index.spec.js

+12
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ test('fills in defaults', () => {
3232
host: null,
3333
hostHeader: null,
3434
hydrate: true,
35+
methodOverride: {
36+
enabled: false,
37+
key: '_method',
38+
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
39+
strategy: 'both'
40+
},
3541
package: {
3642
dir: 'package',
3743
emitTypes: true
@@ -132,6 +138,12 @@ test('fills in partial blanks', () => {
132138
host: null,
133139
hostHeader: null,
134140
hydrate: true,
141+
methodOverride: {
142+
enabled: false,
143+
key: '_method',
144+
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
145+
strategy: 'both'
146+
},
135147
package: {
136148
dir: 'package',
137149
emitTypes: true

packages/kit/src/core/config/options.js

+16
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ const options = object(
7373

7474
hydrate: boolean(true),
7575

76+
methodOverride: object({
77+
enabled: boolean(false),
78+
key: string('_method'),
79+
allowedMethods: validate(['PUT', 'PATCH', 'DELETE'], (input, keypath) => {
80+
if (!Array.isArray(input) || !input.every((method) => typeof method === 'string')) {
81+
throw new Error(`${keypath} must be an array of strings`);
82+
}
83+
84+
return input;
85+
}),
86+
strategy: validate('both', (input, keypath) => {
87+
if (['both', 'url_parameter', 'form_data'].includes(input)) return input;
88+
throw new Error(`${keypath} must be either "both", "url_parameter" or "form_data"`);
89+
})
90+
}),
91+
7692
package: object({
7793
dir: string('package'),
7894
// excludes all .d.ts and filename starting with _

packages/kit/src/core/config/test/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ async function testLoadDefaultConfig(path) {
3737
host: null,
3838
hostHeader: null,
3939
hydrate: true,
40+
methodOverride: {
41+
enabled: false,
42+
key: '_method',
43+
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
44+
strategy: 'both'
45+
},
4046
package: {
4147
dir: 'package',
4248
emitTypes: true

packages/kit/src/core/dev/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ async function create_plugin(config, dir, cwd, get_manifest) {
406406
},
407407
hooks,
408408
hydrate: config.kit.hydrate,
409+
methodOverride: config.kit.methodOverride,
409410
paths: {
410411
base: config.kit.paths.base,
411412
assets: config.kit.paths.assets ? SVELTE_KIT_ASSETS : config.kit.paths.base

packages/kit/src/runtime/server/index.js

+26
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { lowercase_keys } from './utils.js';
77
import { hash } from '../hash.js';
88
import { get_single_valued_header } from '../../utils/http.js';
99
import { coalesce_to_error } from '../../utils/error.js';
10+
import { ReadOnlyFormData } from './parse_body/read_only_form_data.js';
1011

1112
/** @type {import('@sveltejs/kit/ssr').Respond} */
1213
export async function respond(incoming, options, state = {}) {
@@ -40,6 +41,31 @@ export async function respond(incoming, options, state = {}) {
4041
locals: {}
4142
};
4243

44+
if (options.methodOverride.enabled && request.method.toUpperCase() === 'POST') {
45+
const { strategy = '', key: method_key = '', allowedMethods = [] } = options.methodOverride;
46+
let new_request_method;
47+
48+
if (
49+
['both', 'form_data'].includes(strategy) &&
50+
request.body instanceof ReadOnlyFormData &&
51+
request.body.has(method_key)
52+
) {
53+
new_request_method = (request.body.get(method_key) || request.method).toUpperCase();
54+
}
55+
56+
if (['both', 'url_parameter'].includes(strategy) && incoming.query.has(method_key)) {
57+
new_request_method = (incoming.query.get(method_key) || request.method).toUpperCase();
58+
}
59+
60+
if (
61+
new_request_method &&
62+
allowedMethods.includes(new_request_method) &&
63+
new_request_method !== 'GET'
64+
) {
65+
request.method = new_request_method;
66+
}
67+
}
68+
4369
try {
4470
return await options.hooks.handle({
4571
request,

packages/kit/src/runtime/server/parse_body/read_only_form_data.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function read_only_form_data() {
1919
};
2020
}
2121

22-
class ReadOnlyFormData {
22+
export class ReadOnlyFormData {
2323
/** @type {Map<string, string[]>} */
2424
#map;
2525

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as assert from 'uvu/assert';
2+
3+
/** @type {import('test').TestMaker} */
4+
export default function (test) {
5+
test('http method is overridden via URL parameter', '/method-override', async ({ page }) => {
6+
let val;
7+
8+
// Check initial value
9+
val = await page.textContent('h1');
10+
assert.equal('', val);
11+
12+
await page.click('"PATCH"');
13+
val = await page.textContent('h1');
14+
assert.equal('PATCH', val);
15+
16+
await page.click('"DELETE"');
17+
val = await page.textContent('h1');
18+
assert.equal('DELETE', val);
19+
});
20+
21+
test('GET method is not overridden', '/method-override', async ({ page }) => {
22+
await page.click('"No Override From GET"');
23+
const val = await page.textContent('h1');
24+
assert.equal('GET', val);
25+
});
26+
27+
test('POST method is not overridden with GET', '/method-override', async ({ page }) => {
28+
await page.click('"No Override To GET"');
29+
const val = await page.textContent('h1');
30+
assert.equal('POST', val);
31+
});
32+
33+
test('http method is overridden via hidden input', '/method-override', async ({ page }) => {
34+
await page.click('"PATCH Via Hidden Input"');
35+
const val = await page.textContent('h1');
36+
assert.equal('PATCH', val);
37+
});
38+
39+
test('GET method is not overridden via hidden input', '/method-override', async ({ page }) => {
40+
await page.click('"No Override From GET Via Hidden Input"');
41+
const val = await page.textContent('h1');
42+
assert.equal('GET', val);
43+
});
44+
45+
test(
46+
'POST method is not overridden with GET via hidden input',
47+
'/method-override',
48+
async ({ page }) => {
49+
await page.click('"No Override To GET Via Hidden Input"');
50+
const val = await page.textContent('h1');
51+
assert.equal('POST', val);
52+
}
53+
);
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const buildResponse = (/** @type {string} */ method) => ({
2+
status: 303,
3+
headers: {
4+
location: `/method-override?method=${method}`
5+
}
6+
});
7+
8+
/** @type {import('@sveltejs/kit').RequestHandler} */
9+
export const get = (request) => {
10+
return buildResponse(request.method);
11+
};
12+
13+
/** @type {import('@sveltejs/kit').RequestHandler} */
14+
export const post = (request) => {
15+
return buildResponse(request.method);
16+
};
17+
18+
/** @type {import('@sveltejs/kit').RequestHandler} */
19+
export const patch = (request) => {
20+
return buildResponse(request.method);
21+
};
22+
23+
/** @type {import('@sveltejs/kit').RequestHandler} */
24+
export const del = (request) => {
25+
return buildResponse(request.method);
26+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<script context="module">
2+
/** @type {import('@sveltejs/kit').Load} */
3+
export async function load({ page }) {
4+
return {
5+
props: {
6+
method: page.query.get('method') || ''
7+
}
8+
};
9+
}
10+
</script>
11+
12+
<script>
13+
/** @type {string} */
14+
export let method;
15+
</script>
16+
17+
<h1>{method}</h1>
18+
19+
<form action="/method-override/fetch.json?_method=PATCH" method="POST">
20+
<input name="methodoverride" />
21+
<button>PATCH</button>
22+
</form>
23+
24+
<form action="/method-override/fetch.json?_method=DELETE" method="POST">
25+
<input name="methodoverride" />
26+
<button>DELETE</button>
27+
</form>
28+
29+
<form action="/method-override/fetch.json?_method=POST" method="GET">
30+
<input name="methodoverride" />
31+
<button>No Override From GET</button>
32+
</form>
33+
34+
<form action="/method-override/fetch.json?_method=GET" method="POST">
35+
<input name="methodoverride4" />
36+
<button>No Override To GET</button>
37+
</form>
38+
39+
<form action="/method-override/fetch.json" method="POST">
40+
<input type="hidden" name="_method" value="PATCH" />
41+
<button>PATCH Via Hidden Input</button>
42+
</form>
43+
44+
<form action="/method-override/fetch.json" method="GET">
45+
<input type="hidden" name="_method" value="POST" />
46+
<button>No Override From GET Via Hidden Input</button>
47+
</form>
48+
49+
<form action="/method-override/fetch.json" method="POST">
50+
<input type="hidden" name="_method" value="GET" />
51+
<button>No Override To GET Via Hidden Input</button>
52+
</form>

packages/kit/test/apps/basics/svelte.config.js

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const config = {
1212
// the reload confuses Playwright
1313
include: ['cookie', 'marked']
1414
}
15+
},
16+
methodOverride: {
17+
enabled: true
1518
}
1619
}
1720
};

0 commit comments

Comments
 (0)