hive_console_sdk/agent/
builder.rs1use std::{sync::Arc, time::Duration};
2
3use async_dropper_simple::AsyncDropper;
4use recloser::AsyncRecloser;
5use reqwest::header::{HeaderMap, HeaderValue};
6use reqwest_middleware::ClientBuilder;
7use reqwest_retry::RetryTransientMiddleware;
8use std::sync::LazyLock;
9
10use crate::agent::buffer::Buffer;
11use crate::agent::usage_agent::{non_empty_string, AgentError, UsageAgent, UsageAgentInner};
12use crate::agent::utils::OperationProcessor;
13use crate::circuit_breaker;
14use crate::expressions::CompileExpression;
15use retry_policies::policies::ExponentialBackoff;
16
17pub struct UsageAgentBuilder {
18 token: Option<String>,
19 endpoint: String,
20 target_id: Option<String>,
21 buffer_size: usize,
22 connect_timeout: Duration,
23 request_timeout: Duration,
24 accept_invalid_certs: bool,
25 flush_interval: Duration,
26 retry_policy: ExponentialBackoff,
27 user_agent: Option<String>,
28 circuit_breaker: Option<AsyncRecloser>,
29 exclude_expression: Option<String>,
30}
31
32pub static DEFAULT_HIVE_USAGE_ENDPOINT: &str = "https://app.graphql-hive.com/usage";
33
34impl Default for UsageAgentBuilder {
35 fn default() -> Self {
36 Self {
37 endpoint: DEFAULT_HIVE_USAGE_ENDPOINT.to_string(),
38 token: None,
39 target_id: None,
40 buffer_size: 1000,
41 connect_timeout: Duration::from_secs(5),
42 request_timeout: Duration::from_secs(15),
43 accept_invalid_certs: false,
44 flush_interval: Duration::from_secs(5),
45 retry_policy: ExponentialBackoff::builder().build_with_max_retries(3),
46 user_agent: None,
47 circuit_breaker: None,
48 exclude_expression: None,
49 }
50 }
51}
52
53fn is_legacy_token(token: &str) -> bool {
54 !token.starts_with("hvo1/") && !token.starts_with("hvu1/") && !token.starts_with("hvp1/")
55}
56
57impl UsageAgentBuilder {
58 pub fn token(mut self, token: String) -> Self {
60 if let Some(token) = non_empty_string(Some(token)) {
61 self.token = Some(token);
62 }
63 self
64 }
65 pub fn endpoint(mut self, endpoint: String) -> Self {
67 if let Some(endpoint) = non_empty_string(Some(endpoint)) {
68 self.endpoint = endpoint;
69 }
70 self
71 }
72 pub fn target_id(mut self, target_id: String) -> Self {
74 if let Some(target_id) = non_empty_string(Some(target_id)) {
75 self.target_id = Some(target_id);
76 }
77 self
78 }
79 pub fn buffer_size(mut self, buffer_size: usize) -> Self {
82 self.buffer_size = buffer_size;
83 self
84 }
85 pub fn connect_timeout(mut self, connect_timeout: Duration) -> Self {
88 self.connect_timeout = connect_timeout;
89 self
90 }
91 pub fn request_timeout(mut self, request_timeout: Duration) -> Self {
94 self.request_timeout = request_timeout;
95 self
96 }
97 pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self {
100 self.accept_invalid_certs = accept_invalid_certs;
101 self
102 }
103 pub fn flush_interval(mut self, flush_interval: Duration) -> Self {
106 self.flush_interval = flush_interval;
107 self
108 }
109 pub fn user_agent(mut self, user_agent: String) -> Self {
111 if let Some(user_agent) = non_empty_string(Some(user_agent)) {
112 self.user_agent = Some(user_agent);
113 }
114 self
115 }
116 pub fn retry_policy(mut self, retry_policy: ExponentialBackoff) -> Self {
119 self.retry_policy = retry_policy;
120 self
121 }
122 pub fn max_retries(mut self, max_retries: u32) -> Self {
125 self.retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries);
126 self
127 }
128 pub(crate) fn build_agent(self) -> Result<UsageAgentInner, AgentError> {
129 let mut default_headers = HeaderMap::new();
130
131 default_headers.insert("X-Usage-API-Version", HeaderValue::from_static("2"));
132
133 let token = match self.token {
134 Some(token) => token,
135 None => return Err(AgentError::MissingToken),
136 };
137
138 let mut authorization_header = HeaderValue::from_str(&format!("Bearer {}", token))
139 .map_err(|_| AgentError::InvalidToken)?;
140
141 authorization_header.set_sensitive(true);
142
143 default_headers.insert(reqwest::header::AUTHORIZATION, authorization_header);
144
145 default_headers.insert(
146 reqwest::header::CONTENT_TYPE,
147 HeaderValue::from_static("application/json"),
148 );
149
150 let mut reqwest_agent = reqwest::Client::builder()
151 .danger_accept_invalid_certs(self.accept_invalid_certs)
152 .connect_timeout(self.connect_timeout)
153 .timeout(self.request_timeout)
154 .default_headers(default_headers);
155
156 if let Some(user_agent) = &self.user_agent {
157 reqwest_agent = reqwest_agent.user_agent(user_agent);
158 }
159
160 let reqwest_agent = reqwest_agent
161 .build()
162 .map_err(AgentError::HTTPClientCreationError)?;
163 let client = ClientBuilder::new(reqwest_agent)
164 .with(RetryTransientMiddleware::new_with_policy(self.retry_policy))
165 .build();
166
167 let mut endpoint = self.endpoint;
168
169 match self.target_id {
170 Some(_) if is_legacy_token(&token) => return Err(AgentError::TargetIdWithLegacyToken),
171 Some(target_id) if !is_legacy_token(&token) => {
172 let target_id = validate_target_id(&target_id)?;
173 endpoint.push_str(&format!("/{}", target_id));
174 }
175 None if !is_legacy_token(&token) => return Err(AgentError::MissingTargetId),
176 _ => {}
177 }
178
179 let circuit_breaker = if let Some(cb) = self.circuit_breaker {
180 cb
181 } else {
182 circuit_breaker::CircuitBreakerBuilder::default()
183 .build_async()
184 .map_err(AgentError::CircuitBreakerCreationError)?
185 };
186
187 let buffer = Buffer::new(self.buffer_size);
188
189 let exclude = if let Some(expr) = self.exclude_expression {
190 expr.compile_expression(None)?.into()
191 } else {
192 None
193 };
194
195 Ok(UsageAgentInner {
196 endpoint,
197 buffer,
198 processor: OperationProcessor::new(),
199 client,
200 flush_interval: self.flush_interval,
201 circuit_breaker,
202 exclude_expression: exclude,
203 })
204 }
205 pub fn exclude_expression(mut self, expression: String) -> Self {
206 if let Some(expression) = non_empty_string(Some(expression)) {
207 self.exclude_expression = Some(expression);
208 }
209 self
210 }
211 pub fn build(self) -> Result<UsageAgent, AgentError> {
212 let agent = self.build_agent()?;
213 Ok(Arc::new(AsyncDropper::new(agent)))
214 }
215}
216
217static SLUG_REGEX: LazyLock<regex_automata::meta::Regex> = LazyLock::new(|| {
219 regex_automata::meta::Regex::new(r"^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$").unwrap()
220});
221static UUID_REGEX: LazyLock<regex_automata::meta::Regex> = LazyLock::new(|| {
223 regex_automata::meta::Regex::new(
224 r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
225 )
226 .unwrap()
227});
228
229fn validate_target_id(target_id: &str) -> Result<&str, AgentError> {
230 let trimmed_s = target_id.trim();
231 if trimmed_s.is_empty() {
232 Err(AgentError::InvalidTargetId("<empty>".to_string()))
233 } else {
234 if SLUG_REGEX.is_match(trimmed_s) {
235 return Ok(trimmed_s);
236 }
237 if UUID_REGEX.is_match(trimmed_s) {
238 return Ok(trimmed_s);
239 }
240 Err(AgentError::InvalidTargetId(format!(
241 "Invalid target_id format: '{}'. It must be either in slug format '$organizationSlug/$projectSlug/$targetSlug' or UUID format 'a0f4c605-6541-4350-8cfe-b31f21a4bf80'",
242 trimmed_s
243 )))
244 }
245}