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.
syntax = "proto3";
package shop;
message CreateReq { string name = 1; }
message User { string id = 1; }
service UserService {
rpc Create(CreateReq) returns (User);
rpc Get(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:
pub fn routes() -> Array[GatewayRoute] {
[
{ verb: "POST", path: "/UserService/Create", rpc: "/shop.UserService/Create", },
{ verb: "POST", path: "/UserService/Get", rpc: "/shop.UserService/Get", },
{ 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:
# what the browser sees what the RPC is called post /v1/users /shop.UserService/Create get /v1/users/:id /shop.UserService/Get get /v1/users /shop.UserService/List
pub fn routes() -> Array[GatewayRoute] {
[
{ verb: "POST", path: "/v1/users", rpc: "/shop.UserService/Create", },
{ verb: "GET", path: "/v1/users/:id", rpc: "/shop.UserService/Get", },
{ 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
| Status | When | Body |
|---|---|---|
200 | the upstream answered | the RPC's reply, as it came back |
400 | the request body is not JSON, or a path parameter has no object to land in | {"error": "body is not JSON"} |
404 | no route answers that verb and path | {"error": "no route for ..."} |
502 | the upstream call failed | {"error": "upstream 500: ..."} |
pub fn Gateway::handle(
self : Gateway,
verb : String,
path : String,
body : Json,
) -> GatewayReply {
let found = match match_route(verb, path) {
Some(found) => found
None =>
return {
status: 404,
body: error_body("no route for " + verb + " " + path),
}
}
let request = match with_params(body, found.params) {
Ok(request) => request
Err(why) => return { status: 400, body: error_body(why), }
}
let reply = (self.caller.call)(found.rpc, request) 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:
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.
Path parameters
A segment written :name stands for one segment of the request path, and the
value it took is bound into the request document under that name — the same reading a gRPC
method gives its fields. get /v1/users/:id answers /v1/users/7
with {"id": "7"} added to the body:
fn match_route(verb : String, path : String) -> GatewayMatch? {
let parts = segments(path)
let mut best : GatewayMatch? = None
let mut best_weight = -1
for route in routes() {
if route.verb != verb {
continue
}
let pattern = segments(route.path)
if pattern.length() != parts.length() {
continue
}
let params : Map[String, String] = Map([])
let mut weight = 0
let mut matched = true
for i = 0; i < pattern.length(); i = i + 1 {
let segment = pattern[i]
if segment.length() > 1 && segment[0] == ':' {
params[segment[1:].to_owned()] = parts[i]
} else if segment == parts[i] {
weight = weight + 1
} else {
matched = false
break
}
}
if matched && weight > best_weight {
best_weight = weight
best = Some({ rpc: route.rpc, params, })
}
}
best
}
-
A literal segment outranks a parameter, so
get /v1/users/mestill answers/v1/users/mewhenget /v1/users/:idis declared first. -
Two routes of one shape — the same verb with every parameter taken out — answer the same
requests, so the generator refuses the table and writes nothing, rather than a table with
a dead line.
route_clashesreports the pair. -
A parameter wins over the same member in the body: the path is where the client said
which resource it means. A body that is not an object has nowhere to put one, and answers
400; a blank body — aGETwith parameters sends none — is the empty document. -
Empty segments are dropped, so
/v1/users/7/and/v1//users/7are the same request as/v1/users/7.
A value arrives as a string, spelled the way the path spelled it: the gateway has no field types to convert with, and percent-decoding is the server's job, since the path it hands over is the path it read.
What it does not do yet
mctl gateway 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.