github-cache
Version:
Transparent caching layer for node-github module
334 lines (276 loc) • 9.76 kB
JavaScript
var url = require('url')
var util = require('util')
var crypto = require('crypto')
var debug = require('debug')('github-cache')
var lodash = require('lodash')
var libkv = require('libkv')
var async = require('async')
function GitHubCache (GitHubAPI, options) {
if (!(this instanceof GitHubCache)) {
return new GitHubCache(GitHubAPI, options)
}
this.options = lodash.extend({
prefix: '',
separator: '/',
cachedb: {
uri: 'level:///./github-cachedb',
valueOnly: true
}
}, options)
this.api = GitHubAPI
this.prefix = this.options.prefix
this.separator = this.options.separator
if (this.prefix !== '') {
this.prefix += this.separator
}
this._validateGitHubAPI()
this._setupApis()
this._setupCacheDb()
return this
}
module.exports = GitHubCache
GitHubCache.prototype._validateGitHubAPI = function GitHubCacheValidateGitHubAPI () {
if (typeof this.api.config === 'undefined') {
throw new Error('GitHubAPI does not appear to be valid')
}
}
GitHubCache.prototype._setupApis = function GitHubCacheSetupAPIS () {
var self = this
self.authenticate = function (auth) { this.api.authenticate(auth) }
var routes = this.api.routes || require('github/lib/routes.json')
var apis = Object.keys(routes)
apis.forEach(function (api) {
api = toCamelCase(api)
debug('loading api: %s', api)
var keys = Object.keys(self.api[api])
if (typeof self[api] === 'undefined') {
self[api] = {}
}
keys.forEach(function (key) {
debug('loading api: %s, function: %s', api, key)
// self[api]['_' + key] = self[api][key]
self[api][key] = function (options, funCallback) {
var promise
if (funCallback === undefined) {
promise = new Promise(function (resolve, reject) {
funCallback = function (err, data) {
if (err) {
reject(err)
} else {
resolve(data)
}
}
})
}
var cacheId = self.cacheId(api, key, options)
debug('api: %s, key: %s, id: %s, options: %j', api, key, cacheId, options)
var defaultOpts = lodash.merge({
cache: true,
validateCache: true
}, lodash.pick(self.options, ['validateCache', 'cache']))
options = lodash.merge(defaultOpts, options)
self.getCache(cacheId, function (err, cachedEtag, cachedData) {
debug('pre-options: %j', options)
debug('cached etag: %s', cachedEtag)
if (err && (!err.notFound && err.status !== 404)) {
debug('getCache error: %j', err)
return funCallback(err)
}
if (cachedEtag && options.cache === true) {
options = lodash.merge({headers: {'If-None-Match': cachedEtag}}, options)
}
debug('post-options: %j', options)
if (options.validateCache === false && typeof cachedData !== 'undefined') {
return funCallback(null, cachedData)
}
var opts = lodash.omit(options, ['cache', 'validateCache', 'invalidateCache'])
self.api[api][key](opts, function (err, results) {
var notModified = (err && err.code === 304) ||
(results && results.meta.status === '304 Not Modified')
if (err && !notModified) {
return funCallback(err)
}
if (typeof options.invalidateCache !== 'undefined') {
self.invalidateCache(options.invalidateCache, opts)
}
if (options.cache === false) {
return funCallback(null, results)
}
if (notModified) {
return funCallback(null, cachedData)
}
self.putCache(cacheId, results, function (err) {
if (err) {
return funCallback(err)
}
funCallback(null, results)
})
})
})
return promise
}
})
})
}
GitHubCache.prototype._setupCacheDb = function GitHubCacheSetupCacheDB () {
var uri = null
if (typeof this.options.cachedb === 'string') {
// We assume using libkv?
uri = url.parse(this.options.cachedb)
this.cachedb = libkv(uri.protocol.replace(/:/, ''), {
uri: this.options.cachedb,
valueOnly: true
})
} else if (typeof this.options.cachedb === 'object') {
if (typeof this.options.cachedb.uri === 'string') {
// Assume uri with options
uri = url.parse(this.options.cachedb.uri)
this.cachedb = libkv(uri.protocol.replace(/:/, ''), lodash.extend(this.options.cachedb, { valueOnly: true }))
} else {
if (typeof this.options.cachedb.put !== 'function') {
throw new Error('Cache does not have the function PUT')
}
if (typeof this.options.cachedb.get !== 'function') {
throw new Error('Cache does not have the function GET')
}
if (typeof this.options.cachedb.del !== 'function') {
throw new Error('Cache does not have the function DEL')
}
this.cachedb = this.options.cachedb
}
}
}
GitHubCache.prototype.getCache = function GitHubCacheGetCache (cacheId, callback) {
var self = this
debug('getCache id: %s', cacheId)
self.cachedb.get(cacheId + self.separator + 'tag', function (err, tag) {
debug('getCache id: %s, tag: %s', (cacheId + self.separator + 'tag'), tag)
if (err && err.status === '404') {
return callback(null, false, undefined)
}
if (err) {
debug('getCache tag error: %j', err)
return callback(err)
}
self.cachedb.get(cacheId + self.separator + 'meta', function (err, meta) {
debug('getCache id: %s, meta: %j', (cacheId + self.separator + 'meta'), meta)
if (err && err.status === '404') {
return callback(null, false, undefined)
}
if (err) {
debug('getCache meta error: %j', err)
return callback(err)
}
var metaData = {}
try {
metaData = JSON.parse(meta)
} catch (e) {
debug('getCache meta json parse error: %j', err)
return callback(e)
}
self.cachedb.get(cacheId + self.separator + 'data', function (err, data) {
debug('getCache id: %s, data: %j', (cacheId + self.separator + 'data'), data)
if (err && err.status === '404') {
return callback(null, false, undefined)
}
if (err) {
debug('getCache error1: %j', err)
return callback(err)
}
var d = {}
try {
d = JSON.parse(data)
} catch (e) {
debug('getCache data parse error: %j', err)
return callback(e)
}
d.meta = lodash.pick(metaData, ['link', 'etag', 'status'])
d.meta.status = '304 Not Modified'
callback(null, tag, d)
})
})
})
}
GitHubCache.prototype.putCache = function GitHubCachePutCache (cacheId, cachedData, callback) {
var self = this
debug('putCache id: %s', cacheId)
if (typeof cachedData.meta.etag === 'undefined') {
debug('putCache - missing etag data')
return callback(null)
}
var ops = [
{
type: 'put',
key: cacheId + self.separator + 'tag',
value: cachedData.meta.etag
},
{
type: 'put',
key: cacheId + self.separator + 'meta',
value: JSON.stringify(cachedData.meta)
},
{
type: 'put',
key: cacheId + self.separator + 'data',
value: JSON.stringify(cachedData)
}
]
debug('putCache ops: %j', ops)
async.eachSeries(ops, function (op, cb) {
self.cachedb[op.type](op.key, op.value, cb)
}, function (err) {
if (err) {
debug('putCache - err: %j', err)
return callback(err)
}
callback()
})
}
GitHubCache.prototype.invalidateCache = function GitHubCacheInvalidateCache (invalidateOpts, options) {
var self = this
var invalid = false
debug('invalidateCache @ opts: %j', invalidateOpts)
var validOpts = ['api', 'fun', 'fields']
validOpts.forEach(function (f) {
if (typeof invalidateOpts[f] === 'undefined') {
invalid = true
}
})
if (invalid === true) {
return
}
options = lodash.omit(options, ['invalidateCache', 'cache', 'validateCache', 'headers'])
options = lodash.pick(options, invalidateOpts.fields)
options = lodash.merge(options, {page: 0, per_page: 100})
var cacheId = self.cacheId(invalidateOpts.api, invalidateOpts.fun, options)
debug('invalidateCache @ invalid: %j, options: %j, id: %s', invalidateOpts, options, cacheId)
var ops = [
{ type: 'del', key: self.prefix + cacheId + self.separator + 'tag' },
{ type: 'del', key: self.prefix + cacheId + self.separator + 'data' }
]
async.eachSeries(ops, function (op, cb) {
self.cachedb[op.type](op.key, cb)
}, function (err) {
if (err) {
debug('error invalidating cache: %s', err)
}
})
}
GitHubCache.prototype.cacheId = function GitHubCacheCacheID (api, fun, options) {
var self = this
var optionsKey = lodash.omit(options, ['validateCache', 'cache', 'invalidateCache', 'headers'])
var hash = crypto.createHash('sha1').update(JSON.stringify(optionsKey)).digest('hex')
var cacheId = util.format('%s%s%s%s%s%s', self.prefix, api, self.separator, fun, self.separator, hash)
debug('cacheId - api: %s, function: %s, options: %j, id: %s', api, fun, optionsKey, cacheId)
return cacheId
}
// Borrowed from https://github.com/mikedeboer/node-github/blob/master/util.js
function toCamelCase (str, upper) {
str = str.toLowerCase().replace(/(?:(^.)|(\s+.)|(-.))/g, function (match) {
return match.charAt(match.length - 1).toUpperCase()
})
if (upper) {
return str
}
return str.charAt(0).toLowerCase() + str.substr(1)
}