@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,41 @@
|
|
|
1
|
+
import type { SdkBehavior } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Node-specific overrides for exception kind names.
|
|
5
|
+
*
|
|
6
|
+
* The IR `statusCodeMap` uses canonical kind names (e.g. 'Authentication'),
|
|
7
|
+
* but the Node SDK historically uses different names for some status codes.
|
|
8
|
+
* This map translates the IR kind name to the Node-specific name before
|
|
9
|
+
* appending the 'Exception' suffix.
|
|
10
|
+
*/
|
|
11
|
+
const NODE_EXCEPTION_KIND_OVERRIDES: Record<string, string> = {
|
|
12
|
+
Authentication: 'Unauthorized',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Fallback status code map when no SDK behavior is provided. */
|
|
16
|
+
const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
|
|
17
|
+
'400': 'BadRequest',
|
|
18
|
+
'401': 'Authentication',
|
|
19
|
+
'403': 'Authorization',
|
|
20
|
+
'404': 'NotFound',
|
|
21
|
+
'409': 'Conflict',
|
|
22
|
+
'422': 'UnprocessableEntity',
|
|
23
|
+
'429': 'RateLimitExceeded',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build the status-code-to-exception-class-name map from SDK behavior,
|
|
28
|
+
* applying Node-specific naming overrides.
|
|
29
|
+
*
|
|
30
|
+
* Example: IR `401: 'Authentication'` becomes `401: 'UnauthorizedException'`
|
|
31
|
+
* because Node uses `UnauthorizedException` instead of `AuthenticationException`.
|
|
32
|
+
*/
|
|
33
|
+
export function buildNodeStatusExceptions(sdk?: SdkBehavior): Record<number, string> {
|
|
34
|
+
const statusCodeMap = sdk?.errors?.statusCodeMap ?? DEFAULT_STATUS_CODE_MAP;
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
Object.entries(statusCodeMap).map(([code, kind]) => {
|
|
37
|
+
const nodeKind = NODE_EXCEPTION_KIND_OVERRIDES[kind] ?? kind;
|
|
38
|
+
return [Number(code), `${nodeKind}Exception`];
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/node/tests.ts
CHANGED
|
@@ -15,12 +15,12 @@ import { resolveResourceClassName } from './resources.js';
|
|
|
15
15
|
import {
|
|
16
16
|
assignModelsToServices,
|
|
17
17
|
createServiceDirResolver,
|
|
18
|
-
isServiceCoveredByExisting,
|
|
19
18
|
uncoveredOperations,
|
|
20
19
|
relativeImport,
|
|
21
20
|
isListMetadataModel,
|
|
22
21
|
isListWrapperModel,
|
|
23
22
|
} from './utils.js';
|
|
23
|
+
import { groupByMount } from '../shared/resolved-ops.js';
|
|
24
24
|
|
|
25
25
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
26
26
|
const files: GeneratedFile[] = [];
|
|
@@ -34,15 +34,32 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
34
34
|
// Build model lookup for response field assertions
|
|
35
35
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
36
36
|
|
|
37
|
-
// Generate test files per
|
|
38
|
-
// covered by existing hand-written
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
// Generate test files per mount target — merges all sub-services into one
|
|
38
|
+
// test file. Skip operations already covered by existing hand-written classes.
|
|
39
|
+
const mountGroups = groupByMount(ctx);
|
|
40
|
+
|
|
41
|
+
// Build mount-target → property name map so tests use the same accessor
|
|
42
|
+
// as the generated client, even when the mount target name doesn't match
|
|
43
|
+
// any IR service name directly.
|
|
44
|
+
const mountAccessors = new Map<string, string>();
|
|
45
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
46
|
+
if (!mountAccessors.has(r.mountOn)) {
|
|
47
|
+
mountAccessors.set(r.mountOn, servicePropertyName(r.mountOn));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const testEntries: Array<{ name: string; operations: Operation[] }> =
|
|
52
|
+
mountGroups.size > 0
|
|
53
|
+
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
54
|
+
: spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
55
|
+
|
|
56
|
+
for (const { name: mountName, operations } of testEntries) {
|
|
57
|
+
if (operations.length === 0) continue;
|
|
58
|
+
const mergedService: Service = { name: mountName, operations };
|
|
59
|
+
const ops = uncoveredOperations(mergedService, ctx);
|
|
43
60
|
if (ops.length === 0) continue;
|
|
44
|
-
const testService = ops.length <
|
|
45
|
-
files.push(generateServiceTest(testService, spec, ctx, modelMap));
|
|
61
|
+
const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
|
|
62
|
+
files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
// Generate serializer round-trip tests
|
|
@@ -59,11 +76,12 @@ function generateServiceTest(
|
|
|
59
76
|
spec: ApiSpec,
|
|
60
77
|
ctx: EmitterContext,
|
|
61
78
|
modelMap: Map<string, Model>,
|
|
79
|
+
mountAccessors?: Map<string, string>,
|
|
62
80
|
): GeneratedFile {
|
|
63
81
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
64
82
|
const serviceDir = resolveServiceDir(resolvedName);
|
|
65
83
|
const serviceClass = resolvedName;
|
|
66
|
-
const serviceProp = servicePropertyName(resolvedName);
|
|
84
|
+
const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
|
|
67
85
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
68
86
|
|
|
69
87
|
const plans = service.operations.map((op) => ({
|
|
@@ -838,7 +856,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
838
856
|
serializerImports.push(
|
|
839
857
|
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
840
858
|
);
|
|
841
|
-
|
|
859
|
+
const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
860
|
+
fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
|
|
842
861
|
}
|
|
843
862
|
|
|
844
863
|
for (const imp of serializerImports) {
|
|
@@ -851,7 +870,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
851
870
|
|
|
852
871
|
for (const model of models) {
|
|
853
872
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
854
|
-
const
|
|
873
|
+
const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
|
|
874
|
+
const fixtureName = `${camelDomain}Fixture`;
|
|
855
875
|
|
|
856
876
|
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
857
877
|
lines.push(" it('round-trips through serialize/deserialize', () => {");
|
package/src/node/type-map.ts
CHANGED
|
@@ -122,10 +122,12 @@ function mapWirePrimitive(ref: PrimitiveType): string {
|
|
|
122
122
|
* allOf unions use `&` (intersection), oneOf/anyOf/unspecified use `|` (union).
|
|
123
123
|
*/
|
|
124
124
|
function joinUnionVariants(ref: UnionType, variants: string[]): string {
|
|
125
|
+
const unique = [...new Set(variants)];
|
|
125
126
|
if (ref.compositionKind === 'allOf') {
|
|
126
|
-
return
|
|
127
|
+
return unique.join(' & ');
|
|
127
128
|
}
|
|
128
|
-
|
|
129
|
+
if (unique.length === 1) return unique[0];
|
|
130
|
+
return unique.join(' | ');
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
/** Wrap union/intersection types in parentheses when used as array item type. */
|
package/src/node/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Model, EmitterContext, Service, Operation
|
|
1
|
+
import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
export {
|
|
4
4
|
collectModelRefs,
|
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
resolveServiceDir,
|
|
15
15
|
resolveMethodName,
|
|
16
16
|
buildServiceNameMap,
|
|
17
|
-
SERVICE_COVERED_BY,
|
|
18
17
|
} from './naming.js';
|
|
18
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
19
19
|
import { assignModelsToServices } from '@workos/oagen';
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -248,67 +248,8 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
|
|
|
248
248
|
return false;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
* exactly 2 fields named `before` and `after`, both nullable string.
|
|
254
|
-
*
|
|
255
|
-
* These models are redundant because the SDK already has a shared
|
|
256
|
-
* `ListMetadata` type in `src/common/utils/pagination.ts`.
|
|
257
|
-
*/
|
|
258
|
-
export function isListMetadataModel(model: Model): boolean {
|
|
259
|
-
if (model.fields.length !== 2) return false;
|
|
260
|
-
|
|
261
|
-
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
262
|
-
const before = fieldsByName.get('before');
|
|
263
|
-
const after = fieldsByName.get('after');
|
|
264
|
-
|
|
265
|
-
if (!before || !after) return false;
|
|
266
|
-
|
|
267
|
-
return isNullableString(before) && isNullableString(after);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Detect whether a model is a list wrapper — the standard paginated
|
|
272
|
-
* list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
|
|
273
|
-
*
|
|
274
|
-
* These models are redundant because the SDK already has `List<T>` and
|
|
275
|
-
* `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
|
|
276
|
-
* `deserializeList` handles deserialization.
|
|
277
|
-
*/
|
|
278
|
-
export function isListWrapperModel(model: Model): boolean {
|
|
279
|
-
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
280
|
-
|
|
281
|
-
// Must have a `data` field that is an array type
|
|
282
|
-
const dataField = fieldsByName.get('data');
|
|
283
|
-
if (!dataField) return false;
|
|
284
|
-
if (dataField.type.kind !== 'array') return false;
|
|
285
|
-
|
|
286
|
-
// Must have a `list_metadata` field (the IR uses snake_case names)
|
|
287
|
-
const listMetadataField = fieldsByName.get('list_metadata');
|
|
288
|
-
if (!listMetadataField) return false;
|
|
289
|
-
|
|
290
|
-
// Optionally has an `object` field with literal value 'list'
|
|
291
|
-
const objectField = fieldsByName.get('object');
|
|
292
|
-
if (objectField) {
|
|
293
|
-
if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
return true;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Check if a field type is nullable string (nullable<string> or just string). */
|
|
302
|
-
function isNullableString(field: Field): boolean {
|
|
303
|
-
const { type } = field;
|
|
304
|
-
if (type.kind === 'nullable') {
|
|
305
|
-
return type.inner.kind === 'primitive' && type.inner.type === 'string';
|
|
306
|
-
}
|
|
307
|
-
if (type.kind === 'primitive') {
|
|
308
|
-
return type.type === 'string';
|
|
309
|
-
}
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
251
|
+
// Re-export shared model detection utilities
|
|
252
|
+
export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
|
|
312
253
|
|
|
313
254
|
/**
|
|
314
255
|
* Compute a structural fingerprint for a model based on its fields.
|
|
@@ -392,8 +333,10 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
|
|
|
392
333
|
* endpoints (e.g., `GET /connections`).
|
|
393
334
|
*/
|
|
394
335
|
export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
|
|
395
|
-
//
|
|
396
|
-
|
|
336
|
+
// A service is "covered" when its mountOn differs from its own name,
|
|
337
|
+
// meaning its operations are mounted on a different (existing) class.
|
|
338
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
339
|
+
if (mountTarget !== toPascalCase(service.name)) return true;
|
|
397
340
|
|
|
398
341
|
const overlay = ctx.overlayLookup?.methodByOperation;
|
|
399
342
|
if (!overlay || overlay.size === 0) return false;
|
|
@@ -426,12 +369,11 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
|
|
|
426
369
|
const baselineClasses = ctx.apiSurface?.classes;
|
|
427
370
|
if (!baselineClasses) return false;
|
|
428
371
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const cls = baselineClasses[targetClassName];
|
|
372
|
+
// When a service mounts on a different class (via mount rules), check
|
|
373
|
+
// each operation's resolved method name against the target class directly.
|
|
374
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
375
|
+
if (mountTarget !== toPascalCase(service.name)) {
|
|
376
|
+
const cls = baselineClasses[mountTarget];
|
|
435
377
|
if (!cls) return true; // Target class missing from baseline — treat as absent
|
|
436
378
|
for (const op of service.operations) {
|
|
437
379
|
const method = resolveMethodName(op, service, ctx);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
+
import { toCamelCase } from '@workos/oagen';
|
|
3
|
+
import { fieldName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
|
+
import { mapTypeRef } from './type-map.js';
|
|
5
|
+
import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate TypeScript wrapper method lines for union split operations.
|
|
9
|
+
*
|
|
10
|
+
* Each wrapper is a typed convenience method that:
|
|
11
|
+
* - Accepts only the exposed params (not the full union body)
|
|
12
|
+
* - Injects constant defaults (e.g., grant_type)
|
|
13
|
+
* - Reads inferred fields from client config (e.g., clientId)
|
|
14
|
+
* - Delegates to the HTTP client with the constructed body
|
|
15
|
+
*/
|
|
16
|
+
export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
|
|
17
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
18
|
+
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
|
|
21
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
22
|
+
lines.push('');
|
|
23
|
+
emitWrapperMethod(lines, resolvedOp, wrapper, ctx);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collect response model names referenced by wrappers on a resolved operation.
|
|
31
|
+
* Used by the resource generator to ensure the correct imports are emitted.
|
|
32
|
+
*/
|
|
33
|
+
export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
|
|
34
|
+
const models = new Set<string>();
|
|
35
|
+
for (const wrapper of resolvedOp.wrappers ?? []) {
|
|
36
|
+
if (wrapper.responseModelName) {
|
|
37
|
+
models.add(wrapper.responseModelName);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return models;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emitWrapperMethod(
|
|
44
|
+
lines: string[],
|
|
45
|
+
resolvedOp: ResolvedOperation,
|
|
46
|
+
wrapper: ResolvedWrapper,
|
|
47
|
+
ctx: EmitterContext,
|
|
48
|
+
): void {
|
|
49
|
+
const op = resolvedOp.operation;
|
|
50
|
+
const method = toCamelCase(wrapper.name);
|
|
51
|
+
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
52
|
+
|
|
53
|
+
// Build parameter list: path params, then required exposed, then optional exposed
|
|
54
|
+
const paramParts: string[] = [];
|
|
55
|
+
|
|
56
|
+
for (const p of op.pathParams) {
|
|
57
|
+
paramParts.push(`${fieldName(p.name)}: string`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
61
|
+
if (isOptional) continue;
|
|
62
|
+
const tsName = fieldName(paramName);
|
|
63
|
+
const tsType = field ? mapTypeRef(field.type) : 'string';
|
|
64
|
+
paramParts.push(`${tsName}: ${tsType}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
68
|
+
if (!isOptional) continue;
|
|
69
|
+
const tsName = fieldName(paramName);
|
|
70
|
+
const tsType = field ? mapTypeRef(field.type) : 'string';
|
|
71
|
+
paramParts.push(`${tsName}?: ${tsType}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Response type
|
|
75
|
+
const responseTypeName = wrapper.responseModelName ? resolveInterfaceName(wrapper.responseModelName, ctx) : null;
|
|
76
|
+
const wireType = responseTypeName ? wireInterfaceName(responseTypeName) : null;
|
|
77
|
+
const returnType = responseTypeName ?? 'void';
|
|
78
|
+
|
|
79
|
+
// JSDoc
|
|
80
|
+
lines.push(` /** ${formatWrapperDescription(wrapper.name)}. */`);
|
|
81
|
+
|
|
82
|
+
// Method signature
|
|
83
|
+
lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
|
|
84
|
+
|
|
85
|
+
// Build body with wire-format (snake_case) keys
|
|
86
|
+
lines.push(' const body: Record<string, unknown> = {');
|
|
87
|
+
|
|
88
|
+
// Constant defaults
|
|
89
|
+
for (const [key, value] of Object.entries(wrapper.defaults)) {
|
|
90
|
+
lines.push(` ${key}: ${tsLiteral(value)},`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Inferred fields from client config
|
|
94
|
+
for (const field of wrapper.inferFromClient) {
|
|
95
|
+
const expr = clientFieldExpression(field);
|
|
96
|
+
lines.push(` ${field}: ${expr},`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Required exposed params (wire-format key, camelCase value)
|
|
100
|
+
for (const { paramName, isOptional } of wrapperParams) {
|
|
101
|
+
if (isOptional) continue;
|
|
102
|
+
lines.push(` ${paramName}: ${fieldName(paramName)},`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push(' };');
|
|
106
|
+
|
|
107
|
+
// Optional exposed params — add conditionally
|
|
108
|
+
for (const { paramName, isOptional } of wrapperParams) {
|
|
109
|
+
if (!isOptional) continue;
|
|
110
|
+
const tsName = fieldName(paramName);
|
|
111
|
+
lines.push(` if (${tsName} !== undefined) body.${paramName} = ${tsName};`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Build path expression
|
|
115
|
+
const pathStr = buildPathStr(op);
|
|
116
|
+
|
|
117
|
+
// Make the request
|
|
118
|
+
if (responseTypeName) {
|
|
119
|
+
lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, body);`);
|
|
120
|
+
lines.push(` return deserialize${responseTypeName}(data);`);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(` await this.workos.${op.httpMethod}(${pathStr}, body);`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push(' }');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Build a path template string from an Operation. */
|
|
129
|
+
function buildPathStr(op: { path: string; pathParams: Array<{ name: string }> }): string {
|
|
130
|
+
const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
|
|
131
|
+
return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Convert a JS value to a TypeScript literal. */
|
|
135
|
+
function tsLiteral(value: string | number | boolean): string {
|
|
136
|
+
if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
|
|
137
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
138
|
+
return String(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Get the TypeScript expression for reading a client config field. */
|
|
142
|
+
function clientFieldExpression(field: string): string {
|
|
143
|
+
switch (field) {
|
|
144
|
+
case 'client_id':
|
|
145
|
+
return 'this.workos.options.clientId';
|
|
146
|
+
case 'client_secret':
|
|
147
|
+
return 'this.workos.key';
|
|
148
|
+
default:
|
|
149
|
+
return `this.workos.${toCamelCase(field)}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ApiSpec, Service, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase, toCamelCase } from '@workos/oagen';
|
|
3
|
+
import { className, servicePropertyName } from './naming.js';
|
|
4
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
5
|
+
import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* PHP-specific class-name overrides for non-spec services.
|
|
9
|
+
* If a service id isn't listed here, PascalCase(id) is used.
|
|
10
|
+
*/
|
|
11
|
+
const PHP_NON_SPEC_CLASS_NAMES: Record<string, string> = {
|
|
12
|
+
webhook_verification: 'WebhookVerification',
|
|
13
|
+
session_manager: 'SessionManager',
|
|
14
|
+
pkce: 'PKCEHelper',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Derive PHP class name + property name from a non-spec service id. */
|
|
18
|
+
function phpNonSpecAccessor(id: string): { className: string; propName: string } {
|
|
19
|
+
return {
|
|
20
|
+
className: PHP_NON_SPEC_CLASS_NAMES[id] ?? toPascalCase(id),
|
|
21
|
+
propName:
|
|
22
|
+
id === 'webhook_verification'
|
|
23
|
+
? 'webhookVerification'
|
|
24
|
+
: id === 'session_manager'
|
|
25
|
+
? 'sessionManager'
|
|
26
|
+
: toCamelCase(id),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate the main PHP client class (service wiring only).
|
|
32
|
+
*
|
|
33
|
+
* Static infrastructure (HttpClient, PaginatedResponse, RequestOptions) is
|
|
34
|
+
* now hand-maintained in the target SDK with @oagen-ignore-file.
|
|
35
|
+
*/
|
|
36
|
+
export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
37
|
+
const ns = ctx.namespacePascal;
|
|
38
|
+
const dedupedServices = deduplicateByMount(spec.services, ctx);
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
path: `lib/${ns}.php`,
|
|
43
|
+
content: generateMainClient(spec, dedupedServices, ctx),
|
|
44
|
+
overwriteExisting: true,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a map from IR service name to the client accessor property name.
|
|
51
|
+
*/
|
|
52
|
+
export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext): Map<string, string> {
|
|
53
|
+
const map = new Map<string, string>();
|
|
54
|
+
for (const service of services) {
|
|
55
|
+
const target = getMountTarget(service, ctx);
|
|
56
|
+
map.set(service.name, servicePropertyName(target));
|
|
57
|
+
map.set(target, servicePropertyName(target));
|
|
58
|
+
}
|
|
59
|
+
return map;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deduplicateByMount(services: Service[], ctx: EmitterContext): { name: string; propName: string }[] {
|
|
63
|
+
const seen = new Map<string, { name: string; propName: string }>();
|
|
64
|
+
for (const service of services) {
|
|
65
|
+
const target = getMountTarget(service, ctx);
|
|
66
|
+
if (!seen.has(target)) {
|
|
67
|
+
seen.set(target, {
|
|
68
|
+
name: className(target),
|
|
69
|
+
propName: servicePropertyName(target),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...seen.values()];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function generateMainClient(
|
|
77
|
+
spec: ApiSpec,
|
|
78
|
+
services: { name: string; propName: string }[],
|
|
79
|
+
ctx: EmitterContext,
|
|
80
|
+
): string {
|
|
81
|
+
const ns = ctx.namespacePascal;
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
|
|
84
|
+
// No <?php here — the file header from fileHeader() provides it
|
|
85
|
+
lines.push(`namespace ${ns};`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
|
|
88
|
+
// Use imports (sorted case-insensitively for PSR-12)
|
|
89
|
+
const nonSpecAccessors = NON_SPEC_SERVICES.map((s) => phpNonSpecAccessor(s.id));
|
|
90
|
+
const allImports: string[] = [];
|
|
91
|
+
for (const svc of services) {
|
|
92
|
+
allImports.push(`use ${ns}\\Service\\${svc.name};`);
|
|
93
|
+
}
|
|
94
|
+
allImports.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
95
|
+
for (const imp of allImports) {
|
|
96
|
+
lines.push(imp);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
lines.push(`class ${ns}`);
|
|
101
|
+
lines.push('{');
|
|
102
|
+
lines.push(' private static ?string $apiKey = null;');
|
|
103
|
+
lines.push(' private static ?string $clientId = null;');
|
|
104
|
+
lines.push(' private ?HttpClient $httpClient = null;');
|
|
105
|
+
lines.push('');
|
|
106
|
+
lines.push(' public static function getApiKey(): ?string');
|
|
107
|
+
lines.push(' {');
|
|
108
|
+
lines.push(' return self::$apiKey;');
|
|
109
|
+
lines.push(' }');
|
|
110
|
+
lines.push('');
|
|
111
|
+
lines.push(' public static function setApiKey(?string $key): void');
|
|
112
|
+
lines.push(' {');
|
|
113
|
+
lines.push(' self::$apiKey = $key;');
|
|
114
|
+
lines.push(' }');
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(' public static function getClientId(): ?string');
|
|
117
|
+
lines.push(' {');
|
|
118
|
+
lines.push(' return self::$clientId;');
|
|
119
|
+
lines.push(' }');
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push(' public static function setClientId(?string $id): void');
|
|
122
|
+
lines.push(' {');
|
|
123
|
+
lines.push(' self::$clientId = $id;');
|
|
124
|
+
lines.push(' }');
|
|
125
|
+
|
|
126
|
+
// Nullable resource properties
|
|
127
|
+
for (const svc of services) {
|
|
128
|
+
lines.push(` private ?Service\\${svc.name} $${svc.propName} = null;`);
|
|
129
|
+
}
|
|
130
|
+
// Non-spec service properties (hand-maintained modules)
|
|
131
|
+
for (const a of nonSpecAccessors) {
|
|
132
|
+
lines.push(` private ?${a.className} $${a.propName} = null;`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
lines.push('');
|
|
136
|
+
lines.push(' public function __construct(');
|
|
137
|
+
lines.push(' ?string $apiKey = null,');
|
|
138
|
+
lines.push(' ?string $clientId = null,');
|
|
139
|
+
lines.push(` string $baseUrl = '${spec.baseUrl}',`);
|
|
140
|
+
lines.push(' int $timeout = 60,');
|
|
141
|
+
lines.push(' int $maxRetries = 3,');
|
|
142
|
+
lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
|
|
143
|
+
lines.push(' ) {');
|
|
144
|
+
lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
|
|
145
|
+
lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
|
|
146
|
+
lines.push(
|
|
147
|
+
' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler);',
|
|
148
|
+
);
|
|
149
|
+
lines.push(' }');
|
|
150
|
+
|
|
151
|
+
// Resource accessors
|
|
152
|
+
for (const svc of services) {
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(` public function ${svc.propName}(): ${svc.name}`);
|
|
155
|
+
lines.push(' {');
|
|
156
|
+
lines.push(` return $this->${svc.propName} ??= new Service\\${svc.name}($this->httpClient);`);
|
|
157
|
+
lines.push(' }');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Non-spec service accessors (hand-maintained modules)
|
|
161
|
+
for (const a of nonSpecAccessors) {
|
|
162
|
+
lines.push('');
|
|
163
|
+
lines.push(` public function ${a.propName}(): ${a.className}`);
|
|
164
|
+
lines.push(' {');
|
|
165
|
+
lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
|
|
166
|
+
lines.push(' }');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
lines.push('}');
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
}
|
package/src/php/enums.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase } from '@workos/oagen';
|
|
3
|
+
import { className, resolveEnumName } from './naming.js';
|
|
4
|
+
import { phpDocComment } from './utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate PHP enum files from IR enums.
|
|
8
|
+
*/
|
|
9
|
+
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
10
|
+
if (enums.length === 0) return [];
|
|
11
|
+
|
|
12
|
+
const files: GeneratedFile[] = [];
|
|
13
|
+
const emittedCanonical = new Set<string>();
|
|
14
|
+
|
|
15
|
+
for (const e of enums) {
|
|
16
|
+
const canonical = resolveEnumName(e.name);
|
|
17
|
+
if (emittedCanonical.has(canonical)) continue; // skip aliases
|
|
18
|
+
emittedCanonical.add(canonical);
|
|
19
|
+
|
|
20
|
+
const name = className(canonical);
|
|
21
|
+
const _isAllStrings = e.values.every((v) => typeof v.value === 'string');
|
|
22
|
+
const isAllInts = e.values.every((v) => typeof v.value === 'number' && Number.isInteger(v.value));
|
|
23
|
+
const backingType = isAllInts ? 'int' : 'string';
|
|
24
|
+
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
// No <?php here — the file header from fileHeader() provides it
|
|
27
|
+
lines.push(`namespace ${ctx.namespacePascal}\\Resource;`);
|
|
28
|
+
lines.push('');
|
|
29
|
+
lines.push(`enum ${name}: ${backingType}`);
|
|
30
|
+
lines.push('{');
|
|
31
|
+
|
|
32
|
+
// Deduplicate case names
|
|
33
|
+
const usedNames = new Map<string, number>();
|
|
34
|
+
for (const val of e.values) {
|
|
35
|
+
let caseName = toPascalCase(val.name.toLowerCase());
|
|
36
|
+
const baseName = caseName;
|
|
37
|
+
const count = usedNames.get(baseName) ?? 0;
|
|
38
|
+
if (count > 0) {
|
|
39
|
+
caseName = `${baseName}${count + 1}`;
|
|
40
|
+
}
|
|
41
|
+
usedNames.set(baseName, count + 1);
|
|
42
|
+
|
|
43
|
+
if (val.description || val.deprecated) {
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
if (val.description) parts.push(val.description);
|
|
46
|
+
if (val.deprecated) parts.push('@deprecated');
|
|
47
|
+
lines.push(...phpDocComment(parts.join('\n'), 4));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof val.value === 'string') {
|
|
51
|
+
lines.push(` case ${caseName} = '${val.value}';`);
|
|
52
|
+
} else {
|
|
53
|
+
lines.push(` case ${caseName} = ${val.value};`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
lines.push('}');
|
|
58
|
+
|
|
59
|
+
files.push({
|
|
60
|
+
path: `lib/Resource/${name}.php`,
|
|
61
|
+
content: lines.join('\n'),
|
|
62
|
+
overwriteExisting: true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return files;
|
|
67
|
+
}
|