github

    Thin GitHub API client for MoonBit built on gaato/http and gaato/mttp: the whole REST API generated, plus a GraphQL passthrough.

    github
    rest
    graphql
    sdk
    openapi
    Download zip
    Author
    Version
    0.19.0
    License
    Apache-2.0
    Last updated
    9 hours ago
    Downloads
    259

    #gaato/github

    An unofficial GitHub API client for MoonBit. Every operation of GitHub's REST API is generated, with its types, from the first-party OpenAPI description; GraphQL is a passthrough over Json. It is built on gaato/mttp and requires a caller-supplied Backend and Clock; the facade has no async runtime.

    Status: unofficial and experimental. Minor releases may contain breaking changes; the changelog lists each one with a migration note.

    #Install

    moon add gaato/github moon add gaato/http moon add gaato/mttp moon add gaato/http-async

    #Usage

    With gaato/http-async/backend imported as @backend and gaato/http-async/runtime as @runtime and gaato/http-async/codings as @codings (core/async requires an exact experimental package opt-in in your moon.pkg):

    ///|
    let backend = @codings.Decompress::new(@backend.AsyncBackend::new())

    ///|
    let clock = @runtime.AsyncClock::new()

    Then pass them to the client and call an operation:

    ///|
    /// Reads a repository's star count.
    pub async fn stars(
    backend : &@http.Backend,
    clock : &@rt.Clock,
    token~ : String,
    ) -> Result[Int32, @github.GitHubFailure] {
    let gh = @github.GitHub::new(backend, clock, token~)
    let repo = gh.call(
    @gen.repos_get_request("gaato", "mbt-sdk"),
    @gen.repos_get_decode,
    )
    match repo {
    Ok(repo) => Ok(repo.stargazers_count)
    Err(error) => Err(error)
    }
    }

    call and send buffer successful bodies up to 64 MiB by default. Set max_body_bytes in GitHub::new to change this limit. Overflow raises TransportError with kind TooLarge. Close the backend when its owner shuts down.

    #Packages

    PackageWhat it is
    gaato/githubThe GitHub client: sending, pagination, GraphQL, rate limits, retries and error helpers
    gaato/github/genEvery REST operation and its types, generated from the OpenAPI description

    #Requests and responses

    Each REST operation is a sans-IO pair in gaato/github/gen: <operation>_request(...) builds an @http.Request, and <operation>_decode(head, bytes) returns the success payload or a ResponseFailure value. The generated types are the public vocabulary; the client only sends, follows and reads.

    call sends a request and decodes the reply. send returns the response, for an operation with no body to decode (204, 304) or one asked for in another representation, such as a diff:

    ///|
    /// Reads the diff between two refs.
    pub async fn compare_diff(
    gh : @github.GitHub,
    range : String,
    ) -> Result[String, @github.GitHubFailure] {
    let response = gh.send(
    @gen.repos_compare_commits_request("gaato", "mbt-sdk", range).header(
    "accept", "application/vnd.github.diff",
    ),
    )
    match response {
    Ok(response) => Ok(@utf8.decode_lossy(response.body))
    Err(error) => Err(error)
    }
    }

    A header set on the request, such as that accept, wins over the client's default.

    Not every field is typed. Where the generator could not resolve a schema into a single MoonBit type, such as a union whose branches carry no tag of their own, the value is carried as raw Json. The census (in Japanese) lists each one with the reason.

    #Downloads and redirects

    The archive, artifact, log and migration downloads answer 302 with a short-lived location:

    • repos_download_tarball_archive and repos_download_zipball_archive
    • actions_download_artifact and the actions_download_*_logs operations
    • migrations_download_archive_for_org and migrations_get_archive_for_authenticated_user
    • some of the time, repos_get_release_asset with accept: application/octet-stream and code_scanning_get_codeql_database with accept: application/zip

    The default backend does not follow redirects. A 302 returns an HTTP failure value; its head retains location. For large downloads, use an mttp Client with as_stream and the FollowRedirects backend wrapper. It strips sensitive headers on cross-origin redirects and closes intermediate bodies. Host fetch redirect behavior has platform constraints documented by gaato/http-async; select the redirect policy before supplying credentials.

    #Pagination

    GitHub paginates with Link headers, so paginator follows the rel="next" URL the server wrote. It is an @client.Paginator, with each, next_page and collect:

    ///|
    /// Lists up to 250 of a user's repositories.
    pub async fn user_repos(
    gh : @github.GitHub,
    user : String,
    ) -> Result[Array[@gen.MinimalRepository], @github.GitHubFailure] {
    let repos = gh.paginator(
    @gen.repos_list_for_user_request(user, per_page=100),
    @gen.repos_list_for_user_decode,
    )
    repos.collect(max=250)
    }

    #GraphQL

    GraphQL is not generated: the shape of a reply follows the query you write. graphql sends a query with its variables and returns the data:

    ///|
    /// Reads a repository's star count through GraphQL.
    pub async fn stars_graphql(
    gh : @github.GitHub,
    ) -> Result[Json, @github.GitHubFailure] {
    gh.graphql(
    query="query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { stargazerCount } }",
    variables={ "owner": "gaato", "name": "mbt-sdk" },
    )
    }

    A GraphQL reply may contain HTTP 200 with an errors array and partial data. graphql returns a GraphqlFailure value for a non-empty array, retaining the actual response head and body. graphql_errors reads the typed error entries back. graphql_response returns partial data for errors that are not retryable; exhausted rate limits and transient query failures return errors.

    graphql_paginator walks a connection by passing pageInfo.endCursor back through a query variable: after, unless cursor_variable says otherwise.

    #Rate limits

    Search and GraphQL are metered separately from the rest of the API, so requests to /search/* and to /graphql use their own buckets. Writes (any method but GET and HEAD) use core:write, or core:notifications for the paths that send notifications. The default @ratelimit.WindowLimiter paces each bucket against x-ratelimit-remaining and x-ratelimit-reset, keyed by x-ratelimit-resource, so a write still spends the core window.

    Following GitHub's guidance and octokit, the limiter also spaces requests:

    • writes and every GraphQL request at least 1 s apart;
    • writes that send notifications 3 s apart, which also holds the next write;
    • searches 2 s apart.

    GitHub also asks for requests to be made serially. This module has no async runtime to do that, so pass a gate, such as SemaphoreGate from gaato/http-async:

    ///|
    let gh = @github.GitHub::new(backend, clock, token~, gate=() => {
    @runtime.SemaphoreGate::new()
    })

    The gate serialises each group: writes and GraphQL, searches, and reads. timeout_ms limits each attempt, and total_timeout_ms bounds the whole call, including admission and stream processing. A total deadline requires timer; necessary cleanup may continue after expiry. Pass limiter=@client.NoLimiter::new() to opt out of the limiter, the pacing and the gate.

    #Errors and retries

    Calls return Result with GitHubFailure: Http(ResponseFailure) for HTTP and REST decoding failures, GraphqlFailure for GraphQL envelopes, and Validation for invalid SDK inputs. Transport failures raise TransportError; unknown decoder exceptions and cancellation retain their identity.

    • api_error reads GitHub's error body (message, documentation_url, status, errors) out of one.
    • is_not_modified recognises the 304 answer to a conditional request.
    • is_secondary_rate_limit recognises the secondary rate limit, a different mechanism from the hourly quota, reported as 403 or 429 with retry-after or a message naming it.

    Clients created with GitHub::new retry through GitHubRetry around the retry policy, by default an SDK-local conservative rule with three retries and 429 permitted for every method:

    • Three retries, as octokit. A 5xx, 408, 409 or a timeout after sending is retried only for an idempotent request: a POST is not resent, because GitHub has no idempotency key and a second issue or comment could be created.
    • A secondary limit is retried for any method, after retry-after, or else after 60 s, 120 s and 240 s, as GitHub's documentation asks. The policy's Retry-After cap does not apply; its retry count and total_timeout_ms do.
    • A primary limit (x-ratelimit-remaining: 0, or GraphQL RATE_LIMITED) is retried once, one second after x-ratelimit-reset. That can be up to an hour away, so set total_timeout_ms to bound it.

    A GraphQL rate limit, a secondary limit reported in a 200, and "Something went wrong while executing your query" are retried like an error status. A document holding a mutation is not retried on "Something went wrong", since the mutation may have been applied. A rate limit that persists is returned as GraphqlFailure, with the reply's body for graphql_errors.

    #GitHub Enterprise Server

    Point base_url at the REST mount: @github.GitHub::new(backend, clock, token~, base_url="https://ghe.example.com/api/v3"). Enterprise Server serves GraphQL from /api/graphql, outside that mount, so the GraphQL endpoint is derived from base_url rather than appended to it; pass graphql_url for a deployment that spells it differently. from_client takes a preconfigured @client.Client for anything further from the default deployment, and takes graphql_url too.

    #License

    Apache-2.0

    ApiErrorBody

    pub(all) struct ApiErrorBody {
    message : String
    documentation_url : String?
    status : String?
    errors : Array[
    Json
    ]?
    } derive(Eq,
    Debug
    )

    The error body returned by the GitHub REST API.

    status is a string because that is how GitHub writes it ("404"); a numeric value is accepted and rendered the same way. errors keeps the raw elements: a 422 reports objects with resource, field, and code, while a few endpoints report plain strings.

    test {
    let body : @github.ApiErrorBody = {
    message: "Not Found",
    documentation_url: Some("https://docs.github.com/rest"),
    status: Some("404"),
    errors: None,
    }
    assert_eq(body.message, "Not Found")
    }

    ApiErrorBody::equal

    Compares API error bodies field by field.

    ApiErrorBody::not_equal

    fn ApiErrorBody::not_equal(x : ApiErrorBody, y : ApiErrorBody) -> Bool

    Compares API error bodies field by field.

    ApiErrorBody::to_repr

    Debug representation of an API error body.

    GitHub

    pub struct GitHub {
    // private fields
    }

    A GitHub API client backed by the transport-independent SDK runtime.

    REST request and response shapes come from the generated gen package, which is produced from the vendored first-party OpenAPI document. This facade does not restate them: it only sends a generated request, follows Link pages, and reads GitHub's error bodies.

    GraphQL has no such generated vocabulary — a reply's shape belongs to the caller's query rather than to the schema — so it is served by the graphql passthrough over Json.
    impl Debug for GitHub

    GitHub::call

    Sends a generated request and decodes the successful response.

    The two arguments are the matched halves of one generated operation, so a call reads as a single line:

    let repo = gh.call( @gen.repos_get_request("gaato", "mbt-sdk"), @gen.repos_get_decode, )

    Expected API and decoding failures are GitHubFailure values in Result; api_error reads their error bodies. Transport and callback errors remain raised effects.

    GitHub::from_client

    fn GitHub::from_client(client :
    Client
    , graphql_url? : String) -> GitHub

    Wraps a preconfigured runtime client.

    This is the escape hatch for deployments this facade does not model — a proxy that authenticates differently, a GitHub Enterprise Server instance with extra headers — and for tests that build the client themselves.

    A runtime client keeps its base_url to itself, so the GraphQL endpoint cannot be derived from it here and defaults to GitHub's own. Pass graphql_url for anything else.

    GitHub::graphql

    async fn GitHub::graphql(self : GitHub, query~ : String, variables? :
    Json
    , operation_name? : String) -> Result[
    Json
    , GitHubFailure]

    Sends a GraphQL query and returns its data.

    GitHub's GraphQL API is not generated. In OpenAPI the shape of a response belongs to the operation, which is what gaato/github/gen compiles; in GraphQL it belongs to the query document the caller wrote, and the schema only says what is possible. There is no operation-to-type table to generate, so the payload is Json and the caller decodes the shape it asked for.

    let data = gh.graphql( query=( #|query($owner: String!, $name: String!) { #| repository(owner: $owner, name: $name) { stargazerCount } #|} ), variables={ "owner": "gaato", "name": "mbt-sdk" }, )

    A GraphQL failure does not use the HTTP status: the reply is 200 with an errors array beside a possibly partial data. This returns Err whenever that array is present and non-empty, so an error cannot pass unnoticed; read the array back with graphql_errors. Use graphql_response instead when the partial data of such a reply is worth keeping.

    Three kinds of error are recognised inside the retry loop, before the reply is returned (see GitHubRetry):

    • a RATE_LIMITED error (the point budget is spent) is represented as GraphqlFailure with the reply's headers and body, and retried once, one second after x-ratelimit-reset;
    • an error naming the secondary rate limit is represented as GraphqlFailure as well, and retried after retry-after or at least a minute;
    • "Something went wrong while executing your query" is represented as GraphqlFailure and retried with the policy's backoff, but only when the document holds no mutation, since a mutation may have been applied.

    Requests are metered and paced in the "graphql" bucket, or "graphql:mutation" for a document holding a mutation.

    GitHub::graphql_paginator

    fn[T] GitHub::graphql_paginator(self : GitHub, page : (
    Json
    ) -> (Array[T], String?) raise, query~ : String, variables? :
    Json
    , cursor_variable? : String, operation_name? : String) ->
    Paginator
    [T, GitHubFailure]

    Walks a GraphQL connection, threading its cursor through the variables.

    GraphQL paginates in the body rather than in a Link header, and a connection's pageInfo sits wherever the query put it — data.repository .issues.pageInfo for one query, somewhere else for the next. So this takes no path: page receives the data of each reply and returns that page's items together with the cursor for the following one, which is pageInfo.endCursor while pageInfo.hasNextPage is true and None once it is false. Returning a cursor when hasNextPage is false walks off the end of the connection; returning None too early stops silently.

    The query takes the cursor as a variable, named after unless cursor_variable says otherwise, and must declare it nullable so the first page — which is fetched without it — is valid:

    let issues = gh .graphql_paginator( data => { guard data is Object(root) else { ... } ... (nodes, if has_next_page { end_cursor } else { None }) }, query=( #|query($cursor: String) { #| repository(owner: "gaato", name: "mbt-sdk") { #| issues(first: 100, after: $cursor) { #| nodes { number title } #| pageInfo { hasNextPage endCursor } #| } #| } #|} ), cursor_variable="cursor", ) .collect(max=500)

    Each page returns Err on a non-empty errors array, and is retried, exactly as graphql does.

    GitHub::graphql_response

    async fn GitHub::graphql_response(self : GitHub, query~ : String, variables? :
    Json
    , operation_name? : String) -> Result[
    Json
    , GitHubFailure]

    Sends a GraphQL query and returns the whole {data, errors, extensions} envelope.

    This returns Err for rejected HTTP statuses, a body that is not JSON, and for the errors graphql retries once their retries run out: a rate limit, and a transient failure of a document without a mutation. Any other GraphQL reply, such as one that nulls one field and explains it in errors, is returned intact, which is the point of this entry point: graphql would return a failure. TransportError and unknown user exceptions remain raised effects.

    GitHub::new

    fn GitHub::new(backend : &
    Backend
    , clock : &
    Clock
    , token? : String, base_url? : String, api_version? : String, user_agent? : String, retry? :
    RetryPolicy
    , limiter? : &
    RateLimiter
    , graphql_url? : String, gate? : () -> &
    Gate
    , timer? : &
    Timer
    , timeout_ms? : Int, max_body_bytes? : Int, read_idle_ms? : Int, total_timeout_ms? : Int) -> GitHub

    Creates a GitHub client.

    token is optional: GitHub serves public resources unauthenticated, so an absent token means NoAuth rather than a configuration error. A present token is sent as Authorization: Bearer, which covers personal access tokens, installation tokens, and GITHUB_TOKEN.

    base_url is the API root. GitHub Enterprise Server mounts the REST API under /api/v3, so pass https://ghe.example.com/api/v3 there.

    The default headers are accept: application/vnd.github+json, x-github-api-version, and user-agent, which GitHub requires. A generated operation that needs a different representation — application/vnd.github.diff, .patch, .sarif — sets accept on its own request, and the runtime keeps request headers ahead of client defaults.

    retry defaults to a conservative SDK rule with three retries and 429 permitted for every method, and is wrapped in GitHubRetry, which also waits out GitHub's primary and secondary rate limits and retries a transient GraphQL failure of a query.

    Unless limiter says otherwise, responses feed a WindowLimiter reading x-ratelimit-remaining and x-ratelimit-reset, keyed by x-ratelimit-resource so that a write shares the core window. It spaces writes and GraphQL requests at least 1 s apart, writes that send notifications 3 s and searches 2 s, as octokit does. gate makes the requests of each of those groups serial, as docs.github.com asks ("make requests serially instead of concurrently"): pass gate=() => @runtime.SemaphoreGate::new() from gaato/http-async/runtime. Without it concurrent requests are only spaced, not serialised. gate is ignored when limiter is given. Pass limiter=@client.NoLimiter::new() to opt out of both.

    options.timeout_ms limits each attempt and total_timeout_ms the whole call, including admission and consumption. A total deadline requires a supplied Timer. Backend phase deadlines can finish attempts earlier.

    graphql_url is the GraphQL endpoint, which is not under the REST root: GitHub Enterprise Server serves REST from /api/v3 and GraphQL from /api/graphql. It is derived from base_url and only needs passing for a deployment that puts the two somewhere else.

    GitHub::paginator

    Follows Link next URLs, advancing only after a successful decoded page.

    GitHub::send

    Sends a request without interpreting the successful body.

    GitHub::to_repr

    Redacted debug representation of a GitHub client.

    GitHubFailure

    pub(all) enum GitHubFailure {
    Http(
    ResponseFailure
    )
    GraphqlFailure(
    ResponseHead
    , message~ : String, body~ : Bytes)
    Validation(String)
    } derive(
    Debug
    )

    Expected response, GraphQL and validation failures. TransportError remains raised.

    GitHubFailure::to_repr

    Formats an expected failure with its original HTTP metadata.

    GitHubRetry

    pub(all) struct GitHubRetry {
    policy :
    RetryPolicy

    }

    GitHub's retry rule: the rate limits GitHub documents are waited out as docs.github.com asks, a transient GraphQL failure of a query is retried, and everything else follows policy.

    GitHub::new wraps its retry policy in this, by default a conservative rule with three retries and 429 for every method, as octokit's plugin-retry, but a 5xx or a timeout after sending is retried only for an idempotent request, because GitHub has no idempotency key and a resent POST can open a second issue or comment.

    GitHubRetry::decide

    Decides a retry, recognising three GitHub conditions before handing the error to policy; each of them is retried for any method, because the request was rejected rather than processed.

    • Secondary rate limit (is_secondary_rate_limit: a 403 or 429 with retry-after or a message naming the secondary rate limit, which a GraphQL 200 also carries): waits retry-after (seconds or an HTTP date), or, when it is absent or not positive, 60 s doubled for each retry already made ("wait for at least one minute", "an exponentially increasing amount of time"). The policy's Retry-After cap does not apply; its retry count and the call's remaining time (RetryContext::remaining_ms, stopping with Budget) do.
    • Primary rate limit (a 403 or 429 with x-ratelimit-remaining: 0, or a GraphQL RATE_LIMITED error): waits until one second past x-ratelimit-reset ("you should not retry your request until after" it), and only once per call, as octokit does; a second one stops with Exhausted. Without a readable reset it stops with NotRetryable rather than guess, and the policy's retry count and the remaining time bound it as above.
    • Transient GraphQL failure ("Something went wrong while executing your query", returned as GraphqlFailure by graphql for a document that is not a mutation, in the "graphql" bucket): waits the policy's backoff, within its retry count and the remaining time. A mutation's ("graphql:mutation") is never retried, since it may have been applied.

    Every other error, including the cases of policy.rule.retry_after.cap, is decided by policy alone.

    test {
    let retry : @github.GitHubRetry = {
    policy: @client.RetryPolicy::conservative().with_max_retries(3),
    }
    let post = @http.Request::post("/repos/o/r/issues")
    let limited = @github.Http(
    @client.UnexpectedStatus(
    { status: 403, headers: @http.Headers::empty(), },
    body=b"{\"message\":\"You have exceeded a secondary rate limit.\"}",
    ),
    )
    let waits = [0, 1, 2, 3].map(attempt => {
    retry.decide(limited, @client.RetryContext::capture(post, attempt~))
    })
    assert_eq(waits, [Wait(60000), Wait(120000), Wait(240000), Stop(Exhausted)])
    }

    GraphqlError

    pub(all) struct GraphqlError {
    message : String
    type_ : String?
    path : Array[
    Json
    ]?
    locations : Array[
    Json
    ]?
    extensions :
    Json
    ?
    } derive(Eq,
    Debug
    )

    One entry of a GraphQL errors array.

    Only message is required by the GraphQL specification. type_ is GitHub's own addition and names the condition — NOT_FOUND, FORBIDDEN, RATE_LIMITED — but it is absent from the errors the query parser raises, and the set of values it can take is not specified, so it stays a String.

    path and locations keep their raw elements: a path mixes field names with list indices, so it is a Json array of strings and numbers.

    test {
    let error : @github.GraphqlError = {
    message: "Could not resolve to a Repository with the name 'o/r'.",
    type_: Some("NOT_FOUND"),
    path: Some(["repository"]),
    locations: None,
    extensions: None,
    }
    assert_eq(error.type_, Some("NOT_FOUND"))
    }

    GraphqlError::equal

    Compares GraphQL errors field by field.

    GraphqlError::not_equal

    fn GraphqlError::not_equal(x : GraphqlError, y : GraphqlError) -> Bool

    Compares GraphQL errors field by field.

    GraphqlError::to_repr

    Debug representation of a GraphQL error.

    api_error

    fn api_error(error : GitHubFailure) -> ApiErrorBody?

    Extracts GitHub's error body from an HTTP status failure.

    Returns None for transport, decode, and configuration failures, and for a body that is not a GitHub error object — an HTML page from an intermediate proxy, or an empty body.

    graphql_errors

    fn graphql_errors(error : GitHubFailure) -> Array[GraphqlError]?

    Extracts the GraphQL errors array from a failure.

    This is the counterpart of api_error. graphql reports a non-empty errors array as GraphqlFailure — the response decoded, it simply did not carry the value that was asked for — or, for a rate limit, as GraphqlFailure, and attaches the body to either, which this reads back:

    if @github.graphql_errors(error) is Some(errors) { for error in errors { if error.type_ is Some("RATE_LIMITED") { ... } } }

    Returns None for a body that is not a GraphQL envelope with errors, which includes every REST-shaped failure of the endpoint: an unauthenticated request is answered 403 with a message body that api_error reads.

    is_not_modified

    fn is_not_modified(error : GitHubFailure) -> Bool

    Reports the 304 answer to a conditional request.

    A request carrying if-none-match or if-modified-since is answered with 304 Not Modified and an empty body, which the runtime classifies as a failure like any other non-2xx status. A cached copy is still valid, and a 304 is not charged against the rate limit.

    is_secondary_rate_limit

    fn is_secondary_rate_limit(error : GitHubFailure) -> Bool

    Reports a secondary rate limit, which is not the documented hourly quota.

    GitHub answers the hourly quota with 403 or 429 and x-ratelimit-remaining: 0; WindowLimiter paces against those headers and GitHubRetry waits for the reset once. A secondary rate limit — too many concurrent requests, too many points in a minute, too much content creation — is a separate mechanism, reported with 403 or 429 plus a retry-after header, or a message naming it (matched as octokit does, "secondary rate" in any case), in a REST error body or in the errors of a GraphQL reply. It leaves x-ratelimit-remaining untouched, so no limiter can anticipate it: GitHubRetry waits and retries after it occurs.

    HTTP 403 and 429 arrive as UnexpectedStatus; a GraphQL envelope with HTTP 200 arrives as GraphqlFailure. This predicate recognises both forms.