spikard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4cf6a7b7ef7f55964121ce4b373d89a5bd6c61442b616db298cb47109e613cd2
4
+ data.tar.gz: 6a23478f8aa4eda9e92e167ac3af6ee29a387c589e6e36298657f2d731ba9f89
5
+ SHA512:
6
+ metadata.gz: da092ee76bb7bc892dad0115e1888afdba77be734727d462518f85971f31ea0233eeda013884e5764433695c33de70736506005507e0c7570fea3cb2f6a10d97
7
+ data.tar.gz: '09e19829528e6397033485d815999e8a0da6c6f74db24a29ee3bebe9d840341a7da640a081448f466551f1d8c4e06d42238b210d3cab402523bf59747370f921'
data/LICENSE ADDED
@@ -0,0 +1 @@
1
+ See ../../LICENSE
data/README.md ADDED
@@ -0,0 +1,547 @@
1
+ # Spikard Ruby
2
+
3
+ High-performance Ruby web framework with a Rust core. Build REST APIs with Sinatra-style blocks backed by Axum and Tower-HTTP.
4
+
5
+ ## Installation
6
+
7
+ **From source (currently):**
8
+
9
+ ```bash
10
+ cd packages/ruby
11
+ bundle install
12
+ bundle exec rake ext:build
13
+ ```
14
+
15
+ **Requirements:**
16
+ - Ruby 3.2+
17
+ - Bundler
18
+ - Rust toolchain (for building native extension)
19
+
20
+ ## Quick Start
21
+
22
+ ```ruby
23
+ require "spikard"
24
+
25
+ app = Spikard::App.new
26
+
27
+ app.get "/users/:id" do |request|
28
+ user_id = request[:path_params]["id"].to_i
29
+ { id: user_id, name: "Alice" }
30
+ end
31
+
32
+ app.post "/users" do |request|
33
+ { id: 1, name: request[:body]["name"] }
34
+ end
35
+
36
+ app.run(port: 8000)
37
+ ```
38
+
39
+ ## Request Hash Structure
40
+
41
+ Handlers receive a single `request` hash argument with the following keys:
42
+
43
+ - `:method` - HTTP method (String): `"GET"`, `"POST"`, etc.
44
+ - `:path` - URL path (String): `"/users/123"`
45
+ - `:path_params` - Path parameters (Hash): `{"id" => "123"}`
46
+ - `:query` - Query parameters (Hash): `{"search" => "ruby"}`
47
+ - `:raw_query` - Raw query multimap (Hash of Arrays)
48
+ - `:headers` - Request headers (Hash): `{"Authorization" => "Bearer..."}`
49
+ - `:cookies` - Request cookies (Hash): `{"session_id" => "..."}`
50
+ - `:body` - Parsed request body (Hash or nil)
51
+ - `:params` - Merged params from path, query, headers, and cookies
52
+
53
+ **Example:**
54
+
55
+ ```ruby
56
+ app.get "/users/:id" do |request|
57
+ user_id = request[:path_params]["id"]
58
+ search = request[:query]["search"]
59
+ auth = request[:headers]["Authorization"]
60
+
61
+ { id: user_id, search: search }
62
+ end
63
+ ```
64
+
65
+ ## Route Registration
66
+
67
+ ### HTTP Methods
68
+
69
+ ```ruby
70
+ app.get "/path" do |request|
71
+ # Handler code
72
+ { method: request[:method] }
73
+ end
74
+
75
+ app.post "/path" do |request|
76
+ { created: true }
77
+ end
78
+
79
+ app.put "/path" do |request|
80
+ { updated: true }
81
+ end
82
+
83
+ app.patch "/path" do |request|
84
+ { patched: true }
85
+ end
86
+
87
+ app.delete "/path" do |request|
88
+ { deleted: true }
89
+ end
90
+
91
+ app.options "/path" do |request|
92
+ { options: [] }
93
+ end
94
+
95
+ app.head "/path" do |request|
96
+ # HEAD request
97
+ end
98
+
99
+ app.trace "/path" do |request|
100
+ # TRACE request
101
+ end
102
+ ```
103
+
104
+ ### Path Parameters
105
+
106
+ ```ruby
107
+ app.get "/users/:user_id" do |request|
108
+ { user_id: request[:path_params]["user_id"].to_i }
109
+ end
110
+
111
+ app.get "/posts/:post_id/comments/:comment_id" do |request|
112
+ {
113
+ post_id: request[:path_params]["post_id"].to_i,
114
+ comment_id: request[:path_params]["comment_id"].to_i
115
+ }
116
+ end
117
+ ```
118
+
119
+ ### Query Parameters
120
+
121
+ ```ruby
122
+ app.get "/search" do |request|
123
+ q = request[:query]["q"]
124
+ limit = (request[:query]["limit"] || "10").to_i
125
+ { query: q, limit: limit }
126
+ end
127
+ ```
128
+
129
+ ## Validation
130
+
131
+ Spikard supports **dry-schema** and **raw JSON Schema objects**.
132
+
133
+ ### With dry-schema
134
+
135
+ ```ruby
136
+ require "dry-schema"
137
+ Dry::Schema.load_extensions(:json_schema)
138
+
139
+ UserSchema = Dry::Schema.JSON do
140
+ required(:name).filled(:str?)
141
+ required(:email).filled(:str?)
142
+ required(:age).filled(:int?)
143
+ end
144
+
145
+ app.post "/users", request_schema: UserSchema do |request|
146
+ # request[:body] is validated against schema
147
+ { id: 1, name: request[:body]["name"] }
148
+ end
149
+ ```
150
+
151
+ ### With raw JSON Schema
152
+
153
+ ```ruby
154
+ user_schema = {
155
+ "type" => "object",
156
+ "properties" => {
157
+ "name" => { "type" => "string" },
158
+ "email" => { "type" => "string", "format" => "email" }
159
+ },
160
+ "required" => ["name", "email"]
161
+ }
162
+
163
+ app.post "/users", request_schema: user_schema do |request|
164
+ { id: 1, name: request[:body]["name"], email: request[:body]["email"] }
165
+ end
166
+ ```
167
+
168
+ ### With dry-struct
169
+
170
+ ```ruby
171
+ require "dry-struct"
172
+ require "dry-types"
173
+
174
+ module Types
175
+ include Dry.Types()
176
+ end
177
+
178
+ class User < Dry::Struct
179
+ attribute :name, Types::String
180
+ attribute :email, Types::String
181
+ attribute :age, Types::Integer
182
+ end
183
+
184
+ app.post "/users", request_schema: User do |request|
185
+ # request[:body] validated as User
186
+ { id: 1, name: request[:body]["name"] }
187
+ end
188
+ ```
189
+
190
+ ## Response Types
191
+
192
+ ### Simple Hash Response
193
+
194
+ ```ruby
195
+ app.get "/hello" do
196
+ { message: "Hello, World!" }
197
+ end
198
+ ```
199
+
200
+ ### String Response
201
+
202
+ ```ruby
203
+ app.get "/text" do
204
+ "Plain text response"
205
+ end
206
+ ```
207
+
208
+ ### Full Response Object
209
+
210
+ ```ruby
211
+ app.post "/users" do |request|
212
+ Spikard::Response.new(
213
+ content: { id: 1, name: request[:body]["name"] },
214
+ status_code: 201,
215
+ headers: { "X-Custom" => "value" }
216
+ )
217
+ end
218
+ ```
219
+
220
+ ### Streaming Response
221
+
222
+ ```ruby
223
+ app.get "/stream" do
224
+ stream = Enumerator.new do |yielder|
225
+ 10.times do |i|
226
+ yielder << "Chunk #{i}\n"
227
+ sleep 0.1
228
+ end
229
+ end
230
+
231
+ Spikard::StreamingResponse.new(
232
+ stream,
233
+ status_code: 200,
234
+ headers: { "Content-Type" => "text/plain" }
235
+ )
236
+ end
237
+ ```
238
+
239
+ ## File Uploads
240
+
241
+ ```ruby
242
+ app.post "/upload", file_params: true do |request|
243
+ file = request[:body]["file"] # UploadFile instance
244
+
245
+ {
246
+ filename: file.filename,
247
+ size: file.size,
248
+ content_type: file.content_type,
249
+ content: file.read
250
+ }
251
+ end
252
+ ```
253
+
254
+ ## Configuration
255
+
256
+ ```ruby
257
+ config = Spikard::ServerConfig.new(
258
+ host: "0.0.0.0",
259
+ port: 8080,
260
+ workers: 4,
261
+ enable_request_id: true,
262
+ max_body_size: 10 * 1024 * 1024, # 10 MB
263
+ request_timeout: 30,
264
+ compression: Spikard::CompressionConfig.new(
265
+ gzip: true,
266
+ brotli: true,
267
+ quality: 6
268
+ ),
269
+ rate_limit: Spikard::RateLimitConfig.new(
270
+ per_second: 100,
271
+ burst: 200
272
+ )
273
+ )
274
+
275
+ app.run(config: config)
276
+ ```
277
+
278
+ ### Middleware Configuration
279
+
280
+ **Compression:**
281
+
282
+ ```ruby
283
+ compression = Spikard::CompressionConfig.new(
284
+ gzip: true,
285
+ brotli: true,
286
+ min_size: 1024,
287
+ quality: 6
288
+ )
289
+ ```
290
+
291
+ **Rate Limiting:**
292
+
293
+ ```ruby
294
+ rate_limit = Spikard::RateLimitConfig.new(
295
+ per_second: 100,
296
+ burst: 200,
297
+ ip_based: true
298
+ )
299
+ ```
300
+
301
+ **JWT Authentication:**
302
+
303
+ ```ruby
304
+ jwt = Spikard::JwtConfig.new(
305
+ secret: "your-secret-key",
306
+ algorithm: "HS256",
307
+ audience: ["api.example.com"],
308
+ issuer: "auth.example.com",
309
+ leeway: 30
310
+ )
311
+ ```
312
+
313
+ **Static Files:**
314
+
315
+ ```ruby
316
+ static = Spikard::StaticFilesConfig.new(
317
+ directory: "./public",
318
+ route_prefix: "/static",
319
+ index_file: true,
320
+ cache_control: "public, max-age=3600"
321
+ )
322
+ ```
323
+
324
+ **OpenAPI Documentation:**
325
+
326
+ ```ruby
327
+ openapi = Spikard::OpenApiConfig.new(
328
+ enabled: true,
329
+ title: "My API",
330
+ version: "1.0.0",
331
+ description: "API docs",
332
+ swagger_ui_path: "/docs",
333
+ redoc_path: "/redoc"
334
+ )
335
+ ```
336
+
337
+ ## Lifecycle Hooks
338
+
339
+ ```ruby
340
+ app.on_request do |request|
341
+ puts "#{request[:method]} #{request[:path]}"
342
+ request
343
+ end
344
+
345
+ app.pre_validation do |request|
346
+ if too_many_requests?
347
+ Spikard::Response.new(
348
+ content: { error: "Rate limit exceeded" },
349
+ status_code: 429
350
+ )
351
+ else
352
+ request
353
+ end
354
+ end
355
+
356
+ app.pre_handler do |request|
357
+ if invalid_token?(request[:headers]["Authorization"])
358
+ Spikard::Response.new(
359
+ content: { error: "Unauthorized" },
360
+ status_code: 401
361
+ )
362
+ else
363
+ request
364
+ end
365
+ end
366
+
367
+ app.on_response do |response|
368
+ response.headers["X-Frame-Options"] = "DENY"
369
+ response
370
+ end
371
+
372
+ app.on_error do |response|
373
+ puts "Error: #{response.status_code}"
374
+ response
375
+ end
376
+ ```
377
+
378
+ ## WebSockets
379
+
380
+ ```ruby
381
+ class ChatHandler < Spikard::WebSocketHandler
382
+ def on_connect
383
+ puts "Client connected"
384
+ end
385
+
386
+ def handle_message(message)
387
+ # message is a Hash (parsed JSON)
388
+ { echo: message, timestamp: Time.now.to_i }
389
+ end
390
+
391
+ def on_disconnect
392
+ puts "Client disconnected"
393
+ end
394
+ end
395
+
396
+ app.websocket("/chat") { ChatHandler.new }
397
+ ```
398
+
399
+ ## Server-Sent Events (SSE)
400
+
401
+ ```ruby
402
+ class NotificationProducer < Spikard::SseEventProducer
403
+ def initialize
404
+ @count = 0
405
+ end
406
+
407
+ def on_connect
408
+ puts "Client connected to SSE stream"
409
+ end
410
+
411
+ def next_event
412
+ sleep 1
413
+
414
+ return nil if @count >= 10 # End stream
415
+
416
+ event = Spikard::SseEvent.new(
417
+ data: { message: "Notification #{@count}" },
418
+ event_type: "notification",
419
+ id: @count.to_s,
420
+ retry_ms: 3000
421
+ )
422
+ @count += 1
423
+ event
424
+ end
425
+
426
+ def on_disconnect
427
+ puts "Client disconnected from SSE"
428
+ end
429
+ end
430
+
431
+ app.sse("/notifications") { NotificationProducer.new }
432
+ ```
433
+
434
+ ## Background Tasks
435
+
436
+ ```ruby
437
+ app.post "/process" do |request|
438
+ Spikard::Background.run do
439
+ # Heavy processing after response
440
+ ProcessData.perform(request[:path_params]["id"])
441
+ end
442
+
443
+ { status: "processing" }
444
+ end
445
+ ```
446
+
447
+ ## Testing
448
+
449
+ ```ruby
450
+ require "spikard"
451
+
452
+ app = Spikard::App.new
453
+ app.get "/hello" do
454
+ { message: "Hello, World!" }
455
+ end
456
+
457
+ client = Spikard::TestClient.new(app)
458
+
459
+ # HTTP requests
460
+ response = client.get("/hello", query: { name: "Alice" })
461
+ puts response.status_code # => 200
462
+ puts response.json # => { "message" => "Hello, World!" }
463
+
464
+ # POST with JSON
465
+ response = client.post("/users", json: { name: "Bob" })
466
+
467
+ # File upload
468
+ response = client.post("/upload", files: {
469
+ file: ["test.txt", "content", "text/plain"]
470
+ })
471
+
472
+ # WebSocket
473
+ ws = client.websocket("/chat")
474
+ ws.send_json({ message: "hello" })
475
+ message = ws.receive_json
476
+ ws.close
477
+
478
+ # SSE
479
+ sse = client.sse("/events")
480
+ events = sse.events_as_json
481
+ puts events.length
482
+
483
+ # Cleanup
484
+ client.close
485
+ ```
486
+
487
+ ## Running the Server
488
+
489
+ ```ruby
490
+ # Development
491
+ app.run(port: 8000)
492
+
493
+ # Production
494
+ config = Spikard::ServerConfig.new(
495
+ host: "0.0.0.0",
496
+ port: 8080,
497
+ workers: 4
498
+ )
499
+ app.run(config: config)
500
+ ```
501
+
502
+ ## Type Safety with RBS
503
+
504
+ RBS type signatures are provided in `sig/spikard.rbs`:
505
+
506
+ ```ruby
507
+ module Spikard
508
+ class App
509
+ def initialize: () -> void
510
+ def get: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
511
+ def post: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
512
+ def run: (?config: ServerConfig | Hash[Symbol, untyped]?) -> void
513
+ end
514
+
515
+ class ServerConfig
516
+ def initialize: (?host: String, ?port: Integer, **untyped) -> void
517
+ end
518
+ end
519
+ ```
520
+
521
+ Use with Steep for type checking:
522
+
523
+ ```bash
524
+ bundle exec steep check
525
+ ```
526
+
527
+ ## Performance
528
+
529
+ Ruby bindings use:
530
+ - **Magnus** for zero-overhead FFI
531
+ - **rb-sys** for modern Ruby 3.2+ integration
532
+ - Idiomatic Ruby blocks and procs
533
+ - GC-safe handler storage
534
+
535
+ ## Examples
536
+
537
+ See `/examples/ruby/` for more examples.
538
+
539
+ ## Documentation
540
+
541
+ - [Main Project README](../../README.md)
542
+ - [Contributing Guide](../../CONTRIBUTING.md)
543
+ - [RBS Type Signatures](sig/spikard.rbs)
544
+
545
+ ## License
546
+
547
+ MIT
@@ -0,0 +1,16 @@
1
+ [package]
2
+ name = "spikard-rb-ext"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ license = "MIT"
6
+ authors = ["Na'aman Hirschfeld <nhirschfeld@gmail.com>"]
7
+
8
+ [lib]
9
+ name = "spikard_rb"
10
+ crate-type = ["cdylib"]
11
+
12
+ [workspace]
13
+
14
+ [dependencies]
15
+ magnus = { git = "https://github.com/matsadler/magnus", rev = "f6db11769efb517427bf7f121f9c32e18b059b38", features = ["rb-sys"] }
16
+ spikard_rb_core = { package = "spikard-rb", path = "../../../../crates/spikard-rb" }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+ require 'rb_sys/mkmf'
5
+
6
+ default_profile = ENV.fetch('CARGO_PROFILE', 'release')
7
+
8
+ create_rust_makefile('spikard_rb') do |config|
9
+ config.profile = default_profile.to_sym
10
+ end
@@ -0,0 +1,6 @@
1
+ use magnus::Ruby;
2
+
3
+ #[magnus::init]
4
+ fn init(ruby: &Ruby) -> Result<(), magnus::Error> {
5
+ spikard_rb_core::init(ruby)
6
+ }