From e18737cff7dc049ee654f621bb3cfdb06ed50542 Mon Sep 17 00:00:00 2001 From: Youssef Dakkak Date: Fri, 16 Oct 2026 17:29:46 +0000 Subject: [PATCH] feat(api): add request/response schema validation with problem+json and OpenAPI generation (#998) Co-authored-by: Cursor --- api/middleware/schemaValidation.js | 251 +++++++++++++++++++++++++++++ api/routes/accounts.js | 28 +++- api/routes/transactions.js | 20 ++- tests/api/schemaValidation.test.js | 85 ++++++++++ 4 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 api/middleware/schemaValidation.js create mode 100644 tests/api/schemaValidation.test.js diff --git a/api/middleware/schemaValidation.js b/api/middleware/schemaValidation.js new file mode 100644 index 00000000..7bcaf16f --- /dev/null +++ b/api/middleware/schemaValidation.js @@ -0,0 +1,251 @@ +/** + * Declarative API Route Schema Validation & OpenAPI Generator (#998) + * + * Provides: + * - Request (params, query, body) and response schema validation middleware + * - RFC 9457 / 7807 `application/problem+json` error responses on validation failure + * - OpenAPI 3.1 path document generation from the registered route schemas + */ + +/** + * Formats an RFC 7807 / 9457 Problem Details payload. + */ +export function buildProblemDetails({ + status = 400, + title = 'Request Validation Failed', + detail = 'One or more request parameters failed schema validation.', + instance = '/', + errors = [], +}) { + return { + type: 'https://stellar-dev-dashboard.dev/problems/validation-error', + title, + status, + detail, + instance, + errors, + }; +} + +/** + * Validates a value against a declarative JSON-Schema-compatible field descriptor. + */ +export function validateObjectAgainstSchema(input = {}, schema = {}, location = 'body') { + const errors = []; + if (!schema || typeof schema !== 'object') { + return errors; + } + + const required = Array.isArray(schema.required) ? schema.required : []; + const properties = schema.properties || {}; + + for (const field of required) { + const val = input?.[field]; + if (val === undefined || val === null || val === '') { + errors.push({ + location, + field, + code: 'required', + message: `${field} is required in ${location}`, + }); + } + } + + for (const [field, rules] of Object.entries(properties)) { + const rawVal = input?.[field]; + if (rawVal === undefined || rawVal === null) continue; + + if (rules.type === 'integer' || rules.type === 'number') { + const numVal = typeof rawVal === 'number' ? rawVal : Number(rawVal); + if (!Number.isFinite(numVal) || (rules.type === 'integer' && !Number.isInteger(numVal))) { + errors.push({ + location, + field, + code: 'invalid_type', + message: `${field} must be a valid ${rules.type}`, + }); + continue; + } + if (rules.minimum !== undefined && numVal < rules.minimum) { + errors.push({ + location, + field, + code: 'minimum', + message: `${field} must be >= ${rules.minimum}`, + }); + } + if (rules.maximum !== undefined && numVal > rules.maximum) { + errors.push({ + location, + field, + code: 'maximum', + message: `${field} must be <= ${rules.maximum}`, + }); + } + } else if (rules.type === 'string') { + if (typeof rawVal !== 'string') { + errors.push({ + location, + field, + code: 'invalid_type', + message: `${field} must be a string`, + }); + continue; + } + if (rules.minLength !== undefined && rawVal.length < rules.minLength) { + errors.push({ + location, + field, + code: 'min_length', + message: `${field} must be at least ${rules.minLength} characters`, + }); + } + if (rules.maxLength !== undefined && rawVal.length > rules.maxLength) { + errors.push({ + location, + field, + code: 'max_length', + message: `${field} must be at most ${rules.maxLength} characters`, + }); + } + if (rules.pattern && !new RegExp(rules.pattern).test(rawVal)) { + errors.push({ + location, + field, + code: 'pattern', + message: `${field} does not match expected format`, + }); + } + } + } + + return errors; +} + +/** + * Express middleware enforcing request (params, query, body) and optional response schemas. + */ +export function validateRouteSchema(routeSchema = {}) { + return (req, res, next) => { + const errors = [ + ...validateObjectAgainstSchema(req.params, routeSchema.params, 'params'), + ...validateObjectAgainstSchema(req.query, routeSchema.query, 'query'), + ...validateObjectAgainstSchema(req.body, routeSchema.body, 'body'), + ]; + + if (errors.length > 0) { + const problem = buildProblemDetails({ + status: 400, + instance: req.originalUrl || req.url || '/', + errors, + }); + res.setHeader('Content-Type', 'application/problem+json'); + return res.status(400).json(problem); + } + + if (routeSchema.response) { + const originalJson = res.json.bind(res); + res.json = (payload) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + const respErrors = validateObjectAgainstSchema( + payload, + routeSchema.response, + 'response' + ); + if (respErrors.length > 0) { + const problem = buildProblemDetails({ + status: 500, + title: 'Response Contract Violation', + detail: 'The server response did not conform to the declared route schema.', + instance: req.originalUrl || req.url || '/', + errors: respErrors, + }); + res.setHeader('Content-Type', 'application/problem+json'); + res.status(500); + return originalJson(problem); + } + } + return originalJson(payload); + }; + } + + return next(); + }; +} + +/** + * Generates an OpenAPI 3.1 specification object from a map of registered route schemas. + */ +export function generateOpenApiFromSchemas(routeDefinitions = []) { + const paths = {}; + + for (const def of routeDefinitions) { + const method = (def.method || 'get').toLowerCase(); + const openApiPath = def.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + if (!paths[openApiPath]) { + paths[openApiPath] = {}; + } + + const parameters = []; + for (const [inLoc, schemaObj] of [ + ['path', def.schema?.params], + ['query', def.schema?.query], + ]) { + if (!schemaObj?.properties) continue; + const reqList = schemaObj.required || []; + for (const [name, prop] of Object.entries(schemaObj.properties)) { + parameters.push({ + name, + in: inLoc, + required: inLoc === 'path' ? true : reqList.includes(name), + schema: prop, + }); + } + } + + paths[openApiPath][method] = { + summary: def.summary || `${method.toUpperCase()} ${openApiPath}`, + parameters, + ...(def.schema?.body + ? { + requestBody: { + required: true, + content: { + 'application/json': { schema: def.schema.body }, + }, + }, + } + : {}), + responses: { + 200: { + description: 'Valid response matching declared schema', + content: { + 'application/json': { + schema: def.schema?.response || { type: 'object' }, + }, + }, + }, + 400: { + description: 'Problem Details validation error', + content: { + 'application/problem+json': { + schema: { + type: 'object', + required: ['type', 'title', 'status', 'detail', 'errors'], + }, + }, + }, + }, + }, + }; + } + + return { + openapi: '3.1.0', + info: { + title: 'Stellar Dev Dashboard API', + version: '1.0.0', + }, + paths, + }; +} diff --git a/api/routes/accounts.js b/api/routes/accounts.js index 09b8677e..47573855 100644 --- a/api/routes/accounts.js +++ b/api/routes/accounts.js @@ -1,7 +1,33 @@ import express from 'express'; +import { validateRouteSchema } from '../middleware/schemaValidation.js'; + export const router = express.Router(); -router.get('/:accountId', (req, res) => { +export const accountGetSchema = { + params: { + required: ['accountId'], + properties: { + accountId: { + type: 'string', + minLength: 3, + maxLength: 64, + }, + }, + }, + response: { + required: ['id', 'balance', 'status', 'sequence_number'], + properties: { + id: { type: 'string' }, + balance: { type: 'string' }, + status: { type: 'string' }, + sequence_number: { type: 'string' }, + subentry_count: { type: 'integer', minimum: 0 }, + last_modified_ledger: { type: 'integer', minimum: 0 }, + }, + }, +}; + +router.get('/:accountId', validateRouteSchema(accountGetSchema), (req, res) => { const { accountId } = req.params; // Mock data for the dashboard account endpoint diff --git a/api/routes/transactions.js b/api/routes/transactions.js index 79205fb8..ac6a74fb 100644 --- a/api/routes/transactions.js +++ b/api/routes/transactions.js @@ -1,7 +1,25 @@ import express from 'express'; +import { validateRouteSchema } from '../middleware/schemaValidation.js'; + export const router = express.Router(); -router.get('/', (req, res) => { +export const transactionsGetSchema = { + query: { + required: ['accountId'], + properties: { + accountId: { type: 'string', minLength: 1, maxLength: 64 }, + limit: { type: 'integer', minimum: 1, maximum: 200 }, + }, + }, + response: { + required: ['data', 'limit'], + properties: { + limit: { type: 'integer', minimum: 1, maximum: 200 }, + }, + }, +}; + +router.get('/', validateRouteSchema(transactionsGetSchema), (req, res) => { const { accountId, limit = 10 } = req.query; if (!accountId) { diff --git a/tests/api/schemaValidation.test.js b/tests/api/schemaValidation.test.js new file mode 100644 index 00000000..82881c23 --- /dev/null +++ b/tests/api/schemaValidation.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + validateRouteSchema, + generateOpenApiFromSchemas, +} from '../../api/middleware/schemaValidation.js'; +import { accountGetSchema } from '../../api/routes/accounts.js'; +import { transactionsGetSchema } from '../../api/routes/transactions.js'; + +function createMockReqRes({ params = {}, query = {}, body = {}, url = '/api/v1/transactions' } = {}) { + const headers = {}; + const res = { + statusCode: 200, + headers, + payload: null, + setHeader(k, v) { + headers[k.toLowerCase()] = v; + }, + status(code) { + this.statusCode = code; + return this; + }, + json(data) { + this.payload = data; + return this; + }, + }; + const req = { params, query, body, originalUrl: url, url }; + return { req, res }; +} + +describe('API route request/response schema validation & OpenAPI generation (#998)', () => { + it('allows valid payloads and validates response contract on the primary flow', () => { + const mw = validateRouteSchema(transactionsGetSchema); + const { req, res } = createMockReqRes({ + query: { accountId: 'GABC123456', limit: '25' }, + }); + const next = vi.fn(); + + mw(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + + res.json({ data: [], limit: 25 }); + expect(res.statusCode).toBe(200); + expect(res.payload).toEqual({ data: [], limit: 25 }); + }); + + it('enforces boundary limits (minimum=1, maximum=200) and returns problem+json on violation', () => { + const mw = validateRouteSchema(transactionsGetSchema); + const { req, res } = createMockReqRes({ + query: { accountId: 'GABC123456', limit: '201' }, + }); + const next = vi.fn(); + + mw(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(400); + expect(res.headers['content-type']).toBe('application/problem+json'); + expect(res.payload).toMatchObject({ + type: 'https://stellar-dev-dashboard.dev/problems/validation-error', + status: 400, + }); + expect(res.payload.errors[0].field).toBe('limit'); + }); + + it('rejects malformed payloads and generates an OpenAPI 3.1 spec from route schemas', () => { + const mw = validateRouteSchema(transactionsGetSchema); + const { req, res } = createMockReqRes({ + query: { limit: 'not-a-number' }, + }); + const next = vi.fn(); + + mw(req, res, next); + expect(res.statusCode).toBe(400); + expect(res.payload.errors.length).toBeGreaterThanOrEqual(2); + + const spec = generateOpenApiFromSchemas([ + { method: 'GET', path: '/api/v1/accounts/:accountId', schema: accountGetSchema }, + { method: 'GET', path: '/api/v1/transactions', schema: transactionsGetSchema }, + ]); + + expect(spec.openapi).toBe('3.1.0'); + expect(spec.paths['/api/v1/accounts/{accountId}'].get).toBeDefined(); + expect(spec.paths['/api/v1/transactions'].get.responses[400].content['application/problem+json']).toBeDefined(); + }); +});