A Req-based Elixir client for the GitHub REST and GraphQL APIs.
A small generic core reaches every GitHub endpoint over both transports. Typed
convenience modules are added as needed rather than for full endpoint coverage.
See SPEC.md for the design rationale.
Highlights
- REST and GraphQL.
GhEx.REST.get/post/patch/put/deletereach any REST path;GhEx.GraphQL.query/3reaches queries and mutations REST has no equivalent for, including Projects v2 and Discussions. - GitHub App authentication. Authenticate as an App with an OTP-native RS256 JWT (no JOSE dependency), and as an installation with an access token that is minted, cached, and refreshed transparently.
- Built on Req. Test with
Req.Testand no HTTP mocking; errors normalize toGhEx.Error;metacarries a rate-limit snapshot and parsed pagination links; both transports paginate as a lazyStream. - Generic core, convenience by demand. Every endpoint is reachable through
the core. Typed modules such as
GhEx.IssuesandGhEx.PullRequestswrap the common paths and are added as they are needed.
Status: pre-release (0.1). M1 (REST core), M2 (GraphQL core), and M3 (GitHub App auth: JWT, one-shot installation tokens, and transparent installation-token caching) are implemented and tested. The public namespace is
GhEx.
Installation
def deps do
[
{:gh_ex, "~> 0.1.0"}
]
endThe core
One client, used for both transports:
client = GhEx.new(auth: {:token, System.fetch_env!("GITHUB_TOKEN")})REST
get/post/patch/put/delete reach any REST path. Every call returns
{:ok, body, meta} on a 2xx or {:error, reason} otherwise, where meta
carries the status, headers, parsed pagination links, and a rate-limit snapshot.
{:ok, repo, meta} = GhEx.REST.get(client, "/repos/elixir-lang/elixir")
repo["full_name"] #=> "elixir-lang/elixir"
meta.rate_limit.remaining
GhEx.REST.post(client, "/repos/o/r/issues", json: %{title: "Bug", body: "..."})stream/3 follows the Link: rel="next" header into a lazy Stream, so you
page through large collections without holding them in memory:
client
|> GhEx.REST.stream("/repos/elixir-lang/elixir/issues", params: [state: "all", per_page: 100])
|> Stream.map(& &1["number"])
|> Enum.take(250)GraphQL
query/3 runs any query or mutation, including operations REST has no equivalent
for, such as Projects v2 and Discussions. Variables are a keyword list or map.
{:ok, data, _meta} =
GhEx.GraphQL.query(client, "query($login: String!) { user(login: $login) { name } }",
login: "joshrotenberg")GraphQL answers with HTTP 200 even on failure, so a response carrying an errors
array becomes {:error, %GhEx.Error{}} (the same error struct REST uses); any
partial data is preserved on the error.
stream/4 walks a connection's pageInfo cursor into a lazy Stream, mirroring
the REST streamer. The query takes a cursor variable wired into after: and
selects pageInfo { hasNextPage endCursor }; you tell stream/4 where the
connection lives with :path:
client
|> GhEx.GraphQL.stream(
~s|query($org: String!, $cursor: String) {
organization(login: $org) {
projectsV2(first: 100, after: $cursor) {
nodes { number title }
pageInfo { hasNextPage endCursor }
}
}
}|,
[org: "joshrotenberg"],
path: ["organization", "projectsV2"]
)
|> Enum.to_list()Convenience modules
GhEx.Issues and GhEx.PullRequests wrap the common paths and return the same
shape as the core:
GhEx.Issues.list(client, "elixir-lang", "elixir", params: [state: "open"])
GhEx.PullRequests.create(client, "o", "r", %{title: "Fix", head: "fix", base: "main"})Authentication
GhEx.new/1 accepts these credential forms:
# personal access token (classic or fine-grained) or OAuth token
GhEx.new(auth: {:token, token})
# GitHub App: authenticates as the app with a short-lived RS256 JWT,
# minted via OTP crypto with no JOSE dependency
app = GhEx.new(auth: {:app, client_id_or_app_id, File.read!("app-private-key.pem")})To act as an installation, get a client that mints and caches the installation access token (valid one hour) transparently, refreshing it before it expires. This needs a running token cache; add the default ETS cache to your supervision tree:
children = [
GhEx.TokenCache.ETS
# ...
]inst = GhEx.App.installation(app, installation_id, cache: GhEx.TokenCache.ETS)
GhEx.REST.get(inst, "/installation/repositories")The cache is a behaviour: back it with your own module (Nebulex, Redis, ...) to
share tokens across a cluster, without gh_ex depending on any cache library.
If you would rather own the token lifecycle yourself, the stateless primitives mint a single token and hand it back:
# a token-auth client scoped to the installation, plus its expiry
{:ok, inst, _expires_at} = GhEx.App.installation_client(app, installation_id)
# or the raw token body, optionally scoped to repositories/permissions
{:ok, body} = GhEx.App.installation_token(app, installation_id, json: %{repositories: ["gh_ex"]})See the Authentication guide for the full flow.
GitHub Enterprise Server
Override the base URLs:
GhEx.new(
auth: {:token, token},
rest_url: "https://ghe.example.com/api/v3",
graphql_url: "https://ghe.example.com/api/graphql"
)Testing
The client is Req-native, so Req.Test drives it. Install a plug through
:req_options and stub responses, with no live API:
client = GhEx.new(req_options: [plug: {Req.Test, MyStub}])See the Testing guide for asserting requests and stubbing App and installation auth.
Documentation
Run mix docs, or start with the getting-started guide.
Guides cover authentication,
pagination, error handling,
GitHub Enterprise Server, and
testing.
License
MIT.