Archived
el-ui v2: universal platform, service bindings, AOP, auth, publish pipeline
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user