// `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. A segment of `path` that
/// begins with `:` is a parameter — it stands for one segment of the request path,
/// and the value it took is bound into the request document under its name.
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
}
///|
/// Whether a path segment is a parameter: a `:` and a name after it.
pub fn is_param(segment : String) -> Bool {
segment.length() > 1 && segment[0] == ':'
}
///|
/// The name a parameter segment binds to (`:userId` → `userId`), or `""` when the
/// segment is not a parameter.
pub fn param_name(segment : String) -> String {
if is_param(segment) {
segment[1:].to_owned()
} else {
""
}
}
///|
/// Whether a name can be a member of a JSON request document: a letter or an
/// underscore, then letters, digits and underscores. A name the generated document
/// cannot carry is refused here rather than at the first request.
fn is_param_name(name : String) -> Bool {
if name.length() == 0 {
return false
}
for i = 0; i < name.length(); i = i + 1 {
let c = name[i]
let letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
let digit = c >= '0' && c <= '9'
if letter || digit || c == '_' {
// a digit may not open a name, since a generated document reads it as a field
if i == 0 && digit {
return false
}
continue
}
return false
}
true
}
///|
/// What is wrong with a route path, or `""` when nothing is: it has to be a path, a
/// parameter has to name something, and one path may not bind one name twice — the
/// second segment would overwrite the first in the request document.
fn path_problem(path : String) -> String {
if path == "" || path[0] != '/' {
return "the path has to begin with /"
}
let seen : Array[String] = []
for segment in @text.path_segments(path) {
if segment.length() == 0 || segment[0] != ':' {
continue
}
let name = param_name(segment)
if is_param_name(name) == false {
return "`" + segment + "` is not a parameter name"
}
if seen.contains(name) {
return "`:" + name + "` appears twice"
}
seen.push(name)
}
""
}
///|
/// The shape of a route: its verb and its path with every parameter replaced by
/// `{}`. Two routes of one shape answer the same requests, so only the first can be
/// reached.
pub fn route_shape(route : GatewayRoute) -> String {
let mut out = route.verb + " "
let segments = @text.path_segments(route.path)
for i = 0; i < segments.length(); i = i + 1 {
out = out + "/" + (if is_param(segments[i]) { "{}" } else { segments[i] })
}
if segments.length() == 0 {
out = out + "/"
}
out
}
///|
/// The routes of `routes` that no request can tell apart, one sentence each. A table
/// carrying one has a route nothing reaches, so `mctl gateway` refuses to write it
/// rather than generate a table whose last line is dead.
pub fn route_clashes(routes : Array[GatewayRoute]) -> Array[String] {
let out : Array[String] = []
for i = 0; i < routes.length(); i = i + 1 {
for j = 0; j < i; j = j + 1 {
if route_shape(routes[i]) == route_shape(routes[j]) {
out.push(
"`" +
routes[i].verb +
" " +
routes[i].path +
"` answers the same requests as `" +
routes[j].verb +
" " +
routes[j].path +
"`",
)
}
}
}
out
}
///|
/// 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, one that streams,
/// or one whose path will not match anything is left out — `skipped` reports the
/// line itself, 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
}
if path_problem(words[1]) != "" {
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 two route records.
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 `moonbitstack/moonjson` 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. A segment of `path` that begins with `:` is a parameter: it stands\n/// for one segment of the request path, and the value it took is bound into the\n/// request document under the name after the colon.\npub(all) struct GatewayRoute {\n verb : String\n path : String\n rpc : String\n}\n" +
"\n" +
"///|\n/// A route that answered: the gRPC method, and the value each parameter of the path\n/// took, by parameter name.\npub(all) struct GatewayMatch {\n rpc : String\n params : Map[String, 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"
}
///|
/// The matcher: the path split into segments, the lookup that prefers a literal
/// segment over a parameter, and the two ways to read the answer.
fn emit_router() -> String {
"///|\n/// The segments of a path, empty ones dropped: `/v1/users/7` → `[\"v1\", \"users\",\n/// \"7\"]`. A doubled slash and a trailing one are therefore the same request as\n/// without them.\nfn segments(path : String) -> Array[String] {\n let out : Array[String] = []\n let mut from = 0\n for i = 0; i <= path.length(); i = i + 1 {\n if i == path.length() || path[i] == '/' {\n if i > from {\n out.push(path[from:i].to_owned())\n }\n from = i + 1\n }\n }\n out\n}\n\n" +
"///|\n/// The route that answers a verb and a path, or nothing. A literal segment counts\n/// for more than a parameter, so `get /v1/users/me` still answers\n/// `/v1/users/me` when `get /v1/users/:id` is declared first; between two routes of\n/// one weight the earlier one wins, and the generator refuses to write two of the\n/// same shape.\nfn match_route(verb : String, path : String) -> GatewayMatch? {\n let parts = segments(path)\n let mut best : GatewayMatch? = None\n let mut best_weight = -1\n for route in routes() {\n if route.verb != verb {\n continue\n }\n let pattern = segments(route.path)\n if pattern.length() != parts.length() {\n continue\n }\n let params : Map[String, String] = Map([])\n let mut weight = 0\n let mut matched = true\n for i = 0; i < pattern.length(); i = i + 1 {\n let segment = pattern[i]\n if segment.length() > 1 && segment[0] == ':' {\n params[segment[1:].to_owned()] = parts[i]\n } else if segment == parts[i] {\n weight = weight + 1\n } else {\n matched = false\n break\n }\n }\n if matched && weight > best_weight {\n best_weight = weight\n best = Some({ rpc: route.rpc, params, })\n }\n }\n best\n}\n\n" +
"///|\n/// The gRPC method a verb and path name, or `\"\"` when no route answers them.\npub fn method_of(verb : String, path : String) -> String {\n match match_route(verb, path) {\n Some(found) => found.rpc\n None => \"\"\n }\n}\n\n" +
"///|\n/// The request document a route's parameters make: `body` with each parameter bound\n/// as a member, a parameter winning over the same member in the body, since the path\n/// is where the client said which resource it means. A route with no parameters\n/// forwards the body untouched; a parameter on a body that is not an object is an\n/// error, because there is nowhere to put it.\nfn with_params(\n body : Json,\n params : Map[String, String],\n) -> Result[Json, String] {\n if params.length() == 0 {\n return Ok(body)\n }\n let members = match body {\n Object(members) => members\n _ => return Err(\"a path parameter needs a JSON object body\")\n }\n let merged : Map[String, Json] = Map([])\n for name, value in members {\n merged[name] = value\n }\n for name, value in params {\n merged[name] = ToJson::to_json(value)\n }\n Ok(ToJson::to_json(merged))\n}\n\n" +
"///|\n/// Whether a body carries nothing at all: a request with no body, or one whose body\n/// is only whitespace.\nfn blank(body : String) -> Bool {\n for i = 0; i < body.length(); i = i + 1 {\n let c = body[i]\n if c != ' ' && c != '\\t' && c != '\\n' && c != '\\r' {\n return false\n }\n }\n true\n}\n\n"
}
///|
/// Generate the gateway for `proto` with `routes`: the route table, the matcher, 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 or a parameter that has no object to land in, `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 + emit_router()
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/// Answer one request: find the route, bind the path's parameters, forward the\n/// body, wrap the reply.\npub fn Gateway::handle(\n self : Gateway,\n verb : String,\n path : String,\n body : Json,\n) -> GatewayReply {\n let found = match match_route(verb, path) {\n Some(found) => found\n None =>\n return {\n status: 404,\n body: error_body(\"no route for \" + verb + \" \" + path),\n }\n }\n let request = match with_params(body, found.params) {\n Ok(request) => request\n Err(why) => return { status: 400, body: error_body(why), }\n }\n let reply = (self.caller.call)(found.rpc, request) catch {\n e => return { status: 502, body: error_body(describe(e)), }\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; a blank one\n/// is the empty document, which is what a `GET` with path parameters sends.\npub fn Gateway::handle_text(\n self : Gateway,\n verb : String,\n path : String,\n body : String,\n) -> GatewayReply {\n if blank(body) {\n return self.handle(verb, path, Json((Map([]) : Map[String, Json])))\n }\n let doc = @json.loads(body) catch {\n _ => return { status: 400, body: error_body(\"body is not JSON\"), }\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"
}