spikard 0.2.1 → 0.2.5

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +626 -626
  4. data/ext/spikard_rb/Cargo.toml +17 -17
  5. data/ext/spikard_rb/extconf.rb +10 -10
  6. data/ext/spikard_rb/src/lib.rs +6 -6
  7. data/lib/spikard/app.rb +374 -374
  8. data/lib/spikard/background.rb +27 -27
  9. data/lib/spikard/config.rb +396 -396
  10. data/lib/spikard/converters.rb +85 -85
  11. data/lib/spikard/handler_wrapper.rb +116 -116
  12. data/lib/spikard/provide.rb +228 -228
  13. data/lib/spikard/response.rb +109 -109
  14. data/lib/spikard/schema.rb +243 -243
  15. data/lib/spikard/sse.rb +111 -111
  16. data/lib/spikard/streaming_response.rb +21 -21
  17. data/lib/spikard/testing.rb +221 -221
  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 +349 -349
  23. data/vendor/crates/spikard-core/Cargo.toml +40 -0
  24. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
  25. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
  26. data/vendor/crates/spikard-core/src/debug.rs +63 -0
  27. data/vendor/crates/spikard-core/src/di/container.rs +726 -0
  28. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
  29. data/vendor/crates/spikard-core/src/di/error.rs +118 -0
  30. data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
  31. data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
  32. data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
  33. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
  34. data/vendor/crates/spikard-core/src/di/value.rs +283 -0
  35. data/vendor/crates/spikard-core/src/http.rs +153 -0
  36. data/vendor/crates/spikard-core/src/lib.rs +28 -0
  37. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
  38. data/vendor/crates/spikard-core/src/parameters.rs +719 -0
  39. data/vendor/crates/spikard-core/src/problem.rs +310 -0
  40. data/vendor/crates/spikard-core/src/request_data.rs +189 -0
  41. data/vendor/crates/spikard-core/src/router.rs +249 -0
  42. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
  43. data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
  44. data/vendor/crates/spikard-core/src/validation.rs +699 -0
  45. data/vendor/crates/spikard-http/Cargo.toml +58 -0
  46. data/vendor/crates/spikard-http/src/auth.rs +247 -0
  47. data/vendor/crates/spikard-http/src/background.rs +249 -0
  48. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
  49. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
  50. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
  51. data/vendor/crates/spikard-http/src/cors.rs +490 -0
  52. data/vendor/crates/spikard-http/src/debug.rs +63 -0
  53. data/vendor/crates/spikard-http/src/di_handler.rs +423 -0
  54. data/vendor/crates/spikard-http/src/handler_response.rs +190 -0
  55. data/vendor/crates/spikard-http/src/handler_trait.rs +228 -0
  56. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
  57. data/vendor/crates/spikard-http/src/lib.rs +529 -0
  58. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
  59. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
  60. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
  61. data/vendor/crates/spikard-http/src/middleware/multipart.rs +86 -0
  62. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +147 -0
  63. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
  64. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
  65. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +190 -0
  66. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +308 -0
  67. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +195 -0
  68. data/vendor/crates/spikard-http/src/parameters.rs +1 -0
  69. data/vendor/crates/spikard-http/src/problem.rs +1 -0
  70. data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
  71. data/vendor/crates/spikard-http/src/response.rs +399 -0
  72. data/vendor/crates/spikard-http/src/router.rs +1 -0
  73. data/vendor/crates/spikard-http/src/schema_registry.rs +1 -0
  74. data/vendor/crates/spikard-http/src/server/handler.rs +80 -0
  75. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
  76. data/vendor/crates/spikard-http/src/server/mod.rs +805 -0
  77. data/vendor/crates/spikard-http/src/server/request_extraction.rs +119 -0
  78. data/vendor/crates/spikard-http/src/sse.rs +447 -0
  79. data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
  80. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
  81. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
  82. data/vendor/crates/spikard-http/src/testing.rs +377 -0
  83. data/vendor/crates/spikard-http/src/type_hints.rs +1 -0
  84. data/vendor/crates/spikard-http/src/validation.rs +1 -0
  85. data/vendor/crates/spikard-http/src/websocket.rs +324 -0
  86. data/vendor/crates/spikard-rb/Cargo.toml +42 -0
  87. data/vendor/crates/spikard-rb/build.rs +8 -0
  88. data/vendor/crates/spikard-rb/src/background.rs +63 -0
  89. data/vendor/crates/spikard-rb/src/config.rs +294 -0
  90. data/vendor/crates/spikard-rb/src/conversion.rs +392 -0
  91. data/vendor/crates/spikard-rb/src/di.rs +409 -0
  92. data/vendor/crates/spikard-rb/src/handler.rs +534 -0
  93. data/vendor/crates/spikard-rb/src/lib.rs +2020 -0
  94. data/vendor/crates/spikard-rb/src/lifecycle.rs +267 -0
  95. data/vendor/crates/spikard-rb/src/server.rs +283 -0
  96. data/vendor/crates/spikard-rb/src/sse.rs +231 -0
  97. data/vendor/crates/spikard-rb/src/test_client.rs +404 -0
  98. data/vendor/crates/spikard-rb/src/test_sse.rs +143 -0
  99. data/vendor/crates/spikard-rb/src/test_websocket.rs +221 -0
  100. data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
  101. metadata +80 -2
data/README.md CHANGED
@@ -1,626 +1,626 @@
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
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
- [![CI Status](https://img.shields.io/github/actions/workflow/status/Goldziher/spikard/ci.yml?branch=main)](https://github.com/Goldziher/spikard/actions)
9
- [![PyPI](https://img.shields.io/pypi/v/spikard.svg)](https://pypi.org/project/spikard/)
10
- [![npm](https://img.shields.io/npm/v/spikard.svg)](https://www.npmjs.com/package/spikard)
11
- [![Crates.io](https://img.shields.io/crates/v/spikard.svg)](https://crates.io/crates/spikard)
12
- [![Packagist](https://img.shields.io/packagist/v/spikard/spikard.svg)](https://packagist.org/packages/spikard/spikard)
13
-
14
- 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.
15
-
16
- ## Features
17
-
18
- - **Rust-powered performance**: High-throughput HTTP server backed by Tokio and Axum
19
- - **Sinatra-style routing**: Familiar `get`, `post`, `put`, `patch`, `delete` DSL
20
- - **Type-safe with RBS**: Full RBS type definitions for Steep type checking
21
- - **Zero-copy serialization**: Direct Rust-to-Ruby object conversion via Magnus
22
- - **Async-first**: Non-blocking handlers with full async/await support
23
- - **Middleware stack**: Compression, rate limiting, request IDs, authentication
24
- - **WebSockets & SSE**: Native real-time communication primitives
25
- - **Request validation**: JSON Schema and dry-schema support
26
- - **Lifecycle hooks**: onRequest, preValidation, preHandler, onResponse, onError
27
- - **Dependency injection**: Built-in container for services and factories
28
-
29
- ## Installation
30
-
31
- **Via RubyGems (recommended):**
32
-
33
- ```bash
34
- gem install spikard
35
- ```
36
-
37
- **From source (development):**
38
-
39
- ```bash
40
- cd packages/ruby
41
- bundle install
42
- bundle exec rake ext:build
43
- ```
44
-
45
- **Requirements:**
46
- - Ruby 3.2 or later
47
- - Bundler
48
- - Rust toolchain (for building from source)
49
-
50
- ## Quick Start
51
-
52
- ```ruby
53
- require "spikard"
54
- require "dry-schema"
55
-
56
- UserSchema = Dry::Schema.JSON do
57
- required(:name).filled(:str?)
58
- required(:email).filled(:str?)
59
- end
60
-
61
- app = Spikard::App.new
62
-
63
- app.get "/users/:id" do |request|
64
- user_id = request[:path_params]["id"].to_i
65
- { id: user_id, name: "Alice" }
66
- end
67
-
68
- app.post "/users", request_schema: UserSchema do |request|
69
- body = request[:body]
70
- { id: 1, name: body["name"], email: body["email"] }
71
- end
72
-
73
- app.run(port: 8000)
74
- ```
75
-
76
- ## Request Hash Structure
77
-
78
- Handlers receive a single `request` hash argument with the following keys:
79
-
80
- - `:method` - HTTP method (String): `"GET"`, `"POST"`, etc.
81
- - `:path` - URL path (String): `"/users/123"`
82
- - `:path_params` - Path parameters (Hash): `{"id" => "123"}`
83
- - `:query` - Query parameters (Hash): `{"search" => "ruby"}`
84
- - `:raw_query` - Raw query multimap (Hash of Arrays)
85
- - `:headers` - Request headers (Hash): `{"Authorization" => "Bearer..."}`
86
- - `:cookies` - Request cookies (Hash): `{"session_id" => "..."}`
87
- - `:body` - Parsed request body (Hash or nil)
88
- - `:params` - Merged params from path, query, headers, and cookies
89
-
90
- **Example:**
91
-
92
- ```ruby
93
- app.get "/users/:id" do |request|
94
- user_id = request[:path_params]["id"]
95
- search = request[:query]["search"]
96
- auth = request[:headers]["Authorization"]
97
-
98
- { id: user_id, search: search }
99
- end
100
- ```
101
-
102
- ## Route Registration
103
-
104
- ### HTTP Methods
105
-
106
- ```ruby
107
- app.get "/path" do |request|
108
- # Handler code
109
- { method: request[:method] }
110
- end
111
-
112
- app.post "/path" do |request|
113
- { created: true }
114
- end
115
-
116
- app.put "/path" do |request|
117
- { updated: true }
118
- end
119
-
120
- app.patch "/path" do |request|
121
- { patched: true }
122
- end
123
-
124
- app.delete "/path" do |request|
125
- { deleted: true }
126
- end
127
-
128
- app.options "/path" do |request|
129
- { options: [] }
130
- end
131
-
132
- app.head "/path" do |request|
133
- # HEAD request
134
- end
135
-
136
- app.trace "/path" do |request|
137
- # TRACE request
138
- end
139
- ```
140
-
141
- ### Path Parameters
142
-
143
- ```ruby
144
- app.get "/users/:user_id" do |request|
145
- { user_id: request[:path_params]["user_id"].to_i }
146
- end
147
-
148
- app.get "/posts/:post_id/comments/:comment_id" do |request|
149
- {
150
- post_id: request[:path_params]["post_id"].to_i,
151
- comment_id: request[:path_params]["comment_id"].to_i
152
- }
153
- end
154
- ```
155
-
156
- ### Query Parameters
157
-
158
- ```ruby
159
- app.get "/search" do |request|
160
- q = request[:query]["q"]
161
- limit = (request[:query]["limit"] || "10").to_i
162
- { query: q, limit: limit }
163
- end
164
- ```
165
-
166
- ## Validation
167
-
168
- Spikard supports **dry-schema** and **raw JSON Schema objects**.
169
-
170
- ### With dry-schema
171
-
172
- ```ruby
173
- require "dry-schema"
174
- Dry::Schema.load_extensions(:json_schema)
175
-
176
- UserSchema = Dry::Schema.JSON do
177
- required(:name).filled(:str?)
178
- required(:email).filled(:str?)
179
- required(:age).filled(:int?)
180
- end
181
-
182
- app.post "/users", request_schema: UserSchema do |request|
183
- # request[:body] is validated against schema
184
- { id: 1, name: request[:body]["name"] }
185
- end
186
- ```
187
-
188
- ### With raw JSON Schema
189
-
190
- ```ruby
191
- user_schema = {
192
- "type" => "object",
193
- "properties" => {
194
- "name" => { "type" => "string" },
195
- "email" => { "type" => "string", "format" => "email" }
196
- },
197
- "required" => ["name", "email"]
198
- }
199
-
200
- app.post "/users", request_schema: user_schema do |request|
201
- { id: 1, name: request[:body]["name"], email: request[:body]["email"] }
202
- end
203
- ```
204
-
205
- ## Dependency Injection
206
-
207
- Register values or factories and inject them as keyword parameters:
208
-
209
- ```ruby
210
- app.provide("config", { "db_url" => "postgresql://localhost/app" })
211
- app.provide("db_pool", depends_on: ["config"], singleton: true) do |config:|
212
- { url: config["db_url"], driver: "pool" }
213
- end
214
-
215
- app.get "/stats" do |_params, _query, _body, config:, db_pool:|
216
- { db: db_pool[:url], env: config["db_url"] }
217
- end
218
- ```
219
-
220
- ### With dry-struct
221
-
222
- ```ruby
223
- require "dry-struct"
224
- require "dry-types"
225
-
226
- module Types
227
- include Dry.Types()
228
- end
229
-
230
- class User < Dry::Struct
231
- attribute :name, Types::String
232
- attribute :email, Types::String
233
- attribute :age, Types::Integer
234
- end
235
-
236
- app.post "/users", request_schema: User do |request|
237
- # request[:body] validated as User
238
- { id: 1, name: request[:body]["name"] }
239
- end
240
- ```
241
-
242
- ## Response Types
243
-
244
- ### Simple Hash Response
245
-
246
- ```ruby
247
- app.get "/hello" do
248
- { message: "Hello, World!" }
249
- end
250
- ```
251
-
252
- ### String Response
253
-
254
- ```ruby
255
- app.get "/text" do
256
- "Plain text response"
257
- end
258
- ```
259
-
260
- ### Full Response Object
261
-
262
- ```ruby
263
- app.post "/users" do |request|
264
- Spikard::Response.new(
265
- content: { id: 1, name: request[:body]["name"] },
266
- status_code: 201,
267
- headers: { "X-Custom" => "value" }
268
- )
269
- end
270
- ```
271
-
272
- ### Streaming Response
273
-
274
- ```ruby
275
- app.get "/stream" do
276
- stream = Enumerator.new do |yielder|
277
- 10.times do |i|
278
- yielder << "Chunk #{i}\n"
279
- sleep 0.1
280
- end
281
- end
282
-
283
- Spikard::StreamingResponse.new(
284
- stream,
285
- status_code: 200,
286
- headers: { "Content-Type" => "text/plain" }
287
- )
288
- end
289
- ```
290
-
291
- ## File Uploads
292
-
293
- ```ruby
294
- app.post "/upload", file_params: true do |request|
295
- file = request[:body]["file"] # UploadFile instance
296
-
297
- {
298
- filename: file.filename,
299
- size: file.size,
300
- content_type: file.content_type,
301
- content: file.read
302
- }
303
- end
304
- ```
305
-
306
- ## Configuration
307
-
308
- ```ruby
309
- config = Spikard::ServerConfig.new(
310
- host: "0.0.0.0",
311
- port: 8080,
312
- workers: 4,
313
- enable_request_id: true,
314
- max_body_size: 10 * 1024 * 1024, # 10 MB
315
- request_timeout: 30,
316
- compression: Spikard::CompressionConfig.new(
317
- gzip: true,
318
- brotli: true,
319
- quality: 6
320
- ),
321
- rate_limit: Spikard::RateLimitConfig.new(
322
- per_second: 100,
323
- burst: 200
324
- )
325
- )
326
-
327
- app.run(config: config)
328
- ```
329
-
330
- ### Middleware Configuration
331
-
332
- **Compression:**
333
-
334
- ```ruby
335
- compression = Spikard::CompressionConfig.new(
336
- gzip: true,
337
- brotli: true,
338
- min_size: 1024,
339
- quality: 6
340
- )
341
- ```
342
-
343
- **Rate Limiting:**
344
-
345
- ```ruby
346
- rate_limit = Spikard::RateLimitConfig.new(
347
- per_second: 100,
348
- burst: 200,
349
- ip_based: true
350
- )
351
- ```
352
-
353
- **JWT Authentication:**
354
-
355
- ```ruby
356
- jwt = Spikard::JwtConfig.new(
357
- secret: "your-secret-key",
358
- algorithm: "HS256",
359
- audience: ["api.example.com"],
360
- issuer: "auth.example.com",
361
- leeway: 30
362
- )
363
- ```
364
-
365
- **Static Files:**
366
-
367
- ```ruby
368
- static = Spikard::StaticFilesConfig.new(
369
- directory: "./public",
370
- route_prefix: "/static",
371
- index_file: true,
372
- cache_control: "public, max-age=3600"
373
- )
374
- ```
375
-
376
- **OpenAPI Documentation:**
377
-
378
- ```ruby
379
- openapi = Spikard::OpenApiConfig.new(
380
- enabled: true,
381
- title: "My API",
382
- version: "1.0.0",
383
- description: "API docs",
384
- swagger_ui_path: "/docs",
385
- redoc_path: "/redoc"
386
- )
387
- ```
388
-
389
- ## Lifecycle Hooks
390
-
391
- ```ruby
392
- app.on_request do |request|
393
- puts "#{request[:method]} #{request[:path]}"
394
- request
395
- end
396
-
397
- app.pre_validation do |request|
398
- if too_many_requests?
399
- Spikard::Response.new(
400
- content: { error: "Rate limit exceeded" },
401
- status_code: 429
402
- )
403
- else
404
- request
405
- end
406
- end
407
-
408
- app.pre_handler do |request|
409
- if invalid_token?(request[:headers]["Authorization"])
410
- Spikard::Response.new(
411
- content: { error: "Unauthorized" },
412
- status_code: 401
413
- )
414
- else
415
- request
416
- end
417
- end
418
-
419
- app.on_response do |response|
420
- response.headers["X-Frame-Options"] = "DENY"
421
- response
422
- end
423
-
424
- app.on_error do |response|
425
- puts "Error: #{response.status_code}"
426
- response
427
- end
428
- ```
429
-
430
- ## WebSockets
431
-
432
- ```ruby
433
- class ChatHandler < Spikard::WebSocketHandler
434
- def on_connect
435
- puts "Client connected"
436
- end
437
-
438
- def handle_message(message)
439
- # message is a Hash (parsed JSON)
440
- { echo: message, timestamp: Time.now.to_i }
441
- end
442
-
443
- def on_disconnect
444
- puts "Client disconnected"
445
- end
446
- end
447
-
448
- app.websocket("/chat") { ChatHandler.new }
449
- ```
450
-
451
- ## Server-Sent Events (SSE)
452
-
453
- ```ruby
454
- class NotificationProducer < Spikard::SseEventProducer
455
- def initialize
456
- @count = 0
457
- end
458
-
459
- def on_connect
460
- puts "Client connected to SSE stream"
461
- end
462
-
463
- def next_event
464
- sleep 1
465
-
466
- return nil if @count >= 10 # End stream
467
-
468
- event = Spikard::SseEvent.new(
469
- data: { message: "Notification #{@count}" },
470
- event_type: "notification",
471
- id: @count.to_s,
472
- retry_ms: 3000
473
- )
474
- @count += 1
475
- event
476
- end
477
-
478
- def on_disconnect
479
- puts "Client disconnected from SSE"
480
- end
481
- end
482
-
483
- app.sse("/notifications") { NotificationProducer.new }
484
- ```
485
-
486
- ## Background Tasks
487
-
488
- ```ruby
489
- app.post "/process" do |request|
490
- Spikard::Background.run do
491
- # Heavy processing after response
492
- ProcessData.perform(request[:path_params]["id"])
493
- end
494
-
495
- { status: "processing" }
496
- end
497
- ```
498
-
499
- ## Testing
500
-
501
- ```ruby
502
- require "spikard"
503
-
504
- app = Spikard::App.new
505
- app.get "/hello" do
506
- { message: "Hello, World!" }
507
- end
508
-
509
- client = Spikard::TestClient.new(app)
510
-
511
- # HTTP requests
512
- response = client.get("/hello", query: { name: "Alice" })
513
- puts response.status_code # => 200
514
- puts response.json # => { "message" => "Hello, World!" }
515
-
516
- # POST with JSON
517
- response = client.post("/users", json: { name: "Bob" })
518
-
519
- # File upload
520
- response = client.post("/upload", files: {
521
- file: ["test.txt", "content", "text/plain"]
522
- })
523
-
524
- # WebSocket
525
- ws = client.websocket("/chat")
526
- ws.send_json({ message: "hello" })
527
- message = ws.receive_json
528
- ws.close
529
-
530
- # SSE
531
- sse = client.sse("/events")
532
- events = sse.events_as_json
533
- puts events.length
534
-
535
- # Cleanup
536
- client.close
537
- ```
538
-
539
- ## Running the Server
540
-
541
- ```ruby
542
- # Development
543
- app.run(port: 8000)
544
-
545
- # Production
546
- config = Spikard::ServerConfig.new(
547
- host: "0.0.0.0",
548
- port: 8080,
549
- workers: 4
550
- )
551
- app.run(config: config)
552
- ```
553
-
554
- ## Type Safety with RBS
555
-
556
- RBS type signatures are provided in `sig/spikard.rbs`:
557
-
558
- ```ruby
559
- module Spikard
560
- class App
561
- def initialize: () -> void
562
- def get: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
563
- def post: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
564
- def run: (?config: ServerConfig | Hash[Symbol, untyped]?) -> void
565
- end
566
-
567
- class ServerConfig
568
- def initialize: (?host: String, ?port: Integer, **untyped) -> void
569
- end
570
- end
571
- ```
572
-
573
- Use with Steep for type checking:
574
-
575
- ```bash
576
- bundle exec steep check
577
- ```
578
-
579
- ## Performance
580
-
581
- Ruby bindings use:
582
- - **Magnus** for zero-overhead FFI
583
- - **rb-sys** for modern Ruby 3.2+ integration
584
- - Idiomatic Ruby blocks and procs
585
- - GC-safe handler storage
586
-
587
- ## Examples
588
-
589
- The [examples directory](../../examples/) contains comprehensive demonstrations:
590
-
591
- **Ruby-specific examples:**
592
- - [Basic Ruby Example](../../examples/di/ruby_basic.rb) - Simple server with DI
593
- - [Database Integration](../../examples/di/ruby_database.rb) - DI with database pools
594
- - Additional examples in [examples/](../../examples/)
595
-
596
- **API Schemas** (language-agnostic, can be used with code generation):
597
- - [Todo API](../../examples/schemas/todo-api.openapi.yaml) - REST CRUD with validation
598
- - [File Service](../../examples/schemas/file-service.openapi.yaml) - File uploads/downloads
599
- - [Auth Service](../../examples/schemas/auth-service.openapi.yaml) - JWT, API keys, OAuth
600
- - [Chat Service](../../examples/schemas/chat-service.asyncapi.yaml) - WebSocket messaging
601
- - [Event Streams](../../examples/schemas/events-stream.asyncapi.yaml) - SSE streaming
602
-
603
- See [examples/README.md](../../examples/README.md) for code generation instructions.
604
-
605
- ## Documentation
606
-
607
- **API Reference & Guides:**
608
- - [Type Definitions (RBS)](sig/spikard.rbs) - Full type signatures for Steep
609
- - [Configuration Reference](lib/spikard/config.rb) - ServerConfig and middleware options
610
- - [Handler Documentation](lib/spikard/handler_wrapper.rb) - Request/response handling
611
-
612
- **Project Resources:**
613
- - [Main Project README](../../README.md) - Spikard overview and multi-language ecosystem
614
- - [Contributing Guide](../../CONTRIBUTING.md) - Development guidelines
615
- - [Architecture Decisions](../../docs/adr/) - ADRs on design choices
616
- - [Examples](../../examples/ruby/) - Runnable example applications
617
-
618
- **Cross-Language:**
619
- - [Python (PyPI)](https://pypi.org/project/spikard/)
620
- - [Node.js (npm)](https://www.npmjs.com/package/spikard)
621
- - [Rust (Crates.io)](https://crates.io/crates/spikard)
622
- - [PHP (Packagist)](https://packagist.org/packages/spikard/spikard)
623
-
624
- ## License
625
-
626
- 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
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+ [![CI Status](https://img.shields.io/github/actions/workflow/status/Goldziher/spikard/ci.yml?branch=main)](https://github.com/Goldziher/spikard/actions)
9
+ [![PyPI](https://img.shields.io/pypi/v/spikard.svg)](https://pypi.org/project/spikard/)
10
+ [![npm](https://img.shields.io/npm/v/spikard.svg)](https://www.npmjs.com/package/spikard)
11
+ [![Crates.io](https://img.shields.io/crates/v/spikard.svg)](https://crates.io/crates/spikard)
12
+ [![Packagist](https://img.shields.io/packagist/v/spikard/spikard.svg)](https://packagist.org/packages/spikard/spikard)
13
+
14
+ 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.
15
+
16
+ ## Features
17
+
18
+ - **Rust-powered performance**: High-throughput HTTP server backed by Tokio and Axum
19
+ - **Sinatra-style routing**: Familiar `get`, `post`, `put`, `patch`, `delete` DSL
20
+ - **Type-safe with RBS**: Full RBS type definitions for Steep type checking
21
+ - **Zero-copy serialization**: Direct Rust-to-Ruby object conversion via Magnus
22
+ - **Async-first**: Non-blocking handlers with full async/await support
23
+ - **Middleware stack**: Compression, rate limiting, request IDs, authentication
24
+ - **WebSockets & SSE**: Native real-time communication primitives
25
+ - **Request validation**: JSON Schema and dry-schema support
26
+ - **Lifecycle hooks**: onRequest, preValidation, preHandler, onResponse, onError
27
+ - **Dependency injection**: Built-in container for services and factories
28
+
29
+ ## Installation
30
+
31
+ **Via RubyGems (recommended):**
32
+
33
+ ```bash
34
+ gem install spikard
35
+ ```
36
+
37
+ **From source (development):**
38
+
39
+ ```bash
40
+ cd packages/ruby
41
+ bundle install
42
+ bundle exec rake ext:build
43
+ ```
44
+
45
+ **Requirements:**
46
+ - Ruby 3.2 or later
47
+ - Bundler
48
+ - Rust toolchain (for building from source)
49
+
50
+ ## Quick Start
51
+
52
+ ```ruby
53
+ require "spikard"
54
+ require "dry-schema"
55
+
56
+ UserSchema = Dry::Schema.JSON do
57
+ required(:name).filled(:str?)
58
+ required(:email).filled(:str?)
59
+ end
60
+
61
+ app = Spikard::App.new
62
+
63
+ app.get "/users/:id" do |request|
64
+ user_id = request[:path_params]["id"].to_i
65
+ { id: user_id, name: "Alice" }
66
+ end
67
+
68
+ app.post "/users", request_schema: UserSchema do |request|
69
+ body = request[:body]
70
+ { id: 1, name: body["name"], email: body["email"] }
71
+ end
72
+
73
+ app.run(port: 8000)
74
+ ```
75
+
76
+ ## Request Hash Structure
77
+
78
+ Handlers receive a single `request` hash argument with the following keys:
79
+
80
+ - `:method` - HTTP method (String): `"GET"`, `"POST"`, etc.
81
+ - `:path` - URL path (String): `"/users/123"`
82
+ - `:path_params` - Path parameters (Hash): `{"id" => "123"}`
83
+ - `:query` - Query parameters (Hash): `{"search" => "ruby"}`
84
+ - `:raw_query` - Raw query multimap (Hash of Arrays)
85
+ - `:headers` - Request headers (Hash): `{"Authorization" => "Bearer..."}`
86
+ - `:cookies` - Request cookies (Hash): `{"session_id" => "..."}`
87
+ - `:body` - Parsed request body (Hash or nil)
88
+ - `:params` - Merged params from path, query, headers, and cookies
89
+
90
+ **Example:**
91
+
92
+ ```ruby
93
+ app.get "/users/:id" do |request|
94
+ user_id = request[:path_params]["id"]
95
+ search = request[:query]["search"]
96
+ auth = request[:headers]["Authorization"]
97
+
98
+ { id: user_id, search: search }
99
+ end
100
+ ```
101
+
102
+ ## Route Registration
103
+
104
+ ### HTTP Methods
105
+
106
+ ```ruby
107
+ app.get "/path" do |request|
108
+ # Handler code
109
+ { method: request[:method] }
110
+ end
111
+
112
+ app.post "/path" do |request|
113
+ { created: true }
114
+ end
115
+
116
+ app.put "/path" do |request|
117
+ { updated: true }
118
+ end
119
+
120
+ app.patch "/path" do |request|
121
+ { patched: true }
122
+ end
123
+
124
+ app.delete "/path" do |request|
125
+ { deleted: true }
126
+ end
127
+
128
+ app.options "/path" do |request|
129
+ { options: [] }
130
+ end
131
+
132
+ app.head "/path" do |request|
133
+ # HEAD request
134
+ end
135
+
136
+ app.trace "/path" do |request|
137
+ # TRACE request
138
+ end
139
+ ```
140
+
141
+ ### Path Parameters
142
+
143
+ ```ruby
144
+ app.get "/users/:user_id" do |request|
145
+ { user_id: request[:path_params]["user_id"].to_i }
146
+ end
147
+
148
+ app.get "/posts/:post_id/comments/:comment_id" do |request|
149
+ {
150
+ post_id: request[:path_params]["post_id"].to_i,
151
+ comment_id: request[:path_params]["comment_id"].to_i
152
+ }
153
+ end
154
+ ```
155
+
156
+ ### Query Parameters
157
+
158
+ ```ruby
159
+ app.get "/search" do |request|
160
+ q = request[:query]["q"]
161
+ limit = (request[:query]["limit"] || "10").to_i
162
+ { query: q, limit: limit }
163
+ end
164
+ ```
165
+
166
+ ## Validation
167
+
168
+ Spikard supports **dry-schema** and **raw JSON Schema objects**.
169
+
170
+ ### With dry-schema
171
+
172
+ ```ruby
173
+ require "dry-schema"
174
+ Dry::Schema.load_extensions(:json_schema)
175
+
176
+ UserSchema = Dry::Schema.JSON do
177
+ required(:name).filled(:str?)
178
+ required(:email).filled(:str?)
179
+ required(:age).filled(:int?)
180
+ end
181
+
182
+ app.post "/users", request_schema: UserSchema do |request|
183
+ # request[:body] is validated against schema
184
+ { id: 1, name: request[:body]["name"] }
185
+ end
186
+ ```
187
+
188
+ ### With raw JSON Schema
189
+
190
+ ```ruby
191
+ user_schema = {
192
+ "type" => "object",
193
+ "properties" => {
194
+ "name" => { "type" => "string" },
195
+ "email" => { "type" => "string", "format" => "email" }
196
+ },
197
+ "required" => ["name", "email"]
198
+ }
199
+
200
+ app.post "/users", request_schema: user_schema do |request|
201
+ { id: 1, name: request[:body]["name"], email: request[:body]["email"] }
202
+ end
203
+ ```
204
+
205
+ ## Dependency Injection
206
+
207
+ Register values or factories and inject them as keyword parameters:
208
+
209
+ ```ruby
210
+ app.provide("config", { "db_url" => "postgresql://localhost/app" })
211
+ app.provide("db_pool", depends_on: ["config"], singleton: true) do |config:|
212
+ { url: config["db_url"], driver: "pool" }
213
+ end
214
+
215
+ app.get "/stats" do |_params, _query, _body, config:, db_pool:|
216
+ { db: db_pool[:url], env: config["db_url"] }
217
+ end
218
+ ```
219
+
220
+ ### With dry-struct
221
+
222
+ ```ruby
223
+ require "dry-struct"
224
+ require "dry-types"
225
+
226
+ module Types
227
+ include Dry.Types()
228
+ end
229
+
230
+ class User < Dry::Struct
231
+ attribute :name, Types::String
232
+ attribute :email, Types::String
233
+ attribute :age, Types::Integer
234
+ end
235
+
236
+ app.post "/users", request_schema: User do |request|
237
+ # request[:body] validated as User
238
+ { id: 1, name: request[:body]["name"] }
239
+ end
240
+ ```
241
+
242
+ ## Response Types
243
+
244
+ ### Simple Hash Response
245
+
246
+ ```ruby
247
+ app.get "/hello" do
248
+ { message: "Hello, World!" }
249
+ end
250
+ ```
251
+
252
+ ### String Response
253
+
254
+ ```ruby
255
+ app.get "/text" do
256
+ "Plain text response"
257
+ end
258
+ ```
259
+
260
+ ### Full Response Object
261
+
262
+ ```ruby
263
+ app.post "/users" do |request|
264
+ Spikard::Response.new(
265
+ content: { id: 1, name: request[:body]["name"] },
266
+ status_code: 201,
267
+ headers: { "X-Custom" => "value" }
268
+ )
269
+ end
270
+ ```
271
+
272
+ ### Streaming Response
273
+
274
+ ```ruby
275
+ app.get "/stream" do
276
+ stream = Enumerator.new do |yielder|
277
+ 10.times do |i|
278
+ yielder << "Chunk #{i}\n"
279
+ sleep 0.1
280
+ end
281
+ end
282
+
283
+ Spikard::StreamingResponse.new(
284
+ stream,
285
+ status_code: 200,
286
+ headers: { "Content-Type" => "text/plain" }
287
+ )
288
+ end
289
+ ```
290
+
291
+ ## File Uploads
292
+
293
+ ```ruby
294
+ app.post "/upload", file_params: true do |request|
295
+ file = request[:body]["file"] # UploadFile instance
296
+
297
+ {
298
+ filename: file.filename,
299
+ size: file.size,
300
+ content_type: file.content_type,
301
+ content: file.read
302
+ }
303
+ end
304
+ ```
305
+
306
+ ## Configuration
307
+
308
+ ```ruby
309
+ config = Spikard::ServerConfig.new(
310
+ host: "0.0.0.0",
311
+ port: 8080,
312
+ workers: 4,
313
+ enable_request_id: true,
314
+ max_body_size: 10 * 1024 * 1024, # 10 MB
315
+ request_timeout: 30,
316
+ compression: Spikard::CompressionConfig.new(
317
+ gzip: true,
318
+ brotli: true,
319
+ quality: 6
320
+ ),
321
+ rate_limit: Spikard::RateLimitConfig.new(
322
+ per_second: 100,
323
+ burst: 200
324
+ )
325
+ )
326
+
327
+ app.run(config: config)
328
+ ```
329
+
330
+ ### Middleware Configuration
331
+
332
+ **Compression:**
333
+
334
+ ```ruby
335
+ compression = Spikard::CompressionConfig.new(
336
+ gzip: true,
337
+ brotli: true,
338
+ min_size: 1024,
339
+ quality: 6
340
+ )
341
+ ```
342
+
343
+ **Rate Limiting:**
344
+
345
+ ```ruby
346
+ rate_limit = Spikard::RateLimitConfig.new(
347
+ per_second: 100,
348
+ burst: 200,
349
+ ip_based: true
350
+ )
351
+ ```
352
+
353
+ **JWT Authentication:**
354
+
355
+ ```ruby
356
+ jwt = Spikard::JwtConfig.new(
357
+ secret: "your-secret-key",
358
+ algorithm: "HS256",
359
+ audience: ["api.example.com"],
360
+ issuer: "auth.example.com",
361
+ leeway: 30
362
+ )
363
+ ```
364
+
365
+ **Static Files:**
366
+
367
+ ```ruby
368
+ static = Spikard::StaticFilesConfig.new(
369
+ directory: "./public",
370
+ route_prefix: "/static",
371
+ index_file: true,
372
+ cache_control: "public, max-age=3600"
373
+ )
374
+ ```
375
+
376
+ **OpenAPI Documentation:**
377
+
378
+ ```ruby
379
+ openapi = Spikard::OpenApiConfig.new(
380
+ enabled: true,
381
+ title: "My API",
382
+ version: "1.0.0",
383
+ description: "API docs",
384
+ swagger_ui_path: "/docs",
385
+ redoc_path: "/redoc"
386
+ )
387
+ ```
388
+
389
+ ## Lifecycle Hooks
390
+
391
+ ```ruby
392
+ app.on_request do |request|
393
+ puts "#{request[:method]} #{request[:path]}"
394
+ request
395
+ end
396
+
397
+ app.pre_validation do |request|
398
+ if too_many_requests?
399
+ Spikard::Response.new(
400
+ content: { error: "Rate limit exceeded" },
401
+ status_code: 429
402
+ )
403
+ else
404
+ request
405
+ end
406
+ end
407
+
408
+ app.pre_handler do |request|
409
+ if invalid_token?(request[:headers]["Authorization"])
410
+ Spikard::Response.new(
411
+ content: { error: "Unauthorized" },
412
+ status_code: 401
413
+ )
414
+ else
415
+ request
416
+ end
417
+ end
418
+
419
+ app.on_response do |response|
420
+ response.headers["X-Frame-Options"] = "DENY"
421
+ response
422
+ end
423
+
424
+ app.on_error do |response|
425
+ puts "Error: #{response.status_code}"
426
+ response
427
+ end
428
+ ```
429
+
430
+ ## WebSockets
431
+
432
+ ```ruby
433
+ class ChatHandler < Spikard::WebSocketHandler
434
+ def on_connect
435
+ puts "Client connected"
436
+ end
437
+
438
+ def handle_message(message)
439
+ # message is a Hash (parsed JSON)
440
+ { echo: message, timestamp: Time.now.to_i }
441
+ end
442
+
443
+ def on_disconnect
444
+ puts "Client disconnected"
445
+ end
446
+ end
447
+
448
+ app.websocket("/chat") { ChatHandler.new }
449
+ ```
450
+
451
+ ## Server-Sent Events (SSE)
452
+
453
+ ```ruby
454
+ class NotificationProducer < Spikard::SseEventProducer
455
+ def initialize
456
+ @count = 0
457
+ end
458
+
459
+ def on_connect
460
+ puts "Client connected to SSE stream"
461
+ end
462
+
463
+ def next_event
464
+ sleep 1
465
+
466
+ return nil if @count >= 10 # End stream
467
+
468
+ event = Spikard::SseEvent.new(
469
+ data: { message: "Notification #{@count}" },
470
+ event_type: "notification",
471
+ id: @count.to_s,
472
+ retry_ms: 3000
473
+ )
474
+ @count += 1
475
+ event
476
+ end
477
+
478
+ def on_disconnect
479
+ puts "Client disconnected from SSE"
480
+ end
481
+ end
482
+
483
+ app.sse("/notifications") { NotificationProducer.new }
484
+ ```
485
+
486
+ ## Background Tasks
487
+
488
+ ```ruby
489
+ app.post "/process" do |request|
490
+ Spikard::Background.run do
491
+ # Heavy processing after response
492
+ ProcessData.perform(request[:path_params]["id"])
493
+ end
494
+
495
+ { status: "processing" }
496
+ end
497
+ ```
498
+
499
+ ## Testing
500
+
501
+ ```ruby
502
+ require "spikard"
503
+
504
+ app = Spikard::App.new
505
+ app.get "/hello" do
506
+ { message: "Hello, World!" }
507
+ end
508
+
509
+ client = Spikard::TestClient.new(app)
510
+
511
+ # HTTP requests
512
+ response = client.get("/hello", query: { name: "Alice" })
513
+ puts response.status_code # => 200
514
+ puts response.json # => { "message" => "Hello, World!" }
515
+
516
+ # POST with JSON
517
+ response = client.post("/users", json: { name: "Bob" })
518
+
519
+ # File upload
520
+ response = client.post("/upload", files: {
521
+ file: ["test.txt", "content", "text/plain"]
522
+ })
523
+
524
+ # WebSocket
525
+ ws = client.websocket("/chat")
526
+ ws.send_json({ message: "hello" })
527
+ message = ws.receive_json
528
+ ws.close
529
+
530
+ # SSE
531
+ sse = client.sse("/events")
532
+ events = sse.events_as_json
533
+ puts events.length
534
+
535
+ # Cleanup
536
+ client.close
537
+ ```
538
+
539
+ ## Running the Server
540
+
541
+ ```ruby
542
+ # Development
543
+ app.run(port: 8000)
544
+
545
+ # Production
546
+ config = Spikard::ServerConfig.new(
547
+ host: "0.0.0.0",
548
+ port: 8080,
549
+ workers: 4
550
+ )
551
+ app.run(config: config)
552
+ ```
553
+
554
+ ## Type Safety with RBS
555
+
556
+ RBS type signatures are provided in `sig/spikard.rbs`:
557
+
558
+ ```ruby
559
+ module Spikard
560
+ class App
561
+ def initialize: () -> void
562
+ def get: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
563
+ def post: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
564
+ def run: (?config: ServerConfig | Hash[Symbol, untyped]?) -> void
565
+ end
566
+
567
+ class ServerConfig
568
+ def initialize: (?host: String, ?port: Integer, **untyped) -> void
569
+ end
570
+ end
571
+ ```
572
+
573
+ Use with Steep for type checking:
574
+
575
+ ```bash
576
+ bundle exec steep check
577
+ ```
578
+
579
+ ## Performance
580
+
581
+ Ruby bindings use:
582
+ - **Magnus** for zero-overhead FFI
583
+ - **rb-sys** for modern Ruby 3.2+ integration
584
+ - Idiomatic Ruby blocks and procs
585
+ - GC-safe handler storage
586
+
587
+ ## Examples
588
+
589
+ The [examples directory](../../examples/) contains comprehensive demonstrations:
590
+
591
+ **Ruby-specific examples:**
592
+ - [Basic Ruby Example](../../examples/di/ruby_basic.rb) - Simple server with DI
593
+ - [Database Integration](../../examples/di/ruby_database.rb) - DI with database pools
594
+ - Additional examples in [examples/](../../examples/)
595
+
596
+ **API Schemas** (language-agnostic, can be used with code generation):
597
+ - [Todo API](../../examples/schemas/todo-api.openapi.yaml) - REST CRUD with validation
598
+ - [File Service](../../examples/schemas/file-service.openapi.yaml) - File uploads/downloads
599
+ - [Auth Service](../../examples/schemas/auth-service.openapi.yaml) - JWT, API keys, OAuth
600
+ - [Chat Service](../../examples/schemas/chat-service.asyncapi.yaml) - WebSocket messaging
601
+ - [Event Streams](../../examples/schemas/events-stream.asyncapi.yaml) - SSE streaming
602
+
603
+ See [examples/README.md](../../examples/README.md) for code generation instructions.
604
+
605
+ ## Documentation
606
+
607
+ **API Reference & Guides:**
608
+ - [Type Definitions (RBS)](sig/spikard.rbs) - Full type signatures for Steep
609
+ - [Configuration Reference](lib/spikard/config.rb) - ServerConfig and middleware options
610
+ - [Handler Documentation](lib/spikard/handler_wrapper.rb) - Request/response handling
611
+
612
+ **Project Resources:**
613
+ - [Main Project README](../../README.md) - Spikard overview and multi-language ecosystem
614
+ - [Contributing Guide](../../CONTRIBUTING.md) - Development guidelines
615
+ - [Architecture Decisions](../../docs/adr/) - ADRs on design choices
616
+ - [Examples](../../examples/ruby/) - Runnable example applications
617
+
618
+ **Cross-Language:**
619
+ - [Python (PyPI)](https://pypi.org/project/spikard/)
620
+ - [Node.js (npm)](https://www.npmjs.com/package/spikard)
621
+ - [Rust (Crates.io)](https://crates.io/crates/spikard)
622
+ - [PHP (Packagist)](https://packagist.org/packages/spikard/spikard)
623
+
624
+ ## License
625
+
626
+ MIT - See [LICENSE](../../LICENSE) for details