From 31df165c5f5bb27332ce16eb12705b087d709d8c Mon Sep 17 00:00:00 2001 From: Jed Schmidt Date: Mon, 16 Apr 2012 20:56:37 +0900 Subject: [PATCH] first commit --- .gitignore | 2 + package.json | 21 +++++ sajak.js | 250 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 sajak.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab05030 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +*.log \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0020378 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "author": "Jed Schmidt (http://jed.is)", + "name": "sajak", + "description": "Simple Authenticated JSON API Kit", + "version": "0.1.0", + "main": "sajak", + "dependencies": {}, + "devDependencies": { + "express": "*", + "request": "*", + "mocha": "*", + "should": "*" + }, + "optionalDependencies": {}, + "engines": { + "node": "*" + }, + "scripts": { + "test": "node ./test/test.js" + } +} diff --git a/sajak.js b/sajak.js new file mode 100644 index 0000000..3d31327 --- /dev/null +++ b/sajak.js @@ -0,0 +1,250 @@ +var url = require("url") + , express = require("express") + , debug = process.env.NODE_ENV != "production" + +function App(models) { + this.models = Object.create(null) + + this.serve(models) + + this.router = this.router.bind(this) +} + +App.showStack = debug +App.indent = 2 * debug + +App.nameModel = function(Model) { + if (!Model.name) throw new Error("Models must be named.") + + return App.pluralize(Model.name).toLowerCase() +} + +App.pluralize = function(word) { + return word.replace(/(?:ch|o|s|sh|ss|x|y)?$/, function(end) { + return end == "y" ? "ies" : end ? end + "es" : "s" + }) +} + +App.parseAuth = function(req) { + var header = req.headers.authorization + , auth = header && header.match(/^(.+?) (.+)$/) + + if (!auth) return {} + + auth = { + scheme: auth[1], + token: auth[2] + } + + if (auth.scheme.toLowerCase() == "basic") { + auth = Buffer(auth.token, "base64").toString().split(":") + + auth = { + scheme: "password", + token: {username: auth[0], password: auth[1]} + } + } + + return auth +} + +App.getBody = function(req, cb) { + var body + + if (req._body) return cb(req.body) + + req._body = true + body = "" + + req.setEncoding("utf8") + + req.on("data", function(chunk) { + body += chunk + }) + + req.on("end", function() { + if (!body) body = "{}" + + try { cb(null, JSON.parse(body)) } + catch (err) { cb(err) } + }) +} + +App.actions = { + GET : "fetch", + POST : "save", + PUT : "save", + DELETE : "destroy" +} + +App.prototype.fetch = function(cb) { + cb(null, Object + .keys(this.models) + .filter(Boolean) + .map(function(key) { + return {type: key, href: "/" + key } + }) + ) +} + +App.prototype.serve = function(name, Model) { + var type = toString.call(name).slice(8, -1) + + switch (type) { + case "Array": + name.forEach(this.serve, this) + break + + case "Object": + Object.keys(name).forEach(function(key) { + this.serve(key, name[key]) + }, this) + break + + case "Function": + this.serve(App.nameModel(name), name) + break + + case "String": + if (Model.prototype.authenticate) this.User = Model + this.models[name] = Model + break + + default: + throw new Error("Invalid model name: " + name) + } + + return this +} + +App.prototype.authorize = function(user, method, cb) { + cb() +} + +App.prototype.router = function(req, res, next) { + var resource = this.resolve(req.url) + , allows + , auth + , action + , user + , authorize + + if (!next) next = function(err) { + var data + + if (!err) { + err = new Error("Not Found") + err.status = 404 + } + + res.statusCode = err.status || 500 + + data = { + type: "error", + message: err.message, + } + + if (App.showStack) data.stack = err.stack.split(/\n\s*/).slice(1) + + res.json(data) + } + + if (!res.json) res.json = function(data) { + try { + data = new Buffer(JSON.stringify(data, null, App.indent)) + res.setHeader("Content-Type", "application/json") + res.setHeader("Content-Length", data.length) + res.end(data) + } + + catch (err) { next(err) } + } + + if (!resource) return next() + + allows = ["HEAD"] + if (resource.fetch) allows.push("GET") + if (resource.save) allows.push("POST", "PUT") + if (resource.destroy) allows.push("DELETE") + + res.setHeader("Allow", allows.join(", ")) + + auth = App.parseAuth(req) + action = App.actions[req.method] + user = new this.User({}) + authorize = resource.authorize || this.authorize + + App.getBody(req, function(err, data) { + if (err) { + err.status = 400 + return next(err) + } + + user.authenticate(auth, function(err) { + if (err) { + err.status = 401 + return next(err) + } + + authorize.call(resource, user, action, function(err) { + if (err) { + err.status = 403 + return next(err) + } + + if (!resource[action]) { + err = new Error("Method Not Allowed") + err.status = 405 + return next(err) + } + + resource[action](function(err, data) { + err ? next(err) : res.json(data) + }) + }) + }) + }) +} + +App.prototype.resolve = function(uri) { + uri = url.parse(uri, true) + + var segments = uri.pathname.split("/") + , query = uri.query + , type = segments[1] + , id = segments[2] + , Model = this.models[type] + , instance + + if (!Model && !type) return this + + if (!Model || segments[3]) return + + if (id) query.id = id + + Object.keys(query).forEach(function(key) { + if (key.slice(-5) != ".href") return + + query[key.slice(0, -5)] = this.resolve(query[key]) + delete query[key] + }, this) + + instance = new Model(query) + instance.constructor = Model + + return instance +} + +App.prototype.User = User + +function User(){} +User.prototype.authenticate = function(credentials, cb) { + this.credentials = credentials + cb() +} + +function sajak(models) { + return new App(models) +} + +module.exports = sajak