Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created May 11, 2015 12:13
Show Gist options
  • Save bennadel/70f985efd3e3a52e679b to your computer and use it in GitHub Desktop.
Save bennadel/70f985efd3e3a52e679b to your computer and use it in GitHub Desktop.
Learning Node.js: Building A Simple API Powered By MongoDB
// Require our core node modules.
var Q = require( "q" );
// Require our core application modules.
var friendService = require( "./friend-service" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// Export the public methods.
exports.createFriend = createFriend;
exports.deleteFriend = deleteFriend;
exports.getFriend = getFriend;
exports.getFriends = getFriends;
exports.updateFriend = updateFriend;
// ---
// PUBLIC METHODS.
// ---
// I create a new friend.
function createFriend( requestCollection ) {
var name = requestCollection.name;
var description = requestCollection.description;
return( friendService.createFriend( name, description ) );
}
// I delete the given friend.
function deleteFriend( requestCollection ) {
var id = requestCollection.id;
return( friendService.deleteFriend( id ) );
}
// I return the given friend.
function getFriend( requestCollection ) {
var id = requestCollection.id;
return( friendService.getFriend( id ) );
}
// I return all of the friends.
function getFriends( requestCollection ) {
return( friendService.getFriends() );
}
// I update the given friend.
function updateFriend( requestCollection ) {
var id = requestCollection.id;
var name = requestCollection.name;
var description = requestCollection.description;
return( friendService.updateFriend( id, name, description ) );
}
// Require our core node modules.
var ObjectID = require( "mongodb" ).ObjectID;
var Q = require( "q" );
var util = require( "util" );
// Require our core application modules.
var appError = require( "./app-error" ).createAppError;
var mongoGateway = require( "./mongo-gateway" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// Export the public methods.
exports.createFriend = createFriend;
exports.deleteFriend = deleteFriend;
exports.getFriend = getFriend;
exports.getFriends = getFriends;
exports.updateFriend = updateFriend;
// ---
// PUBLIC METHODS.
// ---
// I create a new friend with the given properties. Returns a promise that will resolve
// to the newly inserted friend ID.
function createFriend( name, description ) {
// Test inputs (will throw error if any of them invalid).
testName( name );
testDescription( description );
var promise = getDatabase()
.then(
function handleDatabaseResolve( mongo ) {
var deferred = Q.defer();
mongo.collection( "friend" ).insertOne(
{
name: name,
description: description
},
deferred.makeNodeResolver()
);
return( deferred.promise );
}
)
// When we insert a single document, the resulting object contains metadata about
// the insertion. We don't want that information leaking out into the calling
// context. As such, we want to unwrap that result, and return the inserted ID.
// --
// - result: Contains the operation result.
// - + ok: 1
// - + n: 1
// - ops: Contains the documents inserted with added _id fields.
// - insertedCount: 1
// - insertedId: xxxxxxxxxxxx
// - connection: Contains the connection used to perform the insert.
.get( "insertedId" )
;
return( promise );
};
// I delete the friend with the given ID. Returns a promise.
// --
// CAUTION: If the given friend does not exist, promise will be rejected.
function deleteFriend( id ) {
// Test inputs (will throw error if any of them invalid).
testId( id );
var promise = getDatabase()
.then(
function handleDatabaseResolve( db ) {
var deferred = Q.defer();
db.collection( "friend" ).deleteOne(
{
_id: ObjectID( id )
},
deferred.makeNodeResolver()
);
return( deferred.promise );
}
)
// When we remove a document, the resulting object contains meta information
// about the delete operation. We don't want that information to leak out into
// the calling context; so, let's examine the result and unwrap it.
// --
// - result: Contains the information about the operation:
// - + ok: 1
// - + n: 1
// - connection: Contains the connection used to perform the remove.
// - deletedCount: 1
.then(
function handleResultResolve( result ) {
// If the document was successfully deleted, just echo the ID.
if ( result.deletedCount ) {
return( id );
}
throw(
appError({
type: "App.NotFound",
message: "Friend could not be deleted.",
detail: util.format( "The friend with id [%s] could not be deleted.", id ),
extendedInfo: util.inspect( result.result )
})
);
}
)
;
return( promise );
};
// I get the friend with the given id. Returns a promise.
function getFriend( id ) {
// Test inputs (will throw error if any of them invalid).
testId( id );
var promise = getDatabase()
.then(
function handleDatabaseResolve( mongo ) {
var deferred = Q.defer();
mongo.collection( "friend" ).findOne(
{
_id: ObjectID( id )
},
deferred.makeNodeResolver()
);
return( deferred.promise );
}
)
// If the read operation was a success, the result object will be the document
// that we retrieved from the database. Unlike the WRITE operations, the result
// of a READ operation doesn't contain metadata about the operation.
.then(
function handleResultResolve( result ) {
if ( result ) {
return( result );
}
throw(
appError({
type: "App.NotFound",
message: "Friend could not be found.",
detail: util.format( "The friend with id [%s] could not be found.", id )
})
);
}
)
;
return( promise );
};
// I get all the friends. Returns a promise.
function getFriends() {
var promise = getDatabase().then(
function handleDatabaseResolve( mongo ) {
var deferred = Q.defer();
mongo.collection( "friend" )
.find({})
.toArray( deferred.makeNodeResolver() )
;
return( deferred.promise );
}
);
return( promise );
};
// I update the given friend, assigning the given properties.
// --
// CAUTION: If the given friend does not exist, promise will be rejected.
function updateFriend( id, name, description ) {
// Test inputs (will throw error if any of them invalid).
testId( id );
testName( name );
testDescription( description );
var promise = getDatabase()
.then(
function handleDatabaseResolve( mongo ) {
var deferred = Q.defer();
mongo.collection( "friend" ).updateOne(
{
_id: ObjectID( id )
},
{
$set: {
name: name,
description: description
}
},
deferred.makeNodeResolver()
);
return( deferred.promise );
}
)
// When we update a document, the resulting object contains meta information
// about the update operation. We don't want that information to leak out into
// the calling context; so, let's examine the result and unwrap it.
// --
// - result: Contains the information about the operation:
// - + ok: 0
// - + nModified: 0
// - + n: 0
// - connection: Contains the connection used to perform the update.
// - matchedCount: 0
// - modifiedCount: 0
// - upsertedId: null
// - upsertedCount: 0
.then(
function handleResultResolve( result ) {
// If the document was successfully modified, just echo the ID.
// --
// CAUTION: If the update action doesn't result in modification of the
// document (ie, the document existed, but not values were changed), then
// the modifiedCount:0 but n:1. As such, we have to check n.
if ( result.result.n ) {
return( id );
}
throw(
appError({
type: "App.NotFound",
message: "Friend could not be updated.",
detail: util.format( "The friend with id [%s] could not be updated.", id ),
extendedInfo: util.inspect( result.result )
})
);
}
)
;
return( promise );
};
// ---
// PRIVATE METHODS.
// ---
// I get a MongoDB connection from the resource pool. Returns a promise.
function getDatabase() {
return( mongoGateway.getResource() );
}
// I test the given description for validity.
function testDescription( newDescription ) {
if ( ! newDescription ) {
throw(
appError({
type: "App.InvalidArgument",
message: "Description must be a non-zero length.",
errorCode: "friend.description.short"
})
);
}
}
// I test the given ID for validity.
function testId( newId ) {
if ( ! ObjectID.isValid( newId ) ) {
throw(
appError({
type: "App.InvalidArgument",
message: "Id is not valid.",
detail: util.format( "The id [%s] is not a valid BSON ObjectID.", newId ),
errorCode: "friend.id"
})
);
}
}
// I test the given name for validity.
function testName( newName ) {
if ( ! newName ) {
throw(
appError({
type: "App.InvalidArgument",
message: "Name must be a non-zero length.",
errorCode: "friend.name.short"
})
);
}
if ( newName.length > 30 ) {
throw(
appError({
type: "App.InvalidArgument",
message: "Name must be less than or equal to 30-characters.",
detail: util.format( "The name [%s] is too long.", newName ),
errorCode: "friend.name.long"
})
);
}
}
// Require the core node modules.
var MongoClient = require( "mongodb" ).MongoClient;
var Q = require( "q" );
// Require our core application modules.
var appError = require( "./app-error" ).createAppError;
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// I am the shared MongoClient instance for this process.
var sharedMongoClient = null;
// Export the public methods.
exports.connect = connect;
exports.getResource = getResource;
// ---
// PUBLIC METHODS.
// ---
// I connect to the given MongoDB and store the database instance for use by any context
// that requires this module. Returns a promise.
function connect( connectionString ) {
var deferred = Q.defer();
MongoClient.connect(
connectionString,
function handleConnected( error, mongo ) {
if ( error ) {
deferred.reject( error );
}
deferred.resolve( sharedMongoClient = mongo );
}
);
return( deferred.promise );
}
// I get the shared MongoClient resource.
function getResource() {
if ( ! sharedMongoClient ) {
throw(
appError({
type: "App.DatabaseNotConnected",
message: "The MongoDB connection pool has not been established."
})
);
}
return( Q( sharedMongoClient ) );
}
// Require the core node modules.
var _ = require( "lodash" );
var http = require( "http" );
var url = require( "url" );
var querystring = require( "querystring" );
var Q = require( "q" );
var util = require( "util" );
// Require our core application modules.
var appError = require( "./lib/app-error" ).createAppError;
var friendController = require( "./lib/friend-controller" );
var friendService = require( "./lib/friend-service" );
var mongoGateway = require( "./lib/mongo-gateway" );
var requestBodyStream = require( "./lib/request-body-stream" );
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// Create our server request / response handler.
// --
// NOTE: We are deferring the .listen() call until after we know that we have
// established a connection to the Mongo database instance.
var httpServer = http.createServer(
function handleRequest( request, response ) {
// Always set the CORS (Cross-Origin Resource Sharing) headers so that our client-
// side application can make AJAX calls to this node app (I am letting Apache serve
// the client-side app so as to keep this demo as simple as possible).
response.setHeader( "Access-Control-Allow-Origin", "*" );
response.setHeader( "Access-Control-Allow-Methods", "OPTIONS, GET, POST, DELETE" );
response.setHeader( "Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept" );
// If this is the CORS "pre-flight" test, just return a 200 and halt the process.
// This is just the browser testing to see it has permissions to make CORS AJAX
// requests to the node app.
if ( request.method === "OPTIONS" ) {
return(
response.writeHead( 200, "OK" ),
response.end()
);
}
// For non-GET requests, we will need to accumulate and parse the request body. The
// request-body-stream will emit a "body" event when the incoming request has been
// accumulated and parsed.
var bodyWriteStream = requestBodyStream.createWriteStream()
.on(
"body",
function haneleBodyEvent( body ) {
// Now that we have the body, we're going to merge it together with
// query-string (ie, search) values to provide a unified "request
// collection" that can be passed-around.
var parsedUrl = url.parse( request.url );
// Ensure that the search is defined. If there is no query string,
// search will be null and .slice() won't exist.
var search = querystring.parse( ( parsedUrl.search || "" ).slice( 1 ) );
// Merge the search and body collections into a single collection.
// --
// CAUTION: For this exploration, we are assuming that all POST
// requests contain a serialized hash in JSON format.
processRequest( _.assign( {}, search, body ) );
}
)
.on( "error", processError )
;
request.pipe( bodyWriteStream );
// Once both the query-string and the incoming request body have been
// successfully parsed and merged, route the request into the core application
// (via the Controllers).
function processRequest( requestCollection ) {
var route = ( request.method + ":" + ( requestCollection.action || "" ) );
console.log( "Processing route:", route );
// Default to a 200 OK response. Each route may override this when processing
// the response from the Controller(s).
var statusCode = 200;
var statusText = "OK";
// Since anything inside of the route handling may throw an error, catch any
// error and parle it into an error response.
try {
if ( route === "GET:list" ) {
var apiResponse = friendController.getFriends( requestCollection );
} else if ( route === "GET:get" ) {
var apiResponse = friendController.getFriend( requestCollection );
} else if ( route === "POST:add" ) {
var apiResponse = friendController.createFriend( requestCollection )
.tap(
function handleControllerResolve() {
statusCode = 201;
statusText = "Created";
}
)
;
} else if ( route === "POST:update" ) {
var apiResponse = friendController.updateFriend( requestCollection );
} else if ( route === "POST:delete" ) {
var apiResponse = friendController.deleteFriend( requestCollection )
.tap(
function handleControllerResolve() {
statusCode = 204;
statusText = "No Content";
}
)
;
// If we made it this far, then we did not recognize the incoming request
// as one that we could route to our core application.
} else {
throw(
appError({
type: "App.NotFound",
message: "The requested route is not supported.",
detail: util.format( "The route action [%s] is not supported.", route ),
errorCode: "server.route.missing"
})
);
}
// Render the controller response.
// --
// NOTE: If the API response is rejected, it will be routed to the error
// processor as the fall-through reject-binding.
apiResponse
.then(
function handleApiResolve( result ) {
var serializedResponse = JSON.stringify( result );
response.writeHead(
statusCode,
statusText,
{
"Content-Type": "application/json",
"Content-Length": serializedResponse.length
}
);
response.end( serializedResponse );
}
)
.catch( processError )
;
// Catch any top-level controller and routing errors.
} catch ( controllerError ) {
processError( controllerError );
}
}
// I try to render any errors that occur during the API request routing.
// --
// CAUTION: This method assumes that the header has not yet been committed to the
// response. Since the HTTP response stream never seems to cause an error, I think
// it's OK to assume that any server-side error event would necessarily be thrown
// before the response was committed.
// --
// Read More: http://www.bennadel.com/blog/2823-does-the-http-response-stream-need-error-event-handlers-in-node-js.htm
function processError( error ) {
console.error( error );
console.log( error.stack );
response.setHeader( "Content-Type", "application/json" );
switch ( error.type ) {
case "App.InvalidArgument":
response.writeHead( 400, "Bad Request" );
break;
case "App.NotFound":
response.writeHead( 404, "Not Found" );
break;
default:
response.writeHead( 500, "Server Error" );
break;
}
// We don't want to accidentally leak proprietary information back to the
// user. As such, we only want to send back simple error information that
// the client-side application can use to formulate its own error messages.
response.end(
JSON.stringify({
type: ( error.type || "" ),
code: ( error.errorCode || "" )
})
);
}
}
);
// Establish a connection to our database. Once that is established, we can start
// listening for HTTP requests on the API.
// --
// CAUTION: mongoGateway is a shared-resource module in our node application. Other
// modules will require("mongo-gateway") which exposes methods for getting resources
// out of the connection pool (which is managed automatically by the underlying
// MongoClient instance). It's important that we establish a connection before other
// parts of the application try to use the shared connection pool.
mongoGateway.connect( "mongodb://127.0.0.1:27017/node_mongodb" )
.then(
function handleConnectResolve( mongo ) {
// Start listening for incoming HTTP requests.
httpServer.listen( 8080 );
console.log( "MongoDB connected, server now listening on port 8080." );
},
function handleConnectReject( error ) {
console.log( "Connection to MongoDB failed." );
console.log( error );
}
)
;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment