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//! ## OpenAPI 3.1.0 Parameter Styles
108//!
109//! This library supports all OpenAPI 3.1.0 parameter styles for different parameter types:
110//!
111//! ### Path Parameters
112//!
113//! ```rust
114//! use clawspec_core::{CallPath, ParamValue, ParamStyle};
115//!
116//! # async fn example() {
117//! // Simple style (default): /users/123
118//! let path = CallPath::from("/users/{id}")
119//! .add_param("id", ParamValue::new(123));
120//!
121//! // Label style: /users/.123
122//! let path = CallPath::from("/users/{id}")
123//! .add_param("id", ParamValue::with_style(123, ParamStyle::Label));
124//!
125//! // Matrix style: /users/;id=123
126//! let path = CallPath::from("/users/{id}")
127//! .add_param("id", ParamValue::with_style(123, ParamStyle::Matrix));
128//!
129//! // Arrays with different styles
130//! let tags = vec!["rust", "web", "api"];
131//!
132//! // Simple: /search/rust,web,api
133//! let path = CallPath::from("/search/{tags}")
134//! .add_param("tags", ParamValue::with_style(tags.clone(), ParamStyle::Simple));
135//!
136//! // Label: /search/.rust,web,api
137//! let path = CallPath::from("/search/{tags}")
138//! .add_param("tags", ParamValue::with_style(tags.clone(), ParamStyle::Label));
139//!
140//! // Matrix: /search/;tags=rust,web,api
141//! let path = CallPath::from("/search/{tags}")
142//! .add_param("tags", ParamValue::with_style(tags, ParamStyle::Matrix));
143//! # }
144//! ```
145//!
146//! ### Query Parameters
147//!
148//! ```rust
149//! use clawspec_core::{CallQuery, ParamValue, ParamStyle};
150//!
151//! # async fn example() {
152//! let tags = vec!["rust", "web", "api"];
153//!
154//! // Form style (default): ?tags=rust&tags=web&tags=api
155//! let query = CallQuery::new()
156//! .add_param("tags", ParamValue::new(tags.clone()));
157//!
158//! // Space delimited: ?tags=rust%20web%20api
159//! let query = CallQuery::new()
160//! .add_param("tags", ParamValue::with_style(tags.clone(), ParamStyle::SpaceDelimited));
161//!
162//! // Pipe delimited: ?tags=rust|web|api
163//! let query = CallQuery::new()
164//! .add_param("tags", ParamValue::with_style(tags, ParamStyle::PipeDelimited));
165//!
166//! // Deep object style: ?user[name]=john&user[age]=30
167//! let user_data = serde_json::json!({"name": "john", "age": 30});
168//! let query = CallQuery::new()
169//! .add_param("user", ParamValue::with_style(user_data, ParamStyle::DeepObject));
170//! # }
171//! ```
172//!
173//! ### Cookie Parameters
174//!
175//! ```rust
176//! use clawspec_core::{CallCookies, ParamValue};
177//!
178//! # async fn example() {
179//! // Simple cookie values
180//! let cookies = CallCookies::new()
181//! .add_cookie("session_id", "abc123")
182//! .add_cookie("user_id", 456)
183//! .add_cookie("is_admin", true);
184//!
185//! // Array values in cookies (comma-separated)
186//! let cookies = CallCookies::new()
187//! .add_cookie("preferences", vec!["dark_mode", "notifications"])
188//! .add_cookie("selected_tags", vec!["rust", "web", "api"]);
189//!
190//! // Custom types with automatic serialization
191//! #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
192//! struct UserId(u64);
193//!
194//! let cookies = CallCookies::new()
195//! .add_cookie("user", UserId(12345));
196//! # }
197//! ```
198//!
199//! ## Authentication
200//!
201//! The library supports various authentication methods that can be configured at the client level
202//! or overridden for individual requests.
203//!
204//! ### Client-Level Authentication
205//!
206//! ```rust
207//! use clawspec_core::{ApiClient, Authentication};
208//!
209//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
210//! // Bearer token authentication
211//! let client = ApiClient::builder()
212//! .with_host("api.example.com")
213//! .with_authentication(Authentication::Bearer("my-api-token".into()))
214//! .build()?;
215//!
216//! // Basic authentication
217//! let client = ApiClient::builder()
218//! .with_host("api.example.com")
219//! .with_authentication(Authentication::Basic {
220//! username: "user".to_string(),
221//! password: "pass".into(),
222//! })
223//! .build()?;
224//!
225//! // API key authentication
226//! let client = ApiClient::builder()
227//! .with_host("api.example.com")
228//! .with_authentication(Authentication::ApiKey {
229//! header_name: "X-API-Key".to_string(),
230//! key: "secret-key".into(),
231//! })
232//! .build()?;
233//! # Ok(())
234//! # }
235//! ```
236//!
237//! ### Per-Request Authentication Override
238//!
239//! ```rust
240//! use clawspec_core::{ApiClient, Authentication};
241//!
242//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
243//! // Client with default authentication
244//! let mut client = ApiClient::builder()
245//! .with_host("api.example.com")
246//! .with_authentication(Authentication::Bearer("default-token".into()))
247//! .build()?;
248//!
249//! // Use different authentication for admin endpoints
250//! let admin_users = client
251//! .get("/admin/users")?
252//! .with_authentication(Authentication::Bearer("admin-token".into()))
253//! .await?
254//! .as_json::<serde_json::Value>()
255//! .await?;
256//!
257//! // Remove authentication for public endpoints
258//! let public_data = client
259//! .get("/public/health")?
260//! .with_authentication_none()
261//! .await?
262//! .as_text()
263//! .await?;
264//! # Ok(())
265//! # }
266//! ```
267//!
268//! ### Authentication Types
269//!
270//! - **Bearer**: Adds `Authorization: Bearer <token>` header
271//! - **Basic**: Adds `Authorization: Basic <base64(username:password)>` header
272//! - **ApiKey**: Adds custom header with API key
273//!
274//! ### Security Best Practices
275//!
276//! - Store credentials securely using environment variables or secret management tools
277//! - Rotate tokens regularly
278//! - Use HTTPS for all authenticated requests
279//! - Avoid logging authentication headers
280//!
281//! ## Status Code Validation
282//!
283//! By default, requests expect status codes in the range 200-499 (inclusive of 200, exclusive of 500).
284//! You can customize this behavior:
285//!
286//! ```rust
287//! use clawspec_core::{ApiClient, expected_status_codes};
288//!
289//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
290//! // Single codes
291//! client.post("/users")?
292//! .with_expected_status_codes(expected_status_codes!(201, 202))
293//! .await?;
294//!
295//! // Ranges
296//! client.get("/health")?
297//! .with_expected_status_codes(expected_status_codes!(200-299))
298//! .await?;
299//! # Ok(())
300//! # }
301//! ```
302//!
303//! ## Response Descriptions
304//!
305//! Add descriptive text to your OpenAPI responses for better documentation:
306//!
307//! ```rust
308//! use clawspec_core::ApiClient;
309//!
310//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
311//! // Set a description for the actual returned status code
312//! client.get("/users/{id}")?
313//! .with_response_description("User details if found, or error information")
314//! .await?;
315//!
316//! // The description applies to whatever status code is actually returned
317//! client.post("/users")?
318//! .with_response_description("User created successfully or validation error")
319//! .await?;
320//! # Ok(())
321//! # }
322//! ```
323//!
324//! ## Response Redaction
325//!
326//! *Requires the `redaction` feature.*
327//!
328//! When generating OpenAPI examples from real API responses, dynamic values like UUIDs,
329//! timestamps, and tokens make examples unstable across test runs. The redaction feature
330//! allows you to replace these dynamic values with stable, predictable ones in the generated
331//! OpenAPI specification while preserving the actual values for assertions.
332//!
333//! This is particularly useful for:
334//! - **Snapshot testing**: Generated OpenAPI files remain stable across runs
335//! - **Documentation**: Examples show consistent, readable placeholder values
336//! - **Security**: Sensitive values can be masked in documentation
337//!
338//! ### Basic Usage
339//!
340#![cfg_attr(feature = "redaction", doc = "```rust")]
341#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
342//! use clawspec_core::ApiClient;
343//! # use serde::Deserialize;
344//! # use utoipa::ToSchema;
345//!
346//! #[derive(Deserialize, ToSchema)]
347//! struct User {
348//! id: String, // Dynamic UUID
349//! name: String,
350//! created_at: String, // Dynamic timestamp
351//! }
352//!
353//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
354//! let user: User = client
355//! .post("/users")?
356//! .json(&serde_json::json!({"name": "Alice"}))?
357//! .await?
358//! .as_json_redacted()
359//! .await?
360//! // Replace dynamic UUID with stable value
361//! .redact("/id", "00000000-0000-0000-0000-000000000001")?
362//! // Replace timestamp with stable value
363//! .redact("/created_at", "2024-01-01T00:00:00Z")?
364//! .finish()
365//! .await
366//! .value;
367//!
368//! // The actual user has real dynamic values for assertions
369//! assert!(!user.id.is_empty());
370//! // But the OpenAPI example shows the redacted stable values
371//! # Ok(())
372//! # }
373//! ```
374//!
375//! ### Redaction Operations
376//!
377//! The redaction builder supports two operations using [JSON Pointer (RFC 6901)](https://tools.ietf.org/html/rfc6901):
378//!
379//! - **`redact(pointer, redactor)`**: Replace a value at the given path with a stable value or transformation
380//! - **`redact_remove(pointer)`**: Remove a value entirely from the OpenAPI example
381//!
382#![cfg_attr(feature = "redaction", doc = "```rust")]
383#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
384//! # use clawspec_core::ApiClient;
385//! # use serde::Deserialize;
386//! # use utoipa::ToSchema;
387//! # #[derive(Deserialize, ToSchema)]
388//! # struct Response { token: String, session_id: String, internal_ref: String }
389//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
390//! let response: Response = client
391//! .post("/auth/login")?
392//! .json(&serde_json::json!({"username": "test", "password": "secret"}))?
393//! .await?
394//! .as_json_redacted()
395//! .await?
396//! .redact("/token", "[REDACTED_TOKEN]")?
397//! .redact("/session_id", "session-00000000")?
398//! .redact_remove("/internal_ref")? // Remove internal field from docs
399//! .finish()
400//! .await
401//! .value;
402//! # Ok(())
403//! # }
404//! ```
405//!
406//! ### Path Syntax
407//!
408//! Paths are auto-detected based on their prefix:
409//! - `/...` → JSON Pointer (RFC 6901) - exact paths only
410//! - `$...` → JSONPath (RFC 9535) - supports wildcards
411//!
412//! ### JSON Pointer Syntax
413//!
414//! JSON Pointers use `/` as a path separator. Special characters are escaped:
415//! - `~0` represents `~`
416//! - `~1` represents `/`
417//!
418//! Examples:
419//! - `/id` - Top-level field named "id"
420//! - `/user/name` - Nested field "name" inside "user"
421//! - `/items/0/id` - First element's "id" in an array
422//! - `/foo~1bar` - Field named "foo/bar"
423//!
424//! ### JSONPath Wildcards
425//!
426//! For arrays, use JSONPath syntax (starting with `$`) to redact all elements:
427//!
428#![cfg_attr(feature = "redaction", doc = "```rust")]
429#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
430//! # use clawspec_core::ApiClient;
431//! # use serde::Deserialize;
432//! # use utoipa::ToSchema;
433//! # #[derive(Deserialize, ToSchema)]
434//! # struct User { id: String, created_at: String }
435//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
436//! let users: Vec<User> = client
437//! .get("/users")?
438//! .await?
439//! .as_json_redacted()
440//! .await?
441//! .redact("$[*].id", "stable-uuid")? // All IDs in array
442//! .redact("$[*].created_at", "2024-01-01T00:00:00Z")? // All timestamps
443//! .finish()
444//! .await
445//! .value;
446//! # Ok(())
447//! # }
448//! ```
449//!
450//! ### Dynamic Transformations
451//!
452//! Pass a closure for dynamic redaction. The closure receives the concrete
453//! JSON Pointer path and current value:
454//!
455#![cfg_attr(feature = "redaction", doc = "```rust")]
456#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
457//! # use clawspec_core::ApiClient;
458//! # use serde::Deserialize;
459//! # use serde_json::Value;
460//! # use utoipa::ToSchema;
461//! # #[derive(Deserialize, ToSchema)]
462//! # struct User { id: String }
463//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
464//! let users: Vec<User> = client
465//! .get("/users")?
466//! .await?
467//! .as_json_redacted()
468//! .await?
469//! // Create stable index-based IDs: user-0, user-1, user-2, ...
470//! .redact("$[*].id", |path: &str, _val: &Value| {
471//! let idx = path.split('/').nth(1).unwrap_or("0");
472//! serde_json::json!(format!("user-{idx}"))
473//! })?
474//! .finish()
475//! .await
476//! .value;
477//! # Ok(())
478//! # }
479//! ```
480//!
481#![cfg_attr(feature = "redaction", doc = "### Getting Both Values")]
482#![cfg_attr(feature = "redaction", doc = "")]
483#![cfg_attr(
484 feature = "redaction",
485 doc = "The [`RedactedResult`] returned by `finish()` contains both:"
486)]
487#![cfg_attr(
488 feature = "redaction",
489 doc = "- `value`: The actual deserialized response (with real dynamic values)"
490)]
491#![cfg_attr(
492 feature = "redaction",
493 doc = "- `redacted`: The JSON with redacted values (as stored in OpenAPI)"
494)]
495//!
496#![cfg_attr(feature = "redaction", doc = "```rust")]
497#![cfg_attr(not(feature = "redaction"), doc = "```rust,ignore")]
498//! # use clawspec_core::ApiClient;
499//! # use serde::Deserialize;
500//! # use utoipa::ToSchema;
501//! # #[derive(Deserialize, ToSchema)]
502//! # struct User { id: String }
503//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
504//! let result = client
505//! .get("/users/123")?
506//! .await?
507//! .as_json_redacted::<User>()
508//! .await?
509//! .redact("/id", "user-00000000")?
510//! .finish()
511//! .await;
512//!
513//! // Use actual value for test assertions
514//! let user = result.value;
515//! assert!(!user.id.is_empty());
516//!
517//! // Access redacted JSON if needed
518//! let redacted_json = result.redacted;
519//! assert_eq!(redacted_json["id"], "user-00000000");
520//! # Ok(())
521//! # }
522//! ```
523//!
524//! ## Schema Registration
525//!
526//! ### Automatic Schema Capture
527//!
528//! JSON request and response body schemas are **automatically captured** when using `.json()` and `.as_json()` methods:
529//!
530//! ```rust
531//! use clawspec_core::ApiClient;
532//! # use serde::{Serialize, Deserialize};
533//! # use utoipa::ToSchema;
534//!
535//! #[derive(Serialize, Deserialize, ToSchema)]
536//! struct CreateUser { name: String, email: String }
537//!
538//! #[derive(Deserialize, ToSchema)]
539//! struct User { id: u32, name: String, email: String }
540//!
541//! # async fn example(client: &mut ApiClient) -> Result<(), Box<dyn std::error::Error>> {
542//! // Schemas are captured automatically - no explicit registration needed
543//! let user: User = client
544//! .post("/users")?
545//! .json(&CreateUser { name: "Alice".to_string(), email: "alice@example.com".to_string() })?
546//! .await?
547//! .as_json()
548//! .await?;
549//! # Ok(())
550//! # }
551//! ```
552//!
553//! ### Manual Schema Registration
554//!
555//! For nested schemas or when you need to ensure all dependencies are included, use the `register_schemas!` macro:
556//!
557//! ```rust
558//! use clawspec_core::{ApiClient, register_schemas};
559//! # use serde::{Serialize, Deserialize};
560//! # use utoipa::ToSchema;
561//!
562//! #[derive(Serialize, Deserialize, ToSchema)]
563//! struct Address { street: String, city: String }
564//!
565//! #[derive(Serialize, Deserialize, ToSchema)]
566//! struct CreateUser { name: String, email: String, address: Address }
567//!
568//! #[derive(Deserialize, ToSchema)]
569//! struct ErrorResponse { code: String, message: String }
570//!
571//! # async fn example(client: &mut ApiClient) {
572//! // Register nested schemas and error types for complete documentation
573//! register_schemas!(client, CreateUser, Address, ErrorResponse).await;
574//! # }
575//! ```
576//!
577//! ### ⚠️ Nested Schema Limitation
578//!
579//! **Current Limitation**: While main JSON body schemas are captured automatically, nested schemas may not be fully resolved. If you encounter missing nested schemas in your OpenAPI specification, use the `register_schemas!` macro to explicitly register them:
580//!
581//! ```rust
582//! use clawspec_core::{ApiClient, register_schemas};
583//! # use serde::{Serialize, Deserialize};
584//! # use utoipa::ToSchema;
585//!
586//! #[derive(Serialize, Deserialize, ToSchema)]
587//! struct Position { lat: f64, lng: f64 }
588//!
589//! #[derive(Serialize, Deserialize, ToSchema)]
590//! struct Location { name: String, position: Position } // Position is nested
591//!
592//! # async fn example(client: &mut ApiClient) {
593//! // Register both main and nested schemas to ensure complete OpenAPI generation
594//! register_schemas!(client, Location, Position).await;
595//! # }
596//! ```
597//!
598//! **Workaround**: Always register nested schemas explicitly when you need complete OpenAPI documentation with all referenced types properly defined.
599//!
600//! ## Error Handling
601//!
602//! The library provides two main error types:
603//! - [`ApiClientError`] - HTTP client errors (network, parsing, validation)
604//! - [`TestAppError`](test_client::TestAppError) - Test server lifecycle errors
605//!
606//! ## See Also
607//!
608//! - [`ApiClient`] - HTTP client with OpenAPI collection
609//! - [`ApiCall`] - Request builder with parameter support
610//! - [`test_client`] - Test server integration module
611//! - [`ExpectedStatusCodes`] - Status code validation
612#![cfg_attr(
613 feature = "redaction",
614 doc = "- [`RedactionBuilder`] - Builder for redacting response values in OpenAPI examples"
615)]
616#![cfg_attr(
617 feature = "redaction",
618 doc = "- [`RedactedResult`] - Result containing both actual and redacted values"
619)]
620#![cfg_attr(
621 feature = "redaction",
622 doc = "- [`RedactOptions`] - Options for configuring redaction behavior"
623)]
624#![cfg_attr(
625 feature = "redaction",
626 doc = "- [`Redactor`] - Trait for types that can be used to redact values"
627)]
628#![cfg_attr(
629 feature = "redaction",
630 doc = "- [`redact_value`] - Entry point for redacting arbitrary JSON values"
631)]
632#![cfg_attr(
633 feature = "redaction",
634 doc = "- [`ValueRedactionBuilder`] - Builder for redacting arbitrary JSON values (e.g., OpenAPI specs)"
635)]
636//!
637//! ## Re-exports
638//!
639//! All commonly used types are re-exported from the crate root for convenience.
640
641// TODO: Add comprehensive unit tests for all modules - https://github.com/ilaborie/clawspec/issues/30
642
643pub mod _tutorial;
644
645mod client;
646
647pub mod test_client;
648
649// Public API - only expose user-facing types and functions
650pub use self::client::{
651 ApiCall, ApiClient, ApiClientBuilder, ApiClientError, ApiKeyLocation, Authentication,
652 AuthenticationError, CallBody, CallCookies, CallHeaders, CallPath, CallQuery, CallResult,
653 ExpectedStatusCodes, OAuth2Flow, OAuth2Flows, OAuth2ImplicitFlow, ParamStyle, ParamValue,
654 ParameterValue, RawBody, RawResult, SecureString, SecurityRequirement, SecurityScheme,
655};
656
657// Re-export external types so users don't need to add these crates to their Cargo.toml.
658//
659// With these re-exports, users can write:
660// use clawspec_core::{ApiClient, OpenApi, ToSchema, StatusCode};
661// Instead of:
662// use clawspec_core::ApiClient;
663// use utoipa::openapi::OpenApi;
664// use utoipa::ToSchema;
665// use http::StatusCode;
666
667/// OpenAPI types re-exported from utoipa for convenience.
668pub use utoipa::openapi::{Info, InfoBuilder, OpenApi, Paths, Server, ServerBuilder};
669
670/// The `ToSchema` derive macro for generating OpenAPI schemas.
671/// Types used in JSON request/response bodies should derive this trait.
672pub use utoipa::ToSchema;
673
674/// HTTP status codes re-exported from the `http` crate.
675pub use http::StatusCode;
676
677#[cfg(feature = "redaction")]
678pub use self::client::{
679 RedactOptions, RedactedResult, RedactionBuilder, Redactor, ValueRedactionBuilder, redact_value,
680};
681
682#[cfg(feature = "oauth2")]
683pub use self::client::{OAuth2Config, OAuth2ConfigBuilder, OAuth2Error, OAuth2Token};
684
685// Convenience macro re-exports are handled by the macro_rules! definitions below
686
687/// Creates an [`ExpectedStatusCodes`] instance with the specified status codes and ranges.
688///
689/// This macro provides a convenient syntax for defining expected HTTP status codes
690/// with support for individual codes, inclusive ranges, and exclusive ranges.
691///
692/// # Syntax
693///
694/// - Single codes: `200`, `201`, `404`
695/// - Inclusive ranges: `200-299` (includes both endpoints)
696/// - Exclusive ranges: `200..300` (excludes 300)
697/// - Mixed: `200, 201-204, 400..500`
698///
699/// # Examples
700///
701/// ```rust
702/// use clawspec_core::expected_status_codes;
703///
704/// // Single status codes
705/// let codes = expected_status_codes!(200, 201, 204);
706///
707/// // Ranges
708/// let success_codes = expected_status_codes!(200-299);
709/// let client_errors = expected_status_codes!(400..500);
710///
711/// // Mixed
712/// let mixed = expected_status_codes!(200-204, 301, 302, 400-404);
713/// ```
714#[macro_export]
715macro_rules! expected_status_codes {
716 // Empty case
717 () => {
718 $crate::ExpectedStatusCodes::default()
719 };
720
721 // Single element
722 ($single:literal) => {
723 $crate::ExpectedStatusCodes::from_single($single)
724 };
725
726 // Single range (inclusive)
727 ($start:literal - $end:literal) => {
728 $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end)
729 };
730
731 // Single range (exclusive)
732 ($start:literal .. $end:literal) => {
733 $crate::ExpectedStatusCodes::from_exclusive_range($start..$end)
734 };
735
736 // Multiple elements - single code followed by more
737 ($first:literal, $($rest:tt)*) => {{
738 #[allow(unused_mut)]
739 let mut codes = $crate::ExpectedStatusCodes::from_single($first);
740 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
741 codes
742 }};
743
744 // Multiple elements - inclusive range followed by more
745 ($start:literal - $end:literal, $($rest:tt)*) => {{
746 #[allow(unused_mut)]
747 let mut codes = $crate::ExpectedStatusCodes::from_inclusive_range($start..=$end);
748 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
749 codes
750 }};
751
752 // Multiple elements - exclusive range followed by more
753 ($start:literal .. $end:literal, $($rest:tt)*) => {{
754 #[allow(unused_mut)]
755 let mut codes = $crate::ExpectedStatusCodes::from_exclusive_range($start..$end);
756 $crate::expected_status_codes!(@accumulate codes, $($rest)*);
757 codes
758 }};
759
760 // Internal accumulator - empty (base case for trailing commas)
761 (@accumulate $codes:ident,) => {
762 // Do nothing for trailing commas
763 };
764
765 // Internal accumulator - single code
766 (@accumulate $codes:ident, $single:literal) => {
767 $codes = $codes.add_single($single);
768 };
769
770 // Internal accumulator - single code followed by more
771 (@accumulate $codes:ident, $single:literal, $($rest:tt)*) => {
772 $codes = $codes.add_single($single);
773 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
774 };
775
776 // Internal accumulator - inclusive range
777 (@accumulate $codes:ident, $start:literal - $end:literal) => {
778 $codes = $codes.add_inclusive_range($start..=$end);
779 };
780
781 // Internal accumulator - inclusive range followed by more
782 (@accumulate $codes:ident, $start:literal - $end:literal, $($rest:tt)*) => {
783 $codes = $codes.add_inclusive_range($start..=$end);
784 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
785 };
786
787 // Internal accumulator - exclusive range
788 (@accumulate $codes:ident, $start:literal .. $end:literal) => {
789 $codes = $codes.add_exclusive_range($start..$end);
790 };
791
792 // Internal accumulator - exclusive range followed by more
793 (@accumulate $codes:ident, $start:literal .. $end:literal, $($rest:tt)*) => {
794 $codes = $codes.add_exclusive_range($start..$end);
795 $crate::expected_status_codes!(@accumulate $codes, $($rest)*);
796 };
797
798 // Internal accumulator - empty (catch all for trailing cases)
799 (@accumulate $codes:ident) => {
800 // Base case - do nothing
801 };
802}
803
804/// Registers multiple schema types with the ApiClient for OpenAPI documentation.
805///
806/// This macro simplifies the process of registering multiple types that implement
807/// [`utoipa::ToSchema`] with an [`ApiClient`] instance.
808///
809/// # When to Use
810///
811/// - **Nested Schemas**: When your JSON types contain nested structures that need to be fully resolved
812/// - **Error Types**: To ensure error response schemas are included in the OpenAPI specification
813/// - **Complex Dependencies**: When automatic schema capture doesn't include all referenced types
814///
815/// # Automatic vs Manual Registration
816///
817/// Most JSON request/response schemas are captured automatically when using `.json()` and `.as_json()` methods.
818/// Use this macro when you need to ensure complete schema coverage, especially for nested types.
819///
820/// # Examples
821///
822/// ## Basic Usage
823///
824/// ```rust
825/// use clawspec_core::{ApiClient, register_schemas};
826/// use serde::{Serialize, Deserialize};
827/// use utoipa::ToSchema;
828///
829/// #[derive(Serialize, Deserialize, ToSchema)]
830/// struct User {
831/// id: u64,
832/// name: String,
833/// }
834///
835/// #[derive(Serialize, Deserialize, ToSchema)]
836/// struct Post {
837/// id: u64,
838/// title: String,
839/// author_id: u64,
840/// }
841///
842/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
843/// let mut client = ApiClient::builder().build()?;
844///
845/// // Register multiple schemas at once
846/// register_schemas!(client, User, Post).await;
847/// # Ok(())
848/// # }
849/// ```
850///
851/// ## Nested Schemas
852///
853/// ```rust
854/// use clawspec_core::{ApiClient, register_schemas};
855/// use serde::{Serialize, Deserialize};
856/// use utoipa::ToSchema;
857///
858/// #[derive(Serialize, Deserialize, ToSchema)]
859/// struct Address {
860/// street: String,
861/// city: String,
862/// }
863///
864/// #[derive(Serialize, Deserialize, ToSchema)]
865/// struct User {
866/// id: u64,
867/// name: String,
868/// address: Address, // Nested schema
869/// }
870///
871/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
872/// let mut client = ApiClient::builder().build()?;
873///
874/// // Register both main and nested schemas for complete OpenAPI generation
875/// register_schemas!(client, User, Address).await;
876/// # Ok(())
877/// # }
878/// ```
879#[macro_export]
880macro_rules! register_schemas {
881 ($client:expr, $($schema:ty),+ $(,)?) => {
882 async {
883 $(
884 $client.register_schema::<$schema>().await;
885 )+
886 }
887 };
888}
889
890#[cfg(test)]
891mod macro_tests {
892 use super::*;
893
894 #[test]
895 fn test_expected_status_codes_single() {
896 let codes = expected_status_codes!(200);
897 assert!(codes.contains(200));
898 assert!(!codes.contains(201));
899 }
900
901 #[test]
902 fn test_expected_status_codes_multiple_single() {
903 let codes = expected_status_codes!(200, 201, 204);
904 assert!(codes.contains(200));
905 assert!(codes.contains(201));
906 assert!(codes.contains(204));
907 assert!(!codes.contains(202));
908 }
909
910 #[test]
911 fn test_expected_status_codes_range() {
912 let codes = expected_status_codes!(200 - 204);
913 assert!(codes.contains(200));
914 assert!(codes.contains(202));
915 assert!(codes.contains(204));
916 assert!(!codes.contains(205));
917 }
918
919 #[test]
920 fn test_expected_status_codes_mixed() {
921 let codes = expected_status_codes!(200, 201 - 204, 301, 400 - 404);
922 assert!(codes.contains(200));
923 assert!(codes.contains(202));
924 assert!(codes.contains(301));
925 assert!(codes.contains(402));
926 assert!(!codes.contains(305));
927 }
928
929 #[test]
930 fn test_expected_status_codes_trailing_comma() {
931 let codes = expected_status_codes!(200, 201,);
932 assert!(codes.contains(200));
933 assert!(codes.contains(201));
934 }
935
936 #[test]
937 fn test_expected_status_codes_range_trailing_comma() {
938 let codes = expected_status_codes!(200 - 204,);
939 assert!(codes.contains(202));
940 }
941
942 #[test]
943 fn test_expected_status_codes_five_elements() {
944 let codes = expected_status_codes!(200, 201, 202, 203, 204);
945 assert!(codes.contains(200));
946 assert!(codes.contains(201));
947 assert!(codes.contains(202));
948 assert!(codes.contains(203));
949 assert!(codes.contains(204));
950 }
951
952 #[test]
953 fn test_expected_status_codes_eight_elements() {
954 let codes = expected_status_codes!(200, 201, 202, 203, 204, 205, 206, 207);
955 assert!(codes.contains(200));
956 assert!(codes.contains(204));
957 assert!(codes.contains(207));
958 }
959
960 #[test]
961 fn test_expected_status_codes_multiple_ranges() {
962 let codes = expected_status_codes!(200 - 204, 300 - 304, 400 - 404);
963 assert!(codes.contains(202));
964 assert!(codes.contains(302));
965 assert!(codes.contains(402));
966 assert!(!codes.contains(205));
967 assert!(!codes.contains(305));
968 }
969
970 #[test]
971 fn test_expected_status_codes_edge_cases() {
972 // Empty should work
973 let _codes = expected_status_codes!();
974
975 // Single range should work
976 let codes = expected_status_codes!(200 - 299);
977 assert!(codes.contains(250));
978 }
979
980 #[test]
981 fn test_expected_status_codes_common_patterns() {
982 // Success codes
983 let success = expected_status_codes!(200 - 299);
984 assert!(success.contains(200));
985 assert!(success.contains(201));
986 assert!(success.contains(204));
987
988 // Client errors
989 let client_errors = expected_status_codes!(400 - 499);
990 assert!(client_errors.contains(400));
991 assert!(client_errors.contains(404));
992 assert!(client_errors.contains(422));
993
994 // Specific success codes
995 let specific = expected_status_codes!(200, 201, 204);
996 assert!(specific.contains(200));
997 assert!(!specific.contains(202));
998 }
999
1000 #[test]
1001 fn test_expected_status_codes_builder_alternative() {
1002 // Using macro
1003 let macro_codes = expected_status_codes!(200 - 204, 301, 302, 400 - 404);
1004
1005 // Using builder (should be equivalent)
1006 let builder_codes = ExpectedStatusCodes::default()
1007 .add_inclusive_range(200..=204)
1008 .add_single(301)
1009 .add_single(302)
1010 .add_inclusive_range(400..=404);
1011
1012 // Both should have same results
1013 for code in [200, 202, 204, 301, 302, 400, 402, 404] {
1014 assert_eq!(macro_codes.contains(code), builder_codes.contains(code));
1015 }
1016 }
1017}
1018
1019#[cfg(test)]
1020mod integration_tests {
1021 use super::*;
1022
1023 #[test]
1024 fn test_expected_status_codes_real_world_patterns() {
1025 // REST API common patterns
1026 let rest_success = expected_status_codes!(200, 201, 204);
1027 assert!(rest_success.contains(200)); // GET success
1028 assert!(rest_success.contains(201)); // POST created
1029 assert!(rest_success.contains(204)); // DELETE success
1030
1031 // GraphQL typically uses 200 for everything
1032 let graphql = expected_status_codes!(200);
1033 assert!(graphql.contains(200));
1034 assert!(!graphql.contains(201));
1035
1036 // Health check endpoints
1037 let health = expected_status_codes!(200, 503);
1038 assert!(health.contains(200)); // Healthy
1039 assert!(health.contains(503)); // Unhealthy
1040
1041 // Authentication endpoints
1042 let auth = expected_status_codes!(200, 201, 401, 403);
1043 assert!(auth.contains(200)); // Login success
1044 assert!(auth.contains(401)); // Unauthorized
1045 assert!(auth.contains(403)); // Forbidden
1046 }
1047
1048 #[tokio::test]
1049 async fn test_expected_status_codes_with_api_call() {
1050 // This tests that the macro works correctly with actual API calls
1051 let client = ApiClient::builder().build().expect("should build client");
1052 let codes = expected_status_codes!(200 - 299, 404);
1053
1054 // Should compile and be usable
1055 let _call = client
1056 .get("/test")
1057 .expect("should create call")
1058 .with_expected_status_codes(codes);
1059 }
1060
1061 #[test]
1062 fn test_expected_status_codes_method_chaining() {
1063 let codes = expected_status_codes!(200)
1064 .add_single(201)
1065 .add_inclusive_range(300..=304);
1066
1067 assert!(codes.contains(200));
1068 assert!(codes.contains(201));
1069 assert!(codes.contains(302));
1070 }
1071
1072 #[test]
1073 fn test_expected_status_codes_vs_manual_creation() {
1074 // Macro version
1075 let macro_version = expected_status_codes!(200 - 204, 301, 400);
1076
1077 // Manual version
1078 let manual_version = ExpectedStatusCodes::from_inclusive_range(200..=204)
1079 .add_single(301)
1080 .add_single(400);
1081
1082 // Should behave identically
1083 for code in 100..600 {
1084 assert_eq!(
1085 macro_version.contains(code),
1086 manual_version.contains(code),
1087 "Mismatch for status code {code}"
1088 );
1089 }
1090 }
1091}