Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

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

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[];
}