From 6e6bf7ecef6786626f829f353f32dc2b20920d29 Mon Sep 17 00:00:00 2001 From: Romain Grecourt Date: Fri, 4 Oct 2024 18:44:33 -0700 Subject: [PATCH] 4.x: Helidon SE Media Form Example Fixes #92 Also add minor updates to the multipart example. --- examples/media/form/README.md | 23 ++++ examples/media/form/pom.xml | 106 ++++++++++++++++++ .../examples/media/form/FormService.java | 73 ++++++++++++ .../io/helidon/examples/media/form/Main.java | 70 ++++++++++++ .../examples/media/form/package-info.java | 20 ++++ .../form/src/main/resources/WEB/index.html | 82 ++++++++++++++ .../src/main/resources/logging.properties | 21 ++++ .../examples/media/form/FormServiceTest.java | 86 ++++++++++++++ examples/media/multipart/README.md | 2 +- examples/media/multipart/pom.xml | 4 + .../examples/media/multipart/FileService.java | 44 ++++---- .../examples/media/multipart/Main.java | 4 + .../src/main/resources/WEB/index.html | 8 +- .../media/multipart/FileServiceTest.java | 24 ++-- examples/media/pom.xml | 1 + 15 files changed, 528 insertions(+), 40 deletions(-) create mode 100644 examples/media/form/README.md create mode 100644 examples/media/form/pom.xml create mode 100644 examples/media/form/src/main/java/io/helidon/examples/media/form/FormService.java create mode 100644 examples/media/form/src/main/java/io/helidon/examples/media/form/Main.java create mode 100644 examples/media/form/src/main/java/io/helidon/examples/media/form/package-info.java create mode 100644 examples/media/form/src/main/resources/WEB/index.html create mode 100644 examples/media/form/src/main/resources/logging.properties create mode 100644 examples/media/form/src/test/java/io/helidon/examples/media/form/FormServiceTest.java diff --git a/examples/media/form/README.md b/examples/media/form/README.md new file mode 100644 index 000000000..d1d014789 --- /dev/null +++ b/examples/media/form/README.md @@ -0,0 +1,23 @@ +# Helidon SE Media Form Example + +This example demonstrates how to use `Parameters` consume or upload form data with both the `WebServer` + and `WebClient` APIs. + +This project implements a simple web application that supports uploading +and listing form data. The unit test uses the `WebClient` API to test the endpoints. + +## Build + +```shell +mvn package +``` + +## Run + +First, start the server: + +```shell +java -jar target/helidon-examples-media-form.jar +``` + +Then open in your browser. diff --git a/examples/media/form/pom.xml b/examples/media/form/pom.xml new file mode 100644 index 000000000..da20e3186 --- /dev/null +++ b/examples/media/form/pom.xml @@ -0,0 +1,106 @@ + + + + + 4.0.0 + + io.helidon.applications + helidon-se + 4.2.0-SNAPSHOT + + + io.helidon.examples.media + helidon-examples-media-form + 1.0.0-SNAPSHOT + Helidon Examples Media Support Form + + + Example of a form usage. + + + + io.helidon.examples.media.form.Main + + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.http.media + helidon-http-media-jsonp + + + io.helidon.webserver + helidon-webserver-static-content + + + io.helidon.logging + helidon-logging-jul + + + jakarta.json + jakarta.json-api + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-libs + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + + + + diff --git a/examples/media/form/src/main/java/io/helidon/examples/media/form/FormService.java b/examples/media/form/src/main/java/io/helidon/examples/media/form/FormService.java new file mode 100644 index 000000000..b903b3671 --- /dev/null +++ b/examples/media/form/src/main/java/io/helidon/examples/media/form/FormService.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.examples.media.form; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.helidon.common.parameters.Parameters; +import io.helidon.http.Header; +import io.helidon.http.HeaderNames; +import io.helidon.http.HeaderValues; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.HttpService; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import jakarta.json.Json; +import jakarta.json.JsonBuilderFactory; +import jakarta.json.JsonObjectBuilder; + +import static io.helidon.http.Status.MOVED_PERMANENTLY_301; + +/** + * Form service. + */ +public final class FormService implements HttpService { + private static final Header UI_LOCATION = HeaderValues.createCached(HeaderNames.LOCATION, "/ui"); + private final JsonBuilderFactory jsonFactory = Json.createBuilderFactory(Map.of()); + private final Map> data = new ConcurrentHashMap<>(); + + @Override + public void routing(HttpRules rules) { + rules.get("/", this::list) + .post("/", this::post); + } + + private void list(ServerRequest req, ServerResponse res) { + JsonObjectBuilder jsonBuilder = jsonFactory.createObjectBuilder(); + data.forEach((k, list) -> jsonBuilder.add(k, jsonFactory.createArrayBuilder(list))); + res.send(jsonBuilder.build()); + } + + private void post(ServerRequest req, ServerResponse res) { + Parameters form = req.content().asOptional(Parameters.class).orElseThrow(); + form.names().forEach(name -> data.compute(name, (k, v) -> addAll(v, form.all(k)))); + res.status(MOVED_PERMANENTLY_301) + .header(UI_LOCATION) + .send(); + } + + private static List addAll(List list, List values) { + if (list == null) { + list = new ArrayList<>(); + } + list.addAll(values); + return list; + } +} diff --git a/examples/media/form/src/main/java/io/helidon/examples/media/form/Main.java b/examples/media/form/src/main/java/io/helidon/examples/media/form/Main.java new file mode 100644 index 000000000..d7e2473a7 --- /dev/null +++ b/examples/media/form/src/main/java/io/helidon/examples/media/form/Main.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.examples.media.form; + +import io.helidon.http.Header; +import io.helidon.http.HeaderNames; +import io.helidon.http.HeaderValues; +import io.helidon.http.Status; +import io.helidon.logging.common.LogConfig; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.staticcontent.StaticContentService; + +/** + * This application provides a simple service with a UI to exercise forms. + */ +public final class Main { + private static final Header UI_LOCATION = HeaderValues.createCached(HeaderNames.LOCATION, "/ui"); + + private Main() { + } + + /** + * Executes the example. + * + * @param args command line arguments, ignored + */ + public static void main(String[] args) { + // load logging configuration + LogConfig.configureRuntime(); + + WebServer server = WebServer.builder() + .routing(Main::routing) + .port(8080) + .build() + .start(); + + System.out.println("WEB server is up! http://localhost:" + server.port()); + } + + /** + * Updates the routing rules. + * + * @param rules routing rules + */ + static void routing(HttpRules rules) { + rules.any("/", (req, res) -> { + res.status(Status.MOVED_PERMANENTLY_301); + res.header(UI_LOCATION); + res.send(); + }) + .register("/ui", StaticContentService.builder("WEB") + .welcomeFileName("index.html") + .build()) + .register("/api", new FormService()); + } +} diff --git a/examples/media/form/src/main/java/io/helidon/examples/media/form/package-info.java b/examples/media/form/src/main/java/io/helidon/examples/media/form/package-info.java new file mode 100644 index 000000000..73549223d --- /dev/null +++ b/examples/media/form/src/main/java/io/helidon/examples/media/form/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2020, 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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. + */ + +/** + * Helidon Examples Media MultiPart. + */ +package io.helidon.examples.media.form; diff --git a/examples/media/form/src/main/resources/WEB/index.html b/examples/media/form/src/main/resources/WEB/index.html new file mode 100644 index 000000000..ad21d69a1 --- /dev/null +++ b/examples/media/form/src/main/resources/WEB/index.html @@ -0,0 +1,82 @@ + + + + + + Helidon Examples Media Form + + + + + + +

Data

+
+ +

Form

+
+ + + +
+ + + + diff --git a/examples/media/form/src/main/resources/logging.properties b/examples/media/form/src/main/resources/logging.properties new file mode 100644 index 000000000..ccdbe8a09 --- /dev/null +++ b/examples/media/form/src/main/resources/logging.properties @@ -0,0 +1,21 @@ +# +# Copyright (c) 2018, 2024 Oracle and/or its affiliates. +# +# 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 +# +# http://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. +# + +handlers=java.util.logging.ConsoleHandler +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL %5$s%6$s%n +# Global logging level. Can be overridden by specific loggers +.level=INFO +io.helidon.webserver.level=INFO diff --git a/examples/media/form/src/test/java/io/helidon/examples/media/form/FormServiceTest.java b/examples/media/form/src/test/java/io/helidon/examples/media/form/FormServiceTest.java new file mode 100644 index 000000000..699b98e3a --- /dev/null +++ b/examples/media/form/src/test/java/io/helidon/examples/media/form/FormServiceTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021, 2024 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.examples.media.form; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import io.helidon.common.parameters.Parameters; +import io.helidon.http.Status; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webclient.http1.Http1ClientResponse; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.json.JsonValue; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Tests {@link io.helidon.examples.media.form.FormService}. + */ +@TestMethodOrder(OrderAnnotation.class) +@ServerTest +public class FormServiceTest { + private final Http1Client client; + + FormServiceTest(Http1Client client) { + this.client = client; + } + + @SetUpRoute + static void routing(HttpRouting.Builder builder) { + Main.routing(builder); + } + + @Test + @Order(1) + public void testPost() { + Map> data = Map.of("foo", List.of("bar1", "bar2")); + try (Http1ClientResponse response = client.post("/api") + .followRedirects(false) + .submit(Parameters.create("form", data))) { + assertThat(response.status(), is(Status.MOVED_PERMANENTLY_301)); + } + } + + @Test + @Order(3) + public void testList() { + try (Http1ClientResponse response = client.get("/api").request()) { + assertThat(response.status(), is(Status.OK_200)); + JsonObject json = response.as(JsonObject.class); + assertThat(json, is(notNullValue())); + Map> data = json.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, + e -> e.getValue().asJsonArray().getValuesAs(JsonString::getString))); + assertThat(data, is(Map.of("foo", List.of("bar1", "bar2")))); + } + } +} diff --git a/examples/media/multipart/README.md b/examples/media/multipart/README.md index eb7ee218b..570d1c0dc 100644 --- a/examples/media/multipart/README.md +++ b/examples/media/multipart/README.md @@ -1,4 +1,4 @@ -# Helidon SE MultiPart Example +# Helidon SE Media MultiPart Example This example demonstrates how to use `MultiPartSupport` with both the `WebServer` and `WebClient` APIs. diff --git a/examples/media/multipart/pom.xml b/examples/media/multipart/pom.xml index 71fe3a2cc..4c9bc4b0e 100644 --- a/examples/media/multipart/pom.xml +++ b/examples/media/multipart/pom.xml @@ -57,6 +57,10 @@ io.helidon.webserver helidon-webserver-static-content + + io.helidon.logging + helidon-logging-jul + jakarta.json jakarta.json-api diff --git a/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/FileService.java b/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/FileService.java index 78d31f5e3..40a9deaee 100644 --- a/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/FileService.java +++ b/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/FileService.java @@ -23,14 +23,17 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.List; import java.util.Map; import java.util.stream.Stream; import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.BadRequestException; import io.helidon.http.ContentDisposition; import io.helidon.http.Header; import io.helidon.http.HeaderNames; import io.helidon.http.HeaderValues; +import io.helidon.http.NotFoundException; import io.helidon.http.ServerResponseHeaders; import io.helidon.http.media.multipart.MultiPart; import io.helidon.http.media.multipart.ReadablePart; @@ -40,26 +43,22 @@ import io.helidon.webserver.http.ServerResponse; import jakarta.json.Json; -import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonBuilderFactory; -import static io.helidon.http.Status.BAD_REQUEST_400; import static io.helidon.http.Status.MOVED_PERMANENTLY_301; -import static io.helidon.http.Status.NOT_FOUND_404; /** * File service. */ public final class FileService implements HttpService { private static final Header UI_LOCATION = HeaderValues.createCached(HeaderNames.LOCATION, "/ui"); - private final JsonBuilderFactory jsonFactory; + private final JsonBuilderFactory jsonFactory = Json.createBuilderFactory(Map.of()); private final Path storage; /** * Create a new file upload service instance. */ FileService() { - jsonFactory = Json.createBuilderFactory(Map.of()); storage = createStorage(); System.out.println("Storage: " + storage); } @@ -67,33 +66,32 @@ public final class FileService implements HttpService { @Override public void routing(HttpRules rules) { rules.get("/", this::list) - .get("/{fname}", this::download) + .get("/{fileName}", this::download) .post("/", this::upload); } private static Path createStorage() { try { - return Files.createTempDirectory("fileupload"); + return Files.createTempDirectory("file-upload"); } catch (IOException ex) { throw new RuntimeException(ex); } } - private static Stream listFiles(Path storage) { + private static List listFiles(Path storage) { try (Stream walk = Files.walk(storage)) { return walk.filter(Files::isRegularFile) .map(storage::relativize) .map(Path::toString) - .toList() - .stream(); + .toList(); } catch (IOException ex) { throw new RuntimeException(ex); } } - private static OutputStream newOutputStream(Path storage, String fname) { + private static OutputStream newOutputStream(Path storage, String fileName) { try { - return Files.newOutputStream(storage.resolve(fname), + return Files.newOutputStream(storage.resolve(fileName), StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); @@ -103,24 +101,23 @@ private static OutputStream newOutputStream(Path storage, String fname) { } private void list(ServerRequest req, ServerResponse res) { - JsonArrayBuilder arrayBuilder = jsonFactory.createArrayBuilder(); - listFiles(storage).forEach(arrayBuilder::add); - res.send(jsonFactory.createObjectBuilder().add("files", arrayBuilder).build()); + res.send(jsonFactory.createObjectBuilder() + .add("files", jsonFactory.createArrayBuilder(listFiles(storage))) + .build()); } private void download(ServerRequest req, ServerResponse res) { - Path filePath = storage.resolve(req.path().pathParameters().get("fname")); + Path filePath = req.path().pathParameters().first("fileName") + .map(storage::resolve) + .orElseThrow(); if (!filePath.getParent().equals(storage)) { - res.status(BAD_REQUEST_400).send("Invalid file name"); - return; + throw new BadRequestException("Invalid file name"); } if (!Files.exists(filePath)) { - res.status(NOT_FOUND_404).send(); - return; + throw new NotFoundException("File not found"); } if (!Files.isRegularFile(filePath)) { - res.status(BAD_REQUEST_400).send("Not a file"); - return; + throw new BadRequestException("Not a file"); } ServerResponseHeaders headers = res.headers(); headers.contentType(MediaTypes.APPLICATION_OCTET_STREAM); @@ -136,7 +133,8 @@ private void upload(ServerRequest req, ServerResponse res) { while (mp.hasNext()) { ReadablePart part = mp.next(); if ("file[]".equals(URLDecoder.decode(part.name(), StandardCharsets.UTF_8))) { - try (InputStream in = part.inputStream(); OutputStream out = newOutputStream(storage, part.fileName().get())) { + try (InputStream in = part.inputStream(); + OutputStream out = newOutputStream(storage, part.fileName().orElseThrow())) { in.transferTo(out); } catch (IOException e) { throw new RuntimeException("Failed to write content", e); diff --git a/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/Main.java b/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/Main.java index e1a827e41..1cbec0649 100644 --- a/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/Main.java +++ b/examples/media/multipart/src/main/java/io/helidon/examples/media/multipart/Main.java @@ -19,6 +19,7 @@ import io.helidon.http.HeaderNames; import io.helidon.http.HeaderValues; import io.helidon.http.Status; +import io.helidon.logging.common.LogConfig; import io.helidon.webserver.WebServer; import io.helidon.webserver.http.HttpRules; import io.helidon.webserver.staticcontent.StaticContentService; @@ -38,6 +39,9 @@ private Main() { * @param args command line arguments, ignored */ public static void main(String[] args) { + // load logging configuration + LogConfig.configureRuntime(); + WebServer server = WebServer.builder() .routing(Main::routing) .port(8080) diff --git a/examples/media/multipart/src/main/resources/WEB/index.html b/examples/media/multipart/src/main/resources/WEB/index.html index d7acca73f..5e04f2978 100644 --- a/examples/media/multipart/src/main/resources/WEB/index.html +++ b/examples/media/multipart/src/main/resources/WEB/index.html @@ -16,7 +16,7 @@ limitations under the License. --> - + Helidon Examples Media Multipart @@ -39,14 +39,14 @@

Uploaded files

Upload (buffered)

Select a file to upload: - +

Upload (stream)

Select a file to upload: - +
@@ -56,7 +56,7 @@

Upload (stream)

url: "/api", method: "GET" }).done(function(data) { - var template = $('#repository_tpl').html() + const template = $('#repository_tpl').html() Mustache.parse(template); $("#repository").append(Mustache.render(template, data)) }); diff --git a/examples/media/multipart/src/test/java/io/helidon/examples/media/multipart/FileServiceTest.java b/examples/media/multipart/src/test/java/io/helidon/examples/media/multipart/FileServiceTest.java index 0f3f2926c..ec197c806 100644 --- a/examples/media/multipart/src/test/java/io/helidon/examples/media/multipart/FileServiceTest.java +++ b/examples/media/multipart/src/test/java/io/helidon/examples/media/multipart/FileServiceTest.java @@ -82,13 +82,13 @@ public void testStreamUpload() throws IOException { Path file = Files.writeString(Files.createTempFile(null, null), "stream bar\n"); Path file2 = Files.writeString(Files.createTempFile(null, null), "stream foo\n"); try (Http1ClientResponse response = client.post("/api") - .queryParam("stream", "true") - .followRedirects(false) - .submit(WriteableMultiPart - .builder() - .addPart(writeablePart("file[]", "streamed-foo.txt", file)) - .addPart(writeablePart("otherPart", "streamed-foo2.txt", file2)) - .build())) { + .queryParam("stream", "true") + .followRedirects(false) + .submit(WriteableMultiPart + .builder() + .addPart(writeablePart("file[]", "streamed-foo.txt", file)) + .addPart(writeablePart("otherPart", "streamed-foo2.txt", file2)) + .build())) { assertThat(response.status(), is(Status.MOVED_PERMANENTLY_301)); } } @@ -100,7 +100,7 @@ public void testList() { assertThat(response.status(), is(Status.OK_200)); JsonObject json = response.as(JsonObject.class); assertThat(json, Matchers.is(notNullValue())); - List files = json.getJsonArray("files").getValuesAs(v -> ((JsonString) v).getString()); + List files = json.getJsonArray("files").getValuesAs(JsonString::getString); assertThat(files, hasItem("foo.txt")); } } @@ -119,9 +119,9 @@ public void testDownload() { private WriteablePart writeablePart(String partName, String fileName, Path filePath) throws IOException { return WriteablePart.builder(partName) - .fileName(fileName) - .content(Files.readAllBytes(filePath)) - .contentType(MediaTypes.MULTIPART_FORM_DATA) - .build(); + .fileName(fileName) + .content(Files.readAllBytes(filePath)) + .contentType(MediaTypes.MULTIPART_FORM_DATA) + .build(); } } diff --git a/examples/media/pom.xml b/examples/media/pom.xml index 69e2cfbe6..d618d203f 100644 --- a/examples/media/pom.xml +++ b/examples/media/pom.xml @@ -36,6 +36,7 @@ + form multipart