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 +7 -0
- data/LICENSE +1 -0
- data/README.md +547 -0
- data/ext/spikard_rb/Cargo.toml +16 -0
- data/ext/spikard_rb/extconf.rb +10 -0
- data/ext/spikard_rb/src/lib.rs +6 -0
- data/lib/spikard/app.rb +328 -0
- data/lib/spikard/background.rb +27 -0
- data/lib/spikard/config.rb +396 -0
- data/lib/spikard/converters.rb +85 -0
- data/lib/spikard/handler_wrapper.rb +116 -0
- data/lib/spikard/response.rb +109 -0
- data/lib/spikard/schema.rb +243 -0
- data/lib/spikard/sse.rb +111 -0
- data/lib/spikard/streaming_response.rb +21 -0
- data/lib/spikard/testing.rb +220 -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 +42 -0
- data/sig/spikard.rbs +336 -0
- metadata +84 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ModuleLength
|
|
4
|
+
module Spikard
|
|
5
|
+
# Schema extraction helpers for Ruby type systems
|
|
6
|
+
#
|
|
7
|
+
# Supports:
|
|
8
|
+
# - Plain JSON Schema (Hash)
|
|
9
|
+
# - Dry::Schema with :json_schema extension
|
|
10
|
+
# - Dry::Struct (Dry-Types)
|
|
11
|
+
#
|
|
12
|
+
# @example With Dry::Schema
|
|
13
|
+
# require 'dry-schema'
|
|
14
|
+
# Dry::Schema.load_extensions(:json_schema)
|
|
15
|
+
#
|
|
16
|
+
# UserSchema = Dry::Schema.JSON do
|
|
17
|
+
# required(:email).filled(:str?)
|
|
18
|
+
# required(:age).filled(:int?)
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# schema = Spikard::Schema.extract_json_schema(UserSchema)
|
|
22
|
+
#
|
|
23
|
+
# @example With Dry::Struct
|
|
24
|
+
# require 'dry-struct'
|
|
25
|
+
#
|
|
26
|
+
# class User < Dry::Struct
|
|
27
|
+
# attribute :email, Types::String
|
|
28
|
+
# attribute :age, Types::Integer
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# schema = Spikard::Schema.extract_json_schema(User)
|
|
32
|
+
#
|
|
33
|
+
# @example With plain JSON Schema
|
|
34
|
+
# schema_hash = {
|
|
35
|
+
# "type" => "object",
|
|
36
|
+
# "properties" => {
|
|
37
|
+
# "email" => { "type" => "string" },
|
|
38
|
+
# "age" => { "type" => "integer" }
|
|
39
|
+
# },
|
|
40
|
+
# "required" => ["email", "age"]
|
|
41
|
+
# }
|
|
42
|
+
#
|
|
43
|
+
# schema = Spikard::Schema.extract_json_schema(schema_hash)
|
|
44
|
+
module Schema
|
|
45
|
+
# rubocop:disable Metrics/ClassLength
|
|
46
|
+
class << self
|
|
47
|
+
# Extract JSON Schema from various Ruby schema sources
|
|
48
|
+
#
|
|
49
|
+
# @param schema_source [Object] The schema source (Hash, Dry::Schema, Dry::Struct class)
|
|
50
|
+
# @return [Hash, nil] JSON Schema hash or nil if extraction fails
|
|
51
|
+
def extract_json_schema(schema_source)
|
|
52
|
+
return nil if schema_source.nil?
|
|
53
|
+
|
|
54
|
+
# 1. Check if plain JSON Schema hash
|
|
55
|
+
return schema_source if schema_source.is_a?(Hash) && json_schema_hash?(schema_source)
|
|
56
|
+
|
|
57
|
+
# 2. Check for Dry::Schema with json_schema extension
|
|
58
|
+
return extract_from_dry_schema(schema_source) if dry_schema?(schema_source)
|
|
59
|
+
|
|
60
|
+
# 3. Check for Dry::Struct (Dry-Types)
|
|
61
|
+
return extract_from_dry_struct(schema_source) if dry_struct_class?(schema_source)
|
|
62
|
+
|
|
63
|
+
# 4. Unknown type
|
|
64
|
+
warn "Spikard: Unable to extract JSON Schema from #{schema_source.class}. " \
|
|
65
|
+
'Supported types: Hash, Dry::Schema, Dry::Struct'
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Check if object is a plain JSON Schema hash
|
|
72
|
+
def json_schema_hash?(obj)
|
|
73
|
+
return false unless obj.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
# Must have 'type' key or '$schema' key
|
|
76
|
+
obj.key?('type') || obj.key?('$schema') || obj.key?(:type) || obj.key?(:$schema)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check if object is a Dry::Schema
|
|
80
|
+
def dry_schema?(obj)
|
|
81
|
+
defined?(Dry::Schema::Processor) && obj.is_a?(Dry::Schema::Processor)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if object is a Dry::Struct class
|
|
85
|
+
def dry_struct_class?(obj)
|
|
86
|
+
return false unless obj.is_a?(Class)
|
|
87
|
+
|
|
88
|
+
defined?(Dry::Struct) && obj < Dry::Struct
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Extract JSON Schema from Dry::Schema
|
|
92
|
+
def extract_from_dry_schema(schema)
|
|
93
|
+
unless schema.respond_to?(:json_schema)
|
|
94
|
+
warn 'Spikard: Dry::Schema instance does not have json_schema method. ' \
|
|
95
|
+
'Did you load the :json_schema extension? ' \
|
|
96
|
+
'Add: Dry::Schema.load_extensions(:json_schema)'
|
|
97
|
+
return nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
schema.json_schema
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
warn "Spikard: Failed to extract JSON Schema from Dry::Schema: #{e.message}"
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Extract JSON Schema from Dry::Struct class
|
|
109
|
+
# rubocop:disable Metrics/MethodLength
|
|
110
|
+
def extract_from_dry_struct(struct_class)
|
|
111
|
+
# Dry::Struct doesn't have built-in JSON Schema export
|
|
112
|
+
# We need to manually build it from the attribute schema
|
|
113
|
+
|
|
114
|
+
properties = {}
|
|
115
|
+
required = []
|
|
116
|
+
|
|
117
|
+
struct_class.schema.each do |key, type_definition|
|
|
118
|
+
# Extract attribute name
|
|
119
|
+
attr_name = key.to_s
|
|
120
|
+
|
|
121
|
+
# Determine if required (non-optional)
|
|
122
|
+
is_required = !type_definition.optional?
|
|
123
|
+
required << attr_name if is_required
|
|
124
|
+
|
|
125
|
+
# Convert Dry::Types to JSON Schema type
|
|
126
|
+
json_type = dry_type_to_json_schema(type_definition)
|
|
127
|
+
properties[attr_name] = json_type if json_type
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
'type' => 'object',
|
|
132
|
+
'properties' => properties,
|
|
133
|
+
'required' => required
|
|
134
|
+
}
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
warn "Spikard: Failed to extract JSON Schema from Dry::Struct: #{e.message}"
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
# rubocop:enable Metrics/MethodLength
|
|
140
|
+
|
|
141
|
+
# Convert Dry::Types type to JSON Schema type
|
|
142
|
+
def dry_type_to_json_schema(type_def)
|
|
143
|
+
schema = base_schema_for(type_def)
|
|
144
|
+
apply_metadata_constraints(schema, type_def)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
{ 'type' => 'object' }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# rubocop:disable Metrics/MethodLength
|
|
150
|
+
def base_schema_for(type_def)
|
|
151
|
+
type_class = type_def.primitive.to_s
|
|
152
|
+
case type_class
|
|
153
|
+
when 'String' then { 'type' => 'string' }
|
|
154
|
+
when 'Integer' then { 'type' => 'integer' }
|
|
155
|
+
when 'Float', 'BigDecimal' then { 'type' => 'number' }
|
|
156
|
+
when 'TrueClass', 'FalseClass' then { 'type' => 'boolean' }
|
|
157
|
+
when 'Array'
|
|
158
|
+
{
|
|
159
|
+
'type' => 'array',
|
|
160
|
+
'items' => infer_array_items_schema(type_def)
|
|
161
|
+
}
|
|
162
|
+
when 'Hash'
|
|
163
|
+
{ 'type' => 'object', 'additionalProperties' => true }
|
|
164
|
+
when 'NilClass' then { 'type' => 'null' }
|
|
165
|
+
else
|
|
166
|
+
{ 'type' => 'object' }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
# rubocop:enable Metrics/MethodLength
|
|
170
|
+
|
|
171
|
+
def infer_array_items_schema(type_def)
|
|
172
|
+
if type_def.respond_to?(:member) && type_def.member
|
|
173
|
+
dry_type_to_json_schema(type_def.member)
|
|
174
|
+
else
|
|
175
|
+
{}
|
|
176
|
+
end
|
|
177
|
+
rescue StandardError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def apply_metadata_constraints(schema, type_def)
|
|
182
|
+
metadata = extract_metadata(type_def)
|
|
183
|
+
return schema if metadata.empty?
|
|
184
|
+
|
|
185
|
+
schema = apply_enum_and_format(schema, metadata)
|
|
186
|
+
apply_numeric_constraints(schema, metadata)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def apply_enum_and_format(schema, metadata)
|
|
190
|
+
enum_values = metadata[:enum] || metadata['enum']
|
|
191
|
+
schema['enum'] = Array(enum_values) if enum_values
|
|
192
|
+
|
|
193
|
+
format_value = metadata[:format] || metadata['format']
|
|
194
|
+
schema['format'] = format_value.to_s if format_value
|
|
195
|
+
|
|
196
|
+
description = metadata[:description] || metadata['description']
|
|
197
|
+
schema['description'] = description.to_s if description
|
|
198
|
+
schema
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# rubocop:disable Metrics/MethodLength
|
|
202
|
+
def apply_numeric_constraints(schema, metadata)
|
|
203
|
+
mapping = {
|
|
204
|
+
min_size: 'minLength',
|
|
205
|
+
max_size: 'maxLength',
|
|
206
|
+
min_items: 'minItems',
|
|
207
|
+
max_items: 'maxItems',
|
|
208
|
+
min: 'minimum',
|
|
209
|
+
max: 'maximum',
|
|
210
|
+
gte: 'minimum',
|
|
211
|
+
lte: 'maximum',
|
|
212
|
+
gt: 'exclusiveMinimum',
|
|
213
|
+
lt: 'exclusiveMaximum'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
mapping.each do |meta_key, json_key|
|
|
217
|
+
value = metadata[meta_key] || metadata[meta_key.to_s]
|
|
218
|
+
next unless value
|
|
219
|
+
|
|
220
|
+
schema[json_key] = value
|
|
221
|
+
end
|
|
222
|
+
schema
|
|
223
|
+
end
|
|
224
|
+
# rubocop:enable Metrics/MethodLength
|
|
225
|
+
|
|
226
|
+
def extract_metadata(type_def)
|
|
227
|
+
return {} unless type_def.respond_to?(:meta) || type_def.respond_to?(:options)
|
|
228
|
+
|
|
229
|
+
if type_def.respond_to?(:meta) && type_def.meta
|
|
230
|
+
type_def.meta
|
|
231
|
+
elsif type_def.respond_to?(:options) && type_def.options.is_a?(Hash)
|
|
232
|
+
type_def.options
|
|
233
|
+
else
|
|
234
|
+
{}
|
|
235
|
+
end
|
|
236
|
+
rescue StandardError
|
|
237
|
+
{}
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
# rubocop:enable Metrics/ClassLength
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
# rubocop:enable Metrics/ModuleLength
|
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,21 @@
|
|
|
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
|
|
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
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
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 = JSON.generate(app.route_metadata)
|
|
24
|
+
handlers = app.handler_map.transform_keys(&:to_sym)
|
|
25
|
+
ws_handlers = app.websocket_handlers || {}
|
|
26
|
+
sse_producers = app.sse_producers || {}
|
|
27
|
+
native = Spikard::Native::TestClient.new(routes_json, handlers, config, ws_handlers, sse_producers)
|
|
28
|
+
TestClient.new(native)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# High level wrapper around the native test client.
|
|
32
|
+
class TestClient
|
|
33
|
+
def initialize(native)
|
|
34
|
+
@native = native
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Factory method for creating test client from an app
|
|
38
|
+
def self.new(app_or_native, config: nil)
|
|
39
|
+
# If passed a native client directly, use it
|
|
40
|
+
return super(app_or_native) if app_or_native.is_a?(Spikard::Native::TestClient)
|
|
41
|
+
|
|
42
|
+
# Otherwise, create test client from app
|
|
43
|
+
Spikard::Testing.create_test_client(app_or_native, config: config)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def request(method, path, **options)
|
|
47
|
+
payload = @native.request(method.to_s.upcase, path, options)
|
|
48
|
+
Response.new(payload)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def websocket(path)
|
|
52
|
+
native_ws = @native.websocket(path)
|
|
53
|
+
WebSocketTestConnection.new(native_ws)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def sse(path)
|
|
57
|
+
native_sse = @native.sse(path)
|
|
58
|
+
SseStream.new(native_sse)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def close
|
|
62
|
+
@native.close
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
%w[get post put patch delete head options trace].each do |verb|
|
|
66
|
+
define_method(verb) do |path, **options|
|
|
67
|
+
request(verb.upcase, path, **options)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# WebSocket test connection wrapper
|
|
73
|
+
class WebSocketTestConnection
|
|
74
|
+
def initialize(native_ws)
|
|
75
|
+
@native_ws = native_ws
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def send_text(text)
|
|
79
|
+
@native_ws.send_text(JSON.generate(text))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def send_json(obj)
|
|
83
|
+
@native_ws.send_json(obj)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def receive_text
|
|
87
|
+
raw = @native_ws.receive_text
|
|
88
|
+
JSON.parse(raw)
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
raw
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def receive_json
|
|
94
|
+
@native_ws.receive_json
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def receive_bytes
|
|
98
|
+
receive_text
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def receive_message
|
|
102
|
+
native_msg = @native_ws.receive_message
|
|
103
|
+
WebSocketMessage.new(native_msg)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def close
|
|
107
|
+
@native_ws.close
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# WebSocket message wrapper
|
|
112
|
+
class WebSocketMessage
|
|
113
|
+
def initialize(native_msg)
|
|
114
|
+
@native_msg = native_msg
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def as_text
|
|
118
|
+
raw = @native_msg.as_text
|
|
119
|
+
return unless raw
|
|
120
|
+
|
|
121
|
+
JSON.parse(raw)
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
raw
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def as_json
|
|
127
|
+
@native_msg.as_json
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def as_binary
|
|
131
|
+
@native_msg.as_binary
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def close?
|
|
135
|
+
@native_msg.is_close
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# SSE stream wrapper
|
|
140
|
+
class SseStream
|
|
141
|
+
def initialize(native_sse)
|
|
142
|
+
@native_sse = native_sse
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def body
|
|
146
|
+
@native_sse.body
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def events
|
|
150
|
+
parsed_chunks.map { |chunk| InlineSseEvent.new(chunk) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def events_as_json
|
|
154
|
+
parsed_chunks.filter_map do |chunk|
|
|
155
|
+
JSON.parse(chunk)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
164
|
+
def parsed_chunks
|
|
165
|
+
raw = body.to_s.gsub("\r\n", "\n")
|
|
166
|
+
events = []
|
|
167
|
+
current = []
|
|
168
|
+
|
|
169
|
+
raw.each_line do |line|
|
|
170
|
+
stripped = line.chomp
|
|
171
|
+
if stripped.start_with?('data:')
|
|
172
|
+
current << stripped[5..].strip
|
|
173
|
+
elsif stripped.empty?
|
|
174
|
+
unless current.empty?
|
|
175
|
+
data = current.join("\n").strip
|
|
176
|
+
events << data unless data.empty?
|
|
177
|
+
current = []
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
unless current.empty?
|
|
183
|
+
data = current.join("\n").strip
|
|
184
|
+
events << data unless data.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
events
|
|
188
|
+
end
|
|
189
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# SSE event wrapper
|
|
193
|
+
class SseEvent
|
|
194
|
+
def initialize(native_event)
|
|
195
|
+
@native_event = native_event
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def data
|
|
199
|
+
@native_event.data
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def as_json
|
|
203
|
+
@native_event.as_json
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Lightweight wrapper for parsed SSE events backed by strings.
|
|
208
|
+
class InlineSseEvent
|
|
209
|
+
attr_reader :data
|
|
210
|
+
|
|
211
|
+
def initialize(data)
|
|
212
|
+
@data = data
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def as_json
|
|
216
|
+
JSON.parse(@data)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|