antd 0.2.0 → 0.3.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.
@@ -17,6 +17,8 @@ require_relative "v1/chunks_services_pb"
17
17
  require_relative "v1/files_services_pb"
18
18
  require_relative "v1/upload_services_pb"
19
19
  require_relative "v1/wallet_services_pb"
20
+ require_relative "v1/pointers_services_pb"
21
+ require "base64"
20
22
 
21
23
  module Antd
22
24
  DEFAULT_GRPC_TARGET = "localhost:50051"
@@ -47,6 +49,7 @@ module Antd
47
49
  @file_stub = Antd::V1::FileService::Stub.new(target, :this_channel_is_insecure)
48
50
  @upload_stub = Antd::V1::UploadService::Stub.new(target, :this_channel_is_insecure)
49
51
  @wallet_stub = Antd::V1::WalletService::Stub.new(target, :this_channel_is_insecure)
52
+ @pointer_stub = Antd::V1::PointerService::Stub.new(target, :this_channel_is_insecure)
50
53
  end
51
54
 
52
55
  # --- Health ---
@@ -468,8 +471,183 @@ module Antd
468
471
  resp.approved
469
472
  end
470
473
 
474
+ # --- Pointers ---
475
+ #
476
+ # The same methods, models and errors as the REST +Client+ (see there),
477
+ # over PointerService. The pointer errors share gRPC status codes with
478
+ # older ones, so the daemon names them in the +x-antd-error-code+
479
+ # trailer, which +grpc_call+ reads (see +Antd.error_for_grpc_metadata+).
480
+ # Unset optionals arrive as "" or an absent message and read as nil, as
481
+ # REST's nulls do. Every write pays: do not retry +pointer_create+,
482
+ # +pointer_update+, +pointer_transfer+ or +pointer_finalize+
483
+ # automatically.
484
+
485
+ # Mirrors +Client#pointer_keys+.
486
+ # @return [Array<String>]
487
+ def pointer_keys
488
+ resp = grpc_call { @pointer_stub.list_keys(Antd::V1::ListPointerKeysRequest.new) }
489
+ resp.keys.map(&:address)
490
+ end
491
+
492
+ # Mirrors +Client#pointer_create+. Pays; do not retry automatically.
493
+ # @return [PointerWriteResult]
494
+ def pointer_create(owner, target, kind: nil)
495
+ req = Antd::V1::PointerWriteRequest.new(owner: owner, target: target, kind: kind.to_s)
496
+ pointer_write_result(grpc_call { @pointer_stub.create(req) })
497
+ end
498
+
499
+ # Mirrors +Client#pointer_update+. Pays; do not retry automatically.
500
+ # @return [PointerWriteResult]
501
+ def pointer_update(owner, target, kind: nil)
502
+ req = Antd::V1::PointerWriteRequest.new(owner: owner, target: target, kind: kind.to_s)
503
+ pointer_write_result(grpc_call { @pointer_stub.update(req) })
504
+ end
505
+
506
+ # Mirrors +Client#pointer_transfer+. Pays; do not retry automatically.
507
+ # @return [PointerTransferResult]
508
+ def pointer_transfer(owner, recipient)
509
+ req = Antd::V1::PointerTransferRequest.new(owner: owner, recipient: recipient)
510
+ resp = grpc_call { @pointer_stub.transfer(req) }
511
+ PointerTransferResult.new(
512
+ address: resp.address,
513
+ state_id: resp.state_id,
514
+ finality: resp.finality.nil? ? nil : pointer_finality_from(resp.finality),
515
+ finality_error: presence(resp.finality_error)
516
+ )
517
+ end
518
+
519
+ # Mirrors +Client#pointer_get+.
520
+ # @return [PointerRecord]
521
+ def pointer_get(address)
522
+ resp = grpc_call { @pointer_stub.get(Antd::V1::GetPointerRequest.new(address: address)) }
523
+ PointerRecord.new(
524
+ address: resp.address,
525
+ owner: resp.owner,
526
+ counter: Antd.parse_pointer_counter("counter", resp.counter),
527
+ kind: resp.kind,
528
+ kind_tag: resp.kind_tag,
529
+ target: resp.target,
530
+ state_id: resp.state_id,
531
+ is_final: resp.is_final,
532
+ transferred_to: presence(resp.transferred_to)
533
+ )
534
+ end
535
+
536
+ # Mirrors +Client#pointer_resolve+.
537
+ # @return [PointerTarget]
538
+ def pointer_resolve(address)
539
+ resp = grpc_call { @pointer_stub.resolve(Antd::V1::ResolvePointerRequest.new(address: address)) }
540
+ PointerTarget.new(kind: resp.kind, kind_tag: resp.kind_tag, target: resp.target)
541
+ end
542
+
543
+ # Mirrors +Client#pointer_finality+.
544
+ # @return [PointerFinality]
545
+ def pointer_finality(address)
546
+ resp = grpc_call { @pointer_stub.finality(Antd::V1::PointerFinalityRequest.new(address: address)) }
547
+ pointer_finality_from(resp)
548
+ end
549
+
550
+ # Mirrors +Client#pointer_controller+.
551
+ # @return [PointerController]
552
+ def pointer_controller(address)
553
+ resp = grpc_call { @pointer_stub.controller(Antd::V1::PointerControllerRequest.new(address: address)) }
554
+ PointerController.new(pointer: resp.pointer, transfers: resp.transfers.to_a, frozen: resp.frozen)
555
+ end
556
+
557
+ # Mirrors +Client#pointer_prepare+.
558
+ # @return [PointerPrepareResult]
559
+ def pointer_prepare(owner, op, target: nil, kind: nil, recipient: nil, include_signed_quotes: false)
560
+ req = Antd::V1::PreparePointerRequest.new(
561
+ owner: owner,
562
+ op: op.to_s,
563
+ target: target.to_s,
564
+ kind: kind.to_s,
565
+ recipient: recipient.to_s,
566
+ include_signed_quotes: include_signed_quotes ? true : false
567
+ )
568
+ resp = grpc_call { @pointer_stub.prepare(req) }
569
+ PointerPrepareResult.new(
570
+ upload_id: resp.upload_id,
571
+ op: resp.op,
572
+ address: resp.address,
573
+ state_id: resp.state_id,
574
+ counter: Antd.parse_pointer_counter("counter", resp.counter),
575
+ payment_type: resp.payment_type,
576
+ payments: resp.payments.map do |p|
577
+ PaymentInfo.new(quote_hash: p.quote_hash, rewards_address: p.rewards_address, amount: p.amount)
578
+ end,
579
+ total_amount: resp.total_amount,
580
+ payment_vault_address: resp.payment_vault_address,
581
+ payment_token_address: resp.payment_token_address,
582
+ rpc_url: resp.rpc_url,
583
+ # Bytes on the wire; base64 here, the same form REST returns.
584
+ signed_quotes: resp.signed_quotes.map do |q|
585
+ SignedQuoteEntry.new(
586
+ quote_hash: q.quote_hash,
587
+ quote: Base64.strict_encode64(q.quote),
588
+ commitment_sidecar: q.commitment_sidecar.empty? ? nil : Base64.strict_encode64(q.commitment_sidecar)
589
+ )
590
+ end
591
+ )
592
+ end
593
+
594
+ # Mirrors +Client#pointer_finalize+. A +CloseGroupShortfallError+ with
595
+ # +retryable+ means the daemon kept the paid state: repeat the call with
596
+ # the same +upload_id+.
597
+ # @return [PointerFinalizeResult]
598
+ def pointer_finalize(upload_id, tx_hashes)
599
+ req = Antd::V1::FinalizePointerRequest.new(upload_id: upload_id, tx_hashes: tx_hashes)
600
+ resp = grpc_call { @pointer_stub.finalize(req) }
601
+ PointerFinalizeResult.new(
602
+ address: resp.address,
603
+ state_id: resp.state_id,
604
+ counter: Antd.parse_pointer_counter("counter", resp.counter),
605
+ finality: resp.finality.nil? ? nil : pointer_finality_from(resp.finality)
606
+ )
607
+ end
608
+
471
609
  private
472
610
 
611
+ def pointer_write_result(resp)
612
+ PointerWriteResult.new(
613
+ address: resp.address,
614
+ state_id: resp.state_id,
615
+ counter: Antd.parse_pointer_counter("counter", resp.counter)
616
+ )
617
+ end
618
+
619
+ # Converts the proto finality, where an unset counter or transferred_to is
620
+ # "" and an unset state or majority an absent message: each reads as nil.
621
+ def pointer_finality_from(f)
622
+ PointerFinality.new(
623
+ address: f.address,
624
+ group: f.group,
625
+ answered: f.answered,
626
+ status: f.status,
627
+ counter: f.counter.empty? ? nil : Antd.parse_pointer_counter("finality counter", f.counter),
628
+ state: f.state.nil? ? nil : pointer_final_state_from(f.state),
629
+ states: f.states.map { |s| pointer_final_state_from(s) },
630
+ majority: f.majority.nil? ? nil : pointer_final_state_from(f.majority),
631
+ transferred_to: presence(f.transferred_to)
632
+ )
633
+ end
634
+
635
+ def pointer_final_state_from(s)
636
+ PointerFinalState.new(
637
+ state_id: s.state_id,
638
+ kind: s.kind,
639
+ kind_tag: s.kind_tag,
640
+ target: s.target,
641
+ holders: s.holders,
642
+ transferred_to: presence(s.transferred_to)
643
+ )
644
+ end
645
+
646
+ # A proto string, or nil when unset (empty).
647
+ def presence(value)
648
+ value.nil? || value.empty? ? nil : value
649
+ end
650
+
473
651
  # Drives a +return_op: true+ server-streaming call, yielding +DownloadFrame+s.
474
652
  # The byte denominator is surfaced first as a leading +DownloadFrame+ (its
475
653
  # +meta+ set), read from the response's +x-content-length+ initial metadata;
@@ -560,8 +738,21 @@ module Antd
560
738
 
561
739
 
562
740
  # Executes a gRPC call and translates errors to Antd error types.
741
+ #
742
+ # The daemon's +x-antd-error-code+ trailer comes first: where it names a
743
+ # code that shares its gRPC status with older errors (POINTER_FINAL,
744
+ # POINTER_FORKED, CONFLICT, CLOSE_GROUP_SHORTFALL) and agrees with the
745
+ # status code, it selects the typed error. Anything else is re-raised to
746
+ # the status-code mapping below.
563
747
  def grpc_call
564
- yield
748
+ begin
749
+ yield
750
+ rescue GRPC::BadStatus => e
751
+ tagged = Antd.error_for_grpc_metadata(e.code, e.details, e.message, e.metadata)
752
+ raise tagged unless tagged.nil?
753
+
754
+ raise
755
+ end
565
756
  rescue GRPC::InvalidArgument => e
566
757
  raise BadRequestError, e.message
567
758
  rescue GRPC::NotFound => e
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "manifests"
4
+ require_relative "grpc_client"
5
+ require_relative "v1/manifests_services_pb"
6
+
7
+ # The manifest methods on +Antd::GrpcClient+, over ManifestService. The
8
+ # models and the entry check are in manifests.rb, shared with REST.
9
+ module Antd
10
+ # Channel arguments for the manifest stub's own channel. grpc-ruby has no
11
+ # per-call message limit, so ManifestService gets a channel of its own
12
+ # with both limits raised to +MANIFEST_GRPC_MAX_MESSAGE+; the other
13
+ # services keep gRPC's defaults.
14
+ MANIFEST_GRPC_CHANNEL_ARGS = {
15
+ "grpc.max_receive_message_length" => MANIFEST_GRPC_MAX_MESSAGE,
16
+ "grpc.max_send_message_length" => MANIFEST_GRPC_MAX_MESSAGE
17
+ }.freeze
18
+
19
+ class GrpcClient
20
+ # --- Manifests ---
21
+ #
22
+ # The same methods and models as the REST +Client+ (see there). The
23
+ # `.ant` bytes travel raw over gRPC; REST's base64 never shows. Unset
24
+ # optionals (proto3 +optional+ without a value, or an empty string) read
25
+ # as nil, as REST's omitted fields do.
26
+
27
+ # Mirrors +Client#build_manifest+. Nothing is paid for.
28
+ #
29
+ # Needs antd 0.16.0 or later.
30
+ #
31
+ # @return [BuiltManifest]
32
+ # @raise [BadRequestError] an entry with both or neither of +data_map+ and
33
+ # +address+, or a bad +size+, before anything is sent
34
+ # @raise [BadRequestError] an entry the daemon rejects (INVALID_ARGUMENT);
35
+ # the message names +entries[i]+
36
+ def build_manifest(entries, name: nil, torrent_info_hashes: nil, embed_roots: true)
37
+ inputs = Antd.manifest_entry_inputs(entries)
38
+ req = Antd::V1::BuildManifestRequest.new(
39
+ torrent_info_hashes: Array(torrent_info_hashes),
40
+ # Always sent, so "unset means true" never depends on the daemon.
41
+ embed_roots: embed_roots ? true : false,
42
+ entries: inputs.map { |e| manifest_entry_input_pb(e) }
43
+ )
44
+ req.name = name unless name.nil? || name.empty?
45
+
46
+ resp = grpc_call { manifest_stub.build(req) }
47
+ BuiltManifest.new(
48
+ manifest: resp.manifest,
49
+ link: manifest_pb_string(resp.link),
50
+ entries: resp.entries.map { |e| manifest_entry_info_from_pb(e) }
51
+ )
52
+ end
53
+
54
+ # Mirrors +Client#decode_manifest+. The bytes are sent raw.
55
+ #
56
+ # Needs antd 0.16.0 or later.
57
+ #
58
+ # @return [DecodedManifest]
59
+ def decode_manifest(manifest, resolve: false)
60
+ raise BadRequestError, MANIFEST_DECODE_NEEDS_INPUT if manifest.nil? || manifest.empty?
61
+
62
+ decode_manifest_pb(Antd::V1::DecodeManifestRequest.new(manifest: manifest.b, resolve: resolve ? true : false))
63
+ end
64
+
65
+ # Mirrors +Client#decode_manifest_link+.
66
+ #
67
+ # Needs antd 0.16.0 or later.
68
+ #
69
+ # @return [DecodedManifest]
70
+ def decode_manifest_link(link, resolve: false)
71
+ raise BadRequestError, MANIFEST_DECODE_NEEDS_INPUT if link.nil? || link.empty?
72
+
73
+ decode_manifest_pb(Antd::V1::DecodeManifestRequest.new(link: link, resolve: resolve ? true : false))
74
+ end
75
+
76
+ private
77
+
78
+ # ManifestService on its own channel, with +MANIFEST_GRPC_CHANNEL_ARGS+.
79
+ # Made on first use, so +initialize+ stays as it is.
80
+ def manifest_stub
81
+ @manifest_stub ||= Antd::V1::ManifestService::Stub.new(
82
+ @target, :this_channel_is_insecure, channel_args: MANIFEST_GRPC_CHANNEL_ARGS
83
+ )
84
+ end
85
+
86
+ def decode_manifest_pb(req)
87
+ resp = grpc_call { manifest_stub.decode(req) }
88
+ DecodedManifest.new(
89
+ source: resp.source,
90
+ name: resp.has_name? ? manifest_pb_string(resp.name) : nil,
91
+ torrent_info_hash_v1: manifest_pb_string(resp.torrent_info_hash_v1),
92
+ torrent_info_hash_v2: manifest_pb_string(resp.torrent_info_hash_v2),
93
+ entries: resp.entries.map { |e| manifest_entry_info_from_pb(e) }
94
+ )
95
+ end
96
+
97
+ def manifest_entry_input_pb(e)
98
+ attrs = {}
99
+ attrs[:path] = e.path unless e.path.nil? || e.path.empty?
100
+ attrs[:size] = e.size unless e.size.nil?
101
+ if e.data_map.nil? || e.data_map.empty?
102
+ attrs[:address] = e.address
103
+ else
104
+ attrs[:data_map] = e.data_map
105
+ end
106
+ Antd::V1::ManifestEntryInput.new(**attrs)
107
+ end
108
+
109
+ # proto3 +optional+ sizes are checked for presence: unset is nil, not 0.
110
+ def manifest_entry_info_from_pb(e)
111
+ ManifestEntryInfo.new(
112
+ effective_name: e.effective_name,
113
+ path: e.has_path? ? manifest_pb_string(e.path) : nil,
114
+ size: e.has_size? ? e.size : nil,
115
+ kind: e.kind,
116
+ content_address: e.content_address,
117
+ data_map: manifest_pb_string(e.data_map),
118
+ exact_size: e.has_exact_size? ? e.exact_size : nil,
119
+ error: manifest_pb_string(e.error)
120
+ )
121
+ end
122
+
123
+ # A proto string, or nil when empty.
124
+ def manifest_pb_string(value)
125
+ value.nil? || value.empty? ? nil : value
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require_relative "models"
5
+ require_relative "errors"
6
+ require_relative "client"
7
+
8
+ # Manifests (ADR-0006): `.ant` files and `ant://` links that describe a set
9
+ # of files, each with an optional path and size and either an embedded
10
+ # DataMap or a public DataMap address. The daemon builds and decodes them
11
+ # through ant-core, so callers never re-implement the format. Nothing is
12
+ # uploaded or paid for.
13
+ #
14
+ # This file holds the models and the REST methods on +Antd::Client+;
15
+ # grpc_manifests.rb adds the same methods to +Antd::GrpcClient+.
16
+ module Antd
17
+ # +ManifestEntryInfo#kind+ values.
18
+ module ManifestEntryKind
19
+ # The manifest carries the entry's DataMap: it is the key to the file.
20
+ EMBEDDED = "embedded"
21
+ # The manifest carries a public file's DataMap address.
22
+ PUBLIC = "public"
23
+ end
24
+
25
+ # +DecodedManifest#source+ values.
26
+ module ManifestSource
27
+ # `.ant` bytes or an `ant://manifest/` link.
28
+ MANIFEST = "manifest"
29
+ # A file link or bare address, reported as one public entry with no path.
30
+ FILE_LINK = "file_link"
31
+ end
32
+
33
+ # One file for +build_manifest+. Set exactly one of +data_map+ or
34
+ # +address+; a Hash with the same keys works too.
35
+ #
36
+ # - +path+: the relative path the entry extracts under, subject to the
37
+ # portable path rules. nil: it extracts under its content address.
38
+ # - +size+: the plaintext size to record, as given (the creator's claim).
39
+ # nil: none is recorded; readers take the exact size from an embedded
40
+ # root DataMap.
41
+ # - +data_map+: a hex DataMap, as the upload methods return it. The entry
42
+ # embeds it.
43
+ # - +address+: a public file's DataMap address (hex, 32 bytes).
44
+ ManifestEntryInput = Struct.new(:path, :size, :data_map, :address, keyword_init: true) do
45
+ def initialize(path: nil, size: nil, data_map: nil, address: nil)
46
+ super
47
+ end
48
+ end
49
+
50
+ # What a manifest says about one entry. Unset fields are nil on both
51
+ # transports (REST omits them; gRPC sends no value or an empty string),
52
+ # so a +size+ or +exact_size+ the manifest lacks is nil, never 0.
53
+ #
54
+ # - +effective_name+: +path+, or the content address in hex when there is
55
+ # none.
56
+ # - +path+: the entry's path, or nil.
57
+ # - +size+: the size recorded in the manifest (the creator's claim), or nil.
58
+ # - +kind+: a +ManifestEntryKind+ string.
59
+ # - +content_address+: hex. The file's public DataMap address, whether or
60
+ # not it was ever made public; the same for private and public uploads
61
+ # of one file.
62
+ # - +data_map+: the embedded DataMap in the hex form the download methods
63
+ # accept. Decode only; nil for public entries.
64
+ # - +exact_size+: the exact plaintext size, from an embedded root DataMap
65
+ # or after a resolving decode; nil when unknown.
66
+ # - +error+: why a resolving decode failed for this entry, or nil. A
67
+ # failed entry does not fail the call.
68
+ ManifestEntryInfo = Struct.new(
69
+ :effective_name, :path, :size, :kind, :content_address, :data_map, :exact_size, :error,
70
+ keyword_init: true
71
+ ) do
72
+ def initialize(effective_name: "", path: nil, size: nil, kind: "", content_address: "",
73
+ data_map: nil, exact_size: nil, error: nil)
74
+ super
75
+ end
76
+ end
77
+
78
+ # Result of +build_manifest+.
79
+ #
80
+ # - +manifest+: the `.ant` bytes (a binary String).
81
+ # - +link+: the `ant://manifest/` link, or nil when it would not fit the
82
+ # recommended 1,500 bytes.
83
+ # - +entries+: +ManifestEntryInfo+s in encoded order (sorted by effective
84
+ # name).
85
+ BuiltManifest = Struct.new(:manifest, :link, :entries, keyword_init: true)
86
+
87
+ # Result of +decode_manifest+ and +decode_manifest_link+.
88
+ #
89
+ # - +source+: a +ManifestSource+ string.
90
+ # - +name+: the suggested root directory name, or nil.
91
+ # - +torrent_info_hash_v1+, +torrent_info_hash_v2+: hex, or nil when absent.
92
+ # - +entries+: +ManifestEntryInfo+s.
93
+ DecodedManifest = Struct.new(
94
+ :source, :name, :torrent_info_hash_v1, :torrent_info_hash_v2, :entries,
95
+ keyword_init: true
96
+ )
97
+
98
+ # The gRPC message ceiling for manifest calls, both ways: a manifest can be
99
+ # 64 MiB, and a decode response carries its DataMaps in hex. gRPC's default
100
+ # receive limit is 4 MiB.
101
+ MANIFEST_GRPC_MAX_MESSAGE = 256 * 1024 * 1024
102
+
103
+ # The daemon's cap on the entry errors one 400 names.
104
+ MANIFEST_MAX_REPORTED_ENTRY_ERRORS = 20
105
+ private_constant :MANIFEST_MAX_REPORTED_ENTRY_ERRORS
106
+
107
+ # The daemon's message for empty decode input (empty bytes or link).
108
+ MANIFEST_DECODE_NEEDS_INPUT = "needs a manifest (base64 .ant bytes) or a link"
109
+
110
+ # Normalises +build_manifest+ entries to +ManifestEntryInput+s before
111
+ # anything is sent. A refusal is the +BadRequestError+ (400) the daemon
112
+ # would send, with its wording: every bad entry (both or neither of
113
+ # +data_map+ and +address+, nil and "" counting as unset; or a +size+ that
114
+ # is not a non-negative Integer) is collected and named as
115
+ # "N invalid entries: entries[i]: ...; entries[j]: ..." (at most 20, then
116
+ # "; and K more").
117
+ #
118
+ # @api private
119
+ # @param entries [Array<ManifestEntryInput, Hash>]
120
+ # @return [Array<ManifestEntryInput>]
121
+ # @raise [BadRequestError]
122
+ def self.manifest_entry_inputs(entries)
123
+ raise BadRequestError, "entries must be an Array" unless entries.is_a?(Array)
124
+
125
+ errors = []
126
+ inputs = entries.each_with_index.map do |e, i|
127
+ e = ManifestEntryInput.new(**e.transform_keys(&:to_sym)) if e.is_a?(Hash)
128
+ unless e.is_a?(ManifestEntryInput)
129
+ errors << "entries[#{i}]: must be a ManifestEntryInput or Hash"
130
+ next
131
+ end
132
+
133
+ has_data_map = !(e.data_map.nil? || e.data_map.empty?)
134
+ has_address = !(e.address.nil? || e.address.empty?)
135
+ if has_data_map && has_address
136
+ errors << "entries[#{i}]: give data_map or address, not both"
137
+ elsif !has_data_map && !has_address
138
+ errors << "entries[#{i}]: needs a data_map or an address"
139
+ end
140
+ unless e.size.nil? || (e.size.is_a?(Integer) && e.size >= 0)
141
+ errors << "entries[#{i}]: size must be a non-negative Integer or nil"
142
+ end
143
+ e
144
+ rescue ArgumentError
145
+ # A Hash with a key ManifestEntryInput does not have.
146
+ errors << "entries[#{i}]: unknown field (expected path, size, data_map, address)"
147
+ nil
148
+ end
149
+ raise BadRequestError, Antd.manifest_entry_errors_message(errors) unless errors.empty?
150
+
151
+ inputs
152
+ end
153
+
154
+ # The daemon's +entry_errors_message+.
155
+ #
156
+ # @api private
157
+ def self.manifest_entry_errors_message(errors)
158
+ shown = errors.first(MANIFEST_MAX_REPORTED_ENTRY_ERRORS).join("; ")
159
+ message = "#{errors.length} invalid entries: #{shown}"
160
+ message += "; and #{errors.length - MANIFEST_MAX_REPORTED_ENTRY_ERRORS} more" if errors.length > MANIFEST_MAX_REPORTED_ENTRY_ERRORS
161
+ message
162
+ end
163
+
164
+ class Client
165
+ # --- Manifests ---
166
+
167
+ # Build a `.ant` manifest from DataMaps and public addresses already in
168
+ # hand (POST /v1/manifests/build). Nothing is uploaded or paid for.
169
+ #
170
+ # By default the daemon resolves each shrunk DataMap's root (fetching its
171
+ # wrapper records) and embeds it when it is at most 64 KiB, so recipients
172
+ # skip that fetch (ADR-0006's policy). That reads from the network, so the
173
+ # call can time out; it is safe to retry.
174
+ #
175
+ # Needs antd 0.16.0 or later.
176
+ #
177
+ # @param entries [Array<ManifestEntryInput, Hash>] each with exactly one
178
+ # of +data_map+ or +address+; checked before anything is sent
179
+ # @param name [String, nil] suggested root directory name (one portable
180
+ # path component)
181
+ # @param torrent_info_hashes [Array<String>, nil] BitTorrent info hashes
182
+ # of the same files: hex, 40 characters for v1 and/or 64 for v2
183
+ # @param embed_roots [Boolean] false embeds shrunk DataMaps exactly as
184
+ # given (the wire's +embed_roots+; default true)
185
+ # @return [BuiltManifest]
186
+ # @raise [BadRequestError] an entry with both or neither of +data_map+ and
187
+ # +address+, or a bad +size+, worded as the daemon words it
188
+ # @raise [BadRequestError] an entry the daemon rejects; the message names
189
+ # +entries[i]+
190
+ def build_manifest(entries, name: nil, torrent_info_hashes: nil, embed_roots: true)
191
+ inputs = Antd.manifest_entry_inputs(entries)
192
+ wire = inputs.map do |e|
193
+ m = {}
194
+ m[:path] = e.path unless e.path.nil? || e.path.empty?
195
+ m[:size] = e.size unless e.size.nil?
196
+ if e.data_map.nil? || e.data_map.empty?
197
+ m[:address] = e.address
198
+ else
199
+ m[:data_map] = e.data_map
200
+ end
201
+ m
202
+ end
203
+ body = { entries: wire, embed_roots: embed_roots ? true : false }
204
+ body[:name] = name unless name.nil? || name.empty?
205
+ hashes = Array(torrent_info_hashes)
206
+ body[:torrent_info_hashes] = hashes unless hashes.empty?
207
+
208
+ j = do_json(:post, "/v1/manifests/build", body)
209
+ BuiltManifest.new(
210
+ manifest: manifest_bytes_from_b64(j["manifest"]),
211
+ link: manifest_string(j["link"]),
212
+ entries: manifest_entry_infos(j["entries"])
213
+ )
214
+ end
215
+
216
+ # Decode `.ant` bytes (POST /v1/manifests/decode). With +resolve+, the
217
+ # daemon fetches each entry's DataMap (and wrapper records) to report
218
+ # +exact_size+ and confirm the DataMap is on the network; content chunks
219
+ # are not fetched, and one entry's failure is reported in its +error+,
220
+ # not as a failed call. Nothing is paid for; safe to retry.
221
+ #
222
+ # Needs antd 0.16.0 or later.
223
+ #
224
+ # @param manifest [String] the `.ant` bytes
225
+ # @param resolve [Boolean]
226
+ # @return [DecodedManifest]
227
+ # @raise [BadRequestError] an empty +manifest+
228
+ # @raise [BadRequestError] bytes that are not a manifest
229
+ def decode_manifest(manifest, resolve: false)
230
+ raise BadRequestError, MANIFEST_DECODE_NEEDS_INPUT if manifest.nil? || manifest.empty?
231
+
232
+ post_manifest_decode({ manifest: b64_encode(manifest), resolve: resolve ? true : false })
233
+ end
234
+
235
+ # Decode an `ant://` manifest link, file link or bare 64-hex address
236
+ # (POST /v1/manifests/decode). See +decode_manifest+ for +resolve+.
237
+ #
238
+ # Needs antd 0.16.0 or later.
239
+ #
240
+ # @param link [String]
241
+ # @param resolve [Boolean]
242
+ # @return [DecodedManifest]
243
+ # @raise [BadRequestError] an empty +link+
244
+ # @raise [BadRequestError] a link the daemon cannot read
245
+ def decode_manifest_link(link, resolve: false)
246
+ raise BadRequestError, MANIFEST_DECODE_NEEDS_INPUT if link.nil? || link.empty?
247
+
248
+ post_manifest_decode({ link: link, resolve: resolve ? true : false })
249
+ end
250
+
251
+ private
252
+
253
+ def post_manifest_decode(body)
254
+ j = do_json(:post, "/v1/manifests/decode", body)
255
+ DecodedManifest.new(
256
+ source: j["source"] || "",
257
+ name: manifest_string(j["name"]),
258
+ torrent_info_hash_v1: manifest_string(j["torrent_info_hash_v1"]),
259
+ torrent_info_hash_v2: manifest_string(j["torrent_info_hash_v2"]),
260
+ entries: manifest_entry_infos(j["entries"])
261
+ )
262
+ end
263
+
264
+ # The build response's base64 `.ant` bytes, raw.
265
+ def manifest_bytes_from_b64(value)
266
+ b64_decode(value.to_s)
267
+ rescue ArgumentError => e
268
+ raise InternalError, "build_manifest: manifest is not valid base64: #{e.message}"
269
+ end
270
+
271
+ def manifest_entry_infos(raw)
272
+ return [] unless raw.is_a?(Array)
273
+
274
+ raw.filter_map do |e|
275
+ next unless e.is_a?(Hash)
276
+
277
+ ManifestEntryInfo.new(
278
+ effective_name: e["effective_name"] || "",
279
+ path: manifest_string(e["path"]),
280
+ size: manifest_size(e["size"]),
281
+ kind: e["kind"] || "",
282
+ content_address: e["content_address"] || "",
283
+ data_map: manifest_string(e["data_map"]),
284
+ exact_size: manifest_size(e["exact_size"]),
285
+ error: manifest_string(e["error"])
286
+ )
287
+ end
288
+ end
289
+
290
+ # An optional size: a JSON integer, or nil when absent (never 0).
291
+ def manifest_size(value)
292
+ value.is_a?(Integer) && value >= 0 ? value : nil
293
+ end
294
+
295
+ # An optional string: nil when absent or empty.
296
+ def manifest_string(value)
297
+ value.is_a?(String) && !value.empty? ? value : nil
298
+ end
299
+ end
300
+ end