diff --git a/codegen/lib/endpoint-rules.ts b/codegen/lib/endpoint-rules.ts deleted file mode 100644 index 9e7e9d3..0000000 --- a/codegen/lib/endpoint-rules.ts +++ /dev/null @@ -1,23 +0,0 @@ -// TEMPORARY: Verbatim port of @seamapi/nextlove-sdk-generator -// lib/endpoint-rules.ts. These lists only preserve legacy generated output: -// the ignored paths reproduce the previous endpoint filtering, and the -// deprecated action attempt list keeps those endpoints returning None. -// TODO: Delete this file once generated output is allowed to change; filter -// on blueprint undocumented flags instead and let the deprecated endpoints -// return their real response types. - -export const endpointsReturningDeprecatedActionAttempt = [ - '/access_codes/delete', - '/access_codes/unmanaged/delete', - '/access_codes/update', - '/noise_sensors/noise_thresholds/delete', - '/noise_sensors/noise_thresholds/update', - '/thermostats/climate_setting_schedules/update', -] - -export const ignoredEndpointPaths = [ - '/health', - '/health/get_health', - '/health/get_service_health', - '/health/service/[service_name]', -] diff --git a/codegen/lib/layouts/client.ts b/codegen/lib/layouts/client.ts index b14f73b..f69efca 100644 --- a/codegen/lib/layouts/client.ts +++ b/codegen/lib/layouts/client.ts @@ -2,7 +2,6 @@ // (lib/seam/routes/clients/{snake_name}.rb). // Mirrors the output of the nextlove RubyClient#serialize. -import { endpointsReturningDeprecatedActionAttempt } from '../endpoint-rules.js' import { type ClientMethod, type ClientModel, @@ -37,10 +36,7 @@ const getMethodLayoutContext = ( const { methodName, path, parameters, returnResource, returnPath } = method const hasReturnValue = returnResource != null && returnPath !== '' - const returnsDeprecatedActionAttempt = - endpointsReturningDeprecatedActionAttempt.includes(path) - const canPollActionAttempt = - returnPath === 'action_attempt' && !returnsDeprecatedActionAttempt + const canPollActionAttempt = returnPath === 'action_attempt' const hasParams = parameters.length > 0 const sortedParameters = sortClientMethodParameters(parameters) @@ -54,13 +50,9 @@ const getMethodLayoutContext = ( .map((p) => `${p.name}: ${p.name}`) .join(', ')}}` - // These three branches mirror the nextlove serializer and are independent by - // design, not mutually exclusive: the resource line renders when there is a - // return value that is not polled, and the nil line renders when there is no - // return value or the endpoint returns a deprecated action attempt. const isResource = hasReturnValue && !canPollActionAttempt const isPoll = canPollActionAttempt - const isNil = !hasReturnValue || returnsDeprecatedActionAttempt + const isNil = !hasReturnValue return { name: methodName, diff --git a/codegen/lib/layouts/resource.ts b/codegen/lib/layouts/resource.ts index b517604..bcc1102 100644 --- a/codegen/lib/layouts/resource.ts +++ b/codegen/lib/layouts/resource.ts @@ -1,12 +1,10 @@ // Builds the template context for resource files // (lib/seam/routes/resources/{snake_name}.rb). -// Mirrors the output of the nextlove resource.rb.template.ts. +import type { Property } from '@seamapi/blueprint' import { pascalCase } from 'change-case' import { convertCustomResourceName } from '../custom-resource-name-conversions.js' -import { flattenObjSchema } from '../openapi/flatten-obj-schema.js' -import type { ObjSchema } from '../openapi/types.js' export interface ResourceLayoutContext { className: string @@ -20,22 +18,14 @@ export interface ResourceLayoutContext { export const setResourceLayoutContext = ( snakeName: string, - schema: ObjSchema, + properties: Property[], ): ResourceLayoutContext => { - // TODO: Use resource properties from @seamapi/blueprint once generated output - // is allowed to change. Blueprint omits some schemas, reorders others, and - // collapses integer to number, so the raw OpenAPI schema is used here to keep - // the output identical. - const properties = Object.entries(flattenObjSchema(schema).properties).map( - ([name, propertySchema]) => ({ - name, - isDateTime: - 'format' in propertySchema && propertySchema.format === 'date-time', - }), - ) - - const attrs = properties.filter((p) => !p.isDateTime).map((p) => p.name) - const dateAttrs = properties.filter((p) => p.isDateTime).map((p) => p.name) + const attrs = properties + .filter((property) => property.format !== 'datetime') + .map((property) => property.name) + const dateAttrs = properties + .filter((property) => property.format === 'datetime') + .map((property) => property.name) const noErrorWarningAttrs = attrs.filter( (attr) => attr !== 'errors' && attr !== 'warnings', ) diff --git a/codegen/lib/openapi/deep-flatten-one-of-and-all-of-schema.ts b/codegen/lib/openapi/deep-flatten-one-of-and-all-of-schema.ts deleted file mode 100644 index 3e58594..0000000 --- a/codegen/lib/openapi/deep-flatten-one-of-and-all-of-schema.ts +++ /dev/null @@ -1,138 +0,0 @@ -// TEMPORARY: Verbatim port of @seamapi/nextlove-sdk-generator -// lib/openapi/deep-flatten-one-of-and-all-of-schema.ts. This OpenAPI parsing -// is a frozen output-parity workaround: it exists only so the generated -// output stays byte-identical to the previous generator. Do not review, -// refactor, or improve it. -// TODO: Delete this file and use parameter formats from @seamapi/blueprint -// once generated output is allowed to change. - -import type { - AllOfSchema, - ArraySchema, - ObjSchema, - OneOfSchema, - PrimitiveSchema, - PropertySchema, -} from './types.js' - -export function deepFlattenOneOfAndAllOfSchema( - schema: PropertySchema, -): PropertySchema { - if ('oneOf' in schema) { - return flattenOneOf(schema) - } else if ('allOf' in schema) { - return flattenAllOf(schema) - } else if ( - 'type' in schema && - schema.type === 'object' && - schema.properties != null - ) { - return flattenObject(schema) - } else if ( - 'type' in schema && - schema.type === 'array' && - schema.items != null - ) { - return flattenArray(schema) - } else { - // For primitive types, return the schema as is - return schema - } -} - -function flattenOneOf(oneOfSchema: OneOfSchema): ObjSchema | PrimitiveSchema { - const flattenedSchema: ObjSchema = { - type: 'object', - properties: {}, - required: [], - } - - for (const subSchema of oneOfSchema.oneOf) { - const flattenedSubSchema = deepFlattenOneOfAndAllOfSchema(subSchema) - - // Check if the sub-schema is a primitive schema - if ('type' in flattenedSubSchema && !('properties' in flattenedSubSchema)) { - return { type: flattenedSubSchema.type } as PrimitiveSchema - } - - if ('$ref' in flattenedSubSchema) { - // eslint-disable-next-line no-console - console.error('$ref not currently supported when flattening oneOf') - continue - } - - const subObj = flattenedSubSchema as ObjSchema - - // Merge properties - Object.assign(flattenedSchema.properties, subObj.properties) - - // Update required array with common properties - flattenedSchema.required = - flattenedSchema.required.length === 0 - ? subObj.required - : flattenedSchema.required.filter((prop) => - subObj.required.includes(prop), - ) - } - - return flattenedSchema -} - -function flattenAllOf(allOfSchema: AllOfSchema): ObjSchema | PrimitiveSchema { - const flattenedSchema: ObjSchema = { - type: 'object', - properties: {}, - required: [], - } - - for (const subSchema of allOfSchema.allOf) { - const flattenedSubSchema = deepFlattenOneOfAndAllOfSchema(subSchema) - - // Check if the sub-schema is a primitive schema - if ('type' in flattenedSubSchema && !('properties' in flattenedSubSchema)) { - return { type: flattenedSubSchema.type } as PrimitiveSchema - } - - if ('$ref' in flattenedSubSchema) { - // eslint-disable-next-line no-console - console.error('$ref not currently supported when flattening allOf') - continue - } - - const subObj = flattenedSubSchema as ObjSchema - - // Merge properties - Object.assign(flattenedSchema.properties, subObj.properties) - - // Merge required array - flattenedSchema.required = [ - ...new Set([...flattenedSchema.required, ...subObj.required]), - ] - } - - return flattenedSchema -} - -function flattenObject(objSchema: ObjSchema): ObjSchema { - const flattenedSchema: ObjSchema = { - type: 'object', - properties: {}, - required: objSchema.required ?? [], - } - - for (const prop in objSchema.properties) { - const propSchema = objSchema.properties[prop] - if (propSchema == null) continue - flattenedSchema.properties[prop] = - deepFlattenOneOfAndAllOfSchema(propSchema) - } - - return flattenedSchema -} - -function flattenArray(arraySchema: ArraySchema): ArraySchema { - return { - type: 'array', - items: deepFlattenOneOfAndAllOfSchema(arraySchema.items), - } -} diff --git a/codegen/lib/openapi/flatten-obj-schema.ts b/codegen/lib/openapi/flatten-obj-schema.ts deleted file mode 100644 index 7e2eb80..0000000 --- a/codegen/lib/openapi/flatten-obj-schema.ts +++ /dev/null @@ -1,129 +0,0 @@ -// TEMPORARY: Verbatim port of @seamapi/nextlove-sdk-generator -// lib/openapi/flatten-obj-schema.ts (with the lodash intersectionWith(isEqual) -// call replaced by a plain string-array intersection; required arrays only -// ever contain strings, so the semantics are identical). This is a frozen -// output-parity workaround: it exists only so the generated output stays -// byte-identical to the previous generator. Do not review, refactor, or -// improve it. -// TODO: Delete this file and use resource properties from @seamapi/blueprint -// once generated output is allowed to change. - -import type { - AllOfSchema, - ArraySchema, - ObjSchema, - PrimitiveSchema, - PropertySchema, -} from './types.js' - -const intersection = (a: string[], b: string[]): string[] => - a.filter((x) => b.includes(x)) - -export const flattenObjSchema = ( - s: ObjSchema | { oneOf: ObjSchema[] } | { allOf: ObjSchema[] }, -): ObjSchema => { - if ('type' in s && s.type === 'object') return s - - if ('oneOf' in s) { - if (s.oneOf[0] == null) { - throw new Error('oneOf must have at least one element') - } - - const superObj: ObjSchema = { - type: 'object', - properties: {}, - required: [...s.oneOf[0].required], - } - for (const obj of s.oneOf) { - for (const [k, v] of Object.entries(obj.properties)) { - superObj.properties[k] = v - } - superObj.required = intersection(superObj.required, obj.required) - } - return superObj - } - - if ('allOf' in s) { - return deepFlattenAllOfSchema(s) as ObjSchema - } - - throw new Error(`Unknown schema type "${(s as { type: string }).type}"`) -} - -export const deepFlattenAllOfSchema = ( - s: AllOfSchema, -): Exclude | undefined => { - if (s.allOf.length === 1 && s.allOf[0] != null) { - const recursive = s.allOf[0] - - if ('allOf' in recursive) { - return deepFlattenAllOfSchema(recursive) - } - - return recursive as Exclude - } - - const properties: Record = {} - const required = new Set() - const primitives: Array = [] - - for (let subschema of s.allOf) { - if ('allOf' in subschema) { - const recursive = deepFlattenAllOfSchema(subschema as AllOfSchema) - if (recursive == null) continue - - subschema = recursive - } - - if ('oneOf' in subschema) { - subschema = flattenObjSchema(subschema as { oneOf: ObjSchema[] }) - } - - if ('$ref' in subschema) { - // eslint-disable-next-line no-console - console.error('$ref not currently supported when flattening allOf') - continue - } - - if (subschema.type === 'object') { - const objSchema = subschema as ObjSchema - for (const [k, v] of Object.entries(objSchema.properties)) { - if (properties[k] == null) properties[k] = [] - properties[k]?.push(v) - - if (objSchema.required?.includes(k) ?? false) required.add(k) - } - - continue - } - - primitives.push(subschema as PrimitiveSchema | ArraySchema) - } - - if (Object.keys(properties).length > 0 && primitives.length > 0) { - // eslint-disable-next-line no-console - console.error( - 'Found invalid allOf schema with both properties and primitives', - new Error(JSON.stringify(s, null, 2)), - ) - } - - if (primitives.length > 0) { - // TODO: check that all primitives are the same, then merge nullability/unions - return primitives[0] - } - - if (Object.keys(properties).length > 0) { - return { - type: 'object', - required: [...required], - properties: Object.fromEntries( - Object.entries(properties) - .map(([k, v]) => [k, deepFlattenAllOfSchema({ allOf: v })]) - .filter(([, v]) => v != null), - ), - } - } - - return undefined -} diff --git a/codegen/lib/openapi/get-filtered-routes.ts b/codegen/lib/openapi/get-filtered-routes.ts deleted file mode 100644 index 289a06a..0000000 --- a/codegen/lib/openapi/get-filtered-routes.ts +++ /dev/null @@ -1,23 +0,0 @@ -// TEMPORARY: Verbatim port of @seamapi/nextlove-sdk-generator -// lib/openapi/get-filtered-routes.ts. This is a frozen output-parity -// workaround: it exists only so the generated output stays byte-identical to -// the previous generator. Do not review, refactor, or improve it. -// TODO: Delete this file and use route.isUndocumented from @seamapi/blueprint -// once generated output is allowed to change. - -import type { OpenapiSchema, Route } from './types.js' - -export function getFilteredRoutes(openapi: OpenapiSchema): Route[] { - return Object.entries(openapi.paths) - .filter(([, pathSchema]) => { - const post = pathSchema.post - if (post == null) return false - const summary = post.summary ?? '' - - const isDocumented = post['x-undocumented'] == null - const isSeamInternalRoute = summary.startsWith('/seam/') - - return isDocumented && !isSeamInternalRoute - }) - .map(([path, route]) => ({ path, ...route }) as Route) -} diff --git a/codegen/lib/openapi/get-parameter-and-response-schema.ts b/codegen/lib/openapi/get-parameter-and-response-schema.ts deleted file mode 100644 index cf3cd54..0000000 --- a/codegen/lib/openapi/get-parameter-and-response-schema.ts +++ /dev/null @@ -1,139 +0,0 @@ -// TEMPORARY: Verbatim port of @seamapi/nextlove-sdk-generator -// lib/openapi/get-parameter-and-response-schema.ts. This OpenAPI parsing is a -// frozen output-parity workaround: it exists only so the generated output -// stays byte-identical to the previous generator. Do not review, refactor, or -// improve it. -// TODO: Delete this file and use endpoint.request.parameters and -// endpoint.response from @seamapi/blueprint once generated output is allowed -// to change. - -import { deepFlattenOneOfAndAllOfSchema } from './deep-flatten-one-of-and-all-of-schema.js' -import { flattenObjSchema } from './flatten-obj-schema.js' -import type { ObjSchema, Route } from './types.js' - -export const getParameterAndResponseSchema = ( - route: Route, -): { - parameterSchema?: ObjSchema - responseObjType?: string | undefined - responseArrType?: string | undefined -} => { - const responseSchema = - route.post.responses['200']?.content?.['application/json']?.schema - - if (responseSchema == null) { - return {} - } - - if (route.post.requestBody == null) { - route.post.requestBody = { - content: { - 'application/json': { schema: { type: 'object', properties: {} } }, - }, - } - } - - if (route.post.requestBody.content?.['application/json'] == null) { - return {} - } - - const parameterSchema = processParameterSchema( - route.post.requestBody.content['application/json'].schema, - ) - - const resReturnSchema = - responseSchema.properties?.[route.post['x-response-key'] ?? ''] - - const responseObjRef = resReturnSchema?.$ref - const responseArrRef = resReturnSchema?.items?.$ref - - if (route.post['x-response-key'] === 'batch') { - return { - responseObjType: route.post['x-response-key'], - responseArrType: undefined, - parameterSchema, - } - } else if (responseObjRef == null && responseArrRef == null) { - return { - responseObjType: undefined, - responseArrType: undefined, - parameterSchema, - } - } else { - return { - responseObjType: responseObjRef?.split('/')?.pop(), - responseArrType: responseArrRef?.split('/')?.pop(), - parameterSchema, - } - } -} - -function processParameterSchema( - schema: ObjSchema | { oneOf: ObjSchema[] } | { allOf: ObjSchema[] }, -): ObjSchema { - const parameterSchema = flattenObjSchema(schema) - - for (const [paramName, paramValue] of Object.entries( - parameterSchema.properties, - )) { - if ('oneOf' in paramValue || 'allOf' in paramValue) { - parameterSchema.properties[paramName] = - deepFlattenOneOfAndAllOfSchema(paramValue) - } - } - - return stripUndocumentedProperties(parameterSchema) -} - -function stripUndocumentedProperties(schema: ObjSchema): ObjSchema { - const properties = Object.fromEntries( - Object.entries(schema.properties).flatMap(([name, propertySchema]) => { - const filteredProperty = stripUndocumentedPropertySchema(propertySchema) - - return filteredProperty != null ? [[name, filteredProperty]] : [] - }), - ) - - return { - ...schema, - properties, - required: (schema.required ?? []).filter((name) => name in properties), - } -} - -function stripUndocumentedPropertySchema(propertySchema: any): any { - if (propertySchema?.['x-undocumented'] != null) { - return undefined - } - - if (propertySchema?.type === 'object' && propertySchema.properties != null) { - const properties = Object.fromEntries( - Object.entries(propertySchema.properties).flatMap( - ([name, nestedSchema]) => { - const filteredProperty = stripUndocumentedPropertySchema(nestedSchema) - - return filteredProperty != null ? [[name, filteredProperty]] : [] - }, - ), - ) - - return { - ...propertySchema, - properties, - required: (propertySchema.required ?? []).filter( - (name: string) => name in properties, - ), - } - } - - if (propertySchema?.type === 'array' && propertySchema.items != null) { - const items = stripUndocumentedPropertySchema(propertySchema.items) - - return { - ...propertySchema, - items: items ?? propertySchema.items, - } - } - - return propertySchema -} diff --git a/codegen/lib/openapi/map-parent-to-children-resource.ts b/codegen/lib/openapi/map-parent-to-children-resource.ts deleted file mode 100644 index 780a3f1..0000000 --- a/codegen/lib/openapi/map-parent-to-children-resource.ts +++ /dev/null @@ -1,39 +0,0 @@ -// TEMPORARY: Verbatim port of @seamapi/nextlove-sdk-generator -// lib/openapi/map-parent-to-children-resource.ts. This is a frozen -// output-parity workaround: it exists only so the generated output stays -// byte-identical to the previous generator. Do not review, refactor, or -// improve it. Only the first two segments of x-fern-sdk-group-name are -// considered, so deeply nested namespaces (e.g. acs.encoders.simulate) are -// generated as standalone classes but never wired to a parent property. -// TODO: Delete this file and use blueprint.namespaces parent/child -// relationships once generated output is allowed to change, wiring deeply -// nested namespaces to a property on their parent class at the same time. - -import { ignoredEndpointPaths } from '../endpoint-rules.js' -import type { Route } from './types.js' - -export const mapParentToChildResources = ( - routes: Route[], -): Record => - routes.reduce((acc: Record, route) => { - if (route.post?.['x-fern-sdk-group-name'] == null) return acc - if (ignoredEndpointPaths.includes(route.path)) return acc - - const [parentResourceName, childResourceName] = - route.post['x-fern-sdk-group-name'] - - if (parentResourceName == null) return acc - - if (acc[parentResourceName] == null) { - acc[parentResourceName] = [] - } - - if ( - childResourceName != null && - !(acc[parentResourceName]?.includes(childResourceName) ?? false) - ) { - acc[parentResourceName]?.push(childResourceName) - } - - return acc - }, {}) diff --git a/codegen/lib/openapi/types.ts b/codegen/lib/openapi/types.ts deleted file mode 100644 index 8cfe028..0000000 --- a/codegen/lib/openapi/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -// TEMPORARY: Minimal OpenAPI types ported from @seamapi/nextlove-sdk-generator. -// These only support the frozen output-parity workarounds in this directory. -// Do not review, refactor, or improve them. -// TODO: Delete this file and use @seamapi/blueprint types once generated -// output is allowed to change. - -export interface PrimitiveSchema { - type: 'string' | 'integer' | 'boolean' | 'number' - enum?: string[] - format?: string -} - -export interface ArraySchema { - type: 'array' - items: PropertySchema -} - -export interface ObjSchema { - type: 'object' - properties: Record - required: string[] -} - -export interface OneOfSchema { - oneOf: PropertySchema[] -} - -export interface AllOfSchema { - allOf: PropertySchema[] -} - -export interface RefSchema { - $ref: string -} - -export type PropertySchema = - | PrimitiveSchema - | ArraySchema - | ObjSchema - | OneOfSchema - | AllOfSchema - | RefSchema - -export interface RoutePost { - summary?: string - requestBody?: { - content?: Record - } - responses: Record }> - 'x-fern-sdk-group-name'?: string[] - 'x-fern-sdk-method-name'?: string - 'x-fern-sdk-return-value'?: string - 'x-response-key'?: string | null - 'x-undocumented'?: string - [key: string]: any -} - -export interface Route { - path: string - post: RoutePost - [key: string]: any -} - -export interface OpenapiSchema { - info: { title: string } - paths: Record - components: { schemas: Record } -} diff --git a/codegen/lib/routes.ts b/codegen/lib/routes.ts index 7ca1d5a..74c8d3e 100644 --- a/codegen/lib/routes.ts +++ b/codegen/lib/routes.ts @@ -1,31 +1,26 @@ // The Metalsmith plugin that generates the Ruby SDK route files. -// Ported from @seamapi/nextlove-sdk-generator lib/generate-ruby-sdk/generate-ruby-sdk.ts, -// restructured to mirror the javascript-http codegen plugin (lib/connect.ts). +// Structured to mirror the javascript-http codegen plugin (lib/connect.ts). // -// The blueprint from @seamapi/blueprint drives the iteration order and the -// route, endpoint, and namespace structure. The raw OpenAPI spec is still -// consulted wherever the previous nextlove generator derived output from data -// the blueprint normalizes differently; each of those spots is marked with a -// TODO so they can migrate to the blueprint once output is allowed to change, -// and the supporting code lives in files marked TEMPORARY that will be deleted -// with them. - -import type { Blueprint } from '@seamapi/blueprint' -import * as types from '@seamapi/types/connect' +// The blueprint from @seamapi/blueprint drives all generated output: resources +// come from blueprint.resources (plus the merged action_attempt and the +// pagination resources), and clients come from blueprint.routes and +// blueprint.namespaces. + +import type { + Blueprint, + Endpoint, + Property, + Response, +} from '@seamapi/blueprint' import { pascalCase } from 'change-case' import type Metalsmith from 'metalsmith' import { convertCustomResourceName } from './custom-resource-name-conversions.js' -import { ignoredEndpointPaths } from './endpoint-rules.js' import { setClientLayoutContext } from './layouts/client.js' import { setImportsLayoutContext } from './layouts/imports.js' import { setResourceLayoutContext } from './layouts/resource.js' import { setRoutesFileLayoutContext } from './layouts/routes-file.js' -import { getFilteredRoutes } from './openapi/get-filtered-routes.js' -import { getParameterAndResponseSchema } from './openapi/get-parameter-and-response-schema.js' -import { mapParentToChildResources } from './openapi/map-parent-to-children-resource.js' -import type { ObjSchema, OpenapiSchema } from './openapi/types.js' -import type { ClientModel } from './ruby-client.js' +import type { ClientMethod, ClientModel } from './ruby-client.js' import { resourceErrorRb, resourceErrorsSupportRb, @@ -37,8 +32,6 @@ interface Metadata { blueprint: Blueprint } -const openapi = types.openapi as unknown as OpenapiSchema - const routesPath = 'lib/seam/routes' const resourcesPath = `${routesPath}/resources` const clientsPath = `${routesPath}/clients` @@ -49,15 +42,8 @@ export const routes = ( ): void => { const { blueprint } = metalsmith.metadata() as Metadata - // TODO: Derive the parent to child resource map from blueprint.namespaces - // once generated output is allowed to change. - const rawRoutes = getFilteredRoutes(openapi) - const parentToChildResourcesMap = mapParentToChildResources(rawRoutes) - const resourceNames: string[] = [] - // Static resource support files, in the order the nextlove generator emits - // them (before the schema-derived resources). const staticResources: Array<[string, string]> = [ ['resource_error', resourceErrorRb], ['resource_warning', resourceWarningRb], @@ -69,122 +55,19 @@ export const routes = ( resourceNames.push(name) } - // TODO: Use blueprint.resources, blueprint.events, and blueprint.actionAttempts - // once generated output is allowed to change. Blueprint omits some schemas, - // reorders others, and collapses integer to number, so the raw OpenAPI schemas - // are used to keep the output identical. - for (const [snakeName, schema] of Object.entries( - openapi.components.schemas, - )) { - files[`${resourcesPath}/${snakeName}.rb`] = { + for (const [name, properties] of getResources(blueprint)) { + files[`${resourcesPath}/${name}.rb`] = { contents: Buffer.from('\n'), layout: 'resource.hbs', - ...setResourceLayoutContext(snakeName, schema as ObjSchema), + ...setResourceLayoutContext(name, properties), } - resourceNames.push(snakeName) - } - - const clientMap = new Map() - - const processClient = (resourceName: string): void => { - const childClientIdentifiers = ( - parentToChildResourcesMap[resourceName] ?? [] - ).map((childResource) => ({ - clientName: pascalCase(`${resourceName} ${childResource}`), - namespace: childResource, - })) - const className = pascalCase(resourceName) - - clientMap.set(className, { - name: className, - namespace: resourceName, - methods: [], - childClientIdentifiers, - }) + resourceNames.push(name) } - for (const route of blueprint.routes) { - for (const endpoint of route.endpoints) { - const post = openapi.paths[endpoint.path]?.post - if (post == null) continue - - // TODO: Filter on endpoint.isUndocumented and route.isUndocumented from - // the blueprint once generated output is allowed to change. The raw - // OpenAPI extensions are used here to exclude exactly the same endpoint - // set as the previous nextlove generator. - if (post['x-undocumented'] != null) continue - if ((post.summary ?? '').startsWith('/seam/')) continue - if (post['x-fern-sdk-group-name'] == null) continue - if (ignoredEndpointPaths.includes(endpoint.path)) continue - - const groupNames = [...post['x-fern-sdk-group-name']] - const [baseResource] = groupNames - const namespace = groupNames.join('_') - const className = pascalCase(namespace) - - if (!clientMap.has(className)) { - processClient(namespace) - } - - /* - Special case when we don't have routes for a base resource and thus a - respective x-fern-sdk-group-name for ex. /noise_sensors - */ - if (baseResource != null && !clientMap.has(pascalCase(baseResource))) { - processClient(baseResource) - } - - const cls = clientMap.get(className) - - if (cls == null) { - // eslint-disable-next-line no-console - console.warn(`No client for "${className}", skipping`) - continue - } - - const { parameterSchema, responseObjType, responseArrType } = - getParameterAndResponseSchema({ path: endpoint.path, post }) - - if (parameterSchema == null) { - // eslint-disable-next-line no-console - console.warn(`No parameter schema for "${endpoint.path}", skipping`) - continue - } - - const methodName = post['x-fern-sdk-method-name'] ?? endpoint.name - const returnResource = responseObjType ?? responseArrType - - cls.methods.push({ - methodName, - path: endpoint.path, - // TODO: Use endpoint.request.parameters from the blueprint once - // generated output is allowed to change. The blueprint collapses - // integer to number and flattens unions differently, so parameters are - // derived from the raw OpenAPI schema for identical output. - parameters: Object.entries(parameterSchema.properties) - .filter(([, paramVal]) => 'type' in paramVal) - .map(([paramName]) => ({ - name: paramName, - required: parameterSchema.required?.includes(paramName), - position: - methodName === 'get' && - paramName === `${post['x-fern-sdk-return-value']}_id` - ? 0 - : undefined, - })), - // TODO: Use endpoint.response.responseKey from the blueprint once - // generated output is allowed to change. - returnPath: post['x-fern-sdk-return-value'] ?? '', - returnResource: - returnResource != null - ? pascalCase(convertCustomResourceName(returnResource)) - : null, - }) - } - } + const clients = getClients(blueprint) const clientNames: string[] = [] - for (const cls of clientMap.values()) { + for (const cls of clients.values()) { files[`${clientsPath}/${cls.namespace}.rb`] = { contents: Buffer.from('\n'), layout: 'client.hbs', @@ -208,6 +91,174 @@ export const routes = ( files[`${routesPath}/routes.rb`] = { contents: Buffer.from('\n'), layout: 'routes.hbs', - ...setRoutesFileLayoutContext(Object.keys(parentToChildResourcesMap)), + ...setRoutesFileLayoutContext(getTopLevelClientNamespaces(blueprint)), + } +} + +const getResources = (blueprint: Blueprint): Array<[string, Property[]]> => { + const resources = new Map() + + for (const resource of blueprint.resources) { + resources.set(resource.resourceType, resource.properties) + } + + // The event resource only has the properties common to all events, but the + // SDK exposes a single SeamEvent class, so it needs an accessor for every + // property of every event variant. + const eventProperties = resources.get('event') + if (eventProperties != null) { + resources.set( + 'event', + mergeProperties([ + eventProperties, + ...blueprint.events.map((event) => event.properties), + ]), + ) + } + + // Action attempts are one blueprint entry per action type, but the SDK + // exposes a single ActionAttempt class. + if (blueprint.actionAttempts.length > 0) { + resources.set( + 'action_attempt', + mergeProperties( + blueprint.actionAttempts.map( + (actionAttempt) => actionAttempt.properties, + ), + ), + ) + } + + if (blueprint.pagination != null) { + resources.set('pagination', blueprint.pagination.properties) + } + + return [...resources.entries()].sort(([a], [b]) => a.localeCompare(b)) +} + +const mergeProperties = (propertyLists: Property[][]): Property[] => { + const merged = new Map() + for (const properties of propertyLists) { + for (const property of properties) { + if (!merged.has(property.name)) merged.set(property.name, property) + } + } + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)) +} + +interface ClientSource { + name: string + parentPath: string | null + endpoints: Endpoint[] +} + +const getClients = (blueprint: Blueprint): ClientModel[] => { + const sources = new Map() + + // Namespaces without a route of their own (e.g. /acs) become clients that + // only expose child clients. + for (const namespace of blueprint.namespaces) { + if (namespace.isUndocumented) continue + sources.set(namespace.path, { + name: namespace.name, + parentPath: namespace.parentPath, + endpoints: [], + }) + } + + for (const route of blueprint.routes) { + if (route.isUndocumented) continue + sources.set(route.path, { + name: route.name, + parentPath: route.parentPath, + endpoints: route.endpoints, + }) + } + + const paths = [...sources.keys()].sort() + + const clients = new Map() + for (const path of paths) { + const source = sources.get(path) + if (source == null) continue + const namespace = getClientNamespace(path) + clients.set(path, { + name: pascalCase(namespace), + namespace, + methods: source.endpoints + .filter((endpoint) => !endpoint.isUndocumented) + .map(createClientMethod), + childClientIdentifiers: [], + }) + } + + for (const path of paths) { + const source = sources.get(path) + if (source?.parentPath == null) continue + const parent = clients.get(source.parentPath) + parent?.childClientIdentifiers.push({ + clientName: pascalCase(getClientNamespace(path)), + namespace: source.name, + }) + } + + return [...clients.values()] +} + +const getClientNamespace = (path: string): string => + path.slice(1).split('/').join('_') + +const getTopLevelClientNamespaces = (blueprint: Blueprint): string[] => { + const namespaces = [ + ...blueprint.namespaces.filter((namespace) => !namespace.isUndocumented), + ...blueprint.routes.filter((route) => !route.isUndocumented), + ] + .filter(({ parentPath }) => parentPath == null) + .map(({ path }) => getClientNamespace(path)) + return [...new Set(namespaces)].sort() +} + +const createClientMethod = (endpoint: Endpoint): ClientMethod => { + const { returnPath, returnResource } = getEndpointReturn(endpoint.response) + + return { + methodName: endpoint.name, + path: endpoint.path, + parameters: endpoint.request.parameters + .filter((parameter) => !parameter.isUndocumented) + .map((parameter) => ({ + name: parameter.name, + required: parameter.isRequired, + position: + endpoint.name === 'get' && parameter.name === `${returnPath}_id` + ? 0 + : undefined, + })), + returnPath, + returnResource, + } +} + +const getEndpointReturn = ( + response: Response, +): Pick => { + if (response.responseType === 'void') { + return { returnPath: '', returnResource: null } + } + + const { responseKey, resourceType } = response + + if (resourceType === 'unknown') { + // Batch responses hold multiple resource types keyed by batch key, which + // the Batch resource models directly. + if (responseKey === 'batch') { + return { returnPath: 'batch', returnResource: 'Batch' } + } + return { returnPath: '', returnResource: null } + } + + return { + returnPath: responseKey, + returnResource: pascalCase(convertCustomResourceName(resourceType)), } } diff --git a/codegen/lib/ruby-client.ts b/codegen/lib/ruby-client.ts index 76cc9df..64dd4ad 100644 --- a/codegen/lib/ruby-client.ts +++ b/codegen/lib/ruby-client.ts @@ -28,20 +28,13 @@ export interface ClientModel { childClientIdentifiers: ChildClientIdentifier[] } -// Verbatim port of the nextlove parameter comparator. The original expression -// `(a.position ?? a.required ? 1000 : 9999)` parses as -// `(a.position ?? a.required) ? 1000 : 9999`, so a parameter with position 0 -// is falsy and lands in the 9999 tier together with the optional parameters. -// Combined with a stable sort this yields: required parameters first (in schema -// order), then everything else (in schema order). -// TODO: Fix the operator precedence so position sorts a parameter first as -// originally intended, once generated output is allowed to change. Until then, -// do not "fix" it: the generated output must stay identical. +// Sorts parameters with an explicit position first, then required parameters, +// then optional parameters; the sort is stable within each tier. export const sortClientMethodParameters = ( parameters: ClientMethodParameter[], ): ClientMethodParameter[] => [...parameters].sort( (a, b) => - (a.position ?? a.required ? 1000 : 9999) - - (b.position ?? b.required ? 1000 : 9999), + (a.position ?? (a.required ?? false ? 1000 : 9999)) - + (b.position ?? (b.required ?? false ? 1000 : 9999)), ) diff --git a/lib/seam/routes/clients/acs_encoders.rb b/lib/seam/routes/clients/acs_encoders.rb index 5217d11..35051c4 100644 --- a/lib/seam/routes/clients/acs_encoders.rb +++ b/lib/seam/routes/clients/acs_encoders.rb @@ -10,6 +10,10 @@ def initialize(client:, defaults:) @defaults = defaults end + def simulate + @simulate ||= Seam::Clients::AcsEncodersSimulate.new(client: @client, defaults: @defaults) + end + def encode_credential(acs_encoder_id:, access_method_id: nil, acs_credential_id: nil, wait_for_action_attempt: nil) res = @client.post("/acs/encoders/encode_credential", {acs_encoder_id: acs_encoder_id, access_method_id: access_method_id, acs_credential_id: acs_credential_id}.compact) diff --git a/lib/seam/routes/clients/acs_users.rb b/lib/seam/routes/clients/acs_users.rb index 95d0aad..a946f4a 100644 --- a/lib/seam/routes/clients/acs_users.rb +++ b/lib/seam/routes/clients/acs_users.rb @@ -26,8 +26,8 @@ def delete(acs_system_id: nil, acs_user_id: nil, user_identity_id: nil) nil end - def get(acs_system_id: nil, acs_user_id: nil, user_identity_id: nil) - res = @client.post("/acs/users/get", {acs_system_id: acs_system_id, acs_user_id: acs_user_id, user_identity_id: user_identity_id}.compact) + def get(acs_user_id: nil, acs_system_id: nil, user_identity_id: nil) + res = @client.post("/acs/users/get", {acs_user_id: acs_user_id, acs_system_id: acs_system_id, user_identity_id: user_identity_id}.compact) Seam::Resources::AcsUser.load_from_response(res.body["acs_user"]) end diff --git a/lib/seam/routes/clients/events.rb b/lib/seam/routes/clients/events.rb index 5e273fd..d634150 100644 --- a/lib/seam/routes/clients/events.rb +++ b/lib/seam/routes/clients/events.rb @@ -8,8 +8,8 @@ def initialize(client:, defaults:) @defaults = defaults end - def get(device_id: nil, event_id: nil, event_type: nil) - res = @client.post("/events/get", {device_id: device_id, event_id: event_id, event_type: event_type}.compact) + def get(event_id: nil, device_id: nil, event_type: nil) + res = @client.post("/events/get", {event_id: event_id, device_id: device_id, event_type: event_type}.compact) Seam::Resources::SeamEvent.load_from_response(res.body["event"]) end diff --git a/lib/seam/routes/clients/index.rb b/lib/seam/routes/clients/index.rb index 3dc5d84..eefa352 100644 --- a/lib/seam/routes/clients/index.rb +++ b/lib/seam/routes/clients/index.rb @@ -7,8 +7,8 @@ require_relative "access_grants_unmanaged" require_relative "access_methods" require_relative "access_methods_unmanaged" -require_relative "acs_access_groups" require_relative "acs" +require_relative "acs_access_groups" require_relative "acs_credentials" require_relative "acs_encoders" require_relative "acs_encoders_simulate" diff --git a/lib/seam/routes/clients/workspaces.rb b/lib/seam/routes/clients/workspaces.rb index 3abc2f9..d81f6e9 100644 --- a/lib/seam/routes/clients/workspaces.rb +++ b/lib/seam/routes/clients/workspaces.rb @@ -35,6 +35,12 @@ def reset_sandbox(wait_for_action_attempt: nil) Helpers::ActionAttempt.decide_and_wait(Seam::Resources::ActionAttempt.load_from_response(res.body["action_attempt"]), @client, wait_for_action_attempt) end + + def update(connect_partner_name: nil, connect_webview_customization: nil, is_publishable_key_auth_enabled: nil, is_suspended: nil, name: nil, organization_id: nil) + @client.post("/workspaces/update", {connect_partner_name: connect_partner_name, connect_webview_customization: connect_webview_customization, is_publishable_key_auth_enabled: is_publishable_key_auth_enabled, is_suspended: is_suspended, name: name, organization_id: organization_id}.compact) + + nil + end end end end diff --git a/lib/seam/routes/resources/event.rb b/lib/seam/routes/resources/event.rb index 685d3d4..332f86c 100644 --- a/lib/seam/routes/resources/event.rb +++ b/lib/seam/routes/resources/event.rb @@ -3,7 +3,7 @@ module Seam module Resources class SeamEvent < BaseResource - attr_accessor :access_code_id, :connected_account_custom_metadata, :connected_account_id, :device_custom_metadata, :device_id, :event_description, :event_id, :event_type, :workspace_id, :change_reason, :changed_properties, :description, :from, :to, :requested_mutations, :code, :access_code_errors, :access_code_warnings, :connected_account_errors, :connected_account_warnings, :device_errors, :device_warnings, :backup_access_code_id, :access_grant_id, :acs_entrance_id, :access_grant_key, :ends_at, :starts_at, :error_message, :missing_device_ids, :access_grant_ids, :access_grant_keys, :access_method_id, :is_backup_code, :acs_system_id, :acs_system_errors, :acs_system_warnings, :acs_credential_id, :acs_user_id, :acs_encoder_id, :acs_access_group_id, :client_session_id, :connect_webview_id, :customer_key, :connected_account_type, :action_attempt_id, :action_type, :status, :error_code, :battery_level, :battery_status, :device_name, :minut_metadata, :noise_level_decibels, :noise_level_nrs, :noise_threshold_id, :noise_threshold_name, :noiseaware_metadata, :access_code_is_managed, :is_via_bluetooth, :is_via_nfc, :method, :user_identity_id, :reason, :climate_preset_key, :is_fallback_climate_preset, :thermostat_schedule_id, :cooling_set_point_celsius, :cooling_set_point_fahrenheit, :fan_mode_setting, :heating_set_point_celsius, :heating_set_point_fahrenheit, :hvac_mode_setting, :lower_limit_celsius, :lower_limit_fahrenheit, :temperature_celsius, :temperature_fahrenheit, :upper_limit_celsius, :upper_limit_fahrenheit, :desired_temperature_celsius, :desired_temperature_fahrenheit, :activation_reason, :image_url, :motion_sub_type, :video_url, :enrollment_automation_id, :acs_entrance_ids, :device_ids, :space_id, :space_key + attr_accessor :access_code_errors, :access_code_id, :access_code_is_managed, :access_code_warnings, :access_grant_id, :access_grant_ids, :access_grant_key, :access_grant_keys, :access_method_id, :acs_access_group_id, :acs_credential_id, :acs_encoder_id, :acs_entrance_id, :acs_entrance_ids, :acs_system_errors, :acs_system_id, :acs_system_warnings, :acs_user_id, :action_attempt_id, :action_type, :activation_reason, :backup_access_code_id, :battery_level, :battery_status, :change_reason, :changed_properties, :client_session_id, :climate_preset_key, :code, :connect_webview_id, :connected_account_custom_metadata, :connected_account_errors, :connected_account_id, :connected_account_type, :connected_account_warnings, :cooling_set_point_celsius, :cooling_set_point_fahrenheit, :customer_key, :description, :desired_temperature_celsius, :desired_temperature_fahrenheit, :device_custom_metadata, :device_errors, :device_id, :device_ids, :device_name, :device_warnings, :ends_at, :enrollment_automation_id, :error_code, :error_message, :event_description, :event_id, :event_type, :fan_mode_setting, :from, :heating_set_point_celsius, :heating_set_point_fahrenheit, :hvac_mode_setting, :image_url, :is_backup_code, :is_fallback_climate_preset, :is_via_bluetooth, :is_via_nfc, :lower_limit_celsius, :lower_limit_fahrenheit, :method, :minut_metadata, :missing_device_ids, :motion_sub_type, :noise_level_decibels, :noise_level_nrs, :noise_threshold_id, :noise_threshold_name, :noiseaware_metadata, :reason, :requested_mutations, :space_id, :space_key, :starts_at, :status, :temperature_celsius, :temperature_fahrenheit, :thermostat_schedule_id, :to, :upper_limit_celsius, :upper_limit_fahrenheit, :user_identity_id, :video_url, :workspace_id date_accessor :created_at, :occurred_at end diff --git a/lib/seam/routes/resources/index.rb b/lib/seam/routes/resources/index.rb index 427bcdb..fe4befe 100644 --- a/lib/seam/routes/resources/index.rb +++ b/lib/seam/routes/resources/index.rb @@ -35,7 +35,6 @@ require_relative "noise_threshold" require_relative "pagination" require_relative "phone" -require_relative "phone_registration" require_relative "phone_session" require_relative "space" require_relative "staff_member" diff --git a/lib/seam/routes/resources/phone_registration.rb b/lib/seam/routes/resources/phone_registration.rb deleted file mode 100644 index aef3004..0000000 --- a/lib/seam/routes/resources/phone_registration.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Seam - module Resources - class PhoneRegistration < BaseResource - attr_accessor :is_being_activated, :phone_registration_id, :provider_name, :provider_state - end - end -end