///|
/// The HTTP surface: a GraphiQL IDE page plus the GraphQL endpoint, wired as a
/// `moonasgi` handler so moongql plugs straight into `mooncat` (or any server that
/// binds the ASGI seam). It follows the GraphQL-over-HTTP specification: a `POST`
/// carries an `application/json` request body, a `GET` carries the same fields in
/// its query string (query operations only) or serves the IDE, and the response
/// media type is negotiated from `Accept`. Everything rides the synchronous
/// `run_http_app` core moonasgi exposes, so the whole thing is testable on every
/// backend through `TestClient` without an async runtime.
///|
/// The GraphiQL IDE page, wired to fetch against `endpoint`. This is the same
/// standalone GraphiQL build strawberry serves: React and GraphiQL load from a
/// CDN via an import map, and a fetcher points at the GraphQL endpoint. Splitting
/// the template around the endpoint keeps it a plain string with no interpolation
/// machinery.
pub fn graphiql_html(endpoint? : String = "/graphql") -> String {
graphiql_head() + endpoint + graphiql_tail()
}
///|
/// Everything in the GraphiQL page up to the fetcher URL literal.
fn graphiql_head() -> String {
let head =
#|
#|
#|
#|
#| GraphiQL
#|
#|
#|
#|
#|
#|
#|
Loading GraphiQL…
#|
#|
#|
tail
}
///|
/// An `application/json` error reply carrying a single `errors` entry, for the
/// transport-level failures (wrong path, wrong method, an unacceptable or missing
/// media type, an unreadable body) that stop a request before the executor runs.
fn error_response(status : Int, message : String) -> @moonasgi.Response {
let errs : Array[Json] = [jobj([("message", message.to_json())])]
@moonasgi.Response::json(status~, jobj([("errors", errs.to_json())]))
}
///|
/// The media type a GraphQL response is written as. `GraphqlJson` is the
/// GraphQL-over-HTTP media type, under which the status code carries meaning: a
/// request error answers 400, an operation that ran answers 200 whatever its field
/// errors say. `AppJson` is the legacy `application/json` older clients speak,
/// where every GraphQL response is a 200.
priv enum Wire {
AppJson
GraphqlJson
}
///|
/// An ASCII upper-case code point folded to lower case.
fn fold_case(c : Int) -> Int {
if c >= 65 && c <= 90 {
c + 32
} else {
c
}
}
///|
/// ASCII-case-insensitive equality, for the header names and media types HTTP
/// does not fix a case for.
fn ci_eq(a : String, b : String) -> Bool {
if a.length() != b.length() {
return false
}
for i in 0.. Array[String] {
let out : Array[String] = []
let cur = StringBuilder()
for c in s {
if c == sep {
out.push(cur.to_string())
cur.reset()
} else {
cur.write_char(c)
}
}
out.push(cur.to_string())
out
}
///|
/// Strip leading and trailing spaces and tabs.
fn trim_ws(s : String) -> String {
let mut a = 0
let mut b = s.length()
while a < b && (s[a].to_int() == 32 || s[a].to_int() == 9) {
a = a + 1
}
while b > a && (s[b - 1].to_int() == 32 || s[b - 1].to_int() == 9) {
b = b - 1
}
s[a:b].to_owned()
}
///|
/// A media type with its parameters dropped: `application/json; charset=utf-8`
/// is `application/json`.
fn media_type(s : String) -> String {
trim_ws(split_on(s, ';')[0])
}
///|
/// Read a request header by name, ignoring case.
fn req_header(req : @moonasgi.Request, name : String) -> String? {
for pair in req.headers {
if ci_eq(pair.0, name) {
return Some(pair.1)
}
}
None
}
///|
/// Choose the response media type from an `Accept` header, or `None` when the
/// client accepts neither GraphQL media type and the request is a 406. A client
/// that sends no `Accept` gets `application/json`, which is what every
/// pre-specification client expects.
fn wire_of(accept : String?) -> Wire? {
let header = match accept {
None => return Some(AppJson)
Some(h) => h
}
let mut json = false
for entry in split_on(header, ',') {
let m = media_type(entry)
if ci_eq(m, "application/graphql-response+json") {
return Some(GraphqlJson)
}
if ci_eq(m, "application/json") || m == "*/*" || m == "application/*" {
json = true
}
}
if json {
Some(AppJson)
} else {
None
}
}
///|
/// The `content-type` a response under `wire` carries.
fn wire_content_type(wire : Wire) -> String {
match wire {
AppJson => "application/json"
GraphqlJson => "application/graphql-response+json; charset=utf-8"
}
}
///|
/// The status a GraphQL response gets. Under `application/json` every response is
/// a 200 and the client reads `errors`; under the GraphQL media type a response
/// with no `data` entry is a request error — the operation never ran — which the
/// specification maps to 400. A batch is neither, and answers 200.
fn wire_status(wire : Wire, body : Json) -> Int {
match wire {
AppJson => 200
GraphqlJson =>
match body {
Object(m) => if m.get("data") is Some(_) { 200 } else { 400 }
_ => 200
}
}
}
///|
/// Serialise a GraphQL response body under the negotiated media type.
fn wire_response(wire : Wire, body : Json) -> @moonasgi.Response {
@moonasgi.Response::new(
wire_status(wire, body),
[("content-type", wire_content_type(wire))],
@utf8.encode(body.stringify()),
)
}
///|
/// Percent-decode one query-string component, with `+` meaning a space as
/// `application/x-www-form-urlencoded` requires.
fn url_decode(s : String) -> String {
let spaced = StringBuilder()
for c in s {
spaced.write_char(if c == '+' { ' ' } else { c })
}
@utf8.decode_lossy(@moonasgi.percent_decode(spaced.to_string())[:])
}
///|
/// Decode a URL query string into its parameters. A repeated name keeps the first
/// value, since every GraphQL request parameter is single-valued.
fn query_params(qs : Bytes) -> Map[String, String] {
let out : Map[String, String] = Map([])
for pair in split_on(@utf8.decode_lossy(qs[:]), '&') {
if pair == "" {
continue
}
let cut = split_on(pair, '=')
let name = url_decode(cut[0])
let value = if cut.length() > 1 {
url_decode(cut[1:].join("="))
} else {
""
}
if out.get(name) is None {
out[name] = value
}
}
out
}
///|
/// Pull `query`, `variables` and `operationName` out of a parsed request body.
/// A missing or non-string `query` yields `None`; `variables` defaults to empty
/// and `operationName` to `None`, matching the GraphQL-over-HTTP request shape.
fn parse_request(body : Json) -> (String?, Map[String, Json], String?) {
match body {
Object(m) => {
let query = match m.get("query") {
Some(String(q)) => Some(q)
_ => None
}
let variables = match m.get("variables") {
Some(Object(v)) => v
_ => Map([])
}
let operation_name = match m.get("operationName") {
Some(String(n)) => Some(n)
_ => None
}
(query, variables, operation_name)
}
_ => (None, Map([]), None)
}
}
///|
/// Run one GraphQL request out of a parsed body, or report the request-level
/// failure that stops it.
fn run_body(
schema : Schema,
resolvers : Resolvers,
body : Json,
) -> Result[Json, String] {
let (query, variables, operation_name) = parse_request(body)
match query {
None => Err("No GraphQL query found in request body")
Some(q) => Ok(execute(schema, resolvers, q, variables~, operation_name~))
}
}
///|
/// Serve a `GET` carrying a `?query=`. The specification allows only query
/// operations here, a `GET` being safe and idempotent, so a mutation or
/// subscription is a 405; the parameters are the request body's fields spelled out
/// in the URL, with `variables` a JSON object encoded as a string.
fn get_request(
schema : Schema,
resolvers : Resolvers,
wire : Wire,
params : Map[String, String],
query : String,
) -> @moonasgi.Response {
let operation_name : String? = params.get("operationName")
let variables : Map[String, Json] = match params.get("variables") {
None => Map([])
Some(text) =>
match
(@json.parse(text) catch {
_ =>
return error_response(
400, "The 'variables' parameter is not valid JSON",
)
}) {
Object(m) => m
_ => Map([])
}
}
// A parse or validation failure falls through to `execute`, which reports it
// once in the response body rather than as a transport error.
if operation_type_of(schema, query, operation_name) is Ok(op) &&
not(op is Query) {
return error_response(
405, "GET supports query operations only; use POST for a mutation or subscription",
)
}
wire_response(
wire,
execute(schema, resolvers, query, variables~, operation_name~),
)
}
///|
/// Serve a `POST`: the media type must be `application/json`, and the body is
/// either one request object or an array of them, answered by an array of results
/// in the same order. Batching is outside the specification, which describes one
/// request per POST; it is served here because clients that coalesce queries send
/// it.
fn post_request(
schema : Schema,
resolvers : Resolvers,
wire : Wire,
req : @moonasgi.Request,
) -> @moonasgi.Response {
match req_header(req, "content-type") {
None =>
return error_response(
415, "POST requires a Content-Type of application/json",
)
Some(ct) =>
if not(ci_eq(media_type(ct), "application/json")) {
return error_response(
415,
"Unsupported Content-Type '" + media_type(ct) + "'",
)
}
}
let body = @json.parse(@utf8.decode_lossy(req.body[:])) catch {
_ => return error_response(400, "Request body is not valid JSON")
}
match body {
Array(items) => {
if items.is_empty() {
return error_response(400, "GraphQL batch must not be empty")
}
let results : Array[Json] = []
for item in items {
results.push(
match run_body(schema, resolvers, item) {
Ok(r) => r
Err(m) => build_response(None, [GqlError::msg(m)])
},
)
}
wire_response(wire, results.to_json())
}
_ =>
match run_body(schema, resolvers, body) {
Ok(r) => wire_response(wire, r)
Err(m) => error_response(400, m)
}
}
}
///|
/// Build the moonasgi request handler for a schema and its resolvers, following
/// the GraphQL-over-HTTP specification.
///
/// A `POST` on `path` executes an `application/json` body — one request object, or
/// an array of them for a batch. A `GET` with a `?query=` executes a query
/// operation (only a query: a mutation there is a 405); a `GET` without one serves
/// the GraphiQL IDE when `graphiql` is true. The response media type is negotiated
/// from `Accept`: a client asking for `application/graphql-response+json` gets it,
/// and with it the status codes that separate a request error (400) from a field
/// error (200); anything else gets `application/json` and a 200. Lift it onto an
/// `AsgiApp` with `graphql_app`, or drive it directly with moonasgi's `TestClient`.
pub fn graphql_handler(
schema : Schema,
resolvers : Resolvers,
path? : String = "/graphql",
graphiql? : Bool = true,
) -> @moonasgi.Handler {
fn(req : @moonasgi.Request) -> @moonasgi.Response {
if req.path != path {
return error_response(404, "Not Found")
}
let wire = match wire_of(req_header(req, "accept")) {
Some(w) => w
None =>
return error_response(
406, "Not Acceptable: this endpoint serves application/graphql-response+json or application/json",
)
}
match req.http_method {
"GET" | "HEAD" => {
let params = query_params(req.query_string)
match params.get("query") {
Some(q) => get_request(schema, resolvers, wire, params, q)
None =>
if graphiql {
@moonasgi.Response::new(
200,
[("content-type", "text/html; charset=utf-8")],
@utf8.encode(graphiql_html(endpoint=path)),
)
} else {
error_response(405, "Method Not Allowed")
}
}
}
"POST" => post_request(schema, resolvers, wire, req)
_ => error_response(405, "Method Not Allowed")
}
}
}
///|
/// Lift the GraphQL handler onto the load-bearing `AsgiApp` a server binds to.
/// This is what `mooncat` (or any moonasgi server) mounts to serve the schema
/// over real HTTP; the request→response logic is shared with `graphql_handler`,
/// which `TestClient` exercises without a socket.
pub fn graphql_app(
schema : Schema,
resolvers : Resolvers,
path? : String = "/graphql",
graphiql? : Bool = true,
) -> @moonasgi.AsgiApp {
@moonasgi.to_asgi(graphql_handler(schema, resolvers, path~, graphiql~))
}