NSURLSession and amazon S3 uploads

10,657

Solution 1

I made it work based on Zeev Vax answer. I want to provide some insight on problems I ran into and offer minor improvements.

Build a normal PutRequest, for instance

S3PutObjectRequest* putRequest = [[S3PutObjectRequest alloc] initWithKey:keyName inBucket:bucketName];

putRequest.credentials = credentials;
putRequest.filename = theFilePath;

Now we need to do some work the S3Client usually does for us

// set the endpoint, so it is not null
putRequest.endpoint = s3Client.endpoint;

// if you are using session based authentication, otherwise leave it out
putRequest.securityToken = messageTokenDTO.securityToken;

// sign the request (also computes md5 checksums etc.)
NSMutableURLRequest *request = [s3Client signS3Request:putRequest];

Now copy all of that to a new request. Amazon use their own NSUrlRequest class which would cause an exception

NSMutableURLRequest* request2 = [[NSMutableURLRequest alloc]initWithURL:request.URL];
[request2 setHTTPMethod:request.HTTPMethod];
[request2 setAllHTTPHeaderFields:[request allHTTPHeaderFields]];

Now we can start the actual transfer

NSURLSession* backgroundSession = [self backgroundSession];
_uploadTask = [backgroundSession uploadTaskWithRequest:request2 fromFile:[NSURL fileURLWithPath:theFilePath]];
[_uploadTask resume];

This is the code that creates the background session:

- (NSURLSession *)backgroundSession {
    static NSURLSession *session = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{

        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfiguration:@"com.example.my.unique.id"];
        session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
    });

    return session;
}

It took me a while to figure out that the session / task delegate needs to handle an auth challenge (we are in fact authentication to s3). So just implement

- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *))completionHandler {
    NSLog(@"session did receive challenge");
    completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil);
}

Solution 2

The answers here are slightly outdated, spent a great deal of my day trying to get this work in Swift and the new AWS SDK. So here's how to do it in Swift by using the new AWSS3PreSignedURLBuilder (available in version 2.0.7+):

class S3BackgroundUpload : NSObject {

    // Swift doesn't support static properties yet, so have to use structs to achieve the same thing.
    struct Static {
        static var session : NSURLSession?
    }

    override init() {
        super.init()

        // Note: There are probably safer ways to store the AWS credentials.
        let configPath = NSBundle.mainBundle().pathForResource("appconfig", ofType: "plist")
        let config = NSDictionary(contentsOfFile: configPath!)
        let accessKey = config.objectForKey("awsAccessKeyId") as String?
        let secretKey = config.objectForKey("awsSecretAccessKey") as String?
        let credentialsProvider = AWSStaticCredentialsProvider .credentialsWithAccessKey(accessKey!, secretKey: secretKey!)

        // AWSRegionType.USEast1 is the default S3 endpoint (use it if you don't need specific endpoints such as s3-us-west-2.amazonaws.com)
        let configuration = AWSServiceConfiguration(region: AWSRegionType.USEast1, credentialsProvider: credentialsProvider)

        // This is setting the configuration for all AWS services, you can also pass in this configuration to the AWSS3PreSignedURLBuilder directly.
        AWSServiceManager.defaultServiceManager().setDefaultServiceConfiguration(configuration)

        if Static.session == nil {
            let configIdentifier = "com.example.s3-background-upload"

            var config : NSURLSessionConfiguration
            if NSURLSessionConfiguration.respondsToSelector("backgroundSessionConfigurationWithIdentifier:") {
                // iOS8
                config = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(configIdentifier)
            } else {
                // iOS7
                config = NSURLSessionConfiguration.backgroundSessionConfiguration(configIdentifier)
            }

            // NSURLSession background sessions *need* to have a delegate.
            Static.session = NSURLSession(configuration: config, delegate: self, delegateQueue: nil)
        }
    }

    func upload() {
        let s3path = "/some/path/some_file.jpg"
        let filePath = "/var/etc/etc/some_file.jpg"

        // Check if the file actually exists to prevent weird uncaught obj-c exceptions.
        if NSFileManager.defaultManager().fileExistsAtPath(filePath) == false {
            NSLog("file does not exist at %@", filePath)
            return
        }

        // NSURLSession needs the filepath in a "file://" NSURL format.
        let fileUrl = NSURL(string: "file://\(filePath)")

        let preSignedReq = AWSS3GetPreSignedURLRequest()
        preSignedReq.bucket = "bucket-name"
        preSignedReq.key = s3path
        preSignedReq.HTTPMethod = AWSHTTPMethod.PUT                   // required
        preSignedReq.contentType = "image/jpeg"                       // required
        preSignedReq.expires = NSDate(timeIntervalSinceNow: 60*60)    // required

        // The defaultS3PreSignedURLBuilder uses the global config, as specified in the init method.
        let urlBuilder = AWSS3PreSignedURLBuilder.defaultS3PreSignedURLBuilder()

        // The new AWS SDK uses BFTasks to chain requests together:
        urlBuilder.getPreSignedURL(preSignedReq).continueWithBlock { (task) -> AnyObject! in

            if task.error != nil {
                NSLog("getPreSignedURL error: %@", task.error)
                return nil
            }

            var preSignedUrl = task.result as NSURL
            NSLog("preSignedUrl: %@", preSignedUrl)

            var request = NSMutableURLRequest(URL: preSignedUrl)
            request.cachePolicy = NSURLRequestCachePolicy.ReloadIgnoringLocalCacheData

            // Make sure the content-type and http method are the same as in preSignedReq
            request.HTTPMethod = "PUT"
            request.setValue(preSignedReq.contentType, forHTTPHeaderField: "Content-Type")

            // NSURLSession background session does *not* support completionHandler, so don't set it.
            let uploadTask = Static.session?.uploadTaskWithRequest(request, fromFile: fileUrl)

            // Start the upload task:
            uploadTask?.resume()

            return nil
        }
    }
}

extension S3BackgroundUpload : NSURLSessionDelegate {

    func URLSession(session: NSURLSession, dataTask: NSURLSessionDataTask, didReceiveData data: NSData) {
        NSLog("did receive data: %@", NSString(data: data, encoding: NSUTF8StringEncoding))
    }

    func URLSession(session: NSURLSession, task: NSURLSessionTask, didCompleteWithError error: NSError?) {
        NSLog("session did complete")
        if error != nil {
            NSLog("error: %@", error!.localizedDescription)
        }
        // Finish up your post-upload tasks.
    }
}

Solution 3

I don't know NSURLSessionUploadTask very well yet but I can tell you how I would debug this.

I would use a tool like Charles to be able to see HTTP(S) requests that my application makes. The problem is likely that the NSURLSessionUploadTask ignores a header that you set or it uses a different HTTP method than Amazon's S3 expects for the file upload. This can be easily verified with an intercepting proxy.

Also, when Amazon S3 returns an error like 403, it actually sends back an XML document that has some more information about the error. Maybe there is a delegate method for NSURLSession that can retrieve the response body? If not then Charles will certainly give you more insight.

Solution 4

Here is my code to run the task:

AmazonS3Client *s3Client = [[AmazonS3Client alloc] initWithAccessKey:accessKey withSecretKey:secretKey];
S3PutObjectRequest *s3PutObjectRequest = [[S3PutObjectRequest alloc] initWithKey:[url lastPathComponent] inBucket:bucket];
s3PutObjectRequest.cannedACL = [S3CannedACL publicRead];
s3PutObjectRequest.endpoint = s3Client.endpoint;
s3PutObjectRequest.contentType = fileMIMEType([url absoluteString]);
[s3PutObjectRequest configureURLRequest];

NSMutableURLRequest *request = [s3Client signS3Request:s3PutObjectRequest];
NSMutableURLRequest *request2 = [[NSMutableURLRequest alloc]initWithURL:request.URL];
[request2 setHTTPMethod:request.HTTPMethod];
[request2 setAllHTTPHeaderFields:[request allHTTPHeaderFields]];

NSURLSessionUploadTask *task = [[self backgroundURLSession] uploadTaskWithRequest:request2 fromFile:url];
[task resume];

I open sourced my S3 background uploaded https://github.com/genadyo/S3Uploader/

Solution 5

For background uploading/downloading you need to use NSURLSession with background configuration. Since AWS SDK 2.0.7 you can use pre signed requests:

PreSigned URL Builder** - The SDK now includes support for pre-signed Amazon Simple Storage Service (S3) URLs. You can use these URLS to perform background transfers using the NSURLSession class.

Init background NSURLSession and AWS Services

- (void)initBackgroundURLSessionAndAWS
{
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:AWSS3BackgroundSessionUploadIdentifier];
    self.urlSession = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
    AWSServiceConfiguration *configuration = [AWSServiceConfiguration configurationWithRegion:DefaultServiceRegionType credentialsProvider:credentialsProvider];
    [AWSServiceManager defaultServiceManager].defaultServiceConfiguration = configuration;
    self.awss3 = [[AWSS3 alloc] initWithConfiguration:configuration];
}

Implement upload file function

- (void)uploadFile
{
    AWSS3GetPreSignedURLRequest *getPreSignedURLRequest = [AWSS3GetPreSignedURLRequest new];
    getPreSignedURLRequest.bucket = @"your_bucket";
    getPreSignedURLRequest.key = @"your_key";
    getPreSignedURLRequest.HTTPMethod = AWSHTTPMethodPUT;
    getPreSignedURLRequest.expires = [NSDate dateWithTimeIntervalSinceNow:3600];
    //Important: must set contentType for PUT request
    getPreSignedURLRequest.contentType = @"your_contentType";

    [[[AWSS3PreSignedURLBuilder defaultS3PreSignedURLBuilder] getPreSignedURL:getPreSignedURLRequest] continueWithBlock:^id(BFTask *task) {
        if (task.error)
        {
            NSLog(@"Error BFTask: %@", task.error);
        }
        else
        {
            NSURL *presignedURL = task.result;
            NSLog(@"upload presignedURL is: \n%@", presignedURL);

            NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:presignedURL];
            request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
            [request setHTTPMethod:@"PUT"];
            [request setValue:contentType forHTTPHeaderField:@"Content-Type"];

//          Background NSURLSessions do not support the block interfaces, delegate only.
            NSURLSessionUploadTask *uploadTask = [self.session uploadTaskWithRequest:request fromFile:@"file_path"];

            [uploadTask resume];
        }
        return nil;
    }];
}

NSURLSession Delegate Function:

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
    if (error)
    {
        NSLog(@"S3 UploadTask: %@ completed with error: %@", task, [error localizedDescription]);
    }
    else
    {
//      AWSS3GetPreSignedURLRequest does not contain ACL property, so it has to be set after file was uploaded
        AWSS3PutObjectAclRequest *aclRequest = [AWSS3PutObjectAclRequest new];
        aclRequest.bucket = @"your_bucket";
        aclRequest.key = @"yout_key";
        aclRequest.ACL = AWSS3ObjectCannedACLPublicRead;

        [[self.awss3 putObjectAcl:aclRequest] continueWithBlock:^id(BFTask *bftask) {
            dispatch_async(dispatch_get_main_queue(), ^{
                if (bftask.error)
                {
                    NSLog(@"Error putObjectAcl: %@", [bftask.error localizedDescription]);
                }
                else
                {
                    NSLog(@"ACL for an uploaded file was changed successfully!");
                }
            });
            return nil;
        }];
    }
}
Share:
10,657

Related videos on Youtube

George Green
Author by

George Green

Have been building software for nearly 17 years, concentrating most of my effort recently on iOS apps since the app store launched in 2008. Currently running growth for the PhotoBooks product at TouchNote Ltd in London!

Updated on June 04, 2022

Comments

  • George Green
    George Green almost 2 years

    I have an app which is currently uploading images to amazon S3. I have been trying to switch it from using NSURLConnection to NSURLSession so that the uploads can continue while the app is in the background! I seem to be hitting a bit of an issue. The NSURLRequest is created and passed to the NSURLSession but amazon sends back a 403 - forbidden response, if I pass the same request to a NSURLConnection it uploads the file perfectly.

    Here is the code that creates the response:

    NSString *requestURLString = [NSString stringWithFormat:@"http://%@.%@/%@/%@", BUCKET_NAME, AWS_HOST, DIRECTORY_NAME, filename];
    NSURL *requestURL = [NSURL URLWithString:requestURLString];
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:requestURL
                                                           cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData
                                                       timeoutInterval:60.0];
    // Configure request
    [request setHTTPMethod:@"PUT"];
    [request setValue:[NSString stringWithFormat:@"%@.%@", BUCKET_NAME, AWS_HOST] forHTTPHeaderField:@"Host"];
    [request setValue:[self formattedDateString] forHTTPHeaderField:@"Date"];
    [request setValue:@"public-read" forHTTPHeaderField:@"x-amz-acl"];
    [request setHTTPBody:imageData];
    

    And then this signs the response (I think this came from another SO answer):

    NSString *contentMd5  = [request valueForHTTPHeaderField:@"Content-MD5"];
    NSString *contentType = [request valueForHTTPHeaderField:@"Content-Type"];
    NSString *timestamp   = [request valueForHTTPHeaderField:@"Date"];
    
    if (nil == contentMd5)  contentMd5  = @"";
    if (nil == contentType) contentType = @"";
    
    NSMutableString *canonicalizedAmzHeaders = [NSMutableString string];
    
    NSArray *sortedHeaders = [[[request allHTTPHeaderFields] allKeys] sortedArrayUsingSelector:@selector(caseInsensitiveCompare:)];
    
    for (id key in sortedHeaders)
    {
        NSString *keyName = [(NSString *)key lowercaseString];
        if ([keyName hasPrefix:@"x-amz-"]){
            [canonicalizedAmzHeaders appendFormat:@"%@:%@\n", keyName, [request valueForHTTPHeaderField:(NSString *)key]];
        }
    }
    
    NSString *bucket = @"";
    NSString *path   = request.URL.path;
    NSString *query  = request.URL.query;
    
    NSString *host  = [request valueForHTTPHeaderField:@"Host"];
    
    if (![host isEqualToString:@"s3.amazonaws.com"]) {
        bucket = [host substringToIndex:[host rangeOfString:@".s3.amazonaws.com"].location];
    }
    
    NSString* canonicalizedResource;
    
    if (nil == path || path.length < 1) {
        if ( nil == bucket || bucket.length < 1 ) {
            canonicalizedResource = @"/";
        }
        else {
            canonicalizedResource = [NSString stringWithFormat:@"/%@/", bucket];
        }
    }
    else {
        canonicalizedResource = [NSString stringWithFormat:@"/%@%@", bucket, path];
    }
    
    if (query != nil && [query length] > 0) {
        canonicalizedResource = [canonicalizedResource stringByAppendingFormat:@"?%@", query];
    }
    
    NSString* stringToSign = [NSString stringWithFormat:@"%@\n%@\n%@\n%@\n%@%@", [request HTTPMethod], contentMd5, contentType, timestamp, canonicalizedAmzHeaders, canonicalizedResource];
    
    NSString *signature = [self signatureForString:stringToSign];
    
    [request setValue:[NSString stringWithFormat:@"AWS %@:%@", self.S3AccessKey, signature] forHTTPHeaderField:@"Authorization"];
    

    Then if I use this line of code:

    [NSURLConnection connectionWithRequest:request delegate:self];
    

    It works and uploads the file, but if I use:

    NSURLSessionUploadTask *task = [self.session uploadTaskWithRequest:request fromFile:[NSURL fileURLWithPath:filePath]];
    [task resume];
    

    I get the forbidden error..!?

    Has anyone tried uploading to S3 with this and hit similar issues? I wonder if it is to do with the way the session pauses and resumes uploads, or it is doing something funny to the request..?

    One possible solution would be to upload the file to an interim server that I control and have that forward it to S3 when it is complete... but this is clearly not an ideal solution!

    Any help is much appreciated!!

    Thanks!

  • George Green
    George Green over 10 years
    Awesome, that really helped. Apple were adding an extra header field after I had signed the request!
  • Stavash
    Stavash over 10 years
    @GeorgeGreen can you provide some more information? How did you overcome this eventually?
  • pronebird
    pronebird about 10 years
    @GeorgeGreen I am very interested too.
  • Ryan Romanchuk
    Ryan Romanchuk almost 10 years
    How would you do this using pre-singed authentication? The client only has accessKey and signature, this assumes you have key and secret.
  • Ryan Romanchuk
    Ryan Romanchuk almost 10 years
    @GeorgeGreen can you please expand on this?
  • Marius Jeskulke
    Marius Jeskulke almost 10 years
    I am actually using FederationToken to generate temporary credentials before (docs.aws.amazon.com/STS/latest/APIReference/…). I have never used pre signed url, but as far as I understand the documentation it is a simple url request to the generated url. No need to use AWS SDK methods for that, just create an NSUrlSessionUploadTask without any AWS integration at all.
  • Isak
    Isak over 9 years
    From the docs for "Uploading Body Content using a file": "The session object computes the Content-Length header based on the size of the data object. If your app does not provide a value for the Content-Type header, the session also provides one." This can mess up your s3 signature.
  • Xcoder
    Xcoder over 9 years
    Good that it works..Strangely ive been unable to add prefixes to the file uploaded- i.e s3.amazonaws.com/NSURLessionUploadTest/image.jpg works fine but s3.amazonaws.com/NSURLessionUploadTest/Prefix/image.jpg fails.. anyone faced this?
  • Marius Jeskulke
    Marius Jeskulke over 9 years
    Since this is any other string for S3, results should be the same with or without prefix. Maybe it is the bucket policy that is preventing you from uploading. What exactly is the error? A second idea: Please print out the url of request2 just before starting the upload task, maybe the slash is being changed somehow.
  • Rob
    Rob over 9 years
    Link only answers are discouraged. Please include the salient aspects of your solution in your answer, or delete this answer and just leave a comment.
  • Mr.G
    Mr.G over 9 years
    @Genady is thr anyway that i can resume uploading if the internet connection drops ??
  • Mr.G
    Mr.G over 9 years
    how can i resume if internet connection drops
  • Genady Okrain
    Genady Okrain over 9 years
    Interesting question, I didn't try it yet.
  • Mr.G
    Mr.G over 9 years
    @Genady Okrain currently if the connection drops the upload process will stop , do u think is thr any chance to resume upload from where it stopped ??
  • Marius Jeskulke
    Marius Jeskulke over 9 years
    With s3 you would usually use a multipart upload to do that. It should work with the same principal as demonstrated here (signing by aws sdk + wrapping in a NSUrlRequest). Please see aws.amazon.com/articles/Amazon-S3/0006282245644577 for details on multipart uploads. I don't use them at the moment, so unfortunately I cannot provide you with an example. Also I would recommend opening a new question for that.
  • Mr.G
    Mr.G over 9 years
    i want to upload videos to S3 bucket using V2 api and it should support pause and resume capability , Can use objc version of this code snippet to upload
  • Vyachaslav Gerchicov
    Vyachaslav Gerchicov over 7 years
    Works with API v1 only but currently v2 available so it isn't actual