Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
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 `manifest.el`.
|
||||
|
||||
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 `manifest.el`.
|
||||
|
||||
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 `manifest.el`.
|
||||
//!
|
||||
//! ```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