///|
/// 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~)) }