From 96489f8c4c5616ba86c046ceab4f906e98d23d14 Mon Sep 17 00:00:00 2001 From: Pranav-0440 Date: Tue, 17 Feb 2026 23:56:11 +0530 Subject: [PATCH 1/2] feat(binding-coap): support id-based CoAP paths (fixes #1458) Signed-off-by: Pranav-0440 --- packages/binding-coap/src/coap-server.ts | 63 ++++++++++++------- packages/binding-coap/src/coap.ts | 1 + .../binding-coap/test/coap-server-test.ts | 36 +++++++++++ 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/packages/binding-coap/src/coap-server.ts b/packages/binding-coap/src/coap-server.ts index d31acfa72..298fc90d9 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,44 @@ 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 paths = 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 paths) { + 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 }); + private createThingUrlPaths(thing: ExposedThing): string[] { + const paths: string[] = []; + // Title-based path + if (this.devFriendlyUri || thing.id == null) { + let urlPath = slugify(thing.title, { lower: true }); - // avoid URL clashes - if (this.things.has(urlPath)) { - let uniqueUrlPath; - let nameClashCnt = 2; - do { - uniqueUrlPath = urlPath + "_" + nameClashCnt++; - } while (this.things.has(uniqueUrlPath)); - urlPath = uniqueUrlPath; - } + if (this.things.has(urlPath)) { + let uniqueUrlPath; + let nameClashCnt = 2; + do { + uniqueUrlPath = urlPath + "_" + nameClashCnt++; + } while (this.things.has(uniqueUrlPath)); + urlPath = uniqueUrlPath; + } - return urlPath; + paths.push(urlPath); + } + // ID-based path + if (typeof thing.id === "string" && thing.id.length > 0) { + paths.push(thing.id); + } + return paths; } private fillInBindingData(thing: ExposedThing, port: number, urlPath: string) { @@ -354,20 +366,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..06e7c30b1 100644 --- a/packages/binding-coap/src/coap.ts +++ b/packages/binding-coap/src/coap.ts @@ -30,6 +30,7 @@ export * from "./coaps-client"; export interface CoapServerConfig { port?: number; address?: string; + 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..05b30969e 100644 --- a/packages/binding-coap/test/coap-server-test.ts +++ b/packages/binding-coap/test/coap-server-test.ts @@ -73,6 +73,42 @@ 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 write property"() { const coapServer = new CoapServer({ port: PORT }); From 4f19e39456ffd2da696fca8fdada33800b7c1b54 Mon Sep 17 00:00:00 2001 From: Pranav-0440 Date: Wed, 18 Feb 2026 23:50:42 +0530 Subject: [PATCH 2/2] chore(binding-coap): address review feedback and improve coverage Signed-off-by: Pranav-0440 --- packages/binding-coap/src/coap-server.ts | 29 +++++++++--- packages/binding-coap/src/coap.ts | 19 ++++++++ .../binding-coap/test/coap-server-test.ts | 44 +++++++++++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/packages/binding-coap/src/coap-server.ts b/packages/binding-coap/src/coap-server.ts index 298fc90d9..a7276eedc 100644 --- a/packages/binding-coap/src/coap-server.ts +++ b/packages/binding-coap/src/coap-server.ts @@ -145,14 +145,14 @@ export default class CoapServer implements ProtocolServer { public async expose(thing: ExposedThing, tdTemplate?: WoT.ExposedThingInit): Promise { const port = this.getPort(); - const paths = this.createThingUrlPaths(thing); + const urlPaths = this.createThingUrlPaths(thing); if (port === -1) { warn("CoapServer is assigned an invalid port, aborting expose process."); return; } - for (const urlPath of paths) { + for (const urlPath of urlPaths) { this.fillInBindingData(thing, port, urlPath); debug(`CoapServer on port ${port} exposes '${thing.title}' as unique '/${urlPath}'`); @@ -161,10 +161,19 @@ export default class CoapServer implements ProtocolServer { } } + /** + * 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 paths: string[] = []; + const urlPaths: string[] = []; + // Title-based path - if (this.devFriendlyUri || thing.id == null) { + if (this.devFriendlyUri || !thing.id) { let urlPath = slugify(thing.title, { lower: true }); if (this.things.has(urlPath)) { @@ -176,13 +185,19 @@ export default class CoapServer implements ProtocolServer { urlPath = uniqueUrlPath; } - paths.push(urlPath); + urlPaths.push(urlPath); } + // ID-based path if (typeof thing.id === "string" && thing.id.length > 0) { - paths.push(thing.id); + if (this.things.has(thing.id)) { + warn(`CoapServer path collision detected for id '${thing.id}'.`); + } + + urlPaths.push(thing.id); } - return paths; + + return urlPaths; } private fillInBindingData(thing: ExposedThing, port: number, urlPath: string) { diff --git a/packages/binding-coap/src/coap.ts b/packages/binding-coap/src/coap.ts index 06e7c30b1..db3108670 100644 --- a/packages/binding-coap/src/coap.ts +++ b/packages/binding-coap/src/coap.ts @@ -28,8 +28,27 @@ 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; } diff --git a/packages/binding-coap/test/coap-server-test.ts b/packages/binding-coap/test/coap-server-test.ts index 05b30969e..98c8f776b 100644 --- a/packages/binding-coap/test/coap-server-test.ts +++ b/packages/binding-coap/test/coap-server-test.ts @@ -109,6 +109,50 @@ class CoapServerTest { 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 });