View Javadoc
1   package com.github.davidmoten.aws.lw.client;
2   
3   import java.nio.charset.StandardCharsets;
4   import java.util.ArrayList;
5   import java.util.Arrays;
6   import java.util.HashMap;
7   import java.util.List;
8   import java.util.Map;
9   import java.util.Optional;
10  import java.util.concurrent.TimeUnit;
11  import java.util.stream.Collectors;
12  
13  import com.github.davidmoten.aws.lw.client.internal.Retries;
14  import com.github.davidmoten.aws.lw.client.internal.util.Preconditions;
15  import com.github.davidmoten.aws.lw.client.internal.util.Util;
16  import com.github.davidmoten.aws.lw.client.xml.XmlElement;
17  
18  public final class Request {
19  
20      private final Client client;
21      private Optional<String> region;
22      private String url;
23      private HttpMethod method = HttpMethod.GET;
24      private final Map<String, List<String>> headers = new HashMap<>();
25      private byte[] requestBody;
26      private int connectTimeoutMs;
27      private int readTimeoutMs;
28      private int attributeNumber = 1;
29      private Retries<ResponseInputStream> retries;
30      private String attributePrefix = "Attribute";
31      private String[] pathSegments;
32      private final List<NameValue> queries = new ArrayList<>();
33      private boolean signPayload = true;
34  
35      Request(Client client, String url, String... pathSegments) {
36          this.client = client;
37          this.url = url;
38          this.pathSegments = pathSegments;
39          this.region = client.region();
40          this.connectTimeoutMs = client.connectTimeoutMs();
41          this.readTimeoutMs = client.readTimeoutMs();
42          this.retries = client.retries().copy();
43      }
44  
45      public Request method(HttpMethod method) {
46          Preconditions.checkNotNull(method);
47          this.method = method;
48          return this;
49      }
50  
51      public Request query(String name, String value) {
52          Preconditions.checkNotNull(name);
53          queries.add(new NameValue(name, value));
54          return this;
55      }
56  
57      public Request query(String name) {
58          return query(name, null);
59      }
60  
61      public Request attributePrefix(String attributePrefix) {
62          this.attributePrefix = attributePrefix;
63          this.attributeNumber = 1;
64          return this;
65      }
66  
67      public Request attribute(String name, String value) {
68          int i = attributeNumber;
69          attributeNumber++;
70          return query(attributePrefix + "." + i + ".Name", name) //
71                  .query(attributePrefix + "." + i + ".Value", value);
72      }
73  
74      public Request header(String name, String value) {
75          Preconditions.checkNotNull(name);
76          Preconditions.checkNotNull(value);
77          RequestHelper.put(headers, name, value);
78          return this;
79      }
80  
81      public Request signPayload(boolean signPayload) {
82          this.signPayload = signPayload;
83          return this;
84      }
85  
86      public Request unsignedPayload() {
87          return signPayload(false);
88      }
89  
90      /**
91       * Adds the header {@code x-amz-meta-KEY:value}. {@code KEY} is obtained from
92       * {@code key} by converting to lower-case (headers are case-insensitive) and
93       * only retaining alphabetical and digit characters.
94       * 
95       * @param key   metadata key
96       * @param value metadata value
97       * @return request builder
98       */
99      public Request metadata(String key, String value) {
100         Preconditions.checkNotNull(key);
101         Preconditions.checkNotNull(value);
102         return header("x-amz-meta-" + Util.canonicalMetadataKey(key), value);
103     }
104 
105     public Request requestBody(byte[] requestBody) {
106         Preconditions.checkNotNull(requestBody);
107         this.requestBody = requestBody;
108         return this;
109     }
110 
111     public Request requestBody(String requestBody) {
112         Preconditions.checkNotNull(requestBody);
113         return requestBody(requestBody.getBytes(StandardCharsets.UTF_8));
114     }
115 
116     public Request region(String region) {
117         Preconditions.checkNotNull(region);
118         this.region = Optional.of(region);
119         return this;
120     }
121 
122     public Request connectTimeout(long duration, TimeUnit unit) {
123         Preconditions.checkArgument(duration >= 0, "duration cannot be negative");
124         Preconditions.checkNotNull(unit, "unit cannot be null");
125         this.connectTimeoutMs = (int) unit.toMillis(duration);
126         return this;
127     }
128 
129     public Request readTimeout(long duration, TimeUnit unit) {
130         Preconditions.checkArgument(duration >= 0, "duration cannot be negative");
131         Preconditions.checkNotNull(unit, "unit cannot be null");
132         this.readTimeoutMs = (int) unit.toMillis(duration);
133         return this;
134     }
135 
136     public Request retryInitialInterval(long duration, TimeUnit unit) {
137         Preconditions.checkArgument(duration >= 0, "duration cannot be negative");
138         Preconditions.checkNotNull(unit, "unit cannot be null");
139         retries = retries.withInitialIntervalMs(unit.toMillis(duration));
140         return this;
141     }
142 
143     public Request retryMaxAttempts(int maxAttempts) {
144         Preconditions.checkArgument(maxAttempts >=0, "retryMaxAttempts cannot be negative");
145         retries = retries.withMaxAttempts(maxAttempts);
146         return this;
147     }
148     
149     /**
150      * Sets the level of randomness applied to the next retry interval. The next
151      * calculated retry interval is multiplied by
152      * {@code (1 - jitter * Math.random())}. A value of zero means no jitter, 1
153      * means max jitter.
154      * 
155      * @param jitter level of randomness applied to the retry interval
156      * @return this
157      */
158     public Request retryJitter(double jitter) {
159         Preconditions.checkArgument(jitter >= 0 && jitter <= 1, "jitter must be between 0 and 1");
160         retries = retries.withJitter(jitter);
161         return this;
162     }
163 
164 
165     public Request retryBackoffFactor(double factor) {
166         Preconditions.checkArgument(factor >=0, "retryBackoffFactor cannot be negative");
167         retries = retries.withBackoffFactor(factor);
168         return this;
169     }
170 
171     public Request retryMaxInterval(long duration, TimeUnit unit) {
172         Preconditions.checkArgument(duration >= 0, "duration cannot be negative");
173         Preconditions.checkNotNull(unit, "unit cannot be null");
174         retries = retries.withMaxIntervalMs(unit.toMillis(duration));
175         return this;
176     }
177 
178     /**
179      * Opens a connection and makes the request. This method returns all the
180      * response information including headers, status code, request body as an
181      * InputStream. If an error status code is encountered (outside 200-299) then an
182      * exception is <b>not</b> thrown (unlike the other methods .response*). The
183      * caller <b>must close</b> the InputStream when finished with it.
184      * 
185      * @return all response information, the caller must close the InputStream when
186      *         finished with it
187      */
188     public ResponseInputStream responseInputStream() {
189         String u = calculateUrl(url, client.serviceName(), region, queries, Arrays.asList(pathSegments),
190                 client.baseUrlFactory());
191         return retries //
192                 .call(() -> RequestHelper.request(client.clock(), client.httpClient(), u, method,
193                         RequestHelper.combineHeaders(headers), requestBody, client.serviceName(), region,
194                         client.credentials(), connectTimeoutMs, readTimeoutMs, signPayload));
195     }
196 
197     /**
198      * Opens a connection and makes the request. This method returns all the
199      * response information including headers, status code, request body as a byte
200      * array. If an error status code is encountered (outside 200-299) then an
201      * exception is <b>not</b> thrown (unlike the other methods .response*).
202      * 
203      * @return all response information
204      */
205     public Response response() {
206         ResponseInputStream r = responseInputStream();
207         final byte[] bytes;
208         if (hasBody(r)) {
209             bytes = Util.readBytesAndClose(r);
210         } else {
211             bytes = new byte[0];
212         }
213         return new Response(r.headers(), bytes, r.statusCode());
214     }
215 
216     /**
217      * Opens a connection and makes the request. This method returns all the
218      * response information including headers, status code, request body as a byte
219      * array. If the expected status code is not encountered then a
220      * {@link ServiceException} is thrown.
221      * 
222      * @return all response information
223      * @throws ServiceException
224      */
225     public Response responseExpectStatusCode(int expectedStatusCode) {
226         Response r = response();
227         if (r.statusCode() == expectedStatusCode) {
228             return r;
229         } else {
230             throw new ServiceException(r.statusCode(), r.contentUtf8());
231         }
232     }
233 
234     // VisibleForTesting
235     static boolean hasBody(ResponseInputStream r) {
236         return r.header("Content-Length").isPresent()
237                 || r.header("Transfer-Encoding").orElse("").equalsIgnoreCase("chunked");
238     }
239 
240     private static String calculateUrl(String url, String serviceName, Optional<String> region, List<NameValue> queries,
241             List<String> pathSegments, BaseUrlFactory baseUrlFactory) {
242         String u = url;
243         if (u == null) {
244             String baseUrl = baseUrlFactory.create(serviceName, region);
245             Preconditions.checkNotNull(baseUrl, "baseUrl cannot be null");
246             u = trimAndEnsureHasTrailingSlash(baseUrl) //
247                     + pathSegments //
248                             .stream() //
249                             .map(x -> trimAndRemoveLeadingAndTrailingSlashes(x)) //
250                             .collect(Collectors.joining("/"));
251         }
252         // add queries
253         for (NameValue nv : queries) {
254             if (!u.contains("?")) {
255                 u += "?";
256             }
257             if (!u.endsWith("?")) {
258                 u += "&";
259             }
260             if (nv.value != null) {
261                 u += Util.urlEncode(nv.name, false) + "=" + Util.urlEncode(nv.value, false);
262             } else {
263                 u += Util.urlEncode(nv.name, false);
264             }
265         }
266         return u;
267     }
268 
269     // VisibleForTesting
270     static String trimAndEnsureHasTrailingSlash(String s) {
271         String r = s.trim();
272         if (r.endsWith("/")) {
273             return r;
274         } else {
275             return r + "/";
276         }
277     }
278 
279     public byte[] responseAsBytes() {
280         Response r = response();
281         Optional<? extends RuntimeException> exception = client.exceptionFactory().create(r);
282         if (!exception.isPresent()) {
283             return r.content();
284         } else {
285             throw exception.get();
286         }
287     }
288 
289     /**
290      * Returns true if and only if status code is 2xx. Returns false if status code
291      * is 404 (NOT_FOUND) and throws a {@link ServiceException} otherwise.
292      * 
293      * @return true if status code 2xx, false if 404 otherwise throws
294      *         ServiceException
295      * @throws ServiceException if status code other than 2xx or 404
296      */
297     public boolean exists() {
298         return response().exists();
299     }
300 
301     public void execute() {
302         responseAsBytes();
303     }
304 
305     public String responseAsUtf8() {
306         return new String(responseAsBytes(), StandardCharsets.UTF_8);
307     }
308 
309     public XmlElement responseAsXml() {
310         return XmlElement.parse(responseAsUtf8());
311     }
312 
313     public String presignedUrl(long expiryDuration, TimeUnit unit) {
314         String u = calculateUrl(url, client.serviceName(), region, queries, Arrays.asList(pathSegments),
315                 client.baseUrlFactory());
316         return RequestHelper.presignedUrl(client.clock(), u, method.toString(), RequestHelper.combineHeaders(headers),
317                 requestBody, client.serviceName(), region, client.credentials(), connectTimeoutMs, readTimeoutMs,
318                 unit.toSeconds(expiryDuration), signPayload);
319     }
320 
321     // VisibleForTesting
322     static String trimAndRemoveLeadingAndTrailingSlashes(String s) {
323         Preconditions.checkNotNull(s);
324         s = s.trim();
325         if (s.startsWith("/")) {
326             s = s.substring(1);
327         }
328         if (s.endsWith("/")) {
329             s = s.substring(0, s.length() - 1);
330         }
331         return s;
332     }
333 
334     private static final class NameValue {
335         final String name;
336         final String value;
337 
338         NameValue(String name, String value) {
339             this.name = name;
340             this.value = value;
341         }
342     }
343 }