Welcome back to the second part of this two-part tutorial series on creating an iOS app with a Node.js and MongoDB back-end.
In the first part of this series, you created a simple Node.js server to expose MongoDB through a REST API.
In this second and final part of the series, you’ll create a fun iPhone application that lets users tag interesting places near them so other users can discover them.
As part of this process you’ll take the starter app provided and add several things: a networking layer using NSURLSession
, support for geo queries and the ability to store images on the backend.
Getting Started
First things first: download the starter project and extract it to a convenient location on your system.
The zip file contains two folders:
- server contains the javascript server code from the previous tutorial.
- TourMyTown contains the starter Xcode project with the UI pre-built, but no networking code added yet.
Open TourMyTown\TourMyTown.xcodeproj and build and run. You should see something like this:
Right now not much happens, but here’s a sneak peek of what the app will look like when you finish this tutorial:
Users add new location markers to the app along with descriptions, categories and pictures. The Add button places a marker at the center of the map, and the user can drag the marker to the desired location. Alternatively, a tap and hold places a marker at the selected location.
The view delegate uses Core Location’s geo coder functionality to look up the address and place name of the location, if it’s available. Tapping the Info button on the annotation view presents the detail editing screen.
The app saves all data to the backend so that it can be recalled in future sessions.
You’ve got a lot of work to do to transition the app to this state, so let’s get coding!
Setting up Your Node.js Instance
If you didn’t complete the first part of this tutorial or don’t want to use your existing project, you can use the files contained in the server directory as a starting point.
The following instructions take you through setting up your Node.js instance; if you already have your working instance from Part 1 of this tutorial then feel free to skip straight to the next section.
Open Terminal and navigate to the MongoDB install directory — likely /usr/local/opt/mongodb/ but this may be slightly different on your system.
Execute the following command in Terminal to start the MongoDB daemon:
mongod |
Now navigate to the server directory you extracted above. Execute the following command:
npm install |
This reads the package.json file and installs the dependencies for your new server code.
Finally, launch your Node.js server with the following command:
node . |
localhost
, port 3000. This is fine when you’re running the app locally on your simulator, but if you want to deploy the app to a physical device you’ll have to change localhost
to <mac-name>.local
if your Mac and iOS device are on the same network. If they’re not on the same network, then you’ll need to set it to the IP address of your machine. You’ll find these values near the top of Locations.m.The Data Model of Your App
The Location class of your project represents a single point of interest and its associated data. It does the following things:
- Holds the location’s data, including its coordinates, description, and categories.
- Knows how to serialize and deserialize the object to a JSON-compatible
NSDictionary
. - Conforms to the
MKAnnotation
protocol so it can be placed on an instance ofMKMapView
as a pin. - Has zero or more categories as defined in Categories.m.
The Locations class represents your application’s collection of Location objects and the mechanisms that load the objects from the server. This class is responsible for:
- Serving as the app’s data model by providing a filterable list of locations via
filteredObjects
. - Communicating with the server by loading and saving items via
import
,persist
andquery
.
The Categories class contains the list of categories that a Location can belong to and provides the ability to filter the list of locations by category. Categories also does the following:
- Houses
allCategories
which provides the master list of categories. You can also add additional categories to its array. - Provides a list of all categories in the active set of locations.
- Filters the locations by categories.
Loading Locations from the Server
Replace the stubbed-out implementation of import
in Locations.m with the following code:
- (void)import { NSURL* url = [NSURL URLWithString:[kBaseURL stringByAppendingPathComponent:kLocations]]; //1 NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; request.HTTPMethod = @"GET"; //2 [request addValue:@"application/json" forHTTPHeaderField:@"Accept"]; //3 NSURLSessionConfiguration* config = [NSURLSessionConfiguration defaultSessionConfiguration]; //4 NSURLSession* session = [NSURLSession sessionWithConfiguration:config]; NSURLSessionDataTask* dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { //5 if (error == nil) { NSArray* responseArray = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; //6 [self parseAndAddLocations:responseArray toArray:self.objects]; //7 } }]; [dataTask resume]; //8 } |
Here’s what import
does:
- The most important bits of information are the URL and request headers. The URL is simply the result of concatenating the base URL with the “locations” collections.
- You’re using
GET
since you’re reading data from the server. GET is the default method so it’s not necessary to specify it here, but it’s nice to include it for completeness and clarity. - The server code uses the contents of the
Accept
header as a hint to which type of response to send. By specifying that your request will accept JSON as a response, the returned bytes will be JSON instead of the default format of HTML. - Here you create an instance of NSURLSession with a default configuration.
- A data task is your basic
NSURLSession
task for transferring data from a web service. There are also specialized upload and download tasks that have specialized behavior for long-running transfers and background operation. A data task runs asynchronously on a background thread, so you use a callback block to be notified when the operation completes or fails. - The completion handler checks for any errors; if it finds none it tries to deserialize the data using a
NSJSONSerialization
class method. - Assuming the return value is an array of locations,
parseAndAddLocations:
parses the objects and notifies the view controller with the updated data. - Oddly enough, data tasks are started with the
resume
message. When you create an instance of NSURLSessionTask it starts in the “paused” state, so to start it you simply callresume
.
Still working in the same file, replace the stubbed-out implementation of parseAndAddLocations:
with the following code:
- (void)parseAndAddLocations:(NSArray*)locations toArray:(NSMutableArray*)destinationArray //1 { for (NSDictionary* item in locations) { Location* location = [[Location alloc] initWithDictionary:item]; //2 [destinationArray addObject:location]; } if (self.delegate) { [self.delegate modelUpdated]; //3 } } |
Taking each numbered comment in turn:
- You iterate through the array of JSON dictionaries and create a new Location object for each item.
- Here you use a custom initializer to turn the deserialized JSON dictionary into an instance of Location.
- The model signals the UI that there are new objects available.
Working together, these two methods let your app load the data from the server on startup. import
relies on NSURLSession
to handle the heavy lifting of networking. For more information on the inner workings of NSURLSession
, check out the NSURLSession on this site.
Notice the Location
class already has the following initializer which simply takes the various values in the dictionary and sets the corresponding object properties appropriately:
- (instancetype) initWithDictionary:(NSDictionary*)dictionary { self = [super init]; if (self) { self.name = dictionary[@"name"]; self.location = dictionary[@"location"]; self.placeName = dictionary[@"placename"]; self.imageId = dictionary[@"imageId"]; self.details = dictionary[@"details"]; _categories = [NSMutableArray arrayWithArray:dictionary[@"categories"]]; } return self; } |
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.
Saving Images in your App
The Location class has two properties: image
and imageId
. imageId
is the backend property that links the entity in the locations
collection to the entity in the files
collection. If this were a relational database, you’d use a foreign key to represent this link. image
stores the actual UIImage
object.
Saving and loading files requires an extra request for each object to transfer the file data. The order of operations is important to make sure the file id is property associated with the object. When you save a file, you must send the file first in order to receive the associated id to link it with the location’s data.
Add the following code to the bottom of Locations.m:
- (void) saveNewLocationImageFirst:(Location*)location { NSURL* url = [NSURL URLWithString:[kBaseURL stringByAppendingPathComponent:kFiles]]; //1 NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; request.HTTPMethod = @"POST"; //2 [request addValue:@"image/png" forHTTPHeaderField:@"Content-Type"]; //3 NSURLSessionConfiguration* config = [NSURLSessionConfiguration defaultSessionConfiguration]; NSURLSession* session = [NSURLSession sessionWithConfiguration:config]; NSData* bytes = UIImagePNGRepresentation(location.image); //4 NSURLSessionUploadTask* task = [session uploadTaskWithRequest:request fromData:bytes completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { //5 if (error == nil && [(NSHTTPURLResponse*)response statusCode] < 300) { NSDictionary* responseDict = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; location.imageId = responseDict[@"_id"]; //6 [self persist:location]; //7 } }]; [task resume]; } |
This is a fairly busy module, but it’s fairly straightforward when you break it into small chunks:
- The URL is the files endpoint.
- Using
POST
triggershandleUploadRequest
offileDriver
to save the file. - Setting the content type ensures the file will be saved appropriately on the server. The
Content-Type
header is important for determining the file extension on the server. UIImagePNGRepresentation
turns an instance ofUIImage
into PNG file data.NSURLSessionUploadTask
lets you sendNSData
to the server in the request itself. For example, upload tasks automatically set theContent-Length
header based on the data length. Upload tasks also report progress and can run in the background, but neither of those features is used here.- The response contains the new file data entity, so you save
_id
along with the location object for later retrieval. - Once the image is saved and
_id
recorded, then the main Location entity can be saved to the server.
Add the following code to persist:
in Location.m just after the if (!location || location.name == nil || location.name.length == 0)
block’s closing brace:
- (void) persist:(Location*)location //if there is an image, save it first if (location.image != nil && location.imageId == nil) { //1 [self saveNewLocationImageFirst:location]; //2 return; } |
This checks for the presence of a new image, and saves the image first. Taking each numbered comment in turn, you’ll find the following:
- If there is an image but no image id, then the image hasn’t been saved yet.
- Call the new method to save the image, and exits.
Once the save is complete, persist:
will be called again, but at that point imageId
will be non-nil, and the code will proceed into the existing procedure for saving the Location entity.
Next replace the stub method loadImage:
in Location.m with the following code:
- (void)loadImage:(Location*)location { NSURL* url = [NSURL URLWithString:[[kBaseURL stringByAppendingPathComponent:kFiles] stringByAppendingPathComponent:location.imageId]]; //1 NSURLSessionConfiguration* config = [NSURLSessionConfiguration defaultSessionConfiguration]; NSURLSession* session = [NSURLSession sessionWithConfiguration:config]; NSURLSessionDownloadTask* task = [session downloadTaskWithURL:url completionHandler:^(NSURL *fileLocation, NSURLResponse *response, NSError *error) { //2 if (!error) { NSData* imageData = [NSData dataWithContentsOfURL:fileLocation]; //3 UIImage* image = [UIImage imageWithData:imageData]; if (!image) { NSLog(@"unable to build image"); } location.image = image; if (self.delegate) { [self.delegate modelUpdated]; } } }]; [task resume]; //4 } |
Here’s what’s going on in the code above:
- Just like when loading a specific location, the image’s id is appended to the path along with the name of the endpoint:
files
. - The download task is the third kind of
NSURLSession
; it downloads a file to a temporary location and returns a URL to that location, rather than the rawNSData
object, as the raw object can be rather large. - The temporary location is only guaranteed to be available during the completion block’s execution, so you must either load the file into memory, or move it somewhere else.
- Like all
NSURLSession
tasks, you start the task withresume
.
Next, replace the current parseAndAddLocations:toArray:
with the following code:
- (void)parseAndAddLocations:(NSArray*)locations toArray:(NSMutableArray*)destinationArray { for (NSDictionary* item in locations) { Location* location = [[Location alloc] initWithDictionary:item]; [destinationArray addObject:location]; if (location.imageId) { //1 [self loadImage:location]; } } if (self.delegate) { [self.delegate modelUpdated]; } } |
This updated version of parseAndAddlocations
checks for an imageId; if it finds one, it calls loadImage:
.
A Quick Recap of File Handling
To summarize: file transfers in an iOS app work conceptually the same way as regular data transfers. The big difference is that you’re using NSURLSessionUploadTask
and NSURLSessionDownloadTask
objects and semantics that are slightly different from NSURLSessionDataTask
.
On the server side, file wrangling is a fairly different beast. It requires a special handler object that communicates with the filesystem instead of a Mongo database, but still needs to store some metadata in the database to make retrieval easier.
Special routes are then set up to map the incoming HTTP verb and endpoint to the file driver. You could accomplish this with generic data endpoints, but the code would get quite complicated when determining where to persist the data.
Testing it Out
Build and run your app and add a new location by tapping the button in the upper right.
As part of creating your new location, add an image. Note that you can add images to the simulator by long-pressing on pictures in Safari.
Once you’ve saved your new location, restart the app — and lo and behold, the app reloads your data without a hitch, as shown in the screenshot below:
Querying for Locations
Your ultra-popular Tour My Town app will collect a ton of data incredibly quickly after it’s released. To prevent long wait times while downloading all of the data for the app, you can limit the amount of data retrieved by using location-based filtering. This way you only retrieve the data that’s going to be shown on the screen.
MongoDB has a powerful feature for finding entities that match a given criteria. These criteria can be basic comparisons, type checking, expression evaluation (including regular expression and arbitrary javascript), and geospatial querying.
The geospatial querying of MongoDBis a natural fit with a map-based application. You can use the extents of the map view to obtain only the subset of data that will be shown on the screen.
Your next task is to modify collectionDriver.js to supply filter criteria with a GET request.
Add the following method above the final exports
line in collectionDriver.js:
//Perform a collection query CollectionDriver.prototype.query = function(collectionName, query, callback) { //1 this.getCollection(collectionName, function(error, the_collection) { //2 if( error ) callback(error) else { the_collection.find(query).toArray(function(error, results) { //3 if( error ) callback(error) else callback(null, results) }); } }); }; |
Here’s how the above code functions:
query
is similar to the existingfindAll
, except that it has aquery
parameter for specifying the filter criteria.- You fetch the collection access object just like all the other methods.
CollectionDriver
‘sfindAll
method usedfind()
with no arguments, but here thequery
object is passed in as an argument. This will be passed along to MongoDB for evaluation so that only the matching documents will be returned in the result.
Note: This passes in the query object directly to MongoDB. In an open API case, this can be dangerous since MongoDB permits arbitrary JavaScript using the $where
query operator. This runs the risk of crashes, unexpected results, or security concerns; but in this tutorial project which uses a limited set of operations, it is a minor concern.
Go back to index.js and replace the current app.get('/:collection'...
block with the following:
app.get('/:collection', function(req, res, next) { var params = req.params; var query = req.query.query; //1 if (query) { query = JSON.parse(query); //2 collectionDriver.query(req.params.collection, query, returnCollectionResults(req,res)); //3 } else { collectionDriver.findAll(req.params.collection, returnCollectionResults(req,res)); //4 } }); function returnCollectionResults(req, res) { return function(error, objs) { //5 if (error) { res.send(400, error); } else { if (req.accepts('html')) { //6 res.render('data',{objects: objs, collection: req.params.collection}); } else { res.set('Content-Type','application/json'); res.send(200, objs); } } }; }; |
- HTTP queries can be added to the end of a URL in the form
http://domain/endpoint?key1=value1&key2=value2...
.req.query
gets the whole “query” part of the incoming URL. For this application the key is “query” (hencereq.query.query
) - The query value should be a string representing a MongoDB condition object.
JSON.parse()
turns the JSON-string into a javascript object that can be passed directly to MongoDB. - If a query was supplied to the endpoint, call
collectionDriver.query()
returnCollectionResults is a common helper function that formats the output of the request. - If no query was specified, then
collectionDriver.findAll
returns all the items in the collection. - Since
returnCollectionResults()
is evaluated at the time it is called, this function returns a callback function for the collection driver. - If the request specified HTML for the response, then render the data table in HTML; otherwise return it as a JSON document in the body.
Save your work, kill your Node.js instance and restart it with the following command:
node index.js |
Now that the server is set up for queries, you can add the geo-querying functions to the app.
Replace the stubbed-out implementation of queryRegion
of Locations.m with the following code:
- (void) queryRegion:(MKCoordinateRegion)region { //note assumes the NE hemisphere. This logic should really check first. //also note that searches across hemisphere lines are not interpreted properly by Mongo CLLocationDegrees x0 = region.center.longitude - region.span.longitudeDelta; //1 CLLocationDegrees x1 = region.center.longitude + region.span.longitudeDelta; CLLocationDegrees y0 = region.center.latitude - region.span.latitudeDelta; CLLocationDegrees y1 = region.center.latitude + region.span.latitudeDelta; NSString* boxQuery = [NSString stringWithFormat:@"{\"$geoWithin\":{\"$box\":[[%f,%f],[%f,%f]]}}",x0,y0,x1,y1]; //2 NSString* locationInBox = [NSString stringWithFormat:@"{\"location\":%@}", boxQuery]; //3 NSString* escBox = (NSString *)CFBridgingRelease(CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef) locationInBox, NULL, (CFStringRef) @"!*();':@&=+$,/?%#[]{}", kCFStringEncodingUTF8)); //4 NSString* query = [NSString stringWithFormat:@"?query=%@", escBox]; //5 [self runQuery:query]; //7 } |
This is a fairly straightforward block of code; queryRegion:
turns a Map Kit region generated from a MKMapView into a bounded-box query. Here’s how it does it:
- These four lines calculate the map-coordinates of the two diagonal corners of the bounding box.
- This defines a JSON structure for the query using MongoDB’s specific query language.
A query with a$geoWithin
key specifies the search criteria as everything located within the structure defined by the provided value.$box
specifies the rectangle defined by the provided coordinates and supplied as an array of two longitude-latitude pairs at opposite corners. boxQuery
just defines the criteria value; you also have to provide the search key field alongboxQuery
as a JSON object to MongoDB.- You then escape the entire query object as it will be posted as part of a URL; you need to ensure that that internal quotes, brackets, commas, and other non-alphanumeric bits won’t be interpreted as part of the HTTP query parameter.
CFURLCreateStringByAddingPercentEscapes
is a CoreFoundation method for creating URL-encoded strings. - The final piece of the string building sets the entire escaped MongoDB query as the query value in the URL.
- You then request matching values from the server with your new query.
Replace the stubbed-out implementation of runQuery:
in Locations.m with the following code:
- (void) runQuery:(NSString *)queryString { NSString* urlStr = [[kBaseURL stringByAppendingPathComponent:kLocations] stringByAppendingString:queryString]; //1 NSURL* url = [NSURL URLWithString:urlStr]; NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; request.HTTPMethod = @"GET"; [request addValue:@"application/json" forHTTPHeaderField:@"Accept"]; NSURLSessionConfiguration* config = [NSURLSessionConfiguration defaultSessionConfiguration]; NSURLSession* session = [NSURLSession sessionWithConfiguration:config]; NSURLSessionDataTask* dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { if (error == nil) { [self.objects removeAllObjects]; //2 NSArray* responseArray = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; NSLog(@"received %d items", responseArray.count); [self parseAndAddLocations:responseArray toArray:self.objects]; } }]; [dataTask resume]; } |
runQuery:
is very similar to import
but has two important differences:
- You add the query string generated in
queryRegion:
to the end of the locations endpoint URL. - You also discard the previous set of locations and replace them with the filtered set returned from the server. This keeps the active results at a manageable level.
Build and run your app; create a few new locations of interest that are spread out on the map. Zoom in a little, then pan and zoom the map and watch NSLog display the changing count of the items both inside and outside the map range, as shown below:
Using Queries to Filter by Category
The last bit to add categories to your Locations that users can filter on. This filtering can re-use the server work done in the previous section through the use of MongoDB’s array conditional operators.
Replace the stubbed-out implementation of query
in Categories.m with the following code:
+ (NSString*) query { NSArray* a = [self filteredCategories:YES]; //1 NSString* query = @""; if (a.count > 0) { query = [NSString stringWithFormat:@"{\"categories\":{\"$in\":[%@]}}", [a componentsJoinedByString:@","]]; //2 query = (NSString *)CFBridgingRelease(CFURLCreateStringByAddingPercentEscapes(NULL, (CFStringRef) query, NULL, (CFStringRef) @"!*();':@&=+$,/?%#[]{}", kCFStringEncodingUTF8)); query = [@"?query=" stringByAppendingString:query]; } return query; } |
This creates a query string similar to the one used by the geolocation query that has the following differences:
- This is the list of selected categories.
- The
$in
operator accepts a MongoDB document if the specified propertycategories
has a value matching any of the items in the corresponding array.
Build and run your app; add a few Locations and assign them one or more categories. Tap the folder icon and select a category to filter on. The map will reload just the Location annotations matching the selected categories as shown below:
Where to Go From Here?
You can download the completed sample project here.
In this tutorial you covered the basics of MongoDB storage — but there’s a ton of functionality beyond what you covered here.
MongoDB offers a multitude of options for selecting data out of the database; as well there are a host of server-side features to manage scaling and security. As well, your Node.js installation could definitely be improved by adding user authentication and more privacy around the data.
As for your iOS app, you could add a pile of interesting features, including the following:
- Routing users to points of interest
- Adding additional media to locations
- Improved text editing
Additionally, every decent networked app should cache data locally so it remains functional when data connections are spotty.
Hopefully you’ve enjoyed this small taste of Node.js, Express and MongoDB — if you have any questions or comments please come join the discussion below!
How to Write An iOS App that Uses a Node.js/MongoDB Web Service is a post from: Ray Wenderlich
The post How to Write An iOS App that Uses a Node.js/MongoDB Web Service appeared first on Ray Wenderlich.