Created
May 11, 2015 12:13
-
-
Save bennadel/70f985efd3e3a52e679b to your computer and use it in GitHub Desktop.
Learning Node.js: Building A Simple API Powered By MongoDB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 ) ); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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" | |
}) | |
); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 ) ); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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