Skip to content
Open
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
154 changes: 143 additions & 11 deletions backend/src/routes/openapi.upsun.routes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Comment on lines +26 to +30

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (sdks) {
return 'openapispec-upsun-sdks.json';
}
return format === 'yaml' ? 'openapispec-upsun.yaml' : 'openapispec-upsun.json';
const filename = sdks ? 'openapispec-upsun-sdks' : 'openapispec-upsun';
return format === 'yaml' ? `${filename}.yaml` : `${filename}.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<string, any> = {};

for (const [pathName, pathItem] of Object.entries(originalPaths)) {
if (!pathItem || typeof pathItem !== 'object') {
continue;
}

const filteredPathItem: Record<string, any> = {};

for (const [methodName, operation] of Object.entries(pathItem as Record<string, any>)) {
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
// ========================================
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' });
}
}
});
13 changes: 8 additions & 5 deletions backend/src/utils/resource.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -202,8 +203,10 @@ export class ResourceManager {
* Read raw resource content from local file system with metadata
*/
private getLocalResourceRawWithMetadata(filePath: string): ResourceWithMetadata<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);

try {
const content = fs.readFileSync(fullPath, 'utf-8');
Expand Down