diff --git a/display/middleware/src/modules/mqtt/payload-parser.ts b/display/middleware/src/modules/mqtt/payload-parser.ts new file mode 100644 index 0000000..673addf --- /dev/null +++ b/display/middleware/src/modules/mqtt/payload-parser.ts @@ -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; + 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); +} diff --git a/display/middleware/tests/payload-parser.test.ts b/display/middleware/tests/payload-parser.test.ts new file mode 100644 index 0000000..c702832 --- /dev/null +++ b/display/middleware/tests/payload-parser.test.ts @@ -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]'); + }); + }); +});