Media support for forms improved (#2144)

* Media support for forms improved

Signed-off-by: David Kral <david.k.kral@oracle.com>

* Sending multipart forms from client is now supported

Signed-off-by: David Kral <david.k.kral@oracle.com>

* checkstyle fix

Signed-off-by: David Kral <david.k.kral@oracle.com>

* spotbugs fix

Signed-off-by: David Kral <david.k.kral@oracle.com>
This commit is contained in:
David Král
2020-07-20 22:12:08 +02:00
committed by GitHub
parent 545f8d8b64
commit 57008e4e09
23 changed files with 743 additions and 63 deletions

View File

@@ -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 <B> type of the builder
* @param <T> type which the builder builds
*/
public interface FormBuilder<B, T> extends Builder<T> {
/**
* 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);
}

View File

@@ -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<Builder, FormParams> {
private final Map<String, List<String>> 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<String, List<String>> params() {
return params;
}
}
}

View File

@@ -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<String, List<String>> 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 @@ class FormParamsImpl extends ReadOnlyParameters implements FormParams {
while (m.find()) {
final String key = m.group(1);
final String value = m.group(2);
List<String> 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<String, List<String>> params) {
super(params);
}
}

View File

@@ -126,7 +126,9 @@ public final class FileService implements Service {
}).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 @@ public final class FileService implements Service {
}
}
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),

View File

@@ -23,6 +23,7 @@ import java.util.Collection;
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 class DefaultMediaSupport implements MediaSupport {
return FileBodyWriter.create();
}
/**
* Return {@link FormParams} writer instance.
*
* @return {@link FormParams} writer
*/
public static MessageBodyWriter<FormParams> formParamWriter() {
return FormParamsBodyWriter.create();
}
/**
* Return {@link FormParams} reader instance.
*
* @return {@link FormParams} reader
*/
public static MessageBodyReader<FormParams> formParamReader() {
return FormParamsBodyReader.create();
}
/**
* Return {@link Throwable} writer instance.
*
@@ -138,7 +157,8 @@ public class DefaultMediaSupport implements MediaSupport {
@Override
public Collection<MessageBodyReader<?>> readers() {
return List.of(stringReader(),
inputStreamReader());
inputStreamReader(),
formParamReader());
}
@Override
@@ -147,7 +167,8 @@ public class DefaultMediaSupport implements MediaSupport {
byteChannelBodyWriter,
pathWriter(),
fileWriter(),
throwableBodyWriter);
throwableBodyWriter,
formParamWriter());
}
/**

View File

@@ -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<FormParams> {
private static final FormParamsBodyReader DEFAULT = new FormParamsBodyReader();
private static final Map<MediaType, Pattern> 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 <U extends FormParams> Single<U> read(Flow.Publisher<DataChunk> publisher,
GenericType<U> type,
MessageBodyReaderContext context) {
MediaType mediaType = context.contentType().orElseThrow();
Charset charset = mediaType.charset().map(Charset::forName).orElse(StandardCharsets.UTF_8);
Single<String> result = mediaType.equals(MediaType.APPLICATION_FORM_URLENCODED)
? ContentReaders.readURLEncodedString(publisher, charset)
: ContentReaders.readString(publisher, charset);
return (Single<U>) 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();
}
}

View File

@@ -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<FormParams> {
private static final FormParamsBodyWriter DEFAULT = new FormParamsBodyWriter();
private static final MediaType DEFAULT_FORM_MEDIA_TYPE = MediaType.APPLICATION_FORM_URLENCODED;
private FormParamsBodyWriter() {
}
static MessageBodyWriter<FormParams> 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<DataChunk> write(Single<? extends FormParams> single,
GenericType<? extends FormParams> 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<FormParams, Flow.Publisher<DataChunk>> {
private final MediaType mediaType;
private final Charset charset;
FormParamsToChunks(MediaType mediaType, Charset charset) {
this.mediaType = mediaType;
this.charset = charset;
}
@Override
public Flow.Publisher<DataChunk> map(FormParams formParams) {
return ContentWriters.writeCharSequence(transform(formParams), charset);
}
private String transform(FormParams formParams) {
char separator = separator();
Function<String, String> encoder = encoder();
StringBuilder result = new StringBuilder();
for (Map.Entry<String, List<String>> 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<String, String> encoder() {
if (mediaType == MediaType.TEXT_PLAIN) {
return (s) -> s;
} else {
return (s) -> URLEncoder.encode(s, charset);
}
}
}
}

View File

@@ -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 final class ContentDisposition {
* @return {@code Optional<String>}, never {@code null}
*/
public Optional<String> 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 final class ContentDisposition {
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 @@ public final class ContentDisposition {
*/
public static final class Builder implements io.helidon.common.Builder<ContentDisposition> {
private String type;
private String type = "form-data";
private final Map<String, String> params = new HashMap<>();
/**
@@ -280,7 +280,7 @@ public final class ContentDisposition {
* @return this builder
*/
public Builder name(String name) {
params.put("name", name);
params.put(NAME_PARAMETER, name);
return this;
}
@@ -288,16 +288,9 @@ public final class ContentDisposition {
* 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 final class ContentDisposition {
* @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 final class ContentDisposition {
* @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 final class ContentDisposition {
* @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 final class ContentDisposition {
* @return this builder
*/
public Builder size(long size) {
params.put("size", Long.toString(size));
params.put(SIZE_PARAMETER, Long.toString(size));
return this;
}

View File

@@ -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<Builder, FileFormParams> {
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;
}
}
}

View File

@@ -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<WriteableBodyPart> bodyParts) {
super(bodyParts);
}
}

View File

@@ -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 @@ public final class MultiPartBodyWriter implements MessageBodyWriter<WriteableMul
@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<DataChunk> write(Single<? extends WriteableMultiPart> content,
GenericType<? extends WriteableMultiPart> 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));
}

View File

@@ -76,6 +76,8 @@ public final class WriteableBodyPart implements BodyPart {
private WriteableBodyPartHeaders headers;
private WriteableBodyPartContent content;
private String name;
private String fileName;
private Builder() {
headers = WriteableBodyPartHeaders.create();
@@ -125,8 +127,40 @@ public final class WriteableBodyPart implements BodyPart {
return this;
}
/**
* Name which will be used in {@link ContentDisposition}.
*
* This value will be ignored if an actual instance of {@link WriteableBodyPartHeaders} is set.
*
* @param name content disposition name parameter
* @return this builder instance
*/
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 WriteableBodyPartHeaders} is set.
*
* @param fileName content disposition filename parameter
* @return this builder instance
*/
public Builder filename(String fileName) {
this.fileName = fileName;
return this;
}
@Override
public WriteableBodyPart build() {
if (headers.toMap().size() == 0 && name != null) {
headers = WriteableBodyPartHeaders.builder()
.name(name)
.filename(fileName)
.build();
}
return new WriteableBodyPart(content, headers);
}
}

View File

@@ -99,6 +99,8 @@ public final class WriteableBodyPartHeaders extends HashParameters implements Bo
* The headers map.
*/
private final Map<String, List<String>> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
private String name;
private String fileName;
/**
* Force the use of {@link WriteableBodyPartHeaders#builder() }.
@@ -114,12 +116,7 @@ public final class WriteableBodyPartHeaders extends HashParameters implements Bo
* @return this builder
*/
public Builder header(String name, String value) {
List<String> 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 final class WriteableBodyPartHeaders extends HashParameters implements Bo
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);
}
}
}

View File

@@ -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 @@ import java.util.List;
/**
* Writeable multipart entity.
*/
public final class WriteableMultiPart implements MultiPart<WriteableBodyPart> {
public class WriteableMultiPart implements MultiPart<WriteableBodyPart> {
private final List<WriteableBodyPart> parts;
private WriteableMultiPart(List<WriteableBodyPart> parts) {
WriteableMultiPart(List<WriteableBodyPart> parts) {
this.parts = parts;
}
@@ -67,9 +68,10 @@ public final class WriteableMultiPart implements MultiPart<WriteableBodyPart> {
/**
* Create a new builder instance.
*
* @return Builder
*/
public static Builder builder(){
public static Builder builder() {
return new Builder();
}
@@ -88,6 +90,7 @@ public final class WriteableMultiPart implements MultiPart<WriteableBodyPart> {
/**
* Add a body part.
*
* @param bodyPart body part to add
* @return this builder instance
*/
@@ -96,8 +99,59 @@ public final class WriteableMultiPart implements MultiPart<WriteableBodyPart> {
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
*/

View File

@@ -17,6 +17,9 @@ package io.helidon.media.multipart;
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 class ContentDispositionTest {
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)));
}
}

View File

@@ -27,6 +27,7 @@ import javax.json.JsonBuilderFactory;
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 class GreetService implements Service {
.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 @@ public class GreetService implements Service {
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.
*

View File

@@ -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"));
}
}

View File

@@ -115,9 +115,12 @@ class NettyClientHandler extends SimpleChannelInboundHandler<HttpObject> {
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;
}
}

View File

@@ -36,9 +36,6 @@ class RedirectInterceptor implements HttpInterceptor {
public void handleInterception(HttpResponse httpResponse,
WebClientRequestImpl clientRequest,
CompletableFuture<WebClientResponse> 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);

View File

@@ -174,7 +174,7 @@ class WebClientRequestBuilderImpl implements WebClientRequestBuilder {
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 @@ class WebClientRequestBuilderImpl implements WebClientRequestBuilder {
@Override
public WebClientRequestBuilder contentType(MediaType contentType) {
this.headers.contentType(contentType);
this.writerContext.contentType(contentType);
return this;
}

View File

@@ -14,8 +14,6 @@
* limitations under the License.
*/
import io.helidon.webclient.spi.WebClientServiceProvider;
/**
* Helidon WebClient.
*/

View File

@@ -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.FormParams;
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 @@ import io.helidon.media.common.ContentReaders;
* }</pre>
* 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();

View File

@@ -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()));