RFC: Future of Routing & MVC in Flow

Intro

During our sprint we discussed issues and potential solutions with our current routing and MVC implementations

Problem space

The routing and MVC framework are two of the oldest components of Flow.
While they have been a solid foundation for years, they come with a couple of issues, for example:

  • The routing implementation is tightly coupled to MVC but sometimes it makes sense to just route HTTP requests to a PHP function vice versa
  • It’s not easy to hook into the routing configuration (e.g. provide routes via PHP attributes)
  • The ActionController implementation is quite bloated and it’s not easy to work around the expensive and magic mapping of arguments

Solution space

Some ideas to improve the situation without re-inventing the wheel (and introducing needless breaking changes).

Disclaimer: These are some ideas I take away from our session this morning. Feel free to discuss/correct me

1. Routes provider

Introduce the concept of a routes provider, that could be an interface as simple as:

interface RoutesProvider {
    public function getRoutes(): Routes;
}

Extend the mvc.routes settings by a provider option.
A complete merged settings.yaml could look like this:

Neos:
  Flow:
    mvc:
      routes:
        'Some.PackageA': true
        'Some.PackageB':
          position: 'before Some.PackageA'
        'Neos.Neos':
          variables:
            'defaultUriSuffix': '.html'
        'Some.PackageC':
          provider: `Some\PackageC\Routing\Provider`

(only the last line is new, but the example shows how this could be combined with the existing features)

With that, a 3rd party package (or the Flow core) could implement a provider that reads attributes from PHP classes and turns these into corresponding routes

Note The interface should probably also get some method like onRouteChanges(Closure $callback): void that allows us to flush routing caches automatically upon changes – alternatively some kind of hash that leads to caches being flushed when it changes (if I see it correctly, this functionality is not yet part of the existing related pull request but IMO it should be in order to provide the “Flow feeling”)

2. Simplify controller implementation

We discussed whether to reduce the MVC controller to callable(ServerRequestInterface): ResonseInterface

Flow would still provide the extensible ActionController that converts the incoming request to an ActionRequest and invokes the corresponding action, but we would allow for much simpler implementations.
For example one that @wbehncke mentioned:

#[SomeCustomAttribute(path: '/api/hello-world')]
final class HttpHandler {

    public function __invoke(ServerRequest $request): ResponseInterface {
        return method_exists($this, strtolower($request->getMethod()) ? strtolower($request->getMethod()($request) : new Response(405);
    }

    public function get(ServerRequestInterface $request): ResponseInterface {
      return new Response(200, [], 'hello world');
    }

    public function post(ServerRequestInterface $request): ResponseInterface {
      // create some resource
      return new Response(201);
    }
}

3. Restructure Route DTO

I don’t think it’s feasible to change the Routes.yaml syntax (apart from deprecating/removing the notion of defaults.@subpackage).
But I would suggest to change the Route DTO so that a configuration of

-
  name: 'Some optional name'
  uriPattern: 'foo/bar/{dynamic}'
  defaults:
    '@package':  'Some.Package'
    '@controller': 'Foo'
    '@action': 'detail'

is represented as:

{"name": "Some optional name", "uriPattern": "foo/bar/{dynamic}", "handler": "Some\Package\Controller\FooController->detail"}

Accordingly, the custom routes provider for the HttpHandler example above could produce routes like:

[
    {"uriPattern": "/api/hello-world", "httpMethods": ["GET", "POST"], "handler": "Some\\Package\\HttpHandler"}
]
2 Likes

btw I like how https://framework-x.org/ (new framework by the ReactPHP authors) implement controllers

I suggest to split this into the following distinct parts:

    1. Configure Routing via Flow\Route attributes that is triggered and ordered via Neos.Flow.mvc.routes.__package_key__ - am working on that currently
    1. Allowing to specify a uriPatternPrefix in Neos.Flow.mvc.routes.__package_key__ that is applied to all routes of that package
    1. Allowing to specify additionalRouteProviders in Neos.Flow.mvc.routes.__package_key__
    1. Refactoring of routing to be able to address other methods and controller styles

I really like the ideas and the chronological order of your points.
I would just like to suggest to keep the Neos.Flow.mvc.routes settings the “unique source of truth” basically.
i.e.: ./flow routing:list gives the result in the correct order, ./flow configuration:show --path Neos.Flow.mvc.routes gives all the information about how routes are configured.

As a consequence I would suggest that Route attributes by itself won’t automatically add a route as long as there is no explicit provider configured.

For the record:

@mficzel, @christianm and me discussed this again at our last code sprint and Martin is about to rework the PR at the moment.

In the meantime I had another idea how to support arbitrary invokable classes as controller in a less intrusive way:

Maybe we can keep everything as is, but allow controllerName to be a fully qualified class, too.

This would basically mean, that the two following routes represent the same action:

{
  "uriPattern": "foo/bar/{dynamic}",
  "defaults": {
    "@package":  "Some.Package",
    "@controller": "Foo",
    "@action": "detail"
  }
}
{
  "uriPattern": "foo/bar/{dynamic}",
  "defaults": {
    "@controller": "Some\Package\Controller\FooController",
    "@action": "detail"
  }
}

But it would allow for specifying any other invokable class (which I consider a very important feature and would love to have it for Neos 9):

{
  "uriPattern": "foo/bar/{dynamic}",
  "defaults": {
    "@controller": "Some\Package\HttpHandler"
  }
}

That way we could still use all the URI building infrastructure that we already have, e.g.:

$uri = $this->uriBuilder->uriFor(controllerName: HttpHandler::class);

As a result…:

  • @action and @package would be optional as long as the controllerName is a FQ class name (also in the ActionRequest and in UriBuilder::uriFor())
  • ActionRequest::getControllerObjectName() would be the same as ActionRequest::getControllerName() in those cases
  • While dispatching we could ignore the @action route value – the corresponding controller could evaluate it (and our default ActionController would like today)

I see that this makes a few parts a bit less explicit because there would be more runtime constraints. But – without having thoroughly thought it through yet(!) – it would be worth the change.

What do you think?

3 Likes

I like the general idea, lets see if I get this:

everything as now works, @package, @controller, @action

@controller as FQ class name + @action works as invocable pair

@controller as FQ class anme should work standalone without @action as an invocable

NEAT!
The latter we might need to check if we not add “sensible” defaults somewhere, I think @action is filled with “index” somewhere by default, but I am not sure why and if it’s a problem here… I guess we’ll see when we create this change.

2 Likes

I really like this as this will allow all controller styles from action-controllers to endpoint controllers with http-verb-methods. I will try to finalize the routing annotation pr fast to avoid collisions.

1 Like

I like the idea of @controller as FQN and i think i prototyped around something locally like this but i cant find it anymore ^^
Being able to just copy paste it fully in there is perfect dx :smiley:

I assume we would store the @action in the ServerRequest as attribute?

The question is if @action should be invoked from the outside or rather from the inside like currently via callActionMethod.

Invoking the action directly from the dispatcher would make the code a little cleaner for the implementer, as no processRequest or __invoke has to be implemented which would need to add the “Action” suffix and check if the method is public.

But invoking from outside doesnt give simple full control about error handling, or argument preparation like in the ActionController.

I would like to configure as little magic as possible thats why i also wonder if @action in combination with a fq class name should also be fully qualified?
With that adjustments one could annotate any method and would not be too limited like in martins current approach: FEATURE: Add `Flow\Route` Attribute/Annotation by mficzel · Pull Request #3325 · neos/flow-development-collection · GitHub, where we face the dilemma that not every fqn + method name is mappable to @controller @package and @action

Maybe it would be in the scope of Flow 9 to enhance the controller interface one step further. Christian and me already work on this overhaul !!! FEATURE: Dispatcher psr overhaul by mhsdesign · Pull Request #3311 · neos/flow-development-collection · GitHub, which will make the controller return a psr ResponseInterface instead of using an ActionResponse as object reference.

The problem with the current implementation of @action is that its logic is hidden and limited to Action suffixed actions.

in FEATURE: Add `Flow\Route` Attribute/Annotation by mficzel · Pull Request #3325 · neos/flow-development-collection · GitHub i wrote

The action mapping should include its full name and be guaranteed to called.
Either by invoking the action in the dispatcher or by documenting this feature as part of a implementation of a ControllerInterface

and i think we can make the controller interface more obvious in that regard:

class MyControllerWithActions
{
    public function __invoke(ServerRequest $request, ActionName $actionName): ResponseInterface;
}

class MyControllerWithoutActions
{
    public function __invoke(ServerRequest $request): ResponseInterface;
}

turning the ActionRequest into a ServerRequest for the controller might be out of scope for now and rather something for Flow 10.
Having the ActionName as own parameter (depending if the controller is multi @action aware) might be a good move.

Also as we already refactor a lot in the mentioned pr, we might as well get rid of the ControllerInterface as marker, if that is what we agree upon and use __invoke.

All routing values are already in request attributes

That’s what I tried to address with

It’s basically like today: @action is not respected when resolving the controller.

As mentioned above I would recommend to simplify the controller to an invokable.

Our default ActionController would sill evaluate the @action (via $request->getAttribute(ServerRequestAttributes::ROUTING_PARAMETERS)) and resolve the action method name (and we could extract that to some helper class or trait if we want to reuse that, but it’s probably just a couple of lines of code)

You mean “someAction” rather than “some”?

IMO that’s not really important since it’s up to the above controller implementation how to resolve that.
But changing it might break a lot of existing installations so I’d rather not

That’s still possible. An annotated method should not specify the @action default anyways (and IMO the corresponding implementation should throw if it is)

I really don’t think that it’s necessary to make the $actionName a parameter of the controller as it’s already stored in the passed $request – it even makes the case of a mismatch a potential issue

Thanks for your answers!

yes in some kind of backwards compatible fashion.
Or like: If the @controller is a FQN the @action must also be someAction.
But i only started the discussion because of my points below (and i like less magic)

Okay interesting, if we were to introduce another routing way instead of generating the @action from the annotation this would solve the dilemma.

Currently martins pr would generate for

namespace My\Package\Controller;

class ExampleController extends ActionController
{
    #[Flow\Route(uriPattern:'my/path', httpMethods: ['get'])]
    public function someAction(): void
    {
    }
}

the following routing values (which i originally disliked as it leads to coupling of things that are currently not coupled)

@package: 'My.Package'
@controller: 'Example'
@action: 'some'

but if we would just generate the following value

@package: 'My.Package'
@controller: 'Example'

or later

@controller: 'My\Package\Controller\ExampleController'

and check inside the controller which annotated methods exist and based on that and their uriPattern and the uriPattern of the current request we could resolve the correct method.
Is that what you meant?

Or should we introduce with martins pr a separate @annotatedActionName value?

Thanks for your replies but IMO we are already far too low in the nitty gritty details for this high level post that was meant as general RFC.

Let’s discuss the details in an issue and/or a separate call, OK?

1 Like

Yes i moved that part of the discussion to

and

:wink: