From 688a991664c5283d6b05b413225ad5c1b2af0ff9 Mon Sep 17 00:00:00 2001 From: Florent Huck Date: Tue, 27 Jan 2026 09:47:42 +0100 Subject: [PATCH 1/5] change routes for Extensions --- backend/src/routes/extension.routes.ts | 124 ++++++++++++------------- backend/src/utils/response.format.ts | 2 +- 2 files changed, 59 insertions(+), 67 deletions(-) diff --git a/backend/src/routes/extension.routes.ts b/backend/src/routes/extension.routes.ts index 8b1e279..c52daff 100644 --- a/backend/src/routes/extension.routes.ts +++ b/backend/src/routes/extension.routes.ts @@ -34,11 +34,15 @@ extensionRouter.route({ summary: 'Get all PHP extensions', description: `Returns the list of PHP extensions and their available configuration by PHP versions, grouped by "dedicated" or "cloud" services.`, tags: [TAG], - query: z.object({}), + query: z.object({ + service: z.enum(['all', 'cloud', 'dedicated']) + .optional() + .describe('Filter by service name (e.g., "dedicated", "cloud")'), + }), headers: HeaderAcceptSchema, responses: { 200: { - description: 'Full list of PHP extensions', + description: 'Full list of PHP extensions with optional filtering by service', schema: RuntimeExtensionListSchema, contentTypes: ['application/json', 'application/x-yaml'] }, @@ -49,82 +53,65 @@ extensionRouter.route({ }, handler: async (req: Request, res: Response) => { try { - const data = await resourceManager.getResource('extension/php_extensions.json'); + const { service } = req.query as { + service?: 'all' | 'cloud' | 'dedicated'; + }; + const safeService = service ? escapeHtml(service) : undefined; + + let data = await resourceManager.getResource('extension/php_extensions.json'); + + if (service && service !== 'all') { + let dataFiltered = {}; + dataFiltered = { [service]: data?.[service] }; + if (Object.keys(dataFiltered).length === 0) { + return sendErrorFormatted(res, { + title: `No extensions found for service '${safeService}'`, + detail: `No extensions found for service '${safeService}', "service" should be one of "dedicated" or "cloud".`, + }); + } + data = dataFiltered; + } + const baseUrl = `${config.server.BASE_URL}`; - data.cloud = withSelfLink(data.cloud, (id) => `${baseUrl}${PATH}/cloud/${encodeURIComponent(id)}`); - data.cloud = { - ...data.cloud, - _links: { self: `${baseUrl}${PATH}/cloud` } - }; + // set _links for each service + for (const service of Object.keys(data)) { + data[service] = withSelfLink(data[service], (id) => `${baseUrl}${PATH}/${encodeURIComponent(service)}/${encodeURIComponent(id)}`); + data[service] = { + ...data[service], + _links: { self: `${baseUrl}${PATH}/?service=${service}` } + }; + } sendFormatted(res, data); } catch (error: any) { apiLogger.error({ error: error.message }, 'Failed to read PHP extensions'); sendErrorFormatted(res, { title: 'Unable to read PHP extensions', - detail: error.message || 'An unexpected error occurred while reading PHP extensions', - status: 500 - }); - } - } -}); - -// GET /extension/php/cloud - grouped for cloud -extensionRouter.route({ - method: 'get', - path: `${PATH}/cloud`, - summary: 'Get list of PHP extensions for cloud services', - description: `Returns the list of PHP extensions for \`cloud\` service.`, - tags: [TAG], - query: z.object({}), - headers: HeaderAcceptSchema, - responses: { - 200: { - description: 'List of PHP extensions for cloud services', - schema: CloudExtensionsSchema, - contentTypes: ['application/json', 'application/x-yaml'] - }, - 500: { - description: 'Internal server error', - schema: ErrorDetailsSchema - } - }, - handler: async (req: Request, res: Response) => { - try { - const data = await resourceManager.getResource('extension/php_extensions.json'); - const cloudExtensions: CloudExtensions = data?.cloud || {}; - - const baseUrl = `${config.server.BASE_URL}`; - const cloudExtensionsWithLinks = withSelfLink(cloudExtensions, (id) => `${baseUrl}${PATH}/cloud/${encodeURIComponent(id)}`); - - sendFormatted(res, cloudExtensionsWithLinks); - } catch (error: any) { - apiLogger.error({ error: error.message }, 'Failed to read PHP Cloud extensions'); - sendErrorFormatted(res, { - title: 'Unable to read PHP Cloud extensions', - detail: error.message || 'An unexpected error occurred while reading PHP Cloud extensions', - status: 500 + detail: { + 'message': error.message || 'An unexpected error occurred while reading PHP extensions', + } }); } } }); -// GET /extension/php/grid/:version - grid filtered by version +// GET /extension/php/:service/:id - service filtered by id extensionRouter.route({ method: 'get', - path: `${PATH}/cloud/:id`, - summary: 'Get Cloud extension by Id', - description: `Get a specific Cloud extension entry by its Id from the \`cloud\` root node.`, + path: `${PATH}/:service/:id`, + summary: 'Get Service extension by Id', + description: `Get a specific Service extension entry by its \`id\` from the \`service\` root node.`, tags: [TAG], params: z.object({ + service: z.enum(['cloud', 'dedicated']).describe('Service name (e.g., cloud, dedicated)'), id: z.string().describe('Extension Id (e.g., json, imagick, gd)') }), query: z.object({}), headers: HeaderAcceptSchema, responses: { 200: { - description: 'Map all PHP versions allowing usage of this extension, with their status (e.g. "default", "built-in" or "available") and possible options (e.g. "wepb" for imagick)', + description: 'Map all PHP versions of the choosen service allowing usage of this extension, with their status (e.g. "default", "built-in" or "available") and possible options (e.g. "wepb" for imagick)', schema: RuntimeExtensionVersionSchema, contentTypes: ['application/json', 'application/x-yaml'] }, @@ -141,27 +128,32 @@ extensionRouter.route({ }, handler: async (req: Request, res: Response) => { try { - const { id } = req.params as { id: string }; + const { id, service } = req.params as { id: string; service: string }; const imageId = escapeHtml(id); + const safeService = escapeHtml(service); const data = await resourceManager.getResource('extension/php_extensions.json'); - const extensionEntry = data?.cloud?.[id]; + const extensionEntry = data?.[service]?.[id]; if (!extensionEntry) { - sendErrorFormatted(res, { - title: 'Extension not found', - detail: `Extension "${imageId}" not found. See extra.availableExtensions for a list of valid extension IDs.`, - status: 404, - extra: { availableExtensions: Object.keys(data?.cloud || {}) } + return sendErrorFormatted(res, { + title: 'Invalid path parameters', + detail: { + 'status': 'invalid_value', + 'values': Object.keys(data?.[service] || data), + 'path': 'id', + 'message': `Extension id "${imageId == '{id}' ? undefined : imageId}" in Service "${safeService}" not found.`, + }, }); } sendFormatted(res, extensionEntry); } catch (error: any) { - apiLogger.error({ error: error.message }, 'Failed to read PHP Cloud extensions'); + apiLogger.error({ error: error.message }, 'Failed to read PHP Service extensions'); sendErrorFormatted(res, { - title: 'Unable to read PHP Cloud extensions', - detail: error.message || 'An unexpected error occurred while reading PHP Cloud extensions', - status: 500 + title: 'Unable to read PHP Service extensions', + detail: { + 'message': error.message || 'An unexpected error occurred while reading PHP Service extensions', + }, }); } } diff --git a/backend/src/utils/response.format.ts b/backend/src/utils/response.format.ts index cda998b..30c6a27 100644 --- a/backend/src/utils/response.format.ts +++ b/backend/src/utils/response.format.ts @@ -18,7 +18,7 @@ export function sendFormatted(res: Response, data: T, status = 200) { } } -export function sendErrorFormatted(res: Response, error: { type?: string; title?: string; status?: number; detail?: string; instance?: string; extra?: Record;}, status?: number) { +export function sendErrorFormatted(res: Response, error: { type?: string; title?: string; status?: number; detail?: any; instance?: string; extra?: Record;}, status?: number) { const accept = res.req?.headers['accept'] || ''; if (accept.includes('application/x-yaml')) { res.set('Content-Type', 'text/plain; charset=utf-8'); From 185864fa0bb77c0bd42a79c1d1033d1d7860db19 Mon Sep 17 00:00:00 2001 From: Florent Huck Date: Tue, 27 Jan 2026 13:58:01 +0100 Subject: [PATCH 2/5] solve generic errors + error structure --- backend/src/routes/extension.routes.ts | 66 +++++++++++++------------ backend/src/routes/image.routes.ts | 37 ++++++++------ backend/src/routes/region.routes.ts | 20 ++++---- backend/src/routes/validation.routes.ts | 6 +-- backend/src/schemas/image.schema.ts | 24 ++++++--- backend/src/utils/api.router.ts | 36 ++++++++------ backend/src/utils/response.format.ts | 4 +- 7 files changed, 108 insertions(+), 85 deletions(-) diff --git a/backend/src/routes/extension.routes.ts b/backend/src/routes/extension.routes.ts index c52daff..f2c7e3f 100644 --- a/backend/src/routes/extension.routes.ts +++ b/backend/src/routes/extension.routes.ts @@ -4,12 +4,10 @@ import { registry, z } from 'zod'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { ApiRouter } from '../utils/api.router.js'; import { ResourceManager, escapeHtml, logger } from '../utils/index.js'; -import { ErrorDetailsSchema, HeaderAcceptSchema } from '../schemas/api.schema.js'; +import { ErrorDetails, ErrorDetailsSchema, HeaderAcceptSchema } from '../schemas/api.schema.js'; import { RuntimeExtensionListSchema, RuntimeExtensionList, - CloudExtensionsSchema, - CloudExtensions, RuntimeExtensionVersionSchema, RuntimeExtensionVersion } from '../schemas/extension.schema.js'; @@ -49,6 +47,10 @@ extensionRouter.route({ 500: { description: 'Internal server error', schema: ErrorDetailsSchema + }, + 400: { + description: 'Bad request', + schema: ErrorDetailsSchema } }, handler: async (req: Request, res: Response) => { @@ -58,40 +60,41 @@ extensionRouter.route({ }; const safeService = service ? escapeHtml(service) : undefined; - let data = await resourceManager.getResource('extension/php_extensions.json'); + let extensions = await resourceManager.getResource('extension/php_extensions.json'); if (service && service !== 'all') { - let dataFiltered = {}; - dataFiltered = { [service]: data?.[service] }; - if (Object.keys(dataFiltered).length === 0) { + let extensionsFiltered = {}; + extensionsFiltered = { [service]: extensions?.[service] }; + if (Object.keys(extensionsFiltered).length === 0) { return sendErrorFormatted(res, { title: `No extensions found for service '${safeService}'`, detail: `No extensions found for service '${safeService}', "service" should be one of "dedicated" or "cloud".`, - }); + status: 404 + } as ErrorDetails); } - data = dataFiltered; + extensions = extensionsFiltered; } const baseUrl = `${config.server.BASE_URL}`; // set _links for each service - for (const service of Object.keys(data)) { - data[service] = withSelfLink(data[service], (id) => `${baseUrl}${PATH}/${encodeURIComponent(service)}/${encodeURIComponent(id)}`); - data[service] = { - ...data[service], + for (const service of Object.keys(extensions)) { + extensions[service] = withSelfLink(extensions[service], (id) => `${baseUrl}${PATH}/${encodeURIComponent(service)}/${encodeURIComponent(id)}`); + extensions[service] = { + ...extensions[service], _links: { self: `${baseUrl}${PATH}/?service=${service}` } }; } - sendFormatted(res, data); + const extensionsSafe = RuntimeExtensionListSchema.parse(extensions); + sendFormatted(res, extensionsSafe); } catch (error: any) { apiLogger.error({ error: error.message }, 'Failed to read PHP extensions'); sendErrorFormatted(res, { title: 'Unable to read PHP extensions', - detail: { - 'message': error.message || 'An unexpected error occurred while reading PHP extensions', - } - }); + detail: error.message || 'An unexpected error occurred while reading PHP extensions', + status: 500 + } as ErrorDetails); } } }); @@ -100,7 +103,7 @@ extensionRouter.route({ extensionRouter.route({ method: 'get', path: `${PATH}/:service/:id`, - summary: 'Get Service extension by Id', + summary: 'Get PHP extension by Service and Id', description: `Get a specific Service extension entry by its \`id\` from the \`service\` root node.`, tags: [TAG], params: z.object({ @@ -132,29 +135,28 @@ extensionRouter.route({ const imageId = escapeHtml(id); const safeService = escapeHtml(service); - const data = await resourceManager.getResource('extension/php_extensions.json'); + const extensions = await resourceManager.getResource('extension/php_extensions.json'); - const extensionEntry = data?.[service]?.[id]; + const extensionEntry = extensions?.[service]?.[id]; if (!extensionEntry) { return sendErrorFormatted(res, { title: 'Invalid path parameters', - detail: { - 'status': 'invalid_value', - 'values': Object.keys(data?.[service] || data), - 'path': 'id', - 'message': `Extension id "${imageId == '{id}' ? undefined : imageId}" in Service "${safeService}" not found.`, + detail: `Extension id "${imageId == '{id}' ? undefined : imageId}" in Service "${safeService}" not found. See extra.availableExtensions for a list of valid extension ids for this service.`, + extra: { + availableExtensions: Object.keys(extensions?.[service] || extensions), }, - }); + status: 404, + } as ErrorDetails); } - sendFormatted(res, extensionEntry); + const extensionEntrySafe = RuntimeExtensionVersionSchema.parse(extensionEntry); + sendFormatted(res, extensionEntrySafe); } catch (error: any) { apiLogger.error({ error: error.message }, 'Failed to read PHP Service extensions'); sendErrorFormatted(res, { title: 'Unable to read PHP Service extensions', - detail: { - 'message': error.message || 'An unexpected error occurred while reading PHP Service extensions', - }, - }); + detail: error.message || 'An unexpected error occurred while reading PHP Service extensions', + status: 500 + } as ErrorDetails); } } }); diff --git a/backend/src/routes/image.routes.ts b/backend/src/routes/image.routes.ts index e24658e..8e575f0 100644 --- a/backend/src/routes/image.routes.ts +++ b/backend/src/routes/image.routes.ts @@ -8,10 +8,11 @@ import { sendErrorFormatted, sendFormatted } from '../utils/response.format.js'; import { DeployImageListRegistry, DeployImageListSchema, + DeployImagePublicListSchema, DeployImageRegistry, DeployImageSchema } from '../schemas/image.schema.js'; -import { HeaderAcceptSchema, ErrorDetailsSchema } from '../schemas/api.schema.js'; +import { HeaderAcceptSchema, ErrorDetailsSchema, ErrorDetails } from '../schemas/api.schema.js'; const TAG = 'Images'; const PATH = '/images'; @@ -40,7 +41,7 @@ imageRouter.route({ responses: { 200: { description: 'Complete image registry', - schema: DeployImageListSchema, + schema: DeployImagePublicListSchema, contentTypes: ['application/json', 'application/x-yaml'], }, 500: { @@ -51,19 +52,25 @@ imageRouter.route({ }, handler: async (req: Request, res: Response) => { try { + const includeInternal = (req.headers['x-include-internal'] === 'true'); + const registry = await resourceManager.getResource('image/registry.json'); - const registryParsed = DeployImageListSchema.parse(registry); + + const registryParsed = includeInternal + ? DeployImageListSchema.parse(registry as DeployImageListRegistry) + : DeployImagePublicListSchema.parse(registry as DeployImageListRegistry); + const baseUrl = `${config.server.BASE_URL}`; const registryWithLinks = withSelfLink(registryParsed, (id) => `${baseUrl}${PATH}/${encodeURIComponent(id)}`); - - sendFormatted(res, registryWithLinks); + + return sendFormatted(res, registryWithLinks as DeployImageListRegistry); } catch (error: any) { apiLogger.error({ error: error.message }, 'Failed to read registry'); - sendErrorFormatted(res, { + return sendErrorFormatted(res, { title: 'Unable to read registry', detail: error.message || 'An unexpected error occurred while reading PHP Cloud extensions', status: 500 - }); + } as ErrorDetails); } } }); @@ -84,7 +91,7 @@ imageRouter.route({ responses: { 200: { description: 'Image found and returned', - schema: DeployImageSchema, + schema: DeployImageSchema.omit({ internal: true }), contentTypes: ['application/json', 'application/x-yaml'] }, 400: { @@ -116,14 +123,15 @@ imageRouter.route({ detail: `Image '${imageId}' not found in the existing images. See extra.availableImages for a list of valid image IDs.`, status: 404, extra: { availableImages } - }); + } as ErrorDetails); } const imageData = registry[id]; const imageDataParsed = DeployImageSchema.safeParse(imageData); if (imageDataParsed.success) { - sendFormatted(res, imageDataParsed.data); + const { internal: internal, ...sanitizedImage } = imageDataParsed.data; + sendFormatted(res, sanitizedImage as DeployImageRegistry); } else { let error = imageDataParsed.error; // If error is a stringified JSON, parse it @@ -133,20 +141,19 @@ imageRouter.route({ } catch { errorObj = error; } - sendErrorFormatted(res, { + return sendErrorFormatted(res, { title: 'An error occured', detail: errorObj.message || 'An unexpected error occurred while parsing image data', status: 400 - }); + } as ErrorDetails); } } catch (error: any) { apiLogger.error({ error: error.message }, 'Failed to read registry'); - sendErrorFormatted(res, { + return sendErrorFormatted(res, { title: 'An error occured', detail: error.message || 'Unable to read registry', status: 500 - }); + } as ErrorDetails); } } }); - diff --git a/backend/src/routes/region.routes.ts b/backend/src/routes/region.routes.ts index 03507db..e817d82 100644 --- a/backend/src/routes/region.routes.ts +++ b/backend/src/routes/region.routes.ts @@ -10,7 +10,7 @@ import { HostRegionsListSchema, HostRegionsList } from '../schemas/region.schema.js'; -import { HeaderAcceptSchema, ErrorDetailsSchema } from '../schemas/api.schema.js'; +import { HeaderAcceptSchema, ErrorDetailsSchema, ErrorDetails } from '../schemas/api.schema.js'; import { withSelfLinkArray } from '../utils/api.schema.js'; const TAG = 'Regions'; @@ -90,7 +90,7 @@ regionRouter.route({ detail: `Region '${safeName}' not found, see extra.availableRegions for a list of valid region names.`, status: 404, extra: { availableRegions } - }); + } as ErrorDetails); } } @@ -110,7 +110,7 @@ regionRouter.route({ detail: `No regions found for provider '${safeProvider}', see extra.availableProviders for a list of valid providers.`, status: 404, extra: { availableProviders } - }); + } as ErrorDetails); } } @@ -130,7 +130,7 @@ regionRouter.route({ detail: `No regions found for zone '${safeZone}', see extra.availableZones for a list of valid zones.`, status: 404, extra: { availableZones } - }); + } as ErrorDetails); } } @@ -150,7 +150,7 @@ regionRouter.route({ detail: `No regions found for country code '${safeCountryCode}', see extra.availableCountryCodes for a list of valid country codes.`, status: 404, extra: { availableCountryCodes } - }); + } as ErrorDetails); } } @@ -168,7 +168,7 @@ regionRouter.route({ title: 'An error occured', detail: error.message || 'Unable to read regions', status: 500 - }); + } as ErrorDetails); } } }); @@ -218,10 +218,10 @@ regionRouter.route({ detail: `Region '${safeId}' not found. see extra.availableRegions for a list of valid region IDs.`, status: 404, extra: { availableRegions } - }); + } as ErrorDetails); } - - return sendFormatted(res, region); + const regionSafe = HostRegionSchema.parse(region); + return sendFormatted(res, regionSafe); } } catch (error: any) { apiLogger.error({ error: error.message }, 'Failed to read regions'); @@ -229,7 +229,7 @@ regionRouter.route({ title: 'An error occured', detail: error.message || 'Unable to read regions', status: 500 - }); + } as ErrorDetails); } } }); diff --git a/backend/src/routes/validation.routes.ts b/backend/src/routes/validation.routes.ts index addf0b0..cb4743e 100644 --- a/backend/src/routes/validation.routes.ts +++ b/backend/src/routes/validation.routes.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import { ApiRouter } from '../utils/api.router.js'; import { ResourceManager, logger } from '../utils/index.js'; -import { ErrorDetailsSchema } from '../schemas/api.schema.js'; +import { ErrorDetails, ErrorDetailsSchema } from '../schemas/api.schema.js'; import { sendErrorFormatted, sendFormatted } from '../utils/response.format.js'; import { Validation, ValidationSchema } from '../schemas/validation.schema.js'; @@ -66,7 +66,7 @@ This file is used to validate Upsun configuration files .upsun/config.yaml. title: 'Unable to read Upsun validation schema', detail: error.message || 'An unexpected error occurred while reading Upsun validation schema', status: 500 - }); + } as ErrorDetails); } } }); @@ -100,7 +100,7 @@ validationRouter.route({ title: 'Unable to read image registry validation schema', detail: error.message || 'An unexpected error occurred while reading image registry validation schema', status: 500 - }); + } as ErrorDetails); } } }); diff --git a/backend/src/schemas/image.schema.ts b/backend/src/schemas/image.schema.ts index 99ab760..b1bcfb8 100644 --- a/backend/src/schemas/image.schema.ts +++ b/backend/src/schemas/image.schema.ts @@ -335,10 +335,8 @@ export const DeployImageSchema = z.object({ }) .openapi({ description: 'Internal properties for the image, used only by Upsun', - example: { - repo_name: 'nodejs' - } - }), + "x-internal": true + }), runtime: z.boolean() .openapi({ description: 'Indicates if the image is a runtime image (true)', @@ -361,13 +359,23 @@ export const DeployImageSchema = z.object({ }).openapi('DeployImage', { description: 'Schema representing a single image in the Upsun image registry.' }); + /** * Schema for Images Registry (list of images) */ -export const DeployImageListSchema = z.record( - z.string().openapi('imageId').describe('Unique identifier for an image (e.g., nodejs, php, python)'), - DeployImageSchema -).openapi('DeployImageList', { +export const DeployImageListSchema = z + .record( + z.string().openapi('imageId').describe('Unique identifier for an image (e.g., nodejs, php, python)'), + DeployImageSchema + ); + +// Schema for Public Images Registry (list of images without internal info) +export const DeployImagePublicListSchema = z + .record( + z.string().openapi('imageId').describe('Unique identifier for an image (e.g., nodejs, php, python)'), + DeployImageSchema.omit({ internal: true }) + ) + .openapi('DeployImageList', { description: 'Registry containing all available images (see [DeployImage](#/model/DeployImage) for the full structure).' }); diff --git a/backend/src/utils/api.router.ts b/backend/src/utils/api.router.ts index c71a804..e7725b2 100644 --- a/backend/src/utils/api.router.ts +++ b/backend/src/utils/api.router.ts @@ -2,6 +2,8 @@ import { Express, RequestHandler } from 'express'; import { z } from 'zod'; import { OpenAPIRegistry, OpenApiGeneratorV31, RouteConfig } from '@asteasolutions/zod-to-openapi'; import { logger } from './logger.js'; +import { sendErrorFormatted } from './response.format.js'; +import { ErrorDetails } from '../schemas/api.schema.js'; // Create a dedicated child logger for API Router const routerLogger = logger.child({ component: 'ApiRouter' }); @@ -56,15 +58,23 @@ export class ApiRouter { this.routes.forEach(({ method, path, query, params, body, headers, handler }) => { // Create validation middleware const validationMiddleware: RequestHandler = (req, res, next) => { + const respondValidationError = (title: string, issues: z.ZodIssue[]) => { + sendErrorFormatted(res, { + type: 'invalid_value', + title: title, + detail: 'Validation failed', + status: 400, + issues + } as ErrorDetails); + }; + try { // Validate query if (query) { const result = query.safeParse(req.query); if (!result.success) { - return res.status(400).json({ - error: 'Invalid query parameters', - details: result.error.issues - }); + respondValidationError('Invalid query parameters', result.error.issues); + return; } // Store validated data in a custom property (req as any).validatedQuery = result.data; @@ -74,10 +84,8 @@ export class ApiRouter { if (params) { const result = params.safeParse(req.params); if (!result.success) { - return res.status(400).json({ - error: 'Invalid path parameters', - details: result.error.issues - }); + respondValidationError('Invalid path parameters', result.error.issues); + return; } (req as any).validatedParams = result.data; } @@ -86,10 +94,8 @@ export class ApiRouter { if (body) { const result = body.safeParse(req.body); if (!result.success) { - return res.status(400).json({ - error: 'Invalid request body', - details: result.error.issues - }); + respondValidationError('Invalid request body', result.error.issues); + return; } (req as any).validatedBody = result.data; } @@ -98,10 +104,8 @@ export class ApiRouter { if (headers) { const result = headers.safeParse(req.headers); if (!result.success) { - return res.status(400).json({ - error: 'Invalid headers', - details: result.error.issues - }); + respondValidationError('Invalid headers', result.error.issues); + return; } (req as any).validatedHeaders = result.data; } diff --git a/backend/src/utils/response.format.ts b/backend/src/utils/response.format.ts index 30c6a27..f91aaca 100644 --- a/backend/src/utils/response.format.ts +++ b/backend/src/utils/response.format.ts @@ -1,5 +1,6 @@ import { Response } from 'express'; import YAML from "yaml"; +import { ErrorDetails } from '../schemas/api.schema'; /** * Send response in JSON (default) or YAML if requested by Accept header. @@ -18,8 +19,9 @@ export function sendFormatted(res: Response, data: T, status = 200) { } } -export function sendErrorFormatted(res: Response, error: { type?: string; title?: string; status?: number; detail?: any; instance?: string; extra?: Record;}, status?: number) { +export function sendErrorFormatted(res: Response, error: ErrorDetails, status?: number) { const accept = res.req?.headers['accept'] || ''; + error.instance = res.req?.originalUrl || ''; if (accept.includes('application/x-yaml')) { res.set('Content-Type', 'text/plain; charset=utf-8'); res.status(status || error.status || 500).send(YAML.stringify(error)); From ef8ac2abc91965ace437933ab0efce22f1076744 Mon Sep 17 00:00:00 2001 From: Florent Huck Date: Tue, 27 Jan 2026 15:19:12 +0100 Subject: [PATCH 3/5] useless 400 response as it's sent by api.route.ts --- backend/src/routes/image.routes.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/src/routes/image.routes.ts b/backend/src/routes/image.routes.ts index 8e575f0..ac49f4e 100644 --- a/backend/src/routes/image.routes.ts +++ b/backend/src/routes/image.routes.ts @@ -99,11 +99,6 @@ imageRouter.route({ schema: ErrorDetailsSchema, contentTypes: ['application/json', 'application/x-yaml'] }, - 404: { - description: 'Image not found', - schema: ErrorDetailsSchema, - contentTypes: ['application/json', 'application/x-yaml'] - } }, handler: async (req: Request, res: Response) => { try { From 4038e27b3dda15f8eb6d879495f7ef46d7cb13e4 Mon Sep 17 00:00:00 2001 From: Florent Huck Date: Tue, 27 Jan 2026 18:33:49 +0100 Subject: [PATCH 4/5] rename CloudExtensions to ServiceExtensions + solve issue with Extensions routes --- backend/src/routes/extension.routes.ts | 63 +++++++++++++++---------- backend/src/schemas/extension.schema.ts | 25 ++++------ 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/backend/src/routes/extension.routes.ts b/backend/src/routes/extension.routes.ts index f2c7e3f..3e2a132 100644 --- a/backend/src/routes/extension.routes.ts +++ b/backend/src/routes/extension.routes.ts @@ -22,7 +22,6 @@ const resourceManager = new ResourceManager(); const PATH = '/extension/php'; const TAG = 'Extensions'; - export const extensionRouter = new ApiRouter(); // GET /extension/php - full YAML content @@ -35,6 +34,7 @@ extensionRouter.route({ query: z.object({ service: z.enum(['all', 'cloud', 'dedicated']) .optional() + .default('all') .describe('Filter by service name (e.g., "dedicated", "cloud")'), }), headers: HeaderAcceptSchema, @@ -60,34 +60,41 @@ extensionRouter.route({ }; const safeService = service ? escapeHtml(service) : undefined; - let extensions = await resourceManager.getResource('extension/php_extensions.json'); - - if (service && service !== 'all') { - let extensionsFiltered = {}; - extensionsFiltered = { [service]: extensions?.[service] }; - if (Object.keys(extensionsFiltered).length === 0) { - return sendErrorFormatted(res, { - title: `No extensions found for service '${safeService}'`, - detail: `No extensions found for service '${safeService}', "service" should be one of "dedicated" or "cloud".`, - status: 404 - } as ErrorDetails); - } - extensions = extensionsFiltered; + const extensions = await resourceManager.getResource('extension/php_extensions.json'); + const serviceTypes = service && service !== 'all' + ? [service] + : Object.keys(extensions); + + if (service && service !== 'all' && Object.keys(extensions?.[service]).length === 0) { + return sendErrorFormatted(res, { + title: `No extensions found for service '${safeService}'`, + detail: `No extensions found for service '${safeService}', "service" should be one of "dedicated" or "cloud".`, + status: 404 + } as ErrorDetails); } const baseUrl = `${config.server.BASE_URL}`; - // set _links for each service - for (const service of Object.keys(extensions)) { - extensions[service] = withSelfLink(extensions[service], (id) => `${baseUrl}${PATH}/${encodeURIComponent(service)}/${encodeURIComponent(id)}`); - extensions[service] = { - ...extensions[service], - _links: { self: `${baseUrl}${PATH}/?service=${service}` } + const extensionsFiltered = serviceTypes.reduce((acc, serviceType) => { + const serviceEntries = extensions[serviceType]; + if (!serviceEntries) { + return acc; + } + const entriesWithLinks = withSelfLink(serviceEntries, (id) => + `${baseUrl}${PATH}/${encodeURIComponent(serviceType)}/${encodeURIComponent(id)}` + ); + + return { + ...acc, + [serviceType]: { + data: entriesWithLinks, + _links: { self: `${baseUrl}${PATH}/?service=${serviceType}` } + } }; - } + }, {} as RuntimeExtensionList); + const extensionsSafe = RuntimeExtensionListSchema.safeParse(extensionsFiltered); - const extensionsSafe = RuntimeExtensionListSchema.parse(extensions); - sendFormatted(res, extensionsSafe); + sendFormatted(res, extensionsSafe.data as RuntimeExtensionList); } catch (error: any) { apiLogger.error({ error: error.message }, 'Failed to read PHP extensions'); sendErrorFormatted(res, { @@ -136,9 +143,13 @@ extensionRouter.route({ const safeService = escapeHtml(service); const extensions = await resourceManager.getResource('extension/php_extensions.json'); - - const extensionEntry = extensions?.[service]?.[id]; - if (!extensionEntry) { + const extensionDefinition = extensions?.[service]?.[id]; + const versions = extensionDefinition?.versions as RuntimeExtensionVersion[] | undefined; + const extensionEntry = versions?.reduce( + (acc, versionEntry) => Object.assign(acc, versionEntry), + {} as RuntimeExtensionVersion + ); + if (!extensionDefinition || !extensionEntry || Object.keys(extensionEntry).length === 0) { return sendErrorFormatted(res, { title: 'Invalid path parameters', detail: `Extension id "${imageId == '{id}' ? undefined : imageId}" in Service "${safeService}" not found. See extra.availableExtensions for a list of valid extension ids for this service.`, diff --git a/backend/src/schemas/extension.schema.ts b/backend/src/schemas/extension.schema.ts index cd3b322..3b36723 100644 --- a/backend/src/schemas/extension.schema.ts +++ b/backend/src/schemas/extension.schema.ts @@ -41,28 +41,23 @@ const RuntimeExtensionSchema = z.object({ } }); -const CloudExtensionsEntriesSchema = z.record( - z.string(), - RuntimeExtensionSchema -); +const ExtensionEntriesSchema = z.record(z.string(), RuntimeExtensionSchema); //TODO: @flovntp: Remove this !!! -export const CloudExtensionsSchema = z.intersection( - CloudExtensionsEntriesSchema, - z.object({ - _links: z.record(z.string(), LinkSchema).optional().openapi({ - description: 'Hypermedia links related to the cloud extensions', - }) +export const ServiceExtensionsSchema = z.object({ + data: ExtensionEntriesSchema, + _links: LinkSchema.optional().openapi({ + description: 'Hypermedia links related to the service extensions' }) -).openapi('CloudExtensions', { - description: 'Mapping of Cloud extension IDs to their version entries, with optional links' +}).openapi('ServiceExtensions', { + description: 'Wrapped service extensions payload with optional links' }); export const RuntimeExtensionListSchema = z.object({ - dedicated: z.record(z.string(), RuntimeExtensionSchema), - cloud: CloudExtensionsSchema + dedicated: ServiceExtensionsSchema.optional(), + cloud: ServiceExtensionsSchema.optional(), }).openapi('RuntimeExtensionList'); export type RuntimeExtensionList = z.infer; -export type CloudExtensions = z.infer; //TODO: @flovntp: Remove this !!!s +export type ServiceExtensions = z.infer; //TODO: @flovntp: Remove this !!!s export type RuntimeExtensionVersion = z.infer; From b4a8635e7d84f853cdc82ae1886babe6cfd763aa Mon Sep 17 00:00:00 2001 From: Florent Huck Date: Wed, 28 Jan 2026 08:54:20 +0100 Subject: [PATCH 5/5] Github workflow: create PR only if there is changes --- .github/workflows/gen_openapi_spec.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/gen_openapi_spec.yaml b/.github/workflows/gen_openapi_spec.yaml index c9e2035..f6243e6 100644 --- a/.github/workflows/gen_openapi_spec.yaml +++ b/.github/workflows/gen_openapi_spec.yaml @@ -47,8 +47,19 @@ jobs: - name: Patch OpenAPI specs (json) for SDK generation run: composer run spec:patch + + - name: Check for changes + id: check_changes + run: | + git add resources/openapi/ + if git diff --cached --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi - name: Create Pull Request if changes + if: steps.check_changes.outputs.has_changes == 'true' uses: peter-evans/create-pull-request@v6 with: token: ${{ secrets.OPENAPI_WORKFLOW_TOKEN }}