use crate::context::JobContext;
use crate::executor::{JobError, JobResult, Worker};
use awa_model::callback_contract;
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone)]
pub enum HttpWorkerMode {
Async,
Sync {
response_timeout: Duration,
},
}
#[derive(Debug, Clone)]
pub struct HttpWorkerConfig {
pub url: String,
pub mode: HttpWorkerMode,
pub callback_timeout: Duration,
pub headers: HashMap<String, String>,
pub hmac_secret: Option<[u8; 32]>,
pub callback_base_url: Option<String>,
pub callback_path_prefix: Option<String>,
pub request_timeout: Duration,
}
impl Default for HttpWorkerConfig {
fn default() -> Self {
Self {
url: String::new(),
mode: HttpWorkerMode::Async,
callback_timeout: Duration::from_secs(3600),
headers: HashMap::new(),
hmac_secret: None,
callback_base_url: None,
callback_path_prefix: None,
request_timeout: Duration::from_secs(30),
}
}
}
pub struct HttpWorker {
kind: &'static str,
client: reqwest::Client,
config: HttpWorkerConfig,
}
impl HttpWorker {
pub fn new(kind: String, config: HttpWorkerConfig) -> Self {
let mut builder = reqwest::Client::builder()
.timeout(config.request_timeout)
.user_agent("awa-http-worker/0.5");
if let HttpWorkerMode::Sync { response_timeout } = &config.mode {
builder = builder.timeout(*response_timeout);
}
let client = builder.build().expect("failed to build HTTP client");
Self {
kind: Box::leak(kind.into_boxed_str()),
client,
config,
}
}
fn sign_callback_id(&self, callback_id: &str) -> Option<String> {
self.config
.hmac_secret
.as_ref()
.map(|key| callback_contract::sign(key, callback_id))
}
}
#[derive(serde::Serialize)]
struct HttpWorkerRequest<'a> {
job_id: i64,
kind: &'a str,
args: &'a serde_json::Value,
attempt: i16,
max_attempts: i16,
#[serde(skip_serializing_if = "Option::is_none")]
callback_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
callback_url: Option<String>,
}
#[async_trait::async_trait]
impl Worker for HttpWorker {
fn kind(&self) -> &'static str {
self.kind
}
async fn perform(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
match &self.config.mode {
HttpWorkerMode::Async => self.perform_async(ctx).await,
HttpWorkerMode::Sync { .. } => self.perform_sync(ctx).await,
}
}
}
impl HttpWorker {
async fn perform_async(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
let guard = ctx
.register_callback(self.config.callback_timeout)
.await
.map_err(JobError::retryable)?;
let callback_id_str = guard.id().to_string();
let callback_url = self.config.callback_base_url.as_ref().map(|base| {
let prefix = self
.config
.callback_path_prefix
.as_deref()
.unwrap_or(callback_contract::DEFAULT_CALLBACK_PATH_PREFIX);
callback_contract::callback_url(base, prefix, &callback_id_str, "complete")
});
let body = HttpWorkerRequest {
job_id: ctx.job.id,
kind: self.kind,
args: &ctx.job.args,
attempt: ctx.job.attempt,
max_attempts: ctx.job.max_attempts,
callback_id: Some(callback_id_str.clone()),
callback_url,
};
let mut request = self.client.post(&self.config.url).json(&body);
for (key, value) in &self.config.headers {
request = request.header(key.as_str(), value.as_str());
}
if let Some(signature) = self.sign_callback_id(&callback_id_str) {
request = request.header(callback_contract::SIGNATURE_HEADER, &signature);
}
let response = request.send().await.map_err(JobError::retryable)?;
let status = response.status();
if status.is_success() {
Ok(JobResult::WaitForCallback(guard))
} else if status.is_server_error() {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::retryable_msg(format!(
"function returned {status}: {body_text}"
)))
} else {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::Terminal(format!(
"function returned {status}: {body_text}"
)))
}
}
async fn perform_sync(&self, ctx: &JobContext) -> Result<JobResult, JobError> {
let body = HttpWorkerRequest {
job_id: ctx.job.id,
kind: self.kind,
args: &ctx.job.args,
attempt: ctx.job.attempt,
max_attempts: ctx.job.max_attempts,
callback_id: None,
callback_url: None,
};
let mut request = self.client.post(&self.config.url).json(&body);
for (key, value) in &self.config.headers {
request = request.header(key.as_str(), value.as_str());
}
let response = request.send().await.map_err(JobError::retryable)?;
let status = response.status();
if status.is_success() {
Ok(JobResult::Completed)
} else if status.is_server_error() {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::retryable_msg(format!(
"function returned {status}: {body_text}"
)))
} else {
let body_text = response.text().await.unwrap_or_default();
Err(JobError::Terminal(format!(
"function returned {status}: {body_text}"
)))
}
}
}
pub fn verify_callback_signature(
hmac_secret: &[u8; 32],
callback_id: &str,
provided_signature: &str,
) -> bool {
callback_contract::verify(hmac_secret, callback_id, provided_signature)
}