Skip to content

Commit 45d3cfb

Browse files
siddharthbaleja7relu91danielpeintner
authored
feat(core): implement Thing-level data schema mapping extraction (#1499)
* feat(core): implement Thing-level data schema mapping extraction * refactor(core/consumed-thing): simplify valuePath extraction * refactor(core/comsumed-thing): derive type interactionType from Thing * refactor(core): introduce DataSchemaMapping type Make InteractionOutput accepts a DataSchemaMapping object instead of a strig for the value path. This allows to extend the mapping in the future with more properties if needed. * refactor(core/interaction-output): clean up comments and un-wanted cast * refactor(servient): use type from Thing for dataSchemaMapping property * refactor(core/wot-impl): simplify mapping handling in consume * refactor(core/helpers): remove any references in extractDataFromPath * refactor(core/wot-impl): simplify mapping handling in produce * test(core/client-test): avoid using any casting * test(core/server-test): avoid using any casting * refactor(core/interaction-output): use consistency in arg ordering for ActionInteractionOutput * docs: minor fixes as requested from code review Co-authored-by: danielpeintner <daniel.peintner@gmail.com> * chore: remove leftover files from other branches --------- Co-authored-by: reluc <relu.cri@gmail.com> Co-authored-by: Cristiano Aguzzi <relu91@users.noreply.github.com> Co-authored-by: danielpeintner <daniel.peintner@gmail.com>
1 parent 0e916ee commit 45d3cfb

11 files changed

Lines changed: 344 additions & 19 deletions

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,51 @@ Below are small explanations of what they can be used for:
319319
- Smart Clock: It simply has a property affordance for the time. However, it runs 60 times faster than real-time to allow time-based decisions that can be easily tested.
320320
- Simple Coffee Machine: This is a simpler simulation of the coffee machine above.
321321

322+
## Experimental Features
323+
324+
### Data Mapping per Thing
325+
326+
node-wot allows configuration of "Data Mapping" which extracts specific values from a Thing's response object (e.g., getting `123` from a wrapper `{ value: 123, timestamp: ... }`). This is useful when the Interaction only cares about an inner value but the Thing returns a wrapper object.
327+
328+
This is configured using the experimental node-wot `nw:dataSchemaMapping` vocabulary in the Thing Description. It can be defined at the Thing level or globally injected via the `Servient` configuration:
329+
330+
```json
331+
{
332+
"title": "MyThing",
333+
"properties": {
334+
"status": {
335+
"type": "integer",
336+
"forms": [{ "href": "/status" }]
337+
}
338+
},
339+
"nw:dataSchemaMapping": {
340+
"nw:property": {
341+
"nw:valuePath": "/value"
342+
},
343+
"nw:action": {
344+
"nw:valuePath": "/value"
345+
},
346+
"nw:event": {
347+
"nw:valuePath": "/value"
348+
}
349+
}
350+
}
351+
```
352+
353+
The `nw:valuePath` supports simple JSON Pointer-like notation (e.g., `/value` or `value/nested`) or dot-notation (e.g., `value.nested`) and is evaluated _after_ content deserialization but _before_ JSON Schema validation. Currently, the `nw:` vocabulary is hardcoded internally and doesn't explicitly require an `@context` definition for node-wot to process it.
354+
355+
Servient-level configuration can be added to naturally inject this vocabulary to any consumed or exposed Thing Descriptions without the application needing to do it manually. In the `wot-servient.conf.json` file, you can specify this parameter under the `"servient"` key:
356+
357+
```json
358+
{
359+
"servient": {
360+
"dataSchemaMapping": {
361+
"nw:property": { "nw:valuePath": "/value" }
362+
}
363+
}
364+
}
365+
```
366+
322367
## Documentation
323368

324369
> [!WARNING]

packages/cli/src/cli-default-servient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ export default class DefaultServient extends Servient {
122122
Helpers.setStaticAddress(this.config.servient.staticAddress);
123123
}
124124

125+
if (this.config.servient.dataSchemaMapping) {
126+
this.dataSchemaMapping = this.config.servient.dataSchemaMapping;
127+
}
128+
125129
let coapServer: CoapServer | undefined;
126130
if (this.config.servient.clientOnly === false) {
127131
if (this.config.http != null) {

packages/core/src/consumed-thing.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ export default class ConsumedThing extends Thing implements IConsumedThing {
584584

585585
const content = await client.readResource(formWithUriVariables);
586586
try {
587-
return this.handleInteractionOutput(content, formWithUriVariables, tp);
587+
return this.handleInteractionOutput(content, formWithUriVariables, tp, "nw:property");
588588
} catch (e) {
589589
const error = e instanceof Error ? e : new Error(JSON.stringify(e));
590590
throw new Error(`Error while processing property for ${tp.title}. ${error.message}`);
@@ -594,13 +594,17 @@ export default class ConsumedThing extends Thing implements IConsumedThing {
594594
private handleInteractionOutput(
595595
content: Content,
596596
form: Form,
597-
outputDataSchema: WoT.DataSchema | undefined
597+
outputDataSchema: WoT.DataSchema | undefined,
598+
interactionType: keyof Required<Exclude<Thing["nw:dataSchemaMapping"], undefined>>
598599
): InteractionOutput {
599600
// infer media type from form if not in response metadata
600601
content.type ??= form.contentType ?? "application/json";
601602
// check if returned media type is the same as expected media type (from TD)
602603
this.checkMediaTypeOrThrow(content, form);
603-
return new InteractionOutput(content, form, outputDataSchema);
604+
605+
const mapping = this["nw:dataSchemaMapping"]?.[interactionType];
606+
607+
return new InteractionOutput(content, form, outputDataSchema, mapping);
604608
}
605609

606610
// check if returned media type is the same as expected media type (from TD)
@@ -620,13 +624,17 @@ export default class ConsumedThing extends Thing implements IConsumedThing {
620624
content: Content,
621625
form: Form,
622626
outputDataSchema: WoT.DataSchema | undefined,
627+
interactionType: keyof Required<Exclude<Thing["nw:dataSchemaMapping"], undefined>>,
623628
synchronous?: boolean
624629
): ActionInteractionOutput {
625630
// infer media type from form if not in response metadata
626631
content.type ??= form.contentType ?? "application/json";
627632
// check if returned media type is the same as expected media type (from TD)
628633
this.checkMediaTypeOrThrow(content, form);
629-
return new ActionInteractionOutput(content, form, outputDataSchema, synchronous);
634+
635+
const mapping = this["nw:dataSchemaMapping"]?.[interactionType];
636+
637+
return new ActionInteractionOutput(content, form, outputDataSchema, mapping, synchronous);
630638
}
631639

632640
async _readProperties(propertyNames: string[]): Promise<WoT.PropertyReadMap> {
@@ -745,7 +753,13 @@ export default class ConsumedThing extends Thing implements IConsumedThing {
745753

746754
const content = await client.invokeResource(formWithUriVariables, input);
747755
try {
748-
return this.handleActionInteractionOutput(content, formWithUriVariables, ta.output, ta.synchronous);
756+
return this.handleActionInteractionOutput(
757+
content,
758+
formWithUriVariables,
759+
ta.output,
760+
"nw:action",
761+
ta.synchronous
762+
);
749763
} catch (e) {
750764
const error = e instanceof Error ? e : new Error(JSON.stringify(e));
751765
throw new Error(`Error while processing action for ${ta.title}. ${error.message}`);
@@ -788,7 +802,7 @@ export default class ConsumedThing extends Thing implements IConsumedThing {
788802
// next
789803
(content) => {
790804
try {
791-
listener(this.handleInteractionOutput(content, form, tp));
805+
listener(this.handleInteractionOutput(content, form, tp, "nw:property"));
792806
} catch (e) {
793807
const error = e instanceof Error ? e : new Error(JSON.stringify(e));
794808
warn(`Error while processing observe property for ${tp.title}. ${error.message}`);
@@ -844,7 +858,7 @@ export default class ConsumedThing extends Thing implements IConsumedThing {
844858
formWithoutURITemplates,
845859
(content) => {
846860
try {
847-
listener(this.handleInteractionOutput(content, form, te.data));
861+
listener(this.handleInteractionOutput(content, form, te.data, "nw:event"));
848862
} catch (e) {
849863
const error = e instanceof Error ? e : new Error(JSON.stringify(e));
850864
warn(`Error while processing event for ${te.title}. ${error.message}`);

packages/core/src/helpers.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,37 @@ export default class Helpers implements Resolver {
218218
}
219219
}
220220

221+
/**
222+
* Extracts a value from an object based on a JSON path.
223+
* Supports dot-notation or JSON Pointer-like notation (e.g., "/value", "value", "state.status").
224+
* @param data The object to extract from
225+
* @param path The path string
226+
* @returns The extracted value, or undefined if the path is not found
227+
*/
228+
public static extractDataFromPath(data: unknown, path: string): DataSchemaValue | undefined {
229+
if (data == null) {
230+
return undefined;
231+
}
232+
233+
let cleanPath = path;
234+
if (cleanPath.startsWith("/")) {
235+
cleanPath = cleanPath.substring(1);
236+
}
237+
238+
const parts = cleanPath.split(/[./]/);
239+
let current: unknown = data;
240+
241+
for (const part of parts) {
242+
if (current != null && typeof current === "object" && part in current) {
243+
current = (current as Record<string, unknown>)[part];
244+
} else {
245+
return undefined;
246+
}
247+
}
248+
249+
return current as DataSchemaValue;
250+
}
251+
221252
/**
222253
* Helper function to remove reserved keywords in required property of TD JSON Schema
223254
*/

packages/core/src/interaction-output.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
import * as util from "util";
1616
import * as WoT from "wot-typescript-definitions";
1717
import { ContentSerdes } from "./content-serdes";
18-
import { ProtocolHelpers } from "./core";
18+
import { DataSchemaMapping, ProtocolHelpers } from "./core";
19+
import Helpers from "./helpers";
1920
import { DataSchemaError, NotReadableError, NotSupportedError } from "./errors";
2021
import { Content } from "./content";
2122
import Ajv from "ajv";
@@ -44,6 +45,8 @@ export class InteractionOutput implements WoT.InteractionOutput {
4445
form?: WoT.Form;
4546
schema?: WoT.DataSchema;
4647

48+
mapping?: DataSchemaMapping;
49+
4750
public get data(): ReadableStream {
4851
if (this.#stream) {
4952
return this.#stream;
@@ -58,10 +61,11 @@ export class InteractionOutput implements WoT.InteractionOutput {
5861
return (this.#stream = ProtocolHelpers.toWoTStream(this.#content.body) as ReadableStream);
5962
}
6063

61-
constructor(content: Content, form?: WoT.Form, schema?: WoT.DataSchema) {
64+
constructor(content: Content, form?: WoT.Form, schema?: WoT.DataSchema, mapping?: DataSchemaMapping) {
6265
this.#content = content;
6366
this.form = form;
6467
this.schema = schema;
68+
this.mapping = mapping;
6569
this.dataUsed = false;
6670
}
6771

@@ -128,7 +132,11 @@ export class InteractionOutput implements WoT.InteractionOutput {
128132
this.dataUsed = true;
129133
this.#valueBuffer = bytes;
130134

131-
const json = ContentSerdes.get().contentToValue({ type: this.#content.type, body: bytes }, this.schema);
135+
let json = ContentSerdes.get().contentToValue({ type: this.#content.type, body: bytes }, this.schema);
136+
137+
if (this.mapping !== undefined) {
138+
json = Helpers.extractDataFromPath(json, this.mapping["nw:valuePath"]);
139+
}
132140

133141
// validate the schema
134142
const validate = ajv.compile<T>(this.schema);
@@ -152,8 +160,14 @@ export class InteractionOutput implements WoT.InteractionOutput {
152160
export class ActionInteractionOutput extends InteractionOutput implements WoT.ActionInteractionOutput {
153161
synchronous?: boolean;
154162

155-
constructor(content: Content, form?: WoT.Form, schema?: WoT.DataSchema, synchronous?: boolean) {
156-
super(content, form, schema);
163+
constructor(
164+
content: Content,
165+
form?: WoT.Form,
166+
schema?: WoT.DataSchema,
167+
mapping?: DataSchemaMapping,
168+
synchronous?: boolean
169+
) {
170+
super(content, form, schema, mapping);
157171
this.synchronous = synchronous;
158172
}
159173

packages/core/src/servient.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ProtocolClientFactory, ProtocolServer, ProtocolClient } from "./protoco
2121
import ContentManager, { ContentCodec } from "./content-serdes";
2222
import { v4 } from "uuid";
2323
import { createLoggers } from "./logger";
24-
import { Helpers } from "./core";
24+
import { Helpers, Thing } from "./core";
2525

2626
const { debug, warn } = createLoggers("core", "servient");
2727

@@ -31,6 +31,12 @@ export default class Servient {
3131
private things: Map<string, ExposedThing> = new Map<string, ExposedThing>();
3232
private credentialStore: Map<string, Array<unknown>> = new Map<string, Array<unknown>>();
3333

34+
/**
35+
* Data schema mapping for extracting values from nested response objects.
36+
* @experimental
37+
*/
38+
public dataSchemaMapping: Thing["nw:dataSchemaMapping"];
39+
3440
#wotInstance?: typeof WoT;
3541
#shutdown = false;
3642

packages/core/src/thing-description.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export class Thing implements TDT.ThingDescription {
3333

3434
"@context": TDT.ThingContext;
3535

36+
/**
37+
* Data schema mapping for extracting values from nested response objects.
38+
* @experimental
39+
*/
40+
"nw:dataSchemaMapping"?: {
41+
"nw:property"?: DataSchemaMapping;
42+
"nw:action"?: DataSchemaMapping;
43+
"nw:event"?: DataSchemaMapping;
44+
};
45+
3646
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3747
[key: string]: any;
3848

@@ -253,3 +263,4 @@ export abstract class ThingEvent {
253263
// eslint-disable-next-line @typescript-eslint/no-explicit-any
254264
[key: string]: any;
255265
}
266+
export type DataSchemaMapping = { "nw:valuePath": string };

packages/core/src/wot-impl.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ export default class WoTImpl {
107107
async consume(td: WoT.ThingDescription): Promise<WoT.ConsumedThing> {
108108
try {
109109
const thing = parseTD(JSON.stringify(td), true);
110+
const mapping = { ...(this.srv.dataSchemaMapping ?? {}), ...(thing["nw:dataSchemaMapping"] ?? {}) };
111+
112+
// If no mapping is configured, the property will be left undefined
113+
if (Object.keys(mapping).length > 0) {
114+
thing["nw:dataSchemaMapping"] = mapping;
115+
}
116+
110117
const newThing: ConsumedThing = new ConsumedThing(this.srv, thing as ThingModel);
111118

112119
debug(
@@ -134,6 +141,13 @@ export default class WoTImpl {
134141
throw new Error("Thing Description JSON schema validation failed:\n" + validated.errors);
135142
}
136143

144+
const mapping = { ...(this.srv.dataSchemaMapping ?? {}), ...(init["nw:dataSchemaMapping"] ?? {}) };
145+
146+
// If none mapping is configured, the property will be left undefined
147+
if (Object.keys(mapping).length > 0) {
148+
init["nw:dataSchemaMapping"] = mapping;
149+
}
150+
137151
const newThing = new ExposedThing(this.srv, init);
138152

139153
debug(`WoTImpl producing new ExposedThing '${newThing.title}'`);

0 commit comments

Comments
 (0)