///|
/// 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.
///
/// 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 raised as
/// `@runtime.RateLimited` with the reply's headers and body, and retried
/// once, one second after `x-ratelimit-reset`;
/// - an error naming the secondary rate limit is raised as
/// `@runtime.RateLimited` as well, and retried after `retry-after` or at
/// least a minute;
/// - "Something went wrong while executing your query" is raised as
/// `@runtime.Decode` 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.
pub async fn GitHub::graphql(
self : GitHub,
query~ : String,
variables? : Json,
operation_name? : String,
) -> Json raise @runtime.SdkError {
let (response, envelope) = self.graphql_exchange(
query, variables, operation_name,
)
graphql_data(envelope, response)
}
///|
/// Sends a GraphQL query and returns the whole `{data, errors, extensions}`
/// envelope.
///
/// This raises for the failures the HTTP status reports — an unauthorized
/// request, a transport error, 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 raise and discard
/// the rest.
pub async fn GitHub::graphql_response(
self : GitHub,
query~ : String,
variables? : Json,
operation_name? : String,
) -> Json raise @runtime.SdkError {
self.graphql_exchange(query, variables, operation_name).1
}
///|
/// 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, and is retried, 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.SdkError] {
@runtime.Paginator::with_cursor(cursor => {
let variables = match cursor {
None => variables
Some(cursor) => Some(with_cursor(variables, cursor_variable, cursor))
}
let (response, envelope) = self.graphql_exchange(
query, variables, operation_name,
)
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 — or, for a rate limit, as
/// `@runtime.RateLimited`, 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.
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)
}
}
///|
/// The bucket of a GraphQL document that holds no mutation.
let graphql_bucket : String = "graphql"
///|
/// The bucket of a GraphQL document that holds a mutation: its own name, so
/// `GitHubRetry` knows not to retry a transient failure of it, while
/// `x-ratelimit-resource: graphql` lets it share the `"graphql"` window.
let graphql_mutation_bucket : String = "graphql:mutation"
///|
/// Sends one GraphQL request and parses its reply.
///
/// `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.
///
/// The reply is parsed and checked inside the client's attempt loop (`accept`)
/// so a rate limit or a transient failure reported in a `200` is retried like
/// an error status; the envelope parsed there is handed back so it is not
/// parsed twice.
async fn GitHub::graphql_exchange(
self : GitHub,
query : String,
variables : Json?,
operation_name : String?,
) -> (@http.Response, Json) raise @runtime.SdkError {
let mutation = is_mutation_document(query)
let parsed : Ref[Json?] = { val: None, }
let response = self.client.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(),
),
bucket=if mutation { graphql_mutation_bucket } else { graphql_bucket },
accept=response => {
parsed.val = None
let envelope = graphql_envelope(response)
check_graphql_reply(envelope, response, mutation~)
parsed.val = Some(envelope)
},
)
match parsed.val {
Some(envelope) => (response, envelope)
None => (response, graphql_envelope(response))
}
}
///|
/// Raises, for `GitHubRetry` to decide, the errors of a `200` reply that are
/// worth another attempt: a spent point budget (`RATE_LIMITED`) or a secondary
/// rate limit as `RateLimited` with the reply's headers and body, and a
/// transient failure of a document without a mutation as `Decode`. Any other
/// reply passes.
fn check_graphql_reply(
envelope : Json,
response : @http.Response,
mutation~ : Bool,
) -> Unit raise @runtime.SdkError {
guard envelope_errors(envelope) is Some(errors) && errors.length() > 0 else {
return
}
if has_condition(errors, GraphqlRateLimited) ||
has_condition(errors, GraphqlSecondary) {
raise @runtime.RateLimited(
retry_after_ms=@http.retry_after_ms(response.headers),
headers=response.headers,
body=response.body,
)
}
if !mutation && has_condition(errors, GraphqlTransient) {
raise @runtime.Decode(
message=graphql_error_message(errors),
body=response.body,
)
}
}
///|
/// The GraphQL errors that the retry loop treats specially.
priv enum GraphqlCondition {
/// `type: "RATE_LIMITED"`: the primary point budget is spent.
GraphqlRateLimited
/// A message naming the secondary rate limit.
GraphqlSecondary
/// "Something went wrong while executing your query", which octokit's
/// `plugin-retry` retries as if it were a `500`.
GraphqlTransient
}
///|
/// Whether one GraphQL error is of this kind.
fn GraphqlCondition::matches(
self : GraphqlCondition,
error : GraphqlError,
) -> Bool {
match self {
GraphqlRateLimited => error.type_ is Some("RATE_LIMITED")
GraphqlSecondary => names_secondary_rate_limit(error.message)
GraphqlTransient =>
error.message.contains("Something went wrong while executing your query")
}
}
///|
/// Whether a reply body is a GraphQL envelope with an error of this kind.
fn graphql_reply_has(body : Bytes, condition : GraphqlCondition) -> Bool {
try decode_graphql_errors(body) catch {
_ => false
} noraise {
errors => has_condition(errors, condition)
}
}
///|
/// Whether any of the errors is of this kind.
fn has_condition(
errors : Array[GraphqlError],
condition : GraphqlCondition,
) -> Bool {
errors.iter().any(error => condition.matches(error))
}
///|
/// Whether a GraphQL document holds a mutation operation, so that a failure
/// of it must not be retried.
///
/// The document is scanned for the keyword `mutation` outside every brace,
/// parenthesis and bracket, skipping what GraphQL ignores (whitespace,
/// commas, a byte order mark, `#` comments) and string values. That finds a
/// mutation that is not the first definition — after a fragment, or beside a
/// query in a document whose `operationName` picks it — which a look at the
/// first keyword alone would miss. A query shorthand (`{ … }`), `query` and
/// `subscription` are not mutations; keywords are case-sensitive, so a type
/// condition `on Mutation` is not one either.
fn is_mutation_document(document : String) -> Bool {
let chars = document.to_array()
let length = chars.length()
let mut depth = 0
let mut i = 0
while i < length {
let c = chars[i]
if c == '#' {
while i < length && chars[i] != '\n' && chars[i] != '\r' {
i += 1
}
} else if c == '"' {
i = skip_string(chars, i)
} else if c == '{' || c == '(' || c == '[' {
depth += 1
i += 1
} else if c == '}' || c == ')' || c == ']' {
depth = (depth - 1).max(0)
i += 1
} else if is_name_start(c) {
let start = i
while i < length &&
(is_name_start(chars[i]) || (chars[i] >= '0' && chars[i] <= '9')) {
i += 1
}
if depth == 0 &&
i - start == 8 &&
String::from_array(chars[start:i]) == "mutation" {
return true
}
} else {
i += 1
}
}
false
}
///|
/// Whether a character can start a GraphQL name.
fn is_name_start(c : Char) -> Bool {
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
}
///|
/// The index just past the string value starting at `start`: a block string
/// (`"""…"""`, where only `\"""` escapes), or a string that ends at an
/// unescaped quote or at the end of its line. An unterminated string runs to
/// the end of the document.
fn skip_string(chars : Array[Char], start : Int32) -> Int32 {
let length = chars.length()
let block = start + 2 < length &&
chars[start + 1] == '"' &&
chars[start + 2] == '"'
if block {
let mut i = start + 3
while i < length {
if chars[i] == '\\' &&
i + 3 < length &&
chars[i + 1] == '"' &&
chars[i + 2] == '"' &&
chars[i + 3] == '"' {
i += 4
} else if chars[i] == '"' &&
i + 2 < length &&
chars[i + 1] == '"' &&
chars[i + 2] == '"' {
return i + 3
} else {
i += 1
}
}
return length
}
let mut i = start + 1
while i < length {
match chars[i] {
'\\' => i += 2
'"' => return i + 1
'\n' | '\r' => return i
_ => i += 1
}
}
length
}
///|
/// 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. The errors
/// worth retrying never get here: `check_graphql_reply` raised them inside the
/// retry loop.
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] = Json(cursor)
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),
})
}