Skip to content

Commit 4310601

Browse files
hepisecweaverryan
authored andcommitted
Live component force post requests
1 parent a2418ef commit 4310601

28 files changed

+332
-80
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- Add support for URL binding in `LiveProp`
66
- Allow multiple `LiveListener` attributes on a single method.
7+
- Requests to LiveComponent are sent as POST by default
8+
- Add method prop to AsLiveComponent to still allow GET requests, usage: `#[AsLiveComponent(method: 'get')]`
79

810
## 2.13.2
911

src/LiveComponent/assets/dist/Backend/Backend.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface BackendAction {
2020
}
2121
export default class implements BackendInterface {
2222
private readonly requestBuilder;
23-
constructor(url: string, csrfToken?: string | null);
23+
constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
2424
makeRequest(props: any, actions: BackendAction[], updated: {
2525
[key: string]: any;
2626
}, children: ChildrenFingerprints, updatedPropsFromParent: {

src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { BackendAction, ChildrenFingerprints } from './Backend';
22
export default class {
33
private url;
4+
private method;
45
private readonly csrfToken;
5-
constructor(url: string, csrfToken?: string | null);
6+
constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
67
buildRequest(props: any, actions: BackendAction[], updated: {
78
[key: string]: any;
89
}, children: ChildrenFingerprints, updatedPropsFromParent: {

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
3232
type: StringConstructor;
3333
default: string;
3434
};
35+
requestMethod: {
36+
type: StringConstructor;
37+
default: string;
38+
};
3539
queryMapping: {
3640
type: ObjectConstructor;
3741
default: {};
@@ -48,6 +52,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4852
readonly hasDebounceValue: boolean;
4953
readonly debounceValue: number;
5054
readonly fingerprintValue: string;
55+
readonly requestMethodValue: 'get' | 'post';
5156
readonly queryMappingValue: {
5257
[p: string]: {
5358
name: string;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,8 +2169,9 @@ class BackendRequest {
21692169
}
21702170

21712171
class RequestBuilder {
2172-
constructor(url, csrfToken = null) {
2172+
constructor(url, method = 'post', csrfToken = null) {
21732173
this.url = url;
2174+
this.method = method;
21742175
this.csrfToken = csrfToken;
21752176
}
21762177
buildRequest(props, actions, updated, children, updatedPropsFromParent, files) {
@@ -2187,6 +2188,7 @@ class RequestBuilder {
21872188
const hasFingerprints = Object.keys(children).length > 0;
21882189
if (actions.length === 0 &&
21892190
totalFiles === 0 &&
2191+
this.method === 'get' &&
21902192
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) {
21912193
params.set('props', JSON.stringify(props));
21922194
params.set('updated', JSON.stringify(updated));
@@ -2244,8 +2246,8 @@ class RequestBuilder {
22442246
}
22452247

22462248
class Backend {
2247-
constructor(url, csrfToken = null) {
2248-
this.requestBuilder = new RequestBuilder(url, csrfToken);
2249+
constructor(url, method = 'post', csrfToken = null) {
2250+
this.requestBuilder = new RequestBuilder(url, method, csrfToken);
22492251
}
22502252
makeRequest(props, actions, updated, children, updatedPropsFromParent, files) {
22512253
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files);
@@ -2840,7 +2842,7 @@ class LiveControllerDefault extends Controller {
28402842
initialize() {
28412843
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
28422844
const id = this.element.dataset.liveId || null;
2843-
this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, (currentComponent, onlyParents, onlyMatchName) => LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName), this.fingerprintValue, id, new Backend(this.urlValue, this.csrfValue), new StandardElementDriver());
2845+
this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, (currentComponent, onlyParents, onlyMatchName) => LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName), this.fingerprintValue, id, new Backend(this.urlValue, this.requestMethodValue, this.csrfValue), new StandardElementDriver());
28442846
this.proxiedComponent = proxifyComponent(this.component);
28452847
this.element.__component = this.proxiedComponent;
28462848
if (this.hasDebounceValue) {
@@ -3073,6 +3075,7 @@ LiveControllerDefault.values = {
30733075
debounce: { type: Number, default: 150 },
30743076
id: String,
30753077
fingerprint: { type: String, default: '' },
3078+
requestMethod: { type: String, default: 'post' },
30763079
queryMapping: { type: Object, default: {} },
30773080
};
30783081
LiveControllerDefault.componentRegistry = new ComponentRegistry();

src/LiveComponent/assets/src/Backend/Backend.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export interface BackendAction {
2525
export default class implements BackendInterface {
2626
private readonly requestBuilder: RequestBuilder;
2727

28-
constructor(url: string, csrfToken: string | null = null) {
29-
this.requestBuilder = new RequestBuilder(url, csrfToken);
28+
constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
29+
this.requestBuilder = new RequestBuilder(url, method, csrfToken);
3030
}
3131

3232
makeRequest(

src/LiveComponent/assets/src/Backend/RequestBuilder.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { BackendAction, ChildrenFingerprints } from './Backend';
22

33
export default class {
44
private url: string;
5+
private method: 'get' | 'post';
56
private readonly csrfToken: string | null;
67

7-
constructor(url: string, csrfToken: string | null = null) {
8+
constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
89
this.url = url;
10+
this.method = method;
911
this.csrfToken = csrfToken;
1012
}
1113

@@ -37,6 +39,7 @@ export default class {
3739
if (
3840
actions.length === 0 &&
3941
totalFiles === 0 &&
42+
this.method === 'get' &&
4043
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))
4144
) {
4245
params.set('props', JSON.stringify(props));

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4545
debounce: { type: Number, default: 150 },
4646
id: String,
4747
fingerprint: { type: String, default: '' },
48+
requestMethod: { type: String, default: 'post' },
4849
queryMapping: { type: Object, default: {} },
4950
};
5051

@@ -56,6 +57,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
5657
declare readonly hasDebounceValue: boolean;
5758
declare readonly debounceValue: number;
5859
declare readonly fingerprintValue: string;
60+
declare readonly requestMethodValue: 'get' | 'post';
5961
declare readonly queryMappingValue: { [p: string]: { name: string } };
6062

6163
/** The component, wrapped in the convenience Proxy */
@@ -87,7 +89,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
8789
LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName),
8890
this.fingerprintValue,
8991
id,
90-
new Backend(this.urlValue, this.csrfValue),
92+
new Backend(this.urlValue, this.requestMethodValue, this.csrfValue),
9193
new StandardElementDriver()
9294
);
9395
this.proxiedComponent = proxifyComponent(this.component);

src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import RequestBuilder from '../../src/Backend/RequestBuilder';
22

33
describe('buildRequest', () => {
44
it('sets basic data on GET request', () => {
5-
const builder = new RequestBuilder('/_components?existing_param=1', '_the_csrf_token');
5+
const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token');
66
const { url, fetchOptions } = builder.buildRequest(
77
{ firstName: 'Ryan' },
88
[],
@@ -21,7 +21,7 @@ describe('buildRequest', () => {
2121
});
2222

2323
it('sets basic data on POST request', () => {
24-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
24+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
2525
const { url, fetchOptions } = builder.buildRequest(
2626
{ firstName: 'Ryan' },
2727
[{
@@ -52,7 +52,7 @@ describe('buildRequest', () => {
5252
});
5353

5454
it('sets basic data on POST request with batch actions', () => {
55-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
55+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
5656
const { url, fetchOptions } = builder.buildRequest(
5757
{ firstName: 'Ryan' },
5858
[{
@@ -87,7 +87,7 @@ describe('buildRequest', () => {
8787

8888
// when data is too long it makes a post request
8989
it('makes a POST request when data is too long', () => {
90-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
90+
const builder = new RequestBuilder('/_components', 'get', '_the_csrf_token');
9191
const { url, fetchOptions } = builder.buildRequest(
9292
{ firstName: 'Ryan'.repeat(1000) },
9393
[],
@@ -112,8 +112,38 @@ describe('buildRequest', () => {
112112
}));
113113
});
114114

115+
it('makes a POST request when method is post', () => {
116+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
117+
const { url, fetchOptions } = builder.buildRequest(
118+
{
119+
firstName: 'Ryan'
120+
},
121+
[],
122+
{ firstName: 'Kevin' },
123+
{},
124+
{},
125+
{}
126+
);
127+
128+
expect(url).toEqual('/_components');
129+
expect(fetchOptions.method).toEqual('POST');
130+
expect(fetchOptions.headers).toEqual({
131+
// no token
132+
Accept: 'application/vnd.live-component+html',
133+
'X-Requested-With': 'XMLHttpRequest',
134+
});
135+
const body = <FormData>fetchOptions.body;
136+
expect(body).toBeInstanceOf(FormData);
137+
expect(body.get('data')).toEqual(JSON.stringify({
138+
props: {
139+
firstName: 'Ryan'
140+
},
141+
updated: { firstName: 'Kevin' },
142+
}));
143+
});
144+
115145
it('sends propsFromParent when specified', () => {
116-
const builder = new RequestBuilder('/_components?existing_param=1', '_the_csrf_token');
146+
const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token');
117147
const { url } = builder.buildRequest(
118148
{ firstName: 'Ryan' },
119149
[],
@@ -167,7 +197,7 @@ describe('buildRequest', () => {
167197
};
168198

169199
it('Sends file with request', () => {
170-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
200+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
171201

172202
const { url, fetchOptions } = builder.buildRequest(
173203
{ firstName: 'Ryan' },
@@ -192,7 +222,7 @@ describe('buildRequest', () => {
192222
});
193223

194224
it('Sends multiple files with request', () => {
195-
const builder = new RequestBuilder('/_components', '_the_csrf_token');
225+
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
196226

197227
const { url, fetchOptions } = builder.buildRequest(
198228
{ firstName: 'Ryan' },

src/LiveComponent/src/Attribute/AsLiveComponent.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,15 @@ public function __construct(
4242
string $attributesVar = 'attributes',
4343
public bool $csrf = true,
4444
public string $route = 'ux_live_component',
45+
public string $method = 'post',
4546
) {
4647
parent::__construct($name, $template, $exposePublicProps, $attributesVar);
48+
49+
$this->method = strtolower($this->method);
50+
51+
if (!\in_array($this->method, ['get', 'post'])) {
52+
throw new \UnexpectedValueException('$method must be either \'get\' or \'post\'');
53+
}
4754
}
4855

4956
/**
@@ -56,6 +63,7 @@ public function serviceConfig(): array
5663
'live' => true,
5764
'csrf' => $this->csrf,
5865
'route' => $this->route,
66+
'method' => $this->method,
5967
]);
6068
}
6169

src/LiveComponent/src/EventListener/LiveComponentSubscriber.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ public function onKernelController(ControllerEvent $event): void
164164
throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, $component::class));
165165
}
166166

167+
$componentName = $request->attributes->get('_component_name') ?? $request->attributes->get('_mounted_component')->getName();
168+
$requestMethod = $this->container->get(ComponentFactory::class)->metadataFor($componentName)?->get('method') ?? 'post';
169+
170+
/**
171+
* $requestMethod 'post' allows POST requests only
172+
* $requestMethod 'get' allows GET and POST requests.
173+
*/
174+
if ($request->isMethod('get') && 'post' === $requestMethod) {
175+
throw new MethodNotAllowedHttpException([strtoupper($requestMethod)]);
176+
}
177+
167178
/*
168179
* Either we:
169180
* A) We do NOT have a _mounted_component, so hydrate $component

src/LiveComponent/src/Util/LiveAttributesCollection.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void
9898
$this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch;
9999
}
100100

101+
public function setRequestMethod(string $requestMethod): void
102+
{
103+
$this->attributes['data-live-request-method-value'] = $requestMethod;
104+
}
105+
101106
public function setQueryUrlMapping(array $queryUrlMapping): void
102107
{
103108
$this->attributes['data-live-query-mapping-value'] = $queryUrlMapping;

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
9999
}
100100

101101
$liveMetadata = $this->metadataFactory->getMetadata($mounted->getName());
102+
$requestMethod = $liveMetadata->getComponentMetadata()?->get('method') ?? 'post';
103+
$attributesCollection->setRequestMethod($requestMethod);
102104

103105
if ($liveMetadata->hasQueryStringBindings()) {
104106
$queryMapping = [];

src/LiveComponent/tests/Fixtures/Component/Component1.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
/**
2121
* @author Kevin Bond <[email protected]>
2222
*/
23-
#[AsLiveComponent('component1')]
23+
#[AsLiveComponent('component1', method: 'get')]
2424
final class Component1
2525
{
2626
use DefaultActionTrait;

src/LiveComponent/tests/Fixtures/Component/Component2.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
/**
2626
* @author Kevin Bond <[email protected]>
2727
*/
28-
#[AsLiveComponent('component2', defaultAction: 'defaultAction()')]
28+
#[AsLiveComponent('component2', defaultAction: 'defaultAction()', method: 'get')]
2929
final class Component2
3030
{
3131
#[LiveProp]

src/LiveComponent/tests/Fixtures/Component/Component6.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
/**
2121
* @author Tomas Norkūnas <[email protected]>
2222
*/
23-
#[AsLiveComponent('component6')]
23+
#[AsLiveComponent('component6', method: 'get')]
2424
class Component6
2525
{
2626
use DefaultActionTrait;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
4+
5+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
6+
use Symfony\UX\LiveComponent\DefaultActionTrait;
7+
8+
#[AsLiveComponent('with_method_post', method: 'post')]
9+
final class ComponentWithMethodPost
10+
{
11+
use DefaultActionTrait;
12+
}

src/LiveComponent/tests/Fixtures/Component/ComponentWithWritableProps.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,11 @@
1111

1212
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
1313

14-
use Symfony\Component\HttpFoundation\RedirectResponse;
15-
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
1614
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
17-
use Symfony\UX\LiveComponent\Attribute\PreReRender;
18-
use Symfony\UX\LiveComponent\Attribute\LiveAction;
1915
use Symfony\UX\LiveComponent\Attribute\LiveProp;
20-
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
21-
use Symfony\UX\LiveComponent\Attribute\PreDehydrate;
2216
use Symfony\UX\LiveComponent\DefaultActionTrait;
2317

24-
#[AsLiveComponent('component_with_writable_props')]
18+
#[AsLiveComponent('component_with_writable_props', method: 'get')]
2519
final class ComponentWithWritableProps
2620
{
2721
use DefaultActionTrait;

src/LiveComponent/tests/Fixtures/Component/DeferredComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
88
use Symfony\UX\LiveComponent\DefaultActionTrait;
99

10-
#[AsLiveComponent('deferred_component')]
10+
#[AsLiveComponent('deferred_component', method: 'get')]
1111
final class DeferredComponent
1212
{
1313
use DefaultActionTrait;

src/LiveComponent/tests/Fixtures/Component/TodoListComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1616
use Symfony\UX\LiveComponent\DefaultActionTrait;
1717

18-
#[AsLiveComponent('todo_list')]
18+
#[AsLiveComponent('todo_list', method: 'get')]
1919
final class TodoListComponent
2020
{
2121
#[LiveProp(writable: true)]

src/LiveComponent/tests/Fixtures/Component/TodoListWithKeysComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1616
use Symfony\UX\LiveComponent\DefaultActionTrait;
1717

18-
#[AsLiveComponent('todo_list_with_keys')]
18+
#[AsLiveComponent('todo_list_with_keys', method: 'get')]
1919
final class TodoListWithKeysComponent
2020
{
2121
use DefaultActionTrait;

0 commit comments

Comments
 (0)