use std::time::Duration;
use serde_json::Value;
use super::models::*;
use crate::error::{Error, Result};
use crate::solver_status::SolverStatusResponse;
use crate::transport::Transport;
const ROUTE: &str = "/api/v1/solvers/fieldservice/solve";
#[derive(Clone)]
pub struct FieldServiceClient {
transport: Transport,
}
impl FieldServiceClient {
pub(crate) fn new(transport: Transport) -> Self {
Self { transport }
}
pub async fn start(&self, request: &FieldServiceRequest) -> Result<FieldServiceStartResponse> {
self.transport.post_json(ROUTE, request).await
}
pub async fn get_status(&self, job_id: &str) -> Result<SolverStatusResponse> {
self.transport
.get_json(&format!("{ROUTE}/{job_id}/status"))
.await
}
pub async fn get_result(&self, job_id: &str) -> Result<FieldServiceResultResponse> {
let mut result: FieldServiceResultResponse = self
.transport
.get_json(&format!("{ROUTE}/{job_id}"))
.await?;
result.job_id = Some(job_id.to_string());
Ok(result)
}
pub async fn analyze(&self, job_id: &str) -> Result<Value> {
self.transport
.get_json(&format!("{ROUTE}/{job_id}/analyze"))
.await
}
pub async fn start_and_wait_for_completion(
&self,
request: &FieldServiceRequest,
poll_interval_ms: u64,
max_attempts: u32,
) -> Result<FieldServiceResultResponse> {
let start = self.start(request).await?;
self.wait_for_completion(&start.job_id, poll_interval_ms, max_attempts)
.await
}
pub async fn wait_for_completion(
&self,
job_id: &str,
poll_interval_ms: u64,
max_attempts: u32,
) -> Result<FieldServiceResultResponse> {
if job_id.is_empty() {
return Err(Error::MissingJobId);
}
let poll_interval_ms = if poll_interval_ms == 0 {
5000
} else {
poll_interval_ms
};
let max_attempts = if max_attempts == 0 { 10 } else { max_attempts };
let mut attempts = 0u32;
loop {
tokio::time::sleep(Duration::from_millis(poll_interval_ms)).await;
let status = self.get_status(job_id).await?;
attempts += 1;
let still_solving = status.is_solving();
if !still_solving || attempts >= max_attempts {
if still_solving {
return Err(Error::SolverTimeout);
}
return self.get_result(job_id).await;
}
}
}
}
#[cfg(test)]
mod http_tests {
use super::*;
use crate::transport::Transport;
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client(base_url: String) -> FieldServiceClient {
FieldServiceClient::new(Transport::new(base_url, "secret-key".into()))
}
#[tokio::test]
async fn start_posts_json_with_api_key_header() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/solvers/fieldservice/solve"))
.and(header("X-API-KEY", "secret-key"))
.and(body_json(json!({ "vehicles": [], "visits": [] })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jobId": "job-42" })))
.expect(1)
.mount(&server)
.await;
let resp = client(server.uri())
.start(&FieldServiceRequest::default())
.await
.unwrap();
assert_eq!(resp.job_id, "job-42");
}
#[tokio::test]
async fn non_success_status_maps_to_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/solvers/fieldservice/solve/job-1/status"))
.respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
.mount(&server)
.await;
let err = client(server.uri()).get_status("job-1").await.unwrap_err();
match err {
Error::Api { status, body } => {
assert_eq!(status, 403);
assert!(body.contains("forbidden"), "body was: {body}");
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[tokio::test]
async fn get_result_stamps_the_job_id() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/solvers/fieldservice/solve/job-7"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({ "vehicles": [], "visits": [], "totalDrivingTimeSeconds": 0 }),
))
.mount(&server)
.await;
let result = client(server.uri()).get_result("job-7").await.unwrap();
assert_eq!(result.job_id.as_deref(), Some("job-7"));
}
#[tokio::test]
async fn wait_for_completion_polls_until_done_then_fetches_result() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/solvers/fieldservice/solve/job-9/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"solverStatus": "NOT_SOLVING",
"solving": false,
"score": "0hard/0medium/-3soft"
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/solvers/fieldservice/solve/job-9/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"solverStatus": "SOLVING_ACTIVE",
"solving": true,
"score": ""
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/solvers/fieldservice/solve/job-9"))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({ "vehicles": [], "visits": [], "score": "0hard/0medium/-3soft", "totalDrivingTimeSeconds": 0 }),
))
.expect(1)
.mount(&server)
.await;
let result = client(server.uri())
.wait_for_completion("job-9", 10, 5)
.await
.unwrap();
assert_eq!(result.job_id.as_deref(), Some("job-9"));
assert_eq!(result.score.as_deref(), Some("0hard/0medium/-3soft"));
}
#[tokio::test]
async fn wait_for_completion_times_out_if_never_done() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/solvers/fieldservice/solve/job-x/status"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"solverStatus": "SOLVING_ACTIVE",
"solving": true,
"score": ""
})))
.mount(&server)
.await;
let err = client(server.uri())
.wait_for_completion("job-x", 10, 3)
.await
.unwrap_err();
assert!(matches!(err, Error::SolverTimeout));
}
#[tokio::test]
async fn wait_for_completion_rejects_empty_job_id() {
let err = client("http://unused.invalid".into())
.wait_for_completion("", 10, 3)
.await
.unwrap_err();
assert!(matches!(err, Error::MissingJobId));
}
}