fusion-framework 1.2.2__tar.gz → 1.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/Cargo.lock +4 -4
  2. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/Cargo.toml +1 -1
  3. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/PKG-INFO +1 -1
  4. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/lib.rs +4 -1
  5. fusion_framework-1.2.4/crates/fusion-core/src/naming.rs +111 -0
  6. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/src/api_types.rs +258 -82
  7. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/src/lib.rs +18 -2
  8. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/pyproject.toml +1 -1
  9. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/app.py +101 -19
  10. fusion_framework-1.2.4/python/fusion_framework/http_route.py +116 -0
  11. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/middleware.py +2 -2
  12. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/route.py +33 -1
  13. fusion_framework-1.2.4/python/fusion_framework/test_http_route.py +47 -0
  14. fusion_framework-1.2.4/python/fusion_framework/test_swagger.py +77 -0
  15. fusion_framework-1.2.2/crates/fusion-core/src/naming.rs +0 -56
  16. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/README.md +0 -0
  17. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/Cargo.toml +0 -0
  18. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/api_context.rs +0 -0
  19. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/coerce.rs +0 -0
  20. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/dispatch.rs +0 -0
  21. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/error.rs +0 -0
  22. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/handler.rs +0 -0
  23. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/headers.rs +0 -0
  24. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/http_error.rs +0 -0
  25. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/main.rs +0 -0
  26. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/request.rs +0 -0
  27. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/response.rs +0 -0
  28. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/router.rs +0 -0
  29. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/serialize.rs +0 -0
  30. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/server.rs +0 -0
  31. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/settings.rs +0 -0
  32. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/status.rs +0 -0
  33. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/Cargo.toml +0 -0
  34. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/README.md +0 -0
  35. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/src/json.rs +0 -0
  36. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/__init__.py +0 -0
  37. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/api.py +0 -0
  38. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/async_runtime.py +0 -0
  39. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/config.py +0 -0
  40. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/header.py +0 -0
  41. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/http.py +0 -0
  42. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/status.py +0 -0
  43. {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/test_middleware.py +0 -0
@@ -124,7 +124,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
124
124
 
125
125
  [[package]]
126
126
  name = "fusion-core"
127
- version = "1.2.2"
127
+ version = "1.2.4"
128
128
  dependencies = [
129
129
  "bytes",
130
130
  "console",
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "fusion-ffi"
141
- version = "1.2.2"
141
+ version = "1.2.4"
142
142
  dependencies = [
143
143
  "bytes",
144
144
  "fusion-core",
@@ -148,7 +148,7 @@ dependencies = [
148
148
 
149
149
  [[package]]
150
150
  name = "fusion-node"
151
- version = "1.2.2"
151
+ version = "1.2.4"
152
152
  dependencies = [
153
153
  "fusion-core",
154
154
  "napi",
@@ -161,7 +161,7 @@ dependencies = [
161
161
 
162
162
  [[package]]
163
163
  name = "fusion-py"
164
- version = "1.2.2"
164
+ version = "1.2.4"
165
165
  dependencies = [
166
166
  "bytes",
167
167
  "fusion-core",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/fusion-core", "crates/fusion-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "1.2.2"
6
+ version = "1.2.4"
7
7
  edition = "2024"
8
8
 
9
9
  [workspace.dependencies]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusion-framework
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3
@@ -26,7 +26,10 @@ pub use headers::{
26
26
  X_FRAMEWORK, X_FUSION_VERSION, X_POWERED_BY,
27
27
  };
28
28
  pub use http_error::HttpError;
29
- pub use naming::{HTTP_METHODS, api_resource_name, resolve_route_path};
29
+ pub use naming::{
30
+ HTTP_METHODS, api_action_name, api_resource_name, join_route_paths, resolve_handler_route,
31
+ resolve_method_route_path, resolve_route_path,
32
+ };
30
33
  pub use request::{parse_query, Request};
31
34
  pub use response::Response;
32
35
  pub use router::Router;
@@ -0,0 +1,111 @@
1
+ //! Shared route naming helpers used by all language bindings.
2
+
3
+ /// Canonical HTTP handler method names (lowercase).
4
+ pub const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
5
+
6
+ /// `MyFirstModule` → `myfirst`; names without a `Module`/`MODULE` suffix are lowercased as-is.
7
+ pub fn api_resource_name(class_name: &str) -> String {
8
+ let stem = if let Some(stem) = class_name.strip_suffix("Module") {
9
+ if !stem.is_empty() {
10
+ stem
11
+ } else {
12
+ class_name
13
+ }
14
+ } else if let Some(stem) = class_name.strip_suffix("MODULE") {
15
+ if !stem.is_empty() {
16
+ stem
17
+ } else {
18
+ class_name
19
+ }
20
+ } else {
21
+ class_name
22
+ };
23
+ stem.to_lowercase()
24
+ }
25
+
26
+ /// `UserAction` → `user`; strips trailing `Action` / `ACTION` then lowercases.
27
+ pub fn api_action_name(method_name: &str) -> String {
28
+ let stem = if let Some(stem) = method_name.strip_suffix("Action") {
29
+ if !stem.is_empty() {
30
+ stem
31
+ } else {
32
+ method_name
33
+ }
34
+ } else if let Some(stem) = method_name.strip_suffix("ACTION") {
35
+ if !stem.is_empty() {
36
+ stem
37
+ } else {
38
+ method_name
39
+ }
40
+ } else {
41
+ method_name
42
+ };
43
+ stem.to_lowercase()
44
+ }
45
+
46
+ /// Expand the reserved `[module]` token using the module class name.
47
+ ///
48
+ /// Example: `/api/[module]/{id}` + `ProductsModule` → `/api/products/{id}`.
49
+ /// Other `{param}` / `[param]` segments are left for the router.
50
+ pub fn resolve_route_path(template: &str, class_name: &str) -> String {
51
+ template.replace("[module]", &api_resource_name(class_name))
52
+ }
53
+
54
+ /// Expand `[module]` and `[action]` tokens for a handler method route template.
55
+ pub fn resolve_method_route_path(template: &str, class_name: &str, method_name: &str) -> String {
56
+ resolve_route_path(template, class_name).replace("[action]", &api_action_name(method_name))
57
+ }
58
+
59
+ /// Join a class-level base path with a method route segment.
60
+ pub fn join_route_paths(base: &str, segment: &str) -> String {
61
+ let base = base.trim_end_matches('/');
62
+ let segment = segment.trim_matches('/');
63
+ if segment.is_empty() {
64
+ return if base.is_empty() { "/".into() } else { base.to_string() };
65
+ }
66
+ if base.is_empty() {
67
+ return format!("/{segment}");
68
+ }
69
+ format!("{base}/{segment}")
70
+ }
71
+
72
+ /// Resolve a method-level route template against class context.
73
+ ///
74
+ /// Absolute templates (leading `/`) ignore the class base path.
75
+ pub fn resolve_handler_route(
76
+ class_base_path: &str,
77
+ method_template: &str,
78
+ class_name: &str,
79
+ method_name: &str,
80
+ ) -> String {
81
+ let resolved = resolve_method_route_path(method_template, class_name, method_name);
82
+ if method_template.starts_with('/') {
83
+ join_route_paths("", &resolved.trim_start_matches('/'))
84
+ } else {
85
+ join_route_paths(class_base_path, &resolved)
86
+ }
87
+ }
88
+
89
+ #[cfg(test)]
90
+ mod tests {
91
+ use super::*;
92
+
93
+ #[test]
94
+ fn strips_module_suffix_and_lowercases() {
95
+ assert_eq!(api_resource_name("ProductsModule"), "products");
96
+ assert_eq!(api_resource_name("MyFirstMODULE"), "myfirst");
97
+ assert_eq!(api_resource_name("Health"), "health");
98
+ }
99
+
100
+ #[test]
101
+ fn expands_module_token() {
102
+ assert_eq!(
103
+ resolve_route_path("/test/[module]", "ProductsModule"),
104
+ "/test/products"
105
+ );
106
+ assert_eq!(
107
+ resolve_route_path("/api/[module]/{id}", "UserModule"),
108
+ "/api/user/{id}"
109
+ );
110
+ }
111
+ }
@@ -2,7 +2,7 @@ use std::collections::HashMap;
2
2
  use std::sync::Mutex;
3
3
 
4
4
  use fusion_core::{
5
- HttpError, ParamKind, ParamSpec, Request, bind_args, build_response, resolve_route_path,
5
+ HttpError, ParamKind, ParamSpec, Request, bind_args, build_response, resolve_handler_route,
6
6
  HTTP_METHODS,
7
7
  };
8
8
  use pyo3::exceptions::PyRuntimeError;
@@ -232,13 +232,79 @@ pub fn is_http_exception(py: Python<'_>, err: &Bound<'_, PyAny>) -> Option<Py<Py
232
232
 
233
233
  // ─── Route registry ──────────────────────────────────────────────────────────
234
234
 
235
- struct RegisteredRoute {
235
+ struct RouteMountSlot {
236
236
  path: String,
237
+ http_method: String,
238
+ handler_method: String,
239
+ specs: Vec<ParamSpec>,
240
+ swagger: SwaggerMeta,
241
+ }
242
+
243
+ impl Clone for RouteMountSlot {
244
+ fn clone(&self) -> Self {
245
+ Self {
246
+ path: self.path.clone(),
247
+ http_method: self.http_method.clone(),
248
+ handler_method: self.handler_method.clone(),
249
+ specs: self.specs.clone(),
250
+ swagger: self.swagger.clone(),
251
+ }
252
+ }
253
+ }
254
+
255
+ struct RegisteredRoute {
237
256
  api_name: String,
238
257
  api_cls: Py<PyType>,
239
- method_specs: HashMap<String, Vec<ParamSpec>>,
240
- swagger: SwaggerMeta,
241
258
  middleware: Vec<Py<PyAny>>,
259
+ /// Normalized route version (`v1`), if the `@route(..., version=)` was set.
260
+ version: Option<String>,
261
+ slots: Vec<RouteMountSlot>,
262
+ }
263
+
264
+ fn normalize_api_version(version: Option<String>) -> Option<String> {
265
+ version.and_then(|raw| {
266
+ let trimmed = raw.trim().trim_matches('/').to_string();
267
+ if trimmed.is_empty() {
268
+ None
269
+ } else {
270
+ Some(trimmed)
271
+ }
272
+ })
273
+ }
274
+
275
+ fn route_matches_version_filter(route_version: Option<&str>, filter: Option<&str>) -> bool {
276
+ match filter {
277
+ None => true,
278
+ Some(label) if label.is_empty() || label.eq_ignore_ascii_case("default") => {
279
+ route_version.map(str::is_empty).unwrap_or(true)
280
+ }
281
+ Some(label) => route_version
282
+ .map(|v| v.eq_ignore_ascii_case(label))
283
+ .unwrap_or(false),
284
+ }
285
+ }
286
+
287
+ /// Unique `@route(version=...)` values in registration order.
288
+ pub fn route_versions() -> Vec<String> {
289
+ let Ok(guard) = REGISTRY.lock() else {
290
+ return Vec::new();
291
+ };
292
+ let mut versions = Vec::new();
293
+ for route in guard.iter() {
294
+ if let Some(version) = route.version.as_ref() {
295
+ if !versions.iter().any(|existing| existing == version) {
296
+ versions.push(version.clone());
297
+ }
298
+ }
299
+ }
300
+ versions
301
+ }
302
+
303
+ pub fn has_unversioned_routes() -> bool {
304
+ let Ok(guard) = REGISTRY.lock() else {
305
+ return false;
306
+ };
307
+ guard.iter().any(|route| route.version.is_none())
242
308
  }
243
309
 
244
310
  static REGISTRY: Mutex<Vec<RegisteredRoute>> = Mutex::new(Vec::new());
@@ -262,86 +328,191 @@ pub fn register_route(
262
328
  let py = api_cls.py();
263
329
 
264
330
  let class_name: String = api_cls.name()?.extract()?;
265
- let mut resolved = resolve_route_path(template, &class_name);
266
- if let Some(version) = version {
267
- let v = version.trim().trim_matches('/');
268
- if !v.is_empty() {
269
- // Prefix the resolved api path with version: `v1/api/` style.
270
- let trimmed = resolved.trim_start_matches('/');
271
- resolved = format!("{}/{}", v, trimmed);
272
- }
331
+ let version = normalize_api_version(version);
332
+ let mut class_base_path = fusion_core::resolve_route_path(template, &class_name);
333
+ if !class_base_path.starts_with('/') {
334
+ class_base_path = format!("/{class_base_path}");
335
+ }
336
+ if let Some(v) = version.as_deref() {
337
+ let trimmed = class_base_path.trim_start_matches('/');
338
+ class_base_path = format!("/{v}/{}", trimmed.trim_start_matches('/'));
339
+ }
340
+
341
+ let class_swagger = SwaggerMeta {
342
+ tags,
343
+ description,
344
+ title,
345
+ deprecated,
346
+ };
347
+
348
+ let mut slots = Vec::new();
349
+ let mut custom_handlers = std::collections::HashSet::new();
350
+
351
+ for (method_name, method) in iter_class_callables(py, &api_cls)? {
352
+ let method = method.bind(py);
353
+ let Some(meta) = read_http_route_meta(&method)? else {
354
+ continue;
355
+ };
356
+ custom_handlers.insert(method_name.clone());
357
+ let path = resolve_handler_route(
358
+ &class_base_path,
359
+ &meta.template,
360
+ &class_name,
361
+ &method_name,
362
+ );
363
+ let specs = extract_param_specs(py, &method)?;
364
+ slots.push(RouteMountSlot {
365
+ path,
366
+ http_method: meta.http_method,
367
+ handler_method: method_name,
368
+ specs,
369
+ swagger: meta.swagger,
370
+ });
273
371
  }
274
372
 
275
- let mut method_specs = HashMap::new();
276
373
  for method_name in HTTP_METHODS {
374
+ if custom_handlers.contains(*method_name) {
375
+ continue;
376
+ }
277
377
  if !defines_method(&api_cls, method_name)? {
278
378
  continue;
279
379
  }
280
380
  let method = api_cls.getattr(method_name)?;
281
- method_specs.insert(
282
- method_name.to_string(),
283
- extract_param_specs(py, &method)?,
284
- );
381
+ slots.push(RouteMountSlot {
382
+ path: class_base_path.clone(),
383
+ http_method: (*method_name).to_string(),
384
+ handler_method: (*method_name).to_string(),
385
+ specs: extract_param_specs(py, &method)?,
386
+ swagger: class_swagger.clone(),
387
+ });
285
388
  }
286
389
 
287
- api_cls.setattr("__fusion_path__", &resolved)?;
390
+ let primary_path = slots
391
+ .first()
392
+ .map(|s| s.path.clone())
393
+ .unwrap_or_else(|| class_base_path.clone());
394
+
395
+ api_cls.setattr("__fusion_path__", &primary_path)?;
288
396
  api_cls.setattr("__fusion_path_template__", template)?;
289
397
 
290
398
  REGISTRY
291
399
  .lock()
292
400
  .map_err(|_| PyRuntimeError::new_err("registry lock poisoned"))?
293
401
  .push(RegisteredRoute {
294
- path: resolved.clone(),
295
- api_name: class_name.clone(),
402
+ api_name: class_name,
296
403
  api_cls: api_cls.unbind(),
297
- method_specs,
298
- swagger: SwaggerMeta {
299
- tags,
300
- description,
301
- title,
302
- deprecated,
303
- },
304
404
  middleware,
405
+ version,
406
+ slots,
305
407
  });
306
408
 
307
- Ok(resolved)
409
+ Ok(primary_path)
410
+ }
411
+
412
+ struct HttpRouteMeta {
413
+ http_method: String,
414
+ template: String,
415
+ swagger: SwaggerMeta,
416
+ }
417
+
418
+ fn read_http_route_meta(method: &Bound<'_, PyAny>) -> PyResult<Option<HttpRouteMeta>> {
419
+ if !method.hasattr("__fusion_http_route__")? {
420
+ return Ok(None);
421
+ }
422
+ let meta = method.getattr("__fusion_http_route__")?;
423
+ let dict = meta.downcast::<PyDict>()?;
424
+ let http_method: String = dict
425
+ .get_item("method")?
426
+ .and_then(|v| v.extract().ok())
427
+ .unwrap_or_else(|| "get".to_string());
428
+ let template: String = dict
429
+ .get_item("template")?
430
+ .and_then(|v| v.extract().ok())
431
+ .ok_or_else(|| PyRuntimeError::new_err("http route template required"))?;
432
+ let tags: Vec<String> = dict
433
+ .get_item("tags")?
434
+ .and_then(|v| v.extract().ok())
435
+ .unwrap_or_default();
436
+ let description = dict
437
+ .get_item("desc")?
438
+ .and_then(|v| v.extract().ok());
439
+ let title = dict
440
+ .get_item("title")?
441
+ .and_then(|v| v.extract().ok());
442
+ let deprecated = dict
443
+ .get_item("deprecated")?
444
+ .and_then(|v| v.extract().ok())
445
+ .unwrap_or(false);
446
+ Ok(Some(HttpRouteMeta {
447
+ http_method: http_method.to_ascii_lowercase(),
448
+ template,
449
+ swagger: SwaggerMeta {
450
+ tags,
451
+ description,
452
+ title,
453
+ deprecated,
454
+ },
455
+ }))
456
+ }
457
+
458
+ fn iter_class_callables(
459
+ py: Python<'_>,
460
+ api_cls: &Bound<'_, PyType>,
461
+ ) -> PyResult<Vec<(String, Py<PyAny>)>> {
462
+ let inspect = py.import("inspect")?;
463
+ let members = inspect.call_method1("getmembers", (api_cls,))?;
464
+ let mut out = Vec::new();
465
+ for item in members.try_iter()? {
466
+ let item = item?;
467
+ let (name, value): (String, Bound<'_, PyAny>) = item.extract()?;
468
+ if name.starts_with('_') {
469
+ continue;
470
+ }
471
+ if value.hasattr("__func__")? || inspect.call_method1("isfunction", (value.clone(),))?.is_truthy()? {
472
+ out.push((name, value.unbind()));
473
+ } else if inspect.call_method1("ismethod", (value.clone(),))?.is_truthy()? {
474
+ out.push((name, value.unbind()));
475
+ }
476
+ }
477
+ Ok(out)
308
478
  }
309
479
 
310
480
  pub fn mount_routes(app: &super::PyApp) -> PyResult<()> {
311
- let routes: Vec<(String, Py<PyType>, HashMap<String, Vec<ParamSpec>>, Vec<Py<PyAny>>)> =
312
- Python::with_gil(|py| {
313
- let guard = REGISTRY
314
- .lock()
315
- .map_err(|_| PyRuntimeError::new_err("registry lock poisoned"))?;
316
- Ok::<_, PyErr>(guard
481
+ let routes: Vec<(Vec<RouteMountSlot>, Py<PyType>, Vec<Py<PyAny>>)> = Python::with_gil(|py| {
482
+ let guard = REGISTRY
483
+ .lock()
484
+ .map_err(|_| PyRuntimeError::new_err("registry lock poisoned"))?;
485
+ Ok::<_, PyErr>(
486
+ guard
317
487
  .iter()
318
488
  .map(|r| {
319
489
  (
320
- r.path.clone(),
490
+ r.slots.clone(),
321
491
  r.api_cls.clone_ref(py),
322
- r.method_specs.clone(),
323
492
  r.middleware.iter().map(|m| m.clone_ref(py)).collect(),
324
493
  )
325
494
  })
326
- .collect())
327
- })?;
495
+ .collect(),
496
+ )
497
+ })?;
328
498
 
329
- for (path, api_cls, method_specs, middleware) in routes {
330
- for method_name in HTTP_METHODS {
331
- let Some(specs) = method_specs.get(*method_name) else {
332
- continue;
333
- };
499
+ for (slots, api_cls, middleware) in routes {
500
+ for slot in slots {
334
501
  let handler = Python::with_gil(|py| -> PyResult<PyObject> {
335
502
  let route_handler = RouteHandler {
336
503
  api_cls: api_cls.clone_ref(py),
337
- method_name: (*method_name).to_string(),
338
- specs: specs.clone(),
504
+ method_name: slot.handler_method.clone(),
505
+ specs: slot.specs.clone(),
339
506
  middleware: middleware.iter().map(|m| m.clone_ref(py)).collect(),
340
507
  };
341
508
  Ok(Py::new(py, route_handler)?.into_any().into())
342
509
  })?;
343
510
 
344
- app.route((*method_name).to_uppercase(), path.clone(), handler)?;
511
+ app.route(
512
+ slot.http_method.to_ascii_uppercase(),
513
+ slot.path.clone(),
514
+ handler,
515
+ )?;
345
516
  }
346
517
  }
347
518
  Ok(())
@@ -668,6 +839,15 @@ fn annotation_kind(py: Python<'_>, annotation: &Bound<'_, PyAny>) -> PyResult<Pa
668
839
  }
669
840
 
670
841
  pub fn openapi_spec() -> serde_json::Value {
842
+ openapi_spec_for(None)
843
+ }
844
+
845
+ /// Build an OpenAPI document.
846
+ ///
847
+ /// - `None` — every registered route
848
+ /// - `Some("default")` / `Some("")` — routes without a version
849
+ /// - `Some("v1")` — only that API version
850
+ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value {
671
851
  use serde_json::{json, Map, Value};
672
852
  const OPENAPI_VERSION: &str = "3.0.3";
673
853
 
@@ -680,30 +860,29 @@ pub fn openapi_spec() -> serde_json::Value {
680
860
  let mut paths: Map<String, Value> = Map::new();
681
861
 
682
862
  for r in routes_guard.iter() {
683
- let resolved_path = if r.path.starts_with('/') {
684
- r.path.clone()
685
- } else {
686
- format!("/{}", r.path)
687
- };
688
-
689
- let path_params = extract_path_params_from_pattern(&resolved_path);
863
+ if !route_matches_version_filter(r.version.as_deref(), version) {
864
+ continue;
865
+ }
690
866
 
691
- let mut methods_obj: Map<String, Value> = Map::new();
867
+ for slot in &r.slots {
868
+ let resolved_path = if slot.path.starts_with('/') {
869
+ slot.path.clone()
870
+ } else {
871
+ format!("/{}", slot.path)
872
+ };
692
873
 
693
- for (method, specs) in &r.method_specs {
694
- let method_upper = method.to_ascii_uppercase();
695
- let method_lower = method.to_string();
874
+ let path_params = extract_path_params_from_pattern(&resolved_path);
875
+ let method_upper = slot.http_method.to_ascii_uppercase();
876
+ let method_lower = slot.http_method.to_ascii_lowercase();
696
877
 
697
878
  let mut operation_params: Vec<Value> = Vec::new();
698
879
  let mut body_properties: Map<String, Value> = Map::new();
699
880
  let mut body_required: Vec<String> = Vec::new();
700
881
 
701
- for spec in specs {
882
+ for spec in &slot.specs {
702
883
  let nullable = spec.optional || spec.has_default;
703
884
  let required = !nullable;
704
885
  if path_params.iter().any(|p| p == &spec.name) {
705
- // OpenAPI normally requires path params, but if the Python
706
- // signature marks them Optional / defaulted, expose that in Swagger.
707
886
  operation_params.push(json!({
708
887
  "name": spec.name,
709
888
  "in": "path",
@@ -725,20 +904,20 @@ pub fn openapi_spec() -> serde_json::Value {
725
904
  }
726
905
  }
727
906
 
728
- let title = r
907
+ let title = slot
729
908
  .swagger
730
909
  .title
731
910
  .clone()
732
- .unwrap_or_else(|| format!("{} {}", r.api_name, method_upper));
911
+ .unwrap_or_else(|| format!("{} {}", r.api_name, slot.handler_method));
733
912
 
734
- let description = r.swagger.description.clone().unwrap_or_default();
735
- let deprecated = r.swagger.deprecated;
913
+ let description = slot.swagger.description.clone().unwrap_or_default();
914
+ let deprecated = slot.swagger.deprecated;
736
915
 
737
916
  let mut op = json!({
738
- "tags": r.swagger.tags.clone(),
917
+ "tags": slot.swagger.tags.clone(),
739
918
  "summary": title,
740
919
  "description": description,
741
- "operationId": format!("{}_{}", r.api_name, method_lower),
920
+ "operationId": format!("{}_{}", r.api_name, slot.handler_method),
742
921
  "deprecated": deprecated,
743
922
  "responses": {
744
923
  "200": { "description": "OK" }
@@ -775,7 +954,6 @@ pub fn openapi_spec() -> serde_json::Value {
775
954
  obj.insert("requestBody".to_string(), request_body);
776
955
  }
777
956
 
778
- // Make a best-effort response schema.
779
957
  if let Some(obj) = op.as_object_mut() {
780
958
  if let Some(resp) = obj.get_mut("responses").and_then(|v| v.as_object_mut()) {
781
959
  resp.insert("200".to_string(), json!({
@@ -784,25 +962,23 @@ pub fn openapi_spec() -> serde_json::Value {
784
962
  }));
785
963
  }
786
964
  }
787
- } else {
788
- // If we only have query/path parameters, default to JSON response.
789
- if let Some(obj) = op.as_object_mut() {
790
- if let Some(resp) = obj.get_mut("responses").and_then(|v| v.as_object_mut()) {
791
- resp.insert("200".to_string(), json!({
792
- "description": "OK",
793
- "content": { "application/json": { "schema": { "type": "object" } } }
794
- }));
795
- }
965
+ } else if let Some(obj) = op.as_object_mut() {
966
+ if let Some(resp) = obj.get_mut("responses").and_then(|v| v.as_object_mut()) {
967
+ resp.insert("200".to_string(), json!({
968
+ "description": "OK",
969
+ "content": { "application/json": { "schema": { "type": "object" } } }
970
+ }));
796
971
  }
797
972
  }
798
973
 
799
- methods_obj.insert(method_lower, op);
800
- }
974
+ let methods_obj = paths.entry(resolved_path).or_insert_with(|| Value::Object(Map::new()));
975
+ if let Some(obj) = methods_obj.as_object_mut() {
976
+ obj.insert(method_lower, op);
977
+ }
801
978
 
802
- paths.insert(resolved_path, Value::Object(methods_obj));
803
- // Collect tag names.
804
- for t in &r.swagger.tags {
805
- tags_set.insert(t.clone());
979
+ for t in &slot.swagger.tags {
980
+ tags_set.insert(t.clone());
981
+ }
806
982
  }
807
983
  }
808
984
 
@@ -438,11 +438,25 @@ fn py_register_route(
438
438
  }
439
439
 
440
440
  #[pyfunction(name = "openapi_spec")]
441
- fn py_openapi_spec(py: Python<'_>) -> PyResult<PyObject> {
442
- let spec = api_types::openapi_spec();
441
+ #[pyo3(signature = (version=None))]
442
+ fn py_openapi_spec(py: Python<'_>, version: Option<String>) -> PyResult<PyObject> {
443
+ let spec = match version.as_deref() {
444
+ None => api_types::openapi_spec(),
445
+ Some(v) => api_types::openapi_spec_for(Some(v)),
446
+ };
443
447
  json_to_py(py, &spec)
444
448
  }
445
449
 
450
+ #[pyfunction(name = "route_versions")]
451
+ fn py_route_versions() -> Vec<String> {
452
+ api_types::route_versions()
453
+ }
454
+
455
+ #[pyfunction(name = "has_unversioned_routes")]
456
+ fn py_has_unversioned_routes() -> bool {
457
+ api_types::has_unversioned_routes()
458
+ }
459
+
446
460
  #[pyfunction]
447
461
  fn clear_routes() {
448
462
  clear_registry();
@@ -478,6 +492,8 @@ fn _fusion(m: &Bound<'_, PyModule>) -> PyResult<()> {
478
492
  m.add_function(wrap_pyfunction!(clear_routes, m)?)?;
479
493
  m.add_function(wrap_pyfunction!(http_error_to_response, m)?)?;
480
494
  m.add_function(wrap_pyfunction!(py_openapi_spec, m)?)?;
495
+ m.add_function(wrap_pyfunction!(py_route_versions, m)?)?;
496
+ m.add_function(wrap_pyfunction!(py_has_unversioned_routes, m)?)?;
481
497
  m.add("HTTP_METHODS", HTTP_METHODS)?;
482
498
  add_status_module(m)?;
483
499
  add_header_module(m)?;
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "fusion-framework"
7
- version = "1.2.2"
7
+ version = "1.2.4"
8
8
  description = "Class-based HTTP framework for Python, powered by a shared Rust core"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"