Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
866 changes: 452 additions & 414 deletions codegen/lib/build-model.ts

Large diffs are not rendered by default.

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

// A single enum member, e.g. `[EnumMember(Value = "setting")] Setting = 1,`.
export interface CsEnumMember {
Expand Down
163 changes: 57 additions & 106 deletions codegen/lib/csharp.ts
Original file line number Diff line number Diff line change
@@ -1,130 +1,81 @@
import * as types from '@seamapi/types/connect'
import type { Blueprint, Endpoint } from '@seamapi/blueprint'
import { pascalCase } from 'change-case'
import type Metalsmith from 'metalsmith'

import { buildApiFile, buildModelFile } from './build-model.js'
import { GLOBAL_NAMESPACE } from './constants.js'
import { deepFlattenAllOfSchema } from './openapi/flatten-obj-schema.js'
import { getFilteredRoutes } from './openapi/get-filtered-routes.js'
import { getParameterAndResponseSchema } from './openapi/get-parameter-and-response-schema.js'
import { modifySchemaForSpecialCases } from './schema-modifications.js'
import type { ObjSchema, OpenAPISchema, PropertySchema } from './types.js'
import {
buildActionAttemptFile,
buildApiFile,
buildEventFile,
buildModelFile,
} from './build-model.js'

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

interface RouteInfo {
methodName: string
path: string
parameterSchema: ObjSchema
responseObjType: string | undefined
responseArrType: string | undefined
isVoid: boolean
nullable: boolean
returnPath: string
}
// Resource types that are emitted as discriminated unions rather than plain
// model classes.
const UNION_RESOURCE_TYPES = new Set(['event', 'action_attempt'])

// Derives the Api class name from a route path: the path segments in reverse,
// pascal-cased (e.g. /acs/credential_pools -> CredentialPoolsAcs).
const apiClassName = (path: string): string =>
pascalCase(path.split('/').filter(Boolean).reverse().join('_'))

// Metalsmith plugin that generates the schema-derived C# SDK files: the Api
// Metalsmith plugin that generates the blueprint-derived C# SDK files: the Api
// route classes (output/csharp/src/Seam/Api/*.cs) and the resource models
// (output/csharp/src/Seam/Model/*.cs). Static, schema-independent files (the
// Client/* runtime, the two static Model helpers, the .sln, the test project)
// are normal committed package source and are intentionally NOT generated here.
// Client/* runtime, the static Model helpers, the .sln, the test project) are
// normal committed package source and are intentionally NOT generated here.
//
// The iteration reads the raw OpenAPI spec from @seamapi/types rather than
// @seamapi/blueprint so the generated output stays byte-identical to the
// previous generator.
// TODO: Drive iteration and structure from metalsmith.metadata().blueprint once
// the generated output is allowed to change. Blueprint is not wired into the
// pipeline: the port does not use blueprint data, and @seamapi/blueprint does
// not currently parse the pinned @seamapi/types.
export const csharp = (files: Metalsmith.Files): void => {
const openapi = types.openapi as unknown as OpenAPISchema

const classMap: Record<string, RouteInfo[]> = {}

for (const route of getFilteredRoutes(openapi)) {
if (!route.post) continue
if (!route.post['x-fern-sdk-group-name']) continue

// TODO: Use blueprint route/namespace names once the generated output is
// allowed to change. The class name reverses x-fern-sdk-group-name (e.g.
// ['acs', 'credential_pools'] -> CredentialPoolsAcs), a load-bearing quirk
// of the previous generator that must be preserved for output parity.
const groupNames = [...route.post['x-fern-sdk-group-name']]
groupNames.reverse()
const className = pascalCase(groupNames.join('_'))

const {
parameter_schema: parameterSchema,
response_obj_type: responseObjType,
response_arr_type: responseArrType,
nullable,
response_schema: responseSchema,
} = getParameterAndResponseSchema(route)
// The blueprint is placed on the Metalsmith metadata by the @seamapi/smith
// `blueprint` plugin, which must run before this one.
export const csharp = (
files: Metalsmith.Files,
metalsmith: Metalsmith,
): void => {
const { blueprint } = metalsmith.metadata() as { blueprint: Blueprint }

// TODO: Determine void vs. returning endpoints from
// @seamapi/blueprint endpoint.response once the generated output is allowed
// to change. This reproduces the previous generator's filter, including its
// `ok`-property and x-response-key special-casing, from the raw OpenAPI.
let isVoid = false
if (!responseObjType && !responseArrType) {
if (
!responseSchema ||
'oneOf' in responseSchema ||
(Object.keys(responseSchema.properties).filter(
(k) => k.toLowerCase() !== 'ok',
).length > 0 &&
route.post['x-response-key'] !== null)
) {
continue
}
isVoid = true
const writeModel = (name: string, file: unknown): void => {
files[`${outputRoot}/Model/${name}.cs`] = {
contents: Buffer.from('\n'),
layout: 'model.hbs',
...(file as object),
}
}

if (!parameterSchema) continue
for (const resource of blueprint.resources) {
if (UNION_RESOURCE_TYPES.has(resource.resourceType)) continue
const { name, file } = buildModelFile(resource)
writeModel(name, file)
}

;(classMap[className] ??= []).push({
methodName: route.post['x-fern-sdk-method-name'],
path: route.path,
parameterSchema,
responseObjType,
responseArrType,
isVoid,
nullable,
returnPath: route.post['x-fern-sdk-return-value'],
})
if (blueprint.actionAttempts.length > 0) {
const { name, file } = buildActionAttemptFile(blueprint.actionAttempts)
writeModel(name, file)
}

for (const [className, routes] of Object.entries(classMap)) {
const apiFile = buildApiFile(className, routes)
files[`${outputRoot}/Api/${apiFile.className}.cs`] = {
contents: Buffer.from('\n'),
layout: 'api.hbs',
...apiFile,
}
if (blueprint.events.length > 0) {
const { name, file } = buildEventFile(blueprint.events)
writeModel(name, file)
}

for (const [schemaName, rawSchema] of Object.entries(
openapi.components.schemas,
)) {
let schema = modifySchemaForSpecialCases(
schemaName,
rawSchema as PropertySchema,
const endpointsByClass = new Map<string, Endpoint[]>()
for (const route of blueprint.routes) {
if (route.isUndocumented) continue
const endpoints = route.endpoints.filter(
(endpoint) => !endpoint.isUndocumented,
)
if (endpoints.length === 0) continue
const className = apiClassName(route.path)
const existing = endpointsByClass.get(className) ?? []
endpointsByClass.set(className, [...existing, ...endpoints])
}

if ('allOf' in schema) {
const flattened = deepFlattenAllOfSchema(schema)
if (flattened == null) continue
schema = flattened
}

const { name, file } = buildModelFile(schemaName, schema, [
...GLOBAL_NAMESPACE,
'Model',
])
files[`${outputRoot}/Model/${name}.cs`] = {
for (const [className, endpoints] of endpointsByClass) {
const apiFile = buildApiFile(className, endpoints)
files[`${outputRoot}/Api/${apiFile.className}.cs`] = {
contents: Buffer.from('\n'),
layout: 'model.hbs',
...file,
layout: 'api.hbs',
...apiFile,
}
}
}
136 changes: 0 additions & 136 deletions codegen/lib/openapi/deep-flatten-one-of-and-all-of-schema.ts

This file was deleted.

Loading
Loading