diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/util/JsonWriter.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/util/JsonWriter.java new file mode 100644 index 000000000000..55f5c81599f3 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/util/JsonWriter.java @@ -0,0 +1,339 @@ +package io.opentelemetry.instrumentation.api.incubator.util; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Locale.ROOT; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.io.ByteArrayOutputStream; +import java.io.Flushable; +import java.io.IOException; +import java.io.OutputStreamWriter; + +/** + * A lightweight JSON writer without dependencies. It performs minimal JSON structure checks unless + * using the lenient mode. + * + *

Copied from dd-trace-java.

+ */ +public final class JsonWriter implements Flushable, AutoCloseable { + private static final int INITIAL_CAPACITY = 256; + private final ByteArrayOutputStream outputStream; + private final OutputStreamWriter writer; + + private boolean requireComma; + + /** + * Creates a writer. + */ + public JsonWriter() { + this.outputStream = new ByteArrayOutputStream(INITIAL_CAPACITY); + this.writer = new OutputStreamWriter(this.outputStream, UTF_8); + this.requireComma = false; + } + + /** + * Starts a JSON object. + * + * @return This writer instance. + */ + @CanIgnoreReturnValue + public JsonWriter beginObject() { + injectCommaIfNeeded(); + write('{'); + return this; + } + + /** + * * Ends the current JSON object. + * + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter endObject() { + write('}'); + endsValue(); + return this; + } + + /** + * Writes an object property name. + * + * @param name The property name. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter name(String name) { + if (name == null) { + throw new IllegalArgumentException("name cannot be null"); + } + injectCommaIfNeeded(); + writeStringLiteral(name); + write(':'); + return this; + } + + /** + * Writes a {@code null} value. + * + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter nullValue() { + injectCommaIfNeeded(); + writeStringRaw("null"); + endsValue(); + return this; + } + + /** + * Writes JSON value without escaping it. + * + * @param value The JSON value to write. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter jsonValue(String value) { + // No structure check here assuming raw JSON is safe to write + injectCommaIfNeeded(); + writeStringRaw(value); + endsValue(); + return this; + } + + /** + * Writes a boolean value. + * + * @param value The value to write. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter value(boolean value) { + injectCommaIfNeeded(); + writeStringRaw(value ? "true" : "false"); + endsValue(); + return this; + } + + /** + * Writes a string value. + * + * @param value The value to write. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter value(String value) { + if (value == null) { + return nullValue(); + } + injectCommaIfNeeded(); + writeStringLiteral(value); + endsValue(); + return this; + } + + /** + * Writes an integer as a number value. + * + * @param value The integer to write. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter value(int value) { + injectCommaIfNeeded(); + writeStringRaw(Integer.toString(value)); + endsValue(); + return this; + } + + /** + * Writes a long as a number value. + * + * @param value The long to write. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter value(long value) { + injectCommaIfNeeded(); + writeStringRaw(Long.toString(value)); + endsValue(); + return this; + } + + /** + * Writes a float as a number value. + * + * @param value The float to write. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter value(float value) { + if (Float.isNaN(value)) { + return nullValue(); + } + injectCommaIfNeeded(); + writeStringRaw(Float.toString(value)); + endsValue(); + return this; + } + + /** + * Writes a double as a number value. + * + * @param value The value to write. + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter value(double value) { + if (Double.isNaN(value)) { + return nullValue(); + } + injectCommaIfNeeded(); + writeStringRaw(Double.toString(value)); + endsValue(); + return this; + } + + /** + * Starts a JSON array. + * + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter beginArray() { + injectCommaIfNeeded(); + write('['); + return this; + } + + /** + * Ends the current JSON array. + * + * @return This writer. + */ + @CanIgnoreReturnValue + public JsonWriter endArray() { + endsValue(); + write(']'); + return this; + } + + /** + * Gets the JSON String as a UTF-8 byte array. + * + * @return The JSON String as a UTF-8 byte array. + */ + public byte[] toByteArray() { + flush(); + return this.outputStream.toByteArray(); + } + + @Override + public String toString() { + return new String(toByteArray(), UTF_8); + } + + @Override + public void flush() { + try { + this.writer.flush(); + } catch (IOException ignored) { + // ignore + } + } + + @Override + public void close() { + try { + this.outputStream.close(); + this.writer.close(); + } catch (IOException ignored) { + // ignore + } + } + + private void injectCommaIfNeeded() { + if (this.requireComma) { + write(','); + } + this.requireComma = false; + } + + private void endsValue() { + this.requireComma = true; + } + + private void write(char ch) { + try { + this.writer.write(ch); + } catch (IOException ignored) { + // ignore + } + } + + private void writeStringLiteral(String str) { + try { + this.writer.write('"'); + + for (int i = 0; i < str.length(); ++i) { + char c = str.charAt(i); + // Escape any char outside ASCII to their Unicode equivalent + if (c > 127) { + this.writer.write('\\'); + this.writer.write('u'); + String hexCharacter = Integer.toHexString(c).toUpperCase(ROOT); + if (c < 4096) { + this.writer.write('0'); + if (c < 256) { + this.writer.write('0'); + } + } + this.writer.append(hexCharacter); + } else { + switch (c) { + case '"': // Quotation mark + case '\\': // Reverse solidus + case '/': // Solidus + this.writer.write('\\'); + this.writer.write(c); + break; + case '\b': // Backspace + this.writer.write('\\'); + this.writer.write('b'); + break; + case '\f': // Form feed + this.writer.write('\\'); + this.writer.write('f'); + break; + case '\n': // Line feed + this.writer.write('\\'); + this.writer.write('n'); + break; + case '\r': // Carriage return + this.writer.write('\\'); + this.writer.write('r'); + break; + case '\t': // Horizontal tab + this.writer.write('\\'); + this.writer.write('t'); + break; + default: + this.writer.write(c); + break; + } + } + } + + this.writer.write('"'); + } catch (IOException ignored) { + // ignore + } + } + + private void writeStringRaw(String str) { + try { + this.writer.write(str); + } catch (IOException ignored) { + // ignore + } + } +} \ No newline at end of file diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/util/demo/InputMessage.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/util/demo/InputMessage.java new file mode 100644 index 000000000000..2320ba8247b9 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/util/demo/InputMessage.java @@ -0,0 +1,60 @@ +package io.opentelemetry.instrumentation.api.incubator.util.demo; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.opentelemetry.instrumentation.api.incubator.util.JsonWriter; +import java.util.List; + +public class InputMessage { + + private final String role; + + private final List parts; + + private final String name; + + @CanIgnoreReturnValue + public JsonWriter write(JsonWriter writer) { + writer = writer.beginObject() + .name("role") + .value(role); + + writer = writer.name("parts") + .beginArray(); + for (MessagePart part : parts) { + writer = part.write(writer); + } + writer = writer.endArray(); + + return writer.name("name") + .value(name) + .endObject(); + } + + public InputMessage(String role, List parts, String name) { + this.role = role; + this.parts = parts; + this.name = name; + } + + public interface MessagePart { + JsonWriter write(JsonWriter writer); + } + + public static final class TextPart implements MessagePart { + private final String text; + + @Override + @CanIgnoreReturnValue + public JsonWriter write(JsonWriter writer) { + return writer.beginObject() + .name("type") + .value("text") + .name("text") + .value(text).endObject(); + } + + public TextPart(String text) { + this.text = text; + } + } +} diff --git a/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/util/JsonWriterTest.java b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/util/JsonWriterTest.java new file mode 100644 index 000000000000..db675df2de66 --- /dev/null +++ b/instrumentation-api-incubator/src/test/java/io/opentelemetry/instrumentation/api/incubator/util/JsonWriterTest.java @@ -0,0 +1,22 @@ +package io.opentelemetry.instrumentation.api.incubator.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.opentelemetry.instrumentation.api.incubator.util.demo.InputMessage; +import io.opentelemetry.instrumentation.api.incubator.util.demo.InputMessage.TextPart; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class JsonWriterTest { + + @Test + void testJsonWriter() { + InputMessage inputMessage = new InputMessage("user", + Collections.singletonList(new TextPart("Hello?")), "Bob"); + + try (JsonWriter writer = new JsonWriter()) { + String message = inputMessage.write(writer).toString(); + assertEquals("{\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"text\":\"Hello?\"}],\"name\":\"Bob\"}", message); + } + } +}