Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions at_client/src/main/java/org/atsign/client/api/Metadata.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package org.atsign.client.api;

import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import org.atsign.client.impl.util.JsonUtils;

import lombok.Builder;
Expand Down Expand Up @@ -50,6 +56,14 @@ public class Metadata {
String skeEncKeyName;
String skeEncAlgo;
Boolean immutable;
/**
* Provider-owned crypto metadata for the pluggable encryption/decryption model. Mirrors the
* canonical at_commons {@code AppMetadata}: an SDK-owned {@code providerId} that routes the
* value to the crypto provider able to decrypt it, plus opaque {@code additional} entries the
* SDK preserves but does not interpret. Serialised LAST in the metadata fragment as
* {@code :appMetadata:<base64(JSON)>} and as a flat JSON object in metadata maps.
*/
AppMetadata appMetadata;

/**
* A builder for instantiating {@link Metadata} instances. Note: Metadata is immutable so if you
Expand Down Expand Up @@ -90,6 +104,7 @@ public String toString() {
.append(skeEncKeyName != null ? ":skeEncKeyName:" + skeEncKeyName : "")
.append(skeEncAlgo != null ? ":skeEncAlgo:" + skeEncAlgo : "")
.append(immutable != null ? ":immutable:" + immutable : "")
.append(appMetadata != null ? ":appMetadata:" + appMetadata.encode() : "")
.toString();
}

Expand Down Expand Up @@ -458,4 +473,120 @@ public static class PublicKeyHash {
String hash;
String hashingAlgo;
}

/**
* Encode {@link AppMetadata} to its base64(JSON) wire form. Mirrors canonical
* {@code Metadata.encodeAppMetadata} (at_commons at_key.dart).
*
* @param appMetadata value to encode
* @return base64(JSON) string
*/
public static String encodeAppMetadata(AppMetadata appMetadata) {
return appMetadata.encode();
}

/**
* Decode an {@code appMetadata} value that may arrive as a base64(JSON) String (the wire form),
* a flat JSON object ({@code Map}, the metadata-map form), or an already-parsed
* {@link AppMetadata}. Mirrors canonical {@code Metadata.decodeAppMetadata}.
*
* @param value wire value, or null
* @return the parsed value, or null when absent
*/
public static AppMetadata decodeAppMetadata(Object value) {
return AppMetadata.decode(value);
}

/**
* Provider-owned crypto metadata for the pluggable encryption/decryption model, mirroring the
* canonical Dart {@code AppMetadata} (at_commons). The SDK owns {@code providerId} — the
* routing key that selects the crypto provider able to decrypt the value — and preserves any
* {@code additional} provider-owned entries opaquely. On the wire it is base64(JSON)-encoded
* (see {@link #encode()}); in metadata maps it is the flat JSON object
* {@code {"providerId":…, …additional}} (see {@link #toJson()}).
*/
@Value
@Builder
public static class AppMetadata {
String providerId;
Map<String, Object> additional;

/**
* @return the flat JSON object form — {@code providerId} plus any {@code additional} entries,
* serialised flat (not nested under an {@code additional} key), matching canonical.
*/
@JsonValue
public Map<String, Object> toJson() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("providerId", providerId);
if (additional != null) {
map.putAll(additional);
}
return map;
}

/**
* Build from the flat JSON object form; every key other than {@code providerId} is collected
* into {@code additional}. Used by Jackson when deserialising a metadata map.
*
* @param json flat map with a String {@code providerId} and opaque extras
* @return the parsed value
* @throws IllegalArgumentException if {@code providerId} is missing or blank
*/
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
public static AppMetadata fromJson(Map<String, Object> json) {
Object providerId = json.get("providerId");
if (!(providerId instanceof String) || ((String) providerId).trim().isEmpty()) {
throw new IllegalArgumentException("Invalid appMetadata.providerId: " + providerId);
}
Map<String, Object> additional = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : json.entrySet()) {
if (!"providerId".equals(entry.getKey())) {
additional.put(entry.getKey(), entry.getValue());
}
}
return AppMetadata.builder()
.providerId((String) providerId)
.additional(additional.isEmpty() ? null : additional)
.build();
}

/**
* @return the base64(JSON) wire encoding of {@link #toJson()}.
*/
public String encode() {
return Base64.getEncoder()
.encodeToString(JsonUtils.writeValueAsString(toJson()).getBytes(StandardCharsets.UTF_8));
}

/**
* Decode an {@code appMetadata} value arriving as a base64(JSON) String, a flat JSON
* {@code Map}, or an already-parsed {@link AppMetadata}. Absent (null or the literal
* {@code "null"}) yields null.
*
* @param value wire value
* @return the parsed value, or null when absent
* @throws IllegalArgumentException if the value is a non-decodable shape
*/
@SuppressWarnings("unchecked")
public static AppMetadata decode(Object value) {
if (value == null || "null".equals(value)) {
return null;
}
if (value instanceof AppMetadata) {
return (AppMetadata) value;
}
if (value instanceof Map) {
return fromJson((Map<String, Object>) value);
}
if (value instanceof String && !((String) value).isEmpty()) {
byte[] decoded = Base64.getDecoder().decode((String) value);
Object json = JsonUtils.readValue(new String(decoded, StandardCharsets.UTF_8), Object.class);
if (json instanceof Map) {
return fromJson((Map<String, Object>) json);
}
}
throw new IllegalArgumentException("Invalid appMetadata: " + value);
}
}
}
73 changes: 73 additions & 0 deletions at_client/src/test/java/org/atsign/client/api/MetadataTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -571,4 +571,77 @@ void testSetImmutableIfNotNullReturnsFalseAndDoesNotOverwriteExistingValueWhenNu
assertThat(b.build().immutable(), is(true));
}

// ---- AppMetadata (pluggable-encryption provider metadata) ----

@Test
void testAppMetadataToJsonIsFlatProviderIdPlusAdditional() {
Metadata.AppMetadata am = Metadata.AppMetadata.builder()
.providerId("legacy")
.additional(java.util.Map.of("v", 2))
.build();
java.util.Map<String, Object> json = am.toJson();
assertThat(json.get("providerId"), equalTo("legacy"));
assertThat(json.get("v"), equalTo(2));
// additional is flattened, NOT nested under an "additional" key
assertThat(json.containsKey("additional"), is(false));
}

@Test
void testAppMetadataFromJsonRoutesNonProviderIdKeysToAdditional() {
Metadata.AppMetadata am = Metadata.AppMetadata
.fromJson(new java.util.LinkedHashMap<>(java.util.Map.of("providerId", "p1", "x", "y")));
assertThat(am.providerId(), equalTo("p1"));
assertThat(am.additional().get("x"), equalTo("y"));
}

@Test
void testAppMetadataFromJsonWithOnlyProviderIdHasNullAdditional() {
Metadata.AppMetadata am = Metadata.AppMetadata
.fromJson(new java.util.LinkedHashMap<>(java.util.Map.of("providerId", "p1")));
assertThat(am.additional(), is(nullValue()));
}

@Test
void testAppMetadataFromJsonThrowsWhenProviderIdMissingOrBlank() {
assertThrows(IllegalArgumentException.class,
() -> Metadata.AppMetadata.fromJson(new java.util.LinkedHashMap<>()));
assertThrows(IllegalArgumentException.class, () -> Metadata.AppMetadata
.fromJson(new java.util.LinkedHashMap<>(java.util.Map.of("providerId", " "))));
}

@Test
void testAppMetadataEncodeDecodeRoundTrips() {
Metadata.AppMetadata am = Metadata.AppMetadata.builder().providerId("prov").build();
String encoded = am.encode();
// base64 of a JSON object — decode must reconstruct the same value
assertThat(Metadata.AppMetadata.decode(encoded), equalTo(am));
// canonical parity: the static Metadata helpers delegate to encode/decode
assertThat(Metadata.encodeAppMetadata(am), equalTo(encoded));
assertThat(Metadata.decodeAppMetadata(encoded), equalTo(am));
}

@Test
void testAppMetadataDecodeAcceptsMapAndAppMetadataAndNull() {
Metadata.AppMetadata am = Metadata.AppMetadata.builder().providerId("prov").build();
assertThat(Metadata.AppMetadata.decode(am), sameInstance(am));
assertThat(Metadata.AppMetadata.decode(java.util.Map.of("providerId", "prov")), equalTo(am));
assertThat(Metadata.AppMetadata.decode(null), is(nullValue()));
assertThat(Metadata.AppMetadata.decode("null"), is(nullValue()));
}

@Test
void testToStringEncodesAppMetadataAsBase64Fragment() {
Metadata.AppMetadata am = Metadata.AppMetadata.builder().providerId("prov").build();
Metadata md = Metadata.builder().appMetadata(am).build();
assertThat(md.toString(), containsString(":appMetadata:" + am.encode()));
}

@Test
void testMetadataFromJsonDeserialisesNestedAppMetadataObject() {
// The metadata-map form carries appMetadata as a flat JSON object (not base64).
Metadata md = Metadata.fromJson("{\"appMetadata\":{\"providerId\":\"prov\",\"v\":1}}");
assertThat(md.appMetadata().providerId(), equalTo("prov"));
assertThat(md.appMetadata().additional().get("v"), equalTo(1));
}

}
Loading