├── .gitignore ├── Makefile ├── LICENSE ├── github-0.0.2-1.rockspec ├── src ├── github │ └── parsers.lua └── github.lua └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | inspect.lua 2 | luacov.* 3 | *.src.rock 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LUA_DIR=/usr/local 2 | LUA_LIBDIR=$(LUA_DIR)/lib/lua/5.1 3 | LUA_SHAREDIR=$(LUA_DIR)/share/lua/5.1 4 | HAS_LUACOV=$(shell lua -e "ok, luacov = pcall(require, 'luacov'); if ok then print('true') end") 5 | 6 | install: 7 | mkdir -p $(LUA_SHAREDIR)/github 8 | cp src/github.lua $(LUA_SHAREDIR) 9 | 10 | test: tests/test_*.lua 11 | ifeq ($(HAS_LUACOV), true) 12 | lua -lluacov $? 13 | else 14 | lua $? 15 | endif 16 | 17 | .PHONY: install test 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014 John E. Vincent 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /github-0.0.2-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "github" 2 | version = "0.0.2-1" 3 | source = { 4 | url = "git://github.com/lusis/lua-github", 5 | tag = "0.0.2-1" 6 | } 7 | description = { 8 | summary = "Github API library", 9 | detailed = [[ 10 | A library largely focused on handling user, organization and team related reads from github. Has helpers for oauth" 11 | ]], 12 | homepage = "https://github.com/lusis/lua-github", 13 | license = "Apache" 14 | } 15 | dependencies = { 16 | "httpclient ~> 0.1.0-7", 17 | "lpeg ~> 0.12-1" 18 | } 19 | build = { 20 | type = "builtin", 21 | modules = { 22 | ['github'] = 'src/github.lua', 23 | ['github.parsers'] = 'src/github/parsers.lua' 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/github/parsers.lua: -------------------------------------------------------------------------------- 1 | local lpeg = require 'lpeg' 2 | local P, C, R, S, Cs, Cc, Ct, Cf, Cg, V, Cmt = lpeg.P, lpeg.C, lpeg.R, lpeg.S, lpeg.Cs, lpeg.Cc, lpeg.Ct, lpeg.Cf, lpeg.Cg, lpeg.V, lpeg.Cmt 3 | local lpegmatch, lpegpatterns, replacer = lpeg.match, lpeg.patterns, lpeg.replacer 4 | local inspect = require 'inspect' 5 | 6 | local function parse_link_header(str) 7 | -- This is our placeholder for matches 8 | local links = links or {} 9 | 10 | -- As we get a table match, we add a new entry to the links table in the format of: 11 | -- { reltype = "target" } 12 | local function build_pagination(t1) 13 | links[t1.rel] = t1.url 14 | return t1 15 | end 16 | 17 | -- link headers look like so: 18 | -- ; rel="sometype" 19 | -- multiple links can be joined together with commas: 20 | -- ; rel="foo",; rel="bar" 21 | -- 22 | local openbracket = P("<") 23 | local closebracket = P(">") 24 | local semicolon = P(";") 25 | local equal = P("=") 26 | local comma = P(",") 27 | local quote = P('"') 28 | local endofstring = P(-1) 29 | local nothing = Cc("") 30 | local whitespace = P(" ")^1 31 | -- target is the url in in between <> 32 | local target = Cg(((P(1) - closebracket)^-0), "url") 33 | -- reltype is the value for rel 34 | local reltype = Cg(((P(1) - quote)^-0), "rel") 35 | -- Make a capture table of one match 36 | -- { rel = "next", url = "https://....." } 37 | local match_one = Ct((whitespace)^0 * openbracket * target * closebracket * semicolon * (whitespace)^0 * "rel=" * quote * reltype * quote * (whitespace)^0 * (comma)^0) 38 | -- apply function to one match 39 | local linkwfunc = (match_one / build_pagination) 40 | -- make a capture table of one or more matches 41 | local match_any = Ct((linkwfunc)^1) 42 | -- do the match 43 | match_links = lpegmatch(match_any, str) 44 | return links 45 | end 46 | 47 | local parsers = { 48 | parse_link_header = parse_link_header 49 | } 50 | 51 | return parsers 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lua-github 2 | `lua-github` is a small wrapper around the github API. It's currently primarily focused on user, org and team read-operations but the hope is to extend it to as much of the github api as possible. 3 | 4 | ## Install 5 | Easiest option is probably to install from luarocks: 6 | 7 | `luarocks install github` 8 | 9 | Alternately, you can install via the included `Makefile`. You'll probably want to override the `LUA_SHAREDIR` environment variable. You'll also need to make sure you install [my httpclient library](https://github.com/lusis/lua-httpclient). Since github uses SSL, if you're using the `luasocket` driver you'll need `luasec` as well. If you're only using it via ngx-lua/openresty, you shouldn't need `luasec`. 10 | 11 | ## Requirements 12 | The following versions were tested 13 | - lua 5.2 (the version that shipped with trusty) 14 | - httpclient 0.1.0-6 15 | - openresty 1.7.4.1 16 | 17 | ## Usage 18 | Below are a few examples of usage both with nginx and luasocket/luasec. Results of most api calls return either 19 | - a lua table of the json (via `cjson.decode`) and nil error 20 | or 21 | - a nil result and an error message 22 | 23 | Some api calls return `true`/`false` and any relevant error. 24 | 25 | ### straight lua 26 | 27 | ```lua 28 | local inspect = require 'inspect' 29 | local gh = require('github').new() 30 | gh:set_access_token('XXXXXXXX') 31 | -- alternately: local gh = require('github').new({access_token = 'XXXXXXXXX'}) 32 | local r, err = gh:get_authenticated_user() 33 | print(err) 34 | -- no access token 35 | 36 | gh:set_access_token('XXXXXXXX') 37 | -- alternately: local gh = require('github').new({access_token = 'XXXXXXXXX'}) 38 | 39 | local r, err = gh:get_authenticated_user() 40 | 41 | if err then 42 | print(err) 43 | else 44 | inspect(r) 45 | end 46 | -- { 47 | -- avatar_url = "https://avatars.githubusercontent.com/u/228958?v=3", 48 | -- bio = , 49 | -- blog = "http://about.me/lusis", 50 | -- company = "The Lusis Group", 51 | -- created_at = "2010-03-23T20:28:44Z", 52 | -- email = "lusis.org+github.com@gmail.com", 53 | -- events_url = "https://api.github.com/users/lusis/events{/privacy}", 54 | -- followers = 231, 55 | -- followers_url = "https://api.github.com/users/lusis/followers", 56 | -- following = 97, 57 | -- following_url = "https://api.github.com/users/lusis/following{/other_user}", 58 | -- gists_url = "https://api.github.com/users/lusis/gists{/gist_id}", 59 | -- gravatar_id = "", 60 | -- hireable = false, 61 | -- html_url = "https://github.com/lusis", 62 | -- id = 228958, 63 | -- location = "Roswell, GA.", 64 | -- login = "lusis", 65 | -- name = "John E. Vincent", 66 | -- organizations_url = "https://api.github.com/users/lusis/orgs", 67 | -- public_gists = 231, 68 | -- public_repos = 137, 69 | -- received_events_url = "https://api.github.com/users/lusis/received_events", 70 | -- repos_url = "https://api.github.com/users/lusis/repos", 71 | -- site_admin = false, 72 | -- starred_url = "https://api.github.com/users/lusis/starred{/owner}{/repo}", 73 | -- subscriptions_url = "https://api.github.com/users/lusis/subscriptions", 74 | -- type = "User", 75 | -- updated_at = "2014-12-03T20:56:53Z", 76 | -- url = "https://api.github.com/users/lusis" 77 | -- } 78 | 79 | print(r.login) 80 | -- lusis 81 | local r, err = gh:get_authed_user_org_membership('github', 'lusis') 82 | print(r) 83 | -- false 84 | local r, err = gh:get_authed_user_org_membership('logstash', 'lusis') 85 | print(r) 86 | -- true 87 | ``` 88 | 89 | ### nginx usage 90 | The nginx usage is largely similar but you'll need to follow [the httpclient instructions](https://github.com/lusis/lua-httpclient#openrestynginx-example) to set up the internal redirect in nginx. 91 | Then you'll use this library specifying the alternate driver: 92 | 93 | ```lua 94 | ngx.var.access_token = 'XXXXXXX' 95 | local gh = require('github').new({access_token = ngx.var.access_token, httpclient_driver = 'httpclient.ngx_driver'}) 96 | local r, err = gh:get_authenticated_user() 97 | if err or r.login ~= 'lusis' then 98 | ngx.exit(ngx.HTTP_UNAUTHORIZED) 99 | else 100 | ngx.say("welcome "..r.login) 101 | end 102 | ``` 103 | 104 | ### oauth usage 105 | Alternately you can make all calls with oauth credentials but it's up to you to store those somewhere inside nginx each authenticated user. The library provides a few helpers you can use: 106 | 107 | - Generate a redirect url for oauth requests: `gh:get_authorize_url(client_id, scope)` 108 | - Process an oauth callback code to generate a usable auth token: `gh:request_token(callback_code, args)` 109 | 110 | In the second example, args is a lua table like so: 111 | ```lua 112 | args = { 113 | client_id = 'XXXXXXX', 114 | client_secret = 'YYYYYYY', 115 | redirect_uri = 'http://hostname/my_oauth_callback' 116 | } 117 | ``` 118 | 119 | ## Authenticated vs Unauthenticated calls 120 | Note that the library can be used out of the box without having a github access token. Most calls provide authenticated vs unauthenticated versions. For example: 121 | 122 | - `get_user(username)` 123 | vs 124 | - `get_authenticated_user()` 125 | 126 | The first option makes a call to `https://api.github.com/users/` while the second makes a call to `https://api.github.com/user?access_token=XXXXXXX`. 127 | This allows you to use public calls for some operations where the token isn't neccessary. 128 | 129 | ## Debugging the underlying httpclient 130 | A feature of `httpclient` is the ability to get details about the last request made. The github library exposes the raw httpclient via `gh.hc`. Any functions available to straight httpclient can be used here as well like `res = gh.hc:get('https://httpbin.org/get')`. 131 | 132 | With every action in `httpclient` you can always call `get_last_request()` which will return some information about the driver as well as the data passed in to driver to make the request. 133 | For example while writing this library, I ran into some header related bugs with nginx. I was able to debug this information like so: 134 | 135 | ```lua 136 | local inspect = require 'inspect' 137 | ngx.log(ngx.ERR, inspect(gh.hc:get_last_request().get_headers())) 138 | ``` 139 | 140 | Which is where I realized that my `accept` header wasn't being passed to the github api properly. 141 | 142 | ## TODO 143 | - tests 144 | - environment variable + bin script for a quick cli client 145 | - more docs 146 | - docker/rocket for quick openresty environment for testing 147 | -------------------------------------------------------------------------------- /src/github.lua: -------------------------------------------------------------------------------- 1 | -- @module github 2 | -- Copyright (C) 2014 John E. Vincent (lusis) 3 | 4 | -- TODO 5 | -- * clean up pagination logic. it's ugly as fuck and weaksauce 6 | local cjson = require 'cjson' 7 | local parsers = require 'github.parsers' 8 | 9 | local _M = {} 10 | local m = {} 11 | 12 | _M.VERSION = "0.0.1" 13 | 14 | local defaults = { 15 | api_url = "https://api.github.com", 16 | oauth_url = "https://github.com/login/oauth", 17 | authorize_base_url = "https://github.com/login/oauth/authorize", 18 | access_base_url = "https://github.com/login/oauth/access_token" 19 | } 20 | 21 | local function merge_tables(t1, t2) 22 | for k,v in ipairs(t2) do 23 | table.insert(t1, v) 24 | end 25 | return t1 26 | end 27 | 28 | function m.new(params) 29 | local self = {} 30 | local args = params or {} 31 | self.access_token = nil 32 | if not args.httpclient_driver then 33 | self.hc = require('httpclient').new() 34 | else 35 | self.hc = require('httpclient').new(args.httpclient_driver) 36 | end 37 | if args.access_token then 38 | self.access_token = args.access_token 39 | end 40 | self.user_agent = args.user_agent or "lua-github ".._M.VERSION 41 | setmetatable(self, {__index = _M}) 42 | return self 43 | end 44 | 45 | function _M:set_access_token(token) 46 | self.access_token = token 47 | end 48 | 49 | function _M:get_access_token() 50 | return self.access_token 51 | end 52 | 53 | function _M:unauthed_request(path, args, buffer) 54 | if not path then 55 | return nil, "no path specified" 56 | end 57 | local args = args or {} 58 | local page_buffer = buffer or {} 59 | local page_size = args.page_size or 30 60 | local url = defaults.api_url..path.."?per_page="..page_size 61 | local res = self.hc:get(url,{headers = {accept = "application/json", ["content-type"] = "application/json", ["user-agent"] = self.user_agent}}) 62 | if args.raw then 63 | return res, nil 64 | else 65 | if res.err then 66 | return nil, res.err 67 | else 68 | if res.headers["link"] then 69 | -- we have pagination 70 | local links = parsers.parse_link_header(res.headers["link"]) 71 | if links["next"] then 72 | page_buffer = merge_tables(page_buffer, cjson.decode(res.body)) 73 | local u = self.hc:urlparse(links["next"]) 74 | local next_page = u.path.."?page="..u.query.page 75 | return self:authed_request(next_page, args or {}, page_buffer) 76 | else 77 | -- on last page 78 | page_buffer = merge_tables(page_buffer, cjson.decode(res.body)) 79 | return page_buffer, nil 80 | end 81 | else 82 | return cjson.decode(res.body), nil 83 | end 84 | end 85 | end 86 | end 87 | 88 | function _M:authed_request(path, args, buffer) 89 | if not path then 90 | return nil, "no path specified" 91 | end 92 | local token = self:get_access_token() 93 | if not token then 94 | return nil, "no access token" 95 | end 96 | local args = args or {} 97 | local page_buffer = buffer or {} 98 | local args = args or {} 99 | local page_size = args.page_size or 30 100 | local url = defaults.api_url..path.."?per_page="..page_size.."&access_token="..token 101 | local res = self.hc:get(url,{headers = {accept = "application/json", ["content-type"] = "application/json", ["user-agent"] = self.user_agent}}) 102 | if args.raw then 103 | return res, nil 104 | else 105 | if res.err then 106 | return nil, res.err 107 | else 108 | if res.headers["link"] then 109 | -- we have pagination 110 | local links = parsers.parse_link_header(res.headers["link"]) 111 | if links["next"] then 112 | page_buffer = merge_tables(page_buffer, cjson.decode(res.body)) 113 | local u = self.hc:urlparse(links["next"]) 114 | local next_page = u.path.."?page="..u.query.page 115 | return self:authed_request(next_page, args or {}, page_buffer) 116 | else 117 | -- on last page 118 | page_buffer = merge_tables(page_buffer, cjson.decode(res.body)) 119 | return page_buffer, nil 120 | end 121 | else 122 | return cjson.decode(res.body), nil 123 | end 124 | end 125 | end 126 | end 127 | 128 | -- returns a url that can be used for oauth redirection 129 | function _M:get_authorize_url(client_id, scope) 130 | if not client_id or not scope then 131 | return nil, "missing a require param: client_id or scope" 132 | else 133 | return defaults.authorize_base_url.."?client_id="..client_id.."&scope="..scope, nil 134 | end 135 | end 136 | 137 | -- makes a request to github for an access token 138 | -- based on an oauth callback 139 | function _M:request_token(callback_code, args) 140 | if not callback_code then 141 | return nil, "this requires a callback code from github" 142 | end 143 | if not args.client_id or not args.client_secret or not args.redirect_uri then 144 | return nil, "missing one of the required params: client_id, client_secret or callback url" 145 | end 146 | local params = { 147 | access_token_url = defaults.access_base_url, 148 | client_id = args.client_id, 149 | client_secret = args.client_secret, 150 | code = callback_code, 151 | redirect_uri = args.redirect_uri 152 | } 153 | 154 | local opts = { 155 | headers = { accept = "application/json" }, 156 | params = params, 157 | } 158 | 159 | local res = self.hc:post(defaults.access_base_url, "", opts) 160 | if res.err then 161 | return nil, res.err 162 | else 163 | local b = cjson.decode(res.body) 164 | return b.access_token, nil 165 | end 166 | end 167 | 168 | -- returns details for the requested user 169 | function _M:get_user(username) 170 | if not username then return nil, "must specificy username" end 171 | return self:unauthed_request("/users/"..username) 172 | end 173 | 174 | -- returns details for the authenticated user 175 | function _M:get_authenticated_user() 176 | return self:authed_request("/user") 177 | end 178 | 179 | -- returns the followers for the requested user 180 | function _M:get_user_followers(username) 181 | if not username then return nil, "must specify username" end 182 | return self:unauthed_request("/users/"..username.."/followers") 183 | end 184 | 185 | -- returns the followers for the authenticated user 186 | function _M:get_authenticated_user_followers() 187 | return self:authed_request("/user/followers") 188 | end 189 | 190 | -- returns the teams for the authenticated user 191 | function _M:get_authenticated_user_teams() 192 | return self:authed_request("/user/teams") 193 | end 194 | 195 | -- returns the users followed by the requested user 196 | function _M:get_user_following(username) 197 | if not username then return nil, "must specify username" end 198 | return self:unauthed_request("/users/"..username.."/following") 199 | end 200 | 201 | -- returns the users followed by the authenticated user 202 | function _M:get_authenticated_user_following() 203 | return self:authed_request("/user/following") 204 | end 205 | 206 | -- returns the verified public keys for the requested user 207 | function _M:get_user_pubkeys(username) 208 | if not username then return nil, "must specify username" end 209 | return self:unauthed_request("/users/"..username.."/keys") 210 | end 211 | 212 | -- returns the keys for the authenticated user 213 | function _M:get_authenticated_user_pubkeys(username) 214 | return self:authed_request("/user/keys") 215 | end 216 | 217 | -- gets a single key for the authenticated user 218 | function _M:get_authenticated_user_key(key_id) 219 | if not key_id then return nil, "must specify key id" end 220 | return self:authed_request("/user/keys/"..key_id) 221 | end 222 | 223 | function _M:get_org(org_name) 224 | if not org_name then return nil, "must specify org name" end 225 | res, err = self:unauthed_request("/orgs/"..org_name, {raw = true}) 226 | if res.code ~= 200 then 227 | return nil, res.err 228 | else 229 | return cjson.decode(res.body), nil 230 | end 231 | end 232 | 233 | function _M:get_authed_org(org_name) 234 | if not org_name then return nil, "must specify org name" end 235 | res, err = self:authed_request("/orgs/"..org_name, {raw = true}) 236 | if res.code ~= 200 then 237 | return nil, res.err 238 | else 239 | return cjson.decode(res.body), nil 240 | end 241 | end 242 | 243 | -- gets an organization's public members 244 | function _M:get_org_members(org_name) 245 | if not org_name then return nil, "must specify org name" end 246 | return self:unauthed_request("/orgs/"..org_name.."/members") 247 | end 248 | 249 | -- returns org members as visible to the authenticated user 250 | function _M:get_authed_org_members(org_name) 251 | if not org_name then return nil, "must specify org name" end 252 | return self:authed_request("/orgs/"..org_name.."/members") 253 | end 254 | 255 | -- check if a user is publicly a member of an organization 256 | function _M:get_user_org_membership(org_name, username) 257 | if not org_name then return nil, "must specify org name" end 258 | if not username then return nil, "must specify username" end 259 | local res,_ = self:unauthed_request("/orgs/"..org_name.."/public_members/"..username, {raw = true}) 260 | if res.code == 404 then return false, nil end 261 | if res.code == 204 then return true, nil end 262 | return nil, res.err 263 | end 264 | 265 | -- check if a user is publicly or privately a member of an organization 266 | function _M:get_authed_user_org_membership(org_name, username) 267 | if not org_name then return nil, "must specify org name" end 268 | if not username then return nil, "must specify username" end 269 | local res,_ = self:authed_request("/orgs/"..org_name.."/members/"..string.lower(username), {raw = true}) 270 | if res.code == 404 then return false, nil end 271 | if res.code == 204 then return true, nil end 272 | return nil, res.err 273 | end 274 | 275 | -- returns teams for given org (authed request only) 276 | -- From the github api page: 277 | -- All actions against teams require at a minimum an authenticated user who is a member of the Owners team in the :org being managed. 278 | -- Additionally, OAuth users require the “read:org” scope. 279 | function _M:get_org_teams(org_name) 280 | if not org_name then return nil, "must specify org name" end 281 | local res, err = self:authed_request("/orgs/"..org_name.."/teams", {raw = true}) 282 | if res.code == 403 then 283 | return nil, res.err 284 | else 285 | return cjson.decode(res.body), nil 286 | end 287 | end 288 | 289 | -- helper: is user in org team (uses current user's token) 290 | function _M:current_user_in_org_team(team_name, org_name) 291 | if not team_name then return nil, "must specify team name" end 292 | if not org_name then return nil, "must specify org name" end 293 | local match = false 294 | local user_teams, err = self:get_authenticated_user_teams() 295 | if not user_teams then 296 | return false, err 297 | else 298 | for _, team in pairs(user_teams) do 299 | if string.lower(team.organization.login) == string.lower(org_name) then 300 | if string.lower(team.slug) == string.lower(team_name) then 301 | match = true 302 | break 303 | else 304 | match = false 305 | end 306 | end 307 | end 308 | end 309 | return match, nil 310 | end 311 | 312 | return m 313 | --------------------------------------------------------------------------------