Skip to content

Live component force post requests #1218

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

Merged
merged 1 commit into from
Jan 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

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

## 2.13.2

Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/assets/dist/Backend/Backend.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface BackendAction {
}
export default class implements BackendInterface {
private readonly requestBuilder;
constructor(url: string, csrfToken?: string | null);
constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
makeRequest(props: any, actions: BackendAction[], updated: {
[key: string]: any;
}, children: ChildrenFingerprints, updatedPropsFromParent: {
Expand Down
3 changes: 2 additions & 1 deletion src/LiveComponent/assets/dist/Backend/RequestBuilder.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { BackendAction, ChildrenFingerprints } from './Backend';
export default class {
private url;
private method;
private readonly csrfToken;
constructor(url: string, csrfToken?: string | null);
constructor(url: string, method?: 'get' | 'post', csrfToken?: string | null);
buildRequest(props: any, actions: BackendAction[], updated: {
[key: string]: any;
}, children: ChildrenFingerprints, updatedPropsFromParent: {
Expand Down
5 changes: 5 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
type: StringConstructor;
default: string;
};
requestMethod: {
type: StringConstructor;
default: string;
};
queryMapping: {
type: ObjectConstructor;
default: {};
Expand All @@ -48,6 +52,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
readonly hasDebounceValue: boolean;
readonly debounceValue: number;
readonly fingerprintValue: string;
readonly requestMethodValue: 'get' | 'post';
readonly queryMappingValue: {
[p: string]: {
name: string;
Expand Down
11 changes: 7 additions & 4 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2169,8 +2169,9 @@ class BackendRequest {
}

class RequestBuilder {
constructor(url, csrfToken = null) {
constructor(url, method = 'post', csrfToken = null) {
this.url = url;
this.method = method;
this.csrfToken = csrfToken;
}
buildRequest(props, actions, updated, children, updatedPropsFromParent, files) {
Expand All @@ -2187,6 +2188,7 @@ class RequestBuilder {
const hasFingerprints = Object.keys(children).length > 0;
if (actions.length === 0 &&
totalFiles === 0 &&
this.method === 'get' &&
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))) {
params.set('props', JSON.stringify(props));
params.set('updated', JSON.stringify(updated));
Expand Down Expand Up @@ -2244,8 +2246,8 @@ class RequestBuilder {
}

class Backend {
constructor(url, csrfToken = null) {
this.requestBuilder = new RequestBuilder(url, csrfToken);
constructor(url, method = 'post', csrfToken = null) {
this.requestBuilder = new RequestBuilder(url, method, csrfToken);
}
makeRequest(props, actions, updated, children, updatedPropsFromParent, files) {
const { url, fetchOptions } = this.requestBuilder.buildRequest(props, actions, updated, children, updatedPropsFromParent, files);
Expand Down Expand Up @@ -2840,7 +2842,7 @@ class LiveControllerDefault extends Controller {
initialize() {
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
const id = this.element.dataset.liveId || null;
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());
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());
this.proxiedComponent = proxifyComponent(this.component);
this.element.__component = this.proxiedComponent;
if (this.hasDebounceValue) {
Expand Down Expand Up @@ -3073,6 +3075,7 @@ LiveControllerDefault.values = {
debounce: { type: Number, default: 150 },
id: String,
fingerprint: { type: String, default: '' },
requestMethod: { type: String, default: 'post' },
queryMapping: { type: Object, default: {} },
};
LiveControllerDefault.componentRegistry = new ComponentRegistry();
Expand Down
4 changes: 2 additions & 2 deletions src/LiveComponent/assets/src/Backend/Backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export interface BackendAction {
export default class implements BackendInterface {
private readonly requestBuilder: RequestBuilder;

constructor(url: string, csrfToken: string | null = null) {
this.requestBuilder = new RequestBuilder(url, csrfToken);
constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
this.requestBuilder = new RequestBuilder(url, method, csrfToken);
}

makeRequest(
Expand Down
5 changes: 4 additions & 1 deletion src/LiveComponent/assets/src/Backend/RequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { BackendAction, ChildrenFingerprints } from './Backend';

export default class {
private url: string;
private method: 'get' | 'post';
private readonly csrfToken: string | null;

constructor(url: string, csrfToken: string | null = null) {
constructor(url: string, method: 'get' | 'post' = 'post', csrfToken: string | null = null) {
this.url = url;
this.method = method;
this.csrfToken = csrfToken;
}

Expand Down Expand Up @@ -37,6 +39,7 @@ export default class {
if (
actions.length === 0 &&
totalFiles === 0 &&
this.method === 'get' &&
this.willDataFitInUrl(JSON.stringify(props), JSON.stringify(updated), params, JSON.stringify(children), JSON.stringify(updatedPropsFromParent))
) {
params.set('props', JSON.stringify(props));
Expand Down
4 changes: 3 additions & 1 deletion src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
debounce: { type: Number, default: 150 },
id: String,
fingerprint: { type: String, default: '' },
requestMethod: { type: String, default: 'post' },
queryMapping: { type: Object, default: {} },
};

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

/** The component, wrapped in the convenience Proxy */
Expand Down Expand Up @@ -87,7 +89,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName),
this.fingerprintValue,
id,
new Backend(this.urlValue, this.csrfValue),
new Backend(this.urlValue, this.requestMethodValue, this.csrfValue),
new StandardElementDriver()
);
this.proxiedComponent = proxifyComponent(this.component);
Expand Down
44 changes: 37 additions & 7 deletions src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import RequestBuilder from '../../src/Backend/RequestBuilder';

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

it('sets basic data on POST request', () => {
const builder = new RequestBuilder('/_components', '_the_csrf_token');
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
const { url, fetchOptions } = builder.buildRequest(
{ firstName: 'Ryan' },
[{
Expand Down Expand Up @@ -52,7 +52,7 @@ describe('buildRequest', () => {
});

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

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

it('makes a POST request when method is post', () => {
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');
const { url, fetchOptions } = builder.buildRequest(
{
firstName: 'Ryan'
},
[],
{ firstName: 'Kevin' },
{},
{},
{}
);

expect(url).toEqual('/_components');
expect(fetchOptions.method).toEqual('POST');
expect(fetchOptions.headers).toEqual({
// no token
Accept: 'application/vnd.live-component+html',
'X-Requested-With': 'XMLHttpRequest',
});
const body = <FormData>fetchOptions.body;
expect(body).toBeInstanceOf(FormData);
expect(body.get('data')).toEqual(JSON.stringify({
props: {
firstName: 'Ryan'
},
updated: { firstName: 'Kevin' },
}));
});

it('sends propsFromParent when specified', () => {
const builder = new RequestBuilder('/_components?existing_param=1', '_the_csrf_token');
const builder = new RequestBuilder('/_components?existing_param=1', 'get', '_the_csrf_token');
const { url } = builder.buildRequest(
{ firstName: 'Ryan' },
[],
Expand Down Expand Up @@ -167,7 +197,7 @@ describe('buildRequest', () => {
};

it('Sends file with request', () => {
const builder = new RequestBuilder('/_components', '_the_csrf_token');
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');

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

it('Sends multiple files with request', () => {
const builder = new RequestBuilder('/_components', '_the_csrf_token');
const builder = new RequestBuilder('/_components', 'post', '_the_csrf_token');

const { url, fetchOptions } = builder.buildRequest(
{ firstName: 'Ryan' },
Expand Down
8 changes: 8 additions & 0 deletions src/LiveComponent/src/Attribute/AsLiveComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ public function __construct(
string $attributesVar = 'attributes',
public bool $csrf = true,
public string $route = 'ux_live_component',
public string $method = 'post',
) {
parent::__construct($name, $template, $exposePublicProps, $attributesVar);

$this->method = strtolower($this->method);

if (!\in_array($this->method, ['get', 'post'])) {
throw new \UnexpectedValueException('$method must be either \'get\' or \'post\'');
}
}

/**
Expand All @@ -56,6 +63,7 @@ public function serviceConfig(): array
'live' => true,
'csrf' => $this->csrf,
'route' => $this->route,
'method' => $this->method,
]);
}

Expand Down
11 changes: 11 additions & 0 deletions src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ public function onKernelController(ControllerEvent $event): void
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));
}

$componentName = $request->attributes->get('_component_name') ?? $request->attributes->get('_mounted_component')->getName();
$requestMethod = $this->container->get(ComponentFactory::class)->metadataFor($componentName)?->get('method') ?? 'post';

/**
* $requestMethod 'post' allows POST requests only
* $requestMethod 'get' allows GET and POST requests.
*/
if ($request->isMethod('get') && 'post' === $requestMethod) {
throw new MethodNotAllowedHttpException([strtoupper($requestMethod)]);
}

/*
* Either we:
* A) We do NOT have a _mounted_component, so hydrate $component
Expand Down
5 changes: 5 additions & 0 deletions src/LiveComponent/src/Util/LiveAttributesCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void
$this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch;
}

public function setRequestMethod(string $requestMethod): void
{
$this->attributes['data-live-request-method-value'] = $requestMethod;
}

public function setQueryUrlMapping(array $queryUrlMapping): void
{
$this->attributes['data-live-query-mapping-value'] = $queryUrlMapping;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
}

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

if ($liveMetadata->hasQueryStringBindings()) {
$queryMapping = [];
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/tests/Fixtures/Component/Component1.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[AsLiveComponent('component1')]
#[AsLiveComponent('component1', method: 'get')]
final class Component1
{
use DefaultActionTrait;
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/tests/Fixtures/Component/Component2.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[AsLiveComponent('component2', defaultAction: 'defaultAction()')]
#[AsLiveComponent('component2', defaultAction: 'defaultAction()', method: 'get')]
final class Component2
{
#[LiveProp]
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/tests/Fixtures/Component/Component6.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
/**
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
*/
#[AsLiveComponent('component6')]
#[AsLiveComponent('component6', method: 'get')]
class Component6
{
use DefaultActionTrait;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

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

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('with_method_post', method: 'post')]
final class ComponentWithMethodPost
{
use DefaultActionTrait;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,11 @@

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

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\PreReRender;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Attribute\PostHydrate;
use Symfony\UX\LiveComponent\Attribute\PreDehydrate;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('component_with_writable_props')]
#[AsLiveComponent('component_with_writable_props', method: 'get')]
final class ComponentWithWritableProps
{
use DefaultActionTrait;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('deferred_component')]
#[AsLiveComponent('deferred_component', method: 'get')]
final class DeferredComponent
{
use DefaultActionTrait;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('todo_list')]
#[AsLiveComponent('todo_list', method: 'get')]
final class TodoListComponent
{
#[LiveProp(writable: true)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('todo_list_with_keys')]
#[AsLiveComponent('todo_list_with_keys', method: 'get')]
final class TodoListWithKeysComponent
{
use DefaultActionTrait;
Expand Down
Loading