@workos/oagen-emitters 0.2.1 → 0.3.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/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11893 -3226
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +78 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +2 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +374 -364
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +32 -12
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +18 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +99 -69
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
2
|
+
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
3
|
+
import { snakeName } from './naming.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prefix mapping for generating realistic ID fixture values.
|
|
7
|
+
*/
|
|
8
|
+
export const ID_PREFIXES: Record<string, string> = {
|
|
9
|
+
Connection: 'conn_',
|
|
10
|
+
Organization: 'org_',
|
|
11
|
+
OrganizationMembership: 'om_',
|
|
12
|
+
User: 'user_',
|
|
13
|
+
Directory: 'directory_',
|
|
14
|
+
DirectoryGroup: 'dir_grp_',
|
|
15
|
+
DirectoryUser: 'dir_usr_',
|
|
16
|
+
Invitation: 'inv_',
|
|
17
|
+
Session: 'session_',
|
|
18
|
+
AuthenticationFactor: 'auth_factor_',
|
|
19
|
+
EmailVerification: 'email_verification_',
|
|
20
|
+
MagicAuth: 'magic_auth_',
|
|
21
|
+
PasswordReset: 'password_reset_',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate JSON fixture files for test data.
|
|
26
|
+
*/
|
|
27
|
+
export function generateFixtures(spec: {
|
|
28
|
+
models: Model[];
|
|
29
|
+
enums: Enum[];
|
|
30
|
+
services: any[];
|
|
31
|
+
}): { path: string; content: string }[] {
|
|
32
|
+
if (spec.models.length === 0) return [];
|
|
33
|
+
|
|
34
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
35
|
+
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
36
|
+
const files: { path: string; content: string }[] = [];
|
|
37
|
+
|
|
38
|
+
for (const model of spec.models) {
|
|
39
|
+
if (isListMetadataModel(model)) continue;
|
|
40
|
+
if (isListWrapperModel(model)) continue;
|
|
41
|
+
|
|
42
|
+
const fixture = generateModelFixture(model, modelMap, enumMap);
|
|
43
|
+
|
|
44
|
+
files.push({
|
|
45
|
+
path: `tests/Fixtures/${snakeName(model.name)}.json`,
|
|
46
|
+
content: JSON.stringify(fixture, null, 2),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Generate list fixtures for paginated responses
|
|
51
|
+
for (const service of spec.services) {
|
|
52
|
+
for (const op of service.operations) {
|
|
53
|
+
if (op.pagination) {
|
|
54
|
+
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
55
|
+
if (itemModel) {
|
|
56
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
57
|
+
if (unwrapped) itemModel = unwrapped;
|
|
58
|
+
if (itemModel.fields.length === 0) continue;
|
|
59
|
+
const fixture = generateModelFixture(itemModel, modelMap, enumMap);
|
|
60
|
+
const listFixture = {
|
|
61
|
+
data: [fixture],
|
|
62
|
+
list_metadata: {
|
|
63
|
+
before: null,
|
|
64
|
+
after: null,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
files.push({
|
|
68
|
+
path: `tests/Fixtures/list_${snakeName(itemModel.name)}.json`,
|
|
69
|
+
content: JSON.stringify(listFixture, null, 2),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
|
|
80
|
+
const dataField = model.fields.find((f) => f.name === 'data');
|
|
81
|
+
const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
|
|
82
|
+
if (dataField && hasListMetadata && dataField.type.kind === 'array') {
|
|
83
|
+
const itemType = dataField.type.items;
|
|
84
|
+
if (itemType.kind === 'model') {
|
|
85
|
+
return modelMap.get(itemType.name) ?? null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function generateModelFixture(
|
|
92
|
+
model: Model,
|
|
93
|
+
modelMap: Map<string, Model>,
|
|
94
|
+
enumMap: Map<string, Enum>,
|
|
95
|
+
): Record<string, any> {
|
|
96
|
+
const fixture: Record<string, any> = {};
|
|
97
|
+
|
|
98
|
+
for (const field of model.fields) {
|
|
99
|
+
const wireName = field.name;
|
|
100
|
+
if (field.example !== undefined) {
|
|
101
|
+
fixture[wireName] = field.example;
|
|
102
|
+
} else {
|
|
103
|
+
fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return fixture;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function generateFieldValue(
|
|
111
|
+
ref: TypeRef,
|
|
112
|
+
fieldName: string,
|
|
113
|
+
modelName: string,
|
|
114
|
+
modelMap: Map<string, Model>,
|
|
115
|
+
enumMap: Map<string, Enum>,
|
|
116
|
+
): any {
|
|
117
|
+
switch (ref.kind) {
|
|
118
|
+
case 'primitive':
|
|
119
|
+
return generatePrimitiveValue(ref.type, ref.format, fieldName, modelName);
|
|
120
|
+
case 'literal':
|
|
121
|
+
return ref.value;
|
|
122
|
+
case 'enum': {
|
|
123
|
+
const e = enumMap.get(ref.name);
|
|
124
|
+
return e?.values[0]?.value ?? 'unknown';
|
|
125
|
+
}
|
|
126
|
+
case 'model': {
|
|
127
|
+
const nested = modelMap.get(ref.name);
|
|
128
|
+
if (nested) return generateModelFixture(nested, modelMap, enumMap);
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
case 'array': {
|
|
132
|
+
if (ref.items.kind === 'enum') {
|
|
133
|
+
const e = enumMap.get(ref.items.name);
|
|
134
|
+
if (e && e.values.length > 0) {
|
|
135
|
+
return e.values.map((v) => v.value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const item = generateFieldValue(ref.items, fieldName, modelName, modelMap, enumMap);
|
|
139
|
+
return [item];
|
|
140
|
+
}
|
|
141
|
+
case 'nullable':
|
|
142
|
+
return generateFieldValue(ref.inner, fieldName, modelName, modelMap, enumMap);
|
|
143
|
+
case 'union':
|
|
144
|
+
if (ref.variants.length > 0) {
|
|
145
|
+
return generateFieldValue(ref.variants[0], fieldName, modelName, modelMap, enumMap);
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
case 'map':
|
|
149
|
+
return {
|
|
150
|
+
key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
|
|
156
|
+
switch (type) {
|
|
157
|
+
case 'string':
|
|
158
|
+
if (format === 'date-time') return '2023-01-01T00:00:00.000Z';
|
|
159
|
+
if (format === 'date') return '2023-01-01';
|
|
160
|
+
if (format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
161
|
+
if (name === 'id') {
|
|
162
|
+
const prefix = ID_PREFIXES[modelName] ?? '';
|
|
163
|
+
return `${prefix}01234`;
|
|
164
|
+
}
|
|
165
|
+
if (name.includes('id')) return `${name}_01234`;
|
|
166
|
+
if (name.includes('email')) return 'test@example.com';
|
|
167
|
+
if (name.includes('url') || name.includes('uri')) return 'https://example.com';
|
|
168
|
+
if (name.includes('name')) return 'Test';
|
|
169
|
+
return `test_${name}`;
|
|
170
|
+
case 'integer':
|
|
171
|
+
return 1;
|
|
172
|
+
case 'number':
|
|
173
|
+
return 1.0;
|
|
174
|
+
case 'boolean':
|
|
175
|
+
return true;
|
|
176
|
+
case 'unknown':
|
|
177
|
+
return {};
|
|
178
|
+
default:
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/php/index.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Emitter,
|
|
3
|
+
EmitterContext,
|
|
4
|
+
FormatCommand,
|
|
5
|
+
GeneratedFile,
|
|
6
|
+
ApiSpec,
|
|
7
|
+
Model,
|
|
8
|
+
Enum,
|
|
9
|
+
Service,
|
|
10
|
+
} from '@workos/oagen';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { generateModels } from './models.js';
|
|
15
|
+
import { generateEnums } from './enums.js';
|
|
16
|
+
import { generateResources } from './resources.js';
|
|
17
|
+
import { generateClient } from './client.js';
|
|
18
|
+
import { generateTests } from './tests.js';
|
|
19
|
+
import { generateManifest } from './manifest.js';
|
|
20
|
+
import { initializeEnumDedup } from './naming.js';
|
|
21
|
+
|
|
22
|
+
/** Initialize enum deduplication from spec data. */
|
|
23
|
+
function ensureNamingInitialized(ctx: EmitterContext): void {
|
|
24
|
+
initializeEnumDedup(ctx.spec.enums);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Ensure every generated file's content ends with a trailing newline. */
|
|
28
|
+
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
29
|
+
for (const f of files) {
|
|
30
|
+
if (f.content && !f.content.endsWith('\n')) {
|
|
31
|
+
f.content += '\n';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const phpEmitter: Emitter = {
|
|
38
|
+
language: 'php',
|
|
39
|
+
|
|
40
|
+
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
41
|
+
ensureNamingInitialized(ctx);
|
|
42
|
+
return ensureTrailingNewlines(generateModels(models, ctx));
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
46
|
+
ensureNamingInitialized(ctx);
|
|
47
|
+
return ensureTrailingNewlines(generateEnums(enums, ctx));
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
51
|
+
ensureNamingInitialized(ctx);
|
|
52
|
+
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
56
|
+
ensureNamingInitialized(ctx);
|
|
57
|
+
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
generateErrors(): GeneratedFile[] {
|
|
61
|
+
return [];
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
|
|
65
|
+
// PHP uses inline type hints — no separate type signature files needed
|
|
66
|
+
return [];
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
70
|
+
ensureNamingInitialized(ctx);
|
|
71
|
+
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
75
|
+
ensureNamingInitialized(ctx);
|
|
76
|
+
return ensureTrailingNewlines(generateManifest(spec, ctx));
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
fileHeader(): string {
|
|
80
|
+
return '<?php\n\ndeclare(strict_types=1);\n\n// This file is auto-generated by oagen. Do not edit.';
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
formatCommand(targetDir: string): FormatCommand | null {
|
|
84
|
+
const hasPhpCsFixer =
|
|
85
|
+
fs.existsSync(path.join(targetDir, '.php-cs-fixer.dist.php')) ||
|
|
86
|
+
fs.existsSync(path.join(targetDir, '.php-cs-fixer.php'));
|
|
87
|
+
if (hasPhpCsFixer) {
|
|
88
|
+
return {
|
|
89
|
+
cmd: 'bash',
|
|
90
|
+
args: ['-c', 'php vendor/bin/php-cs-fixer fix --using-cache=no --quiet . || true'],
|
|
91
|
+
batchSize: 999999,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { resolveMethodName } from './naming.js';
|
|
3
|
+
import { buildServiceAccessPaths } from './client.js';
|
|
4
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate smoke test manifest mapping HTTP operations to SDK methods.
|
|
8
|
+
*/
|
|
9
|
+
export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
10
|
+
const manifest: Record<string, { sdkMethod: string; service: string }> = {};
|
|
11
|
+
const accessPaths = buildServiceAccessPaths(spec.services, ctx);
|
|
12
|
+
|
|
13
|
+
for (const service of spec.services) {
|
|
14
|
+
let propName = accessPaths.get(service.name);
|
|
15
|
+
if (!propName) {
|
|
16
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
17
|
+
propName = accessPaths.get(mountTarget);
|
|
18
|
+
}
|
|
19
|
+
if (!propName) {
|
|
20
|
+
throw new Error(`Missing public client access path for service ${service.name}`);
|
|
21
|
+
}
|
|
22
|
+
for (const op of service.operations) {
|
|
23
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
24
|
+
const method = resolveMethodName(op, service, ctx);
|
|
25
|
+
manifest[httpKey] = { sdkMethod: method, service: propName };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
path: 'smoke-manifest.json',
|
|
32
|
+
content: JSON.stringify(manifest, null, 2),
|
|
33
|
+
integrateTarget: false,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import type { Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
3
|
+
import { className, enumClassName, fieldName } from './naming.js';
|
|
4
|
+
import { phpDocComment } from './utils.js';
|
|
5
|
+
|
|
6
|
+
// Import and re-export shared model detection utilities
|
|
7
|
+
import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
|
|
8
|
+
export { isListMetadataModel, isListWrapperModel };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate PHP model files from IR models.
|
|
12
|
+
*/
|
|
13
|
+
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
14
|
+
if (models.length === 0) return [];
|
|
15
|
+
|
|
16
|
+
// Build structural hash for deduplication
|
|
17
|
+
const modelHashMap = new Map<string, string>();
|
|
18
|
+
const hashGroups = new Map<string, string[]>();
|
|
19
|
+
for (const model of models) {
|
|
20
|
+
if (isListMetadataModel(model)) continue;
|
|
21
|
+
if (isListWrapperModel(model)) continue;
|
|
22
|
+
const hash = structuralHash(model);
|
|
23
|
+
modelHashMap.set(model.name, hash);
|
|
24
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
25
|
+
hashGroups.get(hash)!.push(model.name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pick canonical for each duplicate group (shortest class name wins)
|
|
29
|
+
const aliasOf = new Map<string, string>();
|
|
30
|
+
for (const [hash, names] of hashGroups) {
|
|
31
|
+
if (names.length <= 1) continue;
|
|
32
|
+
if (hash === '') continue;
|
|
33
|
+
const sorted = [...names].sort((a, b) => className(a).length - className(b).length);
|
|
34
|
+
const canonical = sorted[0];
|
|
35
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
36
|
+
aliasOf.set(sorted[i], canonical);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const files: GeneratedFile[] = [];
|
|
41
|
+
|
|
42
|
+
// Emit shared JsonSerializableTrait once
|
|
43
|
+
files.push({
|
|
44
|
+
path: 'lib/Resource/JsonSerializableTrait.php',
|
|
45
|
+
content: [
|
|
46
|
+
`namespace ${ctx.namespacePascal}\\Resource;`,
|
|
47
|
+
'',
|
|
48
|
+
'trait JsonSerializableTrait',
|
|
49
|
+
'{',
|
|
50
|
+
' public function jsonSerialize(): array',
|
|
51
|
+
' {',
|
|
52
|
+
' return $this->toArray();',
|
|
53
|
+
' }',
|
|
54
|
+
'}',
|
|
55
|
+
].join('\n'),
|
|
56
|
+
overwriteExisting: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
for (const model of models) {
|
|
60
|
+
if (isListMetadataModel(model)) continue;
|
|
61
|
+
if (isListWrapperModel(model)) continue;
|
|
62
|
+
if (aliasOf.has(model.name)) continue; // skip structural duplicates
|
|
63
|
+
|
|
64
|
+
const name = className(model.name);
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
|
|
67
|
+
// No <?php here — the file header from fileHeader() provides it
|
|
68
|
+
lines.push(`namespace ${ctx.namespacePascal}\\Resource;`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
if (model.description) {
|
|
71
|
+
lines.push(...phpDocComment(model.description, 0));
|
|
72
|
+
}
|
|
73
|
+
lines.push(`readonly class ${name} implements \\JsonSerializable`);
|
|
74
|
+
lines.push('{');
|
|
75
|
+
lines.push(' use JsonSerializableTrait;');
|
|
76
|
+
lines.push('');
|
|
77
|
+
|
|
78
|
+
// Constructor with promoted properties
|
|
79
|
+
lines.push(' public function __construct(');
|
|
80
|
+
const requiredFields = model.fields.filter((f) => f.required);
|
|
81
|
+
const optionalFields = model.fields.filter((f) => !f.required);
|
|
82
|
+
// Deduplicate fields that map to the same PHP name
|
|
83
|
+
const seenNames = new Set<string>();
|
|
84
|
+
const allFields = [...requiredFields, ...optionalFields].filter((f) => {
|
|
85
|
+
const phpName = fieldName(f.name);
|
|
86
|
+
if (seenNames.has(phpName)) return false;
|
|
87
|
+
seenNames.add(phpName);
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < allFields.length; i++) {
|
|
92
|
+
const field = allFields[i];
|
|
93
|
+
const phpName = fieldName(field.name);
|
|
94
|
+
const phpType = mapTypeRef(field.type);
|
|
95
|
+
const isOptional = !field.required;
|
|
96
|
+
const comma = i < allFields.length - 1 ? ',' : ',';
|
|
97
|
+
|
|
98
|
+
const varDocType = mapTypeRefForPHPDoc(field.type);
|
|
99
|
+
const varNullSuffix = isOptional && !varDocType.endsWith('|null') ? '|null' : '';
|
|
100
|
+
const varAnnotation = needsVarAnnotation(field.type) ? `@var ${varDocType}${varNullSuffix}` : null;
|
|
101
|
+
if (field.description || field.deprecated || varAnnotation) {
|
|
102
|
+
const parts: string[] = [];
|
|
103
|
+
if (field.description) parts.push(field.description);
|
|
104
|
+
if (varAnnotation) parts.push(varAnnotation);
|
|
105
|
+
if (field.deprecated) parts.push('@deprecated');
|
|
106
|
+
lines.push(...phpDocComment(parts.join('\n'), 8));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isOptional) {
|
|
110
|
+
const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
|
|
111
|
+
lines.push(` public ${nullableType} $${phpName} = null${comma}`);
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(` public ${phpType} $${phpName}${comma}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
lines.push(' ) {');
|
|
117
|
+
lines.push(' }');
|
|
118
|
+
|
|
119
|
+
// fromArray factory method
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push(` public static function fromArray(array $data): self`);
|
|
122
|
+
lines.push(' {');
|
|
123
|
+
lines.push(` return new self(`);
|
|
124
|
+
for (let i = 0; i < allFields.length; i++) {
|
|
125
|
+
const field = allFields[i];
|
|
126
|
+
const phpName = fieldName(field.name);
|
|
127
|
+
const wireName = field.name;
|
|
128
|
+
const comma = i < allFields.length - 1 ? ',' : ',';
|
|
129
|
+
const accessor = generateFromArrayAccessor(field.type, wireName, field.required);
|
|
130
|
+
|
|
131
|
+
lines.push(` ${phpName}: ${accessor}${comma}`);
|
|
132
|
+
}
|
|
133
|
+
lines.push(' );');
|
|
134
|
+
lines.push(' }');
|
|
135
|
+
|
|
136
|
+
// toArray method
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(' public function toArray(): array');
|
|
139
|
+
lines.push(' {');
|
|
140
|
+
lines.push(' return [');
|
|
141
|
+
for (const field of allFields) {
|
|
142
|
+
const phpName = fieldName(field.name);
|
|
143
|
+
const wireName = field.name;
|
|
144
|
+
const serialized = generateToArrayValue(field.type, `$this->${phpName}`, !field.required);
|
|
145
|
+
lines.push(` '${wireName}' => ${serialized},`);
|
|
146
|
+
}
|
|
147
|
+
lines.push(' ];');
|
|
148
|
+
lines.push(' }');
|
|
149
|
+
|
|
150
|
+
lines.push('}');
|
|
151
|
+
|
|
152
|
+
files.push({
|
|
153
|
+
path: `lib/Resource/${name}.php`,
|
|
154
|
+
content: lines.join('\n'),
|
|
155
|
+
overwriteExisting: true,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return files;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Generate the fromArray accessor expression for a field.
|
|
164
|
+
*/
|
|
165
|
+
function generateFromArrayAccessor(ref: TypeRef, wireName: string, required: boolean): string {
|
|
166
|
+
// For nullable types, always guard with isset() regardless of required flag
|
|
167
|
+
const isNullable = ref.kind === 'nullable';
|
|
168
|
+
if (!required || isNullable) {
|
|
169
|
+
const innerRef = isNullable ? ref.inner : ref;
|
|
170
|
+
const inner = generateFromArrayValue(innerRef, `$data['${wireName}']`);
|
|
171
|
+
if (isComplexType(innerRef)) {
|
|
172
|
+
return `isset($data['${wireName}']) ? ${inner} : null`;
|
|
173
|
+
}
|
|
174
|
+
if (isNullable) {
|
|
175
|
+
return `$data['${wireName}'] ?? null`;
|
|
176
|
+
}
|
|
177
|
+
return `$data['${wireName}'] ?? null`;
|
|
178
|
+
}
|
|
179
|
+
// Required field: access directly
|
|
180
|
+
return generateFromArrayValue(ref, `$data['${wireName}']`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generate the fromArray value expression for a type.
|
|
185
|
+
*/
|
|
186
|
+
function generateFromArrayValue(ref: TypeRef, accessor: string): string {
|
|
187
|
+
switch (ref.kind) {
|
|
188
|
+
case 'primitive':
|
|
189
|
+
if (ref.format === 'date-time') {
|
|
190
|
+
// Always access directly — nullable fields are already guarded by isset() in
|
|
191
|
+
// generateFromArrayAccessor(). Required fields should error if missing.
|
|
192
|
+
return `new \\DateTimeImmutable(${accessor})`;
|
|
193
|
+
}
|
|
194
|
+
return accessor;
|
|
195
|
+
case 'model': {
|
|
196
|
+
const name = className(ref.name);
|
|
197
|
+
return `${name}::fromArray(${accessor})`;
|
|
198
|
+
}
|
|
199
|
+
case 'enum': {
|
|
200
|
+
const name = enumClassName(ref.name);
|
|
201
|
+
return `${name}::from(${accessor})`;
|
|
202
|
+
}
|
|
203
|
+
case 'array':
|
|
204
|
+
if (ref.items.kind === 'model') {
|
|
205
|
+
const itemName = className(ref.items.name);
|
|
206
|
+
return `array_map(fn ($item) => ${itemName}::fromArray($item), ${accessor})`;
|
|
207
|
+
}
|
|
208
|
+
if (ref.items.kind === 'enum') {
|
|
209
|
+
const itemName = enumClassName(ref.items.name);
|
|
210
|
+
return `array_map(fn ($item) => ${itemName}::from($item), ${accessor})`;
|
|
211
|
+
}
|
|
212
|
+
if (ref.items.kind === 'primitive' && ref.items.format === 'date-time') {
|
|
213
|
+
return `array_map(fn ($item) => new \\DateTimeImmutable($item), ${accessor})`;
|
|
214
|
+
}
|
|
215
|
+
return accessor;
|
|
216
|
+
case 'nullable':
|
|
217
|
+
return generateFromArrayValue(ref.inner, accessor);
|
|
218
|
+
case 'union':
|
|
219
|
+
return accessor;
|
|
220
|
+
case 'map':
|
|
221
|
+
return accessor;
|
|
222
|
+
case 'literal':
|
|
223
|
+
return accessor;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check if a TypeRef needs special handling (not a simple key access).
|
|
229
|
+
*/
|
|
230
|
+
function isComplexType(ref: TypeRef): boolean {
|
|
231
|
+
switch (ref.kind) {
|
|
232
|
+
case 'primitive':
|
|
233
|
+
return ref.format === 'date-time';
|
|
234
|
+
case 'model':
|
|
235
|
+
case 'enum':
|
|
236
|
+
return true;
|
|
237
|
+
case 'array':
|
|
238
|
+
return isComplexType(ref.items);
|
|
239
|
+
case 'nullable':
|
|
240
|
+
return isComplexType(ref.inner);
|
|
241
|
+
default:
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Generate the toArray serialization expression for a field value.
|
|
248
|
+
*/
|
|
249
|
+
function generateToArrayValue(ref: TypeRef, accessor: string, nullable = false): string {
|
|
250
|
+
const ns = nullable ? '?' : '';
|
|
251
|
+
switch (ref.kind) {
|
|
252
|
+
case 'primitive':
|
|
253
|
+
if (ref.format === 'date-time') {
|
|
254
|
+
return `${accessor}${ns}->format(\\DateTimeInterface::RFC3339_EXTENDED)`;
|
|
255
|
+
}
|
|
256
|
+
return accessor;
|
|
257
|
+
case 'model':
|
|
258
|
+
return `${accessor}${ns}->toArray()`;
|
|
259
|
+
case 'enum':
|
|
260
|
+
return nullable ? `${accessor}?->value` : `${accessor}->value`;
|
|
261
|
+
case 'array':
|
|
262
|
+
if (ref.items.kind === 'model') {
|
|
263
|
+
return nullable
|
|
264
|
+
? `${accessor} !== null ? array_map(fn ($item) => $item->toArray(), ${accessor}) : null`
|
|
265
|
+
: `array_map(fn ($item) => $item->toArray(), ${accessor})`;
|
|
266
|
+
}
|
|
267
|
+
if (ref.items.kind === 'enum') {
|
|
268
|
+
return nullable
|
|
269
|
+
? `${accessor} !== null ? array_map(fn ($item) => $item->value, ${accessor}) : null`
|
|
270
|
+
: `array_map(fn ($item) => $item->value, ${accessor})`;
|
|
271
|
+
}
|
|
272
|
+
if (ref.items.kind === 'primitive' && ref.items.format === 'date-time') {
|
|
273
|
+
return nullable
|
|
274
|
+
? `${accessor} !== null ? array_map(fn ($item) => $item->format(\\DateTimeInterface::RFC3339_EXTENDED), ${accessor}) : null`
|
|
275
|
+
: `array_map(fn ($item) => $item->format(\\DateTimeInterface::RFC3339_EXTENDED), ${accessor})`;
|
|
276
|
+
}
|
|
277
|
+
return accessor;
|
|
278
|
+
case 'nullable':
|
|
279
|
+
return generateToArrayValue(ref.inner, accessor, true);
|
|
280
|
+
case 'map':
|
|
281
|
+
return accessor;
|
|
282
|
+
case 'union':
|
|
283
|
+
return accessor;
|
|
284
|
+
case 'literal':
|
|
285
|
+
return accessor;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check if a TypeRef needs a @var PHPDoc annotation because the PHP type hint
|
|
291
|
+
* loses information (e.g., `array` vs `array<ConnectionDomain>`).
|
|
292
|
+
*/
|
|
293
|
+
function needsVarAnnotation(ref: TypeRef): boolean {
|
|
294
|
+
switch (ref.kind) {
|
|
295
|
+
case 'array':
|
|
296
|
+
case 'map':
|
|
297
|
+
return true;
|
|
298
|
+
case 'nullable':
|
|
299
|
+
return needsVarAnnotation(ref.inner);
|
|
300
|
+
default:
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function structuralHash(model: Model): string {
|
|
306
|
+
return model.fields
|
|
307
|
+
.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`)
|
|
308
|
+
.sort()
|
|
309
|
+
.join('|');
|
|
310
|
+
}
|