openapi-guide.md (7230B)
1 # Open API guide 2 3 # Bridging the Type Gap: End-to-End Type Safety with Axum, Aide, and Orval 4 5 One of the most frustrating experiences in full-stack development is the "silent 6 failure": you change a field name in your Rust backend, everything compiles 7 perfectly, but your frontend suddenly starts receiving `undefined` and crashing 8 in production. 9 10 To solve this, we need a **Single Source of Truth**. Instead of manually 11 maintaining TypeScript interfaces that mirror your Rust structs, you can 12 automate the entire pipeline: 13 14 **Rust Models $\rightarrow$ OpenAPI Spec $\rightarrow$ TypeScript Axios Client** 15 16 In this guide, we'll use **`aide`** to generate an OpenAPI specification 17 directly from our Axum types and **`orval`** to transform that spec into a 18 type-safe Axios client. 19 20 --- 21 22 ## Part 1: The Backend (Rust + Axum + Aide) 23 24 Traditionally, writing an OpenAPI (Swagger) spec means writing a giant YAML file 25 by hand. **`aide`** eliminates this by leveraging Rust's type system to generate 26 the spec at runtime. 27 28 ### 1. Dependencies 29 30 Add these to your `Cargo.toml`: 31 32 ```toml 33 [dependencies] 34 axum = "0.7" # Ensure version compatibility with aide 35 aide = { version = "0.15", features = ["axum"] } 36 schemars = "0.8" # Required for generating JSON schemas from structs 37 serde = { version = "1.0", features = ["derive"] } 38 serde_json = "1.0" 39 tokio = { version = "1.0", features = ["full"] } 40 ``` 41 42 ### 2. The Implementation 43 44 The core idea is to replace Axum's standard `Router` with `ApiRouter` and ensure 45 your data models implement `JsonSchema`. 46 47 ```rust 48 use aide::{ 49 axum::{routing::{get, post}, ApiRouter, IntoApiResponse}, 50 openapi::{Info, OpenApi}, 51 }; 52 use axum::{Extension, Json}; 53 use schemars::JsonSchema; 54 use serde::{Deserialize, Serialize}; 55 56 // 1. All models must derive JsonSchema to be visible in the OpenAPI spec 57 #[derive(Deserialize, Serialize, JsonSchema)] 58 struct User { 59 id: u64, 60 username: String, 61 email: String, 62 } 63 64 // 2. Handlers must return types that implement IntoApiResponse 65 async fn create_user(Json(user): Json<User>) -> impl IntoApiResponse { 66 // In a real app, you'd save to DB here 67 Json(user) 68 } 69 70 async fn serve_api(Extension(api): Extension<OpenApi>) -> impl IntoApiResponse { 71 Json(api) 72 } 73 74 #[tokio::main] 75 async fn main() { 76 // Define the OpenApi metadata 77 let mut api = OpenApi { 78 info: Info { 79 title: "My Awesome API".to_string(), 80 version: "1.0.0".to_string(), 81 ..Info::default() 82 }, 83 ..OpenApi::default() 84 }; 85 86 // Use ApiRouter instead of Router 87 let app = ApiRouter::new() 88 // Use api_route to explicitly include the route in the documentation 89 .api_route("/users", post(create_user)) 90 // Standard route for the JSON spec itself 91 .route("/api.json", get(serve_api)); 92 93 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 94 95 axum::serve( 96 listener, 97 app 98 .finish_api(&mut api) // Finalize the spec generation 99 .layer(Extension(api)) // Inject the spec into the handlers 100 .into_make_service(), 101 ) 102 .await 103 .unwrap(); 104 } 105 ``` 106 107 ### How it works: 108 109 - **`JsonSchema`**: The `schemars` crate analyzes your Rust struct at 110 compile-time and allows `aide` to describe it in JSON format. 111 - **`ApiRouter`**: This acts as a wrapper. Whenever you call `.api_route()`, 112 `aide` records the types of the request and response. 113 - **`/api.json`**: We expose the generated `OpenApi` struct as a JSON endpoint. 114 This is the bridge to our frontend. 115 116 --- 117 118 ## Part 2: The Frontend (TypeScript + Orval) 119 120 Now that our backend is shouting its structure via `/api.json`, we use **Orval** 121 to listen. Orval reads the OpenAPI spec and generates an entire API 122 client—including request functions and TypeScript types—so you never have to 123 write a `fetch` or `axios` call manually. 124 125 ### 1. Install Dependencies 126 127 ```bash 128 npm install axios 129 npm install -D orval 130 ``` 131 132 ### 2. Configure Orval 133 134 Create an `orval.config.js` file in your project root: 135 136 ```javascript 137 module.exports = { 138 "my-api": { 139 input: "http://localhost:3000/api.json", // The URL from our Rust app 140 output: { 141 target: "./src/api/generated.ts", 142 client: "axios", 143 override: { 144 axios: { 145 useBaseUrl: true, 146 baseURL: "http://localhost:3000", 147 }, 148 }, 149 }, 150 }, 151 }; 152 ``` 153 154 ### 3. Generate the Client 155 156 Run the generator: 157 158 ```bash 159 npx orval 160 ``` 161 162 Orval will now create `src/api/generated.ts`. Inside, you'll find: 163 164 1. **TypeScript Interfaces**: Exactly matching the Rust `User` struct. 165 2. **Request Functions**: A function like `createUser` that takes a `User` 166 object and returns a Promise of a `User`. 167 168 ### 4. Use it in your Frontend (Svelte/React/Vue) 169 170 Now, your API calls are fully type-safe: 171 172 ```typescript 173 import { createUser } from "./api/generated"; 174 175 async function handleSignUp(formData: any) { 176 try { 177 // TypeScript will error here if formData doesn't match the Rust User struct! 178 const user = await createUser({ 179 id: 1, 180 username: "rust_lover", 181 email: "hello@rust.rs", 182 }); 183 console.log("User created:", user.username); 184 } catch (e) { 185 console.error("API Error", e); 186 } 187 } 188 ``` 189 190 --- 191 192 ## Part 3: Using CI to Keep the Spec Up to Date 193 194 To make this architecture truly production-ready, you shouldn't rely on manually 195 running `npx orval` every time you change a field in Rust. Instead, you can 196 integrate this into your **CI/CD pipeline**. 197 198 ### The "Golden File" Strategy 199 200 Since `aide` generates the specification at runtime, the most robust way to 201 handle CI is to treat the OpenAPI JSON as a versioned artifact (a "Golden 202 File"). 203 204 #### 1. Export the Spec during Backend CI 205 206 Create a Rust test in your backend that runs the `ApiRouter`, generates the 207 `OpenApi` object, and writes it to a file: 208 209 ```rust 210 #[tokio::test] 211 async fn export_openapi_spec() { 212 let mut api = OpenApi::default(); 213 let app = ApiRouter::new().api_route("/users", post(create_user)); 214 215 app.finish_api(&mut api); 216 217 let json = serde_json::to_string_pretty(&api).unwrap(); 218 std::fs::write("openapi.json", json).expect("Unable to write spec file"); 219 } 220 ``` 221 222 #### 2. The CI Pipeline (GitHub Actions Example) 223 224 Set up a workflow that triggers the frontend whenever the `openapi.json` 225 changes: 226 227 ```yaml 228 name: API Type Sync 229 on: 230 push: 231 paths: 232 - "backend/**" 233 234 jobs: 235 sync-types: 236 runs-on: ubuntu-latest 237 steps: 238 - uses: actions/checkout@v4 239 240 - name: Export OpenAPI Spec 241 run: | 242 cargo test --test export_openapi_spec 243 244 - name: Generate Frontend Client 245 run: | 246 cd frontend 247 npm install 248 npx orval 249 250 - name: Type Check Frontend 251 run: | 252 cd frontend 253 npm run type-check # Run 'tsc' to see if API changes broke the UI 254 ``` 255 256 ### Summary of the Automated Flow 257 258 1. **Developer** pushes a change to a Rust struct. 259 2. **CI** runs a test to generate the latest `openapi.json`. 260 3. **CI** runs Orval to update the TypeScript Axios client. 261 4. **CI** runs the TypeScript compiler (`tsc`). If the Rust change broke a 262 frontend component, the **build fails** before the code ever reaches 263 production.