Skip to content

Commit efd0c9e

Browse files
committed
やった!
1 parent 210c786 commit efd0c9e

5 files changed

Lines changed: 189 additions & 77 deletions

File tree

src/Commands/GenerateFormRequestsCommand.php

Lines changed: 141 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -26,88 +26,159 @@ public function __construct(
2626

2727
protected function process(): void
2828
{
29+
// https://laravel.com/docs/12.x/validation#available-validation-rules
30+
$typeMap = [
31+
'string' => 'string',
32+
'integer' => 'number',
33+
'int' => 'number',
34+
'numeric' => 'number',
35+
'float' => 'number',
36+
'double' => 'number',
37+
'decimal' => 'number',
38+
'boolean' => 'boolean',
39+
'bool' => 'boolean',
40+
'accepted' => 'boolean',
41+
'array' => 'any[]',
42+
'object' => 'Record<string, any>',
43+
'date' => 'string',
44+
'email' => 'string',
45+
'file' => 'File',
46+
'image' => 'File',
47+
'json' => 'any',
48+
'url' => 'string',
49+
'uuid' => 'string',
50+
'ip' => 'string',
51+
'active_url' => 'string',
52+
'timezone' => 'string',
53+
'digits' => 'string',
54+
'digits_between' => 'number',
55+
'ends_with' => 'string',
56+
'starts_with' => 'string',
57+
'alpha' => 'string',
58+
'alpha_dash' => 'string',
59+
'alpha_num' => 'string',
60+
'regex' => 'string',
61+
'present' => '',
62+
'distinct' => '',
63+
'nullable' => '',
64+
'required' => '',
65+
'sometimes' => '',
66+
'confirmed' => '',
67+
'between' => '',
68+
'in' => '',
69+
'not_in' => '',
70+
'size' => '',
71+
'min' => '',
72+
'max' => '',
73+
];
74+
2975
$path = $this->option('output');
76+
$tsContent = '';
3077

31-
$formRequests = $this->readFormRequests($this->base());
78+
$formRequests = $this->readFormRequests($this->base());
3279

33-
// Group form requests by namespace
34-
$grouped = collect($formRequests)
80+
$formRequests
3581
->groupBy(function ($formRequest) {
36-
return Str::of($formRequest['class'])
37-
->beforeLast('\\')
38-
->replace('\\', '.')
39-
->toString();
40-
});
82+
return Str::of($formRequest['class'])
83+
->beforeLast('\\')
84+
->replace('\\', '.')
85+
->toString();
86+
})
87+
->each(function ($requests, $namespace) use (&$tsContent, $typeMap) {
88+
$tsContent .= "declare namespace {$namespace} {\n";
89+
foreach ($requests as $request) {
90+
$entity = $request['entity'];
91+
$fields = $request['rules'];
92+
93+
94+
$tsFields = [];
95+
foreach ($fields as $field => $rules) {
96+
$tsType = 'string'; // default
97+
$isNullable = false;
98+
$isRequired = false;
99+
100+
foreach ($rules as $rule) {
101+
$rule = strtolower($rule);
102+
if (isset($typeMap[$rule]) && $typeMap[$rule] !== '') {
103+
$tsType = $typeMap[$rule];
104+
}
105+
if ($rule === 'nullable') {
106+
$isNullable = true;
107+
}
108+
if ($rule === 'required') {
109+
$isRequired = true;
110+
}
111+
}
112+
113+
if ($isNullable) {
114+
$tsType .= ' | null';
115+
}
116+
117+
$optional = $isRequired ? '' : '?';
118+
$tsFields[] = " {$field}{$optional}: {$tsType};";
119+
}
41120

42-
$tsContent = $grouped->map(function ($requests, $namespace) {
43-
$interfaces = collect($requests)->map(function ($formRequest) {
44-
$rulesString = collect($formRequest['rules'])
45-
->map(fn($type, $field) => "{$field}{$type};")
46-
->join("\n");
47-
return <<<TS
48-
/**
49-
* @see {$formRequest['class']}
50-
*/
51-
export interface {$formRequest['entity']} {
52-
{$rulesString}
53-
}
54-
TS;
55-
})->join("\n\n");
56-
57-
return <<<JAVASCRIPT
58-
export namespace {$namespace} {
59-
{$interfaces}
60-
}
61-
JAVASCRIPT;
62-
})->join("\n\n");
121+
$tsContent .= " export type {$entity} = {\n";
122+
$tsContent .= implode("\n", $tsFields);
123+
$tsContent .= "\n };\n";
124+
}
125+
$tsContent .= "}\n";
126+
});
63127

64-
$tsContent .= "\n\nexport {};\n";
65128
$this->files->put($this->tsFilePath($path), $tsContent);
66-
67129
}
68130

69-
protected function parseRules(FormRequest $formRequest) {
70-
$mappings = [
71-
// TS -> PHP
72-
'string' => ['string', 'email'],
73-
'boolean' => ['boolean'],
74-
'number' => ['number', 'integer'],
75-
'any[]' => ['array'],
76-
];
77-
78-
$rules = $formRequest->rules();
79-
80-
// Preparse the rules into a consistent format for easier processing
81-
$r = collect($rules)
82-
->map(function ($rules, $field) use ($mappings, $formRequest) {
83-
$adjustedRules = is_array($rules) ? $rules : explode('|', $rules);
131+
protected function parseRules(FormRequest $formRequest) {
132+
$arrayOfRulesOrString = function ($rules) {
133+
if (is_string($rules)) {
134+
return explode('|', $rules);
135+
}
136+
// If it's an array, flatten and explode any pipe-separated strings
137+
return collect($rules)
138+
->flatten()
139+
->filter(fn ($rule) => is_string($rule))
140+
->flatMap(function ($rule) {
141+
return str_contains($rule, '|') ? explode('|', $rule) : [$rule];
142+
})
143+
->map(fn($rule) => trim($rule))
144+
->values()
145+
->toArray();
146+
};
147+
148+
$fields = collect($formRequest->rules());
149+
150+
// Find all child keys (e.g., field.something) so we can remove them from the top level
151+
$childKeys = collect($fields)
152+
->keys()
153+
->filter(fn($k) => str_contains($k, '.') && $fields->has(Str::before($k, '.')))
154+
->values();
84155

85-
return [
86-
'rules' => $adjustedRules
87-
];
88-
})
89-
// Strip any keys that contain a dot
90-
->reject(fn($v, $k) => Str::contains($k, '.'))
91-
// Strip any rules that are closures or invokable objects
92-
->reject(fn($v) => collect($v['rules'])->contains(fn($rule) => is_object($rule)))
93-
->map(function ($item, $field) use ($mappings) {
94-
$isNullable = in_array('nullable', $item['rules']);
95-
$isSometimes = in_array('sometimes', $item['rules']);
96-
$isArray = in_array('array', $item['rules']);
97-
$type = 'any';
98-
99-
foreach ($mappings as $tsType => $phpTypes) {
100-
if (count(array_intersect($phpTypes, $item['rules'])) > 0) {
101-
$type = $tsType;
102-
break;
156+
return $fields
157+
// Normalise all rules to arrays of strings for each field
158+
->map($arrayOfRulesOrString)
159+
// TODO: Support array/object inference
160+
/*
161+
->map(function ($adjusted, $field) use ($fields, $arrayOfRulesOrString) {
162+
if (in_array('array', $adjusted)) {
163+
// Find all rules for keys like 'field.*'
164+
$children = collect($fields)
165+
->filter(fn ($v, $k) => str_starts_with($k, $field . '.') && preg_match('/^' . preg_quote($field, '/') . '\.\*$/', $k))
166+
->map($arrayOfRulesOrString);
167+
168+
if ($children->isNotEmpty()) {
169+
return [
170+
'rules' => $adjusted,
171+
'children' => $children->toArray(),
172+
];
103173
}
104174
}
105175
106-
$prefix = ($isNullable || $isSometimes) ? '?:' : ':';
107-
return "{$prefix} {$type}";
108-
});
109-
110-
return $r->toArray();
176+
return $adjusted;
177+
})
178+
*/
179+
// Remove child keys from the top level
180+
->reject(fn ($v, $k) => $childKeys->contains($k))
181+
->toArray();
111182
}
112183

113184
protected function readFormRequests(string $path)
@@ -131,7 +202,6 @@ protected function readFormRequests(string $path)
131202
'rules' => $this->parseRules(new $class()),
132203
'class' => $class
133204
];
134-
})
135-
->toArray();
205+
}) ;
136206
}
137207
}

test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { App } from "./resources/js/form-requests";
2-
3-
function useFormMock<T extends Record<string, any>>(fields: T) {
1+
function useFormMock<T extends Record<string, unknown>>(fields: T) {
42
return fields;
53
}
64

7-
const form = useFormMock<App.Http.Requests.MemberRequest>({
5+
const form = useFormMock<App.Http.Requests.TestRequest>({
86
name: "John Doe",
97
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace App\Http\Requests\NestedRequest;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
7+
class BasicNestedRequest extends FormRequest
8+
{
9+
public function rules(): array
10+
{
11+
return [
12+
'parent' => [
13+
'array|required'
14+
],
15+
'parent.child' => 'string|required',
16+
'parent.child2' => 'integer|nullable',
17+
'parent.children' => 'array',
18+
'parent.children.*' => 'string',
19+
'second_parent' => 'array|required',
20+
'second_parent.child' => 'string|required',
21+
'third_parent' => 'integer'
22+
];
23+
}
24+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Http\Requests\NestedRequest\NestedNestedRequest;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
7+
class BasicNestedNestedRequest extends FormRequest
8+
{
9+
public function rules(): array
10+
{
11+
return [
12+
'outer' => 'array|required',
13+
'outer.inner' => 'array|required',
14+
'outer.inner.value' => 'string|required',
15+
'outer.inner.values' => 'array',
16+
'outer.inner.values.*' => 'integer',
17+
];
18+
}
19+
}

workbench/app/Http/Requests/TestRequest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ public function rules(): array
2121
'sometimes_required' => 'sometimes|required|string',
2222
'required_rule' => 'required|string',
2323
'nullable_rule' => 'nullable|string',
24-
'rule_array' => 'array',
25-
'rule_array.*' => 'string',
24+
'rule_array' => 'array',
25+
'rule_array.*' => 'string',
26+
'rule_array.id' => 'integer',
2627
];
2728
}
2829
}

0 commit comments

Comments
 (0)