90ebb09
commit 90ebb09
9 files changed
+3149 -0
Showing 9 changed files+3149-0
crates/justapi-bench/Cargo.tomlcrates/justapi-bench/src/gpu_bench.rscrates/justapi-bench/src/inference_bench.rscrates/justapi-bench/src/main.rscrates/justapi-cli/Cargo.tomlcrates/justapi-cli/src/gen_client.rscrates/justapi-cli/src/main.rscrates/justapi-cli/src/profile.rscrates/justapi-cli/src/watcher.rs
▼crates/justapi-bench/Cargo.toml
+34-0
| @@ -0,0 +1,34 @@ | ||
1 | +[package] | |
2 | +name = "justapi-bench" | |
3 | +version.workspace = true | |
4 | +edition.workspace = true | |
5 | +license.workspace = true | |
6 | + | |
7 | +[[bin]] | |
8 | +name = "justapi-bench" | |
9 | +path = "src/main.rs" | |
10 | + | |
11 | +[[bin]] | |
12 | +name = "justapi-bench-inference" | |
13 | +path = "src/inference_bench.rs" | |
14 | + | |
15 | +[[bin]] | |
16 | +name = "justapi-gpu-bench" | |
17 | +path = "src/gpu_bench.rs" | |
18 | + | |
19 | +[features] | |
20 | +default = [] | |
21 | +real = ["justapi-inference/real", "justapi-inference/cuda"] | |
22 | +cuda = ["justapi-inference/cuda"] | |
23 | +simd-json = ["justapi-core/simd-json"] | |
24 | + | |
25 | +[dependencies] | |
26 | +justapi-core.workspace = true | |
27 | +justapi-inference = { path = "../justapi-inference" } | |
28 | +tokio.workspace = true | |
29 | +serde.workspace = true | |
30 | +serde_json.workspace = true | |
31 | +tracing.workspace = true | |
32 | +tracing-subscriber.workspace = true | |
33 | +anyhow.workspace = true | |
34 | +clap = { workspace = true, features = ["derive"] } | |
0 | 35 | |
▼crates/justapi-bench/src/gpu_bench.rs
+305-0
| @@ -0,0 +1,305 @@ | ||
1 | +//! GPU wall-clock benchmark: naive `Engine::generate` vs scheduler-backed | |
2 | +//! `SchedulerEngine::generate` on a real model. | |
3 | +//! | |
4 | +//! Usage: | |
5 | +//! | |
6 | +//! ```bash | |
7 | +//! # With CUDA + real features (requires CUDA toolkit + model weights) | |
8 | +//! cargo run --release -p justapi-bench --bin justapi-gpu-bench \ | |
9 | +//! --features "cuda,real" \ | |
10 | +//! -- --model-path /path/to/model-dir/ --device cuda:0 | |
11 | +//! | |
12 | +//! # TinyLlama GGUF example: | |
13 | +//! # 1. Download: curl -Lo /tmp/tinyllama-1.1b.q4_k_m.gguf \ | |
14 | +//! # https://huggingface.co/TheBloke/TinyLlama-1.1B-GGUF/resolve/main/...gguf | |
15 | +//! # 2. Run: cargo run ... -- --model-path /tmp/tinyllama-1.1b.q4_k_m.gguf | |
16 | +//! ``` | |
17 | +//! | |
18 | +//! When no model is available or the `real` feature is missing, the binary | |
19 | +//! falls back to MockModel to exercise the scheduler path on CPU. | |
20 | + | |
21 | +use std::path::PathBuf; | |
22 | +use std::sync::{Arc, Mutex}; | |
23 | +use std::time::Instant; | |
24 | + | |
25 | +use anyhow::{Context, Result}; | |
26 | +use clap::Parser; | |
27 | + | |
28 | +use justapi_inference::{ | |
29 | + Engine, EngineDevice, KvBlockPool, SamplingParams, Scheduler, SchedulerConfig, SchedulerEngine, | |
30 | +}; | |
31 | + | |
32 | +/// GPU benchmark arguments. | |
33 | +#[derive(Parser, Debug)] | |
34 | +#[command( | |
35 | + name = "justapi-gpu-bench", | |
36 | + about = "GPU throughput benchmark for JustAPI inference" | |
37 | +)] | |
38 | +struct Args { | |
39 | + /// Path to model directory (GGUF file or safetensors + config.json). | |
40 | + #[arg(long, default_value = None)] | |
41 | + model_path: Option<PathBuf>, | |
42 | + | |
43 | + /// Device to run on (cuda:0, cuda:1, cpu). Defaults to CPU when CUDA is | |
44 | + /// not compiled in. | |
45 | + #[arg(long, default_value_t = String::from("cpu"))] | |
46 | + device: String, | |
47 | + | |
48 | + /// Model name to register in the Engine. | |
49 | + #[arg(long, default_value = "bench-model")] | |
50 | + model_name: String, | |
51 | + | |
52 | + /// Number of concurrent requests. | |
53 | + #[arg(long, default_value_t = 4)] | |
54 | + num_requests: usize, | |
55 | + | |
56 | + /// Prompt length (number of token ids). | |
57 | + #[arg(long, default_value_t = 32)] | |
58 | + prompt_len: usize, | |
59 | + | |
60 | + /// Maximum tokens to generate per request. | |
61 | + #[arg(long, default_value_t = 128)] | |
62 | + max_tokens: usize, | |
63 | + | |
64 | + /// Enable naive-path benchmark. | |
65 | + #[arg(long)] | |
66 | + bench_naive: bool, | |
67 | + | |
68 | + /// Enable scheduler-path benchmark. | |
69 | + #[arg(long)] | |
70 | + bench_scheduled: bool, | |
71 | +} | |
72 | + | |
73 | +/// Per-request timing snapshot. | |
74 | +#[derive(Default)] | |
75 | +struct Timing { | |
76 | + /// Wall clock for the entire batch. | |
77 | + wall_ms: f64, | |
78 | + /// Total tokens generated. | |
79 | + total_tokens: u64, | |
80 | + /// Time-to-first-token per request (ms). | |
81 | + ttfts: Vec<f64>, | |
82 | + /// Inter-token latencies (ms). | |
83 | + itls: Vec<f64>, | |
84 | +} | |
85 | + | |
86 | +fn report(name: &str, timing: &Timing) { | |
87 | + let mut ttfts = timing.ttfts.clone(); | |
88 | + ttfts.sort_by(|a, b| a.partial_cmp(b).unwrap()); | |
89 | + let mut itls = timing.itls.clone(); | |
90 | + itls.sort_by(|a, b| a.partial_cmp(b).unwrap()); | |
91 | + | |
92 | + let tput = if timing.wall_ms > 0.0 { | |
93 | + timing.total_tokens as f64 / (timing.wall_ms / 1000.0) | |
94 | + } else { | |
95 | + 0.0 | |
96 | + }; | |
97 | + | |
98 | + let ttft_p50 = ttfts.get(ttfts.len() / 2).copied().unwrap_or(0.0); | |
99 | + let ttft_p99 = ttfts | |
100 | + .get((ttfts.len() as f64 * 0.99) as usize) | |
101 | + .copied() | |
102 | + .unwrap_or(0.0); | |
103 | + let itl_p50 = itls.get(itls.len() / 2).copied().unwrap_or(0.0); | |
104 | + let itl_p99 = itls | |
105 | + .get((itls.len() as f64 * 0.99) as usize) | |
106 | + .copied() | |
107 | + .unwrap_or(0.0); | |
108 | + | |
109 | + println!("### {name}"); | |
110 | + println!(); | |
111 | + println!("| Metric | Value |"); | |
112 | + println!("|---|---|"); | |
113 | + println!("| Total tokens | {} |", timing.total_tokens); | |
114 | + println!("| Wall time (ms) | {:.2} |", timing.wall_ms); | |
115 | + println!("| Throughput (tok/s) | {:.0} |", tput); | |
116 | + println!("| TTFT p50 (ms) | {:.2} |", ttft_p50); | |
117 | + println!("| TTFT p99 (ms) | {:.2} |", ttft_p99); | |
118 | + println!("| ITL p50 (ms) | {:.2} |", itl_p50); | |
119 | + println!("| ITL p99 (ms) | {:.2} |", itl_p99); | |
120 | + println!(); | |
121 | +} | |
122 | + | |
123 | +fn main() -> Result<()> { | |
124 | + let args = Args::parse(); | |
125 | + | |
126 | + // --- Device setup --- | |
127 | + let device = match args.device.as_str() { | |
128 | + "cpu" => EngineDevice::Cpu, | |
129 | + s if s.starts_with("cuda") => { | |
130 | + let ordinal = s | |
131 | + .strip_prefix("cuda:") | |
132 | + .and_then(|n| n.parse::<usize>().ok()) | |
133 | + .unwrap_or(0); | |
134 | + EngineDevice::Cuda(ordinal) | |
135 | + } | |
136 | + s => anyhow::bail!("unsupported device: {s}"), | |
137 | + }; | |
138 | + | |
139 | + let engine = Arc::new(Engine::new(device).context("failed to create Engine")?); | |
140 | + | |
141 | + // --- Model loading --- | |
142 | + if let Some(ref model_path) = args.model_path { | |
143 | + #[cfg(feature = "real")] | |
144 | + { | |
145 | + if model_path.is_dir() || model_path.extension().map_or(false, |e| e == "gguf") { | |
146 | + engine | |
147 | + .load(&args.model_name, model_path) | |
148 | + .context("failed to load model")?; | |
149 | + println!("Loaded model from: {}", model_path.display()); | |
150 | + } else { | |
151 | + anyhow::bail!( | |
152 | + "model path must be a directory (safetensors) or .gguf file, got: {}", | |
153 | + model_path.display() | |
154 | + ); | |
155 | + } | |
156 | + } | |
157 | + #[cfg(not(feature = "real"))] | |
158 | + { | |
159 | + let _ = model_path; | |
160 | + anyhow::bail!( | |
161 | + "Cannot load a real model without the `real` feature. \ | |
162 | + Rebuild with: cargo build --features real" | |
163 | + ); | |
164 | + } | |
165 | + } else { | |
166 | + engine.register_mock(&args.model_name); | |
167 | + println!("No --model-path given; using MockModel (CPU, no GPU workload)."); | |
168 | + } | |
169 | + | |
170 | + let rt = tokio::runtime::Builder::new_current_thread() | |
171 | + .build() | |
172 | + .context("failed to build tokio runtime")?; | |
173 | + | |
174 | + // Build prompt tokens (0, 1, 2, ...). | |
175 | + let prompt: Vec<u32> = (0..args.prompt_len as u32).collect(); | |
176 | + | |
177 | + let params = SamplingParams { | |
178 | + max_tokens: args.max_tokens, | |
179 | + temperature: 0.0, // greedy for deterministic comparison | |
180 | + ..Default::default() | |
181 | + }; | |
182 | + | |
183 | + // --- Scheduler setup --- | |
184 | + let pool = KvBlockPool::new(4096); | |
185 | + let config = SchedulerConfig { | |
186 | + max_num_seqs: args.num_requests.max(1), | |
187 | + ..Default::default() | |
188 | + }; | |
189 | + let scheduler = Arc::new(Mutex::new(Scheduler::new(config, pool))); | |
190 | + let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler)); | |
191 | + | |
192 | + // ---- Naive path ---- | |
193 | + let all_default = !args.bench_naive && !args.bench_scheduled; | |
194 | + if args.bench_naive || all_default { | |
195 | + println!("--- Benchmark: naive Engine::generate ---"); | |
196 | + let timing = rt.block_on(bench_naive( | |
197 | + &engine, | |
198 | + &args.model_name, | |
199 | + &prompt, | |
200 | + ¶ms, | |
201 | + args.num_requests, | |
202 | + )); | |
203 | + report("Naive Engine", &timing); | |
204 | + } | |
205 | + | |
206 | + // ---- Scheduled path ---- | |
207 | + if args.bench_scheduled || all_default { | |
208 | + println!("--- Benchmark: SchedulerEngine::generate ---"); | |
209 | + let timing = rt.block_on(bench_scheduled( | |
210 | + &se, | |
211 | + &args.model_name, | |
212 | + &prompt, | |
213 | + ¶ms, | |
214 | + args.num_requests, | |
215 | + )); | |
216 | + report("SchedulerEngine", &timing); | |
217 | + } | |
218 | + | |
219 | + Ok(()) | |
220 | +} | |
221 | + | |
222 | +/// Run `n` sequential requests through naive `Engine::generate`, measuring | |
223 | +/// per-request TTFT and token timings. | |
224 | +async fn bench_naive( | |
225 | + engine: &Engine, | |
226 | + model_name: &str, | |
227 | + prompt: &[u32], | |
228 | + params: &SamplingParams, | |
229 | + n: usize, | |
230 | +) -> Timing { | |
231 | + let mut timing = Timing::default(); | |
232 | + let batch_start = Instant::now(); | |
233 | + | |
234 | + for _ in 0..n { | |
235 | + let mut rx = engine.generate(model_name, prompt, params.clone()).unwrap(); | |
236 | + let mut req_first = true; | |
237 | + let mut last_tok_time = Instant::now(); | |
238 | + | |
239 | + while let Some(tok) = rx.recv().await { | |
240 | + if req_first { | |
241 | + timing | |
242 | + .ttfts | |
243 | + .push(last_tok_time.elapsed().as_secs_f64() * 1000.0); | |
244 | + req_first = false; | |
245 | + last_tok_time = Instant::now(); | |
246 | + } | |
247 | + if tok.finish_reason.is_some() { | |
248 | + break; | |
249 | + } | |
250 | + timing | |
251 | + .itls | |
252 | + .push(last_tok_time.elapsed().as_secs_f64() * 1000.0); | |
253 | + last_tok_time = Instant::now(); | |
254 | + timing.total_tokens += 1; | |
255 | + } | |
256 | + } | |
257 | + | |
258 | + timing.wall_ms = batch_start.elapsed().as_secs_f64() * 1000.0; | |
259 | + timing | |
260 | +} | |
261 | + | |
262 | +/// Run `n` concurrent requests through scheduler-backed path, measuring | |
263 | +/// per-request TTFT and token timings. | |
264 | +async fn bench_scheduled( | |
265 | + se: &SchedulerEngine, | |
266 | + model_name: &str, | |
267 | + prompt: &[u32], | |
268 | + params: &SamplingParams, | |
269 | + n: usize, | |
270 | +) -> Timing { | |
271 | + let mut timing = Timing::default(); | |
272 | + let batch_start = Instant::now(); | |
273 | + | |
274 | + // Admit all requests up front (continuous batching interleaves them). | |
275 | + let mut receivers = Vec::with_capacity(n); | |
276 | + for _ in 0..n { | |
277 | + receivers.push(se.generate(model_name, prompt, params.clone()).unwrap()); | |
278 | + } | |
279 | + | |
280 | + for rx in receivers.iter_mut() { | |
281 | + let mut req_first = true; | |
282 | + let mut last_tok_time = Instant::now(); | |
283 | + | |
284 | + while let Some(tok) = rx.recv().await { | |
285 | + if req_first { | |
286 | + timing | |
287 | + .ttfts | |
288 | + .push(last_tok_time.elapsed().as_secs_f64() * 1000.0); | |
289 | + req_first = false; | |
290 | + last_tok_time = Instant::now(); | |
291 | + } | |
292 | + if tok.finish_reason.is_some() { | |
293 | + break; | |
294 | + } | |
295 | + timing | |
296 | + .itls | |
297 | + .push(last_tok_time.elapsed().as_secs_f64() * 1000.0); | |
298 | + last_tok_time = Instant::now(); | |
299 | + timing.total_tokens += 1; | |
300 | + } | |
301 | + } | |
302 | + | |
303 | + timing.wall_ms = batch_start.elapsed().as_secs_f64() * 1000.0; | |
304 | + timing | |
305 | +} | |
0 | 306 | |
▼crates/justapi-bench/src/inference_bench.rs
+475-0
| @@ -0,0 +1,475 @@ | ||
1 | +//! Inference-engine scheduler benchmark — structural proof-of-mechanism on the | |
2 | +//! CPU fixture. | |
3 | +//! | |
4 | +//! No GPU is available in CI, so this does **not** measure wall-clock LLM | |
5 | +//! tokens/sec. Instead it drives the *real* JustAPI schedulers | |
6 | +//! ([`PdScheduler`] for disaggregated prefill/decode, [`Scheduler`] for a | |
7 | +//! collocated pool) with a parameterized synthetic GPU-cost model and reports | |
8 | +//! the scheduling metrics that define LLM-serving quality: | |
9 | +//! | |
10 | +//! - **TTFT** (time to first token) — prefill latency experienced by a request. | |
11 | +//! - **ITL** (inter-token latency) — decode-step spacing (streaming smoothness). | |
12 | +//! - **Throughput** — total generated tokens / wall time. | |
13 | +//! | |
14 | +//! ## Cost + parallelism model | |
15 | +//! | |
16 | +//! Prefill cost scales with prompt tokens processed in a step (compute-bound); | |
17 | +//! decode cost is a single fixed step regardless of batch size | |
18 | +//! (memory-bandwidth-bound) — the standard vLLM/SGLang assumption. | |
19 | +//! | |
20 | +//! The two topologies differ in *hardware*: | |
21 | +//! - **Disaggregated** runs prefill and decode on **independent pools** (separate | |
22 | +//! GPUs), so their timelines advance in parallel. Decode ITL is therefore | |
23 | +//! decoupled from prefill — it stays uniform at `decode_us` even while prefill | |
24 | +//! is busy. | |
25 | +//! - **Collocated** shares one pool, so each step serialises prefill-then-decode | |
26 | +//! on the same budget; decode ITL is inflated by whatever prefill work shares | |
27 | +//! the step. | |
28 | +//! | |
29 | +//! We model this with two independent virtual clocks (disaggregated) vs one | |
30 | +//! shared clock (collocated). This is a faithful structural model of the | |
31 | +//! scheduler topology, not a wall-clock GPU claim. | |
32 | + | |
33 | +use std::collections::HashSet; | |
34 | +use std::sync::{Arc, Mutex}; | |
35 | +use std::time::Instant; | |
36 | + | |
37 | +use justapi_inference::{ | |
38 | + Engine, EngineDevice, KvBlockPool, NewRequest, PdScheduler, SamplingParams, Scheduler, | |
39 | + SchedulerConfig, SchedulerEngine, | |
40 | +}; | |
41 | + | |
42 | +/// Benchmark parameters. | |
43 | +#[derive(Clone, Copy)] | |
44 | +struct BenchParams { | |
45 | + /// Number of concurrent requests submitted at t=0 (burst load). | |
46 | + num_requests: usize, | |
47 | + /// Base prompt length; actual length varies per request to force prefill/decode | |
48 | + /// overlap (so the collocated scheduler's ITL degradation is visible). | |
49 | + prompt_len: usize, | |
50 | + /// Max new tokens to generate per request. | |
51 | + max_tokens: usize, | |
52 | + /// Synthetic prefill cost: microseconds per prompt token in a prefill step. | |
53 | + prefill_us_per_token: u64, | |
54 | + /// Synthetic decode cost: microseconds for one batched decode step. | |
55 | + decode_us_per_token: u64, | |
56 | + /// Chunked-prefill size (None = whole prompt at once). | |
57 | + chunked_prefill_size: Option<usize>, | |
58 | +} | |
59 | + | |
60 | +/// Collected scheduling metrics for one run (times in milliseconds). | |
61 | +#[derive(Default)] | |
62 | +struct Metrics { | |
63 | + /// Per-request time-to-first-token. | |
64 | + ttfts: Vec<f64>, | |
65 | + /// Per-decode-step inter-token latency. | |
66 | + itls: Vec<f64>, | |
67 | + /// Total generated (decode) tokens. | |
68 | + total_tokens: u64, | |
69 | + /// Wall-clock duration (virtual). | |
70 | + wall_ms: f64, | |
71 | + /// Tokens transferred prefill→decode pool (disaggregated only). | |
72 | + transferred_tokens: usize, | |
73 | +} | |
74 | + | |
75 | +fn make_request(id: u64, prompt_len: usize, max_tokens: usize) -> NewRequest { | |
76 | + NewRequest { | |
77 | + id, | |
78 | + prompt: (0..prompt_len as u32).collect(), | |
79 | + sampling_params: SamplingParams { | |
80 | + max_tokens, | |
81 | + ..Default::default() | |
82 | + }, | |
83 | + prefix_cached_tokens: 0, | |
84 | + cached_blocks: Vec::new(), | |
85 | + } | |
86 | +} | |
87 | + | |
88 | +fn percentile(sorted: &[f64], p: f64) -> f64 { | |
89 | + if sorted.is_empty() { | |
90 | + return 0.0; | |
91 | + } | |
92 | + let rank = (p * (sorted.len() as f64 - 1.0)).round() as usize; | |
93 | + sorted[rank.min(sorted.len() - 1)] | |
94 | +} | |
95 | + | |
96 | +/// Run the **disaggregated** configuration: independent prefill and decode pools | |
97 | +/// modelled with parallel virtual clocks. | |
98 | +fn run_disaggregated(p: &BenchParams) -> Metrics { | |
99 | + let mut pd = PdScheduler::new( | |
100 | + SchedulerConfig { | |
101 | + max_num_seqs: p.num_requests.max(1), | |
102 | + max_num_batched_tokens: 8192, | |
103 | + max_seq_len: 8192, | |
104 | + chunked_prefill_size: p.chunked_prefill_size, | |
105 | + }, | |
106 | + KvBlockPool::new(8192), | |
107 | + SchedulerConfig { | |
108 | + max_num_seqs: p.num_requests.max(1), | |
109 | + max_num_batched_tokens: 8192, | |
110 | + max_seq_len: 8192, | |
111 | + chunked_prefill_size: p.chunked_prefill_size, | |
112 | + }, | |
113 | + KvBlockPool::new(8192), | |
114 | + ); | |
115 | + for i in 0..p.num_requests as u64 { | |
116 | + // Vary prompt length so sequences finish prefill at different steps and | |
117 | + // prefill/decode overlap. | |
118 | + let pl = p.prompt_len + ((i as usize) % 5) * 48; | |
119 | + pd.add_request(make_request(i, pl, p.max_tokens)); | |
120 | + } | |
121 | + | |
122 | + // Two independent timelines (parallel prefill + decode GPUs). | |
123 | + let mut prefill_clock = 0.0_f64; // µs | |
124 | + let mut decode_clock = 0.0_f64; // µs | |
125 | + let mut m = Metrics::default(); | |
126 | + let mut decoded_once: HashSet<u64> = HashSet::new(); | |
127 | + let mut prev_decode_clock: Option<f64> = None; | |
128 | + | |
129 | + loop { | |
130 | + if pd.is_idle() { | |
131 | + break; | |
132 | + } | |
133 | + | |
134 | + // ---- prefill pool ---- | |
135 | + let sched = pd.schedule_prefill(); | |
136 | + let p_cost: f64 = sched.prefill.iter().map(|s| s.tokens.len()).sum::<usize>() as f64 | |
137 | + * p.prefill_us_per_token as f64; | |
138 | + prefill_clock += p_cost; | |
139 | + for step in &sched.prefill { | |
140 | + pd.on_prefill_complete(step.seq_id, 1); | |
141 | + } | |
142 | + m.transferred_tokens += pd.transfer_completed(); | |
143 | + | |
144 | + // ---- decode pool (runs in parallel on its own GPU) ---- | |
145 | + let sched = pd.schedule_decode(); | |
146 | + let d_cost: f64 = if sched.decode.is_empty() { | |
147 | + 0.0 | |
148 | + } else { | |
149 | + p.decode_us_per_token as f64 | |
150 | + }; | |
151 | + decode_clock += d_cost; | |
152 | + if !sched.decode.is_empty() { | |
153 | + // Global virtual time = the later of the two pool timelines. | |
154 | + let now = prefill_clock.max(decode_clock); | |
155 | + for &seq_id in &sched.decode { | |
156 | + if decoded_once.insert(seq_id) { | |
157 | + m.ttfts.push(now / 1000.0); | |
158 | + } | |
159 | + pd.on_decode_complete(seq_id, 1); | |
160 | + } | |
161 | + m.total_tokens += sched.decode.len() as u64; | |
162 | + // ITL is measured on the decode pool's own timeline (independent). | |
163 | + if let Some(prev) = prev_decode_clock { | |
164 | + m.itls.push((decode_clock - prev) / 1000.0); | |
165 | + } | |
166 | + prev_decode_clock = Some(decode_clock); | |
167 | + } | |
168 | + } | |
169 | + | |
170 | + m.wall_ms = decode_clock.max(prefill_clock) / 1000.0; | |
171 | + m | |
172 | +} | |
173 | + | |
174 | +/// Run the **collocated** configuration: one scheduler, one shared virtual clock | |
175 | +/// (prefill and decode serialised on the same pool/budget). | |
176 | +fn run_collocated(p: &BenchParams) -> Metrics { | |
177 | + let mut sched = Scheduler::new( | |
178 | + SchedulerConfig { | |
179 | + max_num_seqs: p.num_requests.max(1), | |
180 | + max_num_batched_tokens: 8192, | |
181 | + max_seq_len: 8192, | |
182 | + chunked_prefill_size: p.chunked_prefill_size, | |
183 | + }, | |
184 | + KvBlockPool::new(8192), | |
185 | + ); | |
186 | + for i in 0..p.num_requests as u64 { | |
187 | + let pl = p.prompt_len + ((i as usize) % 5) * 48; | |
188 | + sched.add_request(make_request(i, pl, p.max_tokens)); | |
189 | + } | |
190 | + | |
191 | + let mut t = 0.0_f64; // µs, single shared clock | |
192 | + let mut m = Metrics::default(); | |
193 | + let mut decoded_once: HashSet<u64> = HashSet::new(); | |
194 | + let mut prev_t: Option<f64> = None; | |
195 | + | |
196 | + loop { | |
197 | + if sched.is_idle() { | |
198 | + break; | |
199 | + } | |
200 | + let s = sched.schedule(); | |
201 | + | |
202 | + // Shared pool: prefill and decode serialised in the same step. | |
203 | + let mut cost = 0.0_f64; | |
204 | + for step in &s.prefill { | |
205 | + cost += (step.tokens.len() as f64) * p.prefill_us_per_token as f64; | |
206 | + } | |
207 | + if !s.decode.is_empty() { | |
208 | + cost += p.decode_us_per_token as f64; | |
209 | + } | |
210 | + t += cost; | |
211 | + | |
212 | + for step in &s.prefill { | |
213 | + sched.on_step_complete(step.seq_id, 1); | |
214 | + } | |
215 | + for &seq_id in &s.decode { | |
216 | + if decoded_once.insert(seq_id) { | |
217 | + m.ttfts.push(t / 1000.0); | |
218 | + } | |
219 | + sched.on_step_complete(seq_id, 1); | |
220 | + } | |
221 | + m.total_tokens += s.decode.len() as u64; | |
222 | + if !s.decode.is_empty() { | |
223 | + if let Some(prev) = prev_t { | |
224 | + m.itls.push((t - prev) / 1000.0); | |
225 | + } | |
226 | + prev_t = Some(t); | |
227 | + } | |
228 | + } | |
229 | + | |
230 | + m.wall_ms = t / 1000.0; | |
231 | + m | |
232 | +} | |
233 | + | |
234 | +fn report(name: &str, m: &Metrics, p: &BenchParams) { | |
235 | + let mut ttfts = m.ttfts.clone(); | |
236 | + ttfts.sort_by(|a, b| a.partial_cmp(b).unwrap()); | |
237 | + let mut itls = m.itls.clone(); | |
238 | + itls.sort_by(|a, b| a.partial_cmp(b).unwrap()); | |
239 | + | |
240 | + let tput = if m.wall_ms > 0.0 { | |
241 | + m.total_tokens as f64 / (m.wall_ms / 1000.0) | |
242 | + } else { | |
243 | + 0.0 | |
244 | + }; | |
245 | + | |
246 | + println!("### {name}"); | |
247 | + println!(); | |
248 | + println!("| Metric | p50 | p99 |"); | |
249 | + println!("|---|---|---|"); | |
250 | + println!( | |
251 | + "| TTFT (ms) | {:.2} | {:.2} |", | |
252 | + percentile(&ttfts, 0.5), | |
253 | + percentile(&ttfts, 0.99) | |
254 | + ); | |
255 | + println!( | |
256 | + "| ITL (ms) | {:.2} | {:.2} |", | |
257 | + percentile(&itls, 0.5), | |
258 | + percentile(&itls, 0.99) | |
259 | + ); | |
260 | + println!("| Throughput (tok/s) | {:.0} | |", tput); | |
261 | + println!("| Total tokens | {} | |", m.total_tokens); | |
262 | + println!("| Wall time (ms) | {:.2} | |", m.wall_ms); | |
263 | + if m.transferred_tokens > 0 { | |
264 | + println!("| Transferred tokens (P→D) | {} | |", m.transferred_tokens); | |
265 | + } | |
266 | + println!(); | |
267 | + println!( | |
268 | + "Config: {} requests, prompt≈{}-{} tok, max_tokens={}, prefill={}µs/tok, decode={}µs/step, chunk={:?}", | |
269 | + p.num_requests, | |
270 | + p.prompt_len, | |
271 | + p.prompt_len + 4 * 48, | |
272 | + p.max_tokens, | |
273 | + p.prefill_us_per_token, | |
274 | + p.decode_us_per_token, | |
275 | + p.chunked_prefill_size | |
276 | + ); | |
277 | + println!(); | |
278 | +} | |
279 | + | |
280 | +// --------------------------------------------------------------------------- | |
281 | +// Real wall-clock throughput (CPU fixture, MockModel) | |
282 | +// | |
283 | +// The structural benchmark above models GPU cost synthetically. This section | |
284 | +// additionally drives the *real* generation path (naive `Engine::generate` vs | |
285 | +// scheduler-backed `SchedulerEngine::generate`) and measures actual token | |
286 | +// throughput on the CPU fixture. This quantifies the scheduler's per-token | |
287 | +// overhead (thread hop + lock + sampling) — not LLM tokens/sec, which needs a | |
288 | +// GPU. | |
289 | +// --------------------------------------------------------------------------- | |
290 | + | |
291 | +const REAL_MODEL: &str = "mock"; | |
292 | + | |
293 | +/// Run `n` requests through the naive `Engine::generate` path and return | |
294 | +/// (total_tokens, wall_ms). | |
295 | +fn bench_naive(n: usize, prompt_len: usize, max_tokens: usize) -> (u64, f64) { | |
296 | + let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap()); | |
297 | + engine.register_mock(REAL_MODEL); | |
298 | + let rt = tokio::runtime::Builder::new_current_thread() | |
299 | + .build() | |
300 | + .unwrap(); | |
301 | + | |
302 | + let prompt: Vec<u32> = (0..prompt_len as u32).collect(); | |
303 | + let params = SamplingParams { | |
304 | + max_tokens, | |
305 | + ..Default::default() | |
306 | + }; | |
307 | + | |
308 | + let start = Instant::now(); | |
309 | + let total: u64 = rt.block_on(async { | |
310 | + let mut total = 0u64; | |
311 | + for _ in 0..n { | |
312 | + let mut rx = engine | |
313 | + .generate(REAL_MODEL, &prompt, params.clone()) | |
314 | + .unwrap(); | |
315 | + while let Some(tok) = rx.recv().await { | |
316 | + if tok.finish_reason.is_some() { | |
317 | + break; | |
318 | + } | |
319 | + total += 1; | |
320 | + } | |
321 | + } | |
322 | + total | |
323 | + }); | |
324 | + let wall_ms = start.elapsed().as_secs_f64() * 1000.0; | |
325 | + (total, wall_ms) | |
326 | +} | |
327 | + | |
328 | +/// Run `n` requests through the scheduler-backed `SchedulerEngine` path and | |
329 | +/// return (total_tokens, wall_ms). | |
330 | +fn bench_scheduled(n: usize, prompt_len: usize, max_tokens: usize) -> (u64, f64) { | |
331 | + let engine = Arc::new(Engine::new(EngineDevice::Cpu).unwrap()); | |
332 | + engine.register_mock(REAL_MODEL); | |
333 | + let pool = KvBlockPool::new(4096); | |
334 | + let config = SchedulerConfig { | |
335 | + max_num_seqs: n.max(1), | |
336 | + ..Default::default() | |
337 | + }; | |
338 | + let scheduler = Arc::new(Mutex::new(Scheduler::new(config, pool))); | |
339 | + let se = Arc::new(SchedulerEngine::new(engine.clone(), scheduler)); | |
340 | + | |
341 | + let rt = tokio::runtime::Builder::new_current_thread() | |
342 | + .build() | |
343 | + .unwrap(); | |
344 | + | |
345 | + let prompt: Vec<u32> = (0..prompt_len as u32).collect(); | |
346 | + let params = SamplingParams { | |
347 | + max_tokens, | |
348 | + ..Default::default() | |
349 | + }; | |
350 | + | |
351 | + let start = Instant::now(); | |
352 | + let total: u64 = rt.block_on(async { | |
353 | + let mut total = 0u64; | |
354 | + // All requests are admitted up front; the persistent scheduler loop | |
355 | + // interleaves them (continuous batching). | |
356 | + let mut receivers = Vec::new(); | |
357 | + for _ in 0..n { | |
358 | + receivers.push(se.generate(REAL_MODEL, &prompt, params.clone()).unwrap()); | |
359 | + } | |
360 | + for rx in receivers.iter_mut() { | |
361 | + while let Some(tok) = rx.recv().await { | |
362 | + if tok.finish_reason.is_some() { | |
363 | + break; | |
364 | + } | |
365 | + total += 1; | |
366 | + } | |
367 | + } | |
368 | + total | |
369 | + }); | |
370 | + let wall_ms = start.elapsed().as_secs_f64() * 1000.0; | |
371 | + (total, wall_ms) | |
372 | +} | |
373 | + | |
374 | +fn main() { | |
375 | + let params = BenchParams { | |
376 | + num_requests: 16, | |
377 | + prompt_len: 64, | |
378 | + max_tokens: 64, | |
379 | + prefill_us_per_token: 5, | |
380 | + decode_us_per_token: 200, | |
381 | + chunked_prefill_size: Some(16), | |
382 | + }; | |
383 | + | |
384 | + println!("# JustAPI inference scheduler benchmark — disaggregated P/D vs collocated"); | |
385 | + println!(); | |
386 | + println!( | |
387 | + "Hardware: 13th Gen Intel Core i5-13600K (20 threads), CachyOS. \ | |
388 | + Structural run — GPU cost modeled synthetically (no GPU in CI). \ | |
389 | + Disaggregated = parallel prefill/decode timelines; collocated = shared timeline." | |
390 | + ); | |
391 | + println!(); | |
392 | + | |
393 | + let coll = run_collocated(¶ms); | |
394 | + let disagg = run_disaggregated(¶ms); | |
395 | + | |
396 | + report( | |
397 | + "Collocated (single pool, prefill+decode serialised)", | |
398 | + &coll, | |
399 | + ¶ms, | |
400 | + ); | |
401 | + report( | |
402 | + "Disaggregated (independent prefill/decode pools)", | |
403 | + &disagg, | |
404 | + ¶ms, | |
405 | + ); | |
406 | + | |
407 | + let mut coll_itl = coll.itls.clone(); | |
408 | + coll_itl.sort_by(|a, b| a.partial_cmp(b).unwrap()); | |
409 | + let mut disagg_itl = disagg.itls.clone(); | |
410 | + disagg_itl.sort_by(|a, b| a.partial_cmp(b).unwrap()); | |
411 | + | |
412 | + let coll_p99 = percentile(&coll_itl, 0.99); | |
413 | + let disagg_p99 = percentile(&disagg_itl, 0.99); | |
414 | + let itl_improvement = if disagg_p99 > 0.0 { | |
415 | + coll_p99 / disagg_p99 | |
416 | + } else { | |
417 | + 1.0 | |
418 | + }; | |
419 | + let tput_improvement = if coll.wall_ms > 0.0 { | |
420 | + disagg.wall_ms / coll.wall_ms | |
421 | + } else { | |
422 | + 1.0 | |
423 | + }; | |
424 | + | |
425 | + println!("---"); | |
426 | + println!(); | |
427 | + println!( | |
428 | + "**ITL p99:** collocated {:.2} ms → disaggregated {:.2} ms ({:.2}x tighter)", | |
429 | + coll_p99, disagg_p99, itl_improvement | |
430 | + ); | |
431 | + println!( | |
432 | + "**Wall time:** collocated {:.2} ms → disaggregated {:.2} ms ({:.2}x faster, parallel pools)", | |
433 | + coll.wall_ms, disagg.wall_ms, tput_improvement | |
434 | + ); | |
435 | + println!(); | |
436 | + | |
437 | + // ---- Real wall-clock throughput comparison ---- | |
438 | + println!("---"); | |
439 | + println!(); | |
440 | + println!("# Real wall-clock throughput (CPU fixture, MockModel)"); | |
441 | + println!(); | |
442 | + println!( | |
443 | + "Measures actual generation latency on the CPU — quantifies scheduler \ | |
444 | + per-token overhead (thread hop + lock contention + sampling). \ | |
445 | + All models are MockModel (instant forward pass)." | |
446 | + ); | |
447 | + println!(); | |
448 | + | |
449 | + let n_requests_real = 8; | |
450 | + let prompt_len_real = 8; | |
451 | + let max_tokens_real = 16; | |
452 | + let (naive_tokens, naive_ms) = bench_naive(n_requests_real, prompt_len_real, max_tokens_real); | |
453 | + let (sched_tokens, sched_ms) = | |
454 | + bench_scheduled(n_requests_real, prompt_len_real, max_tokens_real); | |
455 | + | |
456 | + let naive_tput = (naive_tokens as f64) / (naive_ms / 1000.0); | |
457 | + let sched_tput = (sched_tokens as f64) / (sched_ms / 1000.0); | |
458 | + let overhead = if naive_tput > 0.0 { | |
459 | + sched_tput / naive_tput * 100.0 | |
460 | + } else { | |
461 | + 0.0 | |
462 | + }; | |
463 | + | |
464 | + println!("| Path | Total tokens | Wall (ms) | Throughput (tok/s) | Overhead vs naive |"); | |
465 | + println!("|---|---|---|---|---|"); | |
466 | + println!("| Naive Engine | {naive_tokens} | {naive_ms:.2} | {naive_tput:.0} | — |"); | |
467 | + println!( | |
468 | + "| SchedulerEngine | {sched_tokens} | {sched_ms:.2} | {sched_tput:.0} | Scheduler {overhead:.1}% of naive |" | |
469 | + ); | |
470 | + println!(); | |
471 | + println!( | |
472 | + "Config: {n_requests_real} requests, {prompt_len_real} prompt tokens, {max_tokens_real} max_tokens each." | |
473 | + ); | |
474 | + println!(); | |
475 | +} | |
0 | 476 | |
▼crates/justapi-bench/src/main.rs
+96-0
| @@ -0,0 +1,96 @@ | ||
1 | +use std::time::Instant; | |
2 | + | |
3 | +use serde::Serialize; | |
4 | + | |
5 | +#[derive(Serialize)] | |
6 | +struct NestedPayload { | |
7 | + user: User, | |
8 | + items: Vec<u32>, | |
9 | + meta: Metadata, | |
10 | +} | |
11 | + | |
12 | +#[derive(Serialize)] | |
13 | +struct User { | |
14 | + name: String, | |
15 | + id: u32, | |
16 | +} | |
17 | + | |
18 | +#[derive(Serialize)] | |
19 | +struct Metadata { | |
20 | + version: String, | |
21 | + timestamp: u64, | |
22 | +} | |
23 | + | |
24 | +fn payload() -> NestedPayload { | |
25 | + NestedPayload { | |
26 | + user: User { | |
27 | + name: "test".into(), | |
28 | + id: 42, | |
29 | + }, | |
30 | + items: vec![1, 2, 3], | |
31 | + meta: Metadata { | |
32 | + version: "1.0".into(), | |
33 | + timestamp: 1700000000, | |
34 | + }, | |
35 | + } | |
36 | +} | |
37 | + | |
38 | +fn bench<F>(name: &str, f: F, iterations: usize) | |
39 | +where | |
40 | + F: Fn(), | |
41 | +{ | |
42 | + for _ in 0..1000 { | |
43 | + f(); | |
44 | + } | |
45 | + | |
46 | + let start = Instant::now(); | |
47 | + for _ in 0..iterations { | |
48 | + f(); | |
49 | + } | |
50 | + let elapsed = start.elapsed(); | |
51 | + let avg_ns = elapsed.as_nanos() as f64 / iterations as f64; | |
52 | + let ops_per_sec = (iterations as f64 / elapsed.as_secs_f64()) as u64; | |
53 | + | |
54 | + println!( | |
55 | + "| {} | {:.0} ns/op | {:.0} ops/sec | {} iterations in {:.2?} |", | |
56 | + name, avg_ns, ops_per_sec, iterations, elapsed | |
57 | + ); | |
58 | +} | |
59 | + | |
60 | +fn main() { | |
61 | + println!(); | |
62 | + println!("## Serialization benchmarks"); | |
63 | + println!(); | |
64 | + println!("Payload: nested JSON object (user+items+meta), ~60 bytes"); | |
65 | + println!(); | |
66 | + println!("| Backend | Latency | Throughput | Details |"); | |
67 | + println!("|---|---|---|---|"); | |
68 | + | |
69 | + let p = payload(); | |
70 | + let iterations = 1_000_000; | |
71 | + | |
72 | + bench( | |
73 | + "justapi_core::serialize (default=serde_json)", | |
74 | + || { | |
75 | + use justapi_core::serialize::to_json_string; | |
76 | + let _ = to_json_string(&p).unwrap(); | |
77 | + }, | |
78 | + iterations, | |
79 | + ); | |
80 | + | |
81 | + #[cfg(feature = "simd-json")] | |
82 | + bench( | |
83 | + "justapi_core::serialize (simd-json feature)", | |
84 | + || { | |
85 | + use justapi_core::serialize::to_json_string; | |
86 | + let _ = to_json_string(&p).unwrap(); | |
87 | + }, | |
88 | + iterations, | |
89 | + ); | |
90 | + | |
91 | + println!(); | |
92 | + println!("---"); | |
93 | + println!(); | |
94 | + println!("Hardware: 13th Gen Intel Core i5-13600K, DDR5"); | |
95 | + println!(); | |
96 | +} | |
0 | 97 | |
▼crates/justapi-cli/Cargo.toml
+33-0
| @@ -0,0 +1,33 @@ | ||
1 | +[package] | |
2 | +name = "justapi-cli" | |
3 | +version.workspace = true | |
4 | +edition.workspace = true | |
5 | +license.workspace = true | |
6 | + | |
7 | +[[bin]] | |
8 | +name = "justapi" | |
9 | +path = "src/main.rs" | |
10 | + | |
11 | +[features] | |
12 | +default = [] | |
13 | +tls = ["justapi-core/tls"] | |
14 | +compression = ["justapi-core/compression"] | |
15 | +opentelemetry = ["justapi-core/opentelemetry"] | |
16 | +inference = ["justapi-core/inference", "dep:justapi-inference"] | |
17 | + | |
18 | +[dependencies] | |
19 | +justapi-core = { workspace = true, features = ["db"] } | |
20 | +justapi-inference = { path = "../justapi-inference", optional = true } | |
21 | +tokio.workspace = true | |
22 | +clap.workspace = true | |
23 | +tracing.workspace = true | |
24 | +tracing-subscriber.workspace = true | |
25 | +anyhow.workspace = true | |
26 | +serde.workspace = true | |
27 | +serde_json.workspace = true | |
28 | +tokio-util.workspace = true | |
29 | +notify-debouncer-mini = "0.7" | |
30 | +notify = "8" | |
31 | + | |
32 | +[dev-dependencies] | |
33 | +hyper = { version = "1", features = ["http1"] } | |
0 | 34 | |
▼crates/justapi-cli/src/gen_client.rs
+806-0
| @@ -0,0 +1,806 @@ | ||
1 | +use std::collections::BTreeMap; | |
2 | +use std::path::Path; | |
3 | + | |
4 | +use justapi_core::openapi::{OpenApiDocument, Operation, ParameterLocation}; | |
5 | + | |
6 | +/// Supported target languages for client generation. | |
7 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
8 | +pub enum ClientLanguage { | |
9 | + Python, | |
10 | + Typescript, | |
11 | +} | |
12 | + | |
13 | +impl std::str::FromStr for ClientLanguage { | |
14 | + type Err = anyhow::Error; | |
15 | + | |
16 | + fn from_str(s: &str) -> Result<Self, Self::Err> { | |
17 | + match s.to_ascii_lowercase().as_str() { | |
18 | + "py" | "python" => Ok(ClientLanguage::Python), | |
19 | + "ts" | "typescript" | "js" => Ok(ClientLanguage::Typescript), | |
20 | + other => anyhow::bail!( | |
21 | + "unsupported language '{}' (expected python or typescript)", | |
22 | + other | |
23 | + ), | |
24 | + } | |
25 | + } | |
26 | +} | |
27 | + | |
28 | +impl ClientLanguage { | |
29 | + pub fn extension(&self) -> &'static str { | |
30 | + match self { | |
31 | + ClientLanguage::Python => "py", | |
32 | + ClientLanguage::Typescript => "ts", | |
33 | + } | |
34 | + } | |
35 | + | |
36 | + pub fn module_name(&self) -> &'static str { | |
37 | + match self { | |
38 | + ClientLanguage::Python => "client", | |
39 | + ClientLanguage::Typescript => "client", | |
40 | + } | |
41 | + } | |
42 | +} | |
43 | + | |
44 | +/// A single parameter with its original spec name, a sanitized argument | |
45 | +/// name safe for the target language, and its scalar schema type (if known). | |
46 | +struct Param { | |
47 | + original: String, | |
48 | + arg: String, | |
49 | + ty: Option<String>, | |
50 | +} | |
51 | + | |
52 | +/// One operation extracted from the spec, ready to emit. | |
53 | +struct Op { | |
54 | + method: String, | |
55 | + raw_path: String, | |
56 | + name: String, | |
57 | + summary: Option<String>, | |
58 | + path_params: Vec<Param>, | |
59 | + query_params: Vec<Param>, | |
60 | + header_params: Vec<Param>, | |
61 | + has_body: bool, | |
62 | +} | |
63 | + | |
64 | +/// Build a valid identifier from an arbitrary operation id / hint. | |
65 | +fn sanitize_ident(s: &str) -> String { | |
66 | + let mut out = String::new(); | |
67 | + let mut upper = false; | |
68 | + for ch in s.chars() { | |
69 | + if ch.is_alphanumeric() { | |
70 | + out.push(if upper { ch.to_ascii_uppercase() } else { ch }); | |
71 | + upper = false; | |
72 | + } else { | |
73 | + upper = true; | |
74 | + } | |
75 | + } | |
76 | + // Collapse leading/trailing digits and ensure non-empty. | |
77 | + let out: String = out.trim_matches(|c: char| !c.is_alphabetic()).to_string(); | |
78 | + if out.is_empty() { | |
79 | + "call".to_string() | |
80 | + } else { | |
81 | + out | |
82 | + } | |
83 | +} | |
84 | + | |
85 | +fn python_type(schema_type: Option<&str>) -> &'static str { | |
86 | + match schema_type { | |
87 | + Some("string") => "str", | |
88 | + Some("integer") => "int", | |
89 | + Some("number") => "float", | |
90 | + Some("boolean") => "bool", | |
91 | + Some("array") => "list", | |
92 | + Some("object") => "dict", | |
93 | + _ => "Any", | |
94 | + } | |
95 | +} | |
96 | + | |
97 | +fn ts_type(schema_type: Option<&str>) -> &'static str { | |
98 | + match schema_type { | |
99 | + Some("string") => "string", | |
100 | + Some("integer") | Some("number") => "number", | |
101 | + Some("boolean") => "boolean", | |
102 | + Some("array") => "unknown[]", | |
103 | + Some("object") => "Record<string, unknown>", | |
104 | + _ => "unknown", | |
105 | + } | |
106 | +} | |
107 | + | |
108 | +fn collect_ops(doc: &OpenApiDocument) -> Vec<Op> { | |
109 | + let mut ops: Vec<Op> = Vec::new(); | |
110 | + let methods = [ | |
111 | + ("get", &doc.paths), | |
112 | + ("post", &doc.paths), | |
113 | + ("put", &doc.paths), | |
114 | + ("delete", &doc.paths), | |
115 | + ("patch", &doc.paths), | |
116 | + ("head", &doc.paths), | |
117 | + ("options", &doc.paths), | |
118 | + ("query", &doc.paths), | |
119 | + ]; | |
120 | + | |
121 | + for (method, paths) in methods { | |
122 | + for (raw_path, item) in paths { | |
123 | + let op: Option<&Operation> = match method { | |
124 | + "get" => item.get.as_ref(), | |
125 | + "post" => item.post.as_ref(), | |
126 | + "put" => item.put.as_ref(), | |
127 | + "delete" => item.delete.as_ref(), | |
128 | + "patch" => item.patch.as_ref(), | |
129 | + "head" => item.head.as_ref(), | |
130 | + "options" => item.options.as_ref(), | |
131 | + "query" => item.query.as_ref(), | |
132 | + _ => None, | |
133 | + }; | |
134 | + let Some(op) = op else { continue }; | |
135 | + | |
136 | + let mut path_params = Vec::new(); | |
137 | + let mut query_params = Vec::new(); | |
138 | + let mut header_params = Vec::new(); | |
139 | + for p in &op.parameters { | |
140 | + let t = p.schema.as_ref().and_then(|s| match s { | |
141 | + justapi_core::openapi::Schema::Object(o) => o.schema_type.clone(), | |
142 | + _ => None, | |
143 | + }); | |
144 | + let arg = sanitize_ident(&p.name); | |
145 | + let param = Param { | |
146 | + original: p.name.clone(), | |
147 | + arg, | |
148 | + ty: t, | |
149 | + }; | |
150 | + match p.location { | |
151 | + ParameterLocation::Path => path_params.push(param), | |
152 | + ParameterLocation::Query => query_params.push(param), | |
153 | + ParameterLocation::Header => header_params.push(param), | |
154 | + ParameterLocation::Cookie => {} | |
155 | + } | |
156 | + } | |
157 | + | |
158 | + let name = op | |
159 | + .operation_id | |
160 | + .clone() | |
161 | + .map(|id| sanitize_ident(&id)) | |
162 | + .unwrap_or_else(|| { | |
163 | + let base = raw_path | |
164 | + .split('/') | |
165 | + .filter(|s| !s.is_empty()) | |
166 | + .map(sanitize_ident) | |
167 | + .collect::<Vec<_>>() | |
168 | + .join("_"); | |
169 | + format!("{}_{}", method, base) | |
170 | + }); | |
171 | + | |
172 | + let has_body = op.request_body.is_some(); | |
173 | + | |
174 | + ops.push(Op { | |
175 | + method: method.to_ascii_uppercase().to_string(), | |
176 | + raw_path: raw_path.clone(), | |
177 | + name, | |
178 | + summary: op.summary.clone(), | |
179 | + path_params, | |
180 | + query_params, | |
181 | + header_params, | |
182 | + has_body, | |
183 | + }); | |
184 | + } | |
185 | + } | |
186 | + | |
187 | + // De-duplicate method names (case-insensitive), appending an index on clash. | |
188 | + let mut seen: BTreeMap<String, usize> = BTreeMap::new(); | |
189 | + for op in ops.iter_mut() { | |
190 | + let key = op.name.to_ascii_lowercase(); | |
191 | + let count = seen.entry(key).or_insert(0); | |
192 | + if *count > 0 { | |
193 | + op.name = format!("{}_{}", op.name, *count); | |
194 | + } | |
195 | + *count += 1; | |
196 | + } | |
197 | + | |
198 | + ops | |
199 | +} | |
200 | + | |
201 | +fn base_url(doc: &OpenApiDocument) -> String { | |
202 | + doc.servers | |
203 | + .as_ref() | |
204 | + .and_then(|s| s.first()) | |
205 | + .map(|s| s.url.clone()) | |
206 | + .unwrap_or_else(|| "http://localhost:8080".to_string()) | |
207 | +} | |
208 | + | |
209 | +pub fn generate_client(doc: &OpenApiDocument, lang: &ClientLanguage) -> String { | |
210 | + let ops = collect_ops(doc); | |
211 | + let base = base_url(doc); | |
212 | + match lang { | |
213 | + ClientLanguage::Python => generate_python(&ops, &base), | |
214 | + ClientLanguage::Typescript => generate_typescript(&ops, &base), | |
215 | + } | |
216 | +} | |
217 | + | |
218 | +fn generate_python(ops: &[Op], base: &str) -> String { | |
219 | + let mut methods = String::new(); | |
220 | + for op in ops { | |
221 | + methods.push_str(&python_method(op)); | |
222 | + } | |
223 | + | |
224 | + format!( | |
225 | + r#""""{} | |
226 | +Auto-generated by `justapi gen client`. Do not edit by hand. | |
227 | + | |
228 | +Base URL: {} | |
229 | +""" | |
230 | +from __future__ import annotations | |
231 | + | |
232 | +from typing import Any | |
233 | + | |
234 | +import requests | |
235 | + | |
236 | + | |
237 | +class Client: | |
238 | + def __init__( | |
239 | + self, | |
240 | + base_url: str = {base:?}, | |
241 | + *, | |
242 | + timeout: float = 30.0, | |
243 | + headers: dict[str, str] | None = None, | |
244 | + ) -> None: | |
245 | + self.base_url = base_url.rstrip("/") | |
246 | + self.timeout = timeout | |
247 | + self.session = requests.Session() | |
248 | + if headers: | |
249 | + self.session.headers.update(headers) | |
250 | + | |
251 | + def _request( | |
252 | + self, | |
253 | + method: str, | |
254 | + path: str, | |
255 | + *, | |
256 | + params: dict[str, Any] | None = None, | |
257 | + json: Any | None = None, | |
258 | + data: Any | None = None, | |
259 | + headers: dict[str, str] | None = None, | |
260 | + ) -> Any: | |
261 | + url = self.base_url + path | |
262 | + resp = self.session.request( | |
263 | + method, | |
264 | + url, | |
265 | + params=params, | |
266 | + json=json, | |
267 | + data=data, | |
268 | + headers=headers, | |
269 | + timeout=self.timeout, | |
270 | + ) | |
271 | + resp.raise_for_status() | |
272 | + ctype = resp.headers.get("content-type", "") | |
273 | + if resp.content and ctype.startswith("application/json"): | |
274 | + return resp.json() | |
275 | + return resp | |
276 | + | |
277 | +{methods} | |
278 | + | |
279 | +if __name__ == "__main__": | |
280 | + client = Client() | |
281 | +"#, | |
282 | + "JustAPI generated Python client.", base | |
283 | + ) | |
284 | +} | |
285 | + | |
286 | +fn python_method(op: &Op) -> String { | |
287 | + let path = &op.raw_path; | |
288 | + let mut args: Vec<String> = Vec::new(); | |
289 | + let mut doc: Vec<String> = Vec::new(); | |
290 | + | |
291 | + for p in &op.path_params { | |
292 | + let ty = python_type(p.ty.as_deref()); | |
293 | + args.push(format!("{}: {}", p.arg, ty)); | |
294 | + } | |
295 | + for p in &op.query_params { | |
296 | + let ty = python_type(p.ty.as_deref()); | |
297 | + args.push(format!("{}: {} | None = None", p.arg, ty)); | |
298 | + } | |
299 | + for p in &op.header_params { | |
300 | + args.push(format!("{}: str | None = None", p.arg)); | |
301 | + } | |
302 | + if op.has_body { | |
303 | + args.push("body: Any | None = None".to_string()); | |
304 | + } | |
305 | + args.push("extra_headers: dict[str, str] | None = None".to_string()); | |
306 | + | |
307 | + let signature = format!(" def {}(self, {}) -> Any:", op.name, args.join(", ")); | |
308 | + | |
309 | + if let Some(summary) = &op.summary { | |
310 | + doc.push(format!(" \"\"\"{}\"\"\"", summary)); | |
311 | + } else { | |
312 | + doc.push(" \"\"\"Auto-generated endpoint call.\"\"\"".to_string()); | |
313 | + } | |
314 | + | |
315 | + // Path substitution. | |
316 | + let path_stmt = if op.path_params.is_empty() { | |
317 | + format!(" path = {}", path.quoted()) | |
318 | + } else { | |
319 | + let mut fmt_path = path.clone(); | |
320 | + let mut fmt_args: Vec<String> = Vec::new(); | |
321 | + for p in &op.path_params { | |
322 | + fmt_path = fmt_path.replace(&format!("{{{}}}", p.original), &format!("{{{}}}", p.arg)); | |
323 | + fmt_args.push(format!("{}={}", p.arg, p.arg)); | |
324 | + } | |
325 | + format!( | |
326 | + " path = {}.format({})", | |
327 | + fmt_path.quoted(), | |
328 | + fmt_args.join(", ") | |
329 | + ) | |
330 | + }; | |
331 | + | |
332 | + let mut body_lines: Vec<String> = Vec::new(); | |
333 | + if !op.query_params.is_empty() { | |
334 | + let keys: Vec<String> = op | |
335 | + .query_params | |
336 | + .iter() | |
337 | + .map(|p| format!("{:?}: {}", p.original, p.arg)) | |
338 | + .collect(); | |
339 | + body_lines.push(format!( | |
340 | + " params = {{k: v for k, v in {{{}}}.items() if v is not None}}", | |
341 | + keys.join(", ") | |
342 | + )); | |
343 | + } | |
344 | + let mut call_args: Vec<String> = Vec::new(); | |
345 | + if !op.query_params.is_empty() { | |
346 | + call_args.push("params=params".to_string()); | |
347 | + } | |
348 | + if op.has_body { | |
349 | + call_args.push("json=body".to_string()); | |
350 | + } | |
351 | + if !op.header_params.is_empty() { | |
352 | + let mut parts: Vec<String> = op | |
353 | + .header_params | |
354 | + .iter() | |
355 | + .map(|p| { | |
356 | + format!( | |
357 | + " {:?}: {} if {} is not None else None,", | |
358 | + p.original, p.arg, p.arg | |
359 | + ) | |
360 | + }) | |
361 | + .collect(); | |
362 | + parts.push(" **(extra_headers or {})".to_string()); | |
363 | + body_lines.push(format!( | |
364 | + " headers = {{\n{}\n }}", | |
365 | + parts.join("\n") | |
366 | + )); | |
367 | + } else { | |
368 | + call_args.push("headers=extra_headers".to_string()); | |
369 | + } | |
370 | + | |
371 | + let call = format!( | |
372 | + " result = self._request({}, path{})", | |
373 | + op.method.quoted(), | |
374 | + if call_args.is_empty() { | |
375 | + String::new() | |
376 | + } else { | |
377 | + format!(", {}", call_args.join(", ")) | |
378 | + } | |
379 | + ); | |
380 | + | |
381 | + let body = format!( | |
382 | + "{}\n{}\n{}\n{}\n return result\n\n", | |
383 | + doc.join("\n"), | |
384 | + path_stmt, | |
385 | + body_lines.join("\n"), | |
386 | + call, | |
387 | + ); | |
388 | + | |
389 | + format!("{}\n{}\n", signature, body) | |
390 | +} | |
391 | + | |
392 | +trait Quote { | |
393 | + fn quoted(&self) -> String; | |
394 | +} | |
395 | + | |
396 | +impl Quote for str { | |
397 | + fn quoted(&self) -> String { | |
398 | + format!("{:?}", self) | |
399 | + } | |
400 | +} | |
401 | + | |
402 | +fn generate_typescript(ops: &[Op], base: &str) -> String { | |
403 | + let mut methods = String::new(); | |
404 | + for op in ops { | |
405 | + methods.push_str(&ts_method(op)); | |
406 | + } | |
407 | + | |
408 | + format!( | |
409 | + r#"// JustAPI generated TypeScript client. | |
410 | +// Auto-generated by `justapi gen client`. Do not edit by hand. | |
411 | +// Base URL: {base} | |
412 | + | |
413 | +export interface ClientOptions {{ | |
414 | + baseUrl?: string; | |
415 | + headers?: Record<string, string>; | |
416 | + fetch?: typeof fetch; | |
417 | +}} | |
418 | + | |
419 | +export class Client {{ | |
420 | + private baseUrl: string; | |
421 | + private headers: Record<string, string>; | |
422 | + private fetcher: typeof fetch; | |
423 | + | |
424 | + constructor(opts: ClientOptions = {{}}) {{ | |
425 | + this.baseUrl = (opts.baseUrl ?? "{base}").replace(/\/$/, ""); | |
426 | + this.headers = opts.headers ?? {{}}; | |
427 | + this.fetcher = opts.fetch ?? fetch; | |
428 | + }} | |
429 | + | |
430 | + private async request<T = unknown>( | |
431 | + method: string, | |
432 | + path: string, | |
433 | + opts: {{ params?: Record<string, unknown>; body?: unknown; headers?: Record<string, string> }} = {{}}, | |
434 | + ): Promise<T> {{ | |
435 | + const url = new URL(this.baseUrl + path); | |
436 | + if (opts.params) {{ | |
437 | + for (const [k, v] of Object.entries(opts.params)) {{ | |
438 | + if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); | |
439 | + }} | |
440 | + }} | |
441 | + const resp = await this.fetcher(url.toString(), {{ | |
442 | + method, | |
443 | + headers: {{ "content-type": "application/json", ...this.headers, ...(opts.headers ?? {{}}) }}, | |
444 | + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, | |
445 | + }}); | |
446 | + if (!resp.ok) throw new Error(`Request failed: ${{resp.status}} ${{resp.statusText}}`); | |
447 | + const ctype = resp.headers.get("content-type") ?? ""; | |
448 | + if (ctype.includes("application/json")) return (await resp.json()) as T; | |
449 | + return (await resp.text()) as unknown as T; | |
450 | + }} | |
451 | + | |
452 | +{methods} | |
453 | +}} | |
454 | +"# | |
455 | + ) | |
456 | +} | |
457 | + | |
458 | +fn ts_method(op: &Op) -> String { | |
459 | + let mut args: Vec<String> = Vec::new(); | |
460 | + for p in &op.path_params { | |
461 | + let ty = ts_type(p.ty.as_deref()); | |
462 | + args.push(format!("{}: {}", p.arg, ty)); | |
463 | + } | |
464 | + for p in &op.query_params { | |
465 | + let ty = ts_type(p.ty.as_deref()); | |
466 | + args.push(format!("{}?: {}", p.arg, ty)); | |
467 | + } | |
468 | + for p in &op.header_params { | |
469 | + args.push(format!("{}?: string", p.arg)); | |
470 | + } | |
471 | + if op.has_body { | |
472 | + args.push("body?: unknown".to_string()); | |
473 | + } | |
474 | + args.push("extraHeaders?: Record<string, string>".to_string()); | |
475 | + | |
476 | + let mut body_lines: Vec<String> = Vec::new(); | |
477 | + | |
478 | + let path_stmt = if op.path_params.is_empty() { | |
479 | + format!(" const path = {};", op.raw_path.quoted()) | |
480 | + } else { | |
481 | + let repl: Vec<String> = op | |
482 | + .path_params | |
483 | + .iter() | |
484 | + .map(|p| { | |
485 | + format!( | |
486 | + "path = path.replace(`{{{}}}`, String({}));", | |
487 | + p.original, p.arg | |
488 | + ) | |
489 | + }) | |
490 | + .collect(); | |
491 | + format!( | |
492 | + " let path = {};\n {}", | |
493 | + op.raw_path.quoted(), | |
494 | + repl.join("\n ") | |
495 | + ) | |
496 | + }; | |
497 | + body_lines.push(path_stmt); | |
498 | + | |
499 | + if !op.query_params.is_empty() { | |
500 | + let obj = op | |
501 | + .query_params | |
502 | + .iter() | |
503 | + .map(|p| format!(" {:?}: {},", p.original, p.arg)) | |
504 | + .collect::<Vec<_>>() | |
505 | + .join("\n"); | |
506 | + body_lines.push(format!(" const params = {{\n{}\n }};", obj)); | |
507 | + } | |
508 | + if !op.header_params.is_empty() { | |
509 | + let obj = op | |
510 | + .header_params | |
511 | + .iter() | |
512 | + .map(|p| format!(" {:?}: {},", p.original, p.arg)) | |
513 | + .collect::<Vec<_>>() | |
514 | + .join("\n"); | |
515 | + body_lines.push(format!( | |
516 | + " const headers = {{\n{}\n ...(extraHeaders ?? {{}}),\n }};", | |
517 | + obj | |
518 | + )); | |
519 | + } | |
520 | + | |
521 | + let mut call_args: Vec<String> = Vec::new(); | |
522 | + if !op.query_params.is_empty() { | |
523 | + call_args.push("params".to_string()); | |
524 | + } | |
525 | + if op.has_body { | |
526 | + call_args.push("body".to_string()); | |
527 | + } | |
528 | + if !op.header_params.is_empty() { | |
529 | + call_args.push("headers".to_string()); | |
530 | + } else { | |
531 | + call_args.push("extraHeaders".to_string()); | |
532 | + } | |
533 | + | |
534 | + let summary = op | |
535 | + .summary | |
536 | + .clone() | |
537 | + .map(|s| format!(" /** {} */\n", s)) | |
538 | + .unwrap_or_default(); | |
539 | + | |
540 | + let call = format!( | |
541 | + " return this.request(\"{}\", path{}) as Promise<unknown>;", | |
542 | + op.method, | |
543 | + if call_args.is_empty() { | |
544 | + String::new() | |
545 | + } else { | |
546 | + format!( | |
547 | + ", {{ {} }}", | |
548 | + call_args | |
549 | + .iter() | |
550 | + .map(|a| format!("{a}: {}", a)) | |
551 | + .collect::<Vec<_>>() | |
552 | + .join(", ") | |
553 | + ) | |
554 | + } | |
555 | + ); | |
556 | + | |
557 | + format!( | |
558 | + "\n{} {}({}): Promise<unknown> {{\n{}\n{}\n }}\n", | |
559 | + summary, | |
560 | + op.name, | |
561 | + args.join(", "), | |
562 | + body_lines.join("\n"), | |
563 | + call, | |
564 | + ) | |
565 | +} | |
566 | + | |
567 | +/// Public entry point used by the CLI. | |
568 | +pub fn write_client( | |
569 | + doc: &OpenApiDocument, | |
570 | + lang: &ClientLanguage, | |
571 | + output_dir: &Path, | |
572 | +) -> anyhow::Result<std::path::PathBuf> { | |
573 | + std::fs::create_dir_all(output_dir)?; | |
574 | + let code = generate_client(doc, lang); | |
575 | + let file_name = format!("{}.{}", lang.module_name(), lang.extension()); | |
576 | + let out_path = output_dir.join(file_name); | |
577 | + std::fs::write(&out_path, code)?; | |
578 | + Ok(out_path) | |
579 | +} | |
580 | + | |
581 | +#[cfg(test)] | |
582 | +mod tests { | |
583 | + use super::*; | |
584 | + use hyper::Method; | |
585 | + use justapi_core::openapi::{ | |
586 | + OpenApiBuilder, Operation, Parameter, ParameterLocation, RequestBody, Schema, | |
587 | + }; | |
588 | + | |
589 | + fn sample_doc() -> OpenApiDocument { | |
590 | + let body_schema = Schema::Object(justapi_core::openapi::SchemaObject::object()); | |
591 | + OpenApiBuilder::new("Demo API", "1.0.0") | |
592 | + .server("https://api.example.com", Some("Prod")) | |
593 | + .operation( | |
594 | + Method::GET, | |
595 | + "/users", | |
596 | + Operation { | |
597 | + summary: Some("List users".to_string()), | |
598 | + description: None, | |
599 | + operation_id: Some("listUsers".to_string()), | |
600 | + tags: vec![], | |
601 | + parameters: vec![ | |
602 | + Parameter { | |
603 | + name: "limit".to_string(), | |
604 | + location: ParameterLocation::Query, | |
605 | + description: None, | |
606 | + required: false, | |
607 | + schema: Some(Schema::integer()), | |
608 | + }, | |
609 | + Parameter { | |
610 | + name: "X-Trace".to_string(), | |
611 | + location: ParameterLocation::Header, | |
612 | + description: None, | |
613 | + required: false, | |
614 | + schema: Some(Schema::string()), | |
615 | + }, | |
616 | + ], | |
617 | + request_body: None, | |
618 | + responses: { | |
619 | + let mut m = std::collections::BTreeMap::new(); | |
620 | + m.insert( | |
621 | + "200".to_string(), | |
622 | + justapi_core::openapi::Response { | |
623 | + description: "ok".to_string(), | |
624 | + content: None, | |
625 | + }, | |
626 | + ); | |
627 | + m | |
628 | + }, | |
629 | + deprecated: None, | |
630 | + }, | |
631 | + ) | |
632 | + .operation( | |
633 | + Method::GET, | |
634 | + "/users/{id}", | |
635 | + Operation { | |
636 | + summary: Some("Get user".to_string()), | |
637 | + description: None, | |
638 | + operation_id: Some("getUser".to_string()), | |
639 | + tags: vec![], | |
640 | + parameters: vec![Parameter { | |
641 | + name: "id".to_string(), | |
642 | + location: ParameterLocation::Path, | |
643 | + description: None, | |
644 | + required: true, | |
645 | + schema: Some(Schema::integer()), | |
646 | + }], | |
647 | + request_body: None, | |
648 | + responses: { | |
649 | + let mut m = std::collections::BTreeMap::new(); | |
650 | + m.insert( | |
651 | + "200".to_string(), | |
652 | + justapi_core::openapi::Response { | |
653 | + description: "ok".to_string(), | |
654 | + content: None, | |
655 | + }, | |
656 | + ); | |
657 | + m | |
658 | + }, | |
659 | + deprecated: None, | |
660 | + }, | |
661 | + ) | |
662 | + .operation( | |
663 | + Method::POST, | |
664 | + "/users", | |
665 | + Operation { | |
666 | + summary: Some("Create user".to_string()), | |
667 | + description: None, | |
668 | + operation_id: Some("createUser".to_string()), | |
669 | + tags: vec![], | |
670 | + parameters: vec![], | |
671 | + request_body: Some(RequestBody { | |
672 | + description: None, | |
673 | + content: { | |
674 | + let mut c = std::collections::BTreeMap::new(); | |
675 | + c.insert( | |
676 | + "application/json".to_string(), | |
677 | + justapi_core::openapi::MediaType { | |
678 | + schema: Some(body_schema), | |
679 | + }, | |
680 | + ); | |
681 | + c | |
682 | + }, | |
683 | + required: true, | |
684 | + }), | |
685 | + responses: { | |
686 | + let mut m = std::collections::BTreeMap::new(); | |
687 | + m.insert( | |
688 | + "200".to_string(), | |
689 | + justapi_core::openapi::Response { | |
690 | + description: "ok".to_string(), | |
691 | + content: None, | |
692 | + }, | |
693 | + ); | |
694 | + m | |
695 | + }, | |
696 | + deprecated: None, | |
697 | + }, | |
698 | + ) | |
699 | + .build() | |
700 | + } | |
701 | + | |
702 | + #[test] | |
703 | + fn test_sanitize_ident() { | |
704 | + assert_eq!(sanitize_ident("list_users"), "listUsers"); | |
705 | + assert_eq!(sanitize_ident("getUser"), "getUser"); | |
706 | + assert_eq!(sanitize_ident("foo-bar"), "fooBar"); | |
707 | + assert_eq!(sanitize_ident("foo bar.baz"), "fooBarBaz"); | |
708 | + assert_eq!(sanitize_ident("123"), "call"); | |
709 | + assert_eq!(sanitize_ident("X-Trace"), "XTrace"); | |
710 | + } | |
711 | + | |
712 | + #[test] | |
713 | + fn test_python_generation() { | |
714 | + let doc = sample_doc(); | |
715 | + let py = generate_client(&doc, &ClientLanguage::Python); | |
716 | + assert!(py.contains("class Client:")); | |
717 | + assert!(py.contains("def listUsers(self, limit: int | None = None")); | |
718 | + assert!(py.contains("def getUser(self, id: int")); | |
719 | + assert!(py.contains("def createUser(self, body: Any | None = None")); | |
720 | + // Header param sanitized, original name preserved in dict key. | |
721 | + assert!(py.contains("XTrace: str | None = None")); | |
722 | + assert!(py.contains("\"X-Trace\": XTrace")); | |
723 | + // Path substitution uses sanitized arg name. | |
724 | + assert!(py.contains("path = \"/users/{id}\".format(id=id)")); | |
725 | + } | |
726 | + | |
727 | + #[test] | |
728 | + fn test_typescript_generation() { | |
729 | + let doc = sample_doc(); | |
730 | + let ts = generate_client(&doc, &ClientLanguage::Typescript); | |
731 | + assert!(ts.contains("export class Client")); | |
732 | + assert!(ts.contains("getUser(id: number")); | |
733 | + assert!(ts.contains("path.replace(`{id}`, String(id))")); | |
734 | + assert!(ts.contains("XTrace?: string")); | |
735 | + assert!(ts.contains("\"X-Trace\": XTrace")); | |
736 | + } | |
737 | + | |
738 | + #[test] | |
739 | + fn test_language_parsing() { | |
740 | + assert_eq!( | |
741 | + "python".parse::<ClientLanguage>().unwrap(), | |
742 | + ClientLanguage::Python | |
743 | + ); | |
744 | + assert_eq!( | |
745 | + "ts".parse::<ClientLanguage>().unwrap(), | |
746 | + ClientLanguage::Typescript | |
747 | + ); | |
748 | + assert!("cobol".parse::<ClientLanguage>().is_err()); | |
749 | + } | |
750 | + | |
751 | + #[test] | |
752 | + fn test_roundtrip_deserialize() { | |
753 | + let json = serde_json::to_string(&sample_doc()).unwrap(); | |
754 | + let back: OpenApiDocument = serde_json::from_str(&json).unwrap(); | |
755 | + assert_eq!(back.info.title, "Demo API"); | |
756 | + assert!(back.paths.get("/users").unwrap().get.is_some()); | |
757 | + } | |
758 | + | |
759 | + #[test] | |
760 | + fn test_query_operation_generated() { | |
761 | + use justapi_core::openapi::{MediaType, Operation, RequestBody}; | |
762 | + let doc = OpenApiBuilder::new("Q", "1.0.0") | |
763 | + .operation( | |
764 | + justapi_core::query_method(), | |
765 | + "/search", | |
766 | + Operation { | |
767 | + summary: Some("Search".to_string()), | |
768 | + description: None, | |
769 | + operation_id: Some("searchItems".to_string()), | |
770 | + tags: vec![], | |
771 | + parameters: vec![], | |
772 | + request_body: Some(RequestBody { | |
773 | + description: None, | |
774 | + content: { | |
775 | + let mut c = std::collections::BTreeMap::new(); | |
776 | + c.insert( | |
777 | + "application/x-www-form-urlencoded".to_string(), | |
778 | + MediaType { | |
779 | + schema: Some(Schema::string()), | |
780 | + }, | |
781 | + ); | |
782 | + c | |
783 | + }, | |
784 | + required: true, | |
785 | + }), | |
786 | + responses: { | |
787 | + let mut m = std::collections::BTreeMap::new(); | |
788 | + m.insert( | |
789 | + "200".to_string(), | |
790 | + justapi_core::openapi::Response { | |
791 | + description: "ok".to_string(), | |
792 | + content: None, | |
793 | + }, | |
794 | + ); | |
795 | + m | |
796 | + }, | |
797 | + deprecated: None, | |
798 | + }, | |
799 | + ) | |
800 | + .build(); | |
801 | + let py = generate_client(&doc, &ClientLanguage::Python); | |
802 | + assert!(py.contains("def searchItems(self, body: Any | None = None")); | |
803 | + let ts = generate_client(&doc, &ClientLanguage::Typescript); | |
804 | + assert!(ts.contains("searchItems(body?: unknown")); | |
805 | + } | |
806 | +} | |
0 | 807 | |
▼crates/justapi-cli/src/main.rs
+875-0
| @@ -0,0 +1,875 @@ | ||
1 | +use std::net::SocketAddr; | |
2 | +use std::path::PathBuf; | |
3 | +use std::time::Duration; | |
4 | + | |
5 | +use clap::{Parser, Subcommand}; | |
6 | +use justapi_core::dx::{DiagLevel, Diagnostic}; | |
7 | +use justapi_core::error_catalog; | |
8 | +use tokio::sync::mpsc; | |
9 | +mod gen_client; | |
10 | +mod profile; | |
11 | +mod watcher; | |
12 | + | |
13 | +#[derive(Parser)] | |
14 | +#[command( | |
15 | + name = "justapi", | |
16 | + about = "JustAPI Runtime — Python application server" | |
17 | +)] | |
18 | +struct Cli { | |
19 | + #[command(subcommand)] | |
20 | + command: Commands, | |
21 | +} | |
22 | + | |
23 | +#[derive(Subcommand)] | |
24 | +enum Commands { | |
25 | + /// Start the JustAPI HTTP server | |
26 | + Serve { | |
27 | + /// Address to bind to | |
28 | + #[arg(short, long, default_value = "127.0.0.1:8080")] | |
29 | + addr: SocketAddr, | |
30 | + | |
31 | + /// Serve static files from this directory | |
32 | + #[arg(long)] | |
33 | + static_dir: Option<String>, | |
34 | + | |
35 | + /// Enable response compression (gzip) | |
36 | + #[cfg(feature = "compression")] | |
37 | + #[arg(long)] | |
38 | + compress: bool, | |
39 | + | |
40 | + /// Enable hot reload (watches for file changes and restarts) | |
41 | + #[arg(short, long)] | |
42 | + reload: bool, | |
43 | + | |
44 | + /// Directory to watch for file changes (defaults to cwd) | |
45 | + #[arg(long)] | |
46 | + watch_dir: Option<PathBuf>, | |
47 | + | |
48 | + /// Additional file extensions to watch (e.g. "html" "css") | |
49 | + #[arg(long)] | |
50 | + watch_ext: Vec<String>, | |
51 | + | |
52 | + /// Timeout in seconds to drain in-flight requests before restart | |
53 | + #[arg(long, default_value = "5")] | |
54 | + drain_timeout: u64, | |
55 | + | |
56 | + /// TLS certificate file (PEM) | |
57 | + #[cfg(feature = "tls")] | |
58 | + #[arg(long)] | |
59 | + tls_cert: Option<String>, | |
60 | + | |
61 | + /// TLS private key file (PEM) | |
62 | + #[cfg(feature = "tls")] | |
63 | + #[arg(long)] | |
64 | + tls_key: Option<String>, | |
65 | + | |
66 | + /// Serve a model via the OpenAI-compatible endpoints | |
67 | + /// (`/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, | |
68 | + /// `/v1/models`). Requires the `inference` feature. The model id is | |
69 | + /// registered as a weight-free `MockModel` (use `--features real` + | |
70 | + /// `--model-path` for real weights). | |
71 | + #[cfg(feature = "inference")] | |
72 | + #[arg(long)] | |
73 | + model: Option<String>, | |
74 | + | |
75 | + /// GPU device ordinal for `--model` (e.g. `0`). Use `cpu` for the | |
76 | + /// CPU backend (default). Requires the `inference` feature. | |
77 | + #[cfg(feature = "inference")] | |
78 | + #[arg(long, default_value = "cpu")] | |
79 | + gpu: String, | |
80 | + | |
81 | + /// Enable the scheduler-backed serving path (continuous batching + | |
82 | + /// RadixAttention prefix cache). Without this flag the engine | |
83 | + /// serves requests directly (no admission control or prefix reuse). | |
84 | + /// Requires `--model`. Requires the `inference` feature. | |
85 | + #[cfg(feature = "inference")] | |
86 | + #[arg(long)] | |
87 | + scheduled: bool, | |
88 | + | |
89 | + /// Number of KV-cache blocks for the scheduler's block pool. | |
90 | + /// Only meaningful with `--scheduled`. | |
91 | + #[cfg(feature = "inference")] | |
92 | + #[arg(long, default_value = "1024")] | |
93 | + pool_blocks: usize, | |
94 | + | |
95 | + /// Maximum number of concurrent sequences for the scheduler. | |
96 | + /// Only meaningful with `--scheduled`. | |
97 | + #[cfg(feature = "inference")] | |
98 | + #[arg(long, default_value = "256")] | |
99 | + max_seqs: usize, | |
100 | + | |
101 | + /// Number of draft tokens per speculative-decode step (`gamma`). | |
102 | + /// Requires `--model`. `>0` enables draft-target speculative decoding | |
103 | + /// (a draft model proposes `gamma` tokens, the target verifies them). | |
104 | + /// Requires the `inference` feature. | |
105 | + #[cfg(feature = "inference")] | |
106 | + #[arg(long, default_value_t = 0)] | |
107 | + gamma: usize, | |
108 | + | |
109 | + /// Tree branch factor for tree-based speculative decoding (Medusa/ | |
110 | + /// EAGLE-style). Requires `--model` and `--gamma`. `>0` enables tree | |
111 | + /// speculation: the draft proposes `branch` candidates at each of the | |
112 | + /// `gamma` positions, and the target verifies the longest matching | |
113 | + /// path (higher acceptance than single-path draft-target). | |
114 | + /// Requires the `inference` feature. | |
115 | + #[cfg(feature = "inference")] | |
116 | + #[arg(long, default_value_t = 0)] | |
117 | + branch: usize, | |
118 | + }, | |
119 | + | |
120 | + /// Database migration commands | |
121 | + #[command(subcommand)] | |
122 | + Db(DbCommands), | |
123 | + | |
124 | + /// List all registered routes | |
125 | + Routes { | |
126 | + /// File to load the OpenAPI spec from (default: built-in routes) | |
127 | + #[arg(long)] | |
128 | + spec_file: Option<PathBuf>, | |
129 | + }, | |
130 | + | |
131 | + /// Run diagnostic checks on the environment and configuration | |
132 | + Doctor { | |
133 | + /// Path to a JustAPI config file to validate | |
134 | + #[arg(short, long)] | |
135 | + config: Option<PathBuf>, | |
136 | + }, | |
137 | + | |
138 | + /// Generate OpenAPI spec file, client code, etc. | |
139 | + #[command(subcommand)] | |
140 | + Gen(GenCommands), | |
141 | + | |
142 | + /// Validate all routes without starting the server | |
143 | + Check { | |
144 | + /// Path to a routes config or app file | |
145 | + #[arg(short, long)] | |
146 | + file: Option<PathBuf>, | |
147 | + }, | |
148 | + | |
149 | + /// Project scaffolding — create a new JustAPI project | |
150 | + New { | |
151 | + /// Name of the project | |
152 | + name: String, | |
153 | + /// Output directory (defaults to project name) | |
154 | + #[arg(short, long)] | |
155 | + output: Option<PathBuf>, | |
156 | + }, | |
157 | + | |
158 | + /// Profile a running JustAPI server (built-in load test) | |
159 | + Profile { | |
160 | + /// Target server address | |
161 | + #[arg(short, long, default_value = "127.0.0.1:8080")] | |
162 | + addr: String, | |
163 | + | |
164 | + /// Profiling duration in seconds | |
165 | + #[arg(short, long, default_value = "10")] | |
166 | + duration: u64, | |
167 | + | |
168 | + /// Number of concurrent connections | |
169 | + #[arg(short, long, default_value = "50")] | |
170 | + connections: u64, | |
171 | + | |
172 | + /// Output file for the report | |
173 | + #[arg(short, long, default_value = "profile_report.txt")] | |
174 | + output: PathBuf, | |
175 | + }, | |
176 | +} | |
177 | + | |
178 | +#[derive(Subcommand)] | |
179 | +enum DbCommands { | |
180 | + /// Run pending migrations | |
181 | + Migrate { | |
182 | + /// Database connection URL | |
183 | + #[arg(short, long)] | |
184 | + url: String, | |
185 | + | |
186 | + /// Path to migrations directory | |
187 | + #[arg(short, long, default_value = "migrations")] | |
188 | + dir: PathBuf, | |
189 | + }, | |
190 | + | |
191 | + /// Roll back the most recent migration | |
192 | + Rollback { | |
193 | + /// Database connection URL | |
194 | + #[arg(short, long)] | |
195 | + url: String, | |
196 | + | |
197 | + /// Path to migrations directory | |
198 | + #[arg(short, long, default_value = "migrations")] | |
199 | + dir: PathBuf, | |
200 | + }, | |
201 | + | |
202 | + /// Initialize the migrations tracking table | |
203 | + Init { | |
204 | + /// Database connection URL | |
205 | + #[arg(short, long)] | |
206 | + url: String, | |
207 | + | |
208 | + /// Path to migrations directory | |
209 | + #[arg(short, long, default_value = "migrations")] | |
210 | + dir: PathBuf, | |
211 | + }, | |
212 | +} | |
213 | + | |
214 | +#[derive(Subcommand)] | |
215 | +enum GenCommands { | |
216 | + /// Generate and save OpenAPI 3.1 spec to a file | |
217 | + Openapi { | |
218 | + /// Output file path | |
219 | + #[arg(short, long, default_value = "openapi.json")] | |
220 | + output: PathBuf, | |
221 | + }, | |
222 | + /// Generate a typed client from an OpenAPI spec | |
223 | + Client { | |
224 | + /// Path to OpenAPI spec file | |
225 | + #[arg(short, long)] | |
226 | + spec: PathBuf, | |
227 | + /// Output directory for generated client | |
228 | + #[arg(short, long, default_value = "client")] | |
229 | + output: PathBuf, | |
230 | + /// Target language: python (default) or typescript | |
231 | + #[arg(long, default_value = "python")] | |
232 | + language: String, | |
233 | + }, | |
234 | +} | |
235 | + | |
236 | +#[tokio::main] | |
237 | +async fn main() { | |
238 | + if let Err(err) = run().await { | |
239 | + emit_rich_error(&err); | |
240 | + std::process::exit(1); | |
241 | + } | |
242 | +} | |
243 | + | |
244 | +/// Inspect an [`anyhow::Error`] and emit a rich diagnostic when possible, | |
245 | +/// falling back to a plain error message otherwise. | |
246 | +fn emit_rich_error(err: &anyhow::Error) { | |
247 | + let msg = format!("{err:#}"); | |
248 | + | |
249 | + // Try to match well-known error patterns and emit rich diagnostics. | |
250 | + let diagnostic = if msg.contains("address") && msg.contains("parse") { | |
251 | + Some(error_catalog::E001_INVALID_ADDRESS.with_context(msg.clone())) | |
252 | + } else if msg.contains("Address already in use") | |
253 | + || msg.contains("address already in use") | |
254 | + || msg.contains("AddrInUse") | |
255 | + { | |
256 | + Some(error_catalog::E002_PORT_IN_USE.with_context(msg.clone())) | |
257 | + } else if (msg.contains("certificate") || msg.contains("tls") || msg.contains("TLS")) | |
258 | + && (msg.contains("not found") || msg.contains("No such file")) | |
259 | + { | |
260 | + Some(error_catalog::E003_TLS_CERT_NOT_FOUND.with_context(msg.clone())) | |
261 | + } else if msg.contains("database") && msg.contains("URL") { | |
262 | + Some(error_catalog::E004_DB_URL_INVALID.with_context(msg.clone())) | |
263 | + } else { | |
264 | + None | |
265 | + }; | |
266 | + | |
267 | + if let Some(d) = diagnostic { | |
268 | + d.emit(); | |
269 | + } else { | |
270 | + // Fallback: still emit a styled error, just without a catalogue code. | |
271 | + let d = Diagnostic::new(DiagLevel::Error, msg); | |
272 | + d.emit(); | |
273 | + } | |
274 | +} | |
275 | + | |
276 | +async fn run() -> anyhow::Result<()> { | |
277 | + justapi_core::tracing_setup::init_tracing()?; | |
278 | + | |
279 | + let cli = Cli::parse(); | |
280 | + match cli.command { | |
281 | + Commands::Serve { | |
282 | + addr, | |
283 | + static_dir, | |
284 | + #[cfg(feature = "compression")] | |
285 | + compress, | |
286 | + reload, | |
287 | + watch_dir, | |
288 | + watch_ext, | |
289 | + drain_timeout, | |
290 | + #[cfg(feature = "tls")] | |
291 | + tls_cert, | |
292 | + #[cfg(feature = "tls")] | |
293 | + tls_key, | |
294 | + #[cfg(feature = "inference")] | |
295 | + model, | |
296 | + #[cfg(feature = "inference")] | |
297 | + gpu, | |
298 | + #[cfg(feature = "inference")] | |
299 | + scheduled, | |
300 | + #[cfg(feature = "inference")] | |
301 | + pool_blocks, | |
302 | + #[cfg(feature = "inference")] | |
303 | + max_seqs, | |
304 | + #[cfg(feature = "inference")] | |
305 | + gamma, | |
306 | + #[cfg(feature = "inference")] | |
307 | + branch, | |
308 | + } => { | |
309 | + // Set up the file watcher once (if --reload), it sends on each | |
310 | + // detected change via mpsc so we can restart multiple times. | |
311 | + let mut reload_rx = if reload { | |
312 | + let dir = watch_dir | |
313 | + .clone() | |
314 | + .unwrap_or_else(|| std::env::current_dir().expect("cannot read cwd")); | |
315 | + tracing::info!(dir = %dir.display(), "Watching for file changes"); | |
316 | + Some(watcher::spawn_file_watcher(&dir, &watch_ext)?) | |
317 | + } else { | |
318 | + None | |
319 | + }; | |
320 | + | |
321 | + let drain_duration = Duration::from_secs(drain_timeout); | |
322 | + | |
323 | + // Build the inference engine if `--model` was supplied (inference | |
324 | + // feature). The engine serves the OpenAI-compatible endpoints with a | |
325 | + // GIL-free generation thread; the scheduler provides continuous | |
326 | + // batching + paged KV cache (real weight loading is gated on `real`). | |
327 | + #[cfg(feature = "inference")] | |
328 | + let engine: Option<std::sync::Arc<justapi_inference::Engine>> = if let Some(id) = | |
329 | + model.as_ref() | |
330 | + { | |
331 | + use justapi_inference::EngineDevice; | |
332 | + let device = if gpu.eq_ignore_ascii_case("cpu") { | |
333 | + EngineDevice::Cpu | |
334 | + } else { | |
335 | + EngineDevice::Cuda(gpu.parse().unwrap_or(0)) | |
336 | + }; | |
337 | + let eng = std::sync::Arc::new( | |
338 | + justapi_inference::Engine::new(device) | |
339 | + .map_err(|e| anyhow::anyhow!("failed to start inference engine: {e}"))?, | |
340 | + ); | |
341 | + eng.register_mock(id); | |
342 | + | |
343 | + // Wire speculative decoding if requested. | |
344 | + if gamma > 0 || branch > 0 { | |
345 | + let target = eng.get(id).unwrap(); | |
346 | + let draft_id = format!("{id}-draft"); | |
347 | + let draft = eng.register_mock(&draft_id); | |
348 | + if branch > 0 { | |
349 | + let g = gamma.max(1); | |
350 | + eng.register_tree_speculative(id, target, draft, g, branch, 0); | |
351 | + tracing::info!( | |
352 | + model = %id, | |
353 | + device = %gpu, | |
354 | + gamma = g, | |
355 | + branch = branch, | |
356 | + "Serving model via OpenAI-compatible endpoints (tree speculative decoding)" | |
357 | + ); | |
358 | + } else { | |
359 | + eng.register_speculative(id, target, draft, gamma, 0); | |
360 | + tracing::info!( | |
361 | + model = %id, | |
362 | + device = %gpu, | |
363 | + gamma = gamma, | |
364 | + "Serving model via OpenAI-compatible endpoints (draft-target speculative decoding)" | |
365 | + ); | |
366 | + } | |
367 | + } else { | |
368 | + tracing::info!(model = %id, device = %gpu, "Serving model via OpenAI-compatible endpoints"); | |
369 | + } | |
370 | + Some(eng) | |
371 | + } else { | |
372 | + None | |
373 | + }; | |
374 | + | |
375 | + // Build the scheduler engine if `--scheduled` is also set. | |
376 | + #[cfg(feature = "inference")] | |
377 | + let scheduler_engine: Option< | |
378 | + std::sync::Arc<justapi_inference::SchedulerEngine>, | |
379 | + > = if scheduled && engine.is_some() { | |
380 | + use justapi_inference::{KvBlockPool, Scheduler, SchedulerConfig, SchedulerEngine}; | |
381 | + let eng = engine.as_ref().unwrap().clone(); | |
382 | + let pool = KvBlockPool::new(pool_blocks.max(64)); | |
383 | + let config = SchedulerConfig { | |
384 | + max_num_seqs: max_seqs.max(1), | |
385 | + ..Default::default() | |
386 | + }; | |
387 | + let scheduler = | |
388 | + std::sync::Arc::new(std::sync::Mutex::new(Scheduler::new(config, pool))); | |
389 | + let se = std::sync::Arc::new(SchedulerEngine::new(eng, scheduler)); | |
390 | + tracing::info!( | |
391 | + pool_blocks = pool_blocks, | |
392 | + max_seqs = max_seqs, | |
393 | + "Scheduler-enabled serving path" | |
394 | + ); | |
395 | + Some(se) | |
396 | + } else { | |
397 | + None | |
398 | + }; | |
399 | + | |
400 | + loop { | |
401 | + let token = tokio_util::sync::CancellationToken::new(); | |
402 | + | |
403 | + // Wire Ctrl+C to graceful shutdown. | |
404 | + let sig_token = token.clone(); | |
405 | + let is_ctrl_c = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); | |
406 | + let ctrl_c_flag = is_ctrl_c.clone(); | |
407 | + tokio::spawn(async move { | |
408 | + tokio::signal::ctrl_c().await.ok(); | |
409 | + tracing::info!("Shutdown signal received"); | |
410 | + ctrl_c_flag.store(true, std::sync::atomic::Ordering::SeqCst); | |
411 | + sig_token.cancel(); | |
412 | + }); | |
413 | + | |
414 | + // If reload is enabled, wait for the watcher to signal a | |
415 | + // file change and then cancel the server token. | |
416 | + if let Some(ref mut rx) = reload_rx { | |
417 | + let cancel = token.clone(); | |
418 | + // We need to take the receiver into the task, but we also | |
419 | + // need it back afterwards. Use a oneshot to shuttle it. | |
420 | + let (return_tx, return_rx) = | |
421 | + tokio::sync::oneshot::channel::<mpsc::Receiver<()>>(); | |
422 | + let mut taken_rx = None; | |
423 | + std::mem::swap(rx, taken_rx.get_or_insert(mpsc::channel(1).1)); | |
424 | + let mut inner_rx = taken_rx.unwrap(); | |
425 | + | |
426 | + tokio::spawn(async move { | |
427 | + // Wait for either a file change or the token being | |
428 | + // cancelled (e.g. Ctrl+C). | |
429 | + tokio::select! { | |
430 | + Some(()) = inner_rx.recv() => { | |
431 | + cancel.cancel(); | |
432 | + } | |
433 | + _ = cancel.cancelled() => {} | |
434 | + } | |
435 | + // Return the receiver so the outer loop can reuse it. | |
436 | + return_tx.send(inner_rx).ok(); | |
437 | + }); | |
438 | + | |
439 | + // We'll collect the receiver back after `server.run()` completes. | |
440 | + // Stash the return channel for later. | |
441 | + // (We use a scope trick: store it in the reload_rx option.) | |
442 | + // Actually we need the return_rx after the server stops, | |
443 | + // so we hold it outside and await it after run(). | |
444 | + // Re-structure: keep return_rx in a variable. | |
445 | + let mut server = justapi_core::Server::new(addr).with_shutdown(token.clone()); | |
446 | + | |
447 | + if let Some(dir) = static_dir.clone() { | |
448 | + server = server.with_static_dir(dir); | |
449 | + } | |
450 | + | |
451 | + #[cfg(feature = "compression")] | |
452 | + if compress { | |
453 | + server = server.add_compression(); | |
454 | + } | |
455 | + | |
456 | + #[cfg(feature = "inference")] | |
457 | + if let Some(ref se) = scheduler_engine { | |
458 | + server = server.with_openai_scheduled(se.clone()); | |
459 | + } else if let Some(ref eng) = engine { | |
460 | + server = server.with_openai(eng.clone()); | |
461 | + } | |
462 | + | |
463 | + #[cfg(feature = "tls")] | |
464 | + if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) { | |
465 | + let config = justapi_core::server::TlsConfig { | |
466 | + cert_path: cert, | |
467 | + key_path: key, | |
468 | + }; | |
469 | + let result = server.with_tls(config).run().await; | |
470 | + justapi_core::tracing_setup::shutdown_tracing(); | |
471 | + return result; | |
472 | + } | |
473 | + | |
474 | + let result = server.run().await; | |
475 | + | |
476 | + // Recover the watcher receiver for the next iteration. | |
477 | + if let Ok(recovered) = return_rx.await { | |
478 | + *rx = recovered; | |
479 | + } | |
480 | + | |
481 | + // If the server stopped because of Ctrl+C, exit cleanly. | |
482 | + if is_ctrl_c.load(std::sync::atomic::Ordering::SeqCst) { | |
483 | + justapi_core::tracing_setup::shutdown_tracing(); | |
484 | + return result; | |
485 | + } | |
486 | + | |
487 | + // File-change triggered reload: drain in-flight requests. | |
488 | + tracing::info!( | |
489 | + drain_secs = drain_timeout, | |
490 | + "Server stopped, draining in-flight requests" | |
491 | + ); | |
492 | + tokio::time::sleep(drain_duration).await; | |
493 | + tracing::info!("Restarting server..."); | |
494 | + continue; | |
495 | + } | |
496 | + | |
497 | + // Non-reload path: run server once and exit. | |
498 | + let mut server = justapi_core::Server::new(addr).with_shutdown(token); | |
499 | + | |
500 | + if let Some(dir) = static_dir.clone() { | |
501 | + server = server.with_static_dir(dir); | |
502 | + } | |
503 | + | |
504 | + #[cfg(feature = "compression")] | |
505 | + if compress { | |
506 | + server = server.add_compression(); | |
507 | + } | |
508 | + | |
509 | + #[cfg(feature = "inference")] | |
510 | + if let Some(ref se) = scheduler_engine { | |
511 | + server = server.with_openai_scheduled(se.clone()); | |
512 | + } else if let Some(ref eng) = engine { | |
513 | + server = server.with_openai(eng.clone()); | |
514 | + } | |
515 | + | |
516 | + #[cfg(feature = "tls")] | |
517 | + if let (Some(cert), Some(key)) = (tls_cert.clone(), tls_key.clone()) { | |
518 | + let config = justapi_core::server::TlsConfig { | |
519 | + cert_path: cert, | |
520 | + key_path: key, | |
521 | + }; | |
522 | + let result = server.with_tls(config).run().await; | |
523 | + justapi_core::tracing_setup::shutdown_tracing(); | |
524 | + return result; | |
525 | + } | |
526 | + | |
527 | + let result = server.run().await; | |
528 | + justapi_core::tracing_setup::shutdown_tracing(); | |
529 | + return result; | |
530 | + } | |
531 | + } | |
532 | + Commands::Db(db_cmd) => match db_cmd { | |
533 | + DbCommands::Migrate { url, dir } => { | |
534 | + if !dir.exists() { | |
535 | + anyhow::bail!("Migrations directory does not exist: {:?}", dir); | |
536 | + } | |
537 | + let config = justapi_core::db::DatabaseConfig { | |
538 | + url, | |
539 | + max_connections: 1, | |
540 | + kind: None, | |
541 | + }; | |
542 | + let mut mgr = justapi_core::db::PoolManager::new(); | |
543 | + let pool = mgr.init("", config).await?; | |
544 | + | |
545 | + let mut migrator = justapi_core::db::Migrator::new(); | |
546 | + migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?; | |
547 | + let applied = migrator.run(&pool).await.map_err(|e| anyhow::anyhow!(e))?; | |
548 | + tracing::info!("Applied {} migration(s)", applied.len()); | |
549 | + for m in &applied { | |
550 | + tracing::info!(" v{}: {}", m.version, m.name); | |
551 | + } | |
552 | + Ok(()) | |
553 | + } | |
554 | + DbCommands::Rollback { url, dir } => { | |
555 | + if !dir.exists() { | |
556 | + anyhow::bail!("Migrations directory does not exist: {:?}", dir); | |
557 | + } | |
558 | + let config = justapi_core::db::DatabaseConfig { | |
559 | + url, | |
560 | + max_connections: 1, | |
561 | + kind: None, | |
562 | + }; | |
563 | + let mut mgr = justapi_core::db::PoolManager::new(); | |
564 | + let pool = mgr.init("", config).await?; | |
565 | + | |
566 | + let mut migrator = justapi_core::db::Migrator::new(); | |
567 | + migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?; | |
568 | + migrator | |
569 | + .rollback_one(&pool) | |
570 | + .await | |
571 | + .map_err(|e| anyhow::anyhow!(e))?; | |
572 | + tracing::info!("Rolled back most recent migration"); | |
573 | + Ok(()) | |
574 | + } | |
575 | + DbCommands::Init { url, dir } => { | |
576 | + let config = justapi_core::db::DatabaseConfig { | |
577 | + url, | |
578 | + max_connections: 1, | |
579 | + kind: None, | |
580 | + }; | |
581 | + let mut mgr = justapi_core::db::PoolManager::new(); | |
582 | + let pool = mgr.init("", config).await?; | |
583 | + | |
584 | + let mut migrator = justapi_core::db::Migrator::new(); | |
585 | + if dir.exists() { | |
586 | + migrator.discover(&dir).map_err(|e| anyhow::anyhow!(e))?; | |
587 | + } | |
588 | + migrator | |
589 | + .ensure_tracking_table(&pool) | |
590 | + .await | |
591 | + .map_err(|e| anyhow::anyhow!(e))?; | |
592 | + tracing::info!("Migrations tracking table initialized"); | |
593 | + Ok(()) | |
594 | + } | |
595 | + }, | |
596 | + Commands::Routes { spec_file } => { | |
597 | + if let Some(path) = spec_file { | |
598 | + let spec = std::fs::read_to_string(&path) | |
599 | + .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", path.display(), e))?; | |
600 | + let doc: serde_json::Value = serde_json::from_str(&spec) | |
601 | + .map_err(|e| anyhow::anyhow!("Invalid OpenAPI spec: {}", e))?; | |
602 | + let paths = doc | |
603 | + .get("paths") | |
604 | + .and_then(|p| p.as_object()) | |
605 | + .map(|p| p.len()) | |
606 | + .unwrap_or(0); | |
607 | + println!("Loaded OpenAPI spec with {} path(s)", paths); | |
608 | + if let Some(paths_obj) = doc.get("paths").and_then(|p| p.as_object()) { | |
609 | + for (path, item) in paths_obj { | |
610 | + if let Some(obj) = item.as_object() { | |
611 | + for method in | |
612 | + ["get", "post", "put", "delete", "patch", "head", "options"] | |
613 | + { | |
614 | + if obj.contains_key(method) { | |
615 | + println!(" {:>6} {}", method.to_uppercase(), path); | |
616 | + } | |
617 | + } | |
618 | + } | |
619 | + } | |
620 | + } | |
621 | + } else { | |
622 | + let router = justapi_core::Server::new("127.0.0.1:0".parse().unwrap()); | |
623 | + let _ = router; | |
624 | + println!("Use --spec-file to load an OpenAPI spec for route listing."); | |
625 | + println!("For built-in routes, access /openapi.json on a running server."); | |
626 | + } | |
627 | + Ok(()) | |
628 | + } | |
629 | + Commands::Doctor { config } => { | |
630 | + Diagnostic::new(DiagLevel::Info, "JustAPI Doctor").emit(); | |
631 | + println!("=================="); | |
632 | + | |
633 | + // Check Rust version | |
634 | + Diagnostic::new(DiagLevel::Hint, "Rust toolchain available").emit(); | |
635 | + | |
636 | + // Check Python availability | |
637 | + let py_ok = std::process::Command::new("python3") | |
638 | + .arg("--version") | |
639 | + .output() | |
640 | + .is_ok(); | |
641 | + if py_ok { | |
642 | + Diagnostic::new(DiagLevel::Hint, "Python 3 detected").emit(); | |
643 | + } else { | |
644 | + error_catalog::E006_PYTHON_NOT_FOUND | |
645 | + .into_diagnostic() | |
646 | + .emit(); | |
647 | + } | |
648 | + | |
649 | + // Check config file | |
650 | + if let Some(path) = config { | |
651 | + if path.exists() { | |
652 | + Diagnostic::new( | |
653 | + DiagLevel::Hint, | |
654 | + format!("Config file found: {}", path.display()), | |
655 | + ) | |
656 | + .emit(); | |
657 | + } else { | |
658 | + error_catalog::E008_INVALID_CONFIG | |
659 | + .with_context(format!("{}", path.display())) | |
660 | + .emit(); | |
661 | + } | |
662 | + } else { | |
663 | + Diagnostic::new( | |
664 | + DiagLevel::Info, | |
665 | + "No config file specified (use --config to check one)", | |
666 | + ) | |
667 | + .emit(); | |
668 | + } | |
669 | + | |
670 | + // Check migrations directory | |
671 | + let migrations_dir = PathBuf::from("migrations"); | |
672 | + if migrations_dir.exists() { | |
673 | + Diagnostic::new(DiagLevel::Hint, "Migrations directory exists").emit(); | |
674 | + } else { | |
675 | + Diagnostic::new( | |
676 | + DiagLevel::Info, | |
677 | + "No migrations directory (create one with `justapi db init`)", | |
678 | + ) | |
679 | + .emit(); | |
680 | + } | |
681 | + | |
682 | + // Check .env | |
683 | + let env_path = PathBuf::from(".env"); | |
684 | + if env_path.exists() { | |
685 | + Diagnostic::new(DiagLevel::Hint, ".env file found").emit(); | |
686 | + } else { | |
687 | + Diagnostic::new( | |
688 | + DiagLevel::Warning, | |
689 | + "No .env file (recommended for configuration)", | |
690 | + ) | |
691 | + .emit(); | |
692 | + } | |
693 | + | |
694 | + // Check Dockerfile | |
695 | + let dockerfile = PathBuf::from("Dockerfile"); | |
696 | + if dockerfile.exists() { | |
697 | + Diagnostic::new(DiagLevel::Hint, "Dockerfile found").emit(); | |
698 | + } else { | |
699 | + Diagnostic::new( | |
700 | + DiagLevel::Info, | |
701 | + "No Dockerfile (use `justapi new` to create one)", | |
702 | + ) | |
703 | + .emit(); | |
704 | + } | |
705 | + | |
706 | + println!(); | |
707 | + Diagnostic::new(DiagLevel::Info, "Doctor check complete.").emit(); | |
708 | + Ok(()) | |
709 | + } | |
710 | + Commands::Gen(gen_cmd) => match gen_cmd { | |
711 | + GenCommands::Openapi { output } => { | |
712 | + let doc = justapi_core::openapi::OpenApiBuilder::new("JustAPI", "0.1.0") | |
713 | + .description("Generated by justapi gen openapi") | |
714 | + .server("http://localhost:8080", Some("Local development")) | |
715 | + .tag("default", Some("Default endpoints")) | |
716 | + .build(); | |
717 | + let json = serde_json::to_string_pretty(&doc)?; | |
718 | + std::fs::write(&output, &json)?; | |
719 | + println!("OpenAPI spec written to {}", output.display()); | |
720 | + Ok(()) | |
721 | + } | |
722 | + GenCommands::Client { | |
723 | + spec, | |
724 | + output, | |
725 | + language, | |
726 | + } => { | |
727 | + let lang: gen_client::ClientLanguage = language.parse()?; | |
728 | + let json = std::fs::read_to_string(&spec) | |
729 | + .map_err(|e| anyhow::anyhow!("Cannot read spec {}: {}", spec.display(), e))?; | |
730 | + let doc: justapi_core::openapi::OpenApiDocument = serde_json::from_str(&json) | |
731 | + .map_err(|e| anyhow::anyhow!("Invalid OpenAPI spec: {}", e))?; | |
732 | + let out_path = gen_client::write_client(&doc, &lang, &output)?; | |
733 | + println!( | |
734 | + "{} client written to {}", | |
735 | + match lang { | |
736 | + gen_client::ClientLanguage::Python => "Python", | |
737 | + gen_client::ClientLanguage::Typescript => "TypeScript", | |
738 | + }, | |
739 | + out_path.display() | |
740 | + ); | |
741 | + Ok(()) | |
742 | + } | |
743 | + }, | |
744 | + Commands::Check { file } => { | |
745 | + if let Some(path) = file { | |
746 | + if path.exists() { | |
747 | + println!("[✓] File found: {}", path.display()); | |
748 | + } else { | |
749 | + anyhow::bail!("File not found: {}", path.display()); | |
750 | + } | |
751 | + } | |
752 | + println!("[✓] Route validation passed (no routes to validate)"); | |
753 | + Ok(()) | |
754 | + } | |
755 | + Commands::New { name, output } => { | |
756 | + let project_dir = output.unwrap_or_else(|| PathBuf::from(&name)); | |
757 | + if project_dir.exists() { | |
758 | + anyhow::bail!("Directory '{}' already exists", project_dir.display()); | |
759 | + } | |
760 | + std::fs::create_dir_all(&project_dir)?; | |
761 | + std::fs::create_dir_all(project_dir.join("app"))?; | |
762 | + std::fs::create_dir_all(project_dir.join("migrations"))?; | |
763 | + std::fs::create_dir_all(project_dir.join("static"))?; | |
764 | + | |
765 | + // Create main.py | |
766 | + let main_py = format!( | |
767 | + r#""""JustAPI application — {} | |
768 | +""" | |
769 | + | |
770 | +from justapi import JustAPIApp | |
771 | + | |
772 | +app = JustAPIApp() | |
773 | + | |
774 | + | |
775 | +@app.get("/") | |
776 | +async def root(): | |
777 | + return {{"message": "Hello from {}!"}} | |
778 | + | |
779 | + | |
780 | +@app.get("/health") | |
781 | +async def health(): | |
782 | + return {{"status": "healthy"}} | |
783 | + | |
784 | + | |
785 | +if __name__ == "__main__": | |
786 | + app.run() | |
787 | +"#, | |
788 | + name, name | |
789 | + ); | |
790 | + std::fs::write(project_dir.join("app").join("__init__.py"), "")?; | |
791 | + std::fs::write(project_dir.join("app").join("main.py"), &main_py)?; | |
792 | + | |
793 | + // Create .env | |
794 | + let env = format!( | |
795 | + r#"# {} configuration | |
796 | +HOST=127.0.0.1 | |
797 | +PORT=8080 | |
798 | +# DATABASE_URL=postgres://user:pass@localhost:5432/{} | |
799 | +# SECRET_KEY=change-me | |
800 | +"#, | |
801 | + name, name | |
802 | + ); | |
803 | + std::fs::write(project_dir.join(".env"), &env)?; | |
804 | + | |
805 | + // Create requirements.txt | |
806 | + std::fs::write( | |
807 | + project_dir.join("requirements.txt"), | |
808 | + "# Add your Python dependencies here\n# justapi is pre-installed with the runtime\n", | |
809 | + )?; | |
810 | + | |
811 | + // Create README.md | |
812 | + let readme = format!( | |
813 | + r#"# {} | |
814 | + | |
815 | +A JustAPI application. | |
816 | + | |
817 | +## Quick start | |
818 | + | |
819 | +```bash | |
820 | +justapi serve | |
821 | +``` | |
822 | + | |
823 | +## Development | |
824 | + | |
825 | +```bash | |
826 | +# Install dependencies | |
827 | +pip install -r requirements.txt | |
828 | + | |
829 | +# Run migrations | |
830 | +justapi db migrate --url postgres://localhost/{} | |
831 | + | |
832 | +# Start server with hot reload | |
833 | +justapi serve --reload | |
834 | +``` | |
835 | +"#, | |
836 | + name, name | |
837 | + ); | |
838 | + std::fs::write(project_dir.join("README.md"), &readme)?; | |
839 | + | |
840 | + // Create .gitignore | |
841 | + std::fs::write( | |
842 | + project_dir.join(".gitignore"), | |
843 | + "*.pyc\n__pycache__/\n.env\n*.egg-info/\ndist/\nbuild/\n", | |
844 | + )?; | |
845 | + | |
846 | + println!("✅ Created new JustAPI project '{}'", name); | |
847 | + println!(); | |
848 | + println!(" cd {}", project_dir.display()); | |
849 | + println!(" justapi serve"); | |
850 | + | |
851 | + Ok(()) | |
852 | + } | |
853 | + Commands::Profile { | |
854 | + addr, | |
855 | + duration, | |
856 | + connections, | |
857 | + output, | |
858 | + } => { | |
859 | + println!("🔬 JustAPI Profiler"); | |
860 | + println!(" Target: {addr}"); | |
861 | + println!(" Duration: {duration}s"); | |
862 | + println!(" Connections: {connections}"); | |
863 | + println!(" Output: {}\n", output.display()); | |
864 | + | |
865 | + let report = profile::run_profile(&addr, duration, connections).await?; | |
866 | + let text = profile::format_report(&report); | |
867 | + print!("{text}"); | |
868 | + | |
869 | + profile::save_report(&text, &output)?; | |
870 | + println!("\n Report saved to {}", output.display()); | |
871 | + | |
872 | + Ok(()) | |
873 | + } | |
874 | + } | |
875 | +} | |
0 | 876 | |
▼crates/justapi-cli/src/profile.rs
+338-0
| @@ -0,0 +1,338 @@ | ||
1 | +use std::collections::HashMap; | |
2 | +use std::io::Write; | |
3 | +use std::path::Path; | |
4 | +use std::time::{Duration, Instant}; | |
5 | + | |
6 | +use tokio::io::{AsyncReadExt, AsyncWriteExt}; | |
7 | +use tokio::net::TcpStream; | |
8 | + | |
9 | +/// Result of a single HTTP request. | |
10 | +struct RequestResult { | |
11 | + latency: Duration, | |
12 | + status: u16, | |
13 | +} | |
14 | + | |
15 | +/// Aggregated profiling report. | |
16 | +pub struct ProfileReport { | |
17 | + pub total_requests: u64, | |
18 | + pub duration: Duration, | |
19 | + pub latencies: Vec<Duration>, | |
20 | + pub status_counts: HashMap<u16, u64>, | |
21 | +} | |
22 | + | |
23 | +/// Unicode block characters for histogram rendering, from thinnest to full block. | |
24 | +const BLOCKS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█']; | |
25 | + | |
26 | +/// Run the profiler against `addr` with the given concurrency and duration. | |
27 | +pub async fn run_profile( | |
28 | + addr: &str, | |
29 | + duration_secs: u64, | |
30 | + connections: u64, | |
31 | +) -> anyhow::Result<ProfileReport> { | |
32 | + let deadline = Duration::from_secs(duration_secs); | |
33 | + | |
34 | + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<RequestResult>(); | |
35 | + | |
36 | + for _ in 0..connections { | |
37 | + let addr = addr.to_string(); | |
38 | + let tx = tx.clone(); | |
39 | + tokio::spawn(async move { | |
40 | + let start = Instant::now(); | |
41 | + while start.elapsed() < deadline { | |
42 | + if let Some(result) = send_request(&addr).await { | |
43 | + if tx.send(result).is_err() { | |
44 | + break; | |
45 | + } | |
46 | + } | |
47 | + } | |
48 | + }); | |
49 | + } | |
50 | + // Drop the original sender so `rx` will close once all tasks finish. | |
51 | + drop(tx); | |
52 | + | |
53 | + let mut latencies = Vec::new(); | |
54 | + let mut status_counts: HashMap<u16, u64> = HashMap::new(); | |
55 | + | |
56 | + while let Some(result) = rx.recv().await { | |
57 | + latencies.push(result.latency); | |
58 | + *status_counts.entry(result.status).or_insert(0) += 1; | |
59 | + } | |
60 | + | |
61 | + Ok(ProfileReport { | |
62 | + total_requests: latencies.len() as u64, | |
63 | + duration: deadline, | |
64 | + latencies, | |
65 | + status_counts, | |
66 | + }) | |
67 | +} | |
68 | + | |
69 | +/// Send a single raw HTTP/1.1 GET / request and return timing + status code. | |
70 | +async fn send_request(addr: &str) -> Option<RequestResult> { | |
71 | + let start = Instant::now(); | |
72 | + let mut stream = TcpStream::connect(addr).await.ok()?; | |
73 | + | |
74 | + let request = format!( | |
75 | + "GET / HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", | |
76 | + addr | |
77 | + ); | |
78 | + stream.write_all(request.as_bytes()).await.ok()?; | |
79 | + | |
80 | + let mut buf = vec![0u8; 4096]; | |
81 | + let n = stream.read(&mut buf).await.ok()?; | |
82 | + let latency = start.elapsed(); | |
83 | + | |
84 | + if n == 0 { | |
85 | + return None; | |
86 | + } | |
87 | + | |
88 | + let response = String::from_utf8_lossy(&buf[..n]); | |
89 | + let status = parse_status_code(&response).unwrap_or(0); | |
90 | + | |
91 | + Some(RequestResult { latency, status }) | |
92 | +} | |
93 | + | |
94 | +/// Parse the HTTP status code from the first line of the response (e.g. "HTTP/1.1 200 OK"). | |
95 | +fn parse_status_code(response: &str) -> Option<u16> { | |
96 | + let first_line = response.lines().next()?; | |
97 | + let mut parts = first_line.split_whitespace(); | |
98 | + parts.next()?; // skip "HTTP/1.1" | |
99 | + parts.next()?.parse().ok() | |
100 | +} | |
101 | + | |
102 | +/// Compute the value at the given percentile from a **sorted** slice of durations. | |
103 | +/// | |
104 | +/// Uses the nearest-rank method: the index is `ceil(p/100 * N) - 1`, clamped to | |
105 | +/// valid bounds. Returns `Duration::ZERO` for an empty slice. | |
106 | +pub fn percentile(sorted: &[Duration], p: f64) -> Duration { | |
107 | + if sorted.is_empty() { | |
108 | + return Duration::ZERO; | |
109 | + } | |
110 | + let rank = (p / 100.0 * sorted.len() as f64).ceil() as usize; | |
111 | + let idx = rank.saturating_sub(1).min(sorted.len() - 1); | |
112 | + sorted[idx] | |
113 | +} | |
114 | + | |
115 | +/// Render a text-based latency histogram. | |
116 | +/// | |
117 | +/// Divides the latency range into `bucket_count` equal-width buckets and draws | |
118 | +/// a horizontal bar for each bucket using Unicode block characters. | |
119 | +/// | |
120 | +/// The bars are scaled so that the bucket with the most samples fills | |
121 | +/// `max_bar_width` character cells. | |
122 | +pub fn render_histogram( | |
123 | + latencies: &[Duration], | |
124 | + bucket_count: usize, | |
125 | + max_bar_width: usize, | |
126 | +) -> String { | |
127 | + if latencies.is_empty() || bucket_count == 0 { | |
128 | + return String::from(" (no data)\n"); | |
129 | + } | |
130 | + | |
131 | + let min_us = latencies.iter().map(|d| d.as_micros()).min().unwrap_or(0); | |
132 | + let max_us = latencies.iter().map(|d| d.as_micros()).max().unwrap_or(0); | |
133 | + | |
134 | + // Avoid division by zero when all latencies are identical. | |
135 | + let range = if max_us == min_us { 1 } else { max_us - min_us }; | |
136 | + let bucket_width = (range as f64 / bucket_count as f64).ceil() as u128; | |
137 | + let bucket_width = bucket_width.max(1); | |
138 | + | |
139 | + let mut buckets = vec![0u64; bucket_count]; | |
140 | + for d in latencies { | |
141 | + let us = d.as_micros(); | |
142 | + let idx = ((us - min_us) / bucket_width) as usize; | |
143 | + let idx = idx.min(bucket_count - 1); | |
144 | + buckets[idx] += 1; | |
145 | + } | |
146 | + | |
147 | + let max_count = *buckets.iter().max().unwrap_or(&1); | |
148 | + let max_count = max_count.max(1); | |
149 | + | |
150 | + let mut out = String::new(); | |
151 | + for (i, &count) in buckets.iter().enumerate() { | |
152 | + let lo = min_us + (i as u128) * bucket_width; | |
153 | + let hi = lo + bucket_width; | |
154 | + let bar = build_bar(count, max_count, max_bar_width); | |
155 | + out.push_str(&format!( | |
156 | + " {:>8.1}ms - {:>8.1}ms │{} ({count})\n", | |
157 | + lo as f64 / 1000.0, | |
158 | + hi as f64 / 1000.0, | |
159 | + bar, | |
160 | + )); | |
161 | + } | |
162 | + out | |
163 | +} | |
164 | + | |
165 | +/// Build a single bar string of the histogram. | |
166 | +/// | |
167 | +/// Uses full-block characters for the integer part and a fractional sub-block | |
168 | +/// for the remainder. | |
169 | +fn build_bar(count: u64, max_count: u64, max_width: usize) -> String { | |
170 | + let fraction = count as f64 / max_count as f64; | |
171 | + let total_eighths = (fraction * max_width as f64 * 8.0).round() as usize; | |
172 | + let full_blocks = total_eighths / 8; | |
173 | + let remainder = total_eighths % 8; | |
174 | + | |
175 | + let mut bar = String::with_capacity(full_blocks + 1); | |
176 | + for _ in 0..full_blocks { | |
177 | + bar.push(BLOCKS[7]); | |
178 | + } | |
179 | + if remainder > 0 { | |
180 | + bar.push(BLOCKS[remainder - 1]); | |
181 | + } | |
182 | + bar | |
183 | +} | |
184 | + | |
185 | +/// Format the profile report as a human-readable string. | |
186 | +pub fn format_report(report: &ProfileReport) -> String { | |
187 | + let mut out = String::new(); | |
188 | + out.push_str("╔══════════════════════════════════════════════════╗\n"); | |
189 | + out.push_str("║ JustAPI Profile Report ║\n"); | |
190 | + out.push_str("╚══════════════════════════════════════════════════╝\n\n"); | |
191 | + | |
192 | + let rps = if report.duration.as_secs_f64() > 0.0 { | |
193 | + report.total_requests as f64 / report.duration.as_secs_f64() | |
194 | + } else { | |
195 | + 0.0 | |
196 | + }; | |
197 | + | |
198 | + out.push_str(&format!( | |
199 | + " Duration: {:.1}s\n", | |
200 | + report.duration.as_secs_f64() | |
201 | + )); | |
202 | + out.push_str(&format!(" Total requests: {}\n", report.total_requests)); | |
203 | + out.push_str(&format!(" Requests/sec: {rps:.1}\n\n")); | |
204 | + | |
205 | + if !report.latencies.is_empty() { | |
206 | + let mut sorted = report.latencies.clone(); | |
207 | + sorted.sort(); | |
208 | + | |
209 | + let p50 = percentile(&sorted, 50.0); | |
210 | + let p95 = percentile(&sorted, 95.0); | |
211 | + let p99 = percentile(&sorted, 99.0); | |
212 | + | |
213 | + out.push_str(" Latency percentiles:\n"); | |
214 | + out.push_str(&format!(" p50: {:.3}ms\n", p50.as_secs_f64() * 1000.0)); | |
215 | + out.push_str(&format!(" p95: {:.3}ms\n", p95.as_secs_f64() * 1000.0)); | |
216 | + out.push_str(&format!( | |
217 | + " p99: {:.3}ms\n\n", | |
218 | + p99.as_secs_f64() * 1000.0 | |
219 | + )); | |
220 | + | |
221 | + out.push_str(" Latency histogram:\n"); | |
222 | + out.push_str(&render_histogram(&sorted, 10, 30)); | |
223 | + out.push('\n'); | |
224 | + } else { | |
225 | + out.push_str(" (no successful requests)\n\n"); | |
226 | + } | |
227 | + | |
228 | + out.push_str(" Status code distribution:\n"); | |
229 | + let mut codes: Vec<_> = report.status_counts.iter().collect(); | |
230 | + codes.sort_by_key(|(code, _)| *code); | |
231 | + for (&code, &count) in &codes { | |
232 | + out.push_str(&format!(" HTTP {code}: {count}\n")); | |
233 | + } | |
234 | + | |
235 | + out | |
236 | +} | |
237 | + | |
238 | +/// Write the report to a file. | |
239 | +pub fn save_report(report_text: &str, path: &Path) -> anyhow::Result<()> { | |
240 | + let mut f = std::fs::File::create(path)?; | |
241 | + f.write_all(report_text.as_bytes())?; | |
242 | + Ok(()) | |
243 | +} | |
244 | + | |
245 | +#[cfg(test)] | |
246 | +mod tests { | |
247 | + use super::*; | |
248 | + | |
249 | + #[test] | |
250 | + fn test_percentile_basic() { | |
251 | + // 10 sorted values: 1ms, 2ms, ... 10ms | |
252 | + let latencies: Vec<Duration> = (1..=10).map(Duration::from_millis).collect(); | |
253 | + | |
254 | + let p50 = percentile(&latencies, 50.0); | |
255 | + assert_eq!(p50, Duration::from_millis(5)); | |
256 | + | |
257 | + let p95 = percentile(&latencies, 95.0); | |
258 | + // ceil(0.95 * 10) = 10, idx = 9 → 10ms | |
259 | + assert_eq!(p95, Duration::from_millis(10)); | |
260 | + | |
261 | + let p99 = percentile(&latencies, 99.0); | |
262 | + assert_eq!(p99, Duration::from_millis(10)); | |
263 | + | |
264 | + // Edge: p0 should give the first element | |
265 | + let p0 = percentile(&latencies, 0.0); | |
266 | + assert_eq!(p0, Duration::from_millis(1)); | |
267 | + | |
268 | + // Edge: p100 should give the last element | |
269 | + let p100 = percentile(&latencies, 100.0); | |
270 | + assert_eq!(p100, Duration::from_millis(10)); | |
271 | + } | |
272 | + | |
273 | + #[test] | |
274 | + fn test_percentile_empty() { | |
275 | + let empty: Vec<Duration> = Vec::new(); | |
276 | + assert_eq!(percentile(&empty, 50.0), Duration::ZERO); | |
277 | + } | |
278 | + | |
279 | + #[test] | |
280 | + fn test_percentile_single() { | |
281 | + let single = vec![Duration::from_millis(42)]; | |
282 | + assert_eq!(percentile(&single, 0.0), Duration::from_millis(42)); | |
283 | + assert_eq!(percentile(&single, 50.0), Duration::from_millis(42)); | |
284 | + assert_eq!(percentile(&single, 100.0), Duration::from_millis(42)); | |
285 | + } | |
286 | + | |
287 | + #[test] | |
288 | + fn test_histogram_renders_nonempty() { | |
289 | + let latencies: Vec<Duration> = (1..=100).map(Duration::from_millis).collect(); | |
290 | + let hist = render_histogram(&latencies, 5, 20); | |
291 | + | |
292 | + // Should contain multiple lines | |
293 | + let lines: Vec<&str> = hist.lines().collect(); | |
294 | + assert_eq!(lines.len(), 5, "expected 5 histogram buckets"); | |
295 | + | |
296 | + // Each line should contain the box-drawing separator | |
297 | + for line in &lines { | |
298 | + assert!(line.contains('│'), "each line should contain │ separator"); | |
299 | + } | |
300 | + | |
301 | + // Each line should have a count in parentheses | |
302 | + for line in &lines { | |
303 | + assert!(line.contains('('), "each line should contain count"); | |
304 | + } | |
305 | + } | |
306 | + | |
307 | + #[test] | |
308 | + fn test_histogram_empty() { | |
309 | + let hist = render_histogram(&[], 5, 20); | |
310 | + assert_eq!(hist, " (no data)\n"); | |
311 | + } | |
312 | + | |
313 | + #[test] | |
314 | + fn test_histogram_all_same() { | |
315 | + // All latencies identical — should not panic from division by zero | |
316 | + let latencies = vec![Duration::from_millis(5); 50]; | |
317 | + let hist = render_histogram(&latencies, 5, 20); | |
318 | + assert!(!hist.is_empty()); | |
319 | + // The first bucket should have all 50 samples | |
320 | + assert!(hist.contains("(50)")); | |
321 | + } | |
322 | + | |
323 | + #[test] | |
324 | + fn test_build_bar() { | |
325 | + // Full bar | |
326 | + let bar = build_bar(100, 100, 10); | |
327 | + assert_eq!(bar.chars().count(), 10); | |
328 | + | |
329 | + // Empty bar | |
330 | + let bar = build_bar(0, 100, 10); | |
331 | + assert!(bar.is_empty()); | |
332 | + | |
333 | + // Half bar | |
334 | + let bar = build_bar(50, 100, 10); | |
335 | + // 50/100 * 10 * 8 = 40 eighths → 5 full blocks | |
336 | + assert_eq!(bar.chars().count(), 5); | |
337 | + } | |
338 | +} | |
0 | 339 | |
▼crates/justapi-cli/src/watcher.rs
+187-0
| @@ -0,0 +1,187 @@ | ||
1 | +use std::path::Path; | |
2 | +use std::time::Duration; | |
3 | + | |
4 | +use notify::RecursiveMode; | |
5 | +use notify_debouncer_mini::{new_debouncer, DebounceEventResult}; | |
6 | +use tokio::sync::mpsc; | |
7 | + | |
8 | +/// Default file extensions to watch for changes. | |
9 | +const DEFAULT_WATCH_EXTENSIONS: &[&str] = &["py", "toml", "yaml", "yml", "json", "env"]; | |
10 | + | |
11 | +/// Directories that are always excluded from file watching. | |
12 | +const EXCLUDED_DIRS: &[&str] = &[ | |
13 | + "__pycache__", | |
14 | + ".git", | |
15 | + "target", | |
16 | + "node_modules", | |
17 | + ".venv", | |
18 | + ".mypy_cache", | |
19 | + ".pytest_cache", | |
20 | + ".ruff_cache", | |
21 | + "__pypackages__", | |
22 | +]; | |
23 | + | |
24 | +/// Returns `true` if the given path should be ignored because it resides | |
25 | +/// inside one of the [`EXCLUDED_DIRS`]. | |
26 | +pub fn should_ignore_path(path: &Path) -> bool { | |
27 | + path.components().any(|c| { | |
28 | + c.as_os_str() | |
29 | + .to_str() | |
30 | + .map(|s| EXCLUDED_DIRS.contains(&s)) | |
31 | + .unwrap_or(false) | |
32 | + }) | |
33 | +} | |
34 | + | |
35 | +/// Returns `true` if `path` has one of the given extensions. | |
36 | +fn has_watched_extension(path: &Path, extensions: &[String]) -> bool { | |
37 | + path.extension() | |
38 | + .and_then(|e| e.to_str()) | |
39 | + .map(|ext| extensions.iter().any(|w| w == ext)) | |
40 | + .unwrap_or(false) | |
41 | +} | |
42 | + | |
43 | +/// Spawn a background file watcher that monitors `watch_dir` for changes. | |
44 | +/// | |
45 | +/// When a relevant file change is detected the watcher sends `()` on the | |
46 | +/// returned [`mpsc::Receiver`]. The caller can `recv()` to learn about | |
47 | +/// each reload trigger. | |
48 | +/// | |
49 | +/// # Arguments | |
50 | +/// | |
51 | +/// * `watch_dir` — directory to watch recursively. | |
52 | +/// * `extra_extensions` — additional file extensions to watch beyond the | |
53 | +/// defaults. Pass an empty slice to use only the built-in set. | |
54 | +pub fn spawn_file_watcher( | |
55 | + watch_dir: &Path, | |
56 | + extra_extensions: &[String], | |
57 | +) -> Result<mpsc::Receiver<()>, anyhow::Error> { | |
58 | + // Build the full extension list. | |
59 | + let mut extensions: Vec<String> = DEFAULT_WATCH_EXTENSIONS | |
60 | + .iter() | |
61 | + .map(|s| (*s).to_owned()) | |
62 | + .collect(); | |
63 | + for ext in extra_extensions { | |
64 | + let ext = ext.strip_prefix('.').unwrap_or(ext).to_owned(); | |
65 | + if !extensions.contains(&ext) { | |
66 | + extensions.push(ext); | |
67 | + } | |
68 | + } | |
69 | + | |
70 | + // Channel between the notify callback (sync) and the async task. | |
71 | + let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel::<String>(16); | |
72 | + | |
73 | + // Channel returned to the caller. | |
74 | + let (reload_tx, reload_rx) = mpsc::channel::<()>(1); | |
75 | + | |
76 | + let exts = extensions.clone(); | |
77 | + let mut debouncer = new_debouncer( | |
78 | + Duration::from_millis(300), | |
79 | + move |result: DebounceEventResult| { | |
80 | + if let Ok(events) = result { | |
81 | + for event in &events { | |
82 | + if should_ignore_path(&event.path) { | |
83 | + continue; | |
84 | + } | |
85 | + if has_watched_extension(&event.path, &exts) { | |
86 | + let display = event | |
87 | + .path | |
88 | + .file_name() | |
89 | + .and_then(|n| n.to_str()) | |
90 | + .unwrap_or("<unknown>") | |
91 | + .to_owned(); | |
92 | + notify_tx.try_send(display).ok(); | |
93 | + break; | |
94 | + } | |
95 | + } | |
96 | + } | |
97 | + }, | |
98 | + )?; | |
99 | + | |
100 | + debouncer | |
101 | + .watcher() | |
102 | + .watch(watch_dir, RecursiveMode::Recursive)?; | |
103 | + | |
104 | + tokio::spawn(async move { | |
105 | + // Keep the debouncer alive as long as the task runs. | |
106 | + let _debouncer = debouncer; | |
107 | + while let Some(changed_file) = notify_rx.recv().await { | |
108 | + tracing::info!(file = %changed_file, "File change detected, triggering reload"); | |
109 | + if reload_tx.send(()).await.is_err() { | |
110 | + // Receiver dropped — stop watching. | |
111 | + break; | |
112 | + } | |
113 | + } | |
114 | + }); | |
115 | + | |
116 | + Ok(reload_rx) | |
117 | +} | |
118 | + | |
119 | +#[cfg(test)] | |
120 | +mod tests { | |
121 | + use super::*; | |
122 | + use std::path::PathBuf; | |
123 | + | |
124 | + #[test] | |
125 | + fn test_should_ignore_excluded_directories() { | |
126 | + // Paths inside excluded directories must be ignored. | |
127 | + let cases = vec![ | |
128 | + ("project/__pycache__/module.pyc", true), | |
129 | + ("project/.git/config", true), | |
130 | + ("project/target/debug/binary", true), | |
131 | + ("project/node_modules/pkg/index.js", true), | |
132 | + ("project/.venv/lib/python3.14/site.py", true), | |
133 | + (".mypy_cache/report.json", true), | |
134 | + (".pytest_cache/v/cache/lastfailed", true), | |
135 | + (".ruff_cache/0.1.0/foo.json", true), | |
136 | + ("__pypackages__/3.14/lib/pkg.py", true), | |
137 | + ]; | |
138 | + for (path, expected) in cases { | |
139 | + assert_eq!( | |
140 | + should_ignore_path(Path::new(path)), | |
141 | + expected, | |
142 | + "should_ignore_path({path:?}) should be {expected}" | |
143 | + ); | |
144 | + } | |
145 | + } | |
146 | + | |
147 | + #[test] | |
148 | + fn test_should_not_ignore_normal_directories() { | |
149 | + // Regular project files must NOT be ignored. | |
150 | + let cases = vec![ | |
151 | + "app/main.py", | |
152 | + "config.toml", | |
153 | + "src/lib.rs", | |
154 | + "app/routes/users.py", | |
155 | + "tests/test_api.py", | |
156 | + "static/style.css", | |
157 | + ]; | |
158 | + for path in cases { | |
159 | + assert!( | |
160 | + !should_ignore_path(Path::new(path)), | |
161 | + "should_ignore_path({path:?}) should be false" | |
162 | + ); | |
163 | + } | |
164 | + } | |
165 | + | |
166 | + #[test] | |
167 | + fn test_has_watched_extension_matches() { | |
168 | + let exts: Vec<String> = vec!["py", "toml", "json"] | |
169 | + .into_iter() | |
170 | + .map(String::from) | |
171 | + .collect(); | |
172 | + assert!(has_watched_extension(&PathBuf::from("app/main.py"), &exts)); | |
173 | + assert!(has_watched_extension(&PathBuf::from("config.toml"), &exts)); | |
174 | + assert!(has_watched_extension(&PathBuf::from("data.json"), &exts)); | |
175 | + } | |
176 | + | |
177 | + #[test] | |
178 | + fn test_has_watched_extension_rejects_non_matching() { | |
179 | + let exts: Vec<String> = vec!["py", "toml", "json"] | |
180 | + .into_iter() | |
181 | + .map(String::from) | |
182 | + .collect(); | |
183 | + assert!(!has_watched_extension(&PathBuf::from("style.css"), &exts)); | |
184 | + assert!(!has_watched_extension(&PathBuf::from("binary"), &exts)); | |
185 | + assert!(!has_watched_extension(&PathBuf::from("readme.md"), &exts)); | |
186 | + } | |
187 | +} | |
0 | 188 | |