How to Write An iOS App that Uses a Node.js/MongoDB Web Service

Learn how to write an iOS app that uses Node.js and MongoDB for its back end. By Michael Katz.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Saving Locations to the Server

Unfortunately, loading locations from an empty database isn’t super interesting. Your next task is to implement the ability to save Locations to the database.

Replace the stubbed-out implementation of persist: in Locations.m with the following code:

- (void) persist:(Location*)location
{
    if (!location || location.name == nil || location.name.length == 0) {
        return; //input safety check
    }
    

    NSString* locations = [kBaseURL stringByAppendingPathComponent:kLocations];

    BOOL isExistingLocation = location._id != nil;
    NSURL* url = isExistingLocation ? [NSURL URLWithString:[locations stringByAppendingPathComponent:location._id]] :
    [NSURL URLWithString:locations]; //1
    
    NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url];
    request.HTTPMethod = isExistingLocation ? @"PUT" : @"POST"; //2

    NSData* data = [NSJSONSerialization dataWithJSONObject:[location toDictionary] options:0 error:NULL]; //3
    request.HTTPBody = data;

    [request addValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; //4
    
    NSURLSessionConfiguration* config = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSURLSession* session = [NSURLSession sessionWithConfiguration:config];
    
    NSURLSessionDataTask* dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { //5
        if (!error) {
            NSArray* responseArray = @[[NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]];
            [self parseAndAddLocations:responseArray toArray:self.objects];
        }
    }];
    [dataTask resume];
}

persist: parallels import and also uses a NSURLSession request to the locations endpoint. However, there are just a few differences:

  1. There are two endpoints for saving an object: /locations when you’re adding a new location, and /locations/_id when updating an existing location that already has an id.
  2. The request uses either PUT for existing objects or POST for new objects. The server code calls the appropriate handler for the route rather than using the default GET handler.
  3. Because you’re updating an entity, you provide an HTTPBody in your request which is an instance of NSData object created by the NSJSONSerialization class.
  4. Instead of an Accept header, you’re providing a Content-Type. This tells the bodyParser on the server how to handle the bytes in the body.
  5. The completion handler once again takes the modified entity returned from the server, parses it and adds it to the local collection of Location objects.

Notice just like initWithDictionary:, Location.m already has a helper module to handle the conversion of Location object into a JSON-compatible dictionary as shown below:

#define safeSet(d,k,v) if (v) d[k] = v;
- (NSDictionary*) toDictionary
{
    NSMutableDictionary* jsonable = [NSMutableDictionary dictionary];
    safeSet(jsonable, @"name", self.name);
    safeSet(jsonable, @"placename", self.placeName);
    safeSet(jsonable, @"location", self.location);
    safeSet(jsonable, @"details", self.details);
    safeSet(jsonable, @"imageId", self.imageId);
    safeSet(jsonable, @"categories", self.categories);
    return jsonable;
}

toDictionary contains a magical macro: safeSet(). Here you check that a value isn’t nil before you assign it to a NSDictionary; this avoids raising an NSInvalidArgumentException. You need this check as your app doesn’t force your object’s properties to be populated.

“Why not use an NSCoder?” you might ask. The NSCoding protocol with NSKeyedArchiver does many of the same things as toDictionary and initWithDictionary; namely, provide a key-value conversion for an object.

However, NSKeyedArchiver is set up to work with plists which is a different format with slightly different data types. The way you’re doing it above is a little simpler than repurposing the NSCoding mechanism.

Saving Images to the Server

The starter project already has a mechanism to add photos to a location; this is a nice visual way to explore the data in the app. The pictures are displayed as thumbnails on the map annotation and in the details screen. The Location object already has a stub imageId which provides a link to to a stored file on the server.

Adding an image requires two things: the client-side call to save and load images and the server-side code to store the images.

Return to Terminal, ensure you’re in the server directory, and execute the following command to create a new file to house your file handler code:

edit fileDriver.js

Add the following code to fileDriver.js:

var ObjectID = require('mongodb').ObjectID, 
    fs = require('fs'); //1

FileDriver = function(db) { //2
  this.db = db;
};

This sets up your FileDriver module as follows:

  1. This module uses the filesystem module fs to read and write to disk.
  2. The constructor accepts a reference to the MongoDB database driver to use in the methods that follows.

Add the following code to fileDriver.js, just below the code you added above:

FileDriver.prototype.getCollection = function(callback) {
  this.db.collection('files', function(error, file_collection) { //1
    if( error ) callback(error);
    else callback(null, file_collection);
  });
};

getCollection() looks through the files collection; in addition to the content of the file itself, each file has an entry in the files collection which stores the file’s metadata including its location on disk.

Add the following code below the block you just added above:

//find a specific file
FileDriver.prototype.get = function(id, callback) {
    this.getCollection(function(error, file_collection) { //1
        if (error) callback(error);
        else {
            var checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$"); //2
            if (!checkForHexRegExp.test(id)) callback({error: "invalid id"});
            else file_collection.findOne({'_id':ObjectID(id)}, function(error,doc) { //3
                if (error) callback(error);
                else callback(null, doc);
            });
        }
    });
};

Here’s what’s going on in the code above:

  1. get fetches the files collection from the database.
  2. Since the input to this function is a string representing the object’s _id, you must convert it to a BSON ObjectID object.
  3. findOne() finds a matching entity if one exists.

Add the following code directly after the code you added above:

FileDriver.prototype.handleGet = function(req, res) { //1
    var fileId = req.params.id;
    if (fileId) {
        this.get(fileId, function(error, thisFile) { //2
            if (error) { res.send(400, error); }
            else {
                    if (thisFile) {
                         var filename = fileId + thisFile.ext; //3
                         var filePath = './uploads/'+ filename; //4
    	                 res.sendfile(filePath); //5
    	            } else res.send(404, 'file not found');
            }
        });        
    } else {
	    res.send(404, 'file not found');
    }
};

handleGet is a request handler used by the Express router. It simplifies the server code by abstracting the file handling away from index.js. It performs the following actions:

  1. Fetches the file entity from the database via the supplied id.
  2. Adds the extension stored in the database entry to the id to create the filename.
  3. Stores the file in the local uploads directory.
  4. Calls sendfile() on the response object; this method knows how to transfer the file and set the appropriate response headers.

Once again, add the following code directly underneath what you just added above:

//save new file
FileDriver.prototype.save = function(obj, callback) { //1
    this.getCollection(function(error, the_collection) {
      if( error ) callback(error);
      else {
        obj.created_at = new Date();
        the_collection.insert(obj, function() {
          callback(null, obj);
        });
      }
    });
};

save() above is the same as the one in collectionDriver; it inserts a new object into the files collection.

Add the following code, again below what you just added:

FileDriver.prototype.getNewFileId = function(newobj, callback) { //2
	this.save(newobj, function(err,obj) {
		if (err) { callback(err); } 
		else { callback(null,obj._id); } //3
	});
};
  1. getNewFileId() is a wrapper for save for the purpose of creating a new file entity and returning id alone.
  2. This returns only _id from the newly created object.

Add the following code after what you just added above:

FileDriver.prototype.handleUploadRequest = function(req, res) { //1
    var ctype = req.get("content-type"); //2
    var ext = ctype.substr(ctype.indexOf('/')+1); //3
    if (ext) {ext = '.' + ext; } else {ext = '';}
    this.getNewFileId({'content-type':ctype, 'ext':ext}, function(err,id) { //4
        if (err) { res.send(400, err); } 
        else { 	         
             var filename = id + ext; //5
             filePath = __dirname + '/uploads/' + filename; //6
            
	     var writable = fs.createWriteStream(filePath); //7
	     req.pipe(writable); //8
             req.on('end', function (){ //9
               res.send(201,{'_id':id});
             });               
             writable.on('error', function(err) { //10
                res.send(500,err);
             });
        }
    });
};

exports.FileDriver = FileDriver;

There’s a lot going on in this method, so take a moment and review the above comments one by one:

  1. handleUploadRequest creates a new object in the file collection using the Content-Type to determine the file extension and returns the new object’s _id.
  2. This looks up the value of the Content-Type header which is set by the mobile app.
  3. This tries to guess the file extension based upon the content type. For instance, an image/png should have a png extension.
  4. This saves Content-Type and extension to the file collection entity.
  5. Create a filename by appending the appropriate extension to the new id.
  6. The designated path to the file is in the server’s root directory, under the uploads sub-folder. __dirname is the Node.js value of the executing script’s directory.
  7. fs includes writeStream which — as you can probably guess — is an output stream.
  8. The request object is also a readStream so you can dump it into a write stream using the pipe() function. These stream objects are good examples of the Node.js event-driven paradigm.
  9. on() associates stream events with a callback. In this case, the readStream’s end event occurs when the pipe operation is complete, and here the response is returned to the Express code with a 201 status and the new file _id.
  10. If the write stream raises an error event then there is an error writing the file. The server response returns a 500 Internal Server Error response along with the appropriate filesystem error.

Since the above code expects there to be an uploads subfolder, execute the command below in Terminal to create it:

mkdir uploads

Add the following code to the end of the require block at the top of index.js:

    FileDriver = require('./fileDriver').FileDriver;

Next, add the following code to index.js just below the line var mongoPort = 27017;:

var fileDriver;

Add the following line to index.js just after the line var db = mongoClient.db("MyDatabase");:

In the mongoClient setup callback create an instance of FileDriver after the CollectionDriver creation:

fileDriver = new FileDriver(db);

This creates an instance of your new FileDriver.

Add the following code just before the generic /:collection routing in index.js:

app.use(express.static(path.join(__dirname, 'public')));
app.get('/', function (req, res) {
  res.send('<html><body><h1>Hello World</h1></body></html>');
});

app.post('/files', function(req,res) {fileDriver.handleUploadRequest(req,res);});
app.get('/files/:id', function(req, res) {fileDriver.handleGet(req,res);});

Putting this before the generic /:collection routing means that files are treated differently than a generic files collection.

Save your work, kill your running Node instance with Control+C if necessary and restart it with the following command:

node index.js

Your server is now set up to handle files, so that means you need to modify your app to post images to the server.