notes

Log | Files | Refs | README

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.