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