Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add routePath to Resource and blueprint.events #141

Merged
merged 17 commits into from
Dec 20, 2024
Merged
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
2 changes: 1 addition & 1 deletion src/lib/blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type Method,
type OpenapiAuthMethod,
} from 'lib/blueprint.js'
import type { OpenapiOperation, OpenapiSchema } from 'lib/openapi.js'
import type { OpenapiOperation, OpenapiSchema } from 'lib/openapi/types.js'

test('createProperties: assigns appropriate default values', (t) => {
const minimalProperties = {
Expand Down
130 changes: 101 additions & 29 deletions src/lib/blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,27 @@ import type {
CodeSampleDefinition,
CodeSampleSyntax,
} from './code-sample/schema.js'
import { findCommonOpenapiSchemaProperties } from './openapi/find-common-openapi-schema-properties.js'
import {
type AuthMethodSchema,
EventResourceSchema,
OpenapiOperationSchema,
PropertySchema,
ResourceSchema,
} from './openapi/schemas.js'
import type {
Openapi,
OpenapiOperation,
OpenapiPathItem,
OpenapiPaths,
OpenapiSchema,
} from './openapi.js'
import {
type AuthMethodSchema,
OpenapiOperationSchema,
PropertySchema,
} from './openapi-schema.js'
} from './openapi/types.js'

export interface Blueprint {
title: string
routes: Route[]
resources: Record<string, Resource>
events: EventResource[]
}

export interface Route {
Expand All @@ -43,6 +47,7 @@ export interface Resource {
resourceType: string
properties: Property[]
description: string
routePath: string
isDeprecated: boolean
deprecationMessage: string
isUndocumented: boolean
Expand All @@ -51,6 +56,12 @@ export interface Resource {
draftMessage: string
}

interface EventResource extends Resource {
resourceType: 'event'
eventType: string
targetResourceType: string | null
}

export interface Namespace {
path: string
isDeprecated: boolean
Expand Down Expand Up @@ -277,6 +288,43 @@ export interface BlueprintOptions {
formatCode?: (content: string, syntax: CodeSampleSyntax) => Promise<string>
}

const createEvents = (
schemas: Openapi['components']['schemas'],
resources: Record<string, Resource>,
): EventResource[] => {
const eventSchema = schemas['event']
if (
eventSchema == null ||
typeof eventSchema !== 'object' ||
!('oneOf' in eventSchema) ||
!Array.isArray(eventSchema.oneOf)
) {
return []
}

return eventSchema.oneOf
.map((schema) => {
if (
typeof schema !== 'object' ||
schema.properties?.event_type?.enum?.[0] == null
) {
return null
}

const eventType = schema.properties.event_type.enum[0]
const targetResourceType = Object.keys(resources).find((resourceName) =>
eventType.split('.').includes(resourceName),
)

return {
...createResource('event', schema as OpenapiSchema),
eventType,
targetResourceType: targetResourceType ?? null,
}
})
.filter((event): event is EventResource => event !== null)
}

export const createBlueprint = async (
typesModule: TypesModuleInput,
{ formatCode = async (content) => content }: BlueprintOptions = {},
Expand All @@ -291,10 +339,13 @@ export const createBlueprint = async (
formatCode,
}

const resources = createResources(openapi.components.schemas)

return {
title: openapi.info.title,
routes: await createRoutes(openapi.paths, context),
resources: createResources(openapi.components.schemas),
resources,
events: createEvents(openapi.components.schemas, resources),
}
}

Expand Down Expand Up @@ -752,39 +803,60 @@ const createParameter = (
}
}

const createResources = (
export const createResources = (
schemas: Openapi['components']['schemas'],
): Record<string, Resource> => {
return Object.entries(schemas).reduce<Record<string, Resource>>(
(acc, [schemaName, schema]) => {
if (
typeof schema === 'object' &&
schema !== null &&
'properties' in schema &&
typeof schema.properties === 'object' &&
schema.properties !== null
) {
(resources, [schemaName, schema]) => {
const { success: isValidEventSchema, data: parsedEvent } =
EventResourceSchema.safeParse(schema)
if (isValidEventSchema) {
const commonProperties = findCommonOpenapiSchemaProperties(
parsedEvent.oneOf,
)
const eventSchema: OpenapiSchema = {
properties: commonProperties,
type: 'object',
}
return {
...resources,
[schemaName]: createResource(schemaName, eventSchema),
}
}

const { success: isValidResourceSchema } =
ResourceSchema.safeParse(schema)
if (isValidResourceSchema) {
return {
...acc,
[schemaName]: {
resourceType: schemaName,
properties: createProperties(schema.properties, [schemaName]),
description: schema.description ?? '',
isDeprecated: schema.deprecated ?? false,
deprecationMessage: schema['x-deprecated'] ?? '',
isUndocumented: (schema['x-undocumented'] ?? '').length > 0,
undocumentedMessage: schema['x-undocumented'] ?? '',
isDraft: (schema['x-draft'] ?? '').length > 0,
draftMessage: schema['x-draft'] ?? '',
},
...resources,
[schemaName]: createResource(schemaName, schema),
}
}
return acc

return resources
},
{},
)
}

const createResource = (
schemaName: string,
schema: OpenapiSchema,
): Resource => {
return {
resourceType: schemaName,
properties: createProperties(schema.properties ?? {}, [schemaName]),
description: schema.description ?? '',
isDeprecated: schema.deprecated ?? false,
routePath: schema['x-route-path'] ?? '',
deprecationMessage: schema['x-deprecated'] ?? '',
isUndocumented: (schema['x-undocumented'] ?? '').length > 0,
undocumentedMessage: schema['x-undocumented'] ?? '',
isDraft: (schema['x-draft'] ?? '').length > 0,
draftMessage: schema['x-draft'] ?? '',
}
}

const createResponse = (
operation: OpenapiOperation,
path: string,
Expand Down
38 changes: 38 additions & 0 deletions src/lib/openapi/find-common-openapi-schema-properties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import test from 'ava'

import type { OpenapiSchema } from 'lib/openapi/types.js'

import { findCommonOpenapiSchemaProperties } from './find-common-openapi-schema-properties.js'

test('findCommonOpenapiSchemaProperties: extracts common properties from openapi schemas', (t) => {
const schemas: OpenapiSchema[] = [
{
type: 'object',
properties: {
event_id: { type: 'string', format: 'uuid' },
event_type: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
foo_id: { type: 'string' },
},
},
{
type: 'object',
properties: {
event_id: { type: 'string', format: 'uuid' },
event_type: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
bar_id: { type: 'string' },
},
},
]

const commonProps = findCommonOpenapiSchemaProperties(schemas)
const commonKeys = Object.keys(commonProps)

t.is(commonKeys.length, 3)
t.true(
['event_id', 'event_type', 'created_at'].every((key) =>
commonKeys.includes(key),
),
)
})
36 changes: 36 additions & 0 deletions src/lib/openapi/find-common-openapi-schema-properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { OpenapiSchema } from 'lib/openapi/types.js'

export function findCommonOpenapiSchemaProperties(
schemas: OpenapiSchema[],
): Record<string, OpenapiSchema> {
const firstSchema = schemas[0]
if (schemas.length === 0 || firstSchema?.properties == null) {
return {}
}

return Object.entries(firstSchema.properties).reduce<
Record<string, OpenapiSchema>
>((commonProps, [propKey, propValue]) => {
const isPropInAllSchemas = schemas.every((schema) =>
Object.keys(schema.properties ?? {}).includes(propKey),
)

if (!isPropInAllSchemas) {
return commonProps
}

if ('enum' in propValue) {
const mergedEnumValues = schemas.reduce<string[]>((allEnums, schema) => {
const enumValues = schema.properties?.[propKey]?.enum ?? []
return [...new Set([...allEnums, ...enumValues])]
}, [])

return {
...commonProps,
[propKey]: { ...propValue, enum: mergedEnumValues },
}
}

return { ...commonProps, [propKey]: propValue }
}, {})
}
3 changes: 3 additions & 0 deletions src/lib/openapi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './find-common-openapi-schema-properties.js'
export * from './schemas.js'
export * from './types.js'
16 changes: 16 additions & 0 deletions src/lib/openapi-schema.ts → src/lib/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,19 @@ export const PropertySchema: z.ZodSchema<any> = z.object({
$ref: z.string().optional(),
format: z.string().optional(),
})

export const ResourceSchema = z.object({
type: z.literal('object'),
properties: z.record(z.string(), PropertySchema),
required: z.array(z.string()).default([]),
description: z.string().default(''),
'x-route-path': z.string().default(''),
'x-undocumented': z.string().default(''),
'x-deprecated': z.string().default(''),
'x-draft': z.string().default(''),
})

export const EventResourceSchema = z.object({
discriminator: z.object({ propertyName: z.string() }),
oneOf: z.array(ResourceSchema),
})
2 changes: 2 additions & 0 deletions src/lib/openapi.ts → src/lib/openapi/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ export interface OpenapiSchema {
items?: OpenapiSchema
$ref?: string
required?: string[]
enum?: string[]
format?: string
description?: string
deprecated?: boolean
'x-deprecated'?: string
'x-draft'?: string
'x-undocumented'?: string
'x-route-path'?: string
}

export interface OpenapiComponents {
Expand Down
37 changes: 37 additions & 0 deletions test/fixtures/types/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default {
},
},
required: ['foo_id', 'name'],
'x-route-path': '/foos',
},
plane: {
type: 'object',
Expand All @@ -69,6 +70,7 @@ export default {
},
},
required: ['plane_id', 'name'],
'x-route-path': '/planes',
},
deprecated_resource: {
type: 'object',
Expand All @@ -83,6 +85,7 @@ export default {
required: ['deprecated_resource_id'],
deprecated: true,
'x-deprecated': 'This resource is deprecated',
'x-route-path': '/deprecated/resources',
},
draft_resource: {
type: 'object',
Expand All @@ -96,6 +99,7 @@ export default {
},
required: ['draft_resource_id'],
'x-draft': 'This resource is draft',
'x-route-path': '/draft/resources',
},
undocumented_resource: {
type: 'object',
Expand All @@ -109,6 +113,39 @@ export default {
},
required: ['undocumented_resource_id'],
'x-undocumented': 'This resource is undocumented',
'x-route-path': '/undocumented/resources',
},
event: {
oneOf: [
{
type: 'object',
description: 'A foo.created event',
properties: {
event_id: {
description: 'Event ID',
format: 'uuid',
type: 'string',
},
event_type: {
description: 'Type of event',
type: 'string',
enum: ['foo.created'],
},
foo_id: {
description: 'ID of the foo that was created',
format: 'uuid',
type: 'string',
},
created_at: {
description: 'When the event occurred',
type: 'string',
format: 'date-time',
},
},
required: ['event_id', 'event_type', 'foo_id', 'created_at'],
'x-route-path': '/foos',
},
],
},
},
},
Expand Down
Loading
Loading