diff --git a/src/net/sourceforge/plantuml/tim/TContext.java b/src/net/sourceforge/plantuml/tim/TContext.java index 50898cf64..78e798a19 100644 --- a/src/net/sourceforge/plantuml/tim/TContext.java +++ b/src/net/sourceforge/plantuml/tim/TContext.java @@ -106,6 +106,7 @@ import net.sourceforge.plantuml.tim.stdlib.InvokeProcedure; import net.sourceforge.plantuml.tim.stdlib.IsDark; import net.sourceforge.plantuml.tim.stdlib.IsLight; import net.sourceforge.plantuml.tim.stdlib.Lighten; +import net.sourceforge.plantuml.tim.stdlib.LoadJson; import net.sourceforge.plantuml.tim.stdlib.LogicalNot; import net.sourceforge.plantuml.tim.stdlib.Lower; import net.sourceforge.plantuml.tim.stdlib.Newline; @@ -177,6 +178,7 @@ public class TContext { functionsSet.addFunction(new Hex2dec()); functionsSet.addFunction(new Dec2hex()); functionsSet.addFunction(new HslColor()); + functionsSet.addFunction(new LoadJson()); functionsSet.addFunction(new Chr()); functionsSet.addFunction(new Size()); // %standard_exists_function diff --git a/src/net/sourceforge/plantuml/tim/stdlib/LoadJson.java b/src/net/sourceforge/plantuml/tim/stdlib/LoadJson.java new file mode 100644 index 000000000..299b4a6f5 --- /dev/null +++ b/src/net/sourceforge/plantuml/tim/stdlib/LoadJson.java @@ -0,0 +1,186 @@ +/* ======================================================================== + * PlantUML : a free UML diagram generator + * ======================================================================== + * + * (C) Copyright 2009-2021, Arnaud Roques + * + * Project Info: http://plantuml.com + * + * If you like this project or if you find it useful, you can support us at: + * + * http://plantuml.com/patreon (only 1$ per month!) + * http://plantuml.com/paypal + * + * This file is part of PlantUML. + * + * PlantUML is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PlantUML distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public + * License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, + * USA. + * + * + * Original Author: Arnaud Roques + * + */ +package net.sourceforge.plantuml.tim.stdlib; + +import net.sourceforge.plantuml.FileSystem; +import net.sourceforge.plantuml.FileUtils; +import net.sourceforge.plantuml.LineLocation; +import net.sourceforge.plantuml.json.Json; +import net.sourceforge.plantuml.json.JsonValue; +import net.sourceforge.plantuml.json.ParseException; +import net.sourceforge.plantuml.security.SFile; +import net.sourceforge.plantuml.security.SURL; +import net.sourceforge.plantuml.tim.*; +import net.sourceforge.plantuml.tim.expression.TValue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Loads JSON data from file or URL source. + *

+ * Supports three parameters for datasource, default JSON value and charset. The datasource will be checked against + * the security rules. + *

+ * Examples:
+ *

+ *     @ startuml
+ *     ' loads a local file
+ *     !$JSON_LOCAL_RELATIVE=%loadJSON("file.json")
+ *
+ *     ' loads a local file from an absolute file path
+ *     !$JSON_LOCAL_ABS=%loadJSON("c:/loaded/data/file.json")
+ *
+ *     ' tries to load a local file and returns an empty JSON
+ *     !$JSON_LOCAL_REL_EMPTY=%loadJSON("file-not-existing.json")
+ *
+ *     ' tries to load a local file and returns an default JSON
+ *     !$DEF_JSON={"status":"No data found"}
+ *     !$JSON_LOCAL_REL_DEF=%loadJSON("file-not-existing.json", $DEF_JSON)
+ *
+ *     ' loads a local file with a specific charset (default is UTF-8)
+ *     !$JSON_LOCAL_RELATIVE_CHARSET=%loadJSON("file.json", "{}", "iso-8859-1")
+ *
+ *     ' loads a remote JSON from an endpoint (and default, if not reachable)
+ *     !$STATUS_NO_CONNECTION={"status": "No connection"}
+ *     !$JSON_REMOTE_DEF=%loadJSON("https://localhost:7778/management/health", $STATUS_NO_CONNECTION)
+ *     status -> $JSON_REMOTE_DEF.status
+ *     @ enduml
+ * 
+ * @author Aljoscha Rittner + */ +public class LoadJson extends SimpleReturnFunction { + + private static final String VALUE_CHARSET_DEFAULT = "UTF-8"; + + private static final String VALUE_DEFAULT_DEFAULT = "{}"; + + public TFunctionSignature getSignature() { + return new TFunctionSignature("%loadJSON", 3 ); + } + + public boolean canCover(int nbArg, Set namedArgument) { + return nbArg == 1 || nbArg == 2 || nbArg == 3; + } + + public TValue executeReturnFunction(TContext context, TMemory memory, LineLocation location, List values, + Map named) throws EaterException, EaterExceptionLocated { + String path = values.get(0).toString(); + try { + String data = loadStringData ( path, getCharset (values) ); + if ( data == null ) { + data = getDefaultJson(values); + } + JsonValue jsonValue = Json.parse(data); + return TValue.fromJson(jsonValue); + } catch (ParseException pe) { + pe.printStackTrace (); + throw EaterException.unlocated ( "JSON parse issue in source " + + path + " on location " + pe.getLocation () ); + } catch (UnsupportedEncodingException e) { + e.printStackTrace (); + throw EaterException.unlocated ( "JSON encoding issue in source " + + path + ": " + e.getMessage () ); + } + } + + /** + * Returns the JSON default, if the data source contains no data. + * @param values value parameters + * @return the defined default JSON or {@code "{}"} + */ + private String getDefaultJson(List values) { + if (values.size() > 1 ) { + return values.get(1).toString (); + } + return VALUE_DEFAULT_DEFAULT; + } + + /** + * Returns the charset name (if set) + * @param values value parameters + * @return defined charset or {@code "UTF-8"} + */ + private String getCharset(List values) { + if ( values.size() == 3) { + return values.get (2).toString (); + } + return VALUE_CHARSET_DEFAULT; + } + + /** + * Loads String data from a data source {@code path} (file or URL) and expects the data encoded in {@code charset}. + * @param path path to data source (http(s)-URL or file). + * @param charset character set to encode the string data + * @return the decoded String from the data source + * @throws EaterException if something went wrong on reading data + */ + private String loadStringData(String path, String charset) throws EaterException, UnsupportedEncodingException { + + byte[] byteData = null; + if (path.startsWith("http://") || path.startsWith("https://")) { + final SURL url = SURL.create(path); + if (url == null) { + throw EaterException.located("load JSON: Invalid URL " + path); + } + byteData = url.getBytes(); + if (byteData != null && byteData.length == 0) { + // no length, no data (we want the default) + byteData = null; + } + } else { + try { + SFile file = FileSystem.getInstance().getFile(path); + if (file != null && file.exists() && file.canRead() && !file.isDirectory()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(1024 * 8); + FileUtils.copyToStream(file, out); + byteData = out.toByteArray(); + } + } catch (IOException e) { + e.printStackTrace(); + throw EaterException.located("load JSON: Cannot read file " + + path + ". " + e.getMessage()); + } + } + if (byteData != null) { + return new String(byteData, charset); + } + return null; + } +} diff --git a/test/net/sourceforge/plantuml/LoadJsonTest.java b/test/net/sourceforge/plantuml/LoadJsonTest.java new file mode 100644 index 000000000..ac5c2c3d4 --- /dev/null +++ b/test/net/sourceforge/plantuml/LoadJsonTest.java @@ -0,0 +1,140 @@ +package net.sourceforge.plantuml; + +import net.sourceforge.plantuml.security.SFile; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static net.sourceforge.plantuml.test.TestUtils.writeUtf8File; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.util.Lists.newArrayList; + +/** + * Tests the load of a JSON file. + *

+ * Current limitation: Only local file tests, not tests to a http rest endpoint + */ +class LoadJsonTest { + + private static final String[] COMMON_OPTIONS = {"-tutxt"}; + + private static final String JSON = "{\"jsonTestKey\": \"exampleValue\"}"; + + private static final String DEF_JSON = "{\"jsonTestKey\": \"exampleDefaultValue\"}"; + + // ************ Test DSL data + private static final String DIAGRAM = "" + + "@startuml\n" + + "!$JSON_DATA=%loadJSON(test.json)\n" + + + // title should have the value from the JSON file + "title $JSON_DATA.jsonTestKey\n" + + "a -> b\n" + + "@enduml\n"; + + private static final String DIAGRAM_DEF = "" + + "@startuml\n" + + "!$DEF_JSON=" + DEF_JSON + "\n" + + "!$JSON_DATA=%loadJSON(\"test-notfound.json\", $DEF_JSON)\n" + + + // title should have the value from the default (because the file doesn't exist) + "title $JSON_DATA.jsonTestKey\n" + + "a -> b\n" + + "@enduml\n"; + + private static final String DIAGRAM_DEF_EMPTY = "" + + "@startuml\n" + + "!$JSON_DATA=%loadJSON(\"test-notfound.json\")\n" + + // JSON_DATA is defined, but empty (loadJSON default). So, title contains only "xx yy". + "title xx $JSON_DATA.jsonTestKey yy\n" + + "a -> b\n" + + "@enduml\n"; + + + @TempDir + Path tempDir; + + /** + * Resets the current directory. + */ + @AfterAll + static void cleanUp() { + FileSystem.getInstance().reset(); + } + + /** + * Prepares test JSON file and sets the current directory for each test. + * + * @throws Exception hopefully not + */ + @BeforeEach + public void beforeEach() throws Exception { + writeUtf8File(tempDir.resolve("test.json"), JSON); + FileSystem.getInstance().setCurrentDir(new SFile(tempDir.toFile().getAbsolutePath())); + } + + /** + * Tests, if the loadJSON is loading the JSON file from test tmp folder. + * + * @throws Exception if something went wrong in this test + */ + @Test + void testLoadJsonSimple() throws Exception { + String rendered = render(DIAGRAM); + assertThat(rendered).doesNotContain("syntax").contains("exampleValue"); + + } + + /** + * Tests, if the loadJSON is using the default JSON given as parameter. + * + * @throws Exception if something went wrong in this test + */ + @Test + void testLoadJsonNotFoundWithDefaultParameter() throws Exception { + String rendered = render(DIAGRAM_DEF); + assertThat(rendered).doesNotContain("syntax").contains("exampleDefaultValue"); + } + + /** + * Tests, if the loadJSON is using the default JSON. + * + * @throws Exception if something went wrong in this test + */ + @Test + void testLoadJsonNotFoundWithDefaultEmpty() throws Exception { + String rendered = render(DIAGRAM_DEF_EMPTY); + assertThat(rendered).doesNotContain("syntax").contains("xx yy"); + } + + private String[] optionArray(String... extraOptions) { + final List list = newArrayList(COMMON_OPTIONS); + Collections.addAll(list, extraOptions); + return list.toArray(new String[0]); + } + + private String render(String diagram, String... extraOptions) throws Exception { + + final Option option = new Option(optionArray(extraOptions)); + + final ByteArrayInputStream bais = new ByteArrayInputStream(diagram.getBytes(UTF_8)); + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + final Pipe pipe = new Pipe(option, new PrintStream(baos), bais, option.getCharset()); + + pipe.managePipe(ErrorStatus.init()); + + return new String(baos.toByteArray(), UTF_8); + } + +}