diff --git a/packages/core/src/content-serdes.ts b/packages/core/src/content-serdes.ts index a9165995f..0fe763755 100644 --- a/packages/core/src/content-serdes.ts +++ b/packages/core/src/content-serdes.ts @@ -63,6 +63,7 @@ export class ContentSerdes { this.instance.addCodec(new JsonCodec(), true); this.instance.addCodec(new JsonCodec("application/senml+json")); this.instance.addCodec(new JsonCodec("application/td+json")); + this.instance.addCodec(new JsonCodec("application/ld+json")); // CBOR this.instance.addCodec(new CborCodec(), true); // Text diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts new file mode 100644 index 000000000..7a14bc048 --- /dev/null +++ b/packages/core/src/validation.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { ErrorObject } from "ajv"; +import Helpers from "./helpers"; + +export function isThingDescription(input: unknown): input is WoT.ThingDescription { + return Helpers.tsSchemaValidator(input); +} + +export function getLastValidationErrors() { + const errors = Helpers.tsSchemaValidator.errors?.map((o: ErrorObject) => o.message).join("\n"); + return new Error(errors); +} diff --git a/packages/core/src/wot-impl.ts b/packages/core/src/wot-impl.ts index 42ca61395..00754fdb8 100644 --- a/packages/core/src/wot-impl.ts +++ b/packages/core/src/wot-impl.ts @@ -21,10 +21,50 @@ import ConsumedThing from "./consumed-thing"; import Helpers from "./helpers"; import { createLoggers } from "./logger"; import ContentManager from "./content-serdes"; -import { ErrorObject } from "ajv"; +import { getLastValidationErrors, isThingDescription } from "./validation"; const { debug } = createLoggers("core", "wot-impl"); +class ThingDiscoveryProcess implements WoT.ThingDiscoveryProcess { + constructor(rawThingDescriptions: WoT.DataSchemaValue, filter?: WoT.ThingFilter) { + this.filter = filter; + this.done = false; + this.rawThingDescriptions = rawThingDescriptions; + } + + rawThingDescriptions: WoT.DataSchemaValue; + + filter?: WoT.ThingFilter | undefined; + done: boolean; + error?: Error | undefined; + async stop(): Promise { + this.done = true; + } + + async *[Symbol.asyncIterator](): AsyncIterator { + if (!(this.rawThingDescriptions instanceof Array)) { + this.error = new Error("Encountered an invalid output value."); + this.done = true; + return; + } + + for (const outputValue of this.rawThingDescriptions) { + if (this.done) { + return; + } + + if (!isThingDescription(outputValue)) { + this.error = getLastValidationErrors(); + continue; + } + + yield outputValue; + } + + this.done = true; + } +} + export default class WoTImpl { private srv: Servient; constructor(srv: Servient) { @@ -38,7 +78,13 @@ export default class WoTImpl { /** @inheritDoc */ async exploreDirectory(url: string, filter?: WoT.ThingFilter): Promise { - throw new Error("not implemented"); + const directoyThingDescription = await this.requestThingDescription(url); + const consumedDirectoy = await this.consume(directoyThingDescription); + + const thingsPropertyOutput = await consumedDirectoy.readProperty("things"); + const rawThingDescriptions = await thingsPropertyOutput.value(); + + return new ThingDiscoveryProcess(rawThingDescriptions, filter); } /** @inheritDoc */ @@ -48,14 +94,11 @@ export default class WoTImpl { const content = await client.requestThingDescription(url); const value = ContentManager.contentToValue({ type: content.type, body: await content.toBuffer() }, {}); - const isValidThingDescription = Helpers.tsSchemaValidator(value); - - if (!isValidThingDescription) { - const errors = Helpers.tsSchemaValidator.errors?.map((o: ErrorObject) => o.message).join("\n"); - throw new Error(errors); + if (isThingDescription(value)) { + return value; } - return value as WoT.ThingDescription; + throw getLastValidationErrors(); } /** @inheritDoc */ diff --git a/packages/core/test/DiscoveryTest.ts b/packages/core/test/DiscoveryTest.ts new file mode 100644 index 000000000..3b41b0f5e --- /dev/null +++ b/packages/core/test/DiscoveryTest.ts @@ -0,0 +1,224 @@ +/******************************************************************************** + * Copyright (c) 2023 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { Form, SecurityScheme } from "@node-wot/td-tools"; +import { Subscription } from "rxjs/Subscription"; +import { Content } from "../src/content"; +import { createLoggers } from "../src/logger"; +import { ProtocolClient, ProtocolClientFactory } from "../src/protocol-interfaces"; +import Servient from "../src/servient"; +import { Readable } from "stream"; +import { expect } from "chai"; + +const { debug, error } = createLoggers("core", "DiscoveryTest"); + +function createDirectoryTestTd(title: string, thingsPropertyHref: string) { + return { + "@context": "https://www.w3.org/2022/wot/td/v1.1", + title, + security: "nosec_sc", + securityDefinitions: { + nosec_sc: { + scheme: "nosec", + }, + }, + properties: { + things: { + forms: [ + { + href: thingsPropertyHref, + }, + ], + }, + }, + }; +} + +function createDiscoveryContent(td: unknown, contentType: string) { + const buffer = Buffer.from(JSON.stringify(td)); + const content = new Content(contentType, Readable.from(buffer)); + return content; +} + +const directoryTdUrl1 = "test://localhost/valid-output-tds"; +const directoryTdUrl2 = "test://localhost/invalid-output-tds"; +const directoryTdUrl3 = "test://localhost/no-array-output"; + +const directoryTdTitle1 = "Directory Test TD 1"; +const directoryTdTitle2 = "Directory Test TD 2"; +const directoryTdTitle3 = "Directory Test TD 3"; + +const directoryThingsUrl1 = "test://localhost/things1"; +const directoryThingsUrl2 = "test://localhost/things2"; +const directoryThingsUrl3 = "test://localhost/things3"; + +const directoryThingDescription1 = createDirectoryTestTd(directoryTdTitle1, directoryThingsUrl1); +const directoryThingDescription2 = createDirectoryTestTd(directoryTdTitle2, directoryThingsUrl2); +const directoryThingDescription3 = createDirectoryTestTd(directoryTdTitle3, directoryThingsUrl3); + +class TestProtocolClient implements ProtocolClient { + async readResource(form: Form): Promise { + const href = form.href; + + switch (href) { + case directoryThingsUrl1: + return createDiscoveryContent([directoryThingDescription1], "application/ld+json"); + case directoryThingsUrl2: + return createDiscoveryContent(["I am an invalid TD!"], "application/ld+json"); + case directoryThingsUrl3: + return createDiscoveryContent("I am no array and therefore invalid!", "application/ld+json"); + } + + throw new Error("Invalid URL"); + } + + writeResource(form: Form, content: Content): Promise { + throw new Error("Method not implemented."); + } + + invokeResource(form: Form, content?: Content | undefined): Promise { + throw new Error("Method not implemented."); + } + + unlinkResource(form: Form): Promise { + throw new Error("Method not implemented."); + } + + subscribeResource( + form: Form, + next: (content: Content) => void, + error?: ((error: Error) => void) | undefined, + complete?: (() => void) | undefined + ): Promise { + throw new Error("Method not implemented."); + } + + async requestThingDescription(uri: string): Promise { + switch (uri) { + case directoryTdUrl1: + debug(`Found corrent URL ${uri} to fetch directory TD`); + return createDiscoveryContent(directoryThingDescription1, "application/td+json"); + case directoryTdUrl2: + debug(`Found corrent URL ${uri} to fetch directory TD`); + return createDiscoveryContent(directoryThingDescription2, "application/td+json"); + case directoryTdUrl3: + debug(`Found corrent URL ${uri} to fetch directory TD`); + return createDiscoveryContent(directoryThingDescription3, "application/td+json"); + } + + throw Error("Invalid URL"); + } + + async start(): Promise { + // Do nothing + } + + async stop(): Promise { + // Do nothing + } + + setSecurity(metadata: SecurityScheme[], credentials?: unknown): boolean { + return true; + } +} + +class TestProtocolClientFactory implements ProtocolClientFactory { + public scheme = "test"; + + getClient(): ProtocolClient { + return new TestProtocolClient(); + } + + init(): boolean { + return true; + } + + destroy(): boolean { + return true; + } +} + +describe("Discovery Tests", () => { + it("should be possible to use the exploreDirectory method", async () => { + const servient = new Servient(); + servient.addClientFactory(new TestProtocolClientFactory()); + + const WoT = await servient.start(); + + const discoveryProcess = await WoT.exploreDirectory(directoryTdUrl1); + + let tdCounter = 0; + for await (const thingDescription of discoveryProcess) { + expect(thingDescription.title).to.eql(directoryTdTitle1); + tdCounter++; + } + expect(tdCounter).to.eql(1); + expect(discoveryProcess.error).to.eq(undefined); + }); + + it("should receive no output and an error by the exploreDirectory method for invalid returned TDs", async () => { + const servient = new Servient(); + servient.addClientFactory(new TestProtocolClientFactory()); + + const WoT = await servient.start(); + + const discoveryProcess = await WoT.exploreDirectory(directoryTdUrl2); + + let tdCounter = 0; + for await (const thingDescription of discoveryProcess) { + error(`Encountered unexpected TD with title ${thingDescription.title}`); + tdCounter++; + } + expect(tdCounter).to.eql(0); + expect(discoveryProcess.error).to.not.eq(undefined); + }); + + it("should receive no output and an error by the exploreDirectory method if no array is returned", async () => { + const servient = new Servient(); + servient.addClientFactory(new TestProtocolClientFactory()); + + const WoT = await servient.start(); + + const discoveryProcess = await WoT.exploreDirectory(directoryTdUrl3); + + let tdCounter = 0; + for await (const thingDescription of discoveryProcess) { + error(`Encountered unexpected TD with title ${thingDescription.title}`); + tdCounter++; + } + expect(tdCounter).to.eql(0); + expect(discoveryProcess.error).to.not.eq(undefined); + }); + + it("should be possible to stop discovery with exploreDirectory prematurely", async () => { + const servient = new Servient(); + servient.addClientFactory(new TestProtocolClientFactory()); + + const WoT = await servient.start(); + + const discoveryProcess = await WoT.exploreDirectory(directoryTdUrl1); + expect(discoveryProcess.done).to.not.eq(true); + discoveryProcess.stop(); + expect(discoveryProcess.done).to.eq(true); + + let tdCounter = 0; + for await (const thingDescription of discoveryProcess) { + error(`Encountered unexpected TD with title ${thingDescription.title}`); + tdCounter++; + } + expect(tdCounter).to.eql(0); + expect(discoveryProcess.error).to.eq(undefined); + }); +});