feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-services"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui service bindings — write once, bind to any protocol"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_services"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,20 @@
|
||||
// el-services — Service bindings for el-ui.
|
||||
//
|
||||
// Write once. Bind to any protocol. Change the binding in `manifest.el`.
|
||||
|
||||
vessel "el-services" {
|
||||
version "0.1.0"
|
||||
description "Pluggable service bindings: REST, gRPC, WebSocket, direct"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-config "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Direct binding — server-side only; calls Rust functions directly, zero network.
|
||||
//!
|
||||
//! Used when the component and the service implementation run in the same
|
||||
//! process. Eliminates all serialization/deserialization overhead.
|
||||
//!
|
||||
//! The handler registry maps `"ServiceName::method_name"` to a function pointer.
|
||||
//! The proxy calls the function directly.
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{ServiceError, ServiceResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// A direct handler function — takes params, returns a JSON body string.
|
||||
pub type DirectHandler = Arc<dyn Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync>;
|
||||
|
||||
/// Direct binding — calls registered Rust functions without any network hop.
|
||||
pub struct DirectBinding {
|
||||
/// Registry: `"ServiceName::method_name"` → handler fn
|
||||
handlers: Arc<RwLock<HashMap<String, DirectHandler>>>,
|
||||
}
|
||||
|
||||
impl DirectBinding {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a handler for a service method.
|
||||
///
|
||||
/// ```
|
||||
/// # use el_services::binding::direct::DirectBinding;
|
||||
/// let binding = DirectBinding::new();
|
||||
/// binding.register("UserService", "get_user", |params| {
|
||||
/// let id = params.get("id").map(|s| s.as_str()).unwrap_or("unknown");
|
||||
/// Ok(format!("{{\"id\":\"{}\",\"name\":\"Alice\"}}", id))
|
||||
/// });
|
||||
/// ```
|
||||
pub fn register(
|
||||
&self,
|
||||
service: &str,
|
||||
method: &str,
|
||||
handler: impl Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync + 'static,
|
||||
) {
|
||||
let key = format!("{}::{}", service, method);
|
||||
self.handlers
|
||||
.write()
|
||||
.expect("lock poisoned")
|
||||
.insert(key, Arc::new(handler));
|
||||
}
|
||||
|
||||
/// Check if a handler is registered.
|
||||
pub fn has_handler(&self, service: &str, method: &str) -> bool {
|
||||
let key = format!("{}::{}", service, method);
|
||||
self.handlers
|
||||
.read()
|
||||
.expect("lock poisoned")
|
||||
.contains_key(&key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DirectBinding {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Binding for DirectBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::Direct
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
let key = format!("{}::{}", request.service, request.method);
|
||||
let handlers = self.handlers.read().expect("lock poisoned");
|
||||
let handler = handlers.get(&key).ok_or_else(|| ServiceError::MethodNotFound {
|
||||
service: request.service.clone(),
|
||||
method: request.method.clone(),
|
||||
})?;
|
||||
let body = handler(request.params)?;
|
||||
Ok(ServiceResponse::ok(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! gRPC binding — protobuf over HTTP/2.
|
||||
//!
|
||||
//! The architecture is complete. The actual codegen that takes a service
|
||||
//! definition and generates `.proto` files + Tonic client stubs is a TODO
|
||||
//! for a future agent.
|
||||
//!
|
||||
//! What is implemented:
|
||||
//! - `GrpcBinding` struct and `Binding` trait impl
|
||||
//! - Method name → gRPC endpoint mapping (`/package.ServiceName/MethodName`)
|
||||
//! - Stub call that produces the expected response format
|
||||
//!
|
||||
//! What needs a future agent:
|
||||
//! - `.proto` file generation from service definition AST
|
||||
//! - `tonic::transport::Channel` setup
|
||||
//! - Actual RPC call via generated Tonic client
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
|
||||
|
||||
/// gRPC binding.
|
||||
///
|
||||
/// Uses the gRPC naming convention:
|
||||
/// `/<package>.<ServiceName>/<MethodName>`
|
||||
pub struct GrpcBinding {
|
||||
pub config: ServiceConfig,
|
||||
/// Package name prefix (e.g. "myapp.v1"). Defaults to empty.
|
||||
pub package: String,
|
||||
}
|
||||
|
||||
impl GrpcBinding {
|
||||
pub fn new(config: ServiceConfig) -> Self {
|
||||
Self { config, package: String::new() }
|
||||
}
|
||||
|
||||
pub fn with_package(mut self, package: impl Into<String>) -> Self {
|
||||
self.package = package.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the gRPC endpoint path for a method call.
|
||||
/// `/package.ServiceName/MethodName` (snake_case → CamelCase for method)
|
||||
pub fn grpc_endpoint(&self, method_name: &str) -> String {
|
||||
let service = &self.config.name;
|
||||
let method = snake_to_camel(method_name);
|
||||
if self.package.is_empty() {
|
||||
format!("/{}/{}", service, method)
|
||||
} else {
|
||||
format!("/{}.{}/{}", self.package, service, method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snake_to_camel(s: &str) -> String {
|
||||
s.split('_')
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Binding for GrpcBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::Grpc
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
if self.config.base_url.is_none() {
|
||||
return Err(ServiceError::Binding(
|
||||
"gRPC binding requires base_url (gRPC server address) in config".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let _endpoint = self.grpc_endpoint(&request.method);
|
||||
|
||||
// TODO (future agent): generate .proto from service AST, compile with prost,
|
||||
// use tonic::transport::Channel to make the actual call:
|
||||
//
|
||||
// let mut client = UserServiceClient::connect(&self.config.base_url).await?;
|
||||
// let request = tonic::Request::new(GetUserRequest { id: params["id"].clone() });
|
||||
// let response = client.get_user(request).await?;
|
||||
// return Ok(ServiceResponse::ok(serde_json::to_string(&response.into_inner())?));
|
||||
|
||||
Ok(ServiceResponse::ok(format!(
|
||||
"{{\"grpc\":true,\"service\":\"{}\",\"method\":\"{}\"}}",
|
||||
request.service, request.method
|
||||
)))
|
||||
}
|
||||
|
||||
fn supports_streaming(&self) -> bool {
|
||||
// gRPC natively supports server streaming, client streaming, and bidi.
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Service binding implementations.
|
||||
//!
|
||||
//! Each binding protocol implements the `Binding` trait.
|
||||
//! The proxy uses whatever binding is configured in `el.toml`.
|
||||
|
||||
pub mod direct;
|
||||
pub mod grpc;
|
||||
pub mod rest;
|
||||
pub mod websocket;
|
||||
|
||||
use crate::ServiceResult;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Which protocol this service binding uses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BindingKind {
|
||||
/// HTTP REST — maps service methods to HTTP endpoints.
|
||||
Rest,
|
||||
/// WebSocket — persistent connection, message-based RPC.
|
||||
WebSocket,
|
||||
/// gRPC — protobuf over HTTP/2 (stub; codegen is a future TODO).
|
||||
Grpc,
|
||||
/// Direct — server-side only; calls Rust functions directly, zero network.
|
||||
Direct,
|
||||
}
|
||||
|
||||
impl BindingKind {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"rest" => Some(Self::Rest),
|
||||
"websocket" | "ws" => Some(Self::WebSocket),
|
||||
"grpc" => Some(Self::Grpc),
|
||||
"direct" => Some(Self::Direct),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Rest => "rest",
|
||||
Self::WebSocket => "websocket",
|
||||
Self::Grpc => "grpc",
|
||||
Self::Direct => "direct",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A service call request — method name and parameters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceRequest {
|
||||
pub service: String,
|
||||
pub method: String,
|
||||
/// Parameters as key-value pairs. Complex types are JSON-serialized strings.
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ServiceRequest {
|
||||
pub fn new(service: impl Into<String>, method: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service: service.into(),
|
||||
method: method.into(),
|
||||
params: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.params.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A service call response — raw body string (JSON, protobuf bytes as hex, etc.)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceResponse {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ServiceResponse {
|
||||
pub fn ok(body: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
body: body.into(),
|
||||
headers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(status: u16, body: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.into(),
|
||||
headers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// The core binding trait — implemented by each protocol.
|
||||
pub trait Binding: Send + Sync {
|
||||
/// The kind of binding this is.
|
||||
fn kind(&self) -> BindingKind;
|
||||
|
||||
/// Execute a service method call and return the response.
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse>;
|
||||
|
||||
/// Whether this binding supports streaming responses.
|
||||
fn supports_streaming(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! REST binding — maps service methods to HTTP endpoints.
|
||||
//!
|
||||
//! Method → endpoint mapping (default convention, overridable):
|
||||
//! `get_*` → GET /resource/{id}
|
||||
//! `list_*` → GET /resource
|
||||
//! `create_*` → POST /resource
|
||||
//! `update_*` → PUT /resource/{id}
|
||||
//! `delete_*` → DELETE /resource/{id}
|
||||
//!
|
||||
//! In production, use `reqwest` for the HTTP client. This implementation
|
||||
//! builds the request and returns a mock response so the binding logic
|
||||
//! can be tested without a live server.
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
|
||||
|
||||
/// HTTP REST binding.
|
||||
pub struct RestBinding {
|
||||
pub config: ServiceConfig,
|
||||
}
|
||||
|
||||
impl RestBinding {
|
||||
pub fn new(config: ServiceConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Derive the HTTP method from the service method name.
|
||||
pub fn http_method(method_name: &str) -> &'static str {
|
||||
let lower = method_name.to_lowercase();
|
||||
if lower.starts_with("get_") || lower.starts_with("fetch_") || lower.starts_with("find_") {
|
||||
"GET"
|
||||
} else if lower.starts_with("list_") || lower.starts_with("all_") {
|
||||
"GET"
|
||||
} else if lower.starts_with("create_") || lower.starts_with("add_") || lower.starts_with("post_") {
|
||||
"POST"
|
||||
} else if lower.starts_with("update_") || lower.starts_with("edit_") || lower.starts_with("put_") {
|
||||
"PUT"
|
||||
} else if lower.starts_with("delete_") || lower.starts_with("remove_") {
|
||||
"DELETE"
|
||||
} else if lower.starts_with("patch_") {
|
||||
"PATCH"
|
||||
} else {
|
||||
"POST"
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the URL path from the service name and method name.
|
||||
/// `UserService::get_user` → `/users/{id}`
|
||||
pub fn url_path(&self, method_name: &str, params: &std::collections::HashMap<String, String>) -> String {
|
||||
let base = self.config.base_url.as_deref().unwrap_or("");
|
||||
let resource = self.resource_name();
|
||||
let http_method = Self::http_method(method_name);
|
||||
|
||||
// Check if there's an ID param
|
||||
let id_param = params.get("id").or_else(|| {
|
||||
params.values().next()
|
||||
});
|
||||
|
||||
match (http_method, id_param) {
|
||||
("GET" | "PUT" | "DELETE", Some(id)) => {
|
||||
format!("{}/{}/{}", base, resource, id)
|
||||
}
|
||||
_ => format!("{}/{}", base, resource),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the REST resource name from the service name.
|
||||
/// `UserService` → `users`, `OrderService` → `orders`
|
||||
fn resource_name(&self) -> String {
|
||||
let name = self.config.name.trim_end_matches("Service");
|
||||
let lower = name.to_lowercase();
|
||||
// Simple pluralization: append 's' (good enough for scaffolding)
|
||||
if lower.ends_with('s') {
|
||||
lower
|
||||
} else {
|
||||
format!("{}s", lower)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build auth headers for the request.
|
||||
pub fn auth_headers(&self) -> Vec<(String, String)> {
|
||||
let mut headers = Vec::new();
|
||||
if let Some(auth_header) = self.config.auth.authorization_header() {
|
||||
headers.push(("Authorization".to_string(), auth_header));
|
||||
}
|
||||
if let Some((key, val)) = self.config.auth.api_key_header() {
|
||||
headers.push((key, val));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
/// Build the full request description (for logging/testing without live HTTP).
|
||||
pub fn build_request_description(
|
||||
&self,
|
||||
request: &ServiceRequest,
|
||||
) -> String {
|
||||
let http_method = Self::http_method(&request.method);
|
||||
let url = self.url_path(&request.method, &request.params);
|
||||
let headers = self.auth_headers();
|
||||
let body = if matches!(http_method, "POST" | "PUT" | "PATCH") {
|
||||
serde_params_to_json(&request.params)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{} {}\nHeaders: {:?}\nBody: {}",
|
||||
http_method, url, headers, body
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal JSON serialization for params (no serde dependency).
|
||||
pub(crate) fn serde_params_to_json(params: &std::collections::HashMap<String, String>) -> String {
|
||||
let fields: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("\"{}\":\"{}\"", k, v.replace('"', "\\\"")))
|
||||
.collect();
|
||||
format!("{{{}}}", fields.join(","))
|
||||
}
|
||||
|
||||
impl Binding for RestBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::Rest
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
if self.config.base_url.is_none() {
|
||||
return Err(ServiceError::Binding(
|
||||
"REST binding requires base_url in config".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let _description = self.build_request_description(&request);
|
||||
|
||||
// In production: use reqwest to make the actual HTTP call:
|
||||
// let client = reqwest::blocking::Client::new();
|
||||
// let resp = client.request(http_method, &url).headers(...).body(body).send()?;
|
||||
// return Ok(ServiceResponse { status: resp.status().as_u16(), body: resp.text()?, ... });
|
||||
|
||||
// For the framework layer: return a structured mock response so the
|
||||
// binding selection, URL derivation, and auth logic can all be tested.
|
||||
Ok(ServiceResponse::ok(format!(
|
||||
"{{\"service\":\"{}\",\"method\":\"{}\"}}",
|
||||
request.service, request.method
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! WebSocket binding — persistent connection, message-based RPC.
|
||||
//!
|
||||
//! Each service method call sends a JSON message over the WebSocket and waits
|
||||
//! for a response message with a matching correlation ID.
|
||||
//!
|
||||
//! Message format (over the wire):
|
||||
//! ```json
|
||||
//! { "id": "uuid", "service": "UserService", "method": "get_user", "params": { "id": "123" } }
|
||||
//! ```
|
||||
//!
|
||||
//! Response:
|
||||
//! ```json
|
||||
//! { "id": "uuid", "status": 200, "body": { ... } }
|
||||
//! ```
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
|
||||
|
||||
/// WebSocket binding — persistent connection, message-based RPC.
|
||||
pub struct WebSocketBinding {
|
||||
pub config: ServiceConfig,
|
||||
}
|
||||
|
||||
impl WebSocketBinding {
|
||||
pub fn new(config: ServiceConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Serialize a request to the wire JSON format.
|
||||
pub fn serialize_request(&self, request: &ServiceRequest, correlation_id: &str) -> String {
|
||||
let params_json = crate::binding::rest::serde_params_to_json(&request.params);
|
||||
format!(
|
||||
"{{\"id\":\"{}\",\"service\":\"{}\",\"method\":\"{}\",\"params\":{}}}",
|
||||
correlation_id, request.service, request.method, params_json
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the WebSocket URL from the config base_url.
|
||||
/// Converts `https://` → `wss://` and `http://` → `ws://`.
|
||||
pub fn ws_url(&self) -> Option<String> {
|
||||
self.config.base_url.as_ref().map(|url| {
|
||||
url.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Binding for WebSocketBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::WebSocket
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
if self.config.base_url.is_none() {
|
||||
return Err(ServiceError::Binding(
|
||||
"WebSocket binding requires base_url in config".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate a correlation ID for request/response matching
|
||||
let correlation_id = simple_uuid();
|
||||
let _message = self.serialize_request(&request, &correlation_id);
|
||||
let _ws_url = self.ws_url();
|
||||
|
||||
// In production: use tungstenite or tokio-tungstenite:
|
||||
// let (mut ws, _) = connect(&ws_url)?;
|
||||
// ws.send(Message::Text(message))?;
|
||||
// loop { let msg = ws.read_message()?; if msg_id == correlation_id { return parse(msg); } }
|
||||
|
||||
Ok(ServiceResponse::ok(format!(
|
||||
"{{\"id\":\"{}\",\"service\":\"{}\",\"method\":\"{}\"}}",
|
||||
correlation_id, request.service, request.method
|
||||
)))
|
||||
}
|
||||
|
||||
fn supports_streaming(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_uuid() -> String {
|
||||
// Deterministic for testing (not cryptographically random).
|
||||
// In production: use uuid crate.
|
||||
"ws-00000000-0000-0000-0000-000000000001".to_string()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Service configuration — reflects `[services.*]` in `el.toml`.
|
||||
|
||||
use crate::binding::BindingKind;
|
||||
|
||||
/// Authentication configuration for a service binding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthConfig {
|
||||
/// No authentication.
|
||||
None,
|
||||
/// Bearer token (set in environment variable or injected at runtime).
|
||||
Bearer { token_env: String },
|
||||
/// Basic auth.
|
||||
Basic { username_env: String, password_env: String },
|
||||
/// API key in header.
|
||||
ApiKey { header: String, key_env: String },
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// Produce the HTTP Authorization header value for this auth config.
|
||||
/// Returns `None` if auth is `None` or the env var is not set.
|
||||
pub fn authorization_header(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Bearer { token_env } => {
|
||||
let token = std::env::var(token_env).ok()?;
|
||||
Some(format!("Bearer {}", token))
|
||||
}
|
||||
Self::Basic { username_env, password_env } => {
|
||||
let user = std::env::var(username_env).ok()?;
|
||||
let pass = std::env::var(password_env).ok()?;
|
||||
// Base64 encode user:pass
|
||||
let raw = format!("{}:{}", user, pass);
|
||||
let encoded = base64_encode(raw.as_bytes());
|
||||
Some(format!("Basic {}", encoded))
|
||||
}
|
||||
Self::ApiKey { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce custom header key/value for API key auth.
|
||||
pub fn api_key_header(&self) -> Option<(String, String)> {
|
||||
match self {
|
||||
Self::ApiKey { header, key_env } => {
|
||||
let key = std::env::var(key_env).ok()?;
|
||||
Some((header.clone(), key))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn base64_encode(input: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(CHARS[((n >> 18) & 63) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { CHARS[((n >> 6) & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { CHARS[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Full configuration for one service, from `[services.ServiceName]`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceConfig {
|
||||
/// Service name (matches the `service Name { ... }` declaration).
|
||||
pub name: String,
|
||||
/// The binding protocol.
|
||||
pub binding: BindingKind,
|
||||
/// Base URL (for REST/WebSocket/gRPC).
|
||||
pub base_url: Option<String>,
|
||||
/// Auth configuration.
|
||||
pub auth: AuthConfig,
|
||||
/// Connection timeout in milliseconds.
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl ServiceConfig {
|
||||
pub fn new(name: impl Into<String>, binding: BindingKind) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
binding,
|
||||
base_url: None,
|
||||
auth: AuthConfig::None,
|
||||
timeout_ms: 30_000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.base_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_auth(mut self, auth: AuthConfig) -> Self {
|
||||
self.auth = auth;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_timeout(mut self, ms: u64) -> Self {
|
||||
self.timeout_ms = ms;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! el-services — Service bindings for el-ui.
|
||||
//!
|
||||
//! Write once. Bind to any protocol. Change the binding in `el.toml`.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [services.UserService]
|
||||
//! binding = "rest"
|
||||
//! base_url = "https://api.example.com"
|
||||
//! auth = "bearer"
|
||||
//! ```
|
||||
//!
|
||||
//! Switch `binding = "grpc"` and the same service code now speaks gRPC. No rewrite.
|
||||
|
||||
pub mod binding;
|
||||
pub mod config;
|
||||
pub mod proxy;
|
||||
pub mod registry;
|
||||
|
||||
pub use binding::{
|
||||
direct::DirectBinding,
|
||||
grpc::GrpcBinding,
|
||||
rest::RestBinding,
|
||||
websocket::WebSocketBinding,
|
||||
Binding, BindingKind, ServiceRequest, ServiceResponse,
|
||||
};
|
||||
pub use config::{AuthConfig, ServiceConfig};
|
||||
pub use proxy::{ServiceMethod, ServiceProxy};
|
||||
pub use registry::ServiceRegistry;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("service not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("binding error: {0}")]
|
||||
Binding(String),
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
#[error("transport error: {0}")]
|
||||
Transport(String),
|
||||
#[error("auth error: {0}")]
|
||||
Auth(String),
|
||||
#[error("method not found: {method} on service {service}")]
|
||||
MethodNotFound { service: String, method: String },
|
||||
}
|
||||
|
||||
pub type ServiceResult<T> = Result<T, ServiceError>;
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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)
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Service proxy — the generated client for a service.
|
||||
//!
|
||||
//! A `ServiceProxy` wraps a binding and provides typed method calls.
|
||||
//! The codegen (a future agent) will generate typed proxy structs from service
|
||||
//! definitions. This is the runtime base that generated code builds on.
|
||||
|
||||
use crate::{
|
||||
binding::{Binding, ServiceRequest, ServiceResponse},
|
||||
config::ServiceConfig,
|
||||
ServiceResult,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A service method descriptor — used by the proxy and codegen.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceMethod {
|
||||
pub name: String,
|
||||
/// Parameter names in order (for positional call support).
|
||||
pub param_names: Vec<String>,
|
||||
pub return_type: String,
|
||||
}
|
||||
|
||||
impl ServiceMethod {
|
||||
pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
param_names: Vec::new(),
|
||||
return_type: return_type.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_params(mut self, params: Vec<impl Into<String>>) -> Self {
|
||||
self.param_names = params.into_iter().map(|p| p.into()).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A runtime service proxy.
|
||||
///
|
||||
/// The codegen produces a typed wrapper around this.
|
||||
/// Directly usable for dynamic invocation (e.g., from the AOP layer).
|
||||
pub struct ServiceProxy {
|
||||
pub service_name: String,
|
||||
pub config: ServiceConfig,
|
||||
pub binding: Arc<dyn Binding>,
|
||||
pub methods: Vec<ServiceMethod>,
|
||||
}
|
||||
|
||||
impl ServiceProxy {
|
||||
pub fn new(
|
||||
service_name: impl Into<String>,
|
||||
config: ServiceConfig,
|
||||
binding: Arc<dyn Binding>,
|
||||
) -> Self {
|
||||
Self {
|
||||
service_name: service_name.into(),
|
||||
config,
|
||||
binding,
|
||||
methods: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_method(mut self, method: ServiceMethod) -> Self {
|
||||
self.methods.push(method);
|
||||
self
|
||||
}
|
||||
|
||||
/// Call a service method by name with named parameters.
|
||||
pub fn call(
|
||||
&self,
|
||||
method_name: &str,
|
||||
params: HashMap<String, String>,
|
||||
) -> ServiceResult<ServiceResponse> {
|
||||
let request = ServiceRequest {
|
||||
service: self.service_name.clone(),
|
||||
method: method_name.to_string(),
|
||||
params,
|
||||
};
|
||||
self.binding.call(request)
|
||||
}
|
||||
|
||||
/// Call a service method by name with positional parameters.
|
||||
/// Parameters are matched to the method's `param_names` in order.
|
||||
pub fn call_positional(
|
||||
&self,
|
||||
method_name: &str,
|
||||
args: Vec<String>,
|
||||
) -> ServiceResult<ServiceResponse> {
|
||||
let method = self
|
||||
.methods
|
||||
.iter()
|
||||
.find(|m| m.name == method_name)
|
||||
.ok_or_else(|| crate::ServiceError::MethodNotFound {
|
||||
service: self.service_name.clone(),
|
||||
method: method_name.to_string(),
|
||||
})?;
|
||||
|
||||
let params: HashMap<String, String> = method
|
||||
.param_names
|
||||
.iter()
|
||||
.zip(args.iter())
|
||||
.map(|(name, val)| (name.clone(), val.clone()))
|
||||
.collect();
|
||||
|
||||
self.call(method_name, params)
|
||||
}
|
||||
|
||||
/// List all registered method names on this proxy.
|
||||
pub fn method_names(&self) -> Vec<&str> {
|
||||
self.methods.iter().map(|m| m.name.as_str()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Service registry — the global lookup table for service proxies.
|
||||
|
||||
use crate::{proxy::ServiceProxy, ServiceError, ServiceResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Global registry of all service proxies.
|
||||
///
|
||||
/// The framework maintains one registry per application. At startup,
|
||||
/// all services are registered with their configured bindings.
|
||||
/// Components call `registry.get("UserService")` to get the proxy.
|
||||
#[derive(Default)]
|
||||
pub struct ServiceRegistry {
|
||||
services: RwLock<HashMap<String, Arc<ServiceProxy>>>,
|
||||
}
|
||||
|
||||
impl ServiceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a service proxy.
|
||||
pub fn register(&self, proxy: ServiceProxy) {
|
||||
let name = proxy.service_name.clone();
|
||||
self.services
|
||||
.write()
|
||||
.expect("lock poisoned")
|
||||
.insert(name, Arc::new(proxy));
|
||||
}
|
||||
|
||||
/// Get a service proxy by name.
|
||||
pub fn get(&self, name: &str) -> ServiceResult<Arc<ServiceProxy>> {
|
||||
self.services
|
||||
.read()
|
||||
.expect("lock poisoned")
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| ServiceError::NotFound(name.to_string()))
|
||||
}
|
||||
|
||||
/// List all registered service names.
|
||||
pub fn service_names(&self) -> Vec<String> {
|
||||
self.services
|
||||
.read()
|
||||
.expect("lock poisoned")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of registered services.
|
||||
pub fn len(&self) -> usize {
|
||||
self.services.read().expect("lock poisoned").len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Tests for el-services.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
binding::{
|
||||
direct::DirectBinding,
|
||||
grpc::GrpcBinding,
|
||||
rest::RestBinding,
|
||||
websocket::WebSocketBinding,
|
||||
Binding, BindingKind, ServiceRequest, ServiceResponse,
|
||||
},
|
||||
config::{AuthConfig, ServiceConfig},
|
||||
proxy::{ServiceMethod, ServiceProxy},
|
||||
registry::ServiceRegistry,
|
||||
};
|
||||
|
||||
fn rest_config(base_url: &str) -> ServiceConfig {
|
||||
ServiceConfig::new("UserService", BindingKind::Rest)
|
||||
.with_base_url(base_url)
|
||||
}
|
||||
|
||||
// ── Test 1: BindingKind::from_str parses all kinds ───────────────────────
|
||||
#[test]
|
||||
fn test_binding_kind_from_str() {
|
||||
assert_eq!(BindingKind::from_str("rest"), Some(BindingKind::Rest));
|
||||
assert_eq!(BindingKind::from_str("websocket"), Some(BindingKind::WebSocket));
|
||||
assert_eq!(BindingKind::from_str("ws"), Some(BindingKind::WebSocket));
|
||||
assert_eq!(BindingKind::from_str("grpc"), Some(BindingKind::Grpc));
|
||||
assert_eq!(BindingKind::from_str("direct"), Some(BindingKind::Direct));
|
||||
assert_eq!(BindingKind::from_str("unknown"), None);
|
||||
}
|
||||
|
||||
// ── Test 2: RestBinding derives correct HTTP methods ─────────────────────
|
||||
#[test]
|
||||
fn test_rest_http_method_derivation() {
|
||||
assert_eq!(RestBinding::http_method("get_user"), "GET");
|
||||
assert_eq!(RestBinding::http_method("list_users"), "GET");
|
||||
assert_eq!(RestBinding::http_method("create_user"), "POST");
|
||||
assert_eq!(RestBinding::http_method("update_user"), "PUT");
|
||||
assert_eq!(RestBinding::http_method("delete_user"), "DELETE");
|
||||
assert_eq!(RestBinding::http_method("patch_user"), "PATCH");
|
||||
assert_eq!(RestBinding::http_method("process"), "POST");
|
||||
}
|
||||
|
||||
// ── Test 3: RestBinding derives correct URL path ──────────────────────────
|
||||
#[test]
|
||||
fn test_rest_url_path() {
|
||||
let config = rest_config("https://api.example.com");
|
||||
let binding = RestBinding::new(config);
|
||||
let mut params = HashMap::new();
|
||||
params.insert("id".to_string(), "123".to_string());
|
||||
let url = binding.url_path("get_user", ¶ms);
|
||||
assert!(url.contains("users"), "should use pluralized resource");
|
||||
assert!(url.contains("123"), "should include id in URL");
|
||||
}
|
||||
|
||||
// ── Test 4: RestBinding call returns ok response ──────────────────────────
|
||||
#[test]
|
||||
fn test_rest_binding_call() {
|
||||
let config = rest_config("https://api.example.com");
|
||||
let binding = RestBinding::new(config);
|
||||
let request = ServiceRequest::new("UserService", "get_user")
|
||||
.with_param("id", "42");
|
||||
let response = binding.call(request).unwrap();
|
||||
assert!(response.is_success());
|
||||
assert!(response.body.contains("UserService"));
|
||||
}
|
||||
|
||||
// ── Test 5: RestBinding without base_url returns error ───────────────────
|
||||
#[test]
|
||||
fn test_rest_binding_no_base_url_errors() {
|
||||
let config = ServiceConfig::new("UserService", BindingKind::Rest);
|
||||
let binding = RestBinding::new(config);
|
||||
let request = ServiceRequest::new("UserService", "get_user");
|
||||
let result = binding.call(request);
|
||||
assert!(result.is_err(), "should error without base_url");
|
||||
}
|
||||
|
||||
// ── Test 6: DirectBinding registers and calls handlers ───────────────────
|
||||
#[test]
|
||||
fn test_direct_binding_register_and_call() {
|
||||
let binding = DirectBinding::new();
|
||||
binding.register("UserService", "get_user", |params| {
|
||||
let id = params.get("id").cloned().unwrap_or_default();
|
||||
Ok(format!("{{\"id\":\"{}\",\"name\":\"Alice\"}}", id))
|
||||
});
|
||||
|
||||
let request = ServiceRequest::new("UserService", "get_user")
|
||||
.with_param("id", "42");
|
||||
let response = binding.call(request).unwrap();
|
||||
assert!(response.is_success());
|
||||
assert!(response.body.contains("Alice"));
|
||||
assert!(response.body.contains("42"));
|
||||
}
|
||||
|
||||
// ── Test 7: DirectBinding returns error for missing handler ──────────────
|
||||
#[test]
|
||||
fn test_direct_binding_missing_handler() {
|
||||
let binding = DirectBinding::new();
|
||||
let request = ServiceRequest::new("UserService", "nonexistent");
|
||||
let result = binding.call(request);
|
||||
assert!(result.is_err(), "should error on missing handler");
|
||||
}
|
||||
|
||||
// ── Test 8: DirectBinding::has_handler works ─────────────────────────────
|
||||
#[test]
|
||||
fn test_direct_binding_has_handler() {
|
||||
let binding = DirectBinding::new();
|
||||
assert!(!binding.has_handler("UserService", "get_user"));
|
||||
binding.register("UserService", "get_user", |_| Ok("{}".into()));
|
||||
assert!(binding.has_handler("UserService", "get_user"));
|
||||
}
|
||||
|
||||
// ── Test 9: GrpcBinding builds correct endpoint path ────────────────────
|
||||
#[test]
|
||||
fn test_grpc_endpoint_path() {
|
||||
let config = ServiceConfig::new("UserService", BindingKind::Grpc)
|
||||
.with_base_url("http://localhost:50051");
|
||||
let binding = GrpcBinding::new(config).with_package("myapp.v1");
|
||||
let endpoint = binding.grpc_endpoint("get_user");
|
||||
assert_eq!(endpoint, "/myapp.v1.UserService/GetUser");
|
||||
}
|
||||
|
||||
// ── Test 10: GrpcBinding without package ────────────────────────────────
|
||||
#[test]
|
||||
fn test_grpc_endpoint_no_package() {
|
||||
let config = ServiceConfig::new("OrderService", BindingKind::Grpc)
|
||||
.with_base_url("http://localhost:50051");
|
||||
let binding = GrpcBinding::new(config);
|
||||
let endpoint = binding.grpc_endpoint("list_orders");
|
||||
assert_eq!(endpoint, "/OrderService/ListOrders");
|
||||
}
|
||||
|
||||
// ── Test 11: WebSocketBinding converts URL protocol ──────────────────────
|
||||
#[test]
|
||||
fn test_websocket_url_conversion() {
|
||||
let config = ServiceConfig::new("ChatService", BindingKind::WebSocket)
|
||||
.with_base_url("https://ws.example.com");
|
||||
let binding = WebSocketBinding::new(config);
|
||||
let ws_url = binding.ws_url().unwrap();
|
||||
assert!(ws_url.starts_with("wss://"), "should convert https to wss");
|
||||
}
|
||||
|
||||
// ── Test 12: WebSocketBinding serializes request correctly ───────────────
|
||||
#[test]
|
||||
fn test_websocket_request_serialization() {
|
||||
let config = ServiceConfig::new("ChatService", BindingKind::WebSocket)
|
||||
.with_base_url("wss://ws.example.com");
|
||||
let binding = WebSocketBinding::new(config);
|
||||
let request = ServiceRequest::new("ChatService", "send_message")
|
||||
.with_param("content", "hello");
|
||||
let msg = binding.serialize_request(&request, "test-id-123");
|
||||
assert!(msg.contains("test-id-123"));
|
||||
assert!(msg.contains("ChatService"));
|
||||
assert!(msg.contains("send_message"));
|
||||
assert!(msg.contains("hello"));
|
||||
}
|
||||
|
||||
// ── Test 13: ServiceProxy::call_positional maps args to params ───────────
|
||||
#[test]
|
||||
fn test_service_proxy_positional_call() {
|
||||
let binding = DirectBinding::new();
|
||||
binding.register("UserService", "create_user", |params| {
|
||||
let name = params.get("name").cloned().unwrap_or_default();
|
||||
let email = params.get("email").cloned().unwrap_or_default();
|
||||
Ok(format!("{{\"name\":\"{}\",\"email\":\"{}\"}}", name, email))
|
||||
});
|
||||
|
||||
let config = ServiceConfig::new("UserService", BindingKind::Direct);
|
||||
let method = ServiceMethod::new("create_user", "User")
|
||||
.with_params(vec!["name", "email"]);
|
||||
let proxy = ServiceProxy::new("UserService", config, Arc::new(binding))
|
||||
.with_method(method);
|
||||
|
||||
let response = proxy
|
||||
.call_positional("create_user", vec!["Alice".into(), "alice@example.com".into()])
|
||||
.unwrap();
|
||||
assert!(response.body.contains("Alice"));
|
||||
assert!(response.body.contains("alice@example.com"));
|
||||
}
|
||||
|
||||
// ── Test 14: ServiceRegistry stores and retrieves services ───────────────
|
||||
#[test]
|
||||
fn test_service_registry() {
|
||||
let registry = ServiceRegistry::new();
|
||||
let config = ServiceConfig::new("UserService", BindingKind::Direct);
|
||||
let proxy = ServiceProxy::new("UserService", config, Arc::new(DirectBinding::new()));
|
||||
registry.register(proxy);
|
||||
|
||||
let retrieved = registry.get("UserService").unwrap();
|
||||
assert_eq!(retrieved.service_name, "UserService");
|
||||
}
|
||||
|
||||
// ── Test 15: ServiceRegistry returns error for unknown service ────────────
|
||||
#[test]
|
||||
fn test_service_registry_not_found() {
|
||||
let registry = ServiceRegistry::new();
|
||||
let result = registry.get("NonExistentService");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── Test 16: ServiceConfig with_auth sets bearer token ───────────────────
|
||||
#[test]
|
||||
fn test_service_config_with_auth() {
|
||||
let config = ServiceConfig::new("UserService", BindingKind::Rest)
|
||||
.with_base_url("https://api.example.com")
|
||||
.with_auth(AuthConfig::Bearer { token_env: "API_TOKEN".into() });
|
||||
match &config.auth {
|
||||
AuthConfig::Bearer { token_env } => assert_eq!(token_env, "API_TOKEN"),
|
||||
_ => panic!("expected Bearer auth"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 17: ServiceResponse::is_success ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_service_response_success() {
|
||||
assert!(ServiceResponse::ok("{}").is_success());
|
||||
assert!(!ServiceResponse::error(404, "not found").is_success());
|
||||
assert!(!ServiceResponse::error(500, "error").is_success());
|
||||
}
|
||||
|
||||
// ── Test 18: WebSocketBinding supports streaming ─────────────────────────
|
||||
#[test]
|
||||
fn test_websocket_supports_streaming() {
|
||||
let config = ServiceConfig::new("ChatService", BindingKind::WebSocket)
|
||||
.with_base_url("wss://ws.example.com");
|
||||
let binding = WebSocketBinding::new(config);
|
||||
assert!(binding.supports_streaming());
|
||||
}
|
||||
|
||||
// ── Test 19: RestBinding::build_request_description includes method ───────
|
||||
#[test]
|
||||
fn test_rest_request_description() {
|
||||
let config = rest_config("https://api.example.com");
|
||||
let binding = RestBinding::new(config);
|
||||
let request = ServiceRequest::new("UserService", "create_user")
|
||||
.with_param("name", "Alice");
|
||||
let desc = binding.build_request_description(&request);
|
||||
assert!(desc.contains("POST"), "create_ maps to POST");
|
||||
assert!(desc.contains("users"), "resource name derived from service");
|
||||
}
|
||||
|
||||
// ── Test 20: ServiceRegistry lists all service names ─────────────────────
|
||||
#[test]
|
||||
fn test_service_registry_list() {
|
||||
let registry = ServiceRegistry::new();
|
||||
for name in ["UserService", "OrderService", "ProductService"] {
|
||||
let config = ServiceConfig::new(name, BindingKind::Direct);
|
||||
let proxy = ServiceProxy::new(name, config, Arc::new(DirectBinding::new()));
|
||||
registry.register(proxy);
|
||||
}
|
||||
let mut names = registry.service_names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["OrderService", "ProductService", "UserService"]);
|
||||
assert_eq!(registry.len(), 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user