Gateway

mctl gateway greet.proto writes an HTTP front door in front of a gRPC service: a route table, a port to call the RPCs through, and the four answers a client cares about.

What it reads

A proto's messages and services. Every unary RPC becomes a route; a streaming RPC has no route, because an HTTP response is one body with one end, and the generated file names the ones it left out rather than dropping them quietly.

shop.proto
syntax = "proto3";
package shop;

message CreateReq { string name = 1; }
message User { string id = 1; }

service UserService {
  rpc Create(CreateReq) returns (User);
  rpc List(CreateReq) returns (User);
  rpc Watch(CreateReq) returns (stream User);
}

Routes

With no mapping the routes are mechanical — POST /<Service>/<Method> — which is what you want on day one, before anyone has decided on URLs:

shop_gateway.mbt
pub fn routes() -> Array[GatewayRoute] {
  [
    { verb: "POST", path: "/UserService/Create", rpc: "/shop.UserService/Create" },
    { verb: "POST", path: "/UserService/List", rpc: "/shop.UserService/List" },
  ]
}

When the URLs are decided, put them in a file next to the proto — same name, extension .routes — and the generator reads it instead:

shop.routes
# what the browser sees            what the RPC is called
post /v1/users                    /shop.UserService/Create
get  /v1/users                    /shop.UserService/List
shop_gateway.mbt
pub fn routes() -> Array[GatewayRoute] {
  [
    { verb: "POST", path: "/v1/users", rpc: "/shop.UserService/Create" },
    { verb: "GET", path: "/v1/users", rpc: "/shop.UserService/List" },
  ]
}

A line naming a method the proto does not declare is reported and skipped, so a typo is a message rather than a route that answers nothing.

The answers

StatusWhenBody
200the upstream answeredthe RPC's reply, as it came back
400the request body is not JSON{"error": "body is not JSON"}
404no route answers that verb and path{"error": "no route for ..."}
502the upstream call failed{"error": "upstream 500: ..."}
shop_gateway.mbt
pub fn Gateway::handle(
  self : Gateway,
  verb : String,
  path : String,
  body : Json,
) -> GatewayReply {
  let target = method_of(verb, path)
  if target == "" {
    return { status: 404, body: error_body("no route for " + verb + " " + path) }
  }
  let reply = (self.caller.call)(target, body) catch {
    e => return { status: 502, body: error_body(describe(e)) }
  }
  { status: 200, body: reply }
}

The Caller port

The gateway does not link a gRPC client. It calls one function, which is the whole of its upstream:

shop_gateway.mbt
pub(all) struct Caller {
  call : (String, Json) -> Json raise GatewayError
}

Bind it to a channel, a pool, or a function that answers from a fixture, and the gateway is live. handle takes a parsed body; handle_text takes the raw one and answers 400 when it will not parse.

What it does not do yet

Exact paths only

Routes are matched by verb and path exactly. A route written get /v1/users/:id is emitted into the table, but a request for /v1/users/7 will not match it, and the path parameter is not passed on to the RPC. Until that is done, keep gateway routes literal.

mctl gateway also does not transcode protobuf to JSON: the body is forwarded as the JSON document it arrived as, so the method behind call is expected to take a JSON request. Bind it to a client that does the conversion, or to a service whose transport is JSON.