From d97f6f5b93299c390711c6729c81ca752413c541 Mon Sep 17 00:00:00 2001 From: Donnie Date: Sat, 12 Apr 2025 13:19:53 +0200 Subject: [PATCH 1/6] add request validation types --- resources/method.blade.ts | 9 + src/GenerateCommand.php | 55 ++--- src/Request.php | 213 ++++++++++++++++++ src/Route.php | 58 ++++- tests/RequestController.test.ts | 36 +++ .../Http/Controllers/RequestController.php | 21 ++ .../app/Http/Requests/StorePostRequest.php | 40 ++++ .../app/Http/Requests/UpdatePostRequest.php | 40 ++++ workbench/routes/web.php | 10 +- 9 files changed, 449 insertions(+), 33 deletions(-) create mode 100644 src/Request.php create mode 100644 tests/RequestController.test.ts create mode 100644 workbench/app/Http/Controllers/RequestController.php create mode 100644 workbench/app/Http/Requests/StorePostRequest.php create mode 100644 workbench/app/Http/Requests/UpdatePostRequest.php diff --git a/resources/method.blade.ts b/resources/method.blade.ts index 5047a4f..1c7fd61 100644 --- a/resources/method.blade.ts +++ b/resources/method.blade.ts @@ -12,6 +12,14 @@ url: @js($uri), } +@if ($request->isNotEmpty()) +{!! when(true, 'export type') !!} {!! $request->get('class') !!} = { +@foreach($request->get('rules') as $rule) + {!! $rule->get('field') !!}: {!! $rule->get('types') !!} +@endforeach +} +@endif + @include('wayfinder::docblock') {!! $method !!}.url = (@include('wayfinder::function-arguments')) => { @if ($parameters->count() === 1) @@ -97,6 +105,7 @@ options method: @js($verbs->first()->formSafe), }) + @foreach ($verbs as $verb) @include('wayfinder::docblock') {!! $method !!}Form.{!! $verb->actual !!} = (@include('wayfinder::function-arguments')): { diff --git a/src/GenerateCommand.php b/src/GenerateCommand.php index 1a32992..6a65482 100644 --- a/src/GenerateCommand.php +++ b/src/GenerateCommand.php @@ -40,7 +40,7 @@ public function __construct( public function handle() { - $this->view->addNamespace('wayfinder', __DIR__.'/../resources'); + $this->view->addNamespace('wayfinder', __DIR__ . '/../resources'); $this->view->addExtension('blade.ts', 'blade'); $this->forcedScheme = (new ReflectionProperty($this->url, 'forceScheme'))->getValue($this->url); @@ -55,7 +55,7 @@ public function handle() $this->urlDefaults[$middleware] ??= $this->getDefaultsForMiddleware($middleware); return $this->urlDefaults[$middleware]; - })->flatMap(fn ($r) => $r); + })->flatMap(fn($r) => $r); return new Route($route, $defaults, $this->forcedScheme, $this->forcedRoot); }); @@ -63,14 +63,14 @@ public function handle() if (! $this->option('skip-actions')) { $this->files->deleteDirectory($this->base()); - $controllers = $routes->filter(fn (Route $route) => $route->hasController())->groupBy(fn (Route $route) => $route->dotNamespace()); + $controllers = $routes->filter(fn(Route $route) => $route->hasController())->groupBy(fn(Route $route) => $route->dotNamespace()); $controllers->undot()->each($this->writeBarrelFiles(...)); $controllers->each($this->writeControllerFile(...)); $this->writeContent(); - info('[Wayfinder] Generated actions in '.$this->base()); + info('[Wayfinder] Generated actions in ' . $this->base()); } $this->pathDirectory = 'routes'; @@ -78,20 +78,20 @@ public function handle() if (! $this->option('skip-routes')) { $this->files->deleteDirectory($this->base()); - $named = $routes->filter(fn (Route $route) => $route->name() && ! Str::endsWith($route->name(), '.'))->groupBy(fn (Route $route) => $route->name()); + $named = $routes->filter(fn(Route $route) => $route->name() && ! Str::endsWith($route->name(), '.'))->groupBy(fn(Route $route) => $route->name()); $named->each($this->writeNamedFile(...)); $named->undot()->each($this->writeBarrelFiles(...)); $this->writeContent(); - info('[Wayfinder] Generated routes in '.$this->base()); + info('[Wayfinder] Generated routes in ' . $this->base()); } $this->pathDirectory = 'wayfinder'; $this->files->ensureDirectoryExists($this->base()); - $this->files->copy(__DIR__.'/../resources/js/wayfinder.ts', join_paths($this->base(), 'index.ts')); + $this->files->copy(__DIR__ . '/../resources/js/wayfinder.ts', join_paths($this->base(), 'index.ts')); } private function appendContent($path, $content): void @@ -121,11 +121,11 @@ private function writeContent(): void private function writeControllerFile(Collection $routes, string $namespace): void { - $path = join_paths($this->base(), ...explode('.', $namespace)).'.ts'; + $path = join_paths($this->base(), ...explode('.', $namespace)) . '.ts'; $this->appendCommonImports($routes, $path, $namespace); - $routes->groupBy(fn (Route $route) => $route->method())->each(function ($methodRoutes) use ($path) { + $routes->groupBy(fn(Route $route) => $route->method())->each(function ($methodRoutes) use ($path) { if ($methodRoutes->count() === 1) { return $this->writeControllerMethodExport($methodRoutes->first(), $path); } @@ -133,20 +133,20 @@ private function writeControllerFile(Collection $routes, string $namespace): voi return $this->writeMultiRouteControllerMethodExport($methodRoutes, $path); }); - [$invokable, $methods] = $routes->partition(fn (Route $route) => $route->hasInvokableController()); + [$invokable, $methods] = $routes->partition(fn(Route $route) => $route->hasInvokableController()); $defaultExport = $invokable->isNotEmpty() ? $invokable->first()->jsMethod() : last(explode('.', $namespace)); if ($invokable->isEmpty()) { - $exportedMethods = $methods->map(fn (Route $route) => $route->jsMethod()); - $reservedMethods = $methods->filter(fn (Route $route) => $route->originalJsMethod() !== $route->jsMethod())->map(fn (Route $route) => $route->originalJsMethod().': '.$route->jsMethod()); + $exportedMethods = $methods->map(fn(Route $route) => $route->jsMethod()); + $reservedMethods = $methods->filter(fn(Route $route) => $route->originalJsMethod() !== $route->jsMethod())->map(fn(Route $route) => $route->originalJsMethod() . ': ' . $route->jsMethod()); $exportedMethods = $exportedMethods->merge($reservedMethods); $methodProps = "const {$defaultExport} = { "; $methodProps .= $exportedMethods->unique()->implode(', '); $methodProps .= ' }'; } else { - $methodProps = $methods->map(fn (Route $route) => $defaultExport.'.'.$route->jsMethod().' = '.$route->jsMethod())->unique()->implode(PHP_EOL); + $methodProps = $methods->map(fn(Route $route) => $defaultExport . '.' . $route->jsMethod() . ' = ' . $route->jsMethod())->unique()->implode(PHP_EOL); } $this->appendContent($path, <<appendContent($path, $this->view->make('wayfinder::multi-method', [ 'method' => $routes->first()->jsMethod(), + 'request' => $routes->first()->controllerMethodRequest(), 'original_method' => $routes->first()->originalJsMethod(), 'path' => $routes->first()->controllerPath(), 'line' => $routes->first()->controllerMethodLineNumber(), 'controller' => $routes->first()->controller(), 'isInvokable' => $routes->first()->hasInvokableController(), 'withForm' => $this->option('with-form') ?? false, - 'routes' => $routes->map(fn ($r) => [ - 'tempMethod' => $r->jsMethod().md5($r->uri()), + 'routes' => $routes->map(fn($r) => [ + 'tempMethod' => $r->jsMethod() . md5($r->uri()), 'parameters' => $r->parameters(), 'verbs' => $r->verbs(), 'uri' => $r->uri(), @@ -179,6 +180,7 @@ private function writeControllerMethodExport(Route $route, string $path): void { $this->appendContent($path, $this->view->make('wayfinder::method', [ 'controller' => $route->controller(), + 'request' => $route->controllerMethodRequest(), 'method' => $route->jsMethod(), 'original_method' => $route->originalJsMethod(), 'isInvokable' => $route->hasInvokableController(), @@ -193,13 +195,13 @@ private function writeControllerMethodExport(Route $route, string $path): void private function writeNamedFile(Collection $routes, string $namespace): void { - $path = join_paths($this->base(), ...explode('.', $namespace)).'.ts'; + $path = join_paths($this->base(), ...explode('.', $namespace)) . '.ts'; $this->appendCommonImports($routes, $path, $namespace); - $routes->each(fn (Route $route) => $this->writeNamedMethodExport($route, $path)); + $routes->each(fn(Route $route) => $this->writeNamedMethodExport($route, $path)); - $imports = $routes->map(fn (Route $route) => $route->namedMethod())->implode(', '); + $imports = $routes->map(fn(Route $route) => $route->namedMethod())->implode(', '); $basename = basename($path, '.ts'); @@ -218,19 +220,20 @@ private function appendCommonImports(Collection $routes, string $path, string $n { $imports = ['queryParams', 'type QueryParams']; - if ($routes->contains(fn (Route $route) => $route->parameters()->contains(fn (Parameter $parameter) => $parameter->optional))) { + if ($routes->contains(fn(Route $route) => $route->parameters()->contains(fn(Parameter $parameter) => $parameter->optional))) { $imports[] = 'validateParameters'; } $importBase = str_repeat('/..', substr_count($namespace, '.') + 1); - $this->appendContent($path, 'import { '.implode(', ', $imports)." } from '.{$importBase}/wayfinder'\n"); + $this->appendContent($path, 'import { ' . implode(', ', $imports) . " } from '.{$importBase}/wayfinder'\n"); } private function writeNamedMethodExport(Route $route, string $path): void { $this->appendContent($path, $this->view->make('wayfinder::method', [ 'controller' => $route->controller(), + 'request' => $route->controllerMethodRequest(), 'method' => $route->namedMethod(), 'original_method' => $route->originalJsMethod(), 'isInvokable' => false, @@ -251,17 +254,17 @@ private function writeBarrelFiles(array|Collection $children, string $parent): v return; } - $normalizeToCamelCase = fn ($value) => str_contains($value, '-') ? Str::camel($value) : $value; + $normalizeToCamelCase = fn($value) => str_contains($value, '-') ? Str::camel($value) : $value; $indexPath = join_paths($this->base(), $parent, 'index.ts'); - $childKeys = $children->keys()->mapWithKeys(fn ($child) => [$normalizeToCamelCase($child) => $child]); + $childKeys = $children->keys()->mapWithKeys(fn($child) => [$normalizeToCamelCase($child) => $child]); - $imports = $childKeys->filter(fn ($child, $key) => $key !== 'index')->map(fn ($child, $key) => "import {$key} from './{$child}'")->implode(PHP_EOL); + $imports = $childKeys->filter(fn($child, $key) => $key !== 'index')->map(fn($child, $key) => "import {$key} from './{$child}'")->implode(PHP_EOL); $this->prependContent($indexPath, $imports); - $keys = $childKeys->keys()->map(fn ($key) => str_repeat(' ', 4).$key)->implode(', '.PHP_EOL); + $keys = $childKeys->keys()->map(fn($key) => str_repeat(' ', 4) . $key)->implode(', ' . PHP_EOL); $varExport = $normalizeToCamelCase(Str::afterLast($parent, DIRECTORY_SEPARATOR)); @@ -275,7 +278,7 @@ private function writeBarrelFiles(array|Collection $children, string $parent): v export default {$varExport} JAVASCRIPT); - $children->each(fn ($grandChildren, $child) => $this->writeBarrelFiles($grandChildren, join_paths($parent, $child))); + $children->each(fn($grandChildren, $child) => $this->writeBarrelFiles($grandChildren, join_paths($parent, $child))); } private function base(): string @@ -313,7 +316,7 @@ private function getDefaultsForMiddleware(string $middleware) } $methodContents = str($methodContents)->after('{')->beforeLast('}')->trim(); - $tokens = token_get_all('types = collect([ 'class' => $this->className, 'rules' => $this->resolveTypes($validations)]); + } + + private function resolveTypes(array $validations): Collection + { + + $rules = new Collection(); + + foreach ($validations as $field => $validationRules) { + + $fieldUnionTypes = new Collection(); + + $explodedRules = is_string($validationRules) ? explode('|', $validationRules) : $validationRules; + + foreach($explodedRules as $rule) { + + if (is_string($rule)) { + + $escapedRule = Str::take($rule, Str::position($rule, ':')); + $escapedRule = $escapedRule === '' ? $rule : $escapedRule; + + if (in_array($escapedRule, $this->optionalRules)) { + $field .= '?'; + } + + // if (array_key_exists($rule, $this->rules)) { + // $fieldUnionTypes->push($this->rules[$rule]); + // } + + // booleans + if($this->booleans()->has($escapedRule)) { + $fieldUnionTypes->push(...$this->booleans()->get($escapedRule)); + } + + + // strings + if($this->strings()->has($escapedRule)) { + $fieldUnionTypes->push(...$this->strings()->get($escapedRule)); + } + + // numbers + if($this->numbers()->has($escapedRule)) { + $fieldUnionTypes->push(...$this->numbers()->get($escapedRule)); + } + + // arrays + if($this->arrays()->has($escapedRule)) { + $fieldUnionTypes->push(...$this->arrays()->get($escapedRule)); + } + + if($escapedRule === $this->nullable) { + $fieldUnionTypes->push('null'); + } + + // temporary fix for optional fields + if(Str::contains($field, '?') && $fieldUnionTypes->isEmpty()) { + $fieldUnionTypes->push('any'); + } + + } else { + // TODO: finish implementation + // // var_dump($rule instanceof Rule); + + // // if($rule instanceof ValidationRule || $rule instanceof Rule) { + + // $ruleClass = Str::afterLast(get_class($rule), '\\'); + // // var_dump($ruleClass); + // if (in_array($ruleClass, $this->optionalRules)) { + // $field .= '?'; + // } + + // if (array_key_exists(strtolower($ruleClass), $this->rules)) { + // $fieldUnionTypes->push($this->rules[$ruleClass]); + // } + + + // } + + } + + + } + + if ($fieldUnionTypes->count() > 0) { + $rules->push(collect(['field' => $field, 'types' => $fieldUnionTypes->uniqueStrict()->join(' | ')])); + } + } + return $rules; + } + + protected function booleans() { + return collect([ + 'accepted' => ["'yes'", "'on'", 1, "'1'", "'true'", 'true'], + 'accepted_if' => ["'yes'", "'on'", 1, "'1'", "'true'", 'true'], + 'boolean' => ['boolean', 1, 0, "'1'", "'0'"], + 'declined' => ["'no'", "'off'", 0, "'0'", "'false'", 'false'], + 'declined_if' => ["'no'", "'off'", 0, "'0'", "'false'", 'false'], + ]); + } + + protected function strings() { + return collect([ + 'active_url' => ['string'], + 'alpha' => ['string'], + 'alpha_dash' => ['string'], + 'alpha_numeric' => ['string'], + 'ascii' => ['string'], + 'confirmed' => ['string'], + 'current_password' => ['string'], + 'different' => ['string'], + 'doesnt_start_with' => ['string'], + 'doesnt_end_with' => ['string'], + 'email' => ['string'], + 'ends_with' => ['string'], + 'enum' => ['string'], + 'hex_color' => ['string'], + 'in' => ['string'], + 'ip_address' => ['string'], + 'json' => ['string'], + 'lowercase' => ['string'], + 'mac_address' => ['string'], + 'not_in' => ['string'], + 'regular_expression' => ['string'], + 'not_regular_expression' => ['string'], + 'same' => ['string'], + 'size' => ['string'], + 'starts_with' => ['string'], + 'string' => ['string'], + 'uppercase' => ['string'], + 'url' => ['string'], + 'ulid' => ['string'], + 'uuid' => ['string'] + ]); + } + + protected function numbers() { + return collect([ + 'between' => ['number'], + 'decimal' => ['number'], + 'different' => ['number'], + 'digits' => ['number'], + 'digits_between' => ['number'], + 'greater_than' => ['number'], + 'greater_than_or_equal' => ['number'], + 'integer' => ['number'], + 'less_than' => ['number'], + 'less_than_or_equal' => ['number'], + 'max_digits' => ['number'], + 'min_digits' => ['number'], + 'multiple_of' => ['number'], + 'numeric' => ['number'], + 'same' => ['number'], + 'size' => ['number'] + ]); + } + + protected function arrays() { + return collect([ + 'array' => ['number[]', 'string[]'], + 'between' => ['number[]', 'string[]'], + 'contains' => ['number[]', 'string[]'], + 'distinct' => ['number[]', 'string[]'], + 'in_array' => ['number[]', 'string[]'], + 'list' => ['number[]', 'string[]'], + 'size' => ['number[]', 'string[]'] + ]); + } + + protected function any() { + return collect([ + 'file' => ['any'], + 'fileimage' => ['any'], + 'image' => ['any'] + ]); + } + +} \ No newline at end of file diff --git a/src/Route.php b/src/Route.php index 465a589..5e37160 100644 --- a/src/Route.php +++ b/src/Route.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use Laravel\SerializableClosure\Support\ReflectionClosure; +use Illuminate\Support\Reflector; use ReflectionClass; class Route @@ -72,12 +73,12 @@ public function parameters(): Collection $signatureParams = collect($this->base->signatureParameters(UrlRoutable::class)); - return collect($this->base->parameterNames())->map(fn ($name) => new Parameter( + return collect($this->base->parameterNames())->map(fn($name) => new Parameter( $name, $optionalParameters->has($name) || $this->paramDefaults->has($name), $this->base->bindingFieldFor($name), $this->paramDefaults->get($name), - $signatureParams->first(fn ($p) => $p->getName() === $name), + $signatureParams->first(fn($p) => $p->getName() === $name), )); } @@ -88,13 +89,13 @@ public function verbs(): Collection public function uri(): string { - $defaultParams = $this->paramDefaults->mapWithKeys(fn ($value, $key) => ["{{$key}}" => "{{$key}?}"]); + $defaultParams = $this->paramDefaults->mapWithKeys(fn($value, $key) => ["{{$key}}" => "{{$key}?}"]); $scheme = $this->scheme() ?? '//'; return str($this->base->uri) ->start('/') - ->when($this->domain() !== null, fn ($uri) => $uri->prepend("{$scheme}{$this->domain()}")) + ->when($this->domain() !== null, fn($uri) => $uri->prepend("{$scheme}{$this->domain()}")) ->replace($defaultParams->keys()->toArray(), $defaultParams->values()->toArray()) ->toString(); } @@ -137,6 +138,54 @@ public function controllerPath(): string return $this->relativePath((new ReflectionClass($controller))->getFileName()); } + public function controllerMethodRequest(): Collection + { + $controller = $this->controller(); + $types = new Collection(); + $request = null; + + if ($controller === '\\Closure') { + return $types; + } + + if (! class_exists($controller)) { + return $types; + } + + $reflection = (new ReflectionClass($controller)); + + if(! $reflection->hasMethod($this->method())) { + return $types; + } + + $reflectionMethod = $reflection->getMethod($this->method()); + $parameters = $reflectionMethod->getParameters(); + + + foreach ($parameters as $parameter) { + if(Reflector::isParameterSubclassOf($parameter, 'Illuminate\\Foundation\\Http\\FormRequest')) { + $request = $parameter; + } + } + + if (is_null($request)) { + return $types; + } + + $requestReflectionClass = (new ReflectionClass($request->getType()->getName())); + + if($requestReflectionClass->hasMethod('rules')) { + $rules = $requestReflectionClass->getMethod('rules')->invoke($requestReflectionClass->newInstance()); + + $types = (new Request( + Str::afterLast($request->getType()->getName(), '\\'), + $rules + ))->types; + } + + return $types; + } + public function controllerMethodLineNumber(): int { $controller = $this->controller(); @@ -167,4 +216,5 @@ private function relativePath(string $path) { return ltrim(str_replace(base_path(), '', $path), DIRECTORY_SEPARATOR); } + } diff --git a/tests/RequestController.test.ts b/tests/RequestController.test.ts new file mode 100644 index 0000000..ec72824 --- /dev/null +++ b/tests/RequestController.test.ts @@ -0,0 +1,36 @@ +import { describe, expectTypeOf, test } from "vitest"; + +import type { StorePostRequest, UpdatePostRequest } from '../workbench/resources/js/actions/App/Http/Controllers/RequestController' + +// test for request, request param in different order (not first), request rules as strings, request rules as instances + +describe("model", () => { + test("model structure", () => { + const data: StorePostRequest = { + name: 'name', + description: 'description', + price: 5, + hidden: false, + stock: 10, + catalog_id: 2, + code: 'code', + slug: 'name' + } + expectTypeOf(data).toMatchObjectType + }); + + test("model structure", () => { + const data: UpdatePostRequest = { + name: 'name', + description: 'description', + price: 5, + hidden: true, + stock: 10, + catalog_id: 2, + code: 'code', + slug: 'name', + image: 'data' + } + expectTypeOf(data).toMatchObjectType + }); +}) \ No newline at end of file diff --git a/workbench/app/Http/Controllers/RequestController.php b/workbench/app/Http/Controllers/RequestController.php new file mode 100644 index 0000000..8af8688 --- /dev/null +++ b/workbench/app/Http/Controllers/RequestController.php @@ -0,0 +1,21 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'description' => ['required', 'string'], + 'price' => ['required', 'numeric'], + 'stock' => ['required', 'integer'], + 'hidden' => ['boolean'], + 'catalog_id' => ['required', 'integer', 'exists:catalogs,id'], + 'slug' => ['string', 'max:255', 'unique:products'], + 'image' => ['nullable', 'sometimes', File::image()->dimensions(Rule::dimensions()->maxWidth(1024)->maxHeight(1024))->min('1kb')->max('500kb')], + 'code' => ['required', 'string', 'max:255', 'unique:products'], + 'category_id' => ['required'] + ]; + } +} diff --git a/workbench/app/Http/Requests/UpdatePostRequest.php b/workbench/app/Http/Requests/UpdatePostRequest.php new file mode 100644 index 0000000..9dde0b9 --- /dev/null +++ b/workbench/app/Http/Requests/UpdatePostRequest.php @@ -0,0 +1,40 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['required', 'string'], + 'price' => ['required', 'numeric'], + 'stock' => ['required', 'integer'], + 'hidden' => ['boolean'], + 'catalog_id' => ['required', 'integer', 'exists:catalogs,id'], + 'slug' => ['string', 'max:255', 'unique:products', Rule::excludeIf(true)], + 'image' => ['nullable', 'sometimes', File::image()->dimensions(Rule::dimensions()->maxWidth(1024)->maxHeight(1024))->min('1kb')->max('500kb')], + 'code' => ['required', 'string', 'max:255', 'unique:products'], + 'category_id' => ['required'] + ]; + } +} diff --git a/workbench/routes/web.php b/workbench/routes/web.php index 670f6ba..412a12a 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -14,13 +14,14 @@ use App\Http\Controllers\TwoRoutesSameActionController; use App\Http\Controllers\UrlDefaultsController; use App\Http\Middleware\UrlDefaultsMiddleware; +use App\Http\Controllers\RequestController; use Illuminate\Support\Facades\Route; Route::get('/', function () { return 'Home'; })->name('home'); -Route::get('/closure', fn () => 'ok'); +Route::get('/closure', fn() => 'ok'); Route::get('/invokable-controller', InvokableController::class); Route::get('/invokable-plus-controller', InvokablePlusController::class); Route::post('/invokable-plus-controller', [InvokablePlusController::class, 'store']); @@ -71,9 +72,12 @@ Route::get('/anonymous-middleware', [AnonymousMiddlewareController::class, 'show']); Route::prefix('/api/v1')->name('api.v1.')->group(function () { - Route::get('/tasks', fn () => 'ok')->name('tasks'); + Route::get('/tasks', fn() => 'ok')->name('tasks'); Route::prefix('/tasks/{task}/task-status')->name('task-status.')->group(function () { - Route::get('/', fn () => 'ok')->name('index'); + Route::get('/', fn() => 'ok')->name('index'); }); }); + +Route::post('/store-post', [RequestController::class, 'store']); +Route::post('/update-post', [RequestController::class, 'update']); \ No newline at end of file From d47b637965ff0708558b6c7afd59b8d015b01298 Mon Sep 17 00:00:00 2001 From: Donnie Date: Sun, 13 Apr 2025 14:35:32 +0200 Subject: [PATCH 2/6] improved Request class logic --- src/Request.php | 234 ++++++++++++++++++++++++++++++------------------ 1 file changed, 149 insertions(+), 85 deletions(-) diff --git a/src/Request.php b/src/Request.php index a9ea176..8ef5b85 100644 --- a/src/Request.php +++ b/src/Request.php @@ -11,24 +11,6 @@ class Request { public Collection $types; - protected array $optionalRules = [ - 'sometimes', 'present_if', 'present_unless', 'present_with', 'present_with_all', 'required_if', 'required_if_accepted', 'required_if_declined', 'required_unless', 'required_with', 'required_with_all', 'required_without', 'required_without_all', 'missing_if', 'missing_unless', 'missing_with', 'missing_with_all' - ]; - - protected string $nullable = 'nullable'; - - protected Collection $booleans; - - protected Collection $strings; - - protected Collection $numbers; - - protected Collection $arrays; - - protected Collection $any; - - // TODO: should manage min, max? handle Rule class - public function __construct( public string $className, public array $validations @@ -48,82 +30,119 @@ private function resolveTypes(array $validations): Collection $explodedRules = is_string($validationRules) ? explode('|', $validationRules) : $validationRules; - foreach($explodedRules as $rule) { - - if (is_string($rule)) { - - $escapedRule = Str::take($rule, Str::position($rule, ':')); - $escapedRule = $escapedRule === '' ? $rule : $escapedRule; - - if (in_array($escapedRule, $this->optionalRules)) { - $field .= '?'; - } - - // if (array_key_exists($rule, $this->rules)) { - // $fieldUnionTypes->push($this->rules[$rule]); - // } - - // booleans - if($this->booleans()->has($escapedRule)) { - $fieldUnionTypes->push(...$this->booleans()->get($escapedRule)); - } - + $fieldExplicitType = ''; - // strings - if($this->strings()->has($escapedRule)) { - $fieldUnionTypes->push(...$this->strings()->get($escapedRule)); - } - - // numbers - if($this->numbers()->has($escapedRule)) { - $fieldUnionTypes->push(...$this->numbers()->get($escapedRule)); - } + foreach($explodedRules as $rule) { + + $snakeCaseRule = $rule; - // arrays - if($this->arrays()->has($escapedRule)) { - $fieldUnionTypes->push(...$this->arrays()->get($escapedRule)); - } + if(! is_string($snakeCaseRule)) { + $snakeCaseRule = Str::snake(Str::afterLast(get_class($snakeCaseRule), '\\')); + } - if($escapedRule === $this->nullable) { - $fieldUnionTypes->push('null'); - } + if(Str::contains($snakeCaseRule, ':')) { + $snakeCaseRule = Str::take($snakeCaseRule, Str::position($snakeCaseRule, ':')); + } - // temporary fix for optional fields - if(Str::contains($field, '?') && $fieldUnionTypes->isEmpty()) { - $fieldUnionTypes->push('any'); - } + if($this->conditionalRules()->contains($snakeCaseRule)) { + $field .= Str::contains($field, '?') ? '' : '?'; + } + + if($this->explicitTypes()->has($snakeCaseRule)){ + // $fieldHasExplicitType = true; + $fieldExplicitType = $this->explicitTypes()->get($snakeCaseRule); + } - } else { - // TODO: finish implementation - // // var_dump($rule instanceof Rule); + if($this->booleans()->has($snakeCaseRule)) { + $fieldUnionTypes->push(...$this->booleans()->get($snakeCaseRule)); + } - // // if($rule instanceof ValidationRule || $rule instanceof Rule) { + if($this->strings()->has($snakeCaseRule)) { + $fieldUnionTypes->push(...$this->strings()->get($snakeCaseRule)); + } - // $ruleClass = Str::afterLast(get_class($rule), '\\'); - // // var_dump($ruleClass); - // if (in_array($ruleClass, $this->optionalRules)) { - // $field .= '?'; - // } + if($this->numbers()->has($snakeCaseRule)) { + $fieldUnionTypes->push(...$this->numbers()->get($snakeCaseRule)); + } - // if (array_key_exists(strtolower($ruleClass), $this->rules)) { - // $fieldUnionTypes->push($this->rules[$ruleClass]); - // } + if($this->arrays()->has($snakeCaseRule)) { + $fieldUnionTypes->push(...$this->arrays()->get($snakeCaseRule)); + } + if($this->files()->has($snakeCaseRule)) { + $fieldUnionTypes->push(...$this->files()->get($snakeCaseRule)); + } - // } - + if($this->nullables()->has($snakeCaseRule)) { + $fieldUnionTypes->push(...$this->nullables()->get($snakeCaseRule)); } - } if ($fieldUnionTypes->count() > 0) { - $rules->push(collect(['field' => $field, 'types' => $fieldUnionTypes->uniqueStrict()->join(' | ')])); + $rules->push(collect(['field' => $field, 'types' => $fieldUnionTypes->uniqueStrict()->filter(function(string $type) use ($fieldExplicitType) { + if(Str::length($fieldExplicitType) > 0) { + + if($fieldExplicitType === 'string') { + return Str::doesntContain($type, ['[', ']', 'File', 'number']); + } else if ($fieldExplicitType === 'number') { + return Str::doesntContain($type, ['[', ']', 'File', 'string']); + } + } + return true; + })->join(' | ')])); } } return $rules; } + protected function explicitTypes() { + return collect([ + 'decimal' => 'number', + 'different' => 'number', + 'digits' => 'number', + 'digits_between' => 'number', + 'gt' => 'number', + 'gte' => 'number', + 'integer' => 'number', + 'lt' => 'number', + 'lte' => 'number', + 'max_digits' => 'number', + 'min_digits' => 'number', + 'multiple_of' => 'number', + 'numeric' => 'number', + 'active_url' => 'string', + 'alpha' => 'string', + 'alpha_dash' => 'string', + 'alpha_numeric' => 'string', + 'ascii' => 'string', + 'confirmed' => 'string', + 'current_password' => 'string', + 'different' => 'string', + 'doesnt_start_with' => 'string', + 'doesnt_end_with' => 'string', + 'email' => 'string', + 'ends_with' => 'string', + 'enum' => 'string', + 'hex_color' => 'string', + 'in' => 'string', + 'ip_address' => 'string', + 'json' => 'string', + 'lowercase' => 'string', + 'mac_address' => 'string', + 'not_in' => 'string', + 'regular_expression' => 'string', + 'not_regular_expression' => 'string', + 'same' => 'string', + 'starts_with' => 'string', + 'string' => 'string', + 'uppercase' => 'string', + 'url' => 'string', + 'ulid' => 'string', + 'uuid' => 'string', + ]); + } + protected function booleans() { return collect([ 'accepted' => ["'yes'", "'on'", 1, "'1'", "'true'", 'true'], @@ -155,6 +174,8 @@ protected function strings() { 'json' => ['string'], 'lowercase' => ['string'], 'mac_address' => ['string'], + 'max' => ['string'], + 'min' => ['string'], 'not_in' => ['string'], 'regular_expression' => ['string'], 'not_regular_expression' => ['string'], @@ -165,7 +186,7 @@ protected function strings() { 'uppercase' => ['string'], 'url' => ['string'], 'ulid' => ['string'], - 'uuid' => ['string'] + 'uuid' => ['string'], ]); } @@ -176,12 +197,14 @@ protected function numbers() { 'different' => ['number'], 'digits' => ['number'], 'digits_between' => ['number'], - 'greater_than' => ['number'], - 'greater_than_or_equal' => ['number'], + 'gt' => ['number'], + 'gte' => ['number'], 'integer' => ['number'], - 'less_than' => ['number'], - 'less_than_or_equal' => ['number'], + 'lt' => ['number'], + 'lte' => ['number'], + 'max' => ['number'], 'max_digits' => ['number'], + 'min' => ['number'], 'min_digits' => ['number'], 'multiple_of' => ['number'], 'numeric' => ['number'], @@ -193,21 +216,62 @@ protected function numbers() { protected function arrays() { return collect([ 'array' => ['number[]', 'string[]'], + 'array_rule' => ['number[]', 'string[]'], 'between' => ['number[]', 'string[]'], 'contains' => ['number[]', 'string[]'], 'distinct' => ['number[]', 'string[]'], 'in_array' => ['number[]', 'string[]'], + 'max' => ['number[]', 'string[]'], + 'min' => ['number[]', 'string[]'], 'list' => ['number[]', 'string[]'], - 'size' => ['number[]', 'string[]'] + 'size' => ['number[]', 'string[]'], ]); } - protected function any() { + /** + * The between validation rule is disabled here to avoid conflicts with the same rule for numbers and strings. Same reasoning was made for the size rule. + */ + protected function files() { return collect([ - 'file' => ['any'], - 'fileimage' => ['any'], - 'image' => ['any'] + 'between' => ['File'], + 'dimensions' => ['File'], + 'extensions' => ['File'], + 'file' => ['File'], + 'image' => ['File'], + 'image_file' => ['File'], + 'max' => ['File'], + 'mimes' => ['File'], + 'size' => ['File'], ]); - } + } + + protected function nullables() { + return collect([ + 'nullable' => ['null'] + ]); + } + + protected function conditionalRules() { + return collect([ + 'sometimes', + 'present_if', + 'present_unless', + 'present_with', + 'present_with_all', + 'required_if', + 'required_if_accepted', + 'required_if_declined', + 'required_unless', + 'required_with', + 'required_with_all', + 'required_without', + 'required_without_all', + 'missing_if', + 'missing_unless', + 'missing_with', + 'missing_with_all', + 'conditional_rules' + ]); + } } \ No newline at end of file From 9c0ef0bec6f657f89195b62692107520585b55bc Mon Sep 17 00:00:00 2001 From: Donnie Date: Sun, 13 Apr 2025 14:36:11 +0200 Subject: [PATCH 3/6] updated store and update request for testing --- workbench/app/Http/Requests/StorePostRequest.php | 2 +- workbench/app/Http/Requests/UpdatePostRequest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/workbench/app/Http/Requests/StorePostRequest.php b/workbench/app/Http/Requests/StorePostRequest.php index fb846be..b20f07e 100644 --- a/workbench/app/Http/Requests/StorePostRequest.php +++ b/workbench/app/Http/Requests/StorePostRequest.php @@ -28,7 +28,7 @@ public function rules(): array 'name' => 'required|string|max:255', 'description' => ['required', 'string'], 'price' => ['required', 'numeric'], - 'stock' => ['required', 'integer'], + 'stock' => ['required', 'integer', Rule::dimensions([2,3])], 'hidden' => ['boolean'], 'catalog_id' => ['required', 'integer', 'exists:catalogs,id'], 'slug' => ['string', 'max:255', 'unique:products'], diff --git a/workbench/app/Http/Requests/UpdatePostRequest.php b/workbench/app/Http/Requests/UpdatePostRequest.php index 9dde0b9..b9fbdff 100644 --- a/workbench/app/Http/Requests/UpdatePostRequest.php +++ b/workbench/app/Http/Requests/UpdatePostRequest.php @@ -28,8 +28,8 @@ public function rules(): array 'name' => ['required', 'string', 'max:255'], 'description' => ['required', 'string'], 'price' => ['required', 'numeric'], - 'stock' => ['required', 'integer'], - 'hidden' => ['boolean'], + 'stock' => ['required', 'integer', 'between:2,33'], + 'hidden' => ['boolean', Rule::when(true, '')], 'catalog_id' => ['required', 'integer', 'exists:catalogs,id'], 'slug' => ['string', 'max:255', 'unique:products', Rule::excludeIf(true)], 'image' => ['nullable', 'sometimes', File::image()->dimensions(Rule::dimensions()->maxWidth(1024)->maxHeight(1024))->min('1kb')->max('500kb')], From 5724c3ba32a36352852ac62a650331081c9e7b9a Mon Sep 17 00:00:00 2001 From: Donnie Date: Sun, 13 Apr 2025 14:37:52 +0200 Subject: [PATCH 4/6] moved type export to end of ts file --- resources/method.blade.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/resources/method.blade.ts b/resources/method.blade.ts index 1c7fd61..d9017bd 100644 --- a/resources/method.blade.ts +++ b/resources/method.blade.ts @@ -12,14 +12,6 @@ url: @js($uri), } -@if ($request->isNotEmpty()) -{!! when(true, 'export type') !!} {!! $request->get('class') !!} = { -@foreach($request->get('rules') as $rule) - {!! $rule->get('field') !!}: {!! $rule->get('types') !!} -@endforeach -} -@endif - @include('wayfinder::docblock') {!! $method !!}.url = (@include('wayfinder::function-arguments')) => { @if ($parameters->count() === 1) @@ -129,3 +121,11 @@ options {!! $method !!}.form = {!! $method !!}Form @endif + +@if ($request->isNotEmpty()) +{!! when(true, 'export type') !!} {!! $request->get('class') !!} = { +@foreach($request->get('rules') as $rule) + {!! $rule->get('field') !!}: {!! $rule->get('types') !!} +@endforeach +} +@endif \ No newline at end of file From 072b3dca4173547b50fb82a8075a9c81fd4aabd0 Mon Sep 17 00:00:00 2001 From: Donnie Date: Sun, 13 Apr 2025 14:41:07 +0200 Subject: [PATCH 5/6] fix request controller tests --- tests/RequestController.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/RequestController.test.ts b/tests/RequestController.test.ts index ec72824..0800218 100644 --- a/tests/RequestController.test.ts +++ b/tests/RequestController.test.ts @@ -2,8 +2,6 @@ import { describe, expectTypeOf, test } from "vitest"; import type { StorePostRequest, UpdatePostRequest } from '../workbench/resources/js/actions/App/Http/Controllers/RequestController' -// test for request, request param in different order (not first), request rules as strings, request rules as instances - describe("model", () => { test("model structure", () => { const data: StorePostRequest = { @@ -29,8 +27,8 @@ describe("model", () => { catalog_id: 2, code: 'code', slug: 'name', - image: 'data' + image: new File([], 'image') } - expectTypeOf(data).toMatchObjectType + expectTypeOf(data).toMatchObjectType }); }) \ No newline at end of file From 9d0e10e67668f08cfa6904d614ad4deb77e2108c Mon Sep 17 00:00:00 2001 From: Donnie Date: Sun, 13 Apr 2025 14:42:28 +0200 Subject: [PATCH 6/6] fix test descriptions for data request --- tests/RequestController.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/RequestController.test.ts b/tests/RequestController.test.ts index 0800218..62381b3 100644 --- a/tests/RequestController.test.ts +++ b/tests/RequestController.test.ts @@ -2,8 +2,8 @@ import { describe, expectTypeOf, test } from "vitest"; import type { StorePostRequest, UpdatePostRequest } from '../workbench/resources/js/actions/App/Http/Controllers/RequestController' -describe("model", () => { - test("model structure", () => { +describe("request validation", () => { + test("store request data structure", () => { const data: StorePostRequest = { name: 'name', description: 'description', @@ -17,7 +17,7 @@ describe("model", () => { expectTypeOf(data).toMatchObjectType }); - test("model structure", () => { + test("update request data structure", () => { const data: UpdatePostRequest = { name: 'name', description: 'description',