# 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] 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] 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] # @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] 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] 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