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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How to Write An iOS App that Uses a Node.js/MongoDB Web Service
40 mins
- Getting Started
- Setting up Your Node.js Instance
- The Data Model of Your App
- Loading Locations from the Server
- Saving Locations to the Server
- Saving Images to the Server
- Saving Images in your App
- A Quick Recap of File Handling
- Testing it Out
- Querying for Locations
- Using Queries to Filter by Category
- Where to Go From Here?
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:
- 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 anid
. - The request uses either
PUT
for existing objects orPOST
for new objects. The server code calls the appropriate handler for the route rather than using the defaultGET
handler. - Because you’re updating an entity, you provide an
HTTPBody
in your request which is an instance ofNSData
object created by theNSJSONSerialization
class. - Instead of an
Accept
header, you’re providing aContent-Type
. This tells thebodyParser
on the server how to handle the bytes in the body. - 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:
- This module uses the filesystem module fs to read and write to disk.
- 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:
-
get
fetches the files collection from the database. - Since the input to this function is a string representing the object’s
_id
, you must convert it to a BSON ObjectID object. -
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:
- Fetches the file entity from the database via the supplied id.
- Adds the extension stored in the database entry to the id to create the filename.
- Stores the file in the local
uploads
directory. - 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
});
};
-
getNewFileId()
is a wrapper forsave
for the purpose of creating a new file entity and returningid
alone. - 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:
-
handleUploadRequest
creates a new object in the file collection using theContent-Type
to determine the file extension and returns the new object’s_id
. - This looks up the value of the
Content-Type
header which is set by the mobile app. - This tries to guess the file extension based upon the content type. For instance, an
image/png
should have apng
extension. - This saves
Content-Type
andextension
to the file collection entity. - Create a filename by appending the appropriate extension to the new
id
. - 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. -
fs
includeswriteStream
which — as you can probably guess — is an output stream. - The request object is also a
readStream
so you can dump it into a write stream using thepipe()
function. These stream objects are good examples of the Node.js event-driven paradigm. -
on()
associates stream events with a callback. In this case, thereadStream’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
. - 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.