waitup/target.rs
1//! Target implementation and builders.
2//!
3//! This module provides implementations for creating and working with targets,
4//! which represent services to wait for. Targets can be either TCP connections
5//! or HTTP endpoints.
6//!
7//! # Features
8//!
9//! - **TCP Targets**: Direct socket connections to host:port
10//! - **HTTP Targets**: HTTP/HTTPS requests with status code validation
11//! - **Builder Pattern**: Fluent APIs for complex target configuration
12//! - **Input Validation**: Comprehensive validation for all inputs
13//! - **Parsing**: String-to-target parsing for CLI and config files
14//!
15//! # Examples
16//!
17//! ## Basic target creation
18//!
19//! ```rust
20//! use waitup::Target;
21//! use url::Url;
22//!
23//! // Create TCP targets
24//! let db = Target::tcp("database.example.com", 5432)?;
25//! let localhost_api = Target::localhost(8080)?;
26//!
27//! // Create HTTP targets
28//! let health_check = Target::http_url("https://api.example.com/health", 200)?;
29//! let status_page = Target::http(
30//! Url::parse("https://status.example.com")?,
31//! 200
32//! )?;
33//! # Ok::<(), waitup::WaitForError>(())
34//! ```
35//!
36//! ## Using builders for complex configurations
37//!
38//! ```rust
39//! use waitup::Target;
40//! use url::Url;
41//!
42//! // HTTP target with custom headers
43//! let api_target = Target::http_builder(Url::parse("https://api.example.com/protected")?)
44//! .status(201)
45//! .auth_bearer("your-api-token")
46//! .content_type("application/json")
47//! .header("X-Custom-Header", "custom-value")
48//! .build()?;
49//!
50//! // TCP target with specific port type validation
51//! let service = Target::tcp_builder("service.example.com")?
52//! .registered_port(8080)
53//! .build()?;
54//! # Ok::<(), waitup::WaitForError>(())
55//! ```
56//!
57//! ## Parsing targets from strings
58//!
59//! ```rust
60//! use waitup::Target;
61//!
62//! // Parse various target formats
63//! let tcp_target = Target::parse("localhost:8080", 200)?;
64//! let http_target = Target::parse("https://example.com/health", 200)?;
65//! let custom_port = Target::parse("api.example.com:3000", 200)?;
66//! # Ok::<(), waitup::WaitForError>(())
67//! ```
68
69use std::borrow::Cow;
70use url::Url;
71
72use crate::types::{Hostname, Port, Target};
73use crate::{Result, ResultExt, WaitForError};
74
75impl Target {
76 /// Create multiple TCP targets from a list of host:port pairs
77 ///
78 /// # Errors
79 ///
80 /// Returns an error if any hostname or port is invalid
81 pub fn tcp_batch<I, S>(targets: I) -> crate::types::TargetVecResult
82 where
83 I: IntoIterator<Item = (S, u16)>,
84 S: AsRef<str>,
85 {
86 targets
87 .into_iter()
88 .map(|(host, port)| Self::tcp(host.as_ref(), port))
89 .collect()
90 }
91
92 /// Create multiple HTTP targets from a list of URLs
93 ///
94 /// # Errors
95 ///
96 /// Returns an error if any URL is invalid or cannot be parsed
97 pub fn http_batch<I, S>(urls: I, default_status: u16) -> crate::types::TargetVecResult
98 where
99 I: IntoIterator<Item = S>,
100 S: AsRef<str>,
101 {
102 urls.into_iter()
103 .map(|url| Self::http_url(url.as_ref(), default_status))
104 .collect()
105 }
106 /// Create a new TCP target.
107 ///
108 /// # Errors
109 ///
110 /// Returns an error if the hostname is invalid or the port is out of range (1-65535)
111 ///
112 /// # Examples
113 ///
114 /// ```rust
115 /// use waitup::Target;
116 ///
117 /// let target = Target::tcp("localhost", 8080)?;
118 /// # Ok::<(), waitup::WaitForError>(())
119 /// ```
120 pub fn tcp(host: impl AsRef<str>, port: u16) -> Result<Self> {
121 let hostname = Hostname::new(host.as_ref())
122 .with_context(|| format!("Invalid hostname '{host}'", host = host.as_ref()))?;
123 let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
124 Ok(Self::Tcp {
125 host: hostname,
126 port,
127 })
128 }
129
130 /// Create a TCP target for localhost.
131 ///
132 /// # Errors
133 ///
134 /// Returns an error if the port is invalid (0 or > 65535)
135 ///
136 /// # Examples
137 ///
138 /// ```rust
139 /// use waitup::Target;
140 ///
141 /// let target = Target::localhost(8080)?;
142 /// # Ok::<(), waitup::WaitForError>(())
143 /// ```
144 pub fn localhost(port: u16) -> Result<Self> {
145 let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
146 Ok(Self::Tcp {
147 host: Hostname::localhost(),
148 port,
149 })
150 }
151
152 /// Create a TCP target for IPv4 loopback (127.0.0.1).
153 ///
154 /// # Errors
155 ///
156 /// Returns an error if the port is invalid (0 or > 65535)
157 ///
158 /// # Examples
159 ///
160 /// ```rust
161 /// use waitup::Target;
162 ///
163 /// let target = Target::loopback(8080)?;
164 /// # Ok::<(), waitup::WaitForError>(())
165 /// ```
166 pub fn loopback(port: u16) -> Result<Self> {
167 let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
168 Ok(Self::Tcp {
169 host: Hostname::loopback(),
170 port,
171 })
172 }
173
174 /// Create a TCP target for IPv6 loopback (`::1`).
175 ///
176 /// # Errors
177 ///
178 /// Returns an error if the port is invalid (0 or > 65535)
179 ///
180 /// # Examples
181 ///
182 /// ```rust
183 /// use waitup::Target;
184 ///
185 /// let target = Target::loopback_v6(8080)?;
186 /// # Ok::<(), waitup::WaitForError>(())
187 /// ```
188 pub fn loopback_v6(port: u16) -> Result<Self> {
189 let port = Port::try_from(port).with_context(|| format!("Invalid port {port}"))?;
190 Ok(Self::Tcp {
191 host: Hostname::loopback_v6(),
192 port,
193 })
194 }
195
196 /// Create a TCP target with validated types (no additional validation).
197 ///
198 /// # Examples
199 ///
200 /// ```rust
201 /// use waitup::{Target, Hostname, Port};
202 ///
203 /// let hostname = Hostname::localhost();
204 /// let port = Port::new(8080).unwrap();
205 /// let target = Target::from_parts(hostname, port);
206 /// ```
207 #[must_use]
208 pub const fn from_parts(host: Hostname, port: Port) -> Self {
209 Self::Tcp { host, port }
210 }
211
212 /// Create a new HTTP target with expected status code 200.
213 ///
214 /// # Errors
215 ///
216 /// Returns an error if the URL scheme is not HTTP/HTTPS or if the status code is invalid
217 ///
218 /// # Examples
219 ///
220 /// ```rust
221 /// use waitup::Target;
222 /// use url::Url;
223 ///
224 /// let url = Url::parse("https://api.example.com/health")?;
225 /// let target = Target::http(url, 200)?;
226 /// # Ok::<(), waitup::WaitForError>(())
227 /// ```
228 pub fn http(url: Url, expected_status: u16) -> Result<Self> {
229 Self::validate_http_config(&url, expected_status, None)?;
230 Ok(Self::Http {
231 url,
232 expected_status,
233 headers: None,
234 })
235 }
236
237 /// Create a new HTTP target from a URL string.
238 ///
239 /// # Errors
240 ///
241 /// Returns an error if the URL cannot be parsed or if validation fails
242 ///
243 /// # Examples
244 ///
245 /// ```rust
246 /// use waitup::Target;
247 ///
248 /// let target = Target::http_url("https://api.example.com/health", 200)?;
249 /// # Ok::<(), waitup::WaitForError>(())
250 /// ```
251 pub fn http_url(url: impl AsRef<str>, expected_status: u16) -> Result<Self> {
252 let url = Url::parse(url.as_ref())
253 .with_context(|| format!("Invalid URL: {url}", url = url.as_ref()))?;
254 Self::http(url, expected_status)
255 }
256
257 /// Validate HTTP target configuration
258 pub(crate) fn validate_http_config(
259 url: &Url,
260 expected_status: u16,
261 headers: Option<&crate::types::HttpHeaders>,
262 ) -> Result<()> {
263 // Validate URL scheme
264 if !matches!(url.scheme(), "http" | "https") {
265 return Err(WaitForError::InvalidTarget(Cow::Owned(format!(
266 "Unsupported URL scheme: {scheme}",
267 scheme = url.scheme()
268 ))));
269 }
270
271 // Validate status code
272 if !(100..=599).contains(&expected_status) {
273 return Err(WaitForError::InvalidTarget(Cow::Owned(format!(
274 "Invalid HTTP status code: {expected_status}"
275 ))));
276 }
277
278 // Validate headers if present
279 if let Some(headers) = headers {
280 for (key, value) in headers {
281 if key.is_empty() {
282 return Err(WaitForError::InvalidTarget(Cow::Borrowed(
283 "HTTP header key cannot be empty",
284 )));
285 }
286 if value.is_empty() {
287 return Err(WaitForError::InvalidTarget(Cow::Borrowed(
288 "HTTP header value cannot be empty",
289 )));
290 }
291 // Basic header name validation (simplified)
292 if !key
293 .chars()
294 .all(|c| c.is_ascii_alphanumeric() || "-_".contains(c))
295 {
296 return Err(WaitForError::InvalidTarget(Cow::Owned(format!(
297 "Invalid HTTP header name: {key}"
298 ))));
299 }
300 }
301 }
302
303 Ok(())
304 }
305
306 /// Create a new HTTP target with custom headers.
307 ///
308 /// # Errors
309 ///
310 /// Returns an error if URL validation fails or if headers are invalid
311 ///
312 /// # Examples
313 ///
314 /// ```rust
315 /// use waitup::Target;
316 /// use url::Url;
317 ///
318 /// let url = Url::parse("https://api.example.com/health")?;
319 /// let headers = vec![("Authorization".to_string(), "Bearer token".to_string())];
320 /// let target = Target::http_with_headers(url, 200, headers)?;
321 /// # Ok::<(), waitup::WaitForError>(())
322 /// ```
323 pub fn http_with_headers(
324 url: Url,
325 expected_status: u16,
326 headers: crate::types::HttpHeaders,
327 ) -> Result<Self> {
328 Self::validate_http_config(&url, expected_status, Some(&headers))?;
329 Ok(Self::Http {
330 url,
331 expected_status,
332 headers: Some(headers),
333 })
334 }
335
336 /// Parse a target from a string.
337 ///
338 /// Supports formats:
339 /// - `host:port` for TCP targets
340 /// - `http://host/path` or `https://host/path` for HTTP targets
341 ///
342 /// # Errors
343 ///
344 /// Returns an error if the string format is invalid or if parsing fails
345 ///
346 /// # Examples
347 ///
348 /// ```rust
349 /// use waitup::Target;
350 ///
351 /// let tcp_target = Target::parse("localhost:8080", 200)?;
352 /// let http_target = Target::parse("https://api.example.com/health", 200)?;
353 /// # Ok::<(), waitup::WaitForError>(())
354 /// ```
355 pub fn parse(target_str: &str, default_http_status: u16) -> Result<Self> {
356 if target_str.starts_with("http://") || target_str.starts_with("https://") {
357 let url = Url::parse(target_str)?;
358 Ok(Self::Http {
359 url,
360 expected_status: default_http_status,
361 headers: None,
362 })
363 } else {
364 let parts: Vec<&str> = target_str.split(':').collect();
365 if parts.len() != 2 {
366 return Err(WaitForError::InvalidTarget(Cow::Owned(
367 target_str.to_string(),
368 )));
369 }
370 let hostname = Hostname::try_from(parts[0]).with_context(|| {
371 format!(
372 "Invalid hostname '{hostname}' in target '{target_str}'",
373 hostname = parts[0]
374 )
375 })?;
376 let port = parts[1]
377 .parse::<u16>()
378 .map_err(|_| WaitForError::InvalidTarget(Cow::Owned(target_str.to_string())))
379 .with_context(|| {
380 format!(
381 "Invalid port '{port}' in target '{target_str}'",
382 port = parts[1]
383 )
384 })?;
385 let port = Port::try_from(port)
386 .with_context(|| format!("Port {port} out of valid range (1-65535)"))?;
387 Ok(Self::Tcp {
388 host: hostname,
389 port,
390 })
391 }
392 }
393
394 /// Get a string representation of this target for display purposes.
395 #[must_use]
396 pub fn display(&self) -> String {
397 crate::zero_cost::TargetDisplay::new(self).to_string()
398 }
399
400 /// Get the hostname for this target (useful for logging and grouping)
401 #[must_use]
402 pub fn hostname(&self) -> &str {
403 match self {
404 Self::Tcp { host, .. } => host.as_str(),
405 Self::Http { url, .. } => url.host_str().unwrap_or("unknown"),
406 }
407 }
408
409 /// Get the port for this target
410 #[must_use]
411 pub fn port(&self) -> Option<u16> {
412 match self {
413 Self::Tcp { port, .. } => Some(port.get()),
414 Self::Http { url, .. } => url.port(),
415 }
416 }
417
418 /// Create a builder for HTTP targets
419 #[must_use]
420 pub const fn http_builder(url: Url) -> HttpTargetBuilder {
421 HttpTargetBuilder::new(url)
422 }
423
424 /// Create a builder for TCP targets
425 ///
426 /// # Errors
427 ///
428 /// Returns an error if the hostname is invalid
429 pub fn tcp_builder(host: impl AsRef<str>) -> Result<TcpTargetBuilder> {
430 let hostname = Hostname::new(host.as_ref())
431 .with_context(|| format!("Invalid hostname '{host}'", host = host.as_ref()))?;
432 Ok(TcpTargetBuilder::new(hostname))
433 }
434}
435
436/// Builder for HTTP targets
437#[derive(Debug)]
438pub struct HttpTargetBuilder {
439 url: Url,
440 expected_status: u16,
441 headers: crate::types::HttpHeaders,
442}
443
444impl HttpTargetBuilder {
445 pub(crate) const fn new(url: Url) -> Self {
446 Self {
447 url,
448 expected_status: 200,
449 headers: Vec::new(),
450 }
451 }
452
453 /// Set the expected HTTP status code
454 #[must_use]
455 pub const fn status(mut self, status: u16) -> Self {
456 self.expected_status = status;
457 self
458 }
459
460 /// Add a header
461 #[must_use]
462 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
463 self.headers.push((key.into(), value.into()));
464 self
465 }
466
467 /// Add multiple headers
468 #[must_use]
469 pub fn headers(mut self, headers: impl IntoIterator<Item = (String, String)>) -> Self {
470 self.headers.extend(headers);
471 self
472 }
473
474 /// Set authorization header with Bearer token
475 #[must_use]
476 pub fn auth_bearer(self, token: impl AsRef<str>) -> Self {
477 self.header(
478 "Authorization",
479 crate::lazy_format!("Bearer {}", token.as_ref()).to_string(),
480 )
481 }
482
483 /// Set content type header
484 #[must_use]
485 pub fn content_type(self, content_type: impl Into<String>) -> Self {
486 self.header("Content-Type", content_type)
487 }
488
489 /// Set user agent header
490 #[must_use]
491 pub fn user_agent(self, user_agent: impl Into<String>) -> Self {
492 self.header("User-Agent", user_agent)
493 }
494
495 /// Build the HTTP target
496 ///
497 /// # Errors
498 ///
499 /// Returns an error if validation fails
500 pub fn build(self) -> Result<Target> {
501 let headers = if self.headers.is_empty() {
502 None
503 } else {
504 Some(self.headers)
505 };
506 Target::validate_http_config(&self.url, self.expected_status, headers.as_ref())?;
507 Ok(Target::Http {
508 url: self.url,
509 expected_status: self.expected_status,
510 headers,
511 })
512 }
513}
514
515/// Builder for TCP targets
516#[derive(Debug)]
517pub struct TcpTargetBuilder {
518 host: Hostname,
519 port: Option<Port>,
520 port_validation_error: Option<WaitForError>,
521}
522
523impl TcpTargetBuilder {
524 pub(crate) const fn new(host: Hostname) -> Self {
525 Self {
526 host,
527 port: None,
528 port_validation_error: None,
529 }
530 }
531
532 /// Set the port
533 #[must_use]
534 pub fn port(mut self, port: u16) -> Self {
535 match Port::try_from(port) {
536 Ok(p) => {
537 self.port = Some(p);
538 self.port_validation_error = None;
539 }
540 Err(e) => {
541 self.port_validation_error = Some(e);
542 }
543 }
544 self
545 }
546
547 /// Set a well-known port (0-1023)
548 #[must_use]
549 pub fn well_known_port(mut self, port: u16) -> Self {
550 match Port::system_port(port) {
551 Some(p) => {
552 self.port = Some(p);
553 self.port_validation_error = None;
554 }
555 None => {
556 self.port_validation_error = Some(WaitForError::InvalidPort(port));
557 }
558 }
559 self
560 }
561
562 /// Set a registered port (1024-49151)
563 #[must_use]
564 pub fn registered_port(mut self, port: u16) -> Self {
565 match Port::user_port(port) {
566 Some(p) => {
567 self.port = Some(p);
568 self.port_validation_error = None;
569 }
570 None => {
571 self.port_validation_error = Some(WaitForError::InvalidPort(port));
572 }
573 }
574 self
575 }
576
577 /// Set a dynamic port (49152-65535)
578 #[must_use]
579 pub fn dynamic_port(mut self, port: u16) -> Self {
580 match Port::dynamic_port(port) {
581 Some(p) => {
582 self.port = Some(p);
583 self.port_validation_error = None;
584 }
585 None => {
586 self.port_validation_error = Some(WaitForError::InvalidPort(port));
587 }
588 }
589 self
590 }
591
592 /// Build the TCP target
593 ///
594 /// # Errors
595 ///
596 /// Returns an error if no port was specified or if validation fails
597 pub fn build(self) -> Result<Target> {
598 // Check for validation errors first
599 if let Some(error) = self.port_validation_error {
600 return Err(error);
601 }
602
603 let port = self
604 .port
605 .ok_or_else(|| WaitForError::InvalidTarget(Cow::Borrowed("Port must be specified")))?;
606 Ok(Target::Tcp {
607 host: self.host,
608 port,
609 })
610 }
611}