Skip to content

Commit 11fe18f

Browse files
committed
what if requests
1 parent 90fadfa commit 11fe18f

File tree

7 files changed

+433
-158
lines changed

7 files changed

+433
-158
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @see App\Http\Requests\MemberRequest
3+
*/
4+
export namespace App.Http.Requests {
5+
export interface MemberRequest {
6+
name: any;
7+
member_number: number;
8+
customer_type: any;
9+
sort_sequence?: any;
10+
commission_rate?: any;
11+
invoice_by_post: boolean;
12+
invoice_by_email: boolean;
13+
payment_method?: any;
14+
vat_number?: number;
15+
holding_number?: any;
16+
fabbl_number?: any;
17+
accs_number?: any;
18+
organic_ref?: any;
19+
other_ref?: any;
20+
enterprise?: any;
21+
ownership?: any;
22+
farm_size?: number;
23+
order_notes?: any;
24+
invoice_notes?: any;
25+
invoices_carried_forward: boolean;
26+
member_group_id?: any;
27+
}
28+
}
29+
30+
export {};

src/Commands/BaseCommand.php

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
3+
namespace SynergiTech\ExportTypes\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Filesystem\Filesystem;
7+
8+
abstract class BaseCommand extends Command
9+
{
10+
protected $signature = '';
11+
12+
protected $description = '';
13+
14+
public function __construct(
15+
private Filesystem $files
16+
) {
17+
parent::__construct();
18+
}
19+
20+
protected function preprocess(): void {
21+
$path = $this->option('output');
22+
23+
$this->files->ensureDirectoryExists(dirname($this->option('input')));
24+
25+
if ($this->files->exists($path)) {
26+
$this->files->deleteDirectory($path);
27+
}
28+
29+
$this->files->makeDirectory(
30+
path: $path,
31+
recursive: true,
32+
);
33+
}
34+
35+
protected function runPostProcessingHooks(): void {
36+
if ($this->option('format')) {
37+
$this->runPrettier($this->option('output'));
38+
}
39+
}
40+
41+
abstract protected function process(): void;
42+
43+
public function handle(): void
44+
{
45+
$this->preprocess();
46+
$this->process();
47+
$this->runPostProcessingHooks();
48+
$this->done();
49+
}
50+
51+
protected function done(): void
52+
{
53+
$path = $this->option('output');
54+
$this->info("Wrote types to {$this->tsFilePath($path)}!");
55+
}
56+
57+
protected function tsFilePath(string $path): string
58+
{
59+
return $this->joinPaths($path, 'index.ts');
60+
}
61+
62+
63+
protected function runPrettier(string $path, string $prettierCommand = 'npm exec prettier -- '): void
64+
{
65+
$prettier = $this->option('prettier') ?: $prettierCommand;
66+
exec("{$prettier} {$path} --write");
67+
}
68+
69+
protected function base(): string
70+
{
71+
return $this->joinPaths(base_path(), $this->option('input'));
72+
}
73+
74+
protected function determineRootNamespace(array $classes): string
75+
{
76+
// Loop through the array of enum class names. Find the root namespace that all enums share.
77+
// This is done by finding the longest common prefix of all class names.
78+
// Then, remove that prefix from each class name to get the relative class name.
79+
// Finally, use that relative class name as the key in the output array.
80+
if (count($classes) === 0) {
81+
return '';
82+
}
83+
84+
$commonPrefix = $classes[0];
85+
foreach ($classes as $class) {
86+
$i = 0;
87+
while (isset($commonPrefix[$i], $class[$i]) && $commonPrefix[$i] === $class[$i]) {
88+
$i++;
89+
}
90+
$commonPrefix = substr($commonPrefix, 0, $i);
91+
}
92+
// Ensure prefix ends at a namespace separator
93+
$lastSep = strrpos($commonPrefix, '\\');
94+
if ($lastSep !== false) {
95+
return substr($commonPrefix, 0, $lastSep + 1);
96+
}
97+
return '';
98+
}
99+
100+
protected function fqcnFromPath(string $path): string
101+
{
102+
$namespace = $class = $buffer = '';
103+
104+
$handle = fopen($path, 'r');
105+
106+
while (!feof($handle)) {
107+
$buffer .= fread($handle, 512);
108+
109+
// Suppress warnings for cases where `$buffer` ends in the middle of a PHP comment.
110+
$tokens = @token_get_all($buffer);
111+
112+
// Filter out whitespace and comments from the tokens, as they are irrelevant.
113+
$tokens = array_filter($tokens, fn($token) => $token[0] !== T_WHITESPACE && $token[0] !== T_COMMENT);
114+
115+
// Reset array indexes after filtering.
116+
$tokens = array_values($tokens);
117+
118+
foreach ($tokens as $index => $token) {
119+
// The namespace is a `T_NAME_QUALIFIED` that is immediately preceded by a `T_NAMESPACE`.
120+
if (
121+
$token[0] === T_NAMESPACE && isset($tokens[$index + 1])
122+
&& $tokens[$index + 1][0] === T_NAME_QUALIFIED
123+
) {
124+
$namespace = $tokens[$index + 1][1];
125+
continue;
126+
}
127+
128+
// The class name is a `T_STRING` which makes it unreliable to match against, so check if we have a
129+
// `T_CLASS` token with a `T_STRING` token ahead of it.
130+
if ($token[0] === T_CLASS && isset($tokens[$index + 1]) && $tokens[$index + 1][0] === T_STRING) {
131+
$class = $tokens[$index + 1][1];
132+
}
133+
}
134+
135+
if ($namespace && $class) {
136+
// We've found both the namespace and the class, we can now stop reading and parsing the file.
137+
break;
138+
}
139+
}
140+
141+
fclose($handle);
142+
return $namespace . '\\' . $class;
143+
}
144+
145+
// Laravel < 11 doesn't have Str::chopStart
146+
protected function chopStart($subject, $needle)
147+
{
148+
foreach ((array) $needle as $n) {
149+
if (str_starts_with($subject, $n)) {
150+
return substr($subject, strlen($n));
151+
}
152+
}
153+
154+
return $subject;
155+
}
156+
157+
// Laravel < 10 doesn't have Illuminate\Filesystem\join_paths
158+
protected function joinPaths($basePath, ...$paths): string
159+
{
160+
foreach ($paths as $index => $path) {
161+
if (empty($path) && $path !== '0') {
162+
unset($paths[$index]);
163+
} else {
164+
$paths[$index] = DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
165+
}
166+
}
167+
168+
return $basePath . implode('', $paths);
169+
}
170+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
namespace SynergiTech\ExportTypes\Commands;
4+
5+
use Illuminate\Filesystem\Filesystem;
6+
use Illuminate\Foundation\Http\FormRequest;
7+
use Illuminate\Support\Str;
8+
use RecursiveDirectoryIterator;
9+
use RecursiveIteratorIterator;
10+
11+
class GenerateFormRequestsCommand extends BaseCommand
12+
{
13+
protected $signature = 'export-form-requests:generate
14+
{--input=app/Http/Requests}
15+
{--output=resources/js/form-requests}
16+
{--format}
17+
{--prettier=}';
18+
19+
protected $description = 'Export models that implement an interface to your frontend.';
20+
21+
public function __construct(
22+
protected Filesystem $files
23+
) {
24+
parent::__construct($files);
25+
}
26+
27+
protected function process(): void
28+
{
29+
$path = $this->option('output');
30+
31+
$formRequests = $this->readFormRequests($this->base());
32+
$tsContent = collect($formRequests)
33+
->map(function ($formRequest) {
34+
$rulesString = collect($formRequest['rules'])
35+
->map(fn($type, $field) => "{$field}{$type};")
36+
->join("\n");
37+
// Derive namespace from the class, e.g.,
38+
// App\Http\Requests\MemberRequest => App.Http.Requests
39+
$namespace = Str::of($formRequest['class'])
40+
->beforeLast('\\')
41+
->replace('\\', '.')
42+
->toString();
43+
44+
return <<<JAVASCRIPT
45+
/**
46+
* @see {$formRequest['class']}
47+
*/
48+
export namespace {$namespace} {
49+
export interface {$formRequest['entity']} {
50+
{$rulesString}
51+
}
52+
}
53+
JAVASCRIPT;
54+
})
55+
->join("\n\n");
56+
$tsContent .= "\n\nexport {};\n";
57+
$this->files->put($this->tsFilePath($path), $tsContent);
58+
}
59+
60+
protected function parseRules(FormRequest $formRequest) {
61+
$mappings = [
62+
// TS -> PHP
63+
'string' => ['string', 'email'],
64+
'boolean' => ['boolean'],
65+
'number' => ['number', 'integer'],
66+
'any[]' => ['array'],
67+
];
68+
69+
return collect($formRequest->rules())
70+
->mapWithKeys(function ($rules, $field) use ($mappings) {
71+
$type = 'any';
72+
$divider = in_array('nullable', $rules) ? '?' : '';
73+
74+
// Determine type by iterating through our mappings.
75+
foreach ($mappings as $tsType => $phpTypes) {
76+
foreach ($phpTypes as $phpType) {
77+
if (in_array($phpType, $rules)) {
78+
$type = $tsType;
79+
break 2;
80+
}
81+
}
82+
}
83+
return [$field => "{$divider}: {$type}"];
84+
});
85+
}
86+
87+
protected function readFormRequests(string $path)
88+
{
89+
$classes = collect(iterator_to_array(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path))))
90+
->reject(fn ($i) => $i->isDir() || str_ends_with($i->getRealPath(), '/..'))
91+
->map(fn ($item) => $this->fqcnFromPath($item->getRealPath()))
92+
->filter( fn($class) => is_subclass_of($class, FormRequest::class))
93+
->values();
94+
95+
$rootNamespace = $this->determineRootNamespace($classes->toArray());
96+
97+
return $classes
98+
->map(function ($class) use ($rootNamespace) {
99+
$classKey = Str::of($this->chopStart($class, $rootNamespace))
100+
->replace('\\', '')
101+
->toString();
102+
103+
return [
104+
'entity' => $classKey,
105+
'rules' => $this->parseRules(new $class()),
106+
'class' => $class
107+
];
108+
})
109+
->toArray();
110+
}
111+
}

0 commit comments

Comments
 (0)