├── .github ├── dependabot.yml ├── pr-title-checker-config.json └── workflows │ ├── pr-title-checker.yml │ └── ci.yml ├── examples ├── axum │ ├── Cargo.toml │ ├── api.yaml │ └── src │ │ └── main.rs ├── actix-web │ ├── Cargo.toml │ ├── api.yaml │ └── src │ │ └── main.rs ├── observability_test.rs └── api.yaml ├── .gitignore ├── src ├── model │ ├── mod.rs │ └── parse.rs ├── lib.rs ├── request │ ├── mod.rs │ ├── axum.rs │ └── actix_web.rs ├── validator │ ├── enum_test.rs │ ├── pattern_test.rs │ ├── validator_test.rs │ └── mod.rs └── observability │ └── mod.rs ├── Cargo.toml ├── tests ├── example │ └── example.yaml └── tests.rs ├── README-ZH.md ├── README.md └── LICENSE /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | labels: 8 | - "dependencies" -------------------------------------------------------------------------------- /.github/pr-title-checker-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LABEL": { 3 | "name": "Invalid PR Title", 4 | "color": "B60205" 5 | }, 6 | "CHECKS": { 7 | "regexp": "^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\(.*\\))?:.*", 8 | "ignoreLabels": [ 9 | "ignore-title" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/axum/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-example" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | openapi-rs = { path = "../..", features = ["axum"] } 8 | axum = "0.7" 9 | tokio = { version = "1.0", features = ["full"] } 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | tower = "0.4" 13 | tower-http = { version = "0.5", features = ["cors"] } 14 | -------------------------------------------------------------------------------- /examples/actix-web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "actix-web-example" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | openapi-rs = { path = "../..", features = ["actix-web"] } 8 | actix-web = "4" 9 | tokio = { version = "1.0", features = ["full"] } 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | actix-cors = "0.7" 13 | futures-util = "0.3" 14 | bytes = "1.0" 15 | chrono = "0.4" 16 | rand = "0.8" 17 | env_logger = "0.10" 18 | -------------------------------------------------------------------------------- /.github/workflows/pr-title-checker.yml: -------------------------------------------------------------------------------- 1 | name: "PR Title Checker" 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - reopened 7 | - synchronize 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | steps: 14 | - uses: thehanimo/pr-title-checker@v1.4.3 15 | with: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | pass_on_octokit_error: false 18 | configuration_path: ".github/pr-title-checker-config.json" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Commonly files 2 | .DS_Store 3 | .history 4 | report.json 5 | 6 | # Generated by Cargo 7 | # will have compiled files and executables 8 | /target/ 9 | /bin/ 10 | 11 | 12 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 13 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 14 | # Cargo.lock 15 | 16 | # These are backup files generated by rustfmt 17 | **/*.rs.bk 18 | 19 | 20 | 21 | /tmp/ 22 | /data/ 23 | /.idea/ 24 | /.vscode 25 | /.env 26 | /.env* 27 | /integration-tests 28 | 29 | #web distribution 30 | /web/dist 31 | /web/.env -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | pub mod parse; 19 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | description = "A powerful openapi library for Rust" 3 | name = "openapi-rs" 4 | version = "0.1.0" 5 | edition = "2021" 6 | license = "Apache-2.0" 7 | repository = "https://github.com/baerwang/openapi-rs" 8 | publish = false 9 | 10 | [features] 11 | default = [] 12 | axum = ["dep:axum"] 13 | actix-web = ["dep:actix-web"] 14 | test-with-axum = ["axum"] 15 | 16 | [dependencies] 17 | serde = { version = "1", features = ["derive"] } 18 | serde_yaml = "0.9" 19 | anyhow = "1.0" 20 | uuid = { version = "1", features = ["v4"] } 21 | url = "2" 22 | serde_json = "1.0.140" 23 | chrono = { version = "0.4", default-features = false, features = ["clock"] } 24 | validator = "0.19" 25 | base64 = "0.21" 26 | regex = "1.0" 27 | log = "0.4" 28 | env_logger = "0.10" 29 | fern = "0.6" 30 | futures-util = "0.3" 31 | axum = { version = "0.7", optional = true } 32 | actix-web = { version = "4", optional = true } 33 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | pub mod model; 19 | pub mod observability; 20 | pub mod request; 21 | pub mod validator; 22 | -------------------------------------------------------------------------------- /src/request/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | #[cfg(feature = "axum")] 19 | pub mod axum; 20 | 21 | #[cfg(feature = "actix-web")] 22 | pub mod actix_web; 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | format: 9 | strategy: 10 | matrix: 11 | include: 12 | - os: macos-latest 13 | - os: ubuntu-latest 14 | name: cargo fmt 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - uses: actions/checkout@v6 18 | 19 | - name: Setup toolchain 20 | uses: dtolnay/rust-toolchain@master 21 | with: 22 | toolchain: nightly 23 | components: rustfmt 24 | 25 | - name: Run fmt 26 | run: cargo fmt --manifest-path ./Cargo.toml --all -- --check --unstable-features 27 | 28 | cargo-clippy: 29 | strategy: 30 | matrix: 31 | include: 32 | - os: macos-latest 33 | - os: ubuntu-latest 34 | name: cargo clippy 35 | runs-on: ${{ matrix.os }} 36 | steps: 37 | - uses: actions/checkout@v6 38 | 39 | - name: Setup toolchain 40 | uses: dtolnay/rust-toolchain@master 41 | with: 42 | toolchain: stable 43 | components: clippy 44 | 45 | - uses: Swatinem/rust-cache@v2 46 | 47 | - run: cargo clippy --manifest-path ./Cargo.toml --all-features --workspace -- -D warnings 48 | 49 | cargo-test: 50 | strategy: 51 | matrix: 52 | include: 53 | - os: macos-latest 54 | - os: ubuntu-latest 55 | name: cargo test 56 | runs-on: ${{ matrix.os }} 57 | steps: 58 | - uses: actions/checkout@v6 59 | 60 | - name: Setup toolchain 61 | uses: dtolnay/rust-toolchain@master 62 | with: 63 | toolchain: stable 64 | components: clippy 65 | 66 | - uses: Swatinem/rust-cache@v2 67 | 68 | - run: cargo test --features test-with-axum -------------------------------------------------------------------------------- /tests/example/example.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: Example API 4 | description: API definitions for example 5 | version: '0.0.1' 6 | x-file-identifier: example 7 | 8 | components: 9 | schemas: 10 | ExampleRequest: 11 | title: example request 12 | description: example request 13 | type: object 14 | properties: 15 | result: 16 | type: string 17 | description: example 18 | example: example 19 | required: 20 | - result 21 | 22 | ExampleResponse: 23 | allOf: 24 | - type: object 25 | properties: 26 | result: 27 | type: object 28 | description: example. 29 | properties: 30 | uuid: 31 | type: string 32 | description: The UUID for this example. 33 | format: uuid 34 | example: 00000000-0000-0000-0000-000000000000 35 | count: 36 | type: integer 37 | description: example count. 38 | example: 1 39 | minimum: 0 40 | required: 41 | - uuid 42 | 43 | security: [ ] 44 | 45 | paths: 46 | 47 | /example/{uuid}: 48 | get: 49 | parameters: 50 | - name: uuid 51 | description: The UUID for this example. 52 | in: path 53 | schema: 54 | type: string 55 | format: uuid 56 | example: 00000000-0000-0000-0000-000000000000 57 | responses: 58 | '200': 59 | description: Get a Example response 60 | content: 61 | application/json: 62 | schema: 63 | $ref: '#/components/schemas/ExampleResponse' 64 | 65 | /example: 66 | post: 67 | summary: Example 68 | description: Example 69 | operationId: example 70 | tags: 71 | - Example 72 | requestBody: 73 | required: true 74 | content: 75 | application/json: 76 | schema: 77 | $ref: "#/components/schemas/ExampleRequest" 78 | responses: 79 | '201': 80 | description: Create a Example response 81 | content: 82 | application/json: 83 | schema: 84 | $ref: '#/components/schemas/ExampleResponse' -------------------------------------------------------------------------------- /examples/observability_test.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use openapi_rs::model::parse::OpenAPI; 3 | use openapi_rs::observability::init_logger; 4 | use openapi_rs::request; 5 | 6 | fn main() -> Result<()> { 7 | init_logger(); 8 | 9 | let content = r#" 10 | openapi: 3.1.0 11 | info: 12 | title: Example API 13 | description: API definitions for example 14 | version: '0.0.1' 15 | x-file-identifier: example 16 | 17 | components: 18 | schemas: 19 | ExampleResponse: 20 | properties: 21 | uuid: 22 | type: string 23 | description: The UUID for this example. 24 | format: uuid 25 | example: 00000000-0000-0000-0000-000000000000 26 | 27 | security: [ ] 28 | 29 | paths: 30 | /example/{uuid}: 31 | get: 32 | parameters: 33 | - name: uuid 34 | description: The UUID for this example. 35 | in: path 36 | schema: 37 | type: string 38 | format: uuid 39 | example: 00000000-0000-0000-0000-000000000000 40 | responses: 41 | '200': 42 | description: Get a Example response 43 | content: 44 | application/json: 45 | schema: 46 | $ref: '#/components/schemas/ExampleResponse' 47 | "#; 48 | 49 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 50 | 51 | fn make_request(uri: &str) -> request::axum::RequestData { 52 | request::axum::RequestData { 53 | path: "/example/{uuid}".to_string(), 54 | inner: axum::http::Request::builder() 55 | .method("GET") 56 | .uri(uri) 57 | .body(axum::body::Body::empty()) 58 | .unwrap(), 59 | body: None, 60 | } 61 | } 62 | 63 | println!("✅ Testing successful case"); 64 | match openapi.validator(make_request( 65 | "/example/00000000-0000-0000-0000-000000000000", 66 | )) { 67 | Ok(_) => println!("✓ Validation successful"), 68 | Err(e) => println!("✗ Validation failed: {}", e), 69 | } 70 | 71 | println!("❌ Testing failure case"); 72 | match openapi.validator(make_request( 73 | "/example/00000000-0000-0000-0000-00000000000x", 74 | )) { 75 | Ok(_) => println!("✓ Validation successful"), 76 | Err(e) => println!("✗ Validation failed: {}", e), 77 | } 78 | 79 | println!("🎉 Testing completed"); 80 | Ok(()) 81 | } 82 | -------------------------------------------------------------------------------- /examples/axum/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: User Management API 4 | description: A simple API for managing users with OpenAPI validation 5 | version: '1.0.0' 6 | 7 | components: 8 | schemas: 9 | User: 10 | type: object 11 | properties: 12 | id: 13 | type: integer 14 | description: Unique identifier for the user 15 | example: 1 16 | name: 17 | type: string 18 | description: Full name of the user 19 | minLength: 1 20 | maxLength: 100 21 | example: "John Doe" 22 | email: 23 | type: string 24 | format: email 25 | description: Email address of the user 26 | example: "john.doe@example.com" 27 | age: 28 | type: integer 29 | description: Age of the user 30 | minimum: 0 31 | maximum: 150 32 | example: 25 33 | required: 34 | - name 35 | - email 36 | - age 37 | 38 | paths: 39 | /health: 40 | get: 41 | summary: Health check endpoint 42 | description: Returns the health status of the service 43 | responses: 44 | '200': 45 | description: Service is healthy 46 | content: 47 | text/plain: 48 | schema: 49 | type: string 50 | example: "Service is running" 51 | 52 | /users: 53 | get: 54 | summary: Get list of users 55 | description: Retrieve a paginated list of users 56 | parameters: 57 | - name: page 58 | in: query 59 | description: Page number for pagination 60 | schema: 61 | type: integer 62 | minimum: 1 63 | default: 1 64 | - name: limit 65 | in: query 66 | description: Number of users per page 67 | schema: 68 | type: integer 69 | minimum: 1 70 | maximum: 100 71 | default: 10 72 | required: 73 | - page 74 | - limit 75 | responses: 76 | '200': 77 | description: List of users retrieved successfully 78 | content: 79 | application/json: 80 | schema: 81 | type: array 82 | items: 83 | $ref: '#/components/schemas/User' 84 | 85 | post: 86 | summary: Create a new user 87 | description: Create a new user with the provided information 88 | requestBody: 89 | required: true 90 | content: 91 | application/json: 92 | schema: 93 | $ref: '#/components/schemas/User' 94 | responses: 95 | '200': 96 | description: User created successfully 97 | content: 98 | application/json: 99 | schema: 100 | $ref: '#/components/schemas/User' 101 | '400': 102 | description: Invalid input data 103 | -------------------------------------------------------------------------------- /examples/actix-web/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: User Management API 4 | description: A simple API for managing users with OpenAPI validation 5 | version: '1.0.0' 6 | 7 | components: 8 | schemas: 9 | User: 10 | type: object 11 | properties: 12 | id: 13 | type: integer 14 | description: Unique identifier for the user 15 | example: 1 16 | name: 17 | type: string 18 | description: Full name of the user 19 | minLength: 1 20 | maxLength: 100 21 | example: "John Doe" 22 | email: 23 | type: string 24 | format: email 25 | description: Email address of the user 26 | example: "john.doe@example.com" 27 | age: 28 | type: integer 29 | description: Age of the user 30 | minimum: 0 31 | maximum: 150 32 | example: 25 33 | required: 34 | - name 35 | - email 36 | - age 37 | 38 | paths: 39 | /health: 40 | get: 41 | summary: Health check endpoint 42 | description: Returns the health status of the service 43 | responses: 44 | '200': 45 | description: Service is healthy 46 | content: 47 | text/plain: 48 | schema: 49 | type: string 50 | example: "Service is running" 51 | 52 | /users: 53 | get: 54 | summary: Get list of users 55 | description: Retrieve a paginated list of users 56 | parameters: 57 | - name: page 58 | in: query 59 | description: Page number for pagination 60 | schema: 61 | type: integer 62 | minimum: 1 63 | default: 1 64 | - name: limit 65 | in: query 66 | description: Number of users per page 67 | schema: 68 | type: integer 69 | minimum: 1 70 | maximum: 100 71 | default: 10 72 | required: 73 | - page 74 | - limit 75 | responses: 76 | '200': 77 | description: List of users retrieved successfully 78 | content: 79 | application/json: 80 | schema: 81 | type: array 82 | items: 83 | $ref: '#/components/schemas/User' 84 | 85 | post: 86 | summary: Create a new user 87 | description: Create a new user with the provided information 88 | requestBody: 89 | required: true 90 | content: 91 | application/json: 92 | schema: 93 | $ref: '#/components/schemas/User' 94 | responses: 95 | '200': 96 | description: User created successfully 97 | content: 98 | application/json: 99 | schema: 100 | $ref: '#/components/schemas/User' 101 | '400': 102 | description: Invalid input data 103 | -------------------------------------------------------------------------------- /examples/actix-web/src/main.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{get, post}; 2 | use actix_web::{web, App, HttpResponse, HttpServer, Result}; 3 | use openapi_rs::observability::init_logger; 4 | use openapi_rs::request::actix_web::OpenApiValidation; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Serialize, Deserialize, Debug)] 8 | struct User { 9 | id: Option, 10 | name: String, 11 | email: String, 12 | age: u32, 13 | } 14 | 15 | #[derive(Deserialize)] 16 | struct UserQuery { 17 | page: u32, 18 | limit: u32, 19 | } 20 | 21 | #[derive(Serialize)] 22 | struct ErrorResponse { 23 | error: String, 24 | message: String, 25 | path: Option, 26 | } 27 | 28 | // User related handlers 29 | #[get("/users")] 30 | async fn get(query: web::Query) -> Result { 31 | let page = query.page; 32 | let limit = query.limit; 33 | // Mock user list with pagination 34 | let all_users = vec![ 35 | User { 36 | id: Some(1), 37 | name: "John Doe".to_string(), 38 | email: "john.doe@example.com".to_string(), 39 | age: 25, 40 | }, 41 | User { 42 | id: Some(2), 43 | name: "Jane Smith".to_string(), 44 | email: "jane.smith@example.com".to_string(), 45 | age: 30, 46 | }, 47 | ]; 48 | 49 | println!("Get users list - page: {}, limit: {}", page, limit); 50 | Ok(HttpResponse::Ok().json(all_users)) 51 | } 52 | 53 | #[post("/users")] 54 | async fn create(user: web::Json) -> Result { 55 | // Additional business logic validation if needed 56 | if user.name.trim().is_empty() { 57 | return Ok(HttpResponse::BadRequest().json(ErrorResponse { 58 | error: "Business validation failed".to_string(), 59 | message: "Name cannot be empty".to_string(), 60 | path: Some("/users".to_string()), 61 | })); 62 | } 63 | 64 | // Mock user creation 65 | let mut new_user = user.into_inner(); 66 | new_user.id = Some(rand::random::() % 1000 + 1000); 67 | 68 | println!("Create user: {:?}", new_user); 69 | Ok(HttpResponse::Ok().json(new_user)) 70 | } 71 | 72 | async fn health_check() -> Result { 73 | Ok(HttpResponse::Ok().json(serde_json::json!({ 74 | "status": "healthy", 75 | "timestamp": chrono::Utc::now().to_rfc3339(), 76 | "service": "openapi-rs-actix-web-example" 77 | }))) 78 | } 79 | 80 | #[actix_web::main] 81 | async fn main() -> std::io::Result<()> { 82 | init_logger(); 83 | 84 | let content = std::fs::read_to_string("api.yaml")?; 85 | let validation = OpenApiValidation::from_yaml(&content) 86 | .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; 87 | 88 | println!("🚀 Server started, access URL: http://127.0.0.1:8080"); 89 | println!("📝 API endpoints:"); 90 | println!(" - GET /health - Health check (no validation)"); 91 | println!(" - GET /users?page=1&limit=10 - Get users list (with OpenAPI validation)"); 92 | println!(" - POST /users - Create user (with OpenAPI validation)"); 93 | 94 | HttpServer::new(move || { 95 | App::new() 96 | .wrap(validation.clone()) 97 | .service(get) 98 | .service(create) 99 | .route("/health", web::get().to(health_check)) 100 | }) 101 | .bind("127.0.0.1:8080")? 102 | .run() 103 | .await 104 | } 105 | -------------------------------------------------------------------------------- /src/request/axum.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | use crate::model::parse::OpenAPI; 19 | use crate::observability::RequestContext; 20 | use crate::validator::{body, method, path, query, ValidateRequest}; 21 | use anyhow::Result; 22 | use axum::body::{Body, Bytes}; 23 | use axum::http::Request; 24 | use serde_json::Value; 25 | use std::collections::HashMap; 26 | 27 | #[allow(dead_code)] 28 | pub struct RequestData { 29 | pub path: String, 30 | pub inner: Request, 31 | pub body: Option, 32 | } 33 | 34 | impl ValidateRequest for RequestData { 35 | fn header(&self, _: &OpenAPI) -> Result<()> { 36 | Ok(()) 37 | } 38 | 39 | fn method(&self, open_api: &OpenAPI) -> Result<()> { 40 | method( 41 | self.path.as_str(), 42 | self.inner.method().to_string().to_lowercase().as_str(), 43 | open_api, 44 | ) 45 | } 46 | 47 | fn query(&self, open_api: &OpenAPI) -> Result<()> { 48 | let uri_parts: Vec<&str> = self 49 | .inner 50 | .uri() 51 | .path_and_query() 52 | .map(|pq| pq.as_str()) 53 | .unwrap_or("") 54 | .split('?') 55 | .collect(); 56 | 57 | let query_pairs = if uri_parts.len() > 1 { 58 | uri_parts[1] 59 | .split('&') 60 | .filter_map(|pair| { 61 | let mut split = pair.split('='); 62 | match (split.next(), split.next()) { 63 | (Some(key), Some(value)) => Some((key.to_string(), value.to_string())), 64 | _ => None, 65 | } 66 | }) 67 | .collect() 68 | } else { 69 | HashMap::new() 70 | }; 71 | 72 | query(self.path.as_str(), &query_pairs, open_api) 73 | } 74 | 75 | fn path(&self, open_api: &OpenAPI) -> Result<()> { 76 | if let Some(last_segment) = self.inner.uri().path().rsplit('/').find(|s| !s.is_empty()) { 77 | path(self.path.as_str(), last_segment, open_api)? 78 | } 79 | 80 | Ok(()) 81 | } 82 | 83 | fn body(&self, open_api: &OpenAPI) -> Result<()> { 84 | if self.body.is_none() { 85 | return Ok(()); 86 | } 87 | let self_body = self 88 | .body 89 | .as_ref() 90 | .ok_or_else(|| anyhow::anyhow!("Missing body"))?; 91 | let request_fields: Value = serde_json::from_slice(self_body)?; 92 | body(self.path.as_str(), request_fields, open_api) 93 | } 94 | 95 | fn context(&self) -> RequestContext { 96 | RequestContext::new( 97 | match *self.inner.method() { 98 | axum::http::Method::GET => "GET".to_string(), 99 | axum::http::Method::POST => "POST".to_string(), 100 | axum::http::Method::PUT => "PUT".to_string(), 101 | axum::http::Method::DELETE => "DELETE".to_string(), 102 | axum::http::Method::PATCH => "PATCH".to_string(), 103 | axum::http::Method::HEAD => "HEAD".to_string(), 104 | axum::http::Method::OPTIONS => "OPTIONS".to_string(), 105 | _ => "UNKNOWN".to_string(), 106 | }, 107 | self.inner.uri().to_string(), 108 | ) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README-ZH.md: -------------------------------------------------------------------------------- 1 | # OpenAPI-RS 2 | 3 | [English](README.md) | [中文](README-ZH.md) 4 | 5 | --- 6 | 7 | 一个功能强大的 Rust OpenAPI 3.1 库,提供 OpenAPI 规范的解析、验证和请求处理功能。 8 | 9 | ### 🚀 特性 10 | 11 | - **OpenAPI 3.1 支持**: 完整支持 OpenAPI 3.1 规范 12 | - **YAML 解析**: 支持从 YAML 格式解析 OpenAPI 文档 13 | - **请求验证**: 全面的 HTTP 请求验证,包括: 14 | - 路径参数验证 15 | - 查询参数验证 16 | - 请求体验证 17 | - **类型安全**: 强类型支持,包括联合类型和复合类型 18 | - **格式验证**: 支持多种数据格式验证(Email、UUID、日期时间等) 19 | - **多框架集成**: 提供多个 Web 框架的集成支持 20 | - [**Axum**](examples/axum): 完整的 Axum 框架集成 21 | - [**Actix-Web**](examples/actix-web): 完整的 Actix-Web 框架集成 22 | - **可选特性**: 支持按需启用特定框架 23 | - **可观测性**: 内置日志记录和验证操作指标,提供结构化日志 24 | - **详细错误信息**: 提供清晰的验证错误消息 25 | 26 | ### 📦 安装 27 | 28 | 将以下内容添加到你的 `Cargo.toml` 文件中: 29 | 30 | ```toml 31 | [dependencies] 32 | openapi-rs = { git = "https://github.com/baerwang/openapi-rs", features = ["axum"] } 33 | axum = "0.7" 34 | ``` 35 | 36 | ### 🔧 使用方法 37 | 38 | ```rust 39 | use openapi_rs::model::parse::OpenAPI; 40 | use openapi_rs::request::axum::RequestData; 41 | 42 | fn main() -> Result<(), Box> { 43 | // 从 YAML 文件解析 OpenAPI 规范 44 | // 你可以使用项目中的示例文件:examples/api.yaml 45 | let content = std::fs::read_to_string("examples/api.yaml")?; 46 | let openapi = OpenAPI::yaml(&content)?; 47 | 48 | // 创建请求数据进行验证 49 | let request_data = RequestData { 50 | path: "/users".to_string(), 51 | inner: axum::http::Request::builder() 52 | .method("GET") 53 | .uri("/users?page=1&limit=10") 54 | .body(axum::body::Body::empty()) 55 | .unwrap(), 56 | body: None, 57 | }; 58 | 59 | // 根据 OpenAPI 规范验证请求 60 | openapi.validator(request_data)?; 61 | 62 | // 对于带请求体的 POST 请求 63 | let body_data = r#"{"name": "John Doe", "email": "john.doe@example.com", "age": 30}"#; 64 | let post_request = RequestData { 65 | path: "/users".to_string(), 66 | inner: axum::http::Request::builder() 67 | .method("POST") 68 | .uri("/users") 69 | .header("content-type", "application/json") 70 | .body(axum::body::Body::from(body_data)) 71 | .unwrap(), 72 | body: Some(axum::body::Bytes::from(body_data)), 73 | }; 74 | 75 | openapi.validator(post_request)?; 76 | 77 | Ok(()) 78 | } 79 | ``` 80 | 81 | **示例 OpenAPI 规范文件 (`examples/api.yaml`):** 82 | 83 | 这个库包含一个完整的示例 OpenAPI 规范文件,展示了用户管理 API 的定义,包括: 84 | 85 | - 📝 **用户 CRUD 操作**:创建、读取、更新、删除用户 86 | - 🔍 **查询参数验证**:分页、搜索等参数 87 | - 📋 **请求体验证**:JSON 格式的用户数据 88 | - 🏷️ **数据类型验证**:字符串、数字、布尔值、数组等 89 | - 📧 **格式验证**:Email、UUID、日期时间等 90 | 91 | ### 🎯 支持的验证类型 92 | 93 | #### 数据类型 94 | 95 | - **字符串**: 支持长度限制、格式验证 96 | - **数字**: 支持最小值、最大值验证 97 | - **整数**: 支持范围验证 98 | - **布尔值**: 类型验证 99 | - **数组**: 支持项目数量限制 100 | - **对象**: 支持嵌套属性验证 101 | - **联合类型**: 支持多类型验证 102 | 103 | #### 格式验证 104 | 105 | - Email (`email`) 106 | - UUID (`uuid`) 107 | - 日期 (`date`) 108 | - 时间 (`time`) 109 | - 日期时间 (`date-time`) 110 | - IPv4 地址 (`ipv4`) 111 | - IPv6 地址 (`ipv6`) 112 | - Base64 编码 (`base64`) 113 | - 二进制数据 (`binary`) 114 | 115 | #### 验证约束 116 | 117 | - 字符串长度 (`minLength`, `maxLength`) 118 | - 数值范围 (`minimum`, `maximum`) 119 | - 数组项目数 (`minItems`, `maxItems`) 120 | - 必填字段 (`required`) 121 | - 枚举值 (`enum`) 122 | - 正则表达式 (`pattern`) 123 | 124 | ### 📊 可观测性 125 | 126 | 本库提供内置的可观测性功能,帮助在生产环境中监控调试验证操作。 127 | 128 | #### 功能特性 129 | 130 | - **结构化日志**: 自动记录验证操作的详细指标 131 | - **性能跟踪**: 测量每个验证请求的持续时间 132 | - **错误报告**: 详细记录失败验证的错误日志 133 | - **请求上下文**: 跟踪方法和路径,实现全面监控 134 | 135 | #### 日志输出格式 136 | 137 | 可观测性系统生成包含以下信息的结构化日志: 138 | 139 | **成功验证:** 140 | 141 | ``` 142 | INFO openapi_validation method="GET" path="/example/{uuid}" success=true duration_ms=2 timestamp=1642752000000 143 | ``` 144 | 145 | **失败验证:** 146 | 147 | ``` 148 | WARN openapi_validation method="GET" path="/example/{uuid}" success=false duration_ms=1 error="Invalid UUID format" timestamp=1642752000001 149 | ``` 150 | 151 | #### 运行可观测性示例 152 | 153 | 你可以运行包含的可观测性示例来查看日志记录的实际效果: 154 | 155 | ```bash 156 | RUST_LOG=debug cargo run --example observability_test 157 | ``` 158 | 159 | 详细实现请查看:[observability_test.rs](examples/observability_test.rs) 160 | 161 | ### 🧪 测试 162 | 163 | 运行测试: 164 | 165 | ```bash 166 | cargo test 167 | ``` 168 | 169 | ### 📋 开发路线图 170 | 171 | - [x] **解析器**: OpenAPI 3.1 规范解析 172 | - [x] **验证器**: 完整的请求验证功能 173 | - [x] **更多框架集成**: 支持 Actix-web、Axum 等框架 174 | - [x] **性能优化**: 提升大型 API 规范的处理性能 175 | 176 | ### 🤝 贡献 177 | 178 | 欢迎贡献代码!请遵循以下步骤: 179 | 180 | 1. Fork 本仓库 181 | 2. 创建特性分支 (`git checkout -b feature/amazing-feature`) 182 | 3. 提交更改 (`git commit -m 'Add some amazing feature'`) 183 | 4. 推送到分支 (`git push origin feature/amazing-feature`) 184 | 5. 开启 Pull Request 185 | 186 | ### 📄 许可证 187 | 188 | 本项目采用 Apache License 2.0 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 189 | -------------------------------------------------------------------------------- /src/validator/enum_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use crate::model::parse::OpenAPI; 4 | use crate::validator::{body, query}; 5 | use serde_json::json; 6 | use std::collections::HashMap; 7 | 8 | #[test] 9 | fn test_enum_validation_query_parameter() { 10 | let yaml_content = r#" 11 | openapi: 3.0.0 12 | info: 13 | title: Test API 14 | version: 1.0.0 15 | paths: 16 | /test: 17 | get: 18 | parameters: 19 | - name: status 20 | in: query 21 | required: true 22 | schema: 23 | type: string 24 | enum: ["active", "inactive", "pending"] 25 | - name: priority 26 | in: query 27 | required: false 28 | enum: [1, 2, 3] 29 | components: {} 30 | "#; 31 | 32 | let open_api: OpenAPI = serde_yaml::from_str(yaml_content).unwrap(); 33 | 34 | let mut valid_query = HashMap::new(); 35 | valid_query.insert("status".to_string(), "active".to_string()); 36 | valid_query.insert("priority".to_string(), "2".to_string()); 37 | 38 | let result = query("/test", &valid_query, &open_api); 39 | if let Err(ref e) = result { 40 | println!("Error message: {}", e); 41 | } 42 | assert!(result.is_ok(), "Valid enum values should pass validation"); 43 | 44 | let mut invalid_query = HashMap::new(); 45 | invalid_query.insert("status".to_string(), "unknown".to_string()); 46 | 47 | let result = query("/test", &invalid_query, &open_api); 48 | assert!(result.is_err(), "Invalid enum values should be rejected"); 49 | 50 | let error_msg = result.unwrap_err().to_string(); 51 | assert!( 52 | error_msg.contains("not in allowed enum values"), 53 | "Error message should contain enum validation hint" 54 | ); 55 | assert!( 56 | error_msg.contains("active"), 57 | "Error message should show allowed enum values" 58 | ); 59 | } 60 | 61 | #[test] 62 | fn test_enum_validation_with_different_types() { 63 | let yaml_content = r#" 64 | openapi: 3.0.0 65 | info: 66 | title: Test API 67 | version: 1.0.0 68 | paths: 69 | /test: 70 | get: 71 | parameters: 72 | - name: active 73 | in: query 74 | schema: 75 | type: boolean 76 | enum: [true, false] 77 | - name: count 78 | in: query 79 | schema: 80 | type: integer 81 | enum: [1, 5, 10] 82 | components: {} 83 | "#; 84 | 85 | let open_api: OpenAPI = serde_yaml::from_str(yaml_content).unwrap(); 86 | 87 | let mut query_params = HashMap::new(); 88 | query_params.insert("active".to_string(), "true".to_string()); 89 | 90 | let result = query("/test", &query_params, &open_api); 91 | assert!( 92 | result.is_ok(), 93 | "Valid boolean enum values should pass validation" 94 | ); 95 | 96 | let mut invalid_query = HashMap::new(); 97 | invalid_query.insert("active".to_string(), "maybe".to_string()); 98 | 99 | let result = query("/test", &invalid_query, &open_api); 100 | assert!( 101 | result.is_err(), 102 | "Invalid boolean enum values should be rejected" 103 | ); 104 | } 105 | 106 | #[test] 107 | fn test_enum_validation_in_properties() { 108 | let yaml_content = r#" 109 | openapi: 3.0.0 110 | info: 111 | title: Test API 112 | version: 1.0.0 113 | paths: 114 | /test: 115 | post: 116 | requestBody: 117 | required: true 118 | content: 119 | application/json: 120 | schema: 121 | $ref: '#/components/schemas/TestRequest' 122 | components: 123 | schemas: 124 | TestRequest: 125 | type: object 126 | properties: 127 | status: 128 | type: string 129 | enum: ["draft", "published", "archived"] 130 | priority: 131 | type: integer 132 | enum: [1, 2, 3, 4, 5] 133 | required: 134 | - status 135 | "#; 136 | 137 | let open_api: OpenAPI = serde_yaml::from_str(yaml_content).unwrap(); 138 | 139 | let valid_body = json!({ 140 | "status": "published", 141 | "priority": 3 142 | }); 143 | 144 | let result = body("/test", valid_body, &open_api); 145 | assert!( 146 | result.is_ok(), 147 | "Valid request body enum values should pass validation" 148 | ); 149 | 150 | let invalid_body = json!({ 151 | "status": "invalid_status", 152 | "priority": 3 153 | }); 154 | 155 | let result = body("/test", invalid_body, &open_api); 156 | assert!( 157 | result.is_err(), 158 | "Invalid request body enum values should be rejected" 159 | ); 160 | 161 | let error_msg = result.unwrap_err().to_string(); 162 | assert!( 163 | error_msg.contains("not in allowed enum values"), 164 | "Error message should contain enum validation hint" 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /examples/axum/src/main.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Query, State}, 3 | http::StatusCode, 4 | middleware, 5 | response::{IntoResponse, Json, Response}, 6 | routing::get, 7 | Router, 8 | }; 9 | use openapi_rs::model::parse::OpenAPI; 10 | use openapi_rs::request::axum::RequestData; 11 | use serde::{Deserialize, Serialize}; 12 | use std::sync::Arc; 13 | use tower_http::cors::CorsLayer; 14 | 15 | // Application state containing OpenAPI instance 16 | #[derive(Clone)] 17 | struct AppState { 18 | openapi: Arc, 19 | } 20 | 21 | #[derive(Serialize, Deserialize, Debug)] 22 | struct User { 23 | id: Option, 24 | name: String, 25 | email: String, 26 | age: u32, 27 | } 28 | 29 | #[derive(Deserialize)] 30 | struct UserQuery { 31 | page: u32, 32 | limit: u32, 33 | } 34 | 35 | // OpenAPI validation middleware 36 | async fn openapi_middleware( 37 | State(state): State, 38 | request: axum::http::Request, 39 | next: axum::middleware::Next, 40 | ) -> Result { 41 | // Get request path 42 | let path = request.uri().path().to_string(); 43 | 44 | // Read request body (if exists) 45 | let (parts, body) = request.into_parts(); 46 | let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { 47 | Ok(bytes) => bytes, 48 | Err(e) => { 49 | eprintln!("Failed to read request body: {}", e); 50 | return Err(( 51 | StatusCode::BAD_REQUEST, 52 | Json(serde_json::json!({ 53 | "error": "Invalid request body", 54 | "message": "Failed to read request body" 55 | })), 56 | ) 57 | .into_response()); 58 | } 59 | }; 60 | 61 | // Rebuild request 62 | let rebuilt_request = 63 | axum::http::Request::from_parts(parts.clone(), axum::body::Body::from(body_bytes.clone())); 64 | 65 | // Create request data for validation 66 | let request_data = RequestData { 67 | path: path.clone(), 68 | inner: rebuilt_request, 69 | body: if body_bytes.is_empty() { 70 | None 71 | } else { 72 | Some(body_bytes.clone()) 73 | }, 74 | }; 75 | 76 | // Validate using cached OpenAPI instance 77 | if let Err(validation_error) = state.openapi.validator(request_data) { 78 | eprintln!( 79 | "OpenAPI validation failed - path: {}, error: {:?}", 80 | path, validation_error 81 | ); 82 | return Err(( 83 | StatusCode::BAD_REQUEST, 84 | Json(serde_json::json!({ 85 | "error": "Validation failed", 86 | "message": format!("Request does not conform to OpenAPI specification: {}", validation_error), 87 | "path": path 88 | })) 89 | ).into_response()); 90 | } 91 | 92 | // Rebuild request for next middleware 93 | let final_request = axum::http::Request::from_parts(parts, axum::body::Body::from(body_bytes)); 94 | 95 | Ok(next.run(final_request).await) 96 | } 97 | 98 | // User related handlers 99 | async fn get_users(Query(params): Query) -> Json> { 100 | let page = params.page; 101 | let limit = params.limit; 102 | 103 | // Mock user list 104 | let users = vec![ 105 | User { 106 | id: Some(1), 107 | name: "John Doe".to_string(), 108 | email: "john.doe@example.com".to_string(), 109 | age: 25, 110 | }, 111 | User { 112 | id: Some(2), 113 | name: "Jane Smith".to_string(), 114 | email: "jane.smith@example.com".to_string(), 115 | age: 30, 116 | }, 117 | ]; 118 | 119 | println!("Get users list - page: {}, limit: {}", page, limit); 120 | Json(users) 121 | } 122 | 123 | async fn create_user(Json(payload): Json) -> Result, StatusCode> { 124 | // Mock user creation 125 | let mut new_user = payload; 126 | new_user.id = Some(3); // Mock assigned ID 127 | 128 | println!("Create user: {:?}", new_user); 129 | Ok(Json(new_user)) 130 | } 131 | 132 | async fn health_check() -> &'static str { 133 | "Service is running" 134 | } 135 | 136 | #[tokio::main] 137 | async fn main() { 138 | // Read and parse OpenAPI specification at startup 139 | let content = std::fs::read_to_string("api.yaml").expect("Unable to read api.yaml file"); 140 | 141 | let openapi = OpenAPI::yaml(&content).expect("Unable to parse OpenAPI specification"); 142 | 143 | // Create application state 144 | let app_state = AppState { 145 | openapi: Arc::new(openapi), 146 | }; 147 | 148 | // Build routes 149 | let app = Router::new() 150 | .route("/health", get(health_check)) 151 | .route("/users", get(get_users).post(create_user)) 152 | .layer(middleware::from_fn_with_state( 153 | app_state.clone(), 154 | openapi_middleware, 155 | )) 156 | .layer(CorsLayer::permissive()) 157 | .with_state(app_state); 158 | 159 | // Start server 160 | let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") 161 | .await 162 | .unwrap(); 163 | 164 | println!("🚀 Server started, access URL: http://127.0.0.1:8080"); 165 | println!("📝 API endpoints:"); 166 | println!(" - GET /health - Health check"); 167 | println!(" - GET /users - Get users list"); 168 | println!(" - POST /users - Create user"); 169 | 170 | axum::serve(listener, app).await.unwrap(); 171 | } 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI-RS 2 | 3 | [English](README.md) | [中文](README-ZH.md) 4 | 5 | --- 6 | 7 | A powerful Rust library for OpenAPI 3.1 specification parsing, validation, and request handling. 8 | 9 | ### 🚀 Features 10 | 11 | - **OpenAPI 3.1 Support**: Full compatibility with OpenAPI 3.1 specification 12 | - **YAML Parsing**: Support for parsing OpenAPI documents from both YAML formats 13 | - **Request Validation**: Comprehensive HTTP request validation including: 14 | - Path parameter validation 15 | - Query parameter validation 16 | - Request body validation 17 | - **Type Safety**: Strong typing support with union types and composite types 18 | - **Format Validation**: Support for various data format validations (Email, UUID, DateTime, etc.) 19 | - **Multi-Framework Integration**: Built-in integration support for multiple web frameworks 20 | - [**Axum**](examples/axum): Complete Axum framework integration 21 | - [**Actix-Web**](examples/actix-web): Complete Actix-Web framework integration 22 | - **Optional Features**: Support for enabling specific frameworks on demand 23 | - **Observability**: Built-in logging and metrics for validation operations with structured logs 24 | - **Detailed Error Messages**: Clear and informative validation error messages 25 | 26 | ### 📦 Installation 27 | 28 | Add this to your `Cargo.toml`: 29 | 30 | ```toml 31 | [dependencies] 32 | openapi-rs = { git = "https://github.com/baerwang/openapi-rs", features = ["axum"] } 33 | axum = "0.7" 34 | ``` 35 | 36 | ### 🔧 Usage 37 | 38 | ```rust 39 | use openapi_rs::model::parse::OpenAPI; 40 | use openapi_rs::request::axum::RequestData; 41 | 42 | fn main() -> Result<(), Box> { 43 | // Parse OpenAPI specification from YAML file 44 | // You can use the example file included in the project: examples/api.yaml 45 | let content = std::fs::read_to_string("examples/api.yaml")?; 46 | let openapi = OpenAPI::yaml(&content)?; 47 | 48 | // Create request data for validation 49 | let request_data = RequestData { 50 | path: "/users".to_string(), 51 | inner: axum::http::Request::builder() 52 | .method("GET") 53 | .uri("/users?page=1&limit=10") 54 | .body(axum::body::Body::empty()) 55 | .unwrap(), 56 | body: None, 57 | }; 58 | 59 | // Validate the request against OpenAPI specification 60 | openapi.validator(request_data)?; 61 | 62 | // For POST requests with body 63 | let body_data = r#"{"name": "John Doe", "email": "john.doe@example.com", "age": 30}"#; 64 | let post_request = RequestData { 65 | path: "/users".to_string(), 66 | inner: axum::http::Request::builder() 67 | .method("POST") 68 | .uri("/users") 69 | .header("content-type", "application/json") 70 | .body(axum::body::Body::from(body_data)) 71 | .unwrap(), 72 | body: Some(axum::body::Bytes::from(body_data)), 73 | }; 74 | 75 | openapi.validator(post_request)?; 76 | 77 | Ok(()) 78 | } 79 | ``` 80 | 81 | **Example OpenAPI Specification File (`examples/api.yaml`):** 82 | 83 | This library includes a complete example OpenAPI specification file that demonstrates a User Management API definition, 84 | featuring: 85 | 86 | - 📝 **User CRUD Operations**: Create, Read, Update, Delete users 87 | - 🔍 **Query Parameter Validation**: Pagination, search parameters 88 | - 📋 **Request Body Validation**: JSON formatted user data 89 | - 🏷️ **Data Type Validation**: Strings, numbers, booleans, arrays 90 | - 📧 **Format Validation**: Email, UUID, date-time formats 91 | 92 | ### 🎯 Supported Validation Types 93 | 94 | #### Data Types 95 | 96 | - **String**: Length constraints and format validation 97 | - **Number**: Minimum and maximum value validation 98 | - **Integer**: Range validation 99 | - **Boolean**: Type validation 100 | - **Array**: Item count constraints 101 | - **Object**: Nested property validation 102 | - **Union Types**: Multi-type validation 103 | 104 | #### Format Validation 105 | 106 | - Email (`email`) 107 | - UUID (`uuid`) 108 | - Date (`date`) 109 | - Time (`time`) 110 | - Date-Time (`date-time`) 111 | - IPv4 Address (`ipv4`) 112 | - IPv6 Address (`ipv6`) 113 | - Base64 Encoding (`base64`) 114 | - Binary Data (`binary`) 115 | 116 | #### Validation Constraints 117 | 118 | - String length (`minLength`, `maxLength`) 119 | - Numeric ranges (`minimum`, `maximum`) 120 | - Array item count (`minItems`, `maxItems`) 121 | - Required fields (`required`) 122 | - Enum values (`enum`) 123 | - Pattern matching (`pattern`) 124 | 125 | ### 📊 Observability 126 | 127 | This library provides built-in observability features to help monitor and debug validation operations in production 128 | environments. 129 | 130 | #### Features 131 | 132 | - **Structured Logging**: Automatic logging of validation operations with detailed metrics 133 | - **Performance Tracking**: Duration measurement for each validation request 134 | - **Error Reporting**: Detailed error logging for failed validations 135 | - **Request Context**: Method and path tracking for comprehensive monitoring 136 | 137 | #### Log Output Format 138 | 139 | The observability system generates structured logs with the following information: 140 | 141 | **Successful Validation:** 142 | 143 | ``` 144 | INFO openapi_validation method="GET" path="/example/{uuid}" success=true duration_ms=2 timestamp=1642752000000 145 | ``` 146 | 147 | **Failed Validation:** 148 | 149 | ``` 150 | WARN openapi_validation method="GET" path="/example/{uuid}" success=false duration_ms=1 error="Invalid UUID format" timestamp=1642752000001 151 | ``` 152 | 153 | #### Running the Observability Example 154 | 155 | You can run the included observability example to see the logging in action: 156 | 157 | ```bash 158 | RUST_LOG=debug cargo run --example observability_test 159 | ``` 160 | 161 | For detailed implementation, see: [observability_test.rs](examples/observability_test.rs) 162 | 163 | ### 🧪 Testing 164 | 165 | Run tests: 166 | 167 | ```bash 168 | cargo test 169 | ``` 170 | 171 | ### 📋 Roadmap 172 | 173 | - [x] **Parser**: OpenAPI 3.1 specification parsing 174 | - [x] **Validator**: Complete request validation functionality 175 | - [x] **More Framework Integration**: Support for Actix-web、Axum, and other frameworks 176 | - [x] **Performance Optimization**: Improve handling of large API specifications 177 | 178 | ### 🤝 Contributing 179 | 180 | Contributions are welcome! Please follow these steps: 181 | 182 | 1. Fork the repository 183 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 184 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 185 | 4. Push to the branch (`git push origin feature/amazing-feature`) 186 | 5. Open a Pull Request 187 | 188 | ### 📄 License 189 | 190 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /src/observability/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | use std::path::Path; 19 | use std::time::Instant; 20 | 21 | #[derive(Debug, Clone)] 22 | pub struct RequestContext { 23 | pub method: String, 24 | pub path: String, 25 | } 26 | 27 | impl RequestContext { 28 | pub fn new(method: String, path: String) -> Self { 29 | Self { method, path } 30 | } 31 | } 32 | 33 | pub struct ValidationMetrics { 34 | start_time: Instant, 35 | method: String, 36 | path: String, 37 | } 38 | 39 | impl ValidationMetrics { 40 | pub fn new(method: &str, path: &str) -> Self { 41 | Self { 42 | start_time: Instant::now(), 43 | method: method.to_string(), 44 | path: path.to_string(), 45 | } 46 | } 47 | 48 | pub fn from_context(context: &RequestContext) -> Self { 49 | Self::new(&context.method, &context.path) 50 | } 51 | 52 | pub fn record_success(self) { 53 | let duration_ms = self.start_time.elapsed().as_millis(); 54 | let timestamp = chrono::Utc::now().timestamp_millis(); 55 | 56 | log::info!( 57 | "openapi_validation method=\"{}\" path=\"{}\" success=true duration_ms={} timestamp={}", 58 | self.method, 59 | self.path, 60 | duration_ms, 61 | timestamp 62 | ); 63 | } 64 | 65 | pub fn record_failure(self, error: String) { 66 | let duration_ms = self.start_time.elapsed().as_millis(); 67 | let timestamp = chrono::Utc::now().timestamp_millis(); 68 | 69 | log::warn!( 70 | "openapi_validation method=\"{}\" path=\"{}\" success=false duration_ms={} error=\"{}\" timestamp={}", 71 | self.method, 72 | self.path, 73 | duration_ms, 74 | error, 75 | timestamp 76 | ); 77 | } 78 | } 79 | 80 | /// Log configuration structure 81 | #[derive(Debug, Clone)] 82 | pub struct LogConfig { 83 | /// Log level (trace, debug, info, warn, error) 84 | pub level: String, 85 | /// Log file path (optional) 86 | pub log_file: Option, 87 | /// Enable console output 88 | pub console_output: bool, 89 | /// Show timestamp 90 | pub show_timestamp: bool, 91 | /// Show code location information 92 | pub show_target: bool, 93 | /// Show thread information 94 | pub show_thread: bool, 95 | } 96 | 97 | impl Default for LogConfig { 98 | fn default() -> Self { 99 | Self { 100 | level: "info".to_string(), 101 | log_file: None, 102 | console_output: true, 103 | show_timestamp: true, 104 | show_target: false, 105 | show_thread: false, 106 | } 107 | } 108 | } 109 | 110 | impl LogConfig { 111 | /// Create new log configuration 112 | pub fn new() -> Self { 113 | Self::default() 114 | } 115 | 116 | /// Set log level 117 | pub fn with_level(mut self, level: &str) -> Self { 118 | self.level = level.to_string(); 119 | self 120 | } 121 | 122 | /// Set log file path 123 | pub fn with_log_file>(mut self, file: P) -> Self { 124 | self.log_file = Some(file.as_ref().to_string_lossy().to_string()); 125 | self 126 | } 127 | 128 | /// Enable/disable console output 129 | pub fn with_console_output(mut self, enabled: bool) -> Self { 130 | self.console_output = enabled; 131 | self 132 | } 133 | 134 | /// Enable/disable timestamp display 135 | pub fn with_timestamp(mut self, enabled: bool) -> Self { 136 | self.show_timestamp = enabled; 137 | self 138 | } 139 | 140 | /// Enable/disable target information display 141 | pub fn with_target(mut self, enabled: bool) -> Self { 142 | self.show_target = enabled; 143 | self 144 | } 145 | 146 | /// Enable/disable thread information display 147 | pub fn with_thread(mut self, enabled: bool) -> Self { 148 | self.show_thread = enabled; 149 | self 150 | } 151 | } 152 | 153 | /// Initialize logger with default configuration 154 | pub fn init_logger() { 155 | init_logger_with_config(LogConfig::default()); 156 | } 157 | 158 | /// Initialize logger with specified configuration 159 | pub fn init_logger_with_config(config: LogConfig) { 160 | let log_level = match config.level.as_str() { 161 | "trace" => log::LevelFilter::Trace, 162 | "debug" => log::LevelFilter::Debug, 163 | "info" => log::LevelFilter::Info, 164 | "warn" => log::LevelFilter::Warn, 165 | "error" => log::LevelFilter::Error, 166 | _ => log::LevelFilter::Info, 167 | }; 168 | 169 | let mut dispatch = fern::Dispatch::new() 170 | .format(move |out, message, record| { 171 | let mut format_str = String::new(); 172 | 173 | if config.show_timestamp { 174 | format_str.push_str(&format!( 175 | "{} ", 176 | chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f") 177 | )); 178 | } 179 | 180 | format_str.push_str(&format!("[{}]", record.level())); 181 | 182 | if config.show_thread { 183 | format_str.push_str(&format!( 184 | " [{}]", 185 | std::thread::current().name().unwrap_or("main") 186 | )); 187 | } 188 | 189 | if config.show_target { 190 | format_str.push_str(&format!(" {}", record.target())); 191 | } 192 | 193 | format_str.push_str(&format!(" - {message}")); 194 | 195 | out.finish(format_args!("{format_str}")) 196 | }) 197 | .level(log_level); 198 | 199 | // Console output 200 | if config.console_output { 201 | dispatch = dispatch.chain(std::io::stdout()); 202 | } 203 | 204 | // File output 205 | if let Some(log_file) = &config.log_file { 206 | // Ensure log file directory exists 207 | if let Some(parent) = Path::new(log_file).parent() { 208 | if let Err(e) = std::fs::create_dir_all(parent) { 209 | eprintln!("Failed to create log directory {parent:?}: {e}"); 210 | return; 211 | } 212 | } 213 | 214 | match fern::log_file(log_file) { 215 | Ok(file) => { 216 | dispatch = dispatch.chain(file); 217 | } 218 | Err(e) => { 219 | eprintln!("Failed to create log file {log_file}: {e}"); 220 | return; 221 | } 222 | } 223 | } 224 | 225 | // Apply configuration 226 | if let Err(e) = dispatch.apply() { 227 | eprintln!("Failed to initialize logger: {e}"); 228 | } else { 229 | log::info!("Logger initialized with config: {config:?}"); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/model/parse.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | use crate::observability::ValidationMetrics; 19 | use crate::validator::ValidateRequest; 20 | use serde::{Deserialize, Serialize}; 21 | use std::collections::HashMap; 22 | use std::hash::Hash; 23 | 24 | #[derive(Debug, Serialize, Deserialize)] 25 | pub struct OpenAPI { 26 | pub openapi: String, 27 | pub info: InfoObject, 28 | #[serde(default)] 29 | pub servers: Vec, 30 | pub paths: HashMap, 31 | pub components: Option, 32 | } 33 | 34 | #[derive(Debug, Serialize, Deserialize)] 35 | pub struct PathItem { 36 | pub parameters: Option>, // Path-level parameters 37 | #[serde(flatten)] 38 | pub operations: HashMap, // For HTTP methods (get, post, etc.) 39 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 40 | pub servers: Vec, // Will be ignored during deserialization 41 | #[serde(flatten)] 42 | pub extra: serde_yaml::Value, // Catches any other fields 43 | } 44 | 45 | macro_rules! require_non_empty { 46 | ($field:expr, $msg:expr) => { 47 | if $field.is_empty() { 48 | return Err($msg.to_string()); 49 | } 50 | }; 51 | } 52 | 53 | impl OpenAPI { 54 | pub fn yaml(contents: &str) -> Result { 55 | serde_yaml::from_str(contents) 56 | } 57 | 58 | pub fn validator(&self, valid: impl ValidateRequest) -> Result<(), String> { 59 | let metrics = ValidationMetrics::from_context(&valid.context()); 60 | 61 | let result = self.perform_validation(valid); 62 | 63 | match &result { 64 | Ok(_) => metrics.record_success(), 65 | Err(err) => metrics.record_failure(err.clone()), 66 | } 67 | 68 | result 69 | } 70 | 71 | fn perform_validation(&self, valid: impl ValidateRequest) -> Result<(), String> { 72 | require_non_empty!(self.openapi, "OpenAPI version is required"); 73 | require_non_empty!(self.info.title, "Title is required"); 74 | require_non_empty!(self.info.version, "Version is required"); 75 | require_non_empty!(self.paths, "Paths are required"); 76 | valid 77 | .method(self) 78 | .map_err(|e| format!("Method validation failed: {e}"))?; 79 | valid 80 | .path(self) 81 | .map_err(|e| format!("Path validation failed: {e}"))?; 82 | valid 83 | .query(self) 84 | .map_err(|e| format!("Query validation failed: {e}"))?; 85 | valid 86 | .body(self) 87 | .map_err(|e| format!("Body validation failed: {e}"))?; 88 | Ok(()) 89 | } 90 | } 91 | 92 | #[derive(Debug, Serialize, Deserialize)] 93 | pub struct SecurityRequirementObject { 94 | #[serde(rename = "type", default)] 95 | pub _type: String, 96 | pub scheme: Option, 97 | pub description: Option, 98 | } 99 | 100 | #[derive(Debug, Serialize, Deserialize)] 101 | pub struct InfoObject { 102 | pub title: String, 103 | pub description: Option, 104 | pub version: String, 105 | } 106 | 107 | #[derive(Debug, Serialize, Deserialize)] 108 | pub struct ServerObject { 109 | pub url: String, 110 | pub description: Option, 111 | } 112 | 113 | #[derive(Debug, Serialize, Deserialize)] 114 | pub struct PathBase { 115 | pub summary: Option, 116 | pub description: Option, 117 | #[serde(rename = "operationId")] 118 | pub operation_id: Option, 119 | pub parameters: Option>, 120 | #[serde(rename = "requestBody")] 121 | pub request: Option, 122 | #[serde(default)] 123 | pub servers: Vec, 124 | } 125 | 126 | #[derive(Debug, Serialize, Deserialize)] 127 | pub struct Parameter { 128 | #[serde(rename = "$ref")] 129 | pub r#ref: Option, 130 | pub name: Option, 131 | #[serde(rename = "in")] 132 | pub r#in: Option, 133 | #[serde(default)] 134 | pub required: bool, 135 | pub description: Option, 136 | pub example: Option, 137 | #[serde(rename = "type")] 138 | pub r#type: Option, 139 | pub r#enum: Option>, 140 | pub pattern: Option, 141 | pub schema: Option>, 142 | #[serde(flatten)] 143 | pub extra: HashMap, 144 | } 145 | 146 | #[derive(Debug, Serialize, Deserialize)] 147 | pub struct Schema { 148 | #[serde(rename = "type")] 149 | pub r#type: Option, 150 | pub format: Option, 151 | pub title: Option, 152 | pub description: Option, 153 | pub r#enum: Option>, 154 | pub pattern: Option, 155 | pub properties: Option>, 156 | pub example: Option, 157 | pub examples: Option>, 158 | #[serde(rename = "$ref")] 159 | pub r#ref: Option, 160 | #[serde(rename = "allOf")] 161 | pub all_of: Option>, 162 | #[serde(rename = "oneOf")] 163 | pub one_of: Option>, 164 | pub items: Option>, 165 | #[serde(default)] 166 | pub required: Vec, 167 | #[serde(rename = "minItems")] 168 | pub min_items: Option, 169 | #[serde(rename = "maxItems")] 170 | pub max_items: Option, 171 | #[serde(rename = "minLength")] 172 | pub min_length: Option, 173 | #[serde(rename = "maxLength")] 174 | pub max_length: Option, 175 | pub minimum: Option, 176 | pub maximum: Option, 177 | } 178 | 179 | #[derive(Debug, Serialize, Deserialize)] 180 | pub struct BaseContent { 181 | pub schema: Schema, 182 | } 183 | 184 | #[derive(Debug, Serialize, Deserialize)] 185 | pub struct Request { 186 | #[serde(default)] 187 | pub required: bool, 188 | pub content: HashMap, 189 | } 190 | 191 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 192 | pub enum SchemaOption { 193 | OneOf, 194 | AllOf, 195 | } 196 | 197 | #[derive(Debug, Serialize, Deserialize)] 198 | pub struct ComponentSchemaBase { 199 | pub title: Option, 200 | pub description: Option, 201 | #[serde(rename = "type")] 202 | pub r#type: Option, 203 | pub items: Option>, 204 | pub properties: Option>, 205 | #[serde(default)] 206 | pub required: Vec, 207 | #[serde(rename = "allOf")] 208 | pub all_of: Option>, 209 | #[serde(rename = "oneOf")] 210 | pub one_of: Option>, 211 | #[serde(rename = "minItems")] 212 | pub min_items: Option, 213 | #[serde(rename = "maxItems")] 214 | pub max_items: Option, 215 | } 216 | 217 | #[derive(Debug, Serialize, Deserialize)] 218 | pub struct ComponentProperties { 219 | #[serde(rename = "type")] 220 | pub r#type: Option, 221 | pub description: Option, 222 | #[serde(default)] 223 | pub properties: HashMap, 224 | #[serde(rename = "$ref")] 225 | pub r#ref: Option, 226 | } 227 | 228 | #[derive(Debug, Serialize, Deserialize)] 229 | pub struct Properties { 230 | #[serde(rename = "type")] 231 | pub r#type: Option, 232 | pub description: Option, 233 | pub format: Option, 234 | pub example: Option, 235 | pub pattern: Option, 236 | #[serde(rename = "minLength")] 237 | pub min_length: Option, 238 | #[serde(rename = "maxLength")] 239 | pub max_length: Option, 240 | #[serde(rename = "minItems")] 241 | pub min_items: Option, 242 | #[serde(rename = "maxItems")] 243 | pub max_items: Option, 244 | pub minimum: Option, 245 | pub maximum: Option, 246 | pub items: Option>, 247 | pub properties: Option>, 248 | #[serde(default)] 249 | pub required: Vec, 250 | pub r#enum: Option>, 251 | } 252 | 253 | #[derive(Debug, Serialize, Deserialize)] 254 | pub struct ComponentsObject { 255 | #[serde(default)] 256 | pub schemas: HashMap, 257 | #[serde(default)] 258 | pub parameters: HashMap, 259 | #[serde(rename = "requestBodies", default)] 260 | pub request_bodies: HashMap, 261 | } 262 | 263 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 264 | #[serde(rename_all(deserialize = "lowercase"))] 265 | pub enum Type { 266 | Object, 267 | String, 268 | Integer, 269 | Number, 270 | Array, 271 | Boolean, 272 | Null, 273 | Binary, 274 | Base64, 275 | } 276 | 277 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 278 | #[serde(untagged)] 279 | pub enum TypeOrUnion { 280 | Single(Type), 281 | Union(Vec), 282 | } 283 | 284 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 285 | #[serde(rename_all(deserialize = "lowercase"))] 286 | pub enum In { 287 | Query, 288 | Header, 289 | Path, 290 | Cookie, 291 | } 292 | 293 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 294 | #[serde(rename_all(deserialize = "lowercase"))] 295 | pub enum Format { 296 | URI, 297 | #[serde(rename = "uri-reference")] 298 | URIReference, 299 | Regex, 300 | Email, 301 | Time, 302 | Date, 303 | #[serde(rename = "date-time")] 304 | DateTime, 305 | UUID, 306 | Hostname, 307 | IPV4, 308 | IPV6, 309 | Password, 310 | #[serde(rename = "json-pointer")] 311 | JsonPointer, 312 | Binary, 313 | #[serde(rename = "external-ip")] 314 | ExternalIP, 315 | #[serde(rename = "int32")] 316 | Int32, 317 | #[serde(rename = "int64")] 318 | Int64, 319 | Svg, 320 | #[serde(rename = "url")] 321 | Url, 322 | #[serde(other)] 323 | Unknown, 324 | } 325 | -------------------------------------------------------------------------------- /examples/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: User Management API 4 | description: A simple API for managing users 5 | version: '1.0.0' 6 | 7 | components: 8 | schemas: 9 | User: 10 | type: object 11 | properties: 12 | id: 13 | type: string 14 | format: uuid 15 | description: Unique identifier for the user 16 | example: "123e4567-e89b-12d3-a456-426614174000" 17 | name: 18 | type: string 19 | description: Full name of the user 20 | minLength: 1 21 | maxLength: 100 22 | example: "John Doe" 23 | email: 24 | type: string 25 | format: email 26 | description: Email address of the user 27 | example: "john.doe@example.com" 28 | age: 29 | type: integer 30 | description: Age of the user 31 | minimum: 0 32 | maximum: 150 33 | example: 30 34 | created_at: 35 | type: string 36 | format: date-time 37 | description: When the user was created 38 | example: "2023-01-01T00:00:00Z" 39 | is_active: 40 | type: boolean 41 | description: Whether the user is active 42 | example: true 43 | tags: 44 | type: array 45 | items: 46 | type: string 47 | description: Tags associated with the user 48 | minItems: 0 49 | maxItems: 10 50 | example: [ "admin", "verified" ] 51 | required: 52 | - id 53 | - name 54 | - email 55 | 56 | CreateUserRequest: 57 | type: object 58 | properties: 59 | name: 60 | type: string 61 | description: Full name of the user 62 | minLength: 1 63 | maxLength: 100 64 | example: "John Doe" 65 | email: 66 | type: string 67 | format: email 68 | description: Email address of the user 69 | example: "john.doe@example.com" 70 | age: 71 | type: integer 72 | description: Age of the user 73 | minimum: 0 74 | maximum: 150 75 | example: 30 76 | tags: 77 | type: array 78 | items: 79 | type: string 80 | description: Tags associated with the user 81 | minItems: 0 82 | maxItems: 10 83 | example: [ "user" ] 84 | required: 85 | - name 86 | - email 87 | 88 | UpdateUserRequest: 89 | type: object 90 | properties: 91 | name: 92 | type: string 93 | description: Full name of the user 94 | minLength: 1 95 | maxLength: 100 96 | example: "John Smith" 97 | email: 98 | type: string 99 | format: email 100 | description: Email address of the user 101 | example: "john.smith@example.com" 102 | age: 103 | type: integer 104 | description: Age of the user 105 | minimum: 0 106 | maximum: 150 107 | example: 31 108 | is_active: 109 | type: boolean 110 | description: Whether the user is active 111 | example: false 112 | tags: 113 | type: array 114 | items: 115 | type: string 116 | description: Tags associated with the user 117 | minItems: 0 118 | maxItems: 10 119 | example: [ "admin", "verified" ] 120 | 121 | UserListResponse: 122 | type: object 123 | properties: 124 | users: 125 | type: array 126 | items: 127 | $ref: '#/components/schemas/User' 128 | description: List of users 129 | total: 130 | type: integer 131 | description: Total number of users 132 | example: 100 133 | page: 134 | type: integer 135 | description: Current page number 136 | example: 1 137 | limit: 138 | type: integer 139 | description: Number of users per page 140 | example: 10 141 | required: 142 | - users 143 | - total 144 | - page 145 | - limit 146 | 147 | ErrorResponse: 148 | type: object 149 | properties: 150 | error: 151 | type: string 152 | description: Error message 153 | example: "User not found" 154 | code: 155 | type: string 156 | description: Error code 157 | example: "USER_NOT_FOUND" 158 | details: 159 | type: object 160 | description: Additional error details 161 | required: 162 | - error 163 | - code 164 | 165 | parameters: 166 | UserIdParam: 167 | name: user_id 168 | in: path 169 | required: true 170 | description: ID of the user 171 | schema: 172 | type: string 173 | format: uuid 174 | example: "123e4567-e89b-12d3-a456-426614174000" 175 | 176 | PageParam: 177 | name: page 178 | in: query 179 | required: false 180 | description: Page number for pagination 181 | schema: 182 | type: integer 183 | minimum: 1 184 | default: 1 185 | example: 1 186 | 187 | LimitParam: 188 | name: limit 189 | in: query 190 | required: false 191 | description: Number of items per page 192 | schema: 193 | type: integer 194 | minimum: 1 195 | maximum: 100 196 | default: 10 197 | example: 10 198 | 199 | SearchParam: 200 | name: search 201 | in: query 202 | required: false 203 | description: Search term for filtering users 204 | schema: 205 | type: string 206 | minLength: 1 207 | maxLength: 100 208 | example: "john" 209 | 210 | paths: 211 | /users: 212 | get: 213 | summary: List users 214 | description: Retrieve a paginated list of users 215 | operationId: listUsers 216 | tags: 217 | - Users 218 | parameters: 219 | - $ref: '#/components/parameters/PageParam' 220 | - $ref: '#/components/parameters/LimitParam' 221 | - $ref: '#/components/parameters/SearchParam' 222 | responses: 223 | '200': 224 | description: Successful response 225 | content: 226 | application/json: 227 | schema: 228 | $ref: '#/components/schemas/UserListResponse' 229 | '400': 230 | description: Bad request 231 | content: 232 | application/json: 233 | schema: 234 | $ref: '#/components/schemas/ErrorResponse' 235 | '500': 236 | description: Internal server error 237 | content: 238 | application/json: 239 | schema: 240 | $ref: '#/components/schemas/ErrorResponse' 241 | security: 242 | - bearerAuth: [ ] 243 | 244 | post: 245 | summary: Create user 246 | description: Create a new user 247 | operationId: createUser 248 | tags: 249 | - Users 250 | requestBody: 251 | required: true 252 | content: 253 | application/json: 254 | schema: 255 | $ref: '#/components/schemas/CreateUserRequest' 256 | responses: 257 | '201': 258 | description: User created successfully 259 | content: 260 | application/json: 261 | schema: 262 | $ref: '#/components/schemas/User' 263 | '400': 264 | description: Bad request 265 | content: 266 | application/json: 267 | schema: 268 | $ref: '#/components/schemas/ErrorResponse' 269 | '409': 270 | description: User already exists 271 | content: 272 | application/json: 273 | schema: 274 | $ref: '#/components/schemas/ErrorResponse' 275 | '500': 276 | description: Internal server error 277 | content: 278 | application/json: 279 | schema: 280 | $ref: '#/components/schemas/ErrorResponse' 281 | security: 282 | - bearerAuth: [ ] 283 | 284 | /users/{user_id}: 285 | get: 286 | summary: Get user by ID 287 | description: Retrieve a specific user by their ID 288 | operationId: getUserById 289 | tags: 290 | - Users 291 | parameters: 292 | - $ref: '#/components/parameters/UserIdParam' 293 | responses: 294 | '200': 295 | description: Successful response 296 | content: 297 | application/json: 298 | schema: 299 | $ref: '#/components/schemas/User' 300 | '404': 301 | description: User not found 302 | content: 303 | application/json: 304 | schema: 305 | $ref: '#/components/schemas/ErrorResponse' 306 | '500': 307 | description: Internal server error 308 | content: 309 | application/json: 310 | schema: 311 | $ref: '#/components/schemas/ErrorResponse' 312 | security: 313 | - bearerAuth: [ ] 314 | 315 | put: 316 | summary: Update user 317 | description: Update an existing user 318 | operationId: updateUser 319 | tags: 320 | - Users 321 | parameters: 322 | - $ref: '#/components/parameters/UserIdParam' 323 | requestBody: 324 | required: true 325 | content: 326 | application/json: 327 | schema: 328 | $ref: '#/components/schemas/UpdateUserRequest' 329 | responses: 330 | '200': 331 | description: User updated successfully 332 | content: 333 | application/json: 334 | schema: 335 | $ref: '#/components/schemas/User' 336 | '400': 337 | description: Bad request 338 | content: 339 | application/json: 340 | schema: 341 | $ref: '#/components/schemas/ErrorResponse' 342 | '404': 343 | description: User not found 344 | content: 345 | application/json: 346 | schema: 347 | $ref: '#/components/schemas/ErrorResponse' 348 | '500': 349 | description: Internal server error 350 | content: 351 | application/json: 352 | schema: 353 | $ref: '#/components/schemas/ErrorResponse' 354 | security: 355 | - bearerAuth: [ ] 356 | 357 | delete: 358 | summary: Delete user 359 | description: Delete a user by their ID 360 | operationId: deleteUser 361 | tags: 362 | - Users 363 | parameters: 364 | - $ref: '#/components/parameters/UserIdParam' 365 | responses: 366 | '204': 367 | description: User deleted successfully 368 | '404': 369 | description: User not found 370 | content: 371 | application/json: 372 | schema: 373 | $ref: '#/components/schemas/ErrorResponse' 374 | '500': 375 | description: Internal server error 376 | content: 377 | application/json: 378 | schema: 379 | $ref: '#/components/schemas/ErrorResponse' 380 | security: 381 | - bearerAuth: [ ] 382 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/request/actix_web.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | use crate::model::parse::OpenAPI; 19 | use crate::observability::RequestContext; 20 | use crate::validator::{body, method, path, query, ValidateRequest}; 21 | use actix_web::{ 22 | body::{EitherBody, MessageBody}, 23 | dev::{forward_ready, Payload, Service, ServiceRequest, ServiceResponse, Transform}, 24 | web::{Bytes, BytesMut}, 25 | Error, HttpMessage, HttpRequest, 26 | }; 27 | use anyhow::Result; 28 | use futures_util::{future::LocalBoxFuture, StreamExt}; 29 | use serde_json::Value; 30 | use std::collections::HashMap; 31 | use std::future::{ready, Ready}; 32 | use std::rc::Rc; 33 | use std::sync::Arc; 34 | 35 | #[allow(dead_code)] 36 | pub struct RequestData { 37 | pub path: String, 38 | pub method: String, 39 | pub query_string: String, 40 | pub body: Option, 41 | } 42 | 43 | impl ValidateRequest for RequestData { 44 | fn header(&self, _: &OpenAPI) -> Result<()> { 45 | Ok(()) 46 | } 47 | 48 | fn method(&self, open_api: &OpenAPI) -> Result<()> { 49 | method(self.path.as_str(), self.method.as_str(), open_api) 50 | } 51 | 52 | fn query(&self, open_api: &OpenAPI) -> Result<()> { 53 | let query_pairs: HashMap = if !self.query_string.is_empty() { 54 | self.query_string 55 | .split('&') 56 | .filter_map(|pair| { 57 | let mut split = pair.split('='); 58 | match (split.next(), split.next()) { 59 | (Some(key), Some(value)) => Some((key.to_string(), value.to_string())), 60 | _ => None, 61 | } 62 | }) 63 | .collect() 64 | } else { 65 | HashMap::new() 66 | }; 67 | 68 | query(self.path.as_str(), &query_pairs, open_api) 69 | } 70 | 71 | fn path(&self, open_api: &OpenAPI) -> Result<()> { 72 | if let Some(last_segment) = self.path.rsplit('/').find(|s| !s.is_empty()) { 73 | path(self.path.as_str(), last_segment, open_api)? 74 | } 75 | 76 | Ok(()) 77 | } 78 | 79 | fn body(&self, open_api: &OpenAPI) -> Result<()> { 80 | if self.body.is_none() { 81 | return Ok(()); 82 | } 83 | let self_body = self 84 | .body 85 | .as_ref() 86 | .ok_or_else(|| anyhow::anyhow!("Missing body"))?; 87 | let request_fields: Value = serde_json::from_slice(self_body)?; 88 | body(self.path.as_str(), request_fields, open_api) 89 | } 90 | 91 | fn context(&self) -> RequestContext { 92 | RequestContext::new(self.method.clone(), self.path.clone()) 93 | } 94 | } 95 | 96 | /// OpenAPI validates middleware 97 | /// 98 | /// Provides request validation based on OpenAPI specifications, supporting path, method, query parameters, and request body validation. 99 | /// 100 | /// # example 101 | /// 102 | /// ```rust 103 | /// use actix_web::{web, App, HttpServer, HttpResponse, Result}; 104 | /// use openapi_rs::request::actix_web::OpenApiValidation; 105 | /// 106 | /// async fn create_user() -> Result { 107 | /// Ok(HttpResponse::Ok().json(serde_json::json!({"status": "created"}))) 108 | /// } 109 | /// 110 | /// #[actix_web::main] 111 | /// async fn main() -> Result<()> { 112 | /// let yaml_content = include_str!("api.yaml"); 113 | /// let validation = OpenApiValidation::from_yaml(yaml_content)?; 114 | /// 115 | /// HttpServer::new(move || { 116 | /// App::new() 117 | /// .wrap(validation.clone()) 118 | /// .route("/api/users", web::post().to(create_user)) 119 | /// }) 120 | /// .bind("127.0.0.1:8080")? 121 | /// .run() 122 | /// .await 123 | /// } 124 | /// ``` 125 | #[derive(Debug, Clone)] 126 | pub struct OpenApiValidation { 127 | openapi: Arc, 128 | } 129 | 130 | impl OpenApiValidation { 131 | pub fn new(openapi: OpenAPI) -> Self { 132 | Self { 133 | openapi: Arc::new(openapi), 134 | } 135 | } 136 | 137 | pub fn from_yaml(yaml_content: &str) -> Result { 138 | let openapi: OpenAPI = serde_yaml::from_str(yaml_content)?; 139 | Ok(Self::new(openapi)) 140 | } 141 | } 142 | 143 | impl Transform for OpenApiValidation 144 | where 145 | S: Service, Error = Error> + 'static, 146 | S::Future: 'static, 147 | B: MessageBody + 'static, 148 | { 149 | type Response = ServiceResponse>; 150 | type Error = Error; 151 | type Transform = OpenApiValidationMiddleware; 152 | type InitError = (); 153 | type Future = Ready>; 154 | 155 | fn new_transform(&self, service: S) -> Self::Future { 156 | ready(Ok(OpenApiValidationMiddleware { 157 | service: Rc::new(service), 158 | openapi: self.openapi.clone(), 159 | })) 160 | } 161 | } 162 | 163 | pub struct OpenApiValidationMiddleware { 164 | service: Rc, 165 | openapi: Arc, 166 | } 167 | 168 | impl Service for OpenApiValidationMiddleware 169 | where 170 | S: Service, Error = Error> + 'static, 171 | S::Future: 'static, 172 | B: MessageBody + 'static, 173 | { 174 | type Response = ServiceResponse>; 175 | type Error = Error; 176 | type Future = LocalBoxFuture<'static, Result>; 177 | 178 | forward_ready!(service); 179 | 180 | fn call(&self, req: ServiceRequest) -> Self::Future { 181 | let service = Rc::clone(&self.service); 182 | let openapi = Arc::clone(&self.openapi); 183 | 184 | Box::pin(async move { 185 | let path = req.path().to_string(); 186 | let method = req.method().as_str().to_lowercase(); 187 | let query_string = req.query_string().to_string(); 188 | 189 | let (http_req, payload) = req.into_parts(); 190 | 191 | let mut req_body = None; 192 | 193 | if Self::should_extract_body(&http_req) { 194 | match Self::extract_body_safely(payload, &http_req).await { 195 | Ok(body) => req_body = body, 196 | Err(e) => { 197 | let error_req = 198 | ServiceRequest::from_parts(http_req, Payload::from(Vec::::new())); 199 | return Ok(error_req.error_response(e).map_into_right_body()); 200 | } 201 | } 202 | } 203 | 204 | let request_data = RequestData { 205 | path: path.clone(), 206 | method, 207 | query_string, 208 | body: req_body.clone(), 209 | }; 210 | 211 | let rebuild_service_request = |http_req: HttpRequest, req_body: &Option| { 212 | if let Some(ref body_bytes) = req_body { 213 | let req = 214 | ServiceRequest::from_parts(http_req, Payload::from(body_bytes.clone())); 215 | req.extensions_mut().insert(body_bytes.clone()); 216 | req 217 | } else { 218 | ServiceRequest::from_parts(http_req, Payload::from(Vec::::new())) 219 | } 220 | }; 221 | 222 | if let Err(e) = openapi.validator(request_data) { 223 | let validation_error = 224 | actix_web::error::ErrorBadRequest(format!("OpenAPI validation failed: {e}")); 225 | 226 | let service_req = rebuild_service_request(http_req, &req_body); 227 | return Ok(service_req 228 | .error_response(validation_error) 229 | .map_into_right_body()); 230 | } 231 | 232 | let service_req = rebuild_service_request(http_req, &req_body); 233 | 234 | service 235 | .call(service_req) 236 | .await 237 | .map(|res| res.map_into_left_body()) 238 | }) 239 | } 240 | } 241 | 242 | impl OpenApiValidationMiddleware { 243 | fn should_extract_body(req: &HttpRequest) -> bool { 244 | req.headers().contains_key("content-length") 245 | || req.headers().contains_key("transfer-encoding") 246 | } 247 | 248 | async fn extract_body_safely( 249 | mut payload: Payload, 250 | _req: &HttpRequest, 251 | ) -> Result, Error> { 252 | let mut body = BytesMut::new(); 253 | 254 | while let Some(chunk_result) = payload.next().await { 255 | let chunk = chunk_result.map_err(|e| { 256 | actix_web::error::ErrorBadRequest(format!("Error reading request chunk: {e}")) 257 | })?; 258 | 259 | body.extend_from_slice(&chunk); 260 | } 261 | 262 | if body.is_empty() { 263 | Ok(None) 264 | } else { 265 | Ok(Some(body.freeze())) 266 | } 267 | } 268 | } 269 | 270 | pub mod middleware { 271 | use super::OpenApiValidation; 272 | 273 | pub struct Validation; 274 | 275 | impl Validation { 276 | pub fn from_yaml(yaml_content: &str) -> anyhow::Result { 277 | OpenApiValidation::from_yaml(yaml_content) 278 | } 279 | 280 | pub fn from_openapi(openapi: crate::model::parse::OpenAPI) -> OpenApiValidation { 281 | OpenApiValidation::new(openapi) 282 | } 283 | } 284 | } 285 | 286 | #[cfg(test)] 287 | mod tests { 288 | use super::*; 289 | use actix_web::{ 290 | test::{self, TestRequest}, 291 | web, App, HttpResponse, Result, 292 | }; 293 | 294 | async fn dummy_handler() -> Result { 295 | Ok(HttpResponse::Ok().json(serde_json::json!({"status": "ok"}))) 296 | } 297 | 298 | #[actix_web::test] 299 | async fn test_middleware_with_valid_request() { 300 | let yaml_content = r#" 301 | openapi: 3.0.0 302 | info: 303 | title: Test API 304 | version: 1.0.0 305 | paths: 306 | /test: 307 | get: 308 | responses: 309 | '200': 310 | description: Success 311 | "#; 312 | 313 | let validation = OpenApiValidation::from_yaml(yaml_content).unwrap(); 314 | 315 | let app = test::init_service( 316 | App::new() 317 | .wrap(validation) 318 | .route("/test", web::get().to(dummy_handler)), 319 | ) 320 | .await; 321 | 322 | let req = TestRequest::get().uri("/test").to_request(); 323 | let resp = test::call_service(&app, req).await; 324 | 325 | assert!(resp.status().is_success()); 326 | } 327 | 328 | #[actix_web::test] 329 | async fn test_middleware_with_post_request() { 330 | let yaml_content = r#" 331 | openapi: 3.0.0 332 | info: 333 | title: Test API 334 | version: 1.0.0 335 | paths: 336 | /test: 337 | post: 338 | requestBody: 339 | content: 340 | application/json: 341 | schema: 342 | type: object 343 | responses: 344 | '200': 345 | description: Success 346 | "#; 347 | 348 | let validation = OpenApiValidation::from_yaml(yaml_content).unwrap(); 349 | 350 | let app = test::init_service( 351 | App::new() 352 | .wrap(validation) 353 | .route("/test", web::post().to(dummy_handler)), 354 | ) 355 | .await; 356 | 357 | let req = TestRequest::post() 358 | .uri("/test") 359 | .set_json(&serde_json::json!({"test": "value"})) 360 | .to_request(); 361 | 362 | let resp = test::call_service(&app, req).await; 363 | assert!(resp.status().is_success()); 364 | } 365 | 366 | #[test] 367 | fn test_should_extract_body() { 368 | use actix_web::http::header; 369 | 370 | let req = TestRequest::post() 371 | .append_header((header::CONTENT_LENGTH, "100")) 372 | .to_http_request(); 373 | 374 | assert!(OpenApiValidationMiddleware::<()>::should_extract_body(&req)); 375 | 376 | let req = TestRequest::get().to_http_request(); 377 | assert!(!OpenApiValidationMiddleware::<()>::should_extract_body( 378 | &req 379 | )); 380 | 381 | let req = TestRequest::post() 382 | .append_header((header::TRANSFER_ENCODING, "chunked")) 383 | .to_http_request(); 384 | 385 | assert!(OpenApiValidationMiddleware::<()>::should_extract_body(&req)); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /src/validator/pattern_test.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use crate::model::parse::{ 21 | In, InfoObject, OpenAPI, Parameter, PathBase, PathItem, Schema, Type, TypeOrUnion, 22 | }; 23 | use crate::validator::{query, validate_pattern}; 24 | use serde_json::Value; 25 | use std::collections::HashMap; 26 | 27 | const EMAIL_PATTERN: &str = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"; 28 | const PHONE_PATTERN: &str = r"^\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}$"; 29 | const SSN_PATTERN: &str = r"^\d{3}-\d{2}-\d{4}$"; 30 | const INVALID_REGEX: &str = "[invalid-regex"; 31 | 32 | struct TestCase { 33 | name: &'static str, 34 | pattern: &'static str, 35 | valid_values: Vec<&'static str>, 36 | invalid_values: Vec<&'static str>, 37 | } 38 | 39 | impl TestCase { 40 | const fn new( 41 | name: &'static str, 42 | pattern: &'static str, 43 | valid_values: Vec<&'static str>, 44 | invalid_values: Vec<&'static str>, 45 | ) -> Self { 46 | Self { 47 | name, 48 | pattern, 49 | valid_values, 50 | invalid_values, 51 | } 52 | } 53 | } 54 | 55 | fn create_base_openapi() -> OpenAPI { 56 | OpenAPI { 57 | openapi: "3.1.0".to_string(), 58 | info: InfoObject { 59 | title: "Test API".to_string(), 60 | description: None, 61 | version: "1.0.0".to_string(), 62 | }, 63 | servers: vec![], 64 | paths: HashMap::new(), 65 | components: None, 66 | } 67 | } 68 | 69 | fn create_parameter_with_pattern( 70 | name: &str, 71 | pattern: Option, 72 | required: bool, 73 | ) -> Parameter { 74 | Parameter { 75 | r#ref: None, 76 | name: Some(name.to_string()), 77 | r#in: Some(In::Query), 78 | required, 79 | description: None, 80 | example: None, 81 | r#type: Some(TypeOrUnion::Single(Type::String)), 82 | r#enum: None, 83 | pattern, 84 | schema: None, 85 | extra: HashMap::new(), 86 | } 87 | } 88 | 89 | fn create_parameter_with_schema_pattern( 90 | name: &str, 91 | pattern: Option, 92 | required: bool, 93 | ) -> Parameter { 94 | let schema = Schema { 95 | r#type: Some(TypeOrUnion::Single(Type::String)), 96 | format: None, 97 | title: None, 98 | description: None, 99 | r#enum: None, 100 | pattern, 101 | properties: None, 102 | example: None, 103 | examples: None, 104 | r#ref: None, 105 | all_of: None, 106 | one_of: None, 107 | items: None, 108 | required: vec![], 109 | min_items: None, 110 | max_items: None, 111 | min_length: None, 112 | max_length: None, 113 | minimum: None, 114 | maximum: None, 115 | }; 116 | 117 | Parameter { 118 | r#ref: None, 119 | name: Some(name.to_string()), 120 | r#in: Some(In::Query), 121 | required, 122 | description: None, 123 | example: None, 124 | r#type: None, 125 | r#enum: None, 126 | pattern: None, 127 | schema: Some(Box::new(schema)), 128 | extra: HashMap::new(), 129 | } 130 | } 131 | 132 | fn create_openapi_with_parameters(parameters: Vec) -> OpenAPI { 133 | let mut openapi = create_base_openapi(); 134 | 135 | let path_base = PathBase { 136 | summary: None, 137 | description: None, 138 | operation_id: None, 139 | parameters: Some(parameters), 140 | request: None, 141 | servers: vec![], 142 | }; 143 | 144 | let mut operations = HashMap::new(); 145 | operations.insert("get".to_string(), path_base); 146 | 147 | let path_item = PathItem { 148 | parameters: None, 149 | operations, 150 | servers: vec![], 151 | extra: serde_yaml::Value::Null, 152 | }; 153 | 154 | openapi.paths.insert("/test".to_string(), path_item); 155 | openapi 156 | } 157 | 158 | fn test_query_validation(openapi: &OpenAPI, params: &[(&str, &str)], should_succeed: bool) { 159 | let query_params: HashMap = params 160 | .iter() 161 | .map(|(k, v)| (k.to_string(), v.to_string())) 162 | .collect(); 163 | 164 | let result = query("/test", &query_params, openapi); 165 | 166 | if should_succeed { 167 | assert!( 168 | result.is_ok(), 169 | "Expected validation to succeed but got error: {:?}", 170 | result.err() 171 | ); 172 | } else { 173 | assert!( 174 | result.is_err(), 175 | "Expected validation to fail but it succeeded" 176 | ); 177 | let error_msg = result.unwrap_err().to_string(); 178 | assert!( 179 | error_msg.contains("does not match the required pattern"), 180 | "Error message should mention pattern mismatch, got: {}", 181 | error_msg 182 | ); 183 | } 184 | } 185 | 186 | #[test] 187 | fn test_pattern_validation_comprehensive() { 188 | let test_cases = [ 189 | TestCase::new( 190 | "email", 191 | EMAIL_PATTERN, 192 | vec![ 193 | "test@example.com", 194 | "user.name+tag@domain.co.uk", 195 | "test123@test-domain.org", 196 | ], 197 | vec![ 198 | "invalid-email", 199 | "test@", 200 | "@domain.com", 201 | "test.domain.com", 202 | "test@domain", 203 | ], 204 | ), 205 | TestCase::new( 206 | "phone", 207 | PHONE_PATTERN, 208 | vec![ 209 | "(555) 123-4567", 210 | "555-123-4567", 211 | "5551234567", 212 | "+1 555 123 4567", 213 | ], 214 | vec!["invalid-phone", "123", "555-123-456", "(555) 123-45678"], 215 | ), 216 | TestCase::new( 217 | "ssn", 218 | SSN_PATTERN, 219 | vec!["123-45-6789", "000-00-0000", "999-99-9999"], 220 | vec!["123456789", "123-4-5678", "1234-56-789", "123-456-789"], 221 | ), 222 | ]; 223 | 224 | for test_case in test_cases.iter() { 225 | let param = create_parameter_with_pattern( 226 | test_case.name, 227 | Some(test_case.pattern.to_string()), 228 | true, 229 | ); 230 | let openapi = create_openapi_with_parameters(vec![param]); 231 | 232 | for valid_value in &test_case.valid_values { 233 | test_query_validation(&openapi, &[(test_case.name, valid_value)], true); 234 | } 235 | 236 | for invalid_value in &test_case.invalid_values { 237 | test_query_validation(&openapi, &[(test_case.name, invalid_value)], false); 238 | } 239 | } 240 | } 241 | 242 | #[test] 243 | fn test_schema_pattern_validation() { 244 | let param = 245 | create_parameter_with_schema_pattern("ssn", Some(SSN_PATTERN.to_string()), true); 246 | let openapi = create_openapi_with_parameters(vec![param]); 247 | 248 | test_query_validation(&openapi, &[("ssn", "123-45-6789")], true); 249 | 250 | test_query_validation(&openapi, &[("ssn", "123456789")], false); 251 | } 252 | 253 | #[test] 254 | fn test_multiple_patterns_validation() { 255 | let parameters = vec![ 256 | create_parameter_with_pattern("email", Some(EMAIL_PATTERN.to_string()), true), 257 | create_parameter_with_pattern("phone", Some(PHONE_PATTERN.to_string()), false), 258 | ]; 259 | let openapi = create_openapi_with_parameters(parameters); 260 | 261 | test_query_validation( 262 | &openapi, 263 | &[("email", "test@example.com"), ("phone", "(555) 123-4567")], 264 | true, 265 | ); 266 | 267 | test_query_validation( 268 | &openapi, 269 | &[("email", "test@example.com"), ("phone", "invalid-phone")], 270 | false, 271 | ); 272 | 273 | test_query_validation(&openapi, &[("email", "test@example.com")], true); 274 | 275 | test_query_validation(&openapi, &[("email", "invalid-email")], false); 276 | } 277 | 278 | #[test] 279 | fn test_invalid_regex_pattern() { 280 | let param = create_parameter_with_pattern("test", Some(INVALID_REGEX.to_string()), true); 281 | let openapi = create_openapi_with_parameters(vec![param]); 282 | 283 | let result = query( 284 | "/test", 285 | &[("test", "anything")] 286 | .iter() 287 | .map(|(k, v)| (k.to_string(), v.to_string())) 288 | .collect(), 289 | &openapi, 290 | ); 291 | 292 | assert!(result.is_err()); 293 | let error_msg = result.unwrap_err().to_string(); 294 | assert!( 295 | error_msg.contains("Invalid regex pattern"), 296 | "Error message should mention invalid regex, got: {}", 297 | error_msg 298 | ); 299 | } 300 | 301 | #[test] 302 | fn test_pattern_with_non_string_values() { 303 | let test_cases = [ 304 | ("number", Value::Number(123.into())), 305 | ("boolean", Value::Bool(true)), 306 | ("null", Value::Null), 307 | ( 308 | "array", 309 | Value::Array(vec![Value::String("test".to_string())]), 310 | ), 311 | ("object", Value::Object(serde_json::Map::new())), 312 | ]; 313 | 314 | for (name, value) in test_cases.iter() { 315 | let result = validate_pattern(name, value, Some(&"^\\d+$".to_string())); 316 | assert!( 317 | result.is_ok(), 318 | "Non-string value {} should pass pattern validation", 319 | name 320 | ); 321 | } 322 | } 323 | 324 | #[test] 325 | fn test_pattern_validation_edge_cases() { 326 | let edge_cases = [ 327 | ( 328 | "empty pattern should always succeed", 329 | "anything", 330 | None, 331 | true, 332 | ), 333 | ( 334 | "empty string with non-empty pattern should fail", 335 | "", 336 | Some("^.+$"), 337 | false, 338 | ), 339 | ( 340 | "empty string with empty pattern should succeed", 341 | "", 342 | Some("^$"), 343 | true, 344 | ), 345 | ( 346 | "whitespace with whitespace pattern", 347 | " ", 348 | Some("^\\s+$"), 349 | true, 350 | ), 351 | ( 352 | "complex unicode pattern", 353 | "test@example.rs", 354 | Some("^[\\p{L}\\p{N}.@]+$"), 355 | true, 356 | ), 357 | ( 358 | "digit pattern with letters should fail", 359 | "abc123", 360 | Some("^\\d+$"), 361 | false, 362 | ), 363 | ( 364 | "optional pattern with valid input", 365 | "test123", 366 | Some("^[a-z]+\\d*$"), 367 | true, 368 | ), 369 | ]; 370 | 371 | for (description, value, pattern, should_succeed) in edge_cases.iter() { 372 | let pattern_string = pattern.map(|p| p.to_string()); 373 | let result = validate_pattern( 374 | "test_field", 375 | &Value::String(value.to_string()), 376 | pattern_string.as_ref(), 377 | ); 378 | 379 | if *should_succeed { 380 | assert!( 381 | result.is_ok(), 382 | "Test '{}' should succeed but failed: {:?}", 383 | description, 384 | result.err() 385 | ); 386 | } else { 387 | assert!( 388 | result.is_err(), 389 | "Test '{}' should fail but succeeded", 390 | description 391 | ); 392 | } 393 | } 394 | } 395 | 396 | #[test] 397 | fn test_pattern_priority_parameter_vs_schema() { 398 | let schema = Schema { 399 | r#type: Some(TypeOrUnion::Single(Type::String)), 400 | pattern: Some("^schema-pattern$".to_string()), 401 | format: None, 402 | title: None, 403 | description: None, 404 | r#enum: None, 405 | properties: None, 406 | example: None, 407 | examples: None, 408 | r#ref: None, 409 | all_of: None, 410 | one_of: None, 411 | items: None, 412 | required: vec![], 413 | min_items: None, 414 | max_items: None, 415 | min_length: None, 416 | max_length: None, 417 | minimum: None, 418 | maximum: None, 419 | }; 420 | 421 | let param = Parameter { 422 | r#ref: None, 423 | name: Some("test".to_string()), 424 | r#in: Some(In::Query), 425 | required: true, 426 | description: None, 427 | example: None, 428 | r#type: None, 429 | r#enum: None, 430 | pattern: Some("^param-pattern$".to_string()), 431 | schema: Some(Box::new(schema)), 432 | extra: HashMap::new(), 433 | }; 434 | 435 | let openapi = create_openapi_with_parameters(vec![param]); 436 | 437 | test_query_validation(&openapi, &[("test", "param-pattern")], false); // 只匹配参数 pattern,不匹配 schema pattern 438 | test_query_validation(&openapi, &[("test", "schema-pattern")], false); // 只匹配 schema pattern,不匹配参数 pattern 439 | } 440 | 441 | #[test] 442 | fn test_pattern_performance_with_complex_regex() { 443 | let complex_pattern = r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"; 444 | 445 | let start = std::time::Instant::now(); 446 | 447 | let param = create_parameter_with_pattern("email", Some(complex_pattern.to_string()), true); 448 | let openapi = create_openapi_with_parameters(vec![param]); 449 | 450 | for _ in 0..100 { 451 | test_query_validation(&openapi, &[("email", "test@example.com")], true); 452 | } 453 | 454 | let duration = start.elapsed(); 455 | assert!( 456 | duration.as_millis() < 1000, 457 | "Pattern validation should be fast, took {:?}", 458 | duration 459 | ); 460 | } 461 | } 462 | -------------------------------------------------------------------------------- /tests/tests.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use openapi_rs::model::parse::{Format, In, OpenAPI, Type, TypeOrUnion}; 21 | use serde_yaml::Value; 22 | use serde_yaml::Value::Sequence; 23 | use std::env; 24 | 25 | #[test] 26 | fn parse_example() -> Result<(), Box> { 27 | let content = 28 | std::fs::read_to_string(env::current_dir()?.join("tests/example/example.yaml"))?; 29 | 30 | let openapi: OpenAPI = OpenAPI::yaml(&content)?; 31 | 32 | // Validate general OpenAPI properties 33 | assert_eq!(openapi.openapi, "3.1.0"); 34 | assert_eq!(openapi.info.title, "Example API"); 35 | assert!(openapi.components.is_some()); 36 | 37 | let components = openapi.components.as_ref().unwrap(); 38 | 39 | // Validate schemas' presence of "oneOf" and "allOf" 40 | let schemas_check = [("ExampleRequest", false), ("ExampleResponse", false)]; 41 | for (schema_name, expected_one_of) in schemas_check.iter() { 42 | let schema = components 43 | .schemas 44 | .get(*schema_name) 45 | .ok_or(format!("Missing schema: {}", *schema_name))?; 46 | assert_eq!(schema.one_of.is_some(), *expected_one_of); 47 | } 48 | 49 | let schemas_check_all_of = [("ExampleRequest", false), ("ExampleResponse", true)]; 50 | for (schema_name, expected_all_of) in schemas_check_all_of.iter() { 51 | let schema = components 52 | .schemas 53 | .get(*schema_name) 54 | .ok_or(format!("Missing schema: {}", *schema_name))?; 55 | assert_eq!(schema.all_of.is_some(), *expected_all_of); 56 | } 57 | 58 | // Validate paths 59 | let example_path = openapi 60 | .paths 61 | .get("/example/{uuid}") 62 | .ok_or("Missing path: /example/{uuid}")?; 63 | let get_value = example_path 64 | .operations 65 | .get("get") 66 | .ok_or("Missing GET method for /example/{uuid}")?; 67 | 68 | // Validate GET parameters 69 | let parameter = get_value 70 | .parameters 71 | .as_ref() 72 | .and_then(|params| params.first()) 73 | .ok_or("Missing parameter")?; 74 | 75 | // Since Parameter is now a struct, access fields directly 76 | assert_eq!(parameter.name.as_deref(), Some("uuid")); 77 | assert_eq!( 78 | parameter.description.as_deref(), 79 | Some("The UUID for this example.") 80 | ); 81 | assert_eq!(parameter.r#in.as_ref(), Some(&In::Path)); 82 | if let Some(schema) = ¶meter.schema { 83 | assert_eq!(schema.r#type, Some(TypeOrUnion::Single(Type::String))); 84 | assert_eq!(schema.format, Some(Format::UUID)); 85 | assert_eq!( 86 | schema.example.clone().unwrap(), 87 | "00000000-0000-0000-0000-000000000000" 88 | ); 89 | assert!(schema.examples.is_none()); 90 | } 91 | 92 | Ok(()) 93 | } 94 | 95 | #[test] 96 | fn parse_components_base() -> Result<(), Box> { 97 | let content = r#" 98 | openapi: 3.1.0 99 | info: 100 | title: Example API 101 | description: API definitions for example 102 | version: '0.0.1' 103 | 104 | components: 105 | schemas: 106 | ExampleRequest: 107 | title: example request 108 | description: example description 109 | type: object 110 | properties: 111 | result: 112 | type: string 113 | description: example 114 | example: example 115 | required: 116 | - result 117 | paths: 118 | "#; 119 | 120 | let openapi: OpenAPI = OpenAPI::yaml(content)?; 121 | 122 | // Validate general properties 123 | assert_eq!(openapi.openapi, "3.1.0"); 124 | assert_eq!(openapi.info.title, "Example API"); 125 | assert_eq!( 126 | openapi.info.description.as_deref(), 127 | Some("API definitions for example") 128 | ); 129 | assert_eq!(openapi.info.version, "0.0.1"); 130 | 131 | // Validate components and schemas 132 | let components = openapi.components.as_ref().unwrap(); 133 | let example_request = components.schemas.get("ExampleRequest").unwrap(); 134 | 135 | assert!(example_request.one_of.is_none()); 136 | assert!(example_request.all_of.is_none()); 137 | 138 | // Validate "ExampleRequest" properties 139 | assert_eq!(example_request.title.as_ref().unwrap(), "example request"); 140 | assert_eq!( 141 | example_request.description.as_ref().unwrap(), 142 | "example description" 143 | ); 144 | assert!(!example_request.required.is_empty()); 145 | 146 | // Validate "result" property in ExampleRequest 147 | let result = example_request 148 | .properties 149 | .as_ref() 150 | .unwrap() 151 | .get("result") 152 | .unwrap(); 153 | assert_eq!(result.r#type, Some(TypeOrUnion::Single(Type::String))); 154 | assert_eq!(result.minimum, None); 155 | assert_eq!(result.maximum, None); 156 | assert_eq!(result.example.clone().unwrap(), "example"); 157 | 158 | Ok(()) 159 | } 160 | 161 | #[test] 162 | fn parse_components_all_of() -> Result<(), Box> { 163 | let content = r#" 164 | openapi: 3.1.0 165 | info: 166 | title: Example API 167 | description: API definitions for example 168 | version: "0.0.1" 169 | 170 | components: 171 | schemas: 172 | ExampleResponse: 173 | allOf: 174 | - type: object 175 | properties: 176 | result: 177 | type: object 178 | description: example. 179 | properties: 180 | uuid: 181 | type: string 182 | description: The UUID for this example. 183 | format: uuid 184 | example: 00000000-0000-0000-0000-000000000000 185 | count: 186 | type: integer 187 | description: example count. 188 | example: 1 189 | maximum: 1 190 | required: 191 | - uuid 192 | paths: 193 | "#; 194 | 195 | let openapi: OpenAPI = OpenAPI::yaml(content)?; 196 | 197 | // Validate general properties 198 | assert_eq!(openapi.openapi, "3.1.0"); 199 | assert_eq!(openapi.info.title, "Example API"); 200 | assert_eq!( 201 | openapi.info.description.as_deref(), 202 | Some("API definitions for example") 203 | ); 204 | assert_eq!(openapi.info.version, "0.0.1"); 205 | 206 | // Validate components and schemas 207 | let components = openapi.components.as_ref().ok_or("Missing components")?; 208 | let example_response = components 209 | .schemas 210 | .get("ExampleResponse") 211 | .ok_or("Missing ExampleResponse schema")?; 212 | 213 | // Assert "allOf" exists and validate it 214 | let all_of = example_response 215 | .all_of 216 | .as_ref() 217 | .ok_or("Missing allOf in ExampleResponse")?; 218 | let first = &all_of[0]; 219 | 220 | // Validate "allOf" object 221 | assert_eq!(first.r#type, Some(TypeOrUnion::Single(Type::Object))); 222 | assert!(first.description.is_none()); 223 | 224 | // Validate "result" object properties 225 | let result = first 226 | .properties 227 | .get("result") 228 | .ok_or("Missing result property")?; 229 | assert_eq!(result.r#type, Some(TypeOrUnion::Single(Type::Object))); 230 | assert_eq!(result.description.as_deref(), Some("example.")); 231 | assert!(!result.required.is_empty()); 232 | 233 | // Validate "uuid" property in result 234 | let uuid = result 235 | .properties 236 | .as_ref() 237 | .ok_or("Missing properties in result")? 238 | .get("uuid") 239 | .ok_or("Missing uuid")?; 240 | assert_eq!(uuid.r#type, Some(TypeOrUnion::Single(Type::String))); 241 | assert_eq!( 242 | uuid.description.as_deref(), 243 | Some("The UUID for this example.") 244 | ); 245 | assert_eq!(uuid.format, Some(Format::UUID)); 246 | assert_eq!( 247 | uuid.example.clone().unwrap(), 248 | "00000000-0000-0000-0000-000000000000" 249 | ); 250 | assert_eq!(uuid.minimum, None); 251 | assert_eq!(uuid.maximum, None); 252 | 253 | // Validate "count" property in result 254 | let count = result 255 | .properties 256 | .as_ref() 257 | .ok_or("Missing properties in result")? 258 | .get("count") 259 | .ok_or("Missing count")?; 260 | assert_eq!(count.r#type, Some(TypeOrUnion::Single(Type::Integer))); 261 | assert_eq!(count.description.as_deref(), Some("example count.")); 262 | assert_eq!(count.format, None); 263 | assert_eq!(count.example.clone().unwrap(), 1); 264 | assert_eq!(count.minimum, None); 265 | assert_eq!(count.maximum, Some(1.0)); 266 | 267 | Ok(()) 268 | } 269 | 270 | #[test] 271 | fn parse_components_one_of() -> Result<(), Box> { 272 | let content = r#" 273 | openapi: 3.1.0 274 | info: 275 | title: Example API 276 | description: API definitions for example 277 | version: '0.0.1' 278 | 279 | components: 280 | schemas: 281 | ExampleResponse: 282 | oneOf: 283 | - type: object 284 | properties: 285 | result: 286 | type: object 287 | description: example. 288 | properties: 289 | uuid: 290 | type: string 291 | description: The UUID for this example. 292 | format: uuid 293 | example: 00000000-0000-0000-0000-000000000000 294 | paths: 295 | "#; 296 | 297 | let openapi: OpenAPI = OpenAPI::yaml(content)?; 298 | 299 | // Validate general properties 300 | assert_eq!(openapi.openapi, "3.1.0"); 301 | assert_eq!(openapi.info.title, "Example API"); 302 | assert_eq!( 303 | openapi.info.description.as_deref(), 304 | Some("API definitions for example") 305 | ); 306 | assert_eq!(openapi.info.version, "0.0.1"); 307 | 308 | // Validate components and schemas 309 | let components = openapi.components.as_ref().ok_or("Missing components")?; 310 | let example_response = components 311 | .schemas 312 | .get("ExampleResponse") 313 | .ok_or("Missing ExampleResponse schema")?; 314 | 315 | assert!(example_response.one_of.is_some()); 316 | 317 | // Validate "oneOf" 318 | let one_of = &example_response.one_of.as_ref().unwrap()[0]; 319 | 320 | // Validate "oneOf" object 321 | assert_eq!(one_of.r#type, Some(TypeOrUnion::Single(Type::Object))); 322 | assert!(one_of.description.is_none()); 323 | 324 | // Validate "result" object properties 325 | 326 | let result = one_of 327 | .properties 328 | .get("result") 329 | .ok_or("Missing result property")?; 330 | assert_eq!(result.r#type, Some(TypeOrUnion::Single(Type::Object))); 331 | assert_eq!(result.description.as_deref(), Some("example.")); 332 | assert!(result.required.is_empty()); 333 | 334 | // Validate "uuid" property in result 335 | let uuid = result 336 | .properties 337 | .as_ref() 338 | .ok_or("Missing properties in result")? 339 | .get("uuid") 340 | .ok_or("Missing uuid")?; 341 | assert_eq!(uuid.r#type, Some(TypeOrUnion::Single(Type::String))); 342 | assert_eq!( 343 | uuid.description.as_deref(), 344 | Some("The UUID for this example.") 345 | ); 346 | assert_eq!(uuid.format, Some(Format::UUID)); 347 | assert_eq!( 348 | uuid.example.clone().unwrap(), 349 | "00000000-0000-0000-0000-000000000000" 350 | ); 351 | assert_eq!(uuid.minimum, None); 352 | assert_eq!(uuid.maximum, None); 353 | 354 | Ok(()) 355 | } 356 | 357 | #[test] 358 | fn parse_path_response_one_of() -> Result<(), Box> { 359 | let content = r#" 360 | openapi: 3.1.0 361 | info: 362 | title: Example API 363 | description: API definitions for example 364 | version: '0.0.1' 365 | 366 | paths: 367 | 368 | /example: 369 | get: 370 | responses: 371 | 200: 372 | description: OK 373 | content: 374 | application/json: 375 | schema: 376 | oneOf: 377 | - $ref: '#/components/schemas/Cat' 378 | - $ref: '#/components/schemas/Dog' 379 | - $ref: '#/components/schemas/Hamster' 380 | "#; 381 | 382 | let openapi: OpenAPI = OpenAPI::yaml(content)?; 383 | 384 | // Validate general properties 385 | assert_eq!(openapi.openapi, "3.1.0"); 386 | assert_eq!(openapi.info.title, "Example API"); 387 | assert_eq!( 388 | openapi.info.description.as_deref(), 389 | Some("API definitions for example") 390 | ); 391 | assert_eq!(openapi.info.version, "0.0.1"); 392 | assert!(openapi.components.is_none()); 393 | 394 | // Validate paths 395 | let example_path = openapi 396 | .paths 397 | .get("/example") 398 | .ok_or("Missing path: /example")?; 399 | 400 | let _ = example_path 401 | .operations 402 | .get("get") 403 | .ok_or("Missing GET method for /example")?; 404 | 405 | Ok(()) 406 | } 407 | 408 | #[test] 409 | fn parse_field_of_example() -> Result<(), Box> { 410 | let content = r#" 411 | openapi: 3.1.0 412 | info: 413 | title: Example API 414 | description: API definitions for example 415 | version: '0.0.1' 416 | 417 | components: 418 | schemas: 419 | ExampleResponse: 420 | type: object 421 | properties: 422 | uuid: 423 | type: string 424 | description: The UUID for this example. 425 | format: uuid 426 | example: 00000000-0000-0000-0000-000000000000 427 | multi_uuid: 428 | type: array 429 | description: The Multi UUID for this example. 430 | format: uuid 431 | example: 432 | - 00000000-0000-0000-0000-000000000000 433 | - 00000000-0000-0000-0000-000000000001 434 | - 00000000-0000-0000-0000-000000000002 435 | paths: 436 | "#; 437 | 438 | let openapi: OpenAPI = OpenAPI::yaml(content)?; 439 | 440 | // Validate general properties 441 | assert_eq!(openapi.openapi, "3.1.0"); 442 | assert_eq!(openapi.info.title, "Example API"); 443 | assert_eq!( 444 | openapi.info.description.as_deref(), 445 | Some("API definitions for example") 446 | ); 447 | assert_eq!(openapi.info.version, "0.0.1"); 448 | 449 | // Validate components and schemas 450 | let components = openapi.components.as_ref().unwrap(); 451 | let example_response = components.schemas.get("ExampleResponse").unwrap(); 452 | let properties = example_response.properties.as_ref().unwrap(); 453 | 454 | // Validate uuid property 455 | let uuid = properties.get("uuid").unwrap(); 456 | assert_eq!(uuid.r#type, Some(TypeOrUnion::Single(Type::String))); 457 | assert_eq!( 458 | uuid.description.as_deref(), 459 | Some("The UUID for this example.") 460 | ); 461 | assert_eq!(uuid.format, Some(Format::UUID)); 462 | assert_eq!( 463 | uuid.example.clone().unwrap(), 464 | "00000000-0000-0000-0000-000000000000" 465 | ); 466 | 467 | // Validate multi_uuid property 468 | let multi_uuid = properties.get("multi_uuid").unwrap(); 469 | assert_eq!(multi_uuid.r#type, Some(TypeOrUnion::Single(Type::Array))); 470 | assert_eq!( 471 | multi_uuid.description.as_deref(), 472 | Some("The Multi UUID for this example.") 473 | ); 474 | assert_eq!(multi_uuid.format, Some(Format::UUID)); 475 | 476 | // Check example value 477 | assert_eq!( 478 | multi_uuid.example, 479 | Some(Sequence(vec![ 480 | Value::String("00000000-0000-0000-0000-000000000000".to_owned()), 481 | Value::String("00000000-0000-0000-0000-000000000001".to_owned()), 482 | Value::String("00000000-0000-0000-0000-000000000002".to_owned()), 483 | ])) 484 | ); 485 | 486 | Ok(()) 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /src/validator/validator_test.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | #[cfg(all(test, feature = "test-with-axum"))] 19 | mod tests { 20 | use crate::model::parse::{Format, OpenAPI}; 21 | use crate::request; 22 | use crate::validator::validate_field_format; 23 | use axum::body::Bytes; 24 | use serde_json::Value; 25 | 26 | fn make_request_body_with_value(value: &str) -> request::axum::RequestData { 27 | request::axum::RequestData { 28 | path: "/example".to_string(), 29 | inner: axum::http::Request::builder() 30 | .method("POST") 31 | .uri("/example") 32 | .body(axum::body::Body::from(format!("{}", value))) 33 | .unwrap(), 34 | body: Some(Bytes::from(format!("{}", value))), 35 | } 36 | } 37 | 38 | #[test] 39 | fn test_uuid_path_validation() { 40 | let content = r#" 41 | openapi: 3.1.0 42 | info: 43 | title: Example API 44 | description: API definitions for example 45 | version: '0.0.1' 46 | x-file-identifier: example 47 | 48 | components: 49 | schemas: 50 | ExampleResponse: 51 | properties: 52 | uuid: 53 | type: string 54 | description: The UUID for this example. 55 | format: uuid 56 | example: 00000000-0000-0000-0000-000000000000 57 | 58 | security: [ ] 59 | 60 | paths: 61 | /example/{uuid}: 62 | get: 63 | parameters: 64 | - name: uuid 65 | description: The UUID for this example. 66 | in: path 67 | schema: 68 | type: string 69 | format: uuid 70 | example: 00000000-0000-0000-0000-000000000000 71 | responses: 72 | '200': 73 | description: Get a Example response 74 | content: 75 | application/json: 76 | schema: 77 | $ref: '#/components/schemas/ExampleResponse' 78 | "#; 79 | 80 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 81 | 82 | fn make_request(uri: &str) -> request::axum::RequestData { 83 | request::axum::RequestData { 84 | path: "/example/{uuid}".to_string(), 85 | inner: axum::http::Request::builder() 86 | .method("GET") 87 | .uri(uri) 88 | .body(axum::body::Body::empty()) 89 | .unwrap(), 90 | body: None, 91 | } 92 | } 93 | 94 | struct Tests { 95 | value: &'static str, 96 | assert: bool, 97 | } 98 | 99 | let tests: Vec = vec![ 100 | Tests { 101 | value: "/example/00000000-0000-0000-0000-000000000000", 102 | assert: true, 103 | }, 104 | Tests { 105 | value: "/example/00000000", 106 | assert: false, 107 | }, 108 | ]; 109 | 110 | for test in tests { 111 | assert_eq!( 112 | openapi.validator(make_request(test.value)).is_ok(), 113 | test.assert 114 | ); 115 | } 116 | } 117 | 118 | #[test] 119 | fn test_uuid_query_validation() { 120 | let content = r#" 121 | openapi: 3.1.0 122 | info: 123 | title: Example API 124 | description: API definitions for example 125 | version: '0.0.1' 126 | x-file-identifier: example 127 | 128 | components: 129 | schemas: 130 | ExampleResponse: 131 | properties: 132 | uuid: 133 | type: string 134 | description: The UUID for this example. 135 | format: uuid 136 | example: 00000000-0000-0000-0000-000000000000 137 | 138 | security: [ ] 139 | 140 | paths: 141 | /example: 142 | get: 143 | summary: Get a example 144 | description: Get a example 145 | operationId: get-a-example 146 | parameters: 147 | - name: uuid 148 | description: UUID of the example 149 | in: query 150 | schema: 151 | type: string 152 | format: uuid 153 | example: "00000000-0000-0000-0000-000000000000" 154 | responses: 155 | '200': 156 | description: Get a Example response 157 | content: 158 | application/json: 159 | schema: 160 | $ref: '#/components/schemas/ExampleResponse' 161 | "#; 162 | 163 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 164 | 165 | fn make_request(uuid: &str) -> request::axum::RequestData { 166 | request::axum::RequestData { 167 | path: "/example".to_string(), 168 | inner: axum::http::Request::builder() 169 | .method("GET") 170 | .uri(format!("/example?uuid={}", uuid)) 171 | .body(axum::body::Body::empty()) 172 | .unwrap(), 173 | body: None, 174 | } 175 | } 176 | 177 | struct Tests { 178 | value: &'static str, 179 | assert: bool, 180 | } 181 | 182 | let tests: Vec = vec![ 183 | Tests { 184 | value: "00000000-0000-0000-0000-000000000000", 185 | assert: true, 186 | }, 187 | Tests { 188 | value: "00000000-0000-0000-0000-xxxx", 189 | assert: false, 190 | }, 191 | ]; 192 | 193 | for test in tests { 194 | assert_eq!( 195 | openapi.validator(make_request(test.value)).is_ok(), 196 | test.assert 197 | ); 198 | } 199 | } 200 | 201 | #[test] 202 | fn test_uuid_body_validation() { 203 | let content = r#" 204 | openapi: 3.1.0 205 | info: 206 | title: Example API 207 | description: API definitions for example 208 | version: '0.0.1' 209 | x-file-identifier: example 210 | 211 | components: 212 | schemas: 213 | ExampleRequest: 214 | type: object 215 | properties: 216 | uuid: 217 | type: string 218 | description: The UUID for this example. 219 | format: uuid 220 | example: 00000000-0000-0000-0000-000000000000 221 | required: 222 | - uuid 223 | ExampleResponse: 224 | properties: 225 | uuid: 226 | type: string 227 | description: The UUID for this example. 228 | format: uuid 229 | example: 00000000-0000-0000-0000-000000000000 230 | 231 | security: [ ] 232 | 233 | paths: 234 | /example: 235 | post: 236 | requestBody: 237 | content: 238 | application/json: 239 | schema: 240 | $ref: '#/components/schemas/ExampleRequest' 241 | responses: 242 | '200': 243 | description: Post a Example response 244 | content: 245 | application/json: 246 | schema: 247 | $ref: '#/components/schemas/ExampleResponse' 248 | "#; 249 | 250 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 251 | 252 | struct Tests { 253 | value: &'static str, 254 | assert: bool, 255 | } 256 | 257 | let tests: Vec = vec![ 258 | Tests { 259 | value: r#"{"uuid":"00000000-0000-0000-0000-000000000000"}"#, 260 | assert: true, 261 | }, 262 | Tests { 263 | value: r#"{"uuid":"00000000-0000-0000-0000-xxxx"}"#, 264 | assert: false, 265 | }, 266 | ]; 267 | 268 | for test in tests { 269 | assert_eq!( 270 | openapi 271 | .validator(make_request_body_with_value(test.value)) 272 | .is_ok(), 273 | test.assert 274 | ); 275 | } 276 | } 277 | 278 | #[test] 279 | fn test_query_required_validation() { 280 | let content = r#" 281 | openapi: 3.1.0 282 | info: 283 | title: Example API 284 | description: API definitions for example 285 | version: '0.0.1' 286 | x-file-identifier: example 287 | 288 | components: 289 | schemas: 290 | ExampleResponse: 291 | properties: 292 | uuid: 293 | type: string 294 | description: The UUID for this example. 295 | format: uuid 296 | example: 00000000-0000-0000-0000-000000000000 297 | 298 | security: [ ] 299 | 300 | paths: 301 | /example: 302 | get: 303 | summary: Get a example 304 | description: Get a example 305 | operationId: get-a-example 306 | parameters: 307 | - name: uuid 308 | description: UUID of the example 309 | in: query 310 | required: true 311 | schema: 312 | type: string 313 | format: uuid 314 | example: "00000000-0000-0000-0000-000000000000" 315 | - name: name 316 | description: Name of the example 317 | in: query 318 | required: true 319 | schema: 320 | type: string 321 | example: "example" 322 | - name: age 323 | description: Age of the example 324 | in: query 325 | required: false 326 | schema: 327 | type: integer 328 | example: 1 329 | responses: 330 | '200': 331 | description: Get a Example response 332 | content: 333 | application/json: 334 | schema: 335 | $ref: '#/components/schemas/ExampleResponse' 336 | "#; 337 | 338 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 339 | 340 | fn make_request(uri: &str) -> request::axum::RequestData { 341 | request::axum::RequestData { 342 | path: "/example".to_string(), 343 | inner: axum::http::Request::builder() 344 | .method("GET") 345 | .uri(uri) 346 | .body(axum::body::Body::empty()) 347 | .unwrap(), 348 | body: None, 349 | } 350 | } 351 | 352 | struct Tests { 353 | value: &'static str, 354 | assert: bool, 355 | } 356 | 357 | let tests: Vec = vec![ 358 | Tests { 359 | value: "/example?uuid=00000000-0000-0000-0000-000000000000&name=example", 360 | assert: true, 361 | }, 362 | Tests { 363 | value: "/example?uuid=00000000-0000-0000-0000-000000000000&name=example&age=1", 364 | assert: true, 365 | }, 366 | Tests { 367 | value: "/example?uuid=00000000-0000-0000-0000-000000000000&age=1", 368 | assert: false, 369 | }, 370 | Tests { 371 | value: "/example?uuid=00000000-0000-0000-0000-000000000000", 372 | assert: false, 373 | }, 374 | ]; 375 | 376 | for test in tests { 377 | assert_eq!( 378 | openapi.validator(make_request(test.value)).is_ok(), 379 | test.assert 380 | ); 381 | } 382 | } 383 | 384 | #[test] 385 | fn format_types_validation() { 386 | fn t(v: &str, format: Format) -> bool { 387 | validate_field_format("", &Value::from(v), Some(&format)).is_ok() 388 | } 389 | 390 | struct Tests { 391 | f: Format, 392 | value: &'static str, 393 | assert: bool, 394 | } 395 | 396 | let tests: Vec = vec![ 397 | Tests { 398 | f: Format::Date, 399 | value: "2025-01-32", 400 | assert: false, 401 | }, 402 | Tests { 403 | f: Format::Email, 404 | value: "e@example .com", 405 | assert: false, 406 | }, 407 | Tests { 408 | f: Format::Time, 409 | value: "00:00:61", 410 | assert: false, 411 | }, 412 | Tests { 413 | f: Format::DateTime, 414 | value: "2025-01-01T00:61:00Z", 415 | assert: false, 416 | }, 417 | Tests { 418 | f: Format::UUID, 419 | value: "00000000-0000-0000-0000-xxxx", 420 | assert: false, 421 | }, 422 | Tests { 423 | f: Format::IPV4, 424 | value: "127.0.0.x", 425 | assert: false, 426 | }, 427 | Tests { 428 | f: Format::IPV6, 429 | value: "example", 430 | assert: false, 431 | }, 432 | Tests { 433 | f: Format::Email, 434 | value: "a@example.com", 435 | assert: true, 436 | }, 437 | Tests { 438 | f: Format::UUID, 439 | value: "00000000-0000-0000-0000-000000000000", 440 | assert: true, 441 | }, 442 | Tests { 443 | f: Format::Time, 444 | value: "00:00:00", 445 | assert: true, 446 | }, 447 | Tests { 448 | f: Format::Date, 449 | value: "2025-01-01", 450 | assert: true, 451 | }, 452 | Tests { 453 | f: Format::DateTime, 454 | value: "2025-01-01T00:00:00Z", 455 | assert: true, 456 | }, 457 | Tests { 458 | f: Format::IPV4, 459 | value: "127.0.0.1", 460 | assert: true, 461 | }, 462 | Tests { 463 | f: Format::IPV6, 464 | value: "::", 465 | assert: true, 466 | }, 467 | Tests { 468 | f: Format::Email, 469 | value: "example@example", 470 | assert: true, 471 | }, 472 | ]; 473 | 474 | for test in tests { 475 | assert_eq!(t(test.value, test.f), test.assert); 476 | } 477 | } 478 | 479 | #[test] 480 | fn test_query_value_limit_validation() { 481 | let content = r#" 482 | openapi: 3.1.0 483 | info: 484 | title: Example API 485 | description: API definitions for example 486 | version: '0.0.1' 487 | x-file-identifier: example 488 | 489 | components: 490 | schemas: 491 | ExampleRequest: 492 | type: object 493 | properties: 494 | name: 495 | type: string 496 | description: The Name for this example. 497 | example: example 498 | minLength: 1 499 | maxLength: 7 500 | age: 501 | type: integer 502 | description: The age for this example. 503 | example: 1 504 | minimum: 1 505 | maximum: 10 506 | required: 507 | - name 508 | - age 509 | ExampleResponse: 510 | properties: 511 | name: 512 | type: string 513 | description: The Name for this example. 514 | example: example 515 | 516 | security: [ ] 517 | 518 | paths: 519 | /example: 520 | post: 521 | requestBody: 522 | content: 523 | application/json: 524 | schema: 525 | $ref: '#/components/schemas/ExampleRequest' 526 | responses: 527 | '200': 528 | description: Post a Example response 529 | content: 530 | application/json: 531 | schema: 532 | $ref: '#/components/schemas/ExampleResponse' 533 | "#; 534 | 535 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 536 | 537 | struct Tests { 538 | value: &'static str, 539 | assert: bool, 540 | } 541 | 542 | let tests: Vec = vec![ 543 | Tests { 544 | value: r#"{"name":"example","age":1}"#, 545 | assert: true, 546 | }, 547 | Tests { 548 | value: r#"{"name":"example","age":100}"#, 549 | assert: false, 550 | }, 551 | Tests { 552 | value: r#"{"name":"example-100","age":1}"#, 553 | assert: false, 554 | }, 555 | ]; 556 | 557 | for test in tests { 558 | assert_eq!( 559 | openapi 560 | .validator(make_request_body_with_value(test.value)) 561 | .is_ok(), 562 | test.assert 563 | ); 564 | } 565 | } 566 | 567 | #[test] 568 | fn test_body_array_validation() { 569 | let content = r#" 570 | openapi: 3.1.0 571 | info: 572 | title: Example API 573 | description: API definitions for example 574 | version: '0.0.1' 575 | x-file-identifier: example 576 | 577 | components: 578 | schemas: 579 | ExampleRequest: 580 | type: array 581 | minItems: 1 582 | maxItems: 2 583 | items: 584 | properties: 585 | name: 586 | type: string 587 | description: The Name for this example. 588 | example: example 589 | minLength: 1 590 | maxLength: 7 591 | age: 592 | type: integer 593 | description: The age for this example. 594 | example: 1 595 | minimum: 1 596 | maximum: 10 597 | required: 598 | - name 599 | - age 600 | ExampleResponse: 601 | properties: 602 | name: 603 | type: string 604 | description: The Name for this example. 605 | example: example 606 | 607 | security: [ ] 608 | 609 | paths: 610 | /example: 611 | post: 612 | requestBody: 613 | content: 614 | application/json: 615 | schema: 616 | $ref: '#/components/schemas/ExampleRequest' 617 | responses: 618 | '200': 619 | description: Post a Example response 620 | content: 621 | application/json: 622 | schema: 623 | $ref: '#/components/schemas/ExampleResponse' 624 | "#; 625 | 626 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 627 | 628 | struct Tests { 629 | value: &'static str, 630 | assert: bool, 631 | } 632 | 633 | let tests: Vec = vec![ 634 | Tests { 635 | value: r#"[{"name":"example","age":1}]"#, 636 | assert: true, 637 | }, 638 | Tests { 639 | value: r#"[{"name":"example","age":100}]"#, 640 | assert: false, 641 | }, 642 | Tests { 643 | value: r#"[{"name":"example-100","age":1}]"#, 644 | assert: false, 645 | }, 646 | Tests { 647 | value: r#"[]"#, 648 | assert: false, 649 | }, 650 | Tests { 651 | value: r#"[{"name":"example-100","age":1},{"name":"example-101","age":2},{"name":"example-102","age":2}]"#, 652 | assert: false, 653 | }, 654 | Tests { 655 | value: r#"{"name":"example","age":1}"#, 656 | assert: false, 657 | }, 658 | ]; 659 | 660 | for test in tests { 661 | assert_eq!( 662 | openapi 663 | .validator(make_request_body_with_value(test.value)) 664 | .is_ok(), 665 | test.assert 666 | ); 667 | } 668 | } 669 | 670 | #[test] 671 | fn test_body_enum_validation() { 672 | let content = r#" 673 | openapi: 3.1.0 674 | info: 675 | title: Example API 676 | description: API definitions for example 677 | version: '0.0.1' 678 | x-file-identifier: example 679 | 680 | components: 681 | schemas: 682 | ExampleRequest: 683 | type: object 684 | properties: 685 | name: 686 | type: string 687 | description: The Name for this example. 688 | example: example 689 | enum: 690 | - example 691 | - example-100 692 | - example-101 693 | priority: 694 | type: integer 695 | description: Priority level 696 | enum: 697 | - 1 698 | - 2 699 | - 3 700 | - 10 701 | status: 702 | type: string 703 | description: Status of the example 704 | enum: 705 | - active 706 | - inactive 707 | - pending 708 | enabled: 709 | type: boolean 710 | description: Whether the example is enabled 711 | enum: 712 | - true 713 | - false 714 | category: 715 | type: number 716 | description: Category identifier 717 | enum: 718 | - 1.0 719 | - 2.5 720 | - 3.14 721 | - 10.0 722 | mixed_type: 723 | description: Mixed type enum (string and number) 724 | enum: 725 | - "text" 726 | - 42 727 | - "another_text" 728 | - 99.99 729 | required: 730 | - name 731 | - priority 732 | ExampleResponse: 733 | properties: 734 | name: 735 | type: string 736 | description: The Name for this example. 737 | example: example 738 | 739 | security: [ ] 740 | 741 | paths: 742 | /example: 743 | post: 744 | requestBody: 745 | content: 746 | application/json: 747 | schema: 748 | $ref: '#/components/schemas/ExampleRequest' 749 | responses: 750 | '200': 751 | description: Post a Example response 752 | content: 753 | application/json: 754 | schema: 755 | $ref: '#/components/schemas/ExampleResponse' 756 | "#; 757 | 758 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI content"); 759 | 760 | struct Tests { 761 | value: &'static str, 762 | assert: bool, 763 | description: &'static str, 764 | } 765 | 766 | let tests: Vec = vec![ 767 | Tests { 768 | value: r#"{"name":"example","priority":1}"#, 769 | assert: true, 770 | description: "Valid string and integer enum", 771 | }, 772 | Tests { 773 | value: r#"{"name":"example-100","priority":2}"#, 774 | assert: true, 775 | description: "Valid string enum variant", 776 | }, 777 | Tests { 778 | value: r#"{"name":"example-101","priority":3}"#, 779 | assert: true, 780 | description: "Another valid string enum variant", 781 | }, 782 | Tests { 783 | value: r#"{"name":"example","priority":10,"status":"active","enabled":true}"#, 784 | assert: true, 785 | description: "Valid with optional boolean and string enums", 786 | }, 787 | Tests { 788 | value: r#"{"name":"example","priority":1,"category":2.5}"#, 789 | assert: true, 790 | description: "Valid with optional number enum", 791 | }, 792 | Tests { 793 | value: r#"{"name":"example","priority":1,"mixed_type":"text"}"#, 794 | assert: true, 795 | description: "Valid with mixed type enum (string)", 796 | }, 797 | Tests { 798 | value: r#"{"name":"example","priority":1,"mixed_type":42}"#, 799 | assert: true, 800 | description: "Valid with mixed type enum (number)", 801 | }, 802 | Tests { 803 | value: r#"{"name":"example-103","priority":1}"#, 804 | assert: false, 805 | description: "Invalid string enum value", 806 | }, 807 | Tests { 808 | value: r#"{"name":"example","priority":5}"#, 809 | assert: false, 810 | description: "Invalid integer enum value", 811 | }, 812 | Tests { 813 | value: r#"{"name":"example","priority":1,"status":"running"}"#, 814 | assert: false, 815 | description: "Invalid status enum value", 816 | }, 817 | Tests { 818 | value: r#"{"name":"example","priority":1,"enabled":"yes"}"#, 819 | assert: false, 820 | description: "Invalid boolean enum value (string instead of boolean)", 821 | }, 822 | Tests { 823 | value: r#"{"name":"example","priority":1,"category":5.5}"#, 824 | assert: false, 825 | description: "Invalid number enum value", 826 | }, 827 | Tests { 828 | value: r#"{"name":"example","priority":1,"mixed_type":"invalid"}"#, 829 | assert: false, 830 | description: "Invalid mixed type enum value", 831 | }, 832 | Tests { 833 | value: r#"{"name":"example","priority":1,"mixed_type":100}"#, 834 | assert: false, 835 | description: "Invalid mixed type enum number value", 836 | }, 837 | Tests { 838 | value: r#"[{"name":"example"}]"#, 839 | assert: false, 840 | description: "Invalid JSON structure (array instead of object)", 841 | }, 842 | Tests { 843 | value: r#"{"name":"example"}"#, 844 | assert: false, 845 | description: "Missing required priority field", 846 | }, 847 | Tests { 848 | value: r#"{"priority":1}"#, 849 | assert: false, 850 | description: "Missing required name field", 851 | }, 852 | ]; 853 | 854 | for test in tests { 855 | let result = openapi.validator(make_request_body_with_value(test.value)); 856 | assert_eq!( 857 | result.is_ok(), 858 | test.assert, 859 | "Test failed: {} - Expected: {}, Got: {:?}", 860 | test.description, 861 | test.assert, 862 | result 863 | ); 864 | } 865 | } 866 | 867 | #[test] 868 | fn test_pattern_validation() { 869 | let content = r#" 870 | openapi: 3.1.0 871 | info: 872 | title: Pattern Validation Test API 873 | description: API for testing pattern validation 874 | version: '1.0.0' 875 | 876 | components: 877 | schemas: 878 | UserRequest: 879 | type: object 880 | properties: 881 | email: 882 | type: string 883 | pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' 884 | description: User email address 885 | phone: 886 | type: string 887 | pattern: '^\+?1?[-.\s]?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}$' 888 | description: User phone number 889 | username: 890 | type: string 891 | pattern: '^[a-zA-Z0-9_]{3,20}$' 892 | description: Username with alphanumeric and underscore only 893 | required: 894 | - email 895 | - username 896 | 897 | paths: 898 | /users: 899 | post: 900 | parameters: 901 | - name: userId 902 | in: query 903 | required: true 904 | schema: 905 | type: string 906 | pattern: '^[0-9]+$' 907 | description: Numeric user ID 908 | - name: token 909 | in: query 910 | required: false 911 | schema: 912 | type: string 913 | pattern: '^[A-Za-z0-9]{32}$' 914 | description: 32-character alphanumeric token 915 | requestBody: 916 | required: true 917 | content: 918 | application/json: 919 | schema: 920 | $ref: '#/components/schemas/UserRequest' 921 | responses: 922 | '201': 923 | description: User created successfully 924 | "#; 925 | 926 | let openapi: OpenAPI = OpenAPI::yaml(content).expect("Failed to parse OpenAPI YAML"); 927 | 928 | fn make_request_with_query_and_body(query: &str, body: &str) -> request::axum::RequestData { 929 | request::axum::RequestData { 930 | path: "/users".to_string(), 931 | inner: axum::http::Request::builder() 932 | .method("POST") 933 | .uri(format!("/users?{}", query)) 934 | .body(axum::body::Body::from(body.to_string())) 935 | .unwrap(), 936 | body: Some(Bytes::from(body.to_string())), 937 | } 938 | } 939 | 940 | struct Tests { 941 | query: &'static str, 942 | body: &'static str, 943 | assert: bool, 944 | description: &'static str, 945 | } 946 | 947 | let tests: Vec = vec![ 948 | Tests { 949 | query: "userId=12345&token=abc123DEF456ghi789JKL012mno345PQ", 950 | body: r#"{"email":"test@example.com","username":"valid_user123","phone":"(555) 123-4567"}"#, 951 | assert: true, 952 | description: "All valid patterns", 953 | }, 954 | Tests { 955 | query: "userId=999", 956 | body: r#"{"email":"user@domain.org","username":"testuser"}"#, 957 | assert: true, 958 | description: "Required fields only with valid patterns", 959 | }, 960 | Tests { 961 | query: "userId=abc123", 962 | body: r#"{"email":"test@example.com","username":"validuser"}"#, 963 | assert: false, 964 | description: "Invalid userId pattern (contains letters)", 965 | }, 966 | Tests { 967 | query: "userId=123&token=short", 968 | body: r#"{"email":"test@example.com","username":"validuser"}"#, 969 | assert: false, 970 | description: "Invalid token pattern (too short)", 971 | }, 972 | Tests { 973 | query: "userId=123", 974 | body: r#"{"email":"invalid-email","username":"validuser"}"#, 975 | assert: false, 976 | description: "Invalid email pattern", 977 | }, 978 | Tests { 979 | query: "userId=123", 980 | body: r#"{"email":"test@example.com","username":"in valid"}"#, 981 | assert: false, 982 | description: "Invalid username pattern (contains space)", 983 | }, 984 | Tests { 985 | query: "userId=123", 986 | body: r#"{"email":"test@example.com","username":"ab"}"#, 987 | assert: false, 988 | description: "Invalid username pattern (too short)", 989 | }, 990 | Tests { 991 | query: "userId=123", 992 | body: r#"{"email":"test@example.com","username":"validuser","phone":"invalid-phone"}"#, 993 | assert: false, 994 | description: "Invalid phone pattern", 995 | }, 996 | ]; 997 | 998 | for test in tests { 999 | let result = openapi.validator(make_request_with_query_and_body(test.query, test.body)); 1000 | assert_eq!( 1001 | result.is_ok(), 1002 | test.assert, 1003 | "Test failed: {} - Expected: {}, Got: {:?}", 1004 | test.description, 1005 | test.assert, 1006 | result 1007 | ); 1008 | } 1009 | } 1010 | } 1011 | -------------------------------------------------------------------------------- /src/validator/mod.rs: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to the Apache Software Foundation (ASF) under one or more 3 | * contributor license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright ownership. 5 | * The ASF licenses this file to You under the Apache License, Version 2.0 6 | * (the "License"); you may not use this file except in compliance with 7 | * the License. You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | mod enum_test; 19 | mod pattern_test; 20 | mod validator_test; 21 | 22 | use crate::model::parse; 23 | use crate::model::parse::{ 24 | ComponentsObject, Format, In, OpenAPI, Properties, Request, Type, TypeOrUnion, 25 | }; 26 | use crate::observability::RequestContext; 27 | use anyhow::{anyhow, Context, Result}; 28 | use base64::{engine::general_purpose, Engine}; 29 | use chrono::{DateTime, NaiveDate, NaiveTime}; 30 | use regex::Regex; 31 | use serde_json::{Map, Value}; 32 | use std::collections::{HashMap, HashSet}; 33 | use std::net::{Ipv4Addr, Ipv6Addr}; 34 | use std::string::String; 35 | use validator::ValidateEmail; 36 | 37 | pub trait ValidateRequest { 38 | fn header(&self, _: &OpenAPI) -> Result<()>; 39 | fn method(&self, _: &OpenAPI) -> Result<()>; 40 | fn query(&self, _: &OpenAPI) -> Result<()>; 41 | fn path(&self, _: &OpenAPI) -> Result<()>; 42 | fn body(&self, _: &OpenAPI) -> Result<()>; 43 | fn context(&self) -> RequestContext; 44 | } 45 | 46 | pub fn method(path: &str, method: &str, open_api: &OpenAPI) -> Result<()> { 47 | let path = open_api.paths.get(path).context("Path not found")?; 48 | 49 | if !path.operations.contains_key(method) { 50 | return Err(anyhow::anyhow!("Path is empty")); 51 | } 52 | 53 | Ok(()) 54 | } 55 | 56 | pub fn path(path: &str, uri: &str, open_api: &OpenAPI) -> Result<()> { 57 | let path_item = open_api.paths.get(path).context("Path not found")?; 58 | let empty_vec = vec![]; 59 | let parameters = path_item 60 | .operations 61 | .get("get") 62 | .and_then(|p| p.parameters.as_ref()) 63 | .unwrap_or(&empty_vec); 64 | 65 | for parameter in parameters { 66 | if parameter.r#ref.is_some() { 67 | // TODO: handle parameter references 68 | continue; 69 | } 70 | 71 | if let (Some(name), Some(r#in)) = (¶meter.name, ¶meter.r#in) { 72 | if *r#in != In::Path { 73 | continue; 74 | } 75 | if let Some(schema) = ¶meter.schema { 76 | validate_field_format(name, &Value::from(uri), schema.format.as_ref())?; 77 | } 78 | } 79 | } 80 | 81 | Ok(()) 82 | } 83 | 84 | fn process_schema_refs( 85 | schema: &parse::Schema, 86 | fields: &Map, 87 | requireds: &mut HashSet, 88 | open_api: &OpenAPI, 89 | ) -> Result<()> { 90 | if let Some(components) = &open_api.components { 91 | for schema_ref in collect_refs(schema) { 92 | requireds.extend(extract_required_and_validate_props( 93 | fields, schema_ref, components, 94 | )?); 95 | } 96 | } 97 | Ok(()) 98 | } 99 | 100 | fn validate_required_fields( 101 | requireds: &HashSet, 102 | query_pairs: &HashMap, 103 | ) -> Result<()> { 104 | for key in requireds { 105 | if !query_pairs.contains_key(key) { 106 | return Err(anyhow!("Missing required query parameter: '{}'", key)); 107 | } 108 | } 109 | Ok(()) 110 | } 111 | 112 | pub fn query(path: &str, query_pairs: &HashMap, open_api: &OpenAPI) -> Result<()> { 113 | let path_base = open_api 114 | .paths 115 | .get(path) 116 | .context("Path not found in OpenAPI specification")?; 117 | let empty_vec = vec![]; 118 | 119 | let all_parameters: Vec<&parse::Parameter> = path_base 120 | .operations 121 | .values() 122 | .flat_map(|op| op.parameters.as_ref().unwrap_or(&empty_vec)) 123 | .chain(path_base.parameters.as_ref().unwrap_or(&empty_vec)) 124 | .collect(); 125 | 126 | let fields: Map = query_pairs 127 | .iter() 128 | .map(|(k, v)| (k.clone(), Value::from(v.clone()))) 129 | .collect(); 130 | 131 | let mut required_fields: HashSet = HashSet::new(); 132 | 133 | for parameter in &all_parameters { 134 | if let Some(param_ref) = ¶meter.r#ref { 135 | if let Some(components) = &open_api.components { 136 | required_fields.extend(extract_required_and_validate_props( 137 | &fields, param_ref, components, 138 | )?); 139 | } 140 | continue; 141 | } 142 | 143 | let (Some(name), Some(In::Query)) = (¶meter.name, ¶meter.r#in) else { 144 | continue; 145 | }; 146 | 147 | match query_pairs.get(name) { 148 | Some(value) => { 149 | if parameter.required && value.trim().is_empty() { 150 | return Err(anyhow!( 151 | "Required query parameter '{}' cannot be empty", 152 | name 153 | )); 154 | } 155 | 156 | let json_value = Value::from(value.as_str()); 157 | 158 | if let Some(enum_values) = ¶meter.r#enum { 159 | validate_enum_value(name, &json_value, enum_values)?; 160 | } 161 | 162 | if let Some(param_type) = ¶meter.r#type { 163 | validate_field_type(name, &json_value, Some(param_type.clone()))?; 164 | } 165 | 166 | if let Some(schema) = ¶meter.schema { 167 | validate_field_format(name, &json_value, schema.format.as_ref())?; 168 | 169 | if let Some(enum_values) = &schema.r#enum { 170 | validate_enum_value(name, &json_value, enum_values)?; 171 | } 172 | 173 | if let Some(schema_type) = &schema.r#type { 174 | validate_field_type(name, &json_value, Some(schema_type.clone()))?; 175 | } 176 | 177 | validate_pattern(name, &json_value, schema.pattern.as_ref())?; 178 | 179 | process_schema_refs(schema, &fields, &mut required_fields, open_api)?; 180 | 181 | validate_string_constraints(name, &json_value, schema)?; 182 | 183 | validate_numeric_constraints(name, &json_value, schema)?; 184 | } 185 | 186 | validate_pattern(name, &json_value, parameter.pattern.as_ref())?; 187 | } 188 | None => { 189 | if parameter.required { 190 | return Err(anyhow!("Required query parameter '{}' is missing", name)); 191 | } 192 | } 193 | } 194 | } 195 | 196 | validate_required_fields(&required_fields, query_pairs)?; 197 | 198 | Ok(()) 199 | } 200 | 201 | pub fn body(path: &str, fields: Value, open_api: &OpenAPI) -> Result<()> { 202 | let path_base = open_api 203 | .paths 204 | .get(path) 205 | .context("Path not found in OpenAPI specification")?; 206 | 207 | let request = path_base.operations.iter().find_map(|(method, operation)| { 208 | if matches!(method.as_str(), "post" | "put" | "patch" | "delete") { 209 | operation.request.as_ref() 210 | } else { 211 | None 212 | } 213 | }); 214 | 215 | if let Some(request) = request { 216 | if request.required && matches!(fields, Value::Null) { 217 | return Err(anyhow!("Request body is required but was not provided")); 218 | } 219 | 220 | let refs: Vec<&str> = request 221 | .content 222 | .values() 223 | .flat_map(|media| collect_refs(&media.schema)) 224 | .collect(); 225 | 226 | let schema_info = get_schema_info(&refs, open_api); 227 | let expected_type = schema_info 228 | .as_ref() 229 | .and_then(|schema| schema.r#type.clone()); 230 | 231 | match fields { 232 | Value::Object(ref map) => { 233 | ensure_type(&expected_type, Type::Object)?; 234 | validate_object_body(map, request, &refs, open_api)?; 235 | } 236 | Value::Array(ref arr) => { 237 | ensure_type(&expected_type, Type::Array)?; 238 | 239 | if let Some(schema) = &schema_info { 240 | validate_array_length_with_schema(arr.len(), schema)?; 241 | } 242 | 243 | validate_array_items(arr, request, &refs, open_api)?; 244 | } 245 | Value::String(_) | Value::Number(_) | Value::Bool(_) => { 246 | if let Some(type_or_union) = &expected_type { 247 | validate_field_type("request_body", &fields, Some(type_or_union.clone()))?; 248 | } 249 | 250 | for media_type in request.content.values() { 251 | if let Some(schema_type) = &media_type.schema.r#type { 252 | validate_field_type("request_body", &fields, Some(schema_type.clone()))?; 253 | } 254 | 255 | if let Some(format) = &media_type.schema.format { 256 | validate_field_format("request_body", &fields, Some(format))?; 257 | } 258 | 259 | if let Some(enum_values) = &media_type.schema.r#enum { 260 | validate_enum_value("request_body", &fields, enum_values)?; 261 | } 262 | } 263 | } 264 | Value::Null => { 265 | if request.required { 266 | return Err(anyhow!("Request body is required but null was provided")); 267 | } 268 | } 269 | } 270 | } 271 | 272 | Ok(()) 273 | } 274 | 275 | fn get_schema_info<'a>( 276 | refs: &[&str], 277 | open_api: &'a OpenAPI, 278 | ) -> Option<&'a parse::ComponentSchemaBase> { 279 | open_api.components.as_ref().and_then(|components| { 280 | refs.iter().find_map(|schema_ref| { 281 | schema_ref 282 | .rsplit('/') 283 | .next() 284 | .and_then(|schema_name| components.schemas.get(schema_name)) 285 | }) 286 | }) 287 | } 288 | 289 | fn validate_object_body( 290 | fields: &Map, 291 | request: &Request, 292 | refs: &[&str], 293 | open_api: &OpenAPI, 294 | ) -> Result<()> { 295 | for (key, media_type) in &request.content { 296 | if let Some(field) = fields.get(key) { 297 | let type_or_union = media_type.schema.r#type.clone(); 298 | validate_field_type(key, field, type_or_union)?; 299 | if media_type.schema.r#type == Some(TypeOrUnion::Single(Type::String)) { 300 | validate_field_format(key, field, media_type.schema.format.as_ref())?; 301 | } 302 | } 303 | } 304 | 305 | let mut requireds = HashSet::new(); 306 | 307 | if let Some(components) = &open_api.components { 308 | for schema_ref in refs { 309 | requireds.extend(extract_required_and_validate_props( 310 | fields, schema_ref, components, 311 | )?); 312 | } 313 | } 314 | 315 | for key in &requireds { 316 | if !fields.contains_key(key) { 317 | return Err(anyhow!("Missing required request body field: '{}'", key)); 318 | } 319 | } 320 | 321 | Ok(()) 322 | } 323 | 324 | fn validate_array_items( 325 | arr: &[Value], 326 | request: &Request, 327 | refs: &[&str], 328 | open_api: &OpenAPI, 329 | ) -> Result<()> { 330 | for (index, item) in arr.iter().enumerate() { 331 | let map = item 332 | .as_object() 333 | .with_context(|| format!("Array item at index {index} must be an object"))?; 334 | validate_map(map, request, refs, open_api)?; 335 | } 336 | Ok(()) 337 | } 338 | 339 | fn validate_array_length_with_schema( 340 | length: usize, 341 | schema: &parse::ComponentSchemaBase, 342 | ) -> Result<()> { 343 | if let Some(min) = schema.min_items { 344 | if length < min as usize { 345 | return Err(anyhow!( 346 | "The array must have at least {} items, but got {}", 347 | min, 348 | length 349 | )); 350 | } 351 | } 352 | 353 | if let Some(max) = schema.max_items { 354 | if length > max as usize { 355 | return Err(anyhow!( 356 | "The array must have at most {} items, but got {}", 357 | max, 358 | length 359 | )); 360 | } 361 | } 362 | 363 | Ok(()) 364 | } 365 | 366 | fn ensure_type(actual: &Option, expected: Type) -> Result<()> { 367 | if let Some(type_or_union) = actual { 368 | match type_or_union { 369 | TypeOrUnion::Single(t) => { 370 | if *t != expected { 371 | return Err(anyhow!( 372 | "Expected request body to be a {:?}, got {:?}", 373 | expected, 374 | t 375 | )); 376 | } 377 | } 378 | TypeOrUnion::Union(types) => { 379 | if !types.contains(&expected) { 380 | return Err(anyhow!( 381 | "Expected request body to be a {:?}, but union types {:?} don't include it", 382 | expected, 383 | types 384 | )); 385 | } 386 | } 387 | } 388 | } 389 | Ok(()) 390 | } 391 | 392 | fn validate_map( 393 | fields: &Map, 394 | request: &Request, 395 | refs: &[&str], 396 | open_api: &OpenAPI, 397 | ) -> Result<()> { 398 | for (key, media_type) in &request.content { 399 | if let Some(field) = fields.get(key) { 400 | let type_or_union = media_type.schema.r#type.clone(); 401 | validate_field_type(key, field, type_or_union)?; 402 | if media_type.schema.r#type == Some(TypeOrUnion::Single(Type::String)) { 403 | validate_field_format(key, field, media_type.schema.format.as_ref())?; 404 | } 405 | } 406 | } 407 | 408 | let mut requireds = HashSet::new(); 409 | 410 | if let Some(components) = &open_api.components { 411 | for schema_ref in refs { 412 | requireds.extend(extract_required_and_validate_props( 413 | fields, schema_ref, components, 414 | )?); 415 | } 416 | } 417 | 418 | for key in &requireds { 419 | if !fields.contains_key(key) { 420 | return Err(anyhow!("Missing required request body field: '{}'", key)); 421 | } 422 | } 423 | 424 | Ok(()) 425 | } 426 | 427 | fn validate_field_format(key: &str, value: &Value, format: Option<&Format>) -> Result<()> { 428 | let Some(str_val) = value.as_str() else { 429 | return Err(anyhow::anyhow!("this value must be string '{}'", key)); 430 | }; 431 | 432 | match format { 433 | Some(Format::Email) => { 434 | if !str_val.validate_email() { 435 | return Err(format_error("Email", key, str_val)); 436 | } 437 | } 438 | Some(Format::Time) => { 439 | NaiveTime::parse_from_str(str_val, "%H:%M:%S") 440 | .map_err(|_| format_error("Time", key, str_val))?; 441 | } 442 | Some(Format::Date) => { 443 | NaiveDate::parse_from_str(str_val, "%Y-%m-%d") 444 | .map_err(|_| format_error("Date", key, str_val))?; 445 | } 446 | Some(Format::DateTime) => { 447 | DateTime::parse_from_rfc3339(str_val) 448 | .map_err(|_| format_error("DateTime", key, str_val))?; 449 | } 450 | Some(Format::UUID) => { 451 | uuid::Uuid::parse_str(str_val).map_err(|_| format_error("UUID", key, str_val))?; 452 | } 453 | Some(Format::IPV4) => { 454 | str_val 455 | .parse::() 456 | .map_err(|_| format_error("IPv4", key, str_val))?; 457 | } 458 | Some(Format::IPV6) => { 459 | str_val 460 | .parse::() 461 | .map_err(|_| format_error("IPV6", key, str_val))?; 462 | } 463 | None => {} 464 | _ => { 465 | return Err(anyhow::anyhow!( 466 | "Unsupported format '{:?}' for query parameter '{}'", 467 | format, 468 | key 469 | )); 470 | } 471 | } 472 | Ok(()) 473 | } 474 | 475 | fn validate_enum_value(key: &str, value: &Value, enum_values: &[serde_yaml::Value]) -> Result<()> { 476 | for enum_val in enum_values { 477 | if values_equal(value, enum_val) { 478 | return Ok(()); 479 | } 480 | } 481 | 482 | let enum_strings: Vec = enum_values.iter().map(format_yaml_value).collect(); 483 | 484 | Err(anyhow!( 485 | "Value '{}' for field '{}' is not in allowed enum values: [{}]", 486 | format_json_value(value), 487 | key, 488 | enum_strings.join(", ") 489 | )) 490 | } 491 | 492 | fn values_equal(json_val: &Value, yaml_val: &serde_yaml::Value) -> bool { 493 | match (json_val, yaml_val) { 494 | (Value::String(s1), serde_yaml::Value::String(s2)) => s1 == s2, 495 | (Value::Number(n1), serde_yaml::Value::Number(n2)) => { 496 | if let (Some(i1), Some(i2)) = (n1.as_i64(), n2.as_i64()) { 497 | i1 == i2 498 | } else if let (Some(f1), Some(f2)) = (n1.as_f64(), n2.as_f64()) { 499 | (f1 - f2).abs() < f64::EPSILON 500 | } else { 501 | false 502 | } 503 | } 504 | (Value::Bool(b1), serde_yaml::Value::Bool(b2)) => b1 == b2, 505 | (Value::Null, serde_yaml::Value::Null) => true, 506 | (Value::String(s), serde_yaml::Value::Number(n)) => { 507 | if let Ok(parsed_int) = s.parse::() { 508 | if let Some(yaml_int) = n.as_i64() { 509 | return parsed_int == yaml_int; 510 | } 511 | } 512 | if let Ok(parsed_float) = s.parse::() { 513 | if let Some(yaml_float) = n.as_f64() { 514 | return (parsed_float - yaml_float).abs() < f64::EPSILON; 515 | } 516 | } 517 | false 518 | } 519 | (Value::String(s), serde_yaml::Value::Bool(b)) => match s.to_lowercase().as_str() { 520 | "true" => *b, 521 | "false" => !*b, 522 | _ => false, 523 | }, 524 | (Value::Number(n), serde_yaml::Value::String(s)) => { 525 | if let Some(int_val) = n.as_i64() { 526 | s == &int_val.to_string() 527 | } else if let Some(float_val) = n.as_f64() { 528 | s == &float_val.to_string() 529 | } else { 530 | false 531 | } 532 | } 533 | (Value::Bool(b), serde_yaml::Value::String(s)) => match s.to_lowercase().as_str() { 534 | "true" => *b, 535 | "false" => !*b, 536 | _ => false, 537 | }, 538 | _ => false, 539 | } 540 | } 541 | 542 | fn format_yaml_value(value: &serde_yaml::Value) -> String { 543 | match value { 544 | serde_yaml::Value::String(s) => format!("\"{s}\""), 545 | serde_yaml::Value::Number(n) => n.to_string(), 546 | serde_yaml::Value::Bool(b) => b.to_string(), 547 | serde_yaml::Value::Null => "null".to_string(), 548 | _ => format!("{value:?}"), 549 | } 550 | } 551 | 552 | fn format_json_value(value: &Value) -> String { 553 | match value { 554 | Value::String(s) => format!("\"{s}\""), 555 | Value::Number(n) => n.to_string(), 556 | Value::Bool(b) => b.to_string(), 557 | Value::Null => "null".to_string(), 558 | _ => format!("{value:?}"), 559 | } 560 | } 561 | fn validate_field_type(key: &str, value: &Value, field_type: Option) -> Result<()> { 562 | use Type::*; 563 | 564 | match field_type { 565 | Some(TypeOrUnion::Single(Object)) => { 566 | if !value.is_object() { 567 | return Err(anyhow!("the value of '{}' must be an Object", key)); 568 | } 569 | } 570 | Some(TypeOrUnion::Single(String)) => { 571 | if !value.is_string() { 572 | return Err(anyhow!("the value of '{}' must be a String", key)); 573 | } 574 | } 575 | Some(TypeOrUnion::Single(Integer)) => { 576 | if !value.is_i64() { 577 | if let Some(str_val) = value.as_str() { 578 | if str_val.parse::().is_err() { 579 | return Err(anyhow!("the value of '{}' must be an Integer", key)); 580 | } 581 | } else { 582 | return Err(anyhow!("the value of '{}' must be an Integer", key)); 583 | } 584 | } 585 | } 586 | Some(TypeOrUnion::Single(Number)) => { 587 | if !value.is_number() { 588 | if let Some(str_val) = value.as_str() { 589 | if str_val.parse::().is_err() { 590 | return Err(anyhow!("the value of '{}' must be a Number", key)); 591 | } 592 | } else { 593 | return Err(anyhow!("the value of '{}' must be a Number", key)); 594 | } 595 | } 596 | } 597 | Some(TypeOrUnion::Single(Array)) => { 598 | if !value.is_array() { 599 | return Err(anyhow!("the value of '{}' must be an Array", key)); 600 | } 601 | } 602 | Some(TypeOrUnion::Single(Boolean)) => { 603 | if !value.is_boolean() { 604 | if let Some(str_val) = value.as_str() { 605 | match str_val.to_lowercase().as_str() { 606 | "true" | "false" => {} 607 | _ => { 608 | return Err(anyhow!("the value of '{}' must be a Boolean", key)); 609 | } 610 | } 611 | } else { 612 | return Err(anyhow!("the value of '{}' must be a Boolean", key)); 613 | } 614 | } 615 | } 616 | Some(TypeOrUnion::Single(Null)) => { 617 | if !value.is_null() { 618 | return Err(anyhow!("the value of '{}' must be Null", key)); 619 | } 620 | } 621 | Some(TypeOrUnion::Single(Base64)) => { 622 | let str_val = value 623 | .as_str() 624 | .ok_or_else(|| anyhow!("the value of '{}' must be a string", key))?; 625 | 626 | if str_val.trim().is_empty() { 627 | return Err(anyhow!("the value of '{}' must not be empty", key)); 628 | } 629 | 630 | if general_purpose::STANDARD.decode(str_val).is_err() { 631 | return Err(anyhow!("the value of '{}' must be valid Base64", key)); 632 | } 633 | } 634 | Some(TypeOrUnion::Single(Binary)) => { 635 | if !value.is_string() { 636 | return Err(anyhow!( 637 | "the value of '{}' must be a String for binary data", 638 | key 639 | )); 640 | } 641 | } 642 | Some(TypeOrUnion::Union(types)) => { 643 | let mut valid = false; 644 | for single_type in types { 645 | if validate_single_type_match(value, &single_type) { 646 | valid = true; 647 | break; 648 | } 649 | } 650 | if !valid { 651 | return Err(anyhow!( 652 | "the value of '{}' must match one of the union types", 653 | key 654 | )); 655 | } 656 | } 657 | None => {} 658 | } 659 | 660 | Ok(()) 661 | } 662 | 663 | fn validate_single_type_match(value: &Value, field_type: &Type) -> bool { 664 | use Type::*; 665 | match field_type { 666 | Object => value.is_object(), 667 | String | Binary => value.is_string(), 668 | Integer => value.is_i64(), 669 | Number => value.is_number(), 670 | Array => value.is_array(), 671 | Boolean => value.is_boolean(), 672 | Null => value.is_null(), 673 | Base64 => { 674 | if let Some(str_val) = value.as_str() { 675 | !str_val.trim().is_empty() && general_purpose::STANDARD.decode(str_val).is_ok() 676 | } else { 677 | false 678 | } 679 | } 680 | } 681 | } 682 | 683 | fn validate_field_length_limit(key: &str, value: &Value, properties: &Properties) -> Result<()> { 684 | use TypeOrUnion::*; 685 | 686 | match &properties.r#type { 687 | Some(Single(type_)) => { 688 | validate_single_type(key, value, type_, properties)?; 689 | } 690 | Some(Union(types)) => { 691 | validate_union_types(key, value, types, properties)?; 692 | } 693 | None => {} 694 | } 695 | 696 | Ok(()) 697 | } 698 | 699 | fn validate_single_type( 700 | key: &str, 701 | value: &Value, 702 | type_: &Type, 703 | properties: &Properties, 704 | ) -> Result<()> { 705 | use Type::*; 706 | 707 | match type_ { 708 | String | Base64 | Binary => { 709 | let str_val = value 710 | .as_str() 711 | .ok_or_else(|| anyhow!("The value of '{}' must be a String", key))?; 712 | validate_string_length(key, str_val, properties)?; 713 | } 714 | Integer => { 715 | let int_val = value 716 | .as_i64() 717 | .ok_or_else(|| anyhow!("The value of '{}' must be an Integer", key))?; 718 | validate_numeric_range(key, int_val as f64, properties)?; 719 | } 720 | Number => { 721 | let num_val = value 722 | .as_f64() 723 | .ok_or_else(|| anyhow!("The value of '{}' must be a Number", key))?; 724 | validate_numeric_range(key, num_val, properties)?; 725 | } 726 | Array => { 727 | if !value.is_array() { 728 | return Err(anyhow!("The value of '{}' must be an Array", key)); 729 | } 730 | let arr_len = value.as_array().unwrap().len(); 731 | validate_array_length(key, arr_len, properties)?; 732 | } 733 | Boolean => { 734 | if !value.is_boolean() { 735 | return Err(anyhow!("The value of '{}' must be a Boolean", key)); 736 | } 737 | } 738 | Null => { 739 | if !value.is_null() { 740 | return Err(anyhow!("The value of '{}' must be null", key)); 741 | } 742 | } 743 | Object => { 744 | if !value.is_object() { 745 | return Err(anyhow!("The value of '{}' must be an Object", key)); 746 | } 747 | } 748 | } 749 | 750 | Ok(()) 751 | } 752 | 753 | fn validate_union_types( 754 | key: &str, 755 | value: &Value, 756 | types: &[Type], 757 | properties: &Properties, 758 | ) -> Result<()> { 759 | let mut validation_errors = Vec::new(); 760 | let mut type_matched = false; 761 | 762 | for type_ in types { 763 | match validate_single_type(key, value, type_, properties) { 764 | Ok(()) => { 765 | type_matched = true; 766 | break; 767 | } 768 | Err(e) => { 769 | validation_errors.push(e.to_string()); 770 | } 771 | } 772 | } 773 | 774 | if !type_matched { 775 | let type_names: Vec = types.iter().map(|t| format!("{t:?}")).collect(); 776 | return Err(anyhow!( 777 | "The value of '{}' does not match any of the union types [{}]. Validation errors: {}", 778 | key, 779 | type_names.join(", "), 780 | validation_errors.join("; ") 781 | )); 782 | } 783 | 784 | Ok(()) 785 | } 786 | 787 | fn validate_string_length(key: &str, str_val: &str, properties: &Properties) -> Result<()> { 788 | let length = str_val.len(); 789 | 790 | if let Some(min) = properties.min_length { 791 | if length < usize::try_from(min)? { 792 | return Err(anyhow!( 793 | "The length of '{}' must be at least {} characters, but got {}", 794 | key, 795 | min, 796 | length 797 | )); 798 | } 799 | } 800 | 801 | if let Some(max) = properties.max_length { 802 | if length > usize::try_from(max)? { 803 | return Err(anyhow!( 804 | "The length of '{}' must be at most {} characters, but got {}", 805 | key, 806 | max, 807 | length 808 | )); 809 | } 810 | } 811 | 812 | Ok(()) 813 | } 814 | 815 | fn validate_numeric_range(key: &str, value: f64, properties: &Properties) -> Result<()> { 816 | if let Some(min) = properties.minimum { 817 | if value < min { 818 | return Err(anyhow!( 819 | "The value of '{}' must be >= {}, but got {}", 820 | key, 821 | min, 822 | value 823 | )); 824 | } 825 | } 826 | 827 | if let Some(max) = properties.maximum { 828 | if value > max { 829 | return Err(anyhow!( 830 | "The value of '{}' must be <= {}, but got {}", 831 | key, 832 | max, 833 | value 834 | )); 835 | } 836 | } 837 | 838 | Ok(()) 839 | } 840 | 841 | fn validate_array_length(key: &str, length: usize, properties: &Properties) -> Result<()> { 842 | if let Some(min) = properties.min_items { 843 | if length < usize::try_from(min)? { 844 | return Err(anyhow!( 845 | "The array '{}' must have at least {} items, but got {}", 846 | key, 847 | min, 848 | length 849 | )); 850 | } 851 | } 852 | 853 | if let Some(max) = properties.max_items { 854 | if length > usize::try_from(max)? { 855 | return Err(anyhow!( 856 | "The array '{}' must have at most {} items, but got {}", 857 | key, 858 | max, 859 | length 860 | )); 861 | } 862 | } 863 | 864 | Ok(()) 865 | } 866 | 867 | fn format_error(kind: &str, key: &str, value: &str) -> anyhow::Error { 868 | anyhow::anyhow!( 869 | "Invalid {} format for query parameter '{}': '{}'", 870 | kind, 871 | key, 872 | value 873 | ) 874 | } 875 | 876 | fn extract_required_and_validate_props( 877 | fields: &Map, 878 | schema_ref: &str, 879 | components: &ComponentsObject, 880 | ) -> Result> { 881 | let filename = schema_ref 882 | .rsplit('/') 883 | .next() 884 | .ok_or_else(|| anyhow!("Invalid schema reference: '{}'", schema_ref))?; 885 | 886 | let mut requireds = HashSet::new(); 887 | 888 | if let Some(schema) = components.schemas.get(filename) { 889 | requireds.extend(schema.required.iter().cloned()); 890 | validate_properties(fields, &schema.properties)?; 891 | 892 | if let Some(items) = &schema.items { 893 | requireds.extend(items.required.iter().cloned()); 894 | validate_properties(fields, &items.properties)?; 895 | } 896 | } 897 | 898 | Ok(requireds) 899 | } 900 | 901 | fn validate_properties( 902 | fields: &Map, 903 | properties: &Option>, 904 | ) -> Result<()> { 905 | if let Some(properties) = properties { 906 | for (key, prop) in properties { 907 | if let Some(value) = fields.get(key) { 908 | validate_field_type(key, value, prop.r#type.clone())?; 909 | 910 | if let Some(TypeOrUnion::Single(Type::String)) = prop.r#type { 911 | validate_field_format(key, value, prop.format.as_ref())?; 912 | } 913 | 914 | if let Some(enum_values) = &prop.r#enum { 915 | validate_enum_value(key, value, enum_values)?; 916 | } 917 | 918 | validate_pattern(key, value, prop.pattern.as_ref())?; 919 | 920 | validate_field_length_limit(key, value, prop)?; 921 | } 922 | validate_properties(fields, &prop.properties)?; 923 | } 924 | } 925 | 926 | Ok(()) 927 | } 928 | 929 | fn collect_refs(schema: &parse::Schema) -> Vec<&str> { 930 | let mut refs = Vec::new(); 931 | if let Some(r) = &schema.r#ref { 932 | refs.push(r.as_str()); 933 | } 934 | if let Some(one_of) = &schema.one_of { 935 | for s in one_of { 936 | if let Some(r) = &s.r#ref { 937 | refs.push(r.as_str()); 938 | } 939 | } 940 | } 941 | if let Some(all_of) = &schema.all_of { 942 | for s in all_of { 943 | if let Some(r) = &s.r#ref { 944 | refs.push(r.as_str()); 945 | } 946 | } 947 | } 948 | refs 949 | } 950 | 951 | fn validate_string_constraints(key: &str, value: &Value, schema: &parse::Schema) -> Result<()> { 952 | if let Some(str_val) = value.as_str() { 953 | if let Some(min_len) = schema.min_length { 954 | if str_val.len() < usize::try_from(min_len)? { 955 | return Err(anyhow!( 956 | "Parameter '{}' must be at least {} characters long, but got {}", 957 | key, 958 | min_len, 959 | str_val.len() 960 | )); 961 | } 962 | } 963 | 964 | if let Some(max_len) = schema.max_length { 965 | if str_val.len() > usize::try_from(max_len)? { 966 | return Err(anyhow!( 967 | "Parameter '{}' must be at most {} characters long, but got {}", 968 | key, 969 | max_len, 970 | str_val.len() 971 | )); 972 | } 973 | } 974 | } 975 | Ok(()) 976 | } 977 | 978 | fn validate_numeric_constraints(key: &str, value: &Value, schema: &parse::Schema) -> Result<()> { 979 | if let Some(num_val) = value.as_f64() { 980 | if let Some(min) = schema.minimum { 981 | if num_val < min { 982 | return Err(anyhow!( 983 | "Parameter '{}' must be >= {}, but got {}", 984 | key, 985 | min, 986 | num_val 987 | )); 988 | } 989 | } 990 | 991 | if let Some(max) = schema.maximum { 992 | if num_val > max { 993 | return Err(anyhow!( 994 | "Parameter '{}' must be <= {}, but got {}", 995 | key, 996 | max, 997 | num_val 998 | )); 999 | } 1000 | } 1001 | } 1002 | Ok(()) 1003 | } 1004 | 1005 | fn validate_pattern(key: &str, value: &Value, pattern: Option<&String>) -> Result<()> { 1006 | if let Some(pattern_str) = pattern { 1007 | if let Some(str_val) = value.as_str() { 1008 | let regex = Regex::new(pattern_str).map_err(|e| { 1009 | anyhow!( 1010 | "Invalid regex pattern '{}' for field '{}': {}", 1011 | pattern_str, 1012 | key, 1013 | e 1014 | ) 1015 | })?; 1016 | 1017 | if !regex.is_match(str_val) { 1018 | return Err(anyhow!( 1019 | "Value '{}' for field '{}' does not match the required pattern '{}'", 1020 | str_val, 1021 | key, 1022 | pattern_str 1023 | )); 1024 | } 1025 | } 1026 | } 1027 | Ok(()) 1028 | } 1029 | --------------------------------------------------------------------------------