From 475c2c2f1e065c71ee220491c853f95c7a60bfcf Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 13 May 2020 11:48:30 +0200 Subject: [PATCH 1/9] Issue #4836 - Too Many Files Error with AWS ALB. Added test to replicate the traffic seen in the issue. The test shows that we do close the server EndPoint. Signed-off-by: Simone Bordet --- .../jetty/client/ssl/SslBytesServerTest.java | 75 +++++++++++++++++-- .../jetty/client/ssl/SslBytesTest.java | 2 + 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java index 16162f34aae..40332f31b01 100644 --- a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java +++ b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesServerTest.java @@ -67,6 +67,7 @@ import org.eclipse.jetty.server.handler.AbstractHandler; import org.eclipse.jetty.toolchain.test.MavenTestingUtils; import org.eclipse.jetty.util.component.Dumpable; import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; @@ -88,8 +89,9 @@ import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.condition.OS.LINUX; import static org.junit.jupiter.api.condition.OS.WINDOWS; -// This whole test is very specific to how TLS < 1.3 works. -@EnabledOnJre({JRE.JAVA_8, JRE.JAVA_9, JRE.JAVA_10}) +// Other JREs have slight differences in how TLS work +// and this test expects a very specific TLS behavior. +@EnabledOnJre({JRE.JAVA_8, JRE.JAVA_11}) public class SslBytesServerTest extends SslBytesTest { private final AtomicInteger sslFills = new AtomicInteger(); @@ -108,9 +110,9 @@ public class SslBytesServerTest extends SslBytesTest @BeforeEach public void init() throws Exception { - - threadPool = Executors.newCachedThreadPool(); - server = new Server(); + QueuedThreadPool serverThreads = new QueuedThreadPool(); + serverThreads.setName("server"); + server = new Server(serverThreads); sslFills.set(0); sslFlushes.set(0); @@ -119,6 +121,8 @@ public class SslBytesServerTest extends SslBytesTest File keyStore = MavenTestingUtils.getTestResourceFile("keystore.p12"); sslContextFactory = new SslContextFactory.Server(); + // This whole test is very specific to how TLS < 1.3 works. + sslContextFactory.setIncludeProtocols("TLSv1.2"); sslContextFactory.setKeyStorePath(keyStore.getAbsolutePath()); sslContextFactory.setKeyStorePassword("storepwd"); @@ -238,6 +242,7 @@ public class SslBytesServerTest extends SslBytesTest sslContext = sslContextFactory.getSslContext(); + threadPool = Executors.newCachedThreadPool(); proxy = new SimpleProxy(threadPool, "localhost", serverPort); proxy.start(); logger.info("proxy:{} <==> server:{}", proxy.getPort(), serverPort); @@ -1128,6 +1133,66 @@ public class SslBytesServerTest extends SslBytesTest client.close(); } + @Test + public void testRequestResponseServerIdleTimeoutClientResets() throws Exception + { + SSLSocket client = newClient(); + + SimpleProxy.AutomaticFlow automaticProxyFlow = proxy.startAutomaticFlow(); + client.startHandshake(); + assertTrue(automaticProxyFlow.stop(5, TimeUnit.SECONDS)); + + Future request = threadPool.submit(() -> + { + OutputStream clientOutput = client.getOutputStream(); + clientOutput.write(( + "GET / HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "\r\n").getBytes(StandardCharsets.UTF_8)); + clientOutput.flush(); + return null; + }); + + // Application data + TLSRecord record = proxy.readFromClient(); + proxy.flushToServer(record); + assertNull(request.get(5, TimeUnit.SECONDS)); + + // Application data + record = proxy.readFromServer(); + assertEquals(TLSRecord.Type.APPLICATION, record.getType()); + proxy.flushToClient(record); + + BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); + String line = reader.readLine(); + assertNotNull(line); + assertTrue(line.startsWith("HTTP/1.1 200 ")); + while ((line = reader.readLine()) != null) + { + if (line.trim().length() == 0) + break; + } + + // Wait for the server idle timeout. + Thread.sleep(idleTimeout); + + // We expect that the server sends the TLS Alert. + record = proxy.readFromServer(); + assertNotNull(record); + assertEquals(TLSRecord.Type.ALERT, record.getType()); + + // Send a RST to the server. + proxy.sendRSTToServer(); + + // Wait for the RST to be processed by the server. + Thread.sleep(1000); + + // The server EndPoint must be closed. + assertFalse(serverEndPoint.get().isOpen()); + + client.close(); + } + @Test @EnabledOnOs(LINUX) // see message below public void testRequestWithCloseAlertWithSplitBoundary() throws Exception diff --git a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java index 06827ecc9e3..b5b287391e4 100644 --- a/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java +++ b/jetty-client/src/test/java/org/eclipse/jetty/client/ssl/SslBytesTest.java @@ -237,6 +237,7 @@ public abstract class SslBytesTest public void flushToServer(TLSRecord record, long sleep) throws Exception { + logger.debug("P --> S {}", record); if (record == null) { server.shutdownOutput(); @@ -275,6 +276,7 @@ public abstract class SslBytesTest public void flushToClient(TLSRecord record) throws Exception { + logger.debug("C <-- P {}", record); if (record == null) { client.shutdownOutput(); From 3ead429476cc82567631e40aa62376de7e178439 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 20 May 2020 12:35:16 +0200 Subject: [PATCH 2/9] Fixes #4892 - Non-blocking JSON parser. Implemented non-blocking JSON parser. Signed-off-by: Simone Bordet --- .../eclipse/jetty/util/ajax/AsyncJSON.java | 1205 +++++++++++++++++ .../jetty/util/ajax/AsyncJSONTest.java | 503 +++++++ 2 files changed, 1708 insertions(+) create mode 100644 jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java create mode 100644 jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java diff --git a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java new file mode 100644 index 00000000000..8dba7382402 --- /dev/null +++ b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java @@ -0,0 +1,1205 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// ------------------------------------------------------------------------ +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Public License v1.0 +// and Apache License v2.0 which accompanies this distribution. +// +// The Eclipse Public License is available at +// http://www.eclipse.org/legal/epl-v10.html +// +// The Apache License v2.0 is available at +// http://www.opensource.org/licenses/apache2.0.php +// +// You may elect to redistribute this code under either of these licenses. +// ======================================================================== +// + +package org.eclipse.jetty.util.ajax; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jetty.util.ArrayTernaryTrie; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Loader; +import org.eclipse.jetty.util.Trie; +import org.eclipse.jetty.util.TypeUtil; +import org.eclipse.jetty.util.Utf8StringBuilder; + +public class AsyncJSON +{ + public static class Factory + { + private Trie cache; + private Map convertors; + private boolean detailedParseException; + + public boolean isDetailedParseException() + { + return detailedParseException; + } + + public void setDetailedParseException(boolean detailedParseException) + { + this.detailedParseException = detailedParseException; + } + + public void cache(String value) + { + if (cache == null) + cache = new ArrayTernaryTrie.Growing<>(false, 64, 64); + cache.put("\"" + value + "\"", value); + } + + public String cached(ByteBuffer buffer) + { + if (cache != null) + { + String result = cache.getBest(buffer, 0, buffer.remaining()); + if (result != null) + { + buffer.position(buffer.position() + result.length() + 2); + return result; + } + } + return null; + } + + public AsyncJSON newAsyncJSON() + { + return new AsyncJSON(this); + } + + public void putConvertor(String className, JSON.Convertor convertor) + { + if (convertors == null) + convertors = new ConcurrentHashMap<>(); + convertors.put(className, convertor); + } + + public JSON.Convertor removeConvertor(String className) + { + if (convertors != null) + return convertors.remove(className); + return null; + } + } + + private static final Object UNSET = new Object(); + + private final Factory factory; + private final FrameStack stack = new FrameStack(); + private final Utf8StringBuilder stringBuilder; + private final NumberBuilder numberBuilder; + private List chunks; + + public AsyncJSON(Factory factory) + { + this.factory = factory; + this.stringBuilder = new Utf8StringBuilder(32); + this.numberBuilder = new NumberBuilder(); + } + + // Used by tests only. + boolean isEmpty() + { + return stack.isEmpty(); + } + + public boolean parse(byte[] bytes) + { + return parse(ByteBuffer.wrap(bytes)); + } + + public boolean parse(ByteBuffer buffer) + { + try + { + if (factory.isDetailedParseException()) + { + if (chunks == null) + chunks = new ArrayList<>(); + ByteBuffer copy = buffer.isDirect() + ? ByteBuffer.allocateDirect(buffer.remaining()) + : ByteBuffer.allocate(buffer.remaining()); + copy.put(buffer).flip(); + chunks.add(copy); + buffer.flip(); + } + + if (stack.isEmpty()) + stack.push(State.COMPLETE, UNSET); + + while (true) + { + Frame frame = stack.peek(); + State state = frame.state; + switch (state) + { + case COMPLETE: + { + if (frame.value == UNSET) + { + if (parseAny(buffer)) + break; + return false; + } + else + { + while (buffer.hasRemaining()) + { + int position = buffer.position(); + byte peek = buffer.get(position); + if (Character.isWhitespace((char)peek)) + buffer.position(position + 1); + else + throw newInvalidJSON(buffer, "invalid character after JSON data"); + } + return true; + } + } + case NULL: + { + if (parseNull(buffer)) + break; + return false; + } + case TRUE: + { + if (parseTrue(buffer)) + break; + return false; + } + case FALSE: + { + if (parseFalse(buffer)) + break; + return false; + } + case NUMBER: + { + if (parseNumber(buffer)) + break; + return false; + } + case STRING: + { + if (parseString(buffer)) + break; + return false; + } + case ESCAPE: + { + if (parseEscape(buffer)) + break; + return false; + } + case UNICODE: + { + if (parseUnicode(buffer)) + break; + return false; + } + case ARRAY: + { + if (parseArray(buffer)) + break; + return false; + } + case OBJECT: + { + if (parseObject(buffer)) + break; + return false; + } + case OBJECT_FIELD: + { + if (parseObjectField(buffer)) + break; + return false; + } + case OBJECT_FIELD_NAME: + { + if (parseObjectFieldName(buffer)) + break; + return false; + } + case OBJECT_FIELD_VALUE: + { + if (parseObjectFieldValue(buffer)) + break; + return false; + } + default: + { + throw new IllegalStateException("invalid state " + state); + } + } + } + } + catch (Throwable x) + { + reset(); + throw x; + } + } + + public R eof() + { + try + { + if (stack.isEmpty()) + throw new IllegalStateException("no JSON parsed"); + + while (true) + { + State state = stack.peek().state; + switch (state) + { + case NUMBER: + { + Number value = numberBuilder.value(); + stack.pop(); + stack.peek().value(value); + break; + } + case COMPLETE: + { + if (stack.peek().value == UNSET) + throw new IllegalStateException("invalid state " + state); + return (R)complete(); + } + default: + { + throw newInvalidJSON(BufferUtil.EMPTY_BUFFER, "incomplete JSON"); + } + } + } + } + catch (Throwable x) + { + reset(); + throw x; + } + } + + protected Map newObject(Context context) + { + return new HashMap<>(); + } + + protected List newArray(Context context) + { + return new ArrayList<>(); + } + + private Object complete() + { + Object result = stack.peek().value; + reset(); + return result; + } + + private void reset() + { + stack.clear(); + chunks = null; + } + + private boolean parseAny(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + case '[': + if (parseArray(buffer)) + return true; + break; + case '{': + if (parseObject(buffer)) + return true; + break; + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + if (parseNumber(buffer)) + return true; + break; + case '"': + if (parseString(buffer)) + return true; + break; + case 'f': + if (parseFalse(buffer)) + return true; + break; + case 'n': + if (parseNull(buffer)) + return true; + break; + case 't': + if (parseTrue(buffer)) + return true; + break; + default: + if (Character.isWhitespace(peek)) + { + buffer.get(); + break; + } + throw newInvalidJSON(buffer, "unrecognized JSON value"); + } + } + return false; + } + + private boolean parseNull(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case 'n': + if (stack.peek().state != State.NULL) + { + stack.push(State.NULL, 0); + parseNullCharacter(buffer, 0); + break; + } + throw newInvalidJSON(buffer, "invalid 'null' literal"); + case 'u': + parseNullCharacter(buffer, 1); + break; + case 'l': + int index = (Integer)stack.peek().value; + if (index == 2 || index == 3) + parseNullCharacter(buffer, index); + else + throw newInvalidJSON(buffer, "invalid 'null' literal"); + if (index == 3) + { + stack.pop(); + stack.peek().value(null); + return true; + } + break; + default: + throw newInvalidJSON(buffer, "invalid 'null' literal"); + } + } + return false; + } + + private void parseNullCharacter(ByteBuffer buffer, int index) + { + Frame frame = stack.peek(); + int value = (Integer)frame.value; + if (value == index) + frame.value = ++value; + else + throw newInvalidJSON(buffer, "invalid 'null' literal"); + } + + private boolean parseTrue(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case 't': + if (stack.peek().state != State.TRUE) + { + stack.push(State.TRUE, 0); + parseTrueCharacter(buffer, 0); + break; + } + throw newInvalidJSON(buffer, "invalid 'true' literal"); + case 'r': + parseTrueCharacter(buffer, 1); + break; + case 'u': + parseTrueCharacter(buffer, 2); + break; + case 'e': + parseTrueCharacter(buffer, 3); + stack.pop(); + stack.peek().value(Boolean.TRUE); + return true; + default: + throw newInvalidJSON(buffer, "invalid 'true' literal"); + } + } + return false; + } + + private void parseTrueCharacter(ByteBuffer buffer, int index) + { + Frame frame = stack.peek(); + int value = (Integer)frame.value; + if (value == index) + frame.value = ++value; + else + throw newInvalidJSON(buffer, "invalid 'true' literal"); + } + + private boolean parseFalse(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case 'f': + if (stack.peek().state != State.FALSE) + { + stack.push(State.FALSE, 0); + parseFalseCharacter(buffer, 0); + break; + } + throw newInvalidJSON(buffer, "invalid 'false' literal"); + case 'a': + parseFalseCharacter(buffer, 1); + break; + case 'l': + parseFalseCharacter(buffer, 2); + break; + case 's': + parseFalseCharacter(buffer, 3); + break; + case 'e': + parseFalseCharacter(buffer, 4); + stack.pop(); + stack.peek().value(Boolean.FALSE); + return true; + default: + throw newInvalidJSON(buffer, "invalid 'false' literal"); + } + } + return false; + } + + private void parseFalseCharacter(ByteBuffer buffer, int index) + { + Frame frame = stack.peek(); + int value = (Integer)frame.value; + if (value == index) + frame.value = ++value; + else + throw newInvalidJSON(buffer, "invalid 'false' literal"); + } + + private boolean parseNumber(ByteBuffer buffer) + { + if (stack.peek().state != State.NUMBER) + stack.push(State.NUMBER, numberBuilder); + + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case '+': + case '-': + if (numberBuilder.appendSign(currentByte)) + break; + throw newInvalidJSON(buffer, "invalid number"); + case '.': + case 'E': + case 'e': + if (numberBuilder.appendAlpha(currentByte)) + break; + throw newInvalidJSON(buffer, "invalid number"); + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + numberBuilder.appendDigit(currentByte); + break; + default: + buffer.position(buffer.position() - 1); + Number value = numberBuilder.value(); + stack.pop(); + stack.peek().value(value); + return true; + } + } + return false; + } + + private boolean parseString(ByteBuffer buffer) + { + Frame frame = stack.peek(); + if (buffer.hasRemaining() && frame.state != State.STRING) + { + String result = factory.cached(buffer); + if (result != null) + { + frame.value(result); + return true; + } + } + + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + // Explicit delimiter, handle push and pop in this method. + case '"': + { + if (stack.peek().state != State.STRING) + { + stack.push(State.STRING, stringBuilder); + break; + } + else + { + String string = stringBuilder.toString(); + stringBuilder.reset(); + stack.pop(); + stack.peek().value(string); + return true; + } + } + case '\\': + { + buffer.position(buffer.position() - 1); + if (parseEscape(buffer)) + break; + return false; + } + default: + { + stringBuilder.append(currentByte); + break; + } + } + } + return false; + } + + private boolean parseEscape(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + case '\\': + if (stack.peek().state != State.ESCAPE) + { + stack.push(State.ESCAPE, stringBuilder); + break; + } + else + { + return parseEscapeCharacter((char)currentByte); + } + case '"': + case '/': + return parseEscapeCharacter((char)currentByte); + case 'b': + return parseEscapeCharacter('\b'); + case 'f': + return parseEscapeCharacter('\f'); + case 'n': + return parseEscapeCharacter('\n'); + case 'r': + return parseEscapeCharacter('\r'); + case 't': + return parseEscapeCharacter('\t'); + case 'u': + stack.push(State.UNICODE, ByteBuffer.allocate(4)); + return parseUnicode(buffer); + default: + throw newInvalidJSON(buffer, "invalid escape sequence"); + } + } + return false; + } + + private boolean parseEscapeCharacter(char escape) + { + stack.pop(); + stringBuilder.append(escape); + return true; + } + + private boolean parseUnicode(ByteBuffer buffer) + { + // Expect 4 hex digits. + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + ByteBuffer hex = (ByteBuffer)stack.peek().value; + hex.put(hexToByte(buffer, currentByte)); + if (!hex.hasRemaining()) + { + int result = (hex.get(0) << 12) + + (hex.get(1) << 8) + + (hex.get(2) << 4) + + (hex.get(3)); + stack.pop(); + // Also done with escape parsing. + stack.pop(); + stringBuilder.append((char)result); + return true; + } + } + return false; + } + + private byte hexToByte(ByteBuffer buffer, byte currentByte) + { + try + { + return TypeUtil.convertHexDigit(currentByte); + } + catch (Throwable x) + { + throw newInvalidJSON(buffer, "invalid hex digit"); + } + } + + private boolean parseArray(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + // Explicit delimiters, handle push and pop in this method. + case '[': + { + buffer.get(); + stack.push(State.ARRAY, newArray(stack)); + break; + } + case ']': + { + buffer.get(); + Object array = stack.peek().value; + stack.pop(); + stack.peek().value(array); + return true; + } + case ',': + { + buffer.get(); + break; + } + default: + { + if (Character.isWhitespace(peek)) + { + buffer.get(); + break; + } + else + { + if (parseAny(buffer)) + { + break; + } + return false; + } + } + } + } + return false; + } + + private boolean parseObject(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte currentByte = buffer.get(); + switch (currentByte) + { + // Explicit delimiters, handle push and pop in this method. + case '{': + { + if (stack.peek().state != State.OBJECT) + { + stack.push(State.OBJECT, newObject(stack)); + break; + } + throw newInvalidJSON(buffer, "invalid object"); + } + case '}': + { + @SuppressWarnings("unchecked") + Map object = (Map)stack.peek().value; + stack.pop(); + stack.peek().value(convertObject(object)); + return true; + } + case ',': + { + break; + } + default: + { + if (Character.isWhitespace(currentByte)) + { + break; + } + else + { + buffer.position(buffer.position() - 1); + if (parseObjectField(buffer)) + break; + return false; + } + } + } + } + return false; + } + + private boolean parseObjectField(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + case '"': + { + if (stack.peek().state == State.OBJECT) + { + stack.push(State.OBJECT_FIELD, UNSET); + if (parseObjectFieldName(buffer)) + { + // We are not done yet, parse the value. + break; + } + return false; + } + else + { + return parseObjectFieldValue(buffer); + } + } + default: + { + if (Character.isWhitespace(peek)) + { + buffer.get(); + break; + } + else if (stack.peek().state == State.OBJECT_FIELD_VALUE) + { + return parseObjectFieldValue(buffer); + } + else + { + throw newInvalidJSON(buffer, "invalid object field"); + } + } + } + } + return false; + } + + private boolean parseObjectFieldName(ByteBuffer buffer) + { + while (buffer.hasRemaining()) + { + byte peek = buffer.get(buffer.position()); + switch (peek) + { + case '"': + { + if (stack.peek().state == State.OBJECT_FIELD) + { + stack.push(State.OBJECT_FIELD_NAME, UNSET); + if (parseString(buffer)) + { + // We are not done yet, parse until the ':'. + break; + } + return false; + } + else + { + throw newInvalidJSON(buffer, "invalid object field"); + } + } + case ':': + { + buffer.get(); + // We are done with the field name. + String fieldName = (String)stack.peek().value; + stack.pop(); + // Change state to parse the field value. + stack.push(fieldName, State.OBJECT_FIELD_VALUE, UNSET); + return true; + } + default: + { + if (Character.isWhitespace(peek)) + { + buffer.get(); + break; + } + else + { + throw newInvalidJSON(buffer, "invalid object field"); + } + } + } + } + return false; + } + + private boolean parseObjectFieldValue(ByteBuffer buffer) + { + if (stack.peek().value == UNSET) + { + if (!parseAny(buffer)) + return false; + } + + // We are done with the field value. + Frame frame = stack.peek(); + Object value = frame.value; + String name = frame.name; + stack.pop(); + // We are done with the field. + stack.pop(); + @SuppressWarnings("unchecked") + Map map = (Map)stack.peek().value; + map.put(name, value); + + return true; + } + + private Object convertObject(Map object) + { + Object result = convertObject("x-class", object); + if (result == null) + { + result = convertObject("class", object); + if (result == null) + return object; + } + return result; + } + + private Object convertObject(String fieldName, Map object) + { + String className = (String)object.get(fieldName); + if (className == null) + return null; + + JSON.Convertible convertible = toConvertible(className); + if (convertible != null) + { + convertible.fromJSON(object); + return convertible; + } + + JSON.Convertor convertor = factory.convertors.get(className); + if (convertor != null) + return convertor.fromJSON(object); + + return null; + } + + private JSON.Convertible toConvertible(String className) + { + try + { + Class klass = Loader.loadClass(className); + if (JSON.Convertible.class.isAssignableFrom(klass)) + return (JSON.Convertible)klass.getConstructor().newInstance(); + return null; + } + catch (Throwable x) + { + throw new IllegalArgumentException(x); + } + } + + protected RuntimeException newInvalidJSON(ByteBuffer buffer, String message) + { + Utf8StringBuilder builder = new Utf8StringBuilder(); + builder.append(System.lineSeparator()); + int position = buffer.position(); + if (factory.isDetailedParseException()) + { + chunks.forEach(chunk -> builder.append(buffer)); + } + else + { + buffer.position(0); + builder.append(buffer); + buffer.position(position); + } + builder.append(System.lineSeparator()); + String indent = ""; + if (position > 1) + { + char[] chars = new char[position - 1]; + Arrays.fill(chars, ' '); + indent = new String(chars); + } + builder.append(indent); + builder.append("^ "); + builder.append(message); + return new IllegalArgumentException(builder.toString()); + } + + public interface Context + { + public int depth(); + } + + private enum State + { + COMPLETE, NULL, TRUE, FALSE, NUMBER, STRING, ESCAPE, UNICODE, ARRAY, OBJECT, OBJECT_FIELD, OBJECT_FIELD_NAME, OBJECT_FIELD_VALUE + } + + private static class Frame + { + private String name; + private State state; + private Object value; + + private Frame(State state, Object value) + { + this.state = state; + this.value = value; + } + + private void value(Object value) + { + switch (state) + { + case COMPLETE: + case STRING: + case OBJECT_FIELD_NAME: + case OBJECT_FIELD_VALUE: + { + this.value = value; + break; + } + case ARRAY: + { + @SuppressWarnings("unchecked") + List array = (List)this.value; + array.add(value); + break; + } + default: + { + throw new IllegalStateException("invalid state " + state); + } + } + } + } + + private static class NumberBuilder + { + // 1 => positive integer + // 0 => non-integer + // -1 => negative integer + private int integer = 1; + private long value; + private StringBuilder builder; + + private boolean appendSign(byte b) + { + if (integer == 0) + { + if (builder.length() == 0) + { + builder.append((char)b); + return true; + } + else + { + char c = builder.charAt(builder.length() - 1); + if (c == 'E' || c == 'e') + { + builder.append((char)b); + return true; + } + } + return false; + } + else + { + if (value == 0) + { + if (b == '-') + { + if (integer == 1) + { + integer = -1; + return true; + } + } + else + { + return true; + } + } + } + return false; + } + + private void appendDigit(byte b) + { + if (integer == 0) + builder.append((char)b); + else + value = value * 10 + (b - '0'); + } + + private boolean appendAlpha(byte b) + { + if (integer == 0) + { + char c = builder.charAt(builder.length() - 1); + if ('0' <= c && c <= '9' && builder.indexOf("" + (char)b) < 0) + { + builder.append((char)b); + return true; + } + } + else + { + if (builder == null) + builder = new StringBuilder(16); + if (integer == -1) + builder.append('-'); + integer = 0; + builder.append(value); + builder.append((char)b); + return true; + } + return false; + } + + private Number value() + { + try + { + if (integer == 0) + return Double.parseDouble(builder.toString()); + return integer * value; + } + finally + { + reset(); + } + } + + private void reset() + { + integer = 1; + value = 0; + if (builder != null) + builder.setLength(0); + } + } + + private static class FrameStack implements AsyncJSON.Context + { + private final List stack = new ArrayList<>(); + private int cursor; + + private FrameStack() + { + grow(6); + } + + private void grow(int grow) + { + for (int i = 0; i < grow; i++) + { + stack.add(new Frame(null, null)); + } + } + + private void clear() + { + while (!isEmpty()) + { + pop(); + } + } + + private boolean isEmpty() + { + return cursor == 0; + } + + @Override + public int depth() + { + return cursor - 1; + } + + private Frame peek() + { + if (isEmpty()) + throw new IllegalStateException("empty stack"); + return stack.get(depth()); + } + + private void push(AsyncJSON.State state, Object value) + { + push(null, state, value); + } + + private void push(String name, AsyncJSON.State state, Object value) + { + if (cursor == stack.size()) + grow(2); + ++cursor; + Frame frame = stack.get(depth()); + frame.name = name; + frame.state = state; + frame.value = value; + } + + private void pop() + { + if (isEmpty()) + throw new IllegalStateException("empty stack"); + Frame frame = stack.get(depth()); + --cursor; + frame.name = null; + frame.value = null; + frame.state = null; + } + } +} diff --git a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java new file mode 100644 index 00000000000..07b2ccadce8 --- /dev/null +++ b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java @@ -0,0 +1,503 @@ +/* + * Copyright (c) 2008-2020 the original author or authors. + * + * Licensed 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.eclipse.jetty.util.ajax; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AsyncJSONTest +{ + private static AsyncJSON newAsyncJSON() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + factory.setDetailedParseException(true); + return factory.newAsyncJSON(); + } + + @ParameterizedTest + @ValueSource(strings = {"|", "}", "]", "{]", "[}", "+", ".", "{} []"}) + public void testParseInvalidJSON(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> parser.parse(bytes)); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + }); + assertTrue(parser.isEmpty()); + } + + + @ParameterizedTest(name = "[{index}] ''{0}'' -> ''{1}''") + @MethodSource("validStrings") + public void testParseString(String string, String expected) + { + String json = "\"${value}\"".replace("${value}", string); + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertTrue(parser.parse(bytes)); + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (int i = 0; i < bytes.length; ++i) + { + byte b = bytes[i]; + if (i == bytes.length - 1) + assertTrue(parser.parse(new byte[]{b})); + else + assertFalse(parser.parse(new byte[]{b})); + } + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + } + + public static List validStrings() + { + List result = new ArrayList<>(); + result.add(new Object[]{"", ""}); + result.add(new Object[]{" \t\r\n", " \t\r\n"}); + result.add(new Object[]{"\u20AC", "\u20AC"}); + result.add(new Object[]{"\\u20AC", "\u20AC"}); + result.add(new Object[]{"/foo", "/foo"}); + result.add(new Object[]{"123E+01", "123E+01"}); + result.add(new Object[]{"A\\u20AC/foo\\t\\n", "A\u20AC/foo\t\n"}); + result.add(new Object[]{" ABC ", " ABC "}); + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"\\u", "\\u0", "\\x"}) + public void testParseInvalidString(String value) + { + String json = "\"${value}\"".replace("${value}", value); + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> parser.parse(bytes)); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + }); + assertTrue(parser.isEmpty()); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource("validArrays") + public void testParseArray(String json, List expected) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertTrue(parser.parse(bytes)); + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + assertTrue(parser.parse(buffer)); + assertFalse(buffer.hasRemaining()); + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + } + + public static List validArrays() + { + List result = new ArrayList<>(); + + List expected = Collections.emptyList(); + result.add(new Object[]{"[]", expected}); + result.add(new Object[]{"[] \n", expected}); + + expected = new ArrayList<>(); + expected.add(Collections.emptyList()); + result.add(new Object[]{"[[]]", expected}); + + expected = new ArrayList<>(); + expected.add("first"); + expected.add(5D); + expected.add(null); + expected.add(true); + expected.add(false); + expected.add(new HashMap<>()); + HashMap last = new HashMap<>(); + last.put("a", new ArrayList<>()); + expected.add(last); + result.add(new Object[]{"[\"first\", 5E+0, null, true, false, {}, {\"a\":[]}]", expected}); + + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"[", "]", "[[,]", " [ 1,2,[ "}) + public void testParseInvalidArray(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> + { + parser.parse(bytes); + parser.eof(); + }); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + parser.eof(); + }); + assertTrue(parser.isEmpty()); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource("validObjects") + public void testParseObject(String json, Object expected) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertTrue(parser.parse(bytes)); + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (int i = 0; i < bytes.length; ++i) + { + byte b = bytes[i]; + if (i == bytes.length - 1) + { + assertTrue(parser.parse(new byte[]{b})); + } + else + { + assertFalse(parser.parse(new byte[]{b})); + } + } + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + } + + public static List validObjects() + { + List result = new ArrayList<>(); + + HashMap expected = new HashMap<>(); + result.add(new Object[]{"{}", expected}); + + expected = new HashMap<>(); + expected.put("", 0L); + result.add(new Object[]{"{\"\":0}", expected}); + + expected = new HashMap<>(); + expected.put("name", "value"); + result.add(new Object[]{"{ \"name\": \"value\" }", expected}); + + expected = new HashMap<>(); + expected.put("name", null); + expected.put("valid", true); + expected.put("secure", false); + expected.put("value", 42L); + result.add(new Object[]{ + "{, \"name\": null, \"valid\": true\n , \"secure\": false\r\n,\n \"value\":42, }", expected + }); + + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"{", "}", "{{,}", "{:\"s\"}", "{[]:0}", "{1:0}", " {\": 0} ", "{\"a: \"b\"}"}) + public void testParseInvalidObject(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> + { + parser.parse(bytes); + parser.eof(); + }); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + parser.eof(); + }); + assertTrue(parser.isEmpty()); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource("validNumbers") + public void testParseNumber(String json, Number expected) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + parser.parse(bytes); + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + ByteBuffer buffer = ByteBuffer.wrap(bytes); + parser.parse(buffer); + assertEquals(expected, parser.eof()); + assertFalse(buffer.hasRemaining()); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + assertEquals(expected, parser.eof()); + assertTrue(parser.isEmpty()); + } + + public static List validNumbers() + { + List result = new ArrayList<>(); + + result.add(new Object[]{"0", 0L}); + result.add(new Object[]{"-0", -0L}); + result.add(new Object[]{"13\n", 13L}); + result.add(new Object[]{"-42", -42L}); + result.add(new Object[]{"123.456", 123.456D}); + result.add(new Object[]{"-234.567", -234.567D}); + result.add(new Object[]{"9e0", 9D}); + result.add(new Object[]{"8E+1\t", 80D}); + result.add(new Object[]{"-7E-2 ", -0.07D}); + result.add(new Object[]{"70.5E-1", 7.05D}); + + return result; + } + + @ParameterizedTest + @ValueSource(strings = {"--", "--1", ".5", "e0", "1a1", "3-7", "1+2", "1e0e1", "1.2.3"}) + public void testParseInvalidNumber(String json) + { + byte[] bytes = json.getBytes(UTF_8); + AsyncJSON parser = newAsyncJSON(); + + // Parse the whole input. + assertThrows(IllegalArgumentException.class, () -> + { + parser.parse(bytes); + parser.eof(); + }); + assertTrue(parser.isEmpty()); + + // Parse byte by byte. + assertThrows(IllegalArgumentException.class, () -> + { + for (byte b : bytes) + { + parser.parse(new byte[]{b}); + } + parser.eof(); + }); + assertTrue(parser.isEmpty()); + } + + @Test + public void testParseObjectWithConvertor() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + CustomConvertor convertor = new CustomConvertor(); + factory.putConvertor(CustomConvertor.class.getName(), convertor); + + String json = "{" + + "\"f1\": {\"class\":\"" + CustomConvertible.class.getName() + "\", \"field\": \"value\"}," + + "\"f2\": {\"class\":\"" + CustomConvertor.class.getName() + "\"}" + + "}"; + + AsyncJSON parser = factory.newAsyncJSON(); + assertTrue(parser.parse(UTF_8.encode(json))); + Map result = parser.eof(); + + Object value1 = result.get("f1"); + assertTrue(value1 instanceof CustomConvertible); + assertEquals("value", ((CustomConvertible)value1).field); + Object value2 = result.get("f2"); + assertTrue(value2 instanceof CustomConvertor.Custom); + + assertSame(convertor, factory.removeConvertor(CustomConvertor.class.getName())); + assertTrue(parser.parse(UTF_8.encode(json))); + result = parser.eof(); + + value1 = result.get("f1"); + assertTrue(value1 instanceof CustomConvertible); + assertEquals("value", ((CustomConvertible)value1).field); + value2 = result.get("f2"); + assertTrue(value2 instanceof Map); + @SuppressWarnings("unchecked") + Map map2 = (Map)value2; + assertEquals(CustomConvertor.class.getName(), map2.get("class")); + } + + public static class CustomConvertible implements JSON.Convertible + { + private Object field; + + @Override + public void toJSON(JSON.Output out) + { + } + + @Override + public void fromJSON(Map map) + { + this.field = map.get("field"); + } + } + + public static class CustomConvertor implements JSON.Convertor + { + @Override + public void toJSON(Object obj, JSON.Output out) + { + } + + @Override + public Object fromJSON(Map map) + { + return new Custom(); + } + + public static class Custom + { + } + } + + @Test + public void testContext() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory() + { + @Override + public AsyncJSON newAsyncJSON() + { + return new AsyncJSON(this) + { + @Override + protected Map newObject(Context context) + { + if (context.depth() == 1) + { + return new CustomMap(); + } + return super.newObject(context); + } + }; + } + }; + AsyncJSON parser = factory.newAsyncJSON(); + + String json = "[{" + + "\"channel\": \"/meta/handshake\"," + + "\"version\": \"1.0\"," + + "\"supportedConnectionTypes\": [\"long-polling\"]," + + "\"advice\": {\"timeout\": 0}" + + "}]"; + + assertTrue(parser.parse(UTF_8.encode(json))); + List messages = parser.eof(); + + for (CustomMap message : messages) + { + @SuppressWarnings("unchecked") + Map advice = (Map)message.get("advice"); + assertFalse(advice instanceof CustomMap); + } + } + + public static class CustomMap extends HashMap + { + } + + @Test + public void testCaching() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + String foo = "foo"; + factory.cache(foo); + AsyncJSON parser = factory.newAsyncJSON(); + + String json = "{\"foo\": [\"foo\", \"foo\"]}"; + parser.parse(UTF_8.encode(json)); + Map object = parser.eof(); + + Map.Entry entry = object.entrySet().iterator().next(); + assertSame(foo, entry.getKey()); + @SuppressWarnings("unchecked") + List array = (List)entry.getValue(); + for (String item : array) + { + assertSame(foo, item); + } + } +} From caf2592587d5c2ff2e94091ce6cfb672a861beaa Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 20 May 2020 13:00:29 +0200 Subject: [PATCH 3/9] Fixes #4892 - Non-blocking JSON parser. Fixed copyright header. Signed-off-by: Simone Bordet --- .../jetty/util/ajax/AsyncJSONTest.java | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java index 07b2ccadce8..cbc70bc13af 100644 --- a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java +++ b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java @@ -1,18 +1,21 @@ -/* - * Copyright (c) 2008-2020 the original author or authors. - * - * Licensed 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. - */ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// ------------------------------------------------------------------------ +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Public License v1.0 +// and Apache License v2.0 which accompanies this distribution. +// +// The Eclipse Public License is available at +// http://www.eclipse.org/legal/epl-v10.html +// +// The Apache License v2.0 is available at +// http://www.opensource.org/licenses/apache2.0.php +// +// You may elect to redistribute this code under either of these licenses. +// ======================================================================== +// + package org.eclipse.jetty.util.ajax; import java.nio.ByteBuffer; From bed046f01ce8019b61694ef62de867bed84a7d27 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 20 May 2020 13:08:02 +0200 Subject: [PATCH 4/9] Fixes #4892 - Non-blocking JSON parser. Fixed checkstyle. Signed-off-by: Simone Bordet --- .../java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java index cbc70bc13af..d808ea400c4 100644 --- a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java +++ b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java @@ -68,7 +68,6 @@ public class AsyncJSONTest assertTrue(parser.isEmpty()); } - @ParameterizedTest(name = "[{index}] ''{0}'' -> ''{1}''") @MethodSource("validStrings") public void testParseString(String string, String expected) @@ -100,11 +99,11 @@ public class AsyncJSONTest List result = new ArrayList<>(); result.add(new Object[]{"", ""}); result.add(new Object[]{" \t\r\n", " \t\r\n"}); - result.add(new Object[]{"\u20AC", "\u20AC"}); - result.add(new Object[]{"\\u20AC", "\u20AC"}); + result.add(new Object[]{"\u20AC", "\u20AC"}); // euro symbol + result.add(new Object[]{"\\u20AC", "\u20AC"}); // euro symbol result.add(new Object[]{"/foo", "/foo"}); result.add(new Object[]{"123E+01", "123E+01"}); - result.add(new Object[]{"A\\u20AC/foo\\t\\n", "A\u20AC/foo\t\n"}); + result.add(new Object[]{"A\\u20AC/foo\\t\\n", "A\u20AC/foo\t\n"}); // euro symbol result.add(new Object[]{" ABC ", " ABC "}); return result; } From f2ef054b289167c98e51f49867eaf32ebdf6dca3 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 20 May 2020 14:03:30 +0200 Subject: [PATCH 5/9] Fixes #4892 - Non-blocking JSON parser. Added javadocs. Signed-off-by: Simone Bordet --- .../eclipse/jetty/util/ajax/AsyncJSON.java | 148 +++++++++++++++++- 1 file changed, 140 insertions(+), 8 deletions(-) diff --git a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java index 8dba7382402..00b83b4063e 100644 --- a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java +++ b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java @@ -32,25 +32,76 @@ import org.eclipse.jetty.util.Loader; import org.eclipse.jetty.util.Trie; import org.eclipse.jetty.util.TypeUtil; import org.eclipse.jetty.util.Utf8StringBuilder; +import org.eclipse.jetty.util.ajax.JSON.Convertible; +import org.eclipse.jetty.util.ajax.JSON.Convertor; +/** + *

A non-blocking JSON parser that can parse partial JSON strings.

+ *

Usage:

+ *
+ * AsyncJSON parser = new AsyncJSON.Factory().newAsyncJSON();
+ *
+ * // Feed the parser with partial JSON string content.
+ * parser.parse(chunk1);
+ * parser.parse(chunk2);
+ *
+ * // Tell the parser that the JSON string content
+ * // is terminated and get the JSON object back.
+ * Map<String, Object> object = parser.eof();
+ * 
+ *

After the call to {@link #eof()} the parser can be reused to parse + * another JSON string.

+ *

Custom objects can be created by specifying a {@code "class"} or + * {@code "x-class"} field:

+ *
+ * String json = """
+ * {
+ *   "x-class": "com.acme.Person",
+ *   "firstName": "John",
+ *   "lastName": "Doe",
+ *   "age": 42
+ * }
+ * """
+ *
+ * parser.parse(json);
+ * com.acme.Person person = parser.eof();
+ * 
+ *

Class {@code com.acme.Person} must either implement {@link Convertible}, + * or be mapped with a {@link Convertor} via {@link Factory#putConvertor(String, Convertor)}.

+ */ public class AsyncJSON { + /** + *

The factory that creates AsyncJSON instances.

+ *

The factory can be configured with custom {@link Convertor}s, + * and with cached strings that will not be allocated if they can + * be looked up from the cache.

+ */ public static class Factory { private Trie cache; - private Map convertors; + private Map convertors; private boolean detailedParseException; + /** + * @return whether a parse failure should report the whole JSON string or just the last chunk + */ public boolean isDetailedParseException() { return detailedParseException; } + /** + * @param detailedParseException whether a parse failure should report the whole JSON string or just the last chunk + */ public void setDetailedParseException(boolean detailedParseException) { this.detailedParseException = detailedParseException; } + /** + * @param value the string to cache + */ public void cache(String value) { if (cache == null) @@ -58,6 +109,16 @@ public class AsyncJSON cache.put("\"" + value + "\"", value); } + /** + *

Attempts to return a cached string from the buffer bytes.

+ *

In case of a cache hit, the string is returned and the buffer + * position updated.

+ *

In case of cache miss, {@code null} is returned and the buffer + * position is left unaltered.

+ * + * @param buffer the buffer to lookup the string from + * @return a cached string or {@code null} + */ public String cached(ByteBuffer buffer) { if (cache != null) @@ -72,24 +133,50 @@ public class AsyncJSON return null; } + /** + * @return a new parser instance + */ public AsyncJSON newAsyncJSON() { return new AsyncJSON(this); } - public void putConvertor(String className, JSON.Convertor convertor) + /** + *

Associates the given {@link Convertor} to the given class name.

+ * + * @param className the domain class name such as {@code com.acme.Person} + * @param convertor the {@link Convertor} that converts {@code Map} to domain objects + */ + public void putConvertor(String className, Convertor convertor) { if (convertors == null) convertors = new ConcurrentHashMap<>(); convertors.put(className, convertor); } - public JSON.Convertor removeConvertor(String className) + /** + *

Removes the {@link Convertor} associated with the given class name.

+ * + * @param className the class name associated with the {@link Convertor} + * @return the {@link Convertor} associated with the class name, or {@code null} + */ + public Convertor removeConvertor(String className) { if (convertors != null) return convertors.remove(className); return null; } + + /** + *

Returns the {@link Convertor} associated with the given class name, if any.

+ * + * @param className the class name associated with the {@link Convertor} + * @return the {@link Convertor} associated with the class name, or {@code null} + */ + public Convertor getConvertor(String className) + { + return convertors == null ? null : convertors.get(className); + } } private static final Object UNSET = new Object(); @@ -113,11 +200,24 @@ public class AsyncJSON return stack.isEmpty(); } + /** + *

Feeds the parser with the given bytes chunk.

+ * + * @param bytes the bytes to parse + * @return whether the JSON parsing was complete + */ public boolean parse(byte[] bytes) { return parse(ByteBuffer.wrap(bytes)); } + /** + *

Feeds the parser with the given buffer chunk.

+ * + * @param buffer the buffer to parse + * @return whether the JSON parsing was complete + * @throws IllegalArgumentException if the JSON is malformed + */ public boolean parse(ByteBuffer buffer) { try @@ -251,6 +351,16 @@ public class AsyncJSON } } + /** + *

Signals to the parser that the parse data is complete, and returns + * the object parsed from the JSON chunks passed to the {@code parse()} + * methods.

+ * + * @param the type the result is cast to + * @return the result of the JSON parsing + * @throws IllegalArgumentException if the JSON is malformed + * @throws IllegalStateException if the no JSON was passed to the {@code parse()} methods + */ public R eof() { try @@ -290,11 +400,27 @@ public class AsyncJSON } } + /** + *

When a JSON { is encountered during parsing, + * this method is called to create a new {@code Map} instance.

+ *

Subclasses may override to return a custom {@code Map} instance.

+ * + * @param context the parsing context + * @return a {@code Map} instance + */ protected Map newObject(Context context) { return new HashMap<>(); } + /** + *

When a JSON [ is encountered during parsing, + * this method is called to create a new {@code List} instance.

+ *

Subclasses may override to return a custom {@code List} instance.

+ * + * @param context the parsing context + * @return a {@code List} instance + */ protected List newArray(Context context) { return new ArrayList<>(); @@ -918,27 +1044,27 @@ public class AsyncJSON if (className == null) return null; - JSON.Convertible convertible = toConvertible(className); + Convertible convertible = toConvertible(className); if (convertible != null) { convertible.fromJSON(object); return convertible; } - JSON.Convertor convertor = factory.convertors.get(className); + Convertor convertor = factory.getConvertor(className); if (convertor != null) return convertor.fromJSON(object); return null; } - private JSON.Convertible toConvertible(String className) + private Convertible toConvertible(String className) { try { Class klass = Loader.loadClass(className); - if (JSON.Convertible.class.isAssignableFrom(klass)) - return (JSON.Convertible)klass.getConstructor().newInstance(); + if (Convertible.class.isAssignableFrom(klass)) + return (Convertible)klass.getConstructor().newInstance(); return null; } catch (Throwable x) @@ -976,8 +1102,14 @@ public class AsyncJSON return new IllegalArgumentException(builder.toString()); } + /** + *

The state of JSON parsing.

+ */ public interface Context { + /** + * @return the depth in the JSON structure + */ public int depth(); } From 9ad7ff795e5ec5139981ce384303baa14171c687 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 20 May 2020 15:12:24 +0200 Subject: [PATCH 6/9] Fixes #4892 - Non-blocking JSON parser. Updates after review. Signed-off-by: Simone Bordet --- .../org/eclipse/jetty/util/ajax/AsyncJSON.java | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java index 00b83b4063e..9899258cfac 100644 --- a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java +++ b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java @@ -181,17 +181,15 @@ public class AsyncJSON private static final Object UNSET = new Object(); - private final Factory factory; private final FrameStack stack = new FrameStack(); - private final Utf8StringBuilder stringBuilder; - private final NumberBuilder numberBuilder; + private final NumberBuilder numberBuilder = new NumberBuilder(); + private final Utf8StringBuilder stringBuilder = new Utf8StringBuilder(32); + private final Factory factory; private List chunks; public AsyncJSON(Factory factory) { this.factory = factory; - this.stringBuilder = new Utf8StringBuilder(32); - this.numberBuilder = new NumberBuilder(); } // Used by tests only. @@ -1124,12 +1122,6 @@ public class AsyncJSON private State state; private Object value; - private Frame(State state, Object value) - { - this.state = state; - this.value = value; - } - private void value(Object value) { switch (state) @@ -1277,7 +1269,7 @@ public class AsyncJSON { for (int i = 0; i < grow; i++) { - stack.add(new Frame(null, null)); + stack.add(new Frame()); } } From e588a1bd9da8e4cc6131ad2fac672b8feeff750e Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Wed, 20 May 2020 16:14:05 +0200 Subject: [PATCH 7/9] Fixes #4892 - Non-blocking JSON parser. Added parse(byte[], int, int). Signed-off-by: Simone Bordet --- .../org/eclipse/jetty/util/ajax/AsyncJSON.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java index 9899258cfac..83087aa38db 100644 --- a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java +++ b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java @@ -203,10 +203,25 @@ public class AsyncJSON * * @param bytes the bytes to parse * @return whether the JSON parsing was complete + * @throws IllegalArgumentException if the JSON is malformed */ public boolean parse(byte[] bytes) { - return parse(ByteBuffer.wrap(bytes)); + return parse(bytes, 0, bytes.length); + } + + /** + *

Feeds the parser with the given bytes chunk.

+ * + * @param bytes the bytes to parse + * @param offset the offset to start parsing from + * @param length the number of bytes to parse + * @return whether the JSON parsing was complete + * @throws IllegalArgumentException if the JSON is malformed + */ + public boolean parse(byte[] bytes, int offset, int length) + { + return parse(ByteBuffer.wrap(bytes, offset, length)); } /** From d16ce1234998e5dc29057267b843cd6d57d37fd7 Mon Sep 17 00:00:00 2001 From: Simone Bordet Date: Thu, 21 May 2020 00:35:31 +0200 Subject: [PATCH 8/9] Fixes #4892 - Non-blocking JSON parser. Updates after review. Signed-off-by: Simone Bordet --- .../eclipse/jetty/util/ajax/AsyncJSON.java | 40 +++++++++++++------ .../jetty/util/ajax/AsyncJSONTest.java | 40 +++++++++---------- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java index 83087aa38db..be701f3ca6a 100644 --- a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java +++ b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java @@ -47,9 +47,9 @@ import org.eclipse.jetty.util.ajax.JSON.Convertor; * * // Tell the parser that the JSON string content * // is terminated and get the JSON object back. - * Map<String, Object> object = parser.eof(); + * Map<String, Object> object = parser.complete(); * - *

After the call to {@link #eof()} the parser can be reused to parse + *

After the call to {@link #complete()} the parser can be reused to parse * another JSON string.

*

Custom objects can be created by specifying a {@code "class"} or * {@code "x-class"} field:

@@ -64,7 +64,7 @@ import org.eclipse.jetty.util.ajax.JSON.Convertor; * """ * * parser.parse(json); - * com.acme.Person person = parser.eof(); + * com.acme.Person person = parser.complete(); * *

Class {@code com.acme.Person} must either implement {@link Convertible}, * or be mapped with a {@link Convertor} via {@link Factory#putConvertor(String, Convertor)}.

@@ -119,7 +119,7 @@ public class AsyncJSON * @param buffer the buffer to lookup the string from * @return a cached string or {@code null} */ - public String cached(ByteBuffer buffer) + private String cached(ByteBuffer buffer) { if (cache != null) { @@ -270,7 +270,7 @@ public class AsyncJSON { int position = buffer.position(); byte peek = buffer.get(position); - if (Character.isWhitespace((char)peek)) + if (isWhitespace(peek)) buffer.position(position + 1); else throw newInvalidJSON(buffer, "invalid character after JSON data"); @@ -374,7 +374,7 @@ public class AsyncJSON * @throws IllegalArgumentException if the JSON is malformed * @throws IllegalStateException if the no JSON was passed to the {@code parse()} methods */ - public R eof() + public R complete() { try { @@ -397,7 +397,7 @@ public class AsyncJSON { if (stack.peek().value == UNSET) throw new IllegalStateException("invalid state " + state); - return (R)complete(); + return (R)end(); } default: { @@ -439,7 +439,7 @@ public class AsyncJSON return new ArrayList<>(); } - private Object complete() + private Object end() { Object result = stack.peek().value; reset(); @@ -498,7 +498,7 @@ public class AsyncJSON return true; break; default: - if (Character.isWhitespace(peek)) + if (isWhitespace(peek)) { buffer.get(); break; @@ -854,7 +854,7 @@ public class AsyncJSON } default: { - if (Character.isWhitespace(peek)) + if (isWhitespace(peek)) { buffer.get(); break; @@ -904,7 +904,7 @@ public class AsyncJSON } default: { - if (Character.isWhitespace(currentByte)) + if (isWhitespace(currentByte)) { break; } @@ -947,7 +947,7 @@ public class AsyncJSON } default: { - if (Character.isWhitespace(peek)) + if (isWhitespace(peek)) { buffer.get(); break; @@ -1002,7 +1002,7 @@ public class AsyncJSON } default: { - if (Character.isWhitespace(peek)) + if (isWhitespace(peek)) { buffer.get(); break; @@ -1115,6 +1115,20 @@ public class AsyncJSON return new IllegalArgumentException(builder.toString()); } + private static boolean isWhitespace(byte ws) + { + switch (ws) + { + case ' ': + case '\n': + case '\r': + case '\t': + return true; + default: + return false; + } + } + /** *

The state of JSON parsing.

*/ diff --git a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java index d808ea400c4..fa93463b1f5 100644 --- a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java +++ b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java @@ -78,7 +78,7 @@ public class AsyncJSONTest // Parse the whole input. assertTrue(parser.parse(bytes)); - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); // Parse byte by byte. @@ -90,7 +90,7 @@ public class AsyncJSONTest else assertFalse(parser.parse(new byte[]{b})); } - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); } @@ -140,12 +140,12 @@ public class AsyncJSONTest // Parse the whole input. assertTrue(parser.parse(bytes)); - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); ByteBuffer buffer = ByteBuffer.wrap(bytes); assertTrue(parser.parse(buffer)); assertFalse(buffer.hasRemaining()); - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); // Parse byte by byte. @@ -153,7 +153,7 @@ public class AsyncJSONTest { parser.parse(new byte[]{b}); } - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); } @@ -195,7 +195,7 @@ public class AsyncJSONTest assertThrows(IllegalArgumentException.class, () -> { parser.parse(bytes); - parser.eof(); + parser.complete(); }); assertTrue(parser.isEmpty()); @@ -206,7 +206,7 @@ public class AsyncJSONTest { parser.parse(new byte[]{b}); } - parser.eof(); + parser.complete(); }); assertTrue(parser.isEmpty()); } @@ -220,7 +220,7 @@ public class AsyncJSONTest // Parse the whole input. assertTrue(parser.parse(bytes)); - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); // Parse byte by byte. @@ -236,7 +236,7 @@ public class AsyncJSONTest assertFalse(parser.parse(new byte[]{b})); } } - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); } @@ -278,7 +278,7 @@ public class AsyncJSONTest assertThrows(IllegalArgumentException.class, () -> { parser.parse(bytes); - parser.eof(); + parser.complete(); }); assertTrue(parser.isEmpty()); @@ -289,7 +289,7 @@ public class AsyncJSONTest { parser.parse(new byte[]{b}); } - parser.eof(); + parser.complete(); }); assertTrue(parser.isEmpty()); } @@ -303,11 +303,11 @@ public class AsyncJSONTest // Parse the whole input. parser.parse(bytes); - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); ByteBuffer buffer = ByteBuffer.wrap(bytes); parser.parse(buffer); - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertFalse(buffer.hasRemaining()); assertTrue(parser.isEmpty()); @@ -316,7 +316,7 @@ public class AsyncJSONTest { parser.parse(new byte[]{b}); } - assertEquals(expected, parser.eof()); + assertEquals(expected, parser.complete()); assertTrue(parser.isEmpty()); } @@ -349,7 +349,7 @@ public class AsyncJSONTest assertThrows(IllegalArgumentException.class, () -> { parser.parse(bytes); - parser.eof(); + parser.complete(); }); assertTrue(parser.isEmpty()); @@ -360,7 +360,7 @@ public class AsyncJSONTest { parser.parse(new byte[]{b}); } - parser.eof(); + parser.complete(); }); assertTrue(parser.isEmpty()); } @@ -379,7 +379,7 @@ public class AsyncJSONTest AsyncJSON parser = factory.newAsyncJSON(); assertTrue(parser.parse(UTF_8.encode(json))); - Map result = parser.eof(); + Map result = parser.complete(); Object value1 = result.get("f1"); assertTrue(value1 instanceof CustomConvertible); @@ -389,7 +389,7 @@ public class AsyncJSONTest assertSame(convertor, factory.removeConvertor(CustomConvertor.class.getName())); assertTrue(parser.parse(UTF_8.encode(json))); - result = parser.eof(); + result = parser.complete(); value1 = result.get("f1"); assertTrue(value1 instanceof CustomConvertible); @@ -467,7 +467,7 @@ public class AsyncJSONTest "}]"; assertTrue(parser.parse(UTF_8.encode(json))); - List messages = parser.eof(); + List messages = parser.complete(); for (CustomMap message : messages) { @@ -491,7 +491,7 @@ public class AsyncJSONTest String json = "{\"foo\": [\"foo\", \"foo\"]}"; parser.parse(UTF_8.encode(json)); - Map object = parser.eof(); + Map object = parser.complete(); Map.Entry entry = object.entrySet().iterator().next(); assertSame(foo, entry.getKey()); From b9466823c8b7dc9e4a01b78ee8eec900b24f201b Mon Sep 17 00:00:00 2001 From: Greg Wilkins Date: Thu, 21 May 2020 11:44:30 +0200 Subject: [PATCH 9/9] Issue #4892 Async JSON Handle encoded cached strings (or not). Signed-off-by: Greg Wilkins --- .../eclipse/jetty/util/ajax/AsyncJSON.java | 45 ++++++++++++++++--- .../jetty/util/ajax/AsyncJSONTest.java | 23 ++++++++++ .../eclipse/jetty/util/ArrayTernaryTrie.java | 2 +- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java index be701f3ca6a..444fc25b223 100644 --- a/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java +++ b/jetty-util-ajax/src/main/java/org/eclipse/jetty/util/ajax/AsyncJSON.java @@ -79,7 +79,7 @@ public class AsyncJSON */ public static class Factory { - private Trie cache; + private Trie cache; private Map convertors; private boolean detailedParseException; @@ -101,12 +101,20 @@ public class AsyncJSON /** * @param value the string to cache + * @return whether the value can be cached */ - public void cache(String value) + public boolean cache(String value) { if (cache == null) cache = new ArrayTernaryTrie.Growing<>(false, 64, 64); - cache.put("\"" + value + "\"", value); + + CachedString cached = new CachedString(value); + if (cached.isCacheable()) + { + cache.put(cached.encoded, cached); + return true; + } + return false; } /** @@ -119,15 +127,15 @@ public class AsyncJSON * @param buffer the buffer to lookup the string from * @return a cached string or {@code null} */ - private String cached(ByteBuffer buffer) + protected String cached(ByteBuffer buffer) { if (cache != null) { - String result = cache.getBest(buffer, 0, buffer.remaining()); + CachedString result = cache.getBest(buffer, 0, buffer.remaining()); if (result != null) { - buffer.position(buffer.position() + result.length() + 2); - return result; + buffer.position(buffer.position() + result.encoded.length()); + return result.value; } } return null; @@ -177,6 +185,29 @@ public class AsyncJSON { return convertors == null ? null : convertors.get(className); } + + private static class CachedString + { + private final String encoded; + private final String value; + + private CachedString(String value) + { + this.encoded = JSON.toString(value); + this.value = value; + } + + private boolean isCacheable() + { + for (int i = encoded.length(); i-- > 0;) + { + char c = encoded.charAt(i); + if (c > 127) + return false; + } + return true; + } + } } private static final Object UNSET = new Object(); diff --git a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java index fa93463b1f5..af66f6aae98 100644 --- a/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java +++ b/jetty-util-ajax/src/test/java/org/eclipse/jetty/util/ajax/AsyncJSONTest.java @@ -502,4 +502,27 @@ public class AsyncJSONTest assertSame(foo, item); } } + + @Test + public void testEncodedCaching() + { + AsyncJSON.Factory factory = new AsyncJSON.Factory(); + assertFalse(factory.cache("yèck")); + String foo = "foo\\yuck"; + assertTrue(factory.cache(foo)); + AsyncJSON parser = factory.newAsyncJSON(); + + String json = "{\"foo\\\\yuck\": [\"foo\\\\yuck\", \"foo\\\\yuck\"]}"; + parser.parse(UTF_8.encode(json)); + Map object = parser.complete(); + + Map.Entry entry = object.entrySet().iterator().next(); + assertSame(foo, entry.getKey()); + @SuppressWarnings("unchecked") + List array = (List)entry.getValue(); + for (String item : array) + { + assertSame(foo, item); + } + } } diff --git a/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java b/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java index fa63c727ae9..a1eecc1846c 100644 --- a/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java +++ b/jetty-util/src/main/java/org/eclipse/jetty/util/ArrayTernaryTrie.java @@ -632,7 +632,7 @@ public class ArrayTernaryTrie extends AbstractTrie boolean added = _trie.put(s, v); while (!added && _growby > 0) { - ArrayTernaryTrie bigger = new ArrayTernaryTrie<>(_trie._key.length + _growby); + ArrayTernaryTrie bigger = new ArrayTernaryTrie<>(_trie.isCaseInsensitive(), _trie._key.length + _growby); for (Map.Entry entry : _trie.entrySet()) { bigger.put(entry.getKey(), entry.getValue());