Merge branch 'master' into 3408-production-datamanager
[arvados.git] / sdk / ruby / lib / arvados.rb
1 require 'rubygems'
2 require 'active_support/inflector'
3 require 'json'
4 require 'fileutils'
5 require 'andand'
6
7 require 'arvados/google_api_client'
8
9 ActiveSupport::Inflector.inflections do |inflect|
10   inflect.irregular 'specimen', 'specimens'
11   inflect.irregular 'human', 'humans'
12 end
13
14 module Kernel
15   def suppress_warnings
16     original_verbosity = $VERBOSE
17     $VERBOSE = nil
18     result = yield
19     $VERBOSE = original_verbosity
20     return result
21   end
22 end
23
24 class Arvados
25
26   class TransactionFailedError < StandardError
27   end
28
29   @@config = nil
30   @@debuglevel = 0
31   class << self
32     attr_accessor :debuglevel
33   end
34
35   def initialize(opts={})
36     @application_version ||= 0.0
37     @application_name ||= File.split($0).last
38
39     @arvados_api_version = opts[:api_version] || 'v1'
40
41     @arvados_api_host = opts[:api_host] ||
42       config['ARVADOS_API_HOST'] or
43       raise "#{$0}: no :api_host or ENV[ARVADOS_API_HOST] provided."
44     @arvados_api_token = opts[:api_token] ||
45       config['ARVADOS_API_TOKEN'] or
46       raise "#{$0}: no :api_token or ENV[ARVADOS_API_TOKEN] provided."
47
48     if (opts[:suppress_ssl_warnings] or
49         %w(1 true yes).index(config['ARVADOS_API_HOST_INSECURE'].
50                              andand.downcase))
51       suppress_warnings do
52         OpenSSL::SSL.const_set 'VERIFY_PEER', OpenSSL::SSL::VERIFY_NONE
53       end
54     end
55
56     # Define a class and an Arvados instance method for each Arvados
57     # resource. After this, self.job will return Arvados::Job;
58     # self.job.new() and self.job.find() will do what you want.
59     _arvados = self
60     namespace_class = Arvados.const_set "A#{self.object_id}", Class.new
61     self.arvados_api.schemas.each do |classname, schema|
62       next if classname.match /List$/
63       klass = Class.new(Arvados::Model) do
64         def self.arvados
65           @arvados
66         end
67         def self.api_models_sym
68           @api_models_sym
69         end
70         def self.api_model_sym
71           @api_model_sym
72         end
73       end
74
75       # Define the resource methods (create, get, update, delete, ...)
76       self.
77         arvados_api.
78         send(classname.underscore.split('/').last.pluralize.to_sym).
79         discovered_methods.
80         each do |method|
81         class << klass; self; end.class_eval do
82           define_method method.name do |*params|
83             self.api_exec method, *params
84           end
85         end
86       end
87
88       # Give the new class access to the API
89       klass.instance_eval do
90         @arvados = _arvados
91         # TODO: Pull these from the discovery document instead.
92         @api_models_sym = classname.underscore.split('/').last.pluralize.to_sym
93         @api_model_sym = classname.underscore.split('/').last.to_sym
94       end
95
96       # Create the new class in namespace_class so it doesn't
97       # interfere with classes created by other Arvados objects. The
98       # result looks like Arvados::A26949680::Job.
99       namespace_class.const_set classname, klass
100
101       self.class.class_eval do
102         define_method classname.underscore do
103           klass
104         end
105       end
106     end
107   end
108
109   def client
110     @client ||= Google::APIClient.
111       new(:host => @arvados_api_host,
112           :application_name => @application_name,
113           :application_version => @application_version.to_s)
114   end
115
116   def arvados_api
117     @arvados_api ||= self.client.discovered_api('arvados', @arvados_api_version)
118   end
119
120   def self.debuglog(message, verbosity=1)
121     $stderr.puts "#{File.split($0).last} #{$$}: #{message}" if @@debuglevel >= verbosity
122   end
123
124   def debuglog *args
125     self.class.debuglog *args
126   end
127
128   def config(config_file_path="~/.config/arvados/settings.conf")
129     return @@config if @@config
130
131     # Initialize config settings with environment variables.
132     config = {}
133     config['ARVADOS_API_HOST']          = ENV['ARVADOS_API_HOST']
134     config['ARVADOS_API_TOKEN']         = ENV['ARVADOS_API_TOKEN']
135     config['ARVADOS_API_HOST_INSECURE'] = ENV['ARVADOS_API_HOST_INSECURE']
136
137     if config['ARVADOS_API_HOST'] and config['ARVADOS_API_TOKEN']
138       # Environment variables take precedence over the config file, so
139       # there is no point reading the config file. If the environment
140       # specifies a _HOST without asking for _INSECURE, we certainly
141       # shouldn't give the config file a chance to create a
142       # system-wide _INSECURE state for this user.
143       #
144       # Note: If we start using additional configuration settings from
145       # this file in the future, we might have to read the file anyway
146       # instead of returning here.
147       return (@@config = config)
148     end
149
150     begin
151       expanded_path = File.expand_path config_file_path
152       if File.exist? expanded_path
153         # Load settings from the config file.
154         lineno = 0
155         File.open(expanded_path).each do |line|
156           lineno = lineno + 1
157           # skip comments and blank lines
158           next if line.match('^\s*#') or not line.match('\S')
159           var, val = line.chomp.split('=', 2)
160           var.strip!
161           val.strip!
162           # allow environment settings to override config files.
163           if !var.empty? and val
164             config[var] ||= val
165           else
166             debuglog "#{expanded_path}: #{lineno}: could not parse `#{line}'", 0
167           end
168         end
169       end
170     rescue StandardError => e
171       debuglog "Ignoring error reading #{config_file_path}: #{e}", 0
172     end
173
174     @@config = config
175   end
176
177   class Model
178     def self.arvados_api
179       arvados.arvados_api
180     end
181     def self.client
182       arvados.client
183     end
184     def self.debuglog(*args)
185       arvados.class.debuglog *args
186     end
187     def debuglog(*args)
188       self.class.arvados.class.debuglog *args
189     end
190     def self.api_exec(method, parameters={})
191       api_method = arvados_api.send(api_models_sym).send(method.name.to_sym)
192       parameters.each do |k,v|
193         parameters[k] = v.to_json if v.is_a? Array or v.is_a? Hash
194       end
195       # Look for objects expected by request.properties.(key).$ref and
196       # move them from parameters (query string) to request body.
197       body = nil
198       method.discovery_document['request'].
199         andand['properties'].
200         andand.each do |k,v|
201         if v.is_a? Hash and v['$ref']
202           body ||= {}
203           body[k] = parameters.delete k.to_sym
204         end
205       end
206       result = client.
207         execute(:api_method => api_method,
208                 :authenticated => false,
209                 :parameters => parameters,
210                 :body_object => body,
211                 :headers => {
212                   authorization: 'OAuth2 '+arvados.config['ARVADOS_API_TOKEN']
213                 })
214       resp = JSON.parse result.body, :symbolize_names => true
215       if resp[:errors]
216         raise Arvados::TransactionFailedError.new(resp[:errors])
217       elsif resp[:uuid] and resp[:etag]
218         self.new(resp)
219       elsif resp[:items].is_a? Array
220         resp.merge(items: resp[:items].collect do |i|
221                      self.new(i)
222                    end)
223       else
224         resp
225       end
226     end
227
228     def []=(x,y)
229       @attributes_to_update[x] = y
230       @attributes[x] = y
231     end
232     def [](x)
233       if @attributes[x].is_a? Hash or @attributes[x].is_a? Array
234         # We won't be notified via []= if these change, so we'll just
235         # assume they are going to get changed, and submit them if
236         # save() is called.
237         @attributes_to_update[x] = @attributes[x]
238       end
239       @attributes[x]
240     end
241     def save
242       @attributes_to_update.keys.each do |k|
243         @attributes_to_update[k] = @attributes[k]
244       end
245       j = self.class.api_exec :update, {
246         :uuid => @attributes[:uuid],
247         self.class.api_model_sym => @attributes_to_update.to_json
248       }
249       unless j.respond_to? :[] and j[:uuid]
250         debuglog "Failed to save #{self.to_s}: #{j[:errors] rescue nil}", 0
251         nil
252       else
253         @attributes_to_update = {}
254         @attributes = j
255       end
256     end
257
258     protected
259
260     def initialize(j)
261       @attributes_to_update = {}
262       @attributes = j
263     end
264   end
265 end