Skip to content

Commit dcb0e8b

Browse files
committed
feat(core/ws/openapi): complete quick-wins must-haves
1 parent a943c4a commit dcb0e8b

File tree

8 files changed

+358
-61
lines changed

8 files changed

+358
-61
lines changed

crates/rustapi-core/src/app.rs

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,20 +1072,7 @@ impl RustApi {
10721072
server.run().await
10731073
}
10741074

1075-
/// Run both HTTP/1.1 and HTTP/3 servers simultaneously
1076-
///
1077-
/// This allows clients to use either protocol. The HTTP/1.1 server
1078-
/// will advertise HTTP/3 availability via Alt-Svc header.
1079-
///
1080-
/// # Example
1081-
///
1082-
/// ```rust,ignore
1083-
/// RustApi::new()
1084-
/// .route("/", get(hello))
1085-
/// .run_dual_stack("0.0.0.0:8080", Http3Config::new("cert.pem", "key.pem"))
1086-
/// .await
1087-
/// ```
1088-
/// Configure HTTP/3 support
1075+
/// Configure HTTP/3 support for `run_http3` and `run_dual_stack`.
10891076
///
10901077
/// # Example
10911078
///
@@ -1101,10 +1088,10 @@ impl RustApi {
11011088
self
11021089
}
11031090

1104-
/// Run both HTTP/1.1 and HTTP/3 servers simultaneously
1091+
/// Run both HTTP/1.1 (TCP) and HTTP/3 (QUIC/UDP) simultaneously.
11051092
///
1106-
/// This allows clients to use either protocol. The HTTP/1.1 server
1107-
/// will advertise HTTP/3 availability via Alt-Svc header.
1093+
/// The HTTP/3 listener is bound to the same host and port as `http_addr`
1094+
/// so clients can upgrade to either protocol on one endpoint.
11081095
///
11091096
/// # Example
11101097
///
@@ -1118,22 +1105,49 @@ impl RustApi {
11181105
#[cfg(feature = "http3")]
11191106
pub async fn run_dual_stack(
11201107
mut self,
1121-
_http_addr: &str,
1108+
http_addr: &str,
11221109
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1123-
// TODO: Dual-stack requires Router, LayerStack, InterceptorChain to implement Clone.
1124-
// For now, we only run HTTP/3.
1125-
// In the future, we can either:
1126-
// 1. Make Router/LayerStack/InterceptorChain Clone
1127-
// 2. Use Arc<RwLock<...>> pattern
1128-
// 3. Create shared state mechanism
1129-
1130-
let config = self
1110+
use std::sync::Arc;
1111+
1112+
let mut config = self
11311113
.http3_config
11321114
.take()
11331115
.ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
11341116

1135-
tracing::warn!("run_dual_stack currently only runs HTTP/3. HTTP/1.1 support coming soon.");
1136-
self.run_http3(config).await
1117+
let http_socket: std::net::SocketAddr = http_addr.parse()?;
1118+
config.bind_addr = http_socket.ip().to_string();
1119+
config.port = http_socket.port();
1120+
let http_addr = http_socket.to_string();
1121+
1122+
// Apply status page if configured
1123+
self.apply_status_page();
1124+
1125+
// Apply body limit layer if configured
1126+
if let Some(limit) = self.body_limit {
1127+
self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1128+
}
1129+
1130+
let router = Arc::new(self.router);
1131+
let layers = Arc::new(self.layers);
1132+
let interceptors = Arc::new(self.interceptors);
1133+
1134+
let http1_server =
1135+
Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1136+
let http3_server =
1137+
crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1138+
1139+
tracing::info!(
1140+
http1_addr = %http_addr,
1141+
http3_addr = %config.socket_addr(),
1142+
"Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1143+
);
1144+
1145+
tokio::try_join!(
1146+
http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1147+
http3_server.run_with_shutdown(std::future::pending::<()>()),
1148+
)?;
1149+
1150+
Ok(())
11371151
}
11381152
}
11391153

crates/rustapi-core/src/server.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ impl Server {
3434
}
3535
}
3636

37+
#[cfg(feature = "http3")]
38+
pub fn from_shared(
39+
router: Arc<Router>,
40+
layers: Arc<LayerStack>,
41+
interceptors: Arc<InterceptorChain>,
42+
) -> Self {
43+
Self {
44+
router,
45+
layers,
46+
interceptors,
47+
}
48+
}
49+
3750
/// Run the server
3851
pub async fn run(self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
3952
self.run_with_shutdown(addr, std::future::pending()).await

crates/rustapi-openapi/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Automated API specifications and Swagger UI integration for RustAPI.
1111

1212
1. **Reflection**: RustAPI macros collect metadata about your routes (path, method, input types, output types) at compile time
1313
2. **Schema Gen**: Uses RustAPI's native schema engine to generate OpenAPI-compatible JSON Schemas
14-
3. **Spec Build**: At runtime, assembles the full OpenAPI 3.0 JSON specification
14+
3. **Spec Build**: At runtime, assembles the full OpenAPI 3.1 JSON specification with schema ref integrity checks
1515
4. **UI Serve**: Embeds the Swagger UI assets and serves them at your specified path
1616

1717
## Route Metadata Macros

crates/rustapi-openapi/src/tests.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,4 +267,100 @@ mod tests {
267267
let missing = result.unwrap_err();
268268
assert!(missing.contains(&"#/components/schemas/NonExistent".to_string()));
269269
}
270+
271+
#[test]
272+
fn test_ref_integrity_components_traverses_all_ref_bearing_members() {
273+
let mut spec = OpenApiSpec::new("Test", "1.0");
274+
let mut components = crate::spec::Components::default();
275+
276+
components.responses.insert(
277+
"badResponse".to_string(),
278+
crate::spec::ResponseSpec {
279+
description: "bad".to_string(),
280+
content: std::collections::BTreeMap::from([(
281+
"application/json".to_string(),
282+
crate::spec::MediaType {
283+
schema: Some(SchemaRef::Ref {
284+
reference: "#/components/schemas/MissingFromResponse".to_string(),
285+
}),
286+
example: None,
287+
},
288+
)]),
289+
headers: std::collections::BTreeMap::from([(
290+
"X-Callback".to_string(),
291+
crate::spec::Header {
292+
description: None,
293+
schema: Some(SchemaRef::Ref {
294+
reference: "#/components/schemas/MissingFromResponseHeader".to_string(),
295+
}),
296+
},
297+
)]),
298+
},
299+
);
300+
301+
components.request_bodies.insert(
302+
"badRequestBody".to_string(),
303+
crate::spec::RequestBody {
304+
description: None,
305+
required: Some(true),
306+
content: std::collections::BTreeMap::from([(
307+
"application/json".to_string(),
308+
crate::spec::MediaType {
309+
schema: Some(SchemaRef::Ref {
310+
reference: "#/components/schemas/MissingFromRequestBody".to_string(),
311+
}),
312+
example: None,
313+
},
314+
)]),
315+
},
316+
);
317+
318+
components.headers.insert(
319+
"badHeader".to_string(),
320+
crate::spec::Header {
321+
description: None,
322+
schema: Some(SchemaRef::Ref {
323+
reference: "#/components/schemas/MissingFromHeader".to_string(),
324+
}),
325+
},
326+
);
327+
328+
let mut callback_operation = crate::spec::Operation::new();
329+
callback_operation.request_body = Some(crate::spec::RequestBody {
330+
description: None,
331+
required: Some(true),
332+
content: std::collections::BTreeMap::from([(
333+
"application/json".to_string(),
334+
crate::spec::MediaType {
335+
schema: Some(SchemaRef::Ref {
336+
reference: "#/components/schemas/MissingFromCallback".to_string(),
337+
}),
338+
example: None,
339+
},
340+
)]),
341+
});
342+
343+
let mut callback_path = crate::spec::PathItem::default();
344+
callback_path.post = Some(callback_operation);
345+
346+
components.callbacks.insert(
347+
"badCallback".to_string(),
348+
std::collections::BTreeMap::from([(
349+
"{$request.body#/callbackUrl}".to_string(),
350+
callback_path,
351+
)]),
352+
);
353+
354+
spec.components = Some(components);
355+
356+
let missing = spec
357+
.validate_integrity()
358+
.expect_err("should report all missing schema refs");
359+
360+
assert!(missing.contains(&"#/components/schemas/MissingFromResponse".to_string()));
361+
assert!(missing.contains(&"#/components/schemas/MissingFromResponseHeader".to_string()));
362+
assert!(missing.contains(&"#/components/schemas/MissingFromRequestBody".to_string()));
363+
assert!(missing.contains(&"#/components/schemas/MissingFromHeader".to_string()));
364+
assert!(missing.contains(&"#/components/schemas/MissingFromCallback".to_string()));
365+
}
270366
}

crates/rustapi-ws/src/compression.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ mod tests {
6868
.client_window_bits(client_window_bits);
6969

7070
let client_extensions = if client_supports_compression {
71-
Some("permessage-deflate; client_max_window_bits".to_string())
71+
Some(
72+
"permessage-deflate; server_max_window_bits=15; client_max_window_bits"
73+
.to_string(),
74+
)
7275
} else {
7376
None
7477
};

0 commit comments

Comments
 (0)