Files
el/ui/vessels/el-services/src/main.el

198 lines
8.0 KiB
EmacsLisp

// el-services Pluggable service bindings.
//
// A "service" is a logical interface. A "binding" is the wire protocol that
// fulfills it. The binding kind is set in manifest.el; the service code does
// not change when the binding changes.
//
// binding = "rest" -> RestBinding
// binding = "grpc" -> GrpcBinding (planned)
// binding = "websocket" -> WebSocketBinding
// binding = "direct" -> in-process function call (for testing)
// Binding kinds
let BIND_REST: String = "rest"
let BIND_GRPC: String = "grpc"
let BIND_WEBSOCKET: String = "websocket"
let BIND_DIRECT: String = "direct"
// Errors
let SVC_ERR_NOT_FOUND: String = "service.not_found"
let SVC_ERR_BINDING: String = "service.binding"
let SVC_ERR_TRANSPORT: String = "service.transport"
let SVC_ERR_AUTH: String = "service.auth"
let SVC_ERR_METHOD_NOT_FOUND: String = "service.method_not_found"
// Service config
let AUTH_NONE: String = "none"
let AUTH_BEARER: String = "bearer"
let AUTH_BASIC: String = "basic"
let AUTH_HMAC: String = "hmac"
type AuthConfig {
kind: String // none | bearer | basic | hmac
secret_ref: String // e.g. "env:API_TOKEN" or "vault:path"
}
type ServiceConfig {
name: String
binding: String
base_url: String
auth: AuthConfig
timeout_ms: Int
}
fn service_config_rest(name: String, base_url: String, auth_secret_ref: String) -> ServiceConfig {
let auth: AuthConfig = { "kind": "bearer", "secret_ref": auth_secret_ref }
{ "name": name, "binding": "rest", "base_url": base_url, "auth": auth, "timeout_ms": 5000 }
}
// Service request/response
type ServiceRequest {
method: String // logical method name (e.g. "list_users")
args: String // JSON
metadata: String // JSON (auth tokens, trace ids)
}
type ServiceResponse {
status: Int // 0 = ok, non-zero = error code
body: String // JSON
error: String // empty if ok
}
fn response_ok(body: String) -> ServiceResponse {
{ "status": 0, "body": body, "error": "" }
}
fn response_err(code: Int, error: String) -> ServiceResponse {
{ "status": code, "body": "", "error": error }
}
// REST binding
//
// Method name -> (HTTP verb, path template). The compiler emits this table
// from the service interface declaration.
fn rest_invoke(cfg: ServiceConfig, http_method: String, path: String, req: ServiceRequest) -> ServiceResponse {
let url: String = cfg.base_url + path
let headers: String = build_auth_header(cfg.auth)
let body: String = req.args
let resp: String = ""
if str_eq(http_method, "GET") {
let resp = http_get_with_headers(url, headers)
}
if str_eq(http_method, "POST") {
let resp = http_post_with_headers(url, body, headers)
}
if str_eq(http_method, "PUT") {
let resp = http_put_with_headers(url, body, headers)
}
if str_eq(http_method, "DELETE") {
let resp = http_delete_with_headers(url, headers)
}
if str_eq(resp, "") { return response_err(599, SVC_ERR_TRANSPORT) }
response_ok(resp)
}
fn build_auth_header(auth: AuthConfig) -> String {
if str_eq(auth.kind, "bearer") {
let secret: String = resolve_secret_ref(auth.secret_ref)
return "Authorization: Bearer " + secret
}
if str_eq(auth.kind, "basic") {
let creds: String = resolve_secret_ref(auth.secret_ref)
return "Authorization: Basic " + base64_encode(creds)
}
""
}
fn resolve_secret_ref(ref: String) -> String {
if str_starts_with(ref, "env:") {
return env(str_slice(ref, 4, str_len(ref)))
}
if str_starts_with(ref, "vault:") {
return vault_kv_get(str_slice(ref, 6, str_len(ref)))
}
""
}
// gRPC binding (planned)
//
// Requires generated stubs from a .proto. The shape is identical to REST:
// a method dispatcher that converts logical method calls to wire calls.
fn grpc_invoke(cfg: ServiceConfig, method: String, req: ServiceRequest) -> ServiceResponse {
response_err(501, "grpc binding not yet implemented")
}
// WebSocket binding
fn ws_invoke(cfg: ServiceConfig, method: String, req: ServiceRequest) -> ServiceResponse {
let frame: String = "{\"method\":\"" + method + "\",\"args\":" + req.args + "}"
let reply: String = ws_send_recv(cfg.base_url, frame)
if str_eq(reply, "") { return response_err(599, SVC_ERR_TRANSPORT) }
response_ok(reply)
}
// Direct binding (in-process used in tests / single-process apps)
fn direct_invoke(handler_fn_name: String, req: ServiceRequest) -> ServiceResponse {
let body: String = call_dynamic2(handler_fn_name, req.method, req.args)
response_ok(body)
}
// ServiceRegistry
//
// Maps service name -> ServiceConfig. Methods are looked up in a separate
// per-service method table (logical method -> (verb, path) for REST, etc).
fn registry_new() -> String {
"{}"
}
fn registry_register(reg: String, cfg: ServiceConfig) -> String {
json_set(reg, cfg.name, json_encode(cfg))
}
fn registry_lookup(reg: String, name: String) -> String {
json_get(reg, name)
}
// ServiceProxy the user-facing call site
//
// A ServiceProxy is a (registry, service_name, method_table) trio. The
// el-ui-compiler emits one proxy class per service interface.
type ServiceProxy {
service_name: String
config: ServiceConfig
method_table: String // JSON: method -> { http_verb, path }
}
fn proxy_invoke(proxy: ServiceProxy, method: String, args_json: String) -> ServiceResponse {
let req: ServiceRequest = { "method": method, "args": args_json, "metadata": "{}" }
let cfg: ServiceConfig = proxy.config
if str_eq(cfg.binding, "rest") {
let m: String = json_get(proxy.method_table, method)
if str_eq(m, "") { return response_err(404, SVC_ERR_METHOD_NOT_FOUND) }
let verb: String = json_get(m, "verb")
let path: String = json_get(m, "path")
return rest_invoke(cfg, verb, path, req)
}
if str_eq(cfg.binding, "grpc") {
return grpc_invoke(cfg, method, req)
}
if str_eq(cfg.binding, "websocket") {
return ws_invoke(cfg, method, req)
}
response_err(400, SVC_ERR_BINDING)
}
// Entry smoke test
let cfg: ServiceConfig = service_config_rest("UserService", "https://api.example.com", "env:API_TOKEN")
println("[el-services] registered " + cfg.name + " @ " + cfg.base_url)