use rustlavel::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Caller {
pub subject: Option<String>,
pub client: Option<String>,
pub scopes: Vec<String>,
}
impl Caller {
pub fn can(&self, scope: &str) -> bool {
self.scopes.iter().any(|held| held == scope)
}
pub fn owner(&self) -> &str {
self.subject.as_deref().or(self.client.as_deref()).unwrap_or("unknown")
}
pub fn is_service(&self) -> bool {
self.subject.is_none()
}
pub fn to_json(&self) -> Json {
Json::object([
(
"subject",
match &self.subject {
Some(subject) => Json::from(subject.as_str()),
None => Json::Null,
},
),
("is_service", Json::from(self.is_service())),
(
"client",
match &self.client {
Some(client) => Json::from(client.as_str()),
None => Json::Null,
},
),
(
"scopes",
Json::Array(self.scopes.iter().map(|s| Json::from(s.as_str())).collect()),
),
])
}
}
pub fn problem(message: &str) -> Json {
Json::object([("message", Json::from(message))])
}
pub fn join(service: &str, config: &Config) -> Option<std::sync::Arc<rustlavel::Registrar>> {
if is_console() {
return None;
}
let registry = rustlavel::env::env_or("DISCOVERY_URL", "");
let port = config.int("server.port", 8000) as u16;
join_at(®istry, service, port)
}
pub fn farewell(
registrar: Option<std::sync::Arc<rustlavel::Registrar>>,
) -> impl FnOnce() -> rustlavel::BoxFuture<()> + Send + Sync + 'static {
move || {
Box::pin(async move {
let Some(registrar) = registrar else { return };
match registrar.deregister().await {
Ok(()) => info!("discovery: deregistered"),
Err(error) => warn!("discovery: could not deregister: {error}"),
}
})
}
}
fn is_console() -> bool {
std::env::args().nth(1).is_some_and(|argument| !argument.starts_with('-'))
}
pub fn join_at(
registry: &str,
service: &str,
port: u16,
) -> Option<std::sync::Arc<rustlavel::Registrar>> {
if registry.trim().is_empty() {
return None;
}
let host = rustlavel::env::env_or("SERVICE_HOST", "127.0.0.1");
let registrar = std::sync::Arc::new(rustlavel::Registrar::new(
vec![registry.trim().to_string()],
rustlavel::Instance::new(service, host, port)
.meta("zone", rustlavel::env::env_or("ZONE", "default")),
));
std::sync::Arc::clone(®istrar).start();
Some(registrar)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_caller_holds_only_the_scopes_it_was_given() {
let caller = Caller {
subject: Some("42".into()),
client: Some("checkout".into()),
scopes: vec!["orders.read".into()],
};
assert!(caller.can("orders.read"));
assert!(!caller.can("orders.write"));
assert!(!caller.can(""));
assert!(!caller.is_service());
assert_eq!(caller.owner(), "42");
}
#[test]
fn a_service_is_owned_by_its_client_rather_than_by_nobody() {
let service = Caller {
subject: None,
client: Some("reconciliation".into()),
scopes: vec!["orders.read".into()],
};
assert!(service.is_service());
assert_eq!(service.owner(), "reconciliation");
}
#[test]
fn the_error_body_is_one_shape() {
assert_eq!(problem("no").to_string(), r#"{"message":"no"}"#);
}
#[test]
fn a_console_command_is_not_a_running_instance() {
assert!(!is_console_argument(None));
assert!(!is_console_argument(Some("--verbose")));
assert!(is_console_argument(Some("migrate")));
assert!(is_console_argument(Some("db:seed")));
}
fn is_console_argument(first: Option<&str>) -> bool {
first.is_some_and(|argument| !argument.starts_with('-'))
}
#[test]
fn joining_without_a_registry_does_nothing() {
assert!(join_at("", "api", 9002).is_none());
assert!(join_at(" ", "api", 9002).is_none(), "a blank setting is not a registry");
}
}