- Implement SynorStorage class for decentralized storage operations including upload, download, pinning, and CAR file management. - Create supporting types and models for storage operations such as UploadOptions, Pin, and StorageConfig. - Implement SynorWallet class for wallet operations including wallet creation, address generation, transaction signing, and balance queries. - Create supporting types and models for wallet operations such as Wallet, Address, and Transaction. - Introduce error handling for both storage and wallet operations.
317 lines
7.8 KiB
Ruby
317 lines
7.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "faraday"
|
|
require "faraday/multipart"
|
|
require "json"
|
|
require "base64"
|
|
|
|
module SynorStorage
|
|
# Synor Storage SDK Client
|
|
#
|
|
# @example
|
|
# client = SynorStorage::Client.new(api_key: 'your-api-key')
|
|
#
|
|
# # Upload file
|
|
# result = client.upload(data, name: 'file.txt')
|
|
#
|
|
# # Download file
|
|
# data = client.download(cid)
|
|
#
|
|
class Client
|
|
attr_reader :config
|
|
|
|
def initialize(api_key: nil, **options)
|
|
@config = Config.new(api_key: api_key, **options)
|
|
raise ArgumentError, "API key is required" unless @config.api_key
|
|
|
|
@conn = Faraday.new(url: @config.base_url) do |f|
|
|
f.request :multipart
|
|
f.request :url_encoded
|
|
f.response :json
|
|
f.options.timeout = @config.timeout
|
|
f.headers["Authorization"] = "Bearer #{@config.api_key}"
|
|
f.headers["X-SDK-Version"] = "ruby/#{VERSION}"
|
|
end
|
|
@closed = false
|
|
end
|
|
|
|
# ==================== Upload Operations ====================
|
|
|
|
# Upload data to storage
|
|
#
|
|
# @param data [String, IO] Data to upload
|
|
# @param options [UploadOptions, Hash] Upload options
|
|
# @return [UploadResponse]
|
|
def upload(data, name: nil, content_type: nil, pin: true, metadata: nil, wrap_with_directory: false)
|
|
check_closed!
|
|
|
|
payload = {
|
|
file: Faraday::Multipart::FilePart.new(
|
|
StringIO.new(data.is_a?(String) ? data : data.read),
|
|
content_type || "application/octet-stream",
|
|
name || "file"
|
|
),
|
|
pin: pin.to_s,
|
|
wrap_with_directory: wrap_with_directory.to_s
|
|
}
|
|
|
|
if metadata
|
|
metadata.each do |key, value|
|
|
payload["metadata[#{key}]"] = value
|
|
end
|
|
end
|
|
|
|
response = with_retry { @conn.post("/upload", payload) }
|
|
UploadResponse.from_hash(response.body)
|
|
end
|
|
|
|
# Upload a directory of files
|
|
#
|
|
# @param files [Array<FileEntry>] Files to upload
|
|
# @param directory_name [String, nil] Optional directory name
|
|
# @return [UploadResponse]
|
|
def upload_directory(files, directory_name: nil)
|
|
check_closed!
|
|
|
|
payload = {}
|
|
files.each_with_index do |file, i|
|
|
payload["files[#{i}]"] = Faraday::Multipart::FilePart.new(
|
|
StringIO.new(file.content),
|
|
file.content_type || "application/octet-stream",
|
|
file.path
|
|
)
|
|
end
|
|
|
|
payload["directory_name"] = directory_name if directory_name
|
|
|
|
response = with_retry { @conn.post("/upload/directory", payload) }
|
|
UploadResponse.from_hash(response.body)
|
|
end
|
|
|
|
# ==================== Download Operations ====================
|
|
|
|
# Download content by CID
|
|
#
|
|
# @param cid [String]
|
|
# @return [String] Binary data
|
|
def download(cid)
|
|
check_closed!
|
|
|
|
response = with_retry do
|
|
@conn.get("/download/#{cid}") do |req|
|
|
req.headers["Accept"] = "*/*"
|
|
end
|
|
end
|
|
|
|
response.body
|
|
end
|
|
|
|
# Download content with streaming
|
|
#
|
|
# @param cid [String]
|
|
# @yield [String] Chunks of data
|
|
def download_stream(cid, &block)
|
|
check_closed!
|
|
raise ArgumentError, "Block required for streaming" unless block_given?
|
|
|
|
@conn.get("/download/#{cid}") do |req|
|
|
req.headers["Accept"] = "*/*"
|
|
req.options.on_data = proc do |chunk, _|
|
|
block.call(chunk)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Get gateway URL for a CID
|
|
#
|
|
# @param cid [String]
|
|
# @param path [String, nil] Optional path within the CID
|
|
# @return [String]
|
|
def get_gateway_url(cid, path: nil)
|
|
url = "#{@config.gateway}/ipfs/#{cid}"
|
|
url += "/#{path.gsub(%r{^/}, '')}" if path
|
|
url
|
|
end
|
|
|
|
# ==================== Pinning Operations ====================
|
|
|
|
# Pin content by CID
|
|
#
|
|
# @param request [PinRequest, Hash] Pin request
|
|
# @return [Pin]
|
|
def pin(request)
|
|
check_closed!
|
|
|
|
body = request.is_a?(PinRequest) ? request.to_h : request
|
|
response = with_retry { post_json("/pins", body) }
|
|
Pin.from_hash(response.body)
|
|
end
|
|
|
|
# Unpin content
|
|
#
|
|
# @param cid [String]
|
|
def unpin(cid)
|
|
check_closed!
|
|
|
|
with_retry { @conn.delete("/pins/#{cid}") }
|
|
nil
|
|
end
|
|
|
|
# Get pin status
|
|
#
|
|
# @param cid [String]
|
|
# @return [Pin]
|
|
def get_pin_status(cid)
|
|
check_closed!
|
|
|
|
response = with_retry { @conn.get("/pins/#{cid}") }
|
|
Pin.from_hash(response.body)
|
|
end
|
|
|
|
# List pins
|
|
#
|
|
# @param status [String, nil] Filter by status
|
|
# @param limit [Integer]
|
|
# @param offset [Integer]
|
|
# @return [Array<Pin>]
|
|
def list_pins(status: nil, limit: 20, offset: 0)
|
|
check_closed!
|
|
|
|
params = { limit: limit, offset: offset }
|
|
params[:status] = status if status
|
|
|
|
response = with_retry { @conn.get("/pins", params) }
|
|
response.body.map { |p| Pin.from_hash(p) }
|
|
end
|
|
|
|
# ==================== CAR File Operations ====================
|
|
|
|
# Create a CAR file from entries
|
|
#
|
|
# @param entries [Array<FileEntry>]
|
|
# @return [CarFile]
|
|
def create_car(entries)
|
|
check_closed!
|
|
|
|
body = entries.map do |e|
|
|
{
|
|
path: e.path,
|
|
content: Base64.strict_encode64(e.content),
|
|
content_type: e.content_type
|
|
}
|
|
end
|
|
|
|
response = with_retry { post_json("/car/create", body) }
|
|
CarFile.from_hash(response.body)
|
|
end
|
|
|
|
# Import a CAR file
|
|
#
|
|
# @param car_data [String] Binary CAR data
|
|
# @return [Array<String>] CIDs
|
|
def import_car(car_data)
|
|
check_closed!
|
|
|
|
response = with_retry do
|
|
@conn.post("/car/import") do |req|
|
|
req.headers["Content-Type"] = "application/vnd.ipld.car"
|
|
req.body = car_data
|
|
end
|
|
end
|
|
|
|
response.body["cids"]
|
|
end
|
|
|
|
# Export content as a CAR file
|
|
#
|
|
# @param cid [String]
|
|
# @return [String] Binary CAR data
|
|
def export_car(cid)
|
|
check_closed!
|
|
|
|
response = with_retry do
|
|
@conn.get("/car/export/#{cid}") do |req|
|
|
req.headers["Accept"] = "application/vnd.ipld.car"
|
|
end
|
|
end
|
|
|
|
response.body
|
|
end
|
|
|
|
# ==================== Directory Operations ====================
|
|
|
|
# List directory contents
|
|
#
|
|
# @param cid [String]
|
|
# @return [Array<DirectoryEntry>]
|
|
def list_directory(cid)
|
|
check_closed!
|
|
|
|
response = with_retry { @conn.get("/directory/#{cid}") }
|
|
response.body.map { |e| DirectoryEntry.from_hash(e) }
|
|
end
|
|
|
|
# ==================== Statistics ====================
|
|
|
|
# Get storage statistics
|
|
#
|
|
# @return [StorageStats]
|
|
def get_stats
|
|
check_closed!
|
|
|
|
response = with_retry { @conn.get("/stats") }
|
|
StorageStats.from_hash(response.body)
|
|
end
|
|
|
|
# ==================== Lifecycle ====================
|
|
|
|
def close
|
|
@closed = true
|
|
@conn.close if @conn.respond_to?(:close)
|
|
end
|
|
|
|
def closed?
|
|
@closed
|
|
end
|
|
|
|
private
|
|
|
|
def check_closed!
|
|
raise ClientClosedError, "Client has been closed" if @closed
|
|
end
|
|
|
|
def post_json(path, body)
|
|
@conn.post(path) do |req|
|
|
req.headers["Content-Type"] = "application/json"
|
|
req.body = JSON.generate(body)
|
|
end
|
|
end
|
|
|
|
def with_retry
|
|
attempts = 0
|
|
begin
|
|
attempts += 1
|
|
response = yield
|
|
handle_response(response)
|
|
response
|
|
rescue Faraday::Error, ApiError => e
|
|
if attempts < @config.retries
|
|
sleep(attempts)
|
|
retry
|
|
end
|
|
raise
|
|
end
|
|
end
|
|
|
|
def handle_response(response)
|
|
return if response.success?
|
|
|
|
error_message = if response.body.is_a?(Hash)
|
|
response.body["error"] || response.body["message"] || "Unknown error"
|
|
else
|
|
"HTTP #{response.status}"
|
|
end
|
|
raise ApiError.new(error_message, status_code: response.status)
|
|
end
|
|
end
|
|
end
|