Skip to content

Add functionality to override http methods (issue #1046) #2989

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 11 commits into from
Jan 11, 2022
6 changes: 6 additions & 0 deletions .changeset/chilly-moose-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sveltejs/kit': patch
'create-svelte': patch
---

Add methodOverride option for submitting PUT/PATCH/DELETE/etc with <form> elements
23 changes: 23 additions & 0 deletions documentation/docs/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,29 @@ The `body` property of the request object will be provided in the case of POST r
- 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.
- All other data will be provided as a `Uint8Array`

#### HTTP Method Overrides

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`:

```js
// svelte.config.js
export default {
kit: {
methodOverride: {
allowed: ['PUT', 'PATCH', 'DELETE']
}
}
};
```

```html
<form method="post" action="/todos/{id}?_method=PUT">
<!-- form elements -->
</form>
```

> Using native `<form>` behaviour ensures your app continues to work when JavaScript fails or is disabled.

### Private modules

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.
Expand Down
11 changes: 11 additions & 0 deletions documentation/docs/14-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const config = {
},
host: null,
hydrate: true,
methodOverride: {
parameter: '_method',
allowed: []
},
package: {
dir: 'package',
emitTypes: true,
Expand Down Expand Up @@ -134,6 +138,13 @@ A value that overrides the one derived from [`config.kit.headers.host`](#configu

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

### methodOverride

See [HTTP Method Overrides](#routing-endpoints-http-method-overrides). An object containing zero or more of the following:

- `parameter` — query parameter name to use for passing the intended method value
- `allowed` - array of HTTP methods that can be used when overriding the original request method

### package

Options related to [creating a package](#packaging).
Expand Down
6 changes: 0 additions & 6 deletions packages/create-svelte/templates/default/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ export const handle: Handle = async ({ request, resolve }) => {
const cookies = cookie.parse(request.headers.cookie || '');
request.locals.userid = cookies.userid || uuid();

// TODO https://github.com/sveltejs/kit/issues/1046
const method = request.url.searchParams.get('_method');
if (method) {
request.method = method.toUpperCase();
}

const response = await resolve(request);

if (!cookies.userid) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
animate:flip={{ duration: 200 }}
>
<form
action="/todos/{todo.uid}.json?_method=patch"
action="/todos/{todo.uid}.json?_method=PATCH"
method="post"
use:enhance={{
pending: (data) => {
Expand All @@ -92,7 +92,7 @@

<form
class="text"
action="/todos/{todo.uid}.json?_method=patch"
action="/todos/{todo.uid}.json?_method=PATCH"
method="post"
use:enhance={{
result: patch
Expand All @@ -103,7 +103,7 @@
</form>

<form
action="/todos/{todo.uid}.json?_method=delete"
action="/todos/{todo.uid}.json?_method=DELETE"
method="post"
use:enhance={{
pending: () => (todo.pending_delete = true),
Expand Down
7 changes: 6 additions & 1 deletion packages/create-svelte/templates/default/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ const config = {
adapter: adapter(),

// hydrate the <div id="svelte"> element in src/app.html
target: '#svelte'
target: '#svelte',

// Override http methods in the Todo forms
methodOverride: {
allowed: ['PATCH', 'DELETE']
}
}
};

Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/build/build_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export class App {
hooks,
hydrate: ${s(config.kit.hydrate)},
manifest,
method_override: ${s(config.kit.methodOverride)},
paths: { base, assets },
prefix: assets + '/${config.kit.appDir}/',
prerender: ${config.kit.prerender.enabled},
Expand Down
8 changes: 8 additions & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ test('fills in defaults', () => {
},
host: null,
hydrate: true,
methodOverride: {
parameter: '_method',
allowed: []
},
package: {
dir: 'package',
emitTypes: true
Expand Down Expand Up @@ -142,6 +146,10 @@ test('fills in partial blanks', () => {
},
host: null,
hydrate: true,
methodOverride: {
parameter: '_method',
allowed: []
},
package: {
dir: 'package',
emitTypes: true
Expand Down
15 changes: 15 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ const options = object(

hydrate: boolean(true),

methodOverride: object({
parameter: string('_method'),
allowed: validate([], (input, keypath) => {
if (!Array.isArray(input) || !input.every((method) => typeof method === 'string')) {
throw new Error(`${keypath} must be an array of strings`);
}

if (input.map((i) => i.toUpperCase()).includes('GET')) {
throw new Error(`${keypath} cannot contain "GET"`);
}

return input;
})
}),

package: object({
dir: string('package'),
// excludes all .d.ts and filename starting with _
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/core/config/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ test('load default config (esm)', async () => {
},
host: null,
hydrate: true,
methodOverride: {
parameter: '_method',
allowed: []
},
package: {
dir: 'package',
emitTypes: true
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/dev/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export async function create_plugin(config, output, cwd) {
hooks,
hydrate: config.kit.hydrate,
manifest,
method_override: config.kit.methodOverride,
paths: {
base: config.kit.paths.base,
assets: config.kit.paths.assets ? SVELTE_KIT_ASSETS : config.kit.paths.base
Expand Down
22 changes: 22 additions & 0 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,28 @@ export async function respond(incoming, options, state = {}) {
locals: {}
};

const { parameter, allowed } = options.method_override;
const method_override = incoming.url.searchParams.get(parameter)?.toUpperCase();

if (method_override) {
if (request.method.toUpperCase() === 'POST') {
if (allowed.includes(method_override)) {
request.method = method_override;
} else {
const verb = allowed.length === 0 ? 'enabled' : 'allowed';
const body = `${parameter}=${method_override} is not ${verb}. See https://kit.svelte.dev/docs#configuration-methodoverride`;

return {
status: 400,
headers: {},
body
};
}
} else {
throw new Error(`${parameter}=${method_override} is only allowed with POST requests`);
}
}

// TODO remove this for 1.0
/**
* @param {string} property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function read_only_form_data() {
};
}

class ReadOnlyFormData {
export class ReadOnlyFormData {
/** @type {Map<string, string[]>} */
#map;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const buildResponse = (/** @type {string} */ method) => ({
status: 303,
headers: {
location: `/method-override?method=${method}`
}
});

/** @type {import('@sveltejs/kit').RequestHandler} */
export const get = (request) => {
return buildResponse(request.method);
};

/** @type {import('@sveltejs/kit').RequestHandler} */
export const post = (request) => {
return buildResponse(request.method);
};

/** @type {import('@sveltejs/kit').RequestHandler} */
export const patch = (request) => {
return buildResponse(request.method);
};

/** @type {import('@sveltejs/kit').RequestHandler} */
export const del = (request) => {
return buildResponse(request.method);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script context="module">
/** @type {import('@sveltejs/kit').Load} */
export async function load({ url }) {
return {
props: {
method: url.searchParams.get('method') || ''
}
};
}
</script>

<script>
/** @type {string} */
export let method;
</script>

<h1>{method}</h1>

<form action="/method-override/fetch.json?_method=PATCH" method="POST">
<input name="methodoverride" />
<button>PATCH</button>
</form>

<form action="/method-override/fetch.json?_method=DELETE" method="POST">
<input name="methodoverride" />
<button>DELETE</button>
</form>

<form action="/method-override/fetch.json?_method=POST" method="GET">
<input name="methodoverride" />
<button>No Override From GET</button>
</form>

<form action="/method-override/fetch.json?_method=GET" method="POST">
<input name="methodoverride" />
<button>No Override To GET</button>
</form>

<form action="/method-override/fetch.json?_method=CONNECT" method="POST">
<input name="methodoverride" />
<button>No Override To CONNECT</button>
</form>
3 changes: 3 additions & 0 deletions packages/kit/test/apps/basics/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const config = {
// the reload confuses Playwright
include: ['cookie', 'marked']
}
},
methodOverride: {
allowed: ['PUT', 'PATCH', 'DELETE']
}
}
};
Expand Down
46 changes: 46 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,52 @@ test.describe.parallel('Load', () => {
});
});

test.describe.parallel('Method overrides', () => {
test('http method is overridden via URL parameter', async ({ page }) => {
await page.goto('/method-override');

let val;

// Check initial value
val = await page.textContent('h1');
expect('').toBe(val);

await page.click('"PATCH"');
val = await page.textContent('h1');
expect('PATCH').toBe(val);

await page.click('"DELETE"');
val = await page.textContent('h1');
expect('DELETE').toBe(val);
});

test('GET method is not overridden', async ({ page }) => {
await page.goto('/method-override');
await page.click('"No Override From GET"');

const val = await page.textContent('h1');
expect('GET').toBe(val);
});

test('400 response when trying to override POST with GET', async ({ page }) => {
await page.goto('/method-override');
await page.click('"No Override To GET"');

expect(await page.innerHTML('pre')).toBe(
'_method=GET is not allowed. See https://kit.svelte.dev/docs#configuration-methodoverride'
);
});

test('400 response when override method not in allowed methods', async ({ page }) => {
await page.goto('/method-override');
await page.click('"No Override To CONNECT"');

expect(await page.innerHTML('pre')).toBe(
'_method=CONNECT is not allowed. See https://kit.svelte.dev/docs#configuration-methodoverride'
);
});
});

test.describe.parallel('Nested layouts', () => {
test('renders a nested layout', async ({ page }) => {
await page.goto('/nested-layout');
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/types/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ export interface Config {
};
host?: string;
hydrate?: boolean;
methodOverride?: {
parameter?: string;
allowed?: string[];
};
package?: {
dir?: string;
emitTypes?: boolean;
Expand Down
5 changes: 5 additions & 0 deletions packages/kit/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export interface SSRRenderOptions {
hooks: Hooks;
hydrate: boolean;
manifest: SSRManifest;
method_override: MethodOverride;
paths: {
base: string;
assets: string;
Expand Down Expand Up @@ -230,3 +231,7 @@ export type NormalizedLoadOutput = Either<
>;

export type TrailingSlash = 'never' | 'always' | 'ignore';
export interface MethodOverride {
parameter: string;
allowed: string[];
}