spikard 0.6.2 → 0.7.2
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.
- checksums.yaml +4 -4
- data/README.md +90 -508
- data/ext/spikard_rb/Cargo.lock +3287 -0
- data/ext/spikard_rb/Cargo.toml +1 -1
- data/ext/spikard_rb/extconf.rb +3 -3
- data/lib/spikard/app.rb +72 -49
- data/lib/spikard/background.rb +38 -7
- data/lib/spikard/testing.rb +42 -4
- data/lib/spikard/version.rb +1 -1
- data/sig/spikard.rbs +4 -0
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +2 -2
- data/vendor/crates/spikard-bindings-shared/tests/config_extractor_behavior.rs +191 -0
- data/vendor/crates/spikard-core/Cargo.toml +1 -1
- data/vendor/crates/spikard-core/src/http.rs +1 -0
- data/vendor/crates/spikard-core/src/lifecycle.rs +63 -0
- data/vendor/crates/spikard-core/tests/bindings_response_tests.rs +136 -0
- data/vendor/crates/spikard-core/tests/di_dependency_defaults.rs +37 -0
- data/vendor/crates/spikard-core/tests/error_mapper.rs +761 -0
- data/vendor/crates/spikard-core/tests/parameters_edge_cases.rs +106 -0
- data/vendor/crates/spikard-core/tests/parameters_full.rs +701 -0
- data/vendor/crates/spikard-core/tests/parameters_schema_and_formats.rs +301 -0
- data/vendor/crates/spikard-core/tests/request_data_roundtrip.rs +67 -0
- data/vendor/crates/spikard-core/tests/validation_coverage.rs +250 -0
- data/vendor/crates/spikard-core/tests/validation_error_paths.rs +45 -0
- data/vendor/crates/spikard-http/Cargo.toml +1 -1
- data/vendor/crates/spikard-http/src/jsonrpc/http_handler.rs +502 -0
- data/vendor/crates/spikard-http/src/jsonrpc/method_registry.rs +648 -0
- data/vendor/crates/spikard-http/src/jsonrpc/mod.rs +58 -0
- data/vendor/crates/spikard-http/src/jsonrpc/protocol.rs +1207 -0
- data/vendor/crates/spikard-http/src/jsonrpc/router.rs +2262 -0
- data/vendor/crates/spikard-http/src/testing/test_client.rs +155 -2
- data/vendor/crates/spikard-http/src/testing.rs +171 -0
- data/vendor/crates/spikard-http/src/websocket.rs +79 -6
- data/vendor/crates/spikard-http/tests/auth_integration.rs +647 -0
- data/vendor/crates/spikard-http/tests/common/test_builders.rs +633 -0
- data/vendor/crates/spikard-http/tests/di_handler_error_responses.rs +162 -0
- data/vendor/crates/spikard-http/tests/middleware_stack_integration.rs +389 -0
- data/vendor/crates/spikard-http/tests/request_extraction_full.rs +513 -0
- data/vendor/crates/spikard-http/tests/server_auth_middleware_behavior.rs +244 -0
- data/vendor/crates/spikard-http/tests/server_configured_router_behavior.rs +200 -0
- data/vendor/crates/spikard-http/tests/server_cors_preflight.rs +82 -0
- data/vendor/crates/spikard-http/tests/server_handler_wrappers.rs +464 -0
- data/vendor/crates/spikard-http/tests/server_method_router_additional_behavior.rs +286 -0
- data/vendor/crates/spikard-http/tests/server_method_router_coverage.rs +118 -0
- data/vendor/crates/spikard-http/tests/server_middleware_behavior.rs +99 -0
- data/vendor/crates/spikard-http/tests/server_middleware_branches.rs +206 -0
- data/vendor/crates/spikard-http/tests/server_openapi_jsonrpc_static.rs +281 -0
- data/vendor/crates/spikard-http/tests/server_router_behavior.rs +121 -0
- data/vendor/crates/spikard-http/tests/sse_full_behavior.rs +584 -0
- data/vendor/crates/spikard-http/tests/sse_handler_behavior.rs +130 -0
- data/vendor/crates/spikard-http/tests/test_client_requests.rs +167 -0
- data/vendor/crates/spikard-http/tests/testing_helpers.rs +87 -0
- data/vendor/crates/spikard-http/tests/testing_module_coverage.rs +156 -0
- data/vendor/crates/spikard-http/tests/urlencoded_content_type.rs +82 -0
- data/vendor/crates/spikard-http/tests/websocket_full_behavior.rs +440 -0
- data/vendor/crates/spikard-http/tests/websocket_integration.rs +152 -0
- data/vendor/crates/spikard-rb/Cargo.toml +1 -1
- data/vendor/crates/spikard-rb/src/gvl.rs +80 -0
- data/vendor/crates/spikard-rb/src/handler.rs +12 -9
- data/vendor/crates/spikard-rb/src/lib.rs +137 -124
- data/vendor/crates/spikard-rb/src/request.rs +342 -0
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +1 -8
- data/vendor/crates/spikard-rb/src/server.rs +1 -8
- data/vendor/crates/spikard-rb/src/testing/client.rs +168 -9
- data/vendor/crates/spikard-rb/src/websocket.rs +119 -30
- data/vendor/crates/spikard-rb-macros/Cargo.toml +14 -0
- data/vendor/crates/spikard-rb-macros/src/lib.rs +52 -0
- metadata +44 -1
data/README.md
CHANGED
|
@@ -7,20 +7,18 @@
|
|
|
7
7
|
[](https://codecov.io/gh/Goldziher/spikard)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Ruby bindings for Spikard: a Rust-centric web framework with type-safe code generation from OpenAPI, GraphQL, AsyncAPI, and OpenRPC specifications. Leverage Sinatra-style routing with zero-copy FFI performance.
|
|
11
11
|
|
|
12
|
-
## Features
|
|
12
|
+
## Key Features
|
|
13
13
|
|
|
14
|
-
- **Rust-powered performance**: High-throughput HTTP server backed by Tokio and Axum
|
|
15
|
-
- **Sinatra-style routing**: Familiar `get`, `post`, `put`, `patch`, `delete` DSL
|
|
16
14
|
- **Type-safe with RBS**: Full RBS type definitions for Steep type checking
|
|
17
|
-
- **Zero-copy
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **Dependency injection
|
|
15
|
+
- **Zero-copy FFI**: Magnus/rb-sys bindings eliminate serialization overhead
|
|
16
|
+
- **Sinatra-style routing**: Familiar `get`, `post`, `put`, `patch`, `delete` DSL
|
|
17
|
+
- **Code generation**: Generate type-safe handlers from OpenAPI, GraphQL, AsyncAPI, and OpenRPC specs
|
|
18
|
+
- **Full async support**: Non-blocking handlers with complete async/await integration
|
|
19
|
+
- **Tower-HTTP middleware**: Compression, rate limiting, authentication, CORS, request IDs
|
|
20
|
+
- **Real-time**: WebSockets and Server-Sent Events (SSE)
|
|
21
|
+
- **Production-ready**: Dependency injection, validation schemas, lifecycle hooks
|
|
24
22
|
|
|
25
23
|
## Installation
|
|
26
24
|
|
|
@@ -30,576 +28,203 @@ High-performance Ruby web framework with a Rust core. Build REST APIs with Sinat
|
|
|
30
28
|
gem install spikard
|
|
31
29
|
```
|
|
32
30
|
|
|
33
|
-
**From source
|
|
31
|
+
**From source:**
|
|
34
32
|
|
|
35
33
|
```bash
|
|
36
|
-
cd packages/ruby
|
|
37
34
|
bundle install
|
|
38
35
|
bundle exec rake ext:build
|
|
39
36
|
```
|
|
40
37
|
|
|
41
|
-
**Requirements:**
|
|
42
|
-
- Ruby 3.2 or later
|
|
43
|
-
- Bundler
|
|
44
|
-
- Rust toolchain (for building from source)
|
|
45
|
-
|
|
46
|
-
## Windows Development
|
|
47
|
-
|
|
48
|
-
On Windows, Spikard uses the GNU toolchain (not MSVC) to match Ruby's official RubyInstaller distribution.
|
|
49
|
-
|
|
50
|
-
### Prerequisites
|
|
51
|
-
|
|
52
|
-
1. **Install RubyInstaller with DevKit:**
|
|
53
|
-
- Download from [RubyInstaller.org](https://rubyinstaller.org/downloads/)
|
|
54
|
-
- Choose Ruby+Devkit 3.2.x (x64)
|
|
55
|
-
- During installation, select "MSYS2 development toolchain"
|
|
56
|
-
|
|
57
|
-
2. **Install Rust with GNU target:**
|
|
58
|
-
```powershell
|
|
59
|
-
rustup toolchain install stable-x86_64-pc-windows-gnu
|
|
60
|
-
rustup default stable-x86_64-pc-windows-gnu
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
3. **Verify setup:**
|
|
64
|
-
```powershell
|
|
65
|
-
ruby --version # Should show 3.2.x
|
|
66
|
-
rustup show # Should show *-pc-windows-gnu
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Building on Windows
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
cd packages/ruby
|
|
73
|
-
bundle install
|
|
74
|
-
bundle exec rake compile
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
The build uses the GNU toolchain automatically via RubyInstaller's MSYS2 DevKit. No MSVC configuration needed.
|
|
38
|
+
**Requirements:** Ruby 3.2+, Bundler, and Rust toolchain (for building from source). On Windows, use RubyInstaller with MSYS2 DevKit and the GNU Rust toolchain.
|
|
78
39
|
|
|
79
40
|
## Quick Start
|
|
80
41
|
|
|
81
42
|
```ruby
|
|
82
43
|
require "spikard"
|
|
83
|
-
require "dry-schema"
|
|
84
|
-
|
|
85
|
-
UserSchema = Dry::Schema.JSON do
|
|
86
|
-
required(:name).filled(:str?)
|
|
87
|
-
required(:email).filled(:str?)
|
|
88
|
-
end
|
|
89
44
|
|
|
90
45
|
app = Spikard::App.new
|
|
91
46
|
|
|
92
|
-
app.get "/
|
|
93
|
-
|
|
94
|
-
{ id: user_id, name: "Alice" }
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
app.post "/users", request_schema: UserSchema do |request|
|
|
98
|
-
body = request[:body]
|
|
99
|
-
{ id: 1, name: body["name"], email: body["email"] }
|
|
47
|
+
app.get "/hello" do |request|
|
|
48
|
+
{ message: "Hello, World!" }
|
|
100
49
|
end
|
|
101
50
|
|
|
102
|
-
app.run(port: 8000)
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
## Request Hash Structure
|
|
106
|
-
|
|
107
|
-
Handlers receive a single `request` hash argument with the following keys:
|
|
108
|
-
|
|
109
|
-
- `:method` - HTTP method (String): `"GET"`, `"POST"`, etc.
|
|
110
|
-
- `:path` - URL path (String): `"/users/123"`
|
|
111
|
-
- `:path_params` - Path parameters (Hash): `{"id" => "123"}`
|
|
112
|
-
- `:query` - Query parameters (Hash): `{"search" => "ruby"}`
|
|
113
|
-
- `:raw_query` - Raw query multimap (Hash of Arrays)
|
|
114
|
-
- `:headers` - Request headers (Hash): `{"Authorization" => "Bearer..."}`
|
|
115
|
-
- `:cookies` - Request cookies (Hash): `{"session_id" => "..."}`
|
|
116
|
-
- `:body` - Parsed request body (Hash or nil)
|
|
117
|
-
- `:params` - Merged params from path, query, headers, and cookies
|
|
118
|
-
|
|
119
|
-
**Example:**
|
|
120
|
-
|
|
121
|
-
```ruby
|
|
122
51
|
app.get "/users/:id" do |request|
|
|
123
52
|
user_id = request[:path_params]["id"]
|
|
124
|
-
|
|
125
|
-
auth = request[:headers]["Authorization"]
|
|
126
|
-
|
|
127
|
-
{ id: user_id, search: search }
|
|
128
|
-
end
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
## Route Registration
|
|
132
|
-
|
|
133
|
-
### HTTP Methods
|
|
134
|
-
|
|
135
|
-
```ruby
|
|
136
|
-
app.get "/path" do |request|
|
|
137
|
-
# Handler code
|
|
138
|
-
{ method: request[:method] }
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
app.post "/path" do |request|
|
|
142
|
-
{ created: true }
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
app.put "/path" do |request|
|
|
146
|
-
{ updated: true }
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
app.patch "/path" do |request|
|
|
150
|
-
{ patched: true }
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
app.delete "/path" do |request|
|
|
154
|
-
{ deleted: true }
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
app.options "/path" do |request|
|
|
158
|
-
{ options: [] }
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
app.head "/path" do |request|
|
|
162
|
-
# HEAD request
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
app.trace "/path" do |request|
|
|
166
|
-
# TRACE request
|
|
53
|
+
{ id: user_id, name: "Alice" }
|
|
167
54
|
end
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### Path Parameters
|
|
171
55
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
{ user_id: request[:path_params]["user_id"].to_i }
|
|
56
|
+
app.post "/users" do |request|
|
|
57
|
+
{ id: 1, name: request[:body]["name"] }
|
|
175
58
|
end
|
|
176
59
|
|
|
177
|
-
app.
|
|
178
|
-
{
|
|
179
|
-
post_id: request[:path_params]["post_id"].to_i,
|
|
180
|
-
comment_id: request[:path_params]["comment_id"].to_i
|
|
181
|
-
}
|
|
182
|
-
end
|
|
60
|
+
app.run(port: 8000)
|
|
183
61
|
```
|
|
184
62
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
```
|
|
63
|
+
The `request` hash provides access to:
|
|
64
|
+
- `request[:method]` - HTTP method
|
|
65
|
+
- `request[:path]` - URL path
|
|
66
|
+
- `request[:path_params]` - Path parameters
|
|
67
|
+
- `request[:query]` - Query parameters
|
|
68
|
+
- `request[:headers]` - Request headers
|
|
69
|
+
- `request[:cookies]` - Request cookies
|
|
70
|
+
- `request[:body]` - Parsed request body
|
|
194
71
|
|
|
195
72
|
## Validation
|
|
196
73
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
### With dry-schema
|
|
74
|
+
Pass a `request_schema` to validate incoming JSON:
|
|
200
75
|
|
|
201
76
|
```ruby
|
|
202
77
|
require "dry-schema"
|
|
203
|
-
Dry::Schema.load_extensions(:json_schema)
|
|
204
78
|
|
|
205
79
|
UserSchema = Dry::Schema.JSON do
|
|
206
80
|
required(:name).filled(:str?)
|
|
207
81
|
required(:email).filled(:str?)
|
|
208
|
-
required(:age).filled(:int?)
|
|
209
82
|
end
|
|
210
83
|
|
|
211
84
|
app.post "/users", request_schema: UserSchema do |request|
|
|
212
|
-
# request[:body] is validated against schema
|
|
213
85
|
{ id: 1, name: request[:body]["name"] }
|
|
214
86
|
end
|
|
215
87
|
```
|
|
216
88
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
```ruby
|
|
220
|
-
user_schema = {
|
|
221
|
-
"type" => "object",
|
|
222
|
-
"properties" => {
|
|
223
|
-
"name" => { "type" => "string" },
|
|
224
|
-
"email" => { "type" => "string", "format" => "email" }
|
|
225
|
-
},
|
|
226
|
-
"required" => ["name", "email"]
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
app.post "/users", request_schema: user_schema do |request|
|
|
230
|
-
{ id: 1, name: request[:body]["name"], email: request[:body]["email"] }
|
|
231
|
-
end
|
|
232
|
-
```
|
|
89
|
+
Also supports raw JSON Schema objects and dry-struct schemas.
|
|
233
90
|
|
|
234
91
|
## Dependency Injection
|
|
235
92
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
```ruby
|
|
239
|
-
app.provide("config", { "db_url" => "postgresql://localhost/app" })
|
|
240
|
-
app.provide("db_pool", depends_on: ["config"], singleton: true) do |config:|
|
|
241
|
-
{ url: config["db_url"], driver: "pool" }
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
app.get "/stats" do |request, config:, db_pool:|
|
|
245
|
-
{ db: db_pool[:url], env: config["db_url"] }
|
|
246
|
-
end
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
### With dry-struct
|
|
93
|
+
Inject dependencies as keyword parameters:
|
|
250
94
|
|
|
251
95
|
```ruby
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
module Types
|
|
256
|
-
include Dry.Types()
|
|
257
|
-
end
|
|
96
|
+
app.provide("config", { "db_url" => "postgresql://localhost" })
|
|
97
|
+
app.provide("db", depends_on: ["config"], singleton: true) { |config:| Pool.new(config) }
|
|
258
98
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
attribute :email, Types::String
|
|
262
|
-
attribute :age, Types::Integer
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
app.post "/users", request_schema: User do |request|
|
|
266
|
-
# request[:body] validated as User
|
|
267
|
-
{ id: 1, name: request[:body]["name"] }
|
|
99
|
+
app.get "/data" do |request, config:, db:|
|
|
100
|
+
{ url: config["db_url"] }
|
|
268
101
|
end
|
|
269
102
|
```
|
|
270
103
|
|
|
271
|
-
##
|
|
104
|
+
## Responses
|
|
272
105
|
|
|
273
|
-
|
|
106
|
+
Return a Hash, String, or Response object:
|
|
274
107
|
|
|
275
108
|
```ruby
|
|
109
|
+
# Simple hash (auto-serialized to JSON)
|
|
276
110
|
app.get "/hello" do
|
|
277
111
|
{ message: "Hello, World!" }
|
|
278
112
|
end
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### String Response
|
|
282
113
|
|
|
283
|
-
|
|
284
|
-
app.get "/text" do
|
|
285
|
-
"Plain text response"
|
|
286
|
-
end
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
### Full Response Object
|
|
290
|
-
|
|
291
|
-
```ruby
|
|
114
|
+
# Custom status and headers
|
|
292
115
|
app.post "/users" do |request|
|
|
293
116
|
Spikard::Response.new(
|
|
294
|
-
content: { id: 1
|
|
117
|
+
content: { id: 1 },
|
|
295
118
|
status_code: 201,
|
|
296
119
|
headers: { "X-Custom" => "value" }
|
|
297
120
|
)
|
|
298
121
|
end
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
### Streaming Response
|
|
302
122
|
|
|
303
|
-
|
|
123
|
+
# Streaming response
|
|
304
124
|
app.get "/stream" do
|
|
305
|
-
stream = Enumerator.new
|
|
306
|
-
|
|
307
|
-
yielder << "Chunk #{i}\n"
|
|
308
|
-
sleep 0.1
|
|
309
|
-
end
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
Spikard::StreamingResponse.new(
|
|
313
|
-
stream,
|
|
314
|
-
status_code: 200,
|
|
315
|
-
headers: { "Content-Type" => "text/plain" }
|
|
316
|
-
)
|
|
125
|
+
stream = Enumerator.new { |y| y << "data" }
|
|
126
|
+
Spikard::StreamingResponse.new(stream)
|
|
317
127
|
end
|
|
318
|
-
```
|
|
319
128
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
```ruby
|
|
129
|
+
# File uploads
|
|
323
130
|
app.post "/upload", file_params: true do |request|
|
|
324
|
-
file = request[:body]["file"]
|
|
325
|
-
|
|
326
|
-
{
|
|
327
|
-
filename: file.filename,
|
|
328
|
-
size: file.size,
|
|
329
|
-
content_type: file.content_type,
|
|
330
|
-
content: file.read
|
|
331
|
-
}
|
|
131
|
+
file = request[:body]["file"]
|
|
132
|
+
{ filename: file.filename, size: file.size }
|
|
332
133
|
end
|
|
333
134
|
```
|
|
334
135
|
|
|
335
136
|
## Configuration
|
|
336
137
|
|
|
138
|
+
Configure the server with middleware options:
|
|
139
|
+
|
|
337
140
|
```ruby
|
|
338
141
|
config = Spikard::ServerConfig.new(
|
|
339
142
|
host: "0.0.0.0",
|
|
340
143
|
port: 8080,
|
|
341
144
|
workers: 4,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
quality: 6
|
|
349
|
-
),
|
|
350
|
-
rate_limit: Spikard::RateLimitConfig.new(
|
|
351
|
-
per_second: 100,
|
|
352
|
-
burst: 200
|
|
353
|
-
)
|
|
145
|
+
compression: Spikard::CompressionConfig.new(gzip: true, brotli: true),
|
|
146
|
+
rate_limit: Spikard::RateLimitConfig.new(per_second: 100),
|
|
147
|
+
jwt: Spikard::JwtConfig.new(secret: "key", algorithm: "HS256"),
|
|
148
|
+
static_files: Spikard::StaticFilesConfig.new(directory: "./public"),
|
|
149
|
+
max_body_size: 10 * 1024 * 1024,
|
|
150
|
+
request_timeout: 30
|
|
354
151
|
)
|
|
355
152
|
|
|
356
153
|
app.run(config: config)
|
|
357
154
|
```
|
|
358
155
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
**Compression:**
|
|
362
|
-
|
|
363
|
-
```ruby
|
|
364
|
-
compression = Spikard::CompressionConfig.new(
|
|
365
|
-
gzip: true,
|
|
366
|
-
brotli: true,
|
|
367
|
-
min_size: 1024,
|
|
368
|
-
quality: 6
|
|
369
|
-
)
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
**Rate Limiting:**
|
|
373
|
-
|
|
374
|
-
```ruby
|
|
375
|
-
rate_limit = Spikard::RateLimitConfig.new(
|
|
376
|
-
per_second: 100,
|
|
377
|
-
burst: 200,
|
|
378
|
-
ip_based: true
|
|
379
|
-
)
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
**JWT Authentication:**
|
|
156
|
+
See [Configuration Reference](lib/spikard/config.rb) for full options.
|
|
383
157
|
|
|
384
|
-
|
|
385
|
-
jwt = Spikard::JwtConfig.new(
|
|
386
|
-
secret: "your-secret-key",
|
|
387
|
-
algorithm: "HS256",
|
|
388
|
-
audience: ["api.example.com"],
|
|
389
|
-
issuer: "auth.example.com",
|
|
390
|
-
leeway: 30
|
|
391
|
-
)
|
|
392
|
-
```
|
|
158
|
+
## Lifecycle Hooks
|
|
393
159
|
|
|
394
|
-
|
|
160
|
+
Execute logic at key points in the request lifecycle:
|
|
395
161
|
|
|
396
162
|
```ruby
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
)
|
|
163
|
+
app.on_request { |req| puts "#{req[:method]} #{req[:path]}" }
|
|
164
|
+
app.pre_validation { |req| req }
|
|
165
|
+
app.pre_handler { |req| req }
|
|
166
|
+
app.on_response { |res| res }
|
|
167
|
+
app.on_error { |res| res }
|
|
403
168
|
```
|
|
404
169
|
|
|
405
|
-
|
|
170
|
+
Return a Request/Response object to continue, or a Response to short-circuit.
|
|
406
171
|
|
|
407
|
-
|
|
408
|
-
openapi = Spikard::OpenApiConfig.new(
|
|
409
|
-
enabled: true,
|
|
410
|
-
title: "My API",
|
|
411
|
-
version: "1.0.0",
|
|
412
|
-
description: "API docs",
|
|
413
|
-
swagger_ui_path: "/docs",
|
|
414
|
-
redoc_path: "/redoc"
|
|
415
|
-
)
|
|
416
|
-
```
|
|
172
|
+
## Real-Time Communication
|
|
417
173
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
```ruby
|
|
421
|
-
app.on_request do |request|
|
|
422
|
-
puts "#{request[:method]} #{request[:path]}"
|
|
423
|
-
request
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
app.pre_validation do |request|
|
|
427
|
-
if too_many_requests?
|
|
428
|
-
Spikard::Response.new(
|
|
429
|
-
content: { error: "Rate limit exceeded" },
|
|
430
|
-
status_code: 429
|
|
431
|
-
)
|
|
432
|
-
else
|
|
433
|
-
request
|
|
434
|
-
end
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
app.pre_handler do |request|
|
|
438
|
-
if invalid_token?(request[:headers]["Authorization"])
|
|
439
|
-
Spikard::Response.new(
|
|
440
|
-
content: { error: "Unauthorized" },
|
|
441
|
-
status_code: 401
|
|
442
|
-
)
|
|
443
|
-
else
|
|
444
|
-
request
|
|
445
|
-
end
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
app.on_response do |response|
|
|
449
|
-
response.headers["X-Frame-Options"] = "DENY"
|
|
450
|
-
response
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
app.on_error do |response|
|
|
454
|
-
puts "Error: #{response.status_code}"
|
|
455
|
-
response
|
|
456
|
-
end
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
## WebSockets
|
|
174
|
+
**WebSockets:**
|
|
460
175
|
|
|
461
176
|
```ruby
|
|
462
177
|
class ChatHandler < Spikard::WebSocketHandler
|
|
463
|
-
def
|
|
464
|
-
puts "Client connected"
|
|
465
|
-
end
|
|
466
|
-
|
|
467
|
-
def handle_message(message)
|
|
468
|
-
# message is a Hash (parsed JSON)
|
|
469
|
-
{ echo: message, timestamp: Time.now.to_i }
|
|
470
|
-
end
|
|
471
|
-
|
|
472
|
-
def on_disconnect
|
|
473
|
-
puts "Client disconnected"
|
|
474
|
-
end
|
|
178
|
+
def handle_message(message) = { echo: message }
|
|
475
179
|
end
|
|
476
180
|
|
|
477
181
|
app.websocket("/chat") { ChatHandler.new }
|
|
478
182
|
```
|
|
479
183
|
|
|
480
|
-
|
|
184
|
+
**Server-Sent Events:**
|
|
481
185
|
|
|
482
186
|
```ruby
|
|
483
|
-
class
|
|
484
|
-
def
|
|
485
|
-
@count = 0
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
def on_connect
|
|
489
|
-
puts "Client connected to SSE stream"
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
def next_event
|
|
493
|
-
sleep 1
|
|
494
|
-
|
|
495
|
-
return nil if @count >= 10 # End stream
|
|
496
|
-
|
|
497
|
-
event = Spikard::SseEvent.new(
|
|
498
|
-
data: { message: "Notification #{@count}" },
|
|
499
|
-
event_type: "notification",
|
|
500
|
-
id: @count.to_s,
|
|
501
|
-
retry_ms: 3000
|
|
502
|
-
)
|
|
503
|
-
@count += 1
|
|
504
|
-
event
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
def on_disconnect
|
|
508
|
-
puts "Client disconnected from SSE"
|
|
509
|
-
end
|
|
187
|
+
class Events < Spikard::SseEventProducer
|
|
188
|
+
def next_event = Spikard::SseEvent.new(data: { msg: "Hello" })
|
|
510
189
|
end
|
|
511
190
|
|
|
512
|
-
app.sse("/
|
|
191
|
+
app.sse("/events") { Events.new }
|
|
513
192
|
```
|
|
514
193
|
|
|
515
194
|
## Background Tasks
|
|
516
195
|
|
|
517
|
-
|
|
518
|
-
app.post "/process" do |request|
|
|
519
|
-
Spikard::Background.run do
|
|
520
|
-
# Heavy processing after response
|
|
521
|
-
ProcessData.perform(request[:path_params]["id"])
|
|
522
|
-
end
|
|
196
|
+
Offload work after sending response:
|
|
523
197
|
|
|
198
|
+
```ruby
|
|
199
|
+
app.post "/process" do
|
|
200
|
+
Spikard::Background.run { perform_long_task }
|
|
524
201
|
{ status: "processing" }
|
|
525
202
|
end
|
|
526
203
|
```
|
|
527
204
|
|
|
528
205
|
## Testing
|
|
529
206
|
|
|
530
|
-
|
|
531
|
-
require "spikard"
|
|
532
|
-
|
|
533
|
-
app = Spikard::App.new
|
|
534
|
-
app.get "/hello" do
|
|
535
|
-
{ message: "Hello, World!" }
|
|
536
|
-
end
|
|
207
|
+
Use the TestClient for integration tests:
|
|
537
208
|
|
|
209
|
+
```ruby
|
|
538
210
|
client = Spikard::TestClient.new(app)
|
|
539
211
|
|
|
540
212
|
# HTTP requests
|
|
541
213
|
response = client.get("/hello", query: { name: "Alice" })
|
|
542
|
-
puts response.status_code #
|
|
543
|
-
puts response.json #
|
|
214
|
+
puts response.status_code # 200
|
|
215
|
+
puts response.json # { "message" => "Hello, World!" }
|
|
544
216
|
|
|
545
|
-
# POST
|
|
217
|
+
# POST, WebSocket, SSE all supported
|
|
546
218
|
response = client.post("/users", json: { name: "Bob" })
|
|
547
|
-
|
|
548
|
-
# File upload
|
|
549
|
-
response = client.post("/upload", files: {
|
|
550
|
-
file: ["test.txt", "content", "text/plain"]
|
|
551
|
-
})
|
|
552
|
-
|
|
553
|
-
# WebSocket
|
|
554
219
|
ws = client.websocket("/chat")
|
|
555
|
-
ws.send_json({ message: "hello" })
|
|
556
|
-
message = ws.receive_json
|
|
557
|
-
ws.close
|
|
558
|
-
|
|
559
|
-
# SSE
|
|
560
220
|
sse = client.sse("/events")
|
|
561
|
-
events = sse.events_as_json
|
|
562
|
-
puts events.length
|
|
563
221
|
|
|
564
|
-
# Cleanup
|
|
565
222
|
client.close
|
|
566
223
|
```
|
|
567
224
|
|
|
568
|
-
##
|
|
225
|
+
## Type Safety with RBS & Steep
|
|
569
226
|
|
|
570
|
-
|
|
571
|
-
# Development
|
|
572
|
-
app.run(port: 8000)
|
|
573
|
-
|
|
574
|
-
# Production
|
|
575
|
-
config = Spikard::ServerConfig.new(
|
|
576
|
-
host: "0.0.0.0",
|
|
577
|
-
port: 8080,
|
|
578
|
-
workers: 4
|
|
579
|
-
)
|
|
580
|
-
app.run(config: config)
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
## Type Safety with RBS
|
|
584
|
-
|
|
585
|
-
RBS type signatures are provided in `sig/spikard.rbs`:
|
|
586
|
-
|
|
587
|
-
```ruby
|
|
588
|
-
module Spikard
|
|
589
|
-
class App
|
|
590
|
-
def initialize: () -> void
|
|
591
|
-
def get: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
|
|
592
|
-
def post: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
|
|
593
|
-
def run: (?config: ServerConfig | Hash[Symbol, untyped]?) -> void
|
|
594
|
-
end
|
|
595
|
-
|
|
596
|
-
class ServerConfig
|
|
597
|
-
def initialize: (?host: String, ?port: Integer, **untyped) -> void
|
|
598
|
-
end
|
|
599
|
-
end
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
Use with Steep for type checking:
|
|
227
|
+
Full RBS type definitions are included in `sig/spikard.rbs`. Use Steep for type checking:
|
|
603
228
|
|
|
604
229
|
```bash
|
|
605
230
|
bundle exec steep check
|
|
@@ -607,67 +232,24 @@ bundle exec steep check
|
|
|
607
232
|
|
|
608
233
|
## Performance
|
|
609
234
|
|
|
610
|
-
|
|
611
|
-
- **Magnus** for zero-overhead FFI
|
|
612
|
-
- **rb-sys** for modern Ruby 3.2+ integration
|
|
613
|
-
- Idiomatic Ruby blocks and procs
|
|
614
|
-
- GC-safe handler storage
|
|
615
|
-
|
|
616
|
-
### CI Benchmarks (2025-12-20)
|
|
617
|
-
|
|
618
|
-
Run: `snapshots/benchmarks/20397054933` (commit `25e4fdf`, oha, 50 concurrency, 10s, Linux x86_64).
|
|
235
|
+
Built with zero-overhead FFI via Magnus and rb-sys. Benchmark results: ~8,000 RPS, ~6.5ms latency at 50 concurrency. See [benchmarks](../../snapshots/benchmarks/) for full results.
|
|
619
236
|
|
|
620
|
-
|
|
621
|
-
| --- | --- |
|
|
622
|
-
| Avg RPS (all workloads) | 8,271 |
|
|
623
|
-
| Avg latency (ms) | 6.50 |
|
|
237
|
+
## Learn More
|
|
624
238
|
|
|
625
|
-
|
|
239
|
+
**Examples & Code Generation:**
|
|
240
|
+
- [Runnable Examples](../../examples/) - Ruby, Python, TypeScript, PHP, and WASM
|
|
241
|
+
- [Code Generation Guide](../../examples/README.md) - Generate from OpenAPI, GraphQL, AsyncAPI, OpenRPC
|
|
626
242
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
| forms | 7,989 | 6.27 |
|
|
632
|
-
| query-params | 7,984 | 6.33 |
|
|
633
|
-
| multipart | 5,604 | 10.36 |
|
|
243
|
+
**Documentation:**
|
|
244
|
+
- [Type Definitions (RBS)](sig/spikard.rbs) - Full Steep type signatures
|
|
245
|
+
- [Main README](../../README.md) - Multi-language ecosystem and feature overview
|
|
246
|
+
- [Architecture Decisions](../../docs/adr/) - Design choices and patterns
|
|
634
247
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
The [examples directory](../../examples/) contains comprehensive demonstrations:
|
|
638
|
-
|
|
639
|
-
**Ruby-specific examples:**
|
|
640
|
-
- [Basic Ruby Example](../../examples/di/ruby_basic.rb) - Simple server with DI
|
|
641
|
-
- [Database Integration](../../examples/di/ruby_database.rb) - DI with database pools
|
|
642
|
-
- Additional examples in [examples/](../../examples/)
|
|
643
|
-
|
|
644
|
-
**API Schemas** (language-agnostic, can be used with code generation):
|
|
645
|
-
- [Todo API](../../examples/schemas/todo-api.openapi.yaml) - REST CRUD with validation
|
|
646
|
-
- [File Service](../../examples/schemas/file-service.openapi.yaml) - File uploads/downloads
|
|
647
|
-
- [Auth Service](../../examples/schemas/auth-service.openapi.yaml) - JWT, API keys, OAuth
|
|
648
|
-
- [Chat Service](../../examples/schemas/chat-service.asyncapi.yaml) - WebSocket messaging
|
|
649
|
-
- [Event Streams](../../examples/schemas/events-stream.asyncapi.yaml) - SSE streaming
|
|
650
|
-
|
|
651
|
-
See [examples/README.md](../../examples/README.md) for code generation instructions.
|
|
652
|
-
|
|
653
|
-
## Documentation
|
|
654
|
-
|
|
655
|
-
**API Reference & Guides:**
|
|
656
|
-
- [Type Definitions (RBS)](sig/spikard.rbs) - Full type signatures for Steep
|
|
657
|
-
- [Configuration Reference](lib/spikard/config.rb) - ServerConfig and middleware options
|
|
658
|
-
- [Handler Documentation](lib/spikard/handler_wrapper.rb) - Request/response handling
|
|
659
|
-
|
|
660
|
-
**Project Resources:**
|
|
661
|
-
- [Main Project README](../../README.md) - Spikard overview and multi-language ecosystem
|
|
662
|
-
- [Contributing Guide](../../CONTRIBUTING.md) - Development guidelines
|
|
663
|
-
- [Architecture Decisions](../../docs/adr/) - ADRs on design choices
|
|
664
|
-
- [Examples](../../examples/ruby/) - Runnable example applications
|
|
665
|
-
|
|
666
|
-
**Cross-Language:**
|
|
248
|
+
**Other Languages:**
|
|
667
249
|
- [Python (PyPI)](https://pypi.org/project/spikard/)
|
|
668
|
-
- [
|
|
669
|
-
- [Rust (Crates.io)](https://crates.io/crates/spikard)
|
|
250
|
+
- [TypeScript (npm)](https://www.npmjs.com/package/spikard)
|
|
670
251
|
- [PHP (Packagist)](https://packagist.org/packages/spikard/spikard)
|
|
252
|
+
- [Rust (Crates.io)](https://crates.io/crates/spikard)
|
|
671
253
|
|
|
672
254
|
## License
|
|
673
255
|
|