diff --git a/packages/binding-coap/src/coap-server.ts b/packages/binding-coap/src/coap-server.ts index d31acfa72..a7276eedc 100644 --- a/packages/binding-coap/src/coap-server.ts +++ b/packages/binding-coap/src/coap-server.ts @@ -67,6 +67,7 @@ export default class CoapServer implements ProtocolServer { private readonly port: number; private readonly address?: string; + private readonly devFriendlyUri: boolean; private mdnsIntroducer?: MdnsIntroducer; @@ -84,6 +85,7 @@ export default class CoapServer implements ProtocolServer { constructor(config?: CoapServerConfig) { this.port = config?.port ?? 5683; this.address = config?.address; + this.devFriendlyUri = config?.devFriendlyUri ?? true; // WoT-specific content formats registerFormat(ContentSerdes.JSON_LD, 2100); @@ -143,34 +145,59 @@ export default class CoapServer implements ProtocolServer { public async expose(thing: ExposedThing, tdTemplate?: WoT.ExposedThingInit): Promise { const port = this.getPort(); - const urlPath = this.createThingUrlPath(thing); + const urlPaths = this.createThingUrlPaths(thing); if (port === -1) { warn("CoapServer is assigned an invalid port, aborting expose process."); return; } - this.fillInBindingData(thing, port, urlPath); + for (const urlPath of urlPaths) { + this.fillInBindingData(thing, port, urlPath); - debug(`CoapServer on port ${port} exposes '${thing.title}' as unique '/${urlPath}'`); + debug(`CoapServer on port ${port} exposes '${thing.title}' as unique '/${urlPath}'`); - this.setUpIntroductionMethods(thing, urlPath, port); + this.setUpIntroductionMethods(thing, urlPath, port); + } } - private createThingUrlPath(thing: ExposedThing) { - let urlPath = slugify(thing.title, { lower: true }); + /** + * Creates the URL paths under which a Thing is exposed. + * If devFriendlyUri is enabled, a slugified title-based path is created. + * If the Thing has a non-empty ID, an additional ID-based path is created. + * + * If devFriendlyUri is disabled and the Thing has no ID, + * a title-based path is still created to ensure the Thing remains accessible. + */ + private createThingUrlPaths(thing: ExposedThing): string[] { + const urlPaths: string[] = []; + + // Title-based path + if (this.devFriendlyUri || !thing.id) { + let urlPath = slugify(thing.title, { lower: true }); + + if (this.things.has(urlPath)) { + let uniqueUrlPath; + let nameClashCnt = 2; + do { + uniqueUrlPath = urlPath + "_" + nameClashCnt++; + } while (this.things.has(uniqueUrlPath)); + urlPath = uniqueUrlPath; + } - // avoid URL clashes - if (this.things.has(urlPath)) { - let uniqueUrlPath; - let nameClashCnt = 2; - do { - uniqueUrlPath = urlPath + "_" + nameClashCnt++; - } while (this.things.has(uniqueUrlPath)); - urlPath = uniqueUrlPath; + urlPaths.push(urlPath); } - return urlPath; + // ID-based path + if (typeof thing.id === "string" && thing.id.length > 0) { + if (this.things.has(thing.id)) { + warn(`CoapServer path collision detected for id '${thing.id}'.`); + } + + urlPaths.push(thing.id); + } + + return urlPaths; } private fillInBindingData(thing: ExposedThing, port: number, urlPath: string) { @@ -354,20 +381,25 @@ export default class CoapServer implements ProtocolServer { public async destroy(thingId: string): Promise { debug(`CoapServer on port ${this.getPort()} destroying thingId '${thingId}'`); - for (const name of this.things.keys()) { - const exposedThing = this.things.get(name); + + let deleted = false; + + for (const [name, exposedThing] of this.things.entries()) { if (exposedThing?.id === thingId) { this.things.delete(name); this.coreResources.delete(name); this.mdnsIntroducer?.delete(name); - - info(`CoapServer successfully destroyed '${exposedThing.title}'`); - return true; + deleted = true; } } - info(`CoapServer failed to destroy thing with thingId '${thingId}'`); - return false; + if (deleted) { + info(`CoapServer successfully destroyed thing with id '${thingId}'`); + } else { + info(`CoapServer failed to destroy thing with thingId '${thingId}'`); + } + + return deleted; } private formatCoreLinkFormatResources() { diff --git a/packages/binding-coap/src/coap.ts b/packages/binding-coap/src/coap.ts index 4273185ae..db3108670 100644 --- a/packages/binding-coap/src/coap.ts +++ b/packages/binding-coap/src/coap.ts @@ -28,8 +28,28 @@ export * from "./coaps-client-factory"; export * from "./coaps-client"; export interface CoapServerConfig { + /** + * Port on which the CoAP server listens. + * Defaults to 5683. + */ port?: number; + + /** + * Network address to bind to. + * If undefined, binds to all interfaces. + */ address?: string; + + /** + * Controls how Thing resource paths are generated. + * + * If `true` (default), a slugified title-based path is created. + * If the Thing has an ID, an additional ID-based path is created. + * + * If `false` and the Thing has an ID, only the ID-based path is created. + * If no ID is provided, a title-based path is still created. + */ + devFriendlyUri?: boolean; } export type CoapMethodName = "GET" | "POST" | "PUT" | "DELETE" | "FETCH" | "PATCH" | "iPATCH"; diff --git a/packages/binding-coap/test/coap-server-test.ts b/packages/binding-coap/test/coap-server-test.ts index 9ab0fc540..98c8f776b 100644 --- a/packages/binding-coap/test/coap-server-test.ts +++ b/packages/binding-coap/test/coap-server-test.ts @@ -73,6 +73,86 @@ class CoapServerTest { await coapServer.stop(); } + @test async "should expose Thing with id and title and be reachable from both"() { + const coapServer = new CoapServer({ port: PORT }); + await coapServer.start(new Servient()); + + const testThing = new ExposedThing(new Servient(), { + title: "TestThing", + id: "urn:dev:wot:test-thing-1234", + properties: { + test: { + type: "string", + }, + }, + }); + + testThing.setPropertyReadHandler("test", async () => "OK"); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.test.forms = []; + + await coapServer.expose(testThing); + + const uriByTitle = `coap://localhost:${coapServer.getPort()}/testthing/properties/test`; + const uriById = `coap://localhost:${coapServer.getPort()}/urn:dev:wot:test-thing-1234/properties/test`; + + const coapClient = new CoapClient(coapServer); + + const resp1 = await coapClient.readResource(new Form(uriByTitle)); + expect((await resp1.toBuffer()).toString()).to.equal('"OK"'); + + const resp2 = await coapClient.readResource(new Form(uriById)); + expect((await resp2.toBuffer()).toString()).to.equal('"OK"'); + + await coapClient.stop(); + await coapServer.stop(); + } + @test async "should expose Thing only by id when devFriendlyUri is false"() { + const coapServer = new CoapServer({ port: PORT, devFriendlyUri: false }); + await coapServer.start(new Servient()); + + const testThing = new ExposedThing(new Servient(), { + title: "TestThing", + id: "urn:dev:wot:test-thing-5678", + properties: { + test: { + type: "string", + }, + }, + }); + + testThing.setPropertyReadHandler("test", async () => "OK"); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testThing.properties.test.forms = []; + + await coapServer.expose(testThing); + + const uriByTitle = `coap://localhost:${coapServer.getPort()}/testthing/properties/test`; + const uriById = `coap://localhost:${coapServer.getPort()}/urn:dev:wot:test-thing-5678/properties/test`; + + const coapClient = new CoapClient(coapServer); + + // ID path works + const respById = await coapClient.readResource(new Form(uriById)); + expect((await respById.toBuffer()).toString()).to.equal('"OK"'); + + // Title path should not exist + await new Promise((resolve) => { + const req = request(uriByTitle); + req.on("response", (res: IncomingMessage) => { + expect(res.code).to.equal("4.04"); + resolve(); + }); + req.end(); + }); + + await coapClient.stop(); + await coapServer.stop(); + } @test async "should write property"() { const coapServer = new CoapServer({ port: PORT });