igniter-ledger-client 0.5.2
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/README.md +173 -0
- data/lib/igniter/ledger_client/client.rb +135 -0
- data/lib/igniter/ledger_client/envelope.rb +71 -0
- data/lib/igniter/ledger_client/error.rb +17 -0
- data/lib/igniter/ledger_client/results.rb +406 -0
- data/lib/igniter/ledger_client/subscription.rb +32 -0
- data/lib/igniter/ledger_client/transports/object_dispatch.rb +64 -0
- data/lib/igniter/ledger_client/transports/remote_http.rb +166 -0
- data/lib/igniter/ledger_client.rb +23 -0
- data/lib/igniter-ledger-client.rb +3 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c4fe38120d406045031a3f7bfaae0c29e576b30c539c6632d7b3dc2a1b1d4b85
|
|
4
|
+
data.tar.gz: eea5c501aa9c2c30a0c512426bd855321de3a4ca8788b4bdc5183bd7755179ee
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4e32fca6b33da4045e7339df9ff2abf2c863941cbb287bba4758c0b6cc17e60105e509469f588eec33a9b695f68bd9817c4717b609f58ad6999e3794cf378fa7
|
|
7
|
+
data.tar.gz: 70f56f887565bc4871173b4749a0d3ce99259608148dba12bdef33e0a42fa995b7c76fa263ad60fddf2d312848ae32987d101c49bbfe6184a87876d0814b8745
|
data/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# igniter-ledger-client
|
|
2
|
+
|
|
3
|
+
Protocol-first client package for Igniter Ledger / Ledger Open Protocol.
|
|
4
|
+
|
|
5
|
+
Status: pre-v1 skeleton. This package owns the client boundary, not the storage
|
|
6
|
+
engine.
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
`igniter-ledger-client` is the shared client layer for packages and host apps
|
|
11
|
+
that need to talk to a Ledger/Store endpoint without depending on
|
|
12
|
+
`igniter-ledger` internals.
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
Embed / Companion / Web / MCP / Spark adapters
|
|
16
|
+
-> Igniter::LedgerClient
|
|
17
|
+
-> Ledger Open Protocol envelope
|
|
18
|
+
-> local dispatch | remote HTTP | future TCP/pool/outbox transport
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package deliberately has no runtime dependency on `igniter-ledger`.
|
|
22
|
+
|
|
23
|
+
## Owns
|
|
24
|
+
|
|
25
|
+
- request/response envelope helpers
|
|
26
|
+
- client error semantics
|
|
27
|
+
- stable Ruby method surface for common Ledger operations
|
|
28
|
+
- transport adapters such as object dispatch and remote HTTP
|
|
29
|
+
- future pooling, timeout, retry, and backpressure policy seams
|
|
30
|
+
|
|
31
|
+
## Does Not Own
|
|
32
|
+
|
|
33
|
+
- fact storage engine
|
|
34
|
+
- WAL, segments, compaction, or changefeed internals
|
|
35
|
+
- contract execution
|
|
36
|
+
- Rails, Sidekiq, ActiveRecord, or Spark-specific code
|
|
37
|
+
- Store-to-Ledger package rename
|
|
38
|
+
|
|
39
|
+
## Example
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require "igniter-ledger-client"
|
|
43
|
+
|
|
44
|
+
client = Igniter::LedgerClient.remote_http(
|
|
45
|
+
"http://127.0.0.1:7300/v1/dispatch",
|
|
46
|
+
open_timeout: 1.0,
|
|
47
|
+
read_timeout: 2.0
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
client.write(
|
|
51
|
+
store: :orders,
|
|
52
|
+
key: "order-1",
|
|
53
|
+
value: { status: :open },
|
|
54
|
+
producer: { type: :app, name: :spark }
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
client.append(
|
|
58
|
+
history: :order_events,
|
|
59
|
+
event: { event_id: "evt-1", order_id: "order-1", event: :opened },
|
|
60
|
+
partition_key: :order_id,
|
|
61
|
+
producer: { type: :app, name: :spark }
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
client.read(store: :orders, key: "order-1")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For local/integration tests, wrap any object exposing `dispatch(envelope)` or
|
|
68
|
+
`wire.dispatch(envelope)`:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
client = Igniter::LedgerClient.wrap(protocol_interpreter.wire)
|
|
72
|
+
client.metadata_snapshot
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When `igniter-ledger` is present in the same process, wrapping
|
|
76
|
+
`LedgerStore#protocol` is the local adoption path:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
ledger = Igniter::Ledger::LedgerStore.new
|
|
80
|
+
client = Igniter::LedgerClient.wrap(ledger.protocol)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Package-level adapters should prefer accepting a `client:` argument over
|
|
84
|
+
reaching into Ledger internals. For example, `ContractableReceiptSink` can be
|
|
85
|
+
constructed with `client: client` and still use the same protocol envelope path
|
|
86
|
+
as a remote HTTP client.
|
|
87
|
+
|
|
88
|
+
## v0 Surface
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
client.register_descriptor(...)
|
|
92
|
+
client.write(store:, key:, value:, **metadata)
|
|
93
|
+
client.append(history:, event:, key: nil, partition_key: nil, **metadata)
|
|
94
|
+
client.read(store:, key:, as_of: nil)
|
|
95
|
+
client.query(store:, where:, limit: nil, as_of: nil, order: nil)
|
|
96
|
+
client.replay(
|
|
97
|
+
store: nil,
|
|
98
|
+
from: nil,
|
|
99
|
+
to: nil,
|
|
100
|
+
key: nil,
|
|
101
|
+
partition_key: nil,
|
|
102
|
+
partition_value: nil,
|
|
103
|
+
filter: nil
|
|
104
|
+
)
|
|
105
|
+
client.resolve(relation:, from:, as_of: nil)
|
|
106
|
+
client.causation_chain(store:, key:)
|
|
107
|
+
client.lineage(store:, key:)
|
|
108
|
+
client.fact_ref(fact_id)
|
|
109
|
+
client.subscribe(stores:, cursor: nil) { |event| ... }
|
|
110
|
+
client.metadata_snapshot
|
|
111
|
+
client.descriptor_snapshot
|
|
112
|
+
client.observability_snapshot
|
|
113
|
+
client.compaction_activity(store: nil, kind: nil, since: nil, limit: nil)
|
|
114
|
+
client.close
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`append` dispatches the Ledger Open Protocol `append` op and keeps append-only
|
|
118
|
+
history semantics distinct from keyed record writes. `key:` may be sent as
|
|
119
|
+
client metadata for future idempotency work, but protocol v0 returns the
|
|
120
|
+
generated fact key and does not treat `key:` as a stable idempotency guarantee.
|
|
121
|
+
|
|
122
|
+
`replay` accepts either an explicit protocol `filter:` or convenience arguments
|
|
123
|
+
for store, key, and partition replay. Partition replay sends
|
|
124
|
+
`filter: { store:, partition_key:, partition_value: }` and uses Ledger
|
|
125
|
+
partition indexes when the endpoint is backed by a Ledger protocol interpreter.
|
|
126
|
+
|
|
127
|
+
`subscribe` returns an idempotently closeable handle and yields
|
|
128
|
+
`Igniter::LedgerClient::Results::ChangeEventResult` objects. Remote HTTP
|
|
129
|
+
subscriptions read SSE from `/v1/events`; `events_url:` can be passed explicitly,
|
|
130
|
+
otherwise it is derived from `/v1/dispatch`. Cursor resume uses the `?cursor=`
|
|
131
|
+
query parameter.
|
|
132
|
+
|
|
133
|
+
## Docs
|
|
134
|
+
|
|
135
|
+
- [docs/tracks/ledger-client-protocol-v0.md](docs/tracks/ledger-client-protocol-v0.md)
|
|
136
|
+
— current implementation/convergence track for this package.
|
|
137
|
+
|
|
138
|
+
## Error Policy
|
|
139
|
+
|
|
140
|
+
The client raises `Igniter::LedgerClient::Error` for protocol error envelopes
|
|
141
|
+
and `Igniter::LedgerClient::TransportError` for transport failures.
|
|
142
|
+
|
|
143
|
+
Successful mutation/read calls return small result objects:
|
|
144
|
+
|
|
145
|
+
- `write` -> `Igniter::LedgerClient::Results::WriteResult`
|
|
146
|
+
- `append` -> `Igniter::LedgerClient::Results::AppendResult`
|
|
147
|
+
- `register_descriptor` -> `Igniter::LedgerClient::Results::ReceiptResult`
|
|
148
|
+
- `read` -> `Igniter::LedgerClient::Results::ReadResult`
|
|
149
|
+
- `query` -> `Igniter::LedgerClient::Results::QueryResult`
|
|
150
|
+
- `resolve` -> `Igniter::LedgerClient::Results::ResolveResult`
|
|
151
|
+
- `replay` -> `Igniter::LedgerClient::Results::ReplayResult`
|
|
152
|
+
- `causation_chain` -> `Igniter::LedgerClient::Results::CausationChainResult`
|
|
153
|
+
- `lineage` -> `Igniter::LedgerClient::Results::LineageResult`
|
|
154
|
+
- `fact_ref` -> `Igniter::LedgerClient::Results::FactRefResult`
|
|
155
|
+
- `subscribe` events -> `Igniter::LedgerClient::Results::ChangeEventResult`
|
|
156
|
+
|
|
157
|
+
Result objects expose named readers, `to_h`, and transitional `[]` access.
|
|
158
|
+
`QueryResult#items` is the canonical row shape for query consumers and includes
|
|
159
|
+
`{ key:, value: }` entries; `QueryResult#results` remains the backward-compatible
|
|
160
|
+
value-only list. `ResolveResult` follows the same `items`/`results` convention
|
|
161
|
+
so typed clients can preserve source record keys.
|
|
162
|
+
`causation_chain`, `lineage`, and `fact_ref` are read-only provenance
|
|
163
|
+
introspection calls. `fact_ref` returns compact metadata only; arbitrary
|
|
164
|
+
`fact_by_id` value reads remain outside the public client surface.
|
|
165
|
+
Snapshot-style methods such as `metadata_snapshot` and `observability_snapshot`
|
|
166
|
+
still return raw protocol hashes in v0.
|
|
167
|
+
|
|
168
|
+
## Package Boundary
|
|
169
|
+
|
|
170
|
+
`igniter-embed` should not own Store connections, pools, retries, or
|
|
171
|
+
backpressure. Embed emits receipts to an adapter protocol. The adapter can then
|
|
172
|
+
use `igniter-ledger-client` to deliver those receipts locally, remotely, or
|
|
173
|
+
through a host outbox.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "envelope"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
require_relative "results"
|
|
6
|
+
require_relative "subscription"
|
|
7
|
+
|
|
8
|
+
module Igniter
|
|
9
|
+
module LedgerClient
|
|
10
|
+
class Client
|
|
11
|
+
attr_reader :transport
|
|
12
|
+
|
|
13
|
+
def initialize(transport:)
|
|
14
|
+
@transport = transport
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register_descriptor(descriptor = nil, **fields)
|
|
18
|
+
Results.wrap(:register_descriptor, dispatch(:register_descriptor, (descriptor || {}).merge(fields)))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(store:, key:, value:, **metadata)
|
|
22
|
+
Results.wrap(:write, dispatch(:write, metadata.merge(store: store, key: key, value: value)))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def append(history:, event:, key: nil, partition_key: nil, **metadata)
|
|
26
|
+
packet = metadata.merge(history: history, event: event)
|
|
27
|
+
packet[:key] = key if key
|
|
28
|
+
packet[:partition_key] = partition_key if partition_key
|
|
29
|
+
Results.wrap(:append, dispatch(:append, packet))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def read(store:, key:, as_of: nil)
|
|
33
|
+
packet = { store: store, key: key }
|
|
34
|
+
packet[:as_of] = as_of if as_of
|
|
35
|
+
Results.wrap(:read, dispatch(:read, packet))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def query(store:, where:, limit: nil, as_of: nil, order: nil)
|
|
39
|
+
packet = { store: store, where: where }
|
|
40
|
+
packet[:limit] = limit if limit
|
|
41
|
+
packet[:as_of] = as_of if as_of
|
|
42
|
+
packet[:order] = order if order
|
|
43
|
+
Results.wrap(:query, dispatch(:query, packet))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def replay(store: nil, from: nil, to: nil, key: nil, partition_key: nil, partition_value: nil, filter: nil)
|
|
47
|
+
packet = {}
|
|
48
|
+
packet[:from] = from if from
|
|
49
|
+
packet[:to] = to if to
|
|
50
|
+
filter_packet = replay_filter(
|
|
51
|
+
store: store,
|
|
52
|
+
key: key,
|
|
53
|
+
partition_key: partition_key,
|
|
54
|
+
partition_value: partition_value,
|
|
55
|
+
filter: filter
|
|
56
|
+
)
|
|
57
|
+
packet[:filter] = filter_packet if filter_packet
|
|
58
|
+
Results.wrap(:replay, dispatch(:replay, packet))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve(relation:, from:, as_of: nil)
|
|
62
|
+
packet = { relation: relation, from: from }
|
|
63
|
+
packet[:as_of] = as_of if as_of
|
|
64
|
+
Results.wrap(:resolve, dispatch(:resolve, packet))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def causation_chain(store:, key:)
|
|
68
|
+
Results.wrap(:causation_chain, dispatch(:causation_chain, { store: store, key: key }))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def lineage(store:, key:)
|
|
72
|
+
Results.wrap(:lineage, dispatch(:lineage, { store: store, key: key }))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fact_ref(fact_id)
|
|
76
|
+
Results.wrap(:fact_ref, dispatch(:fact_ref, { fact_id: fact_id }))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def metadata_snapshot = dispatch(:metadata_snapshot)
|
|
80
|
+
|
|
81
|
+
def descriptor_snapshot = dispatch(:descriptor_snapshot)
|
|
82
|
+
|
|
83
|
+
def observability_snapshot = dispatch(:observability_snapshot)
|
|
84
|
+
|
|
85
|
+
def compaction_activity(store: nil, kind: nil, since: nil, limit: nil)
|
|
86
|
+
packet = {}
|
|
87
|
+
packet[:store] = store if store
|
|
88
|
+
packet[:kind] = kind if kind
|
|
89
|
+
packet[:since] = since if since
|
|
90
|
+
packet[:limit] = limit if limit
|
|
91
|
+
dispatch(:compaction_activity, packet)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def subscribe(stores:, cursor: nil, &block)
|
|
95
|
+
raise ArgumentError, "subscribe requires a block" unless block
|
|
96
|
+
|
|
97
|
+
raise NotImplementedError, "ledger client transport does not support subscriptions" unless transport.respond_to?(:subscribe)
|
|
98
|
+
|
|
99
|
+
transport.subscribe(stores: stores, cursor: cursor) do |event|
|
|
100
|
+
block.call(Results::ChangeEventResult.new(event))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def dispatch(operation, packet = {}, request_id: nil)
|
|
105
|
+
request = Envelope.request(operation: operation, packet: packet, request_id: request_id)
|
|
106
|
+
Envelope.result_or_raise(transport.dispatch(request))
|
|
107
|
+
rescue Error
|
|
108
|
+
raise
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
raise TransportError.new(e.message, request_id: request&.fetch(:request_id, nil))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def close
|
|
114
|
+
transport.close if transport.respond_to?(:close)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def replay_filter(store:, key:, partition_key:, partition_value:, filter:)
|
|
120
|
+
convenience_filter = {}
|
|
121
|
+
convenience_filter[:store] = store if store
|
|
122
|
+
convenience_filter[:key] = key if key
|
|
123
|
+
convenience_filter[:partition_key] = partition_key if partition_key
|
|
124
|
+
convenience_filter[:partition_value] = partition_value if partition_value
|
|
125
|
+
|
|
126
|
+
raise ArgumentError, "replay filter: cannot be combined with store/key/partition convenience arguments" if filter && !convenience_filter.empty?
|
|
127
|
+
|
|
128
|
+
return filter if filter
|
|
129
|
+
return nil if convenience_filter.empty?
|
|
130
|
+
|
|
131
|
+
convenience_filter
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module LedgerClient
|
|
7
|
+
module Envelope
|
|
8
|
+
PROTOCOL = :igniter_store
|
|
9
|
+
SCHEMA_VERSION = 1
|
|
10
|
+
|
|
11
|
+
OPERATIONS = %i[
|
|
12
|
+
register_descriptor
|
|
13
|
+
write
|
|
14
|
+
append
|
|
15
|
+
write_fact
|
|
16
|
+
read
|
|
17
|
+
query
|
|
18
|
+
resolve
|
|
19
|
+
causation_chain
|
|
20
|
+
lineage
|
|
21
|
+
fact_ref
|
|
22
|
+
metadata_snapshot
|
|
23
|
+
descriptor_snapshot
|
|
24
|
+
observability_snapshot
|
|
25
|
+
sync_hub_profile
|
|
26
|
+
replay
|
|
27
|
+
storage_stats
|
|
28
|
+
segment_manifest
|
|
29
|
+
compaction_activity
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def request(operation:, packet: {}, request_id: nil)
|
|
35
|
+
operation = operation.to_sym
|
|
36
|
+
raise ArgumentError, "unknown ledger op: #{operation.inspect}" unless OPERATIONS.include?(operation)
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
protocol: PROTOCOL,
|
|
40
|
+
schema_version: SCHEMA_VERSION,
|
|
41
|
+
request_id: request_id || generate_request_id,
|
|
42
|
+
op: operation,
|
|
43
|
+
packet: packet || {}
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ok?(response)
|
|
48
|
+
normalize(response)[:status]&.to_sym == :ok
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def result_or_raise(response)
|
|
52
|
+
response = normalize(response)
|
|
53
|
+
return response[:result] if ok?(response)
|
|
54
|
+
|
|
55
|
+
raise Error.new(
|
|
56
|
+
response[:error] || "ledger client request failed",
|
|
57
|
+
response: response,
|
|
58
|
+
request_id: response[:request_id]
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize(hash)
|
|
63
|
+
hash.to_h.transform_keys(&:to_sym)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def generate_request_id
|
|
67
|
+
"req_#{SecureRandom.hex(12)}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LedgerClient
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :response, :request_id
|
|
7
|
+
|
|
8
|
+
def initialize(message, response: nil, request_id: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@response = response
|
|
11
|
+
@request_id = request_id
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class TransportError < Error; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LedgerClient
|
|
5
|
+
module Results
|
|
6
|
+
module HashAccess
|
|
7
|
+
def [](key)
|
|
8
|
+
to_h[key.to_sym]
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class ReceiptResult
|
|
13
|
+
include HashAccess
|
|
14
|
+
|
|
15
|
+
FIELDS = %i[
|
|
16
|
+
schema_version
|
|
17
|
+
kind
|
|
18
|
+
status
|
|
19
|
+
name
|
|
20
|
+
store
|
|
21
|
+
key
|
|
22
|
+
fact_id
|
|
23
|
+
value_hash
|
|
24
|
+
warnings
|
|
25
|
+
errors
|
|
26
|
+
derived
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader(*FIELDS)
|
|
30
|
+
|
|
31
|
+
def initialize(raw = {})
|
|
32
|
+
data = self.class.normalize(raw)
|
|
33
|
+
@schema_version = data[:schema_version]
|
|
34
|
+
@kind = token(data[:kind])
|
|
35
|
+
@status = token(data[:status])
|
|
36
|
+
@name = token(data[:name])
|
|
37
|
+
@store = token(data[:store])
|
|
38
|
+
@key = data[:key]
|
|
39
|
+
@fact_id = data[:fact_id]
|
|
40
|
+
@value_hash = data[:value_hash]
|
|
41
|
+
@warnings = Array(data[:warnings]).freeze
|
|
42
|
+
@errors = Array(data[:errors]).freeze
|
|
43
|
+
@derived = Array(data[:derived]).freeze
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def accepted? = status == :accepted
|
|
48
|
+
|
|
49
|
+
def rejected? = status == :rejected
|
|
50
|
+
|
|
51
|
+
def deduplicated? = status == :deduplicated
|
|
52
|
+
|
|
53
|
+
def to_h
|
|
54
|
+
{
|
|
55
|
+
schema_version: schema_version,
|
|
56
|
+
kind: kind,
|
|
57
|
+
status: status,
|
|
58
|
+
name: name,
|
|
59
|
+
store: store,
|
|
60
|
+
key: key,
|
|
61
|
+
fact_id: fact_id,
|
|
62
|
+
value_hash: value_hash,
|
|
63
|
+
warnings: warnings,
|
|
64
|
+
errors: errors,
|
|
65
|
+
derived: derived
|
|
66
|
+
}.compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.normalize(raw)
|
|
70
|
+
hash = if raw.respond_to?(:to_h)
|
|
71
|
+
raw.to_h
|
|
72
|
+
else
|
|
73
|
+
FIELDS.each_with_object({}) do |field, acc|
|
|
74
|
+
acc[field] = raw.public_send(field) if raw.respond_to?(field)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
hash.to_h.transform_keys(&:to_sym)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def token(value)
|
|
84
|
+
value.is_a?(String) ? value.to_sym : value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class WriteResult < ReceiptResult
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class AppendResult < ReceiptResult
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class ReadResult
|
|
95
|
+
include HashAccess
|
|
96
|
+
|
|
97
|
+
attr_reader :value
|
|
98
|
+
|
|
99
|
+
def initialize(raw = {})
|
|
100
|
+
data = normalize(raw)
|
|
101
|
+
@value = data[:value]
|
|
102
|
+
@found = data.key?(:found) ? boolean(data[:found]) : !value.nil?
|
|
103
|
+
freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def found? = @found
|
|
107
|
+
|
|
108
|
+
def to_h
|
|
109
|
+
{ value: value, found: found? }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def normalize(raw)
|
|
115
|
+
hash = raw.respond_to?(:to_h) ? raw.to_h : {}
|
|
116
|
+
hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def boolean(value)
|
|
120
|
+
value ? true : false
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class QueryResult
|
|
125
|
+
include HashAccess
|
|
126
|
+
|
|
127
|
+
attr_reader :items, :results, :count
|
|
128
|
+
|
|
129
|
+
def initialize(raw = {})
|
|
130
|
+
data = normalize(raw)
|
|
131
|
+
@items = normalize_items(data[:items]).freeze
|
|
132
|
+
@results = Array(data[:results] || items.map { |item| item[:value] }).freeze
|
|
133
|
+
@count = data.key?(:count) ? data[:count].to_i : [items.size, results.size].max
|
|
134
|
+
freeze
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def to_h
|
|
138
|
+
{ items: items, results: results, count: count }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def normalize(raw)
|
|
144
|
+
hash = raw.respond_to?(:to_h) ? raw.to_h : {}
|
|
145
|
+
hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def normalize_items(raw_items)
|
|
149
|
+
Array(raw_items).map do |item|
|
|
150
|
+
data = item.to_h.transform_keys(&:to_sym)
|
|
151
|
+
{ key: data[:key], value: normalize_value(data[:value] || {}) }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def normalize_value(value)
|
|
156
|
+
return value unless value.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
value.each_with_object({}) { |(key, entry), acc| acc[key.to_sym] = entry }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class ResolveResult
|
|
163
|
+
include HashAccess
|
|
164
|
+
|
|
165
|
+
attr_reader :items, :results, :count
|
|
166
|
+
|
|
167
|
+
def initialize(raw = {})
|
|
168
|
+
data = normalize(raw)
|
|
169
|
+
@items = normalize_items(data[:items]).freeze
|
|
170
|
+
@results = Array(data[:results] || items.map { |item| item[:value] }).freeze
|
|
171
|
+
@count = data.key?(:count) ? data[:count].to_i : [items.size, results.size].max
|
|
172
|
+
freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def to_h
|
|
176
|
+
{ items: items, results: results, count: count }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def normalize(raw)
|
|
182
|
+
return { results: raw } if raw.is_a?(Array)
|
|
183
|
+
|
|
184
|
+
hash = raw.respond_to?(:to_h) ? raw.to_h : {}
|
|
185
|
+
hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def normalize_items(raw_items)
|
|
189
|
+
Array(raw_items).map do |item|
|
|
190
|
+
data = item.to_h.transform_keys(&:to_sym)
|
|
191
|
+
{ key: data[:key], value: normalize_value(data[:value] || {}) }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def normalize_value(value)
|
|
196
|
+
return value unless value.is_a?(Hash)
|
|
197
|
+
|
|
198
|
+
value.each_with_object({}) { |(key, entry), acc| acc[key.to_sym] = entry }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class ReplayResult
|
|
203
|
+
include HashAccess
|
|
204
|
+
|
|
205
|
+
attr_reader :facts, :count
|
|
206
|
+
|
|
207
|
+
def initialize(raw = {})
|
|
208
|
+
data = normalize(raw)
|
|
209
|
+
@facts = Array(data[:facts]).freeze
|
|
210
|
+
@count = data.key?(:count) ? data[:count].to_i : facts.size
|
|
211
|
+
freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def to_h
|
|
215
|
+
{ facts: facts, count: count }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def normalize(raw)
|
|
221
|
+
hash = raw.respond_to?(:to_h) ? raw.to_h : {}
|
|
222
|
+
hash.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
class CausationChainResult
|
|
227
|
+
include HashAccess
|
|
228
|
+
|
|
229
|
+
attr_reader :chain, :count
|
|
230
|
+
|
|
231
|
+
def initialize(raw = {})
|
|
232
|
+
data = normalize(raw)
|
|
233
|
+
@chain = Array(data[:chain]).map { |entry| normalize_hash(entry) }.freeze
|
|
234
|
+
@count = data.key?(:count) ? data[:count].to_i : chain.size
|
|
235
|
+
freeze
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def to_h
|
|
239
|
+
{ chain: chain, count: count }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def normalize(raw)
|
|
245
|
+
return { chain: raw } if raw.is_a?(Array)
|
|
246
|
+
|
|
247
|
+
normalize_hash(raw)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def normalize_hash(raw)
|
|
251
|
+
raw.respond_to?(:to_h) ? raw.to_h.transform_keys(&:to_sym) : {}
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
class LineageResult
|
|
256
|
+
include HashAccess
|
|
257
|
+
|
|
258
|
+
attr_reader :subject, :chain, :depth, :derived_by, :proof_hash
|
|
259
|
+
|
|
260
|
+
def initialize(raw = {})
|
|
261
|
+
data = normalize_hash(raw)
|
|
262
|
+
@subject = normalize_hash(data[:subject]).freeze
|
|
263
|
+
@chain = Array(data[:chain]).map { |entry| normalize_hash(entry) }.freeze
|
|
264
|
+
@depth = data.key?(:depth) ? data[:depth].to_i : chain.size
|
|
265
|
+
@derived_by = Array(data[:derived_by]).map { |entry| normalize_hash(entry) }.freeze
|
|
266
|
+
@proof_hash = data[:proof_hash]
|
|
267
|
+
freeze
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def to_h
|
|
271
|
+
{
|
|
272
|
+
subject: subject,
|
|
273
|
+
chain: chain,
|
|
274
|
+
depth: depth,
|
|
275
|
+
derived_by: derived_by,
|
|
276
|
+
proof_hash: proof_hash
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
private
|
|
281
|
+
|
|
282
|
+
def normalize_hash(raw)
|
|
283
|
+
raw.respond_to?(:to_h) ? raw.to_h.transform_keys(&:to_sym) : {}
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
class FactRefResult
|
|
288
|
+
include HashAccess
|
|
289
|
+
|
|
290
|
+
attr_reader :ref
|
|
291
|
+
|
|
292
|
+
def initialize(raw = {})
|
|
293
|
+
data = normalize_hash(raw)
|
|
294
|
+
@ref = data[:ref] ? normalize_hash(data[:ref]).freeze : nil
|
|
295
|
+
@found = if data.key?(:found)
|
|
296
|
+
data[:found] ? true : false
|
|
297
|
+
else
|
|
298
|
+
!ref.nil?
|
|
299
|
+
end
|
|
300
|
+
freeze
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def found? = @found
|
|
304
|
+
|
|
305
|
+
def to_h
|
|
306
|
+
{ found: found?, ref: ref }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def normalize_hash(raw)
|
|
312
|
+
raw.respond_to?(:to_h) ? raw.to_h.transform_keys(&:to_sym) : {}
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
class ChangeEventResult
|
|
317
|
+
include HashAccess
|
|
318
|
+
|
|
319
|
+
attr_reader :sequence, :store, :key, :fact_id, :value_hash, :cursor, :raw
|
|
320
|
+
|
|
321
|
+
def initialize(raw = {})
|
|
322
|
+
@raw = raw
|
|
323
|
+
data = normalize(raw)
|
|
324
|
+
@cursor = normalize_cursor(data[:cursor])
|
|
325
|
+
@sequence = (data[:sequence] || cursor[:sequence])&.to_i
|
|
326
|
+
@store = token(data[:store])
|
|
327
|
+
@key = data[:key]
|
|
328
|
+
@fact_id = data[:fact_id]
|
|
329
|
+
@value_hash = data[:value_hash] || fact_value_hash(raw)
|
|
330
|
+
freeze
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def to_h
|
|
334
|
+
{
|
|
335
|
+
sequence: sequence,
|
|
336
|
+
store: store,
|
|
337
|
+
key: key,
|
|
338
|
+
fact_id: fact_id,
|
|
339
|
+
value_hash: value_hash,
|
|
340
|
+
cursor: cursor,
|
|
341
|
+
raw: raw
|
|
342
|
+
}.compact
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
def normalize(raw)
|
|
348
|
+
hash = if raw.respond_to?(:to_h)
|
|
349
|
+
raw.to_h
|
|
350
|
+
else
|
|
351
|
+
%i[sequence store key fact_id value_hash cursor].each_with_object({}) do |field, acc|
|
|
352
|
+
acc[field] = raw.public_send(field) if raw.respond_to?(field)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
hash.to_h.transform_keys(&:to_sym)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def normalize_cursor(cursor)
|
|
360
|
+
return {} unless cursor
|
|
361
|
+
|
|
362
|
+
cursor.to_h.transform_keys(&:to_sym).freeze
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def fact_value_hash(raw)
|
|
366
|
+
return raw.fact.value_hash if raw.respond_to?(:fact) && raw.fact.respond_to?(:value_hash)
|
|
367
|
+
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def token(value)
|
|
372
|
+
value.is_a?(String) ? value.to_sym : value
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
module_function
|
|
377
|
+
|
|
378
|
+
def wrap(operation, raw)
|
|
379
|
+
case operation.to_sym
|
|
380
|
+
when :register_descriptor
|
|
381
|
+
ReceiptResult.new(raw)
|
|
382
|
+
when :write
|
|
383
|
+
WriteResult.new(raw)
|
|
384
|
+
when :append
|
|
385
|
+
AppendResult.new(raw)
|
|
386
|
+
when :read
|
|
387
|
+
ReadResult.new(raw)
|
|
388
|
+
when :query
|
|
389
|
+
QueryResult.new(raw)
|
|
390
|
+
when :resolve
|
|
391
|
+
ResolveResult.new(raw)
|
|
392
|
+
when :replay
|
|
393
|
+
ReplayResult.new(raw)
|
|
394
|
+
when :causation_chain
|
|
395
|
+
CausationChainResult.new(raw)
|
|
396
|
+
when :lineage
|
|
397
|
+
LineageResult.new(raw)
|
|
398
|
+
when :fact_ref
|
|
399
|
+
FactRefResult.new(raw)
|
|
400
|
+
else
|
|
401
|
+
raw
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LedgerClient
|
|
5
|
+
class Subscription
|
|
6
|
+
attr_accessor :error
|
|
7
|
+
|
|
8
|
+
def initialize(&close_proc)
|
|
9
|
+
@close_proc = close_proc
|
|
10
|
+
@closed = false
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def close
|
|
15
|
+
close_proc = nil
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
return self if @closed
|
|
18
|
+
|
|
19
|
+
@closed = true
|
|
20
|
+
close_proc = @close_proc
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
close_proc&.call
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def closed?
|
|
28
|
+
@mutex.synchronize { @closed }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../subscription"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module LedgerClient
|
|
7
|
+
module Transports
|
|
8
|
+
class ObjectDispatch
|
|
9
|
+
def initialize(target)
|
|
10
|
+
@target = target
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def dispatch(envelope)
|
|
14
|
+
if @target.respond_to?(:dispatch)
|
|
15
|
+
@target.dispatch(envelope)
|
|
16
|
+
elsif @target.respond_to?(:wire)
|
|
17
|
+
@target.wire.dispatch(envelope)
|
|
18
|
+
elsif @target.respond_to?(:protocol) && @target.protocol.respond_to?(:wire)
|
|
19
|
+
@target.protocol.wire.dispatch(envelope)
|
|
20
|
+
else
|
|
21
|
+
raise ArgumentError, "object does not expose dispatch(envelope), wire.dispatch(envelope), or protocol.wire.dispatch(envelope)"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def subscribe(stores:, cursor: nil, &block)
|
|
26
|
+
raise ArgumentError, "subscribe requires a block" unless block
|
|
27
|
+
|
|
28
|
+
feed = changefeed_source
|
|
29
|
+
raise NotImplementedError, "object dispatch target does not expose changefeed.subscribe" unless feed.respond_to?(:subscribe)
|
|
30
|
+
|
|
31
|
+
replay(feed, stores: stores, cursor: cursor, handler: block) if cursor
|
|
32
|
+
handle = feed.subscribe(stores: stores) { |event| block.call(event) }
|
|
33
|
+
Subscription.new { handle.close }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def close
|
|
37
|
+
@target.close if @target.respond_to?(:close)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def changefeed_source
|
|
43
|
+
@target.changefeed if @target.respond_to?(:changefeed)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def replay(feed, stores:, cursor:, handler:)
|
|
47
|
+
return unless feed.respond_to?(:replay)
|
|
48
|
+
|
|
49
|
+
store_filter = Array(stores).empty? ? nil : stores
|
|
50
|
+
result = feed.replay(cursor: normalize_cursor(cursor), stores: store_filter)
|
|
51
|
+
raise TransportError, "changefeed cursor is too old" if result[:status].to_sym == :cursor_too_old
|
|
52
|
+
|
|
53
|
+
Array(result[:events]).each { |event| handler.call(event) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_cursor(cursor)
|
|
57
|
+
return cursor if cursor.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
{ sequence: cursor.to_i }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "../subscription"
|
|
7
|
+
|
|
8
|
+
module Igniter
|
|
9
|
+
module LedgerClient
|
|
10
|
+
module Transports
|
|
11
|
+
class RemoteHTTP
|
|
12
|
+
attr_reader :uri, :events_uri, :open_timeout, :read_timeout
|
|
13
|
+
|
|
14
|
+
def initialize(endpoint, events_url: nil, open_timeout: 1.0, read_timeout: 2.0, write_timeout: nil, headers: {})
|
|
15
|
+
@uri = normalize_endpoint(endpoint)
|
|
16
|
+
@events_uri = normalize_events_endpoint(events_url)
|
|
17
|
+
@open_timeout = open_timeout
|
|
18
|
+
@read_timeout = read_timeout
|
|
19
|
+
@write_timeout = write_timeout
|
|
20
|
+
@headers = headers
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dispatch(envelope)
|
|
24
|
+
request = Net::HTTP::Post.new(uri)
|
|
25
|
+
request["Content-Type"] = "application/json"
|
|
26
|
+
@headers.each { |key, value| request[key.to_s] = value }
|
|
27
|
+
request.body = JSON.generate(envelope)
|
|
28
|
+
|
|
29
|
+
response = http.request(request)
|
|
30
|
+
raise TransportError, "ledger HTTP #{uri} returned #{response.code}" unless response.code.to_i.between?(200, 299)
|
|
31
|
+
|
|
32
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
33
|
+
rescue JSON::ParserError => e
|
|
34
|
+
raise TransportError, "invalid ledger HTTP response: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def subscribe(stores:, cursor: nil, &block)
|
|
38
|
+
raise ArgumentError, "subscribe requires a block" unless block
|
|
39
|
+
|
|
40
|
+
http_client = nil
|
|
41
|
+
thread = nil
|
|
42
|
+
subscription = Subscription.new do
|
|
43
|
+
http_client&.finish if http_client&.started?
|
|
44
|
+
thread&.join(1) unless Thread.current.equal?(thread)
|
|
45
|
+
rescue IOError, SystemCallError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
thread = Thread.new do
|
|
50
|
+
stream_uri = events_stream_uri(stores: stores, cursor: cursor)
|
|
51
|
+
request = Net::HTTP::Get.new(stream_uri)
|
|
52
|
+
request["Accept"] = "text/event-stream"
|
|
53
|
+
@headers.each { |key, value| request[key.to_s] = value }
|
|
54
|
+
http_client = http_for(stream_uri)
|
|
55
|
+
http_client.request(request) do |response|
|
|
56
|
+
raise TransportError, "ledger SSE #{stream_uri} returned #{response.code}" unless response.code.to_i.between?(200, 299)
|
|
57
|
+
|
|
58
|
+
read_sse(response, subscription, &block)
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
subscription.error = e unless subscription&.closed?
|
|
62
|
+
ensure
|
|
63
|
+
subscription&.close unless subscription&.closed?
|
|
64
|
+
end
|
|
65
|
+
subscription
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def http
|
|
71
|
+
http_for(uri)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def http_for(target_uri)
|
|
75
|
+
Net::HTTP.new(target_uri.host, target_uri.port).tap do |client|
|
|
76
|
+
client.use_ssl = target_uri.scheme == "https"
|
|
77
|
+
client.open_timeout = open_timeout if open_timeout
|
|
78
|
+
client.read_timeout = read_timeout if read_timeout
|
|
79
|
+
client.write_timeout = @write_timeout if @write_timeout && client.respond_to?(:write_timeout=)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_endpoint(endpoint)
|
|
84
|
+
parsed = URI(endpoint.to_s)
|
|
85
|
+
parsed.path = "/v1/dispatch" if parsed.path.nil? || parsed.path.empty? || parsed.path == "/"
|
|
86
|
+
parsed
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_events_endpoint(events_url)
|
|
90
|
+
return derive_events_uri unless events_url
|
|
91
|
+
|
|
92
|
+
parsed = URI(events_url.to_s)
|
|
93
|
+
parsed.path = "/v1/events" if parsed.path.nil? || parsed.path.empty? || parsed.path == "/"
|
|
94
|
+
parsed
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def derive_events_uri
|
|
98
|
+
uri.dup.tap do |parsed|
|
|
99
|
+
parsed.path = parsed.path.end_with?("/v1/dispatch") ? parsed.path.sub(%r{/v1/dispatch\z}, "/v1/events") : "/v1/events"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def events_stream_uri(stores:, cursor:)
|
|
104
|
+
events_uri.dup.tap do |parsed|
|
|
105
|
+
params = URI.decode_www_form(parsed.query.to_s)
|
|
106
|
+
store_names = Array(stores).map(&:to_s).reject(&:empty?)
|
|
107
|
+
params << ["stores", store_names.join(",")] unless store_names.empty?
|
|
108
|
+
sequence = cursor_sequence(cursor)
|
|
109
|
+
params << ["cursor", sequence.to_s] if sequence
|
|
110
|
+
parsed.query = params.empty? ? nil : URI.encode_www_form(params)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cursor_sequence(cursor)
|
|
115
|
+
return nil unless cursor
|
|
116
|
+
|
|
117
|
+
data = cursor.respond_to?(:to_h) ? cursor.to_h.transform_keys(&:to_sym) : { sequence: cursor }
|
|
118
|
+
data[:sequence]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read_sse(response, subscription, &block)
|
|
122
|
+
buffer = +""
|
|
123
|
+
response.read_body do |chunk|
|
|
124
|
+
break if subscription&.closed?
|
|
125
|
+
|
|
126
|
+
buffer << chunk
|
|
127
|
+
while (frame = next_sse_frame(buffer))
|
|
128
|
+
event = parse_sse_frame(frame)
|
|
129
|
+
block.call(event) if event
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def next_sse_frame(buffer)
|
|
135
|
+
idx = buffer.index("\n\n")
|
|
136
|
+
sep_len = 2
|
|
137
|
+
unless idx
|
|
138
|
+
idx = buffer.index("\r\n\r\n")
|
|
139
|
+
sep_len = 4
|
|
140
|
+
end
|
|
141
|
+
return nil unless idx
|
|
142
|
+
|
|
143
|
+
buffer.slice!(0, idx + sep_len)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_sse_frame(frame)
|
|
147
|
+
event_id = nil
|
|
148
|
+
data_lines = []
|
|
149
|
+
|
|
150
|
+
frame.each_line do |line|
|
|
151
|
+
line = line.chomp
|
|
152
|
+
event_id = line.sub("id: ", "") if line.start_with?("id: ")
|
|
153
|
+
data_lines << line.sub("data: ", "") if line.start_with?("data: ")
|
|
154
|
+
end
|
|
155
|
+
return nil if data_lines.empty?
|
|
156
|
+
|
|
157
|
+
data = JSON.parse(data_lines.join("\n"), symbolize_names: true)
|
|
158
|
+
data[:sequence] ||= event_id.to_i if event_id
|
|
159
|
+
data
|
|
160
|
+
rescue JSON::ParserError => e
|
|
161
|
+
raise TransportError, "invalid ledger SSE event: #{e.message}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ledger_client/envelope"
|
|
4
|
+
require_relative "ledger_client/error"
|
|
5
|
+
require_relative "ledger_client/results"
|
|
6
|
+
require_relative "ledger_client/subscription"
|
|
7
|
+
require_relative "ledger_client/client"
|
|
8
|
+
require_relative "ledger_client/transports/object_dispatch"
|
|
9
|
+
require_relative "ledger_client/transports/remote_http"
|
|
10
|
+
|
|
11
|
+
module Igniter
|
|
12
|
+
module LedgerClient
|
|
13
|
+
def self.wrap(target)
|
|
14
|
+
return target if target.is_a?(Client)
|
|
15
|
+
|
|
16
|
+
Client.new(transport: Transports::ObjectDispatch.new(target))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.remote_http(endpoint, **options)
|
|
20
|
+
Client.new(transport: Transports::RemoteHTTP.new(endpoint, **options))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: igniter-ledger-client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.5.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alexander
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Small client-side package for the Igniter Ledger/Open Protocol boundary:
|
|
13
|
+
envelopes, transports, errors, and stable client methods without embedding the storage
|
|
14
|
+
engine.'
|
|
15
|
+
email:
|
|
16
|
+
- alexander.s.fokin@gmail.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/igniter-ledger-client.rb
|
|
23
|
+
- lib/igniter/ledger_client.rb
|
|
24
|
+
- lib/igniter/ledger_client/client.rb
|
|
25
|
+
- lib/igniter/ledger_client/envelope.rb
|
|
26
|
+
- lib/igniter/ledger_client/error.rb
|
|
27
|
+
- lib/igniter/ledger_client/results.rb
|
|
28
|
+
- lib/igniter/ledger_client/subscription.rb
|
|
29
|
+
- lib/igniter/ledger_client/transports/object_dispatch.rb
|
|
30
|
+
- lib/igniter/ledger_client/transports/remote_http.rb
|
|
31
|
+
homepage: https://github.com/alexander-s-f/igniter
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata: {}
|
|
35
|
+
rdoc_options: []
|
|
36
|
+
require_paths:
|
|
37
|
+
- lib
|
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: 3.1.0
|
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
requirements: []
|
|
49
|
+
rubygems_version: 4.0.10
|
|
50
|
+
specification_version: 4
|
|
51
|
+
summary: Protocol-first Ledger client package for Igniter
|
|
52
|
+
test_files: []
|