Skip to content
Merged
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
10 changes: 10 additions & 0 deletions modules/express/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,13 @@ export class BitGoExpressError extends BitGoJsError {
Object.setPrototypeOf(this, BitGoExpressError.prototype);
}
}

export class ValidationError extends BitGoJsError {
public readonly status = 400;
public override readonly name = 'ValidationError';

public constructor(message: string) {
super(message);
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
17 changes: 16 additions & 1 deletion modules/express/src/typedRoutes/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { createRouter, WrappedRouter } from '@api-ts/typed-express-router';

import { ExpressApi } from './api';
import { createValidationError } from './utils';

const { version: bitgoJsVersion } = require('bitgo/package.json');
const { version: bitgoExpressVersion } = require('../../package.json');

export default function (): WrappedRouter<ExpressApi> {
const router: WrappedRouter<ExpressApi> = createRouter(ExpressApi);
const router: WrappedRouter<ExpressApi> = createRouter(ExpressApi, {
decodeErrorFormatter: (errors) => {
const err = createValidationError(errors);
return {
error: err.message,
message: err.message,
name: err.name,
bitgoJsVersion,
bitgoExpressVersion,
};
},
});
return router;
}
41 changes: 41 additions & 0 deletions modules/express/src/typedRoutes/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as t from 'io-ts';

import { ValidationError } from '../errors';

/**
* Formats io-ts validation errors into clear, human-readable messages.
*/
export function formatValidationErrors(errors: t.Errors): string {
const seen = new Set<string>();
const messages: string[] = [];

for (const error of errors) {
// Build field path, filtering out internal keys
const path = error.context
.map((c) => c.key)
.filter((key) => key && !/^\d+$/.test(key) && key !== 'body')
.join('.');

if (!path || seen.has(path)) continue;
seen.add(path);

const expected = error.context[error.context.length - 1]?.type.name;
if (expected === 'undefined') continue;

if (error.value === undefined) {
messages.push(`Missing required field '${path}'`);
} else {
const value = typeof error.value === 'object' ? JSON.stringify(error.value) : String(error.value);
messages.push(`Invalid value for '${path}': expected ${expected}, got '${value}'`);
}
}

return messages.join('. ') + (messages.length ? '.' : '');
}

/**
* Creates a ValidationError from io-ts validation errors.
*/
export function createValidationError(errors: t.Errors): ValidationError {
return new ValidationError(formatValidationErrors(errors));
}
4 changes: 2 additions & 2 deletions modules/express/test/unit/typedRoutes/acceptShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,8 @@ describe('AcceptShare codec tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
assert.ok(Array.isArray(result.body));
assert.ok(result.body.length > 0);
assert.ok(result.body.error);
assert.ok(result.body.error.length > 0);
});

it('should handle acceptShare method throwing error', async function () {
Expand Down
28 changes: 14 additions & 14 deletions modules/express/test/unit/typedRoutes/expressWalletUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,8 @@ describe('Express Wallet Update Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/signerHost/);
res.body.should.have.property('error');
res.body.error.should.match(/signerHost/);
});

it('should return 400 when signerTlsCert is missing', async function () {
Expand All @@ -337,8 +337,8 @@ describe('Express Wallet Update Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/signerTlsCert/);
res.body.should.have.property('error');
res.body.error.should.match(/signerTlsCert/);
});

it('should return 400 when passphrase is missing', async function () {
Expand All @@ -352,8 +352,8 @@ describe('Express Wallet Update Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/passphrase/);
res.body.should.have.property('error');
res.body.error.should.match(/passphrase/);
});

it('should return 400 when signerHost has invalid type', async function () {
Expand All @@ -367,8 +367,8 @@ describe('Express Wallet Update Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/signerHost/);
res.body.should.have.property('error');
res.body.error.should.match(/signerHost/);
});

it('should return 400 when signerTlsCert has invalid type', async function () {
Expand All @@ -382,8 +382,8 @@ describe('Express Wallet Update Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/signerTlsCert/);
res.body.should.have.property('error');
res.body.error.should.match(/signerTlsCert/);
});

it('should return 400 when passphrase has invalid type', async function () {
Expand All @@ -397,8 +397,8 @@ describe('Express Wallet Update Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/passphrase/);
res.body.should.have.property('error');
res.body.error.should.match(/passphrase/);
});

it('should return 400 when signerMacaroon has invalid type', async function () {
Expand All @@ -413,8 +413,8 @@ describe('Express Wallet Update Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/signerMacaroon/);
res.body.should.have.property('error');
res.body.error.should.match(/signerMacaroon/);
});
});

Expand Down
76 changes: 76 additions & 0 deletions modules/express/test/unit/typedRoutes/formatValidationErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as assert from 'assert';
import * as t from 'io-ts';

import { formatValidationErrors } from '../../../src/typedRoutes/utils';

describe('formatValidationErrors', function () {
it('should format missing required field', function () {
const errors: t.Errors = [{ value: undefined, context: [{ key: 'name', type: t.string }] }];
assert.strictEqual(formatValidationErrors(errors), "Missing required field 'name'.");
});

it('should format wrong type', function () {
const errors: t.Errors = [{ value: 123, context: [{ key: 'field', type: t.string }] }];
assert.strictEqual(formatValidationErrors(errors), "Invalid value for 'field': expected string, got '123'.");
});

it('should format nested paths', function () {
const errors: t.Errors = [
{
value: 123,
context: [
{ key: 'memo', type: t.object },
{ key: 'type', type: t.string },
],
},
];
assert.strictEqual(formatValidationErrors(errors), "Invalid value for 'memo.type': expected string, got '123'.");
});

it('should filter numeric indices', function () {
const errors: t.Errors = [
{
value: 'x',
context: [
{ key: 'recipients', type: t.array(t.unknown) },
{ key: '0', type: t.object },
{ key: 'amount', type: t.number },
],
},
];
assert.strictEqual(
formatValidationErrors(errors),
"Invalid value for 'recipients.amount': expected number, got 'x'."
);
});

it('should filter body from path', function () {
const errors: t.Errors = [
{
value: 123,
context: [
{ key: 'body', type: t.object },
{ key: 'name', type: t.string },
],
},
];
assert.strictEqual(formatValidationErrors(errors), "Invalid value for 'name': expected string, got '123'.");
});

it('should skip undefined type errors', function () {
const errors: t.Errors = [{ value: 123, context: [{ key: 'optional', type: t.undefined }] }];
assert.strictEqual(formatValidationErrors(errors), '');
});

it('should return empty string for empty errors', function () {
assert.strictEqual(formatValidationErrors([]), '');
});

it('should deduplicate same path', function () {
const errors: t.Errors = [
{ value: {}, context: [{ key: 'value', type: t.string }] },
{ value: {}, context: [{ key: 'value', type: t.number }] },
];
assert.strictEqual((formatValidationErrors(errors).match(/'value'/g) || []).length, 1);
});
});
24 changes: 12 additions & 12 deletions modules/express/test/unit/typedRoutes/generateWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,8 @@ describe('Generate Wallet Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/label/);
res.body.should.have.property('error');
res.body.error.should.match(/label/);
});

it('should return 400 when label is not a string', async function () {
Expand All @@ -418,8 +418,8 @@ describe('Generate Wallet Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/label/);
res.body.should.have.property('error');
res.body.error.should.match(/label/);
});

it('should return 400 when type is invalid', async function () {
Expand All @@ -432,8 +432,8 @@ describe('Generate Wallet Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/type/);
res.body.should.have.property('error');
res.body.error.should.match(/type/);
});

it('should return 400 when multisigType is invalid', async function () {
Expand All @@ -446,8 +446,8 @@ describe('Generate Wallet Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/multisigType/);
res.body.should.have.property('error');
res.body.error.should.match(/multisigType/);
});

it('should return 400 when backupXpubProvider is invalid', async function () {
Expand All @@ -460,8 +460,8 @@ describe('Generate Wallet Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/backupXpubProvider/);
res.body.should.have.property('error');
res.body.error.should.match(/backupXpubProvider/);
});

it('should return 400 when disableTransactionNotifications is not boolean', async function () {
Expand All @@ -474,8 +474,8 @@ describe('Generate Wallet Typed Routes Tests', function () {
});

res.status.should.equal(400);
res.body.should.be.an.Array();
res.body[0].should.match(/disableTransactionNotifications/);
res.body.should.have.property('error');
res.body.error.should.match(/disableTransactionNotifications/);
});
});

Expand Down
10 changes: 5 additions & 5 deletions modules/express/test/unit/typedRoutes/isWalletAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@ describe('IsWalletAddress codec tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
assert.ok(Array.isArray(result.body));
assert.ok(result.body.error);
});

it('should return 400 for missing keychains field', async function () {
Expand All @@ -960,7 +960,7 @@ describe('IsWalletAddress codec tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
assert.ok(Array.isArray(result.body));
assert.ok(result.body.error);
});

it('should return 400 for invalid keychains (not an array)', async function () {
Expand All @@ -976,7 +976,7 @@ describe('IsWalletAddress codec tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
assert.ok(Array.isArray(result.body));
assert.ok(result.body.error);
});

it('should return 400 for invalid walletVersion type', async function () {
Expand All @@ -993,7 +993,7 @@ describe('IsWalletAddress codec tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
assert.ok(Array.isArray(result.body));
assert.ok(result.body.error);
});

it('should return 400 for invalid derivedFromParentWithSeed type', async function () {
Expand All @@ -1010,7 +1010,7 @@ describe('IsWalletAddress codec tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
assert.ok(Array.isArray(result.body));
assert.ok(result.body.error);
});

it('should handle isWalletAddress throwing InvalidAddressError', async function () {
Expand Down
18 changes: 9 additions & 9 deletions modules/express/test/unit/typedRoutes/lightningPayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,10 +691,10 @@ describe('Lightning Payment API Tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
result.body.should.be.Array();
result.body.length.should.be.above(0);
result.body.should.have.property('error');
result.body.error.length.should.be.above(0);
// Validation error should mention missing invoice field
result.body[0].should.match(/invoice/);
result.body.error.should.match(/invoice/);
});

it('should return 400 when passphrase is missing', async function () {
Expand All @@ -709,10 +709,10 @@ describe('Lightning Payment API Tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
result.body.should.be.Array();
result.body.length.should.be.above(0);
result.body.should.have.property('error');
result.body.error.length.should.be.above(0);
// Validation error should mention missing passphrase field
result.body[0].should.match(/passphrase/);
result.body.error.should.match(/passphrase/);
});

it('should return 400 when amountMsat is invalid format', async function () {
Expand All @@ -729,10 +729,10 @@ describe('Lightning Payment API Tests', function () {
.send(requestBody);

assert.strictEqual(result.status, 400);
result.body.should.be.Array();
result.body.length.should.be.above(0);
result.body.should.have.property('error');
result.body.error.length.should.be.above(0);
// Validation error should mention amountMsat field
result.body[0].should.match(/amountMsat/);
result.body.error.should.match(/amountMsat/);
});

it('should handle wallet not found error', async function () {
Expand Down
Loading