list = new ArrayList<>();
+ jp.clearCurrentToken();
+ while (jp.nextToken() != JsonToken.END_ARRAY) {
+ list.add(jp.readValueAs(memberClass));
+ }
+ return creator.apply(list);
+ } else {
+ throw new IllegalStateException();
+ }
+ }
+ }
+}
diff --git a/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/Jackson3Mapper.java b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/Jackson3Mapper.java
new file mode 100644
index 000000000..3cdb61b96
--- /dev/null
+++ b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/Jackson3Mapper.java
@@ -0,0 +1,413 @@
+/*
+ * Copyright (C) 2012 All rights reserved
+ * VPRO The Netherlands
+ */
+package nl.vpro.jackson3;
+
+import lombok.AccessLevel;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.StreamReadFeature;
+import tools.jackson.databind.*;
+import tools.jackson.databind.cfg.EnumFeature;
+import tools.jackson.databind.introspect.AnnotationIntrospectorPair;
+import tools.jackson.databind.introspect.JacksonAnnotationIntrospector;
+import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.ser.PropertyFilter;
+import tools.jackson.databind.ser.std.SimpleFilterProvider;
+import tools.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector;
+
+import java.lang.reflect.InvocationTargetException;
+import java.net.http.HttpResponse;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.google.common.annotations.Beta;
+
+import nl.vpro.jackson.Views;
+import nl.vpro.logging.simple.SimpleLogger;
+import nl.vpro.util.LoggingInputStream;
+
+import static nl.vpro.logging.simple.Slf4jSimpleLogger.slf4j;
+import static tools.jackson.core.json.JsonReadFeature.*;
+import static tools.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;
+import static tools.jackson.databind.MapperFeature.DEFAULT_VIEW_INCLUSION;
+import static tools.jackson.databind.MapperFeature.USE_WRAPPER_NAME_AS_PROPERTY_NAME;
+import static tools.jackson.databind.SerializationFeature.INDENT_OUTPUT;
+
+/**
+ * A wrapper around a Jackson {@link ObjectMapper} with one {@link ObjectReader} and one {@link ObjectWriter} configured with specific views.
+ *
+ * In the jackson2 version this was itself an ObjectMapper which could have been configured with default views and so on.
+ * In jackson3 you can actually define multiple sets of configuration in the mapper itself (with serializationContexts) , but that is a bit more cumbersome to use, and deviates
+ * more from the original idea.
+ *
+ * @author Rico
+ * @author Michiel Meeuwissen
+ * @since 5.14
+ */
+@Slf4j
+
+public class Jackson3Mapper {
+
+ private static boolean loggedAboutAvro = false;
+
+ private static final SimpleFilterProvider FILTER_PROVIDER = new SimpleFilterProvider();
+
+ public static final Jackson3Mapper INSTANCE = Jackson3Mapper.builder("instance").build();
+ public static final Jackson3Mapper LENIENT = buildLenientInstance();
+ public static final Jackson3Mapper STRICT = buildStrictInstance();
+ public static final Jackson3Mapper PRETTY_STRICT = buildPrettyStrictInstance();
+ public static final Jackson3Mapper PRETTY = buildPrettyInstance();
+ public static final Jackson3Mapper PUBLISHER = buildPublisherInstance();
+ public static final Jackson3Mapper PRETTY_PUBLISHER = buildPublisherInstance();
+ public static final Jackson3Mapper BACKWARDS_PUBLISHER = buildBackwardsPublisherInstance();
+
+ public static final Jackson3Mapper MODEL = buildModelInstance();
+ public static final Jackson3Mapper MODEL_AND_NORMAL = buildModelAndNormalInstance();
+
+
+ public static Jackson3Mapper getInstance() {
+ return INSTANCE;
+ }
+ public static Jackson3Mapper getLenientInstance() {
+ return LENIENT;
+ }
+ public static Jackson3Mapper getPrettyInstance() {
+ return PRETTY;
+ }
+
+ public static Jackson3Mapper getPrettyStrictInstance() {
+ return PRETTY_STRICT;
+ }
+
+ public static Jackson3Mapper getStrictInstance() {
+ return STRICT;
+ }
+
+ public static Jackson3Mapper getPublisherInstance() {
+ return PUBLISHER;
+ }
+
+ public static Jackson3Mapper getPrettyPublisherInstance() {
+ return PRETTY_PUBLISHER;
+ }
+
+ public static Jackson3Mapper getBackwardsPublisherInstance() {
+ return BACKWARDS_PUBLISHER;
+ }
+
+ @Beta
+ public static Jackson3Mapper getModelInstance() {
+ return MODEL;
+ }
+
+ @Beta
+ public static Jackson3Mapper getModelAndNormalInstance() {
+ return MODEL_AND_NORMAL;
+ }
+
+
+
+ private static final ThreadLocal THREAD_LOCAL = ThreadLocal.withInitial(() -> INSTANCE);
+
+
+ private static Jackson3Mapper buildLenientInstance() {
+ return Jackson3Mapper.builder("lenient")
+ .forward()
+ .configure(Jackson3Mapper::lenient)
+ .build();
+ }
+
+ private static void lenient(JsonMapper.Builder builder) {
+ builder.enable(EnumFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL);
+ builder.disable(FAIL_ON_UNKNOWN_PROPERTIES);
+ builder.enable(ALLOW_UNQUOTED_PROPERTY_NAMES);
+ builder.enable(ALLOW_SINGLE_QUOTES);
+ }
+ private static void strict(JsonMapper.Builder builder) {
+ builder.enable(FAIL_ON_UNKNOWN_PROPERTIES);
+ }
+
+ private static Jackson3Mapper buildPrettyInstance() {
+ Jackson3Mapper.Builder pretty = Jackson3Mapper
+ .builder("pretty")
+ .configure(b -> {
+ lenient(b);
+ b.enable(INDENT_OUTPUT);
+ });
+ return pretty.build();
+ }
+
+ private static Jackson3Mapper buildPrettyStrictInstance() {
+
+ return Jackson3Mapper
+ .builder("pretty_strict")
+ .configure(b -> {
+ strict(b);
+ b.enable(INDENT_OUTPUT);
+ b.enable(FAIL_ON_UNKNOWN_PROPERTIES);
+ })
+ .forward()
+ .build()
+ ;
+ }
+
+
+ private static Jackson3Mapper buildStrictInstance() {
+ return Jackson3Mapper.builder("strict")
+ .configure(b -> {
+ b.enable(FAIL_ON_UNKNOWN_PROPERTIES);
+ }
+ ).forward()
+ .build();
+ }
+
+ private static Jackson3Mapper buildPublisherInstance() {
+ return Jackson3Mapper.builder("publisher")
+ .serializationView(Views.ForwardPublisher.class)
+ .deserializationView(Views.Forward.class)
+ .build();
+ }
+
+ private static Jackson3Mapper buildPrettyPublisherInstance() {
+ return Jackson3Mapper.builder("pretty_publisher")
+ .serializationView(Views.ForwardPublisher.class)
+ .deserializationView(Views.Forward.class)
+ .configure(b -> b.enable(INDENT_OUTPUT))
+ .build();
+ }
+
+ private static Jackson3Mapper buildBackwardsPublisherInstance() {
+ return Jackson3Mapper.builder("backwards_publisher")
+ .serializationView(Views.Publisher.class)
+ .deserializationView(Views.Normal.class)
+ .configure(b -> b.enable(INDENT_OUTPUT))
+ .build();
+ }
+
+ private static Jackson3Mapper buildModelInstance() {
+ return Jackson3Mapper.builder("model")
+ .serializationView(Views.Model.class)
+ .build();
+ }
+
+ private static Jackson3Mapper buildModelAndNormalInstance() {
+ return Jackson3Mapper.builder("model_and_normal")
+ .serializationView(Views.ModelAndNormal.class)
+ .build();
+
+ }
+
+ public static Jackson3Mapper getThreadLocal() {
+ return THREAD_LOCAL.get();
+ }
+ public static void setThreadLocal(Jackson3Mapper set) {
+ if (set == null) {
+ THREAD_LOCAL.remove();
+ } else {
+ THREAD_LOCAL.set(set);
+ }
+ }
+
+ @SneakyThrows({JacksonException.class})
+ public static T lenientTreeToValue(JsonNode jsonNode, Class clazz) {
+ return buildLenientInstance().reader().treeToValue(jsonNode, clazz);
+ }
+
+ private final JsonMapper mapper;
+ private final ObjectWriter writer;
+ private final ObjectReader reader;
+ private final String toString;
+
+ @lombok.Builder(
+ builderMethodName = "_builder",
+ buildMethodName = "_build",
+ access = AccessLevel.PRIVATE)
+ private Jackson3Mapper(
+ String toString,
+ JsonMapper mapper,
+ Class> serializationView,
+ Class> deserializationView) {
+ this.mapper = mapper;
+ this.writer = mapper.writerWithView(serializationView == null ? Views.Normal.class : serializationView);
+ this.reader = mapper.readerWithView(deserializationView == null ? Views.Normal.class : deserializationView);
+ this.toString = toString;
+ }
+
+ public static void configureMapper(Jackson3Mapper.Builder mapper) {
+ configureMapper(mapper, m -> true);
+ }
+
+ public static void configureMapper(Jackson3Mapper.Builder builder, Predicate filter) {
+
+ builder.mapperBuilder.filterProvider(FILTER_PROVIDER);
+
+ AnnotationIntrospector introspector = new AnnotationIntrospectorPair(
+ new JacksonAnnotationIntrospector(),
+ new JakartaXmlBindAnnotationIntrospector(false)
+ );
+
+ builder.mapperBuilder.changeDefaultPropertyInclusion(v -> v.withValueInclusion(JsonInclude.Include.NON_EMPTY));
+ builder.mapperBuilder.annotationIntrospector(introspector);
+
+ builder.mapperBuilder.disable(FAIL_ON_UNKNOWN_PROPERTIES); // This seems a good idea when reading from couchdb or so, but when reading user supplied forms, it is confusing not getting errors.
+
+ builder.mapperBuilder.enable(ALLOW_SINGLE_QUOTES);
+ builder.mapperBuilder.enable(ALLOW_UNQUOTED_PROPERTY_NAMES);
+ builder.mapperBuilder.enable(USE_WRAPPER_NAME_AS_PROPERTY_NAME);
+ builder.mapperBuilder.enable(ALLOW_JAVA_COMMENTS);
+ builder.mapperBuilder.enable(ALLOW_LEADING_ZEROS_FOR_NUMBERS);
+
+
+ // actually a breaking change in jackson3, this defaults to false now.
+ builder.mapperBuilder.enable(DEFAULT_VIEW_INCLUSION);
+
+ register(builder, filter, new DateModule());
+
+
+ //SimpleModule module = new SimpleModule();
+ //module.setDeserializerModifier(new AfterUnmarshalModifier());
+ //mapper.registerModule(module);
+
+ try {
+ Class> avro = Class.forName("nl.vpro.jackson3.SerializeAvroModule");
+ register(builder, filter, (tools.jackson.databind.JacksonModule) avro.getDeclaredConstructor().newInstance());
+ } catch (ClassNotFoundException ncdfe) {
+ if (! loggedAboutAvro) {
+ log.debug("SerializeAvroModule could not be registered because: {} {}", ncdfe.getClass().getName(), ncdfe.getMessage());
+ }
+ loggedAboutAvro = true;
+ } catch (IllegalAccessException | InstantiationException | InvocationTargetException | NoSuchMethodException e) {
+ log.error(e.getMessage(), e);
+ loggedAboutAvro = true;
+ }
+
+ try {
+ Class> guava = Class.forName("nl.vpro.jackson3.GuavaRangeModule");
+ register(builder, filter, (tools.jackson.databind.JacksonModule) guava.getDeclaredConstructor().newInstance());
+ } catch (ClassNotFoundException ncdfe) {
+ log.debug(ncdfe.getMessage());
+ } catch (IllegalAccessException | InstantiationException | InvocationTargetException | NoSuchMethodException e) {
+ log.error(e.getMessage(), e);
+ }
+ }
+
+ public static void addFilter(String key, PropertyFilter filter) {
+ FILTER_PROVIDER.addFilter(key, filter);
+ log.info("Installed filter {} -> {}", key, filter);
+ }
+
+ private static void register(Jackson3Mapper.Builder mapper, Predicate predicate, JacksonModule module) {
+ if (predicate.test(module)) {
+ mapper.mapperBuilder.addModule(module);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return Jackson3Mapper.class.getSimpleName() + " (" + toString + ")";
+ }
+
+ /**
+ * Returns a {@link HttpResponse.BodyHandler} that reads the body as a value of the given type, using this ObjectMapper.
+ * @since 5.11
+ */
+ public HttpResponse.BodyHandler asBodyHandler(Class type) {
+ return asBodyHandler(type, nl.vpro.logging.simple.Level.DEBUG);
+ }
+
+ /**
+ * Returns a {@link HttpResponse.BodyHandler} that reads the body as a value of the given type, using this ObjectMapper.
+ * Note that if logging is enabled at the given level commons-io must be available.
+ * @since 5.11
+ */
+ public HttpResponse.BodyHandler asBodyHandler(Class type, nl.vpro.logging.simple.Level level) {
+
+ return new HttpResponse.BodyHandler() {
+ @Override
+ public HttpResponse.BodySubscriber apply(HttpResponse.ResponseInfo responseInfo) {
+ return HttpResponse.BodySubscribers.mapping(
+ HttpResponse.BodySubscribers.ofInputStream(),
+ body -> {
+ SimpleLogger simple = slf4j(log);
+ if (simple.isEnabled(level)) {
+ body = new LoggingInputStream(simple, body, level);
+ }
+ return mapper.readValue(body, type);
+
+ });
+ }
+ };
+ }
+
+ public JsonMapper mapper() {
+ return mapper;
+ }
+
+ public ObjectWriter writer() {
+ return writer;
+ }
+
+ public ObjectReader reader() {
+ return reader;
+ }
+ public ObjectReader readerFor(Class> clazz) {
+ return reader.forType(clazz);
+ }
+
+ public Builder rebuild() {
+ Builder builder = builder(toString + "'");
+ builder.serializationView(writer.getConfig().getActiveView());
+ builder.deserializationView(reader.getConfig().getActiveView());
+ builder.mapperBuilder = mapper.rebuild();
+ return builder;
+ }
+
+ public static Builder builder(String toString) {
+ return _builder()
+ .toString(toString);
+ }
+
+ public Jackson3Mapper withSourceInLocation() {
+ return rebuild()
+ .toString(toString + " with source in location")
+ .configure(b ->
+ b.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
+ ).build();
+ }
+
+
+
+ public static class Builder {
+ private JsonMapper.Builder mapperBuilder = JsonMapper
+ .builder();
+ {
+ configureMapper(this);
+ }
+
+
+
+
+ public Jackson3Mapper.Builder forward() {
+ return serializationView(Views.Forward.class)
+ .deserializationView(Views.Forward.class);
+ }
+
+
+ public Jackson3Mapper build() {
+ mapper(mapperBuilder.build());
+ return _build();
+ }
+
+ public Builder configure(Consumer consumer) {
+ consumer.accept(mapperBuilder);
+ return this;
+ }
+
+
+ }
+}
+
diff --git a/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/JsonArrayIterator.java b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/JsonArrayIterator.java
new file mode 100644
index 000000000..7faa71e3d
--- /dev/null
+++ b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/JsonArrayIterator.java
@@ -0,0 +1,456 @@
+package nl.vpro.jackson3;
+
+import lombok.*;
+import lombok.extern.slf4j.Slf4j;
+import tools.jackson.core.*;
+import tools.jackson.databind.ObjectReader;
+import tools.jackson.databind.exc.MismatchedInputException;
+import tools.jackson.databind.node.NullNode;
+
+import java.io.*;
+import java.util.*;
+import java.util.function.*;
+
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+
+import com.google.common.collect.PeekingIterator;
+import com.google.common.collect.UnmodifiableIterator;
+
+import nl.vpro.util.CloseableIterator;
+import nl.vpro.util.CountedIterator;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * @author Michiel Meeuwissen
+ * @since 1.0
+ */
+@Slf4j
+public class JsonArrayIterator extends UnmodifiableIterator
+ implements CloseableIterator, PeekingIterator, CountedIterator {
+
+ private final JsonParser jp;
+
+ private final ObjectReader reader;
+
+
+ private T next = null;
+
+ private boolean needsFindNext = true;
+
+ private Boolean hasNext;
+
+ private final BiFunction valueCreator;
+
+ @Getter
+ @Setter
+ private Runnable callback;
+
+ private boolean callBackHasRun = false;
+
+ private final Long size;
+
+ private final Long totalSize;
+
+ private int foundNulls = 0;
+
+ @Setter
+ private Logger logger = log;
+
+ private long count = 0;
+
+ private boolean skipNulls = true;
+
+ private final Listener eventListener;
+
+ public JsonArrayIterator(InputStream inputStream, Class clazz) throws IOException {
+ this(inputStream, clazz, null);
+ }
+
+ public JsonArrayIterator(InputStream inputStream, final Class clazz, Runnable callback) throws IOException {
+ this(inputStream, null, clazz, callback, null, null, null, null, null, null);
+ }
+
+ public JsonArrayIterator(InputStream inputStream, final BiFunction valueCreator) throws IOException {
+ this(inputStream, valueCreator, null, null, null, null, null, null, null, null);
+ }
+
+
+ public static class Builder implements Iterable {
+
+
+ @Override
+ public @NonNull Iterator iterator() {
+ JsonArrayIterator build = build();
+ return build.stream()
+ .onClose(build::close)
+ .iterator();
+ }
+ }
+
+ /**
+ *
+ * @param inputStream The inputstream containing the json
+ * @param valueCreator A function which converts a json {@link TreeNode} to the desired objects in the iterator.
+ * @param valueClass If valueCreator is not given, simply the class of the desired object can be given
+ * Json unmarshalling with the given objectMapper will happen.
+ * @param callback If the iterator is ready, closed or error this callback will be called.
+ * @param sizeField The size of the iterator, i.e. the size of the array represented in the json stream
+ * @param totalSizeField Sometimes the array is part of something bigger, e.g. a page in a search result. The size
+ * of the 'complete' result can be in the beginning of the json in this field.
+ * @param objectMapper Default the objectMapper {@link Jackson3Mapper#LENIENT} will be used (in
+ * conjunction with valueClass, but you may specify another one
+ * @param logger Default this is logging to nl.vpro.jackson2.JsonArrayIterator, but you may override that.
+ * @param skipNulls Whether to skip nulls in the array. Default true.
+ * @param eventListener A listener for events that happen during parsing and iteration of the array. See {@link Event} and extension classes.
+ */
+ @lombok.Builder(builderClassName = "Builder", builderMethodName = "_builder")
+ private JsonArrayIterator(
+ @NonNull InputStream inputStream,
+ @Nullable final BiFunction valueCreator,
+ @Nullable final Class valueClass,
+ @Nullable Runnable callback,
+ @Nullable String sizeField,
+ @Nullable String totalSizeField,
+ @Nullable Jackson3Mapper objectMapper,
+ @Nullable Logger logger,
+ @Nullable Boolean skipNulls,
+ @Nullable Listener eventListener
+ ) {
+ requireNonNull(inputStream, "No inputStream given");
+ this.reader = objectMapper == null ? Jackson3Mapper.LENIENT.reader() : objectMapper.reader();
+ this.jp = this.reader.createParser(inputStream);
+ this.valueCreator = valueCreator == null ? valueCreator(valueClass) : valueCreator;
+ if (valueCreator != null && valueClass != null) {
+ throw new IllegalArgumentException();
+ }
+ if (logger != null) {
+ this.logger = logger;
+ }
+ Long tmpSize = null;
+ Long tmpTotalSize = null;
+ String fieldName = null;
+ if (sizeField == null) {
+ sizeField = "size";
+ }
+ if (totalSizeField == null) {
+ totalSizeField = "totalSize";
+ }
+ this.eventListener = eventListener == null? Listener.noop() : eventListener;
+ // find the start of the array, where we will start iterating.
+ while(true) {
+ JsonToken token = jp.nextToken();
+ if (token == null) {
+ break;
+ }
+ this.eventListener.accept(new TokenEvent(token));
+ if (token == JsonToken.PROPERTY_NAME) {
+ fieldName = jp.currentName();
+ }
+ if (token == JsonToken.VALUE_NUMBER_INT && sizeField.equals(fieldName)) {
+ tmpSize = jp.getLongValue();
+ this.eventListener.accept(new SizeEvent(tmpSize));
+ }
+ if (token == JsonToken.VALUE_NUMBER_INT && totalSizeField.equals(fieldName)) {
+ tmpTotalSize = jp.getLongValue();
+ this.eventListener.accept(new TotalSizeEvent(tmpTotalSize));
+
+ }
+ if (token == JsonToken.START_ARRAY) {
+ break;
+ }
+ }
+ this.size = tmpSize;
+ this.totalSize = tmpTotalSize;
+ this.eventListener.accept(new StartEvent());
+ this.callback = callback;
+ this.skipNulls = skipNulls == null || skipNulls;
+ }
+
+ private static BiFunction valueCreator(Class clazz) {
+ return (m, tree) -> {
+ return m.treeToValue(tree, clazz);
+ };
+
+ }
+
+ @Override
+ public boolean hasNext() {
+ findNext();
+ return hasNext;
+ }
+
+ @Override
+ public T peek() {
+ findNext();
+ return next;
+ }
+
+ @Override
+ public T next() {
+ findNext();
+ if (! hasNext) {
+ throw new NoSuchElementException();
+ }
+ T result = next;
+ next = null;
+ needsFindNext = true;
+ hasNext = null;
+ count += foundNulls;
+ foundNulls = 0;
+ count++;
+ return result;
+ }
+
+
+ @Override
+ public Long getCount() {
+ return count;
+ }
+
+ protected void findNext() {
+ if(needsFindNext) {
+ while(true) {
+ try {
+ jp.nextToken();
+ var currentToken = jp.currentToken();
+ this.eventListener.accept(new TokenEvent(currentToken));
+
+ if (currentToken == JsonToken.END_ARRAY) {
+ callback();
+ next = null;
+ hasNext = false;
+ break;
+ }
+ TreeNode tree = jp.readValueAsTree(); // read the next token.
+ if (tree == null) {
+ next = null;
+ hasNext = false;
+ break;
+ }
+ if (tree instanceof NullNode && skipNulls) {
+ foundNulls++;
+ continue;
+ }
+ try {
+ next = valueCreator.apply(reader, tree);
+ eventListener.accept(new NextEvent(next));
+ hasNext = true;
+ break;
+ } catch (MismatchedInputException jme) {
+ foundNulls++;
+ logger.warn("{} {} for\n{}\nWill be skipped", jme.getClass(), jme.getMessage(), tree);
+ }
+ } catch (RuntimeException rte) {
+ callbackBeforeThrow(rte);
+ }
+ }
+ needsFindNext = false;
+ }
+ }
+
+
+ private void callbackBeforeThrow(RuntimeException e) {
+ callback();
+ next = null;
+ needsFindNext = false;
+ hasNext = false;
+ throw e;
+ }
+
+ @Override
+ public void close() {
+ callback();
+ this.jp.close();
+
+ }
+
+ protected void callback() {
+ if (! callBackHasRun) {
+ if (callback != null) {
+ callback.run();
+ }
+ callBackHasRun = true;
+ }
+ }
+
+ /**
+ * Write the entire stream to an output stream
+ */
+ public void write(OutputStream out, final Consumer logging) {
+ write(this, out, logging == null ? null : (c) -> { logging.accept(c); return null;});
+ }
+
+ public void writeArray(OutputStream out, final Consumer logging) throws IOException {
+ writeArray(this, out, logging == null ? null : (c) -> { logging.accept(c); return null;});
+ }
+
+
+
+ /**
+ * Write the entire stream to an output stream
+ */
+ public static void write(
+ final CountedIterator iterator,
+ final OutputStream out,
+ final Function logging) {
+ try (JsonGenerator jg = Jackson3Mapper.INSTANCE.mapper().createGenerator(out)) {
+ jg.writeStartObject();
+ jg.writeArrayPropertyStart("array");
+ writeObjects(iterator, jg, logging);
+ jg.writeEndArray();
+ jg.writeEndObject();
+ jg.flush();
+ }
+ }
+
+ /**
+ * Write the entire stream to an output stream
+ */
+ public static void writeArray(
+ final CountedIterator iterator,
+ final OutputStream out, final Function logging) throws IOException {
+ try (JsonGenerator jg = Jackson3Mapper.INSTANCE.mapper().createGenerator(out)) {
+ jg.writeStartArray();
+ writeObjects(iterator, jg, logging);
+ jg.writeEndArray();
+ jg.flush();
+ }
+ }
+
+
+ /**
+ * Write the entire stream as an array to jsonGenerator
+ */
+ public static void writeObjects(
+ final CountedIterator iterator,
+ final JsonGenerator jg,
+ final Function logging) {
+ while (iterator.hasNext()) {
+ T change;
+ try {
+ change = iterator.next();
+ if (change != null) {
+ jg.writePOJO(change);
+ } else {
+ jg.writeNull();
+ }
+ if (logging != null) {
+ logging.apply(change);
+ }
+ } catch (Exception e) {
+ Throwable cause = e.getCause();
+ while (cause != null) {
+ if (cause instanceof InterruptedException) {
+ return;
+ }
+ cause = cause.getCause();
+ }
+
+ log.warn("{} {}", e.getClass().getCanonicalName(), e.getMessage());
+ jg.writePOJO(e.getMessage());
+ }
+
+ }
+ }
+
+
+ @Override
+ @NonNull
+ public Optional getSize() {
+ return Optional.ofNullable(size);
+ }
+
+ @Override
+ @NonNull
+ public Optional getTotalSize() {
+ return Optional.ofNullable(totalSize);
+ }
+
+ public static Builder builder() {
+ return JsonArrayIterator._builder();
+ }
+
+
+ public static Builder builder(Class valueClass) {
+ return JsonArrayIterator._builder()
+ .valueClass(valueClass);
+ }
+
+
+
+
+ public class Event {
+
+ public JsonArrayIterator getParent() {
+ return JsonArrayIterator.this;
+ }
+ }
+
+ public class StartEvent extends Event {
+ }
+
+
+ @EqualsAndHashCode(callSuper = true)
+ @Data
+ public class TokenEvent extends Event {
+ final JsonToken token;
+
+ public TokenEvent(JsonToken token) {
+ this.token = token;
+ }
+ }
+
+ @EqualsAndHashCode(callSuper = true)
+ @Data
+ public class TotalSizeEvent extends Event {
+ final long totalSize;
+
+ public TotalSizeEvent(long totalSize) {
+ this.totalSize = totalSize;
+ }
+ }
+
+ @EqualsAndHashCode(callSuper = true)
+ @Data
+ public class SizeEvent extends Event {
+ final long size;
+
+ public SizeEvent(long size) {
+ this.size = size;
+ }
+ }
+
+ public class NextEvent extends Event {
+
+ final T next;
+
+ public NextEvent(T next) {
+ this.next = next;
+ }
+ }
+
+ @FunctionalInterface
+ public interface Listener extends EventListener, Consumer.Event> {
+
+
+ static Listener noop() {
+ return new Listener() {
+
+ @Override
+ public void accept(JsonArrayIterator.Event event) {
+
+ }
+
+ @Override
+ public String toString() {
+ return "noop";
+
+ }
+ };
+ }
+ }
+
+}
diff --git a/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LenientBooleanDeserializer.java b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LenientBooleanDeserializer.java
new file mode 100644
index 000000000..3158d3a2f
--- /dev/null
+++ b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LenientBooleanDeserializer.java
@@ -0,0 +1,39 @@
+package nl.vpro.jackson3;
+
+import tools.jackson.core.*;
+import tools.jackson.databind.DeserializationContext;
+import tools.jackson.databind.ValueDeserializer;
+
+/**
+ * @author Michiel Meeuwissen
+ * @since 2.3
+ */
+public class LenientBooleanDeserializer extends ValueDeserializer {
+
+ public static final LenientBooleanDeserializer INSTANCE = new LenientBooleanDeserializer();
+
+ private LenientBooleanDeserializer() {
+
+ }
+
+
+ @Override
+ public Boolean deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws JacksonException {
+ JsonToken token = jsonParser.currentToken();
+ if (jsonParser.isNaN()) {
+ return false;
+ }
+ if (token.isBoolean()) {
+ return jsonParser.getBooleanValue();
+ } else if (token.isNumeric()) {
+ return jsonParser.getNumberValue().longValue() != 0;
+ } else {
+ String text = jsonParser.getString().toLowerCase();
+ return switch (text) {
+ case "true", "1" -> true;
+ default -> false;
+ };
+ }
+
+ }
+}
diff --git a/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LocalDateTimeToJsonDateWithSpace.java b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LocalDateTimeToJsonDateWithSpace.java
new file mode 100644
index 000000000..4948e2a86
--- /dev/null
+++ b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LocalDateTimeToJsonDateWithSpace.java
@@ -0,0 +1,54 @@
+package nl.vpro.jackson3;
+
+import tools.jackson.core.*;
+import tools.jackson.databind.*;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeParseException;
+
+import nl.vpro.util.TimeUtils;
+
+
+/**
+ * @since 2.0
+ */
+public class LocalDateTimeToJsonDateWithSpace {
+
+ private LocalDateTimeToJsonDateWithSpace() {}
+
+ public static class Serializer extends ValueSerializer {
+
+ public static final Serializer INSTANCE = new Serializer();
+
+ @Override
+ public void serialize(LocalDateTime value, JsonGenerator jgen, SerializationContext ctxt) throws JacksonException {
+ if (value == null) {
+ jgen.writeNull();
+ } else {
+ jgen.writeString(value.toString().replaceFirst("T", " "));
+ }
+ }
+
+
+ }
+
+ public static class Deserializer extends ValueDeserializer {
+
+ public static final Deserializer INSTANCE = new Deserializer();
+
+ @Override
+ public LocalDateTime deserialize(JsonParser jp, DeserializationContext ctxt) throws JacksonException {
+ String text = jp.getString();
+ if (text == null) {
+ return null;
+ } else {
+ try {
+ return LocalDateTime.parse(text.replaceFirst(" ", "T"));
+ } catch (DateTimeParseException dtf) {
+ return TimeUtils.parse(text).map(i -> i.atZone(TimeUtils.ZONE_ID).toLocalDateTime())
+ .orElseThrow(() -> new JacksonException("Cannot parse " + text) {});
+ }
+ }
+ }
+ }
+}
diff --git a/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LocalDateToJsonDate.java b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LocalDateToJsonDate.java
new file mode 100644
index 000000000..f47377b7a
--- /dev/null
+++ b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/LocalDateToJsonDate.java
@@ -0,0 +1,42 @@
+package nl.vpro.jackson3;
+
+import tools.jackson.core.*;
+import tools.jackson.databind.*;
+
+import java.time.LocalDate;
+
+
+public class LocalDateToJsonDate {
+
+ private LocalDateToJsonDate() {}
+
+ public static class Serializer extends ValueSerializer {
+
+ public static final Serializer INSTANCE = new Serializer();
+
+ @Override
+ public void serialize(LocalDate value, JsonGenerator jgen, SerializationContext ctxt) throws JacksonException {
+ if (value == null) {
+ jgen.writeNull();
+ } else {
+ jgen.writeString(value.toString());
+ }
+ }
+ }
+
+
+ public static class Deserializer extends ValueDeserializer {
+
+ public static final Deserializer INSTANCE = new Deserializer();
+
+ @Override
+ public LocalDate deserialize(JsonParser jp, DeserializationContext ctxt) {
+ String text = jp.getString();
+ if (text == null) {
+ return null;
+ } else {
+ return LocalDate.parse(text);
+ }
+ }
+ }
+}
diff --git a/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/StringDurationToJsonTimestamp.java b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/StringDurationToJsonTimestamp.java
new file mode 100644
index 000000000..cf6cf291a
--- /dev/null
+++ b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/StringDurationToJsonTimestamp.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2016 All rights reserved
+ * VPRO The Netherlands
+ */
+package nl.vpro.jackson3;
+
+import tools.jackson.core.*;
+import tools.jackson.databind.*;
+
+import java.time.Duration;
+
+/**
+ * @author rico
+ * @since 0.37
+ */
+public class StringDurationToJsonTimestamp {
+
+ private StringDurationToJsonTimestamp() {}
+
+ public static class Serializer extends ValueSerializer {
+ public static final StringDurationToJsonTimestamp.Serializer INSTANCE = new StringDurationToJsonTimestamp.Serializer();
+
+ @Override
+ public void serialize(String value, JsonGenerator jgen, SerializationContext ctxt) throws JacksonException {
+ if (value == null) {
+ jgen.writeNull();
+ } else {
+ jgen.writeNumber(Duration.parse(value).toMillis());
+ }
+ }
+
+ }
+
+
+ public static class Deserializer extends ValueDeserializer {
+
+ public static final StringDurationToJsonTimestamp.Deserializer INSTANCE = new StringDurationToJsonTimestamp.Deserializer();
+
+ @Override
+ public String deserialize(JsonParser jp, DeserializationContext ctxt) {
+ return Duration.ofMillis(jp.getLongValue()).toString();
+ }
+ }
+}
diff --git a/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/StringInstantToJsonTimestamp.java b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/StringInstantToJsonTimestamp.java
new file mode 100644
index 000000000..2ce7fefbe
--- /dev/null
+++ b/vpro-shared-jackson3/src/main/java/nl/vpro/jackson3/StringInstantToJsonTimestamp.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2016 All rights reserved
+ * VPRO The Netherlands
+ */
+package nl.vpro.jackson3;
+
+import lombok.extern.slf4j.Slf4j;
+import tools.jackson.core.*;
+import tools.jackson.databind.*;
+
+import java.time.Instant;
+import java.util.Optional;
+
+import jakarta.xml.bind.DatatypeConverter;
+
+import org.slf4j.event.Level;
+
+import nl.vpro.util.NattySupport;
+
+/**
+ * These can be used in conjunction with InstantXmlAdapter, if you want 'millis since epoch' in JSON, but formatted date stamps in xml.
+ * (this is what we normally do)
+ * @author Michiel Meeuwissen
+ * @since 0.39
+ */
+@Slf4j
+public class StringInstantToJsonTimestamp {
+
+ private static boolean warnedNatty = false;
+
+ private StringInstantToJsonTimestamp() {}
+
+ public static class Serializer extends ValueSerializer