feat(middleware): add MQTT payload parser with dot-notation JSON path extraction
- src/modules/mqtt/payload-parser.ts: extractValue() supporting \$.field and \$.nested.deep paths - Returns raw string when no path provided, null for invalid JSON or missing paths - Stringifies numbers/booleans, returns JSON string for objects/arrays - tests/payload-parser.test.ts: 19 tests covering no-path passthrough, simple/nested fields, missing paths, invalid JSON, and edge cases (null, zero, arrays) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0df1c523bd
commit
7b60fc252d
62
display/middleware/src/modules/mqtt/payload-parser.ts
Normal file
62
display/middleware/src/modules/mqtt/payload-parser.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Extract a value from a JSON payload using a simple dot-notation JSON path.
|
||||||
|
*
|
||||||
|
* Supported path format: "$.field" or "$.field.nested.deep"
|
||||||
|
* - Returns the raw payload string if no path is provided (undefined, null, or empty string).
|
||||||
|
* - Returns null if the JSON is invalid or the path does not exist.
|
||||||
|
* - Returns the extracted value as a string (numbers, booleans, null are stringified).
|
||||||
|
* - Objects and arrays are returned as their JSON string representation.
|
||||||
|
*/
|
||||||
|
export function extractValue(
|
||||||
|
payload: string,
|
||||||
|
jsonPath: string | null | undefined
|
||||||
|
): string | null {
|
||||||
|
// No path — return raw payload as-is
|
||||||
|
if (!jsonPath) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove leading "$." and split on dots
|
||||||
|
const pathWithoutRoot = jsonPath.startsWith('$.') ? jsonPath.slice(2) : jsonPath;
|
||||||
|
|
||||||
|
if (!pathWithoutRoot) {
|
||||||
|
// Path was just "$" — return the whole object as JSON
|
||||||
|
return JSON.stringify(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = pathWithoutRoot.split('.');
|
||||||
|
|
||||||
|
// Traverse the parsed object
|
||||||
|
let current: unknown = parsed;
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const obj = current as Record<string, unknown>;
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, segment)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
current = obj[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert result to string
|
||||||
|
if (current === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (current === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
if (typeof current === 'object') {
|
||||||
|
// Arrays and objects: return JSON string without spaces
|
||||||
|
return JSON.stringify(current);
|
||||||
|
}
|
||||||
|
return String(current);
|
||||||
|
}
|
||||||
95
display/middleware/tests/payload-parser.test.ts
Normal file
95
display/middleware/tests/payload-parser.test.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractValue } from '../src/modules/mqtt/payload-parser';
|
||||||
|
|
||||||
|
describe('extractValue', () => {
|
||||||
|
describe('no JSON path', () => {
|
||||||
|
it('should return raw payload when no jsonPath provided', () => {
|
||||||
|
expect(extractValue('42.5', undefined)).toBe('42.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return raw payload when jsonPath is null', () => {
|
||||||
|
expect(extractValue('hello world', null)).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return raw payload when jsonPath is empty string', () => {
|
||||||
|
expect(extractValue('{"temp":22}', '')).toBe('{"temp":22}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('simple field extraction', () => {
|
||||||
|
it('should extract top-level field', () => {
|
||||||
|
expect(extractValue('{"temp":22.5}', '$.temp')).toBe('22.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract string field', () => {
|
||||||
|
expect(extractValue('{"status":"running"}', '$.status')).toBe('running');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract integer field', () => {
|
||||||
|
expect(extractValue('{"count":42}', '$.count')).toBe('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract boolean field as string', () => {
|
||||||
|
expect(extractValue('{"active":true}', '$.active')).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nested field extraction', () => {
|
||||||
|
it('should extract nested field with dot notation', () => {
|
||||||
|
expect(extractValue('{"sensor":{"temp":23.1}}', '$.sensor.temp')).toBe('23.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract deeply nested field', () => {
|
||||||
|
expect(
|
||||||
|
extractValue('{"a":{"b":{"c":"deep"}}}', '$.a.b.c')
|
||||||
|
).toBe('deep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract nested integer', () => {
|
||||||
|
expect(extractValue('{"data":{"humidity":65}}', '$.data.humidity')).toBe('65');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('missing path', () => {
|
||||||
|
it('should return null when path does not exist in JSON', () => {
|
||||||
|
expect(extractValue('{"temp":22}', '$.humidity')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when nested path does not exist', () => {
|
||||||
|
expect(extractValue('{"sensor":{}}', '$.sensor.temp')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when intermediate key is missing', () => {
|
||||||
|
expect(extractValue('{}', '$.a.b.c')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid JSON', () => {
|
||||||
|
it('should return null for invalid JSON with a path', () => {
|
||||||
|
expect(extractValue('not json', '$.field')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for malformed JSON with a path', () => {
|
||||||
|
expect(extractValue('{broken}', '$.field')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return raw string for invalid JSON without path', () => {
|
||||||
|
expect(extractValue('not json', null)).toBe('not json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null value in JSON', () => {
|
||||||
|
expect(extractValue('{"value":null}', '$.value')).toBe('null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero value', () => {
|
||||||
|
expect(extractValue('{"count":0}', '$.count')).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle array field (returns JSON string)', () => {
|
||||||
|
const result = extractValue('{"arr":[1,2,3]}', '$.arr');
|
||||||
|
expect(result).toBe('[1,2,3]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user