fusion-framework 1.2.3__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.3 → fusion_framework-1.2.4}/Cargo.lock +4 -4
  2. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/Cargo.toml +1 -1
  3. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/PKG-INFO +1 -1
  4. {fusion_framework-1.2.3 → 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.3 → fusion_framework-1.2.4}/crates/fusion-py/src/api_types.rs +195 -78
  7. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/pyproject.toml +1 -1
  8. fusion_framework-1.2.4/python/fusion_framework/http_route.py +116 -0
  9. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/middleware.py +2 -2
  10. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/route.py +31 -1
  11. fusion_framework-1.2.4/python/fusion_framework/test_http_route.py +47 -0
  12. fusion_framework-1.2.3/crates/fusion-core/src/naming.rs +0 -56
  13. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/README.md +0 -0
  14. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/Cargo.toml +0 -0
  15. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/api_context.rs +0 -0
  16. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/coerce.rs +0 -0
  17. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/dispatch.rs +0 -0
  18. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/error.rs +0 -0
  19. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/handler.rs +0 -0
  20. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/headers.rs +0 -0
  21. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/http_error.rs +0 -0
  22. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/main.rs +0 -0
  23. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/request.rs +0 -0
  24. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/response.rs +0 -0
  25. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/router.rs +0 -0
  26. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/serialize.rs +0 -0
  27. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/server.rs +0 -0
  28. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/settings.rs +0 -0
  29. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/status.rs +0 -0
  30. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/Cargo.toml +0 -0
  31. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/README.md +0 -0
  32. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/src/json.rs +0 -0
  33. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/src/lib.rs +0 -0
  34. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/__init__.py +0 -0
  35. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/api.py +0 -0
  36. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/app.py +0 -0
  37. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/async_runtime.py +0 -0
  38. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/config.py +0 -0
  39. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/header.py +0 -0
  40. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/http.py +0 -0
  41. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/status.py +0 -0
  42. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/test_middleware.py +0 -0
  43. {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/test_swagger.py +0 -0
@@ -124,7 +124,7 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
124
124
 
125
125
  [[package]]
126
126
  name = "fusion-core"
127
- version = "1.2.3"
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.3"
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.3"
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.3"
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.3"
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.3
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,15 +232,33 @@ 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>>,
242
259
  /// Normalized route version (`v1`), if the `@route(..., version=)` was set.
243
260
  version: Option<String>,
261
+ slots: Vec<RouteMountSlot>,
244
262
  }
245
263
 
246
264
  fn normalize_api_version(version: Option<String>) -> Option<String> {
@@ -311,84 +329,190 @@ pub fn register_route(
311
329
 
312
330
  let class_name: String = api_cls.name()?.extract()?;
313
331
  let version = normalize_api_version(version);
314
- let mut resolved = resolve_route_path(template, &class_name);
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
+ }
315
336
  if let Some(v) = version.as_deref() {
316
- // Prefix the resolved api path with version: `v1/api/` style.
317
- let trimmed = resolved.trim_start_matches('/');
318
- resolved = format!("{}/{}", v, trimmed);
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
+ });
319
371
  }
320
372
 
321
- let mut method_specs = HashMap::new();
322
373
  for method_name in HTTP_METHODS {
374
+ if custom_handlers.contains(*method_name) {
375
+ continue;
376
+ }
323
377
  if !defines_method(&api_cls, method_name)? {
324
378
  continue;
325
379
  }
326
380
  let method = api_cls.getattr(method_name)?;
327
- method_specs.insert(
328
- method_name.to_string(),
329
- extract_param_specs(py, &method)?,
330
- );
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
+ });
331
388
  }
332
389
 
333
- 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)?;
334
396
  api_cls.setattr("__fusion_path_template__", template)?;
335
397
 
336
398
  REGISTRY
337
399
  .lock()
338
400
  .map_err(|_| PyRuntimeError::new_err("registry lock poisoned"))?
339
401
  .push(RegisteredRoute {
340
- path: resolved.clone(),
341
- api_name: class_name.clone(),
402
+ api_name: class_name,
342
403
  api_cls: api_cls.unbind(),
343
- method_specs,
344
- swagger: SwaggerMeta {
345
- tags,
346
- description,
347
- title,
348
- deprecated,
349
- },
350
404
  middleware,
351
405
  version,
406
+ slots,
352
407
  });
353
408
 
354
- 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)
355
478
  }
356
479
 
357
480
  pub fn mount_routes(app: &super::PyApp) -> PyResult<()> {
358
- let routes: Vec<(String, Py<PyType>, HashMap<String, Vec<ParamSpec>>, Vec<Py<PyAny>>)> =
359
- Python::with_gil(|py| {
360
- let guard = REGISTRY
361
- .lock()
362
- .map_err(|_| PyRuntimeError::new_err("registry lock poisoned"))?;
363
- 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
364
487
  .iter()
365
488
  .map(|r| {
366
489
  (
367
- r.path.clone(),
490
+ r.slots.clone(),
368
491
  r.api_cls.clone_ref(py),
369
- r.method_specs.clone(),
370
492
  r.middleware.iter().map(|m| m.clone_ref(py)).collect(),
371
493
  )
372
494
  })
373
- .collect())
374
- })?;
495
+ .collect(),
496
+ )
497
+ })?;
375
498
 
376
- for (path, api_cls, method_specs, middleware) in routes {
377
- for method_name in HTTP_METHODS {
378
- let Some(specs) = method_specs.get(*method_name) else {
379
- continue;
380
- };
499
+ for (slots, api_cls, middleware) in routes {
500
+ for slot in slots {
381
501
  let handler = Python::with_gil(|py| -> PyResult<PyObject> {
382
502
  let route_handler = RouteHandler {
383
503
  api_cls: api_cls.clone_ref(py),
384
- method_name: (*method_name).to_string(),
385
- specs: specs.clone(),
504
+ method_name: slot.handler_method.clone(),
505
+ specs: slot.specs.clone(),
386
506
  middleware: middleware.iter().map(|m| m.clone_ref(py)).collect(),
387
507
  };
388
508
  Ok(Py::new(py, route_handler)?.into_any().into())
389
509
  })?;
390
510
 
391
- 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
+ )?;
392
516
  }
393
517
  }
394
518
  Ok(())
@@ -739,30 +863,26 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value {
739
863
  if !route_matches_version_filter(r.version.as_deref(), version) {
740
864
  continue;
741
865
  }
742
- let resolved_path = if r.path.starts_with('/') {
743
- r.path.clone()
744
- } else {
745
- format!("/{}", r.path)
746
- };
747
866
 
748
- let path_params = extract_path_params_from_pattern(&resolved_path);
749
-
750
- 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
+ };
751
873
 
752
- for (method, specs) in &r.method_specs {
753
- let method_upper = method.to_ascii_uppercase();
754
- 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();
755
877
 
756
878
  let mut operation_params: Vec<Value> = Vec::new();
757
879
  let mut body_properties: Map<String, Value> = Map::new();
758
880
  let mut body_required: Vec<String> = Vec::new();
759
881
 
760
- for spec in specs {
882
+ for spec in &slot.specs {
761
883
  let nullable = spec.optional || spec.has_default;
762
884
  let required = !nullable;
763
885
  if path_params.iter().any(|p| p == &spec.name) {
764
- // OpenAPI normally requires path params, but if the Python
765
- // signature marks them Optional / defaulted, expose that in Swagger.
766
886
  operation_params.push(json!({
767
887
  "name": spec.name,
768
888
  "in": "path",
@@ -784,20 +904,20 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value {
784
904
  }
785
905
  }
786
906
 
787
- let title = r
907
+ let title = slot
788
908
  .swagger
789
909
  .title
790
910
  .clone()
791
- .unwrap_or_else(|| format!("{} {}", r.api_name, method_upper));
911
+ .unwrap_or_else(|| format!("{} {}", r.api_name, slot.handler_method));
792
912
 
793
- let description = r.swagger.description.clone().unwrap_or_default();
794
- let deprecated = r.swagger.deprecated;
913
+ let description = slot.swagger.description.clone().unwrap_or_default();
914
+ let deprecated = slot.swagger.deprecated;
795
915
 
796
916
  let mut op = json!({
797
- "tags": r.swagger.tags.clone(),
917
+ "tags": slot.swagger.tags.clone(),
798
918
  "summary": title,
799
919
  "description": description,
800
- "operationId": format!("{}_{}", r.api_name, method_lower),
920
+ "operationId": format!("{}_{}", r.api_name, slot.handler_method),
801
921
  "deprecated": deprecated,
802
922
  "responses": {
803
923
  "200": { "description": "OK" }
@@ -834,7 +954,6 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value {
834
954
  obj.insert("requestBody".to_string(), request_body);
835
955
  }
836
956
 
837
- // Make a best-effort response schema.
838
957
  if let Some(obj) = op.as_object_mut() {
839
958
  if let Some(resp) = obj.get_mut("responses").and_then(|v| v.as_object_mut()) {
840
959
  resp.insert("200".to_string(), json!({
@@ -843,25 +962,23 @@ pub fn openapi_spec_for(version: Option<&str>) -> serde_json::Value {
843
962
  }));
844
963
  }
845
964
  }
846
- } else {
847
- // If we only have query/path parameters, default to JSON response.
848
- if let Some(obj) = op.as_object_mut() {
849
- if let Some(resp) = obj.get_mut("responses").and_then(|v| v.as_object_mut()) {
850
- resp.insert("200".to_string(), json!({
851
- "description": "OK",
852
- "content": { "application/json": { "schema": { "type": "object" } } }
853
- }));
854
- }
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
+ }));
855
971
  }
856
972
  }
857
973
 
858
- methods_obj.insert(method_lower, op);
859
- }
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
+ }
860
978
 
861
- paths.insert(resolved_path, Value::Object(methods_obj));
862
- // Collect tag names.
863
- for t in &r.swagger.tags {
864
- tags_set.insert(t.clone());
979
+ for t in &slot.swagger.tags {
980
+ tags_set.insert(t.clone());
981
+ }
865
982
  }
866
983
  }
867
984
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "fusion-framework"
7
- version = "1.2.3"
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"
@@ -0,0 +1,116 @@
1
+ """Method-level HTTP route decorators (custom paths + verbs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional, Sequence, TypeVar
6
+
7
+ F = TypeVar("F", bound=Callable[..., object])
8
+
9
+
10
+ def _http_route(
11
+ http_method: str,
12
+ route: str,
13
+ *,
14
+ tags: Optional[Sequence[str]] = None,
15
+ desc: Optional[str] = None,
16
+ title: Optional[str] = None,
17
+ deprecated: bool = False,
18
+ ) -> Callable[[F], F]:
19
+ def decorator(fn: F) -> F:
20
+ fn.__fusion_http_route__ = {
21
+ "method": http_method.lower(),
22
+ "template": route,
23
+ "tags": list(tags or []),
24
+ "desc": desc,
25
+ "title": title,
26
+ "deprecated": deprecated,
27
+ }
28
+ return fn
29
+
30
+ return decorator
31
+
32
+
33
+ def http_get(
34
+ route: str,
35
+ *,
36
+ tags: Optional[Sequence[str]] = None,
37
+ desc: Optional[str] = None,
38
+ title: Optional[str] = None,
39
+ deprecated: bool = False,
40
+ ) -> Callable[[F], F]:
41
+ return _http_route("get", route, tags=tags, desc=desc, title=title, deprecated=deprecated)
42
+
43
+
44
+ def http_post(
45
+ route: str,
46
+ *,
47
+ tags: Optional[Sequence[str]] = None,
48
+ desc: Optional[str] = None,
49
+ title: Optional[str] = None,
50
+ deprecated: bool = False,
51
+ ) -> Callable[[F], F]:
52
+ return _http_route("post", route, tags=tags, desc=desc, title=title, deprecated=deprecated)
53
+
54
+
55
+ def http_put(
56
+ route: str,
57
+ *,
58
+ tags: Optional[Sequence[str]] = None,
59
+ desc: Optional[str] = None,
60
+ title: Optional[str] = None,
61
+ deprecated: bool = False,
62
+ ) -> Callable[[F], F]:
63
+ return _http_route("put", route, tags=tags, desc=desc, title=title, deprecated=deprecated)
64
+
65
+
66
+ def http_patch(
67
+ route: str,
68
+ *,
69
+ tags: Optional[Sequence[str]] = None,
70
+ desc: Optional[str] = None,
71
+ title: Optional[str] = None,
72
+ deprecated: bool = False,
73
+ ) -> Callable[[F], F]:
74
+ return _http_route("patch", route, tags=tags, desc=desc, title=title, deprecated=deprecated)
75
+
76
+
77
+ def http_delete(
78
+ route: str,
79
+ *,
80
+ tags: Optional[Sequence[str]] = None,
81
+ desc: Optional[str] = None,
82
+ title: Optional[str] = None,
83
+ deprecated: bool = False,
84
+ ) -> Callable[[F], F]:
85
+ return _http_route("delete", route, tags=tags, desc=desc, title=title, deprecated=deprecated)
86
+
87
+
88
+ def http_head(
89
+ route: str,
90
+ *,
91
+ tags: Optional[Sequence[str]] = None,
92
+ desc: Optional[str] = None,
93
+ title: Optional[str] = None,
94
+ deprecated: bool = False,
95
+ ) -> Callable[[F], F]:
96
+ return _http_route("head", route, tags=tags, desc=desc, title=title, deprecated=deprecated)
97
+
98
+
99
+ def http_options(
100
+ route: str,
101
+ *,
102
+ tags: Optional[Sequence[str]] = None,
103
+ desc: Optional[str] = None,
104
+ title: Optional[str] = None,
105
+ deprecated: bool = False,
106
+ ) -> Callable[[F], F]:
107
+ return _http_route("options", route, tags=tags, desc=desc, title=title, deprecated=deprecated)
108
+
109
+
110
+ HttpGet = http_get
111
+ HttpPost = http_post
112
+ HttpPut = http_put
113
+ HttpPatch = http_patch
114
+ HttpDelete = http_delete
115
+ HttpHead = http_head
116
+ HttpOptions = http_options
@@ -241,13 +241,13 @@ def framework_headers() -> Middleware:
241
241
  hdr, "FRAMEWORK_POWERED_BY", "Fusion Framework"
242
242
  ),
243
243
  getattr(hdr, "X_FRAMEWORK", "X-Framework"): getattr(hdr, "FRAMEWORK_ID", "Fusion"),
244
- getattr(hdr, "X_FUSION_VERSION", "X-Fusion-Version"): "1.2.3",
244
+ getattr(hdr, "X_FUSION_VERSION", "X-Fusion-Version"): "1.2.4",
245
245
  }
246
246
  except Exception:
247
247
  extra = {
248
248
  "X-Powered-By": "Fusion Framework",
249
249
  "X-Framework": "Fusion",
250
- "X-Fusion-Version": "1.2.3",
250
+ "X-Fusion-Version": "1.2.4",
251
251
  }
252
252
 
253
253
  def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any:
@@ -12,15 +12,45 @@ from fusion_framework._fusion import (
12
12
  resolve_route_path,
13
13
  )
14
14
  from fusion_framework.api import FusionBaseApi
15
+ from fusion_framework.http_route import (
16
+ HttpDelete,
17
+ HttpGet,
18
+ HttpHead,
19
+ HttpOptions,
20
+ HttpPatch,
21
+ HttpPost,
22
+ HttpPut,
23
+ http_delete,
24
+ http_get,
25
+ http_head,
26
+ http_options,
27
+ http_patch,
28
+ http_post,
29
+ http_put,
30
+ )
15
31
  from fusion_framework.middleware import require_roles
16
32
 
17
33
  __all__ = [
18
34
  "HTTP_METHODS",
35
+ "HttpDelete",
36
+ "HttpGet",
37
+ "HttpHead",
38
+ "HttpOptions",
39
+ "HttpPatch",
40
+ "HttpPost",
41
+ "HttpPut",
19
42
  "api_resource_name",
43
+ "clear_registry",
44
+ "http_delete",
45
+ "http_get",
46
+ "http_head",
47
+ "http_options",
48
+ "http_patch",
49
+ "http_post",
50
+ "http_put",
20
51
  "resolve_route_path",
21
52
  "route",
22
53
  "router",
23
- "clear_registry",
24
54
  ]
25
55
 
26
56
 
@@ -0,0 +1,47 @@
1
+ """Tests for custom HTTP method routes (@http_get / HttpGet)."""
2
+
3
+ from fusion_framework._fusion import clear_routes
4
+ from fusion_framework.api import FusionBaseApi
5
+ from fusion_framework.http_route import http_get
6
+ from fusion_framework.route import route
7
+
8
+
9
+ def setup_function():
10
+ clear_routes()
11
+
12
+
13
+ def teardown_function():
14
+ clear_routes()
15
+
16
+
17
+ def test_custom_http_get_with_action_token():
18
+ @route("/api/[module]")
19
+ class UserModule(FusionBaseApi):
20
+ @http_get("test/[action]")
21
+ def UserAction(self):
22
+ return {"ok": True}
23
+
24
+ from fusion_framework._fusion import openapi_spec
25
+
26
+ spec = openapi_spec()
27
+ assert "/api/user/test/user" in spec["paths"]
28
+ assert "get" in spec["paths"]["/api/user/test/user"]
29
+ assert spec["paths"]["/api/user/test/user"]["get"]["operationId"] == "UserModule_UserAction"
30
+
31
+
32
+ def test_convention_and_custom_routes_coexist():
33
+ @route("/api/[module]")
34
+ class ProductModule(FusionBaseApi):
35
+ def get(self):
36
+ return {"mode": "convention"}
37
+
38
+ @http_get("catalog/[action]")
39
+ def ListAction(self):
40
+ return {"mode": "custom"}
41
+
42
+ from fusion_framework._fusion import openapi_spec
43
+
44
+ spec = openapi_spec()
45
+ assert "/api/product" in spec["paths"]
46
+ assert "get" in spec["paths"]["/api/product"]
47
+ assert "/api/product/catalog/list" in spec["paths"]
@@ -1,56 +0,0 @@
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
- /// Expand the reserved `[module]` token using the module class name.
27
- ///
28
- /// Example: `/api/[module]/{id}` + `ProductsModule` → `/api/products/{id}`.
29
- /// Other `{param}` / `[param]` segments are left for the router.
30
- pub fn resolve_route_path(template: &str, class_name: &str) -> String {
31
- template.replace("[module]", &api_resource_name(class_name))
32
- }
33
-
34
- #[cfg(test)]
35
- mod tests {
36
- use super::*;
37
-
38
- #[test]
39
- fn strips_module_suffix_and_lowercases() {
40
- assert_eq!(api_resource_name("ProductsModule"), "products");
41
- assert_eq!(api_resource_name("MyFirstMODULE"), "myfirst");
42
- assert_eq!(api_resource_name("Health"), "health");
43
- }
44
-
45
- #[test]
46
- fn expands_module_token() {
47
- assert_eq!(
48
- resolve_route_path("/test/[module]", "ProductsModule"),
49
- "/test/products"
50
- );
51
- assert_eq!(
52
- resolve_route_path("/api/[module]/{id}", "UserModule"),
53
- "/api/user/{id}"
54
- );
55
- }
56
- }