Skip to content

Commit

Permalink
feat: add data model validation (#43)
Browse files Browse the repository at this point in the history
* feat(validator): Add validate function
* feat(validator): Define ValidationError
* test(validator): Add test for IBAN validation
* feat(validator): Add validator package
* feat(validator): Add validateBankAccount function
* feat(payment): Implement partial validateSimplePayment
* chore: Cleanup code
* feat(validator): Validate currency code
* feat(payment): Implement payment due date validation
* feat(validator): Add validation for validateDataModel
* refactor(validator): Export validateDataModel
* refactor(validator): Rename validateModel to validate and set default to true
* fix(validator): Fix typo in validations.test.ts
* docs: Update documentation
* refactor(validator): Adjust ValidationErrorMessage enum
* refactor(validator): Remove TODO from validateSimplePayment
* fix(validator): Handle arbitrary fields equal to an empty string without throwing an error
* feat(cli): Enhance CLI functionality
  • Loading branch information
lukasbicus authored Aug 19, 2024
1 parent d08c57c commit f969570
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 6 deletions.
18 changes: 17 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@
"dependencies": {
"crc-32": "~1.2.0",
"lzma1": "0.0.2",
"rfc4648": "~1.5.0"
"rfc4648": "~1.5.0",
"validator": "^13.12.0"
},
"devDependencies": {
"@types/node": ">=18.18.2",
"@types/validator": "^13.12.0",
"dprint": "~0.47.0",
"ts-node": "~10.9.0",
"typescript": "~5.5.0"
Expand Down
19 changes: 18 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ const args = parseArgs({
type: "boolean",
short: "e",
},
validate: {
type: "boolean",
short: "v",
},
deburr: {
type: "boolean",
short: "b",
},
help: {
type: "boolean",
short: "h",
Expand Down Expand Up @@ -61,7 +69,10 @@ if (process.stdin.isTTY) {
}

if (file.endsWith(".json")) {
console.log(encode(JSON.parse(data)));
console.log(encode(JSON.parse(data), {
validate: Boolean(args.values.validate),
deburr: Boolean(args.values.deburr),
}));
}

process.exit(0);
Expand Down Expand Up @@ -98,6 +109,12 @@ if (process.stdin.isTTY) {
" -e, --encode",
" Encode JSON data from one or more files and print the corresponding QR code.",
"",
" -v, --validate",
" Validate JSON data from one or more files before encoding.",
"",
" -b, --deburr",
" Deburr JSON data from one or more files before encoding.",
"",
" -h, --help",
" Display the help message and exit.",
"",
Expand Down
18 changes: 15 additions & 3 deletions src/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
PaymentOptions,
Version,
} from "./types.js";
import { validateDataModel } from "./validations.js";

const MAX_COMPRESSED_SIZE = 131_072; // 2^17

Expand Down Expand Up @@ -188,7 +189,14 @@ type Options = {
*
* @default true
*/
deburr: boolean;
deburr?: boolean;

/**
* If true, validates the data model before encoding it.
*
* @default true
*/
validate?: boolean;
};

/** @deprecated */
Expand All @@ -199,11 +207,15 @@ export const generate = encode;
*/
export function encode(
model: DataModel,
options: Options = { deburr: true },
options?: Options,
): string {
if (options.deburr) {
const { deburr = true, validate = true } = options ?? {};
if (deburr) {
removeDiacritics(model);
}
if (validate) {
validateDataModel(model);
}

const payload = serialize(model);
const withChecksum = addChecksum(payload);
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { decode, detect, parse } from "./decode.js";
export { encode, generate } from "./encode.js";
export { validateDataModel, ValidationErrorMessage } from "./validations.js";

export * from "./types.js";
131 changes: 131 additions & 0 deletions src/validations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import assert from "node:assert";
import test, { describe } from "node:test";
import { PaymentOptions } from "./types.js";
import {
validateBankAccount,
validateDataModel,
validateSimplePayment,
ValidationError,
ValidationErrorMessage,
} from "./validations.js";

const iban = "LC14BOSL123456789012345678901234";
const validBankAccount = {
iban,
};

describe("validateBankAccount", () => {
const path = "payments[0].bankAccounts[0]";
test("validate IBAN", () => {
assert.throws(
() =>
validateBankAccount({
iban: "1234567890",
}, path),
new ValidationError(ValidationErrorMessage.IBAN, `${path}.iban`),
);
assert.doesNotThrow(() => validateBankAccount(validBankAccount, path));
});

test("validate BIC", () => {
assert.throws(
() =>
validateBankAccount({
iban,
bic: "123",
}, path),
new ValidationError(ValidationErrorMessage.BIC, `${path}.bic`),
);
assert.doesNotThrow(
() =>
validateBankAccount({
iban,
bic: "",
}, path),
);
assert.doesNotThrow(() =>
validateBankAccount({
iban,
bic: "DEUTDEFF500",
}, path)
);
});
});

describe("validateSimplePayment", () => {
const path = "payments[0]";
test("validate bankAccounts", () => {
assert.throws(
() => {
validateSimplePayment({
bankAccounts: [validBankAccount, {
iban: "123",
}],
currencyCode: "EUR",
}, path);
},
new ValidationError(ValidationErrorMessage.IBAN, `${path}.bankAccounts[1].iban`),
);
});

test("validate currencyCode", () => {
assert.doesNotThrow(() =>
validateSimplePayment({
bankAccounts: [validBankAccount],
currencyCode: "EUR",
}, path)
);
assert.throws(
() =>
validateSimplePayment({
bankAccounts: [validBankAccount],
currencyCode: "e",
}, path),
new ValidationError(ValidationErrorMessage.CurrencyCode, `${path}.currencyCode`),
);
});

test("validate paymentDueDate", () => {
assert.doesNotThrow(() =>
validateSimplePayment({
bankAccounts: [validBankAccount],
currencyCode: "EUR",
paymentDueDate: "2024-08-08",
}, path)
);

assert.throws(
() =>
validateSimplePayment({
bankAccounts: [validBankAccount],
currencyCode: "EUR",
paymentDueDate: "2024-08-52",
}, path),
new ValidationError(ValidationErrorMessage.Date, `${path}.paymentDueDate`),
);
});
});

describe("validateDataModel", () => {
assert.doesNotThrow(() =>
validateDataModel({
payments: [{
type: PaymentOptions.PaymentOrder,
currencyCode: "EUR",
bankAccounts: [validBankAccount],
}],
})
);

assert.throws(
() =>
validateDataModel({
payments: [{
type: PaymentOptions.PaymentOrder,
currencyCode: "E",
bankAccounts: [validBankAccount],
}],
}),
new ValidationError(ValidationErrorMessage.CurrencyCode, `payments[0].currencyCode`),
);
});
83 changes: 83 additions & 0 deletions src/validations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import validator from "validator";
import {
BankAccount,
DataModel,
SimplePayment,
} from "./types.js";

export enum ValidationErrorMessage {
IBAN = "Invalid IBAN. Make sure ISO 13616 format is used.",
BIC = "Invalid BIC. Make sure ISO 9362 format is used.",
CurrencyCode = "Invalid currency code. Make sure ISO 4217 format is used.",
Date = "Invalid date. Make sure ISO 8601 format is used.",
}

/**
* This error will be thrown in case of a validation issue. It provides message with error description and specific path to issue in dataModel object.
*/
export class ValidationError extends Error {
override name = "ValidationError";
path: string;

/**
* @param message - explains, what is wrong on the specific field
* @param path - navigates to the specific field in DataModel, where error occurred
*/
constructor(message: ValidationErrorMessage, path: string) {
super(String(message));
this.path = path;
}
}

/**
* validates bankAccount fields:
* - iban (ISO 13616)
* - bic (ISO 9362)
*/
export function validateBankAccount(bankAccount: BankAccount, path: string) {
if (!validator.isIBAN(bankAccount.iban)) {
throw new ValidationError(ValidationErrorMessage.IBAN, `${path}.iban`);
}
if (bankAccount.bic && !validator.isBIC(bankAccount.bic)) {
throw new ValidationError(ValidationErrorMessage.BIC, `${path}.bic`);
}
}

/**
* validate simple payment fields:
* - currencyCode (ISO 4217)
* - paymentDueDate (ISO 8601)
* - bankAccounts
*
* @see validateBankAccount
*/
export function validateSimplePayment(simplePayment: SimplePayment, path: string) {
for (const [index, bankAccount] of simplePayment.bankAccounts.entries()) {
validateBankAccount(bankAccount, `${path}.bankAccounts[${index}]`);
}
if (simplePayment.currencyCode && !validator.isISO4217(simplePayment.currencyCode)) {
throw new ValidationError(
ValidationErrorMessage.CurrencyCode,
`${path}.currencyCode`,
);
}
if (simplePayment.paymentDueDate && !validator.isDate(simplePayment.paymentDueDate)) {
throw new ValidationError(
ValidationErrorMessage.Date,
`${path}.paymentDueDate`,
);
}
}

/**
* Validate `payments` field of dataModel.
*
* @see validateSimplePayment
* @see ValidationError
*/
export function validateDataModel(dataModel: DataModel): DataModel {
for (const [index, payment] of dataModel.payments.entries()) {
validateSimplePayment(payment, `payments[${index}]`);
}
return dataModel;
}

0 comments on commit f969570

Please sign in to comment.