github

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

    github
    rest
    graphql
    sdk
    openapi
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    1 hour ago
    Downloads
    5

    Dependencies

    #gaato/github

    Unofficial GitHub API client for MoonBit, on gaato/http and gaato/sdk-runtime. The caller supplies a Transport and a Clock, for example gaato/http-async. REST is generated in full; GraphQL is a passthrough.

    Unofficial and experimental. Source, issues and design notes: https://github.com/gaato/mbt-sdk

    All 1,221 operations of the vendored first-party OpenAPI document are generated into gaato/github/gen as sans-IO pairs: <operation>_request(...) builds an @http.Request, and <operation>_decode(response) reads the success payload. See spec provenance and generation overlays.

    Unlike gaato/openai and gaato/anthropic, this module does not restate the generated types behind a hand-written vocabulary — at this surface area that would be a second API to keep in step with the first. The generated package is the public type vocabulary, and the facade only sends, follows, and reads:

    let gh = @github.GitHub::new(transport, clock, token="ghp_...")
    let repo = gh.call(@gen.repos_get_request("gaato", "mbt-sdk"), @gen.repos_get_decode)
    let diff = gh.send(
    @gen.repos_compare_commits_request("gaato", "mbt-sdk", "main...topic")
    .header("accept", "application/vnd.github.diff"),
    )

    call decodes; send returns the response for operations with no body to decode (204, 304) or a non-JSON representation such as application/vnd.github.diff. An operation that sets its own accept wins over the client default, as do all request headers.

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

    let repos = gh
    .paginator(@gen.repos_list_for_user_request("gaato", per_page=100), @gen.repos_list_for_user_decode)
    .collect(max=250)

    GraphQL is not generated, because there is nothing to generate from. In OpenAPI the shape of a response belongs to the operation, which is what gen compiles; in GraphQL it belongs to the query document the caller wrote, and the schema only says what is possible. So the endpoint is a passthrough over Json:

    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. graphql raises @runtime.Decode whenever that array is non-empty, so an error cannot pass unnoticed, and graphql_errors reads it back — including type_, which names the condition (NOT_FOUND, FORBIDDEN, RATE_LIMITED). Use graphql_response when the partial data of such a reply is worth keeping. graphql_paginator walks a connection by threading pageInfo.endCursor through a query variable, after unless cursor_variable says otherwise.

    A GraphQL rate limit is a RATE_LIMITED entry in that array rather than a 429, and it is deliberately not retryable: the point budget refills on the hour and carries no retry-after, so replaying it immediately would only spend the retry policy's attempts. The limiter has the reset and paces the next call.

    Search and GraphQL are each metered separately from the rest of the API, so requests to /search/* and to /graphql use their own rate limit buckets; the default @runtime.WindowLimiter paces each bucket against x-ratelimit-remaining and x-ratelimit-reset. Pass limiter=@runtime.NoLimiter::new() to opt out.

    Failures are @runtime.SdkError. 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 limit, which is a different mechanism from the hourly quota, reported as 403 or 429 with retry-after, and which no limiter can anticipate: back off and retry.

    For GitHub Enterprise Server, point base_url at the REST mount: @github.GitHub::new(transport, clock, token~, base_url="https://ghe.example.com/api/v3"). GraphQL is not under that mount — Enterprise Server serves it from /api/graphql — 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 @runtime.Client for anything further from the default deployment, and takes graphql_url too, because a runtime client keeps its base_url to itself.

    Not every field is typed. At 43 places in the specification the generator could not resolve a schema into a single MoonBit type — unions whose branches carry no tag of their own, unions whose tag sits on a sibling property, objects mixing named and typed additional properties — and those are carried as raw Json. The census lists every one with the reason. They can be typed individually later without changing the operation signatures.

    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, )

    Failures arrive as @runtime.SdkError; api_error reads GitHub's error body out of one.

    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) -> Json raise
    SdkError

    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 raises 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.

    GitHub::graphql_paginator

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

    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 raises on a non-empty errors array exactly as graphql does.

    GitHub::graphql_response

    async fn GitHub::graphql_response(self : GitHub, query~ : String, variables? : Json, operation_name? : String) -> Json raise
    SdkError

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

    This raises only for the failures the HTTP status reports — an unauthorized request, a transport error, a body that is not JSON. A GraphQL reply that nulls one field and explains it in errors is returned intact, which is the point of this entry point: graphql would raise and discard the rest.

    GitHub::new

    fn GitHub::new(transport : &
    Transport
    , clock : &
    Clock
    , token? : String, base_url? : String, api_version? : String, user_agent? : String, retry? :
    RetryPolicy
    , limiter? : &
    RateLimiter
    , graphql_url? : String) -> 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.

    Unless limiter says otherwise, responses feed a WindowLimiter reading x-ratelimit-remaining and x-ratelimit-reset. Pass limiter=@runtime.NoLimiter::new() to opt out.

    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

    Walks a Link-paginated collection starting from a generated request.

    GitHub paginates with RFC 8288 Link headers rather than a cursor in the body, so the paginator's cursor is the rel="next" URL exactly as the server wrote it: absolute, and already carrying the page size and every filter of the first request. @runtime.Client resolves an absolute request URL by passing it through, so the following pages need no rebuilding here.

    The decoder is the generated <operation>_decode of an operation whose success type is an array:

    let repos = gh .paginator( @gen.repos_list_for_user_request("gaato", per_page=100), @gen.repos_list_for_user_decode, ) .collect(max=250)

    A search operation does not answer this shape: its payload is an envelope with items and total_count, so wrap search_*_decode in a decoder that returns the items field.

    GitHub::send

    Sends a generated request and returns the successful response undecoded.

    This is for the operations whose body a decoder cannot describe: 204 and 304 replies, and the representations requested through a non-JSON accept such as application/vnd.github.diff.

    GitHub::to_repr

    Redacted debug representation of a GitHub client.

    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

    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

    Extracts the GraphQL errors array from a failure.

    This is the counterpart of api_error. graphql reports a non-empty errors array as @runtime.Decode — the response decoded, it simply did not carry the value that was asked for — and attaches the body, 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 :
    SdkError
    ) -> 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 :
    SdkError
    ) -> 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 already paces against those headers. 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 body whose message names it. It leaves x-ratelimit-remaining untouched, so no limiter can anticipate it: the caller has to back off and retry.

    The runtime's taxonomy classifies only 429 as RateLimited, so a secondary limit delivered as 403 arrives as Status. This predicate spans both.