From a4553adeb28cac9dc8463975c588c5d24d4a98d8 Mon Sep 17 00:00:00 2001 From: matthew16550 Date: Sat, 13 Feb 2021 01:09:28 +1100 Subject: [PATCH] Add "POST /render" endpoint to picoweb server. It expects a JSON body like { "source": PLANTUML_SOURCE_STRING, "options": ARRAY_OF_STRINGS } And returns the rendered diagram with suitable Content-Type header (all output formats are supported). --- src/net/sourceforge/plantuml/FileFormat.java | 33 +- .../plantuml/SourceStringReader.java | 4 +- .../plantuml/picoweb/BadRequest400.java | 14 + .../plantuml/picoweb/PicoWebServer.java | 180 +++++++--- .../plantuml/picoweb/PicoWebServerTest.java | 316 ++++++++++++++++++ .../plantuml/picoweb/ReceivedHTTPRequest.java | 111 ++++++ .../plantuml/picoweb/RenderRequest.java | 45 +++ 7 files changed, 646 insertions(+), 57 deletions(-) create mode 100644 src/net/sourceforge/plantuml/picoweb/BadRequest400.java create mode 100644 src/net/sourceforge/plantuml/picoweb/PicoWebServerTest.java create mode 100644 src/net/sourceforge/plantuml/picoweb/ReceivedHTTPRequest.java create mode 100644 src/net/sourceforge/plantuml/picoweb/RenderRequest.java diff --git a/src/net/sourceforge/plantuml/FileFormat.java b/src/net/sourceforge/plantuml/FileFormat.java index bd6c3e28e..d6ad5f83a 100644 --- a/src/net/sourceforge/plantuml/FileFormat.java +++ b/src/net/sourceforge/plantuml/FileFormat.java @@ -60,8 +60,37 @@ import net.sourceforge.plantuml.ugraphic.UFont; * */ public enum FileFormat { - PNG, SVG, EPS, EPS_TEXT, ATXT, UTXT, XMI_STANDARD, XMI_STAR, XMI_ARGO, SCXML, PDF, MJPEG, ANIMATED_GIF, HTML, HTML5, - VDX, LATEX, LATEX_NO_PREAMBLE, BASE64, BRAILLE_PNG, PREPROC; + PNG("image/png"), + SVG("image/svg+xml"), + EPS("application/postscript"), + EPS_TEXT("application/postscript"), + ATXT("text/plain"), + UTXT("text/plain;charset=UTF-8"), + XMI_STANDARD("application/vnd.xmi+xml"), + XMI_STAR("application/vnd.xmi+xml"), + XMI_ARGO("application/vnd.xmi+xml"), + SCXML("application/scxml+xml"), + PDF("application/pdf"), + MJPEG("video/x-msvideo"), + ANIMATED_GIF("image/gif"), + HTML("text/html"), + HTML5("text/html"), + VDX("application/vnd.visio.xml"), + LATEX("application/x-latex"), + LATEX_NO_PREAMBLE("application/x-latex"), + BASE64("text/plain; charset=x-user-defined"), + BRAILLE_PNG("image/png"), + PREPROC("text/plain"); + + private final String mimeType; + + FileFormat(String mimeType) { + this.mimeType = mimeType; + } + + public String getMimeType() { + return mimeType; + } /** * Returns the file format to be used for that format. diff --git a/src/net/sourceforge/plantuml/SourceStringReader.java b/src/net/sourceforge/plantuml/SourceStringReader.java index 2f6ed6d6c..290e97fa9 100644 --- a/src/net/sourceforge/plantuml/SourceStringReader.java +++ b/src/net/sourceforge/plantuml/SourceStringReader.java @@ -225,7 +225,7 @@ public class SourceStringReader { } - private void noStartumlFound(OutputStream os, FileFormatOption fileFormatOption, long seed) throws IOException { + public ImageData noStartumlFound(OutputStream os, FileFormatOption fileFormatOption, long seed) throws IOException { final TextBlockBackcolored error = GraphicStrings.createForError(Arrays.asList("No @startuml/@enduml found"), fileFormatOption.isUseRedForError()); HColor backcolor = error.getBackcolor(); @@ -233,7 +233,7 @@ public class SourceStringReader { null, ClockwiseTopRightBottomLeft.none(), backcolor); final ImageBuilder imageBuilder = ImageBuilder.build(imageParameter); imageBuilder.setUDrawable(error); - imageBuilder.writeImageTOBEMOVED(fileFormatOption, seed, os); + return imageBuilder.writeImageTOBEMOVED(fileFormatOption, seed, os); } public final List getBlocks() { diff --git a/src/net/sourceforge/plantuml/picoweb/BadRequest400.java b/src/net/sourceforge/plantuml/picoweb/BadRequest400.java new file mode 100644 index 000000000..6fbf9242a --- /dev/null +++ b/src/net/sourceforge/plantuml/picoweb/BadRequest400.java @@ -0,0 +1,14 @@ +package net.sourceforge.plantuml.picoweb; + +import java.io.IOException; + +public class BadRequest400 extends IOException { + + public BadRequest400(String message) { + super(message); + } + + public BadRequest400(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/net/sourceforge/plantuml/picoweb/PicoWebServer.java b/src/net/sourceforge/plantuml/picoweb/PicoWebServer.java index 9ffe12b9f..81a59201e 100644 --- a/src/net/sourceforge/plantuml/picoweb/PicoWebServer.java +++ b/src/net/sourceforge/plantuml/picoweb/PicoWebServer.java @@ -36,34 +36,40 @@ */ package net.sourceforge.plantuml.picoweb; +import java.io.BufferedInputStream; import java.io.BufferedOutputStream; -import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStreamReader; import java.io.OutputStream; +import java.io.PrintWriter; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; +import java.util.Collections; import java.util.Date; import java.util.List; -import java.util.StringTokenizer; import net.sourceforge.plantuml.BlockUml; import net.sourceforge.plantuml.ErrorUml; import net.sourceforge.plantuml.FileFormat; import net.sourceforge.plantuml.FileFormatOption; +import net.sourceforge.plantuml.LineLocationImpl; +import net.sourceforge.plantuml.Option; import net.sourceforge.plantuml.SourceStringReader; +import net.sourceforge.plantuml.StringLocated; import net.sourceforge.plantuml.StringUtils; -import net.sourceforge.plantuml.code.NoPlantumlCompressionException; import net.sourceforge.plantuml.code.Transcoder; import net.sourceforge.plantuml.code.TranscoderUtil; import net.sourceforge.plantuml.core.Diagram; import net.sourceforge.plantuml.core.ImageData; import net.sourceforge.plantuml.error.PSystemError; +import net.sourceforge.plantuml.error.PSystemErrorUtils; import net.sourceforge.plantuml.graphic.QuoteUtils; import net.sourceforge.plantuml.version.Version; +import static java.nio.charset.StandardCharsets.UTF_8; +import static net.sourceforge.plantuml.ErrorUmlType.SYNTAX_ERROR; + public class PicoWebServer implements Runnable { private final Socket connect; @@ -80,6 +86,10 @@ public class PicoWebServer implements Runnable { final InetAddress bindAddress1 = bindAddress == null ? null : InetAddress.getByName(bindAddress); final ServerSocket serverConnect = new ServerSocket(port, 50, bindAddress1); System.err.println("webPort=" + serverConnect.getLocalPort()); + serverLoop(serverConnect); + } + + public static void serverLoop(final ServerSocket serverConnect) throws IOException { while (true) { final PicoWebServer myServer = new PicoWebServer(serverConnect.accept()); final Thread thread = new Thread(myServer); @@ -88,39 +98,39 @@ public class PicoWebServer implements Runnable { } public void run() { - BufferedReader in = null; + BufferedInputStream in = null; BufferedOutputStream out = null; try { - in = new BufferedReader(new InputStreamReader(connect.getInputStream(), "UTF-8")); + in = new BufferedInputStream(connect.getInputStream()); out = new BufferedOutputStream(connect.getOutputStream()); - final String first = in.readLine(); - if (first == null) { + final ReceivedHTTPRequest request = ReceivedHTTPRequest.fromStream(in); + if (request.getMethod().equals("GET")) { + if (request.getPath().startsWith("/png/") && handleGET(request, out, FileFormat.PNG)) + return; + if (request.getPath().startsWith("/plantuml/png/") && handleGET(request, out, FileFormat.PNG)) + return; + if (request.getPath().startsWith("/svg/") && handleGET(request, out, FileFormat.SVG)) + return; + if (request.getPath().startsWith("/plantuml/svg/") && handleGET(request, out, FileFormat.SVG)) + return; + } else if (request.getMethod().equals("POST") && request.getPath().equals("/render")) { + handleRenderRequest(request, out); return; } - - final StringTokenizer parse = new StringTokenizer(first); - final String method = parse.nextToken().toUpperCase(); - - if (method.equals("GET")) { - final String path = parse.nextToken(); - if (path.startsWith("/png/") && sendDiagram(out, path, "image/png", FileFormat.PNG)) - return; - if (path.startsWith("/plantuml/png/") && sendDiagram(out, path, "image/png", FileFormat.PNG)) - return; - if (path.startsWith("/svg/") && sendDiagram(out, path, "image/svg+xml", FileFormat.SVG)) - return; - if (path.startsWith("/plantuml/svg/") && sendDiagram(out, path, "image/svg+xml", FileFormat.SVG)) - return; - } write(out, "HTTP/1.1 302 Found"); write(out, "Location: /plantuml/png/oqbDJyrBuGh8ISmh2VNrKGZ8JCuFJqqAJYqgIotY0aefG5G00000"); write(out, ""); out.flush(); } catch (Throwable e) { - e.printStackTrace(); + try { + sendError(e, out); + } + catch (Throwable e1) { + e.printStackTrace(); + } } finally { try { in.close(); @@ -132,49 +142,113 @@ public class PicoWebServer implements Runnable { } } - private boolean sendDiagram(BufferedOutputStream out, String path, final String mime, final FileFormat format) - throws NoPlantumlCompressionException, IOException { - final int x = path.lastIndexOf('/'); - final String compressed = path.substring(x + 1); + private boolean handleGET(ReceivedHTTPRequest request, BufferedOutputStream out, final FileFormat format) throws IOException { + final int x = request.getPath().lastIndexOf('/'); + final String compressed = request.getPath().substring(x + 1); final Transcoder transcoder = TranscoderUtil.getDefaultTranscoderProtected(); final String source = transcoder.decode(compressed); final SourceStringReader ssr = new SourceStringReader(source); final List blocks = ssr.getBlocks(); if (blocks.size() > 0) { + final FileFormatOption fileFormatOption = new FileFormatOption(format); final Diagram system = blocks.get(0).getDiagram(); final ByteArrayOutputStream os = new ByteArrayOutputStream(); - final ImageData imageData = system.exportDiagram(os, 0, new FileFormatOption(format)); + final ImageData imageData = system.exportDiagram(os, 0, fileFormatOption); os.close(); - final byte[] fileData = os.toByteArray(); - write(out, "HTTP/1.1 " + httpReturnCode(imageData.getStatus())); - write(out, "Cache-Control: no-cache"); - write(out, "Server: PlantUML PicoWebServer " + Version.versionString()); - write(out, "Date: " + new Date()); - write(out, "Content-type: " + mime); - write(out, "Content-length: " + fileData.length); - write(out, "X-PlantUML-Diagram-Width: " + imageData.getWidth()); - write(out, "X-PlantUML-Diagram-Height: " + imageData.getHeight()); - write(out, "X-PlantUML-Diagram-Description: " + system.getDescription().getDescription()); - if (system instanceof PSystemError) { - final PSystemError error = (PSystemError) system; - for (ErrorUml err : error.getErrorsUml()) { - write(out, "X-PlantUML-Diagram-Error: " + err.getError()); - write(out, "X-PlantUML-Diagram-Error-Line: " + (1 + err.getLineLocation().getPosition())); - } - } - write(out, "X-Patreon: Support us on https://plantuml.com/patreon"); - write(out, "X-Donate: https://plantuml.com/paypal"); - write(out, "X-Quote: " + StringUtils.rot(QuoteUtils.getSomeQuote())); - write(out, ""); - out.flush(); - out.write(fileData); - out.flush(); + sendDiagram(out, system, fileFormatOption, httpReturnCode(imageData.getStatus()), imageData, os.toByteArray()); return true; } return false; } + private void handleRenderRequest(ReceivedHTTPRequest request, BufferedOutputStream out) throws Exception { + if (request.getBody().length == 0) { + throw new BadRequest400("No request body"); + } + + final RenderRequest renderRequest; + try { + renderRequest = RenderRequest.fromJson(new String(request.getBody(), UTF_8)); + } catch (Exception e) { + throw new BadRequest400("Error parsing request json: " + e.getMessage(), e); + } + + final Option option = new Option(renderRequest.getOptions()); + + final String source = renderRequest.getSource().startsWith("@start") + ? renderRequest.getSource() + : "@startuml\n" + renderRequest.getSource() + "\n@enduml"; + + final SourceStringReader ssr = new SourceStringReader(option.getDefaultDefines(), source, option.getConfig()); + final ByteArrayOutputStream os = new ByteArrayOutputStream(); + final Diagram system; + final ImageData imageData; + + if (ssr.getBlocks().size() == 0) { + system = PSystemErrorUtils.buildV2( + null, + new ErrorUml(SYNTAX_ERROR, "No @startuml/@enduml found", 0, new LineLocationImpl("", null)), + null, + Collections.emptyList() + ); + imageData = ssr.noStartumlFound(os, option.getFileFormatOption(),42); + } else { + system = ssr.getBlocks().get(0).getDiagram(); + imageData = system.exportDiagram(os, 0, option.getFileFormatOption()); + } + + sendDiagram(out, system, option.getFileFormatOption(), "200", imageData, os.toByteArray()); + } + + private void sendDiagram(final BufferedOutputStream out, final Diagram system, final FileFormatOption fileFormatOption, + final String returnCode, final ImageData imageData, final byte[] fileData) + throws IOException { + + write(out, "HTTP/1.1 " + returnCode); + write(out, "Cache-Control: no-cache"); + write(out, "Server: PlantUML PicoWebServer " + Version.versionString()); + write(out, "Date: " + new Date()); + write(out, "Content-type: " + fileFormatOption.getFileFormat().getMimeType()); + write(out, "Content-length: " + fileData.length); + write(out, "X-PlantUML-Diagram-Width: " + imageData.getWidth()); + write(out, "X-PlantUML-Diagram-Height: " + imageData.getHeight()); + write(out, "X-PlantUML-Diagram-Description: " + system.getDescription().getDescription()); + if (system instanceof PSystemError) { + final PSystemError error = (PSystemError) system; + for (ErrorUml err : error.getErrorsUml()) { + write(out, "X-PlantUML-Diagram-Error: " + err.getError()); + write(out, "X-PlantUML-Diagram-Error-Line: " + (1 + err.getLineLocation().getPosition())); + } + } + write(out, "X-Patreon: Support us on https://plantuml.com/patreon"); + write(out, "X-Donate: https://plantuml.com/paypal"); + write(out, "X-Quote: " + StringUtils.rot(QuoteUtils.getSomeQuote())); + write(out, ""); + out.flush(); + out.write(fileData); + out.flush(); + } + + private void sendError(Throwable e, BufferedOutputStream out) throws Exception { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final PrintWriter printWriter = new PrintWriter(baos); + + if (e instanceof BadRequest400 && e.getCause() == null) { + printWriter.write(e.getMessage()); + } else { + e.printStackTrace(printWriter); + } + printWriter.close(); + + write(out, "HTTP/1.1 " + (e instanceof BadRequest400 ? "400 Bad Request" : "500 Internal Server Error")); + write(out, "Content-type: text/plain"); + write(out, "Content-length: " + baos.size()); + write(out, ""); + out.write(baos.toByteArray()); + out.flush(); + } + private String httpReturnCode(int status) { if (status == 0 || status == 200) { return "200 OK"; diff --git a/src/net/sourceforge/plantuml/picoweb/PicoWebServerTest.java b/src/net/sourceforge/plantuml/picoweb/PicoWebServerTest.java new file mode 100644 index 000000000..6696f884d --- /dev/null +++ b/src/net/sourceforge/plantuml/picoweb/PicoWebServerTest.java @@ -0,0 +1,316 @@ +package net.sourceforge.plantuml.picoweb; + +import net.sourceforge.plantuml.json.Json; +import net.sourceforge.plantuml.json.JsonObject; + +import javax.imageio.ImageIO; +import javax.imageio.stream.MemoryCacheImageInputStream; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static net.sourceforge.plantuml.code.TranscoderUtil.getDefaultTranscoder; + +// Newer Java versions have nice built-in HTTP classes in the jdk.incubator.httpclient / java.net.http packages +// but PlantUML supports older Java versions so the tests here use a kludgy approach to HTTP. + +// Multi-line strings here start with a "" so that IDE auto-indenting will leave the rest of the string nicely +// aligned + +public class PicoWebServerTest { + + public static void main(String[] args) throws Exception { + startServer(); + test_basic_http(); + test_GET_png(); + test_GET_svg(); + test_POST_render(); + test_unknown_path(); + } + + // + // Test Cases + // + + private static void test_basic_http() throws Exception { + assert httpRaw( + "" + ).equals("" + + "HTTP/1.1 400 Bad Request\n" + + "Content-type: text/plain\n" + + "Content-length: 16\n" + + "\n" + + "Bad request line" + ); + + assert httpRaw( + "GET" + ).equals("" + + "HTTP/1.1 400 Bad Request\n" + + "Content-type: text/plain\n" + + "Content-length: 16\n" + + "\n" + + "Bad request line" + ); + + assert httpRaw("" + + "GET /foo HTTP/1.1\n" + + "Content-Length: bar\n" + ).equals("" + + "HTTP/1.1 400 Bad Request\n" + + "Content-type: text/plain\n" + + "Content-length: 22\n" + + "\n" + + "Invalid content length" + ); + + assert httpRaw("" + + "GET /foo HTTP/1.1\n" + + "Content-Length: -1\n" + ).equals("" + + "HTTP/1.1 400 Bad Request\n" + + "Content-type: text/plain\n" + + "Content-length: 23\n" + + "\n" + + "Negative content length" + ); + + assert httpRaw("" + + "GET /foo HTTP/1.1\n" + + "Content-Length: 3\n" + + "\n" + + "12" + ).equals("" + + "HTTP/1.1 400 Bad Request\n" + + "Content-type: text/plain\n" + + "Content-length: 14\n" + + "\n" + + "Body too short" + ); + } + + private static void test_GET_png() throws Exception { + HttpURLConnection response; + + response = httpGet("/png/" + getDefaultTranscoder().encode("A -> B")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("image/png"); + assert readStreamAsImage(response.getInputStream()) != null; + + response = httpGet("/plantuml/png/" + getDefaultTranscoder().encode("A -> B")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("image/png"); + assert readStreamAsImage(response.getInputStream()) != null; + + response = httpGet("/png/" + getDefaultTranscoder().encode("foo")); + assert response.getResponseCode() == 400; + assert response.getHeaderField("X-PlantUML-Diagram-Error").equals("Syntax Error?"); + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line").equals("2"); + assert response.getContentType().equals("image/png"); + assert readStreamAsImage(response.getErrorStream()) != null; + } + + private static void test_GET_svg() throws Exception { + HttpURLConnection response; + + response = httpGet("/svg/" + getDefaultTranscoder().encode("A -> B")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("image/svg+xml"); + assert readStreamAsString(response.getInputStream()).startsWith(" B")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("image/svg+xml"); + assert readStreamAsString(response.getInputStream()).startsWith(" B")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("image/png"); + assert readStreamAsImage(response.getInputStream()) != null; + + response = httpPostJson("/render", renderRequestJson("A -> B", "-tsvg")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("image/svg+xml"); + assert readStreamAsString(response.getInputStream()).startsWith(" B", "-ttxt")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("text/plain"); + assert readStreamAsString(response.getInputStream()).equals("" + + " ,-. ,-.\n" + + " |A| |B|\n" + + " `+' `+'\n" + + " | | \n" + + " |----------->| \n" + + " ,+. ,+.\n" + + " |A| |B|\n" + + " `-' `-'\n" + ); + + response = httpPostJson("/render", renderRequestJson("foo", "-ttxt")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error").equals("Syntax Error?"); + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line").equals("2"); + assert response.getContentType().equals("text/plain"); + assert readStreamAsString(response.getInputStream()).equals("" + + "[From string (line 2) ]\n" + + " \n" + + "@startuml \n" + + "foo \n" + + "^^^^^ \n" + + " Syntax Error? \n" + ); + + response = httpPostJson("/render", renderRequestJson("@startuml", "-ttxt")); + assert response.getResponseCode() == 200; + assert response.getHeaderField("X-PlantUML-Diagram-Error").equals("No @startuml/@enduml found"); + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line").equals("0"); + assert response.getContentType().equals("text/plain"); + assert readStreamAsString(response.getInputStream()).equals("" + + " \n" + + " \n" + + " No @startuml/@enduml found\n" + ); + + response = httpPostJson("/render", ""); + assert response.getResponseCode() == 400; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("text/plain"); + assert readStreamAsString(response.getErrorStream()).equals("No request body"); + + response = httpPostJson("/render", "123abc"); + assert response.getResponseCode() == 400; + assert response.getHeaderField("X-PlantUML-Diagram-Error") == null; + assert response.getHeaderField("X-PlantUML-Diagram-Error-Line") == null; + assert response.getContentType().equals("text/plain"); + assert readStreamAsString(response.getErrorStream()).contains("Error parsing request json: Unexpected character at 1:4\n"); + } + + private static void test_unknown_path() throws Exception { + HttpURLConnection response = httpGet("/foo"); + assert response.getResponseCode() == 302; + assert response.getHeaderField("Location").equals("/plantuml/png/oqbDJyrBuGh8ISmh2VNrKGZ8JCuFJqqAJYqgIotY0aefG5G00000"); + } + + // + // Test DSL + // + + private static HttpURLConnection httpGet(String path) throws Exception { + return urlConnection(path); + } + + private static HttpURLConnection httpPost(String path, String contentType, byte[] content) throws Exception { + HttpURLConnection conn = urlConnection(path); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", contentType); + conn.setRequestProperty("Content-Length", Integer.toString(content.length)); + conn.setDoOutput(true); + conn.getOutputStream().write(content); + return conn; + } + + private static HttpURLConnection httpPostJson(String path, String json) throws Exception { + return httpPost(path, "application/json; utf-8", json.getBytes(UTF_8)); + } + + private static String httpRaw(String request) throws Exception { + try (Socket socket = socketConnection()) { + socket.getOutputStream().write(request.getBytes(UTF_8)); + socket.shutdownOutput(); + return readStreamAsString(socket.getInputStream()) + .replaceAll("\r\n", "\n"); + } + } + + private static BufferedImage readStreamAsImage(InputStream in) throws Exception { + return ImageIO.read(new MemoryCacheImageInputStream(in)); + } + + private static String readStreamAsString(InputStream in) throws IOException { + byte[] buffer = new byte[1024]; + int length; + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + while ((length = in.read(buffer)) != -1) { + baos.write(buffer, 0, length); + } + return baos.toString(UTF_8.name()); + } + + private static String renderRequestJson(String source, String... options) { + final JsonObject object = Json.object(); + object.add("source", source); + if (options.length != 0) { + object.add("options", Json.array(options)); + } + return object.toString(); + } + + // + // System under test + // + + private static int port; + + private static void startServer() throws Exception { + final ServerSocket serverSocket = new ServerSocket(0); + port = serverSocket.getLocalPort(); + + Thread serverLoopThread = new Thread("PicoWebServerLoop") { + @Override + public void run() { + try { + PicoWebServer.serverLoop(serverSocket); + } catch (IOException e) { + e.printStackTrace(); + } + } + }; + + serverLoopThread.setDaemon(true); + serverLoopThread.start(); + } + + private static Socket socketConnection() throws IOException { + return new Socket("localhost", port); + } + + private static HttpURLConnection urlConnection(String path) throws Exception { + final HttpURLConnection conn = (HttpURLConnection) new URL("http://localhost:" + port + path).openConnection(); + conn.setInstanceFollowRedirects(false); + return conn; + } +} diff --git a/src/net/sourceforge/plantuml/picoweb/ReceivedHTTPRequest.java b/src/net/sourceforge/plantuml/picoweb/ReceivedHTTPRequest.java new file mode 100644 index 000000000..624b76d4b --- /dev/null +++ b/src/net/sourceforge/plantuml/picoweb/ReceivedHTTPRequest.java @@ -0,0 +1,111 @@ +package net.sourceforge.plantuml.picoweb; + +import java.io.IOException; +import java.io.InputStream; +import java.util.StringTokenizer; + +public class ReceivedHTTPRequest { + + private static final String CONTENT_LENGTH_HEADER = "content-length: "; + + private String method; + + private String path; + + private byte[] body; + + public String getMethod() { + return method; + } + + public String getPath() { + return path; + } + + public byte[] getBody() { + return body; + } + + public static ReceivedHTTPRequest fromStream(InputStream in) throws IOException { + final ReceivedHTTPRequest request = new ReceivedHTTPRequest(); + + final String requestLine = readLine(in); + + final StringTokenizer tokenizer = new StringTokenizer(requestLine); + if (tokenizer.countTokens() != 3) { + throw new BadRequest400("Bad request line"); + } + + request.method = tokenizer.nextToken().toUpperCase(); + request.path = tokenizer.nextToken(); + + // Headers + int contentLength = 0; + + while (true) { + String line = readLine(in); + if (line.isEmpty()) { + break; + } else if (line.toLowerCase().startsWith(CONTENT_LENGTH_HEADER)) { + contentLength = parseContentLengthHeader(line); + } + } + + request.body = readBody(in, contentLength); + return request; + } + + private static int parseContentLengthHeader(String line) throws IOException { + int contentLength; + + try { + contentLength = Integer.parseInt(line.substring(CONTENT_LENGTH_HEADER.length()).trim()); + } catch (NumberFormatException e) { + throw new BadRequest400("Invalid content length"); + } + + if (contentLength < 0) { + throw new BadRequest400("Negative content length"); + } + + return contentLength; + } + + private static byte[] readBody(InputStream in, int contentLength) throws IOException { + if (contentLength == 0) { + return new byte[0]; + } + + final byte[] body = new byte[contentLength]; + int n = 0; + int offset = 0; + + // java.io.InputStream.readNBytes() can replace this from Java 9 + while (n < contentLength) { + int count = in.read(body, offset + n, contentLength - n); + if (count < 0) { + throw new BadRequest400("Body too short"); + } + n += count; + } + return body; + } + + private static String readLine(InputStream in) throws IOException { + final StringBuilder builder = new StringBuilder(); + + while (true) { + int c = in.read(); + if (c == -1 || c == '\n') { + break; + } + builder.append((char) c); + } + + if (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\r') { + builder.deleteCharAt(builder.length() - 1); + } + + return builder.toString(); + } +} diff --git a/src/net/sourceforge/plantuml/picoweb/RenderRequest.java b/src/net/sourceforge/plantuml/picoweb/RenderRequest.java new file mode 100644 index 000000000..caa68d209 --- /dev/null +++ b/src/net/sourceforge/plantuml/picoweb/RenderRequest.java @@ -0,0 +1,45 @@ +package net.sourceforge.plantuml.picoweb; + +import net.sourceforge.plantuml.json.Json; +import net.sourceforge.plantuml.json.JsonArray; +import net.sourceforge.plantuml.json.JsonObject; + +/** + * POJO of the json sent to "POST /render" + */ +public class RenderRequest { + + private final String[] options; + + private final String source; + + public RenderRequest(String[] options, String source) { + this.options = options; + this.source = source; + } + + public String[] getOptions() { + return options; + } + + public String getSource() { + return source; + } + + public static RenderRequest fromJson(String json) { + final JsonObject parsed = Json.parse(json).asObject(); + final String[] options; + + if (parsed.contains("options")) { + final JsonArray jsonArray = parsed.get("options").asArray(); + options = new String[jsonArray.size()]; + for (int i = 0; i < jsonArray.size(); i++) { + options[i] = jsonArray.get(i).asString(); + } + } else { + options = new String[0]; + } + + return new RenderRequest(options, parsed.get("source").asString()); + } +}