clawspec_core/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! # Clawspec Core
4//!
5//! Generate OpenAPI specifications from your HTTP client test code.
6//!
7//! This crate provides two main ways to generate OpenAPI documentation:
8//! - **[`ApiClient`]** - Direct HTTP client for fine-grained control
9//! - **[`TestClient`](test_client::TestClient)** - Test server integration with automatic lifecycle management
10//!
11//! **New to Clawspec?** Start with the **[Tutorial][_tutorial]** for a step-by-step guide.
12//!
13//! ## Quick Start
14//!
15//! ### Using ApiClient directly
16//!
17//! ```rust,no_run
18//! use clawspec_core::ApiClient;
19//! # use serde::Deserialize;
20//! # use utoipa::ToSchema;
21//! # #[derive(Deserialize, ToSchema)]
22//! # struct User { id: u32, name: String }
23//!
24//! # #[tokio::main]
25//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
26//! let mut client = ApiClient::builder()
27//! .with_host("api.example.com")
28//! .build()?;
29//!
30//! // Make requests - schemas are captured automatically
31//! let user: User = client
32//! .get("/users/123")?
33//! .await? // ← Direct await using IntoFuture
34//! .as_json() // ← Important: Must consume result for OpenAPI generation!
35//! .await?;
36//!
37//! // Generate OpenAPI specification
38//! let spec = client.collected_openapi().await;
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! ### Using TestClient with a test server
44//!
45//! For a complete working example, see the [axum example](https://github.com/ilaborie/clawspec/tree/main/examples/axum-example).
46//!
47//! ```rust,no_run
48//! use clawspec_core::test_client::{TestClient, TestServer};
49//! use std::net::TcpListener;
50//!
51//! # #[derive(Debug)]
52//! # struct MyServer;
53//! # impl TestServer for MyServer {
54//! # type Error = std::io::Error;
55//! # async fn launch(&self, listener: TcpListener) -> Result<(), Self::Error> {
56//! # Ok(())
57//! # }
58//! # }
59//! #[tokio::test]
60//! async fn test_api() -> Result<(), Box<dyn std::error::Error>> {
61//! let mut client = TestClient::start(MyServer).await?;
62//!
63//! // Test your API
64//! let response = client.get("/users")?.await?.as_json::<serde_json::Value>().await?;
65//!
66//! // Write OpenAPI spec
67//! client.write_openapi("api.yml").await?;
68//! Ok(())
69//! }
70//! ```
71//!
72//! ## Working with Parameters
73//!
74//! ```rust
75//! use clawspec_core::{ApiClient, CallPath, CallQuery, CallHeaders, CallCookies, ParamValue, ParamStyle};
76//!
77//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
78//! // Path parameters
79//! let path = CallPath::from("/users/{id}")
80//! .add_param("id", ParamValue::new(123));
81//!
82//! // Query parameters
83//! let query = CallQuery::new()
84//! .add_param("page", ParamValue::new(1))
85//! .add_param("limit", ParamValue::new(10));
86//!
87//! // Headers
88//! let headers = CallHeaders::new()
89//! .add_header("Authorization", "Bearer token");
90//!
91//! // Cookies
92//! let cookies = CallCookies::new()
93//! .add_cookie("session_id", "abc123")
94//! .add_cookie("user_id", 456);
95//!
96//! // Direct await with parameters:
97//! let response = client
98//! .get(path)?
99//! .with_query(query)
100//! .with_headers(headers)
101//! .with_cookies(cookies)
102//! .await?; // Direct await using IntoFuture
103//! # Ok(())
104//! # }
105//! ```
106//!
107//! ## Parameter Styles
108//!
109//! The library supports OpenAPI 3.1.0 parameter styles. Use [`ParamStyle`] for advanced serialization:
110//!
111//! ```rust
112//! use clawspec_core::{CallPath, CallQuery, ParamValue, ParamStyle};
113//!
114//! // Path: simple (default), label, matrix
115//! let path = CallPath::from("/users/{id}").add_param("id", ParamValue::new(123));
116//!
117//! // Query: form (default), spaceDelimited, pipeDelimited, deepObject
118//! let query = CallQuery::new()
119//! .add_param("tags", ParamValue::with_style(vec!["a", "b"], ParamStyle::PipeDelimited));
120//! ```
121//!
122//! See [Chapter 4: Advanced Parameters](crate::_tutorial::chapter_4) for detailed examples.
123//!
124//! ## Authentication
125//!
126//! Configure authentication at the client or per-request level:
127//!
128//! ```rust
129//! use clawspec_core::{ApiClient, Authentication};
130//!
131//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
132//! let mut client = ApiClient::builder()
133//! .with_host("api.example.com")
134//! .with_authentication(Authentication::Bearer("token".into()))
135//! .build()?;
136//!
137//! // Override per-request
138//! client.get("/admin")?.with_authentication(Authentication::Bearer("admin-token".into())).await?;
139//! # Ok(())
140//! # }
141//! ```
142//!
143//! Supported types: `Bearer`, `Basic`, `ApiKey`. See [Chapter 4](crate::_tutorial::chapter_4) for details.
144//!
145//! ## Status Code Validation
146//!
147//! By default, requests expect status codes in the range 200-499 (inclusive of 200, exclusive of 500).
148//! You can customize this behavior:
149//!
150//! ```rust
151//! use clawspec_core::{ApiClient, expected_status_codes};
152//!
153//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
154//! // Single codes
155//! client.post("/users")?
156//! .with_expected_status_codes(expected_status_codes!(201, 202))
157//! .await?;
158//!
159//! // Ranges
160//! client.get("/health")?
161//! .with_expected_status_codes(expected_status_codes!(200-299))
162//! .await?;
163//! # Ok(())
164//! # }
165//! ```
166//!
167//! ## Response Descriptions
168//!
169//! Add descriptive text to your OpenAPI responses for better documentation:
170//!
171//! ```rust
172//! # // `with_response_description` requires the `redaction` feature; gate the example so
173//! # // `cargo test --doc` (default features) still compiles, while it is compile-checked
174//! # // under `--features redaction` / `--all-features`.
175//! # #[cfg(feature = "redaction")]
176//! use clawspec_core::ApiClient;
177//!
178//! # #[cfg(feature = "redaction")]
179//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
180//! // Set a description for the actual returned status code
181//! client.get("/users/{id}")?
182//! .with_response_description("User details if found, or error information")
183//! .await?;
184//!
185//! // The description applies to whatever status code is actually returned
186//! client.post("/users")?
187//! .with_response_description("User created successfully or validation error")
188//! .await?;
189//! # Ok(())
190//! # }
191//! ```
192//!
193//! ## Response Redaction
194//!
195//! *Requires the `redaction` feature.*
196//!
197//! When generating OpenAPI examples from real API responses, dynamic values like UUIDs,
198//! timestamps, and tokens make examples unstable across test runs. The redaction feature
199//! allows you to replace these dynamic values with stable, predictable ones in the generated
200//! OpenAPI specification while preserving the actual values for assertions.
201//!
202//! This is particularly useful for:
203//! - **Snapshot testing**: Generated OpenAPI files remain stable across runs
204//! - **Documentation**: Examples show consistent, readable placeholder values
205//! - **Security**: Sensitive values can be masked in documentation
206//!
207//! ### Basic Usage
208//!
209#![cfg_attr(feature = "redaction", doc = "```rust")]
210#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
211//! use clawspec_core::ApiClient;
212//! # use serde::Deserialize;
213//! # use utoipa::ToSchema;
214//!
215//! #[derive(Deserialize, ToSchema)]
216//! struct User {
217//! id: String, // Dynamic UUID
218//! name: String,
219//! created_at: String, // Dynamic timestamp
220//! }
221//!
222//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
223//! let user: User = client
224//! .post("/users")?
225//! .json(&serde_json::json!({"name": "Alice"}))?
226//! .await?
227//! .as_json_redacted()
228//! .await?
229//! // Replace dynamic UUID with stable value
230//! .redact("/id", "00000000-0000-0000-0000-000000000001")?
231//! // Replace timestamp with stable value
232//! .redact("/created_at", "2024-01-01T00:00:00Z")?
233//! .finish()
234//! .await
235//! .value;
236//!
237//! // The actual user has real dynamic values for assertions
238//! assert!(!user.id.is_empty());
239//! // But the OpenAPI example shows the redacted stable values
240//! # Ok(())
241//! # }
242//! ```
243//!
244//! ### Request Body Redaction
245//!
246//! The same pattern works for request bodies using `json_redacted()`:
247//!
248#![cfg_attr(feature = "redaction", doc = "```rust")]
249#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
250//! use clawspec_core::ApiClient;
251//! # use serde::Serialize;
252//! # use utoipa::ToSchema;
253//!
254//! #[derive(Clone, Serialize, ToSchema)]
255//! struct LoginRequest {
256//! username: String,
257//! password: String,
258//! }
259//!
260//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
261//! let request = LoginRequest {
262//! username: "alice".to_string(),
263//! password: "my-secret-password".to_string(),
264//! };
265//!
266//! // The HTTP request contains the real password,
267//! // but the OpenAPI example shows "[REDACTED]"
268//! client
269//! .post("/auth/login")?
270//! .json_redacted(&request)?
271//! .redact("/password", "[REDACTED]")?
272//! .finish()?
273//! .await?;
274//! # Ok(())
275//! # }
276//! ```
277//!
278//! ### Redaction Operations
279//!
280//! The redaction builder supports two operations using [JSON Pointer (RFC 6901)](https://tools.ietf.org/html/rfc6901)
281//! or [JSONPath (RFC 9535)](https://www.rfc-editor.org/rfc/rfc9535):
282//!
283//! - **`redact(path, redactor)`**: Replace a value at the given path with a stable value or transformation
284//! - **`redact_remove(path)`**: Remove a value entirely from the OpenAPI example
285//!
286#![cfg_attr(feature = "redaction", doc = "```rust")]
287#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
288//! # use clawspec_core::ApiClient;
289//! # use serde::Deserialize;
290//! # use utoipa::ToSchema;
291//! # #[derive(Deserialize, ToSchema)]
292//! # struct Response { token: String, session_id: String, internal_ref: String }
293//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
294//! let response: Response = client
295//! .post("/auth/login")?
296//! .json(&serde_json::json!({"username": "test", "password": "secret"}))?
297//! .await?
298//! .as_json_redacted()
299//! .await?
300//! .redact("/token", "[REDACTED_TOKEN]")?
301//! .redact("/session_id", "session-00000000")?
302//! .redact_remove("/internal_ref")? // Remove internal field from docs
303//! .finish()
304//! .await
305//! .value;
306//! # Ok(())
307//! # }
308//! ```
309//!
310//! ### Path Syntax
311//!
312//! Paths are auto-detected based on their prefix:
313//! - `/...` → JSON Pointer (RFC 6901) - exact paths only
314//! - `$...` → JSONPath (RFC 9535) - supports wildcards
315//!
316//! ### JSON Pointer Syntax
317//!
318//! JSON Pointers use `/` as a path separator. Special characters are escaped:
319//! - `~0` represents `~`
320//! - `~1` represents `/`
321//!
322//! Examples:
323//! - `/id` - Top-level field named "id"
324//! - `/user/name` - Nested field "name" inside "user"
325//! - `/items/0/id` - First element's "id" in an array
326//! - `/foo~1bar` - Field named "foo/bar"
327//!
328//! ### JSONPath Wildcards
329//!
330//! For arrays, use JSONPath syntax (starting with `$`) to redact all elements:
331//!
332#![cfg_attr(feature = "redaction", doc = "```rust")]
333#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
334//! # use clawspec_core::ApiClient;
335//! # use serde::Deserialize;
336//! # use utoipa::ToSchema;
337//! # #[derive(Deserialize, ToSchema)]
338//! # struct User { id: String, created_at: String }
339//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
340//! let users: Vec<User> = client
341//! .get("/users")?
342//! .await?
343//! .as_json_redacted()
344//! .await?
345//! .redact("$[*].id", "stable-uuid")? // All IDs in array
346//! .redact("$[*].created_at", "2024-01-01T00:00:00Z")? // All timestamps
347//! .finish()
348//! .await
349//! .value;
350//! # Ok(())
351//! # }
352//! ```
353//!
354//! ### Dynamic Transformations
355//!
356//! Pass a closure for dynamic redaction. The closure receives the concrete
357//! JSON Pointer path and current value:
358//!
359#![cfg_attr(feature = "redaction", doc = "```rust")]
360#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
361//! # use clawspec_core::ApiClient;
362//! # use serde::Deserialize;
363//! # use serde_json::Value;
364//! # use utoipa::ToSchema;
365//! # #[derive(Deserialize, ToSchema)]
366//! # struct User { id: String }
367//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
368//! let users: Vec<User> = client
369//! .get("/users")?
370//! .await?
371//! .as_json_redacted()
372//! .await?
373//! // Create stable index-based IDs: user-0, user-1, user-2, ...
374//! .redact("$[*].id", |path: &str, _val: &Value| {
375//! let idx = path.split('/').nth(1).unwrap_or("0");
376//! serde_json::json!(format!("user-{idx}"))
377//! })?
378//! .finish()
379//! .await
380//! .value;
381//! # Ok(())
382//! # }
383//! ```
384//!
385#![cfg_attr(feature = "redaction", doc = "### Getting Both Values")]
386#![cfg_attr(feature = "redaction", doc = "")]
387#![cfg_attr(
388 feature = "redaction",
389 doc = "The [`RedactedResult`] returned by `finish()` contains both:"
390)]
391#![cfg_attr(
392 feature = "redaction",
393 doc = "- `value`: The actual deserialized response (with real dynamic values)"
394)]
395#![cfg_attr(
396 feature = "redaction",
397 doc = "- `redacted`: The JSON with redacted values (as stored in OpenAPI)"
398)]
399//!
400#![cfg_attr(feature = "redaction", doc = "```rust")]
401#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
402//! # use clawspec_core::ApiClient;
403//! # use serde::Deserialize;
404//! # use utoipa::ToSchema;
405//! # #[derive(Deserialize, ToSchema)]
406//! # struct User { id: String }
407//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
408//! let result = client
409//! .get("/users/123")?
410//! .await?
411//! .as_json_redacted::<User>()
412//! .await?
413//! .redact("/id", "user-00000000")?
414//! .finish()
415//! .await;
416//!
417//! // Use actual value for test assertions
418//! let user = result.value;
419//! assert!(!user.id.is_empty());
420//!
421//! // Access redacted JSON if needed
422//! let redacted_json = result.redacted;
423//! assert_eq!(redacted_json["id"], "user-00000000");
424//! # Ok(())
425//! # }
426//! ```
427//!
428//! ## Schema Registration
429//!
430//! Schemas are **automatically captured** when using `.json()` and `.as_json()` methods,
431//! including types nested inside them (fields, `Vec<T>`, `Option<T>`, enum variants, etc.
432//! that also derive `ToSchema`) — clawspec walks that graph for you via utoipa's own
433//! recursive schema collection.
434//!
435//! For types that aren't *statically* reachable through a `ToSchema`-deriving field —
436//! e.g. an error type only ever constructed dynamically, or a type behind a
437//! `serde_json::Value` field — use `register_schemas!` to add them explicitly:
438//!
439//! ```rust
440//! use clawspec_core::{ApiClient, register_schemas};
441//! # use serde::Deserialize;
442//! # use utoipa::ToSchema;
443//! # #[derive(Deserialize, ToSchema)]
444//! # struct ApiError { code: String }
445//! # #[derive(Deserialize, ToSchema)]
446//! # struct ValidationError { field: String }
447//!
448//! # async fn example(client: &mut ApiClient) {
449//! // Error types never returned by an exercised endpoint — not statically reachable.
450//! register_schemas!(client, ApiError, ValidationError).await;
451//! # }
452//! ```
453//!
454//! **Recursive types**: if a `ToSchema` type is directly or mutually recursive (e.g. a tree
455//! node holding `Option<Box<Self>>`, or `Pet` -> `Owner` -> `Pet`), the recursive field
456//! *must* be annotated with utoipa's own `#[schema(no_recursion)]` attribute. This is a
457//! requirement of utoipa's `ToSchema::schemas()` itself (it has no built-in cycle
458//! detection — see [utoipa#1134](https://github.com/juhaku/utoipa/issues/1134)), not
459//! something clawspec adds: the same care is needed with `#[derive(OpenApi)]`'s
460//! `components(schemas(...))`. Skipping it causes a stack overflow the first time the
461//! type is captured.
462//!
463//! **Collision diagnostics**: when two distinct types resolve to the same schema name with
464//! different shapes, clawspec keeps the first and emits a `tracing` warning at `WARN` — it
465//! never fails the run. These warnings only surface when a subscriber is installed; note that a
466//! `tracing-subscriber` configured with `.with_test_writer()` buffers output and shows it *only
467//! on test failure*, so a passing test that generates a spec will not display them. Resolve a
468//! reported collision by disambiguating one type with utoipa's `#[schema(as = "module::Type")]`.
469//!
470//! ## Error Handling
471//!
472//! The library provides two main error types:
473//! - [`ApiClientError`] - HTTP client errors (network, parsing, validation)
474//! - [`TestAppError`](test_client::TestAppError) - Test server lifecycle errors
475//!
476//! ## YAML Serialization
477//!
478//! *Requires the `yaml` feature.*
479//!
480//! The library provides YAML serialization support using [serde-saphyr](https://github.com/saphyr-rs/serde_saphyr),
481//! the modern replacement for the deprecated `serde_yaml` crate.
482//!
483#![cfg_attr(feature = "yaml", doc = "```rust")]
484#![cfg_attr(not(feature = "yaml"), doc = "```rust,ignore")]
485//! use clawspec_core::{ApiClient, ToYaml};
486//!
487//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
488//! let mut client = ApiClient::builder()
489//! .with_host("api.example.com")
490//! .build()?;
491//!
492//! // ... make API calls ...
493//!
494//! let spec = client.collected_openapi().await;
495//! let yaml = spec.to_yaml()?;
496//!
497//! std::fs::write("openapi.yml", yaml)?;
498//! # Ok(())
499//! # }
500//! ```
501//!
502//! ## See Also
503//!
504//! - [`ApiClient`] - HTTP client with OpenAPI collection
505//! - [`ApiCall`] - Request builder with parameter support
506//! - [`test_client`] - Test server integration module
507//! - [`ExpectedStatusCodes`] - Status code validation
508#![cfg_attr(
509 feature = "redaction",
510 doc = "- [`RedactionBuilder`] - Builder for redacting response values in OpenAPI examples"
511)]
512#![cfg_attr(
513 feature = "redaction",
514 doc = "- [`RedactedResult`] - Result containing both actual and redacted values"
515)]
516#![cfg_attr(
517 feature = "redaction",
518 doc = "- [`RedactOptions`] - Options for configuring redaction behavior"
519)]
520#![cfg_attr(
521 feature = "redaction",
522 doc = "- [`Redactor`] - Trait for types that can be used to redact values"
523)]
524#![cfg_attr(
525 feature = "redaction",
526 doc = "- [`redact_value`] - Entry point for redacting arbitrary JSON values"
527)]
528#![cfg_attr(
529 feature = "redaction",
530 doc = "- [`ValueRedactionBuilder`] - Builder for redacting arbitrary JSON values (e.g., OpenAPI specs)"
531)]
532#![cfg_attr(
533 feature = "yaml",
534 doc = "- [`ToYaml`] - Extension trait for YAML serialization"
535)]
536#![cfg_attr(
537 feature = "yaml",
538 doc = "- [`YamlError`] - Error type for YAML serialization"
539)]
540//!
541//! ## Re-exports
542//!
543//! All commonly used types are re-exported from the crate root for convenience.
544
545// TODO: Add comprehensive unit tests for all modules - https://github.com/ilaborie/clawspec/issues/30
546
547pub mod _tutorial;
548
549mod client;
550
551pub mod split;
552
553#[cfg(feature = "yaml")]
554#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
555mod yaml;
556
557pub mod test_client;
558
559// Public API - only expose user-facing types and functions
560pub use self::client::{
561 ApiCall, ApiClient, ApiClientBuilder, ApiClientError, ApiKeyLocation, Authentication,
562 AuthenticationError, CallBody, CallCookies, CallHeaders, CallPath, CallQuery, CallResult,
563 ExpectedStatusCodes, OAuth2Flow, OAuth2Flows, OAuth2ImplicitFlow, ParamStyle, ParamValue,
564 ParameterValue, RawBody, RawResult, SecureString, SecurityRequirement, SecurityScheme,
565};
566
567// Re-export external types so users don't need to add these crates to their Cargo.toml.
568//
569// With these re-exports, users can write:
570// use clawspec_core::{ApiClient, OpenApi, ToSchema, StatusCode};
571// Instead of:
572// use clawspec_core::ApiClient;
573// use utoipa::openapi::OpenApi;
574// use utoipa::ToSchema;
575// use http::StatusCode;
576
577/// OpenAPI types re-exported from utoipa for convenience.
578pub use utoipa::openapi::{Info, InfoBuilder, OpenApi, Paths, Server, ServerBuilder};
579
580/// The `ToSchema` derive macro for generating OpenAPI schemas.
581/// Types used in JSON request/response bodies should derive this trait.
582pub use utoipa::ToSchema;
583
584/// HTTP status codes re-exported from the `http` crate.
585pub use http::StatusCode;
586
587#[cfg(feature = "redaction")]
588pub use self::client::{
589 RedactOptions, RedactedResult, RedactionBuilder, Redactor, RequestBodyRedactionBuilder,
590 ValueRedactionBuilder, redact_value,
591};
592
593#[cfg(feature = "oauth2")]
594pub use self::client::{OAuth2Config, OAuth2ConfigBuilder, OAuth2Error, OAuth2Token};
595
596#[cfg(feature = "yaml")]
597#[cfg_attr(docsrs, doc(cfg(feature = "yaml")))]
598pub use self::yaml::{ToYaml, YamlError};
599
600// Convenience macro re-exports are handled by the macro_rules! definitions below
601
602/// Creates an [`ExpectedStatusCodes`] instance with the specified status codes and ranges.
603///
604/// This macro provides a convenient syntax for defining expected HTTP status codes
605/// with support for individual codes, inclusive ranges, and exclusive ranges.
606///
607/// # Syntax
608///
609/// - Single codes: `200`, `201`, `404`
610/// - Inclusive ranges: `200-299` (includes both endpoints)
611/// - Exclusive ranges: `200..300` (excludes 300)
612/// - Mixed: `200, 201-204, 400..500`
613///
614/// # Examples
615///
616/// ```rust
617/// use clawspec_core::expected_status_codes;
618///
619/// // Single status codes
620/// let codes = expected_status_codes!(200, 201, 204);
621///
622/// // Ranges
623/// let success_codes = expected_status_codes!(200-299);
624/// let client_errors = expected_status_codes!(400..500);
625///
626/// // Mixed
627/// let mixed = expected_status_codes!(200-204, 301, 302, 400-404);
628/// ```
629#[macro_export]
630macro_rules! expected_status_codes {
631 // Empty case
632 () => {
633 $crate::ExpectedStatusCodes::default()
634 };
635
636 // Single element
637 ($single:literal) => {
638 $crate::ExpectedStatusCodes::from_single($single)
639 };
640
641 // Single range (inclusive)
642 ($start:literal - $end:literal) => {
643 $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end)
644 };
645
646 // Single range (exclusive)
647 ($start:literal .. $end:literal) => {
648 $crate::ExpectedStatusCodes::from_exclusive_range($start..$end)
649 };
650
651 // Multiple elements - single code followed by more
652 ($first:literal, $($rest:tt)*) => {{
653 #[allow(unused_mut)]
654 let mut codes = $crate::ExpectedStatusCodes::from_single($first);
655 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
656 codes
657 }};
658
659 // Multiple elements - inclusive range followed by more
660 ($start:literal - $end:literal, $($rest:tt)*) => {{
661 #[allow(unused_mut)]
662 let mut codes = $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end);
663 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
664 codes
665 }};
666
667 // Multiple elements - exclusive range followed by more
668 ($start:literal .. $end:literal, $($rest:tt)*) => {{
669 #[allow(unused_mut)]
670 let mut codes = $crate::ExpectedStatusCodes::from_exclusive_range($start..$end);
671 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
672 codes
673 }};
674
675 // Internal accumulator - empty (base case for trailing commas)
676 (@accumulate $codes:ident,) => {
677 // Do nothing for trailing commas
678 };
679
680 // Internal accumulator - single code
681 (@accumulate $codes:ident, $single:literal) => {
682 $codes = $codes.add_single($single);
683 };
684
685 // Internal accumulator - single code followed by more
686 (@accumulate $codes:ident, $single:literal, $($rest:tt)*) => {
687 $codes = $codes.add_single($single);
688 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
689 };
690
691 // Internal accumulator - inclusive range
692 (@accumulate $codes:ident, $start:literal - $end:literal) => {
693 $codes = $codes.add_inclusive_range($start..=$end);
694 };
695
696 // Internal accumulator - inclusive range followed by more
697 (@accumulate $codes:ident, $start:literal - $end:literal, $($rest:tt)*) => {
698 $codes = $codes.add_inclusive_range($start..=$end);
699 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
700 };
701
702 // Internal accumulator - exclusive range
703 (@accumulate $codes:ident, $start:literal .. $end:literal) => {
704 $codes = $codes.add_exclusive_range($start..$end);
705 };
706
707 // Internal accumulator - exclusive range followed by more
708 (@accumulate $codes:ident, $start:literal .. $end:literal, $($rest:tt)*) => {
709 $codes = $codes.add_exclusive_range($start..$end);
710 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
711 };
712
713 // Internal accumulator - empty (catch all for trailing cases)
714 (@accumulate $codes:ident) => {
715 // Base case - do nothing
716 };
717}
718
719/// Registers multiple schema types with the ApiClient for OpenAPI documentation.
720///
721/// This macro simplifies the process of registering multiple types that implement
722/// [`utoipa::ToSchema`] with an [`ApiClient`] instance.
723///
724/// # When to Use
725///
726/// Most JSON request/response schemas are captured automatically when using `.json()` and
727/// `.as_json()` methods — including types nested inside them (fields, `Vec<T>`, `Option<T>`,
728/// enum variants, etc. that also derive [`utoipa::ToSchema`]), since clawspec walks that graph
729/// for you. Use this macro only for types that aren't *statically* reachable through a
730/// `ToSchema`-deriving field, such as:
731///
732/// - **Error Types**: An error response type never returned by any exercised endpoint
733/// - **Dynamic Types**: A type reached only through a `serde_json::Value` field or similar
734/// type-erased boundary
735///
736/// # Examples
737///
738/// ```rust
739/// use clawspec_core::{ApiClient, register_schemas};
740/// use serde::Deserialize;
741/// use utoipa::ToSchema;
742///
743/// // An error type never returned by any exercised endpoint.
744/// #[derive(Deserialize, ToSchema)]
745/// struct ApiError {
746/// code: String,
747/// message: String,
748/// }
749///
750/// // A type only ever reached behind a `serde_json::Value` boundary.
751/// #[derive(Deserialize, ToSchema)]
752/// struct WebhookPayload {
753/// event: String,
754/// }
755///
756/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
757/// let mut client = ApiClient::builder().build()?;
758///
759/// // Register multiple not-statically-reachable schemas at once.
760/// register_schemas!(client, ApiError, WebhookPayload).await;
761/// # Ok(())
762/// # }
763/// ```
764#[macro_export]
765macro_rules! register_schemas {
766 ($client:expr, $($schema:ty),+ $(,)?) => {
767 async {
768 $(
769 $client.register_schema::<$schema>().await;
770 )+
771 }
772 };
773}
774
775#[cfg(test)]
776mod macro_tests {
777 use super::*;
778
779 #[test]
780 fn test_expected_status_codes_single() {
781 let codes = expected_status_codes!(200);
782 assert!(codes.contains(200));
783 assert!(!codes.contains(201));
784 }
785
786 #[test]
787 fn test_expected_status_codes_multiple_single() {
788 let codes = expected_status_codes!(200, 201, 204);
789 assert!(codes.contains(200));
790 assert!(codes.contains(201));
791 assert!(codes.contains(204));
792 assert!(!codes.contains(202));
793 }
794
795 #[test]
796 fn test_expected_status_codes_range() {
797 let codes = expected_status_codes!(200 - 204);
798 assert!(codes.contains(200));
799 assert!(codes.contains(202));
800 assert!(codes.contains(204));
801 assert!(!codes.contains(205));
802 }
803
804 #[test]
805 fn test_expected_status_codes_mixed() {
806 let codes = expected_status_codes!(200, 201 - 204, 301, 400 - 404);
807 assert!(codes.contains(200));
808 assert!(codes.contains(202));
809 assert!(codes.contains(301));
810 assert!(codes.contains(402));
811 assert!(!codes.contains(305));
812 }
813
814 #[test]
815 fn test_expected_status_codes_trailing_comma() {
816 let codes = expected_status_codes!(200, 201,);
817 assert!(codes.contains(200));
818 assert!(codes.contains(201));
819 }
820
821 #[test]
822 fn test_expected_status_codes_range_trailing_comma() {
823 let codes = expected_status_codes!(200 - 204,);
824 assert!(codes.contains(202));
825 }
826
827 #[test]
828 fn test_expected_status_codes_five_elements() {
829 let codes = expected_status_codes!(200, 201, 202, 203, 204);
830 assert!(codes.contains(200));
831 assert!(codes.contains(201));
832 assert!(codes.contains(202));
833 assert!(codes.contains(203));
834 assert!(codes.contains(204));
835 }
836
837 #[test]
838 fn test_expected_status_codes_eight_elements() {
839 let codes = expected_status_codes!(200, 201, 202, 203, 204, 205, 206, 207);
840 assert!(codes.contains(200));
841 assert!(codes.contains(204));
842 assert!(codes.contains(207));
843 }
844
845 #[test]
846 fn test_expected_status_codes_multiple_ranges() {
847 let codes = expected_status_codes!(200 - 204, 300 - 304, 400 - 404);
848 assert!(codes.contains(202));
849 assert!(codes.contains(302));
850 assert!(codes.contains(402));
851 assert!(!codes.contains(205));
852 assert!(!codes.contains(305));
853 }
854
855 #[test]
856 fn test_expected_status_codes_edge_cases() {
857 // Empty should work
858 let _codes = expected_status_codes!();
859
860 // Single range should work
861 let codes = expected_status_codes!(200 - 299);
862 assert!(codes.contains(250));
863 }
864
865 #[test]
866 fn test_expected_status_codes_common_patterns() {
867 // Success codes
868 let success = expected_status_codes!(200 - 299);
869 assert!(success.contains(200));
870 assert!(success.contains(201));
871 assert!(success.contains(204));
872
873 // Client errors
874 let client_errors = expected_status_codes!(400 - 499);
875 assert!(client_errors.contains(400));
876 assert!(client_errors.contains(404));
877 assert!(client_errors.contains(422));
878
879 // Specific success codes
880 let specific = expected_status_codes!(200, 201, 204);
881 assert!(specific.contains(200));
882 assert!(!specific.contains(202));
883 }
884
885 #[test]
886 fn test_expected_status_codes_builder_alternative() {
887 // Using macro
888 let macro_codes = expected_status_codes!(200 - 204, 301, 302, 400 - 404);
889
890 // Using builder (should be equivalent)
891 let builder_codes = ExpectedStatusCodes::default()
892 .add_inclusive_range(200..=204)
893 .add_single(301)
894 .add_single(302)
895 .add_inclusive_range(400..=404);
896
897 // Both should have same results
898 for code in [200, 202, 204, 301, 302, 400, 402, 404] {
899 assert_eq!(macro_codes.contains(code), builder_codes.contains(code));
900 }
901 }
902}
903
904#[cfg(test)]
905mod integration_tests {
906 use super::*;
907
908 #[test]
909 fn test_expected_status_codes_real_world_patterns() {
910 // REST API common patterns
911 let rest_success = expected_status_codes!(200, 201, 204);
912 assert!(rest_success.contains(200)); // GET success
913 assert!(rest_success.contains(201)); // POST created
914 assert!(rest_success.contains(204)); // DELETE success
915
916 // GraphQL typically uses 200 for everything
917 let graphql = expected_status_codes!(200);
918 assert!(graphql.contains(200));
919 assert!(!graphql.contains(201));
920
921 // Health check endpoints
922 let health = expected_status_codes!(200, 503);
923 assert!(health.contains(200)); // Healthy
924 assert!(health.contains(503)); // Unhealthy
925
926 // Authentication endpoints
927 let auth = expected_status_codes!(200, 201, 401, 403);
928 assert!(auth.contains(200)); // Login success
929 assert!(auth.contains(401)); // Unauthorized
930 assert!(auth.contains(403)); // Forbidden
931 }
932
933 #[tokio::test]
934 async fn test_expected_status_codes_with_api_call() {
935 // This tests that the macro works correctly with actual API calls
936 let client = ApiClient::builder().build().expect("should build client");
937 let codes = expected_status_codes!(200 - 299, 404);
938
939 // Should compile and be usable
940 let _call = client
941 .get("/test")
942 .expect("should create call")
943 .with_expected_status_codes(codes);
944 }
945
946 #[test]
947 fn test_expected_status_codes_method_chaining() {
948 let codes = expected_status_codes!(200)
949 .add_single(201)
950 .add_inclusive_range(300..=304);
951
952 assert!(codes.contains(200));
953 assert!(codes.contains(201));
954 assert!(codes.contains(302));
955 }
956
957 #[test]
958 fn test_expected_status_codes_vs_manual_creation() {
959 // Macro version
960 let macro_version = expected_status_codes!(200 - 204, 301, 400);
961
962 // Manual version
963 let manual_version = ExpectedStatusCodes::from_inclusive_range(200..=204)
964 .add_single(301)
965 .add_single(400);
966
967 // Should behave identically
968 for code in 100..600 {
969 assert_eq!(
970 macro_version.contains(code),
971 manual_version.contains(code),
972 "Mismatch for status code {code}"
973 );
974 }
975 }
976}