Json.java

package com.shengzhiai.yugu.internal;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.shengzhiai.yugu.errors.ErrorTable;
import com.shengzhiai.yugu.errors.YuguErrors;
import com.shengzhiai.yugu.errors.YuguException;

import java.io.IOException;
import java.io.StringWriter;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;

/** JSON helpers on one shared, thread safe mapper. */
public final class Json {
    /** Shared mapper; unknown properties are ignored. */
    public static final ObjectMapper MAPPER = new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    private static final JsonFactory FACTORY = MAPPER.getFactory();

    private Json() {
    }

    /**
     * @param value object
     * @return JSON text
     * @throws YuguException 90010 when the object cannot be serialised
     */
    public static String write(Object value) throws YuguException {
        try {
            return MAPPER.writeValueAsString(value);
        } catch (IOException e) {
            throw YuguErrors.local(ErrorTable.INVALID_ARGUMENT, "cannot serialise JSON: " + e.getMessage(), e);
        }
    }

    /**
     * @param text JSON text
     * @return tree
     * @throws YuguException {@code ProtocolViolationException} 90005 when the text is not JSON
     */
    public static JsonNode read(String text) throws YuguException {
        try {
            JsonNode n = MAPPER.readTree(text);
            if (n == null || n.isMissingNode()) {
                throw YuguErrors.local(ErrorTable.PROTOCOL_ERROR, "empty JSON", null);
            }
            return n;
        } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
            throw YuguErrors.local(ErrorTable.PROTOCOL_ERROR, "response is not JSON: " + e.getOriginalMessage(), e);
        }
    }

    /**
     * @param bytes UTF-8 JSON
     * @return tree
     * @throws YuguException 90005 when the bytes are not JSON
     */
    public static JsonNode read(byte[] bytes) throws YuguException {
        return read(new String(bytes, StandardCharsets.UTF_8));
    }

    /**
     * Writes a JSON object whose numbers use plain decimal notation: no exponent, no trailing
     * {@code .0}. The signature of JSON bodies is computed over the same literal text.
     *
     * @param fields ordered fields
     * @return JSON text
     */
    public static String writePlainObject(Map<String, Object> fields) {
        StringWriter w = new StringWriter();
        try (JsonGenerator g = FACTORY.createGenerator(w)) {
            writeValue(g, fields);
        } catch (IOException e) {
            throw YuguErrors.local(ErrorTable.INVALID_ARGUMENT, "cannot serialise JSON: " + e.getMessage(), e);
        }
        return w.toString();
    }

    private static void writeValue(JsonGenerator g, Object v) throws IOException {
        if (v == null) {
            g.writeNull();
        } else if (v instanceof String) {
            g.writeString((String) v);
        } else if (v instanceof Boolean) {
            g.writeBoolean((Boolean) v);
        } else if (v instanceof Number) {
            g.writeNumber(plainNumber((Number) v));
        } else if (v instanceof Map) {
            g.writeStartObject();
            for (Map.Entry<?, ?> e : ((Map<?, ?>) v).entrySet()) {
                g.writeFieldName(String.valueOf(e.getKey()));
                writeValue(g, e.getValue());
            }
            g.writeEndObject();
        } else if (v instanceof Collection) {
            g.writeStartArray();
            for (Object o : (Collection<?>) v) {
                writeValue(g, o);
            }
            g.writeEndArray();
        } else {
            g.writeString(String.valueOf(v));
        }
    }

    /**
     * Plain decimal text of a number: {@code 50}, {@code 0.2}, {@code 0.0000001}.
     *
     * @param n number
     * @return text
     */
    public static String plainNumber(Number n) {
        if (n instanceof Integer || n instanceof Long || n instanceof Short || n instanceof Byte || n instanceof BigInteger) {
            return n.toString();
        }
        BigDecimal d;
        if (n instanceof BigDecimal) {
            d = (BigDecimal) n;
        } else {
            double x = n.doubleValue();
            if (Double.isNaN(x) || Double.isInfinite(x)) {
                throw YuguErrors.local(ErrorTable.INVALID_ARGUMENT, "number is not finite: " + x, null);
            }
            d = BigDecimal.valueOf(x);
        }
        if (d.signum() == 0) {
            return "0";
        }
        return d.stripTrailingZeros().toPlainString();
    }

    /**
     * Signature parameters of a JSON body: the top level fields whose value is a non-null scalar, as
     * the literal text {@link #writePlainObject(Map)} writes.
     *
     * @param fields body fields
     * @return parameters to sign
     */
    public static Map<String, String> scalarFields(Map<String, Object> fields) {
        Map<String, String> m = new LinkedHashMap<>();
        for (Map.Entry<String, Object> e : fields.entrySet()) {
            Object v = e.getValue();
            if (v instanceof String) {
                m.put(e.getKey(), (String) v);
            } else if (v instanceof Number) {
                m.put(e.getKey(), plainNumber((Number) v));
            } else if (v instanceof Boolean) {
                m.put(e.getKey(), v.toString());
            }
        }
        return m;
    }
}