@workos/oagen-emitters 0.2.1 → 0.4.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 +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- 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 +328 -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-dotnet.ts +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- 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 +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -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 +4 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +128 -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 +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- 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 +179 -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 +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -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 +189 -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 +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -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/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -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 +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -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 +682 -0
- package/test/php/tests.test.ts +185 -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,109 @@
|
|
|
1
|
+
import type { Operation, EmitterContext, Service, ResolvedOperation } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase } from '@workos/oagen';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a lookup map from "METHOD /path" to ResolvedOperation.
|
|
6
|
+
* Used by emitters to find the resolved method name for any IR operation.
|
|
7
|
+
*/
|
|
8
|
+
export function buildResolvedLookup(ctx: EmitterContext): Map<string, ResolvedOperation> {
|
|
9
|
+
const map = new Map<string, ResolvedOperation>();
|
|
10
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
11
|
+
const key = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}`;
|
|
12
|
+
map.set(key, r);
|
|
13
|
+
}
|
|
14
|
+
return map;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Look up the resolved method name for an operation.
|
|
19
|
+
* Returns the snake_case resolved name, or undefined if not found.
|
|
20
|
+
*/
|
|
21
|
+
export function lookupMethodName(op: Operation, lookup: Map<string, ResolvedOperation>): string | undefined {
|
|
22
|
+
const key = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
23
|
+
return lookup.get(key)?.methodName;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Look up the full ResolvedOperation for an IR operation.
|
|
28
|
+
*/
|
|
29
|
+
export function lookupResolved(op: Operation, lookup: Map<string, ResolvedOperation>): ResolvedOperation | undefined {
|
|
30
|
+
const key = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
31
|
+
return lookup.get(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A mount group: a set of resolved operations that all mount on the same target.
|
|
36
|
+
* Serves the same role as a Service in the old architecture, but operations may
|
|
37
|
+
* come from multiple IR services.
|
|
38
|
+
*/
|
|
39
|
+
export interface MountGroup {
|
|
40
|
+
/** PascalCase mount target name (e.g., "SSO", "UserManagement"). */
|
|
41
|
+
name: string;
|
|
42
|
+
/** All resolved operations in this group. */
|
|
43
|
+
resolvedOps: ResolvedOperation[];
|
|
44
|
+
/** The raw IR operations (convenience — same as resolvedOps[*].operation). */
|
|
45
|
+
operations: Operation[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Group resolved operations by their mountOn target.
|
|
50
|
+
* Returns a map from PascalCase mount target to MountGroup.
|
|
51
|
+
*/
|
|
52
|
+
export function groupByMount(ctx: EmitterContext): Map<string, MountGroup> {
|
|
53
|
+
const groups = new Map<string, MountGroup>();
|
|
54
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
55
|
+
let group = groups.get(r.mountOn);
|
|
56
|
+
if (!group) {
|
|
57
|
+
group = { name: r.mountOn, resolvedOps: [], operations: [] };
|
|
58
|
+
groups.set(r.mountOn, group);
|
|
59
|
+
}
|
|
60
|
+
group.resolvedOps.push(r);
|
|
61
|
+
group.operations.push(r.operation);
|
|
62
|
+
}
|
|
63
|
+
return groups;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the mount target for an IR service.
|
|
68
|
+
* Checks the first resolved operation that belongs to this service.
|
|
69
|
+
* Falls back to PascalCase of the service name if no resolved ops exist.
|
|
70
|
+
*/
|
|
71
|
+
export function getMountTarget(service: Service, ctx: EmitterContext): string {
|
|
72
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
73
|
+
if (r.service.name === service.name) return r.mountOn;
|
|
74
|
+
}
|
|
75
|
+
return toPascalCase(service.name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Defaults / inferFromClient helpers
|
|
80
|
+
//
|
|
81
|
+
// For non-split operations with operationHints that specify `defaults` or
|
|
82
|
+
// `inferFromClient`, oagen attaches these properties at runtime but they
|
|
83
|
+
// are not part of the ResolvedOperation TypeScript interface (they live on
|
|
84
|
+
// ResolvedWrapper for split operations). These helpers provide type-safe
|
|
85
|
+
// access so individual emitters don't need `as any` casts.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Extract constant defaults from a resolved operation (if any). */
|
|
89
|
+
export function getOpDefaults(resolvedOp?: ResolvedOperation): Record<string, string | number | boolean> {
|
|
90
|
+
return ((resolvedOp as any)?.defaults as Record<string, string | number | boolean> | undefined) ?? {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Extract inferFromClient fields from a resolved operation (if any). */
|
|
94
|
+
export function getOpInferFromClient(resolvedOp?: ResolvedOperation): string[] {
|
|
95
|
+
return ((resolvedOp as any)?.inferFromClient as string[] | undefined) ?? [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Build the set of param names hidden from the public API (injected as defaults or inferred from client config). */
|
|
99
|
+
export function buildHiddenParams(resolvedOp?: ResolvedOperation): Set<string> {
|
|
100
|
+
const hidden = new Set<string>();
|
|
101
|
+
for (const key of Object.keys(getOpDefaults(resolvedOp))) hidden.add(key);
|
|
102
|
+
for (const key of getOpInferFromClient(resolvedOp)) hidden.add(key);
|
|
103
|
+
return hidden;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Check whether a resolved operation has any hidden params. */
|
|
107
|
+
export function hasHiddenParams(resolvedOp?: ResolvedOperation): boolean {
|
|
108
|
+
return Object.keys(getOpDefaults(resolvedOp)).length > 0 || getOpInferFromClient(resolvedOp).length > 0;
|
|
109
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { EmitterContext, Field, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
+
import { toSnakeCase } from '@workos/oagen';
|
|
3
|
+
import { enrichModelsFromSpec } from './model-utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A resolved wrapper parameter with its variant model field and optional status.
|
|
7
|
+
* Pre-computed once per wrapper so emitters don't repeat the lookup.
|
|
8
|
+
*/
|
|
9
|
+
export interface ResolvedWrapperParam {
|
|
10
|
+
/** Wire name of the param (e.g., "email", "grant_type"). */
|
|
11
|
+
paramName: string;
|
|
12
|
+
/** The field from the variant model, or null if not found in the spec. */
|
|
13
|
+
field: Field | null;
|
|
14
|
+
/** Whether this param should be optional in the generated SDK. */
|
|
15
|
+
isOptional: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the variant model's fields for a wrapper's exposed params.
|
|
20
|
+
*
|
|
21
|
+
* Encapsulates the three-step lookup every emitter needs:
|
|
22
|
+
* 1. Find the variant model via wrapper.targetVariant in ctx.spec.models
|
|
23
|
+
* 2. Match each exposed param to its field in the variant
|
|
24
|
+
* 3. Classify as required or optional using wrapper.optionalParams + field.required
|
|
25
|
+
*
|
|
26
|
+
* Field matching uses exact name first, then falls back to snake_case normalization
|
|
27
|
+
* to handle cases where wire names and model field names differ in casing.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveWrapperParams(wrapper: ResolvedWrapper, ctx: EmitterContext): ResolvedWrapperParam[] {
|
|
30
|
+
let variantModel = ctx.spec.models.find((m) => m.name === wrapper.targetVariant);
|
|
31
|
+
|
|
32
|
+
// If the variant model has no fields, try enriching from the raw spec.
|
|
33
|
+
// Some oneOf variants have 0 IR fields until enrichModelsFromSpec backfills them.
|
|
34
|
+
if (!variantModel || variantModel.fields.length === 0) {
|
|
35
|
+
const enriched = enrichModelsFromSpec(ctx.spec.models);
|
|
36
|
+
variantModel = enriched.find((m) => m.name === wrapper.targetVariant) ?? variantModel;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const variantFields = variantModel?.fields ?? [];
|
|
40
|
+
const optionalSet = new Set(wrapper.optionalParams);
|
|
41
|
+
|
|
42
|
+
return wrapper.exposedParams.map((paramName) => {
|
|
43
|
+
const field =
|
|
44
|
+
variantFields.find((f) => f.name === paramName || toSnakeCase(f.name) === toSnakeCase(paramName)) ?? null;
|
|
45
|
+
// Default to required: a wrapper exists to make a specific call shape work,
|
|
46
|
+
// and exposedParams is the contract for that shape. Mark optional only when
|
|
47
|
+
// (a) the wrapper hint explicitly says so, or (b) the IR variant model
|
|
48
|
+
// resolves and reports the field as not required.
|
|
49
|
+
let isOptional: boolean;
|
|
50
|
+
if (optionalSet.has(paramName)) {
|
|
51
|
+
isOptional = true;
|
|
52
|
+
} else if (field) {
|
|
53
|
+
isOptional = !field.required;
|
|
54
|
+
} else {
|
|
55
|
+
isOptional = false;
|
|
56
|
+
}
|
|
57
|
+
return { paramName, field, isOptional };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format a snake_case wrapper name into a human-readable description.
|
|
63
|
+
* "authenticate_with_password" → "Authenticate with password"
|
|
64
|
+
*/
|
|
65
|
+
export function formatWrapperDescription(name: string): string {
|
|
66
|
+
return name
|
|
67
|
+
.split('_')
|
|
68
|
+
.map((w, i) => (i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w))
|
|
69
|
+
.join(' ');
|
|
70
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateClient } from '../../src/dotnet/client.js';
|
|
3
|
+
import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
5
|
+
|
|
6
|
+
const models: Model[] = [
|
|
7
|
+
{
|
|
8
|
+
name: 'Organization',
|
|
9
|
+
fields: [
|
|
10
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
11
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const services: Service[] = [
|
|
17
|
+
{
|
|
18
|
+
name: 'Organizations',
|
|
19
|
+
operations: [
|
|
20
|
+
{
|
|
21
|
+
name: 'listOrganizations',
|
|
22
|
+
httpMethod: 'get',
|
|
23
|
+
path: '/organizations',
|
|
24
|
+
pathParams: [],
|
|
25
|
+
queryParams: [],
|
|
26
|
+
headerParams: [],
|
|
27
|
+
response: { kind: 'model', name: 'Organization' },
|
|
28
|
+
errors: [],
|
|
29
|
+
injectIdempotencyKey: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const spec: ApiSpec = {
|
|
36
|
+
name: 'TestAPI',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
baseUrl: 'https://api.workos.com',
|
|
39
|
+
services,
|
|
40
|
+
models,
|
|
41
|
+
enums: [],
|
|
42
|
+
sdk: defaultSdkBehavior(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ctx: EmitterContext = {
|
|
46
|
+
namespace: 'workos',
|
|
47
|
+
namespacePascal: 'WorkOS',
|
|
48
|
+
spec,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('dotnet/client', () => {
|
|
52
|
+
it('generates only WorkOSClient.Generated.cs', () => {
|
|
53
|
+
const files = generateClient(spec, ctx);
|
|
54
|
+
expect(files).toHaveLength(1);
|
|
55
|
+
expect(files[0].path).toBe('Client/WorkOSClient.Generated.cs');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('generates partial class with service accessors', () => {
|
|
59
|
+
const files = generateClient(spec, ctx);
|
|
60
|
+
const content = files[0].content;
|
|
61
|
+
|
|
62
|
+
expect(content).toContain('namespace WorkOS');
|
|
63
|
+
expect(content).toContain('public partial class WorkOSClient');
|
|
64
|
+
// Lazy-initialized service property
|
|
65
|
+
expect(content).toContain('OrganizationsService');
|
|
66
|
+
expect(content).toContain('??= new OrganizationsService(this)');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not contain static HTTP infrastructure', () => {
|
|
70
|
+
const files = generateClient(spec, ctx);
|
|
71
|
+
const content = files[0].content;
|
|
72
|
+
|
|
73
|
+
// These belong in the hand-maintained WorkOSClient.cs
|
|
74
|
+
expect(content).not.toContain('HttpClient');
|
|
75
|
+
expect(content).not.toContain('ApiKey');
|
|
76
|
+
expect(content).not.toContain('SendAsync');
|
|
77
|
+
expect(content).not.toContain('RequestAsync');
|
|
78
|
+
expect(content).not.toContain('ApiBaseURL');
|
|
79
|
+
expect(content).not.toContain('AuthenticationError');
|
|
80
|
+
expect(content).not.toContain('RateLimitExceededError');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('deduplicates services by mount target', () => {
|
|
84
|
+
const multiSpec: ApiSpec = {
|
|
85
|
+
...spec,
|
|
86
|
+
services: [
|
|
87
|
+
...services,
|
|
88
|
+
{
|
|
89
|
+
name: 'OrganizationsApiKeys',
|
|
90
|
+
operations: [
|
|
91
|
+
{
|
|
92
|
+
name: 'listOrganizationApiKeys',
|
|
93
|
+
httpMethod: 'get',
|
|
94
|
+
path: '/organizations/api_keys',
|
|
95
|
+
pathParams: [],
|
|
96
|
+
queryParams: [],
|
|
97
|
+
headerParams: [],
|
|
98
|
+
response: { kind: 'model', name: 'Organization' },
|
|
99
|
+
errors: [],
|
|
100
|
+
injectIdempotencyKey: false,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const files = generateClient(multiSpec, { ...ctx, spec: multiSpec });
|
|
108
|
+
const content = files[0].content;
|
|
109
|
+
|
|
110
|
+
// Both services should appear since they have different mount targets
|
|
111
|
+
expect(content).toContain('OrganizationsService');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('generates XML doc comments on service properties', () => {
|
|
115
|
+
const files = generateClient(spec, ctx);
|
|
116
|
+
const content = files[0].content;
|
|
117
|
+
|
|
118
|
+
expect(content).toContain('/// <summary>');
|
|
119
|
+
expect(content).toContain('OrganizationsService');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateEnums } from '../../src/dotnet/enums.js';
|
|
3
|
+
import type { EmitterContext, ApiSpec, Enum, Service } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
5
|
+
|
|
6
|
+
const emptySpec: ApiSpec = {
|
|
7
|
+
name: 'Test',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
baseUrl: '',
|
|
10
|
+
services: [],
|
|
11
|
+
models: [],
|
|
12
|
+
enums: [],
|
|
13
|
+
sdk: defaultSdkBehavior(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ctx: EmitterContext = {
|
|
17
|
+
namespace: 'workos',
|
|
18
|
+
namespacePascal: 'WorkOS',
|
|
19
|
+
spec: emptySpec,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('dotnet/enums', () => {
|
|
23
|
+
it('returns empty for no enums', () => {
|
|
24
|
+
expect(generateEnums([], ctx)).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('generates C# enum with EnumMember attributes', () => {
|
|
28
|
+
const enums: Enum[] = [
|
|
29
|
+
{
|
|
30
|
+
name: 'Status',
|
|
31
|
+
values: [
|
|
32
|
+
{ name: 'ACTIVE', value: 'active' },
|
|
33
|
+
{ name: 'INACTIVE', value: 'inactive' },
|
|
34
|
+
{ name: 'PENDING', value: 'pending' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const service: Service = {
|
|
40
|
+
name: 'Organizations',
|
|
41
|
+
operations: [
|
|
42
|
+
{
|
|
43
|
+
name: 'getOrganization',
|
|
44
|
+
httpMethod: 'get',
|
|
45
|
+
path: '/organizations/{id}',
|
|
46
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
47
|
+
queryParams: [{ name: 'status', type: { kind: 'enum', name: 'Status' }, required: false }],
|
|
48
|
+
headerParams: [],
|
|
49
|
+
response: { kind: 'model', name: 'Organization' },
|
|
50
|
+
errors: [],
|
|
51
|
+
injectIdempotencyKey: false,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const files = generateEnums(enums, {
|
|
57
|
+
...ctx,
|
|
58
|
+
spec: { ...emptySpec, services: [service], enums },
|
|
59
|
+
});
|
|
60
|
+
expect(files.length).toBe(1);
|
|
61
|
+
|
|
62
|
+
const content = files[0].content;
|
|
63
|
+
expect(content).toContain('namespace WorkOS');
|
|
64
|
+
expect(content).toContain('public enum Status');
|
|
65
|
+
expect(content).toContain('[EnumMember(Value = "active")]');
|
|
66
|
+
expect(content).toContain('Active');
|
|
67
|
+
expect(content).toContain('[EnumMember(Value = "inactive")]');
|
|
68
|
+
expect(content).toContain('Inactive');
|
|
69
|
+
expect(content).toContain('[EnumMember(Value = "pending")]');
|
|
70
|
+
expect(content).toContain('Pending');
|
|
71
|
+
// Unknown sentinel for forward compatibility
|
|
72
|
+
expect(content).toContain('Unknown');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('skips single-value enums (discriminator consts)', () => {
|
|
76
|
+
const enums: Enum[] = [
|
|
77
|
+
{
|
|
78
|
+
name: 'DiscriminatorType',
|
|
79
|
+
values: [{ name: 'ONLY_VALUE', value: 'only_value' }],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const files = generateEnums(enums, {
|
|
84
|
+
...ctx,
|
|
85
|
+
spec: { ...emptySpec, enums },
|
|
86
|
+
});
|
|
87
|
+
expect(files).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('deduplicates structurally identical enums', () => {
|
|
91
|
+
const enums: Enum[] = [
|
|
92
|
+
{
|
|
93
|
+
name: 'ConnectionType',
|
|
94
|
+
values: [
|
|
95
|
+
{ name: 'SAML', value: 'saml' },
|
|
96
|
+
{ name: 'OIDC', value: 'oidc' },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'ProfileConnectionType',
|
|
101
|
+
values: [
|
|
102
|
+
{ name: 'SAML', value: 'saml' },
|
|
103
|
+
{ name: 'OIDC', value: 'oidc' },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const service: Service = {
|
|
109
|
+
name: 'Test',
|
|
110
|
+
operations: [
|
|
111
|
+
{
|
|
112
|
+
name: 'test',
|
|
113
|
+
httpMethod: 'get',
|
|
114
|
+
path: '/test',
|
|
115
|
+
pathParams: [],
|
|
116
|
+
queryParams: [
|
|
117
|
+
{ name: 'type', type: { kind: 'enum', name: 'ConnectionType' }, required: false },
|
|
118
|
+
{ name: 'profile_type', type: { kind: 'enum', name: 'ProfileConnectionType' }, required: false },
|
|
119
|
+
],
|
|
120
|
+
headerParams: [],
|
|
121
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
122
|
+
errors: [],
|
|
123
|
+
injectIdempotencyKey: false,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const files = generateEnums(enums, {
|
|
129
|
+
...ctx,
|
|
130
|
+
spec: { ...emptySpec, services: [service], enums },
|
|
131
|
+
});
|
|
132
|
+
// Only one enum file should be generated (the canonical)
|
|
133
|
+
expect(files).toHaveLength(1);
|
|
134
|
+
expect(files[0].content).toContain('ConnectionType');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('skips orphan enums not referenced by models or operations', () => {
|
|
138
|
+
const enums: Enum[] = [
|
|
139
|
+
{
|
|
140
|
+
name: 'OrphanEnum',
|
|
141
|
+
values: [
|
|
142
|
+
{ name: 'A', value: 'a' },
|
|
143
|
+
{ name: 'B', value: 'b' },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const files = generateEnums(enums, {
|
|
149
|
+
...ctx,
|
|
150
|
+
spec: { ...emptySpec, enums },
|
|
151
|
+
});
|
|
152
|
+
expect(files).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('generates deprecated enum values with Obsolete attribute', () => {
|
|
156
|
+
const enums: Enum[] = [
|
|
157
|
+
{
|
|
158
|
+
name: 'Status',
|
|
159
|
+
values: [
|
|
160
|
+
{ name: 'ACTIVE', value: 'active' },
|
|
161
|
+
{ name: 'OLD_STATUS', value: 'old_status', deprecated: true, description: 'Use ACTIVE instead' },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const service: Service = {
|
|
167
|
+
name: 'Test',
|
|
168
|
+
operations: [
|
|
169
|
+
{
|
|
170
|
+
name: 'test',
|
|
171
|
+
httpMethod: 'get',
|
|
172
|
+
path: '/test',
|
|
173
|
+
pathParams: [],
|
|
174
|
+
queryParams: [{ name: 'status', type: { kind: 'enum', name: 'Status' }, required: false }],
|
|
175
|
+
headerParams: [],
|
|
176
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
177
|
+
errors: [],
|
|
178
|
+
injectIdempotencyKey: false,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const files = generateEnums(enums, {
|
|
184
|
+
...ctx,
|
|
185
|
+
spec: { ...emptySpec, services: [service], enums },
|
|
186
|
+
});
|
|
187
|
+
expect(files).toHaveLength(1);
|
|
188
|
+
const content = files[0].content;
|
|
189
|
+
|
|
190
|
+
expect(content).toContain('[System.Obsolete');
|
|
191
|
+
expect(content).toContain('OldStatus');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { dotnetEmitter } from '../../src/dotnet/index.js';
|
|
3
|
+
|
|
4
|
+
describe('dotnet/errors', () => {
|
|
5
|
+
it('returns empty array (errors are hand-maintained in the target SDK)', () => {
|
|
6
|
+
const files = dotnetEmitter.generateErrors!({} as any);
|
|
7
|
+
expect(files).toHaveLength(0);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateManifest } from '../../src/dotnet/manifest.js';
|
|
3
|
+
import type { ApiSpec, EmitterContext, Service, Model } from '@workos/oagen';
|
|
4
|
+
import { defaultSdkBehavior } from '@workos/oagen';
|
|
5
|
+
|
|
6
|
+
const models: Model[] = [
|
|
7
|
+
{
|
|
8
|
+
name: 'Organization',
|
|
9
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
10
|
+
},
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const services: Service[] = [
|
|
14
|
+
{
|
|
15
|
+
name: 'Organizations',
|
|
16
|
+
operations: [
|
|
17
|
+
{
|
|
18
|
+
name: 'listOrganizations',
|
|
19
|
+
httpMethod: 'get',
|
|
20
|
+
path: '/organizations',
|
|
21
|
+
pathParams: [],
|
|
22
|
+
queryParams: [],
|
|
23
|
+
headerParams: [],
|
|
24
|
+
response: { kind: 'model', name: 'Organization' },
|
|
25
|
+
errors: [],
|
|
26
|
+
injectIdempotencyKey: false,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'OrganizationsApiKeys',
|
|
32
|
+
operations: [
|
|
33
|
+
{
|
|
34
|
+
name: 'listOrganizationApiKeys',
|
|
35
|
+
httpMethod: 'get',
|
|
36
|
+
path: '/organizations/api_keys',
|
|
37
|
+
pathParams: [],
|
|
38
|
+
queryParams: [],
|
|
39
|
+
headerParams: [],
|
|
40
|
+
response: { kind: 'model', name: 'Organization' },
|
|
41
|
+
errors: [],
|
|
42
|
+
injectIdempotencyKey: false,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const spec: ApiSpec = {
|
|
49
|
+
name: 'TestAPI',
|
|
50
|
+
version: '1.0.0',
|
|
51
|
+
baseUrl: 'https://api.workos.com',
|
|
52
|
+
services,
|
|
53
|
+
models,
|
|
54
|
+
enums: [],
|
|
55
|
+
sdk: defaultSdkBehavior(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const ctx: EmitterContext = {
|
|
59
|
+
namespace: 'workos',
|
|
60
|
+
namespacePascal: 'WorkOS',
|
|
61
|
+
spec,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('dotnet/manifest', () => {
|
|
65
|
+
it('generates smoke-manifest.json', () => {
|
|
66
|
+
const files = generateManifest(spec, ctx);
|
|
67
|
+
expect(files).toHaveLength(1);
|
|
68
|
+
expect(files[0].path).toBe('smoke-manifest.json');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('maps HTTP operations to SDK method names and services', () => {
|
|
72
|
+
const files = generateManifest(spec, ctx);
|
|
73
|
+
const manifest = JSON.parse(files[0].content) as Record<string, { sdkMethod: string; service: string }>;
|
|
74
|
+
|
|
75
|
+
expect(manifest['GET /organizations']).toBeDefined();
|
|
76
|
+
expect(manifest['GET /organizations'].sdkMethod).toBeDefined();
|
|
77
|
+
expect(manifest['GET /organizations'].service).toBeDefined();
|
|
78
|
+
|
|
79
|
+
expect(manifest['GET /organizations/api_keys']).toBeDefined();
|
|
80
|
+
expect(manifest['GET /organizations/api_keys'].sdkMethod).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
});
|