@workos/oagen-emitters 0.3.0 → 0.5.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- 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 +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- 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 +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared acronym fixes
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base set of acronym corrections applied after PascalCase conversion.
|
|
7
|
+
* These are domain-specific terms that `toPascalCase` produces as e.g.
|
|
8
|
+
* "Sso" but should be rendered as "SSO" in every SDK language.
|
|
9
|
+
*
|
|
10
|
+
* Language emitters can extend this with additional entries (e.g. Go adds
|
|
11
|
+
* API, URL, HTTP, JSON, etc. per Go naming conventions).
|
|
12
|
+
*/
|
|
13
|
+
export const BASE_ACRONYM_FIXES: readonly [RegExp, string][] = [
|
|
14
|
+
[/Workos/g, 'WorkOS'],
|
|
15
|
+
[/Sso/g, 'SSO'],
|
|
16
|
+
[/Mfa/g, 'MFA'],
|
|
17
|
+
[/Jwt/g, 'JWT'],
|
|
18
|
+
[/Cors/g, 'CORS'],
|
|
19
|
+
[/Saml/g, 'SAML'],
|
|
20
|
+
[/Scim/g, 'SCIM'],
|
|
21
|
+
[/Rbac/g, 'RBAC'],
|
|
22
|
+
[/Oauth/g, 'OAuth'],
|
|
23
|
+
[/Oidc/g, 'OIDC'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Apply acronym corrections to a PascalCase string.
|
|
28
|
+
* Uses the base set by default; pass extra entries for language-specific
|
|
29
|
+
* conventions (e.g. Go's `[/Api(?=[A-Z]|$)/g, 'API']`).
|
|
30
|
+
*/
|
|
31
|
+
export function applyAcronymFixes(s: string, extra?: readonly [RegExp, string][]): string {
|
|
32
|
+
let result = s;
|
|
33
|
+
for (const [pattern, replacement] of BASE_ACRONYM_FIXES) {
|
|
34
|
+
result = result.replace(pattern, replacement);
|
|
35
|
+
}
|
|
36
|
+
if (extra) {
|
|
37
|
+
for (const [pattern, replacement] of extra) {
|
|
38
|
+
result = result.replace(pattern, replacement);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// URN stripping
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
1
48
|
/** Strip URN OAuth grant-type prefixes from discriminator-derived schema names. */
|
|
2
49
|
export function stripUrnPrefix(name: string): string {
|
|
3
50
|
// Handles both OAuth and Oauth casing from different PascalCase implementations
|
|
@@ -78,10 +125,46 @@ function startsWithVerb(desc: string): boolean {
|
|
|
78
125
|
return VERB_STARTERS.has(firstWord);
|
|
79
126
|
}
|
|
80
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Words beginning with a vowel letter but a consonant /j/ or /w/ sound —
|
|
130
|
+
* take "a", not "an".
|
|
131
|
+
*/
|
|
132
|
+
const CONSONANT_SOUND_INITIAL_VOWEL = new Set([
|
|
133
|
+
'user',
|
|
134
|
+
'unit',
|
|
135
|
+
'unique',
|
|
136
|
+
'united',
|
|
137
|
+
'universal',
|
|
138
|
+
'university',
|
|
139
|
+
'european',
|
|
140
|
+
'one',
|
|
141
|
+
'once',
|
|
142
|
+
'useful',
|
|
143
|
+
'used',
|
|
144
|
+
'usable',
|
|
145
|
+
'ubiquitous',
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Words beginning with a consonant letter but a vowel sound (silent h) —
|
|
150
|
+
* take "an", not "a".
|
|
151
|
+
*/
|
|
152
|
+
const VOWEL_SOUND_INITIAL_CONSONANT = new Set(['hour', 'honest', 'honor', 'honorable', 'heir']);
|
|
153
|
+
|
|
81
154
|
/**
|
|
82
155
|
* Select the correct indefinite article ("a" or "an") for a word.
|
|
156
|
+
*
|
|
157
|
+
* Matches English phonetics, not orthography: "a user" (consonant /j/ sound
|
|
158
|
+
* despite leading 'u'), "an hour" (vowel sound despite leading 'h'). Falls
|
|
159
|
+
* back to a vowel-letter regex for words not in either exception set.
|
|
83
160
|
*/
|
|
84
161
|
export function articleFor(word: string): string {
|
|
162
|
+
const firstWord = word
|
|
163
|
+
.split(/\s+/)[0]
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.replace(/[^a-z]/g, '');
|
|
166
|
+
if (CONSONANT_SOUND_INITIAL_VOWEL.has(firstWord)) return 'a';
|
|
167
|
+
if (VOWEL_SOUND_INITIAL_CONSONANT.has(firstWord)) return 'an';
|
|
85
168
|
return /^[aeiou]/i.test(word) ? 'an' : 'a';
|
|
86
169
|
}
|
|
87
170
|
|
|
@@ -18,6 +18,17 @@ export interface NonSpecService {
|
|
|
18
18
|
* someone reading this file understands what the service does.
|
|
19
19
|
*/
|
|
20
20
|
description: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* When true, the generated Client struct includes a cached field for this
|
|
24
|
+
* service and a public accessor method — identical to spec-driven services.
|
|
25
|
+
* The hand-written file must export the service type (e.g. PasswordlessService)
|
|
26
|
+
* but should NOT define its own Client accessor (the generated code handles that).
|
|
27
|
+
*
|
|
28
|
+
* Defaults to false — most non-spec modules are standalone helpers, not
|
|
29
|
+
* Client-mounted services.
|
|
30
|
+
*/
|
|
31
|
+
hasClientAccessor?: boolean;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/**
|
|
@@ -29,10 +40,12 @@ export const NON_SPEC_SERVICES: readonly NonSpecService[] = [
|
|
|
29
40
|
{
|
|
30
41
|
id: 'passwordless',
|
|
31
42
|
description: 'Passwordless (magic-link) session endpoints, not yet in the OpenAPI spec.',
|
|
43
|
+
hasClientAccessor: true,
|
|
32
44
|
},
|
|
33
45
|
{
|
|
34
46
|
id: 'vault',
|
|
35
47
|
description: 'Vault KV storage, key operations, and client-side AES-GCM encrypt/decrypt.',
|
|
48
|
+
hasClientAccessor: true,
|
|
36
49
|
},
|
|
37
50
|
{
|
|
38
51
|
id: 'webhook_verification',
|
|
@@ -1,11 +1,42 @@
|
|
|
1
|
-
import type { Operation, EmitterContext, Service, ResolvedOperation } from '@workos/oagen';
|
|
1
|
+
import type { Operation, EmitterContext, Service, ResolvedOperation, Model, TypeRef } from '@workos/oagen';
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Fail fast when two distinct paths in the same mount resolve to the same SDK
|
|
6
|
+
* method name. Emitters can sometimes paper over this with per-language
|
|
7
|
+
* deduplication, but manifests and cross-language parity become inconsistent.
|
|
8
|
+
*/
|
|
9
|
+
export function assertUniqueResolvedMethods(ctx: EmitterContext): void {
|
|
10
|
+
const seen = new Map<string, { path: string; httpMethod: string }>();
|
|
11
|
+
|
|
12
|
+
for (const resolved of ctx.resolvedOperations ?? []) {
|
|
13
|
+
const key = `${resolved.mountOn}.${resolved.methodName}`;
|
|
14
|
+
const current = {
|
|
15
|
+
path: resolved.operation.path,
|
|
16
|
+
httpMethod: resolved.operation.httpMethod.toUpperCase(),
|
|
17
|
+
};
|
|
18
|
+
const existing = seen.get(key);
|
|
19
|
+
|
|
20
|
+
if (existing && existing.path !== current.path) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Resolved operation name collision for ${key}: ` +
|
|
23
|
+
`${existing.httpMethod} ${existing.path} conflicts with ${current.httpMethod} ${current.path}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!existing) {
|
|
28
|
+
seen.set(key, current);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
4
33
|
/**
|
|
5
34
|
* Build a lookup map from "METHOD /path" to ResolvedOperation.
|
|
6
35
|
* Used by emitters to find the resolved method name for any IR operation.
|
|
7
36
|
*/
|
|
8
37
|
export function buildResolvedLookup(ctx: EmitterContext): Map<string, ResolvedOperation> {
|
|
38
|
+
assertUniqueResolvedMethods(ctx);
|
|
39
|
+
|
|
9
40
|
const map = new Map<string, ResolvedOperation>();
|
|
10
41
|
for (const r of ctx.resolvedOperations ?? []) {
|
|
11
42
|
const key = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}`;
|
|
@@ -107,3 +138,46 @@ export function buildHiddenParams(resolvedOp?: ResolvedOperation): Set<string> {
|
|
|
107
138
|
export function hasHiddenParams(resolvedOp?: ResolvedOperation): boolean {
|
|
108
139
|
return Object.keys(getOpDefaults(resolvedOp)).length > 0 || getOpInferFromClient(resolvedOp).length > 0;
|
|
109
140
|
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Parameter group helpers
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Collect all parameter names that belong to any mutually-exclusive parameter group.
|
|
148
|
+
* These params are serialized via group-level dispatch (e.g. applyToQuery, isinstance,
|
|
149
|
+
* sealed-class when, etc.) instead of individual struct/class fields.
|
|
150
|
+
*/
|
|
151
|
+
export function collectGroupedParamNames(op: Operation): Set<string> {
|
|
152
|
+
const names = new Set<string>();
|
|
153
|
+
for (const group of op.parameterGroups ?? []) {
|
|
154
|
+
for (const variant of group.variants) {
|
|
155
|
+
for (const param of variant.parameters) {
|
|
156
|
+
names.add(param.name);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return names;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build a fallback map from request-body wire field name to TypeRef.
|
|
165
|
+
*
|
|
166
|
+
* Some parameter-group variants lose array/object fidelity in the IR and fall
|
|
167
|
+
* back to primitive strings. Cross-referencing the request body model restores
|
|
168
|
+
* the actual field type when the grouped params belong to the body.
|
|
169
|
+
*/
|
|
170
|
+
export function collectBodyFieldTypes(op: Operation, models: Model[]): Map<string, TypeRef> {
|
|
171
|
+
const fieldTypes = new Map<string, TypeRef>();
|
|
172
|
+
const reqBody = op.requestBody;
|
|
173
|
+
if (reqBody?.kind !== 'model') return fieldTypes;
|
|
174
|
+
|
|
175
|
+
const bodyModel = models.find((model) => model.name === reqBody.name);
|
|
176
|
+
if (!bodyModel) return fieldTypes;
|
|
177
|
+
|
|
178
|
+
for (const field of bodyModel.fields) {
|
|
179
|
+
fieldTypes.set(field.name, field.type);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return fieldTypes;
|
|
183
|
+
}
|
|
@@ -42,7 +42,18 @@ export function resolveWrapperParams(wrapper: ResolvedWrapper, ctx: EmitterConte
|
|
|
42
42
|
return wrapper.exposedParams.map((paramName) => {
|
|
43
43
|
const field =
|
|
44
44
|
variantFields.find((f) => f.name === paramName || toSnakeCase(f.name) === toSnakeCase(paramName)) ?? null;
|
|
45
|
-
|
|
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
|
+
}
|
|
46
57
|
return { paramName, field, isOptional };
|
|
47
58
|
});
|
|
48
59
|
}
|
|
@@ -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('AuthenticationException');
|
|
80
|
+
expect(content).not.toContain('RateLimitExceededException');
|
|
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
|
+
});
|