Merged [log:thingfish/trunk@473:475] in preparation for merge to [source:thingfish/trunk]!
M bin/thingfish +132 -28
@@ 1,5 1,4 @@ 
 #!/usr/bin/env ruby
-# 
 
 BEGIN {
 	require 'pathname'

          
@@ 15,6 14,7 @@ BEGIN {
 require 'thingfish'
 require 'thingfish/client'
 require 'uri'
+require 'ipaddr'
 require 'optparse'
 require 'ostruct'
 

          
@@ 44,6 44,7 @@ class ThingFish::CommandLineClient
 
 		options.debug_mode = false
 		options.verbose_mode = false
+		options.verbose_mode = false
 		options.host = 'localhost'
 		options.port = DEFAULT_PORT
 

          
@@ 64,7 65,7 @@ class ThingFish::CommandLineClient
 			oparser.banner = "Usage: #{progname.basename} OPTIONS ACTION [ARGS]"
 
 			oparser.separator 'Actions:'
-			oparser.separator '  upload, update, download, check, info'
+			oparser.separator '  upload, update, download, check, info, search'
 
 			oparser.separator ''
 			oparser.separator 'Options:'

          
@@ 91,9 92,8 @@ class ThingFish::CommandLineClient
 				opts.verbosemode = true
 			end
 
-			oparser.on_tail( '--help', '-h', FalseClass, "Display this help." ) do
-				$stderr.puts oparser
-				exit!
+			oparser.on_tail( '--help', '-h', FalseClass, "Display help for the given command." ) do
+				opts.helpmode = true
 	        end
 
 			# Another typical switch to print the version.

          
@@ 106,7 106,7 @@ class ThingFish::CommandLineClient
 		remaining_args = oparser.parse( argv )
 		if remaining_args.empty?
 			$stderr.puts( oparser )
-			exit( 64 ) # EX_USAGE
+			exit( opts.helpmode ? 0 : 64 ) # EX_USAGE
 		end
 
 		return opts, *remaining_args

          
@@ 119,13 119,15 @@ class ThingFish::CommandLineClient
 	
 	### Create a new ThingFish::CommandLineClient
 	def initialize( options )
-		uri = URI.parse( "http://%s:%d/" % [options.host, options.port] )
+		@options = options
+		
+		uri = URI.parse( "http://%s:%d/" % [@options.host, @options.port] )
 		@client = ThingFish::Client.new( uri )
 		
-		if options.debugmode
+		if @options.debugmode
 			ThingFish.reset_logger
 			ThingFish.logger.level = Logger::DEBUG
-		elsif options.verbosemode
+		elsif @options.verbosemode
 			ThingFish.reset_logger
 			ThingFish.logger.level = Logger::INFO
 		end

          
@@ 140,35 142,39 @@ class ThingFish::CommandLineClient
 	def main( verb, *args )
 		self.log.debug "Client requested '%s' with arguments: %p" % [ verb, args ]
 	
+		prefix = @options.helpmode ? 'help' : 'do'
+
 		case verb.downcase
 		when 'upload', 'post'
-			do_upload( args.shift ) until args.empty?
+			return help_upload() if args.empty?
+			send( "#{prefix}_upload", args.shift ) until args.empty?
 	
 		when 'update', 'put'
+			return help_update() if (args.length % 2).nonzero? or args.empty?
 			until args.empty?
 				uuid = args.shift or raise "no UUID specified for update"
 				file = args.shift
-				do_update( uuid, file )
+				send( "#{prefix}_update", uuid, file )
 			end
 	
 		when 'download', 'get', 'fetch'
+			return help_download() if args.empty?			
 			until args.empty?
 				uuid = args.shift or raise "no UUID specified for fetch"
 				file = args.shift
-				do_download( uuid, file )
+				send( "#{prefix}_download", uuid, file )
 			end
 	
 		when 'check', 'head'
-			do_check( args.shift ) until args.empty?
-	
+			return help_check() if args.empty?
+			send( "#{prefix}_check", args.shift ) until args.empty?
+			
+		when 'search', 'find'
+			return help_search() if (args.length % 2).nonzero? or args.empty?
+			send( "#{prefix}_search", args )
+
 		when 'info'
-			self.log.debug "Fetching server info"
-			info = @client.server_info or raise "couldn't fetch server info"
-			$stdout.puts "%s is a version %s server." % [ @client.uri, info['version'] ],
-				"Handlers: ",
-				"  %p" % [ info['handlers'].keys.sort ],
-				"Filters: ",
-				"  %p" % [ info['filters'].keys.sort ]
+			display_server_info()
 	
 		else
 			raise "Don't know how to '#{verb}' yet."

          
@@ 181,12 187,48 @@ class ThingFish::CommandLineClient
 	#########
 
 
+	### Fetch the server info hash from the server and display it.
+	def display_server_info
+		info = @client.server_info or raise "couldn't fetch server info"
+		
+		handler_info = []
+		filter_info  = []
+		
+		if @options.verbosemode
+			max_length = info['handlers'].keys.max {|a,b| a.length <=> b.length }.length
+			self.log.debug "Max length is: %d" % [max_length]
+			info['handlers'].each do |name, hinfo|
+				handler_info << "  %*s -> [ %s ]" % [ max_length, name, hinfo.join(', ') ]
+			end
+
+			info['filters'].each do |name, finfo|
+				filter_info << "  %s v%s (rev %d)" % [
+					name,
+					finfo['version'].join('.'),
+					finfo['rev']
+				  ]
+			end
+			
+		else
+			handler_info << "  %p" % [ info['handlers'].keys.sort ]
+			filter_info << "  %p" % [ info['filters'].keys.sort ]
+		end
+
+		$stdout.puts "%s is a version %s server." % [ @client.uri, info['version'] ],
+			"Handlers: ",
+			handler_info,
+			"Filters: ",
+			filter_info
+		
+	end
+	
+
 	### Upload the contents of the specified +file+ to the ThingFish at the given +uri+. 
 	### If +file+ is nil, the content will be read from STDIN instead.
 	def do_upload( file=nil )
 		fh = get_reader_io( file )
 		resource = ThingFish::Resource.new( fh )
-		resource.format = self.get_mimetype( fh )
+		resource.format = self.get_mimetype( file )
 
 		if file
 			resource.title  = File.basename( file )

          
@@ 197,6 239,12 @@ class ThingFish::CommandLineClient
 		
 		$stderr.puts "Uploaded to: #{resource.uuid}"
 	end
+	
+	
+	### Output upload help to stderr.
+	def help_upload( *args )
+		self.print_help( 'upload <filename> [filename2] [filename3] ... ' )
+	end
 
 
 	### Update the resource at the specified +uuid+ on the ThingFish at the given +uri+

          
@@ 208,7 256,7 @@ class ThingFish::CommandLineClient
 
 		fh = get_reader_io( file )
 		resource = ThingFish::Resource.new( fh, :client => @client, :uuid => uuid )
-		resource.format = self.get_mimetype( file, fh )
+		resource.format = self.get_mimetype( file )
 
 		if file
 			resource.title  = File.basename( file )

          
@@ 220,6 268,12 @@ class ThingFish::CommandLineClient
 		$stderr.puts "Updated #{uuid}"
 	end
 
+	
+	### Output update help to stderr.
+	def help_update( *args )
+		self.print_help( 'update <uuid> <filename> [<uuid2> <filename2>] ... ' )
+	end
+
 
 	### Verify that a resource with the specified +uuid+ exists on the ThingFish at 
 	### the given +uri+.

          
@@ 231,6 285,12 @@ class ThingFish::CommandLineClient
 		end
 	end
 	
+	
+	### Output check help to stderr.
+	def help_check( *args )
+		self.print_help( 'check <uuid> [uuid2] ... ' )
+	end
+
 
 	### Fetch the resource at the specified +uuid+ on the ThingFish at the given +uri+
 	### and write it to the specified +file+. If +file+ is nil, the content will be

          
@@ 246,7 306,45 @@ class ThingFish::CommandLineClient
 		$stderr.puts "Fetched resource at %s." % [ uuid ]
 	end
 
+	
+	### Output download help to stderr.
+	def help_download( *args )
+		self.print_help( <<-EOH )
+			download <uuid> <filename> [<uuid2> <filename2>] ...
+			(Save resources to files on disk)
+				
+		EOH
+	
+		self.print_help( <<-EOH )
+			download <uuid>
+			(Stream a resource to stdout)			
+		EOH
+	end
 
+
+	### Search for the provided terms at the server supplied 'simplesearch'
+	### URL. Prints matching resource UUIDS and titles (if available) to stderr.
+	def do_search( terms )
+		terms = Hash[ *terms ]
+		results = @client.find( terms )
+		
+		if results.empty?
+			$stdout.puts "No resources were found that match your search terms."
+			return
+		end
+		
+		results.each do |resource|
+			$stdout.puts "%s (%s)" % [ resource.uuid, resource.metadata[:title] ]
+		end
+	end
+	
+	
+	### Output search help to stderr.
+	def help_search( *args )
+		self.print_help( 'search <property> <value> [<property2> <value2>] ...' )
+	end
+
+	
 	### Get an IO object for reading from the specified +filename+. If +filename+ is 
 	### +nil+, returns the IO for STDIN instead.
 	def get_reader_io( filename=nil )

          
@@ 304,10 402,9 @@ class ThingFish::CommandLineClient
 	end
 
 
-	### Given a +file+ and an optional +io+, try to deduce a mimetype from the
-	### possible filename extension.  Failing that, use the first 2k of data
-	### read from the given +io+ and return it. The IO object will be rewound
-	### before returning.
+	### Given a +file+, try to deduce a mimetype from the possible filename
+	### extension.	Failing that, use the magic value to try to determine the
+	### file's type if either the Mahoro or the filemagic library is installed.
 	def get_mimetype( file )
 		analyzer = self.get_mime_analyzer or
 			raise "Ack. None of the strategies for deriving mimetype worked."

          
@@ 315,7 412,7 @@ class ThingFish::CommandLineClient
 		if file.respond_to?( :extname )
 			return analyzer.file( file )
 		else	
-			io ||= self.get_reader_io( file )
+			io = self.get_reader_io( file )
 
 			data = io.read( 2048 )
 			io.rewind

          
@@ 324,6 421,13 @@ class ThingFish::CommandLineClient
 		end
 	end
 
+	
+	### Print the given helptext with the program name after stripping leading
+	### tabs.
+	def print_help( helptext )
+		$stderr.puts "%s %s" % [ $0, helptext.gsub(/^\t+/, '') ]
+	end
+
 end
 
 

          
M etc/thingfish.conf-testing +2 -4
@@ 20,13 20,11 @@ logging:
 
 plugins:
     filestore:
-        name: filesystem
+        name: memory
         maxsize: 1073741824
         root: /tmp/thingfish
     metastore:
-        name: sqlite3
-        root: /tmp/thingfish
-        resource_dir: plugins/thingfish-sqlite3ms/resources
+        name: memory
     handlers:
         - inspect:
             uris: /inspect

          
M lib/thingfish/client.rb +98 -73
@@ 23,7 23,7 @@ 
 #   resource = client.store( File.open("mahlonblob.dxf"), :format => 'image/x-dxf' )
 #   # =>  #<ThingFish::Resource:0xa5a5be UUID:7602813e-dc77-11db-837f-0017f2004c5e>
 #   
-#   # Set some more metadata (method style):    #TODO#
+#   # Set some more metadata (method style):
 #   resource.owner = 'mahlon'
 #   resource.description = "3D model used to test the new All Open Source pipeline"
 #   # ...or hash style:

          
@@ 207,12 207,7 @@ class ThingFish::Client
 			request['Accept'] = ACCEPT_HEADER
 
 			send_request( request ) do |response|
-				case response
-				when Net::HTTPSuccess
-					@server_info = self.extract_response_body( response )
-				else
-					response.error!
-				end
+				@server_info = self.extract_response_body( response )
 			end
 			
 			self.log.debug "Server info is: %p" % [ @server_info ]

          
@@ 242,32 237,30 @@ class ThingFish::Client
 
 	### Fetch the resource with the given +uuid+ as a ThingFish::Resource object.
 	def fetch( uuid )
-		resource = nil
+		metadata = self.fetch_metadata( uuid ) or return nil
+		return ThingFish::Resource.new( nil, self, uuid, metadata )
+	end
+
+
+	### Fetch the metadata for the resource identified by the given +uuid+ and
+	### return it as a Hash.
+	def fetch_metadata( uuid )
+		metadata = nil
+
 		fetchuri = self.server_uri( :simplemetadata )
 		fetchuri.path += "/#{uuid}"
 
-		self.log.debug "Fetching data for a resource object from: %s" % [ fetchuri ]
+		self.log.info "Fetching metadata for a resource object from: %s" % [ fetchuri ]
 		request = Net::HTTP::Get.new( fetchuri.path )
 		request['Accept'] = ACCEPT_HEADER
 			
-		send_request( request ) do |response|
-			case response
-			when Net::HTTPOK
-				self.log.debug "Creating a ThingFish::Resource from %p" % [response]
-				
-				resource = self.create_resource_for_response( uuid, response )
-
-			when Net::HTTPSuccess
-				raise ThingFish::ClientError,
-					"Unexpected %d response to %s" % [ response.status, request.to_s ]
-
-			else
-				response.error!
-			end
+		self.send_request( request ) do |response|
+			self.log.debug "Creating a ThingFish::Resource from %p" % [response]
+			metadata = self.extract_response_body( response )
 		end
 
-		self.log.debug { "Returning resource: %p" % [resource] }
-		return resource
+		self.log.debug { "Returning resource: %p" % [metadata] }
+		return metadata
 	end
 
 

          
@@ 275,28 268,17 @@ class ThingFish::Client
 	### return it as an IO object.
 	def fetch_data( uuid )
 		fetchuri = self.server_uri( :default )
-		fetchuri.path += "/#{uuid}"
+		fetchuri.path += "#{uuid}"
 
-		self.log.debug "Fetching resource data from: %s" % [ fetchuri ]
-		request = Net::HTTP::Get.new( fetchuri.path )
+		path = fetchuri.path.squeeze( '/' ) # Eliminate '//'
+		self.log.info "Fetching resource data from: %s" % [ fetchuri ]
+		request = Net::HTTP::Get.new( path )
 		request['Accept'] = '*/*'
 		
-		io = nil
-		send_request( request ) do |response|
-			case response
-			when Net::HTTPOK
-				io = self.create_io_from_response( uuid, response )
-
-			when Net::HTTPSuccess
-				raise ThingFish::ClientError,
-					"Unexpected %d response to %s" % [ response.status, request.to_s ]
-
-			else
-				response.error!
-			end
+		io = self.send_request( request ) do |response|
+			self.create_io_from_response( uuid, response )
 		end
 
-		self.log.debug { "Returning IO: %p" % [io] }
 		return io
 	end
 	

          
@@ 310,7 292,7 @@ class ThingFish::Client
 
 		request = Net::HTTP::Head.new( fetchuri.path )
 			
-		res = send_request( request )
+		res = self.send_request( request )
 		return res.is_a?( Net::HTTPSuccess )
 	end
 	

          
@@ 346,6 328,7 @@ class ThingFish::Client
 		end
 
         self.log.debug "Setting request %p body stream to %p" % [ request, resource.io ]
+
 		request.body_stream = resource.io
 		request['Content-Length'] = resource.extent
 		request['Content-Type'] = resource.format

          
@@ 354,7 337,6 @@ class ThingFish::Client
 		request['Accept'] = ACCEPT_HEADER
 
 		self.send_request( request ) do |response|
-			response.error! unless response.is_a?( Net::HTTPSuccess )
 
 			# Set the UUID if it wasn't already and merge metadata returned in the response 
 			# without clobbering any existing values

          
@@ 385,7 367,9 @@ class ThingFish::Client
 		request['Accept'] = ACCEPT_HEADER
 
 		self.send_request( request ) do |response|
-			response.error! unless response.success?
+			metadata = self.extract_response_body( response ) or
+				raise ThingFish::ClientError, "no body in the update response"
+			resource.metadata = metadata
 		end
 
 		return resource

          
@@ 393,16 377,13 @@ class ThingFish::Client
 	
 
 
-	### Delete the given +resource+ from the server. The +resource+ argument can be either
-	### a ThingFish::Resource or a UUID.
-	def delete( resource )
-		uuid = nil
-		if resource.is_a?( ThingFish::Resource )
-			uuid = resource.uuid or return nil
-		else
-			uuid = UUID.parse( resource )
-		end
-		
+	### Delete the resource that corresponds to a given uuid on the server. The
+	### +argument+ can be either a UUID string or an object that
+	### responds_to? #uuid.
+	def delete( argument )
+		argument = argument.uuid if argument.respond_to?( :uuid )
+		uuid = UUID.parse( argument )
+
 		uri = self.server_uri( :default )
 		request = Net::HTTP::Delete.new( uri.path + "#{uuid}" )
 		

          
@@ 413,24 394,36 @@ class ThingFish::Client
 		return true
 	end
 
+
+	### Search for resources based on the +criteria+ hash, which should consist
+	### of metadata keys and the desired matching values.
+	def find( criteria={} )
+		uri = self.server_uri( :simplesearch )
+		query_args = make_query_args( criteria )
+		request = Net::HTTP::Get.new( uri.path + query_args )
+		resources = []
 	
-
+		request['Accept'] = ACCEPT_HEADER	
+		send_request( request ) do |response|
+			response.error! unless response.is_a?( Net::HTTPSuccess )
+			uuids = self.extract_response_body( response )
+			uuids.each do |uuid|
+				resources << ThingFish::Resource.new( nil, self, uuid )
+			end
+		end
+		
+		return resources
+	end
+	
+	
 	#########
 	protected
 	#########
 
-	### Extract the necessary values from the specified HTTP +response+ (a Net::HTTPResponse) 
-	### as a ThingFish::Resource associated with the given +uuid+ and return it.
-	def create_resource_for_response( uuid, response )
-		metadata = self.extract_response_body( response )
-		return ThingFish::Resource.new( nil, self, uuid, metadata )
-	end
-
-
 	### Extract the serialized object in the given +response+'s entity body and return it.
 	def extract_response_body( response )
 		
-		case response['Content-Type']
+		case response['Content-Type']			
 		when /#{RUBY_MARSHALLED_MIMETYPE}/i
 			self.log.debug "Unmarshalling a marshalled-ruby-object response body."
             return Marshal.load( response.body )

          
@@ 452,12 445,20 @@ class ThingFish::Client
 	### larger than MAX_INMEMORY_RESPONSE_SIZE), or an in-memory StringIO.
 	def create_io_from_response( uuid, response )
 		len = Integer( response['Content-length'] )
+		self.log.debug "Content length is: %p" % [ len ]
 
 		if len > MAX_INMEMORY_RESPONSE_SIZE
+			self.log.debug "...buffering to disk"
 			file = Pathname.new( Dir.tmpdir ) + "tf-#{uuid}.data.0"
-			file = file.sub(/.*/, file.to_s.succ ) while file.exist?
+			fh = nil
 			
-			fh = file.open( File::CREAT|File::RDWR|File::EXCL, 0600 )
+			begin
+				fh = file.open( File::CREAT|File::RDWR|File::EXCL, 0600 )
+			rescue Errno::EEXIST
+				file = file.sub( /\.(\d+)$/ ) { '.' + $1.succ }
+				retry
+			end
+
 			file.delete
 			response.read_body do |chunk|
 				fh.write( chunk )

          
@@ 466,6 467,7 @@ class ThingFish::Client
 			
 			return fh
 		else
+			self.log.debug "...'buffering' to memory"
 			return StringIO.new( response.body )
 		end
 	end

          
@@ 474,20 476,43 @@ class ThingFish::Client
 	### Send the given HTTP::Request to the host and port specified by +uri+ (a URI 
 	### object). The +limit+ specifies how many redirects will be followed before giving 
 	### up.
-	def send_request( req, limit=10, &block )
+	def send_request( req, limit=10 )
 		req['User-Agent'] = USER_AGENT_HEADER
 
 		self.log.debug "Request: " + dump_request_object( req )
 		
-		response = Net::HTTP.start( uri.host, uri.port ) do |conn|
-			conn.request( req, &block )
+		Net::HTTP.start( uri.host, uri.port ) do |conn|
+			conn.request( req ) do |response|
+				return response unless block_given?
+				
+				self.log.debug "Response: " + dump_response_object( response )
+
+				case response
+				when Net::HTTPOK, Net::HTTPCreated
+					return yield( response )
+
+				when Net::HTTPSuccess
+					raise ThingFish::ClientError,
+						"Unexpected %d response to %s" % [ response.status, req.to_s ]
+
+				else
+					response.error!
+				end
+			end
 		end
-	
-		self.log.debug "Response: " + dump_response_object( response )
-		return response
 	end
 
 
+	### Given a +hash+, build and return a query argument string.
+	def make_query_args( hash )
+		query_args = hash.collect do |k,v|
+			"%s=%s" % [ URI.escape(k.to_s), URI.escape(v.to_s) ]
+		end
+		
+		return '?' + query_args.sort.join(';')
+	end
+	
+
 	### Return the request object as a string suitable for debugging
 	def dump_request_object( request )
 		buf = "#{request.method} #{request.path} HTTP/#{Net::HTTP::HTTPVersion}\r\n"

          
M lib/thingfish/resource.rb +98 -34
@@ 97,22 97,22 @@ class ThingFish::Resource
 	### If the optional +client+ argument is set, it will be used as the ThingFish::Client instance 
 	### which should be used to load and store the resource on the server. If the +uuid+ argument
 	### is given, it will be used as the resource's Universally Unique IDentifier when 
-	### communicating with the server. The +metadata+ Hash may include one or more metadata 
+	### communicating with the server. The +new_metadata+ Hash may include one or more metadata 
 	### key-value pairs which will be set on the resource.
-	def initialize( datasource=nil, client=nil, uuid=nil, metadata={} )
-		metadata ||= {}
+	def initialize( datasource=nil, client=nil, uuid=nil, new_metadata={} )
+		new_metadata ||= {}
 
-		metadata, datasource = datasource, nil if datasource.is_a?( Hash )
-		metadata, client = client, nil if client.is_a?( Hash )
-		metadata, uuid = uuid, nil if uuid.is_a?( Hash )
+		new_metadata, datasource = datasource, nil if datasource.is_a?( Hash )
+		new_metadata, client = client, nil if client.is_a?( Hash )
+		new_metadata, uuid = uuid, nil if uuid.is_a?( Hash )
 
-		@io     = normalize_io_obj( datasource )
+		@io     = self.normalize_io_obj( datasource )
 		@client = client
 		@uuid   = uuid
 
 		# Use the accessor to set all remaining pairs
-        @metadata = {}
-		metadata.each do |key, val|
+        @metadata = nil
+		new_metadata.each do |key, val|
 			self.send( "#{key}=", val )
 		end
 	end

          
@@ 122,58 122,106 @@ class ThingFish::Resource
 	public
 	######
 
-	# Metadata hash
-	attr_accessor :metadata
-
 	# The ThingFish::Client the resource is stored in (or will be stored in when it
 	# is saved)
 	attr_accessor :client
 
-	# The object containing the resource data
-	attr_accessor :io
-
 	# The resource's UUID (if it has one)
 	attr_accessor :uuid
 
 
-	### Read the data for the resource into a String and return it.
-	def data
-		rval = nil
+	### Return an IO-ish object that contains the resource data.
+	def io
+		@io ||= self.load_data
+		return @io
+	end
+
 
-		if @io
-			rval = @io.read
-			@io.rewind
-		end
-		
-		return rval
+	### Set the IO-ish object that contains the resource's data.
+	def io=( newio )
+		@io = self.normalize_io_obj( newio )
+	end
+
+
+	### Returns the resource's metadata as a Hash
+	def metadata
+		self.log.debug "Metadata is: %p" % [ @metadata ]
+		@metadata = self.load_metadata if @metadata.nil? || @metadata.empty?
+		return @metadata
 	end
 	
 	
+	### Set the Hash of metadata associated with the Resource.
+	def metadata=( newhash )
+		@metadata = newhash.to_hash
+	end
+	
+
+	### Read the data from the resource's #io and return it as a String.
+	def data
+		ioobj = self.io or return nil
+		return ioobj.read
+	end
+
+	
+	### Write the specified String of +newdata+ to the resource's #io, replacing
+	### what was there before.
+	def data=( newdata )
+		@io ||= StringIO.new
+		@io.truncate( 0 )
+		@io.write( newdata )
+	end
+
+	
 	### Write the data from the resource to the given +io+ object.
 	def export( io )
+		self.load_data
 		buf = ''
-		while @io.read( 8192, buf )
+		resource_io = self.io.dup
+		
+		while resource_io.read( 8192, buf )
 			until buf.empty?
 				bytes = io.write( buf )
 				buf.slice!( 0, bytes )
 			end
 		end
 		
-		@io.rewind
 	end
 	
 	
 	### Save the resource to the server using the resource's client. If no client is
-	### set for this resource, this method raises a RuntimeError.
+	### set for this resource, this method raises a ThingFish::ResourceError.
 	def save
+		self.save_data && self.save_metadata
+	end
+
+
+	### Save the resource's data via its associated ThingFish::Client. This will
+	### raise a ThingFish::ResourceError if there is no associated client.
+	def save_data
 		raise ThingFish::ResourceError, "no client set" unless @client
-		@client.store( self )
+		@client.store_data( self )
+	end
+	
+	
+	### Save the resource's metadata via its associated ThingFish::Client. This
+	### will raise a ThingFish::ResourceError if there is no associated client.
+	def save_metadata
+		raise ThingFish::ResourceError, "no client set" unless @client
+		@client.store_metadata( self )
+	end
+	
+	
+	### Revert the resource's data and metadata to the versions stored on the server.
+	def revert
+		@metadata = nil
+		@io = nil
 	end
 	
 	
 	### Override Kernel#format to return the 'format' metadata key instead.
 	def format
-		return @metadata[ :format ]
+		return self.metadata[ 'format' ]
 	end
 
 

          
@@ 189,30 237,46 @@ class ThingFish::Resource
 	protected
 	#########
 
+	### Load the resource's data via the associated client and return it. If 
+	### the receiver doesn't have both an associated client and a UUID, returns nil.
+	def load_data
+		return nil unless @client && @uuid
+		return @client.fetch_data( @uuid )
+	end
+
+
+	### Load the resource's metadata via the associated client and return it. If
+	### the receiver doesn't have both an associated client and a UUID, returns
+	### an empty Hash.
+	def load_metadata
+		return {} unless @client && @uuid
+		return @client.fetch_metadata( @uuid )
+	end
+
+
 	### Proxy method for calling methods that correspond to keys.
 	def method_missing( sym, val=nil, *args )
 		case sym.to_s
 		when /^(?:has_)(\w+)\?$/
 			propname = $1.to_sym
-			return @metadata.key?( propname )
+			return self.metadata.key?( propname )
 
 		when /^(\w+)=$/
 			propname = $1.to_sym
-			return @metadata[ propname ] = val
+			return self.metadata[ propname ] = val
 			
 		else
-			return @metadata[ sym ]
+			return self.metadata[ sym ]
 		end
 	end
 	
 
-
 	### Set the datasource for the object to +sourceobj+, which can be either an
 	### IO object (in which case it's used directly), or the resource data in a 
 	### String, in which case the datasource will be set to a StringIO containing
 	### the data.
 	def normalize_io_obj( sourceobj )
-		return nil if sourceobj.nil? || (sourceobj.is_a?(String) && sourceobj.empty?)
+		return nil if sourceobj.nil?
 		return sourceobj if sourceobj.respond_to?( :read )
 		return StringIO.new( sourceobj )
 	end

          
M misc/clientlibs/perl/client.pl +1 -0
@@ 35,6 35,7 @@ usage() unless scalar @ARGV;
 
 my $tf = ThingFish::Client->new;
 do {
+	$url =~ s|^https?://||i;
 	my ( $host, $port ) = split ':', $url;
 	usage() unless $host && $port;
 	$tf->host( $host );

          
M spec/client_spec.rb +142 -36
@@ 34,31 34,28 @@ include ThingFish::Constants
 #####################################################################
 
 describe ThingFish::Client do
+	include ThingFish::TestHelpers
 
 	TEST_DATASTRUCTURE = { :some => 'marshalled', :data => 'in', :a => 'Hash' }
 	TEST_MARSHALLED_DATASTRUCTURE = Marshal.dump( TEST_DATASTRUCTURE )
 	TEST_SERVER_INFO = {
-		'version'=>"0.1.0",
+		'version'  => '0.1.0',
 		'handlers' => {
-			"default"=>["/"],
-			"staticcontent"=>["/metadata", "/", "/upload", "/search"],
-			"simplemetadata"=>["/metadata"],
-			"simplesearch"=>["/search"],
-			"inspect"=>["/inspect"],
-			"formupload"=>["/upload"]
+			'default'        => ['/'],
+			'staticcontent'  => ['/metadata', '/', '/upload', '/search'],
+			'simplemetadata' => ['/metadata'],
+			'simplesearch'   => ['/search'],
+			'inspect'        => ['/inspect'],
+			'formupload'     => ['/upload']
 		},
 	}
 
-
 	before( :all ) do
-		ThingFish.reset_logger
-		ThingFish.logger.level = Logger::FATAL
-		ThingFish.logger.formatter.debug_format = 
-			'<code>' + ThingFish::LogFormatter::DEFAULT_FORMAT + '</code><br/>'
+			setup_logging( :fatal )
 	end
 
 	after( :all ) do
-		ThingFish.reset_logger
+		reset_logging()
 	end
 
 

          
@@ 96,6 93,11 @@ describe ThingFish::Client do
 		client.password.should == TEST_PASSWORD
 	end
 
+	it "masks the URI password in inspected output" do
+		client = ThingFish::Client.new( 'http://foompy:chompers@localhost:3474/' )
+		client.inspect.should_not =~ /chompers/
+	end
+	
 
 	### No args creation
 	describe " created with no arguments" do

          
@@ 127,6 129,7 @@ describe ThingFish::Client do
 			@client.password = TEST_PASSWORD
 			@client.password.should == TEST_PASSWORD
 		end
+		
 	end
 
 

          
@@ 182,7 185,7 @@ describe ThingFish::Client do
 			@conn.should_receive( :request ).
 				with( @request ).
 				and_yield( @response )
-			Net::HTTPSuccess.should_receive( :=== ).with( @response).and_return( true )
+			Net::HTTPOK.should_receive( :=== ).with( @response).and_return( true )
 
 			@response.should_receive( :code ).at_least(:once).and_return( HTTP::OK )
 			@response.should_receive( :message ).and_return( "OK" )

          
@@ 199,6 202,29 @@ describe ThingFish::Client do
 		end
 		
 
+		it "raises an exception if fetching #server_info responds with anything other than a 200" do
+			# Unset the cached server info hash for this one test
+			@client.instance_variable_set( :@server_info, nil )
+
+			Net::HTTP::Get.should_receive( :new ).
+				with( '/' ).
+				and_return( @request )
+			@request.should_receive( :method ).and_return( "GET" )
+
+			@conn.should_receive( :request ).
+				with( @request ).
+				and_yield( @response )
+			Net::HTTPOK.should_receive( :=== ).with( @response).and_return( false )
+
+			@response.should_receive( :error! ).once.
+				and_raise( RuntimeError.new("server error") )
+		
+			lambda {
+				@client.server_info
+			  }.should raise_error( RuntimeError, /server error/ )
+		end
+		
+
 		it "can fetch server information (fallback YAML filter)" do
 			# Unset the cached server info hash for this one test
 			@client.instance_variable_set( :@server_info, nil )

          
@@ 211,9 237,9 @@ describe ThingFish::Client do
 			@conn.should_receive( :request ).
 				with( @request ).
 				and_yield( @response )
-			Net::HTTPSuccess.should_receive( :=== ).with( @response).and_return( true )
+			Net::HTTPOK.should_receive( :=== ).with( @response).and_return( true )
 
-			@response.should_receive( :code ).at_least(:once).and_return( HTTP::OK )
+			# @response.should_receive( :status ).at_least(:once).and_return( HTTP::OK )
 			@response.should_receive( :message ).and_return( "OK" )
 			@response.should_receive( :[] ).
 				with( /content-type/i ).

          
@@ 273,6 299,7 @@ describe ThingFish::Client do
 			@conn.should_receive( :request ).with( @request ).and_yield( @response )
 
 			Net::HTTPOK.should_receive( :=== ).with( @response).and_return( false )
+			Net::HTTPCreated.should_receive( :=== ).with( @response).and_return( false )
 			Net::HTTPSuccess.should_receive( :=== ).with( @response).and_return( false )
 
 			@response.should_receive( :code ).at_least(:once).and_return( HTTP::NOT_FOUND )

          
@@ 312,6 339,42 @@ describe ThingFish::Client do
 		end
 
 
+		it "can fetch a resource's data from the server" do
+			Net::HTTP::Get.should_receive( :new ).
+				with( TEST_SERVER_INFO['handlers']['default'].first + TEST_UUID ).
+				and_return( @request )
+			@request.should_receive( :[]= ).with( 'Accept', '*/*' )
+			@request.should_receive( :method ).and_return( "GET" )
+		
+			@conn.should_receive( :request ).with( @request ).and_yield( @response )
+			Net::HTTPOK.should_receive( :=== ).with( @response).and_return( true )
+		
+			# Branch: in-memory vs. on disk
+			pathname = mock( "pathname object" )
+			filehandle = mock( "filehandle object" )
+			
+			@response.should_receive( :[] ).with( /content-length/i ).
+				and_return( ThingFish::Client::MAX_INMEMORY_RESPONSE_SIZE + 100 )
+			Pathname.should_receive( :new ).and_return( pathname )
+			pathname.should_receive( :+ ).with( "tf-#{TEST_UUID}.data.0" ).
+				and_return( pathname )
+
+			flags = File::EXCL|File::CREAT|File::RDWR
+			pathname.should_receive( :open ).with( flags, 0600 ).
+				and_return( filehandle )
+			pathname.should_receive( :delete )
+						
+			@response.should_receive( :read_body ).
+				and_yield( :first_chunker ).
+				and_yield( :second_chunker )
+			filehandle.should_receive( :write ).with( :first_chunker )
+			filehandle.should_receive( :write ).with( :second_chunker )
+			filehandle.should_receive( :rewind )
+						
+			@client.fetch_data( TEST_UUID ).should == filehandle
+		end
+
+
 		### #has? predicate method
 		it "returns true when asked if it has a uuid that corresponds to a resource it has" do
 			Net::HTTP::Head.should_receive( :new ).

          
@@ 322,8 385,6 @@ describe ThingFish::Client do
 			@conn.should_receive( :request ).with( @request ).and_return( @response )
 
 			@response.should_receive( :is_a? ).with( Net::HTTPSuccess ).and_return( true )
-			@response.should_receive( :code ).at_least( :once ).and_return( HTTP::OK )
-			@response.should_receive( :message ).and_return( "OK" )
 		
 			@client.has?( TEST_UUID ).should be_true
 		end

          
@@ 337,9 398,8 @@ describe ThingFish::Client do
 			@request.should_receive( :method ).and_return( "HEAD" )
 
 			@conn.should_receive( :request ).with( @request ).and_return( @response )
-			@response.should_receive( :code ).at_least(:once).and_return( HTTP::NOT_FOUND )
-			@response.should_receive( :message ).and_return( "NOT FOUND" )
-
+			@response.should_receive( :is_a? ).with( Net::HTTPSuccess ).and_return( false )
+			
 			@client.has?( TEST_UUID ).should be_false
 		end
 	

          
@@ 367,12 427,13 @@ describe ThingFish::Client do
 			 	with( /content-disposition/i, /attachment;filename="a title"/i )
 		
 			@conn.should_receive( :request ).with( @request ).and_yield( @response )
+			Net::HTTPOK.should_receive( :=== ).with( @response ).and_return( true )
 
 			# Set the UUID from the response's Location header
 			@response.should_receive( :[] ).with( /location/i ).
 				and_return( 'http://thingfish.example.com/' + TEST_UUID )
 			resource.should_receive( :uuid= ).with( TEST_UUID )
-
+			
 			# Merge response metadata
 			response_metadata = {
 				'description' => 'Bananas Goes to Military School',

          
@@ 382,7 443,7 @@ describe ThingFish::Client do
 				'description' => 'Bananas Goes To See Assemblage 23',
 				'author'      => 'Bananas',
 			  }
-			merged_metadata = response_metadata.merge(resource_metadata)
+			merged_metadata = response_metadata.merge( resource_metadata )
 			
 			resource.should_receive( :metadata ).and_return( resource_metadata )
 			@response.should_receive( :[] ).with( /content-type/i ).

          
@@ 415,6 476,7 @@ describe ThingFish::Client do
 			 	with( /content-disposition/i, /attachment;filename="a title"/i )
 		
 			@conn.should_receive( :request ).with( @request ).and_yield( @response )
+			Net::HTTPOK.should_receive( :=== ).with( @response ).and_return( true )
 
 			# Set the UUID from the response's Location header
 			@response.should_receive( :[] ).with( /location/i ).

          
@@ 460,6 522,7 @@ describe ThingFish::Client do
 			resource_metadata = {
 				'description' => 'Bananas Goes To See Assemblage 23',
 				'author'      => 'Bananas',
+				'extent'	  => 1203,
 			  }
 
 			Net::HTTP::Put.should_receive( :new ).and_return( @request )

          
@@ 468,10 531,29 @@ describe ThingFish::Client do
 			resource = mock( "Mock Resource" )
 			resource.should_receive( :uuid ).and_return( TEST_UUID )
 			resource.should_receive( :metadata ).and_return( resource_metadata )
+
+			@conn.should_receive( :request ).with( @request ).
+				and_yield( @response )
+			Net::HTTPOK.should_receive( :=== ).with( @response ).and_return( true )
+
+			metadata = {
+				'description' => 'Bananas Goes To See Assemblage 23',
+				'author'      => 'Bananas',
+				'extent'	  => 14002,
+				'hump'        => 'catting',
+			  }
+
+			@response.should_receive( :[] ).with( /content-type/i ).
+				and_return( RUBY_MARSHALLED_MIMETYPE )
+			@response.should_receive( :body ).
+				and_return( Marshal.dump(metadata) )
+
+			resource.should_receive( :metadata= ).with( metadata )
 			
 			@client.store_metadata( resource )
 		end
 		
+		
 
 		### Updating
 		it "can update file data if given a ThingFish::Resource that already has a UUID" do

          
@@ 498,6 580,7 @@ describe ThingFish::Client do
 			 	with( /content-disposition/i, /attachment;filename="a title"/i )
 		
 			@conn.should_receive( :request ).with( @request ).and_yield( @response )
+			Net::HTTPOK.should_receive( :=== ).with( @response ).and_return( true )
 		
 			# Merge response metadata
 			response_metadata = {

          
@@ 522,7 605,7 @@ describe ThingFish::Client do
 
 
 		### Deleting
-		it "can delete resources from the server by its UUID" do
+		it "can delete a resource from the server by its UUID" do
 			Net::HTTP::Delete.should_receive( :new ).
 				with( '/' + TEST_UUID ).
 				and_return( @request )

          
@@ 536,29 619,52 @@ describe ThingFish::Client do
 		end
 
 
-		it "can delete resources from the server via a ThingFish::Resource" do
+		it "can delete a resource from the server via an object that knows what its UUID is" do
 			resource = mock( "Mock Resource" )
-			resource.should_receive( :is_a? ).
-				with( ThingFish::Resource ).
-				any_number_of_times().
-				and_return( true )
-			resource.should_receive( :uuid ).
-				and_return( TEST_UUID )
+			resource.should_receive( :respond_to? ).with( :uuid ).and_return( true )
+			resource.should_receive( :uuid ).and_return( TEST_UUID )
 
-			Net::HTTP::Delete.should_receive( :new ).
-				with( '/' + TEST_UUID ).
+			Net::HTTP::Delete.should_receive( :new ).with( '/' + TEST_UUID ).
 				and_return( @request )
 			@request.should_receive( :method ).and_return( "DELETE" )
 
-			@conn.should_receive( :request ).
-				with( @request ).
+			@conn.should_receive( :request ).with( @request ).
 				and_yield( @response )
 		
 			@client.delete( resource ).should be_true()
 		end
 
 
-		it "can find resources by their metadata attributes"
+		it "can find resources by their metadata attributes" do
+			path_and_query = TEST_SERVER_INFO['handlers']['simplesearch'].first +
+				'?format=image/jpeg;title=*energylegs*'
+			Net::HTTP::Get.should_receive( :new ).with( path_and_query ).
+				and_return( @request )
+
+			@request.should_receive( :method ).and_return( 'GET' )
+			@request.should_receive( :[]= ).with( /accept/i, /#{RUBY_MARSHALLED_MIMETYPE}/ )
+			@conn.should_receive( :request ).with( @request ).
+				and_yield( @response )
+			Net::HTTPOK.should_receive( :=== ).with( @response ).and_return( true )
+
+			uuids = [ TEST_UUID, TEST_UUID2, TEST_UUID3 ]
+			@response.should_receive( :[] ).with( /content-type/i ).
+				and_return( RUBY_MARSHALLED_MIMETYPE )
+			@response.should_receive( :body ).
+				and_return( Marshal.dump(uuids) )
+			
+			criteria = {
+				:format => 'image/jpeg',
+				:title  => '*energylegs*',
+			}
+
+			results = @client.find( criteria )
+			results.should be_an_instance_of( Array )
+			results.should have( 3 ).members
+			results.collect {|r| r.uuid }.
+				should include( TEST_UUID, TEST_UUID2, TEST_UUID3 )
+		end
+		
 
 	end # REST API
 

          
M spec/resource_spec.rb +205 -86
@@ 24,136 24,255 @@ rescue LoadError
 end
 
 
-include ThingFish::TestConstants
-include ThingFish::TestHelpers
-
 
 #####################################################################
 ###	E X A M P L E S
 #####################################################################
 
 describe ThingFish::Resource do
+	include ThingFish::TestHelpers,
+		ThingFish::TestConstants
 
 	TEST_METADATA = {
 		'format' => 'image/jpeg',
 		'title' => 'The Way it Was.jpg',
 		'author' => 'Semilin P. Idsnitch',
 		'extent' => 134622,
-		
+		'doodlebops' => 'suck',
+		'byline' => 'Something Naughty That Jonathan Will Make Us Change'
 	}
+	TEST_METADATA.freeze
 
 	before( :all ) do
-		ThingFish.reset_logger
-		ThingFish.logger.level = Logger::FATAL
+		setup_logging( :fatal )
 	end
 
 	after( :all ) do
-		ThingFish.reset_logger
+		reset_logging()
+	end
+
+
+	before( :each ) do
+		@io = StringIO.new( TEST_CONTENT )
+		@client = stub( "Mock client" )
+	end
+	
+
+	it "can be created from an object that responds to #read" do
+		resource = ThingFish::Resource.new( @io )
+		resource.io.should == @io
+		resource.client.should be_nil()
+		resource.uuid.should be_nil()
+	end
+	
+
+	it "can be created from an IO object and a client object" do
+		resource = ThingFish::Resource.new( @io, @client )
+		resource.io.should == @io
+		resource.client.should == @client
+		resource.uuid.should be_nil()
+	end
+
+
+	it "can be create from a Hash of metadata" do
+		resource = ThingFish::Resource.new( :title => 'Piewicket Eats a Potato' )
+		resource.io.should be_nil()
+		resource.client.should be_nil()
+		resource.uuid.should be_nil()
+		resource.title.should == 'Piewicket Eats a Potato'
+	end
+	
+
+	it "can be created from an IO object and a Hash of metadata" do
+		resource = ThingFish::Resource.new( @io, :title => 'Piewicket Eats a Potato' )
+		resource.io.should == @io
+		resource.client.should be_nil()
+		resource.uuid.should be_nil()
+		resource.title.should == 'Piewicket Eats a Potato'
+	end
+	
+	
+	it "can be created from an IO object, a client object, and a Hash of metadata" do
+		resource = ThingFish::Resource.new( @io, @client, :title => 'Piewicket Eats a Potato' )
+		resource.io.should == @io
+		resource.client.should == @client
+		resource.title.should == 'Piewicket Eats a Potato'
+	end
+
+
+	it "can be created from an IO object, a client object, a UUID, and a Hash of metadata" do
+		@client.should_receive( :fetch_metadata ).with( TEST_UUID ).
+			and_return( TEST_METADATA.dup )
+		resource = ThingFish::Resource.new( @io, @client, TEST_UUID,
+		 	:title => 'Piewicket Eats a Potato' )
+		resource.io.should == @io
+		resource.client.should == @client
+		resource.uuid.should == TEST_UUID
+		resource.title.should == 'Piewicket Eats a Potato'
+	end
+
+	it "raises an error when saving without an associated ThingFish::Client" do
+		resource = ThingFish::Resource.new( @io )
+		
+		lambda {
+			resource.save
+		  }.should raise_error( ThingFish::ResourceError, /no client/i )
+	end
+
+
+	it "knows that it is always considered unsaved if it doesn't have an associated client" do
+		resource = ThingFish::Resource.new( @io )
+		resource.saved = true
+		resource.should_not be_saved()
 	end
 
 
 	### Metadata interface
-
-	it "auto-generates accessors for metadata values" do
-		resource = ThingFish::Resource.new
-
-		resource.should_not have_extent()
+	describe "created with no arguments" do
+		before( :each ) do
+			@resource = ThingFish::Resource.new
+		end
+		
+		
+		it "auto-generates accessors for metadata values" do
+			@resource.should_not have_extent()
         
-		resource.extent = 1024
-		resource.extent.should == 1024
+			@resource.extent = 1024
+			@resource.extent.should == 1024
 	    
-		resource.should have_extent()
-		resource.should_not have_pudding() # Awwww... no pudding.
+			@resource.should have_extent()
+			@resource.should_not have_pudding() # Awwww... no pudding.
+		end
+
+
+		it "allows one to replace the metadata hash" do
+			@resource.metadata = TEST_METADATA
+
+			@resource.metadata.should have(6).members
+			@resource.metadata.keys.should include( 'doodlebops', 'title' )
+			@resource.metadata.values.
+				should include( 'suck', 'Something Naughty That Jonathan Will Make Us Change' )
+		end
+	
+	
+		it "returns nil when asked for its data" do
+			@resource.data.should be_nil()
+		end
+
+		it "allows one to replace the metadata with something that can be cast to a Hash" do
+			my_metadata_class = Class.new {
+				def initialize( stuff={} )
+					@innerhash = stuff
+				end
+			
+				def to_hash
+					return @innerhash
+				end
+			}
+		
+			@resource.metadata = my_metadata_class.new( TEST_METADATA )
+
+			@resource.metadata.should have(6).members
+			@resource.metadata.keys.should include( 'doodlebops', 'title' )
+			@resource.metadata.values.
+				should include( 'suck', 'Something Naughty That Jonathan Will Make Us Change' )
+		end
+		
+		it "allows one to overwrite the resource's data" do
+			@resource.io.should be_nil()
+			@resource.data = "newdata"
+			@resource.io.rewind
+			@resource.io.read.should == "newdata"
+		end
+		
 	end
 
 
-
-	### Client interface
-	describe " -- client interface" do
-	
-		before( :each ) do
-			@io = StringIO.new( TEST_CONTENT )
-			@client = mock( "Mock client" )
-		end
+	### With an IO object
+	describe "created with an IO object" do
 		
-
-		it "uses its associated client to save changes to metadata" do
-			resource = ThingFish::Resource.new( @io, @client )
-			
-			@client.should_receive( :store ).with( resource ).
-				and_return( true )
-			
-			resource.save.should be_true()
-		end
-		
-		it "raises an error when saving without an associated ThingFish::Client" do
-			resource = ThingFish::Resource.new( @io )
-			
-			lambda { resource.save }.should raise_error( ThingFish::ResourceError, /no client/i )
-		end
-	
-	end
-
-	### User interface
-	describe " -- user interface" do
-
 		before( :each ) do
-			@io = StringIO.new( TEST_CONTENT )
-			@client = stub( "Mock client" )
+			@io = StringIO.new
+			@resource = ThingFish::Resource.new( @io )
 		end
 		
 
-		it "can be created from an object that responds to #read" do
-			resource = ThingFish::Resource.new( @io )
-			resource.io.should == @io
-			resource.client.should be_nil()
-			resource.uuid.should be_nil()
+		it "allows one to overwrite the resource's data" do
+			@resource.data = "newdata"
+			@resource.io.rewind
+			@resource.io.read.should == "newdata"
+		end
+
+	end
+
+
+	### Client interface
+	describe "created with an associated client object" do
+	
+		before( :each ) do
+			@resource = ThingFish::Resource.new( nil, @client, TEST_UUID )
 		end
 		
 
-		it "can be created from an IO object and a client object" do
-			resource = ThingFish::Resource.new( @io, @client )
-			resource.io.should == @io
-			resource.client.should == @client
-			resource.uuid.should be_nil()
-		end
+		it "uses its associated client to save changes" do
+			@resource.io = @io
+			@client.should_receive( :store_data ).with( @resource ).
+				and_return( @resource )
+			@client.should_receive( :store_metadata ).with( @resource ).
+				and_return( @resource )
 
-
-		it "can be create from a Hash of metadata" do
-			resource = ThingFish::Resource.new( :title => 'Piewicket Eats a Potato' )
-			resource.io.should be_nil()
-			resource.client.should be_nil()
-			resource.uuid.should be_nil()
-			resource.title.should == 'Piewicket Eats a Potato'
+			@resource.save.should == @resource
+		end
+		
+		
+		it "fetches an IO from the client on demand" do
+			@client.should_receive( :fetch_data ).with( TEST_UUID ).
+				and_return( :an_io )
+			@resource.io.should == :an_io
 		end
 		
 
-		it "can be created from an IO object and a Hash of metadata" do
-			resource = ThingFish::Resource.new( @io, :title => 'Piewicket Eats a Potato' )
-			resource.io.should == @io
-			resource.client.should be_nil()
-			resource.uuid.should be_nil()
-			resource.title.should == 'Piewicket Eats a Potato'
+		it "loads its data from the client on demand" do
+			io = mock( "mock io" )
+			@client.should_receive( :fetch_data ).with( TEST_UUID ).
+				and_return( io )
+			io.should_receive( :read ).and_return( :the_data )
+			@resource.data.should == :the_data
+		end
+		
+	end
+
+
+	describe "created with an IO and an associated client object" do
+		before( :each ) do
+			@resource = ThingFish::Resource.new( @io, @client, TEST_UUID )
+		end
+
+		it "can discard local metadata changes in favor of the server-side version" do
+ 			@client.should_receive( :fetch_metadata ).with( TEST_UUID ).twice.
+				and_return( TEST_METADATA.dup, TEST_METADATA.dup )
+			@resource.energy = 'legs'
+			@resource.revert
+
+			@resource.metadata.should == TEST_METADATA
+			@resource.metadata.should_not include( 'energy' )
 		end
 		
 		
-		it "can be created from an IO object, a client object, and a Hash of metadata" do
-			resource = ThingFish::Resource.new( @io, @client, :title => 'Piewicket Eats a Potato' )
-			resource.io.should == @io
-			resource.client.should == @client
-			resource.title.should == 'Piewicket Eats a Potato'
+		it "can discard local changes to the data in favor of the server-side version" do
+			@resource.revert
+			@client.should_receive( :fetch_data ).with( TEST_UUID ).
+				and_return( :a_new_io )
+			@resource.revert
+			@resource.io.should == :a_new_io
 		end
-
-
-		it "can be created from an IO object, a client object, a UUID, and a Hash of metadata" do
-			resource = ThingFish::Resource.new( @io, @client, TEST_UUID,
-			 	:title => 'Piewicket Eats a Potato' )
-			resource.io.should == @io
-			resource.client.should == @client
-			resource.uuid.should == TEST_UUID
-			resource.title.should == 'Piewicket Eats a Potato'
+	
+	
+		it "can read its data from the associated IO object" do
+			@io.should_receive( :read ).and_return( :the_data )
+			@resource.data.should == :the_data
 		end
-
+		
 	end
 
 end