antd 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,675 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "base64"
6
+ require "uri"
7
+
8
+ module Antd
9
+ DEFAULT_BASE_URL = "http://localhost:8082"
10
+ DEFAULT_TIMEOUT = 300 # seconds
11
+
12
+ # REST client for the antd daemon.
13
+ class Client
14
+ # Creates a client using port discovery.
15
+ #
16
+ # Reads the daemon.port file to find the REST port. Falls back to the
17
+ # default base URL if the port file is not found.
18
+ #
19
+ # @param kwargs [Hash] options passed to +initialize+ (e.g. +:timeout+)
20
+ # @return [Array(Client, String)] the client and the resolved URL
21
+ def self.auto_discover(**kwargs)
22
+ url = Antd::Discover.daemon_url
23
+ url = DEFAULT_BASE_URL if url.empty?
24
+ [new(base_url: url, **kwargs), url]
25
+ end
26
+
27
+ # @param base_url [String] Base URL of the antd daemon
28
+ # @param timeout [Integer] HTTP request timeout in seconds
29
+ def initialize(base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT)
30
+ @base_url = base_url.chomp("/")
31
+ @timeout = timeout
32
+ end
33
+
34
+ # --- Health ---
35
+
36
+ # Check daemon status.
37
+ # @return [HealthStatus]
38
+ def health
39
+ j = do_json(:get, "/health")
40
+ HealthStatus.new(
41
+ ok: j["status"] == "ok",
42
+ network: j["network"],
43
+ version: j.fetch("version", ""),
44
+ evm_network: j.fetch("evm_network", ""),
45
+ uptime_seconds: j.fetch("uptime_seconds", 0),
46
+ build_commit: j.fetch("build_commit", ""),
47
+ payment_token_address: j.fetch("payment_token_address", ""),
48
+ payment_vault_address: j.fetch("payment_vault_address", "")
49
+ )
50
+ end
51
+
52
+ # --- Data ---
53
+
54
+ # Store private encrypted data on the network. Returns the caller-held
55
+ # DataMap (hex). The DataMap is NOT stored on-network.
56
+ # @param data [String] raw bytes
57
+ # @param payment_mode [String] PaymentMode::AUTO | MERKLE | SINGLE
58
+ # @return [DataPutResult]
59
+ def data_put(data, payment_mode: PaymentMode::AUTO)
60
+ j = do_json(:post, "/v1/data", { data: b64_encode(data), payment_mode: payment_mode })
61
+ DataPutResult.new(
62
+ data_map: j["data_map"] || "",
63
+ chunks_stored: (j["chunks_stored"] || 0).to_i,
64
+ payment_mode_used: j["payment_mode_used"] || ""
65
+ )
66
+ end
67
+
68
+ # Retrieve private data from a caller-held DataMap (hex).
69
+ # @param data_map [String]
70
+ # @return [String] raw bytes
71
+ def data_get(data_map)
72
+ j = do_json(:post, "/v1/data/get", { data_map: data_map })
73
+ b64_decode(j["data"])
74
+ end
75
+
76
+ # Stream private data from a caller-held DataMap (hex) — the streaming
77
+ # counterpart to +data_get+. Decrypted bytes arrive in chunks, keeping
78
+ # memory usage constant regardless of payload size.
79
+ #
80
+ # When a block is given, each raw byte chunk is yielded as it arrives and
81
+ # the method returns +nil+ once the body is fully consumed. When no block
82
+ # is given, an +Enumerator+ over the chunks is returned (lazy — the HTTP
83
+ # request runs when the enumerator is iterated).
84
+ #
85
+ # @param data_map [String]
86
+ # @yieldparam chunk [String] a raw byte chunk of the decrypted payload
87
+ # @return [nil, Enumerator]
88
+ def data_stream(data_map, &block)
89
+ return enum_for(:data_stream, data_map) unless block_given?
90
+
91
+ do_stream(:post, "/v1/data/stream", { data_map: data_map }, &block)
92
+ end
93
+
94
+ # Like +data_stream+ but opts into NDJSON progress framing
95
+ # (+Accept: application/x-ndjson+), yielding +DownloadFrame+s so the caller
96
+ # can drive a *determinate* progress bar. Each frame is either a plaintext
97
+ # byte chunk (+frame.data+) or a +DownloadProgress+ update
98
+ # (+frame.progress+). The leading +meta+ frame (byte denominator) is parsed
99
+ # and skipped here; a terminal +error+ frame is raised as an SDK error (a
100
+ # raw octet-stream download cannot signal a failure mid-stream).
101
+ #
102
+ # Same block/Enumerator contract as +data_stream+.
103
+ #
104
+ # @param data_map [String]
105
+ # @yieldparam frame [DownloadFrame]
106
+ # @return [nil, Enumerator]
107
+ def data_stream_with_progress(data_map, &block)
108
+ return enum_for(:data_stream_with_progress, data_map) unless block_given?
109
+
110
+ do_stream_ndjson(:post, "/v1/data/stream", { data_map: data_map }, &block)
111
+ end
112
+
113
+ # Store public data. The DataMap is stored on-network as an extra chunk;
114
+ # the returned address is the shareable retrieval handle.
115
+ # @param data [String] raw bytes
116
+ # @param payment_mode [String] PaymentMode::AUTO | MERKLE | SINGLE
117
+ # @return [DataPutPublicResult]
118
+ def data_put_public(data, payment_mode: PaymentMode::AUTO)
119
+ j = do_json(:post, "/v1/data/public", { data: b64_encode(data), payment_mode: payment_mode })
120
+ DataPutPublicResult.new(
121
+ address: j["address"] || "",
122
+ chunks_stored: (j["chunks_stored"] || 0).to_i,
123
+ payment_mode_used: j["payment_mode_used"] || ""
124
+ )
125
+ end
126
+
127
+ # Retrieve public data by address.
128
+ # @param address [String] hex address
129
+ # @return [String] raw bytes
130
+ def data_get_public(address)
131
+ j = do_json(:get, "/v1/data/public/#{address}")
132
+ b64_decode(j["data"])
133
+ end
134
+
135
+ # Stream public data by address — the streaming counterpart to
136
+ # +data_get_public+. Decrypted bytes arrive in chunks, keeping memory
137
+ # usage constant regardless of payload size.
138
+ #
139
+ # When a block is given, each raw byte chunk is yielded as it arrives and
140
+ # the method returns +nil+ once the body is fully consumed. When no block
141
+ # is given, an +Enumerator+ over the chunks is returned (lazy — the HTTP
142
+ # request runs when the enumerator is iterated).
143
+ #
144
+ # @param address [String] hex address
145
+ # @yieldparam chunk [String] a raw byte chunk of the payload
146
+ # @return [nil, Enumerator]
147
+ def data_stream_public(address, &block)
148
+ return enum_for(:data_stream_public, address) unless block_given?
149
+
150
+ do_stream(:get, "/v1/data/public/#{address}/stream", nil, &block)
151
+ end
152
+
153
+ # Like +data_stream_public+ but opts into NDJSON progress framing. See
154
+ # +data_stream_with_progress+ for the contract.
155
+ #
156
+ # @param address [String] hex address
157
+ # @yieldparam frame [DownloadFrame]
158
+ # @return [nil, Enumerator]
159
+ def data_stream_public_with_progress(address, &block)
160
+ return enum_for(:data_stream_public_with_progress, address) unless block_given?
161
+
162
+ do_stream_ndjson(:get, "/v1/data/public/#{address}/stream", nil, &block)
163
+ end
164
+
165
+ # Pre-upload cost breakdown for the given bytes.
166
+ # @param data [String] raw bytes
167
+ # @param payment_mode [String] PaymentMode::AUTO | MERKLE | SINGLE
168
+ # @return [UploadCostEstimate]
169
+ def data_cost(data, payment_mode: PaymentMode::AUTO)
170
+ j = do_json(:post, "/v1/data/cost", { data: b64_encode(data), payment_mode: payment_mode })
171
+ UploadCostEstimate.new(
172
+ cost: j["cost"] || "",
173
+ file_size: j["file_size"] || 0,
174
+ chunk_count: j["chunk_count"] || 0,
175
+ estimated_gas_cost_wei: j["estimated_gas_cost_wei"] || "",
176
+ payment_mode: j["payment_mode"] || ""
177
+ )
178
+ end
179
+
180
+ # --- Chunks ---
181
+
182
+ # Store a raw chunk on the network.
183
+ # @param data [String] raw bytes
184
+ # @return [PutResult]
185
+ def chunk_put(data)
186
+ j = do_json(:post, "/v1/chunks", { data: b64_encode(data) })
187
+ PutResult.new(cost: j["cost"], address: j["address"])
188
+ end
189
+
190
+ # Retrieve a chunk by address.
191
+ # @param address [String] hex address
192
+ # @return [String] raw bytes
193
+ def chunk_get(address)
194
+ j = do_json(:get, "/v1/chunks/#{address}")
195
+ b64_decode(j["data"])
196
+ end
197
+
198
+ # Prepare a single chunk for external-signer publish.
199
+ #
200
+ # Returns either +already_stored: true+ (no payment needed) or a wave-batch
201
+ # payment intent. After the external signer pays, call
202
+ # +finalize_chunk_upload+ with the resulting tx hashes.
203
+ #
204
+ # Unlike +chunk_put+, this method does NOT require the daemon to have a
205
+ # wallet — all funds flow through the external signer.
206
+ #
207
+ # @param data [String] raw chunk bytes
208
+ # @return [PrepareChunkResult]
209
+ def prepare_chunk_upload(data)
210
+ j = do_json(:post, "/v1/chunks/prepare", { data: b64_encode(data) })
211
+ parse_prepare_chunk_response(j)
212
+ end
213
+
214
+ # Submit a prepared chunk to the network after external payment.
215
+ #
216
+ # @param upload_id [String] the upload ID from +prepare_chunk_upload+
217
+ # @param tx_hashes [Hash<String, String>] map of quote_hash to tx_hash
218
+ # @return [String] network address of the stored chunk
219
+ # (matches +PrepareChunkResult#address+)
220
+ def finalize_chunk_upload(upload_id, tx_hashes)
221
+ j = do_json(:post, "/v1/chunks/finalize", {
222
+ upload_id: upload_id,
223
+ tx_hashes: tx_hashes
224
+ })
225
+ j["address"] || ""
226
+ end
227
+
228
+ # --- Files ---
229
+
230
+ # Upload a file privately. Returns the caller-held DataMap (hex).
231
+ # @param path [String] local file path
232
+ # @param payment_mode [String] PaymentMode::AUTO | MERKLE | SINGLE
233
+ # @return [FilePutResult]
234
+ def file_put(path, payment_mode: PaymentMode::AUTO)
235
+ j = do_json(:post, "/v1/files", { path: path, payment_mode: payment_mode })
236
+ FilePutResult.new(
237
+ data_map: j["data_map"] || "",
238
+ storage_cost_atto: j["storage_cost_atto"] || "",
239
+ gas_cost_wei: j["gas_cost_wei"] || "",
240
+ chunks_stored: (j["chunks_stored"] || 0).to_i,
241
+ payment_mode_used: j["payment_mode_used"] || ""
242
+ )
243
+ end
244
+
245
+ # Download a private file from a caller-held DataMap into +dest_path+.
246
+ # @param data_map [String]
247
+ # @param dest_path [String]
248
+ # @return [void]
249
+ def file_get(data_map, dest_path)
250
+ do_json(:post, "/v1/files/get", { data_map: data_map, dest_path: dest_path })
251
+ nil
252
+ end
253
+
254
+ # Upload a file publicly. The DataMap is stored on-network as an extra
255
+ # chunk; the returned address is the shareable retrieval handle.
256
+ # @param path [String] local file path
257
+ # @param payment_mode [String] PaymentMode::AUTO | MERKLE | SINGLE
258
+ # @return [FilePutPublicResult]
259
+ def file_put_public(path, payment_mode: PaymentMode::AUTO)
260
+ j = do_json(:post, "/v1/files/public", { path: path, payment_mode: payment_mode })
261
+ FilePutPublicResult.new(
262
+ address: j["address"] || "",
263
+ storage_cost_atto: j["storage_cost_atto"] || "",
264
+ gas_cost_wei: j["gas_cost_wei"] || "",
265
+ chunks_stored: (j["chunks_stored"] || 0).to_i,
266
+ payment_mode_used: j["payment_mode_used"] || ""
267
+ )
268
+ end
269
+
270
+ # Download a public file from an on-network DataMap address.
271
+ # @param address [String]
272
+ # @param dest_path [String]
273
+ # @return [void]
274
+ def file_get_public(address, dest_path)
275
+ do_json(:post, "/v1/files/public/get", { address: address, dest_path: dest_path })
276
+ nil
277
+ end
278
+
279
+ # Pre-upload cost breakdown for the file at +path+.
280
+ # @param path [String]
281
+ # @param is_public [Boolean]
282
+ # @param payment_mode [String] PaymentMode::AUTO | MERKLE | SINGLE
283
+ # @return [UploadCostEstimate]
284
+ def file_cost(path, is_public, payment_mode: PaymentMode::AUTO)
285
+ j = do_json(:post, "/v1/files/cost", {
286
+ path: path,
287
+ is_public: is_public,
288
+ payment_mode: payment_mode
289
+ })
290
+ UploadCostEstimate.new(
291
+ cost: j["cost"] || "",
292
+ file_size: j["file_size"] || 0,
293
+ chunk_count: j["chunk_count"] || 0,
294
+ estimated_gas_cost_wei: j["estimated_gas_cost_wei"] || "",
295
+ payment_mode: j["payment_mode"] || ""
296
+ )
297
+ end
298
+
299
+ # --- Wallet ---
300
+
301
+ # Get the wallet address configured on the daemon.
302
+ # @return [WalletAddress]
303
+ def wallet_address
304
+ j = do_json(:get, "/v1/wallet/address")
305
+ WalletAddress.new(address: j["address"])
306
+ end
307
+
308
+ # Get the wallet balance and gas balance.
309
+ # @return [WalletBalance]
310
+ def wallet_balance
311
+ j = do_json(:get, "/v1/wallet/balance")
312
+ WalletBalance.new(balance: j["balance"], gas_balance: j["gas_balance"])
313
+ end
314
+
315
+ # Approve the wallet to spend tokens on payment contracts (one-time operation).
316
+ # @return [Boolean]
317
+ def wallet_approve
318
+ j = do_json(:post, "/v1/wallet/approve", {})
319
+ j["approved"] == true
320
+ end
321
+
322
+ # --- External Signer (Two-Phase Upload) ---
323
+
324
+ # Prepare a file upload for external signing.
325
+ #
326
+ # @param path [String] local file path
327
+ # @param visibility [String, nil] +"public"+ to bundle the DataMap chunk
328
+ # into the same external-signer payment batch (the resulting
329
+ # +data_map_address+ on finalize is the shareable retrieval handle).
330
+ # +"private"+ or +nil+ keeps the existing private-only behaviour. When
331
+ # +nil+, the +visibility+ JSON field is omitted entirely to preserve
332
+ # the pre-public daemon wire shape.
333
+ # @return [PrepareUploadResult]
334
+ def prepare_upload(path, visibility: nil)
335
+ body = { path: path }
336
+ body[:visibility] = visibility unless visibility.nil?
337
+ j = do_json(:post, "/v1/upload/prepare", body)
338
+ parse_prepare_response(j)
339
+ end
340
+
341
+ # Convenience wrapper: prepare a *public* file upload for external signing.
342
+ #
343
+ # Equivalent to +prepare_upload(path, visibility: "public")+. In addition
344
+ # to the data chunks, the daemon bundles the serialized DataMap chunk into
345
+ # the same payment batch — the external signer signs ONE EVM transaction
346
+ # covering chunks + DataMap. After +finalize_upload+, the result's
347
+ # +data_map_address+ is the shareable retrieval handle.
348
+ #
349
+ # @param path [String] local file path
350
+ # @return [PrepareUploadResult]
351
+ def prepare_upload_public(path)
352
+ prepare_upload(path, visibility: "public")
353
+ end
354
+
355
+ # Prepare a data upload for external signing.
356
+ # Takes raw bytes, base64-encodes them, and POSTs to /v1/data/prepare.
357
+ # @param data [String] raw bytes to upload
358
+ # @return [PrepareUploadResult]
359
+ def prepare_data_upload(data)
360
+ j = do_json(:post, "/v1/data/prepare", { data: b64_encode(data) })
361
+ parse_prepare_response(j)
362
+ end
363
+
364
+ # Finalize an upload after an external signer has submitted payment transactions.
365
+ # @param upload_id [String] the upload ID from prepare_upload
366
+ # @param tx_hashes [Hash<String, String>] map of quote_hash to tx_hash
367
+ # @return [FinalizeUploadResult]
368
+ def finalize_upload(upload_id, tx_hashes)
369
+ j = do_json(:post, "/v1/upload/finalize", {
370
+ upload_id: upload_id,
371
+ tx_hashes: tx_hashes
372
+ })
373
+ parse_finalize_response(j)
374
+ end
375
+
376
+ # Finalize a merkle-batch upload after selecting a winning pool.
377
+ # @param upload_id [String] the upload ID from prepare_upload
378
+ # @param winner_pool_hash [String] hash of the winning pool commitment
379
+ # @param store_data_map [Boolean] whether to store the data map on-network
380
+ # @return [FinalizeUploadResult]
381
+ def finalize_merkle_upload(upload_id, winner_pool_hash, store_data_map: false)
382
+ j = do_json(:post, "/v1/upload/finalize", {
383
+ upload_id: upload_id,
384
+ winner_pool_hash: winner_pool_hash,
385
+ store_data_map: store_data_map
386
+ })
387
+ parse_finalize_response(j)
388
+ end
389
+
390
+ private
391
+
392
+ # Parse a /v1/upload/finalize JSON response into a FinalizeUploadResult.
393
+ #
394
+ # +data_map_address+ is populated only when prepare was called with
395
+ # visibility="public" — the DataMap chunk was paid + stored in the same
396
+ # external-signer batch.
397
+ def parse_finalize_response(j)
398
+ FinalizeUploadResult.new(
399
+ address: j["address"] || "",
400
+ chunks_stored: (j["chunks_stored"] || 0).to_i,
401
+ data_map: j["data_map"] || "",
402
+ data_map_address: j["data_map_address"] || ""
403
+ )
404
+ end
405
+
406
+ # Parse a /v1/chunks/prepare JSON response into a PrepareChunkResult.
407
+ def parse_prepare_chunk_response(j)
408
+ payments = (j["payments"] || []).map do |p|
409
+ PaymentInfo.new(
410
+ quote_hash: p["quote_hash"],
411
+ rewards_address: p["rewards_address"],
412
+ amount: p["amount"]
413
+ )
414
+ end
415
+
416
+ PrepareChunkResult.new(
417
+ address: j["address"] || "",
418
+ already_stored: j["already_stored"] == true,
419
+ upload_id: j["upload_id"] || "",
420
+ payment_type: j["payment_type"] || "",
421
+ payments: payments,
422
+ total_amount: j["total_amount"] || "",
423
+ payment_vault_address: j["payment_vault_address"] || "",
424
+ payment_token_address: j["payment_token_address"] || "",
425
+ rpc_url: j["rpc_url"] || ""
426
+ )
427
+ end
428
+
429
+ # Parse a prepare-upload JSON response into a PrepareUploadResult.
430
+ def parse_prepare_response(j)
431
+ payment_type = j["payment_type"] || "wave_batch"
432
+
433
+ payments = (j["payments"] || []).map do |p|
434
+ PaymentInfo.new(
435
+ quote_hash: p["quote_hash"],
436
+ rewards_address: p["rewards_address"],
437
+ amount: p["amount"]
438
+ )
439
+ end
440
+
441
+ pool_commitments = []
442
+ if payment_type == "merkle_batch"
443
+ (j["pool_commitments"] || []).each do |pc|
444
+ candidates = (pc["candidates"] || []).map do |c|
445
+ CandidateNodeEntry.new(
446
+ rewards_address: c["rewards_address"] || "",
447
+ amount: c["amount"] || ""
448
+ )
449
+ end
450
+ pool_commitments << PoolCommitmentEntry.new(
451
+ pool_hash: pc["pool_hash"] || "",
452
+ candidates: candidates
453
+ )
454
+ end
455
+ end
456
+
457
+ PrepareUploadResult.new(
458
+ upload_id: j["upload_id"] || "",
459
+ payments: payments,
460
+ total_amount: j["total_amount"] || "",
461
+ payment_vault_address: j["payment_vault_address"] || "",
462
+ payment_token_address: j["payment_token_address"] || "",
463
+ rpc_url: j["rpc_url"] || "",
464
+ payment_type: payment_type,
465
+ depth: j["depth"] || 0,
466
+ pool_commitments: pool_commitments,
467
+ merkle_payment_timestamp: j["merkle_payment_timestamp"] || 0,
468
+ total_chunks: j["total_chunks"] || 0,
469
+ already_stored_count: j["already_stored_count"] || 0
470
+ )
471
+ end
472
+
473
+ def b64_encode(data)
474
+ Base64.strict_encode64(data)
475
+ end
476
+
477
+ def b64_decode(str)
478
+ Base64.strict_decode64(str)
479
+ end
480
+
481
+ def build_uri(path)
482
+ URI("#{@base_url}#{path}")
483
+ end
484
+
485
+ # Perform a JSON HTTP request and return the parsed response body.
486
+ def do_json(method, path, body = nil)
487
+ uri = build_uri(path)
488
+ http = Net::HTTP.new(uri.host, uri.port)
489
+ http.use_ssl = (uri.scheme == "https")
490
+ http.open_timeout = @timeout
491
+ http.read_timeout = @timeout
492
+
493
+ request = case method
494
+ when :get then Net::HTTP::Get.new(uri)
495
+ when :post then Net::HTTP::Post.new(uri)
496
+ when :put then Net::HTTP::Put.new(uri)
497
+ end
498
+
499
+ if body
500
+ request["Content-Type"] = "application/json"
501
+ request.body = JSON.generate(body)
502
+ end
503
+
504
+ response = http.request(request)
505
+ code = response.code.to_i
506
+
507
+ unless (200...300).cover?(code)
508
+ msg = response.body.to_s
509
+ begin
510
+ parsed = JSON.parse(msg)
511
+ msg = parsed["error"] if parsed["error"]
512
+ rescue JSON::ParserError
513
+ # use raw body as message
514
+ end
515
+ raise Antd.error_for_status(code, msg)
516
+ end
517
+
518
+ return {} if response.body.nil? || response.body.empty?
519
+
520
+ JSON.parse(response.body)
521
+ end
522
+
523
+ # Perform a streaming HTTP request, yielding raw body chunks to +block+ as
524
+ # they arrive. Reuses the same base-URL / timeout / error-mapping plumbing
525
+ # as +do_json+, but never buffers the success body in memory.
526
+ #
527
+ # On a non-2xx response the (short) body is read fully, parsed for an
528
+ # +{"error":...}+ field, and raised via +Antd.error_for_status+ — mirroring
529
+ # +do_json+. On 2xx, chunks are streamed straight to the block.
530
+ def do_stream(method, path, body = nil)
531
+ uri = build_uri(path)
532
+ http = Net::HTTP.new(uri.host, uri.port)
533
+ http.use_ssl = (uri.scheme == "https")
534
+ http.open_timeout = @timeout
535
+ http.read_timeout = @timeout
536
+
537
+ request = case method
538
+ when :get then Net::HTTP::Get.new(uri)
539
+ when :post then Net::HTTP::Post.new(uri)
540
+ when :put then Net::HTTP::Put.new(uri)
541
+ end
542
+
543
+ if body
544
+ request["Content-Type"] = "application/json"
545
+ request.body = JSON.generate(body)
546
+ end
547
+
548
+ http.start do
549
+ http.request(request) do |response|
550
+ code = response.code.to_i
551
+
552
+ unless (200...300).cover?(code)
553
+ msg = response.body.to_s
554
+ begin
555
+ parsed = JSON.parse(msg)
556
+ msg = parsed["error"] if parsed["error"]
557
+ rescue JSON::ParserError
558
+ # use raw body as message
559
+ end
560
+ raise Antd.error_for_status(code, msg)
561
+ end
562
+
563
+ response.read_body do |chunk|
564
+ yield chunk unless chunk.empty?
565
+ end
566
+ end
567
+ end
568
+
569
+ nil
570
+ end
571
+
572
+ # Like +do_stream+ but opts into NDJSON progress framing by sending
573
+ # +Accept: application/x-ndjson+, splitting the streamed body into lines
574
+ # (buffering partial lines across byte-chunk boundaries) and yielding a
575
+ # parsed +DownloadFrame+ per non-skipped line. A terminal +error+ frame is
576
+ # raised via +Antd.error_for_status+. Non-2xx responses are handled exactly
577
+ # as in +do_stream+.
578
+ def do_stream_ndjson(method, path, body = nil)
579
+ uri = build_uri(path)
580
+ http = Net::HTTP.new(uri.host, uri.port)
581
+ http.use_ssl = (uri.scheme == "https")
582
+ http.open_timeout = @timeout
583
+ http.read_timeout = @timeout
584
+
585
+ request = case method
586
+ when :get then Net::HTTP::Get.new(uri)
587
+ when :post then Net::HTTP::Post.new(uri)
588
+ when :put then Net::HTTP::Put.new(uri)
589
+ end
590
+ request["Accept"] = "application/x-ndjson"
591
+
592
+ if body
593
+ request["Content-Type"] = "application/json"
594
+ request.body = JSON.generate(body)
595
+ end
596
+
597
+ http.start do
598
+ http.request(request) do |response|
599
+ code = response.code.to_i
600
+
601
+ unless (200...300).cover?(code)
602
+ msg = response.body.to_s
603
+ begin
604
+ parsed = JSON.parse(msg)
605
+ msg = parsed["error"] if parsed["error"]
606
+ rescue JSON::ParserError
607
+ # use raw body as message
608
+ end
609
+ raise Antd.error_for_status(code, msg)
610
+ end
611
+
612
+ buffer = +""
613
+ response.read_body do |chunk|
614
+ buffer << chunk
615
+ while (nl = buffer.index("\n"))
616
+ line = buffer.slice!(0..nl)
617
+ frame = parse_ndjson_frame(line)
618
+ yield frame unless frame.nil?
619
+ end
620
+ end
621
+ # Flush a trailing line with no terminating newline.
622
+ unless buffer.empty?
623
+ frame = parse_ndjson_frame(buffer)
624
+ yield frame unless frame.nil?
625
+ end
626
+ end
627
+ end
628
+
629
+ nil
630
+ end
631
+
632
+ # Parse one NDJSON download line into a +DownloadFrame+. Maps the leading
633
+ # +meta+ frame to a byte-total +DownloadFrame+; returns +nil+ for blank
634
+ # lines and unknown frame types (forward-compat). Raises via
635
+ # +Antd.error_for_status+ on a terminal +error+ frame, which a raw
636
+ # octet-stream download cannot signal mid-stream.
637
+ def parse_ndjson_frame(line)
638
+ line = line.strip
639
+ return nil if line.empty?
640
+
641
+ obj = JSON.parse(line)
642
+ case obj["type"]
643
+ when "data"
644
+ DownloadFrame.new(data: b64_decode(obj["chunk"] || ""))
645
+ when "progress"
646
+ DownloadFrame.new(progress: DownloadProgress.new(
647
+ phase: obj["phase"] || "",
648
+ fetched: (obj["fetched"] || 0).to_i,
649
+ total: (obj["total"] || 0).to_i
650
+ ))
651
+ when "error"
652
+ raise Antd.error_for_status(500, obj["message"] || "stream error")
653
+ when "meta"
654
+ # The leading "meta" frame carries the byte denominator (total_size).
655
+ DownloadFrame.new(meta: (obj["total_size"] || 0).to_i)
656
+ else
657
+ # Unknown frame types are ignored for forward compatibility.
658
+ nil
659
+ end
660
+ end
661
+
662
+ # Perform an HTTP HEAD request and return the status code.
663
+ def do_head(path)
664
+ uri = build_uri(path)
665
+ http = Net::HTTP.new(uri.host, uri.port)
666
+ http.use_ssl = (uri.scheme == "https")
667
+ http.open_timeout = @timeout
668
+ http.read_timeout = @timeout
669
+
670
+ request = Net::HTTP::Head.new(uri)
671
+ response = http.request(request)
672
+ response.code.to_i
673
+ end
674
+ end
675
+ end