· 7 years ago · May 17, 2018, 02:56 PM
1String accessKeyID = '<key>';
2String secretAccessKey = '<secretkey>';
3String region = 'ap-south-1';
4String bucket ='avijit-image';
5
6Connector connector = new Connector(accessKeyID, secretAccessKey);
7
8S3.Content content = connector.s3(region).bucket(bucket).content('ERD_Product+and+Schedule+Objects.png');
9HttpRequest request = content.presign();
10String url = request.getEndpoint().replace('%2B','+');
11System.debug(url);
12
13public class Connector {
14
15 String accessKeyId;
16 String secretKey;
17 public String service;
18 public String region;
19 @TestVisible Datetime now = Datetime.now();
20
21 public Connector(String accessKeyId, String secretKey) {
22 this.accessKeyId = accessKeyId;
23 this.secretKey = secretKey;
24 }
25
26 public S3 s3(String region) {
27 return new s3(this, region);
28 }
29
30 /*public Ec2 ec2(String region) {
31 return new ec2(this, region);
32 }*/
33
34 /**
35* Signature Version 4 Signing Process
36* Requests to AWS must be signed—that is, they must include information that AWS can use to authenticate the
37* requestor. Requests are signed using the access key ID and secret access key of an account or of an IAM user.
38* https://docs.aws.amazon.com/general/latest/gr/signing_aws_api_requests.html
39*/
40 public HttpRequest signedRequest(String method, Url endpoint, Map<String,String> headers, Blob payload, Boolean presign) {
41
42 //defaults
43 if (headers == null) headers = new Map<String,String>();
44 if (payload == null) payload = Blob.valueOf('');
45 if (presign == null) presign = false;
46
47 //assemble
48
49 String termination = 'aws4_request';
50 String iso8601date = this.now.formatGmt('YYYYMMdd');
51 String iso8601time = this.now.formatGmt('YYYYMMdd'T'HHmmss'Z'');
52 String credentialScope = iso8601date + '/' + this.region + '/' + this.service + '/' + termination;
53
54 //prepare headers
55 headers.put('Host', endpoint.getHost());
56 String signedHeaders = signedHeadersFor(headers);
57
58 //handle spaces and special characters in paths
59 String spec = '';
60 spec += endpoint.getProtocol() + '://';
61 spec += endpoint.getHost();
62 spec += rfc3986For(endpoint.getPath(), false);
63 if (endpoint.getQuery() != null) spec += '?' + endpoint.getQuery();
64
65 //prepare parameters
66 PageReference pr = new PageReference(spec);
67 Map<String,String> parameters = pr.getParameters();
68 parameters.put('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
69 parameters.put('X-Amz-Credential', this.accessKeyId + '/' + credentialScope);
70 parameters.put('X-Amz-Date', iso8601time);
71 parameters.put('X-Amz-Expires', '86400');
72 parameters.put('X-Amz-SignedHeaders', signedHeaders);
73
74 //Task 1: Create a Canonical Request for Signature Version 4
75 //https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
76 String canonicalRequest = canonicalMethodFor(method)
77 + 'n' + canonicalUriFor(endpoint.toExternalForm())
78 + 'n' + canonicalQueryStringFor(parameters)
79 + 'n' + canonicalHeadersFor(headers)
80 + 'n' + signedHeadersFor(headers)
81 + 'n' + (presign ? 'UNSIGNED-PAYLOAD' : hexEncodedHashFor(payload))
82 ;
83
84 //Task 2: Create a String to Sign for Signature Version 4
85 //https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
86 String algorithm = 'AWS4-HMAC-SHA256';
87 String canonicalRequestHash = hexEncodedHashFor(Blob.valueOf(canonicalRequest));
88 String stringToSign = algorithm + 'n' + iso8601time + 'n' + credentialScope + 'n' + canonicalRequestHash;
89
90 //Task 3: Calculate the AWS Signature Version 4
91 //https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
92 Blob keySecret = Blob.valueOf('AWS4' + this.secretKey);
93 Blob keyDate = Crypto.generateMac('hmacSHA256', Blob.valueOf(iso8601date), keySecret);
94 Blob keyRegion = Crypto.generateMac('hmacSHA256', Blob.valueOf(this.region), keyDate);
95 Blob keyService = Crypto.generateMac('hmacSHA256', Blob.valueOf(this.service), keyRegion);
96 Blob keySigning = Crypto.generateMac('hmacSHA256', Blob.valueOf('aws4_request'), keyService);
97 Blob blobToSign = Blob.valueOf(stringToSign);
98 Blob hmac = Crypto.generateMac('hmacSHA256', blobToSign, keySigning);
99
100 //Task 4: Add the Signing Information to the Request
101 //https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
102 if (!presign) headers.put('X-Amz-Content-Sha256', hexEncodedHashFor(payload));
103 String signature = EncodingUtil.convertToHex(hmac);
104 parameters.put('X-Amz-Signature', signature);
105
106 //prepare request
107 HttpRequest request = new HttpRequest();
108 request.setMethod(method);
109 request.setEndpoint(pr.getUrl());
110 if (payload != Blob.valueOf('')) request.setBodyAsBlob(payload); //affects http method
111 for (String header : headers.keySet()) request.setHeader(header, headers.get(header));
112
113 return request;
114 }
115
116 @TestVisible static String canonicalMethodFor(String method) {
117 return method.toUpperCase();
118 }
119
120
121 @TestVisible static String canonicalUriFor(String endpoint) {
122 Url uri = new Url(endpoint);
123 return rfc3986For(uri.getPath(), false);
124 }
125
126 @TestVisible static String canonicalQueryStringFor(Map<String,String> parameters) {
127
128 //sort keys by ascii code
129 List<String> sortedKeys = new List<String>(parameters.keySet());
130 sortedKeys.sort();
131
132 //prepare values
133 List<String> canonicalParameters = new List<String>();
134 for (String sortedKey : sortedKeys) canonicalParameters.add(
135 sortedKey +
136 '=' +
137 rfc3986For(parameters.get(sortedKey), true)
138 );
139
140 return String.join(canonicalParameters, '&');
141 }
142
143 /**
144* To create the canonical headers list, convert all header names to lowercase and remove leading spaces and
145* trailing spaces. Convert sequential spaces in the header value to a single space.
146* https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
147*/
148 @TestVisible static String canonicalHeadersFor(Map<String,String> key2value) {
149
150 //lowercase header keys
151 Map<String,String> lower2value = new Map<String,String>();
152 for (String key : key2value.keySet()) lower2value.put(key.toLowerCase(), key2value.get(key).trim().replaceAll('\s+', ' '));
153
154 //sort canonical keys by ascii code
155 List<String> sortedKeys = new List<String>(lower2value.keySet());
156 sortedKeys.sort();
157
158 //prepare values
159 List<String> canonicalHeaders = new List<String>();
160 for (String sortedKey : sortedKeys) canonicalHeaders.add(sortedKey + ':' + lower2value.get(sortedKey) + 'n');
161
162 return String.join(canonicalHeaders, '');
163 }
164
165 /**
166* Build the signed headers list by iterating through the collection of header names, sorted by lowercase character
167* code. For each header name except the last, append a semicolon (';') to the header name to separate it from the
168* following header name.
169* https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
170*/
171 @TestVisible static String signedHeadersFor(Map<String,String> headers) {
172
173 //lowercase header keys
174 List<String> keys = new List<String>(headers.keySet());
175 for (Integer i = 0; i < keys.size(); i++) keys.set(i, keys[i].toLowerCase());
176
177 //sort ascii
178 keys.sort();
179
180 //prepare values
181 List<String> signedHeaders = new List<String>();
182 for (String key : keys) signedHeaders.add(key);
183
184 return String.join(signedHeaders, ';');
185 }
186
187 /**
188* The hashed canonical request must be represented as a string of lowercase hexademical characters.
189* https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
190*/
191 @TestVisible static String hexEncodedHashFor(Blob data) {
192 Blob hash = Crypto.generateDigest('SHA256', data);
193 return EncodingUtil.convertToHex(hash);
194 }
195
196 /**
197* Caution: The standard UriEncode functions provided by your development platform may not work because of
198* differences in implementation and related ambiguity in the underlying RFCs. We recommend that you write your own
199* custom UriEncode function to ensure that your encoding will work.
200* https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
201*/
202 @TestVisible static String rfc3986For(String characters, Boolean encodeSlash) {
203 String result = '';
204 for (Integer i = 0; i < characters.length(); i++) {
205 String character = characters.substring(i, i + 1);
206
207 if (
208 (character >= 'A' && character <= 'Z') ||
209 (character >= 'a' && character <= 'z') ||
210 (character >= '0' && character <= '9') ||
211 character == '_' ||
212 character == '-' ||
213 character == '~' ||
214 character == '.'
215 ) {
216 result += character;
217 } else if (character == '/') {
218 result += encodeSlash ? '%2F' : character;
219 } else {
220 result += '%' + EncodingUtil.convertToHex(Blob.valueOf(character)).toUpperCase();
221 }
222 }
223
224 return result;
225 }
226}
227
228public class S3 {
229
230 public class ClientException extends Exception {}
231
232 Connector connector;
233 public S3(Connector connector, String region) {
234 this.connector = connector;
235 this.connector.region = region;
236 this.connector.service = 's3';
237 }
238
239 /**
240 * Example usage:
241 * new AwsSdk.Connector('access', 'secret').s3('us-east-1').bucket('bucketname');
242 */
243 public Bucket bucket(String name) {
244 return new Bucket(this.connector, name);
245 }
246
247 /**
248 * This section describes operations you can perform on Amazon S3 objects.
249 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectOps.html
250 */
251 public class Bucket {
252
253 /**
254 * Bucket's name.
255 */
256 public String Name;
257
258 /**
259 * Date the bucket was created.
260 */
261 public Datetime CreationDate;
262
263 Connector connector;
264 Bucket(Connector connector, String bucket) {
265 this.connector = connector;
266 this.Name = bucket;
267 }
268
269 /**
270 * Example usage:
271 * new AwsSdk.Connector('access', 'secret').s3('us-east-1').bucket('bucketname').content('key');
272 */
273 public Content content(String key) {
274 return new Content(this, key);
275 }
276
277 /**
278 * This implementation of the GET operation returns some or all (up to 1000) of the objects in a bucket.
279 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketGET.html
280 */
281 public List<Content> listContents(ListContentsRequest listContentsRequest) {
282 PageReference pr = new PageReference('https://'+ this.Name +'.s3.amazonaws.com');
283 Map<String,String> parameters = new RequestFormatter(listContentsRequest).getMap();
284 pr.getParameters().putAll(parameters);
285
286 Url endpoint = new Url(pr.getUrl());
287 HttpRequest request = this.connector.signedRequest('GET', endpoint, null, null, null);
288 HttpResponse response = new Http().send(request);
289 if (response.getStatusCode() != 200) throw new ClientException(response.getBody());
290
291 ListBucketResult result = new ListBucketResult();
292 result.Contents = new List<Content>();
293
294 for (Dom.XmlNode node : response.getBodyDocument().getRootElement().getChildElements()) {
295 if (node.getName() != 'Contents') continue;
296 String data = new ResponseFormatter(node).getJson();
297 Content dto = (Content)Json.deserialize(data, Content.class);
298 dto.bucket = this;
299 result.Contents.add(dto);
300 }
301
302 return result.Contents;
303 }
304
305 /**
306 * The DELETE operation removes the null version (if there is one) of an object
307 * and inserts a delete marker, which becomes the current version of the object.
308 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectDELETE.html
309 */
310 public HttpResponse deleteContent(String key) {
311 if (key.startsWith('/')) throw new ClientException('Keys should not lead with slash');
312 Url endpoint = new Url('https://'+ this.Name +'.s3.amazonaws.com/' + key);
313 HttpRequest request = this.connector.signedRequest('DELETE', endpoint, null, null, null);
314 HttpResponse response = new Http().send(request);
315 if (response.getStatusCode() != 204) throw new ClientException(response.getBody());
316 return response;
317 }
318
319 /**
320 * This implementation of the PUT operation adds an object to a bucket.
321 * You must have WRITE permissions on a bucket to add an object to it.
322 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUT.html
323 */
324 public HttpResponse createContent(String key, Map<String,String> headers, Blob payload) {
325 if (key.startsWith('/')) throw new ClientException('Keys should not lead with slash');
326 Url endpoint = new Url('https://'+ this.Name + '.s3.amazonaws.com/'+ key);
327 HttpRequest request = this.connector.signedRequest('PUT', endpoint, headers, payload, null);
328 HttpResponse response = new Http().send(request);
329 if (response.getStatusCode() != 200) throw new ClientException(response.getBody());
330 return response;
331 }
332
333 }
334
335 /**
336 * Metadata about each object returned.
337 */
338 public class Content {
339
340 /**
341 * The object's key.
342 */
343 public String Key;
344
345 /**
346 * Date and time the object was last modified.
347 */
348 public Datetime LastModified;
349
350 /**
351 * The entity tag is an MD5 hash of the object. The ETag only reflects changes to the contents of an
352 * object, not its metadata.
353 */
354 public String ETag;
355
356 /**
357 * Size in bytes of the object.
358 */
359 public String Size;
360
361 /**
362 * STANDARD | STANDARD_IA | REDUCED_REDUNDANCY | GLACIER
363 */
364 public String StorageClass;
365
366 /**
367 * Bucket owner.
368 */
369 public Owner Owner;
370
371 Bucket bucket;
372 Content(Bucket bucket, String key) {
373 this.bucket = bucket;
374 this.Key = key;
375 }
376
377 /**
378 * Provides the time period, in seconds, for which the generated presigned URL is valid.
379 */
380 public HttpRequest presign() {
381 String method = 'GET';
382 Url endpoint = new Url('https://'+ this.bucket.Name + '.s3.amazonaws.com/' + this.Key);
383 Map<String,String> headers = new Map<String,String>();
384 Blob payload = null;
385 Boolean presign = true;
386 return this.bucket.connector.signedRequest(method, endpoint, headers, payload, presign);
387 }
388
389 }
390
391 /**
392 * This implementation of the GET operation returns a list of all
393 * buckets owned by the authenticated sender of the request.
394 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTServiceGET.html
395 */
396 public class ListContentsRequest {
397
398 /**
399 * A delimiter is a character you use to group keys.
400 *
401 * All keys that contain the same string between the prefix, if specified, and the first occurrence of
402 * the delimiter after the prefix are grouped under a single result element, CommonPrefixes. If you
403 * don't specify the prefix parameter, then the substring starts at the beginning of the key. The keys
404 * that are grouped under CommonPrefixes result element are not returned elsewhere in the response.
405 *
406 */
407 public String delimiter;
408
409 /**
410 * Requests Amazon S3 to encode the response and specifies the encoding method to use.
411 *
412 * An object key can contain any Unicode character; however, XML 1.0 parser cannot parse some
413 * characters, such as characters with an ASCII value from 0 to 10. For characters that are not
414 * supported in XML 1.0, you can add this parameter to request that Amazon S3 encode the keys in the
415 * response.
416 */
417 public String encodingType;
418
419 /**
420 * Specifies the key to start with when listing objects in a bucket. Amazon S3 returns object keys in
421 * UTF-8 binary order, starting with key after the marker in order.
422 */
423 public String marker;
424
425 /**
426 * Sets the maximum number of keys returned in the response body. You can add this to your request if
427 * you want to retrieve fewer than the default 1000 keys.
428 *
429 * The response might contain fewer keys but will never contain more. If there are additional keys
430 * that satisfy the search criteria but were not returned because max-keys was exceeded, the response
431 * contains <IsTruncated>true</IsTruncated>. To return the additional keys, see marker.
432 */
433 public String maxKeys;
434
435 /**
436 * Limits the response to keys that begin with the specified prefix. You can use prefixes to separate
437 * a bucket into different groupings of keys. (You can think of using prefix to make groups in the
438 * same way you'd use a folder in a file system.)
439 */
440 public String prefix;
441
442 }
443
444 /**
445 * This implementation of the GET operation returns a list of all
446 * buckets owned by the authenticated sender of the request.
447 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTServiceGET.html
448 *
449 * AwsApi.S3 s3 = new AwsApi.S3('us-west-2');
450 * List<Bucket> results = s3.get();
451 *
452 */
453 public List<Bucket> listBuckets() {
454 HttpRequest request = this.connector.signedRequest('GET', new Url('https://s3.amazonaws.com/'), null, null, null);
455 HttpResponse response = new Http().send(request);
456 if (response.getStatusCode() != 200) throw new ClientException(response.getBody());
457
458 String data = new ResponseFormatter(response.getBodyDocument().getRootElement()).getJson();
459 Object dto = Json.deserialize(data, ListAllMyBucketsResult.class);
460 return ((ListAllMyBucketsResult)dto).Buckets;
461 }
462
463 /**
464 * This implementation of the PUT operation creates a new bucket.
465 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketPUT.html
466 */
467 public HttpResponse createBucket(String bucketName) {
468 HttpRequest request = this.connector.signedRequest('PUT', new Url('https://s3.amazonaws.com/' + bucketName), null, null, null);
469 HttpResponse response = new Http().send(request);
470 if (response.getStatusCode() != 200) throw new ClientException(response.getBody());
471 return response;
472 }
473
474 /**
475 * This implementation of the DELETE operation deletes the bucket named in the URI.
476 * All objects (including all object versions and delete markers) in the bucket must
477 * be deleted before the bucket itself can be deleted.
478 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketDELETE.html
479 */
480 public HttpResponse deleteBucket(String bucketName) {
481 HttpRequest request = this.connector.signedRequest('DELETE', new Url('https://s3.amazonaws.com/' + bucketName), null, null, null);
482 HttpResponse response = new Http().send(request);
483 if (response.getStatusCode() != 204) throw new ClientException(response.getBody());
484 return response;
485 }
486
487 /**
488 * Takes any request object and formats it as HTTP headers, for example:
489 *
490 * GetRequest: [
491 * delimiter=test,
492 * encodingType=text/plain
493 * ]
494 *
495 * delimiter: test,
496 * encoding-type: text/plain
497 */
498 @TestVisible class RequestFormatter {
499
500 Map<String,String> p = new Map<String,String>();
501
502 public RequestFormatter(Object dto) {
503 if (dto == null) return;
504 Map<String,Object> key2value = (Map<String,Object>)Json.deserializeUntyped(Json.serialize(dto));
505 for (String key : key2value.keySet()) {
506 Object value = key2value.get(key);
507 if (value == null) continue;
508 key = key.replaceAll('([A-Z])', '-$0').toLowerCase();
509 p.put(key, String.valueOf(value));
510 }
511 }
512
513 public Map<String,String> getMap() {
514 return p;
515 }
516
517 }
518
519 /**
520 * Takes any response XML and formats into DTO-ready JSON, for example:
521 *
522 * <DescribeRegionsResponse>
523 * <requestId>eb34bc90-389f-46c1-81bd-d8492f88983a</requestId>
524 * <regionInfo>
525 * <item>
526 * <regionEndpoint>ec2.us-west-1.amazonaws.com</regionEndpoint>
527 * <regionName>us-west-1</regionName>
528 * </item>
529 * </regionInfo>
530 * </DescribeRegionsResponse>
531 *
532 * {
533 * "requestId": "fff48ea8-2445-492e-a2cf-6bf2582896fb",
534 * "regionInfo": [
535 * {
536 * "regionEndpoint": "ec2.us-west-1.amazonaws.com",
537 * "regionName": "us-west-1"
538 * }
539 * ]
540 * }
541 */
542 @TestVisible class ResponseFormatter {
543
544 JsonGenerator g = Json.createGenerator(true);
545
546 public ResponseFormatter(Dom.XmlNode node) {
547 try {
548 traverseNode(node);
549 } catch (Exception e) {
550 e.setMessage(g.getAsString());
551 throw e;
552 }
553 }
554
555 public String getJson() {
556 return g.getAsString();
557 }
558
559 public void traverseNode(Dom.XmlNode node) {
560
561 if (!String.isEmpty(node.getName()) && node.getChildren().isEmpty()) {
562 //found self closing tag (not text and no children) eg <reason/>
563 g.writeNull();
564 return;
565 }
566
567 g.writeStartObject();
568
569 for (Dom.XmlNode child : node.getChildren()) {
570
571 String name = child.getName();
572 String text = child.getText();
573
574 if (String.isBlank(name) && String.isBlank(text)) {
575 //found whitespace
576 continue;
577 }
578
579 if (!String.isBlank(text)) {
580 //found text
581 g.writeFieldName(child.getName());
582 Object value = text;
583
584 //datetime, boolean, string
585 if (child.getName() == 'CreationDate' || child.getName() == 'LastModified') value = Json.deserialize('"' + value + '"', Datetime.class);
586 else if (value == 'true') value = true;
587 else if (value == 'false') value = false;
588 g.writeObject(value);
589
590 } else if (name == 'Buckets') {
591 //found collection
592 g.writeFieldName(child.getName());
593 g.writeStartArray();
594 for (Dom.XmlNode item : child.getChildElements()) traverseNode(item);
595 g.writeEndArray();
596
597 } else {
598 //found object
599 g.writeFieldName(child.getName());
600 traverseNode(child);
601 }
602
603 }
604
605 g.writeEndObject();
606 }
607
608 }
609
610 /**
611 * This implementation of the GET operation returns a list of all buckets owned by the authenticated
612 * sender of the request.
613 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTServiceGET.html
614 */
615 public class ListAllMyBucketsResult {
616 public Owner Owner;
617 public List<Bucket> Buckets;
618 }
619
620 /**
621 * Container for bucket owner information.
622 */
623 public class Owner {
624
625 /**
626 * Bucket owner's user ID.
627 */
628 public String ID;
629
630 /**
631 * Bucket owner's display name.
632 */
633 public String DisplayName;
634
635 }
636
637 /**
638 * This implementation of the GET operation returns some or all (up to 1000) of the objects in a bucket.
639 * https://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketGET.html
640 */
641 public class ListBucketResult {
642
643 /**
644 * Name of the bucket.
645 */
646 public String Name;
647
648 /**
649 * Keys that begin with the indicated prefix.
650 */
651 public String Prefix;
652
653 /**
654 * Indicates where in the bucket listing begins.
655 */
656 public String Marker;
657
658 /**
659 * The maximum number of keys returned in the response body.
660 */
661 public Integer MaxKeys;
662
663 /**
664 * Specifies whether (true) or not (false) all of the results were returned.
665 */
666 public Boolean IsTruncated;
667
668 /**
669 * Metadata about each object returned.
670 */
671 public List<Content> Contents;
672 }
673}