module Aqua module Store module CouchDB # Attachments is a Hash-like container with keys that are attacment names and values that are file-type # objects. Initializing and adding to the collection assures the types of both keys and values. The # collection implements a lazy-loading scheme, such that when an attachment is requested and not found, # it will try to load it from CouchDB. class Attachments < Mash attr_reader :document attr_reader :stubs # Creates a new attachment collection with keys that are attachment names and values that are # file-type objects. The collection manages both the key and the value types. # # @param [String] Document uri; used to save and retrieve attachments directly # @param [Hash] Initialization values # # @api public def initialize( doc, hash={} ) raise ArgumentError, "must be initialized with a document" unless doc.respond_to?( :retrieve ) @document = doc self.class.validate_hash( hash ) unless hash.empty? super( hash ) end # Adds an attachment to the collection, checking for type. Does not add directly to the database. # # @param [String, Symbol] Name of the attachment as a string or symbol # @param [File] The attachment # # @api public def add( name, file ) self.class.validate_hash( name => file ) self[name] = file end # Adds an attachment to the collection and to the database. Document doesn't have to be saved, # but it does need to have an id. # # @param [String, Symbol] Name of the attachment as a string or symbol # @param [File] The attachment # # @api public def add!( name, file ) add( name, file ) content_type = MIME::Types.type_for(file.path).first content_type = content_type.nil? ? "text\/plain" : content_type.simplified data = { 'content_type' => content_type, 'data' => Base64.encode64( file.read ).gsub(/\s/,'') } file.rewind response = CouchDB.put( uri_for( name ), data ) update_doc_rev( response ) file end # Deletes an attachment from the collection, and from the database. Use #delete (from Hash) to just # delete the attachment from the collection. # # @param [String, Symbol] Name of the attachment as a string or symbol # @return [File, nil] File at that location or nil if no file found # # @api public def delete!( name ) if self[name] file = delete( name ) unless document.new? CouchDB.delete( uri_for( name ) ) end file end end # Gets an attachment from the collection first. If not found, it will be requested from the database. # # @param [String, Symbol] Name of the attachment # @return [File, nil] File for that name, or nil if not found in hash or in database # # @api public def get( name, stream=false ) file = self[name] unless file file = get!( name, stream ) end file.rewind if file # just in case of previous streaming file end # Gets an attachment from the database. Stores it in the hash. # # @param [String, Symbol] Name of the attachment # @param [true, false] Stream boolean flag indicating whether the data should be converted to # a file or kept as a stream # @return [File, nil] File for that name, or nil if not found in the database # @raise Any error encountered on retrieval of the attachment, json, http_client, Aqua etc # # @todo make this more memory favorable, maybe streaming/saving in a max number of bytes # @api public def get!( name, stream=false ) file = nil response = CouchDB.get( uri_for( name, false ), true ) rescue nil data = response && response.respond_to?(:keys) ? Base64.decode64( response['data'] ) : nil if data || response file = Tempfile.new( CGI.escape( name.to_s ) ) file.binmode if file.respond_to?( :binmode ) data ? file.write( data ) : file.write( response ) file.rewind self[name] = file end stream ? file.read : file end # Constructs the standalone attachment uri for PUT and DELETE actions. # # @param [String] Name of the attachment as a string or symbol # # @api private def uri_for( name, include_rev = true ) raise ArgumentError, 'Document must have id in order to save an attachment' if document.id.nil? || document.id.empty? document.uri + "/#{CGI.escape( name.to_s )}" + ( document.rev && include_rev ? "?rev=#{document.rev}" : "" ) end # Validates and throws an error on a hash, insisting that the key is a string or symbol, # and the value is a file. # # @param [Hash] # # @api private def self.validate_hash( hash ) hash.each do |name, file| raise ArgumentError, "Attachment name, #{name.inspect}, must be a Symbol or a String" unless [Symbol, String ].include?( name.class ) raise ArgumentError, "Attachment file, #{file.inspect}, must be a File-like object" unless file.respond_to?( :read ) end end # Goes into the document and updates it's rev to match the returned rev. That way #new? will return false # when an attachment is created before the document is saved. It also means that future attempts to save # the doc won't fail with a conflict. # # @param [Hash] response from the put request # @api private def update_doc_rev( response ) document[:_rev] = response['rev'] end # Creates a hash for the CouchDB _attachments key. # @example # "_attachments": # { # "foo.txt": # { # "content_type":"text\/plain", # "data": "VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHRleHQ=" # }, # # "bar.txt": # { # "content_type":"text\/plain", # "data": "VGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHRleHQ=" # } # } def pack pack_hash = {} self.keys.each do |key| file = self[key] content_type = MIME::Types.type_for(file.path).first content_type = content_type.nil? ? "text\/plain" : content_type.simplified data = { 'content_type' => content_type, 'data' => Base64.encode64( file.read ).gsub(/\s/,'') } file.rewind pack_hash[key.to_s] = data end pack_hash end end # Attachments end # CouchDB end # Store end # Aqua