From 3363be22e88e50d6dd15f9a4b904bfe41cdd22bc Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 7 Nov 2010 15:58:39 -0800 Subject: [PATCH] first commit, implements most of the reading API --- LICENSE | 18 +++ Readme.md | 52 ++++++++ lib/index.js | 338 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 25 ++++ 4 files changed, 433 insertions(+) create mode 100644 LICENSE create mode 100644 Readme.md create mode 100644 lib/index.js create mode 100644 package.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c37eb40 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Copyright 2010 Sami Samhuri. All rights reserved. +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. diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..0add858 --- /dev/null +++ b/Readme.md @@ -0,0 +1,52 @@ +gitter +====== + +A GitHub client inspired by pengwynn/octopussy. + +v2 API + + +Installation +============ + +npm install gitter + + +Usage +===== + + var gh = require('gitter') + gh.user('samsonjs', function(err, user) { + if (err) throw err + console.log('---- user: samsonjs ----') + console.dir(user) + }) + + gh.repo('samsonjs/gitter', function(err, repo) { + if (err) throw err + console.log('---- repo: ' + repo.owner + '/' + repo.name + ' ----') + console.dir(repo) + }).getWatchers(function(err, repos) { + if (err) throw err + console.log('---- watchers ----') + console.dir(repos) + }).getBranches(function(err, branches) { + if (err) throw err + console.log('---- branches: samsonjs/gitter ----') + console.dir(branches) + gh.commit(this.repo, branches['master'], function(err, commit) { + if (err) throw err + console.log('---- samsonjs/gitter/master commit: ' + commit.id + ' ----') + console.dir(commit.data()) + }) + }) + +For the full API have a look at the top of [lib/index.js](/samsonjs/gitter/blob/master/lib/index.js). + + +License +======= + +Copyright 2010 Sami Samhuri sami.samhuri@gmail.com + +MIT (see included [LICENSE](/samsonjs/gitter/blob/master/LICENSE)) diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..a0a2d30 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,338 @@ +/// gitter +/// http://github.com/samsonjs/gitter +/// @_sjs +/// +/// Copyright 2010 Sami Samhuri +/// MIT License + +// TODO: +// - authentication and write APIs +// - run in browsers (dojo?) + +var request = require('request') + , util = require('util') + , Blob, Branch, Commit, Raw, Repo, Tree, User + +module.exports = { + blob: function(repo, sha, path, cb) { + return new Blob(repo, sha, path, cb) + }, + branch: function(repo, branch, cb) { + return new Branch(repo, branch, cb) + }, + commits: function(repo, branch, cb) { + return new Branch(repo, branch).getCommits(cb) + }, + commit: function(repo, sha, cb) { + return new Commit(repo, sha, cb) + }, + raw: function(repo, sha, cb) { + return new Raw(repo, sha, cb) + }, + repo: function(repo, cb) { + return new Repo(repo, cb) + }, + branches: function(repo, cb) { + return new Repo(repo).getBranches(cb) + }, + collaborators: function(repo, cb) { + return new Repo(repo).getCollaborators(cb) + }, + contributors: function(repo, cb) { + return new Repo(repo).getContributors(cb) + }, + languages: function(repo, cb) { + return new Repo(repo).getLanguages(cb) + }, + network: function(repo, cb) { + return new Repo(repo).getNetwork(cb) + }, + tags: function(repo, cb) { + return new Repo(repo).getTags(cb) + }, + watchers: function(repo, cb) { + return new Repo(repo).getWatchers(cb) + }, + tree: function(repo, sha, cb) { + return new Tree(repo, sha, cb) + }, + blobs: function(repo, sha, cb) { + return new Tree(repo, sha).getBlobs(cb) + }, + user: function(user, cb) { + return new User(user, cb) + }, + followers: function(user, cb) { + return new User(user).getFollowers(cb) + }, + following: function(user, cb) { + return new User(user).getFollowing(cb) + }, + list: function(user, cb) { + return new User(user).getRepos(cb) + }, + watched: function(user, cb) { + return new User(user).getWatched(cb) + } +} + +// Define resources // + +Blob = createResource('blob/show/:repo/:sha/:path', { + has: [ ['commits', 'commits/list/:repo/:sha/:path'] ] +}) +Branch = createResource('commits/show/:repo/:branch', { + has: [ ['commits', 'commits/list/:repo/:branch'] ] +}) +Commit = createResource('commits/show/:repo/:sha') +Raw = createResource('blob/show/:repo/:sha') +Repo = createResource('repos/show/:repo', { + has: [ 'branches' + , 'collaborators' + , 'contributors' + , 'languages' + , 'network' + , 'tags' + , 'watchers' + ] +}) +Tree = createResource('tree/show/:repo/:sha', { + has: [ ['blobs', 'blob/all/:repo/:sha'] + , ['fullBlobs', 'blob/full/:repo/:sha'] + , ['fullTree', 'tree/full/:repo/:sha'] + ] +}) +User = createResource('user/show/:user', { + has: [ 'followers' + , 'following' + , ['repos', 'repos/show/:user'] + , ['watched', 'repos/watched/:user'] + ] +}) + +// Construct a new github resource. +// +// options: +// - params: params for constructor (optional, inferred from route if missing) +// - has: list of related resources, accessors are created for each item +// +// The members of the `has` list are arrays of the form [name, route, unpack]. +// The first member, name, is used to create an accessor (e.g. getName), and +// is required. +// +// Route and unpack are optional. Route specifies the endpoint for this +// resource and defaults to the name appended to the main resource's endpoint. +// +// Unpack is a function that extracts the desired value from the object fetched +// for this resource. It defaults to a function that picks out the only property +// from an object, or returns the entire walue if not an object or it contains +// more than one property. +// +// When passing only the name you may pass it directly without wrapping it in +// an array. +function createResource(route, options) { + if (!route) throw new Error('route is required') + options = options || {} + + var resource = function() { Resource.apply(this, slice(arguments)) } + util.inherits(resource, Resource) + + resource.prototype._route = route + resource.prototype._params = options.params || paramsFromRoute(route) + + resource.has = function(prop, route, unpack) { + unpack = unpack || onlyProp + var dataProp = '_' + prop + , fn = 'get' + titleCaseFirst(prop) + resource.prototype[fn] = function(cb, force) { + return this._fetch({ prop: dataProp + , route: route || this._route + '/' + prop + , processData: function(d) { + getter(this, dataProp, function() { return camelize(unpack(d)) }) + }.bind(this) + , result: function(resource) { return resource[dataProp] } + }, cb.bind(this), force) + } + return resource + } + if (options.has) options.has.forEach(function(args) { + resource.has.apply(resource, Array.isArray(args) ? args : [args]) + }) + + return resource +} + +// Assigns the given resource args to the new instance. Sets the path to the +// endpoint for main resource data. +// +// If the optional last arg is a function main data is fetched immediately, +// and that function is used as the callback. +// +// If the optional last arg is an object then it is set as the main resource +// data. +function Resource(/* ...args, opt: data or callback */) { + var args = slice(arguments) + , last = args[args.length - 1] + + // assign params from args + this._params.forEach(function(param, i) { + this[param] = args[i] + }.bind(this)) + + // set the resource path + this.path = this.resolve(this._route) + + if (typeof last === 'function') this.fetch(last) + else if (typeof last === 'object') this.data(last) +} + +// Set or get main data for this resource, or fetch +// a specific property from the data. +// +// When the data param is empty cached data is returned. +// +// When the data param is a string the property by that name +// is looked up in the cached data. +// +// Otherwise cached data is set to the data param. +Resource.prototype.data = function(data) { + if (!data) return this._data + if (typeof data === 'string') return this._data && this._data[data] + + getter(this, '_data', function() { return data }, {configurable: true}) + if (!this._propsDefined) { + getter(this, '_propsDefined', function() { return true }) + dataProps(Object.getPrototypeOf(this), Object.keys(this._data)) + } + return this +} + +// Fetch the main data for this resource. +// +// cb: callback(err, data) +// force: if true load data from github, bypassing the local cache +Resource.prototype.fetch = function(cb, force) { + return this._fetch({ prop: '_data' + , route: this.path + , processData: this._processData.bind(this) + , result: function(resource) { return resource } + }, cb.bind(this), force) +} + +// 'repos/show/:user/:repo/branches' -> 'repos/show/samsonjs/gitter +Resource.prototype.resolve = function(route) { // based on crock's supplant + if (route.indexOf(':') < 0) return route + return route.replace(/:(\w+)\b/g, function (s, prop) { + var val = this[prop] + if (typeof val !== 'string' && typeof val !== 'number') + throw new Error('no suitable property named "' + prop + '" (found ' + val + ')') + return val + }.bind(this)) +} + +// Fetch arbitrary data from github. +// +// options: +// - prop: name of data cache property +// - route: route to github endpoint (can contain resource params) +// - processData: function that processes fetched data +// - result: function to obtain the result passed to the callback +// cb: callback(err, data) +// force: if true load data from github, bypassing the local cache +Resource.prototype._fetch = function(options, cb, force) { + if (!force && this[options.prop]) { + cb(null, options.result(this)) + return this + } + + // Interpolate resource params + var path = this.resolve(options.route) + + // Make the request + return this._get(path, function(err, data) { + if (err) { + cb(err) + return + } + options.processData(data) + cb(null, options.result(this)) + }.bind(this)) +} + +// Fetch data from github. JSON responses are parsed. +// +// path: github endpoint +// cb: callback(err, data) +Resource.prototype._get = function(path, cb) { + request({uri: 'http://github.com/api/v2/json/' + path}, function(err, response, body) { + if (err) + cb(err) + else if (response.statusCode !== 200) + cb(new Error('failed to fetch ' + path + ': ' + response.statusCode)) + else if (response.headers['content-type'].match(/json/)) + cb(null, JSON.parse(body)) + else + cb(null, body) + }) + return this +} + +// Descendents of Resource can overwrite _processData and _unpack to process +// the main resource data differently. + +Resource.prototype._processData = function(data) { + return this.data(camelize(this._unpack(data))) +} +Resource.prototype._unpack = onlyProp + + +// Utilities // + +function camel(s) { // created_at => createdAt + return s.replace(/_(.)/g, function(_, l) { return l.toUpperCase() }) +} +function camelize(obj) { // camelize all keys of an object, or all objects in an array + if (!obj) return obj + if (Array.isArray(obj)) return obj.map(camelize) + return Object.keys(obj).reduce(function(newObj, k) { + newObj[camel(k)] = obj[k] + return newObj + }, {}) +} + +function getter(obj, prop, fn, opts) { // minor convenience + opts = opts || {} + opts.get = fn + Object.defineProperty(obj, prop, opts) +} + +// get an only property, if any +function onlyProp(obj) { + if (obj && typeof obj === 'object') { + var keys = Object.keys(obj) + if (keys.length === 1) return obj[keys[0]] + } + return obj +} + +// 'repos/show/:user/:repo/branches' -> ['user', 'repo'] +function paramsFromRoute(route) { + if (route.indexOf(':') === -1) return [] + return route.split('/') + .filter(function(s) { return s.charAt(0) === ':' }) + .map(function(s) { return s.slice(1) }) +} + +// define getters for a list of data properties +function dataProps(obj, keys) { + keys.forEach(function(key) { + if (obj.hasOwnProperty(key)) + console.warn('property "' + key + '" already exists, skipping') + else + getter(obj, key, function() { return this.data(key) }, {enumerable: true}) + }) +} + +function slice(x) { return [].slice.call(x) } + +function titleCaseFirst(s) { return s.charAt(0).toUpperCase() + s.slice(1) } diff --git a/package.json b/package.json new file mode 100644 index 0000000..280789f --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ "name" : "gitter" +, "description" : "GitHub client (API v2), inspired by pengwynn/octopussy" +, "version" : "0.0.1" +, "homepage" : "http://samhuri.net/node/gitter" +, "author" : "Sami Samhuri " +, "repository" : + { "type" : "git" + , "url" : "http://github.com/samsonjs/gitter.git" + } +, "bugs" : + { "mail" : "sami.samhuri+gitter@gmail.com" + , "web" : "http://github.com/samsonjs/gitter/issues" + } +, "directories" : { "lib" : "./lib" } +, "main" : "./lib/index" +, "engines" : { "node" : ">=0.3.0" } +, "licenses" : + [ { "type" : "MIT" + , "url" : "http://github.com/samsonjs/gitter/raw/master/LICENSE" + } + ] +, "dependencies" : { "request" : "0.10.0 - 0.10.999" + , "vows" : "0.5.0 - 0.5.999" + } +} \ No newline at end of file