/*
* Copyright (C) 2010 Red Hat, Inc. and/or its affiliates.
*
* 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.jboss.errai.marshalling.server;
import org.jboss.errai.marshalling.client.api.json.EJValue;
import org.jboss.errai.marshalling.server.json.impl.ErraiJSONValue;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.nio.CharBuffer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* High-performance stream JSON parser. Provides the decoding algorithm to interpret the Errai Wire Protcol,
* including serializable types. This parser always assumes the outer payload is a Map. So it probably shouldn't
* be used as a general parser.
*
* @author Mike Brock
* @since 1.1
*/
public class JSONStreamDecoder {
private final CharBuffer buffer;
private final BufferedReader reader;
private char carry;
private int read;
private boolean initial = true;
/**
* Decodes the JSON payload by reading from the given stream of UTF-8 encoded
* characters. Reads to the end of the input stream unless there are errors,
* in which case the current position in the stream will not be at EOF, but
* may possibly be beyond the character that caused the error.
*
* @param inStream
* The input stream to read from. It must contain character data
* encoded as UTF-8, and it must be positioned to read from the start
* of the JSON message to be parsed.
*/
public JSONStreamDecoder(final InputStream inStream) {
this.buffer = CharBuffer.allocate(25);
try {
this.reader = new BufferedReader(
new InputStreamReader(inStream, "UTF-8")
);
}
catch (UnsupportedEncodingException e) {
throw new Error("UTF-8 is not supported by this JVM?", e);
}
}
public static EJValue decode(final InputStream instream) throws IOException {
return new JSONStreamDecoder(instream).parse();
}
public char read() throws IOException {
if (carry != 0) {
final char oldCarry = carry;
carry = 0;
return oldCarry;
}
if (read <= 0) {
if (!initial) buffer.rewind();
initial = false;
if ((read = reader.read(buffer)) <= 0) {
return 0;
}
buffer.rewind();
}
read--;
return buffer.get();
}
public EJValue parse() {
try {
return new ErraiJSONValue(_parse(new OuterContext()));
}
catch (Exception e) {
throw new RuntimeException(e);
}
}
private Object _parse(Context ctx) throws IOException {
char c;
StringBuilder appender;
while ((c = read()) != 0) {
switch (c) {
case '[':
ctx.addValue(_parse(new ArrayContext(new ArrayList<Object>())));
break;
case '{':
ctx.addValue(_parse(new ObjectContext(new LinkedHashMap<Object, Object>())));
break;
case ']':
case '}':
return ctx.record();
case ',':
ctx.record();
break;
case '"':
case '\'':
char term = c;
appender = new StringBuilder(100);
StrCapture:
while ((c = read()) != 0) {
switch (c) {
case '\\':
appender.append(handleEscapeSequence());
break;
case '"':
case '\'':
if (c == term) {
ctx.addValue(appender.toString());
term = 0;
break StrCapture;
}
default:
appender.append(c);
}
}
if (term != 0) {
throw new RuntimeException("unterminated string literal");
}
break;
case ':':
continue;
default:
if (isNumberStart(c)) {
carry = c;
ctx.addValue(parseDouble());
break;
}
else if (Character.isJavaIdentifierPart(c)) {
appender = new StringBuilder(100).append(c);
while (((c = read()) != 0) && Character.isJavaIdentifierPart(c)) {
appender.append(c);
}
String s = appender.toString();
if (s.length() > 5) ctx.addValue(s);
else if ("null".equals(s)) {
ctx.addValue(null);
}
else if ("true".equals(s)) {
ctx.addValue(Boolean.TRUE);
}
else if ("false".equals(s)) {
ctx.addValue(Boolean.FALSE);
}
else {
ctx.addValue(s);
}
if (c != 0) carry = c;
}
}
}
return ctx.record();
}
private char handleEscapeSequence() throws IOException {
char c;
switch (c = read()) {
case '\\':
return '\\';
case '/':
return '/';
case 'b':
return '\b';
case 'f':
return '\f';
case 't':
return '\t';
case 'r':
return '\r';
case 'n':
return '\n';
case '\'':
return '\'';
case '"':
return '\"';
case 'u':
//handle unicode
char[] unicodeSeq = new char[4];
int i = 0;
for (; i < 4 && isValidHexPart(c = read()); i++) {
unicodeSeq[i] = c;
}
if (i != 4) {
throw new RuntimeException("illegal unicode escape sequence: expected 4 hex characters after \\u");
}
return (char) Integer.decode("0x" + new String(unicodeSeq)).intValue();
default:
throw new RuntimeException("illegal escape sequence: " + c);
}
}
/** The states the double recognizer can go through while attempting to parse a JSON numeric value. */
private static enum State { READ_SIGN, READ_INT, READ_FRAC, READ_EXP_SIGN, READ_EXP };
/**
* Parses a JSON numeric literal <b>with the side effect of consuming
* characters from the input</b> up until a character is encountered that
* cannot be used to form a JSON number. JSON numbers have the following
* grammar:
*
* <dl>
* <dt><i>number</i>
* <dd><i>int</i>
* <dd><i>int frac</i>
* <dd><i>int exp</i>
* <dd><i>int frac exp</i>
*
* <dt><i>int</i>
* <dd><i>digit</i>
* <dd><i>digit1-9</i> <i>digits</i>
* <dd><b>'-'</b> <i>digit</i>
* <dd><b>'-'</b> <i>digit1-9</i> <i>digits</i>
*
* <dt><i>frac</i>
* <dd><b>'.'</b> <i>digits</i>
*
* <dt><i>exp</i>
* <dd><i>e digits</i>
*
* <dt><i>digits</i>
* <dd><i>digit</i>
* <dd><i>digit digits</i>
*
* <dt><i>digit1-9</i>
* <dd><b>'1'</b> | <b>'2'</b> | <b>'3'</b> | <b>'4'</b> | <b>'5'</b> |
* <b>'6'</b> | <b>'7'</b> | <b>'8'</b> | <b>'9'</b>
*
* <dt><i>digit</i>
* <dd><b>'0'</b> | <b>'1'</b> | <b>'2'</b> | <b>'3'</b> | <b>'4'</b> |
* <b>'5'</b> | <b>'6'</b> | <b>'7'</b> | <b>'8'</b> | <b>'9'</b>
*
* <dt><i>e</i>
* <dd><b>'e'</b> | <b>'e+'</b> | <b>'e-'</b> | <b>'E'</b> | <b>'E+'</b> |
* <b>'E-'</b>
* </dl>
*
* @return The number that was parsed from the input stream.
* <p><i>Note on side effects:</i>after this method returns, the next
* @throws IOException
*/
private double parseDouble() throws IOException {
final StringBuilder sb = new StringBuilder(25);
State state = State.READ_SIGN;
char c;
recognize:
while ((c = read()) != 0) {
switch (state) {
case READ_SIGN:
if (c == '-' || ('0' <= c && c <= '9')) {
sb.append(c);
state = State.READ_INT;
}
else {
throw new NumberFormatException("Found '" + c + "' but expected '-' or a digit 1-9");
}
break;
case READ_INT:
if ('0' <= c && c <= '9') {
sb.append(c);
}
else if (c == '.') {
sb.append(c);
state = State.READ_FRAC;
}
else if (c == 'E' || c == 'e') {
sb.append(c);
state = State.READ_EXP_SIGN;
}
else {
// found the end of the numeric literal
carry = c;
break recognize;
}
break;
case READ_FRAC:
if ('0' <= c && c <= '9') {
sb.append(c);
}
else if (c == 'E' || c == 'e') {
sb.append(c);
state = State.READ_EXP_SIGN;
}
else {
// found the end of the numeric literal
carry = c;
break recognize;
}
break;
case READ_EXP_SIGN:
if (c == '-' || c == '+' || ('0' <= c && c <= '9')) {
sb.append(c);
state = State.READ_EXP;
}
else {
throw new NumberFormatException("The numeric literal \"" + sb + "\" is malformed (can't end with e or E)");
}
break;
case READ_EXP:
if ('0' <= c && c <= '9') {
sb.append(c);
}
else {
// found the end of the numeric literal
carry = c;
break recognize;
}
break;
}
}
return Double.parseDouble(sb.toString());
}
/**
* Returns true if c could be the start of a JSON number. Note that a return
* value of true does not indicate that the value will be a valid number. JSON
* numbers are not permitted to begin with a '0' or a '.', so in those cases
* {@link #parseDouble()} will throw an error even though this method returned
* true. This is an acceptable outcome, though, because there is nothing else
* the errant character could represent in the JSON stream.
*
* @param c
* the character to test
* @return true if c is a numeric digit, '-', or '.'.
*/
private static boolean isNumberStart(char c) {
switch (c) {
case '.':
case '-':
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
return true;
default:
return false;
}
}
private static boolean isValidHexPart(char c) {
switch (c) {
case '.':
case '-':
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case 'A':
case 'B':
case 'C':
case 'D':
case 'E':
case 'F':
return true;
default:
return false;
}
}
private static abstract class Context<T> {
abstract T record();
abstract void addValue(Object val);
}
private static class OuterContext extends Context<Object> {
private Context _wrapped;
private Object col;
@Override
Object record() {
return col;
}
@SuppressWarnings("unchecked")
@Override
void addValue(Object val) {
if (_wrapped == null) {
if (val instanceof List) {
_wrapped = new ArrayContext((List<Object>) (col = val));
}
else if (val instanceof Map) {
_wrapped = new ObjectContext((Map<Object, Object>) (col = val));
}
else if (val instanceof String) {
_wrapped = new StringContext((String) (col = val));
}
else {
throw new RuntimeException("expected list or map but found: " + (val == null ? null : val.getClass().getName()));
}
}
else {
_wrapped.addValue(val);
}
}
}
private static class ArrayContext extends Context<List> {
List<Object> collection;
private ArrayContext(List<Object> collection) {
this.collection = collection;
}
@Override
void addValue(Object val) {
collection.add(val);
}
@Override
public List record() {
return collection;
}
}
private static class StringContext extends Context<String> {
String value;
private StringContext(String value) {
this.value = value;
}
@Override
void addValue(Object val) {}
@Override
public String record() {
return value;
}
}
private static class ObjectContext extends Context<Map> {
protected Object lhs;
protected Object rhs;
Map<Object, Object> collection;
private ObjectContext(Map<Object, Object> collection) {
this.collection = collection;
}
@Override
void addValue(Object val) {
if (lhs == null) {
lhs = val;
}
else {
rhs = val;
}
}
@Override
Map record() {
if (lhs != null) {
collection.put(lhs, rhs);
}
lhs = rhs = null;
return collection;
}
}
}