From d5af3a52cd30eebf7a8fb4d8f2402920c42d5f7c Mon Sep 17 00:00:00 2001 From: Benoit Tellier Date: Thu, 15 Dec 2022 13:59:24 +0700 Subject: [PATCH] JAMES-3771 Prevent SPI lookups on the HTTP eventloop --- .../backends/opensearch/ClientProvider.java | 2 +- .../json/JsonpDeserializerBase.java | 397 ++++++++++++++++++ .../opensearch/json/JsonpMapperBase.java | 102 +++++ .../backends/opensearch/json/JsonpUtils.java | 207 +++++++++ .../json/UnexpectedJsonEventException.java | 40 ++ .../json/jackson/JacksonJsonProvider.java | 298 +++++++++++++ .../json/jackson/JacksonJsonpGenerator.java | 374 +++++++++++++++++ .../json/jackson/JacksonJsonpLocation.java | 54 +++ .../json/jackson/JacksonJsonpMapper.java | 123 ++++++ .../json/jackson/JacksonJsonpParser.java | 313 ++++++++++++++ .../opensearch/json/jackson/JacksonUtils.java | 43 ++ .../json/jackson/JsonValueParser.java | 108 +++++ .../opensearch/json/package-info.java | 29 ++ 13 files changed, 2089 insertions(+), 1 deletion(-) create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpDeserializerBase.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpMapperBase.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpUtils.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/UnexpectedJsonEventException.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonProvider.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpGenerator.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpLocation.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpMapper.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpParser.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonUtils.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JsonValueParser.java create mode 100644 backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/package-info.java diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/ClientProvider.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/ClientProvider.java index 89a104bb8ba..abd0fc9945f 100644 --- a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/ClientProvider.java +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/ClientProvider.java @@ -44,9 +44,9 @@ import org.apache.http.impl.nio.client.HttpAsyncClientBuilder; import org.apache.http.ssl.SSLContextBuilder; import org.apache.http.ssl.TrustStrategy; +import org.apache.james.backends.opensearch.json.jackson.JacksonJsonpMapper; import org.apache.james.util.concurrent.NamedThreadFactory; import org.opensearch.client.RestClient; -import org.opensearch.client.json.jackson.JacksonJsonpMapper; import org.opensearch.client.opensearch.OpenSearchAsyncClient; import org.opensearch.client.transport.rest_client.RestClientTransport; import org.slf4j.Logger; diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpDeserializerBase.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpDeserializerBase.java new file mode 100644 index 00000000000..c790e4ab2c8 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpDeserializerBase.java @@ -0,0 +1,397 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.json.JsonpMapper; + +import jakarta.json.JsonNumber; +import jakarta.json.JsonValue; +import jakarta.json.stream.JsonParser; +import jakarta.json.stream.JsonParser.Event; +import jakarta.json.stream.JsonParsingException; + +/** + * Base class for {@link JsonpDeserializer} implementations that accept a set of JSON events known at instanciation time. + */ +public abstract class JsonpDeserializerBase implements JsonpDeserializer { + + private final EnumSet acceptedEvents; + private final EnumSet nativeEvents; + + protected JsonpDeserializerBase(EnumSet acceptedEvents) { + this(acceptedEvents, acceptedEvents); + } + + protected JsonpDeserializerBase(EnumSet acceptedEvents, EnumSet nativeEvents) { + this.acceptedEvents = acceptedEvents; + this.nativeEvents = nativeEvents; + } + + /** Combines accepted events from a number of deserializers */ + protected static EnumSet allAcceptedEvents(JsonpDeserializer... deserializers) { + EnumSet result = EnumSet.noneOf(Event.class); + for (JsonpDeserializer deserializer: deserializers) { + + EnumSet set = deserializer.acceptedEvents(); + // Disabled for now. Only happens with the experimental Union2 and is caused by string and number + // parsers leniency. Need to be replaced with a check on a preferred event type. + //if (!Collections.disjoint(result, set)) { + // throw new IllegalArgumentException("Deserializer accepted events are not disjoint"); + //} + + result.addAll(set); + } + return result; + } + + @Override + public EnumSet nativeEvents() { + return nativeEvents; + } + + /** + * The JSON events this deserializer accepts as a starting point + */ + public final EnumSet acceptedEvents() { + return acceptedEvents; + } + + /** + * Convenience method for {@code acceptedEvents.contains(event)} + */ + public final boolean accepts(Event event) { + return acceptedEvents.contains(event); + } + + //--------------------------------------------------------------------------------------------- + + //----- Builtin types + + static final JsonpDeserializer STRING = + // String parsing is lenient and accepts any other primitive type + new JsonpDeserializerBase(EnumSet.of( + Event.KEY_NAME, Event.VALUE_STRING, Event.VALUE_NUMBER, + Event.VALUE_FALSE, Event.VALUE_TRUE + ), + EnumSet.of(Event.VALUE_STRING) + ) { + @Override + public String deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_TRUE) { + return "true"; + } + if (event == Event.VALUE_FALSE) { + return "false"; + } + return parser.getString(); // also accepts numbers + } + }; + + static final JsonpDeserializer INTEGER = + new JsonpDeserializerBase( + EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_STRING), + EnumSet.of(Event.VALUE_NUMBER) + ) { + @Override + public Integer deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_STRING) { + return Integer.valueOf(parser.getString()); + } + return parser.getInt(); + } + }; + + static final JsonpDeserializer BOOLEAN = + new JsonpDeserializerBase( + EnumSet.of(Event.VALUE_FALSE, Event.VALUE_TRUE, Event.VALUE_STRING), + EnumSet.of(Event.VALUE_FALSE, Event.VALUE_TRUE) + ) { + @Override + public Boolean deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_STRING) { + return Boolean.parseBoolean(parser.getString()); + } else { + return event == Event.VALUE_TRUE; + } + } + }; + + static final JsonpDeserializer LONG = + new JsonpDeserializerBase( + EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_STRING), + EnumSet.of(Event.VALUE_NUMBER) + ) { + @Override + public Long deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_STRING) { + return Long.valueOf(parser.getString()); + } + return parser.getLong(); + } + }; + + static final JsonpDeserializer FLOAT = + new JsonpDeserializerBase( + EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_STRING), + EnumSet.of(Event.VALUE_NUMBER) + + ) { + @Override + public Float deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_STRING) { + return Float.valueOf(parser.getString()); + } + return parser.getBigDecimal().floatValue(); + } + }; + + static final JsonpDeserializer DOUBLE = + new JsonpDeserializerBase( + EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_STRING), + EnumSet.of(Event.VALUE_NUMBER) + ) { + @Override + public Double deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_STRING) { + return Double.valueOf(parser.getString()); + } + return parser.getBigDecimal().doubleValue(); + } + }; + + static final class DoubleOrNullDeserializer extends JsonpDeserializerBase { + static final EnumSet nativeEvents = EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_NULL); + static final EnumSet acceptedEvents = EnumSet.of(Event.VALUE_STRING, Event.VALUE_NUMBER, Event.VALUE_NULL); + private final double defaultValue; + + DoubleOrNullDeserializer(double defaultValue) { + super(acceptedEvents, nativeEvents); + this.defaultValue = defaultValue; + } + + @Override + public Double deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_NULL) { + return defaultValue; + } + if (event == Event.VALUE_STRING) { + return Double.valueOf(parser.getString()); + } + return parser.getBigDecimal().doubleValue(); + } + } + + static final class IntOrNullDeserializer extends JsonpDeserializerBase { + static final EnumSet nativeEvents = EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_NULL); + static final EnumSet acceptedEvents = EnumSet.of(Event.VALUE_STRING, Event.VALUE_NUMBER, Event.VALUE_NULL); + private final int defaultValue; + + IntOrNullDeserializer(int defaultValue) { + super(acceptedEvents, nativeEvents); + this.defaultValue = defaultValue; + } + + @Override + public Integer deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_NULL) { + return defaultValue; + } + if (event == Event.VALUE_STRING) { + return Integer.valueOf(parser.getString()); + } + return parser.getInt(); + } + } + + static final class StringOrNullDeserializer extends JsonpDeserializerBase { + static final EnumSet nativeEvents = EnumSet.of(Event.VALUE_STRING, Event.VALUE_NULL); + static final EnumSet acceptedEvents = EnumSet.of(Event.KEY_NAME, Event.VALUE_STRING, + Event.VALUE_NUMBER, Event.VALUE_FALSE, Event.VALUE_TRUE, Event.VALUE_NULL); + + StringOrNullDeserializer() { + super(acceptedEvents, nativeEvents); + } + + @Override + public String deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_NULL) { + return null; + } + if (event == Event.VALUE_TRUE) { + return "true"; + } + if (event == Event.VALUE_FALSE) { + return "false"; + } + return parser.getString(); + } + } + + static final JsonpDeserializer DOUBLE_OR_NAN = + new JsonpDeserializerBase( + EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_STRING, Event.VALUE_NULL), + EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_NULL) + ) { + @Override + public Double deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_NULL) { + return Double.NaN; + } + if (event == Event.VALUE_STRING) { + return Double.valueOf(parser.getString()); + } + return parser.getBigDecimal().doubleValue(); + } + }; + + static final JsonpDeserializer NUMBER = + new JsonpDeserializerBase( + EnumSet.of(Event.VALUE_NUMBER, Event.VALUE_STRING), + EnumSet.of(Event.VALUE_NUMBER) + ) { + @Override + public Number deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.VALUE_STRING) { + return Double.valueOf(parser.getString()); + } + return ((JsonNumber)parser.getValue()).numberValue(); + } + }; + + static final JsonpDeserializer JSON_VALUE = + new JsonpDeserializerBase( + EnumSet.allOf(Event.class) + ) { + @Override + public JsonValue deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + return parser.getValue(); + } + }; + + static final JsonpDeserializer VOID = new JsonpDeserializerBase( + EnumSet.noneOf(Event.class) + ) { + @Override + public Void deserialize(JsonParser parser, JsonpMapper mapper) { + throw new JsonParsingException("Void types should not have any value", parser.getLocation()); + } + + @Override + public Void deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + return deserialize(parser, mapper); + } + }; + + //----- Collections + + static class ArrayDeserializer implements JsonpDeserializer> { + private final JsonpDeserializer itemDeserializer; + private EnumSet acceptedEvents; + private static final EnumSet nativeEvents = EnumSet.of(Event.START_ARRAY); + + protected ArrayDeserializer(JsonpDeserializer itemDeserializer) { + this.itemDeserializer = itemDeserializer; + } + + @Override + public EnumSet nativeEvents() { + return nativeEvents; + } + + @Override + public EnumSet acceptedEvents() { + // Accepted events is computed lazily + // no need for double-checked lock, we don't care about computing it several times + if (acceptedEvents == null) { + acceptedEvents = EnumSet.of(Event.START_ARRAY); + acceptedEvents.addAll(itemDeserializer.acceptedEvents()); + } + return acceptedEvents; + } + + @Override + public List deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + if (event == Event.START_ARRAY) { + List result = new ArrayList<>(); + while ((event = parser.next()) != Event.END_ARRAY) { + JsonpUtils.ensureAccepts(itemDeserializer, parser, event); + result.add(itemDeserializer.deserialize(parser, mapper, event)); + } + return result; + } else { + // Single-value mode + JsonpUtils.ensureAccepts(itemDeserializer, parser, event); + return Collections.singletonList(itemDeserializer.deserialize(parser, mapper, event)); + } + } + } + + static class StringMapDeserializer extends JsonpDeserializerBase> { + private final JsonpDeserializer itemDeserializer; + + protected StringMapDeserializer(JsonpDeserializer itemDeserializer) { + super(EnumSet.of(Event.START_OBJECT)); + this.itemDeserializer = itemDeserializer; + } + + @Override + public Map deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + Map result = new HashMap<>(); + while ((event = parser.next()) != Event.END_OBJECT) { + JsonpUtils.expectEvent(parser, Event.KEY_NAME, event); + String key = parser.getString(); + T value = itemDeserializer.deserialize(parser, mapper); + result.put(key, value); + } + return result; + } + } + + static class EnumMapDeserializer extends JsonpDeserializerBase> { + private final JsonpDeserializer keyDeserializer; + private final JsonpDeserializer valueDeserializer; + + protected EnumMapDeserializer(JsonpDeserializer keyDeserializer, JsonpDeserializer valueDeserializer) { + super(EnumSet.of(Event.START_OBJECT)); + this.keyDeserializer = keyDeserializer; + this.valueDeserializer = valueDeserializer; + } + + @Override + public Map deserialize(JsonParser parser, JsonpMapper mapper, Event event) { + Map result = new HashMap<>(); + while ((event = parser.next()) != Event.END_OBJECT) { + JsonpUtils.expectEvent(parser, Event.KEY_NAME, event); + K key = keyDeserializer.deserialize(parser, mapper, event); + V value = valueDeserializer.deserialize(parser, mapper); + result.put(key, value); + } + return result; + } + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpMapperBase.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpMapperBase.java new file mode 100644 index 00000000000..5a710795b89 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpMapperBase.java @@ -0,0 +1,102 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json; + +import java.lang.reflect.Field; + +import javax.annotation.Nullable; + +import org.opensearch.client.json.JsonpDeserializable; +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.json.JsonpMapper; +import org.opensearch.client.json.JsonpSerializable; +import org.opensearch.client.json.JsonpSerializer; + +import jakarta.json.JsonValue; +import jakarta.json.stream.JsonGenerator; +import jakarta.json.stream.JsonParser; + +public abstract class JsonpMapperBase implements JsonpMapper { + + /** Get a serializer when none of the builtin ones are applicable */ + protected abstract JsonpDeserializer getDefaultDeserializer(Class clazz); + + @Override + public T deserialize(JsonParser parser, Class clazz) { + JsonpDeserializer deserializer = findDeserializer(clazz); + if (deserializer != null) { + return deserializer.deserialize(parser, this); + } + + return getDefaultDeserializer(clazz).deserialize(parser, this); + } + + @Nullable + @SuppressWarnings("unchecked") + public static JsonpDeserializer findDeserializer(Class clazz) { + JsonpDeserializable annotation = clazz.getAnnotation(JsonpDeserializable.class); + if (annotation != null) { + try { + Field field = clazz.getDeclaredField(annotation.field()); + return (JsonpDeserializer)field.get(null); + } catch (Exception e) { + throw new RuntimeException("No deserializer found in '" + clazz.getName() + "." + annotation.field() + "'"); + } + } + + return null; + } + + @Nullable + @SuppressWarnings("unchecked") + public static JsonpSerializer findSerializer(T value) { + Class valueClass = value.getClass(); + if (JsonpSerializable.class.isAssignableFrom(valueClass)) { + return (JsonpSerializer) JsonpSerializableSerializer.INSTANCE; + } + + if (JsonValue.class.isAssignableFrom(valueClass)) { + return (JsonpSerializer) JsonpValueSerializer.INSTANCE; + } + + return null; + } + + protected static class JsonpSerializableSerializer implements JsonpSerializer { + @Override + public void serialize(T value, JsonGenerator generator, JsonpMapper mapper) { + value.serialize(generator, mapper); + } + + protected static final JsonpSerializer INSTANCE = new JsonpSerializableSerializer<>(); + + } + + protected static class JsonpValueSerializer implements JsonpSerializer { + @Override + public void serialize(JsonValue value, JsonGenerator generator, JsonpMapper mapper) { + generator.write(value); + } + + protected static final JsonpSerializer INSTANCE = new JsonpValueSerializer(); + + } + +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpUtils.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpUtils.java new file mode 100644 index 00000000000..5694a5cbeee --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/JsonpUtils.java @@ -0,0 +1,207 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json; + +import java.io.StringReader; +import java.util.AbstractMap; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; + +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.json.JsonpMapper; +import org.opensearch.client.json.JsonpSerializable; +import org.opensearch.client.json.JsonpSerializer; +import org.opensearch.client.util.ObjectBuilder; + +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import jakarta.json.stream.JsonGenerator; +import jakarta.json.stream.JsonParser; +import jakarta.json.stream.JsonParser.Event; +import jakarta.json.stream.JsonParsingException; + +public class JsonpUtils { + + /** + * Advances the parser to the next event and checks that this even is the expected one. + * + * @return the expected event + * + * @throws jakarta.json.JsonException if an i/o error occurs (IOException would be cause of JsonException) + * @throws JsonParsingException if the event is not the expected one, or if the parser encounters invalid + * JSON when advancing to next state. + * @throws java.util.NoSuchElementException if there are no more parsing states. + */ + public static Event expectNextEvent(JsonParser parser, Event expected) { + Event event = parser.next(); + expectEvent(parser, expected, event); + return event; + } + + public static void expectEvent(JsonParser parser, Event expected, Event event) { + if (event != expected) { + throw new UnexpectedJsonEventException(parser, event, expected); + } + } + + public static String expectKeyName(JsonParser parser, Event event) { + JsonpUtils.expectEvent(parser, Event.KEY_NAME, event); + return parser.getString(); + } + + public static void ensureAccepts(JsonpDeserializer deserializer, JsonParser parser, Event event) { + if (!deserializer.acceptedEvents().contains(event)) { + throw new UnexpectedJsonEventException(parser, event, deserializer.acceptedEvents()); + } + } + + /** + * Skip the value at the next position of the parser. + */ + public static void skipValue(JsonParser parser) { + skipValue(parser, parser.next()); + } + + /** + * Skip the value at the current position of the parser. + */ + public static void skipValue(JsonParser parser, Event event) { + switch (event) { + case START_OBJECT: + parser.skipObject(); + break; + + case START_ARRAY: + parser.skipArray(); + break; + + default: + // Not a structure, no additional skipping needed + break; + } + } + + public static T buildVariant(JsonParser parser, ObjectBuilder builder) { + if (builder == null) { + throw new JsonParsingException("No variant found", parser.getLocation()); + } + return builder.build(); + } + + public static void serialize(T value, JsonGenerator generator, @Nullable JsonpSerializer serializer, JsonpMapper mapper) { + if (serializer != null) { + serializer.serialize(value, generator, mapper); + } else if (value instanceof JsonpSerializable) { + ((JsonpSerializable) value).serialize(generator, mapper); + } else { + mapper.serialize(value, generator); + } + } + + /** + * Looks ahead a field value in the Json object from the upcoming object in a parser, which should be on the + * START_OBJECT event. + * + * Returns a pair containing that value and a parser that should be used to actually parse the object + * (the object has been consumed from the original one). + */ + public static Map.Entry lookAheadFieldValue( + String name, String defaultValue, JsonParser parser, JsonpMapper mapper + ) { + // FIXME: need a buffering parser wrapper so that we don't roundtrip through a JsonObject and a String + // FIXME: resulting parser should return locations that are offset with the original parser's location + JsonObject object = parser.getObject(); + String result = object.getString(name, null); + + if (result == null) { + result = defaultValue; + } + + if (result == null) { + throw new JsonParsingException("Property '" + name + "' not found", parser.getLocation()); + } + + return new AbstractMap.SimpleImmutableEntry<>(result, objectParser(object, mapper)); + } + + /** + * Create a parser that traverses a JSON object + */ + public static JsonParser objectParser(JsonObject object, JsonpMapper mapper) { + // FIXME: we should have used createParser(object), but this doesn't work as it creates a + // org.glassfish.json.JsonStructureParser that doesn't implement the JsonP 1.0.1 features, in particular + // parser.getObject(). So deserializing recursive internally-tagged union would fail with UnsupportedOperationException + // While glassfish has this issue or until we write our own, we roundtrip through a string. + + String strObject = object.toString(); + return mapper.jsonProvider().createParser(new StringReader(strObject)); + } + + public static String toString(JsonValue value) { + switch (value.getValueType()) { + case OBJECT: + throw new IllegalArgumentException("Json objects cannot be used as string"); + + case ARRAY: + return value.asJsonArray().stream() + .map(JsonpUtils::toString) + .collect(Collectors.joining(",")); + + case STRING: + return ((JsonString)value).getString(); + + case TRUE: + return "true"; + + case FALSE: + return "false"; + + case NULL: + return "null"; + + case NUMBER: + return value.toString(); + + default: + throw new IllegalArgumentException("Unknown JSON value type: '" + value + "'"); + } + } + + public static void serializeDoubleOrNull(JsonGenerator generator, double value, double defaultValue) { + // Only output null if the default value isn't finite, which cannot be represented as JSON + if (value == defaultValue && !Double.isFinite(defaultValue)) { + generator.writeNull(); + } else { + generator.write(value); + } + } + + public static void serializeIntOrNull(JsonGenerator generator, int value, int defaultValue) { + // Only output null if the default value isn't finite, which cannot be represented as JSON + if (value == defaultValue && defaultValue == Integer.MAX_VALUE || defaultValue == Integer.MIN_VALUE) { + generator.writeNull(); + } else { + generator.write(value); + } + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/UnexpectedJsonEventException.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/UnexpectedJsonEventException.java new file mode 100644 index 00000000000..faf7d5fe853 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/UnexpectedJsonEventException.java @@ -0,0 +1,40 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json; + +import java.util.EnumSet; + +import jakarta.json.stream.JsonParser; +import jakarta.json.stream.JsonParser.Event; +import jakarta.json.stream.JsonParsingException; + +public class UnexpectedJsonEventException extends JsonParsingException { + public UnexpectedJsonEventException(JsonParser parser, Event event) { + super("Unexpected JSON event '" + event + "'", parser.getLocation()); + } + + public UnexpectedJsonEventException(JsonParser parser, Event event, Event expected) { + super("Unexpected JSON event '" + event + "' instead of '" + expected + "'", parser.getLocation()); + } + + public UnexpectedJsonEventException(JsonParser parser, Event event, EnumSet expected) { + super("Unexpected JSON event '" + event + "' instead of '" + expected + "'", parser.getLocation()); + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonProvider.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonProvider.java new file mode 100644 index 00000000000..e7d128977f6 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonProvider.java @@ -0,0 +1,298 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json.jackson; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonFactory; + +import jakarta.json.JsonArray; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonBuilderFactory; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; +import jakarta.json.JsonReader; +import jakarta.json.JsonReaderFactory; +import jakarta.json.JsonWriter; +import jakarta.json.JsonWriterFactory; +import jakarta.json.spi.JsonProvider; +import jakarta.json.stream.JsonGenerator; +import jakarta.json.stream.JsonGeneratorFactory; +import jakarta.json.stream.JsonParser; +import jakarta.json.stream.JsonParserFactory; + +/** + * A partial implementation of JSONP's SPI on top of Jackson. + */ +public class JacksonJsonProvider extends JsonProvider { + + private final JsonFactory jsonFactory; + + public JacksonJsonProvider(JsonFactory jsonFactory) { + this.jsonFactory = jsonFactory; + } + + public JacksonJsonProvider() { + this(new JsonFactory()); + } + + /** + * Return the underlying Jackson {@link JsonFactory}. + */ + public JsonFactory jacksonJsonFactory() { + return this.jsonFactory; + } + + //--------------------------------------------------------------------------------------------- + // Parser + + private final ParserFactory defaultParserFactory = new ParserFactory(null); + + @Override + public JsonParserFactory createParserFactory(Map config) { + if (config == null || config.isEmpty()) { + return defaultParserFactory; + } else { + // TODO: handle specific configuration + return defaultParserFactory; + } + } + + @Override + public JsonParser createParser(Reader reader) { + return defaultParserFactory.createParser(reader); + } + + @Override + public JsonParser createParser(InputStream in) { + return defaultParserFactory.createParser(in); + } + + private class ParserFactory implements JsonParserFactory { + + private final Map config; + + ParserFactory(Map config) { + this.config = config == null ? Collections.emptyMap() : config; + } + + @Override + public JsonParser createParser(Reader reader) { + try { + return new JacksonJsonpParser(jsonFactory.createParser(reader)); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + } + + @Override + public JsonParser createParser(InputStream in) { + try { + return new JacksonJsonpParser(jsonFactory.createParser(in)); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + } + + @Override + public JsonParser createParser(InputStream in, Charset charset) { + try { + return new JacksonJsonpParser(jsonFactory.createParser(new InputStreamReader(in, charset))); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + } + + /** + * Not implemented. + */ + @Override + public JsonParser createParser(JsonObject obj) { + return JsonProvider.provider().createParserFactory(null).createParser(obj); + } + + /** + * Not implemented. + */ + @Override + public JsonParser createParser(JsonArray array) { + return JsonProvider.provider().createParserFactory(null).createParser(array); + } + + /** + * Not implemented. + */ + @Override + public Map getConfigInUse() { + return config; + } + } + + //--------------------------------------------------------------------------------------------- + // Generator + + private final JsonGeneratorFactory defaultGeneratorFactory = new GeneratorFactory(null); + + @Override + public JsonGeneratorFactory createGeneratorFactory(Map config) { + if (config == null || config.isEmpty()) { + return defaultGeneratorFactory; + } else { + // TODO: handle specific configuration + return defaultGeneratorFactory; + } + } + + @Override + public JsonGenerator createGenerator(Writer writer) { + return defaultGeneratorFactory.createGenerator(writer); + } + + @Override + public JsonGenerator createGenerator(OutputStream out) { + return defaultGeneratorFactory.createGenerator(out); + } + + private class GeneratorFactory implements JsonGeneratorFactory { + + private final Map config; + + GeneratorFactory(Map config) { + this.config = config == null ? Collections.emptyMap() : config; + } + + @Override + public JsonGenerator createGenerator(Writer writer) { + try { + return new JacksonJsonpGenerator(jsonFactory.createGenerator(writer)); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + } + + @Override + public JsonGenerator createGenerator(OutputStream out) { + try { + return new JacksonJsonpGenerator(jsonFactory.createGenerator(out)); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + } + + @Override + public JsonGenerator createGenerator(OutputStream out, Charset charset) { + try { + return new JacksonJsonpGenerator(jsonFactory.createGenerator(new OutputStreamWriter(out, charset))); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + + } + + @Override + public Map getConfigInUse() { + return config; + } + } + + //--------------------------------------------------------------------------------------------- + // Unsupported operations + + /** + * Not implemented. + */ + @Override + public JsonReader createReader(Reader reader) { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonReader createReader(InputStream in) { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonWriter createWriter(Writer writer) { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonWriter createWriter(OutputStream out) { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonWriterFactory createWriterFactory(Map config) { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonReaderFactory createReaderFactory(Map config) { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonObjectBuilder createObjectBuilder() { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonArrayBuilder createArrayBuilder() { + throw new UnsupportedOperationException(); + } + + /** + * Not implemented. + */ + @Override + public JsonBuilderFactory createBuilderFactory(Map config) { + throw new UnsupportedOperationException(); + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpGenerator.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpGenerator.java new file mode 100644 index 00000000000..c8758d11d88 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpGenerator.java @@ -0,0 +1,374 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json.jackson; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonStreamContext; + +import jakarta.json.JsonNumber; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import jakarta.json.stream.JsonGenerationException; +import jakarta.json.stream.JsonGenerator; + +/** + * A JSONP generator implementation on top of Jackson. + */ +public class JacksonJsonpGenerator implements JsonGenerator { + + private final com.fasterxml.jackson.core.JsonGenerator generator; + + public JacksonJsonpGenerator(com.fasterxml.jackson.core.JsonGenerator generator) { + this.generator = generator; + } + + /** + * Returns the underlying Jackson generator. + */ + public com.fasterxml.jackson.core.JsonGenerator jacksonGenerator() { + return generator; + } + + @Override + public JsonGenerator writeStartObject() { + try { + generator.writeStartObject(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator writeStartObject(String name) { + try { + generator.writeFieldName(name); + generator.writeStartObject(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator writeStartArray() { + try { + generator.writeStartArray(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator writeStartArray(String name) { + try { + generator.writeFieldName(name); + generator.writeStartArray(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator writeKey(String name) { + try { + generator.writeFieldName(name); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, JsonValue value) { + try { + generator.writeFieldName(name); + writeValue(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, String value) { + try { + generator.writeFieldName(name); + generator.writeString(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, BigInteger value) { + try { + generator.writeFieldName(name); + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, BigDecimal value) { + try { + generator.writeFieldName(name); + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, int value) { + try { + generator.writeFieldName(name); + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, long value) { + try { + generator.writeFieldName(name); + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, double value) { + try { + generator.writeFieldName(name); + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String name, boolean value) { + try { + generator.writeFieldName(name); + generator.writeBooleanField(name, value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator writeNull(String name) { + try { + generator.writeFieldName(name); + generator.writeNull(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator writeEnd() { + try { + JsonStreamContext ctx = generator.getOutputContext(); + if (ctx.inObject()) { + generator.writeEndObject(); + } else if (ctx.inArray()) { + generator.writeEndArray(); + } else { + throw new JsonGenerationException("Unexpected context: '" + ctx.typeDesc() + "'"); + } + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(JsonValue value) { + try { + writeValue(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(String value) { + try { + generator.writeString(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(BigDecimal value) { + try { + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(BigInteger value) { + try { + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(int value) { + try { + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(long value) { + try { + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(double value) { + try { + generator.writeNumber(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator write(boolean value) { + try { + generator.writeBoolean(value); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public JsonGenerator writeNull() { + try { + generator.writeNull(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + return this; + } + + @Override + public void close() { + try { + generator.close(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + } + + @Override + public void flush() { + try { + generator.flush(); + } catch (IOException e) { + throw JacksonUtils.convertException(e); + } + } + + private void writeValue(JsonValue value) throws IOException { + switch (value.getValueType()) { + case OBJECT: + generator.writeStartObject(); + for (Map.Entry entry: value.asJsonObject().entrySet()) { + generator.writeFieldName(entry.getKey()); + writeValue(entry.getValue()); + } + generator.writeEndObject(); + break; + + case ARRAY: + generator.writeStartArray(); + for (JsonValue item: value.asJsonArray()) { + writeValue(item); + } + generator.writeEndArray(); + break; + + case STRING: + generator.writeString(((JsonString)value).getString()); + break; + + case FALSE: + generator.writeBoolean(false); + break; + + case TRUE: + generator.writeBoolean(true); + break; + + case NULL: + generator.writeNull(); + break; + + case NUMBER: + JsonNumber n = (JsonNumber) value; + if (n.isIntegral()) { + generator.writeNumber(n.longValue()); + } else { + generator.writeNumber(n.doubleValue()); + } + break; + } + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpLocation.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpLocation.java new file mode 100644 index 00000000000..132f40365e5 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpLocation.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json.jackson; + +import jakarta.json.stream.JsonLocation; + +/** + * Translate a Jackson location to a JSONP location. + */ +public class JacksonJsonpLocation implements JsonLocation { + + private final com.fasterxml.jackson.core.JsonLocation location; + + JacksonJsonpLocation(com.fasterxml.jackson.core.JsonLocation location) { + this.location = location; + } + + JacksonJsonpLocation(com.fasterxml.jackson.core.JsonParser parser) { + this(parser.getTokenLocation()); + } + + @Override + public long getLineNumber() { + return location.getLineNr(); + } + + @Override + public long getColumnNumber() { + return location.getColumnNr(); + } + + @Override + public long getStreamOffset() { + long charOffset = location.getCharOffset(); + return charOffset == -1 ? location.getByteOffset() : charOffset; + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpMapper.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpMapper.java new file mode 100644 index 00000000000..692b0e937c3 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpMapper.java @@ -0,0 +1,123 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json.jackson; + +import java.io.IOException; +import java.util.EnumSet; + +import org.apache.james.backends.opensearch.json.JsonpDeserializerBase; +import org.apache.james.backends.opensearch.json.JsonpMapperBase; +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.json.JsonpMapper; +import org.opensearch.client.json.JsonpSerializer; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import jakarta.json.spi.JsonProvider; +import jakarta.json.stream.JsonGenerator; +import jakarta.json.stream.JsonParser; + +public class JacksonJsonpMapper extends JsonpMapperBase { + + private final JacksonJsonProvider provider; + private final ObjectMapper objectMapper; + + public JacksonJsonpMapper(ObjectMapper objectMapper) { + this(objectMapper, new JsonFactory()); + } + + public JacksonJsonpMapper(ObjectMapper objectMapper, JsonFactory jsonFactory) { + this.provider = new JacksonJsonProvider(jsonFactory); + this.objectMapper = objectMapper + .configure(SerializationFeature.INDENT_OUTPUT, false) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + public JacksonJsonpMapper() { + this(new ObjectMapper()); + } + + /** + * Returns the underlying Jackson mapper. + */ + public ObjectMapper objectMapper() { + return this.objectMapper; + } + + @Override + public JsonProvider jsonProvider() { + return provider; + } + + @Override + protected JsonpDeserializer getDefaultDeserializer(Class clazz) { + return new JacksonValueParser<>(clazz); + } + + @Override + public void serialize(T value, JsonGenerator generator) { + + if (!(generator instanceof JacksonJsonpGenerator)) { + throw new IllegalArgumentException("Jackson's ObjectMapper can only be used with the JacksonJsonpProvider"); + } + + JsonpSerializer serializer = findSerializer(value); + if (serializer != null) { + serializer.serialize(value, generator, this); + return; + } + + com.fasterxml.jackson.core.JsonGenerator jkGenerator = ((JacksonJsonpGenerator)generator).jacksonGenerator(); + try { + objectMapper.writeValue(jkGenerator, value); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + } + + private class JacksonValueParser extends JsonpDeserializerBase { + + private final Class clazz; + + protected JacksonValueParser(Class clazz) { + super(EnumSet.allOf(JsonParser.Event.class)); + this.clazz = clazz; + } + + @Override + public T deserialize(JsonParser parser, JsonpMapper mapper, JsonParser.Event event) { + + if (!(parser instanceof JacksonJsonpParser)) { + throw new IllegalArgumentException("Jackson's ObjectMapper can only be used with the JacksonJsonpProvider"); + } + + com.fasterxml.jackson.core.JsonParser jkParser = ((JacksonJsonpParser)parser).jacksonParser(); + + try { + return objectMapper.readValue(jkParser, clazz); + } catch (IOException ioe) { + throw JacksonUtils.convertException(ioe); + } + } + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpParser.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpParser.java new file mode 100644 index 00000000000..b1dcacccb2a --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonJsonpParser.java @@ -0,0 +1,313 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json.jackson; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.EnumMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.stream.Stream; + +import com.fasterxml.jackson.core.JsonToken; + +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonValue; +import jakarta.json.stream.JsonLocation; +import jakarta.json.stream.JsonParser; +import jakarta.json.stream.JsonParsingException; + +/** + * A JSONP parser implementation on top of Jackson. + *

+ * Warning: this implementation isn't fully compliant with the JSONP specification: calling {@link #hasNext()} + * moves forward the underlying Jackson parser as Jackson doesn't provide an equivalent method. This means no value + * getter method (e.g. {@link #getInt()} or {@link #getString()} should be called until the next call to {@link #next()}. + * Such calls will throw an {@code IllegalStateException}. + */ +public class JacksonJsonpParser implements JsonParser { + + private final com.fasterxml.jackson.core.JsonParser parser; + + private boolean hasNextWasCalled = false; + + private static final EnumMap tokenToEvent; + + static { + tokenToEvent = new EnumMap<>(JsonToken.class); + tokenToEvent.put(JsonToken.END_ARRAY, Event.END_ARRAY); + tokenToEvent.put(JsonToken.END_OBJECT, Event.END_OBJECT); + tokenToEvent.put(JsonToken.FIELD_NAME, Event.KEY_NAME); + tokenToEvent.put(JsonToken.START_ARRAY, Event.START_ARRAY); + tokenToEvent.put(JsonToken.START_OBJECT, Event.START_OBJECT); + tokenToEvent.put(JsonToken.VALUE_FALSE, Event.VALUE_FALSE); + tokenToEvent.put(JsonToken.VALUE_NULL, Event.VALUE_NULL); + tokenToEvent.put(JsonToken.VALUE_NUMBER_FLOAT, Event.VALUE_NUMBER); + tokenToEvent.put(JsonToken.VALUE_NUMBER_INT, Event.VALUE_NUMBER); + tokenToEvent.put(JsonToken.VALUE_STRING, Event.VALUE_STRING); + tokenToEvent.put(JsonToken.VALUE_TRUE, Event.VALUE_TRUE); + + // No equivalent for + // - VALUE_EMBEDDED_OBJECT + // - NOT_AVAILABLE + } + + public JacksonJsonpParser(com.fasterxml.jackson.core.JsonParser parser) { + this.parser = parser; + } + + /** + * Returns the underlying Jackson parser. + */ + public com.fasterxml.jackson.core.JsonParser jacksonParser() { + return this.parser; + } + + private JsonParsingException convertException(IOException ioe) { + return new JsonParsingException("Jackson exception: " + ioe.getMessage(), ioe, getLocation()); + } + + private JsonToken fetchNextToken() { + try { + return parser.nextToken(); + } catch (IOException e) { + throw convertException(e); + } + } + + private void ensureTokenIsCurrent() { + if (hasNextWasCalled) { + throw new IllegalStateException("Cannot get event data as parser as already been moved to the next event"); + } + } + + @Override + public boolean hasNext() { + if (hasNextWasCalled) { + return parser.currentToken() != null; + } else { + hasNextWasCalled = true; + return fetchNextToken() != null; + } + } + + @Override + public Event next() { + JsonToken token; + if (hasNextWasCalled) { + token = parser.getCurrentToken(); + hasNextWasCalled = false; + } else { + token = fetchNextToken(); + } + + if (token == null) { + throw new NoSuchElementException(); + } + + Event result = tokenToEvent.get(token); + if (result == null) { + throw new JsonParsingException("Unsupported Jackson event type '" + token + "'", getLocation()); + } + + return result; + } + + @Override + public String getString() { + ensureTokenIsCurrent(); + try { + return parser.getValueAsString(); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public boolean isIntegralNumber() { + ensureTokenIsCurrent(); + return parser.isExpectedNumberIntToken(); + } + + @Override + public int getInt() { + ensureTokenIsCurrent(); + try { + return parser.getIntValue(); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public long getLong() { + ensureTokenIsCurrent(); + try { + return parser.getLongValue(); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public BigDecimal getBigDecimal() { + ensureTokenIsCurrent(); + try { + return parser.getDecimalValue(); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public JsonLocation getLocation() { + return new JacksonJsonpLocation(parser.getCurrentLocation()); + } + + @Override + public void close() { + try { + parser.close(); + } catch (IOException e) { + throw convertException(e); + } + } + + private JsonValueParser valueParser; + + @Override + public JsonObject getObject() { + ensureTokenIsCurrent(); + if (parser.currentToken() != JsonToken.START_OBJECT) { + throw new IllegalStateException("Unexpected event '" + parser.currentToken() + + "' at " + parser.getTokenLocation()); + } + if (valueParser == null) { + valueParser = new JsonValueParser(); + } + try { + return valueParser.parseObject(parser); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public JsonArray getArray() { + ensureTokenIsCurrent(); + if (valueParser == null) { + valueParser = new JsonValueParser(); + } + if (parser.currentToken() != JsonToken.START_ARRAY) { + throw new IllegalStateException("Unexpected event '" + parser.currentToken() + + "' at " + parser.getTokenLocation()); + } + try { + return valueParser.parseArray(parser); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public JsonValue getValue() { + ensureTokenIsCurrent(); + if (valueParser == null) { + valueParser = new JsonValueParser(); + } + try { + return valueParser.parseValue(parser); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public void skipObject() { + ensureTokenIsCurrent(); + if (parser.currentToken() != JsonToken.START_OBJECT) { + return; + } + + try { + int depth = 1; + JsonToken token; + do { + token = parser.nextToken(); + switch (token) { + case START_OBJECT: + depth++; + break; + case END_OBJECT: + depth--; + break; + } + } while (!(token == JsonToken.END_OBJECT && depth == 0)); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public void skipArray() { + ensureTokenIsCurrent(); + if (parser.currentToken() != JsonToken.START_ARRAY) { + return; + } + + try { + int depth = 1; + JsonToken token; + do { + token = parser.nextToken(); + switch (token) { + case START_ARRAY: + depth++; + break; + case END_ARRAY: + depth--; + break; + } + } while (!(token == JsonToken.END_ARRAY && depth == 0)); + } catch (IOException e) { + throw convertException(e); + } + } + + @Override + public Stream> getObjectStream() { + return getObject().entrySet().stream(); + } + + @Override + public Stream getArrayStream() { + return getArray().stream(); + } + + /** + * Not implemented. + */ + @Override + public Stream getValueStream() { + return JsonParser.super.getValueStream(); + } +} + diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonUtils.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonUtils.java new file mode 100644 index 00000000000..d5e7782395b --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JacksonUtils.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json.jackson; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonParseException; + +import jakarta.json.JsonException; +import jakarta.json.stream.JsonGenerationException; +import jakarta.json.stream.JsonParsingException; + +class JacksonUtils { + public static JsonException convertException(IOException ioe) { + if (ioe instanceof com.fasterxml.jackson.core.JsonGenerationException) { + return new JsonGenerationException(ioe.getMessage(), ioe); + + } else if (ioe instanceof JsonParseException) { + JsonParseException jpe = (JsonParseException) ioe; + return new JsonParsingException(ioe.getMessage(), jpe, new JacksonJsonpLocation(jpe.getLocation())); + + } else { + return new JsonException("Jackson exception", ioe); + } + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JsonValueParser.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JsonValueParser.java new file mode 100644 index 00000000000..a66bbd3edb5 --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/jackson/JsonValueParser.java @@ -0,0 +1,108 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json.jackson; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; + +import jakarta.json.JsonArray; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; +import jakarta.json.JsonValue; +import jakarta.json.spi.JsonProvider; +import jakarta.json.stream.JsonParsingException; + +/** + * Reads a Jsonp value/object/array from a Jackson parser. The parser's current token should be the start of the + * object (e.g. START_OBJECT, VALUE_NUMBER, etc). + */ +class JsonValueParser { + private static final JsonProvider provider = JsonProvider.provider(); + + public JsonObject parseObject(JsonParser parser) throws IOException { + + JsonObjectBuilder ob = provider.createObjectBuilder(); + + JsonToken token; + while ((token = parser.nextToken()) != JsonToken.END_OBJECT) { + if (token != JsonToken.FIELD_NAME) { + throw new JsonParsingException("Expected a property name", new JacksonJsonpLocation(parser)); + } + String name = parser.getCurrentName(); + parser.nextToken(); + ob.add(name, parseValue(parser)); + } + return ob.build(); + } + + public JsonArray parseArray(JsonParser parser) throws IOException { + JsonArrayBuilder ab = provider.createArrayBuilder(); + + while (parser.nextToken() != JsonToken.END_ARRAY) { + ab.add(parseValue(parser)); + } + return ab.build(); + } + + public JsonValue parseValue(JsonParser parser) throws IOException { + switch (parser.currentToken()) { + case START_OBJECT: + return parseObject(parser); + + case START_ARRAY: + return parseArray(parser); + + case VALUE_TRUE: + return JsonValue.TRUE; + + case VALUE_FALSE: + return JsonValue.FALSE; + + case VALUE_NULL: + return JsonValue.NULL; + + case VALUE_STRING: + return provider.createValue(parser.getText()); + + case VALUE_NUMBER_FLOAT: + case VALUE_NUMBER_INT: + switch (parser.getNumberType()) { + case INT: + return provider.createValue(parser.getIntValue()); + case LONG: + return provider.createValue(parser.getLongValue()); + case FLOAT: + case DOUBLE: + return provider.createValue(parser.getDoubleValue()); + case BIG_DECIMAL: + return provider.createValue(parser.getDecimalValue()); + case BIG_INTEGER: + return provider.createValue(parser.getBigIntegerValue()); + } + + default: + throw new JsonParsingException("Unexpected token '" + parser.currentToken() + "'", new JacksonJsonpLocation(parser)); + + } + } +} diff --git a/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/package-info.java b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/package-info.java new file mode 100644 index 00000000000..db5d3f4c13d --- /dev/null +++ b/backends-common/opensearch/src/main/java/org/apache/james/backends/opensearch/json/package-info.java @@ -0,0 +1,29 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.opensearch.json; + +/** + * See {@link https://github.com/opensearch-project/opensearch-java/issues/292} + * + * The Jackson related code was copied from {@link https://github.com/opensearch-project/opensearch-java} in order to get rid of a SPI lookup for each deserialized request, + * that was happening on the event loop. + * + * The only modified class is {@link jackson.JsonValueParser} where the SPI lookup was made static. + */ \ No newline at end of file