Skip to content

Commit 3523d38

Browse files
Add functionality to override http methods (issue #1046) (#2989)
* Add functionality to override http methods (issue #1046) See: #1046 * Removing changes to lockfile * Validate allowed_methods does not contain GET, rephrase docs * Remove hidden field strategy and enabled config, rename other config values * lint/format * tweak error messages * slim docs down * tweak changeset Co-authored-by: Rich Harris <[email protected]>
1 parent 5e42af4 commit 3523d38

File tree

19 files changed

+227
-11
lines changed

19 files changed

+227
-11
lines changed

Diff for: .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+
Add methodOverride option for submitting PUT/PATCH/DELETE/etc with <form> elements

Diff for: documentation/docs/01-routing.md

+23
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,29 @@ The `body` property of the request object will be provided in the case of POST r
159159
- 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.
160160
- All other data will be provided as a `Uint8Array`
161161

162+
#### HTTP Method Overrides
163+
164+
HTML `<form>` elements only support `GET` and `POST` methods natively. You can allow other methods, like `PUT` and `DELETE`, by specifying them in your [configuration](#configuration-methodoverride) and adding a `_method=VERB` parameter (you can configure the name) to the form's `action`:
165+
166+
```js
167+
// svelte.config.js
168+
export default {
169+
kit: {
170+
methodOverride: {
171+
allowed: ['PUT', 'PATCH', 'DELETE']
172+
}
173+
}
174+
};
175+
```
176+
177+
```html
178+
<form method="post" action="/todos/{id}?_method=PUT">
179+
<!-- form elements -->
180+
</form>
181+
```
182+
183+
> Using native `<form>` behaviour ensures your app continues to work when JavaScript fails or is disabled.
184+
162185
### Private modules
163186

164187
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.

Diff for: documentation/docs/14-configuration.md

+11
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const config = {
3232
},
3333
host: null,
3434
hydrate: true,
35+
methodOverride: {
36+
parameter: '_method',
37+
allowed: []
38+
},
3539
package: {
3640
dir: 'package',
3741
emitTypes: true,
@@ -134,6 +138,13 @@ A value that overrides the one derived from [`config.kit.headers.host`](#configu
134138

135139
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.)
136140

141+
### methodOverride
142+
143+
See [HTTP Method Overrides](#routing-endpoints-http-method-overrides). An object containing zero or more of the following:
144+
145+
- `parameter` — query parameter name to use for passing the intended method value
146+
- `allowed` - array of HTTP methods that can be used when overriding the original request method
147+
137148
### package
138149

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

Diff for: packages/create-svelte/templates/default/src/hooks.ts

-6
Original file line numberDiff line numberDiff line change
@@ -6,12 +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-
const method = request.url.searchParams.get('_method');
11-
if (method) {
12-
request.method = method.toUpperCase();
13-
}
14-
159
const response = await resolve(request);
1610

1711
if (!cookies.userid) {

Diff for: packages/create-svelte/templates/default/src/routes/todos/index.svelte

+3-3
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
animate:flip={{ duration: 200 }}
7878
>
7979
<form
80-
action="/todos/{todo.uid}.json?_method=patch"
80+
action="/todos/{todo.uid}.json?_method=PATCH"
8181
method="post"
8282
use:enhance={{
8383
pending: (data) => {
@@ -92,7 +92,7 @@
9292

9393
<form
9494
class="text"
95-
action="/todos/{todo.uid}.json?_method=patch"
95+
action="/todos/{todo.uid}.json?_method=PATCH"
9696
method="post"
9797
use:enhance={{
9898
result: patch
@@ -103,7 +103,7 @@
103103
</form>
104104

105105
<form
106-
action="/todos/{todo.uid}.json?_method=delete"
106+
action="/todos/{todo.uid}.json?_method=DELETE"
107107
method="post"
108108
use:enhance={{
109109
pending: () => (todo.pending_delete = true),

Diff for: 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+
allowed: ['PATCH', 'DELETE']
19+
}
1520
}
1621
};
1722

Diff for: packages/kit/src/core/build/build_server.js

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export class App {
7171
hooks,
7272
hydrate: ${s(config.kit.hydrate)},
7373
manifest,
74+
method_override: ${s(config.kit.methodOverride)},
7475
paths: { base, assets },
7576
prefix: assets + '/${config.kit.appDir}/',
7677
prerender: ${config.kit.prerender.enabled},

Diff for: packages/kit/src/core/config/index.spec.js

+8
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ test('fills in defaults', () => {
3535
},
3636
host: null,
3737
hydrate: true,
38+
methodOverride: {
39+
parameter: '_method',
40+
allowed: []
41+
},
3842
package: {
3943
dir: 'package',
4044
emitTypes: true
@@ -142,6 +146,10 @@ test('fills in partial blanks', () => {
142146
},
143147
host: null,
144148
hydrate: true,
149+
methodOverride: {
150+
parameter: '_method',
151+
allowed: []
152+
},
145153
package: {
146154
dir: 'package',
147155
emitTypes: true

Diff for: packages/kit/src/core/config/options.js

+15
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ const options = object(
7575

7676
hydrate: boolean(true),
7777

78+
methodOverride: object({
79+
parameter: string('_method'),
80+
allowed: validate([], (input, keypath) => {
81+
if (!Array.isArray(input) || !input.every((method) => typeof method === 'string')) {
82+
throw new Error(`${keypath} must be an array of strings`);
83+
}
84+
85+
if (input.map((i) => i.toUpperCase()).includes('GET')) {
86+
throw new Error(`${keypath} cannot contain "GET"`);
87+
}
88+
89+
return input;
90+
})
91+
}),
92+
7893
package: object({
7994
dir: string('package'),
8095
// excludes all .d.ts and filename starting with _

Diff for: packages/kit/src/core/config/test/index.js

+4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ test('load default config (esm)', async () => {
3737
},
3838
host: null,
3939
hydrate: true,
40+
methodOverride: {
41+
parameter: '_method',
42+
allowed: []
43+
},
4044
package: {
4145
dir: 'package',
4246
emitTypes: true

Diff for: packages/kit/src/core/dev/plugin.js

+1
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ export async function create_plugin(config, output, cwd) {
214214
hooks,
215215
hydrate: config.kit.hydrate,
216216
manifest,
217+
method_override: config.kit.methodOverride,
217218
paths: {
218219
base: config.kit.paths.base,
219220
assets: config.kit.paths.assets ? SVELTE_KIT_ASSETS : config.kit.paths.base

Diff for: packages/kit/src/runtime/server/index.js

+22
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ export async function respond(incoming, options, state = {}) {
4343
locals: {}
4444
};
4545

46+
const { parameter, allowed } = options.method_override;
47+
const method_override = incoming.url.searchParams.get(parameter)?.toUpperCase();
48+
49+
if (method_override) {
50+
if (request.method.toUpperCase() === 'POST') {
51+
if (allowed.includes(method_override)) {
52+
request.method = method_override;
53+
} else {
54+
const verb = allowed.length === 0 ? 'enabled' : 'allowed';
55+
const body = `${parameter}=${method_override} is not ${verb}. See https://kit.svelte.dev/docs#configuration-methodoverride`;
56+
57+
return {
58+
status: 400,
59+
headers: {},
60+
body
61+
};
62+
}
63+
} else {
64+
throw new Error(`${parameter}=${method_override} is only allowed with POST requests`);
65+
}
66+
}
67+
4668
// TODO remove this for 1.0
4769
/**
4870
* @param {string} property

Diff for: 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,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,42 @@
1+
<script context="module">
2+
/** @type {import('@sveltejs/kit').Load} */
3+
export async function load({ url }) {
4+
return {
5+
props: {
6+
method: url.searchParams.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="methodoverride" />
36+
<button>No Override To GET</button>
37+
</form>
38+
39+
<form action="/method-override/fetch.json?_method=CONNECT" method="POST">
40+
<input name="methodoverride" />
41+
<button>No Override To CONNECT</button>
42+
</form>

Diff for: packages/kit/test/apps/basics/svelte.config.js

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ const config = {
1111
// the reload confuses Playwright
1212
include: ['cookie', 'marked']
1313
}
14+
},
15+
methodOverride: {
16+
allowed: ['PUT', 'PATCH', 'DELETE']
1417
}
1518
}
1619
};

Diff for: packages/kit/test/apps/basics/test/test.js

+46
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,52 @@ test.describe.parallel('Load', () => {
923923
});
924924
});
925925

926+
test.describe.parallel('Method overrides', () => {
927+
test('http method is overridden via URL parameter', async ({ page }) => {
928+
await page.goto('/method-override');
929+
930+
let val;
931+
932+
// Check initial value
933+
val = await page.textContent('h1');
934+
expect('').toBe(val);
935+
936+
await page.click('"PATCH"');
937+
val = await page.textContent('h1');
938+
expect('PATCH').toBe(val);
939+
940+
await page.click('"DELETE"');
941+
val = await page.textContent('h1');
942+
expect('DELETE').toBe(val);
943+
});
944+
945+
test('GET method is not overridden', async ({ page }) => {
946+
await page.goto('/method-override');
947+
await page.click('"No Override From GET"');
948+
949+
const val = await page.textContent('h1');
950+
expect('GET').toBe(val);
951+
});
952+
953+
test('400 response when trying to override POST with GET', async ({ page }) => {
954+
await page.goto('/method-override');
955+
await page.click('"No Override To GET"');
956+
957+
expect(await page.innerHTML('pre')).toBe(
958+
'_method=GET is not allowed. See https://kit.svelte.dev/docs#configuration-methodoverride'
959+
);
960+
});
961+
962+
test('400 response when override method not in allowed methods', async ({ page }) => {
963+
await page.goto('/method-override');
964+
await page.click('"No Override To CONNECT"');
965+
966+
expect(await page.innerHTML('pre')).toBe(
967+
'_method=CONNECT is not allowed. See https://kit.svelte.dev/docs#configuration-methodoverride'
968+
);
969+
});
970+
});
971+
926972
test.describe.parallel('Nested layouts', () => {
927973
test('renders a nested layout', async ({ page }) => {
928974
await page.goto('/nested-layout');

Diff for: packages/kit/types/config.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ export interface Config {
131131
};
132132
host?: string;
133133
hydrate?: boolean;
134+
methodOverride?: {
135+
parameter?: string;
136+
allowed?: string[];
137+
};
134138
package?: {
135139
dir?: string;
136140
emitTypes?: boolean;

Diff for: packages/kit/types/internal.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export interface SSRRenderOptions {
131131
hooks: Hooks;
132132
hydrate: boolean;
133133
manifest: SSRManifest;
134+
method_override: MethodOverride;
134135
paths: {
135136
base: string;
136137
assets: string;
@@ -230,3 +231,7 @@ export type NormalizedLoadOutput = Either<
230231
>;
231232

232233
export type TrailingSlash = 'never' | 'always' | 'ignore';
234+
export interface MethodOverride {
235+
parameter: string;
236+
allowed: string[];
237+
}

0 commit comments

Comments
 (0)