spikard 0.5.0 → 0.6.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 (135) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +674 -674
  4. data/ext/spikard_rb/Cargo.toml +17 -17
  5. data/ext/spikard_rb/extconf.rb +13 -10
  6. data/ext/spikard_rb/src/lib.rs +6 -6
  7. data/lib/spikard/app.rb +405 -405
  8. data/lib/spikard/background.rb +27 -27
  9. data/lib/spikard/config.rb +396 -396
  10. data/lib/spikard/converters.rb +13 -13
  11. data/lib/spikard/handler_wrapper.rb +113 -113
  12. data/lib/spikard/provide.rb +214 -214
  13. data/lib/spikard/response.rb +173 -173
  14. data/lib/spikard/schema.rb +243 -243
  15. data/lib/spikard/sse.rb +111 -111
  16. data/lib/spikard/streaming_response.rb +44 -44
  17. data/lib/spikard/testing.rb +256 -256
  18. data/lib/spikard/upload_file.rb +131 -131
  19. data/lib/spikard/version.rb +5 -5
  20. data/lib/spikard/websocket.rb +59 -59
  21. data/lib/spikard.rb +43 -43
  22. data/sig/spikard.rbs +366 -366
  23. data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -63
  24. data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +132 -132
  25. data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +752 -752
  26. data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -194
  27. data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -246
  28. data/vendor/crates/spikard-bindings-shared/src/error_response.rs +401 -401
  29. data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +238 -238
  30. data/vendor/crates/spikard-bindings-shared/src/lib.rs +24 -24
  31. data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +292 -292
  32. data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +616 -616
  33. data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +305 -305
  34. data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -248
  35. data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +351 -351
  36. data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +454 -454
  37. data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +383 -383
  38. data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +280 -280
  39. data/vendor/crates/spikard-core/Cargo.toml +40 -40
  40. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
  41. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
  42. data/vendor/crates/spikard-core/src/debug.rs +127 -127
  43. data/vendor/crates/spikard-core/src/di/container.rs +702 -702
  44. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
  45. data/vendor/crates/spikard-core/src/di/error.rs +118 -118
  46. data/vendor/crates/spikard-core/src/di/factory.rs +534 -534
  47. data/vendor/crates/spikard-core/src/di/graph.rs +506 -506
  48. data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
  49. data/vendor/crates/spikard-core/src/di/resolved.rs +405 -405
  50. data/vendor/crates/spikard-core/src/di/value.rs +281 -281
  51. data/vendor/crates/spikard-core/src/errors.rs +69 -69
  52. data/vendor/crates/spikard-core/src/http.rs +415 -415
  53. data/vendor/crates/spikard-core/src/lib.rs +29 -29
  54. data/vendor/crates/spikard-core/src/lifecycle.rs +1186 -1186
  55. data/vendor/crates/spikard-core/src/metadata.rs +389 -389
  56. data/vendor/crates/spikard-core/src/parameters.rs +2525 -2525
  57. data/vendor/crates/spikard-core/src/problem.rs +344 -344
  58. data/vendor/crates/spikard-core/src/request_data.rs +1154 -1154
  59. data/vendor/crates/spikard-core/src/router.rs +510 -510
  60. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
  61. data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
  62. data/vendor/crates/spikard-core/src/validation/error_mapper.rs +696 -688
  63. data/vendor/crates/spikard-core/src/validation/mod.rs +457 -457
  64. data/vendor/crates/spikard-http/Cargo.toml +62 -64
  65. data/vendor/crates/spikard-http/examples/sse-notifications.rs +148 -148
  66. data/vendor/crates/spikard-http/examples/websocket-chat.rs +92 -92
  67. data/vendor/crates/spikard-http/src/auth.rs +296 -296
  68. data/vendor/crates/spikard-http/src/background.rs +1860 -1860
  69. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
  70. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
  71. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
  72. data/vendor/crates/spikard-http/src/cors.rs +1005 -1005
  73. data/vendor/crates/spikard-http/src/debug.rs +128 -128
  74. data/vendor/crates/spikard-http/src/di_handler.rs +1668 -1668
  75. data/vendor/crates/spikard-http/src/handler_response.rs +901 -901
  76. data/vendor/crates/spikard-http/src/handler_trait.rs +838 -830
  77. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +290 -290
  78. data/vendor/crates/spikard-http/src/lib.rs +534 -534
  79. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +230 -230
  80. data/vendor/crates/spikard-http/src/lifecycle.rs +1193 -1193
  81. data/vendor/crates/spikard-http/src/middleware/mod.rs +560 -540
  82. data/vendor/crates/spikard-http/src/middleware/multipart.rs +912 -912
  83. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +513 -513
  84. data/vendor/crates/spikard-http/src/middleware/validation.rs +768 -735
  85. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
  86. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -535
  87. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +1363 -1363
  88. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +665 -665
  89. data/vendor/crates/spikard-http/src/query_parser.rs +793 -793
  90. data/vendor/crates/spikard-http/src/response.rs +720 -720
  91. data/vendor/crates/spikard-http/src/server/handler.rs +1650 -1650
  92. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +234 -234
  93. data/vendor/crates/spikard-http/src/server/mod.rs +1593 -1502
  94. data/vendor/crates/spikard-http/src/server/request_extraction.rs +789 -770
  95. data/vendor/crates/spikard-http/src/server/routing_factory.rs +629 -599
  96. data/vendor/crates/spikard-http/src/sse.rs +1409 -1409
  97. data/vendor/crates/spikard-http/src/testing/form.rs +52 -52
  98. data/vendor/crates/spikard-http/src/testing/multipart.rs +64 -60
  99. data/vendor/crates/spikard-http/src/testing/test_client.rs +311 -283
  100. data/vendor/crates/spikard-http/src/testing.rs +406 -377
  101. data/vendor/crates/spikard-http/src/websocket.rs +1404 -1375
  102. data/vendor/crates/spikard-http/tests/background_behavior.rs +832 -832
  103. data/vendor/crates/spikard-http/tests/common/handlers.rs +309 -309
  104. data/vendor/crates/spikard-http/tests/common/mod.rs +26 -26
  105. data/vendor/crates/spikard-http/tests/di_integration.rs +192 -192
  106. data/vendor/crates/spikard-http/tests/doc_snippets.rs +5 -5
  107. data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1093 -1093
  108. data/vendor/crates/spikard-http/tests/multipart_behavior.rs +656 -656
  109. data/vendor/crates/spikard-http/tests/server_config_builder.rs +314 -314
  110. data/vendor/crates/spikard-http/tests/sse_behavior.rs +620 -620
  111. data/vendor/crates/spikard-http/tests/websocket_behavior.rs +663 -663
  112. data/vendor/crates/spikard-rb/Cargo.toml +48 -48
  113. data/vendor/crates/spikard-rb/build.rs +199 -199
  114. data/vendor/crates/spikard-rb/src/background.rs +63 -63
  115. data/vendor/crates/spikard-rb/src/config/mod.rs +5 -5
  116. data/vendor/crates/spikard-rb/src/config/server_config.rs +285 -285
  117. data/vendor/crates/spikard-rb/src/conversion.rs +554 -554
  118. data/vendor/crates/spikard-rb/src/di/builder.rs +100 -100
  119. data/vendor/crates/spikard-rb/src/di/mod.rs +375 -375
  120. data/vendor/crates/spikard-rb/src/handler.rs +618 -618
  121. data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -3
  122. data/vendor/crates/spikard-rb/src/lib.rs +1806 -1810
  123. data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -275
  124. data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -5
  125. data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +442 -447
  126. data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -5
  127. data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +324 -324
  128. data/vendor/crates/spikard-rb/src/server.rs +305 -308
  129. data/vendor/crates/spikard-rb/src/sse.rs +231 -231
  130. data/vendor/crates/spikard-rb/src/testing/client.rs +538 -551
  131. data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -7
  132. data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -143
  133. data/vendor/crates/spikard-rb/src/testing/websocket.rs +608 -635
  134. data/vendor/crates/spikard-rb/src/websocket.rs +377 -374
  135. metadata +15 -1
data/README.md CHANGED
@@ -1,674 +1,674 @@
1
- # Spikard Ruby
2
-
3
- [![Documentation](https://img.shields.io/badge/docs-spikard.dev-58FBDA)](https://spikard.dev)
4
- [![Gem Version](https://img.shields.io/gem/v/spikard.svg)](https://rubygems.org/gems/spikard)
5
- [![Gem Downloads](https://img.shields.io/gem/dt/spikard.svg)](https://rubygems.org/gems/spikard)
6
- [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.2-red.svg)](https://www.ruby-lang.org/)
7
- [![codecov](https://codecov.io/gh/Goldziher/spikard/graph/badge.svg?token=H4ZXDZ4A69)](https://codecov.io/gh/Goldziher/spikard)
8
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
-
10
- High-performance Ruby web framework with a Rust core. Build REST APIs with Sinatra-style routing and zero-overhead async handlers backed by Axum and Tower-HTTP.
11
-
12
- ## Features
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
- - **Type-safe with RBS**: Full RBS type definitions for Steep type checking
17
- - **Zero-copy serialization**: Direct Rust-to-Ruby object conversion via Magnus
18
- - **Async-first**: Non-blocking handlers with full async/await support
19
- - **Middleware stack**: Compression, rate limiting, request IDs, authentication
20
- - **WebSockets & SSE**: Native real-time communication primitives
21
- - **Request validation**: JSON Schema and dry-schema support
22
- - **Lifecycle hooks**: onRequest, preValidation, preHandler, onResponse, onError
23
- - **Dependency injection**: Built-in container for services and factories
24
-
25
- ## Installation
26
-
27
- **Via RubyGems (recommended):**
28
-
29
- ```bash
30
- gem install spikard
31
- ```
32
-
33
- **From source (development):**
34
-
35
- ```bash
36
- cd packages/ruby
37
- bundle install
38
- bundle exec rake ext:build
39
- ```
40
-
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.
78
-
79
- ## Quick Start
80
-
81
- ```ruby
82
- 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
-
90
- app = Spikard::App.new
91
-
92
- app.get "/users/:id" do |request|
93
- user_id = request[:path_params]["id"].to_i
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"] }
100
- end
101
-
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
- app.get "/users/:id" do |request|
123
- user_id = request[:path_params]["id"]
124
- search = request[:query]["search"]
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
167
- end
168
- ```
169
-
170
- ### Path Parameters
171
-
172
- ```ruby
173
- app.get "/users/:user_id" do |request|
174
- { user_id: request[:path_params]["user_id"].to_i }
175
- end
176
-
177
- app.get "/posts/:post_id/comments/:comment_id" do |request|
178
- {
179
- post_id: request[:path_params]["post_id"].to_i,
180
- comment_id: request[:path_params]["comment_id"].to_i
181
- }
182
- end
183
- ```
184
-
185
- ### Query Parameters
186
-
187
- ```ruby
188
- app.get "/search" do |request|
189
- q = request[:query]["q"]
190
- limit = (request[:query]["limit"] || "10").to_i
191
- { query: q, limit: limit }
192
- end
193
- ```
194
-
195
- ## Validation
196
-
197
- Spikard supports **dry-schema** and **raw JSON Schema objects**.
198
-
199
- ### With dry-schema
200
-
201
- ```ruby
202
- require "dry-schema"
203
- Dry::Schema.load_extensions(:json_schema)
204
-
205
- UserSchema = Dry::Schema.JSON do
206
- required(:name).filled(:str?)
207
- required(:email).filled(:str?)
208
- required(:age).filled(:int?)
209
- end
210
-
211
- app.post "/users", request_schema: UserSchema do |request|
212
- # request[:body] is validated against schema
213
- { id: 1, name: request[:body]["name"] }
214
- end
215
- ```
216
-
217
- ### With raw JSON Schema
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
- ```
233
-
234
- ## Dependency Injection
235
-
236
- Register values or factories and inject them as keyword parameters:
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
250
-
251
- ```ruby
252
- require "dry-struct"
253
- require "dry-types"
254
-
255
- module Types
256
- include Dry.Types()
257
- end
258
-
259
- class User < Dry::Struct
260
- attribute :name, Types::String
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"] }
268
- end
269
- ```
270
-
271
- ## Response Types
272
-
273
- ### Simple Hash Response
274
-
275
- ```ruby
276
- app.get "/hello" do
277
- { message: "Hello, World!" }
278
- end
279
- ```
280
-
281
- ### String Response
282
-
283
- ```ruby
284
- app.get "/text" do
285
- "Plain text response"
286
- end
287
- ```
288
-
289
- ### Full Response Object
290
-
291
- ```ruby
292
- app.post "/users" do |request|
293
- Spikard::Response.new(
294
- content: { id: 1, name: request[:body]["name"] },
295
- status_code: 201,
296
- headers: { "X-Custom" => "value" }
297
- )
298
- end
299
- ```
300
-
301
- ### Streaming Response
302
-
303
- ```ruby
304
- app.get "/stream" do
305
- stream = Enumerator.new do |yielder|
306
- 10.times do |i|
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
- )
317
- end
318
- ```
319
-
320
- ## File Uploads
321
-
322
- ```ruby
323
- app.post "/upload", file_params: true do |request|
324
- file = request[:body]["file"] # UploadFile instance
325
-
326
- {
327
- filename: file.filename,
328
- size: file.size,
329
- content_type: file.content_type,
330
- content: file.read
331
- }
332
- end
333
- ```
334
-
335
- ## Configuration
336
-
337
- ```ruby
338
- config = Spikard::ServerConfig.new(
339
- host: "0.0.0.0",
340
- port: 8080,
341
- workers: 4,
342
- enable_request_id: true,
343
- max_body_size: 10 * 1024 * 1024, # 10 MB
344
- request_timeout: 30,
345
- compression: Spikard::CompressionConfig.new(
346
- gzip: true,
347
- brotli: true,
348
- quality: 6
349
- ),
350
- rate_limit: Spikard::RateLimitConfig.new(
351
- per_second: 100,
352
- burst: 200
353
- )
354
- )
355
-
356
- app.run(config: config)
357
- ```
358
-
359
- ### Middleware Configuration
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:**
383
-
384
- ```ruby
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
- ```
393
-
394
- **Static Files:**
395
-
396
- ```ruby
397
- static = Spikard::StaticFilesConfig.new(
398
- directory: "./public",
399
- route_prefix: "/static",
400
- index_file: true,
401
- cache_control: "public, max-age=3600"
402
- )
403
- ```
404
-
405
- **OpenAPI Documentation:**
406
-
407
- ```ruby
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
- ```
417
-
418
- ## Lifecycle Hooks
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
460
-
461
- ```ruby
462
- class ChatHandler < Spikard::WebSocketHandler
463
- def on_connect
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
475
- end
476
-
477
- app.websocket("/chat") { ChatHandler.new }
478
- ```
479
-
480
- ## Server-Sent Events (SSE)
481
-
482
- ```ruby
483
- class NotificationProducer < Spikard::SseEventProducer
484
- def initialize
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
510
- end
511
-
512
- app.sse("/notifications") { NotificationProducer.new }
513
- ```
514
-
515
- ## Background Tasks
516
-
517
- ```ruby
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
523
-
524
- { status: "processing" }
525
- end
526
- ```
527
-
528
- ## Testing
529
-
530
- ```ruby
531
- require "spikard"
532
-
533
- app = Spikard::App.new
534
- app.get "/hello" do
535
- { message: "Hello, World!" }
536
- end
537
-
538
- client = Spikard::TestClient.new(app)
539
-
540
- # HTTP requests
541
- response = client.get("/hello", query: { name: "Alice" })
542
- puts response.status_code # => 200
543
- puts response.json # => { "message" => "Hello, World!" }
544
-
545
- # POST with JSON
546
- 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
- ws = client.websocket("/chat")
555
- ws.send_json({ message: "hello" })
556
- message = ws.receive_json
557
- ws.close
558
-
559
- # SSE
560
- sse = client.sse("/events")
561
- events = sse.events_as_json
562
- puts events.length
563
-
564
- # Cleanup
565
- client.close
566
- ```
567
-
568
- ## Running the Server
569
-
570
- ```ruby
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:
603
-
604
- ```bash
605
- bundle exec steep check
606
- ```
607
-
608
- ## Performance
609
-
610
- Ruby bindings use:
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).
619
-
620
- | Metric | Value |
621
- | --- | --- |
622
- | Avg RPS (all workloads) | 8,271 |
623
- | Avg latency (ms) | 6.50 |
624
-
625
- Category breakdown:
626
-
627
- | Category | Avg RPS | Avg latency (ms) |
628
- | --- | --- | --- |
629
- | path-params | 9,591 | 5.22 |
630
- | json-bodies | 8,648 | 5.78 |
631
- | forms | 7,989 | 6.27 |
632
- | query-params | 7,984 | 6.33 |
633
- | multipart | 5,604 | 10.36 |
634
-
635
- ## Examples
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:**
667
- - [Python (PyPI)](https://pypi.org/project/spikard/)
668
- - [Node.js (npm)](https://www.npmjs.com/package/spikard)
669
- - [Rust (Crates.io)](https://crates.io/crates/spikard)
670
- - [PHP (Packagist)](https://packagist.org/packages/spikard/spikard)
671
-
672
- ## License
673
-
674
- MIT - See [LICENSE](../../LICENSE) for details
1
+ # Spikard Ruby
2
+
3
+ [![Documentation](https://img.shields.io/badge/docs-spikard.dev-58FBDA)](https://spikard.dev)
4
+ [![Gem Version](https://img.shields.io/gem/v/spikard.svg)](https://rubygems.org/gems/spikard)
5
+ [![Gem Downloads](https://img.shields.io/gem/dt/spikard.svg)](https://rubygems.org/gems/spikard)
6
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.2-red.svg)](https://www.ruby-lang.org/)
7
+ [![codecov](https://codecov.io/gh/Goldziher/spikard/graph/badge.svg?token=H4ZXDZ4A69)](https://codecov.io/gh/Goldziher/spikard)
8
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
+
10
+ High-performance Ruby web framework with a Rust core. Build REST APIs with Sinatra-style routing and zero-overhead async handlers backed by Axum and Tower-HTTP.
11
+
12
+ ## Features
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
+ - **Type-safe with RBS**: Full RBS type definitions for Steep type checking
17
+ - **Zero-copy serialization**: Direct Rust-to-Ruby object conversion via Magnus
18
+ - **Async-first**: Non-blocking handlers with full async/await support
19
+ - **Middleware stack**: Compression, rate limiting, request IDs, authentication
20
+ - **WebSockets & SSE**: Native real-time communication primitives
21
+ - **Request validation**: JSON Schema and dry-schema support
22
+ - **Lifecycle hooks**: onRequest, preValidation, preHandler, onResponse, onError
23
+ - **Dependency injection**: Built-in container for services and factories
24
+
25
+ ## Installation
26
+
27
+ **Via RubyGems (recommended):**
28
+
29
+ ```bash
30
+ gem install spikard
31
+ ```
32
+
33
+ **From source (development):**
34
+
35
+ ```bash
36
+ cd packages/ruby
37
+ bundle install
38
+ bundle exec rake ext:build
39
+ ```
40
+
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.
78
+
79
+ ## Quick Start
80
+
81
+ ```ruby
82
+ 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
+
90
+ app = Spikard::App.new
91
+
92
+ app.get "/users/:id" do |request|
93
+ user_id = request[:path_params]["id"].to_i
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"] }
100
+ end
101
+
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
+ app.get "/users/:id" do |request|
123
+ user_id = request[:path_params]["id"]
124
+ search = request[:query]["search"]
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
167
+ end
168
+ ```
169
+
170
+ ### Path Parameters
171
+
172
+ ```ruby
173
+ app.get "/users/:user_id" do |request|
174
+ { user_id: request[:path_params]["user_id"].to_i }
175
+ end
176
+
177
+ app.get "/posts/:post_id/comments/:comment_id" do |request|
178
+ {
179
+ post_id: request[:path_params]["post_id"].to_i,
180
+ comment_id: request[:path_params]["comment_id"].to_i
181
+ }
182
+ end
183
+ ```
184
+
185
+ ### Query Parameters
186
+
187
+ ```ruby
188
+ app.get "/search" do |request|
189
+ q = request[:query]["q"]
190
+ limit = (request[:query]["limit"] || "10").to_i
191
+ { query: q, limit: limit }
192
+ end
193
+ ```
194
+
195
+ ## Validation
196
+
197
+ Spikard supports **dry-schema** and **raw JSON Schema objects**.
198
+
199
+ ### With dry-schema
200
+
201
+ ```ruby
202
+ require "dry-schema"
203
+ Dry::Schema.load_extensions(:json_schema)
204
+
205
+ UserSchema = Dry::Schema.JSON do
206
+ required(:name).filled(:str?)
207
+ required(:email).filled(:str?)
208
+ required(:age).filled(:int?)
209
+ end
210
+
211
+ app.post "/users", request_schema: UserSchema do |request|
212
+ # request[:body] is validated against schema
213
+ { id: 1, name: request[:body]["name"] }
214
+ end
215
+ ```
216
+
217
+ ### With raw JSON Schema
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
+ ```
233
+
234
+ ## Dependency Injection
235
+
236
+ Register values or factories and inject them as keyword parameters:
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
250
+
251
+ ```ruby
252
+ require "dry-struct"
253
+ require "dry-types"
254
+
255
+ module Types
256
+ include Dry.Types()
257
+ end
258
+
259
+ class User < Dry::Struct
260
+ attribute :name, Types::String
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"] }
268
+ end
269
+ ```
270
+
271
+ ## Response Types
272
+
273
+ ### Simple Hash Response
274
+
275
+ ```ruby
276
+ app.get "/hello" do
277
+ { message: "Hello, World!" }
278
+ end
279
+ ```
280
+
281
+ ### String Response
282
+
283
+ ```ruby
284
+ app.get "/text" do
285
+ "Plain text response"
286
+ end
287
+ ```
288
+
289
+ ### Full Response Object
290
+
291
+ ```ruby
292
+ app.post "/users" do |request|
293
+ Spikard::Response.new(
294
+ content: { id: 1, name: request[:body]["name"] },
295
+ status_code: 201,
296
+ headers: { "X-Custom" => "value" }
297
+ )
298
+ end
299
+ ```
300
+
301
+ ### Streaming Response
302
+
303
+ ```ruby
304
+ app.get "/stream" do
305
+ stream = Enumerator.new do |yielder|
306
+ 10.times do |i|
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
+ )
317
+ end
318
+ ```
319
+
320
+ ## File Uploads
321
+
322
+ ```ruby
323
+ app.post "/upload", file_params: true do |request|
324
+ file = request[:body]["file"] # UploadFile instance
325
+
326
+ {
327
+ filename: file.filename,
328
+ size: file.size,
329
+ content_type: file.content_type,
330
+ content: file.read
331
+ }
332
+ end
333
+ ```
334
+
335
+ ## Configuration
336
+
337
+ ```ruby
338
+ config = Spikard::ServerConfig.new(
339
+ host: "0.0.0.0",
340
+ port: 8080,
341
+ workers: 4,
342
+ enable_request_id: true,
343
+ max_body_size: 10 * 1024 * 1024, # 10 MB
344
+ request_timeout: 30,
345
+ compression: Spikard::CompressionConfig.new(
346
+ gzip: true,
347
+ brotli: true,
348
+ quality: 6
349
+ ),
350
+ rate_limit: Spikard::RateLimitConfig.new(
351
+ per_second: 100,
352
+ burst: 200
353
+ )
354
+ )
355
+
356
+ app.run(config: config)
357
+ ```
358
+
359
+ ### Middleware Configuration
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:**
383
+
384
+ ```ruby
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
+ ```
393
+
394
+ **Static Files:**
395
+
396
+ ```ruby
397
+ static = Spikard::StaticFilesConfig.new(
398
+ directory: "./public",
399
+ route_prefix: "/static",
400
+ index_file: true,
401
+ cache_control: "public, max-age=3600"
402
+ )
403
+ ```
404
+
405
+ **OpenAPI Documentation:**
406
+
407
+ ```ruby
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
+ ```
417
+
418
+ ## Lifecycle Hooks
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
460
+
461
+ ```ruby
462
+ class ChatHandler < Spikard::WebSocketHandler
463
+ def on_connect
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
475
+ end
476
+
477
+ app.websocket("/chat") { ChatHandler.new }
478
+ ```
479
+
480
+ ## Server-Sent Events (SSE)
481
+
482
+ ```ruby
483
+ class NotificationProducer < Spikard::SseEventProducer
484
+ def initialize
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
510
+ end
511
+
512
+ app.sse("/notifications") { NotificationProducer.new }
513
+ ```
514
+
515
+ ## Background Tasks
516
+
517
+ ```ruby
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
523
+
524
+ { status: "processing" }
525
+ end
526
+ ```
527
+
528
+ ## Testing
529
+
530
+ ```ruby
531
+ require "spikard"
532
+
533
+ app = Spikard::App.new
534
+ app.get "/hello" do
535
+ { message: "Hello, World!" }
536
+ end
537
+
538
+ client = Spikard::TestClient.new(app)
539
+
540
+ # HTTP requests
541
+ response = client.get("/hello", query: { name: "Alice" })
542
+ puts response.status_code # => 200
543
+ puts response.json # => { "message" => "Hello, World!" }
544
+
545
+ # POST with JSON
546
+ 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
+ ws = client.websocket("/chat")
555
+ ws.send_json({ message: "hello" })
556
+ message = ws.receive_json
557
+ ws.close
558
+
559
+ # SSE
560
+ sse = client.sse("/events")
561
+ events = sse.events_as_json
562
+ puts events.length
563
+
564
+ # Cleanup
565
+ client.close
566
+ ```
567
+
568
+ ## Running the Server
569
+
570
+ ```ruby
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:
603
+
604
+ ```bash
605
+ bundle exec steep check
606
+ ```
607
+
608
+ ## Performance
609
+
610
+ Ruby bindings use:
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).
619
+
620
+ | Metric | Value |
621
+ | --- | --- |
622
+ | Avg RPS (all workloads) | 8,271 |
623
+ | Avg latency (ms) | 6.50 |
624
+
625
+ Category breakdown:
626
+
627
+ | Category | Avg RPS | Avg latency (ms) |
628
+ | --- | --- | --- |
629
+ | path-params | 9,591 | 5.22 |
630
+ | json-bodies | 8,648 | 5.78 |
631
+ | forms | 7,989 | 6.27 |
632
+ | query-params | 7,984 | 6.33 |
633
+ | multipart | 5,604 | 10.36 |
634
+
635
+ ## Examples
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:**
667
+ - [Python (PyPI)](https://pypi.org/project/spikard/)
668
+ - [Node.js (npm)](https://www.npmjs.com/package/spikard)
669
+ - [Rust (Crates.io)](https://crates.io/crates/spikard)
670
+ - [PHP (Packagist)](https://packagist.org/packages/spikard/spikard)
671
+
672
+ ## License
673
+
674
+ MIT - See [LICENSE](../../LICENSE) for details