// `mctl gateway` (← goctl's `gateway`): an HTTP front door for a gRPC service.
//
// A gRPC service speaks `POST /package.Service/Method` with a protobuf body, which
// is a protocol, not a REST API: no browser speaks it, and no CDN caches it. A
// gateway is the translation layer — HTTP verbs and paths in front, gRPC behind —
// so that a `.proto` a team already has can be reached from a web page.
//
// The gateway here is the routing and the envelope, not the transport: a caller
// port takes the gRPC method's full path and the request document and returns the
// reply document, and everything above that port (the route table, the 400/404/502
// answers, the JSON bodies) is generated and typed. Binding the port to a real
// channel is a dozen lines, the same way the mongo port is.
//
// Routes come from a mapping file when there is one — `POST /v1/users
// demo.UserService/Create` per line — and from the gRPC path itself when there is
// not, so a gateway can be generated before anyone has decided on URLs.
///|
/// One HTTP route and the gRPC method behind it: the verb and path the gateway
/// answers, and the fully-qualified method it forwards to.
pub(all) struct GatewayRoute {
verb : String
path : String
rpc : String
}
///|
/// The fully-qualified gRPC path of an RPC: `/package.Service/Method`, the spelling
/// a channel calls and a gateway forwards to.
fn rpc_path(pkg : String, service : String, rpc : String) -> String {
let full = if pkg == "" { service } else { pkg + "." + service }
"/" + full + "/" + rpc
}
///|
/// Whether an RPC can sit behind an HTTP route: a gRPC stream cannot, because an
/// HTTP response is one body with one end. A streaming RPC is left out of the route
/// table, and the generated file says which ones were left out.
fn is_streaming(r : Rpc) -> Bool {
r.client_streaming || r.server_streaming
}
///|
/// Every RPC of `proto` as the default route: `POST //` — the gRPC
/// path with its package dropped, because a gateway in front of one package does not
/// need the package repeated in the URL. A `POST` is the method gRPC itself uses, so
/// the mapping is the one that always works.
pub fn default_routes(proto : Proto) -> Array[GatewayRoute] {
let out : Array[GatewayRoute] = []
for s in proto.services {
for r in s.rpcs {
if is_streaming(r) {
continue
}
out.push({
verb: "POST",
path: "/" + s.name + "/" + r.name,
rpc: rpc_path(proto.package_, s.name, r.name),
})
}
}
out
}
///|
/// Whether `proto` declares the gRPC method `target` (`/package.Service/Method`), so a
/// hand-written route that names a method the spec does not have is caught here
/// rather than at the first request.
pub fn has_method(proto : Proto, target : String) -> Bool {
for s in proto.services {
for r in s.rpcs {
if rpc_path(proto.package_, s.name, r.name) == target {
return true
}
}
}
false
}
///|
/// The routes a mapping file declares, in the order it declares them. A line is
/// `VERB /path /package.Service/Method`; `#` starts a comment, and a blank line is
/// skipped. A line naming a method the `.proto` does not declare, or one that
/// streams, is left out — `skipped` reports it so the caller can say which.
pub fn parse_route_map(
proto : Proto,
source : String,
) -> (Array[GatewayRoute], Array[String]) {
let out : Array[GatewayRoute] = []
let skipped : Array[String] = []
for raw in @text.split_char(source, '\n') {
let mut line = @text.trim(raw)
let hash = @text.index_of_str(line, "#")
if hash >= 0 {
line = @text.trim(line[0:hash].to_owned())
}
if line == "" {
continue
}
let parts = @text.split_char(line, ' ')
let words : Array[String] = []
for p in parts {
let w = @text.trim(p)
if w != "" {
words.push(w)
}
}
if words.length() < 3 {
skipped.push(line)
continue
}
if has_method(proto, words[2]) == false {
skipped.push(line)
continue
}
out.push({ verb: words[0].to_upper(), path: words[1], rpc: words[2], })
}
(out, skipped)
}
///|
/// The prelude of a generated gateway: the error a request can end in, the reply it
/// produces, the caller port, and the route record.
fn gateway_prelude() -> String {
"// Code generated by moonctl. DO NOT EDIT.\n" +
"//\n" +
"// `mctl gateway`: an HTTP front door for this `.proto`. A request's JSON body is\n" +
"// forwarded to the gRPC method its route names, and the reply is handed back as\n" +
"// the response body. The transport is the `Caller` port: bind it to a channel and\n" +
"// the gateway is live.\n" +
"//\n" +
"// This file needs `moonbitlang/core/json` imported as `@json`.\n" +
"\n" +
"///|\n/// What a request can end in: a body that will not parse, a path no route answers,\n/// or an upstream call that failed.\npub(all) suberror GatewayError {\n GatewayBadRequest(why~ : String)\n GatewayNotFound(route~ : String)\n GatewayUpstream(status~ : Int32, message~ : String)\n}\n" +
"\n" +
"///|\n/// One answer: the HTTP status and the JSON body it carries.\npub(all) struct GatewayReply {\n status : Int32\n body : Json\n}\n" +
"\n" +
"///|\n/// The upstream a gateway calls: the gRPC method's full path (`/package.Service/\n/// Method`) and the request document, in return for the reply document.\npub(all) struct Caller {\n call : (String, Json) -> Json raise GatewayError\n}\n" +
"\n" +
"///|\n/// One route: the HTTP verb and path the gateway answers, and the gRPC method it\n/// forwards to.\npub(all) struct GatewayRoute {\n verb : String\n path : String\n rpc : String\n}\n" +
"\n" +
"///|\n/// A JSON object with one `error` member: what a failed request answers with.\nfn error_body(message : String) -> Json {\n let m : Map[String, Json] = Map([])\n m[\"error\"] = ToJson::to_json(message)\n ToJson::to_json(m)\n}\n" +
"\n" +
"///|\n/// The one-line spelling of a failure, which is what the `error` member carries.\nfn describe(e : GatewayError) -> String {\n match e {\n GatewayBadRequest(why~) => \"bad request: \" + why\n GatewayNotFound(route~) => \"no route for \" + route\n GatewayUpstream(status~, message~) =>\n \"upstream \" + status.to_string() + \": \" + message\n }\n}\n\n"
}
///|
/// The route table, as a function so the gateway and its callers read the same list.
fn emit_routes(routes : Array[GatewayRoute]) -> String {
let lines : Array[String] = []
for r in routes {
lines.push(
" { verb: " +
@text.quote(r.verb) +
", path: " +
@text.quote(r.path) +
", rpc: " +
@text.quote(r.rpc) +
" },",
)
}
"///|\n/// Every route of the gateway, in the order the `.proto` (or the mapping file)\n/// declared them.\npub fn routes() -> Array[GatewayRoute] {\n [" +
(if lines.length() == 0 { "" } else { "\n" }) +
@text.join_lines(lines) +
(if lines.length() == 0 { "" } else { "\n " }) +
"]\n}\n\n"
}
///|
/// Generate the gateway for `proto` with `routes`: the route table, the gateway
/// itself, and the two entry points — `handle`, which takes a JSON body already
/// parsed, and `handle_text`, which parses the body a request carried.
///
/// Status codes are the four an HTTP client cares about: `200` for a reply, `400`
/// for a body that is not JSON, `404` for a path no route answers, and `502` for a
/// call the upstream failed. A gRPC status does not leak through: an HTTP client
/// reads it where it is meaningful — in the reply body.
pub fn generate_gateway(proto : Proto, routes : Array[GatewayRoute]) -> String {
let mut out = gateway_prelude()
out = out + emit_routes(routes)
let streamed : Array[String] = []
for s in proto.services {
for r in s.rpcs {
if is_streaming(r) {
streamed.push(rpc_path(proto.package_, s.name, r.name))
}
}
}
if streamed.length() > 0 {
out = out +
"// Streaming methods have no HTTP route: " +
@text.join_lines(streamed) +
".\n\n"
}
out = out +
"///|\n/// The gateway: the upstream it calls. Routes come from `routes()`.\npub(all) struct Gateway {\n caller : Caller\n}\n\n" +
"///|\n/// A gateway over `caller`.\npub fn Gateway::new(caller : Caller) -> Gateway {\n { caller, }\n}\n\n" +
"///|\n/// The gRPC method a verb and path name, or `\"\"` when no route answers them.\nfn method_of(verb : String, path : String) -> String {\n let mut rpc = \"\"\n for r in routes() {\n if rpc == \"\" && r.verb == verb && r.path == path {\n rpc = r.rpc\n }\n }\n rpc\n}\n\n" +
"///|\n/// Answer one request: find the route, forward the body, wrap the reply.\npub fn Gateway::handle(\n self : Gateway,\n verb : String,\n path : String,\n body : Json,\n) -> GatewayReply {\n let target = method_of(verb, path)\n if target == \"\" {\n return {\n status: 404,\n body: error_body(\"no route for \" + verb + \" \" + path),\n }\n }\n let reply = (self.caller.call)(target, body) catch {\n e =>\n return {\n status: 502,\n body: error_body(describe(e)),\n }\n }\n { status: 200, body: reply }\n}\n\n" +
"///|\n/// Answer one request whose body arrived as text, which is what a server hands its\n/// handler. A body that is not JSON answers `400` rather than raising.\npub fn Gateway::handle_text(\n self : Gateway,\n verb : String,\n path : String,\n body : String,\n) -> GatewayReply {\n let doc = @json.parse(body) catch {\n _ =>\n return {\n status: 400,\n body: error_body(\"body is not JSON\"),\n }\n }\n self.handle(verb, path, doc)\n}\n"
out
}
///|
/// The file a gateway lands in: the `.proto`'s own name, without its directory and
/// without its extension — `proto/greet.proto` → `greet_gateway.mbt`. A `.proto` may
/// declare several services, so the file name comes from the file rather than from
/// one of them.
pub fn gateway_file_name(proto_path : String) -> String {
let mut start = 0
for i = 0; i < proto_path.length(); i = i + 1 {
let c = proto_path[i]
if c == '/' || c == '\\' {
start = i + 1
}
}
let mut stem = proto_path[start:].to_owned()
if @text.ends_with(stem, ".proto") {
stem = stem[0:stem.length() - 6].to_owned()
}
if stem == "" {
return "gateway.mbt"
}
@text.to_snake(stem) + "_gateway.mbt"
}