iOS Rails Image Uploads

Alex Egg,

##Web Client

As rails developers, we are spoiled when it comes to handling image uploads: we have paperclip for 90% of the cases and then the complete power of carrierwave for the rest of the cases. These solutions work fine out-of-box for most rails web-user-cases: uploads from a HTML form – i.e. HTTP w/ the multipart/form-data content-type.

##Mobile Client

Now, the case where the client is NOT a web form, but rather a mobile client app running on ios or android. This is a strange case b/c it’s not necessary for you to use a multi-part POST b/c you are not running in the limitations of the browser environment. In a full-featured mobile-client you can do a normal POST w/ a JSON payload – no strange multi-part encoding scheme for all of the form elements. If we wanted a solution that works w/ rails out-of-the box we’d have to hack our ios client to preform a multi-part post, which there are plenty examples of on the internet. However, we don’t need to do this as we can simply POST w/ a JSON payload and w/ a small modification we can get paperclip play along.

##Problem
Paperclip will not pick up the uploaded file unless the POST is multipart/form-data. We can still use the features of paperclip and use a normal JSON post from the client by using a virtual attribute in our Rails model that manually inits paperclip (see paperclip configuration below).

The Paperclip method has_attached_file :profile_image adds an attributes ‘profile_imagge’ to the class that should should be a file on the file system. When rails finds a file upload via a multipart post it automatically converts it to a ActionDispatch::Http::UploadedFile which represents a file saved on the disk. When the paperclip setting method gets a message to profile_image it will start processing (images resizes, S3 uploads etc). However, when we upload via API JSON post we don’t get the rails connivence of converting it to UploadedFile before it hits the the paperclip attribute and paper clip will error.

Our workaround will be to catch the JSON POST and save the image data as a temp file before it gets to paperclip.

###iOS Configuration
The ios client to select an image from the gallery and do a POST is pretty standard so I wont put a lot of details here.

Here snippet from my example header file: (UsersController.h)

#import <UIKit/UIKit.h>
#import "User.h"


@interface UsersController : UIViewController <UIImagePickerControllerDelegate>

@property (strong, nonatomic) IBOutlet UITextField *email_tf;
@property (strong, nonatomic) IBOutlet UIImageView *imageView;

-(NSString *) encodedImage;
-(User *) parseForm;

@end

The important parts to note:

Here a snippet the implementation: (UsersController.m)

#import "UsersController.h"
#import "User.h"
#import "BBRailsAFNClient.h"
#import "UserSession.h"
#import "Base64.h"


@interface UsersController()
@property (nonatomic) UIImagePickerController *imagePickerController;
@end


@implementation UsersController


- (IBAction)save_btn_click:(id)sender {
    User * user = [self parseForm];
    
    User * user = [User currentUser];
    
    [[BBRailsAFNClient sharedClient] createUser:user withCompletionBlock:^(NSString *userID, NSError *error) {
        NSLog(@"saved user: %@", userID);
        user.userID=userID; //update w/ ID from server
    }];
}

-(User *) parseForm
{
    User * user = [[User alloc]init];
    user.email=[self.email_tb text];
    user.image_data=self.encodedImage;
    
    return user;

}

-(NSString *) encodedImage
{
    // Convert our image to Base64 encoding.
    NSData *imageData = UIImagePNGRepresentation(self.imageView.image);
    [Base64 initialize];
    NSString *imageDataEncodedString = [Base64 encode:imageData];
    return imageDataEncodedString;
    

}



- (IBAction)select_photo_click:(id)sender {

    self.imagePickerController = [[UIImagePickerController alloc] init];
    self.imagePickerController.modalPresentationStyle = UIModalPresentationCurrentContext;
    self.imagePickerController.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
    self.imagePickerController.delegate = self;

    [self presentViewController:self.imagePickerController animated:YES completion:nil];
}

#pragma mark UIImagePickerControllerDelegate


- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info {
    
    UIImage *chosenImage = [info objectForKey:UIImagePickerControllerOriginalImage];
    [self.imageView setImage:chosenImage];
    
    [picker dismissViewControllerAnimated:YES completion:NULL];
    
}


- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
    
    [picker dismissViewControllerAnimated:YES completion:NULL];
    
}

@end

The last 4 methods are related to the iOS UIImagePickerControllerDelegate protocol and are mostly boilerplate. The interesting part is in parseForm which is called when the user clicks the ‘save’ button save_btn_click. The method encodedImage is called which base64 encodes our image binary and then it is sent in the POST payload JSON body to be picked up by rails.

###Server/Rails/Paperclip configuration
Paperclip expects all image uploads to come from a HTML form post, so http-multipart encoded. However, our image will just come inside the JSON payload as a member of the other model attributes of our post. We will base64 encode the bits to transmission.

Consider this concrete example of an User JSON payload (compare to a multipart example at the end of this article):

{
    "user": {
        "email": "[email protected]",
        "image_data": "base64-encoded-string....."
    }
}

Which corresponds to this Rails model (User.rb)

class User < ActiveRecord::Base

  has_attached_file :profile_image, styles: {medium: ["300x300>", :png], thumb: ["50x50>", :png]}, :storage => :s3, :bucket=> ENV['S3_BUCKET_NAME']
  validates_attachment_content_type :profile_image, :content_type => /\Aimage\/.*\Z/
  
  attr_accessor :image_data,:image
  before_save :decode_image_data
  
  def decode_image_data
    
  	if self.image_data.present?
    	# If image_data is present, it means that we were sent an image over
      	# JSON and it needs to be decoded.  After decoding, the image is processed
      	# normally via Paperclip.
      	if self.image_data.present?
      		data = StringIO.new(Base64.decode64(self.image_data))
      		data.class.class_eval {attr_accessor :original_filename, :content_type}
      		data.original_filename = self.id.to_s + ".png"
      		data.content_type = "image/png"

      		self.profile_image = data
      	end
  	end
  end
end

Normally paperclip will pickup the post if it’s passed in as the profile_image attribute, however, since the content-type of this POST is not multipart it wont kick into action. We can circumvent that by using a virtual attributes of our model called image_data. We also added a rails before_save callback in order to parse the image data out of the JSON post with the decode_image_data method. We simple decode the image into bits and then set the profile_image which Paperclip is watching for and then everything is back to normal and paperclip will kick off: resize the image and upload to S3.

So basically we are adding inserting a “shim” between POST and Paperclip that allows us to not use a multipart form post. This is because since we are not using a form post rails is not auto creating a temp file (ActionDispatch::Http::UploadedFile) for paper clip to use – so we create one.

##Android
I did a similar post on how to do this w/ android a few years ago: http://eggie5.com/8-hook-share-picture-via-menu-android

##Multipart Post
For reference, this is what a multipart post looks like – compare to JSON payload POST

Remote Address:104.28.0.121:80
Request URL:http://eggie5.com/photos/440
Request Method:POST
Status Code:500 Internal Server Error
Request Headersview source
Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding:gzip,deflate,sdch
Accept-Language:en-US,en;q=0.8
Cache-Control:no-cache
Connection:keep-alive
Content-Length:2789118
Content-Type:multipart/form-data; boundary=----WebKitFormBoundary22u4XWY9BCXeSuqD
Cookie:__cfduid=d1d5f247475e65a3a237383f13a9245701311778252; user_credentials=ef274fad136a2cf0d696494b3a2264512d426939704f9f1c57af78b665ee0e94584a7368661a907f324150440eacad9355af493216f879e9101e590e5b26338e%3A%3A1; _session_id=a21ccc7054c6be0fe836c984f1fc7594; __utma=183315634.1965398001.1311778253.1409233456.1409237409.470; __utmb=183315634.10.10.1409237409; __utmc=183315634; __utmz=183315634.1409064051.464.66.utmcsr=linkedin.com|utmccn=(referral)|utmcmd=referral|utmcct=/
Host:eggie5.com
Origin:http://eggie5.com
Pragma:no-cache
Referer:http://eggie5.com/photos/440/edit
User-Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/36.0.1985.143 Safari/537.36
Request Payload
------WebKitFormBoundary22u4XWY9BCXeSuqD
Content-Disposition: form-data; name="utf8"


------WebKitFormBoundary22u4XWY9BCXeSuqD
Content-Disposition: form-data; name="_method"

put
------WebKitFormBoundary22u4XWY9BCXeSuqD
Content-Disposition: form-data; name="authenticity_token"

R5o8EMzZ9oEe7NFkowtf1oympbB+YG0OkuoqI/Fv1pM=
------WebKitFormBoundary22u4XWY9BCXeSuqD
Content-Disposition: form-data; name="photo[caption]"

via android - Sat Feb 22 14:14:02 GMT+05:30 2014
------WebKitFormBoundary22u4XWY9BCXeSuqD
Content-Disposition: form-data; name="photo[photo]"; filename="DSC_0013.JPG"
Content-Type: image/jpeg


------WebKitFormBoundary22u4XWY9BCXeSuqD
Content-Disposition: form-data; name="commit"



Permalink: ios-rails-image-uploads

Tags:

Last edited by Alex Egg, 2016-10-05 19:12:45
View Revision History