1 /*
2  * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License").
5  * You may not use this file except in compliance with the License.
6  * A copy of the License is located at
7  *
8  *  http://aws.amazon.com/apache2.0
9  *
10  * or in the "license" file accompanying this file. This file is distributed
11  * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12  * express or implied. See the License for the specific language governing
13  * permissions and limitations under the License.
14  */

15
16 package software.amazon.awssdk.utils.http;
17
18 import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely;
19
20 import java.io.UnsupportedEncodingException;
21 import java.net.URI;
22 import java.net.URLDecoder;
23 import java.net.URLEncoder;
24 import java.util.Collections;
25 import java.util.LinkedHashMap;
26 import java.util.List;
27 import java.util.Map;
28 import java.util.Map.Entry;
29 import java.util.Optional;
30 import java.util.Set;
31 import java.util.function.UnaryOperator;
32 import java.util.stream.Collectors;
33 import java.util.stream.Stream;
34 import software.amazon.awssdk.annotations.SdkProtectedApi;
35 import software.amazon.awssdk.utils.StringUtils;
36 import software.amazon.awssdk.utils.Validate;
37
38 /**
39  * A set of utilities that assist with HTTP message-related interactions.
40  */

41 @SdkProtectedApi
42 public final class SdkHttpUtils {
43     private static final String DEFAULT_ENCODING = "UTF-8";
44
45     /**
46      * Characters that we need to fix up after URLEncoder.encode().
47      */

48     private static final String[] ENCODED_CHARACTERS_WITH_SLASHES = new String[] {"+""*""%7E""%2F"};
49     private static final String[] ENCODED_CHARACTERS_WITH_SLASHES_REPLACEMENTS = new String[] {"%20""%2A""~""/"};
50
51     private static final String[] ENCODED_CHARACTERS_WITHOUT_SLASHES = new String[] {"+""*""%7E"};
52     private static final String[] ENCODED_CHARACTERS_WITHOUT_SLASHES_REPLACEMENTS = new String[] {"%20""%2A""~"};
53
54     // List of headers that may appear only once in a request; i.e. is not a list of values.
55     // Taken from https://github.com/apache/httpcomponents-client/blob/81c1bc4dc3ca5a3134c5c60e8beff08be2fd8792/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/HttpTestUtils.java#L69-L85 with modifications:
56     // removed: accept-ranges, if-match, if-none-match, vary since it looks like they're defined as lists
57     private static final Set<String> SINGLE_HEADERS = Stream.of("age""authorization",
58             "content-length""content-location""content-md5""content-range""content-type",
59             "date""etag""expires""from""host""if-modified-since""if-range",
60             "if-unmodified-since""last-modified""location""max-forwards",
61             "proxy-authorization""range""referer""retry-after""server""user-agent")
62             .collect(Collectors.toSet());
63
64     private SdkHttpUtils() {
65     }
66
67     /**
68      * Encode a string according to RFC 3986: encoding for URI paths, query strings, etc.
69      */

70     public static String urlEncode(String value) {
71         return urlEncode(value, false);
72     }
73
74     /**
75      * Encode a string according to RFC 3986, but ignore "/" characters. This is useful for encoding the components of a path,
76      * without encoding the path separators.
77      */

78     public static String urlEncodeIgnoreSlashes(String value) {
79         return urlEncode(value, true);
80     }
81
82     /**
83      * Encode a string according to RFC 1630: encoding for form data.
84      */

85     public static String formDataEncode(String value) {
86         return value == null ? null : invokeSafely(() -> URLEncoder.encode(value, DEFAULT_ENCODING));
87     }
88
89     /**
90      * Decode the string according to RFC 3986: encoding for URI paths, query strings, etc.
91      * <p>
92      * Assumes the decoded string is UTF-8 encoded.
93      *
94      * @param value The string to decode.
95      * @return The decoded string.
96      */

97     public static String urlDecode(String value) {
98         if (value == null) {
99             return null;
100         }
101         try {
102             return URLDecoder.decode(value, DEFAULT_ENCODING);
103         } catch (UnsupportedEncodingException e) {
104             throw new RuntimeException("Unable to decode value", e);
105         }
106     }
107
108     /**
109      * Encode each of the keys and values in the provided query parameters using {@link #urlEncode(String)}.
110      */

111     public static Map<String, List<String>> encodeQueryParameters(Map<String, List<String>> rawQueryParameters) {
112         return encodeMapOfLists(rawQueryParameters, SdkHttpUtils::urlEncode);
113     }
114
115     /**
116      * Encode each of the keys and values in the provided form data using {@link #formDataEncode(String)}.
117      */

118     public static Map<String, List<String>> encodeFormData(Map<String, List<String>> rawFormData) {
119         return encodeMapOfLists(rawFormData, SdkHttpUtils::formDataEncode);
120     }
121
122     private static Map<String, List<String>> encodeMapOfLists(Map<String, List<String>> map, UnaryOperator<String> encoder) {
123         Validate.notNull(map, "Map must not be null.");
124
125         Map<String, List<String>> result = new LinkedHashMap<>();
126
127         for (Entry<String, List<String>> queryParameter : map.entrySet()) {
128             String key = queryParameter.getKey();
129             String encodedKey = encoder.apply(key);
130
131             List<String> value = queryParameter.getValue();
132             List<String> encodedValue = value == null
133                                         ? null
134                                         : queryParameter.getValue().stream().map(encoder).collect(Collectors.toList());
135
136             result.put(encodedKey, encodedValue);
137         }
138
139         return result;
140     }
141
142     /**
143      * Encode a string for use in the path of a URL; uses URLEncoder.encode,
144      * (which encodes a string for use in the query portion of a URL), then
145      * applies some postfilters to fix things up per the RFC. Can optionally
146      * handle strings which are meant to encode a path (ie include '/'es
147      * which should NOT be escaped).
148      *
149      * @param value the value to encode
150      * @param ignoreSlashes  true if the value is intended to represent a path
151      * @return the encoded value
152      */

153     private static String urlEncode(String value, boolean ignoreSlashes) {
154         if (value == null) {
155             return null;
156         }
157
158         String encoded = invokeSafely(() -> URLEncoder.encode(value, DEFAULT_ENCODING));
159
160         if (!ignoreSlashes) {
161             return StringUtils.replaceEach(encoded,
162                                            ENCODED_CHARACTERS_WITHOUT_SLASHES,
163                                            ENCODED_CHARACTERS_WITHOUT_SLASHES_REPLACEMENTS);
164         }
165
166         return StringUtils.replaceEach(encoded, ENCODED_CHARACTERS_WITH_SLASHES, ENCODED_CHARACTERS_WITH_SLASHES_REPLACEMENTS);
167     }
168
169     /**
170      * Encode the provided query parameters using {@link #encodeQueryParameters(Map)} and then flatten them into a string that
171      * can be used as the query string in a URL. The result is not prepended with "?".
172      */

173     public static Optional<String> encodeAndFlattenQueryParameters(Map<String, List<String>> rawQueryParameters) {
174         return flattenQueryParameters(encodeQueryParameters(rawQueryParameters));
175     }
176
177     /**
178      * Encode the provided form data using {@link #encodeFormData(Map)} and then flatten them into a string that
179      * can be used as the body of a form data request.
180      */

181     public static Optional<String> encodeAndFlattenFormData(Map<String, List<String>> rawFormData) {
182         return flattenQueryParameters(encodeFormData(rawFormData));
183     }
184
185     /**
186      * Flatten the provided query parameters into a string that can be used as the query string in a URL. The result is not
187      * prepended with "?". This is useful when you have already-encoded query parameters you wish to flatten.
188      */

189     public static Optional<String> flattenQueryParameters(Map<String, List<String>> toFlatten) {
190         if (toFlatten.isEmpty()) {
191             return Optional.empty();
192         }
193
194         StringBuilder result = new StringBuilder();
195
196         for (Entry<String, List<String>> encodedQueryParameter : toFlatten.entrySet()) {
197             String key = encodedQueryParameter.getKey();
198
199             List<String> values = Optional.ofNullable(encodedQueryParameter.getValue()).orElseGet(Collections::emptyList);
200
201             for (String value : values) {
202                 if (result.length() > 0) {
203                     result.append('&');
204                 }
205                 result.append(key);
206                 if (value != null) {
207                     result.append('=');
208                     result.append(value);
209                 }
210             }
211         }
212         return Optional.of(result.toString());
213     }
214
215     /**
216      * Returns true if the specified port is the standard port for the given protocol. (i.e. 80 for HTTP or 443 for HTTPS).
217      *
218      * Null or -1 ports (to simplify interaction with {@link URI}'s default value) are treated as standard ports.
219      *
220      * @return True if the specified port is standard for the specified protocol, otherwise false.
221      */

222     public static boolean isUsingStandardPort(String protocol, Integer port) {
223         Validate.paramNotNull(protocol, "protocol");
224         Validate.isTrue(protocol.equals("http") || protocol.equals("https"),
225                         "Protocol must be 'http' or 'https', but was '%s'.", protocol);
226
227         String scheme = StringUtils.lowerCase(protocol);
228
229         return port == null || port == -1 ||
230                (scheme.equals("http") && port == 80) ||
231                (scheme.equals("https") && port == 443);
232     }
233
234     /**
235      * Retrieve the standard port for the provided protocol.
236      */

237     public static int standardPort(String protocol) {
238         if (protocol.equalsIgnoreCase("http")) {
239             return 80;
240         } else if (protocol.equalsIgnoreCase("https")) {
241             return 443;
242         } else {
243             throw new IllegalArgumentException("Unknown protocol: " + protocol);
244         }
245     }
246
247     /**
248      * Append the given path to the given baseUri, separating them with a slash, if required. The result will preserve the
249      * trailing slash of the provided path.
250      */

251     public static String appendUri(String baseUri, String path) {
252         Validate.paramNotNull(baseUri, "baseUri");
253         StringBuilder resultUri = new StringBuilder(baseUri);
254
255         if (!StringUtils.isEmpty(path)) {
256             if (!baseUri.endsWith("/")) {
257                 resultUri.append("/");
258             }
259
260             resultUri.append(path.startsWith("/") ? path.substring(1) : path);
261         }
262
263         return resultUri.toString();
264     }
265
266     /**
267      * Perform a case-insensitive search for a particular header in the provided map of headers.
268      *
269      * @param headers The headers to search.
270      * @param header The header to search for (case insensitively).
271      * @return A stream providing the values for the headers that matched the requested header.
272      */

273     public static Stream<String> allMatchingHeaders(Map<String, List<String>> headers, String header) {
274         return headers.entrySet().stream()
275                       .filter(e -> e.getKey().equalsIgnoreCase(header))
276                       .flatMap(e -> e.getValue() != null ? e.getValue().stream() : Stream.empty());
277     }
278
279     /**
280      * Perform a case-insensitive search for a particular header in the provided map of headers, returning the first matching
281      * header, if one is found.
282      * <br>
283      * This is useful for headers like 'Content-Type' or 'Content-Length' of which there is expected to be only one value present.
284      *
285      * @param headers The headers to search.
286      * @param header The header to search for (case insensitively).
287      * @return The first header that matched the requested one, or empty if one was not found.
288      */

289     public static Optional<String> firstMatchingHeader(Map<String, List<String>> headers, String header) {
290         return allMatchingHeaders(headers, header).findFirst();
291     }
292
293     public static boolean isSingleHeader(String h) {
294         return SINGLE_HEADERS.contains(StringUtils.lowerCase(h));
295     }
296 }
297