198 lines
8.0 KiB
EmacsLisp
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)
|