1
0
Fork 0
mirror of https://github.com/archtechx/airwire.git synced 2025-12-12 18:54:03 +00:00
This commit is contained in:
Samuel Štancl 2021-05-20 20:15:55 +02:00
commit d26fa93f1e
35 changed files with 2388 additions and 0 deletions

191
src/Airwire.php Normal file
View file

@ -0,0 +1,191 @@
<?php
namespace Airwire;
use Airwire\Attributes\Encode;
use Airwire\Testing\RequestBuilder;
use Exception;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionProperty;
use ReflectionType;
use ReflectionUnionType;
class Airwire
{
public static array $components = [];
public static array $typeTransformers = [];
public static function component(string $alias, string $class): void
{
static::$components[$alias] = $class;
}
public static function hasComponent(string $alias): bool
{
return isset(static::$components[$alias]);
}
public static function typeTransformer(string $type, callable $decode, callable $encode): void
{
static::$typeTransformers[$type] = compact('decode', 'encode');
}
public static function getDefaultDecoder(): callable
{
return fn (array $data, string $class) => new $class($data);
}
public static function getDefaultEncoder(): callable
{
return fn ($object) => json_decode(json_encode($object), true);
}
public static function decode(ReflectionProperty|ReflectionParameter|array $property, mixed $value): mixed
{
if ($value === null) {
return null;
}
if (is_array($property)) {
$property = new ReflectionProperty(...$property);
}
if ($property->getType() instanceof ReflectionUnionType) {
$types = $property->getType()->getTypes();
} else {
$types = [$property->getType()];
}
foreach ($types as $type) {
// No type = no transformer
if ($type === null) {
continue;
}
if ($type->isBuiltin() && gettype($value) === $type->getName()) {
continue;
}
$class = $type->getName();
$decoder = static::findDecoder($class);
if ($decoder) {
return $decoder($value, $class);
}
}
// No class was found
if (! isset($class)) {
return $value;
}
return static::getDefaultDecoder()($value, $class);
}
public static function encode(ReflectionProperty|ReflectionParameter|ReflectionNamedType|ReflectionUnionType|array $property, mixed $value): mixed
{
if ($value === null) {
return null;
}
if (is_array($property)) {
$property = new ReflectionProperty(...$property);
}
if (($property instanceof ReflectionProperty || $property instanceof ReflectionParameter) && count($encodeAttributes = $property->getAttributes(Encode::class))) {
return $encodeAttributes[0]->newInstance()->encode($value);
}
if ($property instanceof ReflectionType) {
$type = $property;
} else {
$type = $property->getType();
}
if ($type instanceof ReflectionUnionType) {
$types = $type->getTypes();
} else {
$types = [$type];
}
foreach ($types as $type) {
// No type = no transformer
if ($type === null) {
continue;
}
if ($type->isBuiltin() && gettype($value) === $type->getName()) {
continue;
}
$class = $type->getName();
$encoder = static::findEncoder($class);
if ($encoder) {
return $encoder($value, $class);
}
}
return static::getDefaultEncoder()($value);
}
public static function findDecoder(string $class): callable|null
{
if (class_exists($class)) {
return static::getTransformer($class)['decode'] ?? null;
}
return match ($class) {
'int' => fn ($val) => (int) $val,
'string' => fn ($val) => (string) $val,
'float' => fn ($val) => (float) $val,
'bool' => fn ($val) => (bool) $val,
default => null,
};
}
public static function findEncoder(string $class): callable|null
{
if (class_exists($class)) {
return static::getTransformer($class)['encode'] ?? null;
}
return null;
}
public static function getTransformer(string $class): array|null
{
$transformer = null;
while (! $transformer) {
if (isset(static::$typeTransformers[$class])) {
$transformer = static::$typeTransformers[$class];
}
if (! $class = get_parent_class($class)) {
break;
}
}
return $transformer;
}
public static function test(string $component): RequestBuilder
{
if (isset(static::$components[$component])) {
return new RequestBuilder($component);
} else if (in_array($component, static::$components)) {
return new RequestBuilder(array_search($component, static::$components));
}
throw new Exception("Component {$component} not found.");
}
public static function routes()
{
require __DIR__ . '/../routes/airwire.php';
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace Airwire;
use Airwire\Commands\GenerateDefinitions;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
use Illuminate\Support\ServiceProvider;
class AirwireServiceProvider extends ServiceProvider
{
public function boot()
{
$this->commands([GenerateDefinitions::class]);
$this->loadDefaultTransformers();
$this->loadRoutesFrom(__DIR__ . '/../routes/airwire.php');
}
public function loadDefaultTransformers(): void
{
Airwire::typeTransformer(
Model::class,
decode: function (mixed $data, string $model) {
$keyName = $model::make()->getKeyName();
if (is_array($data)) {
if (isset($data[$keyName])) {
if ($instance = $model::find($data[$keyName])) {
return $instance;
}
}
return new $model($data);
} else {
return $model::find($data);
}
},
encode: fn (Model $model) => $model->toArray()
);
Airwire::typeTransformer(
Collection::class,
decode: fn (array $data, string $class) => new $class($data),
encode: fn (Collection $collection) => $collection->toArray(),
);
Airwire::typeTransformer(
LazyCollection::class,
decode: fn (array $data, string $class) => new $class($data),
encode: fn (LazyCollection $collection) => $collection->toArray(),
);
}
}

32
src/Attributes/Encode.php Normal file
View file

@ -0,0 +1,32 @@
<?php
namespace Airwire\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
class Encode
{
public function __construct(
public string|null $property = null,
public string|null $method = null,
public string|null $function = null,
) {}
public function encode(mixed $value): mixed
{
if ($this->property && isset($value->${$this->property})) {
return $value->{$this->property};
}
if ($this->method && method_exists($value, $this->method)) {
return $value->{$this->method}();
}
if ($this->function && function_exists($this->function)) {
return ($this->function)($value);
}
return null;
}
}

15
src/Attributes/Wired.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace Airwire\Attributes;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
class Wired
{
public function __construct(
public mixed $default = null,
public bool $readonly = false,
public string|null $type = null,
) {}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Airwire\Commands;
use Airwire\Airwire;
use Airwire\TypehintConverter;
use Illuminate\Console\Command;
class GenerateDefinitions extends Command
{
public static string $dir;
protected $signature = 'airwire:generate';
protected $description = 'Command description';
public function handle()
{
$defaults = [];
$components = '';
$typemap = "type TypeMap = {\n";
$converter = (new TypehintConverter);
foreach (Airwire::$components as $alias => $class) {
$defaults[$alias] = (new $class([]))->getState();
$components .= $converter->convertComponent(new $class([])) . "\n\n";
$className = $converter->getClassName($class);
$typemap .= " '{$alias}': {$className}\n";
}
$namedTypes = '';
foreach ($converter->namedTypes as $alias => $type) {
$namedTypes .= "type {$alias} = {$type};\n\n";
}
$typemap .= "}";
$defaults = json_encode($defaults);
file_put_contents(resource_path('js/airwire.ts'), <<<JS
// This file is generated by Airwire
export const componentDefaults = {$defaults}
import Airwire from './../../vendor/archtechx/airwire/resources/js';
export default window.Airwire = new Airwire(componentDefaults)
declare global {
interface Window {
Airwire: Airwire
}
}
JS);
file_put_contents(resource_path('js/airwired.d.ts'), <<<JS
declare global {
{$namedTypes}
}
import './../../vendor/archtechx/airwire/resources/js/airwired'
declare module 'airwire' {
export {$typemap}
{$components}
}
JS);
}
}

65
src/Component.php Normal file
View file

@ -0,0 +1,65 @@
<?php
namespace Airwire;
use Airwire\Testing\RequestBuilder;
abstract class Component
{
use Concerns\ManagesState,
Concerns\ManagesActions,
Concerns\ManagesLifecycle,
Concerns\ManagesValidation;
public array $requestState;
public string $requestTarget;
public array $changes;
public array $calls;
public function __construct(array $state)
{
foreach ($this->getSharedProperties() as $property) {
if (isset($state[$property]) && ! $this->isReadonly($property)) {
$this->$property = Airwire::decode([$this, $property], $state[$property]);
} else {
unset($state[$property]);
}
}
$this->requestState = $state;
}
public function handle(array $changes, array $calls, string $target = null): static
{
$this->changes = $changes;
$this->calls = $calls;
$this->requestTarget = $target;
if (isset($calls['mount']) && $target === 'mount' && method_exists($this, 'mount')) {
$this->makeCalls(['mount' => $calls['mount']]);
$this->hasBeenReset = true; // Ignore validation, we're in original state - no request with a user interaction was made
} else {
if ($this->hydrateComponent()) {
$this->makeChanges($changes);
$this->makeCalls($calls);
}
}
$this->dehydrateComponent();
return $this;
}
public function response(): array
{
return [
'data' => $this->getEncodedState(),
'metadata' => $this->metadata,
];
}
public static function test(): RequestBuilder
{
return Airwire::test(static::class);
}
}

View file

@ -0,0 +1,122 @@
<?php
namespace Airwire\Concerns;
use Airwire\Airwire;
use ReflectionMethod;
use ReflectionObject;
use Illuminate\Foundation\Exceptions\Handler;
use Illuminate\Validation\ValidationException;
use Throwable;
trait ManagesActions
{
public array $readonly = [];
public function makeChanges(array $changes): void
{
foreach ($this->getSharedProperties() as $property) {
if (isset($changes[$property]) && ! $this->isReadonly($property)) {
if (! $this->makeChange($property, $changes[$property])) {
unset($changes[$property]);
}
} else {
unset($changes[$property]);
}
}
if ($changes) {
try {
$this->changed($changes);
} catch (ValidationException) {}
}
}
protected function makeChange(string $property, mixed $new): bool
{
$old = $this->$property ?? null;
try {
if ($this->updating($property, $new, $old) === false) {
return false;
}
if (method_exists($this, $method = ('updating' . ucfirst($property)))) {
if ($this->$method($new, $old) === false) {
return false;
}
}
} catch (ValidationException $e) {
return false;
}
$this->$property = $new;
$this->updated($property, $new);
if (method_exists($this, $method = ('updated' . ucfirst($property)))) {
$this->$method($new, $old);
}
return true;
}
public function makeCalls(array $calls): void
{
$this->metadata['calls'] ??= [];
foreach ($this->getSharedMethods() as $method) {
if (isset($calls[$method])) {
try {
$result = $this->callWiredMethod($method, $calls[$method]);
if ($method === 'mount') {
if (isset($result['readonly'])) {
$readonly = $result['readonly'];
unset($result['readonly']);
$result = array_merge($readonly, $result);
$this->readonly = array_unique(array_merge(
$this->readonly, array_keys($readonly)
));
}
}
$this->metadata['calls'][$method] = $result;
} catch (Throwable $e) {
if (! app()->isProduction() && ! $e instanceof ValidationException) {
$reflection = (new ReflectionObject($handler = (new Handler(app()))))->getMethod('convertExceptionToArray');
$reflection->setAccessible(true);
$this->metadata['exceptions'] ??= [];
$this->metadata['exceptions'][$method] = $reflection->invoke($handler, $e);
}
}
}
}
}
protected function callWiredMethod(string $method, array $arguments): mixed
{
$reflectionMethod = new ReflectionMethod($this, $method);
$parameters = $reflectionMethod->getParameters();
foreach ($arguments as $index => &$value) {
if (! isset($parameters[$index])) {
break;
}
$parameter = $parameters[$index];
$value = Airwire::decode($parameter, $value);
}
$result = $this->$method(...$arguments);
if ($returnType = $reflectionMethod->getReturnType()) {
return Airwire::encode($returnType, $result);
} else {
return $result;
}
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Airwire\Concerns;
use Illuminate\Validation\ValidationException;
trait ManagesLifecycle
{
public function hydrateComponent(): bool
{
if ($this->strictValidation && $this->validate(throw: false) === false) {
return false;
}
if (method_exists($this, 'hydrate')) {
$hydrate = app()->call([$this, 'hydrate'], $this->requestState);
if (is_bool($hydrate)) {
return $hydrate;
}
}
return true;
}
public function dehydrateComponent(): void
{
try {
$this->validate();
if (method_exists($this, 'dehydrate')) {
app()->call([$this, 'dehydrate'], $this->requestState);
}
} catch (ValidationException) {}
if (isset($this->errors) && ! $this->hasBeenReset) {
$this->metadata['errors'] = $this->errors->toArray();
} else {
$this->metadata['errors'] = [];
}
$this->metadata['readonly'] = array_unique(array_merge($this->readonly, $this->getReadonlyProperties()));
}
public function updating(string $property, mixed $new, mixed $old): bool
{
return true;
}
public function updated(string $property, mixed $value): void
{
}
public function changed(array $changes): void
{
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace Airwire\Concerns;
use Airwire\Airwire;
use ReflectionMethod;
use ReflectionObject;
use ReflectionProperty;
use Airwire\Attributes\Wired;
trait ManagesState
{
public bool $hasBeenReset = false;
public function getSharedProperties(): array
{
return collect((new ReflectionObject($this))->getProperties())
->filter(
fn (ReflectionProperty $property) => collect($property->getAttributes(Wired::class))->isNotEmpty()
)
->map(fn (ReflectionProperty $property) => $property->getName())
->toArray();
}
public function getSharedMethods(): array
{
return collect((new ReflectionObject($this))->getMethods())
->filter(
fn (ReflectionMethod $method) => collect($method->getAttributes(Wired::class))->isNotEmpty()
)
->map(fn (ReflectionMethod $method) => $method->getName())
->merge(method_exists($this, 'mount') ? ['mount'] : [])
->toArray();
}
public function getState(): array
{
return collect($this->getSharedProperties())
->combine($this->getSharedProperties())
->map(function (string $property) {
if (isset($this->$property)) {
return $this->$property;
}
$default = optional((new ReflectionProperty($this, $property))->getAttributes(Wired::class))[0]->newInstance()->default;
if ($default !== null) {
return Airwire::decode([$this, $property], $default);
}
return null;
})
->all();
}
public function getEncodedState(): array
{
return collect($this->getState())
->map(fn ($value, $key) => Airwire::encode([$this, $key], $value))
->toArray();
}
public function getReadonlyProperties(): array
{
return collect($this->getSharedProperties())
->filter(fn (string $property) => $this->isReadonly($property))
->values()
->toArray();
}
public function isReadonly(string $property): bool
{
$attributes = (new ReflectionProperty($this, $property))->getAttributes(Wired::class);
return count($attributes) === 1 && $attributes[0]->newInstance()->readonly === true;
}
public function reset(array $properties = null): void
{
$properties ??= $this->getSharedProperties();
foreach ($properties as $property) {
unset($this->$property);
}
$this->hasBeenReset = true;
}
public function meta(string|array $key, mixed $value): void
{
if (is_array($key)) {
$this->metadata = array_merge($this->metadata, $key);
} else {
$this->metadata[$key] = $value;
}
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace Airwire\Concerns;
use Illuminate\Contracts\Validation\Validator as AbstractValidator;
use Illuminate\Support\Arr;
use Illuminate\Support\MessageBag;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
trait ManagesValidation
{
public bool $strictValidation = true;
public MessageBag $errors;
/** @throws ValidationException */
public function validate(string|array $properties = null, bool $throw = true): bool
{
$validator = $this->validator($properties);
if ($validator->fails()) {
if (isset($this->errors)) {
foreach ($validator->errors()->toArray() as $property => $errors) {
foreach ($errors as $error) {
if (! in_array($error, $this->errors->get($property))) {
$this->errors->add($property, $error);
}
}
}
} else {
$this->errors = $validator->errors();
}
if ($throw) {
$validator->validate();
} else {
return false;
}
}
return true;
}
/** @throws ValidationException */
public function validated(string|array $properties = null): array
{
return $this->validator($properties)->validated();
}
public function validator(string|array $properties = null): AbstractValidator
{
$state = array_merge($this->getState(), $this->changes);
$rules = $this->rules();
$messages = $this->messages();
$attributes = $this->attributes();
$properties = $properties
? Arr::wrap($properties)
: $this->getSharedProperties();
$state = collect($state)->only($properties)->toArray();
$rules = collect($rules)->only($properties)->toArray();
$messages = collect($messages)->only($properties)->toArray();
$attributes = collect($attributes)->only($properties)->toArray();
return Validator::make($state, $rules, $messages, $attributes);
}
public function rules()
{
return $this->rules ?? [];
}
public function messages()
{
return [];
}
public function attributes()
{
return [];
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Airwire\Http;
use Airwire\Airwire;
use Airwire\Component;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Validator;
class AirwireController
{
public function __invoke(Request $request, string $component, string $target = null)
{
return response()->json($this->response($component, $request->input(), $target));
}
public function response(string $component, array $input, string $target = null): array
{
$validator = $this->validator($input + ['component' => $component]);
if ($validator->fails()) {
return [
'data' => $input['state'] ?? [],
'metadata' => [
'errors' => $validator->errors(),
],
];
}
return $this->makeComponent($component, $input['state'] ?? [], $target)
->handle($input['changes'] ?? [], $input['calls'] ?? [], $target)
->response();
}
public function makeComponent(string $component, array $state): Component
{
return new Airwire::$components[$component]($state);
}
protected function validator(array $data)
{
return Validator::make($data, [
'component' => ['required', function ($attribute, $value, $fail) {
if (! Airwire::hasComponent($value)) {
$fail("Component {$value} not found.");
}
}],
'state' => ['nullable', function ($attribute, $value, $fail) {
if (! is_array($value)) $fail('State must be an array.');
foreach ($value as $k => $v) {
if (! is_string($k)) $fail("[State] Property name must be a string, {$k} given.");
}
}],
'changes' => ['nullable', function ($attribute, $value, $fail) {
if (! is_array($value)) $fail('Changes must be an array.');
foreach ($value as $k => $v) {
if (! is_string($k)) $fail("[Changes] Property name must be a string, {$k} given.");
}
}],
'calls' => ['nullable', function ($attribute, $value, $fail) {
if (! is_array($value)) $fail('Calls must be an array.');
foreach ($value as $k => $v) {
if (! is_string($k)) $fail("[Calls] Method name must be a string, {$k} given.");
}
}],
]);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Airwire\Testing;
class AirwireResponse
{
public array $data;
public ResponseMetadata $metadata;
public function __construct(
protected array $rawResponse
) {
$rawResponse['metadata'] ??= [];
$this->data = ($rawResponse['data'] ?? []);
$this->metadata = new ResponseMetadata(
$rawResponse['metadata']['calls'] ?? [],
$rawResponse['metadata']['errors'] ?? [],
$rawResponse['metadata']['exceptions'] ?? [],
);
}
public function json(string $key, mixed $default = null): mixed
{
return $this->get($key, $default);
}
public function get(string $key, mixed $default = null): mixed
{
$target = str_starts_with($key, 'metadata.')
? $this->metadata
: $this->data;
return data_get($target, $key, $default);
}
public function data(string $key, mixed $default = null): mixed
{
return data_get($this->data, $key, $default);
}
public function metadata(string $key, mixed $default = null): mixed
{
return data_get($this->metadata, $key, $default);
}
public function errors(string $property = null): mixed
{
if ($property) {
return $this->metadata->errors[$property] ?? [];
}
return $this->metadata->errors;
}
public function exceptions(string $method = null): mixed
{
if ($method) {
return $this->metadata->exceptions[$method] ?? [];
}
return $this->metadata->exceptions;
}
/** Get the return value of a call. */
public function call(string $key): mixed
{
return $this->metadata->calls[$key] ?? null;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace Airwire\Testing;
use Airwire\Airwire;
use Airwire\Component;
use Airwire\Http\AirwireController;
class RequestBuilder
{
public function __construct(
public string $alias,
) {}
public string $target = 'test';
public array $state = [];
public array $changes = [];
public array $calls = [];
public function state(array $state): static
{
$this->state = $state;
return $this;
}
public function call(string $method, mixed ...$arguments): static
{
$this->calls[$method] = $arguments;
$this->target = $method;
return $this;
}
public function changes(array $changes): static
{
$this->changes = $changes;
return $this;
}
public function change(string $key, mixed $value): static
{
$this->changes[$key] = $value;
$this->target = $key;
return $this;
}
public function send(): AirwireResponse
{
return new AirwireResponse((new AirwireController)->response($this->alias, [
'state' => $this->state,
'changes' => $this->changes,
'calls' => $this->calls,
], $this->target));
}
public function hydrate(): Component
{
return (new AirwireController)->makeComponent($this->alias, $this->state)
->handle($this->changes, $this->calls, $this->target);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Airwire\Testing;
class ResponseMetadata
{
/** Results of method calls. */
public array $calls;
/**
* Validation errors.
*
* @var array<string, string[]>
*/
public array $errors;
/**
* Exceptions occured during the execution of individual methods..
*
* @var array<string, array>
*/
public array $exceptions;
public function __construct(array $calls, array $errors, array $exceptions)
{
$this->calls = $calls;
$this->errors = $errors;
$this->exceptions = $exceptions;
}
}

241
src/TypehintConverter.php Normal file
View file

@ -0,0 +1,241 @@
<?php
namespace Airwire;
use Airwire\Attributes\Wired;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Collection;
use Illuminate\Support\Enumerable;
use Illuminate\Support\Facades\Schema;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionObject;
use ReflectionProperty;
use ReflectionUnionType;
class TypehintConverter
{
public array $namedTypes = [];
public function convertBuiltinType(string $type): string
{
return match ($type) {
'int' => 'number',
'float' => 'number',
'string' => 'string',
'array' => 'any', // Arrays can be associative, so they're essentially objects
'object' => 'any',
'null' => 'null',
default => 'any',
};
}
public function convertType(string $php): string
{
if (class_exists($php)) {
if (is_subclass_of($php, Model::class) && ($model = $php::first())) {
return $this->convertModel($model);
}
if (is_subclass_of($php, Collection::class) && ($model = $php::first())) {
return 'array';
}
return 'any';
}
return $this->convertBuiltinType($php);
}
public function typeFromValue(mixed $value): string
{
return match (true) {
$value instanceof Model => $this->convertModel($value),
$value instanceof Collection => 'array',
default => $this->convertBuiltinType(gettype($value)),
};
}
public function convertModel(Model $model): string
{
$alias = $this->getClassName($model);
if (! isset($this->namedTypes[$alias])) {
$this->namedTypes[$alias] = 'pending'; // We do this to avoid infinite loops when recursively generating model type definitions
$values = $model->toArray()
?: $model->first()->toArray() // If this model is empty, attempt finding the first one in the DB
?: collect(Schema::getColumnListing($model->getTable()))->mapWithKeys(fn (string $column) => [$column => []])->toArray(); // [] for any
$this->namedTypes[$alias] = '{ ' .
collect($values)
->map(fn (mixed $value) => $this->typeFromValue($value))
->map(function (string $type, string $property) use ($model) {
if ($model->getKeyName() !== $property) {
// Don't do anything
return $type;
}
if ($type === 'any' && $model->getIncrementing()) {
$type = 'number';
}
return $type;
})
->merge($this->getModelRelations($model))
->map(fn (string $type, string $property) => "{$property}: {$type}")->join(', ')
. ' }';
}
return $alias;
}
public function getModelRelations(Model $model): array
{
$loaded = collect($model->getRelations())
->map(fn ($value) => $value instanceof Enumerable ? $value->first() : $value) // todo plural relations are incorrectly typed - should be e.g. Report[]
->filter(fn ($value) => $value instanceof Model);
/** @var Collection<string, Model> */
$reflected = collect((new ReflectionObject($model))->getMethods())
->keyBy(fn (ReflectionMethod $method) => $method->getName())
->filter(fn (ReflectionMethod $method) => $method->getReturnType() && is_subclass_of($method->getReturnType()->getName(), Relation::class)) // todo support this even without typehints
->map(fn (ReflectionMethod $method, string $name) => $model->$name()->getRelated())
->filter(fn ($value, $relation) => ! $loaded->has($relation)); // Ignore relations that we could find using getRelations()
$relations = $loaded->merge($reflected);
return $relations->map(fn (Model $model) => $this->convertModel($model))->toArray();
}
public function getClassName(object|string $class): string
{
if (is_object($class)) {
$class = $class::class;
}
return last(explode('\\', $class));
}
public function convertComponent(Component $component): string
{
$properties = $component->getSharedProperties();
$methods = $component->getSharedMethods();
$tsProperties = [];
$tsMethods = [];
foreach ($properties as $property) {
$tsProperties[$property] = $this->convertProperty($component, $property);
}
foreach ($methods as $method) {
$tsMethods[] = $this->convertMethod($component, $method);
}
$definition = '';
$class = $this->getClassName($component);
$definition .= "interface {$class} {\n";
foreach ($tsProperties as $property => $type) {
$definition .= " {$property}: {$type};\n";
}
foreach ($tsMethods as $signature) {
$definition .= " {$signature}\n";
}
$definition .= <<<TS
errors: {
[key in keyof WiredProperties<{$class}>]: string[];
}
loading: boolean;
watch(responses: (response: ComponentResponse<{$class}>) => void, errors?: (error: AirwireException) => void): void;
defer(callback: CallableFunction): void;
refresh(): ComponentResponse<{$class}>;
remount(...args: any): ComponentResponse<{$class}>;
readonly: {$class};
deferred: {$class};
\$component: {$class};
}
TS;
return $definition;
}
public function convertProperty(object $object, string $property): string
{
$reflection = new ReflectionProperty($object, $property);
if ($wired = optional($reflection->getAttributes(Wired::class))[0]) {
if ($type = $wired->newInstance()->type) {
return $type;
}
}
$type = $reflection->getType();
if ($type instanceof ReflectionUnionType) {
$types = $type->getTypes();
} else {
$types = [$type];
}
if ($type->allowsNull()) {
$types[] = 'null';
}
$results = [];
foreach ($types as $type) {
// If we're working with a union type, some types are only accessible
// from the typehint, but for one type we'll also have the value.
if (isset($object->$property) && gettype($object->$property) === $type->getName()) {
$results[] = $this->typeFromValue($object->$property);
} else {
$results[] = $this->convertType($type->getName());
}
}
return join(' | ', $results);
}
public function convertMethod(object $object, string $method): string
{
$reflection = new ReflectionMethod($object, $method);
$parameters = [];
foreach ($reflection->getParameters() as $parameter) {
$type = $parameter->getType();
if ($type instanceof ReflectionUnionType) {
$types = $type->getTypes();
} else {
$types = [$type];
}
if ($type->allowsNull()) {
$types[] = 'null';
}
$parameters[$parameter->getName()] = join(' | ', array_map(fn (ReflectionNamedType $type) => $this->convertType($type->getName()), $types));
}
$parameters = collect($parameters)->map(fn (string $type, string $name) => "{$name}: {$type}")->join(', ');
$return = match ($type = $reflection->getReturnType()) {
null => 'any',
default => $this->convertType($type),
};
return "{$method}(" . $parameters . "): AirwirePromise<{$return}>;";
}
}