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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

@JsonRootName(value = "data")
@Schema(name="DataWrapper", description="DataWrapper with items")
Expand All @@ -20,10 +21,15 @@ public class DataWrapper<T> {
// Bounded on the request side: every create/update/delete endpoint that is not GraphDataWrapper
// binds this envelope, and an unbounded items[] is how a caller turns many small entities into
// bulk storage. Responses are never bean-validated, so paging is unaffected.
//
// A List, not a Collection: items[] is an ordered JSON array on the wire, the order is the one
// the caller asked for (sort_by, or insertion for a bulk write), and every reader wants to
// address it by position. Typing it as Collection threw that away and left callers writing
// getItems().iterator().next() to read the first of one.
@JacksonXmlElementWrapper(useWrapping = false)
@Valid
@Size(max = FieldLimits.BATCH_ITEMS_MAX)
private Collection<T> items = new ArrayList<>();
private List<T> items = new ArrayList<>();

/**
* Policy warnings raised while writing these items, or null when there were none.
Expand Down Expand Up @@ -62,12 +68,22 @@ public DataWrapper<T> setNextCursor(String nextCursor) {
}

@JsonInclude(JsonInclude.Include.ALWAYS)
public Collection<T> getItems() {
public List<T> getItems() {
return items;
}

/**
* Still takes any {@link Collection}, so the callers that hand one that is not a list — the
* graph endpoints, whose nodes and relations are sets — keep compiling.
*
* <p>A list is kept as it is rather than copied, so a caller that mutates the list it passed
* still sees the envelope change, exactly as before this field was narrowed. Only a non-list
* is copied, and it takes that collection's iteration order. {@code null} is still stored as
* {@code null}: an inbound {@code "items": null} has to stay distinguishable from an empty
* array, because that is what several request validators test for.
*/
public DataWrapper<T> setItems(Collection<T> items) {
this.items = items;
this.items = items == null || items instanceof List<T> ? (List<T>) items : new ArrayList<>(items);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: Apache-2.0
package ai.intellistream.datahub.api.responses;

import org.junit.jupiter.api.Test;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;

/**
* {@code items} is a {@link List}, and the three things that had to keep working when it stopped
* being a {@code Collection}: a caller may still hand over a set, a list it hands over is still the
* one the envelope holds, and {@code "items": null} is still null rather than empty.
*
* <p>The narrowing is what lets a caller read the first of one result by position. Reading it
* through {@code getItems().iterator().next()} was the only way while this was a {@code Collection},
* and every SDK example that reads a single series had to do exactly that.
*/
class DataWrapperItemsListTest {

private final JsonMapper mapper = JsonMapper.builder().build();

@Test
void itemsIsAListAndIsAddressableByPosition() {
DataWrapper<String> wrapper = new DataWrapper<String>().setItems(List.of("first", "second"));

assertInstanceOf(List.class, wrapper.getItems());
assertEquals("first", wrapper.getItems().get(0));
assertEquals("second", wrapper.getItems().get(1));
}

@Test
void aListIsHeldAsGivenRatherThanCopied() {
// Callers that mutate the list they passed still see the envelope change, as before.
List<String> given = new ArrayList<>(List.of("a"));
DataWrapper<String> wrapper = new DataWrapper<String>().setItems(given);

assertSame(given, wrapper.getItems());
given.add("b");
assertEquals(List.of("a", "b"), wrapper.getItems());
}

@Test
void aSetIsAcceptedAndKeepsItsIterationOrder() {
// The graph endpoints hand over sets; DataWrapper still takes any Collection.
DataWrapper<String> wrapper =
new DataWrapper<String>().setItems(new LinkedHashSet<>(List.of("x", "y", "z")));

assertEquals(List.of("x", "y", "z"), wrapper.getItems());
}

@Test
void nullItemsStayNull() {
// Several request validators test `getItems() == null` to reject a body that sent
// "items": null, so an absent array must not arrive as an empty one.
assertNull(new DataWrapper<String>().setItems(null).getItems());

DataWrapper<String> parsed = mapper.readValue(
"{\"items\":null}", new TypeReference<DataWrapper<String>>() {});
assertNull(parsed.getItems());
}

@Test
void theWireShapeIsUnchanged() {
DataWrapper<String> wrapper = new DataWrapper<String>().setItems(List.of("x", "y"));
assertEquals("{\"items\":[\"x\",\"y\"]}", mapper.writeValueAsString(wrapper));

DataWrapper<String> parsed = mapper.readValue(
"{\"items\":[\"x\",\"y\"],\"nextCursor\":\"c1\"}",
new TypeReference<DataWrapper<String>>() {});
assertEquals(List.of("x", "y"), parsed.getItems());
assertEquals("c1", parsed.getNextCursor());
}
}
Loading