@omnifyjp/omnify 3.5.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/ts-dist/php/controller-generator.js +20 -8
- package/ts-dist/php/enum-generator.d.ts +19 -0
- package/ts-dist/php/enum-generator.js +165 -0
- package/ts-dist/php/index.js +9 -0
- package/ts-dist/php/model-generator.js +27 -5
- package/ts-dist/php/openapi-generator.d.ts +31 -0
- package/ts-dist/php/openapi-generator.js +566 -0
- package/ts-dist/php/types.d.ts +37 -0
- package/ts-dist/php/types.js +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnifyjp/omnify",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "Schema-driven code generation for Laravel, TypeScript, and SQL",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"zod": "^3.24.0"
|
|
37
37
|
},
|
|
38
38
|
"optionalDependencies": {
|
|
39
|
-
"@omnifyjp/omnify-darwin-arm64": "3.
|
|
40
|
-
"@omnifyjp/omnify-darwin-x64": "3.
|
|
41
|
-
"@omnifyjp/omnify-linux-x64": "3.
|
|
42
|
-
"@omnifyjp/omnify-linux-arm64": "3.
|
|
43
|
-
"@omnifyjp/omnify-win32-x64": "3.
|
|
39
|
+
"@omnifyjp/omnify-darwin-arm64": "3.7.0",
|
|
40
|
+
"@omnifyjp/omnify-darwin-x64": "3.7.0",
|
|
41
|
+
"@omnifyjp/omnify-linux-x64": "3.7.0",
|
|
42
|
+
"@omnifyjp/omnify-linux-arm64": "3.7.0",
|
|
43
|
+
"@omnifyjp/omnify-win32-x64": "3.7.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { toPascalCase } from './naming-helper.js';
|
|
5
5
|
import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace } from './types.js';
|
|
6
|
+
import { buildControllerMethodAttributes, openApiControllerImports } from './openapi-generator.js';
|
|
6
7
|
const DEFAULT_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
|
|
7
8
|
/** Generate controller classes for all schemas with api config. */
|
|
8
9
|
export function generateControllers(reader, config) {
|
|
@@ -50,10 +51,21 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
50
51
|
if (actions.includes('index') || lookup) {
|
|
51
52
|
imports.push(`use Illuminate\\Http\\Resources\\Json\\AnonymousResourceCollection;`);
|
|
52
53
|
}
|
|
54
|
+
// Issue #35: opt-in `use OpenApi\Attributes as OA;` so the inline `#[OA\...]`
|
|
55
|
+
// attributes resolve when openapi codegen is enabled.
|
|
56
|
+
imports.push(...openApiControllerImports(config));
|
|
57
|
+
// Issue #35: build the per-method `#[OA\...]` attribute block once and
|
|
58
|
+
// splice it in front of each method body. The helper returns an empty
|
|
59
|
+
// array when openapi codegen is disabled, so the rendered template stays
|
|
60
|
+
// byte-identical for projects that don't opt in.
|
|
61
|
+
const attrFor = (m) => {
|
|
62
|
+
const lines = buildControllerMethodAttributes(config, schema, modelName, name, m);
|
|
63
|
+
return lines.length === 0 ? '' : '\n ' + lines.join('\n ');
|
|
64
|
+
};
|
|
53
65
|
// Build methods
|
|
54
66
|
const methods = [];
|
|
55
67
|
if (actions.includes('index')) {
|
|
56
|
-
methods.push(
|
|
68
|
+
methods.push(`${attrFor('index')}
|
|
57
69
|
public function index(Request $request): AnonymousResourceCollection
|
|
58
70
|
{
|
|
59
71
|
$result = $this->service->list($request->all());
|
|
@@ -62,7 +74,7 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
62
74
|
}`);
|
|
63
75
|
}
|
|
64
76
|
if (actions.includes('store')) {
|
|
65
|
-
methods.push(
|
|
77
|
+
methods.push(`${attrFor('store')}
|
|
66
78
|
public function store(${modelName}StoreRequest $request): JsonResponse
|
|
67
79
|
{
|
|
68
80
|
$model = $this->service->create($request->validated());
|
|
@@ -73,14 +85,14 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
73
85
|
}`);
|
|
74
86
|
}
|
|
75
87
|
if (actions.includes('show')) {
|
|
76
|
-
methods.push(
|
|
88
|
+
methods.push(`${attrFor('show')}
|
|
77
89
|
public function show(${modelName} $model): ${modelName}Resource
|
|
78
90
|
{
|
|
79
91
|
return new ${modelName}Resource($model);
|
|
80
92
|
}`);
|
|
81
93
|
}
|
|
82
94
|
if (actions.includes('update')) {
|
|
83
|
-
methods.push(
|
|
95
|
+
methods.push(`${attrFor('update')}
|
|
84
96
|
public function update(${modelName}UpdateRequest $request, ${modelName} $model): ${modelName}Resource
|
|
85
97
|
{
|
|
86
98
|
$model = $this->service->update($model, $request->validated());
|
|
@@ -89,7 +101,7 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
89
101
|
}`);
|
|
90
102
|
}
|
|
91
103
|
if (actions.includes('destroy')) {
|
|
92
|
-
methods.push(
|
|
104
|
+
methods.push(`${attrFor('destroy')}
|
|
93
105
|
public function destroy(${modelName} $model): JsonResponse
|
|
94
106
|
{
|
|
95
107
|
$this->service->delete($model);
|
|
@@ -98,7 +110,7 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
98
110
|
}`);
|
|
99
111
|
}
|
|
100
112
|
if (lookup) {
|
|
101
|
-
methods.push(
|
|
113
|
+
methods.push(`${attrFor('lookup')}
|
|
102
114
|
public function lookup(Request $request): AnonymousResourceCollection
|
|
103
115
|
{
|
|
104
116
|
$result = $this->service->lookup($request->all());
|
|
@@ -107,7 +119,7 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
107
119
|
}`);
|
|
108
120
|
}
|
|
109
121
|
if (bulkDelete) {
|
|
110
|
-
methods.push(
|
|
122
|
+
methods.push(`${attrFor('bulkDelete')}
|
|
111
123
|
public function bulkDelete(Request $request): JsonResponse
|
|
112
124
|
{
|
|
113
125
|
$this->service->bulkDelete($request->input('ids', []));
|
|
@@ -116,7 +128,7 @@ function generateBaseController(name, schema, reader, config) {
|
|
|
116
128
|
}`);
|
|
117
129
|
}
|
|
118
130
|
if (restore) {
|
|
119
|
-
methods.push(
|
|
131
|
+
methods.push(`${attrFor('restore')}
|
|
120
132
|
public function restore(string $id): ${modelName}Resource
|
|
121
133
|
{
|
|
122
134
|
$model = $this->service->restore($id);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates PHP enum classes for `kind: enum` schemas.
|
|
3
|
+
*
|
|
4
|
+
* Issue #34: every enum schema becomes a self-contained PHP 8.1+ backed enum
|
|
5
|
+
* placed in the global Omnify enums namespace (default `App\Omnify\Enums`).
|
|
6
|
+
* Localized labels from the schema are embedded as a `LABELS` constant so
|
|
7
|
+
* the generated enum is usable without setting up Laravel translation files.
|
|
8
|
+
*
|
|
9
|
+
* Class name convention: `{SchemaName}Enum.php` (e.g. schema `MenuStatus` →
|
|
10
|
+
* `MenuStatusEnum`). The trailing `Enum` suffix is intentional — it prevents
|
|
11
|
+
* collisions with model classes of the same base name and signals "this is an
|
|
12
|
+
* enum class" at every callsite (`MenuStatusEnum::Draft`).
|
|
13
|
+
*/
|
|
14
|
+
import { SchemaReader } from './schema-reader.js';
|
|
15
|
+
import type { GeneratedFile, PhpConfig } from './types.js';
|
|
16
|
+
/** Generate one PHP enum class for each `kind: enum` schema. */
|
|
17
|
+
export declare function generateEnums(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
|
|
18
|
+
/** Convert a schema name (e.g. `MenuStatus`) to its enum class name (`MenuStatusEnum`). */
|
|
19
|
+
export declare function enumClassName(schemaName: string): string;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates PHP enum classes for `kind: enum` schemas.
|
|
3
|
+
*
|
|
4
|
+
* Issue #34: every enum schema becomes a self-contained PHP 8.1+ backed enum
|
|
5
|
+
* placed in the global Omnify enums namespace (default `App\Omnify\Enums`).
|
|
6
|
+
* Localized labels from the schema are embedded as a `LABELS` constant so
|
|
7
|
+
* the generated enum is usable without setting up Laravel translation files.
|
|
8
|
+
*
|
|
9
|
+
* Class name convention: `{SchemaName}Enum.php` (e.g. schema `MenuStatus` →
|
|
10
|
+
* `MenuStatusEnum`). The trailing `Enum` suffix is intentional — it prevents
|
|
11
|
+
* collisions with model classes of the same base name and signals "this is an
|
|
12
|
+
* enum class" at every callsite (`MenuStatusEnum::Draft`).
|
|
13
|
+
*/
|
|
14
|
+
import { toPascalCase } from './naming-helper.js';
|
|
15
|
+
import { baseFile, resolveGlobalEnumPath, resolveGlobalEnumNamespace } from './types.js';
|
|
16
|
+
/** Generate one PHP enum class for each `kind: enum` schema. */
|
|
17
|
+
export function generateEnums(reader, config) {
|
|
18
|
+
const files = [];
|
|
19
|
+
for (const [name, schema] of Object.entries(reader.getEnumSchemas())) {
|
|
20
|
+
// Skip package-owned enums — those are generated by their owning package's
|
|
21
|
+
// codegen, not the host project's. Mirrors how object schemas are filtered
|
|
22
|
+
// by `getProjectObjectSchemas()` elsewhere.
|
|
23
|
+
if (schema.package != null)
|
|
24
|
+
continue;
|
|
25
|
+
files.push(generateEnumClass(name, schema, config));
|
|
26
|
+
}
|
|
27
|
+
return files;
|
|
28
|
+
}
|
|
29
|
+
function generateEnumClass(name, schema, config) {
|
|
30
|
+
const className = enumClassName(name);
|
|
31
|
+
const namespace = resolveGlobalEnumNamespace(config, config.models.namespace);
|
|
32
|
+
const values = (schema.values ?? []);
|
|
33
|
+
const cases = values.map(v => buildCase(v));
|
|
34
|
+
const labelsConst = buildLabelsConstant(values);
|
|
35
|
+
const content = `<?php
|
|
36
|
+
|
|
37
|
+
namespace ${namespace};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ${name}${schema.displayName ? ` — ${pickDefaultLabel(schema.displayName)}` : ''}
|
|
41
|
+
*
|
|
42
|
+
* DO NOT EDIT - This file is auto-generated by Omnify.
|
|
43
|
+
* Any changes will be overwritten on next generation.
|
|
44
|
+
*
|
|
45
|
+
* @generated by omnify
|
|
46
|
+
*/
|
|
47
|
+
enum ${className}: string
|
|
48
|
+
{
|
|
49
|
+
${cases.map(c => ` ${c}`).join('\n')}
|
|
50
|
+
|
|
51
|
+
${labelsConst}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the localized label for this enum case.
|
|
55
|
+
*
|
|
56
|
+
* Falls back to the configured app fallback locale, then any defined
|
|
57
|
+
* locale, then the raw value if no labels were declared in the schema.
|
|
58
|
+
*/
|
|
59
|
+
public function label(?string $locale = null): string
|
|
60
|
+
{
|
|
61
|
+
$locale = $locale ?? app()->getLocale();
|
|
62
|
+
$labels = static::LABELS[$this->value] ?? [];
|
|
63
|
+
|
|
64
|
+
return $labels[$locale]
|
|
65
|
+
?? $labels[config('app.fallback_locale', 'en')]
|
|
66
|
+
?? $labels[array_key_first($labels) ?? 'en']
|
|
67
|
+
?? $this->value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get all enum values as a flat list.
|
|
72
|
+
*
|
|
73
|
+
* @return array<int, string>
|
|
74
|
+
*/
|
|
75
|
+
public static function values(): array
|
|
76
|
+
{
|
|
77
|
+
return array_column(self::cases(), 'value');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get an array of value → localized label suitable for select dropdowns.
|
|
82
|
+
*
|
|
83
|
+
* @return array<string, string>
|
|
84
|
+
*/
|
|
85
|
+
public static function options(?string $locale = null): array
|
|
86
|
+
{
|
|
87
|
+
$out = [];
|
|
88
|
+
foreach (self::cases() as $case) {
|
|
89
|
+
$out[$case->value] = $case->label($locale);
|
|
90
|
+
}
|
|
91
|
+
return $out;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
return baseFile(resolveGlobalEnumPath(config, `${className}.php`, config.models.path), content);
|
|
96
|
+
}
|
|
97
|
+
/** Convert a schema name (e.g. `MenuStatus`) to its enum class name (`MenuStatusEnum`). */
|
|
98
|
+
export function enumClassName(schemaName) {
|
|
99
|
+
const pascal = toPascalCase(schemaName);
|
|
100
|
+
return pascal.endsWith('Enum') ? pascal : `${pascal}Enum`;
|
|
101
|
+
}
|
|
102
|
+
/** Build a single `case Foo = 'foo';` line from a schema enum value. */
|
|
103
|
+
function buildCase(v) {
|
|
104
|
+
const value = v.value;
|
|
105
|
+
return `case ${toPhpCaseName(value)} = '${escapePhpString(value)}';`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Convert an enum value into a valid PHP case identifier. PHP case names must
|
|
109
|
+
* start with a letter or underscore and contain only `[A-Za-z0-9_]`. We map
|
|
110
|
+
* everything else through PascalCase and prefix a digit-leading name with
|
|
111
|
+
* `Case` so it's still legal.
|
|
112
|
+
*/
|
|
113
|
+
function toPhpCaseName(value) {
|
|
114
|
+
const cleaned = value
|
|
115
|
+
.replace(/[^A-Za-z0-9_]+/g, '_')
|
|
116
|
+
.split('_')
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
119
|
+
.join('');
|
|
120
|
+
if (!cleaned)
|
|
121
|
+
return 'Unknown';
|
|
122
|
+
return /^[0-9]/.test(cleaned) ? `Case${cleaned}` : cleaned;
|
|
123
|
+
}
|
|
124
|
+
/** Build the `LABELS` constant containing every locale label for every value. */
|
|
125
|
+
function buildLabelsConstant(values) {
|
|
126
|
+
const lines = [];
|
|
127
|
+
lines.push(' /**');
|
|
128
|
+
lines.push(' * Localized labels for each case, embedded directly so the generated enum');
|
|
129
|
+
lines.push(' * is self-contained and works without Laravel translation files.');
|
|
130
|
+
lines.push(' *');
|
|
131
|
+
lines.push(' * @var array<string, array<string, string>>');
|
|
132
|
+
lines.push(' */');
|
|
133
|
+
lines.push(' public const LABELS = [');
|
|
134
|
+
for (const v of values) {
|
|
135
|
+
const rawLabel = v.label;
|
|
136
|
+
// `label` can be either a plain string or a `{locale: text}` map. Normalize.
|
|
137
|
+
const labels = typeof rawLabel === 'string'
|
|
138
|
+
? { en: rawLabel }
|
|
139
|
+
: (rawLabel ?? {});
|
|
140
|
+
const entries = Object.entries(labels);
|
|
141
|
+
if (entries.length === 0) {
|
|
142
|
+
lines.push(` '${escapePhpString(v.value)}' => [],`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const inner = entries
|
|
146
|
+
.map(([loc, txt]) => `'${escapePhpString(loc)}' => '${escapePhpString(String(txt))}'`)
|
|
147
|
+
.join(', ');
|
|
148
|
+
lines.push(` '${escapePhpString(v.value)}' => [${inner}],`);
|
|
149
|
+
}
|
|
150
|
+
lines.push(' ];');
|
|
151
|
+
return lines.join('\n');
|
|
152
|
+
}
|
|
153
|
+
/** Pick a sensible default label from a localized string for the doc comment. */
|
|
154
|
+
function pickDefaultLabel(displayName) {
|
|
155
|
+
if (typeof displayName === 'string')
|
|
156
|
+
return displayName;
|
|
157
|
+
return (displayName.en ??
|
|
158
|
+
displayName.ja ??
|
|
159
|
+
Object.values(displayName)[0] ??
|
|
160
|
+
'');
|
|
161
|
+
}
|
|
162
|
+
/** Escape single quotes and backslashes for inclusion in a PHP single-quoted string. */
|
|
163
|
+
function escapePhpString(value) {
|
|
164
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
165
|
+
}
|
package/ts-dist/php/index.js
CHANGED
|
@@ -30,6 +30,8 @@ import { baseFile } from './types.js';
|
|
|
30
30
|
import { generateControllers } from './controller-generator.js';
|
|
31
31
|
import { generateServices } from './service-generator.js';
|
|
32
32
|
import { generateRoutes } from './route-generator.js';
|
|
33
|
+
import { generateEnums } from './enum-generator.js';
|
|
34
|
+
import { generateOpenApi } from './openapi-generator.js';
|
|
33
35
|
export { derivePhpConfig } from './types.js';
|
|
34
36
|
/** Generate all PHP files from schemas.json data. */
|
|
35
37
|
export function generatePhp(data, overrides) {
|
|
@@ -60,7 +62,14 @@ export function generatePhp(data, overrides) {
|
|
|
60
62
|
files.push(...generateControllers(reader, config));
|
|
61
63
|
files.push(...generateServices(reader, config));
|
|
62
64
|
files.push(...generateRoutes(reader, config));
|
|
65
|
+
// Issue #35: opt-in OpenAPI / Swagger annotation scaffold.
|
|
66
|
+
// Controller-level method attributes are emitted inline by
|
|
67
|
+
// `generateControllers` above when `config.openapi.enable` is true; here
|
|
68
|
+
// we add the standalone Common / Info / Schemas files.
|
|
69
|
+
files.push(...generateOpenApi(reader, config));
|
|
63
70
|
}
|
|
71
|
+
// Issue #34: every `kind: enum` schema becomes a global PHP enum class.
|
|
72
|
+
files.push(...generateEnums(reader, config));
|
|
64
73
|
// Per-schema files
|
|
65
74
|
files.push(...generateLocales(reader, config));
|
|
66
75
|
files.push(...generateModels(reader, config));
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
|
|
5
5
|
import { toCast, toPhpDocType, isHiddenByDefault } from './type-mapper.js';
|
|
6
6
|
import { buildRelation } from './relation-builder.js';
|
|
7
|
-
import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, } from './types.js';
|
|
7
|
+
import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace, resolveSharedBaseNamespace, resolveGlobalTraitNamespace, resolveGlobalEnumNamespace, } from './types.js';
|
|
8
|
+
import { enumClassName } from './enum-generator.js';
|
|
8
9
|
/** Generate base model and user model for all project-owned object schemas,
|
|
9
10
|
* plus user models for package schemas (extending the package model). */
|
|
10
11
|
export function generateModels(reader, config) {
|
|
@@ -71,7 +72,7 @@ function generateBaseModel(name, schema, reader, config) {
|
|
|
71
72
|
const fillable = buildFillable(properties, expandedProperties, propertyOrder);
|
|
72
73
|
const hidden = buildHidden(properties, expandedProperties, propertyOrder);
|
|
73
74
|
const appends = buildAppends(expandedProperties);
|
|
74
|
-
const casts = buildCasts(properties, expandedProperties, propertyOrder, reader);
|
|
75
|
+
const casts = buildCasts(properties, expandedProperties, propertyOrder, reader, config);
|
|
75
76
|
const relations = buildRelations(name, properties, propertyOrder, modelNamespace, reader);
|
|
76
77
|
const accessors = buildAccessors(expandedProperties);
|
|
77
78
|
const fileAccessors = hasFiles ? buildFileAccessors(properties, propertyOrder, modelNamespace) : '';
|
|
@@ -253,9 +254,14 @@ function buildImports(baseNamespace, modelName, hasSoftDelete, isAuthenticatable
|
|
|
253
254
|
lines.push(`use ${traitsNamespace || baseNamespace + '\\Traits'}\\HasLocalizedDisplayName;`);
|
|
254
255
|
lines.push(`use ${localesNamespace || baseNamespace + '\\Locales'}\\${modelName}Locales;`);
|
|
255
256
|
if (hasFiles) {
|
|
256
|
-
// HasFiles
|
|
257
|
+
// Issue #33 followup: HasFiles is a global Omnify trait — see
|
|
258
|
+
// file-trait-generator.ts. The trait now lives at
|
|
259
|
+
// `app/Omnify/Traits/HasFiles.php` (namespace `App\Omnify\Traits`),
|
|
260
|
+
// not under per-module `Modules/File/Traits/`. The import statement
|
|
261
|
+
// here must match, otherwise generated models crash with
|
|
262
|
+
// `Trait "App\Omnify\Modules\File\Traits\HasFiles" not found`.
|
|
257
263
|
const fileTraitsNs = config
|
|
258
|
-
?
|
|
264
|
+
? resolveGlobalTraitNamespace(config, baseNamespace + '\\Traits')
|
|
259
265
|
: (traitsNamespace || baseNamespace + '\\Traits');
|
|
260
266
|
lines.push(`use ${fileTraitsNs}\\HasFiles;`);
|
|
261
267
|
if (modelNamespace) {
|
|
@@ -423,13 +429,29 @@ function buildAppends(expandedProperties) {
|
|
|
423
429
|
return '';
|
|
424
430
|
return appends.map(a => ` '${a}',`).join('\n') + '\n';
|
|
425
431
|
}
|
|
426
|
-
function buildCasts(properties, expandedProperties, propertyOrder, reader) {
|
|
432
|
+
function buildCasts(properties, expandedProperties, propertyOrder, reader, config) {
|
|
427
433
|
const casts = [];
|
|
434
|
+
// Issue #34: cast `EnumRef` properties to their generated PHP enum class.
|
|
435
|
+
// Use a fully-qualified `\Namespace\FooEnum::class` reference so we don't
|
|
436
|
+
// need to track imports — Laravel resolves the enum cast by class name.
|
|
437
|
+
const globalEnumNs = config
|
|
438
|
+
? resolveGlobalEnumNamespace(config, config.models.namespace)
|
|
439
|
+
: '';
|
|
428
440
|
for (const propName of propertyOrder) {
|
|
429
441
|
const prop = properties[propName];
|
|
430
442
|
if (!prop)
|
|
431
443
|
continue;
|
|
432
444
|
const type = prop['type'] ?? 'String';
|
|
445
|
+
// EnumRef → cast to generated enum class (only when the referenced enum
|
|
446
|
+
// schema actually exists; otherwise fall through to a plain string).
|
|
447
|
+
if (type === 'EnumRef') {
|
|
448
|
+
const enumName = prop['enum'];
|
|
449
|
+
if (typeof enumName === 'string' && reader.getSchema(enumName)?.kind === 'enum' && globalEnumNs) {
|
|
450
|
+
const snakeName = toSnakeCase(propName);
|
|
451
|
+
casts.push(`'${snakeName}' => \\${globalEnumNs}\\${enumClassName(enumName)}::class,`);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
433
455
|
if (type === 'Association') {
|
|
434
456
|
const relation = prop['relation'] ?? '';
|
|
435
457
|
if (relation === 'ManyToOne') {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates OpenAPI / Swagger annotation files from schemas (issue #35).
|
|
3
|
+
*
|
|
4
|
+
* Off by default. Opt in via `codegen.laravel.openapi.enable: true`. Requires
|
|
5
|
+
* `darkaonline/l5-swagger` (or any zircote/swagger-php consumer) installed in
|
|
6
|
+
* the host Laravel project.
|
|
7
|
+
*
|
|
8
|
+
* Output (under `app/Omnify/OpenApi/` by default):
|
|
9
|
+
* - `Common.php` — `OmnifyFile`, `PaginationMeta`, `ValidationError`
|
|
10
|
+
* - `OmnifyApiInfo.php` — `#[OA\Info]`, security scheme, one `#[OA\Tag]` per schema
|
|
11
|
+
* - `Schemas/{Name}Schema.php` — one component per schema with `options.api`
|
|
12
|
+
*
|
|
13
|
+
* Controller method attributes are emitted by `controller-generator.ts` —
|
|
14
|
+
* not here. We only own the standalone OpenAPI scaffold files.
|
|
15
|
+
*/
|
|
16
|
+
import { SchemaReader } from './schema-reader.js';
|
|
17
|
+
import type { GeneratedFile, PhpConfig } from './types.js';
|
|
18
|
+
import type { SchemaDefinition } from '../types.js';
|
|
19
|
+
/** Public entry. Generates the OpenAPI scaffold files. No-op when disabled. */
|
|
20
|
+
export declare function generateOpenApi(reader: SchemaReader, config: PhpConfig): GeneratedFile[];
|
|
21
|
+
/**
|
|
22
|
+
* Build the `#[OA\Get(...)]` / `#[OA\Post(...)]` attributes for one CRUD
|
|
23
|
+
* method on a generated controller. Returned as an array of indented attribute
|
|
24
|
+
* lines ready to splice into the controller template.
|
|
25
|
+
*
|
|
26
|
+
* Returns an empty array when openapi codegen is disabled, so callers can
|
|
27
|
+
* unconditionally interpolate `${attrs.join('\n')}\n` without branching.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildControllerMethodAttributes(config: PhpConfig, schema: SchemaDefinition, modelName: string, schemaName: string, method: 'index' | 'store' | 'show' | 'update' | 'destroy' | 'lookup' | 'bulkDelete' | 'restore'): string[];
|
|
30
|
+
/** Returns the imports needed by controller files when openapi is enabled. */
|
|
31
|
+
export declare function openApiControllerImports(config: PhpConfig): string[];
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates OpenAPI / Swagger annotation files from schemas (issue #35).
|
|
3
|
+
*
|
|
4
|
+
* Off by default. Opt in via `codegen.laravel.openapi.enable: true`. Requires
|
|
5
|
+
* `darkaonline/l5-swagger` (or any zircote/swagger-php consumer) installed in
|
|
6
|
+
* the host Laravel project.
|
|
7
|
+
*
|
|
8
|
+
* Output (under `app/Omnify/OpenApi/` by default):
|
|
9
|
+
* - `Common.php` — `OmnifyFile`, `PaginationMeta`, `ValidationError`
|
|
10
|
+
* - `OmnifyApiInfo.php` — `#[OA\Info]`, security scheme, one `#[OA\Tag]` per schema
|
|
11
|
+
* - `Schemas/{Name}Schema.php` — one component per schema with `options.api`
|
|
12
|
+
*
|
|
13
|
+
* Controller method attributes are emitted by `controller-generator.ts` —
|
|
14
|
+
* not here. We only own the standalone OpenAPI scaffold files.
|
|
15
|
+
*/
|
|
16
|
+
import { toPascalCase, toSnakeCase, pluralize } from './naming-helper.js';
|
|
17
|
+
import { baseFile } from './types.js';
|
|
18
|
+
/** Public entry. Generates the OpenAPI scaffold files. No-op when disabled. */
|
|
19
|
+
export function generateOpenApi(reader, config) {
|
|
20
|
+
if (!config.openapi.enable)
|
|
21
|
+
return [];
|
|
22
|
+
if (!reader.hasApiSchemas())
|
|
23
|
+
return [];
|
|
24
|
+
const files = [];
|
|
25
|
+
files.push(generateCommonFile(config));
|
|
26
|
+
files.push(generateInfoFile(reader, config));
|
|
27
|
+
for (const [name, schema] of Object.entries(reader.getSchemasWithApi())) {
|
|
28
|
+
files.push(generateSchemaComponent(name, schema, reader, config));
|
|
29
|
+
}
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Common.php — reusable component schemas referenced from everywhere else
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function generateCommonFile(config) {
|
|
36
|
+
const ns = config.openapi.namespace;
|
|
37
|
+
const content = `<?php
|
|
38
|
+
|
|
39
|
+
namespace ${ns};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reusable OpenAPI component schemas.
|
|
43
|
+
*
|
|
44
|
+
* DO NOT EDIT - This file is auto-generated by Omnify.
|
|
45
|
+
* Any changes will be overwritten on next generation.
|
|
46
|
+
*
|
|
47
|
+
* @generated by omnify
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
use OpenApi\\Attributes as OA;
|
|
51
|
+
|
|
52
|
+
#[OA\\Schema(
|
|
53
|
+
schema: 'OmnifyFile',
|
|
54
|
+
type: 'object',
|
|
55
|
+
description: 'A file attached via Omnify\\'s polymorphic file system.',
|
|
56
|
+
properties: [
|
|
57
|
+
new OA\\Property(property: 'id', type: 'string'),
|
|
58
|
+
new OA\\Property(property: 'collection', type: 'string', nullable: true),
|
|
59
|
+
new OA\\Property(property: 'disk', type: 'string'),
|
|
60
|
+
new OA\\Property(property: 'path', type: 'string'),
|
|
61
|
+
new OA\\Property(property: 'original_name', type: 'string'),
|
|
62
|
+
new OA\\Property(property: 'mime_type', type: 'string'),
|
|
63
|
+
new OA\\Property(property: 'size', type: 'integer', format: 'int64'),
|
|
64
|
+
new OA\\Property(property: 'status', type: 'string', enum: ['temporary', 'permanent']),
|
|
65
|
+
new OA\\Property(property: 'url', type: 'string', nullable: true),
|
|
66
|
+
new OA\\Property(property: 'expires_at', type: 'string', format: 'date-time', nullable: true),
|
|
67
|
+
new OA\\Property(property: 'sort_order', type: 'integer'),
|
|
68
|
+
new OA\\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
|
69
|
+
new OA\\Property(property: 'updated_at', type: 'string', format: 'date-time'),
|
|
70
|
+
],
|
|
71
|
+
)]
|
|
72
|
+
#[OA\\Schema(
|
|
73
|
+
schema: 'PaginationMeta',
|
|
74
|
+
type: 'object',
|
|
75
|
+
description: 'Standard Laravel paginator meta block.',
|
|
76
|
+
properties: [
|
|
77
|
+
new OA\\Property(property: 'current_page', type: 'integer'),
|
|
78
|
+
new OA\\Property(property: 'from', type: 'integer', nullable: true),
|
|
79
|
+
new OA\\Property(property: 'last_page', type: 'integer'),
|
|
80
|
+
new OA\\Property(property: 'per_page', type: 'integer'),
|
|
81
|
+
new OA\\Property(property: 'to', type: 'integer', nullable: true),
|
|
82
|
+
new OA\\Property(property: 'total', type: 'integer'),
|
|
83
|
+
new OA\\Property(property: 'path', type: 'string'),
|
|
84
|
+
],
|
|
85
|
+
)]
|
|
86
|
+
#[OA\\Schema(
|
|
87
|
+
schema: 'PaginationLinks',
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: [
|
|
90
|
+
new OA\\Property(property: 'first', type: 'string', nullable: true),
|
|
91
|
+
new OA\\Property(property: 'last', type: 'string', nullable: true),
|
|
92
|
+
new OA\\Property(property: 'prev', type: 'string', nullable: true),
|
|
93
|
+
new OA\\Property(property: 'next', type: 'string', nullable: true),
|
|
94
|
+
],
|
|
95
|
+
)]
|
|
96
|
+
#[OA\\Schema(
|
|
97
|
+
schema: 'ValidationError',
|
|
98
|
+
type: 'object',
|
|
99
|
+
description: 'Standard Laravel 422 validation error envelope.',
|
|
100
|
+
properties: [
|
|
101
|
+
new OA\\Property(property: 'message', type: 'string'),
|
|
102
|
+
new OA\\Property(
|
|
103
|
+
property: 'errors',
|
|
104
|
+
type: 'object',
|
|
105
|
+
additionalProperties: new OA\\AdditionalProperties(
|
|
106
|
+
type: 'array',
|
|
107
|
+
items: new OA\\Items(type: 'string'),
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
)]
|
|
112
|
+
#[OA\\Schema(
|
|
113
|
+
schema: 'NotFoundError',
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: [
|
|
116
|
+
new OA\\Property(property: 'message', type: 'string'),
|
|
117
|
+
],
|
|
118
|
+
)]
|
|
119
|
+
final class Common
|
|
120
|
+
{
|
|
121
|
+
// Marker class — all schemas are declared via attributes above.
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
return baseFile(`${config.openapi.path}/Common.php`, content);
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// OmnifyApiInfo.php — #[OA\Info] + #[OA\Tag] for every API schema
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
function generateInfoFile(reader, config) {
|
|
130
|
+
const ns = config.openapi.namespace;
|
|
131
|
+
const tagsPrefix = config.openapi.tagsPrefix;
|
|
132
|
+
const securityScheme = config.openapi.securityScheme;
|
|
133
|
+
const tags = Object.entries(reader.getSchemasWithApi())
|
|
134
|
+
.map(([name, schema]) => {
|
|
135
|
+
const tag = `${tagsPrefix}${pluralize(toPascalCase(name))}`;
|
|
136
|
+
const description = pickLabel(schema.displayName) ?? `${toPascalCase(name)} CRUD endpoints`;
|
|
137
|
+
return `#[OA\\Tag(name: '${escapePhp(tag)}', description: '${escapePhp(description)}')]`;
|
|
138
|
+
})
|
|
139
|
+
.join('\n');
|
|
140
|
+
const content = `<?php
|
|
141
|
+
|
|
142
|
+
namespace ${ns};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* OpenAPI document root: #[OA\\Info], security schemes and one #[OA\\Tag] per
|
|
146
|
+
* schema with \`options.api\`.
|
|
147
|
+
*
|
|
148
|
+
* DO NOT EDIT - This file is auto-generated by Omnify.
|
|
149
|
+
* Any changes will be overwritten on next generation.
|
|
150
|
+
*
|
|
151
|
+
* @generated by omnify
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
use OpenApi\\Attributes as OA;
|
|
155
|
+
|
|
156
|
+
#[OA\\Info(
|
|
157
|
+
version: '1.0.0',
|
|
158
|
+
title: 'API',
|
|
159
|
+
description: 'Auto-generated by Omnify from schemas.',
|
|
160
|
+
)]
|
|
161
|
+
#[OA\\Server(url: '${escapePhp(config.openapi.pathsPrefix.replace(/\/$/, ''))}', description: 'Default API server')]
|
|
162
|
+
#[OA\\SecurityScheme(
|
|
163
|
+
securityScheme: '${escapePhp(securityScheme)}',
|
|
164
|
+
type: 'http',
|
|
165
|
+
scheme: 'bearer',
|
|
166
|
+
bearerFormat: 'sanctum',
|
|
167
|
+
)]
|
|
168
|
+
${tags}
|
|
169
|
+
final class OmnifyApiInfo
|
|
170
|
+
{
|
|
171
|
+
// Marker class — all metadata lives in attributes above.
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
174
|
+
return baseFile(`${config.openapi.path}/OmnifyApiInfo.php`, content);
|
|
175
|
+
}
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Schemas/{Name}Schema.php — one component per API schema
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
function generateSchemaComponent(name, schema, reader, config) {
|
|
180
|
+
const className = `${toPascalCase(name)}Schema`;
|
|
181
|
+
const ns = `${config.openapi.namespace}\\Schemas`;
|
|
182
|
+
const componentName = toPascalCase(name);
|
|
183
|
+
const description = pickLabel(schema.displayName) ?? componentName;
|
|
184
|
+
const propertyLines = buildSchemaProperties(schema, reader);
|
|
185
|
+
const content = `<?php
|
|
186
|
+
|
|
187
|
+
namespace ${ns};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* OpenAPI component schema for ${componentName}.
|
|
191
|
+
*
|
|
192
|
+
* DO NOT EDIT - This file is auto-generated by Omnify.
|
|
193
|
+
* Any changes will be overwritten on next generation.
|
|
194
|
+
*
|
|
195
|
+
* @generated by omnify
|
|
196
|
+
*/
|
|
197
|
+
|
|
198
|
+
use OpenApi\\Attributes as OA;
|
|
199
|
+
|
|
200
|
+
#[OA\\Schema(
|
|
201
|
+
schema: '${escapePhp(componentName)}',
|
|
202
|
+
type: 'object',
|
|
203
|
+
description: '${escapePhp(description)}',
|
|
204
|
+
properties: [
|
|
205
|
+
${propertyLines.map(l => ` ${l},`).join('\n')}
|
|
206
|
+
],
|
|
207
|
+
)]
|
|
208
|
+
final class ${className}
|
|
209
|
+
{
|
|
210
|
+
// Marker class — schema declared via attributes above.
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
213
|
+
return baseFile(`${config.openapi.path}/Schemas/${className}.php`, content);
|
|
214
|
+
}
|
|
215
|
+
function buildSchemaProperties(schema, reader) {
|
|
216
|
+
const lines = [];
|
|
217
|
+
// ID column (only when auto-ID is on)
|
|
218
|
+
const idType = schema.options?.id;
|
|
219
|
+
if (idType !== false) {
|
|
220
|
+
if (idType === 'Uuid' || idType === 'Ulid' || idType === 'String') {
|
|
221
|
+
lines.push("new OA\\Property(property: 'id', type: 'string')");
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
lines.push("new OA\\Property(property: 'id', type: 'integer', format: 'int64')");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const propertyOrder = schema.propertyOrder ?? Object.keys(schema.properties ?? {});
|
|
228
|
+
const properties = schema.properties ?? {};
|
|
229
|
+
for (const propName of propertyOrder) {
|
|
230
|
+
const prop = properties[propName];
|
|
231
|
+
if (!prop)
|
|
232
|
+
continue;
|
|
233
|
+
// Skip explicitly hidden properties (`hidden: true` in YAML — field is
|
|
234
|
+
// not declared on `PropertyDefinition`, so do an unknown-cast lookup).
|
|
235
|
+
if (prop.hidden === true)
|
|
236
|
+
continue;
|
|
237
|
+
const lineSet = propertyToOpenApi(propName, prop, reader);
|
|
238
|
+
lines.push(...lineSet);
|
|
239
|
+
}
|
|
240
|
+
// Audit columns
|
|
241
|
+
const audit = schema.options?.audit;
|
|
242
|
+
if (audit?.createdBy)
|
|
243
|
+
lines.push("new OA\\Property(property: 'created_by_id', type: 'integer', format: 'int64', nullable: true)");
|
|
244
|
+
if (audit?.updatedBy)
|
|
245
|
+
lines.push("new OA\\Property(property: 'updated_by_id', type: 'integer', format: 'int64', nullable: true)");
|
|
246
|
+
if (audit?.deletedBy)
|
|
247
|
+
lines.push("new OA\\Property(property: 'deleted_by_id', type: 'integer', format: 'int64', nullable: true)");
|
|
248
|
+
// Timestamps
|
|
249
|
+
if (schema.options?.timestamps !== false) {
|
|
250
|
+
lines.push("new OA\\Property(property: 'created_at', type: 'string', format: 'date-time')");
|
|
251
|
+
lines.push("new OA\\Property(property: 'updated_at', type: 'string', format: 'date-time')");
|
|
252
|
+
}
|
|
253
|
+
if (schema.options?.softDelete) {
|
|
254
|
+
lines.push("new OA\\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true)");
|
|
255
|
+
}
|
|
256
|
+
return lines;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Convert one schema property into one or more `new OA\Property(...)` lines.
|
|
260
|
+
* Returns multiple lines for relations (FK column + relation), zero for
|
|
261
|
+
* unsupported types.
|
|
262
|
+
*/
|
|
263
|
+
function propertyToOpenApi(propName, prop, reader) {
|
|
264
|
+
const colName = toSnakeCase(propName);
|
|
265
|
+
const nullable = prop.nullable === true;
|
|
266
|
+
const type = prop.type;
|
|
267
|
+
// File: array of OmnifyFile or single OmnifyFile ref.
|
|
268
|
+
if (type === 'File') {
|
|
269
|
+
if (prop.multiple) {
|
|
270
|
+
return [
|
|
271
|
+
`new OA\\Property(property: '${escapePhp(colName)}', type: 'array', items: new OA\\Items(ref: '#/components/schemas/OmnifyFile'))`,
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
return [
|
|
275
|
+
`new OA\\Property(property: '${escapePhp(colName)}', ref: '#/components/schemas/OmnifyFile', nullable: ${nullable ? 'true' : 'false'})`,
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
// Association: emit only the FK side here. Eager-loaded relations are out
|
|
279
|
+
// of scope for the auto-generated component (resource generator decides
|
|
280
|
+
// whether to expand them).
|
|
281
|
+
if (type === 'Association') {
|
|
282
|
+
const relation = prop.relation ?? '';
|
|
283
|
+
if (relation === 'ManyToOne' || relation === 'OneToOne') {
|
|
284
|
+
const target = prop.target ?? '';
|
|
285
|
+
const targetSchema = target ? reader.getSchema(target) : undefined;
|
|
286
|
+
const targetIdType = targetSchema?.options?.id;
|
|
287
|
+
const isStringId = targetIdType === 'Uuid' || targetIdType === 'Ulid' || targetIdType === 'String';
|
|
288
|
+
const fkType = isStringId ? "type: 'string'" : "type: 'integer', format: 'int64'";
|
|
289
|
+
return [
|
|
290
|
+
`new OA\\Property(property: '${escapePhp(colName)}_id', ${fkType}, nullable: ${nullable ? 'true' : 'false'})`,
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
// Many-to-many / one-to-many: no scalar FK column on this side.
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
// EnumRef: resolve the referenced enum schema and embed the values.
|
|
297
|
+
if (type === 'EnumRef' && typeof prop.enum === 'string') {
|
|
298
|
+
const enumSchema = reader.getSchema(prop.enum);
|
|
299
|
+
if (enumSchema?.kind === 'enum') {
|
|
300
|
+
const values = (enumSchema.values ?? [])
|
|
301
|
+
.map(v => `'${escapePhp(v.value)}'`)
|
|
302
|
+
.join(', ');
|
|
303
|
+
return [
|
|
304
|
+
`new OA\\Property(property: '${escapePhp(colName)}', type: 'string', enum: [${values}], nullable: ${nullable ? 'true' : 'false'})`,
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Inline Enum: literal values list.
|
|
309
|
+
if (type === 'Enum' && Array.isArray(prop.enum)) {
|
|
310
|
+
const values = prop.enum.map(v => `'${escapePhp(String(v))}'`).join(', ');
|
|
311
|
+
return [
|
|
312
|
+
`new OA\\Property(property: '${escapePhp(colName)}', type: 'string', enum: [${values}], nullable: ${nullable ? 'true' : 'false'})`,
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
const mapping = toOpenApiType(type);
|
|
316
|
+
if (!mapping)
|
|
317
|
+
return [];
|
|
318
|
+
const parts = [`property: '${escapePhp(colName)}'`, `type: '${mapping.type}'`];
|
|
319
|
+
if (mapping.format)
|
|
320
|
+
parts.push(`format: '${mapping.format}'`);
|
|
321
|
+
// String length: prefer explicit max/maxLength, fall back to `length`.
|
|
322
|
+
const maxLength = prop.maxLength ?? prop.length;
|
|
323
|
+
if (mapping.type === 'string' && typeof maxLength === 'number') {
|
|
324
|
+
parts.push(`maxLength: ${maxLength}`);
|
|
325
|
+
}
|
|
326
|
+
if (mapping.type === 'string' && typeof prop.minLength === 'number') {
|
|
327
|
+
parts.push(`minLength: ${prop.minLength}`);
|
|
328
|
+
}
|
|
329
|
+
if ((mapping.type === 'integer' || mapping.type === 'number') && typeof prop.min === 'number') {
|
|
330
|
+
parts.push(`minimum: ${prop.min}`);
|
|
331
|
+
}
|
|
332
|
+
if ((mapping.type === 'integer' || mapping.type === 'number') && typeof prop.max === 'number') {
|
|
333
|
+
parts.push(`maximum: ${prop.max}`);
|
|
334
|
+
}
|
|
335
|
+
parts.push(`nullable: ${nullable ? 'true' : 'false'}`);
|
|
336
|
+
return [`new OA\\Property(${parts.join(', ')})`];
|
|
337
|
+
}
|
|
338
|
+
/** Map an Omnify property type to its OpenAPI counterpart. */
|
|
339
|
+
function toOpenApiType(type) {
|
|
340
|
+
switch (type) {
|
|
341
|
+
case 'String':
|
|
342
|
+
case 'Slug':
|
|
343
|
+
case 'Phone':
|
|
344
|
+
case 'Text':
|
|
345
|
+
case 'MediumText':
|
|
346
|
+
case 'LongText':
|
|
347
|
+
return { type: 'string' };
|
|
348
|
+
case 'Email':
|
|
349
|
+
return { type: 'string', format: 'email' };
|
|
350
|
+
case 'Url':
|
|
351
|
+
return { type: 'string', format: 'uri' };
|
|
352
|
+
case 'Password':
|
|
353
|
+
return { type: 'string', format: 'password' };
|
|
354
|
+
case 'Uuid':
|
|
355
|
+
return { type: 'string', format: 'uuid' };
|
|
356
|
+
case 'Int':
|
|
357
|
+
case 'BigInt':
|
|
358
|
+
case 'TinyInt':
|
|
359
|
+
return { type: 'integer', format: 'int64' };
|
|
360
|
+
case 'Float':
|
|
361
|
+
case 'Decimal':
|
|
362
|
+
return { type: 'number' };
|
|
363
|
+
case 'Boolean':
|
|
364
|
+
return { type: 'boolean' };
|
|
365
|
+
case 'Date':
|
|
366
|
+
return { type: 'string', format: 'date' };
|
|
367
|
+
case 'DateTime':
|
|
368
|
+
case 'Timestamp':
|
|
369
|
+
return { type: 'string', format: 'date-time' };
|
|
370
|
+
case 'Time':
|
|
371
|
+
return { type: 'string' };
|
|
372
|
+
case 'Json':
|
|
373
|
+
case 'Point':
|
|
374
|
+
case 'Coordinates':
|
|
375
|
+
return { type: 'object' };
|
|
376
|
+
default:
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Build the `#[OA\Get(...)]` / `#[OA\Post(...)]` attributes for one CRUD
|
|
382
|
+
* method on a generated controller. Returned as an array of indented attribute
|
|
383
|
+
* lines ready to splice into the controller template.
|
|
384
|
+
*
|
|
385
|
+
* Returns an empty array when openapi codegen is disabled, so callers can
|
|
386
|
+
* unconditionally interpolate `${attrs.join('\n')}\n` without branching.
|
|
387
|
+
*/
|
|
388
|
+
export function buildControllerMethodAttributes(config, schema, modelName, schemaName, method) {
|
|
389
|
+
if (!config.openapi.enable)
|
|
390
|
+
return [];
|
|
391
|
+
const tag = `${config.openapi.tagsPrefix}${pluralize(toPascalCase(schemaName))}`;
|
|
392
|
+
const prefix = (schema.options?.api?.prefix ?? pluralize(toSnakeCase(schemaName))).replace(/^\/+|\/+$/g, '');
|
|
393
|
+
const basePath = `${config.openapi.pathsPrefix.replace(/\/$/, '')}/${prefix}`;
|
|
394
|
+
const security = `[['${escapePhp(config.openapi.securityScheme)}' => []]]`;
|
|
395
|
+
const componentRef = `'#/components/schemas/${escapePhp(toPascalCase(schemaName))}'`;
|
|
396
|
+
const collectionResponse = (status) => ` new OA\\Response(
|
|
397
|
+
response: ${status},
|
|
398
|
+
description: 'OK',
|
|
399
|
+
content: new OA\\JsonContent(
|
|
400
|
+
properties: [
|
|
401
|
+
new OA\\Property(property: 'data', type: 'array', items: new OA\\Items(ref: ${componentRef})),
|
|
402
|
+
new OA\\Property(property: 'meta', ref: '#/components/schemas/PaginationMeta'),
|
|
403
|
+
new OA\\Property(property: 'links', ref: '#/components/schemas/PaginationLinks'),
|
|
404
|
+
],
|
|
405
|
+
),
|
|
406
|
+
),`;
|
|
407
|
+
const singleResponse = (status, description = 'OK') => ` new OA\\Response(
|
|
408
|
+
response: ${status},
|
|
409
|
+
description: '${escapePhp(description)}',
|
|
410
|
+
content: new OA\\JsonContent(
|
|
411
|
+
properties: [
|
|
412
|
+
new OA\\Property(property: 'data', ref: ${componentRef}),
|
|
413
|
+
],
|
|
414
|
+
),
|
|
415
|
+
),`;
|
|
416
|
+
const errorResponses = ` new OA\\Response(response: 401, description: 'Unauthenticated'),
|
|
417
|
+
new OA\\Response(response: 403, description: 'Forbidden'),
|
|
418
|
+
new OA\\Response(response: 404, description: 'Not found', content: new OA\\JsonContent(ref: '#/components/schemas/NotFoundError')),`;
|
|
419
|
+
const validationResponse = ` new OA\\Response(response: 422, description: 'Validation error', content: new OA\\JsonContent(ref: '#/components/schemas/ValidationError')),`;
|
|
420
|
+
switch (method) {
|
|
421
|
+
case 'index': {
|
|
422
|
+
return wrapAttribute(`#[OA\\Get(
|
|
423
|
+
path: '${escapePhp(basePath)}',
|
|
424
|
+
summary: 'List ${escapePhp(modelName)}',
|
|
425
|
+
tags: ['${escapePhp(tag)}'],
|
|
426
|
+
security: ${security},
|
|
427
|
+
parameters: [
|
|
428
|
+
new OA\\Parameter(name: 'search', in: 'query', required: false, schema: new OA\\Schema(type: 'string')),
|
|
429
|
+
new OA\\Parameter(name: 'sort', in: 'query', required: false, schema: new OA\\Schema(type: 'string')),
|
|
430
|
+
new OA\\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\\Schema(type: 'integer', default: ${schema.options?.api?.perPage ?? 15})),
|
|
431
|
+
new OA\\Parameter(name: 'page', in: 'query', required: false, schema: new OA\\Schema(type: 'integer')),
|
|
432
|
+
],
|
|
433
|
+
responses: [
|
|
434
|
+
${collectionResponse(200)}
|
|
435
|
+
${errorResponses}
|
|
436
|
+
],
|
|
437
|
+
)]`);
|
|
438
|
+
}
|
|
439
|
+
case 'store':
|
|
440
|
+
return wrapAttribute(`#[OA\\Post(
|
|
441
|
+
path: '${escapePhp(basePath)}',
|
|
442
|
+
summary: 'Create ${escapePhp(modelName)}',
|
|
443
|
+
tags: ['${escapePhp(tag)}'],
|
|
444
|
+
security: ${security},
|
|
445
|
+
requestBody: new OA\\RequestBody(required: true, content: new OA\\JsonContent(ref: ${componentRef})),
|
|
446
|
+
responses: [
|
|
447
|
+
${singleResponse(201, 'Created')}
|
|
448
|
+
${errorResponses}
|
|
449
|
+
${validationResponse}
|
|
450
|
+
],
|
|
451
|
+
)]`);
|
|
452
|
+
case 'show':
|
|
453
|
+
return wrapAttribute(`#[OA\\Get(
|
|
454
|
+
path: '${escapePhp(basePath)}/{id}',
|
|
455
|
+
summary: 'Show ${escapePhp(modelName)}',
|
|
456
|
+
tags: ['${escapePhp(tag)}'],
|
|
457
|
+
security: ${security},
|
|
458
|
+
parameters: [
|
|
459
|
+
new OA\\Parameter(name: 'id', in: 'path', required: true, schema: new OA\\Schema(type: 'string')),
|
|
460
|
+
],
|
|
461
|
+
responses: [
|
|
462
|
+
${singleResponse(200)}
|
|
463
|
+
${errorResponses}
|
|
464
|
+
],
|
|
465
|
+
)]`);
|
|
466
|
+
case 'update':
|
|
467
|
+
return wrapAttribute(`#[OA\\Put(
|
|
468
|
+
path: '${escapePhp(basePath)}/{id}',
|
|
469
|
+
summary: 'Update ${escapePhp(modelName)}',
|
|
470
|
+
tags: ['${escapePhp(tag)}'],
|
|
471
|
+
security: ${security},
|
|
472
|
+
parameters: [
|
|
473
|
+
new OA\\Parameter(name: 'id', in: 'path', required: true, schema: new OA\\Schema(type: 'string')),
|
|
474
|
+
],
|
|
475
|
+
requestBody: new OA\\RequestBody(required: true, content: new OA\\JsonContent(ref: ${componentRef})),
|
|
476
|
+
responses: [
|
|
477
|
+
${singleResponse(200)}
|
|
478
|
+
${errorResponses}
|
|
479
|
+
${validationResponse}
|
|
480
|
+
],
|
|
481
|
+
)]`);
|
|
482
|
+
case 'destroy':
|
|
483
|
+
return wrapAttribute(`#[OA\\Delete(
|
|
484
|
+
path: '${escapePhp(basePath)}/{id}',
|
|
485
|
+
summary: 'Delete ${escapePhp(modelName)}',
|
|
486
|
+
tags: ['${escapePhp(tag)}'],
|
|
487
|
+
security: ${security},
|
|
488
|
+
parameters: [
|
|
489
|
+
new OA\\Parameter(name: 'id', in: 'path', required: true, schema: new OA\\Schema(type: 'string')),
|
|
490
|
+
],
|
|
491
|
+
responses: [
|
|
492
|
+
new OA\\Response(response: 204, description: 'No content'),
|
|
493
|
+
${errorResponses}
|
|
494
|
+
],
|
|
495
|
+
)]`);
|
|
496
|
+
case 'lookup':
|
|
497
|
+
return wrapAttribute(`#[OA\\Get(
|
|
498
|
+
path: '${escapePhp(basePath)}/lookup',
|
|
499
|
+
summary: 'Lookup ${escapePhp(modelName)} (lightweight collection for selects)',
|
|
500
|
+
tags: ['${escapePhp(tag)}'],
|
|
501
|
+
security: ${security},
|
|
502
|
+
parameters: [
|
|
503
|
+
new OA\\Parameter(name: 'search', in: 'query', required: false, schema: new OA\\Schema(type: 'string')),
|
|
504
|
+
],
|
|
505
|
+
responses: [
|
|
506
|
+
${collectionResponse(200)}
|
|
507
|
+
${errorResponses}
|
|
508
|
+
],
|
|
509
|
+
)]`);
|
|
510
|
+
case 'bulkDelete':
|
|
511
|
+
return wrapAttribute(`#[OA\\Post(
|
|
512
|
+
path: '${escapePhp(basePath)}/bulk-delete',
|
|
513
|
+
summary: 'Bulk delete ${escapePhp(modelName)}',
|
|
514
|
+
tags: ['${escapePhp(tag)}'],
|
|
515
|
+
security: ${security},
|
|
516
|
+
requestBody: new OA\\RequestBody(required: true, content: new OA\\JsonContent(
|
|
517
|
+
properties: [
|
|
518
|
+
new OA\\Property(property: 'ids', type: 'array', items: new OA\\Items(type: 'string')),
|
|
519
|
+
],
|
|
520
|
+
)),
|
|
521
|
+
responses: [
|
|
522
|
+
new OA\\Response(response: 204, description: 'No content'),
|
|
523
|
+
${errorResponses}
|
|
524
|
+
${validationResponse}
|
|
525
|
+
],
|
|
526
|
+
)]`);
|
|
527
|
+
case 'restore':
|
|
528
|
+
return wrapAttribute(`#[OA\\Post(
|
|
529
|
+
path: '${escapePhp(basePath)}/{id}/restore',
|
|
530
|
+
summary: 'Restore ${escapePhp(modelName)}',
|
|
531
|
+
tags: ['${escapePhp(tag)}'],
|
|
532
|
+
security: ${security},
|
|
533
|
+
parameters: [
|
|
534
|
+
new OA\\Parameter(name: 'id', in: 'path', required: true, schema: new OA\\Schema(type: 'string')),
|
|
535
|
+
],
|
|
536
|
+
responses: [
|
|
537
|
+
${singleResponse(200, 'Restored')}
|
|
538
|
+
${errorResponses}
|
|
539
|
+
],
|
|
540
|
+
)]`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** Returns the imports needed by controller files when openapi is enabled. */
|
|
544
|
+
export function openApiControllerImports(config) {
|
|
545
|
+
if (!config.openapi.enable)
|
|
546
|
+
return [];
|
|
547
|
+
return ['use OpenApi\\Attributes as OA;'];
|
|
548
|
+
}
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// helpers
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
/** Wrap a multi-line attribute string into the array format the caller expects. */
|
|
553
|
+
function wrapAttribute(attr) {
|
|
554
|
+
return attr.split('\n');
|
|
555
|
+
}
|
|
556
|
+
function escapePhp(value) {
|
|
557
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
558
|
+
}
|
|
559
|
+
/** Pick a sensible default label from a localized string for descriptions. */
|
|
560
|
+
function pickLabel(displayName) {
|
|
561
|
+
if (!displayName)
|
|
562
|
+
return null;
|
|
563
|
+
if (typeof displayName === 'string')
|
|
564
|
+
return displayName;
|
|
565
|
+
return displayName.en ?? displayName.ja ?? Object.values(displayName)[0] ?? null;
|
|
566
|
+
}
|
package/ts-dist/php/types.d.ts
CHANGED
|
@@ -61,6 +61,32 @@ export interface LaravelPathOverride {
|
|
|
61
61
|
export interface NestedSetOverride {
|
|
62
62
|
namespace?: string;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* OpenAPI / Swagger annotation generation override (issue #35).
|
|
66
|
+
* Opt-in: only emits files / attributes when `enable: true`.
|
|
67
|
+
*
|
|
68
|
+
* Generates `OpenApi/Common.php`, `OpenApi/OmnifyApiInfo.php`, and one
|
|
69
|
+
* `OpenApi/Schemas/{Name}Schema.php` per schema with `options.api`. When
|
|
70
|
+
* enabled, also injects `#[OA\Get/Post/...]` attributes onto the auto-generated
|
|
71
|
+
* controller CRUD methods so docs stay in sync with the actual handlers.
|
|
72
|
+
*
|
|
73
|
+
* Requires `darkaonline/l5-swagger` (or any zircote/swagger-php consumer)
|
|
74
|
+
* installed in the target Laravel project.
|
|
75
|
+
*/
|
|
76
|
+
export interface OpenApiOverride {
|
|
77
|
+
/** Toggle OpenAPI codegen on/off. Off by default — opt-in feature. */
|
|
78
|
+
enable?: boolean;
|
|
79
|
+
/** PHP namespace for the generated `OpenApi/...` classes. Default `App\Omnify\OpenApi`. */
|
|
80
|
+
namespace?: string;
|
|
81
|
+
/** Filesystem path for the generated `OpenApi/...` files. Default `app/Omnify/OpenApi`. */
|
|
82
|
+
path?: string;
|
|
83
|
+
/** URL prefix prepended to every generated path attribute. Default `/api/v1`. */
|
|
84
|
+
pathsPrefix?: string;
|
|
85
|
+
/** Optional prefix for tag names (e.g. `Brand` → `BrandProducts`). */
|
|
86
|
+
tagsPrefix?: string;
|
|
87
|
+
/** Name of the OpenAPI security scheme to attach to every endpoint. Default `sanctum`. */
|
|
88
|
+
securityScheme?: string;
|
|
89
|
+
}
|
|
64
90
|
/** Overrides from codegen.laravel YAML config (all optional). */
|
|
65
91
|
export interface LaravelCodegenOverrides {
|
|
66
92
|
/**
|
|
@@ -102,6 +128,8 @@ export interface LaravelCodegenOverrides {
|
|
|
102
128
|
route?: LaravelPathOverride;
|
|
103
129
|
config?: LaravelPathOverride;
|
|
104
130
|
nestedset?: NestedSetOverride;
|
|
131
|
+
/** OpenAPI / Swagger codegen — opt-in. See `OpenApiOverride`. */
|
|
132
|
+
openapi?: OpenApiOverride;
|
|
105
133
|
}
|
|
106
134
|
/** PHP codegen configuration (resolved with defaults). */
|
|
107
135
|
export interface PhpConfig {
|
|
@@ -192,6 +220,15 @@ export interface PhpConfig {
|
|
|
192
220
|
nestedset: {
|
|
193
221
|
namespace: string;
|
|
194
222
|
};
|
|
223
|
+
/** OpenAPI / Swagger codegen config (issue #35). */
|
|
224
|
+
openapi: {
|
|
225
|
+
enable: boolean;
|
|
226
|
+
namespace: string;
|
|
227
|
+
path: string;
|
|
228
|
+
pathsPrefix: string;
|
|
229
|
+
tagsPrefix: string;
|
|
230
|
+
securityScheme: string;
|
|
231
|
+
};
|
|
195
232
|
}
|
|
196
233
|
/**
|
|
197
234
|
* Derive full PHP config from optional overrides.
|
package/ts-dist/php/types.js
CHANGED
|
@@ -155,6 +155,7 @@ const DEFAULT_MODULES_PATH = 'app/Omnify/Modules';
|
|
|
155
155
|
const DEFAULT_SHARED_PATH = 'app/Omnify/Shared';
|
|
156
156
|
const DEFAULT_GLOBAL_ENUMS_PATH = 'app/Omnify/Enums';
|
|
157
157
|
const DEFAULT_GLOBAL_TRAITS_PATH = 'app/Omnify/Traits';
|
|
158
|
+
const DEFAULT_OPENAPI_PATH = 'app/Omnify/OpenApi';
|
|
158
159
|
/**
|
|
159
160
|
* Apply a rootPath prefix to a path. Absolute paths and paths that already
|
|
160
161
|
* begin with `${rootPath}/` are returned as-is, so users can mix and match
|
|
@@ -203,6 +204,13 @@ export function derivePhpConfig(overrides) {
|
|
|
203
204
|
const routePath = withRoot(rootPath, overrides?.route?.path ?? DEFAULT_ROUTE_PATH);
|
|
204
205
|
const configFilePath = withRoot(rootPath, overrides?.config?.path ?? DEFAULT_CONFIG_FILE_PATH);
|
|
205
206
|
const nestedsetNs = overrides?.nestedset?.namespace ?? 'Aimeos\\Nestedset';
|
|
207
|
+
// OpenAPI codegen (issue #35) — re-uses the same path/namespace derivation
|
|
208
|
+
// helper as every other layer so a single `namespace` override stays in sync
|
|
209
|
+
// with the file path (and vice-versa).
|
|
210
|
+
const openapiOverride = overrides?.openapi;
|
|
211
|
+
const { path: openapiPath, namespace: openapiNs } = resolvePathAndNamespace(rootPath, openapiOverride && (openapiOverride.path !== undefined || openapiOverride.namespace !== undefined)
|
|
212
|
+
? { path: openapiOverride.path, namespace: openapiOverride.namespace }
|
|
213
|
+
: undefined, DEFAULT_OPENAPI_PATH, nsFromPath);
|
|
206
214
|
const structure = overrides?.structure ?? 'legacy';
|
|
207
215
|
return {
|
|
208
216
|
rootPath,
|
|
@@ -265,5 +273,13 @@ export function derivePhpConfig(overrides) {
|
|
|
265
273
|
nestedset: {
|
|
266
274
|
namespace: nestedsetNs,
|
|
267
275
|
},
|
|
276
|
+
openapi: {
|
|
277
|
+
enable: openapiOverride?.enable ?? false,
|
|
278
|
+
namespace: openapiNs,
|
|
279
|
+
path: openapiPath,
|
|
280
|
+
pathsPrefix: openapiOverride?.pathsPrefix ?? '/api/v1',
|
|
281
|
+
tagsPrefix: openapiOverride?.tagsPrefix ?? '',
|
|
282
|
+
securityScheme: openapiOverride?.securityScheme ?? 'sanctum',
|
|
283
|
+
},
|
|
268
284
|
};
|
|
269
285
|
}
|