Remove all .rs bootstrap files from el-ui vessels
El SDK CI - dev / build-and-test (pull_request) Successful in 3m33s

el-ui vessels are El. The Rust bootstrap implementations were added as
a stopgap but don't belong here — everything should be El source.
Each vessel's src/main.el and manifest.el are the source of truth.
This commit is contained in:
2026-05-06 23:12:25 -05:00
parent e8b01583d8
commit 5d9299a472
98 changed files with 0 additions and 17426 deletions
@@ -1,84 +0,0 @@
//! 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))
}
}
@@ -1,97 +0,0 @@
//! 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
}
}
-114
View File
@@ -1,114 +0,0 @@
//! 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
}
}
-147
View File
@@ -1,147 +0,0 @@
//! 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
)))
}
}
@@ -1,85 +0,0 @@
//! 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()
}
-108
View File
@@ -1,108 +0,0 @@
//! 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
}
}
-51
View File
@@ -1,51 +0,0 @@
//! 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>;
-113
View File
@@ -1,113 +0,0 @@
//! 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()
}
}
-59
View File
@@ -1,59 +0,0 @@
//! 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
}
}
-261
View File
@@ -1,261 +0,0 @@
//! 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", &params);
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);
}
}