Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for form parameters #1093

Merged
merged 3 commits into from
Oct 10, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions common/http/src/main/java/io/helidon/common/http/FormParams.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved.
*
* 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;

/**
* Provides access to any form parameters present in the request entity.
*/
public interface FormParams extends Parameters {

/**
* Creates a new {@code FormParams} instance using the provided assignment string and media
* type.
*
* @param paramAssignments String containing the parameter assignments, formatted according
* to the media type specified ({@literal &} separator for
* URL-encoded, NL for text/plain)
* @param mediaType MediaType for which the parameter conversion is occurring
* @return the new {@code FormParams} instance
*/
static FormParams create(String paramAssignments, MediaType mediaType) {
return FormParamsImpl.create(paramAssignments, mediaType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved.
*
* 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 java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import io.helidon.common.CollectionsHelper;

/**
* Implementation of the {@link FormParams} interface.
*/
class FormParamsImpl extends ReadOnlyParameters implements FormParams {

/*
* For form params represented in text/plain (uncommon), newlines appear between name=value
* assignments. When urlencoded, ampersands separate the name=value assignments.
*/
private static final Map<MediaType, Pattern> PATTERNS = CollectionsHelper.mapOf(
MediaType.APPLICATION_FORM_URLENCODED, preparePattern("&"),
MediaType.TEXT_PLAIN, preparePattern("\n"));

private static Pattern preparePattern(String assignmentSeparator) {
return Pattern.compile(String.format("([^=]+)=([^%1$s]+)%1$s?", assignmentSeparator));
}

static FormParams create(String paramAssignments, MediaType mediaType) {
final Map<String, List<String>> params = new HashMap<>();
Matcher m = PATTERNS.get(mediaType).matcher(paramAssignments);
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;
});
}
return new FormParamsImpl(params);
}

private FormParamsImpl(Map<String, List<String>> params) {
super(params);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved.
*
* 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.CollectionsHelper;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;


import java.util.List;
import java.util.Optional;

public class FormParamsTest {

private static final String KEY1 = "key1";
private static final String VAL1 = "value1";

private static final String KEY2 = "key2";
private static final String VAL2_1 = "value2.1";
private static final String VAL2_2 = "value2.2";


@Test
void testOneLineSingleAssignment() {
FormParams fp = FormParams.create(KEY1 + "=" + VAL1, MediaType.TEXT_PLAIN);

checkKey1(fp);
}

@Test
void testTwoDifferentAssignments() {
String assignments = String.format("%s=%s\n%s=%s\n%s=%s", KEY1, VAL1,
KEY2, VAL2_1, KEY2, VAL2_2);

FormParams fp = FormParams.create(assignments, MediaType.TEXT_PLAIN);

checkKey1(fp);
checkKey2(fp);
}

@Test
void testTwoDifferentAssignmentsURLEncoded() {
String assignments = String.format("%s=%s&%s=%s&%s=%s", KEY1, VAL1, KEY2, VAL2_1, KEY2, VAL2_2);

FormParams fp = FormParams.create(assignments, MediaType.APPLICATION_FORM_URLENCODED);

checkKey1(fp);
checkKey2(fp);
}

@Test
void testAbsentKey() {
FormParams fp = FormParams.create(KEY1 + "=" + VAL1, MediaType.TEXT_PLAIN);

Optional<String> shouldNotExist = fp.first(KEY2);
assertThat(shouldNotExist.isPresent(), is(false));

List<String> shouldBeEmpty = fp.all(KEY2);
assertThat(shouldBeEmpty.size(), is(0));

assertThrows(UnsupportedOperationException.class, () -> fp.computeSingleIfAbsent(KEY2, k -> "replacement"));
}

private static void checkKey1(FormParams fp) {
Optional<String> result = fp.first(KEY1);
assertThat(result.orElse("missing"), is(equalTo(VAL1)));

List<String> listResult = fp.all(KEY1);
assertThat(listResult, hasItem(VAL1));
assertThat(listResult.size(), is(1));
}

private static void checkKey2(FormParams fp) {
Optional<String> result = fp.first(KEY2);
assertThat(result.orElse("missing"), is(equalTo(VAL2_1)));

List<String> listResult = fp.all(KEY2);
assertThat(listResult, hasItems(VAL2_1, VAL2_2));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.util.concurrent.CompletableFuture;

Expand Down Expand Up @@ -64,6 +66,18 @@ public static Single<String> readString(Publisher<DataChunk> chunks, Charset cha
return readBytes(chunks).map(new BytesToString(charset));
}

/**
* Convert the publisher of {@link DataChunk} into a {@link String} processed through URL
* decoding.
* @param chunks source publisher
* @param charset charset to use for decoding the input
* @return Single
*/
public static Single<String> readURLEncodedString(Publisher<DataChunk> chunks,
Charset charset) {
return readString(chunks, charset).map(new StringToDecodedString(charset));
}

/**
* Get a reader that converts a {@link DataChunk} publisher to a
* {@link String}.
Expand All @@ -75,6 +89,17 @@ public static Reader<String> stringReader(Charset charset) {
return (chunks, type) -> readString(chunks, charset).toStage();
}

/**
* Gets a reader that converts a {@link DataChunk} publisher to a {@link String} processed
* through URL decoding.
*
* @param charset the charset to use with the returned string content reader
* @return the URL-decoded string content reader
*/
public static Reader<String> urlEncodedStringReader(Charset charset) {
return (chunks, type) -> readURLEncodedString(chunks, charset).toStage();
}

/**
* Get a reader that converts a {@link DataChunk} publisher to an array of
* bytes.
Expand Down Expand Up @@ -118,6 +143,30 @@ public String map(byte[] bytes) {
}
}

/**
* Mapper that applies URL decoding to a {@code String}.
*/
private static final class StringToDecodedString implements Mapper<String, String> {

private final Charset charset;

StringToDecodedString(Charset charset) {
this.charset = charset;
}

@Override
public String map(String s) {
try {
return URLDecoder.decode(s, charset.name());
} catch (UnsupportedEncodingException e) {
/*
* Convert the encoding exception into an unchecked one to simplify the mapper's use
* in lambdas.
*/
throw new RuntimeException(e);
}
}
}
/**
* Implementation of {@link Collector} that collects chunks into a single
* {@code byte[]}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package io.helidon.media.common;

import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
Expand Down Expand Up @@ -74,4 +75,19 @@ void test() throws Exception {
byte[] actualBytes = InputStreamHelper.readAllBytes(inputStream);
assertThat(actualBytes, is(bytes));
}

@Test
void testURLDecodingReader() throws Exception {
String original = "myParam=\"Now@is'the/time";
String encoded = URLEncoder.encode(original, "UTF-8");
Multi<DataChunk> chunks = Multi.just(DataChunk.create(encoded.getBytes(StandardCharsets.UTF_8)));

CompletableFuture<? extends String> future =
ContentReaders.urlEncodedStringReader(StandardCharsets.UTF_8)
.apply(chunks)
.toCompletableFuture();

String s = future.get(10, TimeUnit.SECONDS);
assertThat(s, is(original));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2019 Oracle and/or its affiliates. All rights reserved.
*
* 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.webserver;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

import io.helidon.common.http.DataChunk;
import io.helidon.common.http.FormParams;
import io.helidon.common.http.MediaType;
import io.helidon.common.reactive.Flow.Publisher;
import io.helidon.common.reactive.Single;
import io.helidon.media.common.ContentReaders;

/**
* Provides support for form parameters in requests, adding a reader for URL-encoded text
* (if the request's media type so indicates) and also adding a reader for {@code FormParams}.
* <p>
* Developers will typically add this support to routing:
* <pre>{@code
* Routing.builder()
* .register(FormParamSupport.create())
* . ... // any other handlers
* }</pre>
* <p>
* When responding to a request, the developer can use
* <pre>{@code
* request.content().as(FormParams.class).thenApply(fp -> ...)
* }</pre>
* and use all the methods defined on {@link FormParams} (which extends
* {@link io.helidon.common.http.Parameters}).
*/
public class FormParamsSupport implements Service, Handler {

private static final FormParamsSupport INSTANCE = new FormParamsSupport();

@Override
public void update(Routing.Rules rules) {
rules.any(this);
}

@Override
public void accept(ServerRequest req, ServerResponse res) {
MediaType reqMediaType = req.headers().contentType().orElse(MediaType.TEXT_PLAIN);
Charset charset = reqMediaType.charset().map(Charset::forName).orElse(StandardCharsets.UTF_8);

req.content().registerReader(FormParams.class,
(chunks, type) -> readContent(reqMediaType, charset, chunks)
.map(s -> FormParams.create(s, reqMediaType)).toStage());

req.next();
}

/**
*
* @return the singleton instance of {@code FormParamSupport}
*/
public static FormParamsSupport create() {
return INSTANCE;
}

private static Single<String> readContent(MediaType mediaType, Charset charset,
Publisher<DataChunk> chunks) {
return mediaType.equals(MediaType.APPLICATION_FORM_URLENCODED)
? ContentReaders.readURLEncodedString(chunks, charset)
: ContentReaders.readString(chunks, charset);
}
}
1 change: 1 addition & 0 deletions webserver/webserver/src/main/java9/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
requires io.helidon.common;
requires transitive io.helidon.media.common;
requires transitive io.helidon.common.http;
requires io.helidon.common.mapper;
requires transitive io.helidon.common.pki;
requires transitive io.helidon.common.reactive;
requires transitive io.helidon.common.context;
Expand Down
Loading