diff --git a/package-lock.json b/package-lock.json index 695ff3f..cdaf7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@seamapi/types": "1.345.0", + "@seamapi/types": "1.345.1", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", @@ -1052,9 +1052,9 @@ ] }, "node_modules/@seamapi/types": { - "version": "1.345.0", - "resolved": "https://registry.npmjs.org/@seamapi/types/-/types-1.345.0.tgz", - "integrity": "sha512-NJ8tNMpIUrVZd3ii92xytKUk2cNHoKSikcstCGTzfSkquUK0DQ2hCLD32z+kzC1EeaYMh//+jT05c9HtswYaNA==", + "version": "1.345.1", + "resolved": "https://registry.npmjs.org/@seamapi/types/-/types-1.345.1.tgz", + "integrity": "sha512-sMtLXqGGHVRVXtgy6XySfUTzSTQboALa18ZJJe1MC8Za20Va65aUoBYf/SiaOr5scslrC8thSK7fK2MctqxbBA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e992074..9f82206 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "zod": "^3.23.8" }, "devDependencies": { - "@seamapi/types": "1.345.0", + "@seamapi/types": "1.345.1", "@types/node": "^20.8.10", "ava": "^6.0.1", "c8": "^10.1.2", diff --git a/src/lib/blueprint.ts b/src/lib/blueprint.ts index 7b3934e..d721eda 100644 --- a/src/lib/blueprint.ts +++ b/src/lib/blueprint.ts @@ -277,7 +277,7 @@ export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' interface Context extends Required { codeSampleDefinitions: CodeSampleDefinition[] - actionAttempts: ActionAttempt[] + validActionAttemptTypes: string[] } export const TypesModuleSchema = z.object({ @@ -303,24 +303,56 @@ export const createBlueprint = async ( // TODO: Move openapi to TypesModuleSchema const openapi = typesModule.openapi as Openapi - const resources = createResources(openapi.components.schemas) - const actionAttempts = createActionAttempts(openapi.components.schemas) + const validActionAttemptTypes = extractValidActionAttemptTypes( + openapi.components.schemas, + ) - const context = { + const context: Context = { codeSampleDefinitions, formatCode, - actionAttempts, + validActionAttemptTypes, } + const routes = await createRoutes(openapi.paths, context) + const resources = createResources(openapi.components.schemas, routes) + const actionAttempts = createActionAttempts( + openapi.components.schemas, + routes, + ) + return { title: openapi.info.title, - routes: await createRoutes(openapi.paths, context), + routes, resources, - events: createEvents(openapi.components.schemas, resources), + events: createEvents(openapi.components.schemas, resources, routes), actionAttempts, } } +const extractValidActionAttemptTypes = ( + schemas: Openapi['components']['schemas'], +): string[] => { + const actionAttemptSchema = schemas['action_attempt'] + if ( + actionAttemptSchema == null || + typeof actionAttemptSchema !== 'object' || + !('oneOf' in actionAttemptSchema) || + !Array.isArray(actionAttemptSchema.oneOf) + ) { + return [] + } + + const processedActionAttemptTypes = new Set() + actionAttemptSchema.oneOf.forEach((schema) => { + const actionType = schema.properties?.action_type?.enum?.[0] + if (typeof actionType === 'string') { + processedActionAttemptTypes.add(actionType) + } + }) + + return Array.from(processedActionAttemptTypes) +} + const createRoutes = async ( paths: OpenapiPaths, context: Context, @@ -792,6 +824,7 @@ const createParameter = ( export const createResources = ( schemas: Openapi['components']['schemas'], + routes: Route[], ): Record => { return Object.entries(schemas).reduce>( (resources, [schemaName, schema]) => { @@ -802,12 +835,13 @@ export const createResources = ( parsedEvent.oneOf, ) const eventSchema: OpenapiSchema = { + 'x-route-path': parsedEvent['x-route-path'], properties: commonProperties, type: 'object', } return { ...resources, - [schemaName]: createResource(schemaName, eventSchema), + [schemaName]: createResource(schemaName, eventSchema, routes), } } @@ -816,7 +850,7 @@ export const createResources = ( if (isValidResourceSchema) { return { ...resources, - [schemaName]: createResource(schemaName, schema), + [schemaName]: createResource(schemaName, schema, routes), } } @@ -829,13 +863,20 @@ export const createResources = ( const createResource = ( schemaName: string, schema: OpenapiSchema, + routes: Route[], ): Resource => { + const routePath = validateRoutePath( + schemaName, + schema['x-route-path'], + routes, + ) + return { resourceType: schemaName, properties: createProperties(schema.properties ?? {}, [schemaName]), description: schema.description ?? '', isDeprecated: schema.deprecated ?? false, - routePath: schema['x-route-path'] ?? '', + routePath, deprecationMessage: schema['x-deprecated'] ?? '', isUndocumented: (schema['x-undocumented'] ?? '').length > 0, undocumentedMessage: schema['x-undocumented'] ?? '', @@ -844,6 +885,21 @@ const createResource = ( } } +const validateRoutePath = ( + resourceName: string, + routePath: string | undefined, + routes: Route[], +): string => { + if (routePath == null || routePath.length === 0) { + throw new Error(`Resource ${resourceName} is missing a route path`) + } + if (!routes.some((r) => r.path === routePath)) { + throw new Error(`Route path ${routePath} not found in routes`) + } + + return routePath +} + const createResponse = ( operation: OpenapiOperation, path: string, @@ -929,7 +985,7 @@ const createResponse = ( parsedOperation['x-action-attempt-type'], responseKey, path, - context, + context.validActionAttemptTypes, ) const refKey = responseKey @@ -957,7 +1013,7 @@ const validateActionAttemptType = ( actionAttemptType: string | undefined, responseKey: string, path: string, - context: Context, + validActionAttemptTypes: string[], ): string | undefined => { const excludedPaths = ['/action_attempts'] const isPathExcluded = excludedPaths.some((p) => path.startsWith(p)) @@ -972,9 +1028,7 @@ const validateActionAttemptType = ( if ( actionAttemptType != null && - !context.actionAttempts.some( - (attempt) => attempt.actionAttemptType === actionAttemptType, - ) + !validActionAttemptTypes.includes(actionAttemptType) ) { throw new Error( `Invalid action_attempt_type '${actionAttemptType}' for path ${path}`, @@ -1132,6 +1186,7 @@ export const getPreferredMethod = ( const createEvents = ( schemas: Openapi['components']['schemas'], resources: Record, + routes: Route[], ): EventResource[] => { const eventSchema = schemas['event'] if ( @@ -1158,7 +1213,7 @@ const createEvents = ( ) return { - ...createResource('event', schema as OpenapiSchema), + ...createResource('event', schema as OpenapiSchema, routes), eventType, targetResourceType: targetResourceType ?? null, } @@ -1168,6 +1223,7 @@ const createEvents = ( const createActionAttempts = ( schemas: Openapi['components']['schemas'], + routes: Route[], ): ActionAttempt[] => { const actionAttemptSchema = schemas['action_attempt'] if ( @@ -1198,6 +1254,7 @@ const createActionAttempts = ( processedActionTypes.add(actionType) const schemaWithStandardStatus: OpenapiSchema = { + 'x-route-path': actionAttemptSchema['x-route-path'], ...schema, properties: { ...schema.properties, @@ -1212,6 +1269,7 @@ const createActionAttempts = ( const resource = createResource( 'action_attempt', schemaWithStandardStatus, + routes, ) return { diff --git a/src/lib/openapi/schemas.ts b/src/lib/openapi/schemas.ts index 77b5385..f33d448 100644 --- a/src/lib/openapi/schemas.ts +++ b/src/lib/openapi/schemas.ts @@ -116,6 +116,7 @@ export const ResourceSchema = z.object({ }) export const EventResourceSchema = z.object({ + 'x-route-path': z.string().default(''), discriminator: z.object({ propertyName: z.string() }), oneOf: z.array(ResourceSchema), }) diff --git a/test/fixtures/types/openapi.ts b/test/fixtures/types/openapi.ts index bff3c54..6fca82e 100644 --- a/test/fixtures/types/openapi.ts +++ b/test/fixtures/types/openapi.ts @@ -84,7 +84,7 @@ export default { }, }, required: ['plane_id', 'name'], - 'x-route-path': '/planes', + 'x-route-path': '/transport/air/planes', }, deprecated_resource: { type: 'object', @@ -99,7 +99,7 @@ export default { required: ['deprecated_resource_id'], deprecated: true, 'x-deprecated': 'This resource is deprecated', - 'x-route-path': '/deprecated/resources', + 'x-route-path': '/deprecated/undocumented', }, draft_resource: { type: 'object', @@ -113,7 +113,7 @@ export default { }, required: ['draft_resource_id'], 'x-draft': 'This resource is draft', - 'x-route-path': '/draft/resources', + 'x-route-path': '/draft', }, undocumented_resource: { type: 'object', @@ -127,9 +127,10 @@ export default { }, required: ['undocumented_resource_id'], 'x-undocumented': 'This resource is undocumented', - 'x-route-path': '/undocumented/resources', + 'x-route-path': '/deprecated/undocumented', }, event: { + 'x-route-path': '/events', oneOf: [ { type: 'object', @@ -162,6 +163,7 @@ export default { ], }, action_attempt: { + 'x-route-path': '/action_attempts', oneOf: [ { type: 'object', @@ -506,5 +508,40 @@ export default { 'x-title': 'Draft endpoint', }, }, + '/action_attempts/get': { + post: { + operationId: 'actionAttemptsGetPost', + responses: { + 200: { + content: { + 'application/json': { + schema: { + properties: { + ok: { type: 'boolean' }, + action_attempt: { + $ref: '#/components/schemas/action_attempt', + }, + }, + required: ['action_attempt', 'ok'], + type: 'object', + }, + }, + }, + description: 'Get an action attempt.', + }, + 400: { description: 'Bad Request' }, + 401: { description: 'Unauthorized' }, + }, + security: [ + { + api_key: [], + }, + ], + summary: '/action_attempts/get', + tags: ['/action_attempts'], + 'x-response-key': 'action_attempt', + 'x-title': 'Get an action attempt', + }, + }, }, } diff --git a/test/snapshots/blueprint.test.ts.md b/test/snapshots/blueprint.test.ts.md index 3b62966..778fc62 100644 --- a/test/snapshots/blueprint.test.ts.md +++ b/test/snapshots/blueprint.test.ts.md @@ -101,7 +101,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, ], @@ -205,7 +205,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'deprecated_resource', - routePath: '/deprecated/resources', + routePath: '/deprecated/undocumented', undocumentedMessage: '', }, draft_resource: { @@ -230,7 +230,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'draft_resource', - routePath: '/draft/resources', + routePath: '/draft', undocumentedMessage: '', }, foo: { @@ -436,7 +436,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'plane', - routePath: '/planes', + routePath: '/transport/air/planes', undocumentedMessage: '', }, undocumented_resource: { @@ -461,7 +461,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'undocumented_resource', - routePath: '/undocumented/resources', + routePath: '/deprecated/undocumented', undocumentedMessage: 'This resource is undocumented', }, }, @@ -1140,6 +1140,48 @@ Generated by [AVA](https://avajs.dev). path: '/draft', subroutes: [], }, + { + endpoints: [ + { + authMethods: [ + 'api_key', + ], + codeSamples: [], + deprecationMessage: '', + description: '', + draftMessage: '', + isDeprecated: false, + isDraft: false, + isUndocumented: false, + name: 'get', + path: '/action_attempts/get', + request: { + methods: [ + 'POST', + ], + parameters: [], + preferredMethod: 'POST', + semanticMethod: 'POST', + }, + response: { + description: 'Get an action attempt.', + resourceType: 'action_attempt', + responseKey: 'action_attempt', + responseType: 'resource', + }, + title: 'Get an action attempt', + undocumentedMessage: '', + workspaceScope: 'required', + }, + ], + isDeprecated: false, + isDraft: false, + isUndocumented: false, + name: 'action_attempts', + namespace: null, + path: '/action_attempts', + subroutes: [], + }, ], title: 'Foo', } @@ -1241,7 +1283,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, ], @@ -1345,7 +1387,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'deprecated_resource', - routePath: '/deprecated/resources', + routePath: '/deprecated/undocumented', undocumentedMessage: '', }, draft_resource: { @@ -1370,7 +1412,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'draft_resource', - routePath: '/draft/resources', + routePath: '/draft', undocumentedMessage: '', }, foo: { @@ -1576,7 +1618,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'plane', - routePath: '/planes', + routePath: '/transport/air/planes', undocumentedMessage: '', }, undocumented_resource: { @@ -1601,7 +1643,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'undocumented_resource', - routePath: '/undocumented/resources', + routePath: '/deprecated/undocumented', undocumentedMessage: 'This resource is undocumented', }, }, @@ -2360,6 +2402,48 @@ Generated by [AVA](https://avajs.dev). path: '/draft', subroutes: [], }, + { + endpoints: [ + { + authMethods: [ + 'api_key', + ], + codeSamples: [], + deprecationMessage: '', + description: '', + draftMessage: '', + isDeprecated: false, + isDraft: false, + isUndocumented: false, + name: 'get', + path: '/action_attempts/get', + request: { + methods: [ + 'POST', + ], + parameters: [], + preferredMethod: 'POST', + semanticMethod: 'POST', + }, + response: { + description: 'Get an action attempt.', + resourceType: 'action_attempt', + responseKey: 'action_attempt', + responseType: 'resource', + }, + title: 'Get an action attempt', + undocumentedMessage: '', + workspaceScope: 'required', + }, + ], + isDeprecated: false, + isDraft: false, + isUndocumented: false, + name: 'action_attempts', + namespace: null, + path: '/action_attempts', + subroutes: [], + }, ], title: 'Foo', } diff --git a/test/snapshots/blueprint.test.ts.snap b/test/snapshots/blueprint.test.ts.snap index b8624b3..2ba87dc 100644 Binary files a/test/snapshots/blueprint.test.ts.snap and b/test/snapshots/blueprint.test.ts.snap differ diff --git a/test/snapshots/seam-blueprint.test.ts.md b/test/snapshots/seam-blueprint.test.ts.md index 9e1d40e..e6656d0 100644 --- a/test/snapshots/seam-blueprint.test.ts.md +++ b/test/snapshots/seam-blueprint.test.ts.md @@ -101,7 +101,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -195,7 +195,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -289,7 +289,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -383,7 +383,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -477,7 +477,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -571,7 +571,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -665,7 +665,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -759,7 +759,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -853,7 +853,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -947,7 +947,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -1041,7 +1041,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -1135,7 +1135,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -1229,7 +1229,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -1323,7 +1323,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, { @@ -1417,7 +1417,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'action_attempt', - routePath: '', + routePath: '/action_attempts', undocumentedMessage: '', }, ], @@ -16076,7 +16076,7 @@ Generated by [AVA](https://avajs.dev). }, ], resourceType: 'event', - routePath: '', + routePath: '/events', undocumentedMessage: '', }, network: { @@ -16652,87 +16652,6 @@ Generated by [AVA](https://avajs.dev). routePath: '/phones', undocumentedMessage: '', }, - service_health: { - deprecationMessage: '', - description: '', - draftMessage: '', - isDeprecated: false, - isDraft: false, - isUndocumented: false, - properties: [ - { - deprecationMessage: '', - description: '', - draftMessage: '', - format: 'string', - isDeprecated: false, - isDraft: false, - isUndocumented: false, - jsonType: 'string', - name: 'description', - undocumentedMessage: '', - }, - { - deprecationMessage: '', - description: '', - draftMessage: '', - format: 'string', - isDeprecated: false, - isDraft: false, - isUndocumented: false, - jsonType: 'string', - name: 'service', - undocumentedMessage: '', - }, - { - deprecationMessage: '', - description: '', - draftMessage: '', - format: 'enum', - isDeprecated: false, - isDraft: false, - isUndocumented: false, - jsonType: 'string', - name: 'status', - undocumentedMessage: '', - values: [ - { - deprecationMessage: '', - description: '', - draftMessage: '', - isDeprecated: false, - isDraft: false, - isUndocumented: false, - name: 'healthy', - undocumentedMessage: '', - }, - { - deprecationMessage: '', - description: '', - draftMessage: '', - isDeprecated: false, - isDraft: false, - isUndocumented: false, - name: 'degraded', - undocumentedMessage: '', - }, - { - deprecationMessage: '', - description: '', - draftMessage: '', - isDeprecated: false, - isDraft: false, - isUndocumented: false, - name: 'down', - undocumentedMessage: '', - }, - ], - }, - ], - resourceType: 'service_health', - routePath: '/health', - undocumentedMessage: '', - }, thermostat_schedule: { deprecationMessage: '', description: 'Represents a [thermostat schedule](https://docs.seam.co/latest/capability-guides/thermostats/creating-and-managing-thermostat-schedules) that activates a configured [climate preset](https://docs.seam.co/latest/capability-guides/thermostats/creating-and-managing-climate-presets) on a [thermostat](https://docs.seam.co/latest/capability-guides/thermostats) at a specified starting time and deactivates the climate preset at a specified ending time.', diff --git a/test/snapshots/seam-blueprint.test.ts.snap b/test/snapshots/seam-blueprint.test.ts.snap index 8ddadda..0e03d18 100644 Binary files a/test/snapshots/seam-blueprint.test.ts.snap and b/test/snapshots/seam-blueprint.test.ts.snap differ