mirror of
https://github.com/jlengrand/helidon.git
synced 2026-03-10 08:21:17 +00:00
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:
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import io.helidon.webclient.spi.WebClientServiceProvider;
|
||||
|
||||
/**
|
||||
* Helidon WebClient.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user