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->registerBroadcastAuthRoute();
|
||||
$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()
|
||||
|
|
|
|||
|
|
@ -80,7 +80,13 @@ class Controller
|
|||
// #[Websocket] attribute may handle this event. The registry
|
||||
// exposes a flat event-name → callable map built by
|
||||
// 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) {
|
||||
return self::dispatchHttpAttributeTarget($connection, $message, $target);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,23 @@ class DispatcherFallbackTest extends TestCase
|
|||
$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 */
|
||||
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