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
251 changes: 251 additions & 0 deletions api/middleware/schemaValidation.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
28 changes: 27 additions & 1 deletion api/routes/accounts.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 19 additions & 1 deletion api/routes/transactions.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
85 changes: 85 additions & 0 deletions tests/api/schemaValidation.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});