From a9d950af1fa29926631bb7c4db81f59bcf96c74c Mon Sep 17 00:00:00 2001 From: Jan Romann Date: Mon, 3 Jun 2024 21:25:02 +0200 Subject: [PATCH 1/5] feat!: implement proper serialization logic --- lib/src/binding_coap/coap_extensions.dart | 2 +- .../additional_expected_response.dart | 25 +- lib/src/core/definitions/context.dart | 26 +- lib/src/core/definitions/data_schema.dart | 71 ++- .../core/definitions/expected_response.dart | 13 +- .../definitions/extensions/json_parser.dart | 10 + .../extensions/json_serializer.dart | 29 ++ .../definitions/extensions/serializable.dart | 11 + lib/src/core/definitions/form.dart | 48 +- .../interaction_affordances/action.dart | 38 ++ .../interaction_affordance.dart | 47 +- .../interaction_affordances/property.dart | 3 - lib/src/core/definitions/link.dart | 35 +- lib/src/core/definitions/operation_type.dart | 3 + .../security/ace_security_scheme.dart | 23 + .../security/apikey_security_scheme.dart | 14 + .../security/basic_security_scheme.dart | 14 + .../security/bearer_security_scheme.dart | 20 + .../security/combo_security_scheme.dart | 15 + .../security/digest_security_scheme.dart | 15 + .../security/oauth2_security_scheme.dart | 28 ++ .../security/psk_security_scheme.dart | 11 + .../definitions/security/security_scheme.dart | 41 +- .../core/definitions/thing_description.dart | 109 ++++- lib/src/core/definitions/version_info.dart | 18 +- .../core/implementation/augmented_form.dart | 5 +- .../core/implementation/content_serdes.dart | 7 +- test/core/dart_wot_test.dart | 6 +- test/core/definitions/serialization_test.dart | 413 ++++++++++++++++++ test/core/definitions_test.dart | 5 +- 30 files changed, 1058 insertions(+), 47 deletions(-) create mode 100644 lib/src/core/definitions/extensions/json_serializer.dart create mode 100644 lib/src/core/definitions/extensions/serializable.dart create mode 100644 test/core/definitions/serialization_test.dart diff --git a/lib/src/binding_coap/coap_extensions.dart b/lib/src/binding_coap/coap_extensions.dart index 2789d445..00492b21 100644 --- a/lib/src/binding_coap/coap_extensions.dart +++ b/lib/src/binding_coap/coap_extensions.dart @@ -116,7 +116,7 @@ extension CoapFormExtension on AugmentedForm { extension CoapExpectedResponseExtension on ExpectedResponse { T? _obtainVocabularyTerm(String vocabularyTerm) { final curieString = coapPrefixMapping.expandCurieString(vocabularyTerm); - final formDefinition = additionalFields?[curieString]; + final formDefinition = additionalFields[curieString]; if (formDefinition is T) { return formDefinition; diff --git a/lib/src/core/definitions/additional_expected_response.dart b/lib/src/core/definitions/additional_expected_response.dart index c8f2c50c..ea2ba85b 100644 --- a/lib/src/core/definitions/additional_expected_response.dart +++ b/lib/src/core/definitions/additional_expected_response.dart @@ -9,17 +9,18 @@ import "package:curie/curie.dart"; import "package:meta/meta.dart"; import "extensions/json_parser.dart"; +import "extensions/serializable.dart"; /// Communication metadata describing the expected response message for the /// primary response. @immutable -class AdditionalExpectedResponse { +class AdditionalExpectedResponse implements Serializable { /// Constructs a new [AdditionalExpectedResponse] object from a [contentType]. const AdditionalExpectedResponse( this.contentType, { this.schema, this.success = false, - this.additionalFields, + this.additionalFields = const {}, }); /// Creates an [AdditionalExpectedResponse] from a [json] object. @@ -61,7 +62,7 @@ class AdditionalExpectedResponse { final String? schema; /// Any other additional field will be included in this [Map]. - final Map? additionalFields; + final Map additionalFields; @override bool operator ==(Object other) { @@ -79,4 +80,22 @@ class AdditionalExpectedResponse { @override int get hashCode => Object.hash(success, schema, contentType, additionalFields); + + @override + Map toJson() { + final result = { + "contentType": contentType, + ...additionalFields, + }; + + if (success) { + result["success"] = success; + } + + if (schema != null) { + result["schema"] = schema; + } + + return result; + } } diff --git a/lib/src/core/definitions/context.dart b/lib/src/core/definitions/context.dart index 3638112b..d028992e 100644 --- a/lib/src/core/definitions/context.dart +++ b/lib/src/core/definitions/context.dart @@ -8,12 +8,14 @@ import "package:collection/collection.dart"; import "package:curie/curie.dart"; import "package:meta/meta.dart"; +import "extensions/serializable.dart"; + const _tdVersion10ContextUrl = "https://www.w3.org/2019/wot/td/v1"; const _tdVersion11ContextUrl = "https://www.w3.org/2022/wot/td/v1.1"; /// Represents the JSON-LD `@context` of a Thing Description or Thing Model. @immutable -final class Context { +final class Context implements Serializable { /// Creates a new context from a list of [contextEntries]. Context(this.contextEntries) : prefixMapping = _createPrefixMapping(contextEntries); @@ -114,6 +116,28 @@ final class Context { @override int get hashCode => Object.hashAll(contextEntries); + + @override + List toJson() { + final result = []; + final mapResult = {}; + + for (final contextEntry in contextEntries) { + switch (contextEntry) { + case SingleContextEntry(:final uri): + result.add(uri.toString()); + case MapContextEntry(:final key, :final value): + //TODO: Could there be duplicate keys? + mapResult[key] = value; + } + } + + if (mapResult.isNotEmpty) { + result.add(mapResult); + } + + return result; + } } /// Base class for `@context` entries. diff --git a/lib/src/core/definitions/data_schema.dart b/lib/src/core/definitions/data_schema.dart index af27337f..28186b6a 100644 --- a/lib/src/core/definitions/data_schema.dart +++ b/lib/src/core/definitions/data_schema.dart @@ -8,6 +8,8 @@ import "package:curie/curie.dart"; import "package:meta/meta.dart"; import "extensions/json_parser.dart"; +import "extensions/json_serializer.dart"; +import "extensions/serializable.dart"; /// Metadata that describes the data format used. It can be used for validation. /// @@ -15,7 +17,7 @@ import "extensions/json_parser.dart"; /// /// [spec link]: https://w3c.github.io/wot-thing-description/#dataschema @immutable -class DataSchema { +class DataSchema implements Serializable { /// Constructor const DataSchema({ this.atType, @@ -47,8 +49,7 @@ class DataSchema { this.pattern, this.contentEncoding, this.contentMediaType, - this.rawJson, - this.additionalFields, + this.additionalFields = const {}, }); // TODO: Consider creating separate classes for each data type. @@ -136,7 +137,6 @@ class DataSchema { contentMediaType: contentMediaType, oneOf: oneOf, properties: properties, - rawJson: json, additionalFields: additionalFields, ); } @@ -274,8 +274,63 @@ class DataSchema { final String? contentMediaType; /// Additional fields that could not be deserialized as class members. - final Map? additionalFields; - - /// The original JSON object that was parsed when creating this [DataSchema]. - final Map? rawJson; + final Map additionalFields; + + @override + Map toJson() { + final result = { + ...additionalFields, + }; + + final keyValuePairs = [ + ("@type", atType), + ("title", title), + ("titles", titles), + ("description", description), + ("descriptions", descriptions), + ("const", constant), + ("default", defaultValue), + ("enum", enumeration), + ("readOnly", readOnly), + ("writeOnly", writeOnly), + ("format", format), + ("unit", unit), + ("type", type), + ("minimum", minimum), + ("exclusiveMinimum", exclusiveMinimum), + ("maximum", maximum), + ("exclusiveMaximum", exclusiveMaximum), + ("multipleOf", multipleOf), + ("items", items), + ("minItems", minItems), + ("maxItems", maxItems), + ("required", required), + ("minLength", minLength), + ("maxLength", maxLength), + ("pattern", pattern), + ("contentEncoding", contentEncoding), + ("contentMediaType", contentMediaType), + ("oneOf", oneOf), + ("properties", properties), + ]; + + for (final (key, value) in keyValuePairs) { + final dynamic convertedValue; + + switch (value) { + case null: + continue; + case List(): + convertedValue = value.toJson(); + case Map(): + convertedValue = value.toJson(); + default: + convertedValue = value; + } + + result[key] = convertedValue; + } + + return result; + } } diff --git a/lib/src/core/definitions/expected_response.dart b/lib/src/core/definitions/expected_response.dart index 29f8457d..dd6a5c28 100644 --- a/lib/src/core/definitions/expected_response.dart +++ b/lib/src/core/definitions/expected_response.dart @@ -8,15 +8,16 @@ import "package:curie/curie.dart"; import "package:meta/meta.dart"; import "extensions/json_parser.dart"; +import "extensions/serializable.dart"; /// Communication metadata describing the expected response message for the /// primary response. @immutable -class ExpectedResponse { +class ExpectedResponse implements Serializable { /// Constructs a new [ExpectedResponse] object from a [contentType]. const ExpectedResponse( this.contentType, { - this.additionalFields, + this.additionalFields = const {}, }); /// Creates an [ExpectedResponse] from a [json] object. @@ -38,5 +39,11 @@ class ExpectedResponse { final String contentType; /// Any other additional field will be included in this [Map]. - final Map? additionalFields; + final Map additionalFields; + + @override + Map toJson() => { + "contentType": contentType, + ...additionalFields, + }; } diff --git a/lib/src/core/definitions/extensions/json_parser.dart b/lib/src/core/definitions/extensions/json_parser.dart index b1f14b08..1adb3f61 100644 --- a/lib/src/core/definitions/extensions/json_parser.dart +++ b/lib/src/core/definitions/extensions/json_parser.dart @@ -55,6 +55,12 @@ extension ParseField on Map { return fieldValue; } + if ((T == Map) && + fieldValue is Map && + fieldValue.isEmpty) { + return {} as T; + } + throw FormatException("Expected $T, got ${fieldValue.runtimeType}"); } @@ -151,6 +157,10 @@ extension ParseField on Map { return null; } + if (fieldValue is Map && fieldValue.isEmpty) { + return {}; + } + if (fieldValue is Map) { final Map result = {}; diff --git a/lib/src/core/definitions/extensions/json_serializer.dart b/lib/src/core/definitions/extensions/json_serializer.dart new file mode 100644 index 00000000..bb2a837d --- /dev/null +++ b/lib/src/core/definitions/extensions/json_serializer.dart @@ -0,0 +1,29 @@ +// Copyright 2024 Contributors to the Eclipse Foundation. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// SPDX-License-Identifier: BSD-3-Clause + +import "../form.dart"; +import "../link.dart"; +import "serializable.dart"; + +/// Extension that provides JSON serialization for [List]s of [Link]s. +extension SerializableList on List { + /// Converts this [List] of [Serializable] elements to JSON. + List toJson() => + map((listItem) => listItem.toJson()).toList(growable: false); +} + +/// Extension that provides JSON serialization for [List]s of [Form]s. +extension SerializableMap on Map { + /// Converts this [Map] of [Serializable] key-value pairs to JSON. + Map toJson() => + map((key, value) => MapEntry(key, value.toJson())); +} + +/// Extension that provides JSON serialization for [List]s of [Uri]s. +extension UriListToJsonExtension on List { + /// Converts this [List] of [Uri]s to JSON. + List toJson() => map((uri) => uri.toString()).toList(growable: false); +} diff --git a/lib/src/core/definitions/extensions/serializable.dart b/lib/src/core/definitions/extensions/serializable.dart new file mode 100644 index 00000000..6affea79 --- /dev/null +++ b/lib/src/core/definitions/extensions/serializable.dart @@ -0,0 +1,11 @@ +// Copyright 2024 Contributors to the Eclipse Foundation. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// SPDX-License-Identifier: BSD-3-Clause + +/// Interface for converting a class object [toJson]. +abstract interface class Serializable { + /// Converts this class object into a JSON value. + dynamic toJson(); +} diff --git a/lib/src/core/definitions/form.dart b/lib/src/core/definitions/form.dart index 8df6818a..bd514d4c 100644 --- a/lib/src/core/definitions/form.dart +++ b/lib/src/core/definitions/form.dart @@ -10,11 +10,12 @@ import "package:meta/meta.dart"; import "additional_expected_response.dart"; import "expected_response.dart"; import "extensions/json_parser.dart"; +import "extensions/serializable.dart"; import "operation_type.dart"; /// Contains the information needed for performing interactions with a Thing. @immutable -class Form { +class Form implements Serializable { /// Creates a new [Form] object. /// /// An [href] has to be provided. A [contentType] is optional. @@ -128,4 +129,49 @@ class Form { /// Additional fields collected during the parsing of a JSON object. final Map additionalFields = {}; + + @override + Map toJson() { + final result = { + "href": href.toString(), + "contentType": contentType, + ...additionalFields, + }; + + if (subprotocol != null) { + result["subprotocol"] = subprotocol; + } + + final op = this.op; + if (op != null) { + result["op"] = + op.map((opValue) => opValue.toString()).toList(growable: false); + } + + if (contentCoding != null) { + result["contentCoding"] = contentCoding; + } + + if (security != null) { + result["security"] = security; + } + + if (scopes != null) { + result["scopes"] = scopes; + } + + final response = this.response; + if (response != null) { + result["response"] = response.toJson(); + } + + final additionalResponses = this.additionalResponses; + if (additionalResponses != null) { + result["additionalResponses"] = additionalResponses + .map((additionalResponse) => additionalResponse.toJson()) + .toList(growable: false); + } + + return result; + } } diff --git a/lib/src/core/definitions/interaction_affordances/action.dart b/lib/src/core/definitions/interaction_affordances/action.dart index 31d87162..b24148cc 100644 --- a/lib/src/core/definitions/interaction_affordances/action.dart +++ b/lib/src/core/definitions/interaction_affordances/action.dart @@ -100,4 +100,42 @@ final class Action extends InteractionAffordance { /// the status of the action is needed. Lack of this keyword means that no /// claim on the synchronicity of the action can be made. final bool? synchronous; + + /// Converts this [InteractionAffordance] to a [Map] resembling a JSON + /// object. + @override + Map toJson() { + final result = { + ...super.toJson(), + }; + + for (final (key, value) in [("idempotent", idempotent), ("safe", safe)]) { + if (value) { + result[key] = value; + } + } + + final keyValuePairs = [ + ("input", input), + ("output", output), + ("synchronous", synchronous), + ]; + + for (final (key, value) in keyValuePairs) { + final dynamic convertedValue; + + switch (value) { + case null: + continue; + case DataSchema(): + convertedValue = value.toJson(); + default: + convertedValue = value; + } + + result[key] = convertedValue; + } + + return result; + } } diff --git a/lib/src/core/definitions/interaction_affordances/interaction_affordance.dart b/lib/src/core/definitions/interaction_affordances/interaction_affordance.dart index 2556cfdc..f62430d0 100644 --- a/lib/src/core/definitions/interaction_affordances/interaction_affordance.dart +++ b/lib/src/core/definitions/interaction_affordances/interaction_affordance.dart @@ -13,6 +13,8 @@ import "package:meta/meta.dart"; import "../data_schema.dart"; import "../extensions/json_parser.dart"; +import "../extensions/json_serializer.dart"; +import "../extensions/serializable.dart"; import "../form.dart"; part "action.dart"; @@ -21,18 +23,22 @@ part "property.dart"; /// Base class for Interaction Affordances (Properties, Actions, and Events). @immutable -sealed class InteractionAffordance { +sealed class InteractionAffordance implements Serializable { /// Creates a new [InteractionAffordance]. Accepts a [List] of [forms]. const InteractionAffordance({ + this.atType, this.title, this.titles, this.description, this.descriptions, this.uriVariables, required this.forms, - this.additionalFields, + this.additionalFields = const {}, }); + /// /// JSON-LD keyword to label the object with semantic tags (or types). + final List? atType; + /// The default [title] of this [InteractionAffordance]. final String? title; @@ -54,5 +60,40 @@ sealed class InteractionAffordance { final Map? uriVariables; /// Additional fields that could not be deserialized as class members. - final Map? additionalFields; + final Map additionalFields; + + @mustCallSuper + @override + Map toJson() { + final result = { + "forms": forms.toJson(), + ...additionalFields, + }; + + final keyValuePairs = [ + ("@type", atType), + ("title", title), + ("titles", titles), + ("description", description), + ("descriptions", descriptions), + ("uriVariables", uriVariables), + ]; + + for (final (key, value) in keyValuePairs) { + final dynamic convertedValue; + + switch (value) { + case null: + continue; + case Map(): + convertedValue = value.toJson(); + default: + convertedValue = value; + } + + result[key] = convertedValue; + } + + return result; + } } diff --git a/lib/src/core/definitions/interaction_affordances/property.dart b/lib/src/core/definitions/interaction_affordances/property.dart index 3b197f98..c6e3ff3c 100644 --- a/lib/src/core/definitions/interaction_affordances/property.dart +++ b/lib/src/core/definitions/interaction_affordances/property.dart @@ -146,9 +146,6 @@ class Property extends InteractionAffordance implements DataSchema { /// `observeproperty` and `unobserveproperty` operations for this Property. final bool observable; - @override - Map? get rawJson => dataSchema?.rawJson; - @override Map get additionalFields => dataSchema?.additionalFields ?? {}; diff --git a/lib/src/core/definitions/link.dart b/lib/src/core/definitions/link.dart index 3ea92c63..8f7e2b1e 100644 --- a/lib/src/core/definitions/link.dart +++ b/lib/src/core/definitions/link.dart @@ -8,6 +8,7 @@ import "package:curie/curie.dart"; import "package:meta/meta.dart"; import "extensions/json_parser.dart"; +import "extensions/serializable.dart"; /// Represents an element of the `links` array in a Thing Description. /// @@ -15,7 +16,7 @@ import "extensions/json_parser.dart"; /// type resource at link target", where the optional target attributes may /// further describe the resource. @immutable -class Link { +class Link implements Serializable { /// Constructor. const Link( this.href, { @@ -24,7 +25,7 @@ class Link { this.anchor, this.sizes, this.hreflang, - this.additionalFields, + this.additionalFields = const {}, }); /// Creates a new [Link] from a [json] object. @@ -83,5 +84,33 @@ class Link { final List? hreflang; /// Additional fields collected during the parsing of a JSON object. - final Map? additionalFields; + final Map additionalFields; + + @override + Map toJson() { + final result = { + "href": href.toString(), + ...additionalFields, + }; + + final anchor = this.anchor; + if (anchor != null) { + result["anchor"] = anchor.toString(); + } + + final keyValuePairs = [ + ("type", type), + ("rel", rel), + ("sizes", sizes), + ("hreflang", hreflang), + ]; + + for (final (key, value) in keyValuePairs) { + if (value != null) { + result[key] = value; + } + } + + return result; + } } diff --git a/lib/src/core/definitions/operation_type.dart b/lib/src/core/definitions/operation_type.dart index f23c0fc6..16501e39 100644 --- a/lib/src/core/definitions/operation_type.dart +++ b/lib/src/core/definitions/operation_type.dart @@ -49,6 +49,9 @@ enum OperationType { static final Map _registry = Map.fromEntries(OperationType.values.map((e) => MapEntry(e.name, e))); + @override + String toString() => name; + /// Creates an [OperationType] from a [stringValue]. static OperationType fromString(String stringValue) { final operationType = OperationType._registry[stringValue]; diff --git a/lib/src/core/definitions/security/ace_security_scheme.dart b/lib/src/core/definitions/security/ace_security_scheme.dart index a02aa249..1d059383 100644 --- a/lib/src/core/definitions/security/ace_security_scheme.dart +++ b/lib/src/core/definitions/security/ace_security_scheme.dart @@ -81,4 +81,27 @@ final class AceSecurityScheme extends SecurityScheme { /// Indicates whether a [cnonce] is required by the Resource Server. final bool? cnonce; + + @override + Map toJson() { + final result = super.toJson(); + + if (as != null) { + result["ace:as"] = as.toString(); + } + + if (audience != null) { + result["ace:audience"] = audience; + } + + if (scopes != null) { + result["ace:scopes"] = scopes; + } + + if (cnonce != null) { + result["ace:cnonce"] = cnonce; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/apikey_security_scheme.dart b/lib/src/core/definitions/security/apikey_security_scheme.dart index 73abac94..dfc90833 100644 --- a/lib/src/core/definitions/security/apikey_security_scheme.dart +++ b/lib/src/core/definitions/security/apikey_security_scheme.dart @@ -65,4 +65,18 @@ final class ApiKeySecurityScheme extends SecurityScheme { @override String get scheme => apiKeySecuritySchemeName; + + @override + Map toJson() { + final result = { + "in": in_, + ...super.toJson(), + }; + + if (name != null) { + result["name"] = name; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/basic_security_scheme.dart b/lib/src/core/definitions/security/basic_security_scheme.dart index 8ac1a641..fdd0e033 100644 --- a/lib/src/core/definitions/security/basic_security_scheme.dart +++ b/lib/src/core/definitions/security/basic_security_scheme.dart @@ -65,4 +65,18 @@ final class BasicSecurityScheme extends SecurityScheme { @override String get scheme => basicSecuritySchemeName; + + @override + Map toJson() { + final result = { + "in": in_, + ...super.toJson(), + }; + + if (name != null) { + result["name"] = name; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/bearer_security_scheme.dart b/lib/src/core/definitions/security/bearer_security_scheme.dart index 3412fe58..008f1e41 100644 --- a/lib/src/core/definitions/security/bearer_security_scheme.dart +++ b/lib/src/core/definitions/security/bearer_security_scheme.dart @@ -88,4 +88,24 @@ final class BearerSecurityScheme extends SecurityScheme { @override String get scheme => bearerSecuritySchemeName; + + @override + Map toJson() { + final result = { + "in": in_, + "alg": alg, + "format": format, + ...super.toJson(), + }; + + if (name != null) { + result["name"] = name; + } + + if (authorization != null) { + result["authorization"] = authorization; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/combo_security_scheme.dart b/lib/src/core/definitions/security/combo_security_scheme.dart index 8fac24c3..4e684f8b 100644 --- a/lib/src/core/definitions/security/combo_security_scheme.dart +++ b/lib/src/core/definitions/security/combo_security_scheme.dart @@ -86,4 +86,19 @@ final class ComboSecurityScheme extends SecurityScheme { @override String get scheme => comboSecuritySchemeName; + + @override + Map toJson() { + final result = super.toJson(); + + if (oneOf != null) { + result["oneOf"] = oneOf; + } + + if (allOf != null) { + result["allOf"] = allOf; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/digest_security_scheme.dart b/lib/src/core/definitions/security/digest_security_scheme.dart index 78fee8a8..8ca10869 100644 --- a/lib/src/core/definitions/security/digest_security_scheme.dart +++ b/lib/src/core/definitions/security/digest_security_scheme.dart @@ -74,4 +74,19 @@ final class DigestSecurityScheme extends SecurityScheme { @override String get scheme => digestSecuritySchemeName; + + @override + Map toJson() { + final result = { + "in": in_, + "qop": qop, + ...super.toJson(), + }; + + if (name != null) { + result["name"] = name; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/oauth2_security_scheme.dart b/lib/src/core/definitions/security/oauth2_security_scheme.dart index 657d55da..99696127 100644 --- a/lib/src/core/definitions/security/oauth2_security_scheme.dart +++ b/lib/src/core/definitions/security/oauth2_security_scheme.dart @@ -53,6 +53,8 @@ final class OAuth2SecurityScheme extends SecurityScheme { final additionalFields = json.parseAdditionalFields(prefixMapping, parsedFields); + // TODO: Add validation for the different flow-specific assertions + // https://www.w3.org/TR/wot-thing-description11/#oauth2securityscheme return OAuth2SecurityScheme( flow, description: description, @@ -92,4 +94,30 @@ final class OAuth2SecurityScheme extends SecurityScheme { @override String get scheme => oAuth2SecuritySchemeName; + + @override + Map toJson() { + final result = { + "flow": flow, + ...super.toJson(), + }; + + if (authorization != null) { + result["authorization"] = authorization; + } + + if (token != null) { + result["token"] = token; + } + + if (refresh != null) { + result["refresh"] = refresh; + } + + if (scopes != null) { + result["scopes"] = scopes; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/psk_security_scheme.dart b/lib/src/core/definitions/security/psk_security_scheme.dart index 3f36b330..af1bcd00 100644 --- a/lib/src/core/definitions/security/psk_security_scheme.dart +++ b/lib/src/core/definitions/security/psk_security_scheme.dart @@ -57,4 +57,15 @@ final class PskSecurityScheme extends SecurityScheme { @override String get scheme => pskSecuritySchemeName; + + @override + Map toJson() { + final result = super.toJson(); + + if (identity != null) { + result["identity"] = identity; + } + + return result; + } } diff --git a/lib/src/core/definitions/security/security_scheme.dart b/lib/src/core/definitions/security/security_scheme.dart index a2b2c6af..f835afcf 100644 --- a/lib/src/core/definitions/security/security_scheme.dart +++ b/lib/src/core/definitions/security/security_scheme.dart @@ -6,17 +6,19 @@ import "package:meta/meta.dart"; +import "../extensions/serializable.dart"; + /// Class that contains metadata describing the configuration of a security /// mechanism. @immutable -abstract base class SecurityScheme { +abstract base class SecurityScheme implements Serializable { /// Constructor. const SecurityScheme({ this.jsonLdType, this.description, this.proxy, this.descriptions, - this.additionalFields, + this.additionalFields = const {}, }); /// The actual security [scheme] identifier. @@ -41,5 +43,38 @@ abstract base class SecurityScheme { final List? jsonLdType; /// Additional fields collected during the parsing of a JSON object. - final Map? additionalFields; + final Map additionalFields; + + @mustCallSuper + @override + Map toJson() { + final result = { + "scheme": scheme, + ...additionalFields, + }; + + final keyValuePairs = [ + ("description", description), + ("descriptions", descriptions), + ("@type", jsonLdType), + ("proxy", proxy), + ]; + + for (final (key, value) in keyValuePairs) { + final dynamic convertedValue; + + switch (value) { + case null: + continue; + case Uri(): + convertedValue = value.toString(); + default: + convertedValue = value; + } + + result[key] = convertedValue; + } + + return result; + } } diff --git a/lib/src/core/definitions/thing_description.dart b/lib/src/core/definitions/thing_description.dart index a96c1d9f..b88553ea 100644 --- a/lib/src/core/definitions/thing_description.dart +++ b/lib/src/core/definitions/thing_description.dart @@ -11,6 +11,7 @@ import "additional_expected_response.dart"; import "context.dart"; import "data_schema.dart"; import "extensions/json_parser.dart"; +import "extensions/json_serializer.dart"; import "form.dart"; import "interaction_affordances/interaction_affordance.dart"; import "link.dart"; @@ -27,7 +28,6 @@ class ThingDescription { required this.title, required this.security, required this.securityDefinitions, - required Map rawThingDescription, this.titles, this.atType, this.id, @@ -47,7 +47,7 @@ class ThingDescription { this.description, this.version, this.uriVariables, - }) : _rawThingDescription = rawThingDescription; + }); /// Creates a [ThingDescription] from a [json] object. /// @@ -138,7 +138,6 @@ class ThingDescription { security: security, securityDefinitions: securityDefinitions, atType: atType, - rawThingDescription: json, ); } @@ -149,10 +148,106 @@ class ThingDescription { } /// Converts this [ThingDescription] to a [Map] resembling a JSON object. - // TODO: Revisit this for dynamic serialization - Map toJson() => _rawThingDescription; - - final Map _rawThingDescription; + Map toJson() { + final result = { + "@context": context.toJson(), + "title": title, + "securityDefinitions": securityDefinitions.map( + (key, securityDefinition) => MapEntry( + key, + securityDefinition.toJson(), + ), + ), + "security": security, + }; + + if (titles != null) { + result["titles"] = titles; + } + + if (description != null) { + result["description"] = description; + } + + if (descriptions != null) { + result["descriptions"] = descriptions; + } + + final version = this.version; + if (version != null) { + result["version"] = version.toJson(); + } + + final created = this.created; + if (created != null) { + result["created"] = created.toIso8601String(); + } + + final modified = this.modified; + if (modified != null) { + result["modified"] = modified.toIso8601String(); + } + + if (support != null) { + result["support"] = support.toString(); + } + + if (base != null) { + result["base"] = base.toString(); + } + + if (id != null) { + result["id"] = id; + } + + final forms = this.forms; + if (forms != null) { + result["forms"] = forms.map((form) => form.toJson()); + } + + final properties = this.properties; + if (properties != null) { + result["properties"] = + (properties as Map).toJson(); + } + + final actions = this.actions; + if (actions != null) { + result["actions"] = actions.toJson(); + } + + final events = this.events; + if (events != null) { + result["events"] = events.toJson(); + } + + final atType = this.atType; + if (atType != null) { + result["@type"] = atType; + } + + final schemaDefinitions = this.schemaDefinitions; + if (schemaDefinitions != null) { + result["schemaDefinitions"] = schemaDefinitions.toJson(); + } + + final links = this.links; + if (links != null) { + result["links"] = links.toJson(); + } + + final uriVariables = this.uriVariables; + if (uriVariables != null) { + result["uriVariables"] = uriVariables.toJson(); + } + + final profile = this.profile; + if (profile != null) { + result["profile"] = profile.toJson(); + } + + return result; + } /// Contains the values of the @context for CURIE expansion. PrefixMapping get prefixMapping => context.prefixMapping; diff --git a/lib/src/core/definitions/version_info.dart b/lib/src/core/definitions/version_info.dart index c2a485e7..ce665d9f 100644 --- a/lib/src/core/definitions/version_info.dart +++ b/lib/src/core/definitions/version_info.dart @@ -18,7 +18,7 @@ class VersionInfo { VersionInfo( this.instance, { this.model, - this.additionalFields, + this.additionalFields = const {}, }); /// Creates a new [VersionInfo] instance from a [json] object. @@ -47,5 +47,19 @@ class VersionInfo { final String? model; /// Additional fields collected during the parsing of a JSON object. - final Map? additionalFields; + final Map additionalFields; + + /// Converts this [VersionInfo] to a [Map] resembling a JSON object. + Map toJson() { + final result = { + "instance": instance, + ...additionalFields, + }; + + if (model != null) { + result["model"] = model; + } + + return result; + } } diff --git a/lib/src/core/implementation/augmented_form.dart b/lib/src/core/implementation/augmented_form.dart index 58340ec5..eec2f8ad 100644 --- a/lib/src/core/implementation/augmented_form.dart +++ b/lib/src/core/implementation/augmented_form.dart @@ -157,7 +157,7 @@ final class AugmentedForm implements Form { } final schemaValue = affordanceUriVariable.value; - final schema = JsonSchema.create(schemaValue.rawJson ?? {}); + final schema = JsonSchema.create(schemaValue.toJson()); final result = schema.validate(userProvidedValue); if (!result.isValid) { @@ -165,4 +165,7 @@ final class AugmentedForm implements Form { } } } + + @override + Map toJson() => _form.toJson(); } diff --git a/lib/src/core/implementation/content_serdes.dart b/lib/src/core/implementation/content_serdes.dart index e534344f..424a05e3 100644 --- a/lib/src/core/implementation/content_serdes.dart +++ b/lib/src/core/implementation/content_serdes.dart @@ -137,9 +137,12 @@ class ContentSerdes { // needs to be reworked. const filteredKeys = ["uriVariables"]; - final filteredDataSchemaJson = dataSchema.rawJson?.entries + final filteredDataSchemaJson = dataSchema + .toJson() + .entries .where((element) => !filteredKeys.contains(element.key)); - if (filteredDataSchemaJson == null || filteredDataSchemaJson.isEmpty) { + + if (filteredDataSchemaJson.isEmpty) { return; } diff --git a/test/core/dart_wot_test.dart b/test/core/dart_wot_test.dart index dfaedd0d..ed167618 100644 --- a/test/core/dart_wot_test.dart +++ b/test/core/dart_wot_test.dart @@ -86,7 +86,7 @@ void main() { expect(parsedLink?.type, "test"); expect(parsedLink?.sizes, "42x42"); expect(parsedLink?.hreflang, ["de"]); - expect(parsedLink?.additionalFields?["test"], "test"); + expect(parsedLink?.additionalFields["test"], "test"); final secondParsedLink = parsedTd.links?[1]; expect(secondParsedLink?.hreflang, ["de", "en"]); @@ -108,12 +108,12 @@ void main() { expect(link.hreflang, ["de"]); expect(link.type, "test"); expect(link.sizes, "42"); - expect(link.additionalFields?["test"], "test"); + expect(link.additionalFields["test"], "test"); final link2 = Link(Uri.parse("https://example.org")); expect(link2.href, Uri.parse("https://example.org")); expect(link2.anchor, null); - expect(link2.additionalFields, isNull); + expect(link2.additionalFields, const {}); }); }); } diff --git a/test/core/definitions/serialization_test.dart b/test/core/definitions/serialization_test.dart new file mode 100644 index 00000000..0a7e7f4e --- /dev/null +++ b/test/core/definitions/serialization_test.dart @@ -0,0 +1,413 @@ +// Copyright 2024 Contributors to the Eclipse Foundation. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// SPDX-License-Identifier: BSD-3-Clause +import "package:curie/curie.dart"; +import "package:dart_wot/core.dart"; +import "package:dart_wot/src/core/definitions/version_info.dart"; +import "package:test/test.dart"; + +void main() { + group("Should serialize and deserialize", () { + test("ThingDescriptions", () async { + final thingDescriptionJson = { + "@context": ["https://www.w3.org/2022/wot/td/v1.1"], + "@type": ["foobar"], + "title": "Test Thing", + "titles": { + "en": "Test Thing", + }, + "description": "Test Thing", + "descriptions": { + "en": "Test Thing", + }, + "version": { + "instance": "1.0.0", + }, + // TODO: Should fields like these be able to be "roundtripped"? + // I.e., getting the same result back that was put in? + "created": "2024-05-25T00:00:00.000", + "modified": "2024-05-25T00:00:00.000", + "support": "https://example.org", + "base": "https://example.org", + "id": "urn:uuid:5edfed77-fc4e-46d4-a550-ef7f07592fbd", + "forms": [ + { + "href": "https://example.org", + "op": [ + "readmultipleproperties", + ], + // TODO: Should defaults actually be set? + "contentType": "application/json", + }, + ], + "properties": {}, + "actions": {}, + "events": {}, + "links": [], + "schemaDefinitions": {}, + "uriVariables": {}, + "profile": ["https://example.org"], + "security": ["nosec_sc"], + "securityDefinitions": { + "nosec_sc": { + "scheme": "nosec", + }, + }, + }; + + final thingDescription = thingDescriptionJson.toThingDescription(); + + expect( + thingDescriptionJson, + thingDescription.toJson(), + ); + }); + + test("VersionInfo", () async { + final versionInfoJson = { + "instance": "1.0.0", + "model": "1.0.0", + }; + + final versionInfo = + VersionInfo.fromJson(versionInfoJson, PrefixMapping()); + + expect( + versionInfoJson, + versionInfo.toJson(), + ); + }); + + test("Links", () async { + final linkJson = { + "href": "https://example.org", + "anchor": "https://example.org", + "type": "", + "rel": "me", + "sizes": "42x42", + "hreflang": ["en"], + }; + + final link = Link.fromJson(linkJson, PrefixMapping()); + + expect( + linkJson, + link.toJson(), + ); + }); + + test("Forms", () async { + final formJson = { + "href": "https://example.org", + "subprotocol": "foobar", + "contentCoding": "test", + "contentType": "application/json", + "security": ["test"], + "response": { + "contentType": "application/json", + }, + "additionalResponses": [], + "scopes": ["foo", "bar"], + }; + + final form = Form.fromJson(formJson, PrefixMapping()); + + expect( + formJson, + form.toJson(), + ); + }); + + test("AugmentedForms", () async { + final formJson = { + "href": "https://example.org", + "contentType": "application/json", + }; + + final thingDescription = { + "@context": ["https://www.w3.org/2022/wot/td/v1.1"], + "title": "Test Thing", + "properties": { + "test": { + "forms": [ + formJson, + ], + }, + }, + "security": ["nosec_sc"], + "securityDefinitions": { + "nosec_sc": { + "scheme": "nosec", + }, + }, + }.toThingDescription(); + + final property = thingDescription.properties!["test"]; + final form = property!.forms[0]; + + final augmentedForm = + AugmentedForm(form, property, thingDescription, null); + + expect( + formJson, + augmentedForm.toJson(), + ); + }); + + test("AdditionalExpectedResponses", () async { + final additionalExpectedResponseJson = { + "success": true, + "contentType": "application/cbor", + "schema": "foobar", + }; + + final additionalExpectedResponse = AdditionalExpectedResponse.fromJson( + additionalExpectedResponseJson, + // TODO: Document this parameter + "application/json", + PrefixMapping(), + ); + + expect( + additionalExpectedResponseJson, + additionalExpectedResponse.toJson(), + ); + }); + + test("Actions", () async { + final actionJson = { + "input": {}, + "output": {}, + "idempotent": true, + "safe": true, + "synchronous": true, + "forms": [ + { + "href": "https://example.org", + "contentType": "application/json", + } + ], + }; + + final action = Action.fromJson( + actionJson, + PrefixMapping(), + ); + + expect( + actionJson, + action.toJson(), + ); + }); + + test("DataSchemas", () async { + final dataSchemaJson = { + "items": [ + { + "type": "string", + } + ], + "properties": { + "baz": { + "type": "string", + }, + }, + }; + + final dataSchema = DataSchema.fromJson( + dataSchemaJson, + PrefixMapping(), + ); + + expect( + dataSchemaJson, + dataSchema.toJson(), + ); + }); + + test("OAuth2SecurityScheme", () async { + final oAuth2SecuritySchemeJson = { + "scheme": "oauth2", + "authorization": "https://example.org", + "token": "https://example.org", + "refresh": "https://example.org", + "scopes": ["foo", "bar"], + "flow": "code", + }; + + final parsedFields = {"scheme"}; + + final oAuth2SecurityScheme = OAuth2SecurityScheme.fromJson( + oAuth2SecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + oAuth2SecuritySchemeJson, + oAuth2SecurityScheme.toJson(), + ); + }); + + test("BearerSecurityScheme", () async { + final bearerSecuritySchemeJson = { + "scheme": "bearer", + "authorization": "https://example.org", + "name": "foobar", + "alg": "ES256", + "format": "jwt", + "in": "header", + }; + + final parsedFields = {"scheme"}; + + final bearerSecurityScheme = BearerSecurityScheme.fromJson( + bearerSecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + bearerSecuritySchemeJson, + bearerSecurityScheme.toJson(), + ); + }); + + test("DigestSecurityScheme", () async { + final digestSecuritySchemeJson = { + "scheme": "digest", + "name": "foobar", + "in": "header", + "qop": "auth", + }; + + final parsedFields = {"scheme"}; + + final digestSecurityScheme = DigestSecurityScheme.fromJson( + digestSecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + digestSecuritySchemeJson, + digestSecurityScheme.toJson(), + ); + }); + + test("BasicSecurityScheme", () async { + final basicSecuritySchemeJson = { + "scheme": "basic", + "name": "foobar", + "in": "header", + }; + + final parsedFields = {"scheme"}; + + final basicSecurityScheme = BasicSecurityScheme.fromJson( + basicSecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + basicSecuritySchemeJson, + basicSecurityScheme.toJson(), + ); + }); + + test("ApiKeySecurityScheme", () async { + final apiKeySecuritySchemeJson = { + "scheme": "apikey", + "name": "foobar", + "in": "query", + }; + + final parsedFields = {"scheme"}; + + final apiKeySecurityScheme = ApiKeySecurityScheme.fromJson( + apiKeySecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + apiKeySecuritySchemeJson, + apiKeySecurityScheme.toJson(), + ); + }); + + test("PskSecurityScheme", () async { + final pskSecuritySchemeJson = { + "scheme": "psk", + "identity": "foobar", + "proxy": "https://example.org", + "description": "Hi. This is a test", + "description2": { + "en": "Hi. This is a test", + }, + "@type": ["bar", "baz"], + }; + + final parsedFields = {"scheme"}; + + final pskSecurityScheme = PskSecurityScheme.fromJson( + pskSecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + pskSecuritySchemeJson, + pskSecurityScheme.toJson(), + ); + }); + + test("ComboSecurityScheme", () async { + for (final comboVariantKey in ["allOf", "oneOf"]) { + final comboSecuritySchemeJson = { + "scheme": "combo", + comboVariantKey: ["foo", "bar"], + }; + + final parsedFields = {"scheme"}; + + final comboSecurityScheme = ComboSecurityScheme.fromJson( + comboSecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + comboSecuritySchemeJson, + comboSecurityScheme.toJson(), + ); + } + }); + + test("AceSecurityScheme", () async { + final aceSecuritySchemeJson = { + "scheme": "ace:ACESecurityScheme", + "ace:as": "https://example.org", + "ace:audience": "foobar", + "ace:scopes": ["foo", "bar"], + "ace:cnonce": true, + }; + + final parsedFields = {"scheme"}; + + final aceSecurityScheme = AceSecurityScheme.fromJson( + aceSecuritySchemeJson, + PrefixMapping(), + parsedFields, + ); + + expect( + aceSecuritySchemeJson, + aceSecurityScheme.toJson(), + ); + }); + }); +} diff --git a/test/core/definitions_test.dart b/test/core/definitions_test.dart index 3327cec6..418afe49 100644 --- a/test/core/definitions_test.dart +++ b/test/core/definitions_test.dart @@ -145,7 +145,6 @@ void main() { const AdditionalExpectedResponse( "application/json", schema: "hallo", - additionalFields: {}, ), ]); expect(form3.additionalFields, {"test": "test"}); @@ -491,7 +490,7 @@ void main() { additionalFields: {"test": "test"}, ); - expect(firstResponse.additionalFields?["test"], "test"); + expect(firstResponse.additionalFields["test"], "test"); final expectedResponseJson = { "contentType": "application/json", @@ -502,7 +501,7 @@ void main() { ExpectedResponse.fromJson(expectedResponseJson, PrefixMapping()); expect(secondResponse, isA()); - expect(secondResponse.additionalFields?["test"], "test"); + expect(secondResponse.additionalFields["test"], "test"); }); test("Should reject invalid @context entries", () { From 25c4cdc5456bfbc107876a234b608e97dbc73d29 Mon Sep 17 00:00:00 2001 From: Jan Romann Date: Tue, 4 Jun 2024 22:08:30 +0200 Subject: [PATCH 2/5] fix(data_schema.dart): use correct field for deserializing maximum --- lib/src/core/definitions/data_schema.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/core/definitions/data_schema.dart b/lib/src/core/definitions/data_schema.dart index 28186b6a..9ed6f4bc 100644 --- a/lib/src/core/definitions/data_schema.dart +++ b/lib/src/core/definitions/data_schema.dart @@ -84,7 +84,7 @@ class DataSchema implements Serializable { final minimum = json.parseField("minimum", parsedFields); final exclusiveMinimum = json.parseField("exclusiveMinimum", parsedFields); - final maximum = json.parseField("minimum", parsedFields); + final maximum = json.parseField("maximum", parsedFields); final exclusiveMaximum = json.parseField("exclusiveMaximum", parsedFields); final multipleOf = json.parseField("multipleOf", parsedFields); From 45423a45ca447fc12f4285c02239b8124622a2f0 Mon Sep 17 00:00:00 2001 From: Jan Romann Date: Tue, 4 Jun 2024 23:18:35 +0200 Subject: [PATCH 3/5] fix(version.dart): use correct field for deserializing model --- lib/src/core/definitions/version_info.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/core/definitions/version_info.dart b/lib/src/core/definitions/version_info.dart index ce665d9f..d2b2b0e1 100644 --- a/lib/src/core/definitions/version_info.dart +++ b/lib/src/core/definitions/version_info.dart @@ -29,7 +29,7 @@ class VersionInfo { final Set parsedFields = {}; final instance = json.parseRequiredField("instance", parsedFields); - final model = json.parseField("instance", parsedFields); + final model = json.parseField("model", parsedFields); final additionalFields = json.parseAdditionalFields(prefixMapping, parsedFields); From ed9daa8162600d7c80cf021e9dc640e0ff5b67af Mon Sep 17 00:00:00 2001 From: Jan Romann Date: Tue, 4 Jun 2024 23:49:24 +0200 Subject: [PATCH 4/5] fix(link.dart): use correct field for deserializing type --- lib/src/core/definitions/link.dart | 2 +- test/core/dart_wot_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/core/definitions/link.dart b/lib/src/core/definitions/link.dart index 8f7e2b1e..ce15d0ce 100644 --- a/lib/src/core/definitions/link.dart +++ b/lib/src/core/definitions/link.dart @@ -36,7 +36,7 @@ class Link implements Serializable { final Set parsedFields = {}; final href = json.parseRequiredUriField("href", parsedFields); - final type = json.parseField("@type", parsedFields); + final type = json.parseField("type", parsedFields); final rel = json.parseField("rel", parsedFields); final anchor = json.parseUriField("anchor", parsedFields); final sizes = json.parseField("sizes", parsedFields); diff --git a/test/core/dart_wot_test.dart b/test/core/dart_wot_test.dart index ed167618..2394bb9e 100644 --- a/test/core/dart_wot_test.dart +++ b/test/core/dart_wot_test.dart @@ -48,7 +48,7 @@ void main() { "href": "https://example.org", "rel": "icon", "anchor": "https://example.org", - "@type": "test", + "type": "test", "sizes": "42x42", "test": "test", "hreflang": "de", From 7755c3e8d7e106a3290002b7951c2df86abf757f Mon Sep 17 00:00:00 2001 From: Jan Romann Date: Wed, 5 Jun 2024 00:54:48 +0200 Subject: [PATCH 5/5] feat(ace_security_scheme.dart)!: parse `as` field as Uri --- lib/src/binding_coap/coap_client.dart | 2 +- lib/src/core/definitions/security/ace_security_scheme.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/binding_coap/coap_client.dart b/lib/src/binding_coap/coap_client.dart index a551f2fe..cb1ad7d9 100644 --- a/lib/src/binding_coap/coap_client.dart +++ b/lib/src/binding_coap/coap_client.dart @@ -284,7 +284,7 @@ final class CoapClient extends ProtocolClient return AuthServerRequestCreationHint( authorizationServer: - aceSecurityScheme.as ?? creationHint?.authorizationServer, + aceSecurityScheme.as?.toString() ?? creationHint?.authorizationServer, scope: scope ?? creationHint?.scope, audience: aceSecurityScheme.audience ?? creationHint?.audience, clientNonce: creationHint?.clientNonce, diff --git a/lib/src/core/definitions/security/ace_security_scheme.dart b/lib/src/core/definitions/security/ace_security_scheme.dart index 1d059383..b05dafbf 100644 --- a/lib/src/core/definitions/security/ace_security_scheme.dart +++ b/lib/src/core/definitions/security/ace_security_scheme.dart @@ -40,7 +40,7 @@ final class AceSecurityScheme extends SecurityScheme { final jsonLdType = json.parseArrayField("@type"); final proxy = json.parseUriField("proxy", parsedFields); - final as = json.parseField("ace:as", parsedFields); + final as = json.parseUriField("ace:as", parsedFields); final cnonce = json.parseField("ace:cnonce", parsedFields); final audience = json.parseField("ace:audience", parsedFields); final scopes = @@ -66,7 +66,7 @@ final class AceSecurityScheme extends SecurityScheme { String get scheme => aceSecuritySchemeName; /// URI of the authorization server. - final String? as; + final Uri? as; /// The intended audience for this [AceSecurityScheme]. final String? audience;