From cf8cee2a58c5ab98b520dec270d84fbb52eb9021 Mon Sep 17 00:00:00 2001 From: Jan Romann Date: Thu, 14 Sep 2023 17:51:52 +0200 Subject: [PATCH] feat(coap-server): add support for property meta operations --- packages/binding-coap/src/coap-server.ts | 173 ++++++++++++++++-- .../binding-coap/test/coap-server-test.ts | 103 +++++++++++ 2 files changed, 261 insertions(+), 15 deletions(-) diff --git a/packages/binding-coap/src/coap-server.ts b/packages/binding-coap/src/coap-server.ts index 1bc354c90..4256337bd 100644 --- a/packages/binding-coap/src/coap-server.ts +++ b/packages/binding-coap/src/coap-server.ts @@ -34,6 +34,7 @@ import { Readable } from "stream"; import { MdnsIntroducer } from "./mdns-introducer"; import { PropertyElement, DataSchema } from "wot-thing-description-types"; import { CoapServerConfig } from "./coap"; +import { DataSchemaValue } from "wot-typescript-definitions"; const { debug, warn, info, error } = createLoggers("binding-coap", "coap-server"); @@ -171,6 +172,8 @@ export default class CoapServer implements ProtocolServer { for (const offeredMediaType of offeredMediaTypes) { const base = this.createThingBase(address, port, urlPath); + this.fillInMetaPropertiesBindingData(thing, base, offeredMediaType); + this.fillInPropertyBindingData(thing, base, offeredMediaType); this.fillInActionBindingData(thing, base, offeredMediaType); this.fillInEventBindingData(thing, base, offeredMediaType); @@ -182,17 +185,73 @@ export default class CoapServer implements ProtocolServer { return `${this.scheme}://${address}:${port}/${encodeURIComponent(urlPath)}`; } + private fillInMetaPropertiesBindingData(thing: ExposedThing, base: string, offeredMediaType: string) { + const opValues = this.createPropertyMetaOpValues(thing); + + if (opValues.length === 0) { + return; + } + + if (thing.forms == null) { + thing.forms = []; + } + + const form = this.createAffordanceForm(base, this.PROPERTY_DIR, offeredMediaType, opValues, thing.uriVariables); + + thing.forms.push(form); + } + + private getReadableProperties(thing: ExposedThing) { + return Object.entries(thing.properties).filter(([_, value]) => value.writeOnly !== true); + } + + private getWritableProperties(thing: ExposedThing) { + return Object.entries(thing.properties).filter(([_, value]) => value.readOnly !== true); + } + + private createPropertyMetaOpValues(thing: ExposedThing): string[] { + const properties = Object.values(thing.properties); + const numberOfProperties = properties.length; + + if (numberOfProperties === 0) { + return []; + } + + const readableProperties = this.getReadableProperties(thing).length; + const writableProperties = this.getWritableProperties(thing).length; + + const opValues: string[] = []; + + if (readableProperties > 0) { + opValues.push("readmultipleproperties"); + } + + if (readableProperties === numberOfProperties) { + opValues.push("readallproperties"); + } + + if (writableProperties > 0) { + opValues.push("writemultipleproperties"); + } + + if (writableProperties === numberOfProperties) { + opValues.push("writeallproperties"); + } + + return opValues; + } + private fillInPropertyBindingData(thing: ExposedThing, base: string, offeredMediaType: string) { for (const [propertyName, property] of Object.entries(thing.properties)) { const opValues = ProtocolHelpers.getPropertyOpValues(property); const form = this.createAffordanceForm( base, this.PROPERTY_DIR, - propertyName, offeredMediaType, opValues, - property.uriVariables, - thing.uriVariables + thing.uriVariables, + propertyName, + property.uriVariables ); property.forms.push(form); @@ -205,11 +264,11 @@ export default class CoapServer implements ProtocolServer { const form = this.createAffordanceForm( base, this.ACTION_DIR, - actionName, offeredMediaType, "invokeaction", - action.uriVariables, - thing.uriVariables + thing.uriVariables, + actionName, + action.uriVariables ); action.forms.push(form); @@ -222,11 +281,11 @@ export default class CoapServer implements ProtocolServer { const form = this.createAffordanceForm( base, this.EVENT_DIR, - eventName, offeredMediaType, ["subscribeevent", "unsubscribeevent"], - event.uriVariables, - thing.uriVariables + thing.uriVariables, + eventName, + event.uriVariables ); event.forms.push(form); @@ -237,19 +296,23 @@ export default class CoapServer implements ProtocolServer { private createAffordanceForm( base: string, affordancePathSegment: string, - affordanceName: string, offeredMediaType: string, opValues: string | string[], - affordanceUriVariables: PropertyElement["uriVariables"] = {}, - thingUriVariables: PropertyElement["uriVariables"] = {} + thingUriVariables: PropertyElement["uriVariables"], + affordanceName?: string, + affordanceUriVariables?: PropertyElement["uriVariables"] ): TD.Form { const affordanceNamePattern = Helpers.updateInteractionNameWithUriVariablePattern( - affordanceName, + affordanceName ?? "", affordanceUriVariables, thingUriVariables ); - const href = `${base}/${affordancePathSegment}/${encodeURIComponent(affordanceNamePattern)}`; + let href = `${base}/${affordancePathSegment}`; + + if (affordanceNamePattern.length > 0) { + href += `/${encodeURIComponent(affordanceNamePattern)}`; + } const form = new TD.Form(href, offeredMediaType); form.op = opValues; @@ -473,7 +536,7 @@ export default class CoapServer implements ProtocolServer { const property = thing.properties[affordanceKey]; if (property == null) { - this.sendNotFoundResponse(res); + this.handlePropertiesRequest(req, contentType, thing, res); return; } @@ -498,6 +561,86 @@ export default class CoapServer implements ProtocolServer { } } + private async handlePropertiesRequest( + req: IncomingMessage, + contentType: string, + thing: ExposedThing, + res: OutgoingMessage + ) { + const forms = thing.forms; + + if (forms == null) { + this.sendNotFoundResponse(res); + return; + } + + const method = req.method; + + switch (method) { + case "GET": + this.handleReadMultipleProperties(forms, req, contentType, thing, res); + break; + case "PUT": + this.handleWriteMultipleProperties(forms, req, contentType, thing, res); + break; + default: + this.sendMethodNotAllowedResponse(res); + break; + } + } + + private async handleReadMultipleProperties( + forms: TD.Form[], + req: IncomingMessage, + contentType: string, + thing: ExposedThing, + res: OutgoingMessage + ) { + try { + const interactionOptions = this.createInteractionOptions( + forms, + thing, + req, + contentType, + thing.uriVariables + ); + const readablePropertyKeys = this.getReadableProperties(thing).map(([key, _]) => key); + const contentMap = await thing.handleReadMultipleProperties(readablePropertyKeys, interactionOptions); + + const recordResponse: Record = {}; + for (const [key, content] of contentMap.entries()) { + const value = ContentSerdes.get().contentToValue( + { type: ContentSerdes.DEFAULT, body: await content.toBuffer() }, + {} + ); + + if (value == null) { + // TODO: How should this case be handled? + continue; + } + + recordResponse[key] = value; + } + + const content = ContentSerdes.get().valueToContent(recordResponse, undefined, contentType); + this.streamContentResponse(res, content); + } catch (err) { + const errorMessage = `${err}`; + error(`CoapServer on port ${this.getPort()} got internal error on read '${req.url}': ${errorMessage}`); + this.sendResponse(res, "5.00", errorMessage); + } + } + + private async handleWriteMultipleProperties( + forms: TD.Form[], + req: IncomingMessage, + contentType: string, + thing: ExposedThing, + res: OutgoingMessage + ) { + this.sendResponse(res, "5.01", "Writing multiple properties is not implemented yet."); + } + private async handleReadProperty( property: PropertyElement, req: IncomingMessage, diff --git a/packages/binding-coap/test/coap-server-test.ts b/packages/binding-coap/test/coap-server-test.ts index 294815152..5feca0a96 100644 --- a/packages/binding-coap/test/coap-server-test.ts +++ b/packages/binding-coap/test/coap-server-test.ts @@ -463,4 +463,107 @@ class CoapServerTest { await coapClient.stop(); await coapServer.stop(); } + + @test async "should report allproperties excluding non-JSON properties"() { + const port = 5683; + const coapServer = new CoapServer({ port }); + const servient = new Servient(); + + await coapServer.start(servient); + + const tdTemplate: WoT.ExposedThingInit = { + title: "TestA", + properties: { + image: { + forms: [ + { + contentType: "image/svg+xml", + }, + ], + }, + testInteger: { + type: "integer", + }, + testBoolean: { + type: "boolean", + }, + testString: { + type: "string", + }, + testObject: { + type: "object", + }, + testArray: { + type: "array", + }, + }, + }; + const testThing = new ExposedThing(servient, tdTemplate); + + const image = "FOO"; + const integer = 123; + const boolean = true; + const string = "ABCD"; + const object = { t1: "xyz", i: 77 }; + const array = ["x", "y", "z"]; + testThing.setPropertyReadHandler("image", async (_) => image); + testThing.setPropertyReadHandler("testInteger", async (_) => integer); + testThing.setPropertyReadHandler("testBoolean", async (_) => boolean); + testThing.setPropertyReadHandler("testString", async (_) => string); + testThing.setPropertyReadHandler("testObject", async (_) => object); + testThing.setPropertyReadHandler("testArray", async (_) => array); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.image.forms = []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.testInteger.forms = []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.testBoolean.forms = []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.testString.forms = []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.testObject.forms = []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.testArray.forms = []; + + await coapServer.expose(testThing, tdTemplate); + + const coapClient = new CoapClient(coapServer); + + const decodeContent = async (content: Content) => JSON.parse((await content.toBuffer()).toString()); + + const baseUri = `coap://localhost:${port}/testa/properties`; + + // check values one by one first + const responseInteger = await coapClient.readResource(new TD.Form(`${baseUri}/testInteger`)); + expect(await decodeContent(responseInteger)).to.equal(integer); + const responseBoolean = await coapClient.readResource(new TD.Form(`${baseUri}/testBoolean`)); + expect(await decodeContent(responseBoolean)).to.equal(boolean); + const responseString = await coapClient.readResource(new TD.Form(`${baseUri}/testString`)); + expect(await decodeContent(responseString)).to.equal(string); + const responseObject = await coapClient.readResource(new TD.Form(`${baseUri}/testObject`)); + expect(await decodeContent(responseObject)).to.deep.equal(object); + const responseArray = await coapClient.readResource(new TD.Form(`${baseUri}/testArray`)); + expect(await decodeContent(responseArray)).to.deep.equal(array); + + // check values of readallproperties + const responseAll = await coapClient.readResource(new TD.Form(baseUri)); + expect(await decodeContent(responseAll)).to.deep.equal({ + image: image, + testInteger: integer, + testBoolean: boolean, + testString: string, + testObject: object, + testArray: array, + }); + + await coapServer.stop(); + await coapClient.stop(); + } }