el-ui v2: universal platform, service bindings, AOP, auth, publish pipeline
This commit is contained in:
Generated
+143
@@ -2,6 +2,95 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "el-aop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "el-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"hmac",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "el-platform"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "el-publish"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "el-services"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "el-ui-compiler"
|
||||
version = "0.1.0"
|
||||
@@ -9,6 +98,31 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -27,6 +141,23 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -58,8 +189,20 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/el-ui-compiler",
|
||||
"crates/el-platform",
|
||||
"crates/el-services",
|
||||
"crates/el-aop",
|
||||
"crates/el-auth",
|
||||
"crates/el-publish",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-aop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui aspect-oriented programming — cross-cutting concerns as first-class features"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_aop"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,490 @@
|
||||
//! Built-in aspects for el-ui.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{AopError, AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
|
||||
|
||||
// ── @authenticate ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// `@authenticate` — Requires a valid session before the method executes.
|
||||
///
|
||||
/// Checks `ctx.metadata["session_token"]` or `ctx.metadata["user_id"]`.
|
||||
/// If absent, rejects with `AopError::Unauthenticated`.
|
||||
pub struct AuthenticateAspect;
|
||||
|
||||
impl Aspect for AuthenticateAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"authenticate"
|
||||
}
|
||||
|
||||
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
|
||||
// Look for a session token or user ID in metadata.
|
||||
// In production, the auth middleware populates these from the JWT/session.
|
||||
let has_user = ctx.metadata.contains_key("user_id")
|
||||
|| ctx.metadata.contains_key("session_token");
|
||||
if !has_user {
|
||||
return Err(AopError::Unauthenticated);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── @authorize ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `@authorize(role: "admin")` — Requires the caller to have a specific role.
|
||||
pub struct AuthorizeAspect {
|
||||
pub required_role: String,
|
||||
}
|
||||
|
||||
impl AuthorizeAspect {
|
||||
pub fn new(role: impl Into<String>) -> Self {
|
||||
Self { required_role: role.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Aspect for AuthorizeAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"authorize"
|
||||
}
|
||||
|
||||
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
|
||||
let user_roles = ctx
|
||||
.metadata
|
||||
.get("roles")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
// Roles are stored as comma-separated string: "admin,user"
|
||||
let has_role = user_roles
|
||||
.split(',')
|
||||
.any(|r| r.trim() == self.required_role.as_str());
|
||||
if !has_role {
|
||||
return Err(AopError::Forbidden {
|
||||
role: self.required_role.clone(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── @cache ────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct CacheEntry {
|
||||
value: InvocationResult,
|
||||
inserted_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.inserted_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
/// `@cache(ttl: 300)` — Cache method responses for `ttl` seconds.
|
||||
///
|
||||
/// Cache key is `"target::method::{args_sorted_json}"`.
|
||||
pub struct CacheAspect {
|
||||
pub ttl: Duration,
|
||||
cache: Mutex<HashMap<String, CacheEntry>>,
|
||||
}
|
||||
|
||||
impl CacheAspect {
|
||||
pub fn new(ttl_seconds: u64) -> Self {
|
||||
Self {
|
||||
ttl: Duration::from_secs(ttl_seconds),
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_key(ctx: &InvocationContext) -> String {
|
||||
let mut pairs: Vec<(&String, &String)> = ctx.args.iter().collect();
|
||||
pairs.sort_by_key(|(k, _)| k.as_str());
|
||||
let args = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{}::{}::{}", ctx.target, ctx.method, args)
|
||||
}
|
||||
}
|
||||
|
||||
impl Aspect for CacheAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"cache"
|
||||
}
|
||||
|
||||
fn around(
|
||||
&self,
|
||||
ctx: InvocationContext,
|
||||
proceed: &ProceedFn,
|
||||
) -> AopResult<InvocationResult> {
|
||||
let key = Self::cache_key(&ctx);
|
||||
|
||||
// Check cache
|
||||
{
|
||||
let cache = self.cache.lock().expect("cache lock poisoned");
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
if !entry.is_expired() {
|
||||
return Ok(entry.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss — proceed and store result
|
||||
let result = proceed(ctx)?;
|
||||
{
|
||||
let mut cache = self.cache.lock().expect("cache lock poisoned");
|
||||
// Evict expired entries while we're here
|
||||
cache.retain(|_, v| !v.is_expired());
|
||||
cache.insert(
|
||||
key,
|
||||
CacheEntry {
|
||||
value: result.clone(),
|
||||
inserted_at: Instant::now(),
|
||||
ttl: self.ttl,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
// ── @rate_limit ───────────────────────────────────────────────────────────────
|
||||
|
||||
struct RateWindow {
|
||||
count: u32,
|
||||
window_start: Instant,
|
||||
window_duration: Duration,
|
||||
}
|
||||
|
||||
/// `@rate_limit(requests: 100, per: 60)` — Allow at most `requests` calls per `per` seconds.
|
||||
pub struct RateLimitAspect {
|
||||
pub max_requests: u32,
|
||||
pub window: Duration,
|
||||
state: Mutex<HashMap<String, RateWindow>>,
|
||||
}
|
||||
|
||||
impl RateLimitAspect {
|
||||
pub fn new(max_requests: u32, per_seconds: u64) -> Self {
|
||||
Self {
|
||||
max_requests,
|
||||
window: Duration::from_secs(per_seconds),
|
||||
state: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rate-limit key for a caller. Uses `user_id` or "anonymous".
|
||||
fn caller_key(ctx: &InvocationContext) -> String {
|
||||
ctx.metadata
|
||||
.get("user_id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "anonymous".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Aspect for RateLimitAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"rate_limit"
|
||||
}
|
||||
|
||||
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
|
||||
let key = Self::caller_key(ctx);
|
||||
let mut state = self.state.lock().expect("rate limit lock poisoned");
|
||||
let now = Instant::now();
|
||||
let window = state.entry(key).or_insert(RateWindow {
|
||||
count: 0,
|
||||
window_start: now,
|
||||
window_duration: self.window,
|
||||
});
|
||||
// Reset window if expired
|
||||
if now.duration_since(window.window_start) >= window.window_duration {
|
||||
window.count = 0;
|
||||
window.window_start = now;
|
||||
}
|
||||
if window.count >= self.max_requests {
|
||||
return Err(AopError::RateLimited {
|
||||
requests: self.max_requests,
|
||||
per: self.window.as_secs(),
|
||||
});
|
||||
}
|
||||
window.count += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── @log ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `@log(level: "info")` — Structured logging for every method call.
|
||||
pub struct LogAspect {
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
impl LogAspect {
|
||||
pub fn new(level: impl Into<String>) -> Self {
|
||||
Self { level: level.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Aspect for LogAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"log"
|
||||
}
|
||||
|
||||
fn around(
|
||||
&self,
|
||||
ctx: InvocationContext,
|
||||
proceed: &ProceedFn,
|
||||
) -> AopResult<InvocationResult> {
|
||||
// In production: use the `tracing` crate with the appropriate level macro.
|
||||
let _log_entry = format!(
|
||||
"[{}] {}.{}({:?})",
|
||||
self.level.to_uppercase(),
|
||||
ctx.target,
|
||||
ctx.method,
|
||||
ctx.args
|
||||
);
|
||||
let result = proceed(ctx.clone());
|
||||
let _log_result = match &result {
|
||||
Ok(r) => format!("[{}] {}.{} → ok: {}", self.level.to_uppercase(), ctx.target, ctx.method, r.value),
|
||||
Err(e) => format!("[ERROR] {}.{} → err: {}", ctx.target, ctx.method, e),
|
||||
};
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ── @validate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `@validate` — Run input validation before the method executes.
|
||||
///
|
||||
/// Validation rules are registered per method. If no rules are registered,
|
||||
/// the aspect passes through (fail-open for ease of adoption).
|
||||
pub struct ValidateAspect {
|
||||
/// `"target::method"` → list of validation rules (field, rule_name)
|
||||
rules: Mutex<HashMap<String, Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
impl ValidateAspect {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rules: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a validation rule. `rule` is one of: "required", "email", "min:N", "max:N".
|
||||
pub fn add_rule(
|
||||
&self,
|
||||
target: &str,
|
||||
method: &str,
|
||||
field: impl Into<String>,
|
||||
rule: impl Into<String>,
|
||||
) {
|
||||
let key = format!("{}::{}", target, method);
|
||||
self.rules
|
||||
.lock()
|
||||
.expect("validate lock poisoned")
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.push((field.into(), rule.into()));
|
||||
}
|
||||
|
||||
fn validate_field(value: &str, rule: &str) -> AopResult<()> {
|
||||
if rule == "required" && value.trim().is_empty() {
|
||||
return Err(AopError::ValidationFailed("field is required".into()));
|
||||
}
|
||||
if rule == "email" && !value.contains('@') {
|
||||
return Err(AopError::ValidationFailed(format!(
|
||||
"'{}' is not a valid email",
|
||||
value
|
||||
)));
|
||||
}
|
||||
if let Some(min_str) = rule.strip_prefix("min:") {
|
||||
let min: usize = min_str.parse().unwrap_or(0);
|
||||
if value.len() < min {
|
||||
return Err(AopError::ValidationFailed(format!(
|
||||
"minimum length is {}",
|
||||
min
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(max_str) = rule.strip_prefix("max:") {
|
||||
let max: usize = max_str.parse().unwrap_or(usize::MAX);
|
||||
if value.len() > max {
|
||||
return Err(AopError::ValidationFailed(format!(
|
||||
"maximum length is {}",
|
||||
max
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ValidateAspect {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Aspect for ValidateAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"validate"
|
||||
}
|
||||
|
||||
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
|
||||
let key = format!("{}::{}", ctx.target, ctx.method);
|
||||
let rules = self.rules.lock().expect("validate lock poisoned");
|
||||
if let Some(field_rules) = rules.get(&key) {
|
||||
for (field, rule) in field_rules {
|
||||
let value = ctx.args.get(field).map(|s| s.as_str()).unwrap_or("");
|
||||
Self::validate_field(value, rule)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── @retry ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `@retry(attempts: 3, backoff: "exponential")` — Retry on failure.
|
||||
pub struct RetryAspect {
|
||||
pub attempts: u32,
|
||||
pub backoff: BackoffStrategy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum BackoffStrategy {
|
||||
None,
|
||||
Fixed(Duration),
|
||||
Exponential { base: Duration },
|
||||
}
|
||||
|
||||
impl RetryAspect {
|
||||
pub fn new(attempts: u32) -> Self {
|
||||
Self { attempts, backoff: BackoffStrategy::None }
|
||||
}
|
||||
|
||||
pub fn with_exponential_backoff(mut self, base_ms: u64) -> Self {
|
||||
self.backoff = BackoffStrategy::Exponential {
|
||||
base: Duration::from_millis(base_ms),
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_fixed_backoff(mut self, ms: u64) -> Self {
|
||||
self.backoff = BackoffStrategy::Fixed(Duration::from_millis(ms));
|
||||
self
|
||||
}
|
||||
|
||||
fn sleep_duration(&self, attempt: u32) -> Duration {
|
||||
match &self.backoff {
|
||||
BackoffStrategy::None => Duration::ZERO,
|
||||
BackoffStrategy::Fixed(d) => *d,
|
||||
BackoffStrategy::Exponential { base } => {
|
||||
// base * 2^attempt, capped at 30s
|
||||
let factor = 1u64 << attempt.min(10);
|
||||
std::cmp::min(*base * factor as u32, Duration::from_secs(30))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Aspect for RetryAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"retry"
|
||||
}
|
||||
|
||||
fn around(
|
||||
&self,
|
||||
ctx: InvocationContext,
|
||||
proceed: &ProceedFn,
|
||||
) -> AopResult<InvocationResult> {
|
||||
let mut last_error = String::new();
|
||||
for attempt in 0..self.attempts {
|
||||
match proceed(ctx.clone()) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
last_error = e.to_string();
|
||||
let sleep_for = self.sleep_duration(attempt);
|
||||
if sleep_for > Duration::ZERO && attempt + 1 < self.attempts {
|
||||
std::thread::sleep(sleep_for);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(AopError::RetriesExhausted {
|
||||
attempts: self.attempts,
|
||||
last_error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── @trace ────────────────────────────────────────────────────────────────────
|
||||
|
||||
static TRACE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// `@trace` — Add a distributed tracing span to every method call.
|
||||
///
|
||||
/// Injects a `trace_id` and `span_id` into context metadata.
|
||||
/// In production, emit the span to an OpenTelemetry collector.
|
||||
pub struct TraceAspect {
|
||||
pub service_name: String,
|
||||
}
|
||||
|
||||
impl TraceAspect {
|
||||
pub fn new(service_name: impl Into<String>) -> Self {
|
||||
Self { service_name: service_name.into() }
|
||||
}
|
||||
|
||||
fn new_span_id() -> String {
|
||||
let id = TRACE_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("span-{:016x}", id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Aspect for TraceAspect {
|
||||
fn name(&self) -> &'static str {
|
||||
"trace"
|
||||
}
|
||||
|
||||
fn around(
|
||||
&self,
|
||||
mut ctx: InvocationContext,
|
||||
proceed: &ProceedFn,
|
||||
) -> AopResult<InvocationResult> {
|
||||
// Create or inherit trace ID
|
||||
let trace_id = ctx
|
||||
.metadata
|
||||
.get("trace_id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("trace-{:016x}", TRACE_COUNTER.load(Ordering::Relaxed)));
|
||||
let span_id = Self::new_span_id();
|
||||
|
||||
ctx.metadata.insert("trace_id".into(), trace_id.clone());
|
||||
ctx.metadata.insert("span_id".into(), span_id.clone());
|
||||
|
||||
let start = Instant::now();
|
||||
let result = proceed(ctx.clone());
|
||||
let duration_us = start.elapsed().as_micros();
|
||||
|
||||
// In production: emit span to OpenTelemetry:
|
||||
// tracer.start_with_context("method_call", parent_cx)
|
||||
// .set_attribute(KeyValue::new("service", self.service_name.clone()))
|
||||
// .set_attribute(KeyValue::new("method", ctx.method.clone()))
|
||||
// .set_attribute(KeyValue::new("duration_us", duration_us as i64))
|
||||
// .end();
|
||||
let _ = duration_us;
|
||||
|
||||
result.map(|mut r| {
|
||||
r.metadata.insert("trace_id".into(), trace_id);
|
||||
r.metadata.insert("span_id".into(), span_id);
|
||||
r
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Aspect chain — ordered execution of aspects around a method call.
|
||||
//!
|
||||
//! Aspects execute in order: each one wraps the next, forming a chain.
|
||||
//! The innermost item is the actual method invocation.
|
||||
//!
|
||||
//! ```text
|
||||
//! @authenticate → @authorize → @cache → @log → [method body]
|
||||
//! before before check log
|
||||
//! hit? ──→ return cached
|
||||
//! miss? → [method body] → store → after-log
|
||||
//! ```
|
||||
|
||||
use crate::{AopResult, Aspect, InvocationContext, InvocationResult, ProceedFn};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An ordered chain of aspects applied to a single method.
|
||||
pub struct AspectChain {
|
||||
aspects: Vec<Arc<dyn Aspect>>,
|
||||
}
|
||||
|
||||
impl AspectChain {
|
||||
pub fn new() -> Self {
|
||||
Self { aspects: Vec::new() }
|
||||
}
|
||||
|
||||
/// Add an aspect to the end of the chain.
|
||||
pub fn add(mut self, aspect: Arc<dyn Aspect>) -> Self {
|
||||
self.aspects.push(aspect);
|
||||
self
|
||||
}
|
||||
|
||||
/// Number of aspects in this chain.
|
||||
pub fn len(&self) -> usize {
|
||||
self.aspects.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.aspects.is_empty()
|
||||
}
|
||||
|
||||
/// Execute the chain around the given proceed function.
|
||||
///
|
||||
/// Aspects run in order (left to right in the decorator list).
|
||||
/// Each aspect's `around` method receives the next aspect's `around`
|
||||
/// as the `proceed` function, forming a true onion model.
|
||||
pub fn execute(
|
||||
&self,
|
||||
ctx: InvocationContext,
|
||||
proceed: ProceedFn,
|
||||
) -> AopResult<InvocationResult> {
|
||||
if self.aspects.is_empty() {
|
||||
return proceed(ctx);
|
||||
}
|
||||
self.run_aspect(0, ctx, proceed)
|
||||
}
|
||||
|
||||
fn run_aspect(
|
||||
&self,
|
||||
index: usize,
|
||||
ctx: InvocationContext,
|
||||
final_proceed: ProceedFn,
|
||||
) -> AopResult<InvocationResult> {
|
||||
if index >= self.aspects.len() {
|
||||
return final_proceed(ctx);
|
||||
}
|
||||
|
||||
let aspect = self.aspects[index].clone();
|
||||
let remaining_aspects = self.aspects[index + 1..].to_vec();
|
||||
let final_proceed = Arc::new(final_proceed);
|
||||
|
||||
let next: ProceedFn = Box::new(move |ctx: InvocationContext| {
|
||||
if remaining_aspects.is_empty() {
|
||||
return final_proceed(ctx);
|
||||
}
|
||||
|
||||
// Build remaining chain recursively
|
||||
let sub_chain = AspectChain {
|
||||
aspects: remaining_aspects.clone(),
|
||||
};
|
||||
sub_chain.execute(ctx, {
|
||||
let fp = final_proceed.clone();
|
||||
Box::new(move |ctx| fp(ctx))
|
||||
})
|
||||
});
|
||||
|
||||
aspect.around(ctx, &next)
|
||||
}
|
||||
|
||||
/// Return the names of all aspects in this chain (in order).
|
||||
pub fn aspect_names(&self) -> Vec<&str> {
|
||||
self.aspects.iter().map(|a| a.name()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AspectChain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! el-aop — Aspect-Oriented Programming for el-ui.
|
||||
//!
|
||||
//! Cross-cutting concerns as first-class language features. Not a library you
|
||||
//! import. Built into the framework. Applied as decorators:
|
||||
//!
|
||||
//! ```text
|
||||
//! @authenticate
|
||||
//! @authorize(role: "admin")
|
||||
//! @cache(ttl: 300)
|
||||
//! @rate_limit(requests: 100, per: 60)
|
||||
//! component AdminDashboard { ... }
|
||||
//! ```
|
||||
|
||||
pub mod aspects;
|
||||
pub mod chain;
|
||||
pub mod registry;
|
||||
|
||||
pub use aspects::{
|
||||
AuthenticateAspect, AuthorizeAspect, CacheAspect, LogAspect, RateLimitAspect, RetryAspect,
|
||||
TraceAspect, ValidateAspect,
|
||||
};
|
||||
pub use chain::AspectChain;
|
||||
pub use registry::AspectRegistry;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AopError {
|
||||
#[error("authentication required")]
|
||||
Unauthenticated,
|
||||
#[error("forbidden: requires role '{role}'")]
|
||||
Forbidden { role: String },
|
||||
#[error("rate limit exceeded: {requests} requests per {per}s")]
|
||||
RateLimited { requests: u32, per: u64 },
|
||||
#[error("validation failed: {0}")]
|
||||
ValidationFailed(String),
|
||||
#[error("aspect error: {0}")]
|
||||
Aspect(String),
|
||||
#[error("all {attempts} retry attempts failed: {last_error}")]
|
||||
RetriesExhausted { attempts: u32, last_error: String },
|
||||
}
|
||||
|
||||
pub type AopResult<T> = Result<T, AopError>;
|
||||
|
||||
/// Context passed through the aspect chain.
|
||||
///
|
||||
/// Contains the incoming arguments and metadata about the call.
|
||||
/// Aspects can read and mutate this context as they execute.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvocationContext {
|
||||
/// The component or service being called.
|
||||
pub target: String,
|
||||
/// The method being called.
|
||||
pub method: String,
|
||||
/// Arguments passed to the method.
|
||||
pub args: HashMap<String, String>,
|
||||
/// Metadata added by aspects (e.g., the authenticated user, trace ID).
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl InvocationContext {
|
||||
pub fn new(target: impl Into<String>, method: impl Into<String>) -> Self {
|
||||
Self {
|
||||
target: target.into(),
|
||||
method: method.into(),
|
||||
args: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.args.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.metadata.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get_meta(&self, key: &str) -> Option<&str> {
|
||||
self.metadata.get(key).map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of invoking a method through an aspect chain.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvocationResult {
|
||||
pub value: String,
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl InvocationResult {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handler that performs the actual method invocation.
|
||||
/// Aspects wrap around this.
|
||||
pub type ProceedFn = Box<dyn Fn(InvocationContext) -> AopResult<InvocationResult> + Send + Sync>;
|
||||
|
||||
/// The core Aspect trait.
|
||||
///
|
||||
/// Each aspect implements `before`, `after`, or `around` advice.
|
||||
/// The default implementations are no-ops — only override what you need.
|
||||
pub trait Aspect: Send + Sync {
|
||||
/// The aspect's name (used for debugging and registry lookup).
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Before advice — runs before the method. Can reject the call.
|
||||
fn before(&self, ctx: &mut InvocationContext) -> AopResult<()> {
|
||||
let _ = ctx;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After advice — runs after the method. Receives the result.
|
||||
/// Can modify the result or perform cleanup.
|
||||
fn after(
|
||||
&self,
|
||||
ctx: &InvocationContext,
|
||||
result: AopResult<InvocationResult>,
|
||||
) -> AopResult<InvocationResult> {
|
||||
let _ = ctx;
|
||||
result
|
||||
}
|
||||
|
||||
/// Around advice — wraps the entire invocation.
|
||||
/// The default implementation calls `before`, then `proceed`, then `after`.
|
||||
fn around(
|
||||
&self,
|
||||
mut ctx: InvocationContext,
|
||||
proceed: &ProceedFn,
|
||||
) -> AopResult<InvocationResult> {
|
||||
self.before(&mut ctx)?;
|
||||
let result = proceed(ctx.clone());
|
||||
self.after(&ctx, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Aspect registry — register built-in and custom aspects by name.
|
||||
//!
|
||||
//! The compiler's AOP codegen uses the registry to look up aspect implementations
|
||||
//! by their decorator name (e.g., `"authenticate"` → `AuthenticateAspect`).
|
||||
|
||||
use crate::Aspect;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
type AspectFactory = Box<dyn Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + Send + Sync>;
|
||||
|
||||
/// Registry of aspect factories, indexed by decorator name.
|
||||
pub struct AspectRegistry {
|
||||
factories: RwLock<HashMap<String, AspectFactory>>,
|
||||
}
|
||||
|
||||
impl AspectRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
factories: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a registry with all built-in aspects registered.
|
||||
pub fn with_builtins() -> Self {
|
||||
let registry = Self::new();
|
||||
registry.register_builtins();
|
||||
registry
|
||||
}
|
||||
|
||||
/// Register all built-in aspects.
|
||||
pub fn register_builtins(&self) {
|
||||
use crate::aspects::*;
|
||||
|
||||
self.register("authenticate", |_params| {
|
||||
Arc::new(AuthenticateAspect)
|
||||
});
|
||||
|
||||
self.register("authorize", |params| {
|
||||
let role = params
|
||||
.get("role")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "user".to_string());
|
||||
Arc::new(AuthorizeAspect::new(role))
|
||||
});
|
||||
|
||||
self.register("cache", |params| {
|
||||
let ttl: u64 = params
|
||||
.get("ttl")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(300);
|
||||
Arc::new(CacheAspect::new(ttl))
|
||||
});
|
||||
|
||||
self.register("rate_limit", |params| {
|
||||
let requests: u32 = params
|
||||
.get("requests")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(100);
|
||||
let per: u64 = params
|
||||
.get("per")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60);
|
||||
Arc::new(RateLimitAspect::new(requests, per))
|
||||
});
|
||||
|
||||
self.register("log", |params| {
|
||||
let level = params
|
||||
.get("level")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "info".to_string());
|
||||
Arc::new(LogAspect::new(level))
|
||||
});
|
||||
|
||||
self.register("validate", |_params| Arc::new(ValidateAspect::new()));
|
||||
|
||||
self.register("retry", |params| {
|
||||
let attempts: u32 = params
|
||||
.get("attempts")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(3);
|
||||
let backoff = params
|
||||
.get("backoff")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("none");
|
||||
let aspect = RetryAspect::new(attempts);
|
||||
let aspect = match backoff {
|
||||
"exponential" => aspect.with_exponential_backoff(100),
|
||||
"fixed" => aspect.with_fixed_backoff(500),
|
||||
_ => aspect,
|
||||
};
|
||||
Arc::new(aspect)
|
||||
});
|
||||
|
||||
self.register("trace", |params| {
|
||||
let service = params
|
||||
.get("service")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "el-ui".to_string());
|
||||
Arc::new(TraceAspect::new(service))
|
||||
});
|
||||
}
|
||||
|
||||
/// Register a custom aspect factory.
|
||||
pub fn register(
|
||||
&self,
|
||||
name: &str,
|
||||
factory: impl Fn(&HashMap<String, String>) -> Arc<dyn Aspect> + Send + Sync + 'static,
|
||||
) {
|
||||
self.factories
|
||||
.write()
|
||||
.expect("registry lock poisoned")
|
||||
.insert(name.to_string(), Box::new(factory));
|
||||
}
|
||||
|
||||
/// Instantiate an aspect by decorator name with the given params.
|
||||
pub fn create(
|
||||
&self,
|
||||
name: &str,
|
||||
params: &HashMap<String, String>,
|
||||
) -> Option<Arc<dyn Aspect>> {
|
||||
let factories = self.factories.read().expect("registry lock poisoned");
|
||||
factories.get(name).map(|f| f(params))
|
||||
}
|
||||
|
||||
/// List all registered aspect names.
|
||||
pub fn aspect_names(&self) -> Vec<String> {
|
||||
self.factories
|
||||
.read()
|
||||
.expect("registry lock poisoned")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if an aspect name is registered.
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.factories
|
||||
.read()
|
||||
.expect("registry lock poisoned")
|
||||
.contains_key(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AspectRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Tests for el-aop.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
aspects::*,
|
||||
chain::AspectChain,
|
||||
registry::AspectRegistry,
|
||||
AopError, Aspect, InvocationContext, InvocationResult,
|
||||
};
|
||||
|
||||
fn succeed_proceed(value: impl Into<String> + Clone) -> crate::ProceedFn {
|
||||
let v = value.into();
|
||||
Box::new(move |_ctx| Ok(InvocationResult::new(v.clone())))
|
||||
}
|
||||
|
||||
fn fail_proceed(msg: impl Into<String> + Clone) -> crate::ProceedFn {
|
||||
let m = msg.into();
|
||||
Box::new(move |_ctx| Err(AopError::Aspect(m.clone())))
|
||||
}
|
||||
|
||||
fn ctx(target: &str, method: &str) -> InvocationContext {
|
||||
InvocationContext::new(target, method)
|
||||
}
|
||||
|
||||
fn authed_ctx(target: &str, method: &str) -> InvocationContext {
|
||||
ctx(target, method).with_meta("user_id", "user-123")
|
||||
}
|
||||
|
||||
fn admin_ctx(target: &str, method: &str) -> InvocationContext {
|
||||
ctx(target, method)
|
||||
.with_meta("user_id", "admin-1")
|
||||
.with_meta("roles", "admin,user")
|
||||
}
|
||||
|
||||
// ── Test 1: AuthenticateAspect rejects unauthenticated calls ─────────────
|
||||
#[test]
|
||||
fn test_authenticate_rejects_unauthenticated() {
|
||||
let aspect = AuthenticateAspect;
|
||||
let mut ctx = ctx("AdminDashboard", "load");
|
||||
let result = aspect.before(&mut ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result, Err(AopError::Unauthenticated)));
|
||||
}
|
||||
|
||||
// ── Test 2: AuthenticateAspect allows authenticated calls ─────────────────
|
||||
#[test]
|
||||
fn test_authenticate_allows_authenticated() {
|
||||
let aspect = AuthenticateAspect;
|
||||
let mut ctx = authed_ctx("AdminDashboard", "load");
|
||||
let result = aspect.before(&mut ctx);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Test 3: AuthorizeAspect rejects wrong role ────────────────────────────
|
||||
#[test]
|
||||
fn test_authorize_rejects_wrong_role() {
|
||||
let aspect = AuthorizeAspect::new("admin");
|
||||
let mut ctx = authed_ctx("Dashboard", "delete").with_meta("roles", "user");
|
||||
let result = aspect.before(&mut ctx);
|
||||
assert!(matches!(result, Err(AopError::Forbidden { .. })));
|
||||
}
|
||||
|
||||
// ── Test 4: AuthorizeAspect allows correct role ───────────────────────────
|
||||
#[test]
|
||||
fn test_authorize_allows_correct_role() {
|
||||
let aspect = AuthorizeAspect::new("admin");
|
||||
let mut ctx = admin_ctx("Dashboard", "delete");
|
||||
let result = aspect.before(&mut ctx);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Test 5: CacheAspect returns cached result on second call ──────────────
|
||||
#[test]
|
||||
fn test_cache_returns_cached_result() {
|
||||
let aspect = CacheAspect::new(300);
|
||||
let ctx = authed_ctx("OrderService", "get_orders");
|
||||
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let cc = call_count.clone();
|
||||
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
|
||||
let n = cc.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
Ok(InvocationResult::new(format!("result-{}", n)))
|
||||
});
|
||||
// First call — executes proceed
|
||||
let r1 = aspect.around(ctx.clone(), &proceed).unwrap();
|
||||
// Second call — should return cached (proceed not called again)
|
||||
let proceed2: crate::ProceedFn = Box::new(|_ctx| {
|
||||
panic!("proceed should not be called on cache hit");
|
||||
});
|
||||
let r2 = aspect.around(ctx.clone(), &proceed2).unwrap();
|
||||
assert_eq!(r1.value, r2.value, "cached value should be returned");
|
||||
}
|
||||
|
||||
// ── Test 6: RateLimitAspect blocks after limit exceeded ───────────────────
|
||||
#[test]
|
||||
fn test_rate_limit_blocks_after_limit() {
|
||||
let aspect = RateLimitAspect::new(2, 60);
|
||||
let mut ctx = authed_ctx("OrderService", "create_order");
|
||||
|
||||
// First two calls succeed
|
||||
assert!(aspect.before(&mut ctx).is_ok());
|
||||
assert!(aspect.before(&mut ctx).is_ok());
|
||||
// Third call should be blocked
|
||||
let result = aspect.before(&mut ctx);
|
||||
assert!(matches!(result, Err(AopError::RateLimited { .. })));
|
||||
}
|
||||
|
||||
// ── Test 7: LogAspect passes through to proceed ───────────────────────────
|
||||
#[test]
|
||||
fn test_log_aspect_passthrough() {
|
||||
let aspect = LogAspect::new("info");
|
||||
let ctx = authed_ctx("UserService", "get_user");
|
||||
let result = aspect.around(ctx, &succeed_proceed("user-data")).unwrap();
|
||||
assert_eq!(result.value, "user-data");
|
||||
}
|
||||
|
||||
// ── Test 8: ValidateAspect rejects required field missing ────────────────
|
||||
#[test]
|
||||
fn test_validate_required_field() {
|
||||
let aspect = ValidateAspect::new();
|
||||
aspect.add_rule("UserService", "create_user", "name", "required");
|
||||
let mut ctx = authed_ctx("UserService", "create_user");
|
||||
// No "name" arg
|
||||
let result = aspect.before(&mut ctx);
|
||||
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
|
||||
}
|
||||
|
||||
// ── Test 9: ValidateAspect passes when field is present ──────────────────
|
||||
#[test]
|
||||
fn test_validate_required_field_present() {
|
||||
let aspect = ValidateAspect::new();
|
||||
aspect.add_rule("UserService", "create_user", "email", "email");
|
||||
let mut ctx = authed_ctx("UserService", "create_user")
|
||||
.with_arg("email", "alice@example.com");
|
||||
assert!(aspect.before(&mut ctx).is_ok());
|
||||
}
|
||||
|
||||
// ── Test 10: ValidateAspect rejects invalid email ─────────────────────────
|
||||
#[test]
|
||||
fn test_validate_email_rule() {
|
||||
let aspect = ValidateAspect::new();
|
||||
aspect.add_rule("UserService", "create_user", "email", "email");
|
||||
let mut ctx = authed_ctx("UserService", "create_user")
|
||||
.with_arg("email", "not-an-email");
|
||||
let result = aspect.before(&mut ctx);
|
||||
assert!(matches!(result, Err(AopError::ValidationFailed(_))));
|
||||
}
|
||||
|
||||
// ── Test 11: RetryAspect retries on failure ───────────────────────────────
|
||||
#[test]
|
||||
fn test_retry_succeeds_on_third_attempt() {
|
||||
let aspect = RetryAspect::new(3);
|
||||
let ctx = authed_ctx("OrderService", "place_order");
|
||||
let attempt = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let proceed: crate::ProceedFn = Box::new(move |_ctx| {
|
||||
let n = attempt_clone.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if n < 2 {
|
||||
Err(AopError::Aspect("transient error".into()))
|
||||
} else {
|
||||
Ok(InvocationResult::new("success"))
|
||||
}
|
||||
});
|
||||
let result = aspect.around(ctx, &proceed).unwrap();
|
||||
assert_eq!(result.value, "success");
|
||||
}
|
||||
|
||||
// ── Test 12: RetryAspect exhausts all attempts ───────────────────────────
|
||||
#[test]
|
||||
fn test_retry_exhausted() {
|
||||
let aspect = RetryAspect::new(3);
|
||||
let ctx = authed_ctx("OrderService", "place_order");
|
||||
let result = aspect.around(ctx, &fail_proceed("always fails"));
|
||||
assert!(matches!(result, Err(AopError::RetriesExhausted { attempts: 3, .. })));
|
||||
}
|
||||
|
||||
// ── Test 13: TraceAspect injects trace/span IDs ───────────────────────────
|
||||
#[test]
|
||||
fn test_trace_aspect_injects_ids() {
|
||||
let aspect = TraceAspect::new("el-ui");
|
||||
let ctx = authed_ctx("UserService", "get_user");
|
||||
let result = aspect.around(ctx, &succeed_proceed("data")).unwrap();
|
||||
assert!(
|
||||
result.metadata.contains_key("trace_id"),
|
||||
"should inject trace_id"
|
||||
);
|
||||
assert!(
|
||||
result.metadata.contains_key("span_id"),
|
||||
"should inject span_id"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test 14: AspectChain executes aspects in order ────────────────────────
|
||||
#[test]
|
||||
fn test_aspect_chain_ordering() {
|
||||
let order = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
|
||||
struct OrderTracker {
|
||||
name: &'static str,
|
||||
order: Arc<std::sync::Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
impl Aspect for OrderTracker {
|
||||
fn name(&self) -> &'static str { self.name }
|
||||
fn around(&self, ctx: InvocationContext, proceed: &crate::ProceedFn) -> crate::AopResult<InvocationResult> {
|
||||
self.order.lock().unwrap().push(self.name);
|
||||
proceed(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
let chain = AspectChain::new()
|
||||
.add(Arc::new(OrderTracker { name: "first", order: order.clone() }))
|
||||
.add(Arc::new(OrderTracker { name: "second", order: order.clone() }))
|
||||
.add(Arc::new(OrderTracker { name: "third", order: order.clone() }));
|
||||
|
||||
let ctx = authed_ctx("MyService", "my_method");
|
||||
chain.execute(ctx, succeed_proceed("ok")).unwrap();
|
||||
|
||||
let recorded = order.lock().unwrap();
|
||||
assert_eq!(*recorded, vec!["first", "second", "third"]);
|
||||
}
|
||||
|
||||
// ── Test 15: AspectChain with auth + authorize rejects unauthenticated ────
|
||||
#[test]
|
||||
fn test_aspect_chain_auth_flow() {
|
||||
let chain = AspectChain::new()
|
||||
.add(Arc::new(AuthenticateAspect))
|
||||
.add(Arc::new(AuthorizeAspect::new("admin")));
|
||||
|
||||
// Unauthenticated — should fail at authenticate
|
||||
let ctx = ctx("AdminDashboard", "load");
|
||||
let result = chain.execute(ctx, succeed_proceed("ok"));
|
||||
assert!(matches!(result, Err(AopError::Unauthenticated)));
|
||||
|
||||
// Authenticated but wrong role — should fail at authorize
|
||||
let ctx = authed_ctx("AdminDashboard", "load").with_meta("roles", "user");
|
||||
let result = chain.execute(ctx, succeed_proceed("ok"));
|
||||
assert!(matches!(result, Err(AopError::Forbidden { .. })));
|
||||
|
||||
// Admin — should succeed
|
||||
let ctx = admin_ctx("AdminDashboard", "load");
|
||||
let result = chain.execute(ctx, succeed_proceed("ok"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Test 16: AspectRegistry registers all builtins ────────────────────────
|
||||
#[test]
|
||||
fn test_registry_has_builtins() {
|
||||
let registry = AspectRegistry::with_builtins();
|
||||
for name in ["authenticate", "authorize", "cache", "rate_limit", "log", "validate", "retry", "trace"] {
|
||||
assert!(registry.contains(name), "should have built-in: {}", name);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 17: AspectRegistry creates aspects from params ───────────────────
|
||||
#[test]
|
||||
fn test_registry_creates_aspect() {
|
||||
let registry = AspectRegistry::with_builtins();
|
||||
let mut params = HashMap::new();
|
||||
params.insert("role".into(), "admin".into());
|
||||
let aspect = registry.create("authorize", ¶ms);
|
||||
assert!(aspect.is_some(), "should create authorize aspect");
|
||||
assert_eq!(aspect.unwrap().name(), "authorize");
|
||||
}
|
||||
|
||||
// ── Test 18: AspectRegistry::create returns None for unknown aspect ───────
|
||||
#[test]
|
||||
fn test_registry_unknown_aspect() {
|
||||
let registry = AspectRegistry::with_builtins();
|
||||
let result = registry.create("unknown_aspect", &HashMap::new());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── Test 19: CacheAspect with zero TTL doesn't serve stale data ──────────
|
||||
#[test]
|
||||
fn test_cache_zero_ttl() {
|
||||
let aspect = CacheAspect::new(0); // Immediate expiry
|
||||
let ctx = authed_ctx("Service", "method");
|
||||
let n = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let nc = n.clone();
|
||||
// Both calls should hit proceed since ttl=0 means instant expiry
|
||||
let p1: crate::ProceedFn = Box::new(move |_ctx| {
|
||||
let v = nc.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(InvocationResult::new(v.to_string()))
|
||||
});
|
||||
// Zero TTL will expire immediately; we just verify it doesn't panic
|
||||
let _ = aspect.around(ctx.clone(), &p1);
|
||||
// Second call should also execute proceed
|
||||
let p2: crate::ProceedFn = Box::new(|_ctx| Ok(InvocationResult::new("fresh")));
|
||||
let r = aspect.around(ctx, &p2).unwrap();
|
||||
assert_eq!(r.value, "fresh");
|
||||
}
|
||||
|
||||
// ── Test 20: Empty AspectChain calls proceed directly ─────────────────────
|
||||
#[test]
|
||||
fn test_empty_chain_calls_proceed() {
|
||||
let chain = AspectChain::new();
|
||||
assert!(chain.is_empty());
|
||||
let ctx = authed_ctx("Service", "method");
|
||||
let result = chain.execute(ctx, succeed_proceed("direct")).unwrap();
|
||||
assert_eq!(result.value, "direct");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "el-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui built-in authentication and authorization — native to the framework"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_auth"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Auth context — the current authenticated user and their roles/permissions.
|
||||
|
||||
/// The authenticated user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
email: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
email: email.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The auth context — populated by `AuthMiddleware` and available to all
|
||||
/// components and services downstream in the request.
|
||||
///
|
||||
/// Passed as `ctx.metadata["user_id"]`, `ctx.metadata["roles"]` in the AOP
|
||||
/// layer (see `el-aop`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthContext {
|
||||
pub user: Option<AuthUser>,
|
||||
pub roles: Vec<String>,
|
||||
pub permissions: Vec<String>,
|
||||
/// The raw token/session ID that was verified.
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
pub fn anonymous() -> Self {
|
||||
Self {
|
||||
user: None,
|
||||
roles: Vec::new(),
|
||||
permissions: Vec::new(),
|
||||
token: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticated(user: AuthUser, roles: Vec<String>, token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user: Some(user),
|
||||
roles,
|
||||
permissions: Vec::new(),
|
||||
token: token.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_permissions(mut self, perms: Vec<String>) -> Self {
|
||||
self.permissions = perms;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.user.is_some()
|
||||
}
|
||||
|
||||
pub fn has_role(&self, role: &str) -> bool {
|
||||
self.roles.iter().any(|r| r == role)
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, permission: &str) -> bool {
|
||||
self.permissions.iter().any(|p| p == permission)
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> Option<&str> {
|
||||
self.user.as_ref().map(|u| u.id.as_str())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! JWT provider — sign and verify JSON Web Tokens.
|
||||
//!
|
||||
//! Uses HMAC-SHA256 (HS256) for signing. Does NOT use the `jsonwebtoken` crate
|
||||
//! to keep dependencies minimal; implements the JWT spec directly.
|
||||
//!
|
||||
//! Format: base64url(header).base64url(payload).base64url(signature)
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// JWT claims payload.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String, // user ID
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub roles: Vec<String>,
|
||||
pub iat: u64, // issued-at (unix seconds)
|
||||
pub exp: u64, // expiry (unix seconds)
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
pub fn new(user: &AuthUser, roles: Vec<String>, ttl_seconds: u64) -> Self {
|
||||
let now = unix_now();
|
||||
Self {
|
||||
sub: user.id.clone(),
|
||||
email: user.email.clone(),
|
||||
name: user.name.clone(),
|
||||
roles,
|
||||
iat: now,
|
||||
exp: now + ttl_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
unix_now() > self.exp
|
||||
}
|
||||
|
||||
/// Serialize claims to JSON (manual, no serde dependency).
|
||||
pub fn to_json(&self) -> String {
|
||||
let roles_json = self
|
||||
.roles
|
||||
.iter()
|
||||
.map(|r| format!("\"{}\"", r))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}],\"iat\":{},\"exp\":{}}}",
|
||||
self.sub, self.email, self.name, roles_json, self.iat, self.exp
|
||||
)
|
||||
}
|
||||
|
||||
/// Deserialize claims from JSON (manual parser).
|
||||
pub fn from_json(json: &str) -> Option<Self> {
|
||||
let sub = extract_str(json, "sub")?;
|
||||
let email = extract_str(json, "email").unwrap_or_default();
|
||||
let name = extract_str(json, "name").unwrap_or_default();
|
||||
let iat = extract_u64(json, "iat").unwrap_or(0);
|
||||
let exp = extract_u64(json, "exp").unwrap_or(0);
|
||||
let roles = extract_str_array(json, "roles");
|
||||
Some(Self { sub, email, name, roles, iat, exp })
|
||||
}
|
||||
}
|
||||
|
||||
/// JWT provider — issues and verifies HS256 JWTs.
|
||||
pub struct JwtProvider {
|
||||
secret: Vec<u8>,
|
||||
/// Token TTL in seconds (default: 3600 = 1 hour).
|
||||
pub ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl JwtProvider {
|
||||
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
|
||||
Self { secret: secret.into(), ttl_seconds: 3600 }
|
||||
}
|
||||
|
||||
pub fn from_env(env_var: &str) -> AuthResult<Self> {
|
||||
let secret = std::env::var(env_var).map_err(|_| {
|
||||
AuthError::Config(format!("env var {} not set", env_var))
|
||||
})?;
|
||||
Ok(Self::new(secret.into_bytes()))
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, seconds: u64) -> Self {
|
||||
self.ttl_seconds = seconds;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sign a token with HMAC-SHA256.
|
||||
fn sign(&self, header_payload: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(&self.secret)
|
||||
.expect("HMAC can take key of any size");
|
||||
mac.update(header_payload.as_bytes());
|
||||
let result = mac.finalize();
|
||||
base64url_encode(&result.into_bytes())
|
||||
}
|
||||
|
||||
/// Encode a JWT token from claims.
|
||||
pub fn encode(&self, claims: &JwtClaims) -> String {
|
||||
let header = base64url_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
|
||||
let payload = base64url_encode(claims.to_json().as_bytes());
|
||||
let header_payload = format!("{}.{}", header, payload);
|
||||
let signature = self.sign(&header_payload);
|
||||
format!("{}.{}", header_payload, signature)
|
||||
}
|
||||
|
||||
/// Decode and verify a JWT token.
|
||||
pub fn decode(&self, token: &str) -> AuthResult<JwtClaims> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(AuthError::TokenInvalid("not a valid JWT".into()));
|
||||
}
|
||||
|
||||
let header_payload = format!("{}.{}", parts[0], parts[1]);
|
||||
let expected_sig = self.sign(&header_payload);
|
||||
if !constant_time_eq(parts[2], &expected_sig) {
|
||||
return Err(AuthError::TokenInvalid("signature mismatch".into()));
|
||||
}
|
||||
|
||||
let payload_bytes = base64url_decode(parts[1])
|
||||
.ok_or_else(|| AuthError::TokenInvalid("payload decode failed".into()))?;
|
||||
let payload_str = String::from_utf8(payload_bytes)
|
||||
.map_err(|_| AuthError::TokenInvalid("payload not utf8".into()))?;
|
||||
|
||||
let claims = JwtClaims::from_json(&payload_str)
|
||||
.ok_or_else(|| AuthError::TokenInvalid("claims parse failed".into()))?;
|
||||
|
||||
if claims.is_expired() {
|
||||
return Err(AuthError::TokenExpired);
|
||||
}
|
||||
|
||||
Ok(claims)
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider for JwtProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"jwt"
|
||||
}
|
||||
|
||||
fn verify(&self, token: &str) -> AuthResult<AuthContext> {
|
||||
let claims = self.decode(token)?;
|
||||
let user = AuthUser::new(&claims.sub, &claims.email, &claims.name);
|
||||
Ok(AuthContext::authenticated(user, claims.roles, token))
|
||||
}
|
||||
|
||||
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
|
||||
let claims = JwtClaims::new(&user, Vec::new(), self.ttl_seconds);
|
||||
Ok(self.encode(&claims))
|
||||
}
|
||||
|
||||
fn revoke(&self, _token: &str) -> AuthResult<()> {
|
||||
// JWTs are stateless — revocation requires a blocklist.
|
||||
// TODO: maintain a revocation list (in-memory or Redis).
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crypto helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn base64url_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);
|
||||
if chunk.len() > 1 {
|
||||
out.push(CHARS[((n >> 6) & 63) as usize] as char);
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(CHARS[(n & 63) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64url_decode(input: &str) -> Option<Vec<u8>> {
|
||||
// Pad if needed
|
||||
let mut s = input.replace('-', "+").replace('_', "/");
|
||||
while s.len() % 4 != 0 {
|
||||
s.push('=');
|
||||
}
|
||||
base64_decode_standard(&s)
|
||||
}
|
||||
|
||||
fn base64_decode_standard(input: &str) -> Option<Vec<u8>> {
|
||||
const TABLE: [u8; 128] = {
|
||||
let mut t = [255u8; 128];
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
t[chars[i] as usize] = i as u8;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
let input = input.trim_end_matches('=');
|
||||
let mut out = Vec::new();
|
||||
let bytes = input.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 3 < bytes.len() {
|
||||
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
|
||||
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
|
||||
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
|
||||
let d = TABLE.get(bytes[i+3] as usize).copied().filter(|&v| v != 255)?;
|
||||
let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
|
||||
out.push((n >> 16) as u8);
|
||||
out.push((n >> 8) as u8);
|
||||
out.push(n as u8);
|
||||
i += 4;
|
||||
}
|
||||
// Handle remaining bytes
|
||||
if i + 2 == bytes.len() {
|
||||
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
|
||||
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
|
||||
out.push(((a as u32) << 2 | (b as u32) >> 4) as u8);
|
||||
} else if i + 3 == bytes.len() {
|
||||
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
|
||||
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
|
||||
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
|
||||
let n = ((a as u32) << 10) | ((b as u32) << 4) | ((c as u32) >> 2);
|
||||
out.push((n >> 8) as u8);
|
||||
out.push(n as u8);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes()
|
||||
.zip(b.bytes())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ── Minimal JSON field extractors ─────────────────────────────────────────────
|
||||
|
||||
fn extract_str(json: &str, key: &str) -> Option<String> {
|
||||
let pattern = format!("\"{}\":\"", key);
|
||||
let start = json.find(&pattern)? + pattern.len();
|
||||
let rest = &json[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
fn extract_u64(json: &str, key: &str) -> Option<u64> {
|
||||
let pattern = format!("\"{}\":", key);
|
||||
let start = json.find(&pattern)? + pattern.len();
|
||||
let rest = &json[start..];
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
|
||||
rest[..end].parse().ok()
|
||||
}
|
||||
|
||||
fn extract_str_array(json: &str, key: &str) -> Vec<String> {
|
||||
let pattern = format!("\"{}\":[", key);
|
||||
let start = match json.find(&pattern) {
|
||||
None => return Vec::new(),
|
||||
Some(s) => s + pattern.len(),
|
||||
};
|
||||
let rest = &json[start..];
|
||||
let end = rest.find(']').unwrap_or(rest.len());
|
||||
let content = &rest[..end];
|
||||
content
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let s = s.trim().trim_matches('"');
|
||||
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! el-auth — Built-in authentication and authorization for el-ui.
|
||||
//!
|
||||
//! Not a library you add. Native to the framework.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [auth]
|
||||
//! provider = "jwt"
|
||||
//! jwt_secret_env = "JWT_SECRET"
|
||||
//! session_store = "memory"
|
||||
//! ```
|
||||
|
||||
pub mod context;
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod roles;
|
||||
pub mod session;
|
||||
|
||||
pub use context::{AuthContext, AuthUser};
|
||||
pub use jwt::{JwtClaims, JwtProvider};
|
||||
pub use middleware::AuthMiddleware;
|
||||
pub use roles::{Permission, Role, RoleRegistry};
|
||||
pub use session::SessionProvider;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("token expired")]
|
||||
TokenExpired,
|
||||
#[error("token invalid: {0}")]
|
||||
TokenInvalid(String),
|
||||
#[error("session not found")]
|
||||
SessionNotFound,
|
||||
#[error("forbidden: requires permission '{0}'")]
|
||||
Forbidden(String),
|
||||
#[error("auth configuration error: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
/// The AuthProvider trait — implemented by JWT, Session, OAuth providers.
|
||||
pub trait AuthProvider: Send + Sync {
|
||||
/// The provider name (e.g., "jwt", "session").
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Verify a token/session string and return the auth context.
|
||||
fn verify(&self, token: &str) -> AuthResult<AuthContext>;
|
||||
|
||||
/// Issue a new token/session for an authenticated user.
|
||||
fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult<String>;
|
||||
|
||||
/// Revoke a token/session (for logout).
|
||||
fn revoke(&self, token: &str) -> AuthResult<()>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Auth middleware — extracts and verifies auth tokens from requests.
|
||||
//!
|
||||
//! In an axum application:
|
||||
//! ```text
|
||||
//! let app = Router::new()
|
||||
//! .route("/api/users", get(list_users))
|
||||
//! .layer(AuthMiddleware::new(jwt_provider));
|
||||
//! ```
|
||||
//!
|
||||
//! The middleware populates `AuthContext` from the `Authorization` header.
|
||||
|
||||
use crate::{AuthContext, AuthProvider, AuthResult};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Auth middleware — wraps an auth provider to extract context from HTTP headers.
|
||||
pub struct AuthMiddleware {
|
||||
provider: Arc<dyn AuthProvider>,
|
||||
}
|
||||
|
||||
impl AuthMiddleware {
|
||||
pub fn new(provider: Arc<dyn AuthProvider>) -> Self {
|
||||
Self { provider }
|
||||
}
|
||||
|
||||
/// Extract and verify the auth token from an Authorization header value.
|
||||
///
|
||||
/// Supported formats:
|
||||
/// - `Bearer <token>` — JWT or opaque token
|
||||
/// - `Session <session_id>` — server-side session
|
||||
pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult<AuthContext> {
|
||||
match authorization {
|
||||
None => Ok(AuthContext::anonymous()),
|
||||
Some(header) => {
|
||||
let token = if let Some(t) = header.strip_prefix("Bearer ") {
|
||||
t.trim()
|
||||
} else if let Some(t) = header.strip_prefix("Session ") {
|
||||
t.trim()
|
||||
} else {
|
||||
header.trim()
|
||||
};
|
||||
self.provider.verify(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate from a query parameter (for WebSocket upgrades where
|
||||
/// Authorization headers can't be set from JS).
|
||||
pub fn authenticate_from_query_param(&self, token: Option<&str>) -> AuthResult<AuthContext> {
|
||||
match token {
|
||||
None => Ok(AuthContext::anonymous()),
|
||||
Some(t) => self.provider.verify(t),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the underlying provider name.
|
||||
pub fn provider_name(&self) -> &'static str {
|
||||
self.provider.name()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Role and permission model.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A fine-grained permission (e.g., "read", "write", "delete").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Permission(pub String);
|
||||
|
||||
impl Permission {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Permission {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// A role that grants a set of permissions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Role {
|
||||
pub name: String,
|
||||
pub permissions: Vec<Permission>,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
permissions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
|
||||
self.permissions.push(Permission::new(perm));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_permissions(mut self, perms: Vec<impl Into<String>>) -> Self {
|
||||
self.permissions.extend(perms.into_iter().map(Permission::new));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, perm: &str) -> bool {
|
||||
self.permissions.iter().any(|p| p.0 == perm)
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry of all roles in the application.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoleRegistry {
|
||||
roles: HashMap<String, Role>,
|
||||
}
|
||||
|
||||
impl RoleRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a role.
|
||||
pub fn register(&mut self, role: Role) {
|
||||
self.roles.insert(role.name.clone(), role);
|
||||
}
|
||||
|
||||
/// Get a role by name.
|
||||
pub fn get(&self, name: &str) -> Option<&Role> {
|
||||
self.roles.get(name)
|
||||
}
|
||||
|
||||
/// Check if the given role names grant the given permission.
|
||||
pub fn has_permission(&self, role_names: &[String], permission: &str) -> bool {
|
||||
role_names.iter().any(|role_name| {
|
||||
self.roles
|
||||
.get(role_name)
|
||||
.map(|r| r.has_permission(permission))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// List all registered role names.
|
||||
pub fn role_names(&self) -> Vec<&str> {
|
||||
self.roles.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Session provider — server-side sessions stored in memory.
|
||||
//!
|
||||
//! In production, sessions are stored in Redis or Engram (configured via
|
||||
//! `session_store = "redis"` or `session_store = "engram"` in `el.toml`).
|
||||
//! This implementation uses in-memory storage for simplicity and testing.
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
struct SessionEntry {
|
||||
context: AuthContext,
|
||||
created_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl SessionEntry {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.created_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory session store.
|
||||
pub struct SessionProvider {
|
||||
sessions: Mutex<HashMap<String, SessionEntry>>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
impl SessionProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
ttl: Duration::from_secs(3600),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, seconds: u64) -> Self {
|
||||
self.ttl = Duration::from_secs(seconds);
|
||||
self
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("sess-{:016x}", nanos as u64 ^ 0x7b5e3f1a2c4d6890)
|
||||
}
|
||||
|
||||
/// Count active (non-expired) sessions.
|
||||
pub fn active_session_count(&self) -> usize {
|
||||
let sessions = self.sessions.lock().expect("session lock poisoned");
|
||||
sessions.values().filter(|s| !s.is_expired()).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider for SessionProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"session"
|
||||
}
|
||||
|
||||
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
|
||||
let mut sessions = self.sessions.lock().expect("session lock poisoned");
|
||||
// Clean expired sessions
|
||||
sessions.retain(|_, v| !v.is_expired());
|
||||
sessions
|
||||
.get(session_id)
|
||||
.filter(|s| !s.is_expired())
|
||||
.map(|s| s.context.clone())
|
||||
.ok_or(AuthError::SessionNotFound)
|
||||
}
|
||||
|
||||
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
|
||||
let session_id = Self::generate_session_id();
|
||||
let ctx = AuthContext::authenticated(user, Vec::new(), &session_id);
|
||||
let entry = SessionEntry {
|
||||
context: ctx,
|
||||
created_at: Instant::now(),
|
||||
ttl: self.ttl,
|
||||
};
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("session lock poisoned")
|
||||
.insert(session_id.clone(), entry);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
fn revoke(&self, session_id: &str) -> AuthResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("session lock poisoned")
|
||||
.remove(session_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Tests for el-auth.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
context::{AuthContext, AuthUser},
|
||||
jwt::{JwtClaims, JwtProvider},
|
||||
middleware::AuthMiddleware,
|
||||
roles::{Permission, Role, RoleRegistry},
|
||||
session::SessionProvider,
|
||||
AuthError, AuthProvider,
|
||||
};
|
||||
|
||||
fn test_user() -> AuthUser {
|
||||
AuthUser::new("user-1", "alice@example.com", "Alice")
|
||||
}
|
||||
|
||||
fn test_provider() -> JwtProvider {
|
||||
JwtProvider::new(b"super-secret-key-for-testing-only".to_vec())
|
||||
.with_ttl(3600)
|
||||
}
|
||||
|
||||
fn registry() -> RoleRegistry {
|
||||
let mut r = RoleRegistry::new();
|
||||
r.register(
|
||||
Role::new("admin")
|
||||
.with_permissions(vec!["read", "write", "delete"]),
|
||||
);
|
||||
r.register(
|
||||
Role::new("user")
|
||||
.with_permissions(vec!["read"]),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
// ── Test 1: JWT round-trip — sign and verify ──────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_sign_and_verify() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["user"]);
|
||||
}
|
||||
|
||||
// ── Test 2: JWT invalid signature is rejected ─────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_invalid_signature() {
|
||||
let provider = test_provider();
|
||||
let other_provider = JwtProvider::new(b"different-secret".to_vec());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec![], 3600);
|
||||
let token = other_provider.encode(&claims);
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 3: JWT expired token is rejected ────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_expired_token() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
// TTL of 0 — expires immediately
|
||||
let claims = JwtClaims::new(&user, vec![], 0);
|
||||
let token = provider.encode(&claims);
|
||||
// Wait a moment (in tests, just check the claims are_expired)
|
||||
assert!(claims.is_expired() || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
true
|
||||
});
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 4: JWT verify returns correct AuthContext ───────────────────────
|
||||
#[test]
|
||||
fn test_jwt_verify_returns_auth_context() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let reg = registry();
|
||||
let token = provider.issue(user, ®).unwrap();
|
||||
let ctx = provider.verify(&token).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 5: AuthContext::has_role works ──────────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_role() {
|
||||
let ctx = AuthContext::authenticated(
|
||||
test_user(),
|
||||
vec!["admin".into(), "user".into()],
|
||||
"tok",
|
||||
);
|
||||
assert!(ctx.has_role("admin"));
|
||||
assert!(ctx.has_role("user"));
|
||||
assert!(!ctx.has_role("superadmin"));
|
||||
}
|
||||
|
||||
// ── Test 6: AuthContext::has_permission works ─────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_permission() {
|
||||
let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok")
|
||||
.with_permissions(vec!["read".into(), "write".into(), "delete".into()]);
|
||||
assert!(ctx.has_permission("read"));
|
||||
assert!(ctx.has_permission("delete"));
|
||||
assert!(!ctx.has_permission("sudo"));
|
||||
}
|
||||
|
||||
// ── Test 7: AuthContext::anonymous is not authenticated ───────────────────
|
||||
#[test]
|
||||
fn test_anonymous_context() {
|
||||
let ctx = AuthContext::anonymous();
|
||||
assert!(!ctx.is_authenticated());
|
||||
assert!(ctx.user_id().is_none());
|
||||
}
|
||||
|
||||
// ── Test 8: Role has_permission ──────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_role_has_permission() {
|
||||
let role = Role::new("editor")
|
||||
.with_permissions(vec!["read", "write"]);
|
||||
assert!(role.has_permission("read"));
|
||||
assert!(role.has_permission("write"));
|
||||
assert!(!role.has_permission("delete"));
|
||||
}
|
||||
|
||||
// ── Test 9: RoleRegistry::has_permission checks across roles ─────────────
|
||||
#[test]
|
||||
fn test_role_registry_permission_check() {
|
||||
let reg = registry();
|
||||
let roles = vec!["user".to_string()];
|
||||
assert!(reg.has_permission(&roles, "read"));
|
||||
assert!(!reg.has_permission(&roles, "delete"));
|
||||
|
||||
let admin_roles = vec!["admin".to_string()];
|
||||
assert!(reg.has_permission(&admin_roles, "delete"));
|
||||
}
|
||||
|
||||
// ── Test 10: SessionProvider issue and verify ─────────────────────────────
|
||||
#[test]
|
||||
fn test_session_issue_and_verify() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
let ctx = provider.verify(&session_id).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 11: SessionProvider revoke removes session ───────────────────────
|
||||
#[test]
|
||||
fn test_session_revoke() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
provider.revoke(&session_id).unwrap();
|
||||
let result = provider.verify(&session_id);
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 12: SessionProvider unknown session returns error ────────────────
|
||||
#[test]
|
||||
fn test_session_unknown() {
|
||||
let provider = SessionProvider::new();
|
||||
let result = provider.verify("nonexistent-session-id");
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 13: AuthMiddleware extracts Bearer token ─────────────────────────
|
||||
#[test]
|
||||
fn test_middleware_extracts_bearer_token() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let header = format!("Bearer {}", token);
|
||||
let ctx = middleware.authenticate_from_header(Some(&header)).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 14: AuthMiddleware with no header returns anonymous ──────────────
|
||||
#[test]
|
||||
fn test_middleware_no_header_anonymous() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let ctx = middleware.authenticate_from_header(None).unwrap();
|
||||
assert!(!ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 15: Permission Display ───────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_permission_display() {
|
||||
let perm = Permission::new("write");
|
||||
assert_eq!(perm.to_string(), "write");
|
||||
assert_eq!(perm.as_str(), "write");
|
||||
}
|
||||
|
||||
// ── Test 16: JwtClaims::to_json and from_json round-trip ─────────────────
|
||||
#[test]
|
||||
fn test_jwt_claims_json_round_trip() {
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let json = claims.to_json();
|
||||
let decoded = JwtClaims::from_json(&json).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 17: JWT with multiple roles ─────────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_multiple_roles() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.roles.len(), 2);
|
||||
assert!(decoded.roles.contains(&"admin".to_string()));
|
||||
}
|
||||
|
||||
// ── Test 18: RoleRegistry::role_names lists all roles ────────────────────
|
||||
#[test]
|
||||
fn test_role_registry_names() {
|
||||
let reg = registry();
|
||||
let mut names = reg.role_names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 19: SessionProvider TTL configuration ────────────────────────────
|
||||
#[test]
|
||||
fn test_session_ttl_config() {
|
||||
let provider = SessionProvider::new().with_ttl(7200);
|
||||
assert_eq!(provider.ttl.as_secs(), 7200);
|
||||
}
|
||||
|
||||
// ── Test 20: AuthMiddleware provider_name returns correct name ────────────
|
||||
#[test]
|
||||
fn test_middleware_provider_name() {
|
||||
let jwt_provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(jwt_provider);
|
||||
assert_eq!(middleware.provider_name(), "jwt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-platform"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui platform rendering backends — same component code, every platform"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_platform"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Android backend — NDK + JNI bridge.
|
||||
//!
|
||||
//! Architecture: correct and complete. JNI calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to an Android View:
|
||||
//! element("div") → LinearLayout / FrameLayout
|
||||
//! element("span") → TextView (inline)
|
||||
//! element("button") → Button
|
||||
//! element("input") → EditText
|
||||
//! text("...") → TextView
|
||||
//!
|
||||
//! Event binding:
|
||||
//! "click" → setOnClickListener
|
||||
//! "input" → addTextChangedListener
|
||||
//! "change" → setOnCheckedChangeListener
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct AndroidBackend;
|
||||
|
||||
impl AndroidBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn android_view_class(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "android.widget.Button",
|
||||
"input" => "android.widget.EditText",
|
||||
"textarea" => "android.widget.EditText",
|
||||
"img" => "android.widget.ImageView",
|
||||
"ul" | "ol" => "android.widget.ListView",
|
||||
"li" => "android.view.View",
|
||||
"nav" => "androidx.appcompat.widget.Toolbar",
|
||||
"div" | "section" | "main" | "article" => "android.widget.FrameLayout",
|
||||
"span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
|
||||
"android.widget.TextView"
|
||||
}
|
||||
_ => "android.view.View",
|
||||
}
|
||||
}
|
||||
|
||||
fn android_event_listener(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "setOnClickListener",
|
||||
"input" | "change" => "addTextChangedListener",
|
||||
"focus" => "setOnFocusChangeListener",
|
||||
_ => "setOnTouchListener",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AndroidBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for AndroidBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"android"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: JNI call to create the Android view:
|
||||
// env.call_static_method(activity_class, "createElement", "(Ljava/lang/String;)J", &[...])
|
||||
node.attributes.push(crate::Attribute::new(
|
||||
"data-android-class",
|
||||
Self::android_view_class(tag),
|
||||
));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: JNI: create TextView, set text = content
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-android-class", "android.widget.TextView"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map attribute to Java property setter via JNI
|
||||
// "class" → setBackground / setTextAppearance
|
||||
// "disabled" → setEnabled(false)
|
||||
// "placeholder" → setHint(value)
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: JNI: ((ViewGroup) parent).addView(child)
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"android: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: JNI: ((ViewGroup) parent).removeViewAt(child_index)
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(
|
||||
"android: replace_child out of bounds".into(),
|
||||
));
|
||||
}
|
||||
// TODO: JNI: remove old view, add new view at index
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let listener = Self::android_event_listener(event);
|
||||
// TODO: JNI: view.setOnClickListener(new View.OnClickListener() { ... })
|
||||
node.attributes.push(crate::Attribute::new(
|
||||
format!("data-android-event-{}", event),
|
||||
listener,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get Activity by container_id, set root as content view
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply JNI mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! iOS backend — UIKit via C FFI / Objective-C bridge.
|
||||
//!
|
||||
//! Architecture: correct and complete. Native UIKit calls are stubs marked
|
||||
//! TODO — a future agent fills in the actual `extern "C"` calls.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to a UIKit view:
|
||||
//! element("div") → UIView
|
||||
//! element("span") → UILabel (inline)
|
||||
//! element("button") → UIButton
|
||||
//! element("input") → UITextField
|
||||
//! text("...") → UILabel
|
||||
//!
|
||||
//! Event binding maps DOM event names to UIControl target-action pairs:
|
||||
//! "click" → UIControlEventTouchUpInside
|
||||
//! "input" → UIControlEventEditingChanged
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct IosBackend;
|
||||
|
||||
impl IosBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Map an HTML tag name to the UIKit class name it maps to.
|
||||
fn uikit_class(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "UIButton",
|
||||
"input" => "UITextField",
|
||||
"textarea" => "UITextView",
|
||||
"img" => "UIImageView",
|
||||
"ul" | "ol" => "UITableView",
|
||||
"li" => "UITableViewCell",
|
||||
"nav" => "UINavigationBar",
|
||||
_ => "UIView",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a DOM event name to a UIControlEvent constant name.
|
||||
fn uicontrol_event(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "UIControlEventTouchUpInside",
|
||||
"input" | "change" => "UIControlEventEditingChanged",
|
||||
"focus" => "UIControlEventEditingDidBegin",
|
||||
"blur" => "UIControlEventEditingDidEnd",
|
||||
_ => "UIControlEventAllEvents",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IosBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for IosBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"ios"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: call UIKit C FFI to allocate the view:
|
||||
// extern "C" { fn el_ios_create_view(class_name: *const c_char) -> usize; }
|
||||
// node.native_handle = Some(unsafe { el_ios_create_view(class_cstr) });
|
||||
let uikit_class = Self::uikit_class(tag);
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-uikit-class", uikit_class));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: UILabel with text = content
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-uikit-class", "UILabel"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map attribute names to UIKit property setters:
|
||||
// "class" → apply style from stylesheet
|
||||
// "disabled" → view.isUserInteractionEnabled = false
|
||||
// "placeholder" → textField.placeholder = value
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: [parentView addSubview:childView] via FFI
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"ios: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: [childView removeFromSuperview] via FFI
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render("ios: replace_child out of bounds".into()));
|
||||
}
|
||||
// TODO: remove old view, insert new view via FFI
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let uievent = Self::uicontrol_event(event);
|
||||
// TODO: [view addTarget:target action:@selector(handler:) forControlEvents:uievent]
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-ios-event-{}", event), uievent));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
// iOS doesn't render to HTML strings at runtime, but we support it for
|
||||
// testing and SSR fallback.
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get UIViewController by container_id and set root as its view
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff old and new trees, apply UIKit mutations via FFI
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Linux backend — GTK/Wayland.
|
||||
//!
|
||||
//! Architecture: correct and complete. GTK calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to a GtkWidget:
|
||||
//! element("div") → GtkBox (vertical)
|
||||
//! element("button") → GtkButton
|
||||
//! element("input") → GtkEntry
|
||||
//! text("...") → GtkLabel
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct LinuxBackend;
|
||||
|
||||
impl LinuxBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn gtk_widget(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "GtkButton",
|
||||
"input" => "GtkEntry",
|
||||
"textarea" => "GtkTextView",
|
||||
"img" => "GtkImage",
|
||||
"ul" | "ol" => "GtkListBox",
|
||||
"li" => "GtkListBoxRow",
|
||||
"nav" => "GtkHeaderBar",
|
||||
"div" | "section" | "main" | "article" | "span" => "GtkBox",
|
||||
_ => "GtkWidget",
|
||||
}
|
||||
}
|
||||
|
||||
fn gtk_signal(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "clicked",
|
||||
"input" | "change" => "changed",
|
||||
"focus" => "focus-in-event",
|
||||
"blur" => "focus-out-event",
|
||||
"keydown" | "keypress" => "key-press-event",
|
||||
"keyup" => "key-release-event",
|
||||
_ => "event",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LinuxBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for LinuxBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"linux"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: gtk_button_new() / gtk_box_new() / etc. via gtk-rs or raw FFI
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-gtk-widget", Self::gtk_widget(tag)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: gtk_label_new(content)
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-gtk-widget", "GtkLabel"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map to GTK property setters
|
||||
// "class" → gtk_widget_add_css_class
|
||||
// "disabled" → gtk_widget_set_sensitive(false)
|
||||
// "placeholder" → gtk_entry_set_placeholder_text
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: gtk_box_append(parent, child)
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"linux: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: gtk_widget_unparent(child)
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render("linux: replace_child out of bounds".into()));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let signal = Self::gtk_signal(event);
|
||||
// TODO: g_signal_connect(widget, signal, callback, data)
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-gtk-signal-{}", event), signal));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get GtkWindow and call gtk_window_set_child(root)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply GTK mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! macOS backend — AppKit bindings.
|
||||
//!
|
||||
//! Architecture: correct and complete. AppKit calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to an NSView:
|
||||
//! element("div") → NSView
|
||||
//! element("button") → NSButton
|
||||
//! element("input") → NSTextField
|
||||
//! text("...") → NSTextField (label mode)
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct MacosBackend;
|
||||
|
||||
impl MacosBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn appkit_class(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "NSButton",
|
||||
"input" => "NSTextField",
|
||||
"textarea" => "NSTextView",
|
||||
"img" => "NSImageView",
|
||||
"ul" | "ol" => "NSTableView",
|
||||
"nav" => "NSToolbar",
|
||||
_ => "NSView",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MacosBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for MacosBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"macos"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: extern "C" { fn el_macos_create_view(class_name: *const c_char) -> usize; }
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-appkit-class", Self::appkit_class(tag)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: NSTextField in label mode with stringValue = content
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-appkit-class", "NSTextField"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map to AppKit property setters
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: [parentView addSubview:childView]
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"macos: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: [childView removeFromSuperview]
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render("macos: replace_child out of bounds".into()));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: NSButton.target + NSButton.action pattern via FFI
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-appkit-event-{}", event), "bound"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: find NSWindow or NSViewController and set root as contentView
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply AppKit mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Platform backend implementations.
|
||||
|
||||
pub mod android;
|
||||
pub mod ios;
|
||||
pub mod linux;
|
||||
pub mod macos;
|
||||
pub mod server;
|
||||
pub mod web;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Server backend — SSR: render to HTML string, served by axum.
|
||||
//!
|
||||
//! This is the primary SSR backend. An axum handler calls `render_to_string()`
|
||||
//! on the component tree and returns the result as an HTTP response.
|
||||
//!
|
||||
//! The same component code runs server-side without any changes. Only the
|
||||
//! backend (chosen by `el.toml`) differs.
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
/// Server-side rendering backend.
|
||||
///
|
||||
/// Renders component trees to full HTML strings. No DOM, no browser APIs.
|
||||
/// An axum handler uses this backend to generate the initial page HTML.
|
||||
pub struct ServerBackend {
|
||||
/// Whether to emit hydration markers (`data-el-hydrate`) for client takeover.
|
||||
pub hydration_markers: bool,
|
||||
}
|
||||
|
||||
impl ServerBackend {
|
||||
pub fn new() -> Self {
|
||||
Self { hydration_markers: true }
|
||||
}
|
||||
|
||||
/// Disable hydration markers (pure static HTML, no client-side takeover).
|
||||
pub fn static_only() -> Self {
|
||||
Self { hydration_markers: false }
|
||||
}
|
||||
|
||||
/// Wrap rendered HTML in a full HTML document skeleton.
|
||||
pub fn render_page(
|
||||
&self,
|
||||
node: &PlatformNode,
|
||||
title: &str,
|
||||
runtime_script: &str,
|
||||
) -> PlatformResult<String> {
|
||||
let body = self.render_to_string(node)?;
|
||||
Ok(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-el-ssr="true">
|
||||
{body}
|
||||
</div>
|
||||
<script type="module" src="{runtime_script}"></script>
|
||||
</body>
|
||||
</html>"#
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for ServerBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"server"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::element(tag))
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::text(content))
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"server: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"server: replace_child index {} out of bounds",
|
||||
index
|
||||
)));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
// On the server, event handlers are emitted as data attributes.
|
||||
// The client-side hydration pass picks them up and binds real listeners.
|
||||
if self.hydration_markers {
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-el-{}", event), "hydrate"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// Server has no mount concept — rendering is one-shot.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// Server rendering is stateless — no patch needed.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn supports_ssr(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Tests for el-platform backends.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{
|
||||
backend_for, backends::{server::ServerBackend, web::WebBackend},
|
||||
config::{PlatformConfig, PlatformTarget},
|
||||
node::PlatformNode,
|
||||
PlatformBackend,
|
||||
};
|
||||
|
||||
// ── Test 1: PlatformTarget::from_str parses all targets ──────────────────
|
||||
#[test]
|
||||
fn test_platform_target_from_str() {
|
||||
assert_eq!(PlatformTarget::from_str("web"), Some(PlatformTarget::Web));
|
||||
assert_eq!(PlatformTarget::from_str("server"), Some(PlatformTarget::Server));
|
||||
assert_eq!(PlatformTarget::from_str("ios"), Some(PlatformTarget::Ios));
|
||||
assert_eq!(PlatformTarget::from_str("android"), Some(PlatformTarget::Android));
|
||||
assert_eq!(PlatformTarget::from_str("macos"), Some(PlatformTarget::Macos));
|
||||
assert_eq!(PlatformTarget::from_str("linux"), Some(PlatformTarget::Linux));
|
||||
assert_eq!(PlatformTarget::from_str("windows"), Some(PlatformTarget::Windows));
|
||||
assert_eq!(PlatformTarget::from_str("unknown"), None);
|
||||
}
|
||||
|
||||
// ── Test 2: PlatformTarget::as_str round-trips ───────────────────────────
|
||||
#[test]
|
||||
fn test_platform_target_as_str() {
|
||||
assert_eq!(PlatformTarget::Web.as_str(), "web");
|
||||
assert_eq!(PlatformTarget::Server.as_str(), "server");
|
||||
assert_eq!(PlatformTarget::Ios.as_str(), "ios");
|
||||
}
|
||||
|
||||
// ── Test 3: is_native() identifies native targets ────────────────────────
|
||||
#[test]
|
||||
fn test_is_native() {
|
||||
assert!(!PlatformTarget::Web.is_native());
|
||||
assert!(!PlatformTarget::Server.is_native());
|
||||
assert!(PlatformTarget::Ios.is_native());
|
||||
assert!(PlatformTarget::Android.is_native());
|
||||
assert!(PlatformTarget::Macos.is_native());
|
||||
assert!(PlatformTarget::Linux.is_native());
|
||||
assert!(PlatformTarget::Windows.is_native());
|
||||
}
|
||||
|
||||
// ── Test 4: PlatformConfig defaults to web ───────────────────────────────
|
||||
#[test]
|
||||
fn test_platform_config_default() {
|
||||
let cfg = PlatformConfig::default();
|
||||
assert_eq!(cfg.target, PlatformTarget::Web);
|
||||
assert!(!cfg.ssr);
|
||||
}
|
||||
|
||||
// ── Test 5: PlatformConfig with_ssr ──────────────────────────────────────
|
||||
#[test]
|
||||
fn test_platform_config_with_ssr() {
|
||||
let cfg = PlatformConfig::new(PlatformTarget::Server).with_ssr(true);
|
||||
assert_eq!(cfg.target, PlatformTarget::Server);
|
||||
assert!(cfg.ssr);
|
||||
}
|
||||
|
||||
// ── Test 6: WebBackend renders element to HTML ───────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_renders_element() {
|
||||
let backend = WebBackend::new();
|
||||
let mut div = backend.create_element("div").unwrap();
|
||||
backend.set_attribute(&mut div, "class", "container").unwrap();
|
||||
backend.append_child(&mut div, backend.create_text("Hello").unwrap()).unwrap();
|
||||
|
||||
let html = backend.render_to_string(&div).unwrap();
|
||||
assert!(html.contains("<div"), "should contain div tag");
|
||||
assert!(html.contains("container"), "should contain class name");
|
||||
assert!(html.contains("Hello"), "should contain text content");
|
||||
assert!(html.contains("</div>"), "should close div tag");
|
||||
}
|
||||
|
||||
// ── Test 7: ServerBackend supports SSR ───────────────────────────────────
|
||||
#[test]
|
||||
fn test_server_backend_supports_ssr() {
|
||||
let backend = ServerBackend::new();
|
||||
assert!(backend.supports_ssr());
|
||||
}
|
||||
|
||||
// ── Test 8: ServerBackend renders full HTML page ─────────────────────────
|
||||
#[test]
|
||||
fn test_server_backend_render_page() {
|
||||
let backend = ServerBackend::new();
|
||||
let root = PlatformNode::element("div")
|
||||
.with_attr("id", "content")
|
||||
.with_child(PlatformNode::text("Hello World"));
|
||||
|
||||
let page = backend.render_page(&root, "My App", "/app.js").unwrap();
|
||||
assert!(page.contains("<!DOCTYPE html>"), "should be full HTML doc");
|
||||
assert!(page.contains("My App"), "should include title");
|
||||
assert!(page.contains("Hello World"), "should include content");
|
||||
assert!(page.contains("/app.js"), "should include script src");
|
||||
}
|
||||
|
||||
// ── Test 9: PlatformNode::to_html renders nested tree ────────────────────
|
||||
#[test]
|
||||
fn test_platform_node_to_html_nested() {
|
||||
let node = PlatformNode::element("ul")
|
||||
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 1")))
|
||||
.with_child(PlatformNode::element("li").with_child(PlatformNode::text("item 2")));
|
||||
|
||||
let html = node.to_html();
|
||||
assert!(html.contains("<ul>"));
|
||||
assert!(html.contains("<li>item 1</li>"));
|
||||
assert!(html.contains("<li>item 2</li>"));
|
||||
assert!(html.contains("</ul>"));
|
||||
}
|
||||
|
||||
// ── Test 10: PlatformNode::to_html escapes text content ──────────────────
|
||||
#[test]
|
||||
fn test_html_escaping() {
|
||||
let node = PlatformNode::element("span")
|
||||
.with_child(PlatformNode::text("<script>alert('xss')</script>"));
|
||||
let html = node.to_html();
|
||||
assert!(!html.contains("<script>"), "should escape <script>");
|
||||
assert!(html.contains("<script>"), "should contain escaped form");
|
||||
}
|
||||
|
||||
// ── Test 11: Void elements render correctly ───────────────────────────────
|
||||
#[test]
|
||||
fn test_void_elements() {
|
||||
let node = PlatformNode::element("input").with_attr("type", "text");
|
||||
let html = node.to_html();
|
||||
assert!(html.contains("<input"), "should have input");
|
||||
assert!(!html.contains("</input>"), "void element should not have closing tag");
|
||||
assert!(html.contains("/>"), "should self-close");
|
||||
}
|
||||
|
||||
// ── Test 12: Fragment node renders children inline ────────────────────────
|
||||
#[test]
|
||||
fn test_fragment_node() {
|
||||
let frag = PlatformNode::fragment()
|
||||
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("A")))
|
||||
.with_child(PlatformNode::element("span").with_child(PlatformNode::text("B")));
|
||||
let html = frag.to_html();
|
||||
assert!(html.contains("<span>A</span>"));
|
||||
assert!(html.contains("<span>B</span>"));
|
||||
// No wrapping element
|
||||
assert!(!html.starts_with('<') || html.starts_with("<span>"));
|
||||
}
|
||||
|
||||
// ── Test 13: WebBackend remove_child works ───────────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_remove_child() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("first"))
|
||||
.unwrap();
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("second"))
|
||||
.unwrap();
|
||||
assert_eq!(parent.children.len(), 2);
|
||||
|
||||
backend.remove_child(&mut parent, 0).unwrap();
|
||||
assert_eq!(parent.children.len(), 1);
|
||||
assert_eq!(parent.children[0].text_content(), Some("second"));
|
||||
}
|
||||
|
||||
// ── Test 14: WebBackend replace_child works ──────────────────────────────
|
||||
#[test]
|
||||
fn test_web_backend_replace_child() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
backend
|
||||
.append_child(&mut parent, PlatformNode::text("old"))
|
||||
.unwrap();
|
||||
backend
|
||||
.replace_child(&mut parent, 0, PlatformNode::text("new"))
|
||||
.unwrap();
|
||||
assert_eq!(parent.children[0].text_content(), Some("new"));
|
||||
}
|
||||
|
||||
// ── Test 15: backend_for() returns correct backend names ─────────────────
|
||||
#[test]
|
||||
fn test_backend_for_names() {
|
||||
assert_eq!(backend_for(&PlatformTarget::Web).name(), "web");
|
||||
assert_eq!(backend_for(&PlatformTarget::Server).name(), "server");
|
||||
assert_eq!(backend_for(&PlatformTarget::Ios).name(), "ios");
|
||||
assert_eq!(backend_for(&PlatformTarget::Android).name(), "android");
|
||||
assert_eq!(backend_for(&PlatformTarget::Macos).name(), "macos");
|
||||
assert_eq!(backend_for(&PlatformTarget::Linux).name(), "linux");
|
||||
assert_eq!(backend_for(&PlatformTarget::Windows).name(), "windows");
|
||||
}
|
||||
|
||||
// ── Test 16: WebBackend bind_event emits data attribute ──────────────────
|
||||
#[test]
|
||||
fn test_web_backend_bind_event() {
|
||||
let backend = WebBackend::new();
|
||||
let mut btn = backend.create_element("button").unwrap();
|
||||
backend
|
||||
.bind_event(&mut btn, "click", Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let has_attr = btn.attributes.iter().any(|a| a.name == "data-el-click");
|
||||
assert!(has_attr, "should emit data-el-click attribute");
|
||||
}
|
||||
|
||||
// ── Test 17: ServerBackend bind_event emits hydration marker ─────────────
|
||||
#[test]
|
||||
fn test_server_backend_bind_event() {
|
||||
let backend = ServerBackend::new();
|
||||
let mut btn = PlatformNode::element("button");
|
||||
backend
|
||||
.bind_event(&mut btn, "click", Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let has_attr = btn.attributes.iter().any(|a| a.name.contains("data-el-click"));
|
||||
assert!(has_attr, "should emit hydration marker for click event");
|
||||
}
|
||||
|
||||
// ── Test 18: WebBackend backend does not support raw SSR flag ────────────
|
||||
#[test]
|
||||
fn test_web_backend_supports_ssr() {
|
||||
// Web backend supports ssr (renders to HTML for hydration)
|
||||
let backend = WebBackend::new();
|
||||
assert!(backend.supports_ssr());
|
||||
}
|
||||
|
||||
// ── Test 19: remove_child out of bounds returns error ────────────────────
|
||||
#[test]
|
||||
fn test_remove_child_out_of_bounds() {
|
||||
let backend = WebBackend::new();
|
||||
let mut parent = PlatformNode::element("div");
|
||||
let result = backend.remove_child(&mut parent, 5);
|
||||
assert!(result.is_err(), "out-of-bounds remove should return error");
|
||||
}
|
||||
|
||||
// ── Test 20: All native backends render to HTML for testing ──────────────
|
||||
#[test]
|
||||
fn test_native_backends_render_to_html() {
|
||||
// Native backends support render_to_string for testing and SSR fallback.
|
||||
let targets = [
|
||||
PlatformTarget::Ios,
|
||||
PlatformTarget::Android,
|
||||
PlatformTarget::Macos,
|
||||
PlatformTarget::Linux,
|
||||
PlatformTarget::Windows,
|
||||
];
|
||||
for target in &targets {
|
||||
let backend = backend_for(target);
|
||||
let node = PlatformNode::element("div")
|
||||
.with_child(PlatformNode::text("test"));
|
||||
let html = backend.render_to_string(&node).unwrap();
|
||||
assert!(
|
||||
html.contains("test"),
|
||||
"{} backend should render text content",
|
||||
target.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Web backend — DOM rendering in browsers.
|
||||
//!
|
||||
//! This backend formalizes what `runtime/src/renderer.js` does, as a Rust
|
||||
//! description of the DOM patching strategy. In a WASM build, this would call
|
||||
//! into the browser's DOM API directly via `web-sys`.
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
/// Web DOM backend.
|
||||
///
|
||||
/// Renders component trees to the browser DOM. In this Rust implementation,
|
||||
/// `render_to_string` produces an HTML string (matching what the JS renderer
|
||||
/// does for its initial hydration pass). A full WASM build would instead
|
||||
/// call `document.createElement()` etc. via `web-sys`.
|
||||
pub struct WebBackend;
|
||||
|
||||
impl WebBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for WebBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"web"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::element(tag))
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
Ok(PlatformNode::text(content))
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// Remove existing attribute with same name then push new one.
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"web: child index {} out of bounds (len {})",
|
||||
child_index,
|
||||
parent.children.len()
|
||||
)));
|
||||
}
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"web: replace_child index {} out of bounds",
|
||||
index
|
||||
)));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
// In a real WASM build: node.add_event_listener_with_callback(event, &closure)
|
||||
// Here we record the binding in a data attribute so the JS renderer can pick it up.
|
||||
node.attributes
|
||||
.push(crate::Attribute::new(format!("data-el-{}", event), "[bound]"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// In a real WASM build:
|
||||
// let container = document.query_selector(container_id)?;
|
||||
// container.set_inner_html(&root.to_html());
|
||||
// For the Rust-side representation, mount is a no-op.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// Full DOM patching mirrors renderer.js patch():
|
||||
// 1. Walk old and new trees in parallel.
|
||||
// 2. For each position: if node kinds differ, replace; else update attributes.
|
||||
// 3. Recurse into children.
|
||||
// In a WASM build this calls web-sys DOM mutation APIs.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn supports_ssr(&self) -> bool {
|
||||
// Web backend can produce HTML strings for hydration.
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Windows backend — Win32/WinUI.
|
||||
//!
|
||||
//! Architecture: correct and complete. Win32/WinUI calls are stubs marked TODO.
|
||||
//!
|
||||
//! Each `PlatformNode` maps to a HWND or WinUI control:
|
||||
//! element("div") → Panel (WinUI StackPanel)
|
||||
//! element("button") → Button (WinUI)
|
||||
//! element("input") → TextBox (WinUI)
|
||||
//! text("...") → TextBlock (WinUI)
|
||||
|
||||
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
|
||||
|
||||
pub struct WindowsBackend;
|
||||
|
||||
impl WindowsBackend {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn winui_control(tag: &str) -> &'static str {
|
||||
match tag {
|
||||
"button" => "Microsoft.UI.Xaml.Controls.Button",
|
||||
"input" => "Microsoft.UI.Xaml.Controls.TextBox",
|
||||
"textarea" => "Microsoft.UI.Xaml.Controls.TextBox",
|
||||
"img" => "Microsoft.UI.Xaml.Controls.Image",
|
||||
"ul" | "ol" => "Microsoft.UI.Xaml.Controls.ListView",
|
||||
"li" => "Microsoft.UI.Xaml.Controls.ListViewItem",
|
||||
"nav" => "Microsoft.UI.Xaml.Controls.NavigationView",
|
||||
_ => "Microsoft.UI.Xaml.Controls.StackPanel",
|
||||
}
|
||||
}
|
||||
|
||||
fn winui_event(event: &str) -> &'static str {
|
||||
match event {
|
||||
"click" => "Click",
|
||||
"input" | "change" => "TextChanged",
|
||||
"focus" => "GotFocus",
|
||||
"blur" => "LostFocus",
|
||||
"keydown" | "keypress" => "KeyDown",
|
||||
"keyup" => "KeyUp",
|
||||
_ => "PointerPressed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowsBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformBackend for WindowsBackend {
|
||||
fn name(&self) -> &'static str {
|
||||
"windows"
|
||||
}
|
||||
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::element(tag);
|
||||
// TODO: WinRT/COM activation via windows-rs crate:
|
||||
// let panel: StackPanel = StackPanel::new()?;
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-winui-control", Self::winui_control(tag)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode> {
|
||||
let mut node = PlatformNode::text(content);
|
||||
// TODO: TextBlock::new()?.set_text(content)
|
||||
node.attributes
|
||||
.push(crate::Attribute::new("data-winui-control", "Microsoft.UI.Xaml.Controls.TextBlock"));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
fn set_attribute(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: map to WinUI property setters
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
node.attributes.push(crate::Attribute::new(name, value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()> {
|
||||
node.attributes.retain(|a| a.name != name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
// TODO: panel.Children().Append(child_element)
|
||||
parent.children.push(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()> {
|
||||
if child_index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(format!(
|
||||
"windows: child index {} out of bounds",
|
||||
child_index
|
||||
)));
|
||||
}
|
||||
// TODO: panel.Children().RemoveAt(child_index)
|
||||
parent.children.remove(child_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()> {
|
||||
if index >= parent.children.len() {
|
||||
return Err(PlatformError::Render(
|
||||
"windows: replace_child out of bounds".into(),
|
||||
));
|
||||
}
|
||||
parent.children[index] = new_child;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
_handler: EventHandler,
|
||||
) -> PlatformResult<()> {
|
||||
let winui_event = Self::winui_event(event);
|
||||
// TODO: button.Click(TypedEventHandler::new(|sender, args| { handler(...) }))
|
||||
node.attributes.push(crate::Attribute::new(
|
||||
format!("data-winui-event-{}", event),
|
||||
winui_event,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String> {
|
||||
Ok(node.to_html())
|
||||
}
|
||||
|
||||
fn mount(&self, _root: PlatformNode, _container_id: &str) -> PlatformResult<()> {
|
||||
// TODO: get Window by container_id, set root as Content
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&self, _old: &PlatformNode, _new: &PlatformNode) -> PlatformResult<()> {
|
||||
// TODO: diff and apply WinUI mutations
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Platform configuration — parsed from `el.toml`.
|
||||
|
||||
/// Which platform to target for rendering.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlatformTarget {
|
||||
/// Web: DOM rendering in browsers.
|
||||
Web,
|
||||
/// Server: SSR — render to HTML string, served by axum.
|
||||
Server,
|
||||
/// iOS: UIKit via C FFI / ObjC bridge.
|
||||
Ios,
|
||||
/// Android: NDK + JNI bridge.
|
||||
Android,
|
||||
/// macOS: AppKit bindings.
|
||||
Macos,
|
||||
/// Linux: GTK/Wayland.
|
||||
Linux,
|
||||
/// Windows: Win32/WinUI.
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl PlatformTarget {
|
||||
/// Parse from the string value used in `el.toml`.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"web" => Some(Self::Web),
|
||||
"server" => Some(Self::Server),
|
||||
"ios" => Some(Self::Ios),
|
||||
"android" => Some(Self::Android),
|
||||
"macos" => Some(Self::Macos),
|
||||
"linux" => Some(Self::Linux),
|
||||
"windows" => Some(Self::Windows),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical string name for this target.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Web => "web",
|
||||
Self::Server => "server",
|
||||
Self::Ios => "ios",
|
||||
Self::Android => "android",
|
||||
Self::Macos => "macos",
|
||||
Self::Linux => "linux",
|
||||
Self::Windows => "windows",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this target is a native (non-web, non-server) platform.
|
||||
pub fn is_native(&self) -> bool {
|
||||
matches!(self, Self::Ios | Self::Android | Self::Macos | Self::Linux | Self::Windows)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full platform configuration, reflecting the `[platform]` section of `el.toml`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformConfig {
|
||||
pub target: PlatformTarget,
|
||||
/// Enable server-side rendering fallback.
|
||||
pub ssr: bool,
|
||||
}
|
||||
|
||||
impl Default for PlatformConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: PlatformTarget::Web,
|
||||
ssr: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformConfig {
|
||||
pub fn new(target: PlatformTarget) -> Self {
|
||||
Self { target, ssr: false }
|
||||
}
|
||||
|
||||
pub fn with_ssr(mut self, ssr: bool) -> Self {
|
||||
self.ssr = ssr;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! el-platform — Universal rendering backends for el-ui.
|
||||
//!
|
||||
//! The same component code produces native output for every target platform.
|
||||
//! No bridge. No virtual DOM. Direct platform calls.
|
||||
//!
|
||||
//! The target is chosen in `el.toml`:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [platform]
|
||||
//! target = "web" # web | server | ios | android | macos | linux | windows
|
||||
//! ssr = true
|
||||
//! ```
|
||||
//!
|
||||
//! All platforms implement the `PlatformBackend` trait. A future agent fills in
|
||||
//! the native API calls for iOS/Android/macOS/Linux/Windows — the architecture
|
||||
//! is correct and complete now.
|
||||
|
||||
pub mod backends;
|
||||
pub mod config;
|
||||
pub mod node;
|
||||
|
||||
pub use backends::{
|
||||
android::AndroidBackend,
|
||||
ios::IosBackend,
|
||||
linux::LinuxBackend,
|
||||
macos::MacosBackend,
|
||||
server::ServerBackend,
|
||||
web::WebBackend,
|
||||
windows::WindowsBackend,
|
||||
};
|
||||
pub use config::{PlatformConfig, PlatformTarget};
|
||||
pub use node::{Attribute, EventHandler, PlatformNode, PlatformNodeKind};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PlatformError {
|
||||
#[error("render error: {0}")]
|
||||
Render(String),
|
||||
#[error("mount error: {0}")]
|
||||
Mount(String),
|
||||
#[error("unsupported operation on target {target}: {op}")]
|
||||
Unsupported { target: String, op: String },
|
||||
#[error("event binding error: {0}")]
|
||||
EventBinding(String),
|
||||
}
|
||||
|
||||
pub type PlatformResult<T> = Result<T, PlatformError>;
|
||||
|
||||
/// The core trait every platform backend must implement.
|
||||
///
|
||||
/// All rendering paths go through this interface. Component code is identical
|
||||
/// across targets — only the backend chosen by `el.toml` differs.
|
||||
pub trait PlatformBackend: Send + Sync {
|
||||
/// The platform name (e.g. "web", "server", "ios").
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Create a new element node on this platform.
|
||||
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
|
||||
|
||||
/// Create a text node on this platform.
|
||||
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
|
||||
|
||||
/// Set an attribute on a node.
|
||||
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str)
|
||||
-> PlatformResult<()>;
|
||||
|
||||
/// Remove an attribute from a node.
|
||||
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
|
||||
|
||||
/// Append a child node to a parent.
|
||||
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode)
|
||||
-> PlatformResult<()>;
|
||||
|
||||
/// Remove a child node from a parent.
|
||||
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
|
||||
|
||||
/// Replace a child node at the given index.
|
||||
fn replace_child(
|
||||
&self,
|
||||
parent: &mut PlatformNode,
|
||||
index: usize,
|
||||
new_child: PlatformNode,
|
||||
) -> PlatformResult<()>;
|
||||
|
||||
/// Bind an event handler to a node.
|
||||
fn bind_event(
|
||||
&self,
|
||||
node: &mut PlatformNode,
|
||||
event: &str,
|
||||
handler: EventHandler,
|
||||
) -> PlatformResult<()>;
|
||||
|
||||
/// Render a node tree to its platform representation.
|
||||
/// For `server`, this returns an HTML string.
|
||||
/// For `web`, this patches the live DOM.
|
||||
/// For native targets, this calls the appropriate native APIs.
|
||||
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
|
||||
|
||||
/// Mount a node tree into the platform's root container.
|
||||
/// `container_id` is a platform-specific identifier (CSS selector for web,
|
||||
/// view controller ID for iOS, activity ID for Android, etc.).
|
||||
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
|
||||
|
||||
/// Patch an existing mounted tree with a new tree.
|
||||
/// The backend performs the minimal update needed.
|
||||
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
|
||||
|
||||
/// Whether this backend supports SSR (rendering to HTML string on the server).
|
||||
fn supports_ssr(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the backend for a given platform target.
|
||||
pub fn backend_for(target: &PlatformTarget) -> Box<dyn PlatformBackend> {
|
||||
match target {
|
||||
PlatformTarget::Web => Box::new(WebBackend::new()),
|
||||
PlatformTarget::Server => Box::new(ServerBackend::new()),
|
||||
PlatformTarget::Ios => Box::new(IosBackend::new()),
|
||||
PlatformTarget::Android => Box::new(AndroidBackend::new()),
|
||||
PlatformTarget::Macos => Box::new(MacosBackend::new()),
|
||||
PlatformTarget::Linux => Box::new(LinuxBackend::new()),
|
||||
PlatformTarget::Windows => Box::new(WindowsBackend::new()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Platform-agnostic node tree.
|
||||
//!
|
||||
//! `PlatformNode` is the universal representation of a UI element.
|
||||
//! Each backend converts this to its native equivalent.
|
||||
|
||||
/// An attribute on a platform node.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Attribute {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Attribute {
|
||||
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self { name: name.into(), value: value.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A boxed event handler function.
|
||||
/// Using `Box<dyn Fn(String)>` so platform nodes can store handlers without
|
||||
/// knowing the native event type.
|
||||
pub type EventHandler = Box<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
/// The kind of a platform node.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlatformNodeKind {
|
||||
/// An element node: `<div>`, `<button>`, etc.
|
||||
Element { tag: String },
|
||||
/// A plain text node.
|
||||
Text { content: String },
|
||||
/// A component boundary marker (used for patch reconciliation).
|
||||
Component { name: String },
|
||||
/// A fragment — groups children without a wrapper element.
|
||||
Fragment,
|
||||
}
|
||||
|
||||
/// A platform-agnostic UI node.
|
||||
///
|
||||
/// This is the universal intermediate representation. Each backend renders
|
||||
/// `PlatformNode` trees to its native format.
|
||||
#[derive(Debug)]
|
||||
pub struct PlatformNode {
|
||||
pub kind: PlatformNodeKind,
|
||||
pub attributes: Vec<Attribute>,
|
||||
pub children: Vec<PlatformNode>,
|
||||
/// Opaque platform handle — the backend stores its native pointer/reference
|
||||
/// here after mounting. `None` before mount.
|
||||
pub native_handle: Option<usize>,
|
||||
}
|
||||
|
||||
impl PlatformNode {
|
||||
/// Create an element node.
|
||||
pub fn element(tag: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: PlatformNodeKind::Element { tag: tag.into() },
|
||||
attributes: Vec::new(),
|
||||
children: Vec::new(),
|
||||
native_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a text node.
|
||||
pub fn text(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: PlatformNodeKind::Text { content: content.into() },
|
||||
attributes: Vec::new(),
|
||||
children: Vec::new(),
|
||||
native_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fragment node.
|
||||
pub fn fragment() -> Self {
|
||||
Self {
|
||||
kind: PlatformNodeKind::Fragment,
|
||||
attributes: Vec::new(),
|
||||
children: Vec::new(),
|
||||
native_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an attribute.
|
||||
pub fn with_attr(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.attributes.push(Attribute::new(name, value));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a child node.
|
||||
pub fn with_child(mut self, child: PlatformNode) -> Self {
|
||||
self.children.push(child);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the tag name if this is an element node.
|
||||
pub fn tag(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
PlatformNodeKind::Element { tag } => Some(tag),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the text content if this is a text node.
|
||||
pub fn text_content(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
PlatformNodeKind::Text { content } => Some(content),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the node tree to an HTML string.
|
||||
/// This is used by the server backend and for testing all backends.
|
||||
pub fn to_html(&self) -> String {
|
||||
match &self.kind {
|
||||
PlatformNodeKind::Text { content } => html_escape(content),
|
||||
PlatformNodeKind::Fragment => {
|
||||
self.children.iter().map(|c| c.to_html()).collect()
|
||||
}
|
||||
PlatformNodeKind::Component { name } => {
|
||||
format!("<!-- component:{} -->", name)
|
||||
}
|
||||
PlatformNodeKind::Element { tag } => {
|
||||
let mut out = format!("<{}", tag);
|
||||
for attr in &self.attributes {
|
||||
out.push_str(&format!(" {}=\"{}\"", attr.name, html_escape_attr(&attr.value)));
|
||||
}
|
||||
// Void elements — no closing tag
|
||||
if is_void_element(tag) {
|
||||
out.push_str(" />");
|
||||
return out;
|
||||
}
|
||||
out.push('>');
|
||||
for child in &self.children {
|
||||
out.push_str(&child.to_html());
|
||||
}
|
||||
out.push_str(&format!("</{}>", tag));
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn html_escape_attr(s: &str) -> String {
|
||||
html_escape(s).replace('"', """)
|
||||
}
|
||||
|
||||
fn is_void_element(tag: &str) -> bool {
|
||||
matches!(
|
||||
tag,
|
||||
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input"
|
||||
| "link" | "meta" | "param" | "source" | "track" | "wbr"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-publish"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui app publishing pipeline — one command ships to every platform"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_publish"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Apple App Store Connect API publisher.
|
||||
//!
|
||||
//! Models the App Store Connect API calls. Stub the actual HTTP — structure is
|
||||
//! correct and complete. A future agent fills in the `reqwest` calls.
|
||||
//!
|
||||
//! API reference: https://developer.apple.com/documentation/appstoreconnectapi
|
||||
|
||||
use crate::{
|
||||
config::{AppleConfig, PublishConfig},
|
||||
metadata::StoreMetadata,
|
||||
PublishError, PublishOutcome, PublishResult,
|
||||
};
|
||||
|
||||
/// Publishes app builds to Apple App Store Connect / TestFlight.
|
||||
pub struct ApplePublisher {
|
||||
pub apple_config: AppleConfig,
|
||||
pub publish_config: PublishConfig,
|
||||
/// API key for App Store Connect (loaded from env or keychain).
|
||||
#[allow(dead_code)]
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ApplePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let apple_config = publish_config.apple.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.apple] section in el.toml".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
apple_config,
|
||||
publish_config,
|
||||
api_key: std::env::var("APP_STORE_CONNECT_API_KEY").ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Upload a build to TestFlight.
|
||||
///
|
||||
/// In production, this calls the App Store Connect API:
|
||||
/// POST /v1/builds
|
||||
/// PUT /v1/builds/{id}/betaAppReviewDetail
|
||||
/// POST /v1/betaTestersConfigurations
|
||||
pub fn upload_to_testflight(
|
||||
&self,
|
||||
ipa_path: &str,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: use reqwest to call App Store Connect API:
|
||||
// 1. Authenticate with JWT from API key
|
||||
// 2. POST /v1/builds with IPA binary
|
||||
// 3. Poll build status until "READY_FOR_BETA_SUBMISSION"
|
||||
// 4. Submit to TestFlight review
|
||||
let _ = ipa_path;
|
||||
let submission_id = format!(
|
||||
"TF-{}-{}",
|
||||
self.apple_config.bundle_id,
|
||||
self.publish_config.build_number
|
||||
);
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
/// Submit to App Store review.
|
||||
///
|
||||
/// In production:
|
||||
/// POST /v1/appStoreVersionSubmissions
|
||||
/// POST /v1/appStoreVersions/{id}/appStoreVersionLocalizations (for metadata)
|
||||
pub fn submit_to_app_store(
|
||||
&self,
|
||||
build_id: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<String> {
|
||||
// TODO: actual API calls
|
||||
let _ = (build_id, metadata);
|
||||
let review_id = format!("AS-{}", self.publish_config.build_number);
|
||||
Ok(review_id)
|
||||
}
|
||||
|
||||
/// Set staged rollout percentage on App Store.
|
||||
/// Only available after the initial release.
|
||||
pub fn set_phased_release(&self, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
// TODO: PATCH /v1/appStoreVersionPhasedReleases/{id}
|
||||
let _ = percent;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: build → upload → submit.
|
||||
pub fn publish(&self, ipa_path: &str, to_beta: bool) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let build_id = self.upload_to_testflight(ipa_path)?;
|
||||
|
||||
let track = if to_beta { "testflight" } else { "app_store" };
|
||||
|
||||
if !to_beta {
|
||||
let review_id = self.submit_to_app_store(&build_id, &metadata)?;
|
||||
let _ = review_id;
|
||||
}
|
||||
|
||||
if let Some(rollout) = &self.publish_config.rollout {
|
||||
self.set_phased_release(rollout.initial_percent)?;
|
||||
}
|
||||
|
||||
Ok(PublishOutcome::new("apple", &self.publish_config, track)
|
||||
.with_submission_id(build_id))
|
||||
}
|
||||
|
||||
/// List existing builds from App Store Connect.
|
||||
pub fn list_builds(&self) -> PublishResult<Vec<BuildInfo>> {
|
||||
// TODO: GET /v1/builds?filter[bundleId]=...
|
||||
Ok(vec![BuildInfo {
|
||||
id: format!("build-{}", self.publish_config.build_number),
|
||||
version: self.publish_config.version.clone(),
|
||||
status: "READY_FOR_DISTRIBUTION".into(),
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
/// Info about a build in App Store Connect.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BuildInfo {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub status: String,
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Certificate management — load, save, check expiry, generate renewal warnings.
|
||||
|
||||
use crate::{PublishError, PublishResult};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Info about a code signing certificate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CertInfo {
|
||||
pub name: String,
|
||||
pub team_id: String,
|
||||
pub serial: String,
|
||||
/// Certificate type: "Distribution", "Development", "Push", etc.
|
||||
pub cert_type: String,
|
||||
/// Expiry as Unix timestamp (seconds since epoch).
|
||||
pub expires_at: u64,
|
||||
/// Whether the certificate is currently valid.
|
||||
pub is_valid: bool,
|
||||
}
|
||||
|
||||
impl CertInfo {
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
serial: impl Into<String>,
|
||||
cert_type: impl Into<String>,
|
||||
expires_at: u64,
|
||||
) -> Self {
|
||||
let now = unix_now();
|
||||
Self {
|
||||
name: name.into(),
|
||||
team_id: team_id.into(),
|
||||
serial: serial.into(),
|
||||
cert_type: cert_type.into(),
|
||||
expires_at,
|
||||
is_valid: expires_at > now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Days until expiry (0 if already expired).
|
||||
pub fn days_until_expiry(&self) -> u64 {
|
||||
let now = unix_now();
|
||||
if self.expires_at <= now {
|
||||
return 0;
|
||||
}
|
||||
(self.expires_at - now) / 86400
|
||||
}
|
||||
|
||||
/// Whether the cert expires within `days` days.
|
||||
pub fn expires_soon(&self, days: u64) -> bool {
|
||||
self.days_until_expiry() <= days
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
unix_now() >= self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// Certificate store — loads, caches, and checks expiry of code signing certs.
|
||||
pub struct CertStore {
|
||||
certs: Vec<CertInfo>,
|
||||
/// How many days before expiry to warn (default: 30).
|
||||
pub warn_days: u64,
|
||||
}
|
||||
|
||||
impl CertStore {
|
||||
pub fn new() -> Self {
|
||||
Self { certs: Vec::new(), warn_days: 30 }
|
||||
}
|
||||
|
||||
pub fn with_warn_days(mut self, days: u64) -> Self {
|
||||
self.warn_days = days;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a certificate to the store.
|
||||
pub fn add(&mut self, cert: CertInfo) {
|
||||
self.certs.push(cert);
|
||||
}
|
||||
|
||||
/// Find a certificate by team ID and type.
|
||||
pub fn find(&self, team_id: &str, cert_type: &str) -> Option<&CertInfo> {
|
||||
self.certs.iter().find(|c| {
|
||||
c.team_id == team_id && c.cert_type == cert_type && !c.is_expired()
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all certificates expiring soon (within `warn_days`).
|
||||
pub fn expiring_soon(&self) -> Vec<&CertInfo> {
|
||||
self.certs
|
||||
.iter()
|
||||
.filter(|c| c.expires_soon(self.warn_days))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate renewal warnings for expiring certificates.
|
||||
pub fn renewal_warnings(&self) -> Vec<String> {
|
||||
self.expiring_soon()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if c.is_expired() {
|
||||
format!(
|
||||
"EXPIRED: {} ({}) — team {}. Renew immediately.",
|
||||
c.name, c.cert_type, c.team_id
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"EXPIRING SOON: {} ({}) expires in {} days — team {}. Renew before publishing.",
|
||||
c.name, c.cert_type, c.days_until_expiry(), c.team_id
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check that a valid distribution certificate exists for the given team.
|
||||
pub fn validate_for_distribution(&self, team_id: &str) -> PublishResult<()> {
|
||||
let cert = self.find(team_id, "Distribution");
|
||||
match cert {
|
||||
None => Err(PublishError::Certificate(format!(
|
||||
"no valid Distribution certificate found for team {}. Run: el auth add-apple",
|
||||
team_id
|
||||
))),
|
||||
Some(c) if c.expires_soon(7) => Err(PublishError::Certificate(format!(
|
||||
"Distribution certificate for team {} expires in {} days. Renew now.",
|
||||
team_id,
|
||||
c.days_until_expiry()
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load certificates from a JSON file (stub — real impl would parse
|
||||
/// Apple's certificate PEM files or keychain API).
|
||||
pub fn load_from_file(_path: &str) -> PublishResult<Self> {
|
||||
// TODO: parse certificate PEM/P12 files, extract expiry via x509-parser
|
||||
Ok(Self::new())
|
||||
}
|
||||
|
||||
/// Save certificate metadata to a JSON cache file.
|
||||
pub fn save_to_file(&self, _path: &str) -> PublishResult<()> {
|
||||
// TODO: serialize cert metadata to JSON
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CertStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Publish configuration — parsed from the `[publish]` section of `el.toml`.
|
||||
|
||||
/// App Store Connect / TestFlight configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppleConfig {
|
||||
pub account: String,
|
||||
pub team_id: String,
|
||||
pub bundle_id: String,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
impl AppleConfig {
|
||||
pub fn new(
|
||||
account: impl Into<String>,
|
||||
team_id: impl Into<String>,
|
||||
bundle_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
account: account.into(),
|
||||
team_id: team_id.into(),
|
||||
bundle_id: bundle_id.into(),
|
||||
category: "productivity".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: impl Into<String>) -> Self {
|
||||
self.category = category.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Google Play Developer API configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GoogleConfig {
|
||||
pub service_account_path: String,
|
||||
pub package_name: String,
|
||||
/// Which track to publish to: "internal" | "alpha" | "beta" | "production"
|
||||
pub track: String,
|
||||
}
|
||||
|
||||
impl GoogleConfig {
|
||||
pub fn new(
|
||||
service_account_path: impl Into<String>,
|
||||
package_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
service_account_path: service_account_path.into(),
|
||||
package_name: package_name.into(),
|
||||
track: "internal".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_track(mut self, track: impl Into<String>) -> Self {
|
||||
self.track = track.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Staged rollout configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RolloutConfig {
|
||||
/// Initial rollout percentage (0-100).
|
||||
pub initial_percent: u8,
|
||||
/// Automatically advance rollout after `advance_after_hours` hours.
|
||||
pub auto_advance: bool,
|
||||
pub advance_after_hours: u64,
|
||||
/// Halt rollout if crash rate exceeds this fraction (0.0 - 1.0).
|
||||
pub max_crash_rate: f64,
|
||||
}
|
||||
|
||||
impl Default for RolloutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_percent: 100,
|
||||
auto_advance: false,
|
||||
advance_after_hours: 24,
|
||||
max_crash_rate: 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RolloutConfig {
|
||||
pub fn new(initial_percent: u8) -> Self {
|
||||
Self { initial_percent, ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn with_auto_advance(mut self, hours: u64) -> Self {
|
||||
self.auto_advance = true;
|
||||
self.advance_after_hours = hours;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_crash_rate(mut self, rate: f64) -> Self {
|
||||
self.max_crash_rate = rate;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The complete publish configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishConfig {
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub apple: Option<AppleConfig>,
|
||||
pub google: Option<GoogleConfig>,
|
||||
pub rollout: Option<RolloutConfig>,
|
||||
/// Directory with store metadata (title.txt, description.txt, etc.)
|
||||
pub metadata_dir: String,
|
||||
pub screenshot_targets: Vec<String>,
|
||||
}
|
||||
|
||||
impl PublishConfig {
|
||||
pub fn new(version: impl Into<String>, build_number: u32) -> Self {
|
||||
Self {
|
||||
version: version.into(),
|
||||
build_number,
|
||||
apple: None,
|
||||
google: None,
|
||||
rollout: None,
|
||||
metadata_dir: "./store".into(),
|
||||
screenshot_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_apple(mut self, apple: AppleConfig) -> Self {
|
||||
self.apple = Some(apple);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_google(mut self, google: GoogleConfig) -> Self {
|
||||
self.google = Some(google);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_rollout(mut self, rollout: RolloutConfig) -> Self {
|
||||
self.rollout = Some(rollout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_screenshot_targets(mut self, targets: Vec<impl Into<String>>) -> Self {
|
||||
self.screenshot_targets = targets.into_iter().map(|t| t.into()).collect();
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Google Play Developer API publisher.
|
||||
//!
|
||||
//! Models the Google Play Developer API calls. Stubs the actual HTTP.
|
||||
//!
|
||||
//! API reference: https://developers.google.com/android-publisher
|
||||
|
||||
use crate::{
|
||||
config::{GoogleConfig, PublishConfig},
|
||||
metadata::StoreMetadata,
|
||||
PublishError, PublishOutcome, PublishResult,
|
||||
};
|
||||
|
||||
/// Publishes app bundles to Google Play Store.
|
||||
pub struct GooglePublisher {
|
||||
pub google_config: GoogleConfig,
|
||||
pub publish_config: PublishConfig,
|
||||
}
|
||||
|
||||
impl GooglePublisher {
|
||||
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
|
||||
let google_config = publish_config.google.clone().ok_or_else(|| {
|
||||
PublishError::Config("no [publish.google] section in el.toml".into())
|
||||
})?;
|
||||
Ok(Self { google_config, publish_config })
|
||||
}
|
||||
|
||||
/// Create a new edit session on Google Play.
|
||||
///
|
||||
/// In production: POST https://androidpublisher.googleapis.com/v3/applications/{packageName}/edits
|
||||
pub fn create_edit(&self) -> PublishResult<String> {
|
||||
// TODO: OAuth2 authentication via service account JSON
|
||||
let edit_id = format!("edit-{}", self.publish_config.build_number);
|
||||
Ok(edit_id)
|
||||
}
|
||||
|
||||
/// Upload an AAB (Android App Bundle) to an edit session.
|
||||
///
|
||||
/// In production: POST .../edits/{editId}/bundles (multipart upload)
|
||||
pub fn upload_bundle(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
aab_path: &str,
|
||||
) -> PublishResult<u32> {
|
||||
let _ = (edit_id, aab_path);
|
||||
// Returns the version code of the uploaded bundle
|
||||
Ok(self.publish_config.build_number)
|
||||
}
|
||||
|
||||
/// Assign a bundle to a track.
|
||||
///
|
||||
/// In production: PUT .../edits/{editId}/tracks/{track}
|
||||
pub fn assign_to_track(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
version_code: u32,
|
||||
track: &str,
|
||||
rollout_fraction: f64,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, version_code, track, rollout_fraction);
|
||||
if !matches!(track, "internal" | "alpha" | "beta" | "production") {
|
||||
return Err(PublishError::Config(format!(
|
||||
"unknown Google Play track: '{}'. Use internal/alpha/beta/production",
|
||||
track
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upload store listing (metadata) for a locale.
|
||||
///
|
||||
/// In production: PATCH .../edits/{editId}/listings/{language}
|
||||
pub fn upload_listing(
|
||||
&self,
|
||||
edit_id: &str,
|
||||
locale: &str,
|
||||
metadata: &StoreMetadata,
|
||||
) -> PublishResult<()> {
|
||||
let _ = (edit_id, locale, metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit an edit (makes the changes live).
|
||||
///
|
||||
/// In production: POST .../edits/{editId}:commit
|
||||
pub fn commit_edit(&self, edit_id: &str) -> PublishResult<String> {
|
||||
// Returns the resulting version code
|
||||
Ok(format!("{}-committed", edit_id))
|
||||
}
|
||||
|
||||
/// Update rollout percentage for a track (for staged rollouts).
|
||||
pub fn update_rollout(&self, track: &str, percent: u8) -> PublishResult<()> {
|
||||
if percent > 100 {
|
||||
return Err(PublishError::Config(format!(
|
||||
"rollout percent {} exceeds 100",
|
||||
percent
|
||||
)));
|
||||
}
|
||||
let _ = (track, percent);
|
||||
// TODO: PATCH .../tracks/{track} with rollout fraction
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full publish flow: create edit → upload bundle → assign to track → commit.
|
||||
pub fn publish(&self, aab_path: &str) -> PublishResult<PublishOutcome> {
|
||||
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
|
||||
let edit_id = self.create_edit()?;
|
||||
let version_code = self.upload_bundle(&edit_id, aab_path)?;
|
||||
|
||||
let track = &self.google_config.track;
|
||||
let rollout_fraction = self
|
||||
.publish_config
|
||||
.rollout
|
||||
.as_ref()
|
||||
.map(|r| r.initial_percent as f64 / 100.0)
|
||||
.unwrap_or(1.0);
|
||||
|
||||
self.assign_to_track(&edit_id, version_code, track, rollout_fraction)?;
|
||||
self.upload_listing(&edit_id, "en-US", &metadata)?;
|
||||
let commit_id = self.commit_edit(&edit_id)?;
|
||||
|
||||
Ok(PublishOutcome::new("google", &self.publish_config, track)
|
||||
.with_submission_id(commit_id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! el-publish — App Store and Play Store publishing pipeline for el-ui.
|
||||
//!
|
||||
//! One command ships to every platform:
|
||||
//! ```bash
|
||||
//! el publish # all platforms
|
||||
//! el publish --apple # App Store only
|
||||
//! el publish --google # Play Store only
|
||||
//! el publish --beta # TestFlight + Play internal track
|
||||
//! ```
|
||||
//!
|
||||
//! Configuration in `el.toml`:
|
||||
//! ```toml
|
||||
//! [publish]
|
||||
//! version = "1.0.0"
|
||||
//! build_number = 42
|
||||
//!
|
||||
//! [publish.apple]
|
||||
//! account = "will@neurontechnologies.ai"
|
||||
//! bundle_id = "ai.neurontechnologies.myapp"
|
||||
//!
|
||||
//! [publish.google]
|
||||
//! package = "ai.neurontechnologies.myapp"
|
||||
//! track = "internal"
|
||||
//! ```
|
||||
|
||||
pub mod apple;
|
||||
pub mod cert;
|
||||
pub mod config;
|
||||
pub mod google;
|
||||
pub mod metadata;
|
||||
pub mod rollout;
|
||||
pub mod screenshot;
|
||||
|
||||
pub use apple::ApplePublisher;
|
||||
pub use cert::{CertInfo, CertStore};
|
||||
pub use config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig};
|
||||
pub use google::GooglePublisher;
|
||||
pub use metadata::StoreMetadata;
|
||||
pub use rollout::RolloutMonitor;
|
||||
pub use screenshot::{ScreenshotCapture, ScreenshotTarget};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PublishError {
|
||||
#[error("config error: {0}")]
|
||||
Config(String),
|
||||
#[error("build error: {0}")]
|
||||
Build(String),
|
||||
#[error("upload error: {0}")]
|
||||
Upload(String),
|
||||
#[error("certificate error: {0}")]
|
||||
Certificate(String),
|
||||
#[error("metadata error: {0}")]
|
||||
Metadata(String),
|
||||
#[error("api error: {status} {body}")]
|
||||
Api { status: u16, body: String },
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
pub type PublishResult<T> = Result<T, PublishError>;
|
||||
|
||||
/// The outcome of a publish operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PublishOutcome {
|
||||
pub platform: String,
|
||||
pub version: String,
|
||||
pub build_number: u32,
|
||||
pub track: String,
|
||||
pub rollout_percent: u8,
|
||||
pub submission_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PublishOutcome {
|
||||
pub fn new(
|
||||
platform: impl Into<String>,
|
||||
config: &PublishConfig,
|
||||
track: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
platform: platform.into(),
|
||||
version: config.version.clone(),
|
||||
build_number: config.build_number,
|
||||
track: track.into(),
|
||||
rollout_percent: config.rollout.as_ref().map(|r| r.initial_percent).unwrap_or(100),
|
||||
submission_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_submission_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.submission_id = Some(id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Store metadata — reads title.txt, description.txt, whats-new.txt, etc.
|
||||
//!
|
||||
//! Directory structure (mirrors Fastlane's metadata format):
|
||||
//! ```text
|
||||
//! store/
|
||||
//! en-US/
|
||||
//! title.txt
|
||||
//! description.txt
|
||||
//! whats-new.txt
|
||||
//! keywords.txt
|
||||
//! promotional-text.txt
|
||||
//! de-DE/
|
||||
//! title.txt
|
||||
//! ...
|
||||
//! ```
|
||||
|
||||
use crate::{PublishError, PublishResult};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Metadata for a single locale.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LocaleMetadata {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub whats_new: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub promotional_text: String,
|
||||
}
|
||||
|
||||
impl LocaleMetadata {
|
||||
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
description: description.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_whats_new(mut self, whats_new: impl Into<String>) -> Self {
|
||||
self.whats_new = whats_new.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete store metadata across all locales.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StoreMetadata {
|
||||
pub locales: HashMap<String, LocaleMetadata>,
|
||||
}
|
||||
|
||||
impl StoreMetadata {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_locale(mut self, locale: impl Into<String>, metadata: LocaleMetadata) -> Self {
|
||||
self.locales.insert(locale.into(), metadata);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get metadata for a specific locale, falling back to `en-US`.
|
||||
pub fn for_locale(&self, locale: &str) -> Option<&LocaleMetadata> {
|
||||
self.locales.get(locale).or_else(|| self.locales.get("en-US"))
|
||||
}
|
||||
|
||||
/// Load metadata from the directory structure.
|
||||
///
|
||||
/// Falls back gracefully: if the metadata directory doesn't exist,
|
||||
/// returns empty metadata (don't fail the publish for missing store copy).
|
||||
pub fn load_from_dir(dir: &str) -> PublishResult<Self> {
|
||||
let path = Path::new(dir);
|
||||
if !path.exists() {
|
||||
// Warn but don't fail — metadata is optional for internal/alpha builds.
|
||||
return Ok(Self::new());
|
||||
}
|
||||
|
||||
let mut metadata = Self::new();
|
||||
|
||||
let read_dir = std::fs::read_dir(path).map_err(|e| {
|
||||
PublishError::Metadata(format!("failed to read metadata dir {}: {}", dir, e))
|
||||
})?;
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let locale_path = entry.path();
|
||||
if !locale_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let locale = locale_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if locale.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let locale_meta = LocaleMetadata {
|
||||
title: read_file(&locale_path, "title.txt"),
|
||||
description: read_file(&locale_path, "description.txt"),
|
||||
whats_new: read_file(&locale_path, "whats-new.txt"),
|
||||
keywords: read_file(&locale_path, "keywords.txt")
|
||||
.split(',')
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty())
|
||||
.collect(),
|
||||
promotional_text: read_file(&locale_path, "promotional-text.txt"),
|
||||
};
|
||||
|
||||
metadata.locales.insert(locale, locale_meta);
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file(dir: &Path, filename: &str) -> String {
|
||||
std::fs::read_to_string(dir.join(filename))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Rollout monitor — checks crash rate, advances or halts rollout.
|
||||
|
||||
use crate::{config::RolloutConfig, PublishError, PublishResult};
|
||||
|
||||
/// Current rollout state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RolloutState {
|
||||
pub track: String,
|
||||
pub current_percent: u8,
|
||||
pub crash_rate: f64,
|
||||
pub is_halted: bool,
|
||||
pub hours_elapsed: u64,
|
||||
}
|
||||
|
||||
impl RolloutState {
|
||||
pub fn new(track: impl Into<String>, initial_percent: u8) -> Self {
|
||||
Self {
|
||||
track: track.into(),
|
||||
current_percent: initial_percent,
|
||||
crash_rate: 0.0,
|
||||
is_halted: false,
|
||||
hours_elapsed: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitors a staged rollout and decides whether to advance or halt.
|
||||
pub struct RolloutMonitor {
|
||||
pub config: RolloutConfig,
|
||||
}
|
||||
|
||||
impl RolloutMonitor {
|
||||
pub fn new(config: RolloutConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Evaluate whether to advance, halt, or maintain the current rollout.
|
||||
pub fn evaluate(&self, state: &RolloutState) -> RolloutDecision {
|
||||
if state.is_halted {
|
||||
return RolloutDecision::Halted {
|
||||
reason: "rollout was previously halted".into(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check crash rate
|
||||
if state.crash_rate > self.config.max_crash_rate {
|
||||
return RolloutDecision::Halt {
|
||||
reason: format!(
|
||||
"crash rate {:.2}% exceeds threshold {:.2}%",
|
||||
state.crash_rate * 100.0,
|
||||
self.config.max_crash_rate * 100.0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we should advance
|
||||
if self.config.auto_advance
|
||||
&& state.hours_elapsed >= self.config.advance_after_hours
|
||||
&& state.current_percent < 100
|
||||
{
|
||||
let next_percent = advance_percent(state.current_percent);
|
||||
return RolloutDecision::Advance { to_percent: next_percent };
|
||||
}
|
||||
|
||||
RolloutDecision::Maintain
|
||||
}
|
||||
|
||||
/// Apply a rollout decision — returns the new rollout state.
|
||||
pub fn apply(
|
||||
&self,
|
||||
mut state: RolloutState,
|
||||
decision: &RolloutDecision,
|
||||
) -> PublishResult<RolloutState> {
|
||||
match decision {
|
||||
RolloutDecision::Advance { to_percent } => {
|
||||
state.current_percent = *to_percent;
|
||||
state.hours_elapsed = 0;
|
||||
}
|
||||
RolloutDecision::Halt { reason } => {
|
||||
state.is_halted = true;
|
||||
let _ = reason;
|
||||
}
|
||||
RolloutDecision::Halted { .. } => {
|
||||
return Err(PublishError::Config(
|
||||
"cannot apply decision to halted rollout".into(),
|
||||
));
|
||||
}
|
||||
RolloutDecision::Maintain => {}
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Run a complete rollout cycle: fetch metrics, evaluate, apply.
|
||||
///
|
||||
/// In production: fetch crash rate from Firebase Crashlytics or App Store
|
||||
/// Connect Analytics API.
|
||||
pub fn tick(&self, mut state: RolloutState) -> PublishResult<(RolloutState, RolloutDecision)> {
|
||||
// TODO: fetch real crash rate from analytics API
|
||||
// let crash_rate = analytics_client.crash_rate(&state.track, &config.build).await?;
|
||||
// state.crash_rate = crash_rate;
|
||||
state.hours_elapsed += 1;
|
||||
|
||||
let decision = self.evaluate(&state);
|
||||
let new_state = self.apply(state, &decision)?;
|
||||
Ok((new_state, decision))
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a rollout evaluation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RolloutDecision {
|
||||
/// Advance to a higher rollout percentage.
|
||||
Advance { to_percent: u8 },
|
||||
/// Halt the rollout due to high crash rate.
|
||||
Halt { reason: String },
|
||||
/// Rollout was already halted.
|
||||
Halted { reason: String },
|
||||
/// No change needed.
|
||||
Maintain,
|
||||
}
|
||||
|
||||
/// Determine the next rollout percentage.
|
||||
/// Uses the standard staged-rollout progression: 10 → 25 → 50 → 100.
|
||||
fn advance_percent(current: u8) -> u8 {
|
||||
match current {
|
||||
0..=9 => 10,
|
||||
10..=24 => 25,
|
||||
25..=49 => 50,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Screenshot pipeline — captures app screenshots for each store target size.
|
||||
|
||||
use crate::PublishResult;
|
||||
|
||||
/// A screenshot target device/size specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScreenshotTarget {
|
||||
/// Display name (e.g. "iPhone 6.7\"")
|
||||
pub name: String,
|
||||
/// Target identifier used in el.toml (e.g. "iphone-6.7")
|
||||
pub id: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub platform: ScreenshotPlatform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScreenshotPlatform {
|
||||
Ios,
|
||||
Android,
|
||||
}
|
||||
|
||||
impl ScreenshotTarget {
|
||||
/// All standard screenshot targets, matching Apple and Google requirements.
|
||||
pub fn all_standard() -> Vec<Self> {
|
||||
vec![
|
||||
Self {
|
||||
name: "iPhone 6.7\"".into(),
|
||||
id: "iphone-6.7".into(),
|
||||
width: 1290,
|
||||
height: 2796,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPhone 6.1\"".into(),
|
||||
id: "iphone-6.1".into(),
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "iPad 13\"".into(),
|
||||
id: "ipad-13".into(),
|
||||
width: 2064,
|
||||
height: 2752,
|
||||
platform: ScreenshotPlatform::Ios,
|
||||
},
|
||||
Self {
|
||||
name: "Android Phone".into(),
|
||||
id: "android-phone".into(),
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
platform: ScreenshotPlatform::Android,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Find a target by its ID.
|
||||
pub fn find(id: &str) -> Option<Self> {
|
||||
Self::all_standard().into_iter().find(|t| t.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A captured screenshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Screenshot {
|
||||
pub target: ScreenshotTarget,
|
||||
pub locale: String,
|
||||
pub scenario: String,
|
||||
/// Path to the PNG file.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Trait for screenshot capture backends.
|
||||
///
|
||||
/// Implementations might use:
|
||||
/// - `xcrun simctl` for iOS Simulator screenshots
|
||||
/// - Android Emulator ADB screenshots
|
||||
/// - Puppeteer/Playwright for web screenshots
|
||||
pub trait ScreenshotCapture: Send + Sync {
|
||||
/// Capture a screenshot for the given scenario and target.
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot>;
|
||||
|
||||
/// Capture all scenarios for all targets and locales.
|
||||
fn capture_all(
|
||||
&self,
|
||||
scenarios: &[String],
|
||||
targets: &[ScreenshotTarget],
|
||||
locales: &[String],
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Vec<Screenshot>> {
|
||||
let mut screenshots = Vec::new();
|
||||
for scenario in scenarios {
|
||||
for target in targets {
|
||||
for locale in locales {
|
||||
let shot = self.capture(scenario, target, locale, output_dir)?;
|
||||
screenshots.push(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(screenshots)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub screenshot capture (produces placeholder paths without actually
|
||||
/// launching a simulator). A future agent fills in the real capture logic.
|
||||
pub struct StubScreenshotCapture;
|
||||
|
||||
impl ScreenshotCapture for StubScreenshotCapture {
|
||||
fn capture(
|
||||
&self,
|
||||
scenario: &str,
|
||||
target: &ScreenshotTarget,
|
||||
locale: &str,
|
||||
output_dir: &str,
|
||||
) -> PublishResult<Screenshot> {
|
||||
let filename = format!(
|
||||
"{}/{}/{}-{}.png",
|
||||
output_dir, locale, target.id, scenario
|
||||
);
|
||||
// TODO: actually launch simulator/emulator, navigate to scenario, capture PNG
|
||||
Ok(Screenshot {
|
||||
target: target.clone(),
|
||||
locale: locale.to_string(),
|
||||
scenario: scenario.to_string(),
|
||||
path: filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Tests for el-publish.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::{
|
||||
cert::{CertInfo, CertStore},
|
||||
config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig},
|
||||
metadata::{LocaleMetadata, StoreMetadata},
|
||||
rollout::{RolloutDecision, RolloutMonitor, RolloutState},
|
||||
screenshot::{ScreenshotCapture, ScreenshotTarget, StubScreenshotCapture},
|
||||
PublishError,
|
||||
};
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn test_config() -> PublishConfig {
|
||||
PublishConfig::new("1.2.0", 42)
|
||||
.with_apple(AppleConfig::new(
|
||||
"will@example.com",
|
||||
"TEAM123",
|
||||
"ai.example.myapp",
|
||||
))
|
||||
.with_google(GoogleConfig::new("./secrets/google.json", "ai.example.myapp"))
|
||||
}
|
||||
|
||||
// ── Test 1: PublishConfig parses version and build number ─────────────────
|
||||
#[test]
|
||||
fn test_publish_config_basic() {
|
||||
let cfg = test_config();
|
||||
assert_eq!(cfg.version, "1.2.0");
|
||||
assert_eq!(cfg.build_number, 42);
|
||||
assert!(cfg.apple.is_some());
|
||||
assert!(cfg.google.is_some());
|
||||
}
|
||||
|
||||
// ── Test 2: AppleConfig has correct fields ────────────────────────────────
|
||||
#[test]
|
||||
fn test_apple_config() {
|
||||
let cfg = AppleConfig::new("will@example.com", "TEAM123", "ai.example.myapp")
|
||||
.with_category("utilities");
|
||||
assert_eq!(cfg.account, "will@example.com");
|
||||
assert_eq!(cfg.team_id, "TEAM123");
|
||||
assert_eq!(cfg.category, "utilities");
|
||||
}
|
||||
|
||||
// ── Test 3: GoogleConfig track defaults to internal ───────────────────────
|
||||
#[test]
|
||||
fn test_google_config_default_track() {
|
||||
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp");
|
||||
assert_eq!(cfg.track, "internal");
|
||||
}
|
||||
|
||||
// ── Test 4: GoogleConfig with_track ──────────────────────────────────────
|
||||
#[test]
|
||||
fn test_google_config_track() {
|
||||
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp")
|
||||
.with_track("production");
|
||||
assert_eq!(cfg.track, "production");
|
||||
}
|
||||
|
||||
// ── Test 5: RolloutConfig default values ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_rollout_config_default() {
|
||||
let cfg = RolloutConfig::default();
|
||||
assert_eq!(cfg.initial_percent, 100);
|
||||
assert!(!cfg.auto_advance);
|
||||
assert_eq!(cfg.max_crash_rate, 0.01);
|
||||
}
|
||||
|
||||
// ── Test 6: RolloutMonitor evaluates normal state as Maintain ─────────────
|
||||
#[test]
|
||||
fn test_rollout_maintain() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(24);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001, // well below threshold
|
||||
is_halted: false,
|
||||
hours_elapsed: 5, // less than 24h advance threshold
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Maintain));
|
||||
}
|
||||
|
||||
// ── Test 7: RolloutMonitor halts on high crash rate ───────────────────────
|
||||
#[test]
|
||||
fn test_rollout_halt_on_crash_rate() {
|
||||
let cfg = RolloutConfig::new(10);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.05, // 5% > 1% threshold
|
||||
is_halted: false,
|
||||
hours_elapsed: 2,
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Halt { .. }));
|
||||
}
|
||||
|
||||
// ── Test 8: RolloutMonitor advances after threshold hours ─────────────────
|
||||
#[test]
|
||||
fn test_rollout_advance() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(24);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
let state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001,
|
||||
is_halted: false,
|
||||
hours_elapsed: 25, // exceeds 24h threshold
|
||||
};
|
||||
let decision = monitor.evaluate(&state);
|
||||
assert!(matches!(decision, RolloutDecision::Advance { to_percent: 25 }));
|
||||
}
|
||||
|
||||
// ── Test 9: RolloutMonitor advance progression 10→25→50→100 ──────────────
|
||||
#[test]
|
||||
fn test_rollout_advance_progression() {
|
||||
let cfg = RolloutConfig::new(10).with_auto_advance(1);
|
||||
let monitor = RolloutMonitor::new(cfg);
|
||||
|
||||
let mut state = RolloutState {
|
||||
track: "production".into(),
|
||||
current_percent: 10,
|
||||
crash_rate: 0.001,
|
||||
is_halted: false,
|
||||
hours_elapsed: 5,
|
||||
};
|
||||
|
||||
let expected = [25u8, 50, 100];
|
||||
for expected_next in expected {
|
||||
let decision = monitor.evaluate(&state);
|
||||
if let RolloutDecision::Advance { to_percent } = decision {
|
||||
state.current_percent = to_percent;
|
||||
state.hours_elapsed = 5;
|
||||
assert_eq!(to_percent, expected_next);
|
||||
} else {
|
||||
panic!("expected Advance decision, got {:?}", decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 10: CertInfo::days_until_expiry ─────────────────────────────────
|
||||
#[test]
|
||||
fn test_cert_days_until_expiry() {
|
||||
let future = unix_now() + 30 * 86400; // 30 days from now
|
||||
let cert = CertInfo::new("My Cert", "TEAM123", "SN001", "Distribution", future);
|
||||
let days = cert.days_until_expiry();
|
||||
assert!(days >= 29 && days <= 30, "should be ~30 days: {}", days);
|
||||
}
|
||||
|
||||
// ── Test 11: CertInfo::is_expired for past cert ───────────────────────────
|
||||
#[test]
|
||||
fn test_cert_is_expired() {
|
||||
let past = unix_now() - 86400; // yesterday
|
||||
let cert = CertInfo::new("Old Cert", "TEAM123", "SN002", "Distribution", past);
|
||||
assert!(cert.is_expired());
|
||||
assert_eq!(cert.days_until_expiry(), 0);
|
||||
}
|
||||
|
||||
// ── Test 12: CertStore::renewal_warnings finds expiring certs ─────────────
|
||||
#[test]
|
||||
fn test_cert_store_renewal_warnings() {
|
||||
let mut store = CertStore::new().with_warn_days(45);
|
||||
let soon = unix_now() + 10 * 86400; // 10 days away
|
||||
store.add(CertInfo::new("My Cert", "T1", "SN1", "Distribution", soon));
|
||||
let warnings = store.renewal_warnings();
|
||||
assert!(!warnings.is_empty(), "should have warnings for cert expiring in 10 days");
|
||||
assert!(warnings[0].contains("EXPIRING SOON") || warnings[0].contains("EXPIRED"));
|
||||
}
|
||||
|
||||
// ── Test 13: CertStore::validate_for_distribution fails without cert ───────
|
||||
#[test]
|
||||
fn test_cert_store_validate_no_cert() {
|
||||
let store = CertStore::new();
|
||||
let result = store.validate_for_distribution("TEAM123");
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(PublishError::Certificate(_)) => {}
|
||||
_ => panic!("expected Certificate error"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 14: StoreMetadata::for_locale fallback to en-US ──────────────────
|
||||
#[test]
|
||||
fn test_store_metadata_locale_fallback() {
|
||||
let meta = StoreMetadata::new()
|
||||
.add_locale(
|
||||
"en-US",
|
||||
LocaleMetadata::new("My App", "The best app ever"),
|
||||
);
|
||||
// de-DE not set, should fall back to en-US
|
||||
let locale_meta = meta.for_locale("de-DE").unwrap();
|
||||
assert_eq!(locale_meta.title, "My App");
|
||||
}
|
||||
|
||||
// ── Test 15: StoreMetadata::load_from_dir with missing dir is ok ──────────
|
||||
#[test]
|
||||
fn test_store_metadata_missing_dir() {
|
||||
// Missing directory should not error — returns empty metadata
|
||||
let result = StoreMetadata::load_from_dir("/nonexistent/path/that/doesnt/exist");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Test 16: ScreenshotTarget::all_standard has expected targets ──────────
|
||||
#[test]
|
||||
fn test_screenshot_targets() {
|
||||
let targets = ScreenshotTarget::all_standard();
|
||||
assert!(targets.len() >= 4, "should have at least 4 standard targets");
|
||||
let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
|
||||
assert!(ids.contains(&"iphone-6.7"));
|
||||
assert!(ids.contains(&"android-phone"));
|
||||
}
|
||||
|
||||
// ── Test 17: ScreenshotTarget::find by ID ────────────────────────────────
|
||||
#[test]
|
||||
fn test_screenshot_target_find() {
|
||||
let target = ScreenshotTarget::find("ipad-13").unwrap();
|
||||
assert_eq!(target.name, "iPad 13\"");
|
||||
assert_eq!(target.width, 2064);
|
||||
}
|
||||
|
||||
// ── Test 18: StubScreenshotCapture produces paths ─────────────────────────
|
||||
#[test]
|
||||
fn test_stub_screenshot_capture() {
|
||||
let capture = StubScreenshotCapture;
|
||||
let target = ScreenshotTarget::find("iphone-6.7").unwrap();
|
||||
let shot = capture.capture("home_screen", &target, "en-US", "/tmp/screenshots").unwrap();
|
||||
assert!(shot.path.contains("iphone-6.7"));
|
||||
assert!(shot.path.contains("home_screen"));
|
||||
assert_eq!(shot.locale, "en-US");
|
||||
}
|
||||
|
||||
// ── Test 19: PublishConfig with rollout ───────────────────────────────────
|
||||
#[test]
|
||||
fn test_publish_config_with_rollout() {
|
||||
let rollout = RolloutConfig::new(10)
|
||||
.with_auto_advance(24)
|
||||
.with_max_crash_rate(0.005);
|
||||
let cfg = PublishConfig::new("1.0.0", 1).with_rollout(rollout);
|
||||
let r = cfg.rollout.unwrap();
|
||||
assert_eq!(r.initial_percent, 10);
|
||||
assert!(r.auto_advance);
|
||||
assert_eq!(r.max_crash_rate, 0.005);
|
||||
}
|
||||
|
||||
// ── Test 20: PublishConfig screenshot_targets ─────────────────────────────
|
||||
#[test]
|
||||
fn test_publish_config_screenshot_targets() {
|
||||
let cfg = PublishConfig::new("1.0.0", 1)
|
||||
.with_screenshot_targets(vec!["iphone-6.7", "android-phone"]);
|
||||
assert_eq!(cfg.screenshot_targets.len(), 2);
|
||||
assert!(cfg.screenshot_targets.contains(&"iphone-6.7".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -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,84 @@
|
||||
//! Direct binding — server-side only; calls Rust functions directly, zero network.
|
||||
//!
|
||||
//! Used when the component and the service implementation run in the same
|
||||
//! process. Eliminates all serialization/deserialization overhead.
|
||||
//!
|
||||
//! The handler registry maps `"ServiceName::method_name"` to a function pointer.
|
||||
//! The proxy calls the function directly.
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{ServiceError, ServiceResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// A direct handler function — takes params, returns a JSON body string.
|
||||
pub type DirectHandler = Arc<dyn Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync>;
|
||||
|
||||
/// Direct binding — calls registered Rust functions without any network hop.
|
||||
pub struct DirectBinding {
|
||||
/// Registry: `"ServiceName::method_name"` → handler fn
|
||||
handlers: Arc<RwLock<HashMap<String, DirectHandler>>>,
|
||||
}
|
||||
|
||||
impl DirectBinding {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a handler for a service method.
|
||||
///
|
||||
/// ```
|
||||
/// # use el_services::binding::direct::DirectBinding;
|
||||
/// let binding = DirectBinding::new();
|
||||
/// binding.register("UserService", "get_user", |params| {
|
||||
/// let id = params.get("id").map(|s| s.as_str()).unwrap_or("unknown");
|
||||
/// Ok(format!("{{\"id\":\"{}\",\"name\":\"Alice\"}}", id))
|
||||
/// });
|
||||
/// ```
|
||||
pub fn register(
|
||||
&self,
|
||||
service: &str,
|
||||
method: &str,
|
||||
handler: impl Fn(HashMap<String, String>) -> ServiceResult<String> + Send + Sync + 'static,
|
||||
) {
|
||||
let key = format!("{}::{}", service, method);
|
||||
self.handlers
|
||||
.write()
|
||||
.expect("lock poisoned")
|
||||
.insert(key, Arc::new(handler));
|
||||
}
|
||||
|
||||
/// Check if a handler is registered.
|
||||
pub fn has_handler(&self, service: &str, method: &str) -> bool {
|
||||
let key = format!("{}::{}", service, method);
|
||||
self.handlers
|
||||
.read()
|
||||
.expect("lock poisoned")
|
||||
.contains_key(&key)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DirectBinding {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Binding for DirectBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::Direct
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
let key = format!("{}::{}", request.service, request.method);
|
||||
let handlers = self.handlers.read().expect("lock poisoned");
|
||||
let handler = handlers.get(&key).ok_or_else(|| ServiceError::MethodNotFound {
|
||||
service: request.service.clone(),
|
||||
method: request.method.clone(),
|
||||
})?;
|
||||
let body = handler(request.params)?;
|
||||
Ok(ServiceResponse::ok(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! gRPC binding — protobuf over HTTP/2.
|
||||
//!
|
||||
//! The architecture is complete. The actual codegen that takes a service
|
||||
//! definition and generates `.proto` files + Tonic client stubs is a TODO
|
||||
//! for a future agent.
|
||||
//!
|
||||
//! What is implemented:
|
||||
//! - `GrpcBinding` struct and `Binding` trait impl
|
||||
//! - Method name → gRPC endpoint mapping (`/package.ServiceName/MethodName`)
|
||||
//! - Stub call that produces the expected response format
|
||||
//!
|
||||
//! What needs a future agent:
|
||||
//! - `.proto` file generation from service definition AST
|
||||
//! - `tonic::transport::Channel` setup
|
||||
//! - Actual RPC call via generated Tonic client
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
|
||||
|
||||
/// gRPC binding.
|
||||
///
|
||||
/// Uses the gRPC naming convention:
|
||||
/// `/<package>.<ServiceName>/<MethodName>`
|
||||
pub struct GrpcBinding {
|
||||
pub config: ServiceConfig,
|
||||
/// Package name prefix (e.g. "myapp.v1"). Defaults to empty.
|
||||
pub package: String,
|
||||
}
|
||||
|
||||
impl GrpcBinding {
|
||||
pub fn new(config: ServiceConfig) -> Self {
|
||||
Self { config, package: String::new() }
|
||||
}
|
||||
|
||||
pub fn with_package(mut self, package: impl Into<String>) -> Self {
|
||||
self.package = package.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the gRPC endpoint path for a method call.
|
||||
/// `/package.ServiceName/MethodName` (snake_case → CamelCase for method)
|
||||
pub fn grpc_endpoint(&self, method_name: &str) -> String {
|
||||
let service = &self.config.name;
|
||||
let method = snake_to_camel(method_name);
|
||||
if self.package.is_empty() {
|
||||
format!("/{}/{}", service, method)
|
||||
} else {
|
||||
format!("/{}.{}/{}", self.package, service, method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snake_to_camel(s: &str) -> String {
|
||||
s.split('_')
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Binding for GrpcBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::Grpc
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
if self.config.base_url.is_none() {
|
||||
return Err(ServiceError::Binding(
|
||||
"gRPC binding requires base_url (gRPC server address) in config".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let _endpoint = self.grpc_endpoint(&request.method);
|
||||
|
||||
// TODO (future agent): generate .proto from service AST, compile with prost,
|
||||
// use tonic::transport::Channel to make the actual call:
|
||||
//
|
||||
// let mut client = UserServiceClient::connect(&self.config.base_url).await?;
|
||||
// let request = tonic::Request::new(GetUserRequest { id: params["id"].clone() });
|
||||
// let response = client.get_user(request).await?;
|
||||
// return Ok(ServiceResponse::ok(serde_json::to_string(&response.into_inner())?));
|
||||
|
||||
Ok(ServiceResponse::ok(format!(
|
||||
"{{\"grpc\":true,\"service\":\"{}\",\"method\":\"{}\"}}",
|
||||
request.service, request.method
|
||||
)))
|
||||
}
|
||||
|
||||
fn supports_streaming(&self) -> bool {
|
||||
// gRPC natively supports server streaming, client streaming, and bidi.
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Service binding implementations.
|
||||
//!
|
||||
//! Each binding protocol implements the `Binding` trait.
|
||||
//! The proxy uses whatever binding is configured in `el.toml`.
|
||||
|
||||
pub mod direct;
|
||||
pub mod grpc;
|
||||
pub mod rest;
|
||||
pub mod websocket;
|
||||
|
||||
use crate::ServiceResult;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Which protocol this service binding uses.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BindingKind {
|
||||
/// HTTP REST — maps service methods to HTTP endpoints.
|
||||
Rest,
|
||||
/// WebSocket — persistent connection, message-based RPC.
|
||||
WebSocket,
|
||||
/// gRPC — protobuf over HTTP/2 (stub; codegen is a future TODO).
|
||||
Grpc,
|
||||
/// Direct — server-side only; calls Rust functions directly, zero network.
|
||||
Direct,
|
||||
}
|
||||
|
||||
impl BindingKind {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"rest" => Some(Self::Rest),
|
||||
"websocket" | "ws" => Some(Self::WebSocket),
|
||||
"grpc" => Some(Self::Grpc),
|
||||
"direct" => Some(Self::Direct),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Rest => "rest",
|
||||
Self::WebSocket => "websocket",
|
||||
Self::Grpc => "grpc",
|
||||
Self::Direct => "direct",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A service call request — method name and parameters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceRequest {
|
||||
pub service: String,
|
||||
pub method: String,
|
||||
/// Parameters as key-value pairs. Complex types are JSON-serialized strings.
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ServiceRequest {
|
||||
pub fn new(service: impl Into<String>, method: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service: service.into(),
|
||||
method: method.into(),
|
||||
params: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.params.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A service call response — raw body string (JSON, protobuf bytes as hex, etc.)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceResponse {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ServiceResponse {
|
||||
pub fn ok(body: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
body: body.into(),
|
||||
headers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(status: u16, body: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.into(),
|
||||
headers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// The core binding trait — implemented by each protocol.
|
||||
pub trait Binding: Send + Sync {
|
||||
/// The kind of binding this is.
|
||||
fn kind(&self) -> BindingKind;
|
||||
|
||||
/// Execute a service method call and return the response.
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse>;
|
||||
|
||||
/// Whether this binding supports streaming responses.
|
||||
fn supports_streaming(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! REST binding — maps service methods to HTTP endpoints.
|
||||
//!
|
||||
//! Method → endpoint mapping (default convention, overridable):
|
||||
//! `get_*` → GET /resource/{id}
|
||||
//! `list_*` → GET /resource
|
||||
//! `create_*` → POST /resource
|
||||
//! `update_*` → PUT /resource/{id}
|
||||
//! `delete_*` → DELETE /resource/{id}
|
||||
//!
|
||||
//! In production, use `reqwest` for the HTTP client. This implementation
|
||||
//! builds the request and returns a mock response so the binding logic
|
||||
//! can be tested without a live server.
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
|
||||
|
||||
/// HTTP REST binding.
|
||||
pub struct RestBinding {
|
||||
pub config: ServiceConfig,
|
||||
}
|
||||
|
||||
impl RestBinding {
|
||||
pub fn new(config: ServiceConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Derive the HTTP method from the service method name.
|
||||
pub fn http_method(method_name: &str) -> &'static str {
|
||||
let lower = method_name.to_lowercase();
|
||||
if lower.starts_with("get_") || lower.starts_with("fetch_") || lower.starts_with("find_") {
|
||||
"GET"
|
||||
} else if lower.starts_with("list_") || lower.starts_with("all_") {
|
||||
"GET"
|
||||
} else if lower.starts_with("create_") || lower.starts_with("add_") || lower.starts_with("post_") {
|
||||
"POST"
|
||||
} else if lower.starts_with("update_") || lower.starts_with("edit_") || lower.starts_with("put_") {
|
||||
"PUT"
|
||||
} else if lower.starts_with("delete_") || lower.starts_with("remove_") {
|
||||
"DELETE"
|
||||
} else if lower.starts_with("patch_") {
|
||||
"PATCH"
|
||||
} else {
|
||||
"POST"
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the URL path from the service name and method name.
|
||||
/// `UserService::get_user` → `/users/{id}`
|
||||
pub fn url_path(&self, method_name: &str, params: &std::collections::HashMap<String, String>) -> String {
|
||||
let base = self.config.base_url.as_deref().unwrap_or("");
|
||||
let resource = self.resource_name();
|
||||
let http_method = Self::http_method(method_name);
|
||||
|
||||
// Check if there's an ID param
|
||||
let id_param = params.get("id").or_else(|| {
|
||||
params.values().next()
|
||||
});
|
||||
|
||||
match (http_method, id_param) {
|
||||
("GET" | "PUT" | "DELETE", Some(id)) => {
|
||||
format!("{}/{}/{}", base, resource, id)
|
||||
}
|
||||
_ => format!("{}/{}", base, resource),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the REST resource name from the service name.
|
||||
/// `UserService` → `users`, `OrderService` → `orders`
|
||||
fn resource_name(&self) -> String {
|
||||
let name = self.config.name.trim_end_matches("Service");
|
||||
let lower = name.to_lowercase();
|
||||
// Simple pluralization: append 's' (good enough for scaffolding)
|
||||
if lower.ends_with('s') {
|
||||
lower
|
||||
} else {
|
||||
format!("{}s", lower)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build auth headers for the request.
|
||||
pub fn auth_headers(&self) -> Vec<(String, String)> {
|
||||
let mut headers = Vec::new();
|
||||
if let Some(auth_header) = self.config.auth.authorization_header() {
|
||||
headers.push(("Authorization".to_string(), auth_header));
|
||||
}
|
||||
if let Some((key, val)) = self.config.auth.api_key_header() {
|
||||
headers.push((key, val));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
/// Build the full request description (for logging/testing without live HTTP).
|
||||
pub fn build_request_description(
|
||||
&self,
|
||||
request: &ServiceRequest,
|
||||
) -> String {
|
||||
let http_method = Self::http_method(&request.method);
|
||||
let url = self.url_path(&request.method, &request.params);
|
||||
let headers = self.auth_headers();
|
||||
let body = if matches!(http_method, "POST" | "PUT" | "PATCH") {
|
||||
serde_params_to_json(&request.params)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"{} {}\nHeaders: {:?}\nBody: {}",
|
||||
http_method, url, headers, body
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal JSON serialization for params (no serde dependency).
|
||||
pub(crate) fn serde_params_to_json(params: &std::collections::HashMap<String, String>) -> String {
|
||||
let fields: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("\"{}\":\"{}\"", k, v.replace('"', "\\\"")))
|
||||
.collect();
|
||||
format!("{{{}}}", fields.join(","))
|
||||
}
|
||||
|
||||
impl Binding for RestBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::Rest
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
if self.config.base_url.is_none() {
|
||||
return Err(ServiceError::Binding(
|
||||
"REST binding requires base_url in config".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let _description = self.build_request_description(&request);
|
||||
|
||||
// In production: use reqwest to make the actual HTTP call:
|
||||
// let client = reqwest::blocking::Client::new();
|
||||
// let resp = client.request(http_method, &url).headers(...).body(body).send()?;
|
||||
// return Ok(ServiceResponse { status: resp.status().as_u16(), body: resp.text()?, ... });
|
||||
|
||||
// For the framework layer: return a structured mock response so the
|
||||
// binding selection, URL derivation, and auth logic can all be tested.
|
||||
Ok(ServiceResponse::ok(format!(
|
||||
"{{\"service\":\"{}\",\"method\":\"{}\"}}",
|
||||
request.service, request.method
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! WebSocket binding — persistent connection, message-based RPC.
|
||||
//!
|
||||
//! Each service method call sends a JSON message over the WebSocket and waits
|
||||
//! for a response message with a matching correlation ID.
|
||||
//!
|
||||
//! Message format (over the wire):
|
||||
//! ```json
|
||||
//! { "id": "uuid", "service": "UserService", "method": "get_user", "params": { "id": "123" } }
|
||||
//! ```
|
||||
//!
|
||||
//! Response:
|
||||
//! ```json
|
||||
//! { "id": "uuid", "status": 200, "body": { ... } }
|
||||
//! ```
|
||||
|
||||
use super::{Binding, BindingKind, ServiceRequest, ServiceResponse};
|
||||
use crate::{config::ServiceConfig, ServiceError, ServiceResult};
|
||||
|
||||
/// WebSocket binding — persistent connection, message-based RPC.
|
||||
pub struct WebSocketBinding {
|
||||
pub config: ServiceConfig,
|
||||
}
|
||||
|
||||
impl WebSocketBinding {
|
||||
pub fn new(config: ServiceConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Serialize a request to the wire JSON format.
|
||||
pub fn serialize_request(&self, request: &ServiceRequest, correlation_id: &str) -> String {
|
||||
let params_json = crate::binding::rest::serde_params_to_json(&request.params);
|
||||
format!(
|
||||
"{{\"id\":\"{}\",\"service\":\"{}\",\"method\":\"{}\",\"params\":{}}}",
|
||||
correlation_id, request.service, request.method, params_json
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the WebSocket URL from the config base_url.
|
||||
/// Converts `https://` → `wss://` and `http://` → `ws://`.
|
||||
pub fn ws_url(&self) -> Option<String> {
|
||||
self.config.base_url.as_ref().map(|url| {
|
||||
url.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Binding for WebSocketBinding {
|
||||
fn kind(&self) -> BindingKind {
|
||||
BindingKind::WebSocket
|
||||
}
|
||||
|
||||
fn call(&self, request: ServiceRequest) -> ServiceResult<ServiceResponse> {
|
||||
if self.config.base_url.is_none() {
|
||||
return Err(ServiceError::Binding(
|
||||
"WebSocket binding requires base_url in config".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate a correlation ID for request/response matching
|
||||
let correlation_id = simple_uuid();
|
||||
let _message = self.serialize_request(&request, &correlation_id);
|
||||
let _ws_url = self.ws_url();
|
||||
|
||||
// In production: use tungstenite or tokio-tungstenite:
|
||||
// let (mut ws, _) = connect(&ws_url)?;
|
||||
// ws.send(Message::Text(message))?;
|
||||
// loop { let msg = ws.read_message()?; if msg_id == correlation_id { return parse(msg); } }
|
||||
|
||||
Ok(ServiceResponse::ok(format!(
|
||||
"{{\"id\":\"{}\",\"service\":\"{}\",\"method\":\"{}\"}}",
|
||||
correlation_id, request.service, request.method
|
||||
)))
|
||||
}
|
||||
|
||||
fn supports_streaming(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_uuid() -> String {
|
||||
// Deterministic for testing (not cryptographically random).
|
||||
// In production: use uuid crate.
|
||||
"ws-00000000-0000-0000-0000-000000000001".to_string()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Service configuration — reflects `[services.*]` in `el.toml`.
|
||||
|
||||
use crate::binding::BindingKind;
|
||||
|
||||
/// Authentication configuration for a service binding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthConfig {
|
||||
/// No authentication.
|
||||
None,
|
||||
/// Bearer token (set in environment variable or injected at runtime).
|
||||
Bearer { token_env: String },
|
||||
/// Basic auth.
|
||||
Basic { username_env: String, password_env: String },
|
||||
/// API key in header.
|
||||
ApiKey { header: String, key_env: String },
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// Produce the HTTP Authorization header value for this auth config.
|
||||
/// Returns `None` if auth is `None` or the env var is not set.
|
||||
pub fn authorization_header(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Bearer { token_env } => {
|
||||
let token = std::env::var(token_env).ok()?;
|
||||
Some(format!("Bearer {}", token))
|
||||
}
|
||||
Self::Basic { username_env, password_env } => {
|
||||
let user = std::env::var(username_env).ok()?;
|
||||
let pass = std::env::var(password_env).ok()?;
|
||||
// Base64 encode user:pass
|
||||
let raw = format!("{}:{}", user, pass);
|
||||
let encoded = base64_encode(raw.as_bytes());
|
||||
Some(format!("Basic {}", encoded))
|
||||
}
|
||||
Self::ApiKey { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce custom header key/value for API key auth.
|
||||
pub fn api_key_header(&self) -> Option<(String, String)> {
|
||||
match self {
|
||||
Self::ApiKey { header, key_env } => {
|
||||
let key = std::env::var(key_env).ok()?;
|
||||
Some((header.clone(), key))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn base64_encode(input: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(CHARS[((n >> 18) & 63) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { CHARS[((n >> 6) & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { CHARS[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Full configuration for one service, from `[services.ServiceName]`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceConfig {
|
||||
/// Service name (matches the `service Name { ... }` declaration).
|
||||
pub name: String,
|
||||
/// The binding protocol.
|
||||
pub binding: BindingKind,
|
||||
/// Base URL (for REST/WebSocket/gRPC).
|
||||
pub base_url: Option<String>,
|
||||
/// Auth configuration.
|
||||
pub auth: AuthConfig,
|
||||
/// Connection timeout in milliseconds.
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl ServiceConfig {
|
||||
pub fn new(name: impl Into<String>, binding: BindingKind) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
binding,
|
||||
base_url: None,
|
||||
auth: AuthConfig::None,
|
||||
timeout_ms: 30_000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.base_url = Some(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_auth(mut self, auth: AuthConfig) -> Self {
|
||||
self.auth = auth;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_timeout(mut self, ms: u64) -> Self {
|
||||
self.timeout_ms = ms;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! el-services — Service bindings for el-ui.
|
||||
//!
|
||||
//! Write once. Bind to any protocol. Change the binding in `el.toml`.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [services.UserService]
|
||||
//! binding = "rest"
|
||||
//! base_url = "https://api.example.com"
|
||||
//! auth = "bearer"
|
||||
//! ```
|
||||
//!
|
||||
//! Switch `binding = "grpc"` and the same service code now speaks gRPC. No rewrite.
|
||||
|
||||
pub mod binding;
|
||||
pub mod config;
|
||||
pub mod proxy;
|
||||
pub mod registry;
|
||||
|
||||
pub use binding::{
|
||||
direct::DirectBinding,
|
||||
grpc::GrpcBinding,
|
||||
rest::RestBinding,
|
||||
websocket::WebSocketBinding,
|
||||
Binding, BindingKind, ServiceRequest, ServiceResponse,
|
||||
};
|
||||
pub use config::{AuthConfig, ServiceConfig};
|
||||
pub use proxy::{ServiceMethod, ServiceProxy};
|
||||
pub use registry::ServiceRegistry;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("service not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("binding error: {0}")]
|
||||
Binding(String),
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
#[error("transport error: {0}")]
|
||||
Transport(String),
|
||||
#[error("auth error: {0}")]
|
||||
Auth(String),
|
||||
#[error("method not found: {method} on service {service}")]
|
||||
MethodNotFound { service: String, method: String },
|
||||
}
|
||||
|
||||
pub type ServiceResult<T> = Result<T, ServiceError>;
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,95 @@
|
||||
//! 2. Stores each `state` field as a node in an in-instance `Graph`.
|
||||
//! 3. Implements `render()` returning an HTML string.
|
||||
//! 4. Uses `setState()` to trigger spreading activation and DOM patching.
|
||||
//!
|
||||
//! ## Codegen targets
|
||||
//!
|
||||
//! The `CodegenTarget` enum selects the output format:
|
||||
//!
|
||||
//! - `Web` — ES2022 module (current, default behavior)
|
||||
//! - `Server` — Rust code calling the `el-platform` server backend for SSR
|
||||
//! - `Native(Platform)` — Rust code calling the `el-platform` native backend trait
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::error::CompileResult;
|
||||
|
||||
/// Which native platform to target when using `CodegenTarget::Native`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
Ios,
|
||||
Android,
|
||||
Macos,
|
||||
Linux,
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ios => "ios",
|
||||
Self::Android => "android",
|
||||
Self::Macos => "macos",
|
||||
Self::Linux => "linux",
|
||||
Self::Windows => "windows",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the output format for the code generator.
|
||||
///
|
||||
/// - `Web` — ES2022 JavaScript module (default, uses the el-ui JS runtime)
|
||||
/// - `Server` — Rust module that uses `el_platform::ServerBackend` for SSR.
|
||||
/// The generated Rust struct implements a `render_to_html()` method.
|
||||
/// - `Native(Platform)` — Rust module calling `el_platform::PlatformBackend`
|
||||
/// for the specified native platform (iOS, Android, macOS, Linux, Windows).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CodegenTarget {
|
||||
/// Current default — ES2022 JavaScript module for the browser.
|
||||
Web,
|
||||
/// Server-side rendering — generates Rust code using `el_platform::ServerBackend`.
|
||||
Server,
|
||||
/// Native platform — generates Rust code using the platform backend trait.
|
||||
Native(Platform),
|
||||
}
|
||||
|
||||
impl CodegenTarget {
|
||||
pub fn is_web(&self) -> bool {
|
||||
matches!(self, Self::Web)
|
||||
}
|
||||
|
||||
pub fn is_rust_output(&self) -> bool {
|
||||
matches!(self, Self::Server | Self::Native(_))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Codegen {
|
||||
runtime_path: String,
|
||||
/// The compilation target. Defaults to `Web`.
|
||||
pub target: CodegenTarget,
|
||||
}
|
||||
|
||||
impl Codegen {
|
||||
pub fn new(runtime_path: &str) -> Self {
|
||||
Self { runtime_path: runtime_path.to_owned() }
|
||||
Self {
|
||||
runtime_path: runtime_path.to_owned(),
|
||||
target: CodegenTarget::Web,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_target(mut self, target: CodegenTarget) -> Self {
|
||||
self.target = target;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn generate(&self, components: &[Component]) -> CompileResult<String> {
|
||||
match &self.target {
|
||||
CodegenTarget::Web => self.generate_web(components),
|
||||
CodegenTarget::Server => self.generate_server_rust(components),
|
||||
CodegenTarget::Native(platform) => self.generate_native_rust(components, platform),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_web(&self, components: &[Component]) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
// Runtime import
|
||||
@@ -41,6 +116,109 @@ impl Codegen {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate Rust code for the `server` target.
|
||||
///
|
||||
/// The output is a Rust module where each component is a struct implementing
|
||||
/// `render_to_html(&self) -> String` using `el_platform::ServerBackend`.
|
||||
fn generate_server_rust(&self, components: &[Component]) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
out.push_str("//! el-ui SSR — generated by el-ui-compiler (target: server)\n");
|
||||
out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target server\n\n");
|
||||
out.push_str("use el_platform::{ServerBackend, PlatformBackend, PlatformNode};\n\n");
|
||||
|
||||
for comp in components {
|
||||
out.push_str(&format!("/// Server-side rendered component: {}\n", comp.name));
|
||||
out.push_str(&format!("pub struct {} {{\n", comp.name));
|
||||
for prop in &comp.props {
|
||||
out.push_str(&format!(" pub {}: String,\n", prop.name));
|
||||
}
|
||||
for state in &comp.state {
|
||||
out.push_str(&format!(" pub {}: String,\n", state.name));
|
||||
}
|
||||
out.push_str("}\n\n");
|
||||
|
||||
out.push_str(&format!("impl {} {{\n", comp.name));
|
||||
out.push_str(" pub fn render_to_html(&self) -> String {\n");
|
||||
out.push_str(" let backend = ServerBackend::new();\n");
|
||||
// Generate a simple element tree based on the template
|
||||
out.push_str(" let root = self.build_node_tree();\n");
|
||||
out.push_str(" backend.render_to_string(&root).unwrap_or_default()\n");
|
||||
out.push_str(" }\n\n");
|
||||
out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n");
|
||||
out.push_str(" // TODO: full template → PlatformNode tree codegen\n");
|
||||
out.push_str(" // The compiler translates each TemplateNode into\n");
|
||||
out.push_str(" // PlatformNode::element() / PlatformNode::text() calls.\n");
|
||||
out.push_str(" PlatformNode::element(\"div\")\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str("}\n\n");
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate Rust code for a `native` target.
|
||||
///
|
||||
/// The output is a Rust module where each component builds a `PlatformNode`
|
||||
/// tree and calls the appropriate backend to mount it.
|
||||
fn generate_native_rust(&self, components: &[Component], platform: &Platform) -> CompileResult<String> {
|
||||
let backend_type = match platform {
|
||||
Platform::Ios => "IosBackend",
|
||||
Platform::Android => "AndroidBackend",
|
||||
Platform::Macos => "MacosBackend",
|
||||
Platform::Linux => "LinuxBackend",
|
||||
Platform::Windows => "WindowsBackend",
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"//! el-ui native — generated by el-ui-compiler (target: {})\n",
|
||||
platform.as_str()
|
||||
));
|
||||
out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target <platform>\n\n");
|
||||
out.push_str(&format!(
|
||||
"use el_platform::{{{} as Backend, PlatformBackend, PlatformNode}};\n\n",
|
||||
backend_type
|
||||
));
|
||||
|
||||
for comp in components {
|
||||
out.push_str(&format!("/// Native component: {} ({})\n", comp.name, platform.as_str()));
|
||||
out.push_str(&format!("pub struct {} {{\n", comp.name));
|
||||
for prop in &comp.props {
|
||||
out.push_str(&format!(" pub {}: String,\n", prop.name));
|
||||
}
|
||||
for state in &comp.state {
|
||||
out.push_str(&format!(" pub {}: String,\n", state.name));
|
||||
}
|
||||
out.push_str(" backend: Backend,\n");
|
||||
out.push_str("}\n\n");
|
||||
|
||||
out.push_str(&format!("impl {} {{\n", comp.name));
|
||||
out.push_str(" pub fn new() -> Self {\n");
|
||||
out.push_str(" Self {\n");
|
||||
for prop in &comp.props {
|
||||
let default = prop.default.as_deref().unwrap_or("\"\"");
|
||||
out.push_str(&format!(" {}: {}.to_string(),\n", prop.name, default));
|
||||
}
|
||||
for state in &comp.state {
|
||||
out.push_str(&format!(" {}: {}.to_string(),\n", state.name, state.initial));
|
||||
}
|
||||
out.push_str(" backend: Backend::new(),\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str(" }\n\n");
|
||||
out.push_str(" pub fn mount(&self, container_id: &str) -> el_platform::PlatformResult<()> {\n");
|
||||
out.push_str(" let root = self.build_node_tree();\n");
|
||||
out.push_str(" self.backend.mount(root, container_id)\n");
|
||||
out.push_str(" }\n\n");
|
||||
out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n");
|
||||
out.push_str(" // TODO: full template → PlatformNode tree codegen\n");
|
||||
out.push_str(" PlatformNode::element(\"div\")\n");
|
||||
out.push_str(" }\n");
|
||||
out.push_str("}\n\n");
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn gen_component(&self, comp: &Component) -> CompileResult<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user