View Javadoc
1   /*
2    * ====================================================================
3    * Licensed to the Apache Software Foundation (ASF) under one
4    * or more contributor license agreements.  See the NOTICE file
5    * distributed with this work for additional information
6    * regarding copyright ownership.  The ASF licenses this file
7    * to you under the Apache License, Version 2.0 (the
8    * "License"); you may not use this file except in compliance
9    * with the License.  You may obtain a copy of the License at
10   *
11   *   http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing,
14   * software distributed under the License is distributed on an
15   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16   * KIND, either express or implied.  See the License for the
17   * specific language governing permissions and limitations
18   * under the License.
19   * ====================================================================
20   *
21   * This software consists of voluntary contributions made by many
22   * individuals on behalf of the Apache Software Foundation.  For more
23   * information on the Apache Software Foundation, please see
24   * <http://www.apache.org/>.
25   *
26   */
27  package org.apache.hc.client5.http.impl.cache;
28  
29  import java.net.URI;
30  import java.net.URISyntaxException;
31  import java.nio.charset.StandardCharsets;
32  import java.util.ArrayList;
33  import java.util.Collection;
34  import java.util.Iterator;
35  import java.util.List;
36  import java.util.Locale;
37  import java.util.Spliterator;
38  import java.util.Spliterators;
39  import java.util.concurrent.atomic.AtomicBoolean;
40  import java.util.function.Consumer;
41  import java.util.stream.StreamSupport;
42  
43  import org.apache.hc.client5.http.cache.HttpCacheEntry;
44  import org.apache.hc.core5.annotation.Contract;
45  import org.apache.hc.core5.annotation.Internal;
46  import org.apache.hc.core5.annotation.ThreadingBehavior;
47  import org.apache.hc.core5.function.Resolver;
48  import org.apache.hc.core5.http.Header;
49  import org.apache.hc.core5.http.HeaderElement;
50  import org.apache.hc.core5.http.HttpHeaders;
51  import org.apache.hc.core5.http.HttpHost;
52  import org.apache.hc.core5.http.HttpRequest;
53  import org.apache.hc.core5.http.MessageHeaders;
54  import org.apache.hc.core5.http.NameValuePair;
55  import org.apache.hc.core5.http.URIScheme;
56  import org.apache.hc.core5.http.message.BasicHeaderElementIterator;
57  import org.apache.hc.core5.http.message.BasicHeaderValueFormatter;
58  import org.apache.hc.core5.http.message.BasicNameValuePair;
59  import org.apache.hc.core5.http.message.MessageSupport;
60  import org.apache.hc.core5.net.PercentCodec;
61  import org.apache.hc.core5.net.URIAuthority;
62  import org.apache.hc.core5.net.URIBuilder;
63  import org.apache.hc.core5.util.Args;
64  import org.apache.hc.core5.util.CharArrayBuffer;
65  import org.apache.hc.core5.util.TextUtils;
66  
67  /**
68   * @since 4.1
69   */
70  @Contract(threading = ThreadingBehavior.STATELESS)
71  public class CacheKeyGenerator implements Resolver<URI, String> {
72  
73      /**
74       * Default instance of {@link CacheKeyGenerator}.
75       */
76      public static final CacheKeyGenerator INSTANCE = new CacheKeyGenerator();
77  
78      @Override
79      public String resolve(final URI uri) {
80          return generateKey(uri);
81      }
82  
83      /**
84       * Returns text representation of the request URI of the given {@link HttpRequest}.
85       * This method will use {@link HttpRequest#getPath()}, {@link HttpRequest#getScheme()} and
86       * {@link HttpRequest#getAuthority()} values when available or attributes of target
87       * {@link HttpHost } in order to construct an absolute URI.
88       * <p>
89       * This method will not attempt to ensure validity of the resultant text representation.
90       *
91       * @param target target host
92       * @param request the {@link HttpRequest}
93       *
94       * @return String the request URI
95       */
96      @Internal
97      public static String getRequestUri(final HttpHost target, final HttpRequest request) {
98          Args.notNull(target, "Target");
99          Args.notNull(request, "HTTP request");
100         final StringBuilder buf = new StringBuilder();
101         final URIAuthority authority = request.getAuthority();
102         if (authority != null) {
103             final String scheme = request.getScheme();
104             buf.append(scheme != null ? scheme : URIScheme.HTTP.id).append("://");
105             buf.append(authority.getHostName());
106             if (authority.getPort() >= 0) {
107                 buf.append(":").append(authority.getPort());
108             }
109         } else {
110             buf.append(target.getSchemeName()).append("://");
111             buf.append(target.getHostName());
112             if (target.getPort() >= 0) {
113                 buf.append(":").append(target.getPort());
114             }
115         }
116         final String path = request.getPath();
117         if (path == null) {
118             buf.append("/");
119         } else {
120             if (buf.length() > 0 && !path.startsWith("/")) {
121                 buf.append("/");
122             }
123             buf.append(path);
124         }
125         return buf.toString();
126     }
127 
128     /**
129      * Returns normalized representation of the request URI optimized for use as a cache key.
130      * This method ensures the resultant URI has an explicit port in the authority component,
131      * and explicit path component and no fragment.
132      */
133     @Internal
134     public static URI normalize(final URI requestUri) throws URISyntaxException {
135         Args.notNull(requestUri, "URI");
136         final URIBuilder builder = new URIBuilder(requestUri);
137         if (builder.getHost() != null) {
138             if (builder.getScheme() == null) {
139                 builder.setScheme(URIScheme.HTTP.id);
140             }
141             if (builder.getPort() <= -1) {
142                 if (URIScheme.HTTP.same(builder.getScheme())) {
143                     builder.setPort(80);
144                 } else if (URIScheme.HTTPS.same(builder.getScheme())) {
145                     builder.setPort(443);
146                 }
147             }
148         }
149         builder.setFragment(null);
150         return builder.optimize().build();
151     }
152 
153     /**
154      * Lenient URI parser that normalizes valid {@link URI}s and returns {@code null} for malformed URIs.
155      */
156     @Internal
157     public static URI normalize(final String requestUri) {
158         if (requestUri == null) {
159             return null;
160         }
161         try {
162             return CacheKeyGenerator.normalize(new URI(requestUri));
163         } catch (final URISyntaxException ex) {
164             return null;
165         }
166     }
167 
168     /**
169      * Computes a key for the given request {@link URI} that can be used as
170      * a unique identifier for cached resources. The URI is expected to
171      * in an absolute form.
172      *
173      * @param requestUri request URI
174      * @return cache key
175      */
176     public String generateKey(final URI requestUri) {
177         try {
178             final URI normalizeRequestUri = normalize(requestUri);
179             return normalizeRequestUri.toASCIIString();
180         } catch (final URISyntaxException ex) {
181             return requestUri.toASCIIString();
182         }
183     }
184 
185     /**
186      * Computes a root key for the given {@link HttpHost} and {@link HttpRequest}
187      * that can be used as a unique identifier for cached resources.
188      *
189      * @param host The host for this request
190      * @param request the {@link HttpRequest}
191      * @return cache key
192      */
193     public String generateKey(final HttpHost host, final HttpRequest request) {
194         final String s = getRequestUri(host, request);
195         try {
196             return generateKey(new URI(s));
197         } catch (final URISyntaxException ex) {
198             return s;
199         }
200     }
201 
202     /**
203      * Returns all variant names contained in {@literal VARY} headers of the given message.
204      *
205      * @since 5.4
206      */
207     public static List<String> variantNames(final MessageHeaders message) {
208         if (message == null) {
209             return null;
210         }
211         final List<String> names = new ArrayList<>();
212         for (final Iterator<Header> it = message.headerIterator(HttpHeaders.VARY); it.hasNext(); ) {
213             final Header header = it.next();
214             MessageSupport.parseTokens(header, names::add);
215         }
216         return names;
217     }
218 
219     @Internal
220     public static void normalizeElements(final MessageHeaders message, final String headerName, final Consumer<String> consumer) {
221         // User-Agent as a special case due to its grammar
222         if (headerName.equalsIgnoreCase(HttpHeaders.USER_AGENT)) {
223             final Header header = message.getFirstHeader(headerName);
224             if (header != null) {
225                 consumer.accept(header.getValue().toLowerCase(Locale.ROOT));
226             }
227         } else {
228             normalizeElements(message.headerIterator(headerName), consumer);
229         }
230     }
231 
232     @Internal
233     public static void normalizeElements(final Iterator<Header> iterator, final Consumer<String> consumer) {
234         final Iterator<HeaderElement> it = new BasicHeaderElementIterator(iterator);
235         StreamSupport.stream(Spliterators.spliteratorUnknownSize(it, Spliterator.NONNULL), false)
236                 .filter(e -> !TextUtils.isBlank(e.getName()))
237                 .map(e -> {
238                     if (e.getValue() == null && e.getParameterCount() == 0) {
239                         return e.getName().toLowerCase(Locale.ROOT);
240                     } else {
241                         final CharArrayBuffer buf = new CharArrayBuffer(1024);
242                         BasicHeaderValueFormatter.INSTANCE.formatNameValuePair(
243                                 buf,
244                                 new BasicNameValuePair(
245                                     e.getName().toLowerCase(Locale.ROOT),
246                                     !TextUtils.isBlank(e.getValue()) ? e.getValue() : null),
247                                 false);
248                         if (e.getParameterCount() > 0) {
249                             for (final NameValuePair nvp : e.getParameters()) {
250                                 if (!TextUtils.isBlank(nvp.getName())) {
251                                     buf.append(';');
252                                     BasicHeaderValueFormatter.INSTANCE.formatNameValuePair(
253                                             buf,
254                                             new BasicNameValuePair(
255                                                     nvp.getName().toLowerCase(Locale.ROOT),
256                                                     !TextUtils.isBlank(nvp.getValue()) ? nvp.getValue() : null),
257                                             false);
258                                 }
259                             }
260                         }
261                         return buf.toString();
262                     }
263                 })
264                 .sorted()
265                 .distinct()
266                 .forEach(consumer);
267     }
268 
269     /**
270      * Computes a "variant key" for the given request and the given variants.
271      * @param request originating request
272      * @param variantNames variant names
273      * @return variant key
274      *
275      * @since 5.4
276      */
277     public String generateVariantKey(final HttpRequest request, final Collection<String> variantNames) {
278         Args.notNull(variantNames, "Variant names");
279         final StringBuilder buf = new StringBuilder("{");
280         final AtomicBoolean firstHeader = new AtomicBoolean();
281         variantNames.stream()
282                 .map(h -> h.toLowerCase(Locale.ROOT))
283                 .sorted()
284                 .distinct()
285                 .forEach(h -> {
286                     if (!firstHeader.compareAndSet(false, true)) {
287                         buf.append("&");
288                     }
289                     buf.append(PercentCodec.encode(h, StandardCharsets.UTF_8)).append("=");
290                     final AtomicBoolean firstToken = new AtomicBoolean();
291                     normalizeElements(request, h, t -> {
292                         if (!firstToken.compareAndSet(false, true)) {
293                             buf.append(",");
294                         }
295                         buf.append(PercentCodec.encode(t, StandardCharsets.UTF_8));
296                     });
297                 });
298         buf.append("}");
299         return buf.toString();
300     }
301 
302     /**
303      * Computes a "variant key" from the headers of the given request if the given
304      * cache entry can have variants ({@code Vary} header is present).
305      *
306      * @param request originating request
307      * @param entry cache entry
308      * @return variant key if the given cache entry can have variants, {@code null} otherwise.
309      */
310     public String generateVariantKey(final HttpRequest request, final HttpCacheEntry entry) {
311         if (entry.containsHeader(HttpHeaders.VARY)) {
312             final List<String> variantNames = variantNames(entry);
313             return generateVariantKey(request, variantNames);
314         } else {
315             return null;
316         }
317     }
318 
319     /**
320      * Computes a key for the given {@link HttpHost} and {@link HttpRequest}
321      * that can be used as a unique identifier for cached resources. if the request has a
322      * {@literal VARY} header the identifier will also include variant key.
323      *
324      * @param host The host for this request
325      * @param request the {@link HttpRequest}
326      * @param entry the parent entry used to track the variants
327      * @return cache key
328      *
329      * @deprecated Use {@link #generateKey(HttpHost, HttpRequest)} or {@link #generateVariantKey(HttpRequest, Collection)}
330      */
331     @Deprecated
332     public String generateKey(final HttpHost host, final HttpRequest request, final HttpCacheEntry entry) {
333         final String rootKey = generateKey(host, request);
334         final List<String> variantNames = variantNames(entry);
335         if (variantNames.isEmpty()) {
336             return rootKey;
337         } else {
338             return generateVariantKey(request, variantNames) + rootKey;
339         }
340     }
341 
342 }