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