
org.eclipse.jetty.util.ajax.AsyncJSON Maven / Gradle / Ivy
//
// ========================================================================
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
//
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
// ========================================================================
//
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.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.Index;
import org.eclipse.jetty.util.Loader;
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.complete();
*
* 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:
*
* String json = """
* {
* "x-class": "com.acme.Person",
* "firstName": "John",
* "lastName": "Doe",
* "age": 42
* }
* """
*
* parser.parse(json);
* 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)}.
* JSON arrays are by default represented with a {@code List
*/
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 Index.Mutable cache;
private Map convertors;
private Function, Object> arrayConverter = list -> list;
private boolean detailedParseException;
/**
* @return the function to customize the Java representation of JSON arrays
* @see #setArrayConverter(Function)
*/
public Function, Object> getArrayConverter()
{
return arrayConverter;
}
/**
* Sets the function to convert JSON arrays from their default Java
* representation, a {@code List
*
* @param arrayConverter the function to customize the Java representation of JSON arrays
* @see #getArrayConverter()
*/
public void setArrayConverter(Function, Object> arrayConverter)
{
this.arrayConverter = Objects.requireNonNull(arrayConverter);
}
/**
* @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
* @return whether the value can be cached
*/
public boolean cache(String value)
{
if (cache == null)
cache = new Index.Builder()
.caseSensitive(true)
.mutable()
.build();
CachedString cached = new CachedString(value);
if (cached.isCacheable())
{
cache.put(cached.encoded, cached);
return true;
}
return false;
}
/**
* 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}
*/
protected String cached(ByteBuffer buffer)
{
if (cache != null)
{
CachedString result = cache.getBest(buffer, 0, buffer.remaining());
if (result != null)
{
buffer.position(buffer.position() + result.encoded.length());
return result.value;
}
}
return null;
}
/**
* @return a new parser instance
*/
public AsyncJSON newAsyncJSON()
{
return new AsyncJSON(this);
}
/**
* 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);
}
/**
* 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 class CachedString
{
private final String encoded;
private final String value;
private CachedString(String value)
{
this.encoded = new JSON().toJSON(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();
private final FrameStack stack = new FrameStack();
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;
}
// Used by tests only.
boolean isEmpty()
{
return stack.isEmpty();
}
/**
* Feeds the parser with the given bytes chunk.
*
* @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(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));
}
/**
* 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
{
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 (isWhitespace(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;
}
}
/**
* 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 complete()
{
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)end();
}
default:
{
throw newInvalidJSON(BufferUtil.EMPTY_BUFFER, "incomplete JSON");
}
}
}
}
catch (Throwable x)
{
reset();
throw x;
}
}
/**
* 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