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.
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/Cargo.lock +4 -4
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/Cargo.toml +1 -1
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/PKG-INFO +1 -1
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/lib.rs +4 -1
- fusion_framework-1.2.4/crates/fusion-core/src/naming.rs +111 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/src/api_types.rs +195 -78
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/pyproject.toml +1 -1
- fusion_framework-1.2.4/python/fusion_framework/http_route.py +116 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/middleware.py +2 -2
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/route.py +31 -1
- fusion_framework-1.2.4/python/fusion_framework/test_http_route.py +47 -0
- fusion_framework-1.2.3/crates/fusion-core/src/naming.rs +0 -56
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/README.md +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/Cargo.toml +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/api_context.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/coerce.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/dispatch.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/error.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/handler.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/headers.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/http_error.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/main.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/request.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/response.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/router.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/serialize.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/server.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/settings.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-core/src/status.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/Cargo.toml +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/README.md +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/src/json.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/crates/fusion-py/src/lib.rs +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/__init__.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/api.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/app.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/async_runtime.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/config.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/header.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/http.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/status.py +0 -0
- {fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/test_middleware.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
164
|
+
version = "1.2.4"
|
|
165
165
|
dependencies = [
|
|
166
166
|
"bytes",
|
|
167
167
|
"fusion-core",
|
|
@@ -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::{
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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<(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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.
|
|
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 (
|
|
377
|
-
for
|
|
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:
|
|
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(
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
753
|
-
let method_upper =
|
|
754
|
-
let method_lower =
|
|
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 =
|
|
907
|
+
let title = slot
|
|
788
908
|
.swagger
|
|
789
909
|
.title
|
|
790
910
|
.clone()
|
|
791
|
-
.unwrap_or_else(|| format!("{} {}", r.api_name,
|
|
911
|
+
.unwrap_or_else(|| format!("{} {}", r.api_name, slot.handler_method));
|
|
792
912
|
|
|
793
|
-
let description =
|
|
794
|
-
let 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":
|
|
917
|
+
"tags": slot.swagger.tags.clone(),
|
|
798
918
|
"summary": title,
|
|
799
919
|
"description": description,
|
|
800
|
-
"operationId": format!("{}_{}", r.api_name,
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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.
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fusion_framework-1.2.3 → fusion_framework-1.2.4}/python/fusion_framework/test_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|