diff --git a/spring-core/src/main/java/org/springframework/aot/nativex/BasicJsonWriter.java b/spring-core/src/main/java/org/springframework/aot/nativex/BasicJsonWriter.java new file mode 100644 index 00000000000..4fa022252ae --- /dev/null +++ b/spring-core/src/main/java/org/springframework/aot/nativex/BasicJsonWriter.java @@ -0,0 +1,297 @@ +/* + * Copyright 2002-2022 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 + * + * https://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.springframework.aot.nativex; + +import java.io.IOException; +import java.io.Writer; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Very basic json writer for the purposes of translating runtime hints to native + * configuration. + * + * @author Stephane Nicoll + */ +class BasicJsonWriter { + + private final IndentingWriter writer; + + /** + * Create a new instance with the specified indent value. + * @param writer the writer to use + * @param singleIndent the value of one indent + */ + public BasicJsonWriter(Writer writer, String singleIndent) { + this.writer = new IndentingWriter(writer, singleIndent); + } + + /** + * Create a new instance using two whitespaces for the indent. + * @param writer the writer to use + */ + public BasicJsonWriter(Writer writer) { + this(writer, " "); + } + + + /** + * Write an object with the specified attributes. Each attribute is + * written according to its value type: + * + * @param attributes the attributes of the object + */ + public void writeObject(Map attributes) { + writeObject(attributes, true); + } + + /** + * Write an array with the specified items. Each item in the + * list is written either as a nested object or as an attribute + * depending on its type. + * @param items the items to write + * @see #writeObject(Map) + */ + public void writeArray(List items) { + writeArray(items, true); + } + + private void writeObject(Map attributes, boolean newLine) { + if (attributes.isEmpty()) { + this.writer.print("{ }"); + } + else { + this.writer.println("{").indented(writeAll(attributes.entrySet().iterator(), + entry -> writeAttribute(entry.getKey(), entry.getValue()))).print("}"); + } + if (newLine) { + this.writer.println(); + } + } + + private void writeArray(List items, boolean newLine) { + if (items.isEmpty()) { + this.writer.print("[ ]"); + } + else { + this.writer.println("[") + .indented(writeAll(items.iterator(), this::writeValue)).print("]"); + } + if (newLine) { + this.writer.println(); + } + } + + private Runnable writeAll(Iterator it, Consumer writer) { + return () -> { + while (it.hasNext()) { + writer.accept(it.next()); + if (it.hasNext()) { + this.writer.println(","); + } + else { + this.writer.println(); + } + } + }; + } + + private void writeAttribute(String name, Object value) { + this.writer.print(quote(name) + ": "); + writeValue(value); + } + + @SuppressWarnings("unchecked") + private void writeValue(Object value) { + if (value instanceof Map map) { + writeObject((Map) map, false); + } + else if (value instanceof List list) { + writeArray(list, false); + } + else if (value instanceof CharSequence string) { + this.writer.print(quote(escape(string))); + } + else if (value instanceof Boolean flag) { + this.writer.print(Boolean.toString(flag)); + } + else { + throw new IllegalStateException("unsupported type: " + value.getClass()); + } + } + + private String quote(String name) { + return "\"" + name + "\""; + } + + private static String escape(CharSequence input) { + StringBuilder builder = new StringBuilder(); + input.chars().forEach(c -> { + switch (c) { + case '"': + builder.append("\\\""); + break; + case '\\': + builder.append("\\\\"); + break; + case '/': + builder.append("\\/"); + break; + case '\b': + builder.append("\\b"); + break; + case '\f': + builder.append("\\f"); + break; + case '\n': + builder.append("\\n"); + break; + case '\r': + builder.append("\\r"); + break; + case '\t': + builder.append("\\t"); + break; + default: + if (c <= 0x1F) { + builder.append(String.format("\\u%04x", c)); + } + else { + builder.append((char) c); + } + break; + } + }); + return builder.toString(); + } + + + static class IndentingWriter extends Writer { + + private final Writer out; + + private final String singleIndent; + + private int level = 0; + + private String currentIndent = ""; + + private boolean prependIndent = false; + + IndentingWriter(Writer out, String singleIndent) { + this.out = out; + this.singleIndent = singleIndent; + } + + /** + * Write the specified text. + * @param string the content to write + */ + public IndentingWriter print(String string) { + write(string.toCharArray(), 0, string.length()); + return this; + } + + /** + * Write the specified text and append a new line. + * @param string the content to write + */ + public IndentingWriter println(String string) { + write(string.toCharArray(), 0, string.length()); + return println(); + } + + /** + * Write a new line. + */ + public IndentingWriter println() { + String separator = System.lineSeparator(); + try { + this.out.write(separator.toCharArray(), 0, separator.length()); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + this.prependIndent = true; + return this; + } + + /** + * Increase the indentation level and execute the {@link Runnable}. Decrease the + * indentation level on completion. + * @param runnable the code to execute withing an extra indentation level + */ + public IndentingWriter indented(Runnable runnable) { + indent(); + runnable.run(); + return outdent(); + } + + /** + * Increase the indentation level. + */ + private IndentingWriter indent() { + this.level++; + return refreshIndent(); + } + + /** + * Decrease the indentation level. + */ + private IndentingWriter outdent() { + this.level--; + return refreshIndent(); + } + + private IndentingWriter refreshIndent() { + this.currentIndent = this.singleIndent.repeat(Math.max(0, this.level)); + return this; + } + + @Override + public void write(char[] chars, int offset, int length) { + try { + if (this.prependIndent) { + this.out.write(this.currentIndent.toCharArray(), 0, this.currentIndent.length()); + this.prependIndent = false; + } + this.out.write(chars, offset, length); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + public void flush() throws IOException { + this.out.flush(); + } + + @Override + public void close() throws IOException { + this.out.close(); + } + + } + +} diff --git a/spring-core/src/main/java/org/springframework/aot/nativex/JsonUtils.java b/spring-core/src/main/java/org/springframework/aot/nativex/JsonUtils.java deleted file mode 100644 index 15ed1b58e6d..00000000000 --- a/spring-core/src/main/java/org/springframework/aot/nativex/JsonUtils.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2002-2022 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 - * - * https://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.springframework.aot.nativex; - -/** - * Utility class for JSON. - * - * @author Sebastien Deleuze - */ -abstract class JsonUtils { - - /** - * Escape a JSON String. - */ - static String escape(String input) { - StringBuilder builder = new StringBuilder(); - input.chars().forEach(c -> { - switch (c) { - case '"': - builder.append("\\\""); - break; - case '\\': - builder.append("\\\\"); - break; - case '/': - builder.append("\\/"); - break; - case '\b': - builder.append("\\b"); - break; - case '\f': - builder.append("\\f"); - break; - case '\n': - builder.append("\\n"); - break; - case '\r': - builder.append("\\r"); - break; - case '\t': - builder.append("\\t"); - break; - default: - if (c <= 0x1F) { - builder.append(String.format("\\u%04x", c)); - } - else { - builder.append((char) c); - } - break; - } - }); - return builder.toString(); - } -} diff --git a/spring-core/src/test/java/org/springframework/aot/nativex/BasicJsonWriterTests.java b/spring-core/src/test/java/org/springframework/aot/nativex/BasicJsonWriterTests.java new file mode 100644 index 00000000000..4545c9caa9c --- /dev/null +++ b/spring-core/src/test/java/org/springframework/aot/nativex/BasicJsonWriterTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2002-2022 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 + * + * https://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.springframework.aot.nativex; + +import java.io.StringWriter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link BasicJsonWriter}. + * + * @author Stephane Nicoll + */ +class BasicJsonWriterTests { + + private final StringWriter out = new StringWriter(); + + private final BasicJsonWriter json = new BasicJsonWriter(out, "\t"); + + @Test + void writeObject() { + Map attributes = orderedMap("test", "value"); + attributes.put("another", true); + this.json.writeObject(attributes); + assertThat(out.toString()).isEqualTo(""" + { + "test": "value", + "another": true + } + """); + } + + @Test + void writeObjectWitNestedObject() { + Map attributes = orderedMap("test", "value"); + attributes.put("nested", orderedMap("enabled", false)); + this.json.writeObject(attributes); + assertThat(out.toString()).isEqualTo(""" + { + "test": "value", + "nested": { + "enabled": false + } + } + """); + } + + @Test + void writeObjectWitNestedArrayOfString() { + Map attributes = orderedMap("test", "value"); + attributes.put("nested", List.of("test", "value", "another")); + this.json.writeObject(attributes); + assertThat(out.toString()).isEqualTo(""" + { + "test": "value", + "nested": [ + "test", + "value", + "another" + ] + } + """); + } + + @Test + void writeObjectWitNestedArrayOfObject() { + Map attributes = orderedMap("test", "value"); + LinkedHashMap secondNested = orderedMap("name", "second"); + secondNested.put("enabled", false); + attributes.put("nested", List.of(orderedMap("name", "first"), secondNested, orderedMap("name", "third"))); + this.json.writeObject(attributes); + assertThat(out.toString()).isEqualTo(""" + { + "test": "value", + "nested": [ + { + "name": "first" + }, + { + "name": "second", + "enabled": false + }, + { + "name": "third" + } + ] + } + """); + } + + @Test + void writeObjectWithNestedEmptyArray() { + Map attributes = orderedMap("test", "value"); + attributes.put("nested", Collections.emptyList()); + this.json.writeObject(attributes); + assertThat(out.toString()).isEqualTo(""" + { + "test": "value", + "nested": [ ] + } + """); + } + + @Test + void writeObjectWithNestedEmptyObject() { + Map attributes = orderedMap("test", "value"); + attributes.put("nested", Collections.emptyMap()); + this.json.writeObject(attributes); + assertThat(out.toString()).isEqualTo(""" + { + "test": "value", + "nested": { } + } + """); + } + + @Test + void writeWithEscapeDoubleQuote() { + assertEscapedValue("foo\"bar", "foo\\\"bar"); + } + + @Test + void writeWithEscapeBackslash() { + assertEscapedValue("foo\"bar", "foo\\\"bar"); + } + + @Test + void writeWithEscapeBackspace() { + assertEscapedValue("foo\bbar", "foo\\bbar"); + } + + @Test + void writeWithEscapeFormFeed() { + assertEscapedValue("foo\fbar", "foo\\fbar"); + } + + @Test + void writeWithEscapeNewline() { + assertEscapedValue("foo\nbar", "foo\\nbar"); + } + + @Test + void writeWithEscapeCarriageReturn() { + assertEscapedValue("foo\rbar", "foo\\rbar"); + } + + @Test + void writeWithEscapeTab() { + assertEscapedValue("foo\tbar", "foo\\tbar"); + } + + @Test + void writeWithEscapeUnicode() { + assertEscapedValue("foo\u001Fbar", "foo\\u001fbar"); + } + + void assertEscapedValue(String value, String expectedEscapedValue) { + Map attributes = new LinkedHashMap<>(); + attributes.put("test", value); + this.json.writeObject(attributes); + assertThat(out.toString()).contains("\"test\": \"" + expectedEscapedValue + "\""); + } + + private static LinkedHashMap orderedMap(String key, Object value) { + LinkedHashMap map = new LinkedHashMap<>(); + map.put(key, value); + return map; + } + +} diff --git a/spring-core/src/test/java/org/springframework/aot/nativex/JsonUtilsTests.java b/spring-core/src/test/java/org/springframework/aot/nativex/JsonUtilsTests.java deleted file mode 100644 index 1773b8c35c0..00000000000 --- a/spring-core/src/test/java/org/springframework/aot/nativex/JsonUtilsTests.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2002-2022 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 - * - * https://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.springframework.aot.nativex; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link JsonUtils}. - * - * @author Sebastien Deleuze - */ -public class JsonUtilsTests { - - @Test - void unescaped() { - assertThat(JsonUtils.escape("azerty")).isEqualTo("azerty"); - } - - @Test - void escapeDoubleQuote() { - assertThat(JsonUtils.escape("foo\"bar")).isEqualTo("foo\\\"bar"); - } - - @Test - void escapeBackslash() { - assertThat(JsonUtils.escape("foo\"bar")).isEqualTo("foo\\\"bar"); - } - - @Test - void escapeBackspace() { - assertThat(JsonUtils.escape("foo\bbar")).isEqualTo("foo\\bbar"); - } - - @Test - void escapeFormfeed() { - assertThat(JsonUtils.escape("foo\fbar")).isEqualTo("foo\\fbar"); - } - - @Test - void escapeNewline() { - assertThat(JsonUtils.escape("foo\nbar")).isEqualTo("foo\\nbar"); - } - - @Test - void escapeCarriageReturn() { - assertThat(JsonUtils.escape("foo\rbar")).isEqualTo("foo\\rbar"); - } - - @Test - void escapeTab() { - assertThat(JsonUtils.escape("foo\tbar")).isEqualTo("foo\\tbar"); - } - - @Test - void escapeUnicode() { - assertThat(JsonUtils.escape("foo\u001Fbar")).isEqualTo("foo\\u001fbar"); - } -}