├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib ├── Controller.js ├── Model.js └── simple-api.js ├── package.json └── test ├── .DS_Store ├── mocha.opts ├── simple ├── controllers │ ├── convenience.js │ └── object.js ├── index.js └── models │ └── object.js └── simpleAPI.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple-API (v0.1) 2 | 3 | Simple-API is an easy-to-use API scaffolding module for Node.js. It creates a clean M ~~V~~ C structure for APIs and handles all the URL parsing and routing. 4 | 5 | ## Install 6 | ``` 7 | npm install simple-api 8 | ``` 9 | 10 | ## Usage 11 | 12 | ### You and Your Server 13 | 14 | A very basic API server is incredibly easy to start. 15 | 16 | ``` 17 | var api = require("simple-api"); 18 | 19 | var v0 = new api({ 20 | prefix: ["api", "v0"], 21 | port: 8080, 22 | host: "localhost", 23 | logLevel: 5 24 | }); 25 | ``` 26 | 27 | You now have an API running on http://localhost:8080, responding to anything with the prefix [/api/v0](http://localhost:8080/api/v0). Of course, your API doesn't do anything yet because you haven't built Models or Controllers, but we haven't gotten there yet. We still have to get through these startup options. 28 | 29 | #### Serving Non-API Requests 30 | 31 | Simple-API provides a `fallback` option in the case that your server receives a request that doesn't match the API prefix. The fallback option defaults to sending a 404 request, but you can override that with anything you want. Just provide a callback that receives a HTTPRequest and HTTPResponse paramters. 32 | 33 | ``` 34 | var api = require("simple-api"); 35 | 36 | var v0 = new api({ 37 | prefix: ["api", "v0"], 38 | port: 8080, 39 | host: "localhost", 40 | fallback: function(req, res) { 41 | res.end("fallback"); 42 | console.log("Fallback Hit"); 43 | }, 44 | host: "localhost", 45 | logLevel: 5 46 | }); 47 | ``` 48 | 49 | Also as a convenience, `v0.app` is the [HTTP Server Object](http://nodejs.org/api/http.html#http_class_http_server). 50 | 51 | 52 | #### Before API Request Hook 53 | 54 | Sometimes you might want to run some code before every API request. Perhaps this is for authentication or session management. Simple-API has a before hook that will let you run any code, including manipulation of the request and response objects. It also allows you to abort the API request and go to the fallback option. 55 | 56 | The following code will fallback all API requests to the `users` controller. Returning any value other than `FALSE` will allow simple-api to continue the API call. 57 | 58 | ``` 59 | var api = require("simple-api"); 60 | 61 | var v0 = new api({ 62 | prefix: ["api", "v0"], 63 | port: 8080, 64 | host: "localhost", 65 | before: function(req, res, controller) { 66 | if(controller === "users") { 67 | return false; 68 | } 69 | }, 70 | host: "localhost", 71 | logLevel: 5 72 | }); 73 | ``` 74 | 75 | 76 | #### A Word About Log Levels 77 | 78 | The `logLevel` option when starting your server tells Simple-API what amount of logs you want to receive. The higher the number you provide, the less logs you will receive. Obviously, high priority logs are paired with high numbers. 79 | 80 | *I've done my best to not go over `4` with any of my logs. Generally anything more important than that is really an error I should be throwing* 81 | 82 | ### Controllers 83 | 84 | This is where stuff gets fun. Controllers are where you build in all of the routes and actions that will map to what your frontend system's require. The basic structure of a controller is as follows. 85 | 86 | ``` 87 | var ObjectController = { 88 | options: { 89 | 90 | }, 91 | routes: { 92 | 93 | }, 94 | actions: { 95 | 96 | }, 97 | helpers: { 98 | 99 | } 100 | }; 101 | 102 | module.exports = exports = ObjectController; 103 | ``` 104 | 105 | #### Options 106 | 107 | There are no options yet. See the [Contributing](https://github.com/josephwegner/simple-api#contributing) section for more info on that 108 | 109 | #### Routes 110 | 111 | A route should map to a single action on a controller. Additionally, routes are filtered by the request method (GET, PUT, POST, DELETE). If a user requests a page that doesn't match a route, they will be served a 404. 112 | 113 | A routing entry is formatted as an array, where each element matches one _piece_ of the URL. A piece is defined as something inbetween the slashes. (ie: in http://github.com/go/get/some/work/done the pieces are go, get some, work, and done). The routing mechanism automatically chooses the first piece (after the hostname) of the URL as the controller name. Everything after that is used to match the route. 114 | 115 | ``` 116 | ... 117 | 118 | routes: { 119 | myGETControllerAction: { 120 | method: "GET", 121 | path: ["do", "stuff"] //Responds to http://hostname/api/controller/do/stuff 122 | } 123 | }, 124 | 125 | ... 126 | ``` 127 | 128 | ##### Getting Parameters from the URL 129 | 130 | The routing mechanism can parse complex data out of portions of the URL to serve as parameters. For example, on a social network an API endpoint might look like this: 131 | 132 | `https://api.twitter.com/user/Joe_Wegner/follow/kwegner` 133 | 134 | In that scenario, the API needs to be able to successfully route on dynamic URLs, but also gather that information ("Joe_Wegner" and "kwegner") so it can do work. 135 | 136 | In order to increase the accuracy of the routes, Simple-API provides 4 match types. 137 | 138 | - String 139 | - Numerical 140 | - Mixed (String & Numerical) 141 | - RegExp 142 | 143 | In your route you will choose a match type and then specify a name for that parameter. When your controller action is called, it will be passed a third argument, which is an object containing all of the parameters 144 | 145 | ``` 146 | ... 147 | 148 | actions: { 149 | getObjectInfo: function(req, res, params) { 150 | 151 | } 152 | }, 153 | 154 | ... 155 | ``` 156 | 157 | Route paths can be defined either as an array or a string. If you choose to use an array, each element should represent a piece of the path *(ie: the stuff between the slashes)*. Each of the examples below will have the path defined both as an array and as a string. 158 | 159 | ###### String (:parameter) 160 | 161 | Match string parameters by placing a colon ( `:` ) 162 | 163 | ``` 164 | ... 165 | 166 | 167 | { 168 | method: "GET", 169 | path: [":username", "info"] //Matches http://hostname/api/user/Joe_Wegner/info 170 | //or path: ":username/info" 171 | } 172 | 173 | ... 174 | ``` 175 | 176 | ###### Numerical (%parameter) 177 | 178 | Match string parameters by placing a colon ( `%` ) 179 | 180 | ``` 181 | ... 182 | 183 | 184 | { 185 | method: "GET", 186 | path: ["%account_id", "status"] //Matches http://hostname/api/account/834987/status 187 | //or path: "%account_id/status" 188 | } 189 | 190 | ... 191 | ``` 192 | 193 | ###### Mixed (*parameter) _AlphaNumeric, including capitals and lowercase_ 194 | 195 | Match string parameters by placing a colon ( `*` ) 196 | 197 | ``` 198 | ... 199 | 200 | 201 | { 202 | method: "GET", 203 | path: ["*hash", "diff"] //Matches http://hostname/api/revision/4d95a875d8c45aa228602a6de42a9c56e5b6a9a8/diff 204 | //or path: "*hash/diff" 205 | } 206 | 207 | ... 208 | ``` 209 | 210 | ###### RegExp ([[A-Z0-9]+]parameter) 211 | 212 | Match string parameters by placing a colon ( `[regexp]` ) 213 | 214 | ``` 215 | ... 216 | 217 | 218 | { 219 | method: "GET", 220 | path: ["[[A-Z0-9]+]code", "access"] //Matches http://hostname/api/secret/ABC123/access 221 | //or path: "[[A-Z0-9]+]code/access" 222 | } 223 | 224 | ... 225 | ``` 226 | 227 | #### Actions 228 | 229 | Actions get called by an associated route, and are called with three parameters: 230 | 231 | - `req`: The [Node.js HTTP Server Request Object](http://nodejs.org/api/http.html#http_class_http_serverrequest) 232 | - `res`: The [Node.js HTTP Server Response Object](http://nodejs.org/api/http.html#http_class_http_serverresponse) 233 | - `params`: An object containing any matched params from the URL 234 | 235 | Within the context of a controller action, `this` refers to the controller. That means you can access any of your helper functions using `this.helpers`. 236 | You can also access the master API object from `this.api`. 237 | 238 | Obviously, because you are holding the HTTP request, actions are expected to call `res.end()` to finish the connection. 239 | 240 | ``` 241 | ... 242 | 243 | 244 | getObjectInfo: function(req, res, params) { 245 | 246 | var info; 247 | //Do some work to get the object info, and store it in var info 248 | 249 | res.write(JSON.stringify(info)); 250 | res.end(); 251 | 252 | } 253 | 254 | ... 255 | ``` 256 | 257 | #### Convenience Functions 258 | 259 | Simple-API defines a number of convenient response functions that are commonly used in APIs. All of the convience functions can be passed either a string or an object as an optional message; objects will be output as JSON. Convenience functions are members of the `responses` key on the controller, so can be accessed within an action as `this.responses.response(res, ...);`. As you can see, the first parameter for each convenience function is the HTTP response object. 260 | 261 | - **notAvailable**: Returns a 404 ~ `(res, *message*)` 262 | - **notAuth**: Returns a 404 ~ `(res, *message*)` 263 | - **redirect**: Returns a 301/302 ~ `(res, destination, *permanent* (defaults to false)*)` 264 | - **response**: Abstract response, you define everything ~ `(res, *message*, *statusCode*)` 265 | 266 | These convenience functions also exist on the API object under the `responses` key so that you can access them from any event hooks. They function identically as in controllers, so you would call `v0.responses.respond(res, ...);`. 267 | 268 | ### Models 269 | 270 | I haven't really finalized my vision of Models yet, so I haven't fully implemented them. Please see the [Contributing](https://github.com/josephwegner/simple-api#contributing) section to learn more 271 | 272 | ## Contributing 273 | 274 | **Simple-API is young**. So far it's only got one developer, which means only one brain feeding into what a great Node.js API Library looks like. 275 | 276 | PLEASE contribute. If that means spending hours churning out code, **awesome**. If that means shooting me an email for a great feature, wonderful. If that means putting in bug reports, splendid. Best of all, if that means using Simple-API for a real project, and giving me feedback on where it lacked and where it was great, then I love you. 277 | 278 | As you can see, entire sections of the codebase are currently left out waiting to hear back about real usage (controller options, models, convenience functions, etc.). I need to hear from you! Check out the Author section, or drop a note on the issues page. 279 | 280 | ## Thanks 281 | 282 | - [@Joe_Wegner](http://www.twitter.com/Joe_Wegner) from [WegnerDesign](http://www.wegnerdesign.com). 283 | - [@jessepollack](http://www.twitter.com/jessepollak) from [Clef](http://getclef.com) 284 | 285 | ## License 286 | This project is licensed under the [MIT license](http://opensource.org/licenses/MIT). 287 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require(__dirname + "/lib/simple-api.js"); -------------------------------------------------------------------------------- /lib/Controller.js: -------------------------------------------------------------------------------- 1 | var Controller = function(api, configuration) { 2 | this.api = api; 3 | this.options = {}; 4 | this.routes = {}; 5 | this.actions = {}; 6 | this.helpers = {}; 7 | 8 | this.responses = api.responses; 9 | 10 | if(typeof(configuration.options) === "object") this.options = configuration.options; 11 | if(typeof(configuration.routes) === "object") this.routes = configuration.routes; 12 | if(typeof(configuration.actions) === "object") this.actions = configuration.actions; 13 | if(typeof(configuration.helpers) === "object") this.helpers = configuration.helpers; 14 | 15 | for(var funcName in this.helpers) { 16 | this.helpers[funcName] = wrapFunction(this.helpers[funcName], this); 17 | } 18 | }; 19 | 20 | Controller.prototype.checkRoute = function(req, urlParts) { 21 | var selectedRoute = false; 22 | var curAction = false; 23 | var params = {}; 24 | 25 | for(var action in this.routes) { 26 | curAction = action; 27 | var route = this.routes[action]; 28 | 29 | if(typeof(route.path) === "string" && route.path.substr(0, 1) === "/") { 30 | route.path = route.path.substr(1); 31 | } 32 | 33 | var routePieces = typeof(route.path) === "string" ? route.path.split("/") : route.path; 34 | 35 | //Before we get fancy, check to see if the url and the route have the same number of parts 36 | if(routePieces.length !== urlParts.length) { 37 | continue; 38 | } 39 | 40 | if(route.method.toUpperCase() !== req.method.toUpperCase()) { 41 | continue; 42 | } 43 | 44 | 45 | //Store any matched params here 46 | params = {}; 47 | 48 | //If we determine that it's not this route somewhere in the inner loop 49 | //we will set cont = true, and then break the inner loop. Then we can continue the outer loop 50 | var cont = false; 51 | var i, max; 52 | 53 | for(i=0, max=routePieces.length; i 0 && exec[0].length === urlPart.length) { 100 | params[routePart.substr(routePart.lastIndexOf("]") + 1)] = urlPart; 101 | } else { 102 | cont = true; 103 | } 104 | } else { 105 | //This doesn't actually look like a regexp, so just match it like a 1-to-1 string 106 | if(routePart !== urlPart) { 107 | cont = true; 108 | } 109 | } 110 | break; 111 | 112 | default: //1-to-1 string 113 | if(routePart !== urlPart) { 114 | cont = true; 115 | } 116 | 117 | break; 118 | 119 | } 120 | 121 | //If continue is true, then we break out of this loop, and continue on the next one. This means 122 | //that the current route did not match 123 | if(cont) { 124 | this.api.private.debug("Route did not match "+curAction, 1); 125 | break; 126 | } 127 | 128 | } 129 | 130 | //The only way the code can get to here and cont still be false is if the previous route passed on all measures 131 | //If that's the case 'Move Along' 132 | if(cont) { 133 | continue; 134 | } 135 | 136 | //So, presumably, if we get to this point then we have the correct route. Like I said, 'Move Along' 137 | selectedRoute = curAction; 138 | this.api.private.debug("Found matching route "+ curAction, 1); 139 | break; 140 | 141 | } 142 | 143 | if(selectedRoute === false) { 144 | return false; 145 | } else { 146 | return { 147 | meta: this.routes[selectedRoute], 148 | handler: function(req, res) { 149 | if(typeof(this.actions[selectedRoute]) === "function") { 150 | this.actions[selectedRoute].call(this, req, res, params); 151 | } else { 152 | this.api.responses.internalError(res); 153 | this.api.private.debug("We found a matching route, but there is no action for that route!", 3); 154 | throw new Error("Simple-API: Selected Route has no matching Action: " + selectedRoute); 155 | } 156 | } 157 | } 158 | } 159 | } 160 | 161 | /** Helper Functions **/ 162 | var wrapFunction = function(func, that) { 163 | return function() { 164 | return func.apply(that, arguments); 165 | } 166 | } 167 | 168 | module.exports = exports = Controller; -------------------------------------------------------------------------------- /lib/Model.js: -------------------------------------------------------------------------------- 1 | var Model = function() { 2 | 3 | } 4 | 5 | module.exports = exports = Model; -------------------------------------------------------------------------------- /lib/simple-api.js: -------------------------------------------------------------------------------- 1 | /* Dependencies */ 2 | var http = require('http'); 3 | var url = require('url'); 4 | var formidable = require('formidable'); 5 | var Controller = require(__dirname + "/Controller.js"); 6 | var Model = require(__dirname + "/Model.js"); 7 | 8 | /* Simple-API */ 9 | 10 | var API = function(options) { 11 | 12 | /* Do something with default options here */ 13 | 14 | this.options = options; 15 | this.controllers = {}; 16 | this.models = {}; 17 | this.private.API = this; 18 | 19 | var that = this; 20 | this.app = http.createServer(function(req, res) { 21 | that.private.handleHTTPRequest.call(that, req, res); 22 | }).listen(this.options.port, this.options.host); 23 | this.private.debug("simple-api listening on http://"+this.options.host+":"+this.options.port, 4); 24 | 25 | return this; 26 | } 27 | 28 | API.prototype.private = { 29 | 30 | sendError: function(msg) { 31 | console.log(Date.now() + " API ERROR: " + msg); 32 | }, 33 | 34 | //This function will return false if it is not an API call, or a string containing the remainder of the URL if it is an API call 35 | isAPICall: function(req) { 36 | var urlObj = url.parse(req.url); 37 | var parts = urlObj.pathname.split("/"); 38 | //Get rid of the empty element because of the leading slash 39 | parts.shift(); 40 | 41 | switch(typeof(this.options.prefix)) { 42 | 43 | case "string": 44 | //Rejoin the parts, because the user wants to use a simple string as a prefix 45 | if(parts.join("").indexOf(this.options.prefix) === 0) { 46 | return parts.join("").substr(this.options.prefix.length); 47 | } else { 48 | return false; 49 | } 50 | 51 | break; 52 | 53 | case "object": 54 | if(typeof(this.options.prefix.length) === "undefined") { 55 | this.private.sendError("Prefix must be a string or array!"); 56 | return false; 57 | } 58 | 59 | //We only want to check as much of the URL as the user provided in prefix. Strip out anything else for now. 60 | var importantParts = parts.slice(0, this.options.prefix.length); 61 | 62 | //So, we rejoin them here just like they're a string. However, I still thing the array option 63 | //Provides a little bit more accuracy. WE'RE LEAVING IT IN! 64 | if(importantParts.join("/") === this.options.prefix.join("/")) { 65 | return parts.slice(this.options.prefix.length).join("/"); 66 | } else { 67 | return false; 68 | } 69 | 70 | break; 71 | 72 | } 73 | }, 74 | 75 | handleHTTPRequest: function(req, res) { 76 | this.private.debug("Got HTTP Request", 1); 77 | 78 | var url; 79 | if((url = this.private.isAPICall.call(this, req)) !== false) { 80 | this.private.debug("HTTP Request is API related", 2); 81 | //Do controller+action checks 82 | var parts = url.split("/"); 83 | 84 | /** 85 | * At this point, parts should look something like this 86 | * 87 | * ["", "parts", "to", "route"] 88 | * 89 | **/ 90 | 91 | var controllerName = parts.shift(); 92 | 93 | //First check if the controller exists 94 | if(typeof(this.controllers[controllerName]) !== "undefined") { 95 | //OK, the controller exists. Now lets check if it matches a route 96 | var controller = this.controllers[controllerName]; 97 | 98 | //controller.checkRoute will return us a function that expects req,res as paramters. 99 | //It handles internally pulling the paramaters out of the route 100 | var route; 101 | if(typeof(route = controller.checkRoute(req, parts)) === "object") { 102 | this.private.debug("Found matching route for API request", 2); 103 | 104 | var cont = true; 105 | if(typeof(this.options.before) === "function") { 106 | this.private.debug("Running API Before Hook"); 107 | cont = !(this.options.before(req, res, controllerName) === false); 108 | } 109 | 110 | if(cont) { 111 | var form = new formidable.IncomingForm(); 112 | 113 | form.parse(req, function(err, fields, files) { 114 | req.body = fields; 115 | route.handler.call(controller, req, res); 116 | }); 117 | } else { 118 | this.private.debug("API Before Hook returned false. Skipping API", 1); 119 | this.doFallback(res, res); 120 | } 121 | } else { 122 | this.private.debug("Route does not exist", 2); 123 | this.responses.notAvailable(res); 124 | } 125 | 126 | } else { 127 | this.private.debug("Controller " + controllerName + " does not exist", 2); 128 | this.responses.notAvailable(res); 129 | } 130 | 131 | } else { 132 | this.private.debug("This is not an API request", 2); 133 | this.doFallback(req, res); 134 | } 135 | }, 136 | 137 | debug: function(msg, level) { 138 | if(level >= this.API.options.logLevel) { 139 | console.log(Date.now() + " --> " + msg); 140 | } 141 | } 142 | 143 | } 144 | 145 | API.prototype.Controller = function(name, configuration) { 146 | this.controllers[name] = new Controller(this, configuration); 147 | this.private.debug("Created controller: " + name, 3); 148 | return this.controllers[name]; 149 | }; 150 | 151 | API.prototype.doFallback = function(req, res) { 152 | if(typeof(this.options.fallback) === "function") { 153 | this.private.debug("Falling Back", 2); 154 | this.options.fallback(req, res); 155 | } else { 156 | this.private.debug("No fallback option. Sending 404", 2); 157 | this.responses.notAvailable(res); 158 | } 159 | } 160 | 161 | API.prototype.responses = { 162 | notAvailable: function(res, message) { 163 | if(typeof(message) === "object") { 164 | res.setHeader("Content-Type", "application/json"); 165 | message = JSON.stringify(message); 166 | } 167 | 168 | res.statusCode = 404; 169 | res.end(message); 170 | }, 171 | internalError: function(res, message, statusCode) { 172 | //Message AND status code are optional, so handle the case where only statusCode is given 173 | if(typeof(message) === "number" && typeof(statusCode === "undefined")) { 174 | statusCode = message; 175 | message = undefined; 176 | } 177 | 178 | if(typeof(statusCode) === "undefined") { 179 | statusCode = 500; 180 | } 181 | 182 | this.respond(res, message, statusCode); 183 | }, 184 | respond: function(res, message, statusCode) { 185 | //Message AND status code are optional, so handle the case where only statusCode is given 186 | if(typeof(message) === "number" && typeof(statusCode === "undefined")) { 187 | statusCode = message; 188 | message = undefined; 189 | } 190 | 191 | if(typeof(message) === "object") { 192 | res.setHeader("Content-Type", "application/json"); 193 | message = JSON.stringify(message); 194 | } 195 | 196 | if(typeof(statusCode) !== "undefined") { 197 | res.statusCode = statusCode; 198 | } 199 | 200 | res.end(message); 201 | }, 202 | notAuth: function(res, message, statusCode) { 203 | //Message AND status code are optional, so handle the case where only statusCode is given 204 | if(typeof(message) === "number" && typeof(statusCode === "undefined")) { 205 | statusCode = message; 206 | message = undefined; 207 | } 208 | 209 | if(typeof(statusCode) === "undefined") { 210 | statusCode = 403; 211 | } 212 | 213 | this.respond(res, message, statusCode); 214 | }, 215 | redirect: function(res, destination, permanent) { 216 | if(typeof(permanent) === "undefined") { 217 | permanent = false; 218 | } 219 | 220 | res.setHeader("Location", destination); 221 | 222 | var statusCode = permanent ? 302 : 301; 223 | res.statusCode = statusCode; 224 | 225 | res.end(); 226 | } 227 | } 228 | 229 | module.exports = exports = API; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-api", 3 | "version": "0.1.1", 4 | "description": "A Node.js API Scaffolding Library", 5 | "keywords": [ 6 | "node", 7 | "api", 8 | "scaffolding", 9 | "routing", 10 | "routes", 11 | "controller", 12 | "action", 13 | "model" 14 | ], 15 | "dependencies": { 16 | "formidable": "~1.0.14" 17 | }, 18 | "devDependencies": { 19 | "mocha": "~1.8", 20 | "request": "~2.27.0" 21 | }, 22 | "engines": { 23 | "node": "~0.8" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/josephwegner/simple-api/issues", 27 | "email": "joe@wegnerdesign.com" 28 | }, 29 | "scripts": { 30 | "start": "node index.js" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/josephwegner/simple-api" 35 | }, 36 | "contributors": [ 37 | { 38 | "name": "Joe Wegner", 39 | "email": "joe@wegnerdesign.com", 40 | "url": "http://www.wegnerdesign.com" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /test/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josephwegner/simple-api/05aae2571cd79de5f66bc06a2d3c960d80a8ce16/test/.DS_Store -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec -------------------------------------------------------------------------------- /test/simple/controllers/convenience.js: -------------------------------------------------------------------------------- 1 | var ConvenienceController = { 2 | options: { 3 | 4 | }, 5 | routes: { 6 | fourOhFour: { 7 | method: "GET", 8 | path: ["404"] 9 | }, 10 | fourOhFourWithMessage: { 11 | method: "GET", 12 | path: ["404withMessage"] 13 | }, 14 | intError: { 15 | method: "GET", 16 | path: ["500"] 17 | }, 18 | intErrorWithMessage: { 19 | method: "GET", 20 | path: ["500withMessage"] 21 | }, 22 | intErrorCode: { 23 | method: "GET", 24 | path: ["505"] 25 | }, 26 | intErrorCodeWithMessage: { 27 | method: "GET", 28 | path: ["505withMessage"] 29 | }, 30 | authError: { 31 | method: "GET", 32 | path: ["403"] 33 | }, 34 | authErrorWithMessage: { 35 | method: "GET", 36 | path: ["403withMessage"] 37 | }, 38 | tempRedirect: { 39 | method: "GET", 40 | path: ["301"] 41 | }, 42 | permRedirect: { 43 | method: "GET", 44 | path: ["302"] 45 | 46 | }, 47 | getJSON: { 48 | method: "GET", 49 | path: ["JSON"] 50 | } 51 | 52 | }, 53 | actions: { 54 | fourOhFour: function(req, res, params) { 55 | this.responses.notAvailable(res); 56 | }, 57 | fourOhFourWithMessage: function(req, res, params) { 58 | this.responses.notAvailable(res, "It's Not Here") 59 | }, 60 | intError: function(req, res, params) { 61 | this.responses.internalError(res); 62 | }, 63 | intErrorWithMessage: function(req, res, params) { 64 | this.responses.internalError(res, "You Broke It"); 65 | }, 66 | intErrorCode: function(req, res, params) { 67 | this.responses.internalError(res, 505); 68 | }, 69 | intErrorCodeWithMessage: function(req, res, params) { 70 | this.responses.internalError(res, "You Broke It", 505); 71 | }, 72 | authError: function(req, res, params) { 73 | this.responses.notAuth(res); 74 | }, 75 | authErrorWithMessage: function(req, res, params) { 76 | this.responses.notAuth(res, "Not Permitted"); 77 | }, 78 | tempRedirect: function(req, res, params) { 79 | this.responses.redirect(res, "/red"); 80 | }, 81 | permRedirect: function(req, res, params) { 82 | this.responses.redirect(res, "/red", true); 83 | }, 84 | getJSON: function(req, res, params) { 85 | this.responses.respond(res, {hello: "world"}); 86 | } 87 | }, 88 | helpers: { 89 | } 90 | }; 91 | 92 | module.exports = exports = ConvenienceController; -------------------------------------------------------------------------------- /test/simple/controllers/object.js: -------------------------------------------------------------------------------- 1 | var ObjectController = { 2 | options: { 3 | 4 | }, 5 | routes: { 6 | getStringy: { 7 | method: "GET", 8 | path: ["stringy", "information"] 9 | }, 10 | getObjectProperty: { 11 | method: "GET", 12 | path: "/property/:property" //string case 13 | }, 14 | getById: { 15 | method: "GET", 16 | path: ["%id"] //number case 17 | }, 18 | getByHash: { 19 | method: "GET", 20 | path: "hash/*hash" //mixed case 21 | }, 22 | getByRegexp: { 23 | method: "GET", 24 | path: ["regexp", "[[A-Z0-9]+]regexp"] //regexp case 25 | }, 26 | getMultipleParams: { 27 | method: "GET", 28 | path: "/mixed/:stringy/other/%numerical/onemore/*mixed" 29 | }, 30 | postReturn: { 31 | method: "POST", 32 | path: "/post" 33 | }, 34 | testHelperScope: { 35 | method: "GET", 36 | path: ["testHelperScope"] 37 | 38 | }, 39 | testControllerScope: { 40 | method: "GET", 41 | path: "testControllerScope" 42 | }, 43 | breakBefore: { 44 | method : "GET", 45 | path: ["breakBefore"] 46 | } 47 | }, 48 | actions: { 49 | getStringy: function(req, res, params) { 50 | res.end("stringy"); 51 | }, 52 | getObjectProperty: function(req, res, params) { 53 | if(typeof(params.property) !== "undefined") { 54 | res.end("property="+params.property); 55 | } else { 56 | this.responses.notAvailable(res); 57 | } 58 | }, 59 | getById: function(req, res, params) { 60 | if(typeof(params.id) !== "undefined") { 61 | res.end("id="+params.id); 62 | } else { 63 | this.responses.notAvailable(res); 64 | } 65 | }, 66 | getByHash: function(req, res, params) { 67 | if(typeof(params.hash) !== "undefined") { 68 | res.end("hash="+params.hash); 69 | } else { 70 | this.responses.notAvailable(res); 71 | } 72 | }, 73 | getByRegexp: function(req, res, params) { 74 | if(typeof(params.regexp) !== "undefined") { 75 | res.end("regexp="+params.regexp); 76 | } else { 77 | this.responses.notAvailable(res); 78 | } 79 | }, 80 | getMultipleParams: function(req, res, params) { 81 | if(typeof(params.stringy) !== "undefined" && typeof(params.numerical) !== "undefined" && typeof(params.mixed) !== "undefined") { 82 | res.end("stringy="+params.stringy+"&numerical="+params.numerical+"&mixed="+params.mixed); 83 | } else { 84 | this.responses.notAvailable(res); 85 | } 86 | }, 87 | postReturn: function(req, res, params) { 88 | res.end(JSON.stringify(req.body)); 89 | }, 90 | testHelperScope: function(req, res, params) { 91 | res.end(this.helpers.getTheHost()); 92 | }, 93 | testControllerScope: function(req, res, params) { 94 | res.end(this.api.options.host); 95 | }, 96 | breakBefore: function(req, res, params) { } //This will never get called, because of the before hook 97 | }, 98 | helpers: { 99 | getTheHost: function() { 100 | return this.api.options.host; 101 | } 102 | } 103 | }; 104 | 105 | module.exports = exports = ObjectController; -------------------------------------------------------------------------------- /test/simple/index.js: -------------------------------------------------------------------------------- 1 | var api = require(__dirname + "/../../index.js"); 2 | 3 | var v0 = new api({ 4 | prefix: ["api", "v0"], 5 | port: 8080, 6 | host: "localhost", 7 | before: function(req, res, controller) { 8 | if(req.url === "/api/v0/object/breakBefore") { 9 | return false; 10 | } 11 | }, 12 | fallback: function(req, res) { 13 | res.end("fallback"); 14 | }, 15 | logLevel: 5 16 | }); 17 | 18 | var ObjectController = v0.Controller("object", require(__dirname + "/controllers/object.js")); 19 | var ConvenienceController = v0.Controller("convenience", require(__dirname + "/controllers/convenience.js")); 20 | //var ObjectModel = v0.Model("object", require(__dirname + "/models/object.js")); 21 | 22 | if(typeof(module.exports !== "undefined") || typeof(exports) !== "undefined") { 23 | module.exports = exports = v0; 24 | } -------------------------------------------------------------------------------- /test/simple/models/object.js: -------------------------------------------------------------------------------- 1 | var ObjectModel = { 2 | actions: { 3 | 4 | }, 5 | helpers: { 6 | 7 | } 8 | }; 9 | 10 | module.exports = exports = ObjectModel; -------------------------------------------------------------------------------- /test/simpleAPI.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var assert = require("assert"); 3 | var request = require('request'); 4 | var api; 5 | 6 | before(function() { 7 | //Start simple API server 8 | api = require(__dirname + "/simple/index.js"); 9 | }); 10 | 11 | describe("Initializations", function() { 12 | it("should set this.app to the HTTP server", function(done) { 13 | assert.equal(typeof(api.app), "object"); 14 | done(); 15 | }) 16 | }); 17 | 18 | describe("POST Requests", function() { 19 | var BASE_URL = 'http://localhost:8080/api/v0/object'; 20 | 21 | it("should parse post data", function(done) { 22 | var form = { key: 'value' } 23 | request.post( 24 | BASE_URL + "/post", 25 | { form: form }, 26 | function (error, response, body) { 27 | assert.deepEqual(form, JSON.parse(body)); 28 | done(); 29 | } 30 | ); 31 | }) 32 | }); 33 | 34 | describe("GET Requests", function() { 35 | it("should respond to routes with no parameters", function(done) { 36 | http.get("http://localhost:8080/api/v0/object/stringy/information", function(res) { 37 | 38 | assert.equal(res.statusCode, 200); 39 | 40 | var data = ""; 41 | 42 | res.on('data', function(chunk) { 43 | data += chunk; 44 | }); 45 | 46 | res.on('end', function() { 47 | assert.strictEqual(data, "stringy"); 48 | done(); 49 | }) 50 | 51 | }); 52 | }); 53 | 54 | it("should respond to routes with string parameters", function(done) { 55 | http.get("http://localhost:8080/api/v0/object/property/teststring", function(res) { 56 | 57 | assert.equal(res.statusCode, 200); 58 | 59 | var data = ""; 60 | 61 | res.on('data', function(chunk) { 62 | data += chunk; 63 | }); 64 | 65 | res.on('end', function() { 66 | assert.strictEqual(data, "property=teststring"); 67 | done(); 68 | }) 69 | 70 | }); 71 | 72 | }); 73 | 74 | it("should respond to routes with GET parameters", function(done) { 75 | http.get("http://localhost:8080/api/v0/object/property/teststring?test=test", function(res) { 76 | 77 | assert.equal(res.statusCode, 200); 78 | 79 | var data = ""; 80 | 81 | res.on('data', function(chunk) { 82 | data += chunk; 83 | }); 84 | 85 | res.on('end', function() { 86 | assert.strictEqual(data, "property=teststring"); 87 | done(); 88 | }) 89 | 90 | }); 91 | 92 | }); 93 | 94 | it("should respond to routes with numerical parameters", function(done) { 95 | http.get("http://localhost:8080/api/v0/object/54782", function(res) { 96 | 97 | assert.equal(res.statusCode, 200); 98 | 99 | var data = ""; 100 | 101 | res.on('data', function(chunk) { 102 | data += chunk; 103 | }); 104 | 105 | res.on('end', function() { 106 | assert.strictEqual(data, "id=54782"); 107 | done(); 108 | }) 109 | 110 | }); 111 | 112 | }); 113 | 114 | it("should respond to routes with mixed-type paramters", function(done) { 115 | http.get("http://localhost:8080/api/v0/object/hash/ABC987abc", function(res) { 116 | 117 | assert.equal(res.statusCode, 200); 118 | 119 | var data = ""; 120 | 121 | res.on('data', function(chunk) { 122 | data += chunk; 123 | }); 124 | 125 | res.on('end', function() { 126 | assert.strictEqual(data, "hash=ABC987abc"); 127 | done(); 128 | }) 129 | 130 | }); 131 | 132 | }); 133 | 134 | it("should respond to routes with regexp parameters", function(done) { 135 | http.get("http://localhost:8080/api/v0/object/regexp/HJK729", function(res) { 136 | 137 | assert.equal(res.statusCode, 200); 138 | 139 | var data = ""; 140 | 141 | res.on('data', function(chunk) { 142 | data += chunk; 143 | }); 144 | 145 | res.on('end', function() { 146 | assert.strictEqual(data, "regexp=HJK729"); 147 | done(); 148 | }) 149 | 150 | }); 151 | 152 | }); 153 | 154 | it("should respond to routes with multiple paramters", function(done) { 155 | http.get("http://localhost:8080/api/v0/object/mixed/teststring/other/1234/onemore/1337DaWg", function(res) { 156 | 157 | assert.equal(res.statusCode, 200); 158 | 159 | var data = ""; 160 | 161 | res.on('data', function(chunk) { 162 | data += chunk; 163 | }); 164 | 165 | res.on('end', function() { 166 | assert.strictEqual(data, "stringy=teststring&numerical=1234&mixed=1337DaWg"); 167 | done(); 168 | }) 169 | 170 | }); 171 | 172 | }); 173 | 174 | it("should 404 for actions that don't exist", function(done) { 175 | http.get("http://localhost:8080/api/v0/object/thisactiondoesntexist", function(res) { 176 | assert.equal(res.statusCode, 404); 177 | done(); 178 | }); 179 | }); 180 | 181 | it("should 404 for controllers that don't exist", function(done) { 182 | http.get("http://localhost:8080/api/v0/fakeController", function(res) { 183 | assert.equal(res.statusCode, 404); 184 | done(); 185 | }); 186 | }); 187 | 188 | it("should fallback for requests that don't match the prefix", function(done) { 189 | http.get("http://localhost:8080/notanapirequest", function(res) { 190 | 191 | assert.equal(res.statusCode, 200); 192 | 193 | var data = ""; 194 | 195 | res.on('data', function(chunk) { 196 | data += chunk; 197 | }); 198 | 199 | res.on('end', function() { 200 | 201 | assert.strictEqual(data, "fallback"); 202 | 203 | done(); 204 | }); 205 | }); 206 | }); 207 | 208 | it("should run the before hook for all API requests", function(done) { 209 | http.get("http://localhost:8080/api/v0/object/breakBefore", function(res) { 210 | 211 | assert.equal(res.statusCode, 200); 212 | 213 | var data = ""; 214 | 215 | res.on('data', function(chunk) { 216 | data += chunk; 217 | }); 218 | 219 | res.on('end', function() { 220 | 221 | assert.strictEqual(data, "fallback"); 222 | 223 | done(); 224 | }); 225 | }); 226 | }); 227 | }); 228 | 229 | describe("Convenience Functions", function() { 230 | 231 | 232 | // 404s 233 | it("should send 404s", function(done) { 234 | http.get("http://localhost:8080/api/v0/convenience/404", function(res) { 235 | assert.equal(res.statusCode, 404); 236 | res.on('data', function() {}); 237 | done(); 238 | }); 239 | }); 240 | 241 | it("should send 404s with messages", function(done) { 242 | http.get("http://localhost:8080/api/v0/convenience/404withMessage", function(res) { 243 | 244 | assert.equal(res.statusCode, 404); 245 | 246 | var data = ""; 247 | 248 | res.on('data', function(chunk) { 249 | data += chunk; 250 | }); 251 | 252 | res.on('end', function() { 253 | 254 | assert.strictEqual(data, "It's Not Here"); 255 | 256 | done(); 257 | }); 258 | }); 259 | }); 260 | 261 | 262 | // 500s 263 | it("should handle internal errors", function(done) { 264 | http.get("http://localhost:8080/api/v0/convenience/500", function(res) { 265 | assert.equal(res.statusCode, 500); 266 | res.on('data', function() {}); 267 | done(); 268 | }); 269 | }); 270 | 271 | it("should handle internal errors with messages", function(done) { 272 | http.get("http://localhost:8080/api/v0/convenience/500withMessage", function(res) { 273 | 274 | assert.equal(res.statusCode, 500); 275 | 276 | var data = ""; 277 | 278 | res.on('data', function(chunk) { 279 | data += chunk; 280 | }); 281 | 282 | res.on('end', function() { 283 | 284 | assert.strictEqual(data, "You Broke It"); 285 | 286 | done(); 287 | }); 288 | }); 289 | }); 290 | 291 | it("should handle internal errors with status codes", function(done) { 292 | http.get("http://localhost:8080/api/v0/convenience/505", function(res) { 293 | assert.equal(res.statusCode, 505); 294 | res.on('data', function() {}); 295 | done(); 296 | }); 297 | }); 298 | 299 | it("should handle internal errors with status codes and messages", function(done) { 300 | http.get("http://localhost:8080/api/v0/convenience/505withMessage", function(res) { 301 | 302 | assert.equal(res.statusCode, 505); 303 | 304 | var data = ""; 305 | 306 | res.on('data', function(chunk) { 307 | data += chunk; 308 | }); 309 | 310 | res.on('end', function() { 311 | 312 | assert.strictEqual(data, "You Broke It"); 313 | done(); 314 | }); 315 | }); 316 | }); 317 | 318 | //403s 319 | it("should handle authentication errors", function(done) { 320 | http.get("http://localhost:8080/api/v0/convenience/403", function(res) { 321 | assert.equal(res.statusCode, 403); 322 | res.on('data', function() {}); 323 | done(); 324 | }); 325 | }); 326 | 327 | it("should handle authentication errors with messages", function(done) { 328 | http.get("http://localhost:8080/api/v0/convenience/403withMessage", function(res) { 329 | 330 | assert.equal(res.statusCode, 403); 331 | 332 | var data = ""; 333 | 334 | res.on('data', function(chunk) { 335 | data += chunk; 336 | }); 337 | 338 | res.on('end', function() { 339 | 340 | assert.strictEqual(data, "Not Permitted"); 341 | 342 | done(); 343 | }); 344 | }); 345 | }); 346 | 347 | 348 | // 30*s 349 | it("should do temporary redirects", function(done) { 350 | http.get("http://localhost:8080/api/v0/convenience/301", function(res) { 351 | assert.equal(res.statusCode, 301); 352 | assert.equal(res.headers.location, "/red") 353 | res.on('data', function() {}); 354 | done(); 355 | }); 356 | }); 357 | 358 | it("should do permanent redirects", function(done) { 359 | http.get("http://localhost:8080/api/v0/convenience/302", function(res) { 360 | assert.equal(res.statusCode, 302); 361 | assert.equal(res.headers.location, "/red") 362 | res.on('data', function() {}); 363 | done(); 364 | }); 365 | }); 366 | 367 | // 200 368 | it("should get JSON with the correct content type", function(done) { 369 | http.get("http://localhost:8080/api/v0/convenience/JSON", function(res) { 370 | 371 | assert.equal(res.statusCode, 200); 372 | assert.equal(res.headers['content-type'], "application/json"); 373 | 374 | var data = ""; 375 | 376 | res.on('data', function(chunk) { 377 | data += chunk; 378 | }); 379 | 380 | res.on('end', function() { 381 | 382 | assert.equal(data, JSON.stringify({hello: "world"})); 383 | 384 | done(); 385 | }); 386 | }); 387 | }); 388 | 389 | }); 390 | 391 | describe("Controllers", function() { 392 | it("should be able to access the global api object", function(done) { 393 | http.get("http://localhost:8080/api/v0/object/testControllerScope", function(res) { 394 | 395 | var data = ""; 396 | 397 | res.on('data', function(chunk) { 398 | data += chunk; 399 | }); 400 | 401 | res.on('end', function() { 402 | 403 | assert.strictEqual(data, "localhost"); 404 | 405 | done(); 406 | }); 407 | 408 | }); 409 | }) 410 | it("should be able to access `this` from helpers", function(done) { 411 | http.get("http://localhost:8080/api/v0/object/testHelperScope", function(res) { 412 | 413 | var data = ""; 414 | 415 | res.on('data', function(chunk) { 416 | data += chunk; 417 | }); 418 | 419 | res.on('end', function() { 420 | 421 | assert.strictEqual(data, "localhost"); 422 | 423 | done(); 424 | }); 425 | 426 | }); 427 | }); 428 | }); --------------------------------------------------------------------------------