Skip to content

Commit 552d3fa

Browse files
[dynamic control] Add json and keyvalue parsing and mapping (#2655)
Co-authored-by: Jay DeLuca <jaydeluca4@gmail.com>
1 parent 88b5548 commit 552d3fa

File tree

7 files changed

+476
-0
lines changed

7 files changed

+476
-0
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.contrib.dynamic.policy.source;
7+
8+
import com.fasterxml.jackson.core.JsonProcessingException;
9+
import com.fasterxml.jackson.databind.JsonNode;
10+
import com.fasterxml.jackson.databind.ObjectMapper;
11+
import java.util.ArrayList;
12+
import java.util.Collections;
13+
import java.util.List;
14+
import java.util.Objects;
15+
import javax.annotation.Nullable;
16+
17+
/** JSON-backed source wrapper for a single-policy object. */
18+
public final class JsonSourceWrapper implements SourceWrapper {
19+
private static final ObjectMapper MAPPER = new ObjectMapper();
20+
private final JsonNode source;
21+
22+
public JsonSourceWrapper(JsonNode source) {
23+
this.source = Objects.requireNonNull(source, "source cannot be null");
24+
}
25+
26+
@Override
27+
public SourceFormat getFormat() {
28+
return SourceFormat.JSON;
29+
}
30+
31+
@Override
32+
@Nullable
33+
public String getPolicyType() {
34+
JsonNode node = asJsonNode();
35+
if (!node.isObject() || node.size() != 1) {
36+
return null;
37+
}
38+
return node.fieldNames().next();
39+
}
40+
41+
public JsonNode asJsonNode() {
42+
return source;
43+
}
44+
45+
/**
46+
* Parses JSON source into one wrapper per top-level policy object.
47+
*
48+
* @return an empty list if the source is an empty JSON array; a non-empty list of wrappers if the
49+
* source is a valid single-policy object or array thereof; or {@code null} if the shape is
50+
* unsupported or the source is not valid JSON.
51+
* @throws NullPointerException if source is null
52+
*/
53+
@Nullable
54+
public static List<SourceWrapper> parse(String source) {
55+
Objects.requireNonNull(source, "source cannot be null");
56+
try {
57+
JsonNode parsed = MAPPER.readTree(source);
58+
if (!isSupportedJsonShape(parsed)) {
59+
return null;
60+
}
61+
if (parsed.isObject()) {
62+
return Collections.singletonList(new JsonSourceWrapper(parsed));
63+
}
64+
List<SourceWrapper> wrappers = new ArrayList<>();
65+
for (JsonNode element : parsed) {
66+
wrappers.add(new JsonSourceWrapper(element));
67+
}
68+
return Collections.unmodifiableList(wrappers);
69+
} catch (JsonProcessingException e) {
70+
return null;
71+
}
72+
}
73+
74+
private static boolean isSupportedJsonShape(JsonNode node) {
75+
if (node.isObject()) {
76+
return isSinglePolicyObject(node);
77+
}
78+
if (!node.isArray()) {
79+
return false;
80+
}
81+
for (JsonNode element : node) {
82+
if (!isSinglePolicyObject(element)) {
83+
return false;
84+
}
85+
}
86+
return true;
87+
}
88+
89+
private static boolean isSinglePolicyObject(JsonNode node) {
90+
return node.isObject() && node.size() == 1;
91+
}
92+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.contrib.dynamic.policy.source;
7+
8+
import java.util.ArrayList;
9+
import java.util.Collections;
10+
import java.util.List;
11+
import java.util.Objects;
12+
import java.util.regex.Pattern;
13+
import javax.annotation.Nullable;
14+
15+
/** KEYVALUE-backed source wrapper for a single key/value policy entry. */
16+
public final class KeyValueSourceWrapper implements SourceWrapper {
17+
private final String key;
18+
private final String value;
19+
20+
private static final Pattern LINE_SEPARATOR = Pattern.compile("\\R");
21+
22+
public KeyValueSourceWrapper(String key, String value) {
23+
this.key = Objects.requireNonNull(key, "key cannot be null");
24+
this.value = Objects.requireNonNull(value, "value cannot be null");
25+
}
26+
27+
@Override
28+
public SourceFormat getFormat() {
29+
return SourceFormat.KEYVALUE;
30+
}
31+
32+
@Override
33+
@Nullable
34+
public String getPolicyType() {
35+
return key;
36+
}
37+
38+
public String getKey() {
39+
return key;
40+
}
41+
42+
public String getValue() {
43+
return value;
44+
}
45+
46+
/**
47+
* Parses KEYVALUE source into one wrapper per non-empty line.
48+
*
49+
* @return an empty list if the source contains no non-blank lines; a non-empty list of wrappers
50+
* if all non-blank lines are valid key=value pairs; or {@code null} if any line is malformed.
51+
* @throws NullPointerException if source is null
52+
*/
53+
@Nullable
54+
public static List<SourceWrapper> parse(String source) {
55+
Objects.requireNonNull(source, "source cannot be null");
56+
String[] lines = LINE_SEPARATOR.split(source, -1);
57+
List<SourceWrapper> wrappers = new ArrayList<>();
58+
for (String rawLine : lines) {
59+
String trimmedLine = rawLine.trim();
60+
if (trimmedLine.isEmpty()) {
61+
continue;
62+
}
63+
KeyValueSourceWrapper wrapper = parseSingleKeyValue(trimmedLine);
64+
if (wrapper == null) {
65+
return null;
66+
}
67+
wrappers.add(wrapper);
68+
}
69+
if (wrappers.isEmpty()) {
70+
return Collections.emptyList();
71+
}
72+
return Collections.unmodifiableList(wrappers);
73+
}
74+
75+
@Nullable
76+
private static KeyValueSourceWrapper parseSingleKeyValue(String line) {
77+
int separatorIndex = line.indexOf('=');
78+
if (separatorIndex <= 0) {
79+
return null;
80+
}
81+
String key = line.substring(0, separatorIndex).trim();
82+
String value = line.substring(separatorIndex + 1).trim();
83+
if (key.isEmpty()) {
84+
return null;
85+
}
86+
return new KeyValueSourceWrapper(key, value);
87+
}
88+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.contrib.dynamic.policy.source;
7+
8+
import com.google.errorprone.annotations.Immutable;
9+
import java.util.List;
10+
import java.util.Objects;
11+
import javax.annotation.Nullable;
12+
13+
/** Supported source formats and their parser dispatch. */
14+
public enum SourceFormat {
15+
KEYVALUE("keyvalue", KeyValueSourceWrapper::parse),
16+
JSON("json", JsonSourceWrapper::parse);
17+
18+
private final String configValue;
19+
private final SourceParser parser;
20+
21+
SourceFormat(String configValue, SourceParser parser) {
22+
this.configValue = configValue;
23+
this.parser = parser;
24+
}
25+
26+
public String configValue() {
27+
return configValue;
28+
}
29+
30+
/**
31+
* Parses source text into normalized wrappers for this format.
32+
*
33+
* @return an empty list if the source is valid but contains no policies; a non-empty list of
34+
* wrappers if one or more policies were parsed successfully; or {@code null} if the source is
35+
* malformed or does not conform to the expected shape for this format.
36+
* @throws NullPointerException if source is null
37+
*/
38+
@Nullable
39+
public List<SourceWrapper> parse(String source) {
40+
Objects.requireNonNull(source, "source cannot be null");
41+
return parser.parse(source);
42+
}
43+
44+
@Immutable
45+
@FunctionalInterface
46+
private interface SourceParser {
47+
@Nullable
48+
List<SourceWrapper> parse(String source);
49+
}
50+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.contrib.dynamic.policy.source;
7+
8+
import javax.annotation.Nullable;
9+
10+
/** Parsed source payload paired with its source format. */
11+
public interface SourceWrapper {
12+
SourceFormat getFormat();
13+
14+
@Nullable
15+
String getPolicyType();
16+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.contrib.dynamic.policy.source;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
10+
11+
import com.fasterxml.jackson.databind.ObjectMapper;
12+
import java.util.List;
13+
import org.junit.jupiter.api.Test;
14+
15+
class JsonSourceWrapperTest {
16+
private static final ObjectMapper MAPPER = new ObjectMapper();
17+
18+
@Test
19+
void parseSupportsSingleObject() {
20+
List<SourceWrapper> parsed = JsonSourceWrapper.parse("{\"trace-sampling\": 0.5}");
21+
22+
assertThat(parsed).hasSize(1);
23+
assertThat(parsed.get(0)).isInstanceOf(JsonSourceWrapper.class);
24+
assertThat(parsed.get(0).getPolicyType()).isEqualTo("trace-sampling");
25+
}
26+
27+
@Test
28+
void parseSupportsArrayOfObjects() {
29+
List<SourceWrapper> parsed =
30+
JsonSourceWrapper.parse("[{\"other-policy\": 1}, {\"trace-sampling\": 0.5}]");
31+
32+
assertThat(parsed).hasSize(2);
33+
assertThat(parsed.get(0).getPolicyType()).isEqualTo("other-policy");
34+
assertThat(parsed.get(1).getPolicyType()).isEqualTo("trace-sampling");
35+
}
36+
37+
@Test
38+
void parseSupportsEmptyArray() {
39+
assertThat(JsonSourceWrapper.parse("[]")).isEmpty();
40+
}
41+
42+
@Test
43+
void parseArrayResultIsImmutable() {
44+
List<SourceWrapper> parsed = JsonSourceWrapper.parse("[{\"trace-sampling\": 0.5}]");
45+
46+
assertThatThrownBy(() -> parsed.add(new JsonSourceWrapper(MAPPER.readTree("{\"x\":1}"))))
47+
.isInstanceOf(UnsupportedOperationException.class);
48+
}
49+
50+
@Test
51+
void getPolicyTypeReturnsNullWhenObjectHasMultipleFields() throws Exception {
52+
JsonSourceWrapper wrapper = new JsonSourceWrapper(MAPPER.readTree("{\"a\": 1, \"b\": 2}"));
53+
54+
assertThat(wrapper.getPolicyType()).isNull();
55+
}
56+
57+
@Test
58+
void parseRejectsUnsupportedJsonShapes() {
59+
assertThat(JsonSourceWrapper.parse("{}")).isNull();
60+
assertThat(JsonSourceWrapper.parse("{\"a\": 1, \"b\": 2}")).isNull();
61+
assertThat(JsonSourceWrapper.parse("[1, 2, 3]")).isNull();
62+
assertThat(JsonSourceWrapper.parse("[{\"trace-sampling\": 0.5}, {}]")).isNull();
63+
assertThat(JsonSourceWrapper.parse("[{\"a\": 1, \"b\": 2}]")).isNull();
64+
assertThat(JsonSourceWrapper.parse("\"text\"")).isNull();
65+
assertThat(JsonSourceWrapper.parse("{invalid-json")).isNull();
66+
}
67+
68+
@Test
69+
void parseRejectsNullInput() {
70+
assertThatThrownBy(() -> JsonSourceWrapper.parse(null))
71+
.isInstanceOf(NullPointerException.class)
72+
.hasMessage("source cannot be null");
73+
}
74+
}

0 commit comments

Comments
 (0)