Skip to content

Commit

Permalink
feat!: rework TD @context handling
Browse files Browse the repository at this point in the history
  • Loading branch information
JKRhb committed May 18, 2024
1 parent 24df271 commit 4d4e0f7
Show file tree
Hide file tree
Showing 7 changed files with 406 additions and 60 deletions.
213 changes: 213 additions & 0 deletions lib/src/core/definitions/context.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// 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:collection/collection.dart";
import "package:curie/curie.dart";
import "package:meta/meta.dart";

import "../exceptions.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 {
/// Creates a new context from a list of [contextEntries].
Context(this.contextEntries)
: prefixMapping = _createPrefixMappping(contextEntries);

/// Determines the default prefix URL via the procedure described in
/// [section 5.3.1.1] of the Thing Description 1.1 specification.
///
/// [section 5.3.1.1]: https://www.w3.org/TR/wot-thing-description11/#thing
static String _determineDefaultPrefix(
List<ContextEntry> contextEntries,
) {
final firstContextEntry = contextEntries.firstOrNull;

if (firstContextEntry is! SingleContextEntry) {
throw const ValidationException("Missing TD context URL.");
}

final firstContextValue = firstContextEntry.value;

if (![_tdVersion10ContextUrl, _tdVersion11ContextUrl]
.contains(firstContextValue)) {
throw ValidationException(
"Encountered invalid TD context URL $firstContextEntry",
);
}

final String? secondContextValue;

final secondContextEntry = contextEntries.elementAtOrNull(1);
if (secondContextEntry is SingleContextEntry) {
secondContextValue = secondContextEntry.value;
} else {
secondContextValue = null;
}

if (firstContextValue == _tdVersion10ContextUrl &&
secondContextValue == _tdVersion11ContextUrl) {
return _tdVersion11ContextUrl;
}

return firstContextValue;
}

static PrefixMapping _createPrefixMappping(
List<ContextEntry> contextEntries,
) {
final defaultPrefixValue = _determineDefaultPrefix(contextEntries);
final prefixMapping = PrefixMapping(defaultPrefixValue: defaultPrefixValue);

contextEntries
.whereType<UriMapContextEntry>()
.where((contextEntry) => !contextEntry.key.startsWith("@"))
.forEach(
(contextEntry) =>
prefixMapping.addPrefix(contextEntry.key, contextEntry.value),
);

return prefixMapping;
}

/// List of [ContextEntry] elements in this `@context` definition.
///
/// These elements can either be [SingleContextEntry]s (that contain a single
/// URI value) or [MapContextEntry]s (that contain key-value pairs).
final List<ContextEntry> contextEntries;

/// Used to map context extension prefixes within the `@context` to URIs.
final PrefixMapping prefixMapping;

/// Allows for directly accessing this [Context]'s [contextEntries] by
/// [index].
ContextEntry operator [](int index) {
return contextEntries[index];
}

@override
bool operator ==(Object other) {
if (other is! Context) {
return false;
}

for (final contextEntryPair
in IterableZip([contextEntries, other.contextEntries])) {
if (contextEntryPair[0] != contextEntryPair[1]) {
return false;
}
}

return true;
}

@override
int get hashCode => Object.hashAll(contextEntries);
}

/// Base class for `@context` entries.
@immutable
sealed class ContextEntry {
const ContextEntry();

/// The key of this `@context` entry.
///
/// Not defined for entries that are not part of a map, i.e. the
/// [SingleContextEntry] class.
String? get key;

/// The value of this `@context` entry.
String get value;
}

/// Represents a `@context` entry that contains a [uri] as its [value] and has
/// no [key] defined.
final class SingleContextEntry extends ContextEntry {
/// Creates a new [SingleContextEntry] from a [uri].
const SingleContextEntry(this.uri);

/// Creates a new [SingleContextEntry] from a [string] that represents a URI.
///
/// If the [string] should not be a valid URI, this factory constructor will
/// throw a [ValidationException].
factory SingleContextEntry.fromString(String string) {
final parsedUri = Uri.tryParse(string);

if (parsedUri == null) {
throw ValidationException("Encountered invalid URI $string");
}

return SingleContextEntry(parsedUri);
}

@override
String? get key => null;

/// The [value] of this `@context` entry as a [Uri] object.
final Uri uri;

/// The [String] representation of this `@context` entry's value.
@override
String get value => uri.toString();

@override
bool operator ==(Object other) {
if (other is! SingleContextEntry) {
return false;
}

return value == other.value;
}

@override
int get hashCode => value.hashCode;
}

/// Super class of `@context` entries that are [key]-[value] pairs.
sealed class MapContextEntry extends ContextEntry {
const MapContextEntry(this.key);

/// The key of this `@context` entry.
@override
final String key;

@override
bool operator ==(Object other) {
if (other is! MapContextEntry) {
return false;
}

return key == other.key && value == other.value;
}

@override
int get hashCode => Object.hash(key, value);
}

/// Key-value `@context` entry that contains a [uri] as its [value].
final class UriMapContextEntry extends MapContextEntry {
/// Creates a new [UriMapContextEntry] from a [key] and a [uri].
const UriMapContextEntry(super.key, this.uri);

/// The URI that the [key] of this `@context` entry points to.
final Uri uri;

@override
String get value => uri.toString();
}

/// Key-value `@context` entry that contains a non-URI string as its [value].
final class StringMapContextEntry extends MapContextEntry {
/// Creates a new [UriMapContextEntry] from a [key] and a plain string
/// [value].
const StringMapContextEntry(super.key, this.value);

@override
final String value;
}
76 changes: 27 additions & 49 deletions lib/src/core/definitions/extensions/json_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
//
// SPDX-License-Identifier: BSD-3-Clause

import "package:collection/collection.dart";
import "package:curie/curie.dart";

import "../../exceptions.dart";
import "../additional_expected_response.dart";
import "../context.dart";
import "../data_schema.dart";
import "../expected_response.dart";
import "../form.dart";
Expand All @@ -26,14 +26,8 @@ import "../security/no_security_scheme.dart";
import "../security/oauth2_security_scheme.dart";
import "../security/psk_security_scheme.dart";
import "../security/security_scheme.dart";
import "../thing_description.dart";
import "../version_info.dart";

const _validTdContextValues = [
"https://www.w3.org/2019/wot/td/v1",
"https://www.w3.org/2022/wot/td/v1.1",
];

/// Extension for parsing fields of JSON objects.
extension ParseField on Map<String, dynamic> {
dynamic _processFieldName(String name, Set<String>? parsedFields) {
Expand Down Expand Up @@ -586,71 +580,55 @@ extension ParseField on Map<String, dynamic> {
return value;
}

/// Parses the JSON-LD @context of a TD and returns a [List] of
/// Parses the JSON-LD `@context` of a TD and returns a [List] of
/// [ContextEntry]s.
List<ContextEntry> parseContext(
PrefixMapping prefixMapping,
Set<String>? parsedFields, {
bool firstEntry = true,
}) {
Context parseContext(Set<String>? parsedFields) {
final fieldValue = parseField("@context", parsedFields);
final contextEntries = _parseContextEntries(fieldValue).toList();

return _parseContext(fieldValue, prefixMapping);
return Context(contextEntries);
}
}

/// Parses a [List] of `@context` entries from a given [json] value.
///
/// `@context` extensions are added to the provided [prefixMapping].
/// If a given entry is the [firstEntry], it will be set in the
/// [prefixMapping] accordingly.
List<ContextEntry> _parseContext(
dynamic json,
PrefixMapping prefixMapping, {
bool firstEntry = true,
}) {
Iterable<ContextEntry> _parseContextEntries(dynamic json) sync* {
switch (json) {
case final String jsonString:
{
if (firstEntry && _validTdContextValues.contains(jsonString)) {
prefixMapping.defaultPrefixValue = jsonString;
}
return [(key: null, value: jsonString)];
yield SingleContextEntry.fromString(jsonString);
}
case final List<dynamic> contextList:
case final List<dynamic> contextEntryList:
{
final List<ContextEntry> result = [];
contextList
.mapIndexed(
(index, contextEntry) => _parseContext(
contextEntry,
prefixMapping,
firstEntry: index == 0,
),
)
.forEach(result.addAll);
return result;
for (final contextEntry in contextEntryList.map(_parseContextEntries)) {
yield* contextEntry;
}
}
case final Map<String, dynamic> contextList:
case final Map<String, dynamic> contextEntryList:
{
return contextList.entries.map((entry) {
yield* contextEntryList.entries.map((entry) {
final key = entry.key;
final value = entry.value;

if (value is! String) {
throw ValidationException(
"Excepted either a String or a Map<String, String> "
"Expected $value to be a String or a Map<String, String> "
"as @context entry, got ${value.runtimeType} instead.");
}

if (!key.startsWith("@") && Uri.tryParse(value) != null) {
prefixMapping.addPrefix(key, value);
final uri = Uri.tryParse(value);

if (!key.startsWith("@") && uri != null) {
return UriMapContextEntry(key, uri);
}
return (key: key, value: value);
}).toList();

return StringMapContextEntry(key, value);
});
}
default:
throw ValidationException(
"Expected the @context entry $json to "
"either be a String or a Map<String, String>, "
"got ${json.runtimeType} instead.",
);
}

throw ValidationException("Excepted either a String or a Map<String, String> "
"as @context entry, got ${json.runtimeType} instead.");
}
15 changes: 6 additions & 9 deletions lib/src/core/definitions/thing_description.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import "package:meta/meta.dart";

import "../exceptions.dart";
import "additional_expected_response.dart";
import "context.dart";
import "data_schema.dart";
import "extensions/json_parser.dart";
import "form.dart";
Expand All @@ -19,9 +20,6 @@ import "thing_model.dart";
import "validation/thing_description_schema.dart";
import "version_info.dart";

/// Type definition for a JSON-LD @context entry.
typedef ContextEntry = ({String? key, String value});

/// Represents a WoT Thing Description
@immutable
class ThingDescription {
Expand Down Expand Up @@ -51,7 +49,6 @@ class ThingDescription {
this.description,
this.version,
this.uriVariables,
this.prefixMapping,
}) : _rawThingDescription = rawThingDescription;

/// Creates a [ThingDescription] from a [json] object.
Expand All @@ -71,9 +68,10 @@ class ThingDescription {
}

final Set<String> parsedFields = {};
final prefixMapping = PrefixMapping();

final context = json.parseContext(prefixMapping, parsedFields);
final context = json.parseContext(parsedFields);
final prefixMapping = context.prefixMapping;

final atType = json.parseArrayField<String>("@type", parsedFields);
final title = json.parseRequiredField<String>("title", parsedFields);
final titles = json.parseMapField<String>("titles", parsedFields);
Expand Down Expand Up @@ -113,7 +111,6 @@ class ThingDescription {
json.parseAdditionalFields(prefixMapping, parsedFields);

return ThingDescription._(
prefixMapping: prefixMapping,
context: context,
title: title,
titles: titles,
Expand Down Expand Up @@ -154,10 +151,10 @@ class ThingDescription {
final Map<String, dynamic> _rawThingDescription;

/// Contains the values of the @context for CURIE expansion.
final PrefixMapping? prefixMapping;
PrefixMapping get prefixMapping => context.prefixMapping;

/// The JSON-LD `@context`, represented by a [List] of [ContextEntry]s.
final List<ContextEntry> context;
final Context context;

/// JSON-LD keyword to label the object with semantic tags (or types).
final List<String>? atType;
Expand Down
Loading

0 comments on commit 4d4e0f7

Please sign in to comment.