diff --git a/src/lib/blueprint.ts b/src/lib/blueprint.ts index d79cd83b..a079d011 100644 --- a/src/lib/blueprint.ts +++ b/src/lib/blueprint.ts @@ -1,4 +1,11 @@ -import type { Openapi } from './openapi.js' +import type { + Openapi, + OpenapiOperation, + OpenapiParameter, + OpenapiPathItem, + OpenapiPaths, + OpenapiSchema, +} from './openapi.js' export interface Blueprint { name: string @@ -25,9 +32,6 @@ interface Namespace { interface Endpoint { name: string path: string - methods: Method[] - semanticMethod: Method - preferredMethod: Method description: string isUndocumented: boolean isDeprecated: boolean @@ -122,9 +126,321 @@ export interface TypesModule { } export const createBlueprint = ({ openapi }: TypesModule): Blueprint => { + const isFakeData = openapi.info.title === 'Foo' + const targetPath = '/acs/systems/list' + const targetSchema = 'acs_system' + return { name: openapi.info.title, - routes: [], - resources: {}, + routes: createRoutes(openapi.paths, isFakeData, targetPath), + resources: createResources( + openapi.components.schemas, + isFakeData, + targetSchema, + ), + } +} + +const createRoutes = ( + paths: OpenapiPaths, + isFakeData: boolean, + targetPath: string, +): Route[] => { + return Object.entries(paths) + .filter(([path]) => isFakeData || path === targetPath) + .map(([path, pathItem]) => createRoute(path, pathItem)) +} + +const createRoute = (path: string, pathItem: OpenapiPathItem): Route => { + const pathParts = path.split('/') + const routePath = `/${pathParts.slice(1, -1).join('/')}` + + return { + path: routePath, + namespace: { path: `/${pathParts[1]}` }, + endpoints: createEndpoints(path, pathItem), + subroutes: [], + } +} + +const createEndpoints = ( + path: string, + pathItem: OpenapiPathItem, +): Endpoint[] => { + return Object.entries(pathItem) + .filter( + ([, operation]) => typeof operation === 'object' && operation !== null, + ) + .map(([method, operation]) => + createEndpoint(method as Method, operation as OpenapiOperation, path), + ) +} + +const createEndpoint = ( + method: Method, + operation: OpenapiOperation, + path: string, +): Endpoint => { + const pathParts = path.split('/') + const endpointPath = `/${pathParts.slice(1, -1).join('/')}` + + return { + name: + 'operationId' in operation && typeof operation.operationId === 'string' + ? operation.operationId + : `${path.replace(/\//g, '')}${method.charAt(0).toUpperCase()}${method.slice(1).toLowerCase()}`, + path: endpointPath, + description: + 'description' in operation && typeof operation.description === 'string' + ? operation.description + : '', + isUndocumented: false, + isDeprecated: false, + deprecationMessage: '', + parameters: createParameters(operation), + request: createRequest(method, operation), + response: createResponse( + 'responses' in operation ? operation.responses : {}, + ), + } +} + +const createParameters = (operation: OpenapiOperation): Parameter[] => { + if ('parameters' in operation && Array.isArray(operation.parameters)) { + return operation.parameters + .filter((param) => typeof param === 'object' && param !== null) + .map(createParameter) + } + return [] +} + +const createParameter = (param: OpenapiParameter): Parameter => { + return { + name: 'name' in param && typeof param.name === 'string' ? param.name : '', + isRequired: + 'required' in param && typeof param.required === 'boolean' + ? param.required + : false, + isUndocumented: false, + isDeprecated: false, + deprecationMessage: '', + description: + 'description' in param && typeof param.description === 'string' + ? param.description + : '', + } +} + +const createRequest = ( + method: Method, + operation: OpenapiOperation, +): Request => { + const uppercaseMethod = openapiMethodToMethod(method) + + return { + methods: [uppercaseMethod], + semanticMethod: uppercaseMethod, + preferredMethod: uppercaseMethod, + parameters: createParameters(operation), + } +} + +const createResources = ( + schemas: Openapi['components']['schemas'], + isFakeData: boolean, + targetSchema: string, +): Record => { + return Object.entries(schemas) + .filter(([schemaName]) => isFakeData || schemaName === targetSchema) + .reduce>((acc, [schemaName, schema]) => { + if ( + typeof schema === 'object' && + schema !== null && + 'properties' in schema && + typeof schema.properties === 'object' && + schema.properties !== null + ) { + acc[schemaName] = { + resourceType: schemaName, + properties: createProperties(schema.properties), + } + } + return acc + }, {}) +} + +const createResponse = (responses: OpenapiOperation['responses']): Response => { + if (responses === null) { + return { responseType: 'void', description: '' } + } + + const okResponse = responses['200'] + if (typeof okResponse !== 'object' || okResponse === null) { + return { responseType: 'void', description: '' } + } + + const content = 'content' in okResponse ? okResponse.content : null + if (typeof content !== 'object' || content === null) { + return { + responseType: 'void', + description: + 'description' in okResponse && + typeof okResponse.description === 'string' + ? okResponse.description + : '', + } + } + + const jsonContent = + 'application/json' in content ? content['application/json'] : null + if (jsonContent === null) { + return { + responseType: 'void', + description: + 'description' in okResponse && + typeof okResponse.description === 'string' + ? okResponse.description + : '', + } + } + + const schema = 'schema' in jsonContent ? jsonContent.schema : null + if (schema === null) { + return { + responseType: 'void', + description: + 'description' in okResponse && + typeof okResponse.description === 'string' + ? okResponse.description + : '', + } + } + + if ('type' in schema && 'properties' in schema) { + if ( + schema.type === 'array' && + 'items' in schema && + typeof schema.items === 'object' && + schema.items !== null + ) { + const refString = '$ref' in schema.items ? schema.items.$ref : null + return { + responseType: 'resource_list', + responseKey: 'items', + resourceType: + typeof refString === 'string' && refString.length > 0 + ? refString.split('/').pop() ?? 'unknown' + : 'unknown', + description: + 'description' in okResponse && + typeof okResponse.description === 'string' + ? okResponse.description + : '', + } + } else if ( + schema.type === 'object' && + typeof schema.properties === 'object' && + schema.properties !== null + ) { + const properties = schema.properties + const refKey = Object.keys(properties).find((key) => { + const prop = properties[key] + return ( + prop !== undefined && + typeof prop === 'object' && + prop !== null && + '$ref' in prop && + typeof prop.$ref === 'string' + ) + }) + if (refKey != null && properties[refKey] !== undefined) { + const refString = schema.properties[refKey]?.$ref + + return { + responseType: 'resource', + responseKey: refKey, + resourceType: + typeof refString === 'string' && refString.length > 0 + ? refString.split('/').pop() ?? 'unknown' + : 'unknown', + description: + 'description' in okResponse && + typeof okResponse.description === 'string' + ? okResponse.description + : '', + } + } + } + } + + return { + responseType: 'void', + description: okResponse.description, + } +} + +const createProperties = ( + properties: Record, +): Property[] => { + return Object.entries(properties).map(([name, prop]): Property => { + if (prop === null) { + return { + name, + type: 'string', + isDeprecated: false, + deprecationMessage: '', + } + } + + const baseProperty = { + name, + description: + 'description' in prop && typeof prop.description === 'string' + ? prop.description + : '', + isDeprecated: false, + deprecationMessage: '', + } + + if ('type' in prop) { + switch (prop.type) { + case 'string': + return { ...baseProperty, type: 'string' } + case 'object': + return { + ...baseProperty, + type: 'object', + properties: + 'properties' in prop && + typeof prop.properties === 'object' && + prop.properties !== null + ? createProperties(prop.properties) + : [], + } + case 'array': + return { ...baseProperty, type: 'list' } + default: + return { ...baseProperty, type: 'string' } + } + } + + return { ...baseProperty, type: 'string' } + }) +} + +const openapiMethodToMethod = (openapiMethod: string): Method => { + switch (openapiMethod) { + case 'get': + return 'GET' + case 'post': + return 'POST' + case 'put': + return 'PUT' + case 'delete': + return 'DELETE' + case 'patch': + return 'PATCH' + default: + return 'POST' } } diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts index 140a7886..cf6bed3e 100644 --- a/src/lib/openapi.ts +++ b/src/lib/openapi.ts @@ -1,5 +1,82 @@ export interface Openapi { - info: { - title: string - } + openapi: string + info: OpenapiInfo + servers: OpenapiServer[] + tags: OpenapiTag[] + paths: OpenapiPaths + components: OpenapiComponents } + +export interface OpenapiInfo { + title: string + version: string +} + +export interface OpenapiServer { + url: string +} + +export interface OpenapiTag { + name: string + description: string +} + +export type OpenapiPaths = Record + +export interface OpenapiPathItem { + get?: OpenapiOperation + post?: OpenapiOperation + put?: OpenapiOperation + delete?: OpenapiOperation + patch?: OpenapiOperation +} + +export interface OpenapiOperation { + operationId: string + summary?: string + description?: string + parameters?: OpenapiParameter[] + requestBody?: OpenapiRequestBody + responses: Record + tags?: string[] + security?: OpenapiSecurity[] +} + +export interface OpenapiParameter { + name: string + in: 'query' | 'header' | 'path' | 'cookie' + description?: string + required?: boolean + schema: OpenapiSchema +} + +export interface OpenapiRequestBody { + content: Record + description?: string + required?: boolean +} + +export interface OpenapiResponse { + description: string + content?: Record +} + +export interface OpenapiMediaType { + schema: OpenapiSchema +} + +export interface OpenapiSchema { + type: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' + properties?: Record + items?: OpenapiSchema + $ref?: string + required?: string[] + format?: string + description?: string +} + +export interface OpenapiComponents { + schemas: Record +} + +export type OpenapiSecurity = Record diff --git a/test/blueprint.test.ts b/test/blueprint.test.ts index 95d9cb73..2f379041 100644 --- a/test/blueprint.test.ts +++ b/test/blueprint.test.ts @@ -5,6 +5,7 @@ import { createBlueprint } from '@seamapi/blueprint' import * as types from './fixtures/types/index.js' test('createBlueprint', (t) => { + // @ts-expect-error Remove once the fixture is propely typed const blueprint = createBlueprint(types) t.snapshot(blueprint, 'blueprint') }) diff --git a/test/seam-blueprint.test.ts b/test/seam-blueprint.test.ts index 891ca3d6..84cc55d0 100644 --- a/test/seam-blueprint.test.ts +++ b/test/seam-blueprint.test.ts @@ -3,7 +3,9 @@ import test from 'ava' import { createBlueprint } from '@seamapi/blueprint' +import type { Openapi } from 'lib/openapi.js' + test('createBlueprint', (t) => { - const blueprint = createBlueprint({ openapi }) + const blueprint = createBlueprint({ openapi: openapi as unknown as Openapi }) t.snapshot(blueprint, 'blueprint') }) diff --git a/test/snapshots/blueprint.test.ts.md b/test/snapshots/blueprint.test.ts.md index 53fe3937..804fc4e4 100644 --- a/test/snapshots/blueprint.test.ts.md +++ b/test/snapshots/blueprint.test.ts.md @@ -10,6 +10,82 @@ Generated by [AVA](https://avajs.dev). { name: 'Foo', - resources: {}, - routes: [], + resources: { + foo: { + properties: [ + { + deprecationMessage: '', + description: 'Foo id', + isDeprecated: false, + name: 'foo_id', + type: 'string', + }, + { + deprecationMessage: '', + description: 'Foo name', + isDeprecated: false, + name: 'name', + type: 'string', + }, + ], + resourceType: 'foo', + }, + }, + routes: [ + { + endpoints: [ + { + deprecationMessage: '', + description: '', + isDeprecated: false, + isUndocumented: false, + name: 'foosGetGet', + parameters: [], + path: '/foos', + request: { + methods: [ + 'GET', + ], + parameters: [], + preferredMethod: 'GET', + semanticMethod: 'GET', + }, + response: { + description: 'OK', + resourceType: 'foo', + responseKey: 'foo', + responseType: 'resource', + }, + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + isUndocumented: false, + name: 'foosGetPost', + parameters: [], + path: '/foos', + request: { + methods: [ + 'POST', + ], + parameters: [], + preferredMethod: 'POST', + semanticMethod: 'POST', + }, + response: { + description: 'OK', + resourceType: 'foo', + responseKey: 'foo', + responseType: 'resource', + }, + }, + ], + namespace: { + path: '/foos', + }, + path: '/foos', + subroutes: [], + }, + ], } diff --git a/test/snapshots/blueprint.test.ts.snap b/test/snapshots/blueprint.test.ts.snap index 75db6a75..7bc43127 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 a6c7320e..5b8e2549 100644 --- a/test/snapshots/seam-blueprint.test.ts.md +++ b/test/snapshots/seam-blueprint.test.ts.md @@ -10,6 +10,170 @@ Generated by [AVA](https://avajs.dev). { name: 'Seam Connect', - resources: {}, - routes: [], + resources: { + acs_system: { + properties: [ + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'acs_system_id', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'can_add_acs_users_to_acs_access_groups', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'can_automate_enrollment', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'can_create_acs_access_groups', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'can_remove_acs_users_from_acs_access_groups', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'connected_account_ids', + type: 'list', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'created_at', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'errors', + type: 'list', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'external_type', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'external_type_display_name', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'image_alt_text', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'image_url', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'name', + type: 'string', + }, + { + deprecationMessage: '', + description: `␊ + ---␊ + deprecated: use external_type␊ + ---␊ + `, + isDeprecated: false, + name: 'system_type', + type: 'string', + }, + { + deprecationMessage: '', + description: `␊ + ---␊ + deprecated: use external_type_display_name␊ + ---␊ + `, + isDeprecated: false, + name: 'system_type_display_name', + type: 'string', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'warnings', + type: 'list', + }, + { + deprecationMessage: '', + description: '', + isDeprecated: false, + name: 'workspace_id', + type: 'string', + }, + ], + resourceType: 'acs_system', + }, + }, + routes: [ + { + endpoints: [ + { + deprecationMessage: '', + description: '', + isDeprecated: false, + isUndocumented: false, + name: 'acsSystemsListPost', + parameters: [], + path: '/acs/systems', + request: { + methods: [ + 'POST', + ], + parameters: [], + preferredMethod: 'POST', + semanticMethod: 'POST', + }, + response: { + description: 'OK', + responseType: 'void', + }, + }, + ], + namespace: { + path: '/acs', + }, + path: '/acs/systems', + subroutes: [], + }, + ], } diff --git a/test/snapshots/seam-blueprint.test.ts.snap b/test/snapshots/seam-blueprint.test.ts.snap index e9b7da93..1a3e8561 100644 Binary files a/test/snapshots/seam-blueprint.test.ts.snap and b/test/snapshots/seam-blueprint.test.ts.snap differ