feat: websocket attribute
This commit is contained in:
parent
dd6be893a6
commit
fb84abb464
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebSockets\Attributes;
|
||||
|
||||
/**
|
||||
* Marks an HTTP controller method (or whole controller class) as ALSO
|
||||
* reachable via the websocket dispatcher.
|
||||
*
|
||||
* Default event-name resolution mirrors the existing
|
||||
* {@see \BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver}:
|
||||
*
|
||||
* App\Http\Controllers\Api\V1\FlightschoolController::index
|
||||
* → controller prefix derived from PascalCase → kebab-case ("flightschool")
|
||||
* → event name "flightschool.index"
|
||||
*
|
||||
* Override either piece by passing the named arguments:
|
||||
*
|
||||
* #[Websocket(event: 'flightschools.list')]
|
||||
* public function index() { ... }
|
||||
*
|
||||
* #[Websocket(prefix: 'flightschool-guest')]
|
||||
* public function index() { ... } // → 'flightschool-guest.index'
|
||||
*
|
||||
* The actual websocket dispatch wiring lives in
|
||||
* {@see \BlaxSoftware\LaravelWebSockets\Websocket\Controller::handle()} —
|
||||
* the {@see \BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry}
|
||||
* provides the event → callable map that the dispatcher consults as a
|
||||
* fallback when no `App\Websocket\Controllers\` match is found.
|
||||
*
|
||||
* Class-level usage applies the same prefix to every public method on the
|
||||
* controller (each becomes its own event name `prefix.methodName`).
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
|
||||
final class Websocket
|
||||
{
|
||||
public function __construct(
|
||||
/**
|
||||
* Full event name, e.g. "flightschool.index". Wins over `prefix`.
|
||||
*/
|
||||
public ?string $event = null,
|
||||
|
||||
/**
|
||||
* Event prefix override (the part before the ".") — useful when the
|
||||
* controller's class name doesn't match the desired event prefix.
|
||||
* The method name supplies the suffix.
|
||||
*/
|
||||
public ?string $prefix = null,
|
||||
|
||||
/**
|
||||
* Whether the method requires an authenticated websocket connection.
|
||||
* Mirrors the `$need_auth` property on existing WS controllers.
|
||||
*/
|
||||
public bool $needAuth = false,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebSockets\Websocket;
|
||||
|
||||
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
|
||||
/**
|
||||
* Discovers HTTP controller methods tagged with {@see Websocket} and
|
||||
* exposes them as a flat event-name → target map.
|
||||
*
|
||||
* Acts as the bridge between Laravel HTTP controllers and the websocket
|
||||
* dispatcher: {@see Controller::handle()} consults
|
||||
* {@see EventRegistry::resolve()} as a fallback when
|
||||
* {@see ControllerResolver::resolve()} returns null, so the same controller
|
||||
* method can serve both surfaces.
|
||||
*
|
||||
* The registry is cached per-process via static properties; call
|
||||
* {@see EventRegistry::clear()} from hot-reload paths.
|
||||
*/
|
||||
class EventRegistry
|
||||
{
|
||||
/**
|
||||
* Map of event name → ['class' => ..., 'method' => ..., 'needAuth' => bool].
|
||||
*
|
||||
* @var array<string, array{class: class-string, method: string, needAuth: bool}>|null
|
||||
*/
|
||||
private static ?array $map = null;
|
||||
|
||||
/**
|
||||
* Directories to scan for HTTP controllers carrying the attribute.
|
||||
* Defaults to Laravel's standard `app/Http/Controllers/`; tests / non-
|
||||
* standard apps can override via {@see setSearchPaths()}.
|
||||
*
|
||||
* @var array<int|string, string>|null
|
||||
*/
|
||||
private static ?array $searchPaths = null;
|
||||
|
||||
/**
|
||||
* Override the directories scanned for tagged controllers.
|
||||
*
|
||||
* Accepts either:
|
||||
* - a list of absolute paths (each defaults to the App\Http\Controllers
|
||||
* namespace, with subdirectory namespacing inferred from the relative
|
||||
* path), OR
|
||||
* - a [path => namespace-prefix] map for explicit control.
|
||||
*
|
||||
* @param array<int|string, string> $paths
|
||||
*/
|
||||
public static function setSearchPaths(array $paths): void
|
||||
{
|
||||
self::$searchPaths = $paths;
|
||||
self::$map = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the target for a websocket event name.
|
||||
*
|
||||
* @return array{class: class-string, method: string, needAuth: bool}|null
|
||||
*/
|
||||
public static function resolve(string $event): ?array
|
||||
{
|
||||
return self::map()[$event] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full event-name → target map. Lazily built on first access.
|
||||
*
|
||||
* @return array<string, array{class: class-string, method: string, needAuth: bool}>
|
||||
*/
|
||||
public static function map(): array
|
||||
{
|
||||
if (self::$map !== null) {
|
||||
return self::$map;
|
||||
}
|
||||
|
||||
self::$map = [];
|
||||
|
||||
foreach (self::candidateClasses() as [$class, $baseNamespace]) {
|
||||
self::indexClass($class, $baseNamespace);
|
||||
}
|
||||
|
||||
return self::$map;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$map = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the websocket event prefix from a controller FQCN — exact
|
||||
* inverse of {@see ControllerResolver}'s event-prefix → class algorithm.
|
||||
*
|
||||
* App\Http\Controllers\Api\V1\FlightschoolController
|
||||
* ↓ strip base namespace Api\V1\Flightschool(Controller)
|
||||
* ↓ kebab each segment api / v1 / flightschool
|
||||
* ↓ join with '-' api-v1-flightschool
|
||||
*
|
||||
* The resolver's reverse pass on `api-v1-flightschool` would try
|
||||
* App\Websocket\Controllers\ApiV1FlightschoolController, then
|
||||
* Api\V1FlightschoolController, then
|
||||
* Api\V1\FlightschoolController.
|
||||
*
|
||||
* That last form matches our v1 layout exactly — so an event sent to
|
||||
* `api-v1-flightschool.index` ends up at the same controller
|
||||
* regardless of which side (registry or resolver) finds it first.
|
||||
*/
|
||||
public static function eventPrefixFor(string $fqcn, string $baseNamespace = 'App\\Http\\Controllers\\'): string
|
||||
{
|
||||
$baseNamespace = rtrim($baseNamespace, '\\') . '\\';
|
||||
|
||||
if (str_starts_with($fqcn, $baseNamespace)) {
|
||||
$relative = substr($fqcn, strlen($baseNamespace));
|
||||
} else {
|
||||
// Fallback: just the short class name
|
||||
$relative = ltrim(strrchr($fqcn, '\\') ?: $fqcn, '\\');
|
||||
}
|
||||
|
||||
$segments = explode('\\', $relative);
|
||||
|
||||
// Strip trailing "Controller" from the leaf segment
|
||||
$last = array_pop($segments);
|
||||
$last = preg_replace('/Controller$/', '', $last) ?? $last;
|
||||
|
||||
if ($last === '') {
|
||||
// Defensive: class literally named "Controller" — fall back to
|
||||
// parent folder if any.
|
||||
$last = array_pop($segments) ?? 'controller';
|
||||
}
|
||||
|
||||
$segments[] = $last;
|
||||
|
||||
$kebabSegments = array_map(
|
||||
static fn(string $seg): string => strtolower(
|
||||
preg_replace('/([a-z\d])([A-Z])/', '$1-$2', $seg) ?? $seg
|
||||
),
|
||||
$segments
|
||||
);
|
||||
|
||||
return implode('-', array_filter($kebabSegments, static fn(string $s) => $s !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Backwards-compatible alias for {@see eventPrefixFor()}.
|
||||
*
|
||||
* @deprecated Use {@see eventPrefixFor()} which understands folder structure.
|
||||
*/
|
||||
public static function defaultPrefixFor(string $shortClassName): string
|
||||
{
|
||||
return self::eventPrefixFor($shortClassName, '');
|
||||
}
|
||||
|
||||
private static function indexClass(string $class, string $baseNamespace): void
|
||||
{
|
||||
try {
|
||||
$reflection = new ReflectionClass($class);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($reflection->isAbstract() || $reflection->isInterface()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$autoPrefix = self::eventPrefixFor($class, $baseNamespace);
|
||||
$classPrefix = null;
|
||||
|
||||
// Class-level attribute: applies prefix to every public method
|
||||
$classAttr = $reflection->getAttributes(Websocket::class)[0] ?? null;
|
||||
if ($classAttr) {
|
||||
/** @var Websocket $instance */
|
||||
$instance = $classAttr->newInstance();
|
||||
$classPrefix = $instance->prefix ?? $autoPrefix;
|
||||
|
||||
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
if ($method->isStatic() || $method->isAbstract() || $method->getDeclaringClass()->getName() !== $class) {
|
||||
continue;
|
||||
}
|
||||
$event = $instance->event ?? ($classPrefix . '.' . $method->getName());
|
||||
self::$map[$event] = [
|
||||
'class' => $class,
|
||||
'method' => $method->getName(),
|
||||
'needAuth' => $instance->needAuth,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Method-level attributes override or supplement the class-level map
|
||||
foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
if ($method->getDeclaringClass()->getName() !== $class) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($method->getAttributes(Websocket::class) as $attr) {
|
||||
/** @var Websocket $instance */
|
||||
$instance = $attr->newInstance();
|
||||
|
||||
$prefix = $instance->prefix
|
||||
?? $classPrefix
|
||||
?? $autoPrefix;
|
||||
|
||||
$event = $instance->event ?? ($prefix . '.' . $method->getName());
|
||||
|
||||
self::$map[$event] = [
|
||||
'class' => $class,
|
||||
'method' => $method->getName(),
|
||||
'needAuth' => $instance->needAuth,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield [fully-qualified class name, base namespace] pairs for every PHP
|
||||
* file in each configured search path.
|
||||
*
|
||||
* The base namespace is what {@see eventPrefixFor()} strips before
|
||||
* deriving the kebab-case event prefix — so passing it through here
|
||||
* preserves folder context (e.g. `App\Http\Controllers\Api\V1\…` becomes
|
||||
* `api-v1-…` rather than just `…`).
|
||||
*
|
||||
* @return iterable<int, array{0: class-string, 1: string}>
|
||||
*/
|
||||
private static function candidateClasses(): iterable
|
||||
{
|
||||
foreach (self::resolvedSearchPaths() as $base => $namespace) {
|
||||
if (! is_dir($base)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
$namespace = rtrim($namespace, '\\') . '\\';
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = ltrim(str_replace($base, '', $file->getPathname()), DIRECTORY_SEPARATOR);
|
||||
$relativeClass = str_replace([DIRECTORY_SEPARATOR, '.php'], ['\\', ''], $relative);
|
||||
$className = $namespace . $relativeClass;
|
||||
|
||||
if (class_exists($className, true)) {
|
||||
yield [$className, $namespace];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> base path → namespace
|
||||
*/
|
||||
private static function resolvedSearchPaths(): array
|
||||
{
|
||||
if (self::$searchPaths !== null) {
|
||||
$out = [];
|
||||
foreach (self::$searchPaths as $key => $value) {
|
||||
if (is_int($key)) {
|
||||
// List form: infer namespace from the path
|
||||
$path = $value;
|
||||
$namespace = self::inferNamespace($path);
|
||||
$out[$path] = $namespace;
|
||||
} else {
|
||||
// [path => namespace] form
|
||||
$out[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
$base = function_exists('app_path')
|
||||
? app_path('Http/Controllers')
|
||||
: (defined('\\BASE_PATH') ? constant('\\BASE_PATH') . '/app/Http/Controllers' : null);
|
||||
|
||||
if (! $base) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$base => 'App\\Http\\Controllers\\'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort PSR-4 namespace inference: anything under the Laravel
|
||||
* `app/` directory becomes `App\<RelativePath>\`.
|
||||
*/
|
||||
private static function inferNamespace(string $path): string
|
||||
{
|
||||
if (! function_exists('app_path')) {
|
||||
return 'App\\';
|
||||
}
|
||||
|
||||
try {
|
||||
$appPath = app_path();
|
||||
} catch (\Throwable) {
|
||||
return 'App\\';
|
||||
}
|
||||
|
||||
$appPath = rtrim($appPath, DIRECTORY_SEPARATOR);
|
||||
$path = rtrim($path, DIRECTORY_SEPARATOR);
|
||||
|
||||
if (! str_starts_with($path, $appPath)) {
|
||||
return 'App\\';
|
||||
}
|
||||
|
||||
$relative = ltrim(substr($path, strlen($appPath)), DIRECTORY_SEPARATOR);
|
||||
$segments = $relative === '' ? [] : explode(DIRECTORY_SEPARATOR, $relative);
|
||||
|
||||
return 'App\\' . (empty($segments) ? '' : implode('\\', $segments) . '\\');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue