feat: integrate websocket routes into Laravel's route list; implement RouteListInjector for attribute-tagged methods and legacy controllers; add tests for route injection and collection
This commit is contained in:
parent
be3c7400fe
commit
f030ff1fbf
|
|
@ -0,0 +1,196 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BlaxSoftware\LaravelWebSockets\Routing;
|
||||||
|
|
||||||
|
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
|
||||||
|
use Illuminate\Routing\Router;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers every websocket event as a Laravel route with method `WS|WSS`
|
||||||
|
* so the full WS surface shows up in `php artisan route:list` next to the
|
||||||
|
* regular HTTP endpoints.
|
||||||
|
*
|
||||||
|
* The routes are inert for HTTP dispatch — Laravel's router will never
|
||||||
|
* match a real HTTP request against the `WS`/`WSS` methods — but they
|
||||||
|
* appear in `route:list`, `route:cache`, and similar tooling, which is
|
||||||
|
* the whole point: a developer wanting to enumerate the realtime API no
|
||||||
|
* longer has to grep `App\Websocket\Controllers\` by hand.
|
||||||
|
*
|
||||||
|
* Two sources are merged into one list:
|
||||||
|
* 1. Legacy WS controllers under `App\Websocket\Controllers\`. Every
|
||||||
|
* public, declared, non-lifecycle method becomes one event using
|
||||||
|
* the same kebab-prefix algorithm `ControllerResolver` resolves.
|
||||||
|
* 2. Attribute-tagged HTTP controller methods (`#[Websocket]`) via
|
||||||
|
* {@see EventRegistry}.
|
||||||
|
*
|
||||||
|
* Conflicts between the two sources are resolved with the HTTP-attribute
|
||||||
|
* winning (last write), since the attribute is the explicit declaration
|
||||||
|
* by the developer.
|
||||||
|
*/
|
||||||
|
class RouteListInjector
|
||||||
|
{
|
||||||
|
/** Methods on the package's WS Controller base class that aren't real events. */
|
||||||
|
private const LIFECYCLE_METHODS = [
|
||||||
|
'boot', 'booted', 'unboot',
|
||||||
|
'error', 'success',
|
||||||
|
'getConnection', 'getChannel', 'getEvent', 'getChannelManager',
|
||||||
|
'controll_message', 'handle',
|
||||||
|
'send_error',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register every discovered WS event as a `WS|WSS` route on $router.
|
||||||
|
*/
|
||||||
|
public static function inject(Router $router): void
|
||||||
|
{
|
||||||
|
foreach (self::collect() as $event => $target) {
|
||||||
|
$router->addRoute(
|
||||||
|
['WS', 'WSS'],
|
||||||
|
$event,
|
||||||
|
['uses' => $target['class'] . '@' . $target['method']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a sorted event-name → target map merged from both sources.
|
||||||
|
*
|
||||||
|
* @return array<string, array{class: class-string, method: string, source: string, needAuth: bool}>
|
||||||
|
*/
|
||||||
|
public static function collect(): array
|
||||||
|
{
|
||||||
|
$events = [];
|
||||||
|
|
||||||
|
// 1. Legacy `App\Websocket\Controllers\*` controllers
|
||||||
|
foreach (self::scanLegacyControllers() as $class => $methods) {
|
||||||
|
$prefix = EventRegistry::eventPrefixFor($class, 'App\\Websocket\\Controllers\\');
|
||||||
|
|
||||||
|
// The `$need_auth` property defaults to true on the base controller
|
||||||
|
// unless the subclass overrides it to false (see existing pattern
|
||||||
|
// on `*GuestController`). Use a static reflection read so we can
|
||||||
|
// surface auth status in route:list.
|
||||||
|
$needAuth = self::classNeedsAuth($class);
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$events[$prefix . '.' . $method] = [
|
||||||
|
'class' => $class,
|
||||||
|
'method' => $method,
|
||||||
|
'source' => 'ws-controller',
|
||||||
|
'needAuth' => $needAuth,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Attribute-tagged HTTP controllers — these win on collision
|
||||||
|
foreach (EventRegistry::map() as $event => $target) {
|
||||||
|
$events[$event] = [
|
||||||
|
'class' => $target['class'],
|
||||||
|
'method' => $target['method'],
|
||||||
|
'source' => 'http-attribute',
|
||||||
|
'needAuth' => $target['needAuth'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($events);
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan App\Websocket\Controllers/ recursively for controllers and their
|
||||||
|
* public, declared, non-lifecycle methods.
|
||||||
|
*
|
||||||
|
* @return array<class-string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private static function scanLegacyControllers(): array
|
||||||
|
{
|
||||||
|
if (! function_exists('app_path')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$base = app_path('Websocket/Controllers');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_dir($base)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$found = [];
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file->isFile() || $file->getExtension() !== 'php') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = ltrim(str_replace($base, '', $file->getPathname()), DIRECTORY_SEPARATOR);
|
||||||
|
$className = 'App\\Websocket\\Controllers\\' . str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], $relative);
|
||||||
|
|
||||||
|
if (! class_exists($className, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflection = new \ReflectionClass($className);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reflection->isAbstract() || $reflection->isInterface()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$methods = [];
|
||||||
|
foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||||
|
if ($method->isStatic() || $method->isConstructor() || $method->isAbstract()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($method->getDeclaringClass()->getName() !== $className) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($method->getName(), self::LIFECYCLE_METHODS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$methods[] = $method->getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($methods) {
|
||||||
|
$found[$className] = $methods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function classNeedsAuth(string $class): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$reflection = new \ReflectionClass($class);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $reflection->hasProperty('need_auth')) {
|
||||||
|
return true; // base default
|
||||||
|
}
|
||||||
|
|
||||||
|
$prop = $reflection->getProperty('need_auth');
|
||||||
|
|
||||||
|
// Try to read default value (works whether the property is public
|
||||||
|
// or protected, because we're reading the declared default, not
|
||||||
|
// an instance value).
|
||||||
|
$defaults = $reflection->getDefaultProperties();
|
||||||
|
if (array_key_exists('need_auth', $defaults)) {
|
||||||
|
return (bool) $defaults['need_auth'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,25 @@ class WebSocketsServiceProvider extends ServiceProvider
|
||||||
$this->registerIdentityFormatter();
|
$this->registerIdentityFormatter();
|
||||||
$this->registerBroadcastAuthRoute();
|
$this->registerBroadcastAuthRoute();
|
||||||
$this->registerCommands();
|
$this->registerCommands();
|
||||||
|
$this->registerRouteListIntegration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject every WS event as a `WS|WSS` route in the Laravel router so
|
||||||
|
* `php artisan route:list` shows the realtime surface alongside HTTP.
|
||||||
|
*
|
||||||
|
* Only runs in the console: HTTP requests can never carry a `WS` or
|
||||||
|
* `WSS` method, so the routes are inert for production dispatch, but
|
||||||
|
* registering them per-request would still cost a directory scan we
|
||||||
|
* don't need to pay there.
|
||||||
|
*/
|
||||||
|
protected function registerRouteListIntegration()
|
||||||
|
{
|
||||||
|
if (! $this->app->runningInConsole()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Routing\RouteListInjector::inject($this->app['router']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register()
|
public function register()
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,13 @@ class Controller
|
||||||
// #[Websocket] attribute may handle this event. The registry
|
// #[Websocket] attribute may handle this event. The registry
|
||||||
// exposes a flat event-name → callable map built by
|
// exposes a flat event-name → callable map built by
|
||||||
// reflecting attribute-tagged methods at scan time.
|
// reflecting attribute-tagged methods at scan time.
|
||||||
$target = EventRegistry::resolve($message['event']);
|
//
|
||||||
|
// IMPORTANT: registry keys are stored without the client-side
|
||||||
|
// `[uniquifier]` segment (e.g. `flightschool.index[abc123]`),
|
||||||
|
// so we rebuild the lookup name from the cleaned prefix +
|
||||||
|
// cleaned method instead of using `$message['event']` raw.
|
||||||
|
$cleanEvent = $eventPrefix . '.' . $method;
|
||||||
|
$target = EventRegistry::resolve($cleanEvent);
|
||||||
if ($target) {
|
if ($target) {
|
||||||
return self::dispatchHttpAttributeTarget($connection, $message, $target);
|
return self::dispatchHttpAttributeTarget($connection, $message, $target);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,23 @@ class DispatcherFallbackTest extends TestCase
|
||||||
$this->assertSame('test-channel', $result['sent']['channel']);
|
$this->assertSame('test-channel', $result['sent']['channel']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_strips_the_client_side_uniquifier_before_registry_lookup()
|
||||||
|
{
|
||||||
|
// Real-world client-side wrappers append a per-request uniquifier in
|
||||||
|
// square brackets (e.g. `dispatchable.array[abc123]`) so the response
|
||||||
|
// can be correlated. The dispatcher must strip that before looking
|
||||||
|
// up the registry — otherwise every WS client request would 404 the
|
||||||
|
// registry path even though the underlying event is well-formed.
|
||||||
|
$result = $this->dispatch($this->message('dispatchable.array[abc123]'));
|
||||||
|
|
||||||
|
$this->assertSame(['kind' => 'array', 'ok' => true], $result['return']);
|
||||||
|
// The :response envelope echoes back the ORIGINAL event name (with
|
||||||
|
// uniquifier) so clients can match the response to the request.
|
||||||
|
$this->assertSame('dispatchable.array[abc123]:response', $result['sent']['event']);
|
||||||
|
$this->assertSame(['kind' => 'array', 'ok' => true], $result['sent']['data']);
|
||||||
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function it_falls_through_to_the_send_error_when_neither_resolver_nor_registry_matches()
|
public function it_falls_through_to_the_send_error_when_neither_resolver_nor_registry_matches()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BlaxSoftware\LaravelWebSockets\Tests\Unit;
|
||||||
|
|
||||||
|
use BlaxSoftware\LaravelWebSockets\Routing\RouteListInjector;
|
||||||
|
use BlaxSoftware\LaravelWebSockets\Test\TestCase;
|
||||||
|
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
|
||||||
|
use Illuminate\Routing\Router;
|
||||||
|
|
||||||
|
class RouteListInjectorTest extends TestCase
|
||||||
|
{
|
||||||
|
private const FIXTURES_NS = 'BlaxSoftware\\LaravelWebSockets\\Test\\Unit\\Fixtures\\Controllers';
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Pin the registry to the same fixtures used by EventRegistryTest so
|
||||||
|
// we have a deterministic set of attribute-tagged events to merge in.
|
||||||
|
EventRegistry::clear();
|
||||||
|
EventRegistry::setSearchPaths([
|
||||||
|
__DIR__ . '/Fixtures/Controllers' => self::FIXTURES_NS,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tearDown(): void
|
||||||
|
{
|
||||||
|
EventRegistry::clear();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_injects_a_route_per_attribute_tagged_method()
|
||||||
|
{
|
||||||
|
$router = new Router(app('events'), app());
|
||||||
|
|
||||||
|
RouteListInjector::inject($router);
|
||||||
|
|
||||||
|
$routes = $router->getRoutes();
|
||||||
|
|
||||||
|
// At least one of the fixture events should be present
|
||||||
|
$matched = collect($routes)->first(fn($r) => $r->uri() === 'plain.alpha');
|
||||||
|
$this->assertNotNull($matched, 'Expected route plain.alpha to be registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function injected_routes_use_ws_and_wss_as_methods()
|
||||||
|
{
|
||||||
|
$router = new Router(app('events'), app());
|
||||||
|
|
||||||
|
RouteListInjector::inject($router);
|
||||||
|
|
||||||
|
$matched = collect($router->getRoutes())->first(fn($r) => $r->uri() === 'plain.alpha');
|
||||||
|
|
||||||
|
$this->assertNotNull($matched);
|
||||||
|
$this->assertContains('WS', $matched->methods());
|
||||||
|
$this->assertContains('WSS', $matched->methods());
|
||||||
|
|
||||||
|
// No HTTP methods
|
||||||
|
$this->assertNotContains('GET', $matched->methods());
|
||||||
|
$this->assertNotContains('POST', $matched->methods());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function injected_routes_carry_a_controller_action_string()
|
||||||
|
{
|
||||||
|
$router = new Router(app('events'), app());
|
||||||
|
|
||||||
|
RouteListInjector::inject($router);
|
||||||
|
|
||||||
|
$matched = collect($router->getRoutes())->first(fn($r) => $r->uri() === 'plain.alpha');
|
||||||
|
|
||||||
|
$this->assertNotNull($matched);
|
||||||
|
$action = $matched->getActionName();
|
||||||
|
$this->assertSame(
|
||||||
|
self::FIXTURES_NS . '\\PlainController@alpha',
|
||||||
|
$action
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function collect_includes_attribute_tagged_events()
|
||||||
|
{
|
||||||
|
$events = RouteListInjector::collect();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('plain.alpha', $events);
|
||||||
|
$this->assertArrayHasKey('plain.bravo', $events);
|
||||||
|
$this->assertArrayHasKey('api-v1-me.show', $events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function collect_marks_attribute_sourced_events()
|
||||||
|
{
|
||||||
|
$events = RouteListInjector::collect();
|
||||||
|
|
||||||
|
$this->assertSame('http-attribute', $events['plain.alpha']['source']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function collect_propagates_need_auth_for_attribute_targets()
|
||||||
|
{
|
||||||
|
$events = RouteListInjector::collect();
|
||||||
|
|
||||||
|
$this->assertTrue($events['api-v1-me.show']['needAuth']);
|
||||||
|
$this->assertFalse($events['plain.alpha']['needAuth']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function collect_returns_a_sorted_map()
|
||||||
|
{
|
||||||
|
$events = RouteListInjector::collect();
|
||||||
|
$keys = array_keys($events);
|
||||||
|
$sorted = $keys;
|
||||||
|
sort($sorted);
|
||||||
|
|
||||||
|
$this->assertSame($sorted, $keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue