@omnifyjp/omnify 3.6.0 → 3.7.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnifyjp/omnify",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
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.6.0",
40
- "@omnifyjp/omnify-darwin-x64": "3.6.0",
41
- "@omnifyjp/omnify-linux-x64": "3.6.0",
42
- "@omnifyjp/omnify-linux-arm64": "3.6.0",
43
- "@omnifyjp/omnify-win32-x64": "3.6.0"
39
+ "@omnifyjp/omnify-darwin-arm64": "3.7.1",
40
+ "@omnifyjp/omnify-darwin-x64": "3.7.1",
41
+ "@omnifyjp/omnify-linux-x64": "3.7.1",
42
+ "@omnifyjp/omnify-linux-arm64": "3.7.1",
43
+ "@omnifyjp/omnify-win32-x64": "3.7.1"
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);
@@ -31,6 +31,7 @@ import { generateControllers } from './controller-generator.js';
31
31
  import { generateServices } from './service-generator.js';
32
32
  import { generateRoutes } from './route-generator.js';
33
33
  import { generateEnums } from './enum-generator.js';
34
+ import { generateOpenApi } from './openapi-generator.js';
34
35
  export { derivePhpConfig } from './types.js';
35
36
  /** Generate all PHP files from schemas.json data. */
36
37
  export function generatePhp(data, overrides) {
@@ -61,6 +62,11 @@ export function generatePhp(data, overrides) {
61
62
  files.push(...generateControllers(reader, config));
62
63
  files.push(...generateServices(reader, config));
63
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));
64
70
  }
65
71
  // Issue #34: every `kind: enum` schema becomes a global PHP enum class.
66
72
  files.push(...generateEnums(reader, config));
@@ -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
+ }
@@ -4,6 +4,24 @@
4
4
  import { toPascalCase, toSnakeCase, toCamelCase } from './naming-helper.js';
5
5
  import { toStoreRules, toUpdateRules, formatRules, hasRuleObject } from './type-mapper.js';
6
6
  import { baseFile, userFile, resolveModularBasePath, resolveModularBaseNamespace } from './types.js';
7
+ /**
8
+ * Resolve the Laravel validation rule type ('integer', 'uuid', or 'string') for
9
+ * a foreign key column based on the target schema's primary key type.
10
+ *
11
+ * Mirrors the FK cast logic in `model-generator.ts` so validation rules and
12
+ * Eloquent casts agree about the wire format. Issue #36.
13
+ */
14
+ function resolveFkRuleType(target, reader) {
15
+ if (!target)
16
+ return 'integer';
17
+ const targetSchema = reader.getSchema(target);
18
+ const id = targetSchema?.options?.id;
19
+ if (id === 'Uuid')
20
+ return 'uuid';
21
+ if (id === 'Ulid' || id === 'String')
22
+ return 'string';
23
+ return 'integer';
24
+ }
7
25
  /** Generate Store/Update request classes for all project-owned visible object schemas. */
8
26
  export function generateRequests(reader, config) {
9
27
  const files = [];
@@ -78,9 +96,11 @@ function generateBaseRequest(name, schema, reader, config, action) {
78
96
  const relation = prop['relation'] ?? '';
79
97
  if (relation === 'ManyToOne') {
80
98
  const snakeName = toSnakeCase(propName) + '_id';
99
+ const target = prop['target'] ?? '';
100
+ const targetIdType = resolveFkRuleType(target, reader);
81
101
  const rules = isUpdate
82
- ? toUpdateRules(prop, tableName, modelRouteParam)
83
- : toStoreRules(prop, tableName);
102
+ ? toUpdateRules(prop, tableName, modelRouteParam, targetIdType)
103
+ : toStoreRules(prop, tableName, targetIdType);
84
104
  rulesLines.push(` '${snakeName}' => ${formatRules(rules)},`);
85
105
  attributeKeys.push(snakeName);
86
106
  }
@@ -6,10 +6,17 @@ type Rule = string | ['rule_in', string[]] | ['rule_unique_ignore', string, stri
6
6
  export declare function toCast(type: string, property?: Record<string, unknown>): string | null;
7
7
  /** Map Omnify property type to PHP doc type. */
8
8
  export declare function toPhpDocType(type: string, nullable?: boolean): string;
9
- /** Generate validation rules for a property (Store request). */
10
- export declare function toStoreRules(property: Record<string, unknown>, tableName: string): Rule[];
9
+ /**
10
+ * Generate validation rules for a property (Store request).
11
+ *
12
+ * @param fkRuleType - For ManyToOne associations, the validation rule type
13
+ * matching the target schema's primary key ('integer' for Int/BigInt PKs,
14
+ * 'uuid' for Uuid PKs, 'string' for Ulid/String PKs). Defaults to 'integer'
15
+ * for backwards compatibility. Issue #36.
16
+ */
17
+ export declare function toStoreRules(property: Record<string, unknown>, tableName: string, fkRuleType?: 'integer' | 'uuid' | 'string'): Rule[];
11
18
  /** Generate validation rules for a property (Update request). */
12
- export declare function toUpdateRules(property: Record<string, unknown>, tableName: string, modelRouteParam: string): Rule[];
19
+ export declare function toUpdateRules(property: Record<string, unknown>, tableName: string, modelRouteParam: string, fkRuleType?: 'integer' | 'uuid' | 'string'): Rule[];
13
20
  /** Format validation rules as PHP code string. */
14
21
  export declare function formatRules(rules: Rule[]): string;
15
22
  /** Resolve enum values from property definition. */
@@ -70,8 +70,15 @@ const STRING_TYPES = new Set([
70
70
  ]);
71
71
  const NUMERIC_TYPES = new Set(['Int', 'BigInt', 'TinyInt', 'Float', 'Decimal']);
72
72
  const ARRAY_TYPES = new Set(['Json']);
73
- /** Generate validation rules for a property (Store request). */
74
- export function toStoreRules(property, tableName) {
73
+ /**
74
+ * Generate validation rules for a property (Store request).
75
+ *
76
+ * @param fkRuleType - For ManyToOne associations, the validation rule type
77
+ * matching the target schema's primary key ('integer' for Int/BigInt PKs,
78
+ * 'uuid' for Uuid PKs, 'string' for Ulid/String PKs). Defaults to 'integer'
79
+ * for backwards compatibility. Issue #36.
80
+ */
81
+ export function toStoreRules(property, tableName, fkRuleType = 'integer') {
75
82
  const type = property['type'] ?? 'String';
76
83
  // File type defaults to nullable (uploads are optional, attached separately)
77
84
  const nullable = property['nullable'] ?? (type === 'File' ? true : false);
@@ -143,7 +150,7 @@ export function toStoreRules(property, tableName) {
143
150
  case 'Association': {
144
151
  const relation = property['relation'] ?? '';
145
152
  if (relation === 'ManyToOne') {
146
- rules.push('integer');
153
+ rules.push(fkRuleType);
147
154
  const target = property['target'] ?? '';
148
155
  if (target)
149
156
  rules.push(`exists:${toTableName(target)},id`);
@@ -267,8 +274,8 @@ function mergeValidationRules(rules, vr, type) {
267
274
  }
268
275
  }
269
276
  /** Generate validation rules for a property (Update request). */
270
- export function toUpdateRules(property, tableName, modelRouteParam) {
271
- let rules = toStoreRules(property, tableName);
277
+ export function toUpdateRules(property, tableName, modelRouteParam, fkRuleType = 'integer') {
278
+ let rules = toStoreRules(property, tableName, fkRuleType);
272
279
  const unique = property['unique'] ?? false;
273
280
  // Replace 'required' with 'sometimes'
274
281
  rules = rules.map(r => r === 'required' ? 'sometimes' : r);
@@ -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.
@@ -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
  }