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.
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'base64'
5
+
6
+ module Spikard
7
+ # File upload handling for multipart/form-data requests
8
+ #
9
+ # This class provides an interface for handling file uploads,
10
+ # designed to be compatible with Rails patterns while optimized
11
+ # for Spikard's Rust-backed request processing.
12
+ #
13
+ # @example
14
+ # app.post('/upload') do |body|
15
+ # file = body[:file] # UploadFile instance
16
+ # content = file.read
17
+ # {
18
+ # filename: file.filename,
19
+ # size: file.size,
20
+ # content_type: file.content_type,
21
+ # description: body[:description]
22
+ # }
23
+ # end
24
+ class UploadFile
25
+ # @return [String] Original filename from the client
26
+ attr_reader :filename
27
+
28
+ # @return [String] MIME type of the uploaded file
29
+ attr_reader :content_type
30
+
31
+ # @return [Integer] Size of the file in bytes
32
+ attr_reader :size
33
+
34
+ # @return [Hash<String, String>] Additional headers associated with this file field
35
+ attr_reader :headers
36
+
37
+ # Create a new UploadFile instance
38
+ #
39
+ # @param filename [String] Original filename from the client
40
+ # @param content [String] File contents (may be base64 encoded)
41
+ # @param content_type [String, nil] MIME type (defaults to "application/octet-stream")
42
+ # @param size [Integer, nil] File size in bytes (computed from content if not provided)
43
+ # @param headers [Hash<String, String>, nil] Additional headers from the multipart field
44
+ # @param content_encoding [String, nil] Encoding type (e.g., "base64")
45
+ def initialize(filename, content, content_type: nil, size: nil, headers: nil, content_encoding: nil)
46
+ @filename = filename
47
+ @content_type = content_type || 'application/octet-stream'
48
+ @headers = headers || {}
49
+
50
+ # Decode content if base64 encoded
51
+ @content = if content_encoding == 'base64' || base64_encoded?(content)
52
+ Base64.decode64(content)
53
+ else
54
+ content
55
+ end
56
+
57
+ @size = size || @content.bytesize
58
+ @io = StringIO.new(@content)
59
+ end
60
+
61
+ # Read file contents
62
+ #
63
+ # @param size [Integer, nil] Number of bytes to read (nil for all remaining)
64
+ # @return [String] File contents
65
+ def read(size = nil)
66
+ @io.read(size)
67
+ end
68
+
69
+ # Read file contents as text
70
+ #
71
+ # @param encoding [String] Character encoding (defaults to UTF-8)
72
+ # @return [String] File contents as text
73
+ def text(encoding: 'UTF-8')
74
+ @content.force_encoding(encoding)
75
+ end
76
+
77
+ # Seek to a specific position in the file
78
+ #
79
+ # @param offset [Integer] Byte offset
80
+ # @param whence [Integer] Position reference (IO::SEEK_SET, IO::SEEK_CUR, IO::SEEK_END)
81
+ # @return [Integer] New position
82
+ def seek(offset, whence = IO::SEEK_SET)
83
+ @io.seek(offset, whence)
84
+ end
85
+
86
+ # Get current position in the file
87
+ #
88
+ # @return [Integer] Current byte offset
89
+ def tell
90
+ @io.tell
91
+ end
92
+ alias pos tell
93
+
94
+ # Rewind to the beginning of the file
95
+ #
96
+ # @return [Integer] Always returns 0
97
+ def rewind
98
+ @io.rewind
99
+ end
100
+
101
+ # Close the file (no-op for StringIO-based implementation)
102
+ #
103
+ # @return [nil]
104
+ def close
105
+ @io.close
106
+ end
107
+
108
+ # Check if file is closed
109
+ #
110
+ # @return [Boolean]
111
+ def closed?
112
+ @io.closed?
113
+ end
114
+
115
+ # Get the raw content as a string
116
+ #
117
+ # @return [String] Raw file content
118
+ attr_reader :content
119
+
120
+ private
121
+
122
+ # Check if a string appears to be base64 encoded
123
+ #
124
+ # @param str [String] String to check
125
+ # @return [Boolean]
126
+ def base64_encoded?(str)
127
+ # Simple heuristic: check if string matches base64 pattern
128
+ str.is_a?(String) && str.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spikard
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spikard
4
+ # Base class for WebSocket message handlers.
5
+ #
6
+ # Implement this class to handle WebSocket connections and messages.
7
+ #
8
+ # @example
9
+ # class ChatHandler < Spikard::WebSocketHandler
10
+ # def handle_message(message)
11
+ # # Echo message back
12
+ # message
13
+ # end
14
+ #
15
+ # def on_connect
16
+ # puts "Client connected"
17
+ # end
18
+ #
19
+ # def on_disconnect
20
+ # puts "Client disconnected"
21
+ # end
22
+ # end
23
+ #
24
+ # app = Spikard::App.new
25
+ #
26
+ # app.websocket('/chat') do
27
+ # ChatHandler.new
28
+ # end
29
+ #
30
+ # app.run
31
+ class WebSocketHandler
32
+ # Handle an incoming WebSocket message.
33
+ #
34
+ # @param message [Hash] Parsed JSON message from the client
35
+ # @return [Hash, nil] Optional response message to send back to the client.
36
+ # Return nil to not send a response.
37
+ def handle_message(message)
38
+ raise NotImplementedError, "#{self.class.name} must implement #handle_message"
39
+ end
40
+
41
+ # Called when a client connects.
42
+ #
43
+ # Override this method to perform initialization when a client connects.
44
+ #
45
+ # @return [void]
46
+ def on_connect
47
+ # Optional hook - default implementation does nothing
48
+ end
49
+
50
+ # Called when a client disconnects.
51
+ #
52
+ # Override this method to perform cleanup when a client disconnects.
53
+ #
54
+ # @return [void]
55
+ def on_disconnect
56
+ # Optional hook - default implementation does nothing
57
+ end
58
+ end
59
+ end
data/lib/spikard.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main Ruby namespace for the Spikard bindings.
4
+ module Spikard
5
+ end
6
+
7
+ begin
8
+ require 'json'
9
+ rescue LoadError
10
+ # Fallback to pure-Ruby implementation when native JSON extension is unavailable
11
+ require 'json/pure'
12
+ end
13
+ require_relative 'spikard/version'
14
+ require_relative 'spikard/config'
15
+ require_relative 'spikard/response'
16
+ require_relative 'spikard/streaming_response'
17
+ require_relative 'spikard/background'
18
+ require_relative 'spikard/schema'
19
+ require_relative 'spikard/websocket'
20
+ require_relative 'spikard/sse'
21
+ require_relative 'spikard/upload_file'
22
+ require_relative 'spikard/converters'
23
+ require_relative 'spikard/handler_wrapper'
24
+ require_relative 'spikard/app'
25
+ require_relative 'spikard/testing'
26
+
27
+ begin
28
+ require 'spikard_rb'
29
+ rescue LoadError => e
30
+ raise LoadError, <<~MSG, e.backtrace
31
+ Failed to load the Spikard native extension (spikard_rb). Run `bundle exec rake ext:build` to compile it before executing tests.
32
+ Original error: #{e.message}
33
+ MSG
34
+ end
35
+
36
+ # Convenience aliases and methods at top level
37
+ module Spikard
38
+ TestClient = Testing::TestClient
39
+
40
+ # Handler wrapper utilities
41
+ extend HandlerWrapper
42
+ end
data/sig/spikard.rbs ADDED
@@ -0,0 +1,336 @@
1
+ module Spikard
2
+ VERSION: String
3
+
4
+ module Background
5
+ def self.run: () { () -> void } -> void
6
+ end
7
+
8
+ class CompressionConfig
9
+ attr_accessor gzip: bool
10
+ attr_accessor brotli: bool
11
+ attr_accessor min_size: Integer
12
+ attr_accessor quality: Integer
13
+
14
+ def initialize: (?gzip: bool, ?brotli: bool, ?min_size: Integer, ?quality: Integer) -> void
15
+ end
16
+
17
+ class RateLimitConfig
18
+ attr_accessor per_second: Integer
19
+ attr_accessor burst: Integer
20
+ attr_accessor ip_based: bool
21
+
22
+ def initialize: (per_second: Integer, burst: Integer, ?ip_based: bool) -> void
23
+ end
24
+
25
+ class JwtConfig
26
+ attr_accessor secret: String
27
+ attr_accessor algorithm: String
28
+ attr_accessor audience: Array[String]?
29
+ attr_accessor issuer: String?
30
+ attr_accessor leeway: Integer
31
+
32
+ def initialize: (secret: String, ?algorithm: String, ?audience: Array[String]?, ?issuer: String?, ?leeway: Integer) -> void
33
+ end
34
+
35
+ class ApiKeyConfig
36
+ attr_accessor keys: Array[String]
37
+ attr_accessor header_name: String
38
+
39
+ def initialize: (keys: Array[String], ?header_name: String) -> void
40
+ end
41
+
42
+ class StaticFilesConfig
43
+ attr_accessor directory: String
44
+ attr_accessor route_prefix: String
45
+ attr_accessor index_file: bool
46
+ attr_accessor cache_control: String?
47
+
48
+ def initialize: (directory: String, route_prefix: String, ?index_file: bool, ?cache_control: String?) -> void
49
+ end
50
+
51
+ class ContactInfo
52
+ attr_accessor name: String?
53
+ attr_accessor email: String?
54
+ attr_accessor url: String?
55
+
56
+ def initialize: (?name: String?, ?email: String?, ?url: String?) -> void
57
+ end
58
+
59
+ class LicenseInfo
60
+ attr_accessor name: String
61
+ attr_accessor url: String?
62
+
63
+ def initialize: (name: String, ?url: String?) -> void
64
+ end
65
+
66
+ class ServerInfo
67
+ attr_accessor url: String
68
+ attr_accessor description: String?
69
+
70
+ def initialize: (url: String, ?description: String?) -> void
71
+ end
72
+
73
+ class SecuritySchemeInfo
74
+ attr_accessor type: String
75
+ attr_accessor scheme: String?
76
+ attr_accessor bearer_format: String?
77
+ attr_accessor location: String?
78
+ attr_accessor name: String?
79
+
80
+ def initialize: (type: String, ?scheme: String?, ?bearer_format: String?, ?location: String?, ?name: String?) -> void
81
+ end
82
+
83
+ class OpenApiConfig
84
+ attr_accessor enabled: bool
85
+ attr_accessor title: String
86
+ attr_accessor version: String
87
+ attr_accessor description: String?
88
+ attr_accessor swagger_ui_path: String
89
+ attr_accessor redoc_path: String
90
+ attr_accessor openapi_json_path: String
91
+ attr_accessor contact: ContactInfo?
92
+ attr_accessor license: LicenseInfo?
93
+ attr_accessor servers: Array[ServerInfo]
94
+ attr_accessor security_schemes: Hash[String, SecuritySchemeInfo]
95
+
96
+ def initialize: (
97
+ ?enabled: bool,
98
+ ?title: String,
99
+ ?version: String,
100
+ ?description: String?,
101
+ ?swagger_ui_path: String,
102
+ ?redoc_path: String,
103
+ ?openapi_json_path: String,
104
+ ?contact: ContactInfo?,
105
+ ?license: LicenseInfo?,
106
+ ?servers: Array[ServerInfo],
107
+ ?security_schemes: Hash[String, SecuritySchemeInfo]
108
+ ) -> void
109
+ end
110
+
111
+ class ServerConfig
112
+ attr_accessor host: String
113
+ attr_accessor port: Integer
114
+ attr_accessor workers: Integer
115
+ attr_accessor enable_request_id: bool
116
+ attr_accessor max_body_size: Integer?
117
+ attr_accessor request_timeout: Integer?
118
+ attr_accessor compression: CompressionConfig
119
+ attr_accessor rate_limit: RateLimitConfig?
120
+ attr_accessor jwt_auth: JwtConfig?
121
+ attr_accessor api_key_auth: ApiKeyConfig?
122
+ attr_accessor static_files: Array[StaticFilesConfig]
123
+ attr_accessor graceful_shutdown: bool
124
+ attr_accessor shutdown_timeout: Integer
125
+ attr_accessor openapi: OpenApiConfig?
126
+
127
+ def initialize: (
128
+ ?host: String,
129
+ ?port: Integer,
130
+ ?workers: Integer,
131
+ ?enable_request_id: bool,
132
+ ?max_body_size: Integer?,
133
+ ?request_timeout: Integer?,
134
+ ?compression: CompressionConfig,
135
+ ?rate_limit: RateLimitConfig?,
136
+ ?jwt_auth: JwtConfig?,
137
+ ?api_key_auth: ApiKeyConfig?,
138
+ ?static_files: Array[StaticFilesConfig],
139
+ ?graceful_shutdown: bool,
140
+ ?shutdown_timeout: Integer,
141
+ ?openapi: OpenApiConfig?
142
+ ) -> void
143
+ end
144
+
145
+ class Response
146
+ attr_accessor content: untyped
147
+ attr_reader status_code: Integer
148
+ attr_reader headers: Hash[String, String]
149
+
150
+ def initialize: (
151
+ ?content: untyped,
152
+ ?body: untyped,
153
+ ?status_code: Integer,
154
+ ?headers: Hash[untyped, untyped]?,
155
+ ?content_type: String?
156
+ ) -> void
157
+ def status: () -> Integer
158
+ def status_code=: (Integer) -> Integer
159
+ def headers=: (Hash[untyped, untyped]?) -> Hash[String, String]
160
+ def set_header: (String, String) -> String
161
+ def set_cookie: (
162
+ String,
163
+ String,
164
+ ?max_age: Integer?,
165
+ ?domain: String?,
166
+ ?path: String?,
167
+ ?secure: bool?,
168
+ ?httponly: bool?,
169
+ ?samesite: String?
170
+ ) -> String
171
+ end
172
+
173
+ class StreamingResponse
174
+ attr_reader stream: Enumerable[untyped]
175
+ attr_reader status_code: Integer
176
+ attr_reader headers: Hash[String, String]
177
+
178
+ def initialize: (Enumerable[untyped], ?status_code: Integer, ?headers: Hash[untyped, untyped]?) -> void
179
+ end
180
+
181
+ module LifecycleHooks
182
+ def on_request: () { (untyped) -> untyped } -> Proc
183
+ def pre_validation: () { (untyped) -> untyped } -> Proc
184
+ def pre_handler: () { (untyped) -> untyped } -> Proc
185
+ def on_response: () { (untyped) -> untyped } -> Proc
186
+ def on_error: () { (untyped) -> untyped } -> Proc
187
+ def lifecycle_hooks: () -> Hash[Symbol, Array[Proc]]
188
+ end
189
+
190
+ class App
191
+ include LifecycleHooks
192
+
193
+ attr_reader routes: Array[untyped]
194
+
195
+ def initialize: () -> void
196
+ def register_route: (String, String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
197
+ def get: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
198
+ def post: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
199
+ def put: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
200
+ def patch: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
201
+ def delete: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
202
+ def options: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
203
+ def head: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
204
+ def trace: (String, ?handler_name: String?, **untyped) { (untyped) -> untyped } -> Proc
205
+ def websocket: (String, ?handler_name: String?, **untyped) { () -> WebSocketHandler } -> Proc
206
+ def sse: (String, ?handler_name: String?, **untyped) { () -> SseEventProducer } -> Proc
207
+ def websocket_handlers: () -> Hash[String, Proc]
208
+ def sse_producers: () -> Hash[String, Proc]
209
+ def run: (?config: ServerConfig | Hash[Symbol, untyped]?, ?host: String?, ?port: Integer?) -> void
210
+ end
211
+
212
+ class WebSocketHandler
213
+ def handle_message: (Hash[untyped, untyped]) -> Hash[untyped, untyped]?
214
+ def on_connect: () -> void
215
+ def on_disconnect: () -> void
216
+ end
217
+
218
+ class SseEvent
219
+ attr_accessor data: Hash[untyped, untyped]
220
+ attr_accessor event_type: String?
221
+ attr_accessor id: String?
222
+ attr_accessor retry_ms: Integer?
223
+
224
+ def initialize: (data: Hash[untyped, untyped], ?event_type: String?, ?id: String?, ?retry_ms: Integer?) -> void
225
+ def to_h: () -> Hash[Symbol, untyped]
226
+ end
227
+
228
+ class SseEventProducer
229
+ def next_event: () -> SseEvent?
230
+ def on_connect: () -> void
231
+ def on_disconnect: () -> void
232
+ end
233
+
234
+ module Schema
235
+ def self.extract_json_schema: (untyped) -> Hash[untyped, untyped]?
236
+ end
237
+
238
+ module Native
239
+ def self.run_server: (
240
+ String,
241
+ Hash[Symbol, Proc],
242
+ ServerConfig,
243
+ Hash[Symbol, Array[Proc]],
244
+ Hash[String, Proc],
245
+ Hash[String, Proc]
246
+ ) -> void
247
+
248
+ class TestClient
249
+ def initialize: (
250
+ String,
251
+ Hash[Symbol, Proc],
252
+ ServerConfig,
253
+ Hash[String, Proc],
254
+ Hash[String, Proc]
255
+ ) -> void
256
+ def request: (String, String, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
257
+ def websocket: (String) -> untyped
258
+ def sse: (String) -> untyped
259
+ def close: () -> void
260
+ end
261
+ end
262
+
263
+ module Testing
264
+ def self.create_test_client: (App, ?config: ServerConfig?) -> Testing::TestClient
265
+
266
+ class Response
267
+ attr_reader status_code: Integer
268
+ attr_reader headers: Hash[String, String]
269
+ attr_reader body: String?
270
+
271
+ def initialize: (Hash[Symbol, untyped]) -> void
272
+ def status: () -> Integer
273
+ def body_bytes: () -> String
274
+ def body_text: () -> String?
275
+ def text: () -> String?
276
+ def json: () -> untyped
277
+ def bytes: () -> Array[Integer]
278
+ end
279
+
280
+ class TestClient
281
+ def initialize: (Spikard::Native::TestClient) -> void
282
+ def self.new: (App | Spikard::Native::TestClient, ?config: ServerConfig?) -> TestClient
283
+ def request: (String, String, **Hash[Symbol, untyped]) -> Response
284
+ def websocket: (String) -> WebSocketTestConnection
285
+ def sse: (String) -> SseStream
286
+ def close: () -> void
287
+ def get: (String, **Hash[Symbol, untyped]) -> Response
288
+ def post: (String, **Hash[Symbol, untyped]) -> Response
289
+ def put: (String, **Hash[Symbol, untyped]) -> Response
290
+ def patch: (String, **Hash[Symbol, untyped]) -> Response
291
+ def delete: (String, **Hash[Symbol, untyped]) -> Response
292
+ def head: (String, **Hash[Symbol, untyped]) -> Response
293
+ def options: (String, **Hash[Symbol, untyped]) -> Response
294
+ def trace: (String, **Hash[Symbol, untyped]) -> Response
295
+ end
296
+
297
+ class WebSocketTestConnection
298
+ def initialize: (untyped) -> void
299
+ def send_text: (untyped) -> void
300
+ def send_json: (untyped) -> void
301
+ def receive_text: () -> untyped
302
+ def receive_json: () -> untyped
303
+ def receive_bytes: () -> untyped
304
+ def receive_message: () -> WebSocketMessage
305
+ def close: () -> void
306
+ end
307
+
308
+ class WebSocketMessage
309
+ def initialize: (untyped) -> void
310
+ def as_text: () -> untyped
311
+ def as_json: () -> untyped
312
+ def as_binary: () -> untyped
313
+ def close?: () -> bool
314
+ end
315
+
316
+ class SseStream
317
+ def initialize: (untyped) -> void
318
+ def body: () -> String?
319
+ def events: () -> Array[InlineSseEvent]
320
+ def events_as_json: () -> Array[untyped]
321
+ end
322
+
323
+ class SseEvent
324
+ def initialize: (untyped) -> void
325
+ def data: () -> untyped
326
+ def as_json: () -> untyped
327
+ end
328
+
329
+ class InlineSseEvent
330
+ attr_reader data: String
331
+
332
+ def initialize: (String) -> void
333
+ def as_json: () -> untyped
334
+ end
335
+ end
336
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spikard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Na'aman Hirschfeld
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-11-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: websocket-client-simple
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ description: |
28
+ Spikard provides a high-performance HTTP toolkit with a Rust core and thin language bindings.
29
+ This gem bundles the Ruby bridge implemented with Magnus.
30
+ email:
31
+ - nhirschfeld@gmail.com
32
+ executables: []
33
+ extensions:
34
+ - ext/spikard_rb/extconf.rb
35
+ extra_rdoc_files: []
36
+ files:
37
+ - LICENSE
38
+ - README.md
39
+ - ext/spikard_rb/Cargo.toml
40
+ - ext/spikard_rb/extconf.rb
41
+ - ext/spikard_rb/src/lib.rs
42
+ - lib/spikard.rb
43
+ - lib/spikard/app.rb
44
+ - lib/spikard/background.rb
45
+ - lib/spikard/config.rb
46
+ - lib/spikard/converters.rb
47
+ - lib/spikard/handler_wrapper.rb
48
+ - lib/spikard/response.rb
49
+ - lib/spikard/schema.rb
50
+ - lib/spikard/sse.rb
51
+ - lib/spikard/streaming_response.rb
52
+ - lib/spikard/testing.rb
53
+ - lib/spikard/upload_file.rb
54
+ - lib/spikard/version.rb
55
+ - lib/spikard/websocket.rb
56
+ - sig/spikard.rbs
57
+ homepage: https://github.com/Goldziher/spikard
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/Goldziher/spikard
62
+ source_code_uri: https://github.com/Goldziher/spikard
63
+ changelog_uri: https://github.com/Goldziher/spikard/blob/main/CHANGELOG.md
64
+ rubygems_mfa_required: 'true'
65
+ post_install_message:
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.2.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 3.5.22
81
+ signing_key:
82
+ specification_version: 4
83
+ summary: Ruby bindings for the Spikard HTTP toolkit
84
+ test_files: []