diff --git a/common/http/src/main/java/io/helidon/common/http/FormBuilder.java b/common/http/src/main/java/io/helidon/common/http/FormBuilder.java new file mode 100644 index 00000000000..bf343ad6e67 --- /dev/null +++ b/common/http/src/main/java/io/helidon/common/http/FormBuilder.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2020 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.common.http; + +import io.helidon.common.Builder; + +/** + * Form builder interface. + * + * @param type of the builder + * @param type which the builder builds + */ +public interface FormBuilder extends Builder { + + /** + * Add a new values to specific content disposition name. + * + * @param name param name + * @param values param values + * @return updated builder instance + */ + B add(String name, String... values); + +} diff --git a/common/http/src/main/java/io/helidon/common/http/FormParams.java b/common/http/src/main/java/io/helidon/common/http/FormParams.java index 13db823dc67..6e2d06f1813 100644 --- a/common/http/src/main/java/io/helidon/common/http/FormParams.java +++ b/common/http/src/main/java/io/helidon/common/http/FormParams.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2020 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. @@ -15,6 +15,12 @@ */ package io.helidon.common.http; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + /** * Provides access to any form parameters present in the request entity. */ @@ -29,8 +35,47 @@ public interface FormParams extends Parameters { * URL-encoded, NL for text/plain) * @param mediaType MediaType for which the parameter conversion is occurring * @return the new {@code FormParams} instance + * @deprecated use {@link FormParams#builder()} instead or register {@code io.helidon.media.common.FormParamsBodyReader} */ + @Deprecated(since = "2.0.2") static FormParams create(String paramAssignments, MediaType mediaType) { return FormParamsImpl.create(paramAssignments, mediaType); } + + /** + * Creates a new {@link Builder} of {@code FormParams} instance. + * + * @return builder instance + */ + static Builder builder() { + return new Builder(); + } + + /** + * Builder of a new {@link FormParams} instance. + */ + class Builder implements FormBuilder { + + private final Map> params = new LinkedHashMap<>(); + + private Builder() { + } + + @Override + public FormParams build() { + return new FormParamsImpl(this); + } + + @Override + public Builder add(String name, String... values) { + params.computeIfAbsent(name, k -> new ArrayList<>()).addAll(Arrays.asList(values)); + return this; + } + + Map> params() { + return params; + } + + } + } diff --git a/common/http/src/main/java/io/helidon/common/http/FormParamsImpl.java b/common/http/src/main/java/io/helidon/common/http/FormParamsImpl.java index 12a5630089d..8867a1638c7 100644 --- a/common/http/src/main/java/io/helidon/common/http/FormParamsImpl.java +++ b/common/http/src/main/java/io/helidon/common/http/FormParamsImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2020 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. @@ -35,6 +35,14 @@ class FormParamsImpl extends ReadOnlyParameters implements FormParams { MediaType.APPLICATION_FORM_URLENCODED, preparePattern("&"), MediaType.TEXT_PLAIN, preparePattern("\n")); + private FormParamsImpl(Map> params) { + super(params); + } + + FormParamsImpl(FormParams.Builder builder) { + super(builder.params()); + } + private static Pattern preparePattern(String assignmentSeparator) { return Pattern.compile(String.format("([^=]+)=([^%1$s]+)%1$s?", assignmentSeparator)); } @@ -45,18 +53,9 @@ static FormParams create(String paramAssignments, MediaType mediaType) { while (m.find()) { final String key = m.group(1); final String value = m.group(2); - List values = params.compute(key, (k, v) -> { - if (v == null) { - v = new ArrayList<>(); - } - v.add(value); - return v; - }); + params.computeIfAbsent(key, k -> new ArrayList<>()).add(value); } return new FormParamsImpl(params); } - private FormParamsImpl(Map> params) { - super(params); - } } 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 f473ae2ef43..2414ab66809 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 @@ -126,7 +126,9 @@ private void streamUpload(ServerRequest req, ServerResponse res) { }).forEach((part) -> { if ("file[]".equals(part.name())) { final ByteChannel channel = newByteChannel(storage, part.filename()); - Multi.create(part.content()).forEach(chunk -> writeChunk(channel, chunk)); + Multi.create(part.content()) + .forEach(chunk -> writeChunk(channel, chunk)) + .thenAccept(it -> closeChannel(channel)); } }); } @@ -173,6 +175,14 @@ private static void writeChunk(ByteChannel channel, DataChunk chunk) { } } + private void closeChannel(ByteChannel channel) { + try { + channel.close(); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + private static ByteChannel newByteChannel(Path storage, String fname) { try { return Files.newByteChannel(storage.resolve(fname), diff --git a/media/common/src/main/java/io/helidon/media/common/DefaultMediaSupport.java b/media/common/src/main/java/io/helidon/media/common/DefaultMediaSupport.java index 2696ff479af..44741f8ef83 100644 --- a/media/common/src/main/java/io/helidon/media/common/DefaultMediaSupport.java +++ b/media/common/src/main/java/io/helidon/media/common/DefaultMediaSupport.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Objects; +import io.helidon.common.http.FormParams; import io.helidon.common.reactive.RetrySchema; import io.helidon.config.Config; @@ -125,6 +126,24 @@ public static MessageBodyWriter fileWriter() { return FileBodyWriter.create(); } + /** + * Return {@link FormParams} writer instance. + * + * @return {@link FormParams} writer + */ + public static MessageBodyWriter formParamWriter() { + return FormParamsBodyWriter.create(); + } + + /** + * Return {@link FormParams} reader instance. + * + * @return {@link FormParams} reader + */ + public static MessageBodyReader formParamReader() { + return FormParamsBodyReader.create(); + } + /** * Return {@link Throwable} writer instance. * @@ -138,7 +157,8 @@ public static MessageBodyWriter throwableWriter(boolean includeStackT @Override public Collection> readers() { return List.of(stringReader(), - inputStreamReader()); + inputStreamReader(), + formParamReader()); } @Override @@ -147,7 +167,8 @@ public Collection> writers() { byteChannelBodyWriter, pathWriter(), fileWriter(), - throwableBodyWriter); + throwableBodyWriter, + formParamWriter()); } /** diff --git a/media/common/src/main/java/io/helidon/media/common/FormParamsBodyReader.java b/media/common/src/main/java/io/helidon/media/common/FormParamsBodyReader.java new file mode 100644 index 00000000000..717ec8a3563 --- /dev/null +++ b/media/common/src/main/java/io/helidon/media/common/FormParamsBodyReader.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2020 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.media.common; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.Flow; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.helidon.common.GenericType; +import io.helidon.common.http.DataChunk; +import io.helidon.common.http.FormParams; +import io.helidon.common.http.MediaType; +import io.helidon.common.reactive.Single; + +/** + * Message body reader for {@link FormParams}. + */ +class FormParamsBodyReader implements MessageBodyReader { + + private static final FormParamsBodyReader DEFAULT = new FormParamsBodyReader(); + + private static final Map PATTERNS = Map.of( + MediaType.APPLICATION_FORM_URLENCODED, preparePattern("&"), + MediaType.TEXT_PLAIN, preparePattern("\n")); + + private FormParamsBodyReader() { + } + + static FormParamsBodyReader create() { + return DEFAULT; + } + + private static Pattern preparePattern(String assignmentSeparator) { + return Pattern.compile(String.format("([^=]+)=([^%1$s]+)%1$s?", assignmentSeparator)); + } + + @Override + public PredicateResult accept(GenericType type, MessageBodyReaderContext context) { + return context.contentType() + .filter(mediaType -> mediaType == MediaType.APPLICATION_FORM_URLENCODED + || mediaType == MediaType.TEXT_PLAIN) + .map(it -> PredicateResult.supports(FormParams.class, type)) + .orElse(PredicateResult.NOT_SUPPORTED); + } + + @Override + @SuppressWarnings("unchecked") + public Single read(Flow.Publisher publisher, + GenericType type, + MessageBodyReaderContext context) { + MediaType mediaType = context.contentType().orElseThrow(); + Charset charset = mediaType.charset().map(Charset::forName).orElse(StandardCharsets.UTF_8); + + Single result = mediaType.equals(MediaType.APPLICATION_FORM_URLENCODED) + ? ContentReaders.readURLEncodedString(publisher, charset) + : ContentReaders.readString(publisher, charset); + + return (Single) result.map(formStr -> create(formStr, mediaType)); + } + + private FormParams create(String paramAssignments, MediaType mediaType) { + FormParams.Builder builder = FormParams.builder(); + Matcher m = PATTERNS.get(mediaType).matcher(paramAssignments); + while (m.find()) { + final String key = m.group(1); + final String value = m.group(2); + builder.add(key, value); + } + return builder.build(); + } + +} diff --git a/media/common/src/main/java/io/helidon/media/common/FormParamsBodyWriter.java b/media/common/src/main/java/io/helidon/media/common/FormParamsBodyWriter.java new file mode 100644 index 00000000000..44acad97dc1 --- /dev/null +++ b/media/common/src/main/java/io/helidon/media/common/FormParamsBodyWriter.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2020 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.media.common; + +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Flow; +import java.util.function.Function; + +import io.helidon.common.GenericType; +import io.helidon.common.http.DataChunk; +import io.helidon.common.http.FormParams; +import io.helidon.common.http.MediaType; +import io.helidon.common.mapper.Mapper; +import io.helidon.common.reactive.Single; + +/** + * Message body writer for {@link FormParams}. + */ +class FormParamsBodyWriter implements MessageBodyWriter { + + private static final FormParamsBodyWriter DEFAULT = new FormParamsBodyWriter(); + private static final MediaType DEFAULT_FORM_MEDIA_TYPE = MediaType.APPLICATION_FORM_URLENCODED; + + private FormParamsBodyWriter() { + } + + static MessageBodyWriter create() { + return DEFAULT; + } + + @Override + public PredicateResult accept(GenericType type, MessageBodyWriterContext context) { + //User didn't have to set explicit content type. In that case set default and class filters out unsupported types. + return context.contentType() + .or(() -> Optional.of(DEFAULT_FORM_MEDIA_TYPE)) + .filter(mediaType -> mediaType == MediaType.APPLICATION_FORM_URLENCODED + || mediaType == MediaType.TEXT_PLAIN) + .map(it -> PredicateResult.supports(FormParams.class, type)) + .orElse(PredicateResult.NOT_SUPPORTED); + } + + @Override + public Flow.Publisher write(Single single, + GenericType type, + MessageBodyWriterContext context) { + MediaType mediaType = context.contentType().orElseGet(() -> { + context.contentType(DEFAULT_FORM_MEDIA_TYPE); + return DEFAULT_FORM_MEDIA_TYPE; + }); + Charset charset = mediaType.charset().map(Charset::forName).orElse(StandardCharsets.UTF_8); + + return single.flatMap(new FormParamsToChunks(mediaType, charset)); + } + + static final class FormParamsToChunks implements Mapper> { + + private final MediaType mediaType; + private final Charset charset; + + FormParamsToChunks(MediaType mediaType, Charset charset) { + this.mediaType = mediaType; + this.charset = charset; + } + + @Override + public Flow.Publisher map(FormParams formParams) { + return ContentWriters.writeCharSequence(transform(formParams), charset); + } + + private String transform(FormParams formParams) { + char separator = separator(); + Function encoder = encoder(); + StringBuilder result = new StringBuilder(); + for (Map.Entry> entry : formParams.toMap().entrySet()) { + for (String value : entry.getValue()) { + if (result.length() > 0) { + result.append(separator); + } + result.append(encoder.apply(entry.getKey())); + result.append("="); + result.append(encoder.apply(value)); + } + } + return result.toString(); + } + + private char separator() { + if (mediaType == MediaType.TEXT_PLAIN) { + return '\n'; + } else { + return '&'; + } + } + + private Function encoder() { + if (mediaType == MediaType.TEXT_PLAIN) { + return (s) -> s; + } else { + return (s) -> URLEncoder.encode(s, charset); + } + } + + } +} diff --git a/media/multipart/src/main/java/io/helidon/media/multipart/ContentDisposition.java b/media/multipart/src/main/java/io/helidon/media/multipart/ContentDisposition.java index 1d6111d7d58..55d4d4003ff 100644 --- a/media/multipart/src/main/java/io/helidon/media/multipart/ContentDisposition.java +++ b/media/multipart/src/main/java/io/helidon/media/multipart/ContentDisposition.java @@ -15,9 +15,9 @@ */ package io.helidon.media.multipart; -import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; import java.util.Collections; import java.util.HashMap; @@ -112,16 +112,10 @@ public Optional name() { * @return {@code Optional}, never {@code null} */ public Optional filename() { - String filename; - try { - String value = parameters.get(FILENAME_PARAMETER); - if (value != null) { - filename = URLDecoder.decode(value, "UTF-8"); - } else { - filename = null; - } - } catch (UnsupportedEncodingException ex) { - filename = null; + String filename = null; + String value = parameters.get(FILENAME_PARAMETER); + if (value != null) { + filename = URLDecoder.decode(value, StandardCharsets.UTF_8); } return Optional.ofNullable(filename); } @@ -191,7 +185,13 @@ public String toString() { sb.append(";"); sb.append(param.getKey()); sb.append("="); - sb.append(param.getValue()); + if (SIZE_PARAMETER.equals(param.getKey())) { + sb.append(param.getValue()); + } else { + sb.append("\""); + sb.append(param.getValue()); + sb.append("\""); + } } return sb.toString(); } @@ -261,7 +261,7 @@ static ContentDisposition parse(String input) { */ public static final class Builder implements io.helidon.common.Builder { - private String type; + private String type = "form-data"; private final Map params = new HashMap<>(); /** @@ -280,7 +280,7 @@ public Builder type(String type) { * @return this builder */ public Builder name(String name) { - params.put("name", name); + params.put(NAME_PARAMETER, name); return this; } @@ -288,16 +288,9 @@ public Builder name(String name) { * Set the content disposition {@code filename} parameter. * @param filename filename parameter * @return this builder - * @throws IllegalStateException if an - * {@link UnsupportedEncodingException} exception is thrown for - * {@code UTF-8} */ public Builder filename(String filename) { - try { - params.put("filename", URLEncoder.encode(filename, "UTF-8")); - } catch (UnsupportedEncodingException ex) { - throw new IllegalStateException(ex); - } + params.put(FILENAME_PARAMETER, URLEncoder.encode(filename, StandardCharsets.UTF_8)); return this; } @@ -307,7 +300,7 @@ public Builder filename(String filename) { * @return this builder */ public Builder creationDate(ZonedDateTime date) { - params.put("creation-date", date.format(Http.DateTime.RFC_1123_DATE_TIME)); + params.put(CREATION_DATE_PARAMETER, date.format(Http.DateTime.RFC_1123_DATE_TIME)); return this; } @@ -317,7 +310,7 @@ public Builder creationDate(ZonedDateTime date) { * @return this builder */ public Builder modificationDate(ZonedDateTime date) { - params.put("modification-date", date.format(Http.DateTime.RFC_1123_DATE_TIME)); + params.put(MODIFICATION_DATE_PARAMETER, date.format(Http.DateTime.RFC_1123_DATE_TIME)); return this; } @@ -327,7 +320,7 @@ public Builder modificationDate(ZonedDateTime date) { * @return this builder */ public Builder readDate(ZonedDateTime date) { - params.put("read-date", date.format(Http.DateTime.RFC_1123_DATE_TIME)); + params.put(READ_DATE_PARAMETER, date.format(Http.DateTime.RFC_1123_DATE_TIME)); return this; } @@ -337,7 +330,7 @@ public Builder readDate(ZonedDateTime date) { * @return this builder */ public Builder size(long size) { - params.put("size", Long.toString(size)); + params.put(SIZE_PARAMETER, Long.toString(size)); return this; } diff --git a/media/multipart/src/main/java/io/helidon/media/multipart/FileFormParams.java b/media/multipart/src/main/java/io/helidon/media/multipart/FileFormParams.java new file mode 100644 index 00000000000..657e99545c5 --- /dev/null +++ b/media/multipart/src/main/java/io/helidon/media/multipart/FileFormParams.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2020 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.media.multipart; + +import java.nio.file.Path; + +import io.helidon.common.http.FormBuilder; + +/** + * Form object which simplifies sending of multipart forms. + */ +public interface FileFormParams { + + /** + * Create a new builder for {@link FileFormParams}. + * + * @return new builder + */ + static Builder builder() { + return new Builder(); + } + + /** + * Fluent API builder of {@link FileFormParams}. + */ + class Builder implements FormBuilder { + + private final WriteableMultiPart.Builder builder = WriteableMultiPart.builder(); + + private Builder() { + } + + @Override + public FileFormParams build() { + return new FileFormParamsImpl(builder.build().bodyParts()); + } + + @Override + public Builder add(String name, String... values) { + for (String value : values) { + builder.bodyPart(name, value); + } + return this; + } + + /** + * Add file with specific name and filename to the form. + * + * @param name content disposition name + * @param fileName content disposition filename + * @param file file path + * @return update builder instance + */ + public Builder addFile(String name, String fileName, Path file) { + builder.bodyPart(name, fileName, file); + return this; + } + + /** + * Add files with specific name to the form. + * + * Filename parameter is based on an actual name of the file. + * + * @param name content disposition name + * @param files files + * @return update builder instance + */ + public Builder addFile(String name, Path... files) { + builder.bodyPart(name, files); + return this; + } + + } + +} diff --git a/media/multipart/src/main/java/io/helidon/media/multipart/FileFormParamsImpl.java b/media/multipart/src/main/java/io/helidon/media/multipart/FileFormParamsImpl.java new file mode 100644 index 00000000000..e8fcdef16fb --- /dev/null +++ b/media/multipart/src/main/java/io/helidon/media/multipart/FileFormParamsImpl.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2020 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.media.multipart; + +import java.util.List; + +/** + * Implementation of {@link FileFormParams}. + */ +class FileFormParamsImpl extends WriteableMultiPart implements FileFormParams { + + FileFormParamsImpl(List bodyParts) { + super(bodyParts); + } + +} diff --git a/media/multipart/src/main/java/io/helidon/media/multipart/MultiPartBodyWriter.java b/media/multipart/src/main/java/io/helidon/media/multipart/MultiPartBodyWriter.java index 223218c7468..d52068818a2 100644 --- a/media/multipart/src/main/java/io/helidon/media/multipart/MultiPartBodyWriter.java +++ b/media/multipart/src/main/java/io/helidon/media/multipart/MultiPartBodyWriter.java @@ -15,10 +15,12 @@ */ package io.helidon.media.multipart; +import java.util.Optional; import java.util.concurrent.Flow.Publisher; import io.helidon.common.GenericType; import io.helidon.common.http.DataChunk; +import io.helidon.common.http.Http; import io.helidon.common.http.MediaType; import io.helidon.common.mapper.Mapper; import io.helidon.common.reactive.Multi; @@ -44,15 +46,24 @@ private MultiPartBodyWriter(String boundary) { @Override public PredicateResult accept(GenericType type, MessageBodyWriterContext context) { - return PredicateResult.supports(WriteableMultiPart.class, type); + return context.contentType() + .or(() -> Optional.of(MediaType.MULTIPART_FORM_DATA)) + .filter(mediaType -> mediaType == MediaType.MULTIPART_FORM_DATA) + .map(it -> PredicateResult.supports(WriteableMultiPart.class, type)) + .orElse(PredicateResult.NOT_SUPPORTED); } @Override public Publisher write(Single content, GenericType type, MessageBodyWriterContext context) { - - context.contentType(MediaType.MULTIPART_FORM_DATA); + MediaType mediaType = MediaType.MULTIPART_FORM_DATA; + MediaType mediaWithBoundary = MediaType.builder() + .type(mediaType.type()) + .subtype(mediaType.subtype()) + .addParameter("boundary", "\"" + boundary + "\"") + .build(); + context.headers().put(Http.Header.CONTENT_TYPE, mediaWithBoundary.toString()); return content.flatMap(new MultiPartToChunks(boundary, context)); } diff --git a/media/multipart/src/main/java/io/helidon/media/multipart/WriteableBodyPart.java b/media/multipart/src/main/java/io/helidon/media/multipart/WriteableBodyPart.java index 7f20827664a..e96e5c2958c 100644 --- a/media/multipart/src/main/java/io/helidon/media/multipart/WriteableBodyPart.java +++ b/media/multipart/src/main/java/io/helidon/media/multipart/WriteableBodyPart.java @@ -76,6 +76,8 @@ public static final class Builder implements io.helidon.common.Builder> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private String name; + private String fileName; /** * Force the use of {@link WriteableBodyPartHeaders#builder() }. @@ -114,12 +116,7 @@ private Builder() { * @return this builder */ public Builder header(String name, String value) { - List values = headers.get(name); - if (values == null) { - values = new ArrayList<>(); - headers.put(name, values); - } - values.add(value); + headers.computeIfAbsent(name, k -> new ArrayList<>()).add(value); return this; } @@ -141,9 +138,47 @@ public Builder contentDisposition(ContentDisposition contentDisp) { return header(Http.Header.CONTENT_DISPOSITION, contentDisp.toString()); } + /** + * Name which will be used in {@link ContentDisposition}. + * + * This value will be ignored if an actual instance of {@link ContentDisposition} is set. + * + * @param name content disposition name parameter + * @return this builder + */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Filename which will be used in {@link ContentDisposition}. + * + * This value will be ignored if an actual instance of {@link ContentDisposition} is set. + * + * @param fileName content disposition filename parameter + * @return this builder + */ + public Builder filename(String fileName) { + this.fileName = fileName; + return this; + } + @Override public WriteableBodyPartHeaders build() { + if (!headers.containsKey(Http.Header.CONTENT_DISPOSITION) && name != null) { + ContentDisposition.Builder builder = ContentDisposition.builder().name(this.name); + if (fileName != null) { + builder.filename(fileName); + if (!headers.containsKey(Http.Header.CONTENT_TYPE)) { + contentType(MediaType.APPLICATION_OCTET_STREAM); + } + } + contentDisposition(builder.build()); + } + return new WriteableBodyPartHeaders(headers); } + } } diff --git a/media/multipart/src/main/java/io/helidon/media/multipart/WriteableMultiPart.java b/media/multipart/src/main/java/io/helidon/media/multipart/WriteableMultiPart.java index f6a37d5ae62..5c80b18c6b7 100644 --- a/media/multipart/src/main/java/io/helidon/media/multipart/WriteableMultiPart.java +++ b/media/multipart/src/main/java/io/helidon/media/multipart/WriteableMultiPart.java @@ -15,6 +15,7 @@ */ package io.helidon.media.multipart; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -22,11 +23,11 @@ /** * Writeable multipart entity. */ -public final class WriteableMultiPart implements MultiPart { +public class WriteableMultiPart implements MultiPart { private final List parts; - private WriteableMultiPart(List parts) { + WriteableMultiPart(List parts) { this.parts = parts; } @@ -67,9 +68,10 @@ public static WriteableMultiPart create(Collection entities) /** * Create a new builder instance. + * * @return Builder */ - public static Builder builder(){ + public static Builder builder() { return new Builder(); } @@ -88,6 +90,7 @@ private Builder() { /** * Add a body part. + * * @param bodyPart body part to add * @return this builder instance */ @@ -96,8 +99,59 @@ public Builder bodyPart(WriteableBodyPart bodyPart) { return this; } + /** + * Add a new body part based on the name entity. + * + * @param name body part name + * @param entity body part entity + * @return this builder instance + */ + public Builder bodyPart(String name, Object entity) { + return bodyPart(WriteableBodyPart.builder() + .name(name) + .entity(entity) + .build()); + } + + /** + * Add a new body part based on the name, filename and {@link Path} to the file. + * + * @param name body part name + * @param filename body part filename + * @param file file path + * @return this builder instance + */ + public Builder bodyPart(String name, String filename, Path file) { + bodyPart(WriteableBodyPart.builder() + .name(name) + .filename(filename) + .entity(file) + .build()); + return this; + } + + /** + * Add a new body part based on the name and {@link Path} to the files. + * + * Filename for each file is set as actual file name. + * + * @param name body part name + * @param files file path + * @return this builder instance + */ + public Builder bodyPart(String name, Path... files) { + for (Path file : files) { + Path fileName = file.getFileName(); + if (fileName != null) { + bodyPart(name, fileName.toString(), file); + } + } + return this; + } + /** * Add body parts. + * * @param bodyParts body parts to add * @return this builder instance */ diff --git a/media/multipart/src/test/java/io/helidon/media/multipart/ContentDispositionTest.java b/media/multipart/src/test/java/io/helidon/media/multipart/ContentDispositionTest.java index b414c6e489d..5094d21dae4 100644 --- a/media/multipart/src/test/java/io/helidon/media/multipart/ContentDispositionTest.java +++ b/media/multipart/src/test/java/io/helidon/media/multipart/ContentDispositionTest.java @@ -17,6 +17,9 @@ import java.time.ZoneId; import java.time.ZonedDateTime; + +import io.helidon.common.http.Http; + import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.equalTo; @@ -206,4 +209,41 @@ public void testBuilderWithCustomParam() { assertThat(cd.type(), is(equalTo("inline"))); assertThat(cd.parameters().get("foo"), is(equalTo("bar"))); } + + @Test + public void testContentDispositionDefault(){ + ContentDisposition cd = ContentDisposition.builder().build(); + assertThat(cd.type(), is(equalTo("form-data"))); + assertThat(cd.parameters().size(), is(0)); + } + + @Test + public void testQuotes(){ + String template = "form-data;" + + "filename=\"file.txt\";" + + "size=300;" + + "name=\"someName\""; + ContentDisposition cd = ContentDisposition.builder() + .name("someName") + .filename("file.txt") + .size(300) + .build(); + assertThat(cd.toString(), is(equalTo(template))); + } + + @Test + public void testDateQuotes() { + ZonedDateTime zonedDateTime = ZonedDateTime.now(); + String date = zonedDateTime.format(Http.DateTime.RFC_1123_DATE_TIME); + String template = "form-data;" + + "creation-date=\"" + date + "\";" + + "modification-date=\"" + date + "\";" + + "read-date=\"" + date + "\""; + ContentDisposition cd = ContentDisposition.builder() + .creationDate(zonedDateTime) + .readDate(zonedDateTime) + .modificationDate(zonedDateTime) + .build(); + assertThat(cd.toString(), is(equalTo(template))); + } } diff --git a/tests/integration/webclient/src/main/java/io/helidon/tests/integration/webclient/GreetService.java b/tests/integration/webclient/src/main/java/io/helidon/tests/integration/webclient/GreetService.java index f493a71f57b..9719da4f60d 100644 --- a/tests/integration/webclient/src/main/java/io/helidon/tests/integration/webclient/GreetService.java +++ b/tests/integration/webclient/src/main/java/io/helidon/tests/integration/webclient/GreetService.java @@ -27,6 +27,7 @@ import javax.json.JsonException; import javax.json.JsonObject; +import io.helidon.common.http.FormParams; import io.helidon.common.http.Http; import io.helidon.config.Config; import io.helidon.security.SecurityContext; @@ -78,6 +79,7 @@ public void update(Routing.Rules rules) { .get("/redirect", this::redirect) .get("/redirectPath", this::redirectPath) .get("/redirect/infinite", this::redirectInfinite) + .post("/form", this::form) .get("/secure/basic", this::basicAuth) .get("/secure/basic/outbound", this::basicAuthOutbound) .put("/greeting", this::updateGreetingHandler); @@ -147,6 +149,12 @@ private void redirectInfinite(ServerRequest serverRequest, ServerResponse respon response.status(Http.Status.MOVED_PERMANENTLY_301).send(); } + private void form(ServerRequest req, ServerResponse res) { + req.content().as(FormParams.class) + .thenApply(form -> "Hi " + form.first("name").orElse("unknown")) + .thenAccept(res::send); + } + /** * Set the greeting to use in future messages. * diff --git a/tests/integration/webclient/src/test/java/io/helidon/tests/integration/webclient/FormTest.java b/tests/integration/webclient/src/test/java/io/helidon/tests/integration/webclient/FormTest.java new file mode 100644 index 00000000000..220a4237098 --- /dev/null +++ b/tests/integration/webclient/src/test/java/io/helidon/tests/integration/webclient/FormTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2020 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.tests.integration.webclient; + +import io.helidon.common.http.FormParams; +import io.helidon.common.http.MediaType; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests if client-server form sending. + */ +public class FormTest extends TestParent { + + private static final FormParams TEST_FORM = FormParams.builder() + .add("name", "David Tester") + .build(); + + @Test + public void testHelloWorld() { + webClient.post() + .path("/form") + .submit(TEST_FORM, String.class) + .thenAccept(resp -> assertThat(resp, is("Hi David Tester"))) + .await(); + } + + @Test + public void testSpecificContentType() { + webClient.post() + .path("/form") + .contentType(MediaType.TEXT_PLAIN) + .submit(TEST_FORM, String.class) + .thenAccept(resp -> assertThat(resp, is("Hi David Tester"))) + .await(); + } + + @Test + public void testSpecificContentTypeIncorrect() { + Exception ex = assertThrows(IllegalStateException.class, () -> webClient.post() + .path("/form") + .contentType(MediaType.APPLICATION_ATOM_XML) + .submit(TEST_FORM).await()); + + assertThat(ex.getCause().getMessage(), + is("No writer found for type: class io.helidon.common.http.FormParamsImpl")); + } +} diff --git a/webclient/webclient/src/main/java/io/helidon/webclient/NettyClientHandler.java b/webclient/webclient/src/main/java/io/helidon/webclient/NettyClientHandler.java index 4dcc050e2c2..45d896b114f 100644 --- a/webclient/webclient/src/main/java/io/helidon/webclient/NettyClientHandler.java +++ b/webclient/webclient/src/main/java/io/helidon/webclient/NettyClientHandler.java @@ -115,9 +115,12 @@ protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws IO for (HttpInterceptor interceptor : HTTP_INTERCEPTORS) { if (interceptor.shouldIntercept(response.status(), requestConfiguration)) { - interceptor.handleInterception(response, clientRequest, ctx.channel().attr(RESULT).get()); - if (!interceptor.continueAfterInterception()) { + boolean continueAfter = !interceptor.continueAfterInterception(); + if (continueAfter) { responseCloser.close().thenAccept(future -> LOGGER.finest(() -> "Response closed due to redirection")); + } + interceptor.handleInterception(response, clientRequest, ctx.channel().attr(RESULT).get()); + if (continueAfter) { return; } } diff --git a/webclient/webclient/src/main/java/io/helidon/webclient/RedirectInterceptor.java b/webclient/webclient/src/main/java/io/helidon/webclient/RedirectInterceptor.java index 995d0cf8883..c5b33c9d796 100644 --- a/webclient/webclient/src/main/java/io/helidon/webclient/RedirectInterceptor.java +++ b/webclient/webclient/src/main/java/io/helidon/webclient/RedirectInterceptor.java @@ -36,9 +36,6 @@ class RedirectInterceptor implements HttpInterceptor { public void handleInterception(HttpResponse httpResponse, WebClientRequestImpl clientRequest, CompletableFuture responseFuture) { - if (clientRequest.method() != Http.Method.GET) { - throw new WebClientException("Redirecting is currently supported only for GET method."); - } if (httpResponse.headers().contains(Http.Header.LOCATION)) { String newUri = httpResponse.headers().get(Http.Header.LOCATION); LOGGER.fine(() -> "Redirecting to " + newUri); diff --git a/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java b/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java index f76c2fd2a42..a6e9bb5634c 100644 --- a/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java +++ b/webclient/webclient/src/main/java/io/helidon/webclient/WebClientRequestBuilderImpl.java @@ -174,7 +174,7 @@ public static WebClientRequestBuilder create(LazyValue eventG static WebClientRequestBuilder create(WebClientRequestImpl clientRequest) { WebClientRequestBuilderImpl builder = new WebClientRequestBuilderImpl(NettyClient.eventGroup(), clientRequest.configuration(), - clientRequest.method()); + Http.Method.GET); builder.headers(clientRequest.headers()); builder.queryParams(clientRequest.queryParams()); builder.uri = clientRequest.uri(); @@ -349,6 +349,7 @@ public WebClientRequestBuilder path(String path) { @Override public WebClientRequestBuilder contentType(MediaType contentType) { this.headers.contentType(contentType); + this.writerContext.contentType(contentType); return this; } diff --git a/webclient/webclient/src/main/java/module-info.java b/webclient/webclient/src/main/java/module-info.java index 8f53d17f3e7..699b431f256 100644 --- a/webclient/webclient/src/main/java/module-info.java +++ b/webclient/webclient/src/main/java/module-info.java @@ -14,8 +14,6 @@ * limitations under the License. */ -import io.helidon.webclient.spi.WebClientServiceProvider; - /** * Helidon WebClient. */ diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/FormParamsSupport.java b/webserver/webserver/src/main/java/io/helidon/webserver/FormParamsSupport.java index 7880f9cda20..8884d7c78fe 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/FormParamsSupport.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/FormParamsSupport.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2019, 2020 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. @@ -24,6 +24,7 @@ import io.helidon.common.http.MediaType; import io.helidon.common.reactive.Single; import io.helidon.media.common.ContentReaders; +import io.helidon.media.common.DefaultMediaSupport; /** * Provides support for form parameters in requests, adding a reader for URL-encoded text @@ -42,7 +43,9 @@ * } * and use all the methods defined on {@link FormParams} (which extends * {@link io.helidon.common.http.Parameters}). + * @deprecated use {@link DefaultMediaSupport#formParamReader()} instead */ +@Deprecated(since = "2.0.2") public class FormParamsSupport implements Service, Handler { private static final FormParamsSupport INSTANCE = new FormParamsSupport(); diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/FormParamsSupportTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/FormParamsSupportTest.java index 396ad439bca..014781091a6 100644 --- a/webserver/webserver/src/test/java/io/helidon/webserver/FormParamsSupportTest.java +++ b/webserver/webserver/src/test/java/io/helidon/webserver/FormParamsSupportTest.java @@ -41,7 +41,6 @@ public class FormParamsSupportTest { @BeforeAll public static void startup() throws InterruptedException, ExecutionException, TimeoutException { testServer = WebServer.create(Routing.builder() - .register(FormParamsSupport.create()) .put("/params", (req, resp) -> { req.content().as(FormParams.class).thenAccept(fp -> resp.send(fp.toMap().toString()));