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.
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/Cargo.lock +4 -4
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/Cargo.toml +1 -1
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/PKG-INFO +1 -1
- {fusion_framework-1.2.2 → 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.2 → fusion_framework-1.2.4}/crates/fusion-py/src/api_types.rs +258 -82
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/src/lib.rs +18 -2
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/pyproject.toml +1 -1
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/app.py +101 -19
- fusion_framework-1.2.4/python/fusion_framework/http_route.py +116 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/middleware.py +2 -2
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/route.py +33 -1
- fusion_framework-1.2.4/python/fusion_framework/test_http_route.py +47 -0
- fusion_framework-1.2.4/python/fusion_framework/test_swagger.py +77 -0
- fusion_framework-1.2.2/crates/fusion-core/src/naming.rs +0 -56
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/README.md +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/Cargo.toml +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/api_context.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/coerce.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/dispatch.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/error.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/handler.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/headers.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/http_error.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/main.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/request.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/response.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/router.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/serialize.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/server.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/settings.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-core/src/status.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/Cargo.toml +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/README.md +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/crates/fusion-py/src/json.rs +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/__init__.py +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/api.py +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/async_runtime.py +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/config.py +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/header.py +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/http.py +0 -0
- {fusion_framework-1.2.2 → fusion_framework-1.2.4}/python/fusion_framework/status.py +0 -0
- {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.
|
|
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,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
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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<(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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.
|
|
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 (
|
|
330
|
-
for
|
|
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:
|
|
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(
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
let method_upper =
|
|
695
|
-
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();
|
|
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 =
|
|
907
|
+
let title = slot
|
|
729
908
|
.swagger
|
|
730
909
|
.title
|
|
731
910
|
.clone()
|
|
732
|
-
.unwrap_or_else(|| format!("{} {}", r.api_name,
|
|
911
|
+
.unwrap_or_else(|| format!("{} {}", r.api_name, slot.handler_method));
|
|
733
912
|
|
|
734
|
-
let description =
|
|
735
|
-
let 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":
|
|
917
|
+
"tags": slot.swagger.tags.clone(),
|
|
739
918
|
"summary": title,
|
|
740
919
|
"description": description,
|
|
741
|
-
"operationId": format!("{}_{}", r.api_name,
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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.
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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)?;
|