Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

first version

  • Loading branch information...
commit cffd5a58121a44e6c46163f9caffaaa8517c82ec 1 parent 4644ead
@fjakobs fjakobs authored
View
2  .gitignore
@@ -0,0 +1,2 @@
+.c9revisions/
+node_modules/
View
21 LICENSE
@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright (c) 2012 Ajax.org B.V
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
View
20 README.md
@@ -1,18 +1,4 @@
-# README for a newly created project.
+Frontdoor
+=========
-There are a couple of things you should do first, before you can use all of Git's power:
-
- * Add a remote to this project: in the Cloud9 IDE command line, you can execute the following commands
- `git remote add [remote name] [remote url (eg. 'git@github.com:/ajaxorg/node_chat')]` [Enter]
- * Create new files inside your project
- * Add them to to Git by executing the following command
- `git add [file1, file2, file3, ...]` [Enter]
- * Create a commit which can be pushed to the remote you just added
- `git commit -m 'added new files'` [Enter]
- * Push the commit the remote
- `git push [remote name] master` [Enter]
-
-That's it! If this doesn't work for you, please visit the excellent resources from [Github.com](http://help.github.com) and the [Pro Git](http://http://progit.org/book/) book.
-If you can't find your answers there, feel free to ask us via Twitter (@cloud9ide), [mailing list](groups.google.com/group/cloud9-ide) or IRC (#cloud9ide on freenode).
-
-Happy coding!
+Frontdoor is a libarary for creating RESTful API servers.
View
35 lib/api-client.js
@@ -0,0 +1,35 @@
+var request = require("request");
+
+module.exports = function(endpoint, description, callback) {
+
+ if (typeof description === "string") {
+ loadDesc(description, function(err) {
+ if (err) return callback(err);
+ buildApi(description, callback);
+ });
+ }
+ else {
+ buildApi(description, callback);
+ }
+
+
+ function buildApi(err, description) {
+ //if (err)
+ }
+
+ function loadDesc(url, callback) {
+ request(url, function(error, response, body) {
+ if (error || response.statusCode !== 200)
+ return callback(new Error("Could not load API description from " + url));
+
+ var json;
+ try {
+ json = JSON.parse(body);
+ } catch(e) {
+ return callback(new Error("Could not JSON parse API description"));
+ }
+
+ callback(null, json);
+ });
+ }
+};
View
54 lib/api-client_test.js
@@ -0,0 +1,54 @@
+"use strict";
+
+var assert = require("assert");
+var sinon = require("sinon");
+var http = require("http");
+
+var api = require("./api");
+var apiClientBuilder = require("./api-client");
+
+module.exports = {
+
+ setUp: function(next) {
+ var self = this;
+
+ this.getUsers = sinon.stub();
+ this.getUser = sinon.stub();
+ this.onError = sinon.stub();
+
+ var root = new api.Api();
+ root.section("users")
+ .get("/", this.getUsers)
+ .get("/:uid", this.getUser);
+
+ root.get({
+ route: "/inspect.json"
+ }, api.mw.describeApi(root));
+
+ var port = process.env.PORT || 8383;
+ this.server = http.createServer(function(req, res) {
+ root.handle(req, res, self.onError);
+ }).listen(port, function() {
+ apiClientBuilder("http://localhost:8383");
+ });
+ },
+
+ "test string array type": function() {
+ var t = new types.Array(new types.String());
+
+ assert.ok(t.check(["a", "b", "c"]));
+ assert.ok(!t.check([12, "b", "c"]));
+ },
+
+ "test array type": function() {
+ var t = new types.Array();
+
+ assert.ok(t.check(["a", "b", "c"]));
+ assert.ok(t.check([12, "b", "c"]));
+ assert.ok(!t.check({}));
+ assert.ok(!t.check(12));
+ assert.ok(!t.check("juhu"));
+ }
+};
+
+!module.parent && require("asyncjs").test.testcase(module.exports).exec();
View
25 lib/api-ext.js
@@ -0,0 +1,25 @@
+"use strict";
+
+var api = require("./api");
+
+module.exports = function(options, imports, register) {
+
+ var connect = imports.connect;
+ connect.useSetup(connect.getModule().logger('":method :url" - ":referrer" [:date]'));
+
+ var root = new api.Api();
+
+ connect.useMain(function(req, res, next) {
+ root.handle(req, res, next);
+ });
+
+ register(null, {
+ "api": {
+ use: root.use.bind(root),
+ section: root.section.bind(root),
+ route: root.route.bind(root),
+ registerType: root.registerType.bind(root),
+ types: require("./types")
+ }
+ });
+};
View
33 lib/api-middleware.js
@@ -0,0 +1,33 @@
+"use strict";
+
+var error = require("http-error");
+
+exports.jsonWriter = function() {
+ return function(req, res, next) {
+ res.json = function(json, headers) {
+ var data;
+ try {
+ data = JSON.stringify(json);
+ } catch(e) {
+ console.error(e);
+ return next(new error.InternalServerError("Could not stringify JSON"));
+ }
+
+ if (req.parsedUrl && req.parsedUrl.query.jsonp)
+ data = req.parsedUrl.query.jsonp + "(" + data + ")";
+
+ headers = headers || {};
+ headers["Content-Type"] = "application/json";
+ res.writeHead(200, headers);
+ res.end(data);
+ };
+
+ next();
+ };
+};
+
+exports.describeApi = function(root) {
+ return function(req, res, next) {
+ res.json(root.describe());
+ };
+};
View
417 lib/api.js
@@ -0,0 +1,417 @@
+"use strict";
+
+// Desired API
+
+// additional features
+// - rate limiting
+// - per IP
+// - per UID
+// - EATG
+// - HEAD support for GETs
+//
+// var channels = api.section({
+// name: "channels",
+// description: "Manage channels and channel assignments"
+// });
+//
+// channels.route("get", {
+// description: "get list of ide channels",
+// method: "GET",
+// route: "/",
+// access: "admin"
+// }, function(req, res, next) {});
+//
+// channels.route("add", {
+// method: "PUT",
+// route: "/:channel",
+// access: "admin",
+// params: {
+// "channel": {
+// "type": "String"
+// }
+// }
+// }, function(req, res, next) {});
+
+var url = require("url");
+var Types = require("./types").Types;
+var RegExpType = require("./types").RegExp;
+var middleware = require("./api-middleware");
+
+exports.Section = Section;
+exports.Api = Api;
+exports.Route = Route;
+exports.mw = middleware;
+
+function Api(description) {
+ Section.call(this, "", description);
+
+ this.use(middleware.jsonWriter());
+}
+
+function Section(name, description, types) {
+
+ types = types || new Types();
+ var routes = [];
+ var sections = {};
+ var self = this;
+
+ this.middlewares = [];
+ this.name = name;
+
+ var methods = ["head", "get", "put", "delete", "update"];
+ methods.forEach(function(method) {
+ routes[method] = [];
+
+ self[method] = (function(method, options, handler) {
+ options.method = method;
+ return self.route(options, handler);
+ }).bind(self, method);
+ });
+ this.del = this["delete"];
+
+ this.registerType = types.register.bind(types);
+
+ this.use = function(middleware) {
+ this.middlewares.push(middleware);
+ };
+
+ this.route = function(options, handler) {
+ var route = new Route(options, handler, types);
+ route.parent = this;
+ routes[route.method].push(route);
+ return this;
+ };
+
+ this.section = function(name, description) {
+ var section = new Section(name, description, types);
+ section.parent = this;
+ if (!sections[name])
+ sections[name] = [];
+
+ sections[name].push(section);
+ return section;
+ };
+
+ this.handle = function(path, req, res, next) {
+ if (arguments.length === 3) {
+ req = arguments[0];
+ res = arguments[1];
+ next = arguments[2];
+
+ if (!req.parsedUrl)
+ req.parsedUrl = url.parse(req.url, true);
+
+ path = req.parsedUrl.pathname;
+ }
+
+ var method = req.method.toLowerCase();
+ if (method in methods)
+ return next();
+
+ var handler = this.match(path, method);
+ if (!handler)
+ return next();
+
+ var middleware = [];
+ while (handler) {
+ middleware.unshift.apply(middleware, handler.middlewares || []);
+ handler = handler.parent;
+ }
+
+ var i = 0;
+ function processNext() {
+ handler = middleware[i++];
+ if (!handler)
+ return next();
+
+ handler(req, res, function(err) {
+ if (err)
+ return next(err);
+
+ processNext();
+ });
+ }
+
+ processNext();
+ };
+
+ this.match = function(path, method) {
+ var splitPath = path.split("/");
+ if (!splitPath[0])
+ splitPath.shift();
+ if (splitPath.length) {
+ var section = sections[splitPath[0]];
+ if (section && section.length) {
+ var subPath = "/" + splitPath.slice(1).join("/");
+ for (var i = 0; i < section.length; i++) {
+ var handler = section[i].match(subPath, method);
+ if (handler)
+ return handler;
+ }
+ }
+ }
+
+ var methodRoutes = routes[method];
+ for (var i = 0; i < methodRoutes.length; i++) {
+ var route = methodRoutes[i];
+ if (route.match(path))
+ return route;
+ }
+ };
+
+ this.describe = function() {
+ // sections and routes
+ var api = {};
+
+ if (name)
+ api.name = name;
+
+ if (description)
+ api.description = description;
+ api.sections = [];
+ for (var key in sections) {
+ for (var i=0; i < sections[key].length; i++) {
+ api.sections.push(sections[key][i].describe());
+ }
+ }
+ if (!api.sections.length)
+ delete api.sections;
+
+ api.routes = [];
+ for (var method in routes) {
+ for (var i=0; i < routes[method].length; i++) {
+ api.routes.push(routes[method][i].describe());
+ }
+ }
+ if (!api.routes.length)
+ delete api.routes;
+
+ return api;
+ };
+
+}
+
+function Route(options, handler, types) {
+
+ types = types || new Types();
+
+ if (Array.isArray(handler))
+ this.middlewares = handler;
+ else
+ this.middlewares = [handler];
+
+ this.middlewares.unshift(decodeParams);
+
+ this.method = (options.method || "GET").toLowerCase();
+ this.lastMatch = {};
+
+ var self = this;
+ var keys = [];
+ var params = options.params || {};
+ var routeRe = normalizePath(options.route, keys, params);
+ params = normalizeParams(params);
+
+ /**
+ * Creates a rgular expression to match this route.
+ * Url param names are stored in `keys` and the `params` are completed with
+ * the default values for url parameters.
+ */
+ function normalizePath(path, keys, params) {
+ path = path
+ .replace(/\/:(\w+)/g, function(match, key) {
+ keys.push(key);
+ if (!params[key]) {
+ params[key] = {};
+ }
+ // url params default to type string and optional=false
+ var param = params[key];
+ param.type = param.type || "string";
+
+ if (!param.source || param.source == "url")
+ param.source = "url";
+ else
+ throw new Error("Url parameters must have 'url' as source but found '" + param.source + "'");
+ return "\/([^\\/]+)";
+ })
+ .replace(/([\/.])/g, '\\$1');
+
+ return new RegExp('^' + path + '$', 'i');
+ }
+
+ function normalizeParams(params) {
+ for (var name in params) {
+ var param = params[name];
+
+ if (param.source == "query") {
+ // query params default to string
+ param.type = param.type || "string";
+ }
+ else if (!param.source || param.source == "body") {
+ // body params default to json
+ param.type = param.type || "json";
+ param.source = "body";
+ }
+ else if (param.source !== "url") {
+ throw new Error("parameter source muste be 'url', 'query' or 'body'");
+ }
+
+ // optional defaults to false
+ param.optional = !!param.optional;
+
+ // allow regular expressions as types
+ if (param.type instanceof RegExp)
+ param.type = new RegExpType(param.type);
+
+ // convert all types to type objects
+ param.type = types.get(param.type);
+ }
+ return params;
+ }
+
+ /**
+ * Check if the given path matched the route regular expression. If the
+ * regexp matches the url params are parsed and sored in `lastMatch`. If
+ * the regular expression doesn't match or parsing fails `match` will
+ * return `false`
+ **/
+ this.match = function(path) {
+ var m = path.match(routeRe);
+ if (!m) return false;
+
+ this.lastMatch = {};
+ for (var i = 0; i < keys.length; i++) {
+ var value = m[i+1];
+ var key = keys[i];
+ var param = params[key];
+ var type = param.type;
+ try {
+ value = type.parse(value);
+ } catch (e) {
+ this.lastMatch = {};
+ return false;
+ }
+ if (!type.check(value)) {
+ this.lastMatch = {};
+ return false;
+ }
+ this.lastMatch[key] = value;
+ }
+
+ return true;
+ };
+
+ /**
+ * Middleware to validate the parameters. It will take `lastMatch` for
+ * url params, decode the query and body parameters. If validation passes
+ * the decoded and validated parameters are stored in `req.params`
+ * otherwhise an error is returned.
+ */
+ function decodeParams(req, res, next) {
+ if (!self.lastMatch)
+ return;
+
+ var body = req.body || {};
+ var query = req.parsedUrl.query;
+ var urlParams = self.lastMatch;
+
+ req.params = {};
+ var errors = [];
+
+ // 1. check if all required params are there
+ for (var key in params) {
+ var param = params[key];
+
+ if (
+ (!param.optional) && (
+ (param.source == "body" && !(key in body)) ||
+ (param.source == "query" && !(key in query)) ||
+ (param.source == "url" && !(key in urlParams))
+ )
+ ) {
+ errors.push({
+ "resource": self.name || "root",
+ "field": key,
+ "source": param.source,
+ "code": "missing_field"
+ });
+ }
+ else {
+ var type = param.type;
+ var value;
+ var isValid;
+ switch(param.source) {
+ case "body":
+ value = body[key]; // body is already JSON parsed
+ isValid = type.check(value);
+ break;
+ case "query":
+ try {
+ value = type.parse(query[key]);
+ } catch(e) {
+ isValid = false;
+ }
+ isValid = isValid === false ? false : type.check(value);
+ break;
+ case "url":
+ value = urlParams[key]; // is already parsed and checked
+ isValid = true;
+ break;
+ }
+
+ if (!isValid) {
+ errors.push({
+ "resource": self.name || "root",
+ "field": key,
+ "type_expected": type.toString(),
+ "code": "invalid"
+ });
+ }
+ else {
+ req.params[key] = value;
+ }
+ }
+ }
+
+ if (errors.length) {
+ res.writeHead(422, {"Content-Type": "application/json"});
+ res.end(JSON.stringify({
+ "message": "Validation failed",
+ errors: errors
+ }));
+ return;
+ }
+
+ next();
+ }
+
+ this.describe = function() {
+ var route = {
+ route: options.route,
+ method: this.method
+ };
+
+ if (options.name)
+ route.name = options.name;
+
+ if (options.description)
+ route.description = options.description;
+
+ route.params = {};
+ for (var name in params) {
+ var param = params[name];
+ route.params[name] = {
+ name: param.name,
+ type: param.type.toString(),
+ source: param.source,
+ optional: param.optional
+ };
+ if (param.description)
+ route.params[name].description = param.description;
+ }
+
+ if (!Object.keys(route.params).length)
+ delete route.params;
+
+ return route;
+ };
+}
View
529 lib/api_test.js
@@ -0,0 +1,529 @@
+"use strict";
+
+var assert = require("assert");
+var sinon = require("sinon");
+
+var api = require("./api");
+
+module.exports = {
+
+ setUp: function() {
+ this.res = {
+ writeHead: sinon.stub(),
+ end: sinon.stub()
+ };
+ },
+
+ "test router: simple route with argument": function() {
+ var route = new api.Route({
+ route: "/user/:name"
+ });
+
+ assert.equal(route.match("/juhu"), false);
+ assert.equal(route.match("/juhu/12"), false);
+
+ assert.equal(route.match("/user/fabian"), true);
+ assert.equal(route.lastMatch.name, "fabian");
+ },
+
+ "test router: simple route with number argument": function() {
+ var route = new api.Route({
+ route: "/user/:id",
+ params: {
+ id: {
+ type: "int"
+ }
+ }
+ });
+
+ assert.equal(route.match("/user/fabian"), false);
+ assert.equal(route.match("/user/123"), true);
+ assert.equal(route.lastMatch.id, 123);
+ },
+
+ "test router: complex route with multiple arguments": function() {
+ var route = new api.Route({
+ route: "/user/:name/:id",
+ params: {
+ id: {
+ type: "int"
+ }
+ }
+ });
+
+ assert.equal(route.match("/user/fabian"), false);
+ assert.equal(route.match("/user/123"), false);
+ assert.equal(route.match("/user/fabian/123"), true);
+ assert.equal(route.lastMatch.id, 123);
+ assert.equal(route.lastMatch.name, "fabian");
+ },
+
+ "test API routing": function() {
+ var root = new api.Api("API");
+ var channels = root.section("channels", "Channels");
+
+ var next = sinon.stub();
+ var listChannels = sinon.stub();
+ var getChannel = sinon.stub();
+ var delChannel = sinon.stub();
+
+ function reset() {
+ next.reset();
+ listChannels.reset();
+ getChannel.reset();
+ delChannel.reset();
+ }
+
+ channels.get({
+ route: "/"
+ }, listChannels);
+
+ channels.get({
+ route: "/:id",
+ params: {
+ id: { type: "int" }
+ }
+ }, getChannel);
+
+ channels.del({
+ method: "delete",
+ route: "/:id",
+ params: {
+ id: { type: "int" }
+ }
+ }, delChannel);
+
+ root.handle({
+ method: "GET",
+ url: "/channels"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.calledOnce(listChannels);
+ sinon.assert.notCalled(getChannel);
+ sinon.assert.notCalled(delChannel);
+ reset();
+
+ root.handle({
+ method: "GET",
+ url: "/channels/123"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.notCalled(listChannels);
+ sinon.assert.calledOnce(getChannel);
+ sinon.assert.notCalled(delChannel);
+ reset();
+
+ root.handle({
+ method: "GET",
+ url: "/channels/abcd"
+ }, this.res, next);
+ sinon.assert.called(next);
+ sinon.assert.notCalled(listChannels);
+ sinon.assert.notCalled(getChannel);
+ sinon.assert.notCalled(delChannel);
+ reset();
+
+ root.handle({
+ method: "DELETE",
+ url: "/channels/123"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.notCalled(listChannels);
+ sinon.assert.notCalled(getChannel);
+ sinon.assert.calledOnce(delChannel);
+ reset();
+ },
+
+ "test two sections with same name should be additive": function() {
+ var next = sinon.stub();
+ var getUser = sinon.stub();
+ var getProject = sinon.stub();
+
+ function reset() {
+ next.reset();
+ getUser.reset();
+ getProject.reset();
+ }
+
+ var root = new api.Api("API");
+
+ var users = root.section("users");
+ users.get({
+ route: "/:uid"
+ }, getUser);
+
+ var projects = root.section("users");
+ projects.get({
+ route: "/:uid/:pid"
+ }, getProject);
+
+ root.handle({
+ method: "GET",
+ url: "/users/fjakobs"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.calledOnce(getUser);
+ sinon.assert.notCalled(getProject);
+ reset();
+
+ root.handle({
+ method: "GET",
+ url: "/users/fjakobs/ace"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.notCalled(getUser);
+ sinon.assert.calledOnce(getProject);
+ reset();
+ },
+
+ "test add route to root": function() {
+ var onPing = sinon.stub();
+
+ var root = new api.Api("API");
+
+ root.get({
+ route: "/ping"
+ }, onPing);
+
+ root.handle({
+ method: "GET",
+ url: "/ping"
+ }, this.res, assert.fail);
+ sinon.assert.calledOnce(onPing);
+ },
+
+ "test section middleware shall only be called if the route was matched": function() {
+ var root = new api.Api();
+
+ var next = sinon.stub();
+ var mw1 = sinon.stub().callsArg(2);
+ var mw2 = sinon.stub().callsArg(2);
+ var mw3 = sinon.stub().callsArg(2);
+ var cb1 = sinon.stub();
+ var cb2 = sinon.stub();
+
+ function reset() {
+ next.reset();
+ mw1.reset();
+ mw2.reset();
+ mw3.reset();
+ cb1.reset();
+ cb2.reset();
+ }
+
+ root.use(mw1);
+ var s1 = root.section("s1");
+ s1.use(mw2);
+ s1.get({ route: "/juhu"}, cb1);
+
+ var s2 = root.section("s2");
+ s2.use(mw3);
+ s2.get({ route: "/kinners"}, cb2);
+
+ root.handle({
+ method: "GET",
+ url: "/s1/juhu"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.callOrder(mw1, mw2, cb1);
+ sinon.assert.notCalled(mw3);
+ reset();
+
+ root.handle({
+ method: "GET",
+ url: "/s2/kinners"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.callOrder(mw1, mw3, cb2);
+ sinon.assert.notCalled(mw2);
+ reset();
+
+ root.handle({
+ method: "GET",
+ url: "/nothing/here"
+ }, this.res, next);
+ sinon.assert.calledOnce(next);
+ sinon.assert.notCalled(mw1);
+ sinon.assert.notCalled(mw2);
+ sinon.assert.notCalled(mw3);
+ reset();
+ },
+
+ "test handler can be an array": function() {
+ var next = sinon.stub();
+ var middleware1 = sinon.stub().callsArg(2);
+ var middleware2 = sinon.stub().callsArg(2);
+ var handler = sinon.stub();
+
+ function reset() {
+ next.reset();
+ middleware1.reset();
+ middleware2.reset();
+ handler.reset();
+ }
+
+ var root = new api.Api();
+ root.get({
+ route: "/juhu"
+ }, [middleware1, middleware2, handler]);
+
+ root.handle({
+ method: "GET",
+ url: "/juhu"
+ }, this.res, next);
+ sinon.assert.notCalled(next);
+ sinon.assert.callOrder(middleware1, middleware2, handler);
+ reset();
+
+ root.handle({
+ method: "GET",
+ url: "/nothing"
+ }, this.res, next);
+ sinon.assert.calledOnce(next);
+ sinon.assert.notCalled(middleware1);
+ sinon.assert.notCalled(middleware2);
+ sinon.assert.notCalled(handler);
+ reset();
+ },
+
+ "test add route to '/' of a section": function() {
+ var onGet = sinon.stub();
+ var next = sinon.stub();
+
+ var root = new api.Api();
+ root.section("juhu")
+ .get({
+ route: "/"
+ }, onGet);
+
+ root.handle({
+ method: "GET",
+ url: "/juhu"
+ }, this.res, next);
+
+ sinon.assert.calledOnce(onGet);
+ sinon.assert.notCalled(next);
+ },
+
+ "test parameter decoding": function() {
+ var onPost = sinon.stub();
+
+ var root = new api.Api();
+ root.put({
+ route: "/post/:id",
+ params: {
+ id: {},
+ name: {},
+ age: {
+ source: "query",
+ type: "int"
+ }
+ }
+ }, onPost);
+
+ root.handle({
+ method: "PUT",
+ url: "/post/fab?age=34",
+ body: {
+ name: "Fabian"
+ }
+ }, this.res, assert.fail);
+ var params = onPost.args[0][0].params;
+ assert.equal(params.name, "Fabian");
+ assert.equal(params.id, "fab");
+ assert.equal(params.age, 34);
+
+ root.handle({
+ method: "PUT",
+ url: "/post/fab?age=34"
+ }, this.res, assert.fail);
+ sinon.assert.calledWith(this.res.writeHead, 422);
+ var errors = JSON.parse(this.res.end.args[0][0]).errors;
+ assert.equal(errors.length, 1);
+ assert.equal(errors[0].resource, "root");
+ assert.equal(errors[0].field, "name");
+ assert.equal(errors[0].code, "missing_field");
+
+ this.res.writeHead.reset();
+ this.res.end.reset();
+
+ root.handle({
+ method: "PUT",
+ url: "/post/fab?age=juhu",
+ body: { name: "Fabian"}
+ }, this.res, assert.fail);
+ sinon.assert.calledWith(this.res.writeHead, 422);
+ var errors = JSON.parse(this.res.end.args[0][0]).errors;
+ assert.equal(errors.length, 1);
+ assert.equal(errors[0].resource, "root");
+ assert.equal(errors[0].field, "age");
+ assert.equal(errors[0].type_expected, "int");
+ assert.equal(errors[0].code, "invalid");
+ },
+
+ "test regexp types": function() {
+ var route = new api.Route({
+ route: "/users/:uid",
+ params: {
+ uid: {
+ type: /u\d+/
+ }
+ }
+ });
+
+ assert.ok(route.match("/users/u123"));
+ assert.ok(!route.match("/users/_u123"));
+ },
+
+ "test custom type without register": function() {
+ var DateType = {
+ parse: function(string) {
+ if (!/\d{13}/.test(string))
+ throw new Error("not a timestamp");
+
+ return new Date(parseInt(string, 10));
+ },
+ check: function(value) {
+ return value instanceof Date;
+ }
+ };
+
+ var route = new api.Route({
+ route: "/ts/:ts",
+ params: {
+ ts: {
+ type: DateType
+ }
+ }
+ });
+
+ assert.ok(route.match("/ts/1353676299181"));
+ assert.ok(route.lastMatch.ts instanceof Date);
+
+ assert.ok(!route.match("/ts/353676299181"));
+ assert.ok(!route.match("/ts/abc"));
+ },
+
+ "test custom type with register": function() {
+ var DateType = {
+ parse: function(string) {
+ if (!/\d{13}/.test(string))
+ throw new Error("not a timestamp");
+
+ return new Date(parseInt(string, 10));
+ },
+ check: function(value) {
+ return value instanceof Date;
+ }
+ };
+
+ var onGet = sinon.stub();
+
+ var root = new api.Api();
+ var section = root.section("ts");
+ section.registerType("ts", DateType);
+ section.get({
+ route: "/:ts",
+ params: {
+ ts: {
+ type: "ts"
+ }
+ }
+ }, onGet);
+
+ assert.ok(root.match("/ts/1353676299181", "get"));
+ root.handle({
+ method: "GET",
+ url: "/ts/1353676299181"
+ }, {}, assert.fail);
+ assert.ok(onGet.args[0][0].params.ts instanceof Date);
+
+ assert.ok(!root.match("/ts/353676299181", "get"));
+ assert.ok(!root.match("/abc", "get"));
+ },
+
+ "test describe API": function() {
+ var root = new api.Api("api");
+ root.section("users", "User management")
+ .get({
+ name: "get",
+ description: "list all users",
+ route: "/"
+ })
+ .put({
+ route: "/:id",
+ params: {
+ id: { type: /\d{4}/ },
+ name: {
+ type: "string",
+ body: true
+ },
+ age: {
+ body: true,
+ optional: true,
+ type: "int"
+ }
+ }
+ })
+ .delete({
+ route: "/:id"
+ });
+
+ var description = root.describe();
+ //console.log(JSON.stringify(description, null, " "));
+
+ var expected = {
+ "description": "api",
+ "sections": [
+ {
+ "name": "users",
+ "description": "User management",
+ "routes": [
+ {
+ "route": "/",
+ "method": "get",
+ "name": "get",
+ "description": "list all users"
+ },
+ {
+ "route": "/:id",
+ "method": "put",
+ "params": {
+ "id": {
+ "type": "/\\d{4}/",
+ "source": "url",
+ "optional": false
+ },
+ "name": {
+ "type": "string",
+ "source": "body",
+ "optional": false
+ },
+ "age": {
+ "type": "int",
+ "source": "body",
+ "optional": true
+ }
+ }
+ },
+ {
+ "route": "/:id",
+ "method": "delete",
+ "params": {
+ "id": {
+ "type": "string",
+ "source": "url",
+ "optional": false
+ }
+ }
+ }
+ ]
+ }
+ ]
+ };
+ assert.equal(JSON.stringify(description), JSON.stringify(expected));
+
+ }
+};
+
+!module.parent && require("asyncjs").test.testcase(module.exports).exec();
View
114 lib/types.js
@@ -0,0 +1,114 @@
+"use strict";
+
+var inherits = require("util").inherits;
+
+exports.Types = function() {
+ this.types = {};
+
+ this.register("string", new exports.String());
+ this.register("number", new exports.Number());
+ this.register("int", new exports.Integer());
+ this.register("json", new exports.Json());
+ this.register("array", new exports.Array());
+ this.register("array[string]", new exports.Array(new exports.String()));
+};
+exports.Types.prototype.register = function(name, type) {
+ type.name = name;
+ if (type instanceof RegExp)
+ type = new exports.RegExp(type);
+
+ this.types[name] = type;
+ return this;
+};
+exports.Types.prototype.get = function(name) {
+ // if it already is a type return it
+ if (name.check && name.parse)
+ return name;
+
+ var type = this.types[name];
+ if (!type)
+ throw new Error("Unknown type ", name);
+
+ return type;
+};
+
+exports.Type = function() {};
+exports.Type.prototype.parse = function(string) {
+ return string;
+};
+exports.Type.prototype.check = function(value) {
+ return true;
+};
+exports.Type.prototype.toString = function() {
+ return this.name;
+};
+
+exports.RegExp = function(re) {
+ this.re = re;
+};
+inherits(exports.RegExp, exports.Type);
+exports.RegExp.prototype.parse = function(value) {
+ return value.toString();
+};
+exports.RegExp.prototype.check = function(value) {
+ value = value.toString();
+ var match = value.match(this.re);
+ return match && value === match[0];
+};
+exports.RegExp.prototype.toString = function() {
+ return (this.name ? this.name + " " : "") + this.re.toString();
+};
+
+exports.Json = function() {};
+inherits(exports.Json, exports.Type);
+exports.Json.prototype.parse = function(string) {
+ return JSON.parse(string);
+};
+
+exports.Array = function(itemType) {
+ this.itemType = itemType;
+};
+inherits(exports.Array, exports.Json);
+exports.Array.prototype.check = function(value) {
+ if (!Array.isArray(value))
+ return false;
+
+ if (!this.itemType)
+ return true;
+
+ for (var i = 0; i < value.length; i++) {
+ if (!this.itemType.check(value[i]))
+ return false;
+ }
+ return true;
+};
+
+exports.String = function() {};
+inherits(exports.String, exports.Type);
+exports.String.prototype.check = function(value) {
+ return typeof value == "string";
+};
+
+exports.Number = function() {};
+inherits(exports.Number, exports.Type);
+exports.Number.prototype.parse = function(string) {
+ var value = parseFloat(string);
+ if (isNaN(value))
+ throw new TypeError("Could not parse string as number");
+ return value;
+};
+exports.Number.prototype.check = function(value) {
+ return typeof value == "number";
+};
+
+exports.Integer = function() {};
+inherits(exports.Integer, exports.Type);
+exports.Integer.prototype.parse = function(string) {
+ return parseInt(string, 10);
+};
+exports.Integer.prototype.check = function(value) {
+ return (
+ typeof value == "number" &&
+ value.toString().match(/^\d+$/)
+ );
+};
View
26 lib/types_test.js
@@ -0,0 +1,26 @@
+"use strict";
+
+var assert = require("assert");
+
+var types = require("./types");
+
+module.exports = {
+ "test string array type": function() {
+ var t = new types.Array(new types.String());
+
+ assert.ok(t.check(["a", "b", "c"]));
+ assert.ok(!t.check([12, "b", "c"]));
+ },
+
+ "test array type": function() {
+ var t = new types.Array();
+
+ assert.ok(t.check(["a", "b", "c"]));
+ assert.ok(t.check([12, "b", "c"]));
+ assert.ok(!t.check({}));
+ assert.ok(!t.check(12));
+ assert.ok(!t.check("juhu"));
+ }
+};
+
+!module.parent && require("asyncjs").test.testcase(module.exports).exec();
View
33 package.json
@@ -0,0 +1,33 @@
+{
+ "author": "Ajax.org B.V. <info@ajax.org>",
+ "contributors": [{
+ "name": "Fabian Jakobs",
+ "email": "fabian@c9.io"
+ }],
+ "name": "frontdoor",
+ "description": "Frontdoor is a libarary for creating RESTful API servers.",
+ "version": "0.0.1",
+ "scripts": {
+ "test": "find lib | grep '_test.js$' | xargs -n 1 node"
+ },
+ "licenses": [{
+ "type": "MIT",
+ "url": "http://github.com/frontdoor/smith/raw/master/LICENSE"
+ }],
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/c9/frontdoor.git"
+ },
+ "main": "frontdoor.js",
+ "engines": {
+ "node": ">=0.6.0"
+ },
+ "dependencies": {
+ "http-error": "~0.0.1",
+ "request": "~2.12.0"
+ },
+ "devDependencies": {
+ "asyncjs": "~0.0.9",
+ "sinon": "~1.5.1"
+ }
+}
Please sign in to comment.
Something went wrong with that request. Please try again.