spikard 0.6.2 → 0.7.1

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -508
  3. data/ext/spikard_rb/Cargo.lock +3287 -0
  4. data/ext/spikard_rb/Cargo.toml +1 -1
  5. data/ext/spikard_rb/extconf.rb +3 -3
  6. data/lib/spikard/app.rb +72 -49
  7. data/lib/spikard/background.rb +38 -7
  8. data/lib/spikard/testing.rb +42 -4
  9. data/lib/spikard/version.rb +1 -1
  10. data/sig/spikard.rbs +4 -0
  11. data/vendor/crates/spikard-bindings-shared/Cargo.toml +1 -1
  12. data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
  13. data/vendor/crates/spikard-core/Cargo.toml +1 -1
  14. data/vendor/crates/spikard-core/src/http.rs +1 -0
  15. data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
  16. data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
  17. data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
  18. data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
  19. data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
  20. data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
  21. data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
  22. data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
  23. data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
  24. data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
  25. data/vendor/crates/spikard-http/Cargo.toml +1 -1
  26. data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
  27. data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
  28. data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
  29. data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
  30. data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
  31. data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
  32. data/vendor/crates/spikard-http/src/testing.rs +171 -0
  33. data/vendor/crates/spikard-http/src/websocket.rs +79 -6
  34. data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
  35. data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
  36. data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
  37. data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
  38. data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
  39. data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
  40. data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
  41. data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
  42. data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
  43. data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
  44. data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
  45. data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
  46. data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
  47. data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
  48. data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
  49. data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
  50. data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
  51. data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
  52. data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
  53. data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
  54. data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
  55. data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
  56. data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
  57. data/vendor/crates/spikard-rb/Cargo.toml +1 -1
  58. data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
  59. data/vendor/crates/spikard-rb/src/handler.rs +12 -9
  60. data/vendor/crates/spikard-rb/src/lib.rs +137 -124
  61. data/vendor/crates/spikard-rb/src/request.rs +342 -0
  62. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
  63. data/vendor/crates/spikard-rb/src/server.rs +1 -8
  64. data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
  65. data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
  66. data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
  67. data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
  68. metadata +44 -1
@@ -0,0 +1,244 @@
1
+ use axum::body::Body;
2
+ use axum::http::{Request, StatusCode};
3
+ use jsonwebtoken::{EncodingKey, Header, encode};
4
+ use spikard_http::server::build_router_with_handlers_and_config;
5
+ use spikard_http::{ApiKeyConfig, Claims, Handler, HandlerResult, JwtConfig, Method, RequestData, Route, ServerConfig};
6
+ use std::future::Future;
7
+ use std::pin::Pin;
8
+ use std::sync::Arc;
9
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
+ use tower::ServiceExt;
11
+
12
+ struct OkHandler;
13
+
14
+ impl Handler for OkHandler {
15
+ fn call(
16
+ &self,
17
+ _request: Request<Body>,
18
+ _request_data: RequestData,
19
+ ) -> Pin<Box<dyn Future<Output = HandlerResult> + Send + '_>> {
20
+ Box::pin(async move {
21
+ Ok(axum::http::Response::builder()
22
+ .status(StatusCode::OK)
23
+ .body(Body::from("ok"))
24
+ .expect("response"))
25
+ })
26
+ }
27
+ }
28
+
29
+ fn route(method: Method, path: &str, handler_name: &str) -> Route {
30
+ Route {
31
+ method,
32
+ path: path.to_string(),
33
+ handler_name: handler_name.to_string(),
34
+ expects_json_body: false,
35
+ cors: None,
36
+ is_async: true,
37
+ file_params: None,
38
+ request_validator: None,
39
+ response_validator: None,
40
+ parameter_validator: None,
41
+ jsonrpc_method: None,
42
+ #[cfg(feature = "di")]
43
+ handler_dependencies: Vec::new(),
44
+ }
45
+ }
46
+
47
+ fn now_plus(seconds: u64) -> usize {
48
+ let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("time");
49
+ usize::try_from((now + Duration::from_secs(seconds)).as_secs()).expect("timestamp fits usize")
50
+ }
51
+
52
+ #[tokio::test]
53
+ async fn jwt_auth_layer_rejects_missing_authorization() {
54
+ let config = ServerConfig {
55
+ jwt_auth: Some(JwtConfig {
56
+ secret: "secret".to_string(),
57
+ algorithm: "HS256".to_string(),
58
+ audience: None,
59
+ issuer: None,
60
+ leeway: 0,
61
+ }),
62
+ ..Default::default()
63
+ };
64
+
65
+ let router = build_router_with_handlers_and_config(
66
+ vec![(
67
+ route(Method::Get, "/protected", "ok"),
68
+ Arc::new(OkHandler) as Arc<dyn Handler>,
69
+ )],
70
+ config,
71
+ Vec::new(),
72
+ )
73
+ .expect("router");
74
+
75
+ let response = router
76
+ .oneshot(
77
+ Request::builder()
78
+ .method("GET")
79
+ .uri("/protected")
80
+ .body(Body::empty())
81
+ .expect("request"),
82
+ )
83
+ .await
84
+ .expect("response");
85
+
86
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
87
+ }
88
+
89
+ #[tokio::test]
90
+ async fn jwt_auth_layer_accepts_valid_bearer_token() {
91
+ let secret = "secret";
92
+ let config = ServerConfig {
93
+ jwt_auth: Some(JwtConfig {
94
+ secret: secret.to_string(),
95
+ algorithm: "HS256".to_string(),
96
+ audience: None,
97
+ issuer: None,
98
+ leeway: 0,
99
+ }),
100
+ ..Default::default()
101
+ };
102
+
103
+ let claims = Claims {
104
+ sub: "user123".to_string(),
105
+ exp: now_plus(60),
106
+ iat: None,
107
+ nbf: None,
108
+ aud: None,
109
+ iss: None,
110
+ };
111
+ let token = encode(
112
+ &Header::default(),
113
+ &claims,
114
+ &EncodingKey::from_secret(secret.as_bytes()),
115
+ )
116
+ .expect("token");
117
+
118
+ let router = build_router_with_handlers_and_config(
119
+ vec![(
120
+ route(Method::Get, "/protected", "ok"),
121
+ Arc::new(OkHandler) as Arc<dyn Handler>,
122
+ )],
123
+ config,
124
+ Vec::new(),
125
+ )
126
+ .expect("router");
127
+
128
+ let response = router
129
+ .oneshot(
130
+ Request::builder()
131
+ .method("GET")
132
+ .uri("/protected")
133
+ .header("authorization", format!("Bearer {token}"))
134
+ .body(Body::empty())
135
+ .expect("request"),
136
+ )
137
+ .await
138
+ .expect("response");
139
+
140
+ assert_eq!(response.status(), StatusCode::OK);
141
+ }
142
+
143
+ #[tokio::test]
144
+ async fn api_key_auth_layer_rejects_missing_key() {
145
+ let config = ServerConfig {
146
+ api_key_auth: Some(ApiKeyConfig {
147
+ keys: vec!["k1".to_string()],
148
+ header_name: "X-API-Key".to_string(),
149
+ }),
150
+ ..Default::default()
151
+ };
152
+
153
+ let router = build_router_with_handlers_and_config(
154
+ vec![(
155
+ route(Method::Get, "/protected", "ok"),
156
+ Arc::new(OkHandler) as Arc<dyn Handler>,
157
+ )],
158
+ config,
159
+ Vec::new(),
160
+ )
161
+ .expect("router");
162
+
163
+ let response = router
164
+ .oneshot(
165
+ Request::builder()
166
+ .method("GET")
167
+ .uri("/protected")
168
+ .body(Body::empty())
169
+ .expect("request"),
170
+ )
171
+ .await
172
+ .expect("response");
173
+
174
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
175
+ }
176
+
177
+ #[tokio::test]
178
+ async fn api_key_auth_layer_accepts_valid_key_from_header() {
179
+ let config = ServerConfig {
180
+ api_key_auth: Some(ApiKeyConfig {
181
+ keys: vec!["k1".to_string()],
182
+ header_name: "X-API-Key".to_string(),
183
+ }),
184
+ ..Default::default()
185
+ };
186
+
187
+ let router = build_router_with_handlers_and_config(
188
+ vec![(
189
+ route(Method::Get, "/protected", "ok"),
190
+ Arc::new(OkHandler) as Arc<dyn Handler>,
191
+ )],
192
+ config,
193
+ Vec::new(),
194
+ )
195
+ .expect("router");
196
+
197
+ let response = router
198
+ .oneshot(
199
+ Request::builder()
200
+ .method("GET")
201
+ .uri("/protected")
202
+ .header("x-api-key", "k1")
203
+ .body(Body::empty())
204
+ .expect("request"),
205
+ )
206
+ .await
207
+ .expect("response");
208
+
209
+ assert_eq!(response.status(), StatusCode::OK);
210
+ }
211
+
212
+ #[tokio::test]
213
+ async fn api_key_auth_layer_accepts_valid_key_from_query_param() {
214
+ let config = ServerConfig {
215
+ api_key_auth: Some(ApiKeyConfig {
216
+ keys: vec!["k1".to_string()],
217
+ header_name: "X-API-Key".to_string(),
218
+ }),
219
+ ..Default::default()
220
+ };
221
+
222
+ let router = build_router_with_handlers_and_config(
223
+ vec![(
224
+ route(Method::Get, "/protected", "ok"),
225
+ Arc::new(OkHandler) as Arc<dyn Handler>,
226
+ )],
227
+ config,
228
+ Vec::new(),
229
+ )
230
+ .expect("router");
231
+
232
+ let response = router
233
+ .oneshot(
234
+ Request::builder()
235
+ .method("GET")
236
+ .uri("/protected?api_key=k1")
237
+ .body(Body::empty())
238
+ .expect("request"),
239
+ )
240
+ .await
241
+ .expect("response");
242
+
243
+ assert_eq!(response.status(), StatusCode::OK);
244
+ }
@@ -0,0 +1,200 @@
1
+ use axum::body::Body;
2
+ use http_body_util::BodyExt;
3
+ use spikard_core::JsonRpcMethodInfo;
4
+ use spikard_http::handler_trait::{Handler, HandlerResult, RequestData};
5
+ use spikard_http::jsonrpc::JsonRpcConfig;
6
+ use spikard_http::openapi::OpenApiConfig;
7
+ use spikard_http::{Method, Route, Server, ServerConfig, StaticFilesConfig};
8
+ use std::pin::Pin;
9
+ use std::sync::Arc;
10
+ use tempfile::tempdir;
11
+ use tower::ServiceExt;
12
+
13
+ struct JsonOkHandler;
14
+
15
+ impl Handler for JsonOkHandler {
16
+ fn call(
17
+ &self,
18
+ _request: axum::http::Request<Body>,
19
+ _request_data: RequestData,
20
+ ) -> Pin<Box<dyn std::future::Future<Output = HandlerResult> + Send + '_>> {
21
+ Box::pin(async move {
22
+ Ok(axum::http::Response::builder()
23
+ .status(200)
24
+ .header("content-type", "application/json")
25
+ .body(Body::from(r#"{"ok":true}"#))
26
+ .unwrap())
27
+ })
28
+ }
29
+ }
30
+
31
+ fn route(path: &str, method: Method) -> Route {
32
+ Route {
33
+ path: path.to_string(),
34
+ method,
35
+ handler_name: "ok".to_string(),
36
+ expects_json_body: true,
37
+ cors: None,
38
+ is_async: true,
39
+ file_params: None,
40
+ request_validator: None,
41
+ response_validator: None,
42
+ parameter_validator: None,
43
+ jsonrpc_method: None,
44
+ #[cfg(feature = "di")]
45
+ handler_dependencies: vec![],
46
+ }
47
+ }
48
+
49
+ #[tokio::test]
50
+ async fn server_with_openapi_and_static_files_serves_expected_endpoints() {
51
+ let dir = tempdir().unwrap();
52
+ let index_path = dir.path().join("index.html");
53
+ std::fs::write(&index_path, "<h1>hello</h1>").unwrap();
54
+
55
+ let openapi = OpenApiConfig {
56
+ enabled: true,
57
+ title: "Test".to_string(),
58
+ version: "0.4.0".to_string(),
59
+ openapi_json_path: "/openapi.json".to_string(),
60
+ swagger_ui_path: "/docs".to_string(),
61
+ redoc_path: "/redoc".to_string(),
62
+ ..Default::default()
63
+ };
64
+
65
+ let jsonrpc = JsonRpcConfig {
66
+ enabled: true,
67
+ endpoint_path: "/rpc".to_string(),
68
+ ..Default::default()
69
+ };
70
+
71
+ let config = ServerConfig {
72
+ openapi: Some(openapi),
73
+ jsonrpc: Some(jsonrpc),
74
+ static_files: vec![StaticFilesConfig {
75
+ directory: dir.path().display().to_string(),
76
+ route_prefix: "/static".to_string(),
77
+ index_file: true,
78
+ cache_control: Some("public, max-age=60".to_string()),
79
+ }],
80
+ ..Default::default()
81
+ };
82
+
83
+ let handler: Arc<dyn Handler> = Arc::new(JsonOkHandler);
84
+ let app = Server::with_handlers(config, vec![(route("/ping", Method::Get), handler)]).unwrap();
85
+
86
+ let openapi_response = app
87
+ .clone()
88
+ .oneshot(
89
+ axum::http::Request::builder()
90
+ .method("GET")
91
+ .uri("/openapi.json")
92
+ .body(Body::empty())
93
+ .unwrap(),
94
+ )
95
+ .await
96
+ .unwrap();
97
+ assert_eq!(openapi_response.status(), 200);
98
+ assert_eq!(
99
+ openapi_response
100
+ .headers()
101
+ .get("content-type")
102
+ .unwrap()
103
+ .to_str()
104
+ .unwrap(),
105
+ "application/json"
106
+ );
107
+
108
+ let openapi_body = openapi_response.into_body().collect().await.unwrap().to_bytes();
109
+ let openapi_json: serde_json::Value = serde_json::from_slice(&openapi_body).unwrap();
110
+ assert_eq!(openapi_json.get("openapi").and_then(|v| v.as_str()), Some("3.1.0"));
111
+
112
+ let docs_response = app
113
+ .clone()
114
+ .oneshot(
115
+ axum::http::Request::builder()
116
+ .method("GET")
117
+ .uri("/docs")
118
+ .body(Body::empty())
119
+ .unwrap(),
120
+ )
121
+ .await
122
+ .unwrap();
123
+ assert_eq!(docs_response.status(), 200);
124
+ let docs_body = docs_response.into_body().collect().await.unwrap().to_bytes();
125
+ assert!(String::from_utf8_lossy(&docs_body).contains("SwaggerUIBundle"));
126
+
127
+ let static_response = app
128
+ .oneshot(
129
+ axum::http::Request::builder()
130
+ .method("GET")
131
+ .uri("/static/")
132
+ .body(Body::empty())
133
+ .unwrap(),
134
+ )
135
+ .await
136
+ .unwrap();
137
+ assert_eq!(static_response.status(), 200);
138
+ assert_eq!(
139
+ static_response
140
+ .headers()
141
+ .get(axum::http::header::CACHE_CONTROL)
142
+ .unwrap()
143
+ .to_str()
144
+ .unwrap(),
145
+ "public, max-age=60"
146
+ );
147
+ let static_body = static_response.into_body().collect().await.unwrap().to_bytes();
148
+ assert!(String::from_utf8_lossy(&static_body).contains("<h1>hello</h1>"));
149
+ }
150
+
151
+ #[tokio::test]
152
+ async fn server_registers_jsonrpc_endpoint_when_method_metadata_present() {
153
+ let jsonrpc = JsonRpcConfig {
154
+ enabled: true,
155
+ endpoint_path: "/rpc".to_string(),
156
+ ..Default::default()
157
+ };
158
+
159
+ let config = ServerConfig {
160
+ jsonrpc: Some(jsonrpc),
161
+ ..Default::default()
162
+ };
163
+
164
+ let mut rpc_route = route("/rpc_method", Method::Post);
165
+ rpc_route.jsonrpc_method = Some(JsonRpcMethodInfo {
166
+ method_name: "math.ping".to_string(),
167
+ description: Some("ping".to_string()),
168
+ params_schema: None,
169
+ result_schema: None,
170
+ deprecated: false,
171
+ tags: vec!["math".to_string()],
172
+ });
173
+
174
+ let handler: Arc<dyn Handler> = Arc::new(JsonOkHandler);
175
+ let app = Server::with_handlers(config, vec![(rpc_route, handler)]).unwrap();
176
+
177
+ let response = app
178
+ .oneshot(
179
+ axum::http::Request::builder()
180
+ .method("POST")
181
+ .uri("/rpc")
182
+ .header("content-type", "application/json")
183
+ .body(Body::from(r#"{"jsonrpc":"2.0","method":"math.ping","id":1}"#))
184
+ .unwrap(),
185
+ )
186
+ .await
187
+ .unwrap();
188
+
189
+ assert_eq!(response.status(), 200);
190
+ assert_eq!(
191
+ response.headers().get("content-type").unwrap().to_str().unwrap(),
192
+ "application/json"
193
+ );
194
+
195
+ let bytes = response.into_body().collect().await.unwrap().to_bytes();
196
+ let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
197
+ assert_eq!(json.get("jsonrpc").and_then(|v| v.as_str()), Some("2.0"));
198
+ assert_eq!(json.get("id").and_then(serde_json::Value::as_i64), Some(1));
199
+ assert_eq!(json.get("result"), Some(&serde_json::json!({"ok": true})));
200
+ }
@@ -0,0 +1,82 @@
1
+ use axum::http::StatusCode;
2
+ use spikard_http::server::build_router_with_handlers_and_config;
3
+ use spikard_http::{CorsConfig, Handler, HandlerResult, RequestData, Route, ServerConfig};
4
+ use std::future::Future;
5
+ use std::pin::Pin;
6
+ use std::sync::Arc;
7
+
8
+ struct OkHandler;
9
+
10
+ impl Handler for OkHandler {
11
+ fn call(
12
+ &self,
13
+ _request: axum::http::Request<axum::body::Body>,
14
+ _request_data: RequestData,
15
+ ) -> Pin<Box<dyn Future<Output = HandlerResult> + Send + '_>> {
16
+ Box::pin(async move {
17
+ Ok(axum::http::Response::builder()
18
+ .status(StatusCode::OK)
19
+ .header("content-type", "application/json")
20
+ .body(axum::body::Body::from("{\"ok\":true}"))
21
+ .unwrap())
22
+ })
23
+ }
24
+ }
25
+
26
+ #[tokio::test]
27
+ async fn router_generates_cors_preflight_when_missing_options_handler() {
28
+ let cors = CorsConfig {
29
+ allowed_origins: vec!["https://example.com".to_string()],
30
+ allowed_methods: vec!["GET".to_string(), "OPTIONS".to_string()],
31
+ allowed_headers: vec!["x-test".to_string()],
32
+ expose_headers: None,
33
+ max_age: Some(600),
34
+ allow_credentials: Some(true),
35
+ };
36
+
37
+ let route = Route {
38
+ method: "GET".parse().unwrap(),
39
+ path: "/cors".to_string(),
40
+ handler_name: "ok".to_string(),
41
+ expects_json_body: false,
42
+ cors: Some(cors.clone()),
43
+ is_async: true,
44
+ file_params: None,
45
+ request_validator: None,
46
+ response_validator: None,
47
+ parameter_validator: None,
48
+ jsonrpc_method: None,
49
+ #[cfg(feature = "di")]
50
+ handler_dependencies: vec![],
51
+ };
52
+
53
+ let config = ServerConfig::default();
54
+ let router =
55
+ build_router_with_handlers_and_config(vec![(route, Arc::new(OkHandler))], config, Vec::new()).expect("router");
56
+
57
+ let server = axum_test::TestServer::new(router).unwrap();
58
+
59
+ let preflight = server
60
+ .method(axum::http::Method::OPTIONS, "/cors")
61
+ .add_header("origin", "https://example.com")
62
+ .add_header("access-control-request-method", "GET")
63
+ .add_header("access-control-request-headers", "x-test")
64
+ .await;
65
+
66
+ assert_eq!(preflight.status_code(), StatusCode::NO_CONTENT);
67
+ assert_eq!(
68
+ preflight.header("access-control-allow-origin").to_str().unwrap(),
69
+ "https://example.com"
70
+ );
71
+ assert!(
72
+ preflight
73
+ .header("access-control-allow-methods")
74
+ .to_str()
75
+ .unwrap()
76
+ .contains("GET")
77
+ );
78
+
79
+ let response = server.get("/cors").add_header("origin", "https://example.com").await;
80
+ assert_eq!(response.status_code(), StatusCode::OK);
81
+ assert!(response.text().contains("\"ok\":true"));
82
+ }