Skip to content

Commit e15b05d

Browse files
committed
refactor(codegen): drive C# generation from @seamapi/blueprint
Remove all temporary, output-parity workarounds and TODOs from the C# SDK codegen and generate entirely from @seamapi/blueprint. The generator no longer parses the raw OpenAPI spec. - Wire the @seamapi/smith blueprint plugin into smith.ts so generation reads metalsmith.metadata().blueprint. - Add lib/fields.ts to normalize blueprint request parameters and resource properties into a single neutral Field shape. - Rewrite lib/build-model.ts and lib/csharp.ts to build models, discriminated unions (including the Event and ActionAttempt unions), and API route classes from the blueprint. - Clean previously generated Api/Model files before each run so schemas removed from the blueprint no longer leave stale output behind. - Delete the ported OpenAPI parsing helpers (lib/openapi/*), the frozen lib/types.ts, and lib/schema-modifications.ts. The generated output changes as a result: resource model properties are now uniformly optional/nullable, internal /seam and /unstable_partner routes are excluded from the public SDK, and a few routes/resources are added or removed to match the current blueprint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsd5rETycPuurxdfT9LHa4
1 parent f460307 commit e15b05d

114 files changed

Lines changed: 77725 additions & 88914 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

codegen/lib/build-model.ts

Lines changed: 323 additions & 390 deletions
Large diffs are not rendered by default.

codegen/lib/class-model.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Durable data model for the C# SDK codegen.
22
//
33
// These interfaces hold the resolved structure of each generated file, decoupled
4-
// from serialization. build-model.ts produces them from the (raw-OpenAPI)
5-
// schemas; the Handlebars layouts and their context builders turn them into C#.
6-
// String serialization lives entirely in the templates.
4+
// from serialization. build-model.ts produces them from the @seamapi/blueprint;
5+
// the Handlebars layouts turn them into C#. String serialization lives entirely
6+
// in the templates.
77

88
// A single enum member, e.g. `[EnumMember(Value = "setting")] Setting = 1,`.
99
export interface CsEnumMember {

codegen/lib/csharp.ts

Lines changed: 117 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,143 @@
1-
import * as types from '@seamapi/types/connect'
1+
import type { Blueprint, Resource, Route } from '@seamapi/blueprint'
22
import { pascalCase } from 'change-case'
33
import type Metalsmith from 'metalsmith'
44

5-
import { buildApiFile, buildModelFile } from './build-model.js'
6-
import { GLOBAL_NAMESPACE } from './constants.js'
7-
import { deepFlattenAllOfSchema } from './openapi/flatten-obj-schema.js'
8-
import { getFilteredRoutes } from './openapi/get-filtered-routes.js'
9-
import { getParameterAndResponseSchema } from './openapi/get-parameter-and-response-schema.js'
10-
import { modifySchemaForSpecialCases } from './schema-modifications.js'
11-
import type { ObjSchema, OpenAPISchema, PropertySchema } from './types.js'
5+
import { buildApiFile, buildModelFile, buildUnionFile } from './build-model.js'
6+
import {
7+
type Field,
8+
normalizeField,
9+
type RouteEndpoint,
10+
type Variant,
11+
} from './fields.js'
1212

1313
const outputRoot = 'output/csharp/src/Seam'
1414

15-
interface RouteInfo {
16-
methodName: string
17-
path: string
18-
parameterSchema: ObjSchema
19-
responseObjType: string | undefined
20-
responseArrType: string | undefined
21-
isVoid: boolean
22-
nullable: boolean
23-
returnPath: string
15+
// Resource types modeled as top-level discriminated unions instead of plain
16+
// resource classes. `event` is a resource in the blueprint but is emitted from
17+
// blueprint.events; `action_attempt` is only available via blueprint.actionAttempts.
18+
const UNION_RESOURCE_TYPES = new Set(['event', 'action_attempt'])
19+
20+
// Internal API surfaces that the public C# SDK does not expose.
21+
const isInternalRoute = (route: Route): boolean =>
22+
route.path.startsWith('/seam/') || route.path.startsWith('/unstable_partner')
23+
24+
// The API class name reverses the route path segments, e.g. /acs/credential_pools
25+
// becomes CredentialPoolsAcs.
26+
const apiClassName = (route: Route): string =>
27+
pascalCase(route.path.split('/').filter(Boolean).reverse().join('_'))
28+
29+
const resourceClassName = (resourceType: string): string =>
30+
resourceType === 'unknown' ? 'object' : pascalCase(resourceType)
31+
32+
const toEndpoint = (endpoint: Route['endpoints'][number]): RouteEndpoint => {
33+
const parameters: Field[] = endpoint.request.parameters.map(normalizeField)
34+
const { response } = endpoint
35+
36+
if (response.responseType === 'void') {
37+
return {
38+
name: endpoint.name,
39+
path: endpoint.path,
40+
parameters,
41+
isVoid: true,
42+
isList: false,
43+
responseKey: '',
44+
returnType: '',
45+
}
46+
}
47+
48+
return {
49+
name: endpoint.name,
50+
path: endpoint.path,
51+
parameters,
52+
isVoid: false,
53+
isList: response.responseType === 'resource_list',
54+
responseKey: response.responseKey,
55+
returnType: resourceClassName(response.resourceType),
56+
}
2457
}
2558

59+
const resourceVariants = (
60+
resources: Array<Resource & { discriminatorValue: string }>,
61+
): Variant[] =>
62+
resources.map((resource) => ({
63+
value: resource.discriminatorValue,
64+
fields: resource.properties.map(normalizeField),
65+
}))
66+
2667
// Metalsmith plugin that generates the schema-derived C# SDK files: the Api
2768
// route classes (output/csharp/src/Seam/Api/*.cs) and the resource models
2869
// (output/csharp/src/Seam/Model/*.cs). Static, schema-independent files (the
2970
// Client/* runtime, the two static Model helpers, the .sln, the test project)
30-
// are normal committed package source and are intentionally NOT generated here.
71+
// are normal committed package source and are intentionally not generated here.
3172
//
32-
// The iteration reads the raw OpenAPI spec from @seamapi/types rather than
33-
// @seamapi/blueprint so the generated output stays byte-identical to the
34-
// previous generator.
35-
// TODO: Drive iteration and structure from metalsmith.metadata().blueprint once
36-
// the generated output is allowed to change. Blueprint is not wired into the
37-
// pipeline: the port does not use blueprint data, and @seamapi/blueprint does
38-
// not currently parse the pinned @seamapi/types.
39-
export const csharp = (files: Metalsmith.Files): void => {
40-
const openapi = types.openapi as unknown as OpenAPISchema
41-
42-
const classMap: Record<string, RouteInfo[]> = {}
43-
44-
for (const route of getFilteredRoutes(openapi)) {
45-
if (!route.post) continue
46-
if (!route.post['x-fern-sdk-group-name']) continue
47-
48-
// TODO: Use blueprint route/namespace names once the generated output is
49-
// allowed to change. The class name reverses x-fern-sdk-group-name (e.g.
50-
// ['acs', 'credential_pools'] -> CredentialPoolsAcs), a load-bearing quirk
51-
// of the previous generator that must be preserved for output parity.
52-
const groupNames = [...route.post['x-fern-sdk-group-name']]
53-
groupNames.reverse()
54-
const className = pascalCase(groupNames.join('_'))
55-
56-
const {
57-
parameter_schema: parameterSchema,
58-
response_obj_type: responseObjType,
59-
response_arr_type: responseArrType,
60-
nullable,
61-
response_schema: responseSchema,
62-
} = getParameterAndResponseSchema(route)
63-
64-
// TODO: Determine void vs. returning endpoints from
65-
// @seamapi/blueprint endpoint.response once the generated output is allowed
66-
// to change. This reproduces the previous generator's filter, including its
67-
// `ok`-property and x-response-key special-casing, from the raw OpenAPI.
68-
let isVoid = false
69-
if (!responseObjType && !responseArrType) {
70-
if (
71-
!responseSchema ||
72-
'oneOf' in responseSchema ||
73-
(Object.keys(responseSchema.properties).filter(
74-
(k) => k.toLowerCase() !== 'ok',
75-
).length > 0 &&
76-
route.post['x-response-key'] !== null)
77-
) {
78-
continue
79-
}
80-
isVoid = true
73+
// Generation is driven entirely by the @seamapi/blueprint stored in
74+
// metalsmith.metadata().blueprint by the @seamapi/smith blueprint plugin.
75+
export const csharp = (
76+
files: Metalsmith.Files,
77+
metalsmith: Metalsmith,
78+
): void => {
79+
const { blueprint } = metalsmith.metadata() as { blueprint: Blueprint }
80+
81+
const emitModel = (
82+
name: string,
83+
file: ReturnType<typeof buildModelFile>['file'],
84+
): void => {
85+
files[`${outputRoot}/Model/${name}.cs`] = {
86+
contents: Buffer.from('\n'),
87+
layout: 'model.hbs',
88+
...file,
8189
}
90+
}
8291

83-
if (!parameterSchema) continue
84-
85-
;(classMap[className] ??= []).push({
86-
methodName: route.post['x-fern-sdk-method-name'],
87-
path: route.path,
88-
parameterSchema,
89-
responseObjType,
90-
responseArrType,
91-
isVoid,
92-
nullable,
93-
returnPath: route.post['x-fern-sdk-return-value'],
94-
})
92+
for (const resource of blueprint.resources) {
93+
if (UNION_RESOURCE_TYPES.has(resource.resourceType)) continue
94+
const { name, file } = buildModelFile(
95+
resource.resourceType,
96+
resource.properties.map(normalizeField),
97+
)
98+
emitModel(name, file)
9599
}
96100

97-
for (const [className, routes] of Object.entries(classMap)) {
98-
const apiFile = buildApiFile(className, routes)
99-
files[`${outputRoot}/Api/${apiFile.className}.cs`] = {
100-
contents: Buffer.from('\n'),
101-
layout: 'api.hbs',
102-
...apiFile,
103-
}
101+
{
102+
const { name, file } = buildUnionFile(
103+
'event',
104+
'event_type',
105+
resourceVariants(
106+
blueprint.events.map((event) => ({
107+
...event,
108+
discriminatorValue: event.eventType,
109+
})),
110+
),
111+
)
112+
emitModel(name, file)
104113
}
105114

106-
for (const [schemaName, rawSchema] of Object.entries(
107-
openapi.components.schemas,
108-
)) {
109-
let schema = modifySchemaForSpecialCases(
110-
schemaName,
111-
rawSchema as PropertySchema,
115+
{
116+
const { name, file } = buildUnionFile(
117+
'action_attempt',
118+
'action_type',
119+
resourceVariants(
120+
blueprint.actionAttempts.map((actionAttempt) => ({
121+
...actionAttempt,
122+
discriminatorValue: actionAttempt.actionAttemptType,
123+
})),
124+
),
112125
)
126+
emitModel(name, file)
127+
}
113128

114-
if ('allOf' in schema) {
115-
const flattened = deepFlattenAllOfSchema(schema)
116-
if (flattened == null) continue
117-
schema = flattened
118-
}
129+
for (const route of blueprint.routes) {
130+
if (isInternalRoute(route)) continue
131+
if (route.endpoints.length === 0) continue
119132

120-
const { name, file } = buildModelFile(schemaName, schema, [
121-
...GLOBAL_NAMESPACE,
122-
'Model',
123-
])
124-
files[`${outputRoot}/Model/${name}.cs`] = {
133+
const apiFile = buildApiFile(
134+
apiClassName(route),
135+
route.endpoints.map(toEndpoint),
136+
)
137+
files[`${outputRoot}/Api/${apiFile.className}.cs`] = {
125138
contents: Buffer.from('\n'),
126-
layout: 'model.hbs',
127-
...file,
139+
layout: 'api.hbs',
140+
...apiFile,
128141
}
129142
}
130143
}

0 commit comments

Comments
 (0)