@workos/oagen-emitters 0.3.0 → 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.
Files changed (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -1,4 +1,4 @@
1
- import type { Model, Field, TypeRef } from '@workos/oagen';
1
+ import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  // @ts-ignore -- js-yaml has no type declarations in this project
@@ -103,13 +103,80 @@ function lookupRawSchema(name: string): Record<string, any> | null {
103
103
  return spec?.components?.schemas?.[name] ?? null;
104
104
  }
105
105
 
106
- /** Convert a raw OpenAPI type+format to an IR TypeRef. */
107
- function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
106
+ // ---------------------------------------------------------------------------
107
+ // Synthetic model / enum collection
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Accumulator for synthetic models and enums generated from inline
112
+ * definitions encountered during oneOf flattening.
113
+ */
114
+ interface SyntheticCollector {
115
+ models: Model[];
116
+ enums: Array<{ name: string; values: Array<{ value: string; description?: string }> }>;
117
+ /** Track names already used to avoid duplicates. */
118
+ usedNames: Set<string>;
119
+ }
120
+
121
+ function createCollector(): SyntheticCollector {
122
+ return { models: [], enums: [], usedNames: new Set() };
123
+ }
124
+
125
+ /**
126
+ * Singularize a snake_case name for use as an array-item model name.
127
+ * `redirect_uris` -> `redirect_uri`, `scopes` -> `scope`.
128
+ */
129
+ function singularizeSnake(name: string): string {
130
+ if (name.endsWith('ies') && name.length > 3) {
131
+ return `${name.slice(0, -3)}y`;
132
+ }
133
+ if (name.endsWith('s') && !name.endsWith('ss')) {
134
+ return name.slice(0, -1);
135
+ }
136
+ return name;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // rawSchemaToTypeRef -- with synthetic model/enum generation
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Convert a raw OpenAPI type+format to an IR TypeRef.
145
+ *
146
+ * When `parentModelName` and `fieldName` are provided, inline objects and
147
+ * enums generate synthetic models/enums instead of degrading to `unknown`
148
+ * or `string`.
149
+ */
150
+ function rawSchemaToTypeRef(
151
+ schema: Record<string, any>,
152
+ parentModelName?: string,
153
+ fName?: string,
154
+ collector?: SyntheticCollector,
155
+ ): TypeRef {
108
156
  if (schema.const !== undefined) {
109
157
  return { kind: 'literal', value: schema.const };
110
158
  }
159
+ if (schema.enum && collector && parentModelName && fName) {
160
+ // Generate a synthetic enum
161
+ const syntheticName = `${parentModelName}_${fName}`;
162
+ if (!collector.usedNames.has(syntheticName)) {
163
+ collector.usedNames.add(syntheticName);
164
+ collector.enums.push({
165
+ name: syntheticName,
166
+ values: (schema.enum as string[]).map((v: string) => ({
167
+ value: v,
168
+ description: undefined,
169
+ })),
170
+ });
171
+ }
172
+ return {
173
+ kind: 'enum',
174
+ name: syntheticName,
175
+ values: schema.enum as string[],
176
+ } as TypeRef;
177
+ }
111
178
  if (schema.enum) {
112
- // Simple string enum -- represent as primitive string
179
+ // Simple string enum -- represent as primitive string (no collector)
113
180
  return { kind: 'primitive', type: 'string' } as TypeRef;
114
181
  }
115
182
  if (schema.$ref) {
@@ -127,11 +194,39 @@ function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
127
194
  }
128
195
 
129
196
  let ref: TypeRef;
130
- if (baseType === 'object' && schema.properties) {
131
- // Inline object -- treat as unknown
197
+ if (baseType === 'object' && schema.properties && collector && parentModelName && fName) {
198
+ // Inline object -- generate a synthetic model
199
+ const syntheticName = `${parentModelName}_${fName}`;
200
+ if (!collector.usedNames.has(syntheticName)) {
201
+ collector.usedNames.add(syntheticName);
202
+ const fields: Field[] = [];
203
+ const requiredSet = new Set<string>(schema.required ?? []);
204
+ for (const [propName, propSchema] of Object.entries(schema.properties) as [string, Record<string, any>][]) {
205
+ fields.push({
206
+ name: propName,
207
+ type: rawSchemaToTypeRef(propSchema, syntheticName, propName, collector),
208
+ required: requiredSet.has(propName),
209
+ description: propSchema.description,
210
+ deprecated: propSchema.deprecated,
211
+ });
212
+ }
213
+ collector.models.push({
214
+ name: syntheticName,
215
+ fields,
216
+ description: schema.description,
217
+ } as Model);
218
+ }
219
+ ref = { kind: 'model', name: syntheticName } as TypeRef;
220
+ } else if (baseType === 'object' && schema.properties) {
221
+ // Inline object -- treat as unknown (no collector)
132
222
  ref = { kind: 'primitive', type: 'unknown' } as TypeRef;
133
223
  } else if (baseType === 'array' && schema.items) {
134
- ref = { kind: 'array', items: rawSchemaToTypeRef(schema.items) } as TypeRef;
224
+ // For array items that are inline objects, use the singular field name
225
+ const itemFieldName = fName ? singularizeSnake(fName) : undefined;
226
+ ref = {
227
+ kind: 'array',
228
+ items: rawSchemaToTypeRef(schema.items, parentModelName, itemFieldName, collector),
229
+ } as TypeRef;
135
230
  } else if (baseType === 'boolean') {
136
231
  ref = { kind: 'primitive', type: 'boolean' } as TypeRef;
137
232
  } else if (baseType === 'integer' || baseType === 'number') {
@@ -151,13 +246,17 @@ function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
151
246
  * All fields are returned as optional (not required) since they come from
152
247
  * oneOf variants where only one variant is active at a time.
153
248
  */
154
- function extractFieldsFromRawSchema(schema: Record<string, any>): Field[] {
249
+ function extractFieldsFromRawSchema(
250
+ schema: Record<string, any>,
251
+ parentModelName?: string,
252
+ collector?: SyntheticCollector,
253
+ ): Field[] {
155
254
  const fields: Field[] = [];
156
255
  const props = schema.properties ?? {};
157
256
  for (const [name, propSchema] of Object.entries(props) as [string, Record<string, any>][]) {
158
257
  fields.push({
159
258
  name,
160
- type: rawSchemaToTypeRef(propSchema),
259
+ type: rawSchemaToTypeRef(propSchema, parentModelName, name, collector),
161
260
  required: false, // All oneOf variant fields are optional
162
261
  description: propSchema.description,
163
262
  deprecated: propSchema.deprecated,
@@ -170,14 +269,18 @@ function extractFieldsFromRawSchema(schema: Record<string, any>): Field[] {
170
269
  * Recursively collect all fields from a oneOf schema, flattening nested
171
270
  * allOf+oneOf compositions. All fields are marked optional.
172
271
  */
173
- function collectOneOfFields(schema: Record<string, any>): Field[] {
272
+ function collectOneOfFields(
273
+ schema: Record<string, any>,
274
+ parentModelName?: string,
275
+ collector?: SyntheticCollector,
276
+ ): Field[] {
174
277
  const allFields: Field[] = [];
175
278
  const seenFieldNames = new Set<string>();
176
279
 
177
280
  function walkSchema(s: Record<string, any>): void {
178
281
  // Direct properties
179
282
  if (s.properties) {
180
- for (const f of extractFieldsFromRawSchema(s)) {
283
+ for (const f of extractFieldsFromRawSchema(s, parentModelName, collector)) {
181
284
  if (!seenFieldNames.has(f.name)) {
182
285
  seenFieldNames.add(f.name);
183
286
  allFields.push(f);
@@ -208,6 +311,83 @@ function collectOneOfFields(schema: Record<string, any>): Field[] {
208
311
  return allFields;
209
312
  }
210
313
 
314
+ // ---------------------------------------------------------------------------
315
+ // Array-item type upgrade
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /**
319
+ * Check if a TypeRef is `unknown` (the degraded type for inline objects).
320
+ */
321
+ function isUnknownType(ref: TypeRef): boolean {
322
+ return ref.kind === 'primitive' && (ref as any).type === 'unknown';
323
+ }
324
+
325
+ /**
326
+ * If a field is an `array<unknown>` (or `nullable<array<unknown>>`) and the
327
+ * raw spec defines inline object/enum items, replace the item type with a
328
+ * synthetic model/enum. Returns the original field unchanged when no upgrade
329
+ * is needed.
330
+ */
331
+ function upgradeArrayItemType(
332
+ field: Field,
333
+ rawSchema: Record<string, any>,
334
+ parentModelName: string,
335
+ collector: SyntheticCollector,
336
+ ): Field {
337
+ // Unwrap nullable to find the array
338
+ let arrayRef: TypeRef | null = null;
339
+ let isNullableWrapper = false;
340
+ if (field.type.kind === 'array') {
341
+ arrayRef = field.type;
342
+ } else if (field.type.kind === 'nullable' && field.type.inner.kind === 'array') {
343
+ arrayRef = field.type.inner;
344
+ isNullableWrapper = true;
345
+ }
346
+ if (!arrayRef || arrayRef.kind !== 'array') return field;
347
+ if (!isUnknownType(arrayRef.items)) return field;
348
+
349
+ // Look up the raw spec for this field
350
+ const rawProp = rawSchema.properties?.[field.name];
351
+ if (!rawProp) return field;
352
+
353
+ // Handle the case where the raw property is inside a nullable type array
354
+ let rawArraySchema = rawProp;
355
+ if (Array.isArray(rawProp.type)) {
356
+ const nonNull = rawProp.type.filter((t: string) => t !== 'null');
357
+ if (nonNull[0] === 'array') {
358
+ rawArraySchema = rawProp;
359
+ }
360
+ }
361
+ if (rawArraySchema.type !== 'array' && !(Array.isArray(rawArraySchema.type) && rawArraySchema.type.includes('array')))
362
+ return field;
363
+ if (!rawArraySchema.items) return field;
364
+
365
+ // Generate a proper TypeRef from the raw items schema
366
+ const itemFieldName = singularizeSnake(field.name);
367
+ const newItemRef = rawSchemaToTypeRef(rawArraySchema.items, parentModelName, itemFieldName, collector);
368
+ if (isUnknownType(newItemRef)) return field;
369
+
370
+ const newArrayRef: TypeRef = { kind: 'array', items: newItemRef } as TypeRef;
371
+ const newType: TypeRef = isNullableWrapper ? ({ kind: 'nullable', inner: newArrayRef } as TypeRef) : newArrayRef;
372
+
373
+ return { ...field, type: newType };
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Module-level store for synthetic enums produced during enrichment.
378
+ // Consumed by `getSyntheticEnums()` after `enrichModelsFromSpec` runs.
379
+ // ---------------------------------------------------------------------------
380
+ let _lastSyntheticEnums: Enum[] = [];
381
+
382
+ /**
383
+ * Return the synthetic enums generated during the last call to
384
+ * `enrichModelsFromSpec`. Call this after enrichment to merge them into the
385
+ * enum generation phase.
386
+ */
387
+ export function getSyntheticEnums(): Enum[] {
388
+ return _lastSyntheticEnums;
389
+ }
390
+
211
391
  /**
212
392
  * Enrich IR models by flattening oneOf/allOf+oneOf variant fields from the raw spec.
213
393
  *
@@ -217,13 +397,24 @@ function collectOneOfFields(schema: Record<string, any>): Field[] {
217
397
  * For models whose raw spec schema has allOf containing a oneOf:
218
398
  * - Collect the missing variant fields and add them as optional.
219
399
  *
400
+ * Inline objects and enums in oneOf branches are promoted to synthetic
401
+ * models/enums instead of degrading to `object` / `string`.
402
+ *
220
403
  * Returns a new array of enriched models (original models are not mutated).
404
+ * Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
221
405
  */
222
406
  export function enrichModelsFromSpec(models: Model[]): Model[] {
223
407
  const spec = loadRawSpec();
224
- if (!spec) return models;
408
+ if (!spec) {
409
+ _lastSyntheticEnums = [];
410
+ return models;
411
+ }
225
412
 
226
- return models.map((model) => {
413
+ const collector = createCollector();
414
+ // Avoid name collisions with existing models
415
+ for (const m of models) collector.usedNames.add(m.name);
416
+
417
+ const enriched = models.map((model) => {
227
418
  const rawSchema = lookupRawSchema(model.name);
228
419
  if (!rawSchema) return model;
229
420
 
@@ -237,8 +428,9 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
237
428
  rawSchema.allOf?.some((s: any) => s.discriminator || s.oneOf?.some((v: any) => v.discriminator));
238
429
  if (hasDiscriminator) return model;
239
430
 
240
- // Collect all variant fields from the raw schema
241
- const variantFields = collectOneOfFields(rawSchema);
431
+ // Collect all variant fields from the raw schema, generating synthetic
432
+ // models/enums for inline definitions along the way.
433
+ const variantFields = collectOneOfFields(rawSchema, model.name, collector);
242
434
  if (variantFields.length === 0) return model;
243
435
 
244
436
  // Merge variant fields into the existing model, skipping duplicates
@@ -252,4 +444,29 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
252
444
  fields: [...model.fields, ...newFields],
253
445
  };
254
446
  });
447
+
448
+ // Second pass: fix array fields whose items degraded to `unknown` in the
449
+ // IR but are actually inline objects or enums in the raw spec.
450
+ const enriched2 = enriched.map((model) => {
451
+ const rawSchema = lookupRawSchema(model.name);
452
+ if (!rawSchema?.properties) return model;
453
+
454
+ let modified = false;
455
+ const newFields = model.fields.map((field) => {
456
+ const upgraded = upgradeArrayItemType(field, rawSchema, model.name, collector);
457
+ if (upgraded !== field) modified = true;
458
+ return upgraded;
459
+ });
460
+
461
+ return modified ? { ...model, fields: newFields } : model;
462
+ });
463
+
464
+ // Convert synthetic enum collector entries to proper Enum objects
465
+ _lastSyntheticEnums = collector.enums.map((e) => ({
466
+ name: e.name,
467
+ values: e.values.map((v) => ({ value: v.value, description: v.description })),
468
+ })) as Enum[];
469
+
470
+ // Append synthetic models to the output
471
+ return [...enriched2, ...collector.models];
255
472
  }
@@ -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
@@ -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
- const isOptional = optionalSet.has(paramName) || !field?.required;
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('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
+ });