Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
5 changes: 0 additions & 5 deletions apps/keeper/jest.setup.js

This file was deleted.

18 changes: 5 additions & 13 deletions apps/keeper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,33 @@
"license": "Apache-2.0",
"dependencies": {
"@asyncapi/parser": "^3.4.0",
"@hyperjump/json-schema": "^1.16.2"
"ajv": "^8.17.1"
},
"devDependencies": {
"@babel/cli": "^7.25.9",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@ungap/structured-clone": "^1.3.0",
"babel-jest": "^27.3.1",
"jest": "^27.3.1",
"jest-esm-transformer": "^1.0.0"
},
"jest": {
"setupFiles": [
"<rootDir>/jest.setup.js"
],
"transformIgnorePatterns": [
"node_modules/(?!@hyperjump)"
],
"moduleFileExtensions": [
"js",
"json",
"jsx"
],
"transform": {
"^.+\\.jsx?$": "jest-esm-transformer"
"^.+\\.jsx?$": "babel-jest"
},
"moduleNameMapper": {
"^nimma/legacy$": "<rootDir>/../../node_modules/nimma/dist/legacy/cjs/index.js",
"^nimma/(.*)": "<rootDir>/../../node_modules/nimma/dist/cjs/$1",
"^@hyperjump/browser/jref$": "<rootDir>/../../node_modules/@hyperjump/browser/lib/jref/index.js",
"^@hyperjump/json-schema/experimental$": "<rootDir>/node_modules/@hyperjump/json-schema/lib/experimental.js"
"^nimma/(.*)": "<rootDir>/../../node_modules/nimma/dist/cjs/$1"
}
},
"babel": {
"presets": [
"@babel/preset-env"
]
}
}
}
119 changes: 50 additions & 69 deletions apps/keeper/src/index.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,31 @@
import {
getAllRegisteredSchemaUris,
registerSchema,
unregisterSchema,
validate,
setMetaSchemaOutputFormat
} from '@hyperjump/json-schema/draft-07';
import { generateSchemaURI, parseAsyncAPIDocumentFromFile } from './utils';
import { DETAILED } from '@hyperjump/json-schema/experimental';
import { parseAsyncAPIDocumentFromFile } from './utils.js';
import Ajv from 'ajv';

setMetaSchemaOutputFormat(DETAILED);
const ajv = new Ajv({ strict: false, allErrors: true });

/**
* Compiles a single JSON Schema for validation.
* @param {object} schema - The JSON Schema object to compile.
* @returns {function} A compiled schema validator function.
* @throws {Error} Throws error if AJV cannot compile the schema.
*/
export function compileSchema(schema) {
return ajv.compile(schema);
}

/**
* Compiles multiple JSON Schemas for validation.
* @async
* @param {Array<object>} schemas - Array of JSON Schema objects to compile
* @returns {Promise<Array<function>>} Array of compiled schema validator functions
* @throws {Error} When schemas parameter is not an array
* @param {Array<object>} schemas - Array of JSON Schema objects to compile.
* @returns {Array<function>} Array of compiled schema validator functions.
* @throws {Error} Throws error if AJV cannot compile a schema.
*/
export async function compileSchemas(schemas) {
if (!schemas || schemas.length === 0) {
console.log('Skipping compilation: no schemas provided.');
return [];
}
export function compileSchemas(schemas) {
if (!Array.isArray(schemas)) {
throw new Error(`Invalid "schemas" parameter: expected an array, received ${typeof schemas}`);
throw new Error('Invalid "schemas" parameter: must be an array.');
}
const dialectId = 'http://json-schema.org/draft-07/schema#';
const schemaUris = [];

// Register all schemas first
for (const schema of schemas) {
const schemaURI = generateSchemaURI();
registerSchema(schema, schemaURI, dialectId);
schemaUris.push(schemaURI);
}

const compiledSchemas = [];
for (const schemaUri of schemaUris) {
const compileSchema = await validate(schemaUri);
compiledSchemas.push(compileSchema);
for (const schema of schemas) {
compiledSchemas.push(compileSchema(schema));
}
return compiledSchemas;
}
Expand All @@ -48,57 +35,51 @@ export async function compileSchemas(schemas) {
* @async
* @param {string} asyncapiFilepath - Path to the AsyncAPI document file
* @param {string} operationId - ID of the operation to extract message schemas from
* @returns {Promise<Array<function>>} Array of compiled schema validator functions
* @throws {Error} When operationId is invalid or when the operation/messages can't be found
* @returns {Promise<Array<function>>} A Promise that resolves to an array of compiled schema validator functions.
* @throws {Error} When operationId is invalid or when the operation/messages can't be found.
*/
export async function compileSchemasByOperationId(asyncapiFilepath, operationId) {
if (typeof operationId !== 'string' || !operationId.trim()) {
throw new Error(`Invalid "operationId" parameter: must be a non-empty string, received ${operationId}`);
throw new Error(`Invalid "operationId" parameter: must be a non-empty string, received ${operationId}`);
}
const asyncapi = await parseAsyncAPIDocumentFromFile(asyncapiFilepath);
const operation = asyncapi.operations().get(operationId);
if (!operation) {
throw new Error(`Operation with ID "${operationId}" not found in the AsyncAPI document.`);
}
const messages = operation.messages().all();
if (!messages || messages.length === 0) {
console.warn(`No messages found for operation ID "${operationId}".`);
return [];
}
const schemas = messages
.filter(message => message.hasPayload())
.map(message => message.payload().json());
.map(message => message.payload().json());
return compileSchemas(schemas);
}

/**
* Validates a message payload against an array of operation message schemas.
* Uses the Hyperjump JSON Schema validator (Draft-07) to check if the message
* conforms to at least one of the provided schemas.
* @param {Array<function>} compiledSchemas - Array of compiled schema validator functions
* @param {object} message - The message payload to validate
* @returns {{ isValid: boolean, validationErrors?: Array<object> }} Object containing validation result and errors if invalid
* @throws {Error} When message parameter is null or undefined
* Validates a message payload against a compiled message schema.
*
* @param {function} compiledSchema - Compiled schema validator function.
* @param {object} message - The message payload to validate.
* @returns {{ isValid: boolean, validationErrors?: Array<object> }} Object containing validation result.
* If invalid, `validationErrors` will contain the validation errors.
* @throws {Error} When message parameter is null/undefined or compiledSchema is not a function.
*/
export function validateMessage(compiledSchemas, message) {
if (!compiledSchemas || compiledSchemas.length === 0) {
console.log('Skipping validation: no schemas provided for message validation.');
return { isValid: true };
}
if (message === null || message === undefined) {
throw new Error(`Invalid "message" parameter: expected a non-null object to validate, but received ${message}`);
export function validateMessage(compiledSchema, message) {
if (message === undefined) {
throw new Error(`Invalid "message" parameter: expected a value to validate, but received ${message}`);
}
const validationErrors = [];
for (const compiledSchema of compiledSchemas) {
const result = compiledSchema(message, DETAILED);
if (result.valid) {
return { isValid: true };
}
validationErrors.push(...result.errors);
if (typeof compiledSchema !== 'function') {
throw new Error(`Invalid "compiledSchema" parameter: expected a validator function, received ${typeof compiledSchema}`);
}
return { isValid: false, validationErrors };
}

/**
* Unregisters all currently registered schemas from the validator.
* @returns {void}
*/
export function removeCompiledSchemas() {
const schemaUris = getAllRegisteredSchemaUris();
for (const schemaUri of schemaUris) {
unregisterSchema(schemaUri);
const validate = compiledSchema(message);
if (validate) {
return { isValid: true };
}
return {
isValid: false,
validationErrors: compiledSchema.errors || []
};
}
10 changes: 0 additions & 10 deletions apps/keeper/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,4 @@ export const parseAsyncAPIDocumentFromFile = async (asyncapiFilepath) => {
throw new Error(`Failed to parse AsyncAPI document: ${error.message}`);
}
return asyncapi;
};

/**
* Generates a unique schema URI using the current timestamp.
*
* @returns {string} A unique schema URI.
*/
export const generateSchemaURI = () => {
const timestamp = Date.now().toString();
return `http://asyncapi/keeper/schema/${timestamp}`;
};
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ components:
type: string
description: The content to be echoed back
example: "test message"
timestamp:
type: string
format: date-time
description: When the message was sent
messageId:
type: number
description: Unique identifier for the message
required:
- content
92 changes: 62 additions & 30 deletions apps/keeper/test/index.test.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
import path from 'path';
import { Parser, fromFile } from '@asyncapi/parser';
import {
compileSchema,
compileSchemas,
validateMessage,
compileSchemasByOperationId,
removeCompiledSchemas
compileSchemasByOperationId
} from '../src/index.js';

const parser = new Parser();
const asyncapi_v3_path = path.resolve(__dirname, '../test/__fixtures__/asyncapi-message-validation.yml');

describe('Integration Tests for message validation module', () => {
// Cleanup: Remove all compiled schemas from the local registry after all tests complete
afterAll(() => {
removeCompiledSchemas();
});
// Single helper function that can be used differently
async function parseAsyncAPIFile() {
const parseResult = await fromFile(parser, asyncapi_v3_path).parse();
return parseResult.document;
}

describe('Integration Tests for message validation module', () => {
describe('Schema Compilation & Basic Validation', () => {
let compiledSchemas;
let rawSchemas;
let compiledSchema;

beforeAll(async () => {
const parseResult = await fromFile(parser, asyncapi_v3_path).parse();
const parsedAsyncAPIDocument = parseResult.document;
rawSchemas = parsedAsyncAPIDocument.schemas().all().map(schema => schema.json());
compiledSchemas = await compileSchemas(rawSchemas);
const parsedAsyncAPIDocument = await parseAsyncAPIFile();
const firstSchema = parsedAsyncAPIDocument.schemas().all()[0].json();
compiledSchema = compileSchema(firstSchema);
});

test('should validate a correct message against schemas and return true', async () => {
test('should validate a correct message against schema and return true', async () => {
const validMessage = {
content: 'This is a test message',
timestamp: new Date().toISOString()
messageId: 11
};
const result = validateMessage(compiledSchemas, validMessage);
const result = validateMessage(compiledSchema, validMessage);
expect(result.isValid).toBe(true);
expect(result.validationErrors).toBeUndefined();
});
Expand All @@ -41,58 +40,91 @@ describe('Integration Tests for message validation module', () => {
const invalidMessage = {
content: 42
};
const result = validateMessage(compiledSchemas, invalidMessage);
const result = validateMessage(compiledSchema, invalidMessage);
expect(result.isValid).toBe(false);
expect(result.validationErrors.length).toBeGreaterThan(0);
});

test('should return false when message cannot match any schema', async () => {
test('should return false when message cannot match schema', async () => {
const invalidMessage = 42;
const result = validateMessage(compiledSchemas, invalidMessage);
const result = validateMessage(compiledSchema, invalidMessage);
expect(result.isValid).toBe(false);
expect(result.validationErrors.length).toBeGreaterThan(0);
});

test('should return false when required field is missing', async () => {
const invalidMessage = {
timestamp: new Date().toISOString()
messageId: 12
};
const result = validateMessage(compiledSchemas, invalidMessage);
const result = validateMessage(compiledSchema, invalidMessage);
expect(result.isValid).toBe(false);
expect(result.validationErrors.length).toBeGreaterThan(0);
});

test('should throw error if message is null', () => {
expect(() => validateMessage(compiledSchemas, null)).toThrow('Invalid "message" parameter');
test('should throw error if message is undefined', () => {
expect(() => validateMessage(compiledSchema, undefined)).toThrow('Invalid "message" parameter');
});

test('should throw error if message is undefined', () => {
expect(() => validateMessage(compiledSchemas, undefined)).toThrow('Invalid "message" parameter');
test('should throw error if compiledSchema is not a function', () => {
expect(() => validateMessage({}, { content: 'test' })).toThrow('Invalid "compiledSchema" parameter');
});
});

describe('compileSchemas utility function', () => {
test('should compile multiple schemas successfully', async () => {
const parsedAsyncAPIDocument = await parseAsyncAPIFile();
const allSchemas = parsedAsyncAPIDocument.schemas().all();
const rawSchemas = allSchemas.map(schema => schema.json());
const compiledSchemas = compileSchemas(rawSchemas);

expect(Array.isArray(compiledSchemas)).toBe(true);
expect(compiledSchemas.length).toBe(rawSchemas.length);
compiledSchemas.forEach(schema => {
expect(typeof schema).toBe('function');
});
});

test('should throw error if schemas parameter is not an array', () => {
expect(() => compileSchemas({})).toThrow('Invalid "schemas" parameter');
});
});

describe('Operation-Specific Validation', () => {
let compiledSchemas;
let compiledSchema;

beforeAll(async () => {
compiledSchemas = await compileSchemasByOperationId(asyncapi_v3_path, 'sendHelloMessage');
compiledSchema = compiledSchemas[0];
});

test('should validate a correct message against operation schemas and return true', async () => {
test('should validate a correct message against operation schema and return true', async () => {
const validMessage = {
content: 'This is a operation test message'
};
const result = validateMessage(compiledSchemas, validMessage);
const result = validateMessage(compiledSchema, validMessage);
expect(result.isValid).toBe(true);
});

test('should return false for invalid message against operation schemas', async () => {
test('should return false for invalid message against operation schema', async () => {
const invalidMessage = {
time: new Date().toISOString()
messageId: 10
};
const result = validateMessage(compiledSchemas, invalidMessage);
const result = validateMessage(compiledSchema, invalidMessage);
expect(result.isValid).toBe(false);
expect(result.validationErrors.length).toBeGreaterThan(0);
});

test('should throw error for invalid operationId', async () => {
await expect(
compileSchemasByOperationId(asyncapi_v3_path, 'nonExistentOperation')
).rejects.toThrow('Operation with ID "nonExistentOperation" not found');
});

test('should throw error for empty operationId', async () => {
await expect(
compileSchemasByOperationId(asyncapi_v3_path, '')
).rejects.toThrow('Invalid "operationId" parameter');
});
});
});
Loading
Loading