Add support for short-form metadata namespace in Item Yaml config (#5250)

* Support deserializing short-form metadata in YamlMetadataDTO

So we can set
`metadata: value`

instead of
```
metadata:
  value: value
```

Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
This commit is contained in:
jimtng
2026-01-23 19:53:12 +01:00
committed by GitHub
parent 9e9409b98b
commit 4b7ad7a23e
9 changed files with 476 additions and 0 deletions
@@ -19,12 +19,18 @@ import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.model.yaml.internal.util.YamlElementUtils;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* The {@link YamlMetadataDTO} is a data transfer object used to serialize a metadata for a particular namespace
* in a YAML configuration file.
*
* @author Laurent Garnier - Initial contribution
* @author Jimmy Tanagra - Support scalar metadata namespace
*/
@JsonSerialize(using = YamlMetadataDTOSerializer.class)
@JsonDeserialize(using = YamlMetadataDTODeserializer.class)
public class YamlMetadataDTO {
public String value;
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.model.yaml.internal.items;
import java.io.IOException;
import java.util.Map;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Custom deserializer for {@link YamlMetadataDTO} that converts any YAML scalar
* (string, integer, boolean, or float) into a metadata String {@code value} with an empty config.
*
* @author Jimmy Tanagra - Initial contribution
*/
class YamlMetadataDTODeserializer extends JsonDeserializer<YamlMetadataDTO> {
@Override
public YamlMetadataDTO deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JsonToken token = p.currentToken();
if (token == JsonToken.VALUE_STRING || token == JsonToken.VALUE_NUMBER_INT
|| token == JsonToken.VALUE_NUMBER_FLOAT || token == JsonToken.VALUE_TRUE
|| token == JsonToken.VALUE_FALSE) {
YamlMetadataDTO dto = new YamlMetadataDTO();
dto.value = p.getValueAsString("");
return dto;
}
if (token == JsonToken.START_OBJECT) {
ObjectCodec codec = p.getCodec();
JsonNode node = codec.readTree(p);
YamlMetadataDTO dto = new YamlMetadataDTO();
JsonNode valueNode = node.get("value");
if (valueNode != null && !valueNode.isNull()) {
dto.value = valueNode.asText();
}
JsonNode configNode = node.get("config");
if (configNode != null && !configNode.isNull()) {
dto.config = codec.treeToValue(configNode, Map.class);
}
return dto;
}
return (YamlMetadataDTO) ctxt.handleUnexpectedToken(YamlMetadataDTO.class, p);
}
@Override
public YamlMetadataDTO getNullValue(DeserializationContext ctxt) {
return new YamlMetadataDTO();
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.model.yaml.internal.items;
import java.io.IOException;
import java.util.Map;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
/**
* Custom serializer for {@link YamlMetadataDTO} that writes the namespace as a scalar
* when its config is empty, otherwise writes as an object with value and config fields.
*
* @author Jimmy Tanagra - Initial contribution
*/
public class YamlMetadataDTOSerializer extends JsonSerializer<YamlMetadataDTO> {
@Override
public void serialize(YamlMetadataDTO value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
Map<?, ?> config = value.config;
boolean configIsEmpty = (config == null || config.isEmpty());
if (configIsEmpty) {
gen.writeString(value.getValue());
} else {
gen.writeStartObject();
gen.writeStringField("value", value.getValue());
gen.writeObjectField("config", value.config);
gen.writeEndObject();
}
}
}
@@ -18,15 +18,21 @@ import static org.hamcrest.Matchers.contains;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -40,6 +46,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.openhab.core.model.yaml.YamlModelListener;
import org.openhab.core.model.yaml.internal.items.YamlItemDTO;
import org.openhab.core.model.yaml.test.FirstTypeDTO;
import org.openhab.core.model.yaml.test.SecondTypeDTO;
import org.openhab.core.service.WatchService;
@@ -464,4 +471,165 @@ public class YamlModelRepositoryImplTest {
assertThat(secondTypeElements, hasSize(1));
assertThat(secondTypeElements, contains(new SecondTypeDTO("Second1", "Label1")));
}
@Test
public void testObjectFormMetadataLoadingAndGeneration() throws IOException {
Files.copy(SOURCE_PATH.resolve("itemWithObjectFormMetadata.yaml"), fullModelPath);
YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock);
@SuppressWarnings("unchecked")
YamlModelListener<YamlItemDTO> itemListener = mock(YamlModelListener.class);
when(itemListener.getElementClass()).thenReturn(YamlItemDTO.class);
when(itemListener.isVersionSupported(anyInt())).thenReturn(true);
when(itemListener.isDeprecated()).thenReturn(false);
modelRepository.addYamlModelListener(itemListener);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, fullModelPath);
// Verify the listener was called
@SuppressWarnings("unchecked")
ArgumentCaptor<Collection<YamlItemDTO>> captor = ArgumentCaptor.forClass(Collection.class);
verify(itemListener).addedModel(eq(MODEL_NAME), captor.capture());
// Verify that the valid item with object-form metadata was loaded
Collection<YamlItemDTO> items = captor.getValue();
assertThat(items, hasSize(1));
YamlItemDTO item = items.iterator().next();
assertThat(item.name, is("ValidItem"));
assertThat(item.metadata, is(notNullValue()));
assertThat(item.metadata.keySet(), containsInAnyOrder("alexa", "homekit"));
assertThat(item.metadata.get("alexa").value, is("Switchable"));
assertThat(item.metadata.get("alexa").config, is(Map.of("setting1", "value1")));
assertThat(item.metadata.get("homekit").value, is("Lighting"));
assertThat(item.metadata.get("homekit").config, is(nullValue()));
// Verify YAML output contains object form metadata
modelRepository.addElementsToBeGenerated("items", List.copyOf(items));
ByteArrayOutputStream out = new ByteArrayOutputStream();
modelRepository.generateFileFormat("items", out);
String yaml = out.toString();
// Should contain object form metadata for 'alexa'
assertThat(yaml, containsString("alexa:"));
assertThat(yaml, containsString("value: Switchable"));
assertThat(yaml, containsOnlyOnce("config:"));
assertThat(yaml, containsString("setting1: value1"));
}
@Test
public void testShortFormMetadataLoadingAndGeneration() throws IOException {
Files.copy(SOURCE_PATH.resolve("itemWithShortFormMetadata.yaml"), fullModelPath);
YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock);
@SuppressWarnings("unchecked")
YamlModelListener<YamlItemDTO> itemListener = mock(YamlModelListener.class);
when(itemListener.getElementClass()).thenReturn(YamlItemDTO.class);
when(itemListener.isVersionSupported(anyInt())).thenReturn(true);
when(itemListener.isDeprecated()).thenReturn(false);
modelRepository.addYamlModelListener(itemListener);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, fullModelPath);
// Verify the listener was called
@SuppressWarnings("unchecked")
ArgumentCaptor<Collection<YamlItemDTO>> captor = ArgumentCaptor.forClass(Collection.class);
verify(itemListener).addedModel(eq(MODEL_NAME), captor.capture());
// Verify items were loaded with short-form metadata
Collection<YamlItemDTO> items = captor.getValue();
assertThat(items, hasSize(1));
YamlItemDTO item = items.iterator().next();
assertThat(item.metadata, is(notNullValue()));
assertThat(item.metadata.keySet(), containsInAnyOrder("alexa", "homekit"));
// deserializer converts short-form to value field
assertThat(item.metadata.get("alexa").value, is("Switchable"));
assertThat(item.metadata.get("alexa").config, is(nullValue()));
assertThat(item.metadata.get("homekit").value, is("Lighting"));
assertThat(item.metadata.get("homekit").config, is(nullValue()));
// Verify YAML output contains short form metadata
modelRepository.addElementsToBeGenerated("items", List.copyOf(items));
ByteArrayOutputStream out = new ByteArrayOutputStream();
modelRepository.generateFileFormat("items", out);
String yaml = out.toString();
assertThat(yaml, containsString("alexa: Switchable"));
assertThat(yaml, containsString("homekit: Lighting"));
// Should not contain object form keys
assertThat(yaml, not(containsString("value:")));
assertThat(yaml, not(containsString("config:")));
}
@Test
public void testMixedFormMetadataLoadingAndGeneration() throws IOException {
Files.copy(SOURCE_PATH.resolve("itemWithMixedFormMetadata.yaml"), fullModelPath);
YamlModelRepositoryImpl modelRepository = new YamlModelRepositoryImpl(watchServiceMock);
@SuppressWarnings("unchecked")
YamlModelListener<YamlItemDTO> itemListener = mock(YamlModelListener.class);
when(itemListener.getElementClass()).thenReturn(YamlItemDTO.class);
when(itemListener.isVersionSupported(anyInt())).thenReturn(true);
when(itemListener.isDeprecated()).thenReturn(false);
modelRepository.addYamlModelListener(itemListener);
modelRepository.processWatchEvent(WatchService.Kind.CREATE, fullModelPath);
// Verify the listener was called
@SuppressWarnings("unchecked")
ArgumentCaptor<Collection<YamlItemDTO>> captor = ArgumentCaptor.forClass(Collection.class);
verify(itemListener).addedModel(eq(MODEL_NAME), captor.capture());
// Verify that the valid item with mixed-form metadata was loaded
Collection<YamlItemDTO> items = captor.getValue();
assertThat(items, hasSize(1));
YamlItemDTO item = items.iterator().next();
assertThat(item.name, is("ValidItem"));
assertThat(item.metadata, is(notNullValue()));
assertThat(item.metadata.keySet(), containsInAnyOrder("alexa", "homekit", "matter"));
assertThat(item.metadata.get("alexa").value, is("Switchable"));
assertThat(item.metadata.get("alexa").config, is(Map.of("setting1", "value1")));
assertThat(item.metadata.get("homekit").value, is("Lighting"));
assertThat(item.metadata.get("homekit").config, is(nullValue()));
assertThat(item.metadata.get("matter").value, is("OnOffLight"));
assertThat(item.metadata.get("matter").config, is(nullValue()));
// Verify YAML output contains metadata in both forms
modelRepository.addElementsToBeGenerated("items", List.copyOf(items));
ByteArrayOutputStream out = new ByteArrayOutputStream();
modelRepository.generateFileFormat("items", out);
String yaml = out.toString();
// Should contain object form metadata for 'alexa'
assertThat(yaml, containsString("alexa:"));
assertThat(yaml, containsString("value: Switchable"));
assertThat(yaml, containsOnlyOnce("config:"));
assertThat(yaml, containsString("setting1: value1"));
// Should contain short form metadata for 'homekit' and 'matter'
assertThat(yaml, containsString("homekit: Lighting"));
assertThat(yaml, containsString("matter: OnOffLight"));
}
private static Matcher<String> containsOnlyOnce(String substring) {
return new TypeSafeMatcher<String>() {
@Override
protected boolean matchesSafely(String item) {
return StringUtils.countMatches(item, substring) == 1;
}
@Override
@NonNullByDefault({})
public void describeTo(Description description) {
description.appendText("string appearing exactly once: ").appendValue(substring);
}
@Override
@NonNullByDefault({})
protected void describeMismatchSafely(String item, Description mismatchDescription) {
int count = StringUtils.countMatches(item, substring);
mismatchDescription.appendText("was found ").appendValue(count).appendText(" times");
}
};
}
}
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.model.yaml.internal.items;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/**
* Tests for {@link YamlMetadataDTODeserializer}.
*
* @author Jimmy Tanagra - Initial contribution
*/
class YamlMetadataDTODeserializerTest {
private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
@ParameterizedTest
@ValueSource(strings = { "string value", "123", "45.67", "true", "false" })
void shouldDeserializeScalarAsValueWithEmptyConfig(String scalar) throws IOException {
String yaml = "ns: " + scalar;
YamlMetadataDTO dto = mapper.treeToValue(mapper.readTree(yaml).get("ns"), YamlMetadataDTO.class);
assertNotNull(dto);
assertEquals(scalar, dto.getValue());
assertNull(dto.config);
}
@Test
void shouldDeserializeObjectWithValueAndConfig() throws IOException {
String yaml = "ns: { value: bar, config: { a: 1, b: two } }";
YamlMetadataDTO dto = mapper.treeToValue(mapper.readTree(yaml).get("ns"), YamlMetadataDTO.class);
assertNotNull(dto);
assertEquals("bar", dto.getValue());
assertNotNull(dto.config);
assertEquals(1, dto.config.get("a"));
assertEquals("two", dto.config.get("b"));
}
@Test
void shouldDeserializeObjectWithValueAndNoConfig() throws IOException {
String yaml = "ns: { value: bar }";
YamlMetadataDTO dto = mapper.treeToValue(mapper.readTree(yaml).get("ns"), YamlMetadataDTO.class);
assertNotNull(dto);
assertEquals("bar", dto.getValue());
assertNull(dto.config);
}
@Test
void shouldDeserializeObjectWithNoValueAndConfig() throws IOException {
String yaml = "ns: { config: { a: 1, b: two } }";
YamlMetadataDTO dto = mapper.treeToValue(mapper.readTree(yaml).get("ns"), YamlMetadataDTO.class);
assertNotNull(dto);
assertEquals("", dto.getValue());
assertNotNull(dto.config);
assertEquals(1, dto.config.get("a"));
assertEquals("two", dto.config.get("b"));
}
@Test
void shouldDeserializeEmptyObjectAsEmptyDto() throws IOException {
String yaml = "ns: { }";
YamlMetadataDTO dto = mapper.treeToValue(mapper.readTree(yaml).get("ns"), YamlMetadataDTO.class);
assertNotNull(dto);
assertEquals("", dto.getValue());
assertNull(dto.config);
}
@ParameterizedTest
@ValueSource(strings = { "null", "", "''" })
void shouldDeserializeNullAndEmptyStringAsEmptyDto(String scalar) throws IOException {
String yaml = "ns: " + scalar;
YamlMetadataDTO dto = mapper.treeToValue(mapper.readTree(yaml).get("ns"), YamlMetadataDTO.class);
assertNotNull(dto);
assertEquals("", dto.getValue());
assertNull(dto.config);
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.model.yaml.internal.items;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
/**
* Tests for {@link YamlMetadataDTOSerializer}.
*
* @author Jimmy Tanagra - Initial contribution
*/
class YamlMetadataDTOSerializerTest {
private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
@Test
void testSerializeScalarWhenConfigEmpty() throws Exception {
YamlMetadataDTO dto = new YamlMetadataDTO();
dto.value = "scalarValue";
dto.config = null;
String yaml = mapper.writeValueAsString(dto).trim();
assertEquals("--- \"scalarValue\"", yaml);
}
@Test
void testSerializeScalarWhenConfigEmptyMap() throws Exception {
YamlMetadataDTO dto = new YamlMetadataDTO();
dto.value = "scalarValue";
dto.config = new HashMap<>();
String yaml = mapper.writeValueAsString(dto).trim();
assertEquals("--- \"scalarValue\"", yaml);
}
@Test
void testSerializeObjectWhenConfigPresent() throws Exception {
YamlMetadataDTO dto = new YamlMetadataDTO();
dto.value = "objectValue";
Map<String, Object> config = new HashMap<>();
config.put("foo", "bar");
dto.config = config;
String yaml = mapper.writeValueAsString(dto).trim();
System.out.println("YAML output:\n" + yaml);
// Check for document start and all expected fields
assert yaml.startsWith("---");
assert yaml.contains("value: \"objectValue\"");
assert yaml.contains("config:");
assert yaml.contains("foo: \"bar\"");
}
}
@@ -0,0 +1,13 @@
version: 1
items:
# This item has metadata in both short and object forms
ValidItem:
type: Switch
metadata:
alexa:
value: Switchable
config:
setting1: value1
homekit:
value: Lighting
matter: OnOffLight
@@ -0,0 +1,12 @@
version: 1
items:
# This item has the object-form metadata
ValidItem:
type: Switch
metadata:
alexa:
value: Switchable
config:
setting1: value1
homekit:
value: Lighting
@@ -0,0 +1,7 @@
version: 1
items:
TestSwitch:
type: Switch
metadata:
alexa: Switchable
homekit: Lighting