@furystack/rest
Version:
Generic REST package
164 lines (145 loc) • 5.75 kB
text/typescript
import type { ApiDocumentMetadata, ApiEndpointSchema } from './api-endpoint-schema.js'
import type { Method } from './methods.js'
import type { OpenApiDocument, Operation, ReferenceObject, SecuritySchemeObject } from './openapi-document.js'
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'] as const
const isReferenceObject = (obj: unknown): obj is ReferenceObject =>
typeof obj === 'object' && obj !== null && '$ref' in obj
/**
* Converts an OpenAPI `{param}` path format to FuryStack `:param` format.
*
* @param path - The OpenAPI path (e.g. `/users/{id}`)
* @returns The FuryStack path (e.g. `/users/:id`)
*/
export const convertOpenApiPathToFuryStack = (path: string): string => path.replace(/\{([^{}]+)\}/g, ':$1')
const extractResponseSchema = (operation: Operation): unknown => {
for (const statusCode of ['200', '201', '2XX', 'default']) {
const response = operation.responses?.[statusCode]
if (!response || isReferenceObject(response)) continue
const jsonContent = response.content?.['application/json']
if (jsonContent?.schema) return jsonContent.schema
}
return undefined
}
const extractSchemaName = (operation: Operation, method: string, path: string): string => {
if (operation.operationId) return operation.operationId
const cleanPath = path
.replace(/\{([^{}]+)\}/g, '$1')
.replace(/\//g, '_')
.replace(/^_/, '')
return `${method}_${cleanPath}`
}
const isOperationAuthenticated = (operation: Operation, docSecurity?: OpenApiDocument['security']): boolean => {
if (operation.security !== undefined) {
return operation.security.length > 0
}
if (docSecurity !== undefined) {
return docSecurity.length > 0
}
return false
}
const extractSecuritySchemeNames = (
operation: Operation,
docSecurity?: OpenApiDocument['security'],
): string[] | undefined => {
const security = operation.security !== undefined ? operation.security : docSecurity
if (!security || security.length === 0) return undefined
const names = security.flatMap((req) => Object.keys(req))
return names.length > 0 ? names : undefined
}
const extractDocumentMetadata = (doc: OpenApiDocument): ApiDocumentMetadata | undefined => {
const metadata: ApiDocumentMetadata = {}
let hasMetadata = false
if (doc.info.summary) {
metadata.summary = doc.info.summary
hasMetadata = true
}
if (doc.info.termsOfService) {
metadata.termsOfService = doc.info.termsOfService
hasMetadata = true
}
if (doc.info.contact) {
metadata.contact = doc.info.contact
hasMetadata = true
}
if (doc.info.license) {
metadata.license = doc.info.license
hasMetadata = true
}
if (doc.servers?.length) {
metadata.servers = doc.servers
hasMetadata = true
}
if (doc.tags?.length) {
metadata.tags = doc.tags
hasMetadata = true
}
if (doc.externalDocs) {
metadata.externalDocs = doc.externalDocs
hasMetadata = true
}
const schemes = doc.components?.securitySchemes
if (schemes) {
const resolved: Record<string, SecuritySchemeObject> = {}
for (const [name, scheme] of Object.entries(schemes)) {
if (!isReferenceObject(scheme)) {
resolved[name] = scheme
}
}
if (Object.keys(resolved).length > 0) {
metadata.securitySchemes = resolved
hasMetadata = true
}
}
return hasMetadata ? metadata : undefined
}
/**
* Converts an OpenAPI 3.x document to a FuryStack `ApiEndpointSchema`.
*
* This enables consuming external OpenAPI documents with FuryStack's runtime pipeline.
* Preserves operation-level metadata (tags, deprecated, summary, description) and
* document-level metadata (servers, tags, contact, license, securitySchemes).
*
* **Important:** If the document contains `$ref` pointers, call `resolveOpenApiRefs(doc)`
* first to inline them. This function does not resolve `$ref` on its own.
*
* @param doc - The OpenAPI document to convert
* @returns An ApiEndpointSchema that can be used with FuryStack's API tools
*/
export const openApiToSchema = (doc: OpenApiDocument): ApiEndpointSchema => {
const endpoints: ApiEndpointSchema['endpoints'] = {}
if (doc.paths) {
for (const [openApiPath, pathItemOrRef] of Object.entries(doc.paths)) {
if (isReferenceObject(pathItemOrRef)) continue
const pathItem = pathItemOrRef
const furyStackPath = convertOpenApiPathToFuryStack(openApiPath)
for (const method of HTTP_METHODS) {
const operation = pathItem[method]
if (!operation) continue
const upperMethod = method.toUpperCase() as Method
const methodEndpoints = endpoints[upperMethod] ?? {}
endpoints[upperMethod] = methodEndpoints
const responseSchema = extractResponseSchema(operation)
const schemaName = extractSchemaName(operation, method, openApiPath)
const securitySchemeNames = extractSecuritySchemeNames(operation, doc.security)
methodEndpoints[furyStackPath] = {
path: furyStackPath,
schema: responseSchema ?? {},
schemaName,
isAuthenticated: isOperationAuthenticated(operation, doc.security),
...(securitySchemeNames ? { securitySchemes: securitySchemeNames } : {}),
...(operation.tags?.length ? { tags: operation.tags } : {}),
...(operation.deprecated ? { deprecated: true } : {}),
...(operation.summary ? { summary: operation.summary } : {}),
...(operation.description ? { description: operation.description } : {}),
}
}
}
}
return {
name: doc.info.title,
description: doc.info.description ?? '',
version: doc.info.version,
metadata: extractDocumentMetadata(doc),
endpoints,
}
}