spikard 0.4.0-x64-mingw-ucrt
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 +7 -0
- data/LICENSE +1 -0
- data/README.md +659 -0
- data/ext/spikard_rb/Cargo.toml +17 -0
- data/ext/spikard_rb/extconf.rb +10 -0
- data/ext/spikard_rb/src/lib.rs +6 -0
- data/lib/spikard/app.rb +405 -0
- data/lib/spikard/background.rb +27 -0
- data/lib/spikard/config.rb +396 -0
- data/lib/spikard/converters.rb +13 -0
- data/lib/spikard/handler_wrapper.rb +113 -0
- data/lib/spikard/provide.rb +214 -0
- data/lib/spikard/response.rb +173 -0
- data/lib/spikard/schema.rb +243 -0
- data/lib/spikard/sse.rb +111 -0
- data/lib/spikard/streaming_response.rb +44 -0
- data/lib/spikard/testing.rb +221 -0
- data/lib/spikard/upload_file.rb +131 -0
- data/lib/spikard/version.rb +5 -0
- data/lib/spikard/websocket.rb +59 -0
- data/lib/spikard.rb +43 -0
- data/sig/spikard.rbs +366 -0
- data/vendor/bundle/ruby/3.4.0/gems/diff-lcs-1.6.2/mise.toml +5 -0
- data/vendor/bundle/ruby/3.4.0/gems/rake-compiler-dock-1.10.0/build/buildkitd.toml +2 -0
- data/vendor/crates/spikard-bindings-shared/Cargo.toml +63 -0
- data/vendor/crates/spikard-bindings-shared/examples/config_extraction.rs +139 -0
- data/vendor/crates/spikard-bindings-shared/src/config_extractor.rs +561 -0
- data/vendor/crates/spikard-bindings-shared/src/conversion_traits.rs +194 -0
- data/vendor/crates/spikard-bindings-shared/src/di_traits.rs +246 -0
- data/vendor/crates/spikard-bindings-shared/src/error_response.rs +403 -0
- data/vendor/crates/spikard-bindings-shared/src/handler_base.rs +274 -0
- data/vendor/crates/spikard-bindings-shared/src/lib.rs +25 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_base.rs +298 -0
- data/vendor/crates/spikard-bindings-shared/src/lifecycle_executor.rs +637 -0
- data/vendor/crates/spikard-bindings-shared/src/response_builder.rs +309 -0
- data/vendor/crates/spikard-bindings-shared/src/test_client_base.rs +248 -0
- data/vendor/crates/spikard-bindings-shared/src/validation_helpers.rs +355 -0
- data/vendor/crates/spikard-bindings-shared/tests/comprehensive_coverage.rs +502 -0
- data/vendor/crates/spikard-bindings-shared/tests/error_response_edge_cases.rs +389 -0
- data/vendor/crates/spikard-bindings-shared/tests/handler_base_integration.rs +413 -0
- data/vendor/crates/spikard-core/Cargo.toml +40 -0
- data/vendor/crates/spikard-core/src/bindings/mod.rs +3 -0
- data/vendor/crates/spikard-core/src/bindings/response.rs +133 -0
- data/vendor/crates/spikard-core/src/debug.rs +63 -0
- data/vendor/crates/spikard-core/src/di/container.rs +726 -0
- data/vendor/crates/spikard-core/src/di/dependency.rs +273 -0
- data/vendor/crates/spikard-core/src/di/error.rs +118 -0
- data/vendor/crates/spikard-core/src/di/factory.rs +538 -0
- data/vendor/crates/spikard-core/src/di/graph.rs +545 -0
- data/vendor/crates/spikard-core/src/di/mod.rs +192 -0
- data/vendor/crates/spikard-core/src/di/resolved.rs +411 -0
- data/vendor/crates/spikard-core/src/di/value.rs +283 -0
- data/vendor/crates/spikard-core/src/errors.rs +39 -0
- data/vendor/crates/spikard-core/src/http.rs +153 -0
- data/vendor/crates/spikard-core/src/lib.rs +29 -0
- data/vendor/crates/spikard-core/src/lifecycle.rs +422 -0
- data/vendor/crates/spikard-core/src/metadata.rs +397 -0
- data/vendor/crates/spikard-core/src/parameters.rs +723 -0
- data/vendor/crates/spikard-core/src/problem.rs +310 -0
- data/vendor/crates/spikard-core/src/request_data.rs +189 -0
- data/vendor/crates/spikard-core/src/router.rs +249 -0
- data/vendor/crates/spikard-core/src/schema_registry.rs +183 -0
- data/vendor/crates/spikard-core/src/type_hints.rs +304 -0
- data/vendor/crates/spikard-core/src/validation/error_mapper.rs +689 -0
- data/vendor/crates/spikard-core/src/validation/mod.rs +459 -0
- data/vendor/crates/spikard-http/Cargo.toml +58 -0
- data/vendor/crates/spikard-http/examples/sse-notifications.rs +147 -0
- data/vendor/crates/spikard-http/examples/websocket-chat.rs +91 -0
- data/vendor/crates/spikard-http/src/auth.rs +247 -0
- data/vendor/crates/spikard-http/src/background.rs +1562 -0
- data/vendor/crates/spikard-http/src/bindings/mod.rs +3 -0
- data/vendor/crates/spikard-http/src/bindings/response.rs +1 -0
- data/vendor/crates/spikard-http/src/body_metadata.rs +8 -0
- data/vendor/crates/spikard-http/src/cors.rs +490 -0
- data/vendor/crates/spikard-http/src/debug.rs +63 -0
- data/vendor/crates/spikard-http/src/di_handler.rs +1878 -0
- data/vendor/crates/spikard-http/src/handler_response.rs +532 -0
- data/vendor/crates/spikard-http/src/handler_trait.rs +861 -0
- data/vendor/crates/spikard-http/src/handler_trait_tests.rs +284 -0
- data/vendor/crates/spikard-http/src/lib.rs +524 -0
- data/vendor/crates/spikard-http/src/lifecycle/adapter.rs +149 -0
- data/vendor/crates/spikard-http/src/lifecycle.rs +428 -0
- data/vendor/crates/spikard-http/src/middleware/mod.rs +285 -0
- data/vendor/crates/spikard-http/src/middleware/multipart.rs +930 -0
- data/vendor/crates/spikard-http/src/middleware/urlencoded.rs +541 -0
- data/vendor/crates/spikard-http/src/middleware/validation.rs +287 -0
- data/vendor/crates/spikard-http/src/openapi/mod.rs +309 -0
- data/vendor/crates/spikard-http/src/openapi/parameter_extraction.rs +535 -0
- data/vendor/crates/spikard-http/src/openapi/schema_conversion.rs +867 -0
- data/vendor/crates/spikard-http/src/openapi/spec_generation.rs +678 -0
- data/vendor/crates/spikard-http/src/query_parser.rs +369 -0
- data/vendor/crates/spikard-http/src/response.rs +399 -0
- data/vendor/crates/spikard-http/src/server/handler.rs +1557 -0
- data/vendor/crates/spikard-http/src/server/lifecycle_execution.rs +98 -0
- data/vendor/crates/spikard-http/src/server/mod.rs +806 -0
- data/vendor/crates/spikard-http/src/server/request_extraction.rs +630 -0
- data/vendor/crates/spikard-http/src/server/routing_factory.rs +497 -0
- data/vendor/crates/spikard-http/src/sse.rs +961 -0
- data/vendor/crates/spikard-http/src/testing/form.rs +14 -0
- data/vendor/crates/spikard-http/src/testing/multipart.rs +60 -0
- data/vendor/crates/spikard-http/src/testing/test_client.rs +285 -0
- data/vendor/crates/spikard-http/src/testing.rs +377 -0
- data/vendor/crates/spikard-http/src/websocket.rs +831 -0
- data/vendor/crates/spikard-http/tests/background_behavior.rs +918 -0
- data/vendor/crates/spikard-http/tests/common/handlers.rs +308 -0
- data/vendor/crates/spikard-http/tests/common/mod.rs +21 -0
- data/vendor/crates/spikard-http/tests/di_integration.rs +202 -0
- data/vendor/crates/spikard-http/tests/doc_snippets.rs +4 -0
- data/vendor/crates/spikard-http/tests/lifecycle_execution.rs +1135 -0
- data/vendor/crates/spikard-http/tests/multipart_behavior.rs +688 -0
- data/vendor/crates/spikard-http/tests/server_config_builder.rs +324 -0
- data/vendor/crates/spikard-http/tests/sse_behavior.rs +728 -0
- data/vendor/crates/spikard-http/tests/websocket_behavior.rs +724 -0
- data/vendor/crates/spikard-rb/Cargo.toml +43 -0
- data/vendor/crates/spikard-rb/build.rs +199 -0
- data/vendor/crates/spikard-rb/src/background.rs +63 -0
- data/vendor/crates/spikard-rb/src/config/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/config/server_config.rs +283 -0
- data/vendor/crates/spikard-rb/src/conversion.rs +459 -0
- data/vendor/crates/spikard-rb/src/di/builder.rs +105 -0
- data/vendor/crates/spikard-rb/src/di/mod.rs +413 -0
- data/vendor/crates/spikard-rb/src/handler.rs +612 -0
- data/vendor/crates/spikard-rb/src/integration/mod.rs +3 -0
- data/vendor/crates/spikard-rb/src/lib.rs +1857 -0
- data/vendor/crates/spikard-rb/src/lifecycle.rs +275 -0
- data/vendor/crates/spikard-rb/src/metadata/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/metadata/route_extraction.rs +427 -0
- data/vendor/crates/spikard-rb/src/runtime/mod.rs +5 -0
- data/vendor/crates/spikard-rb/src/runtime/server_runner.rs +326 -0
- data/vendor/crates/spikard-rb/src/server.rs +283 -0
- data/vendor/crates/spikard-rb/src/sse.rs +231 -0
- data/vendor/crates/spikard-rb/src/testing/client.rs +404 -0
- data/vendor/crates/spikard-rb/src/testing/mod.rs +7 -0
- data/vendor/crates/spikard-rb/src/testing/sse.rs +143 -0
- data/vendor/crates/spikard-rb/src/testing/websocket.rs +221 -0
- data/vendor/crates/spikard-rb/src/websocket.rs +233 -0
- data/vendor/crates/spikard-rb/tests/magnus_ffi_tests.rs +14 -0
- metadata +213 -0
data/lib/spikard/sse.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spikard
|
|
4
|
+
# Represents a Server-Sent Event.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [rw] data
|
|
7
|
+
# @return [Hash] Event data (will be JSON serialized)
|
|
8
|
+
# @!attribute [rw] event_type
|
|
9
|
+
# @return [String, nil] Optional event type
|
|
10
|
+
# @!attribute [rw] id
|
|
11
|
+
# @return [String, nil] Optional event ID for client reconnection support
|
|
12
|
+
# @!attribute [rw] retry_ms
|
|
13
|
+
# @return [Integer, nil] Optional retry timeout in milliseconds
|
|
14
|
+
class SseEvent
|
|
15
|
+
attr_accessor :data, :event_type, :id, :retry_ms
|
|
16
|
+
|
|
17
|
+
# Create a new SSE event.
|
|
18
|
+
#
|
|
19
|
+
# @param data [Hash] Event data (will be JSON serialized)
|
|
20
|
+
# @param event_type [String, nil] Optional event type
|
|
21
|
+
# @param id [String, nil] Optional event ID for client reconnection support
|
|
22
|
+
# @param retry_ms [Integer, nil] Optional retry timeout in milliseconds
|
|
23
|
+
def initialize(data:, event_type: nil, id: nil, retry_ms: nil)
|
|
24
|
+
@data = data
|
|
25
|
+
@event_type = event_type
|
|
26
|
+
@id = id
|
|
27
|
+
@retry_ms = retry_ms
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Convert to hash for JSON serialization.
|
|
31
|
+
#
|
|
32
|
+
# @return [Hash] Hash representation of the event
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
data: @data,
|
|
36
|
+
event_type: @event_type,
|
|
37
|
+
id: @id,
|
|
38
|
+
retry: @retry_ms
|
|
39
|
+
}.compact
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Base class for SSE event producers.
|
|
44
|
+
#
|
|
45
|
+
# Implement this class to generate Server-Sent Events.
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# class NotificationProducer < Spikard::SseEventProducer
|
|
49
|
+
# def initialize
|
|
50
|
+
# @count = 0
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# def next_event
|
|
54
|
+
# sleep 1 # Wait 1 second between events
|
|
55
|
+
#
|
|
56
|
+
# return nil if @count >= 10 # End stream after 10 events
|
|
57
|
+
#
|
|
58
|
+
# event = Spikard::SseEvent.new(
|
|
59
|
+
# data: { message: "Notification #{@count}" },
|
|
60
|
+
# event_type: 'notification',
|
|
61
|
+
# id: @count.to_s
|
|
62
|
+
# )
|
|
63
|
+
# @count += 1
|
|
64
|
+
# event
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# def on_connect
|
|
68
|
+
# puts "Client connected to SSE stream"
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# def on_disconnect
|
|
72
|
+
# puts "Client disconnected from SSE stream"
|
|
73
|
+
# end
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# app = Spikard::App.new
|
|
77
|
+
#
|
|
78
|
+
# app.sse('/notifications') do
|
|
79
|
+
# NotificationProducer.new
|
|
80
|
+
# end
|
|
81
|
+
#
|
|
82
|
+
# app.run
|
|
83
|
+
class SseEventProducer
|
|
84
|
+
# Generate the next event.
|
|
85
|
+
#
|
|
86
|
+
# This method is called repeatedly to produce the event stream.
|
|
87
|
+
#
|
|
88
|
+
# @return [SseEvent, nil] SseEvent when an event is ready, or nil to end the stream.
|
|
89
|
+
def next_event
|
|
90
|
+
raise NotImplementedError, "#{self.class.name} must implement #next_event"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Called when a client connects to the SSE endpoint.
|
|
94
|
+
#
|
|
95
|
+
# Override this method to perform initialization when a client connects.
|
|
96
|
+
#
|
|
97
|
+
# @return [void]
|
|
98
|
+
def on_connect
|
|
99
|
+
# Optional hook - default implementation does nothing
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Called when a client disconnects from the SSE endpoint.
|
|
103
|
+
#
|
|
104
|
+
# Override this method to perform cleanup when a client disconnects.
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
def on_disconnect
|
|
108
|
+
# Optional hook - default implementation does nothing
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spikard
|
|
4
|
+
# Represents a streaming HTTP response made of chunks produced lazily.
|
|
5
|
+
class StreamingResponse
|
|
6
|
+
attr_reader :stream, :status_code, :headers, :native_response
|
|
7
|
+
|
|
8
|
+
def initialize(stream, status_code: 200, headers: nil)
|
|
9
|
+
unless stream.respond_to?(:next) || stream.respond_to?(:each)
|
|
10
|
+
raise ArgumentError, 'StreamingResponse requires an object responding to #next or #each'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
@stream = stream.respond_to?(:to_enum) ? stream.to_enum : stream
|
|
14
|
+
@status_code = Integer(status_code || 200)
|
|
15
|
+
header_hash = headers || {}
|
|
16
|
+
@headers = header_hash.each_with_object({}) do |(key, value), memo|
|
|
17
|
+
memo[String(key)] = String(value)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
rebuild_native!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_native_response
|
|
24
|
+
@native_response
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def rebuild_native!
|
|
30
|
+
ensure_native!
|
|
31
|
+
@native_response = Spikard::Native.build_streaming_response(@stream, @status_code, @headers)
|
|
32
|
+
return unless @native_response
|
|
33
|
+
|
|
34
|
+
@status_code = @native_response.status_code
|
|
35
|
+
@headers = @native_response.headers
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ensure_native!
|
|
39
|
+
return if defined?(Spikard::Native) && Spikard::Native.respond_to?(:build_streaming_response)
|
|
40
|
+
|
|
41
|
+
raise 'Spikard native extension is not loaded'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Spikard
|
|
6
|
+
# Testing helpers that wrap the native Ruby extension.
|
|
7
|
+
module Testing
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def create_test_client(app, config: nil)
|
|
11
|
+
unless defined?(Spikard::Native::TestClient)
|
|
12
|
+
raise LoadError, 'Spikard native test client is not available. Build the native extension before running tests.'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Allow generated apps to stash a test config
|
|
16
|
+
if config.nil? && app.instance_variable_defined?(:@__spikard_test_config)
|
|
17
|
+
config = app.instance_variable_get(:@__spikard_test_config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Use default config if none provided
|
|
21
|
+
config ||= Spikard::ServerConfig.new
|
|
22
|
+
|
|
23
|
+
routes_json = app.normalized_routes_json
|
|
24
|
+
handlers = app.handler_map.transform_keys(&:to_sym)
|
|
25
|
+
ws_handlers = app.websocket_handlers || {}
|
|
26
|
+
sse_producers = app.sse_producers || {}
|
|
27
|
+
dependencies = app.dependencies || {}
|
|
28
|
+
native = Spikard::Native::TestClient.new(routes_json, handlers, config, ws_handlers, sse_producers, dependencies)
|
|
29
|
+
TestClient.new(native)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# High level wrapper around the native test client.
|
|
33
|
+
class TestClient
|
|
34
|
+
def initialize(native)
|
|
35
|
+
@native = native
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Factory method for creating test client from an app
|
|
39
|
+
def self.new(app_or_native, config: nil)
|
|
40
|
+
# If passed a native client directly, use it
|
|
41
|
+
return super(app_or_native) if app_or_native.is_a?(Spikard::Native::TestClient)
|
|
42
|
+
|
|
43
|
+
# Otherwise, create test client from app
|
|
44
|
+
Spikard::Testing.create_test_client(app_or_native, config: config)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def request(method, path, **options)
|
|
48
|
+
payload = @native.request(method.to_s.upcase, path, options)
|
|
49
|
+
Response.new(payload)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def websocket(path)
|
|
53
|
+
native_ws = @native.websocket(path)
|
|
54
|
+
WebSocketTestConnection.new(native_ws)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def sse(path)
|
|
58
|
+
native_sse = @native.sse(path)
|
|
59
|
+
SseStream.new(native_sse)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def close
|
|
63
|
+
@native.close
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
%w[get post put patch delete head options trace].each do |verb|
|
|
67
|
+
define_method(verb) do |path, **options|
|
|
68
|
+
request(verb.upcase, path, **options)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# WebSocket test connection wrapper
|
|
74
|
+
class WebSocketTestConnection
|
|
75
|
+
def initialize(native_ws)
|
|
76
|
+
@native_ws = native_ws
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def send_text(text)
|
|
80
|
+
@native_ws.send_text(JSON.generate(text))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def send_json(obj)
|
|
84
|
+
@native_ws.send_json(obj)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def receive_text
|
|
88
|
+
raw = @native_ws.receive_text
|
|
89
|
+
JSON.parse(raw)
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
raw
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def receive_json
|
|
95
|
+
@native_ws.receive_json
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def receive_bytes
|
|
99
|
+
receive_text
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def receive_message
|
|
103
|
+
native_msg = @native_ws.receive_message
|
|
104
|
+
WebSocketMessage.new(native_msg)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def close
|
|
108
|
+
@native_ws.close
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# WebSocket message wrapper
|
|
113
|
+
class WebSocketMessage
|
|
114
|
+
def initialize(native_msg)
|
|
115
|
+
@native_msg = native_msg
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def as_text
|
|
119
|
+
raw = @native_msg.as_text
|
|
120
|
+
return unless raw
|
|
121
|
+
|
|
122
|
+
JSON.parse(raw)
|
|
123
|
+
rescue JSON::ParserError
|
|
124
|
+
raw
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def as_json
|
|
128
|
+
@native_msg.as_json
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def as_binary
|
|
132
|
+
@native_msg.as_binary
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def close?
|
|
136
|
+
@native_msg.is_close
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# SSE stream wrapper
|
|
141
|
+
class SseStream
|
|
142
|
+
def initialize(native_sse)
|
|
143
|
+
@native_sse = native_sse
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def body
|
|
147
|
+
@native_sse.body
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def events
|
|
151
|
+
parsed_chunks.map { |chunk| InlineSseEvent.new(chunk) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def events_as_json
|
|
155
|
+
parsed_chunks.filter_map do |chunk|
|
|
156
|
+
JSON.parse(chunk)
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
165
|
+
def parsed_chunks
|
|
166
|
+
raw = body.to_s.gsub("\r\n", "\n")
|
|
167
|
+
events = []
|
|
168
|
+
current = []
|
|
169
|
+
|
|
170
|
+
raw.each_line do |line|
|
|
171
|
+
stripped = line.chomp
|
|
172
|
+
if stripped.start_with?('data:')
|
|
173
|
+
current << stripped[5..].strip
|
|
174
|
+
elsif stripped.empty?
|
|
175
|
+
unless current.empty?
|
|
176
|
+
data = current.join("\n").strip
|
|
177
|
+
events << data unless data.empty?
|
|
178
|
+
current = []
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
unless current.empty?
|
|
184
|
+
data = current.join("\n").strip
|
|
185
|
+
events << data unless data.empty?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
events
|
|
189
|
+
end
|
|
190
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# SSE event wrapper
|
|
194
|
+
class SseEvent
|
|
195
|
+
def initialize(native_event)
|
|
196
|
+
@native_event = native_event
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def data
|
|
200
|
+
@native_event.data
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def as_json
|
|
204
|
+
@native_event.as_json
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Lightweight wrapper for parsed SSE events backed by strings.
|
|
209
|
+
class InlineSseEvent
|
|
210
|
+
attr_reader :data
|
|
211
|
+
|
|
212
|
+
def initialize(data)
|
|
213
|
+
@data = data
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def as_json
|
|
217
|
+
JSON.parse(@data)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -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,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,43 @@
|
|
|
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/provide'
|
|
24
|
+
require_relative 'spikard/handler_wrapper'
|
|
25
|
+
require_relative 'spikard/app'
|
|
26
|
+
require_relative 'spikard/testing'
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
require 'spikard_rb'
|
|
30
|
+
rescue LoadError => e
|
|
31
|
+
raise LoadError, <<~MSG, e.backtrace
|
|
32
|
+
Failed to load the Spikard native extension (spikard_rb). Run `bundle exec rake ext:build` to compile it before executing tests.
|
|
33
|
+
Original error: #{e.message}
|
|
34
|
+
MSG
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convenience aliases and methods at top level
|
|
38
|
+
module Spikard
|
|
39
|
+
TestClient = Testing::TestClient
|
|
40
|
+
|
|
41
|
+
# Handler wrapper utilities
|
|
42
|
+
extend HandlerWrapper
|
|
43
|
+
end
|