spikard 0.3.5 → 0.3.6

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +659 -659
  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 -386
  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 +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 +366 -360
  23. data/vendor/crates/spikard-core/Cargo.toml +40 -40
  24. data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -3
  25. data/vendor/crates/spikard-core/src/bindings/response.rs +133 -133
  26. data/vendor/crates/spikard-core/src/debug.rs +63 -63
  27. data/vendor/crates/spikard-core/src/di/container.rs +726 -726
  28. data/vendor/crates/spikard-core/src/di/dependency.rs +273 -273
  29. data/vendor/crates/spikard-core/src/di/error.rs +118 -118
  30. data/vendor/crates/spikard-core/src/di/factory.rs +538 -538
  31. data/vendor/crates/spikard-core/src/di/graph.rs +545 -545
  32. data/vendor/crates/spikard-core/src/di/mod.rs +192 -192
  33. data/vendor/crates/spikard-core/src/di/resolved.rs +411 -411
  34. data/vendor/crates/spikard-core/src/di/value.rs +283 -283
  35. data/vendor/crates/spikard-core/src/errors.rs +39 -39
  36. data/vendor/crates/spikard-core/src/http.rs +153 -153
  37. data/vendor/crates/spikard-core/src/lib.rs +29 -29
  38. data/vendor/crates/spikard-core/src/lifecycle.rs +422 -422
  39. data/vendor/crates/spikard-core/src/parameters.rs +722 -722
  40. data/vendor/crates/spikard-core/src/problem.rs +310 -310
  41. data/vendor/crates/spikard-core/src/request_data.rs +189 -189
  42. data/vendor/crates/spikard-core/src/router.rs +249 -249
  43. data/vendor/crates/spikard-core/src/schema_registry.rs +183 -183
  44. data/vendor/crates/spikard-core/src/type_hints.rs +304 -304
  45. data/vendor/crates/spikard-core/src/validation.rs +699 -699
  46. data/vendor/crates/spikard-http/Cargo.toml +68 -68
  47. data/vendor/crates/spikard-http/src/auth.rs +247 -247
  48. data/vendor/crates/spikard-http/src/background.rs +249 -249
  49. data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -3
  50. data/vendor/crates/spikard-http/src/bindings/response.rs +1 -1
  51. data/vendor/crates/spikard-http/src/body_metadata.rs +8 -8
  52. data/vendor/crates/spikard-http/src/cors.rs +490 -490
  53. data/vendor/crates/spikard-http/src/debug.rs +63 -63
  54. data/vendor/crates/spikard-http/src/di_handler.rs +423 -423
  55. data/vendor/crates/spikard-http/src/handler_response.rs +190 -190
  56. data/vendor/crates/spikard-http/src/handler_trait.rs +228 -228
  57. data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -284
  58. data/vendor/crates/spikard-http/src/lib.rs +529 -529
  59. data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -149
  60. data/vendor/crates/spikard-http/src/lifecycle.rs +428 -428
  61. data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -285
  62. data/vendor/crates/spikard-http/src/middleware/multipart.rs +86 -86
  63. data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +147 -147
  64. data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -287
  65. data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -309
  66. data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +190 -190
  67. data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +308 -308
  68. data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +195 -195
  69. data/vendor/crates/spikard-http/src/parameters.rs +1 -1
  70. data/vendor/crates/spikard-http/src/problem.rs +1 -1
  71. data/vendor/crates/spikard-http/src/query_parser.rs +369 -369
  72. data/vendor/crates/spikard-http/src/response.rs +399 -399
  73. data/vendor/crates/spikard-http/src/router.rs +1 -1
  74. data/vendor/crates/spikard-http/src/schema_registry.rs +1 -1
  75. data/vendor/crates/spikard-http/src/server/handler.rs +87 -87
  76. data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -98
  77. data/vendor/crates/spikard-http/src/server/mod.rs +805 -805
  78. data/vendor/crates/spikard-http/src/server/request_extraction.rs +119 -119
  79. data/vendor/crates/spikard-http/src/sse.rs +447 -447
  80. data/vendor/crates/spikard-http/src/testing/form.rs +14 -14
  81. data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -60
  82. data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -285
  83. data/vendor/crates/spikard-http/src/testing.rs +377 -377
  84. data/vendor/crates/spikard-http/src/type_hints.rs +1 -1
  85. data/vendor/crates/spikard-http/src/validation.rs +1 -1
  86. data/vendor/crates/spikard-http/src/websocket.rs +324 -324
  87. data/vendor/crates/spikard-rb/Cargo.toml +42 -42
  88. data/vendor/crates/spikard-rb/build.rs +8 -8
  89. data/vendor/crates/spikard-rb/src/background.rs +63 -63
  90. data/vendor/crates/spikard-rb/src/config.rs +294 -294
  91. data/vendor/crates/spikard-rb/src/conversion.rs +453 -453
  92. data/vendor/crates/spikard-rb/src/di.rs +409 -409
  93. data/vendor/crates/spikard-rb/src/handler.rs +625 -625
  94. data/vendor/crates/spikard-rb/src/lib.rs +2771 -2771
  95. data/vendor/crates/spikard-rb/src/lifecycle.rs +274 -274
  96. data/vendor/crates/spikard-rb/src/server.rs +283 -283
  97. data/vendor/crates/spikard-rb/src/sse.rs +231 -231
  98. data/vendor/crates/spikard-rb/src/test_client.rs +404 -404
  99. data/vendor/crates/spikard-rb/src/test_sse.rs +143 -143
  100. data/vendor/crates/spikard-rb/src/test_websocket.rs +221 -221
  101. data/vendor/crates/spikard-rb/src/websocket.rs +233 -233
  102. metadata +1 -1
data/README.md CHANGED
@@ -1,659 +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
- [![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
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