mirror of
https://github.com/archtechx/airwire.git
synced 2025-12-12 18:54:03 +00:00
initial
This commit is contained in:
commit
d26fa93f1e
35 changed files with 2388 additions and 0 deletions
191
src/Airwire.php
Normal file
191
src/Airwire.php
Normal 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';
|
||||
}
|
||||
}
|
||||
56
src/AirwireServiceProvider.php
Normal file
56
src/AirwireServiceProvider.php
Normal 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
32
src/Attributes/Encode.php
Normal 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
15
src/Attributes/Wired.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
72
src/Commands/GenerateDefinitions.php
Normal file
72
src/Commands/GenerateDefinitions.php
Normal 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
65
src/Component.php
Normal 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);
|
||||
}
|
||||
}
|
||||
122
src/Concerns/ManagesActions.php
Normal file
122
src/Concerns/ManagesActions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/Concerns/ManagesLifecycle.php
Normal file
57
src/Concerns/ManagesLifecycle.php
Normal 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
|
||||
{
|
||||
}
|
||||
}
|
||||
96
src/Concerns/ManagesState.php
Normal file
96
src/Concerns/ManagesState.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/Concerns/ManagesValidation.php
Normal file
84
src/Concerns/ManagesValidation.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
71
src/Http/AirwireController.php
Normal file
71
src/Http/AirwireController.php
Normal 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.");
|
||||
}
|
||||
}],
|
||||
]);
|
||||
}
|
||||
}
|
||||
71
src/Testing/AirwireResponse.php
Normal file
71
src/Testing/AirwireResponse.php
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/Testing/RequestBuilder.php
Normal file
67
src/Testing/RequestBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/Testing/ResponseMetadata.php
Normal file
30
src/Testing/ResponseMetadata.php
Normal 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
241
src/TypehintConverter.php
Normal 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}>;";
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue