diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 540c45fa..11897a38 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -4,7 +4,7 @@ me.qoomon maven-git-versioning-extension - 9.7.0 + 9.12.3 diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/common/RestApi.java b/server/src/main/java/au/org/aodn/ogcapi/server/common/RestApi.java index 316c7276..bbfbf8a9 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/common/RestApi.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/common/RestApi.java @@ -30,6 +30,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import java.math.BigDecimal; import java.net.URI; @@ -58,13 +59,13 @@ public class RestApi implements ApiApi, DefaultApi, ConformanceApi { @Override public ResponseEntity apiGet(String f) { switch(OGCMediaTypeMapper.valueOf(f.toLowerCase())) { - case json: { + case json -> { return ResponseEntity .status(HttpStatus.TEMPORARY_REDIRECT) .location(URI.create("/api/v1/ogc/api-docs/v3")) .build(); } - default: { + default -> { return ResponseEntity .status(HttpStatus.TEMPORARY_REDIRECT) .location(URI.create("/api/v1/ogc/swagger-ui/index.html")) @@ -134,7 +135,7 @@ public ResponseEntity getConformanceDeclaration(String f) { // "text/html" }, method = RequestMethod.GET) - public ResponseEntity getCollections( + public ResponseEntity getCollections( @Parameter(in = ParameterIn.QUERY, description = "Property to be return" ,schema=@Schema()) @Valid @RequestParam(value = "properties", required = false) List properties, @Parameter(in = ParameterIn.QUERY, description = "Only records that have a geometry that intersects the bounding box are selected. The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (height or depth): * Lower left corner, coordinate axis 1 * Lower left corner, coordinate axis 2 * Minimum value, coordinate axis 3 (optional) * Upper right corner, coordinate axis 1 * Upper right corner, coordinate axis 2 * Maximum value, coordinate axis 3 (optional) The coordinate reference system of the values is WGS 84 long/lat (http://www.opengis.net/def/crs/OGC/1.3/CRS84) unless a different coordinate reference system is specified in the parameter `bbox-crs`. For WGS 84 longitude/latitude the values are in most cases the sequence of minimum longitude, minimum latitude, maximum longitude and maximum latitude. However, in cases where the box spans the antimeridian the first value (west-most box edge) is larger than the third value (east-most box edge). If the vertical axis is included, the third and the sixth number are the bottom and the top of the 3-dimensional bounding box. If a record has multiple spatial geometry properties, it is the decision of the server whether only a single spatial geometry property is used to determine the extent or all relevant geometries." ,schema=@Schema()) @@ -162,14 +163,13 @@ public ResponseEntity getCollections( // the same, we append the bbox parameter to the filter in case user use this parameter filter = OGCApiService.processBBoxParameter(CQLFields.geometry.name(), bbox, filter); } - return commonService.getCollectionList( + return commonService.writeCollectionList( q, filter, properties, sortBy, - OGCMediaTypeMapper.json, CQLCrsType.convertFromUrl(crs), - stacToCollection::convert); + stacToCollection::convertOne); } else { List reasons = new ArrayList<>(); diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/mapper/StacToCollections.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/mapper/StacToCollections.java index 435b7800..99028588 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/mapper/StacToCollections.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/mapper/StacToCollections.java @@ -23,7 +23,7 @@ public abstract class StacToCollections implements Converter model, Filter filter) { List collections = model.getCollections().stream() - .map(m -> getCollection(m, filter, hostname)) + .map(m -> convertOne(m, filter)) .toList(); ExtendedCollections result = new ExtendedCollections(); @@ -36,4 +36,8 @@ public Collections convert(ElasticSearch.SearchResult model return result; } + + public Collection convertOne(StacCollectionModel model, Filter filter) { + return getCollection(model, filter, hostname); + } } diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/CollectionsResponseWriter.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/CollectionsResponseWriter.java new file mode 100644 index 00000000..12d1aec7 --- /dev/null +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/CollectionsResponseWriter.java @@ -0,0 +1,46 @@ +package au.org.aodn.ogcapi.server.core.service; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +/** + * Writes the same JSON object as {@code ExtendedCollections}, one collection at a time. + * Field order is {@code links}, {@code collections}, {@code total}, {@code search_after}. + */ +public final class CollectionsResponseWriter { + + private CollectionsResponseWriter() { + } + + /** + * Opens {@code {"links":[],"collections":[} and leaves the generator inside the array. + */ + public static JsonGenerator open(OutputStream output, ObjectMapper mapper) throws IOException { + JsonGenerator generator = mapper.getFactory().createGenerator(output); + generator.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + generator.setCodec(mapper); + generator.writeStartObject(); + generator.writeFieldName("links"); + generator.writeStartArray(); + generator.writeEndArray(); + generator.writeFieldName("collections"); + generator.writeStartArray(); + return generator; + } + + public static void finish(JsonGenerator generator, Long total, List searchAfter) throws IOException { + generator.writeEndArray(); + if (total != null) { + generator.writeNumberField("total", total); + } + if (searchAfter != null) { + generator.writeFieldName("search_after"); + generator.writeObject(searchAfter); + } + generator.writeEndObject(); + } +} diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java index 868b477e..46c839df 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearch.java @@ -28,6 +28,7 @@ import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.function.Consumer; import java.util.function.Supplier; import static au.org.aodn.ogcapi.server.core.configuration.CacheConfig.ELASTIC_SEARCH_UUID_ONLY; @@ -574,6 +575,53 @@ protected Supplier buildParameterSearchRequestSupplier( maxSize); } + @Override + public void validateByParameters(List keywords, String cql, List properties, String sortBy, CQLCrsType coor) throws CQLException { + buildParameterSearchRequestSupplier(keywords, cql, properties, sortBy, coor); + if (cql != null) { + readPageSize(cql, coor); + } + } + + @Override + public ElasticSearchBase.SearchResult visitByParameters( + List keywords, + String cql, + List properties, + String sortBy, + CQLCrsType coor, + Consumer consumer) throws CQLException { + + boolean unfiltered = (keywords == null || keywords.isEmpty()) && cql == null; + return visitCollectionBy( + buildParameterSearchRequestSupplier(keywords, cql, properties, sortBy, coor), + unfiltered ? null : properties, + unfiltered ? null : readPageSize(cql, coor), + consumer); + } + + /** + * CQL {@code page_size} cap for {@code pageableSearch}. The request supplier applies the same cap + * to each batch; the iterator still needs the total cap when it is larger than one batch. + */ + private Long readPageSize(String cql, CQLCrsType coor) throws CQLException { + if (cql == null) { + return null; + } + CQLToElasticFilterFactory factory = new CQLToElasticFilterFactory<>(coor, CQLFields.class); + CompilerUtil.parseFilter(Language.ECQL, cql, factory); + String raw = factory.getQuerySetting().get(CQLElasticSetting.page_size); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.parseLong(raw); + } + catch (NumberFormatException pe) { + return null; + } + } + @Override public ElasticSearchBase.SearchResult searchByParameters(List keywords, String cql, List properties, String sortBy, CQLCrsType coor) throws CQLException { diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java index 42fab9db..b695f030 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/ElasticSearchBase.java @@ -39,6 +39,7 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -357,23 +358,36 @@ protected SearchResult searchCollectionBy(final List queries, should, filters, properties, searchAfter, sortOptions, score, maxSize ); + List collections = new ArrayList<>(); + SearchResult result = visitCollectionBy(builderSupplier, properties, maxSize, collections::add); + result.setCollections(collections); + return result; + } + + /** + * Same search as {@link #searchCollectionBy}, but each model is passed to {@code consumer} + * and then dropped. The returned result has {@code total} and {@code sortValues} only. + */ + protected SearchResult visitCollectionBy(Supplier builderSupplier, + List properties, + Long maxSize, + Consumer consumer) { try { log.info("Start search {} {}", ZonedDateTime.now(), Thread.currentThread().getName()); Iterable> response = pageableSearch(builderSupplier, ObjectNode.class, maxSize); SearchResult result = new SearchResult<>(); - result.collections = new ArrayList<>(); result.total = countRecordsHit(builderSupplier); List lastSortValue = null; - for(Hit i : response) { - if(i != null) { + for (Hit i : response) { + if (i != null) { StacCollectionModel model = this.formatResult(i.source()); // Use cache value of noland_geometry if user request centroid - if(properties != null && properties.contains(CQLFields.centroid.name())) { + if (properties != null && properties.contains(CQLFields.centroid.name())) { StacCollectionModel cache = cacheNoLandGeometry.getAllNoLandGeometry().get(model.getUuid()); - if(cache != null && cache.getSummaries() != null) { - if(model.getSummaries() == null) { + if (cache != null && cache.getSummaries() != null) { + if (model.getSummaries() == null) { model.setSummaries(cache.getSummaries()); } else { @@ -381,36 +395,39 @@ protected SearchResult searchCollectionBy(final List } } } - result.collections.add(model); + consumer.accept(model); lastSortValue = i.sort(); } } log.info("End search {} {}", ZonedDateTime.now(), Thread.currentThread().getName()); - // Return the last sort value if exist - if(lastSortValue != null && !lastSortValue.isEmpty()) { - List values = new ArrayList<>(); - for (FieldValue value : lastSortValue) { - if (value.isBoolean()) { - values.add(value.booleanValue()); - } else if (value.isDouble()) { - values.add(value.doubleValue()); - } else if (value.isLong()) { - values.add(value.longValue()); - } else if (value.isString()) { - values.add(STR_INDICATOR + value.stringValue()); - } - } - result.setSortValues(values); + if (lastSortValue != null && !lastSortValue.isEmpty()) { + result.setSortValues(toSortValues(lastSortValue)); } return result; } - catch(ElasticsearchException ee) { + catch (ElasticsearchException ee) { log.warn("Elastic exception on query, reason is {}", ee.error().rootCause()); throw ee; } } + private static List toSortValues(List lastSortValue) { + List values = new ArrayList<>(); + for (FieldValue value : lastSortValue) { + if (value.isBoolean()) { + values.add(value.booleanValue()); + } else if (value.isDouble()) { + values.add(value.doubleValue()); + } else if (value.isLong()) { + values.add(value.longValue()); + } else if (value.isString()) { + values.add(STR_INDICATOR + value.stringValue()); + } + } + return values; + } + protected JsonNode explainCollectionBy(Supplier requestSupplier, boolean simplified) throws IOException { SearchRequest request = requestSupplier.get() @@ -583,111 +600,6 @@ public Hit next() { } return Collections.emptySet(); } - /** - * There is a limit of how many record a query can return, this mean the record return may not be full set, you - * need to keep loading until you reach the end of records - * - * @param requestBuilder, assume it is sorted with order, what order isn't important, as long as it is sorted - * @param clazz - The type - * @return - The items that matches the query mentioned in the requestBuilder - * @param A generic type for Elastic query - */ - protected Iterable pageableAggregation( - BiFunction, Map, SearchRequest.Builder> requestBuilder, - Class clazz, - Map arguments, - Long maxSize) { - try { - SearchRequest sr = requestBuilder.apply(arguments, null).build(); - log.debug("Final elastic aggregation payload {}", sr); - - final AtomicLong count = new AtomicLong(0); - final AtomicReference> response = new AtomicReference<>( - esClient.search(sr, clazz) - ); - - return () -> new Iterator<>() { - private int index = 0; - - @Override - public boolean hasNext() { - // No need continue if we already hit the end - if(maxSize != null) { - return count.get() < maxSize; - } - - Aggregate ags = response.get() - .aggregations() - .get(arguments.get("aggKey").stringValue()) - .nested() - .aggregations() - .get(arguments.get("aggKey").stringValue()); - - Buckets stb = ags.composite().buckets(); - - // If we hit the end, that means we have iterated to end of page. - if (index < stb.array().size()) { - return true; - } - else { - // If last index is zero that mean nothing found already, so no need to look more - if (index == 0) return false; - - // Load next batch - try { - // Get the last sorted value from the last batch - // Use the last builder and append the searchAfter values - SearchRequest request = requestBuilder.apply(arguments, ags.composite().afterKey()).build(); - log.debug("Final elastic aggregation payload {}", request.toString()); - - response.set(esClient.search(request, clazz)); - // Reset counter from start - index = 0; - - ags = response.get() - .aggregations() - .get(arguments.get("aggKey").stringValue()) - .nested() - .aggregations() - .get(arguments.get("aggKey").stringValue()); - - stb = ags.composite().buckets(); - - return index < stb.array().size(); - } - catch(IOException ieo) { - throw new RuntimeException(ieo); - } - } - } - - @Override - public T next() { - count.incrementAndGet(); - - Aggregate ags = response.get() - .aggregations() - .get(arguments.get("aggKey").stringValue()) - .nested() - .aggregations() - .get(arguments.get("aggKey").stringValue()); - - Buckets stb = ags.composite().buckets(); - - if(index < stb.array().size()) { - return clazz.cast(stb.array().get(index++)); - } - else { - return null; - } - } - }; - } - catch(Exception e) { - log.error("Fail to fetch record", e); - } - return Collections.emptySet(); - } protected StacCollectionModel formatResult(ObjectNode nodes) { try { diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/OGCApiService.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/OGCApiService.java index b96b7d5b..ae795c99 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/OGCApiService.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/OGCApiService.java @@ -1,22 +1,28 @@ package au.org.aodn.ogcapi.server.core.service; +import au.org.aodn.ogcapi.features.model.Collection; import au.org.aodn.ogcapi.features.model.FeatureCollectionGeoJSON; import au.org.aodn.ogcapi.server.core.exception.CustomException; import au.org.aodn.stac.model.StacCollectionModel; import au.org.aodn.ogcapi.server.core.model.enumeration.CQLCrsType; import au.org.aodn.ogcapi.server.core.model.enumeration.FeatureId; -import au.org.aodn.ogcapi.server.core.model.enumeration.OGCMediaTypeMapper; import au.org.aodn.ogcapi.server.core.parser.stac.CQLToStacFilterFactory; import au.org.aodn.ogcapi.server.tile.RestApi; import org.geotools.filter.text.commons.CompilerUtil; import org.geotools.filter.text.commons.Language; import org.opengis.filter.Filter; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import java.io.IOException; +import java.io.UncheckedIOException; import java.math.BigDecimal; import java.util.List; import java.util.function.BiFunction; @@ -31,6 +37,9 @@ public abstract class OGCApiService { @Autowired protected Search search; + @Autowired + protected ObjectMapper mapper; + /** * You can find conformance id * here @@ -41,7 +50,7 @@ public abstract class OGCApiService { public ResponseEntity getFeature(String collectionId, FeatureId fid, List properties, - String filter) throws Exception { + String filter) { switch(fid) { default -> { // Individual item @@ -49,60 +58,68 @@ public ResponseEntity getFeature(String collectionId, } } } - - - public ResponseEntity getCollectionList(List keywords, - String filter, - List properties, - String sortBy, - OGCMediaTypeMapper f, - CQLCrsType coor, - BiFunction, Filter, R> converter) { + /** + * This function is used to generate the collection list but Streaming the output, this will lower the use of memory + * because we do not create the whole collection in memory before writing to client. + */ + public ResponseEntity writeCollectionList(List keywords, + String filter, + List properties, + String sortBy, + CQLCrsType coor, + BiFunction toCollection) { try { - switch (f) { - case json -> { - ElasticSearchBase.SearchResult result = search.searchByParameters(keywords, filter, properties, sortBy, coor); - - CQLToStacFilterFactory factory = CQLToStacFilterFactory.builder() - .cqlCrsType(coor) - .build(); - - Filter cql = null; - - try { - cql = filter != null ? CompilerUtil.parseFilter(Language.CQL, filter, factory) : null; - } - catch(Exception ex) { - // Do nothing - } - - return ResponseEntity.ok() - .body(converter.apply(result, cql)); - } - default -> { - /* - * https://opengeospatial.github.io/ogcna-auto-review/19-072.html - * The OGC API — Common Standard does not mandate a specific encoding or format for - * representations of resources. However, both HTML and JSON are commonly used encodings for spatial - * data on the web. The HTML and JSON requirements classes specify the encoding of resource - * representations using: - * HTML - * JSON - * Neither of these encodings is mandatory. An implementer of the API-Common Standard may decide - * to implement other encodings instead of, or in addition to, these two. - */ - // TODO: html return - return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); - } - } + search.validateByParameters(keywords, filter, properties, sortBy, coor); } - catch(IllegalArgumentException iae) { - // We do not need to wrap it as it is already RuntimeException + catch (IllegalArgumentException iae) { throw iae; } - catch(Exception e) { + catch (Exception e) { throw new CustomException(e.getMessage(), e); } + + Filter cql = null; + try { + if (filter != null) { + CQLToStacFilterFactory factory = CQLToStacFilterFactory.builder() + .cqlCrsType(coor) + .build(); + cql = CompilerUtil.parseFilter(Language.CQL, filter, factory); + } + } + catch (Exception ex) { + // Do nothing, same as getCollectionList + } + Filter itemFilter = cql; + + StreamingResponseBody body = output -> { + try (JsonGenerator generator = CollectionsResponseWriter.open(output, mapper)) { + ElasticSearchBase.SearchResult meta; + try { + meta = search.visitByParameters( + keywords, filter, properties, sortBy, coor, model -> { + try { + generator.writeObject(toCollection.apply(model, itemFilter)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + List searchAfter = meta.getSortValues() == null + ? null + : meta.getSortValues().stream().map(String::valueOf).toList(); + CollectionsResponseWriter.finish(generator, meta.getTotal(), searchAfter); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + }; + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(body); } /** * Rewrite the datetime parameter to CQL filter as this can be handled by CQL directly. diff --git a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java index 46dc6bbd..16f2ce5d 100644 --- a/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java +++ b/server/src/main/java/au/org/aodn/ogcapi/server/core/service/Search.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; +import java.util.function.Consumer; public interface Search { ElasticSearchBase.SearchResult searchCollectionWithGeometry(List ids, String sortBy) throws Exception; @@ -23,6 +24,25 @@ ElasticSearchBase.SearchResult searchByParameters( String sortBy, CQLCrsType coor ) throws Exception; + /** + * Rejects a bad filter or property list before a response is committed. + */ + void validateByParameters( + List targets, + String filter, + List properties, + String sortBy, + CQLCrsType coor + ) throws Exception; + + ElasticSearchBase.SearchResult visitByParameters( + List targets, + String filter, + List properties, + String sortBy, + CQLCrsType coor, + Consumer consumer + ) throws Exception; JsonNode explainByParameters( List targets, diff --git a/server/src/main/resources/application.yaml b/server/src/main/resources/application.yaml index 9238a48a..1b0ebde8 100644 --- a/server/src/main/resources/application.yaml +++ b/server/src/main/resources/application.yaml @@ -21,8 +21,8 @@ ogcapi: elasticsearch: index: name: dev_portal_records - # search_after batch for full-document / geometry / links queries - pageSize: 1000 + # search_after batch for full-document / geometry / links queries, 800 match UI default + pageSize: 800 # search_after batch when properties is a small field list (id, temporal, title, ...) lightweightPageSize: 7000 vocabs_index: diff --git a/server/src/test/java/au/org/aodn/ogcapi/server/core/service/CollectionsResponseWriterTest.java b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/CollectionsResponseWriterTest.java new file mode 100644 index 00000000..2777d65c --- /dev/null +++ b/server/src/test/java/au/org/aodn/ogcapi/server/core/service/CollectionsResponseWriterTest.java @@ -0,0 +1,46 @@ +package au.org.aodn.ogcapi.server.core.service; + +import au.org.aodn.ogcapi.features.model.Collection; +import au.org.aodn.ogcapi.server.core.model.ExtendedCollection; +import au.org.aodn.ogcapi.server.core.model.ExtendedCollections; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CollectionsResponseWriterTest { + + @Test + void streamedJsonMatchesExtendedCollections() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + mapper.setDefaultPropertyInclusion(JsonInclude.Value.construct( + JsonInclude.Include.NON_NULL, + JsonInclude.Include.USE_DEFAULTS)); + + ExtendedCollection first = new ExtendedCollection(); + first.setId("a"); + first.setTitle("alpha"); + ExtendedCollection second = new ExtendedCollection(); + second.setId("b"); + second.setTitle("beta"); + + ExtendedCollections expected = new ExtendedCollections(); + expected.setCollections(List.of(first, second)); + expected.setTotal(2L); + expected.setSearchAfter(List.of("1.0", "str:b")); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (var generator = CollectionsResponseWriter.open(output, mapper)) { + for (Collection collection : List.of(first, second)) { + generator.writeObject(collection); + } + CollectionsResponseWriter.finish(generator, 2L, List.of("1.0", "str:b")); + } + + assertEquals(mapper.writeValueAsString(expected), output.toString()); + } +}