diff --git a/backend/src/routes/openapi.upsun.routes.ts b/backend/src/routes/openapi.upsun.routes.ts index cbfd0c4..037d019 100644 --- a/backend/src/routes/openapi.upsun.routes.ts +++ b/backend/src/routes/openapi.upsun.routes.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { z } from 'zod'; +import YAML from 'yaml'; import { config } from '../config/env.config.js'; import { ApiRouter } from '../utils/api.router.js'; import { ResourceManager, logger, extractConditionalHeaders, setCacheHeaders, sendNotModified } from '../utils/index.js'; @@ -14,6 +15,76 @@ const apiLogger = logger.child({ component: 'API' }); // Initialize Resource Manager const resourceManager = new ResourceManager(); +const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']); + +function resolveRequestedFormat(req: Request): 'json' | 'yaml' { + const acceptHeader = (req.headers.accept || '').toLowerCase(); + return acceptHeader.includes('yaml') ? 'yaml' : 'json'; +} + +function resolveOpenApiFileName(sdks: boolean, format: 'json' | 'yaml'): string { + if (sdks) { + return 'openapispec-upsun-sdks.json'; + } + + return format === 'yaml' ? 'openapispec-upsun.yaml' : 'openapispec-upsun.json'; +} + +function parseOpenApiRawData(rawData: string, format: 'json' | 'yaml') { + return format === 'yaml' ? YAML.parse(rawData) : JSON.parse(rawData); +} + +function filterOpenApiSpecByTag(spec: any, requestedTag: string) { + const normalizedRequestedTag = requestedTag.trim().toLowerCase(); + const availableTags = Array.isArray(spec?.tags) ? spec.tags : []; + const matchedTag = availableTags.find((tag: any) => String(tag?.name || '').toLowerCase() === normalizedRequestedTag); + + if (!matchedTag) { + return null; + } + + const originalPaths = spec?.paths || {}; + const filteredPaths: Record = {}; + + for (const [pathName, pathItem] of Object.entries(originalPaths)) { + if (!pathItem || typeof pathItem !== 'object') { + continue; + } + + const filteredPathItem: Record = {}; + + for (const [methodName, operation] of Object.entries(pathItem as Record)) { + const lowerMethodName = methodName.toLowerCase(); + if (!HTTP_METHODS.has(lowerMethodName)) { + filteredPathItem[methodName] = operation; + continue; + } + + const operationTags = Array.isArray((operation as any)?.tags) ? (operation as any).tags : []; + const hasRequestedTag = operationTags.some((tag: string) => String(tag).toLowerCase() === normalizedRequestedTag); + + if (hasRequestedTag) { + filteredPathItem[methodName] = operation; + } + } + + const hasMatchingOperation = Object.keys(filteredPathItem).some((key) => HTTP_METHODS.has(key.toLowerCase())); + if (hasMatchingOperation) { + filteredPaths[pathName] = filteredPathItem; + } + } + + if (Object.keys(filteredPaths).length === 0) { + return null; + } + + return { + ...spec, + paths: filteredPaths, + tags: [matchedTag] + }; +} + // ======================================== // OPENAPI ROUTES - SINGLE SOURCE OF TRUTH // ======================================== @@ -49,19 +120,10 @@ openapiRouter.route({ handler: async (req: Request, res: Response) => { try { // Detect requested format - const acceptHeader = (req.headers.accept || '').toLowerCase(); + const format = resolveRequestedFormat(req); const sdks = req.query.sdks === 'true'; - let format: 'json' | 'yaml' = 'json'; - if (acceptHeader.includes('yaml')) { - format = 'yaml'; - } - let fileName; - if (sdks) { - fileName = 'openapispec-upsun-sdks.json'; - } else { - fileName = format === 'yaml' ? 'openapispec-upsun.yaml' : 'openapispec-upsun.json'; - } + const fileName = resolveOpenApiFileName(sdks, format); try { // serving the raw file according to the format with metadata const conditionalHeaders = extractConditionalHeaders(req); @@ -90,3 +152,73 @@ openapiRouter.route({ } } }); + +// ======================================== +// GET /openapi-spec/tag/:tag - Get Upsun OpenAPI spec filtered by tag +// ======================================== +openapiRouter.route({ + method: 'get', + path: `${PATH}/tag/:tag`, + summary: 'Get Upsun OpenAPI specification filtered by tag', + description: 'Returns a valid Upsun OpenAPI specification filtered to only endpoints linked to the selected tag.', + tags: [TAG], + headers: HeaderAcceptSchema, + params: z.object({ + tag: z.string().min(1).describe('OpenAPI tag used to filter endpoints') + }), + query: z.object({ + sdks: z.string().optional().describe('If true, returns the SDKs specific patched version') + }), + responses: { + 200: { + description: 'Filtered Upsun OpenAPI specification', + schema: z.any() + }, + 404: { + description: 'Tag not found or no endpoints linked to this tag', + schema: ErrorDetailsSchema + }, + 500: { + description: 'Internal server error', + schema: ErrorDetailsSchema + } + }, + handler: async (req: Request, res: Response) => { + try { + const format = resolveRequestedFormat(req); + const sdks = req.query.sdks === 'true'; + const requestedTag = String(req.params.tag || '').trim(); + const fileName = resolveOpenApiFileName(sdks, format); + + const conditionalHeaders = extractConditionalHeaders(req); + const { data, metadata, notModified } = await resourceManager.getResourceRawWithMetadata(`openapi/${fileName}`, conditionalHeaders); + + if (notModified) { + return sendNotModified(res, metadata, config.cache.TTL); + } + + if (!data) { + apiLogger.error({ fileName }, 'Spec file not found'); + return res.status(404).json({ error: 'Spec file not found' }); + } + + const parsedSpec = parseOpenApiRawData(data, format); + const filteredSpec = filterOpenApiSpecByTag(parsedSpec, requestedTag); + + if (!filteredSpec) { + return res.status(404).json({ error: `Tag '${requestedTag}' not found or no endpoints linked to this tag` }); + } + + setCacheHeaders(res, metadata, config.cache.TTL); + + if (format === 'yaml' && !sdks) { + return res.type('text/plain; charset=utf-8').send(YAML.stringify(filteredSpec)); + } + + return res.type('application/json').send(filteredSpec); + } catch (error: any) { + apiLogger.error({ error: error.message }, 'Failed to read filtered Upsun OpenAPI spec by tag'); + return res.status(500).json({ error: error.message || 'Unable to read filtered Upsun OpenAPI spec by tag' }); + } + } +}); diff --git a/backend/src/utils/resource.manager.ts b/backend/src/utils/resource.manager.ts index 597382c..ccef5dc 100644 --- a/backend/src/utils/resource.manager.ts +++ b/backend/src/utils/resource.manager.ts @@ -177,13 +177,14 @@ export class ResourceManager { * Read raw resource content from local file system */ private getLocalResourceRaw(filePath: string): string { - const localBase = path.resolve(__dirname, this.config.localPath!); - const fullPath = this.resolveLocalPath(localBase, filePath); + // Keep raw reads aligned with parsed reads to avoid path drift in local mode. + const projectRoot = path.resolve(__dirname, '../../..'); + const resourcesBase = path.resolve(projectRoot, 'resources'); + const fullPath = this.resolveLocalPath(resourcesBase, filePath); resourceLogger.debug({ mode: this.config.mode, dirname: __dirname, - localPath: this.config.localPath, filePath, fullPath }, 'Reading local raw resource'); @@ -202,8 +203,10 @@ export class ResourceManager { * Read raw resource content from local file system with metadata */ private getLocalResourceRawWithMetadata(filePath: string): ResourceWithMetadata { - const localBase = path.resolve(__dirname, this.config.localPath!); - const fullPath = this.resolveLocalPath(localBase, filePath); + // Keep raw reads aligned with parsed reads to avoid path drift in local mode. + const projectRoot = path.resolve(__dirname, '../../..'); + const resourcesBase = path.resolve(projectRoot, 'resources'); + const fullPath = this.resolveLocalPath(resourcesBase, filePath); try { const content = fs.readFileSync(fullPath, 'utf-8');