///|
/// 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.
pub async fn GitHub::graphql(
self : GitHub,
query~ : String,
variables? : Json,
operation_name? : String,
) -> Json raise @runtime.SdkError {
let response = self.graphql_exchange(query, variables, operation_name)
let envelope = graphql_envelope(response)
graphql_data(envelope, response)
}
///|
/// 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.
pub async fn GitHub::graphql_response(
self : GitHub,
query~ : String,
variables? : Json,
operation_name? : String,
) -> Json raise @runtime.SdkError {
let response = self.graphql_exchange(query, variables, operation_name)
graphql_envelope(response)
}
///|
/// 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.
pub fn[T] GitHub::graphql_paginator(
self : GitHub,
page : (Json) -> (Array[T], String?) raise @runtime.SdkError,
query~ : String,
variables? : Json,
cursor_variable? : String = "after",
operation_name? : String,
) -> @runtime.Paginator[T] {
@runtime.Paginator::new(cursor => {
let variables = match cursor {
None => variables
Some(cursor) => Some(with_cursor(variables, cursor_variable, cursor))
}
let response = self.graphql_exchange(query, variables, operation_name)
let envelope = graphql_envelope(response)
let (items, next) = page(graphql_data(envelope, response))
@runtime.Page::{ items, next, }
})
}
///|
/// 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.
///
/// ```mbt check
/// 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"))
/// }
/// ```
pub(all) struct GraphqlError {
message : String
type_ : String?
path : Array[Json]?
locations : Array[Json]?
extensions : Json?
} derive(Eq, @debug.Debug)
///|
/// Compares GraphQL errors field by field.
pub extend GraphqlError with Eq::{equal, not_equal}
///|
/// Debug representation of a GraphQL error.
pub extend GraphqlError with @debug.Debug::{to_repr}
///|
/// 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.
pub fn graphql_errors(error : @runtime.SdkError) -> Array[GraphqlError]? {
let body = match error {
@runtime.Decode(body~, ..)
| @runtime.Status(body~, ..)
| @runtime.RateLimited(body~, ..) => body
_ => return None
}
try decode_graphql_errors(body) catch {
_ => None
} noraise {
value => Some(value)
}
}
///|
/// Sends one GraphQL request.
///
/// `accept` is set here because the client default is the REST media type,
/// which this endpoint has no use for. The `x-github-api-version` default rides
/// along; it selects a REST API version and GitHub ignores it for GraphQL,
/// which has no versions.
async fn GitHub::graphql_exchange(
self : GitHub,
query : String,
variables : Json?,
operation_name : String?,
) -> @http.Response raise @runtime.SdkError {
self.send(
@http.Request::post(self.graphql_url)
.header("accept", "application/json")
.json_body(
@sdkjson.ObjBuilder::new()
.field("query", query)
.opt("variables", variables)
.opt("operationName", operation_name)
.build(),
),
)
}
///|
/// Parses a GraphQL reply body.
fn graphql_envelope(response : @http.Response) -> Json raise @runtime.SdkError {
response.json() catch {
error =>
raise @runtime.Decode(message=error.to_string(), body=response.body)
}
}
///|
/// Reads `data` out of an envelope, raising when the reply reports errors.
///
/// The raise is `@runtime.Decode` rather than `@runtime.Status`: the request
/// succeeded at the HTTP level, so reporting it as a status would put a `200` in
/// `SdkError::status` and mislead anything that branches on one. `Decode` is
/// also not retryable, which is right even for `RATE_LIMITED` — GraphQL's point
/// budget refills on the hour and carries no `retry-after`, so an immediate
/// retry only spends the policy's attempts. The response's `x-ratelimit-reset`
/// has already reached the limiter, which paces the next request.
fn graphql_data(
envelope : Json,
response : @http.Response,
) -> Json raise @runtime.SdkError {
if envelope_errors(envelope) is Some(errors) && errors.length() > 0 {
raise @runtime.Decode(
message=graphql_error_message(errors),
body=response.body,
)
}
guard envelope is Object(fields) else {
raise @runtime.Decode(
message="graphql response is not an object",
body=response.body,
)
}
match fields.get("data") {
Some(Null) | None =>
raise @runtime.Decode(
message="graphql response carries neither data nor errors",
body=response.body,
)
Some(data) => data
}
}
///|
/// Summarises an errors array for the message of a raised failure.
fn graphql_error_message(errors : Array[GraphqlError]) -> String {
let head = "graphql: " + errors[0].message
if errors.length() > 1 {
head + " (and " + (errors.length() - 1).to_string() + " more)"
} else {
head
}
}
///|
/// Reads the `errors` array of an already parsed envelope.
fn envelope_errors(envelope : Json) -> Array[GraphqlError]? {
try decode_envelope_errors(envelope) catch {
_ => None
} noraise {
value => Some(value)
}
}
///|
/// Decodes the `errors` array of a reply body, raising when there is none.
fn decode_graphql_errors(body : Bytes) -> Array[GraphqlError] raise {
decode_envelope_errors(@json.parse(@utf8.decode(body)))
}
///|
/// Decodes the `errors` array of an envelope, raising when there is none.
fn decode_envelope_errors(envelope : Json) -> Array[GraphqlError] raise {
let decoded : GraphqlEnvelope = @json.from_json(envelope)
decoded.0
}
///|
/// Builds the variables of the page after the first.
///
/// The caller's map is copied rather than extended, so a paginator does not
/// mutate the value it was handed and a retry of one page sees the same
/// variables twice.
fn with_cursor(
variables : Json?,
name : String,
cursor : String,
) -> Json raise @runtime.SdkError {
let fields = match variables {
None | Some(Null) => Map([])
Some(Object(source)) => source.copy()
Some(_) =>
raise @runtime.Config(
"graphql variables must be an object to carry the \{name} cursor",
)
}
fields[name] = cursor.to_json()
Json::object(fields)
}
///|
priv struct GraphqlEnvelope(Array[GraphqlError])
///|
impl @json.FromJson for GraphqlEnvelope with fn from_json(value, path) {
let obj = @sdkjson.expect_object(value, path)
let errors : Array[GraphqlErrorJson] = @sdkjson.field(obj, "errors", path)
GraphqlEnvelope(errors.map(error => error.0))
}
///|
priv struct GraphqlErrorJson(GraphqlError)
///|
impl @json.FromJson for GraphqlErrorJson with fn from_json(value, path) {
let obj = @sdkjson.expect_object(value, path)
GraphqlErrorJson({
message: @sdkjson.field(obj, "message", path),
type_: @sdkjson.opt_field(obj, "type", path),
path: @sdkjson.opt_field(obj, "path", path),
locations: @sdkjson.opt_field(obj, "locations", path),
extensions: @sdkjson.opt_field(obj, "extensions", path),
})
}