wuputah / basecamp-timereport
- Source
- Commits
- Network (0)
- Issues (0)
- Downloads (0)
- Wiki (1)
- Graphs
-
Branch:
master
basecamp-timereport / basecamp.rb
| cb8afd26 » | jdance | 2008-11-24 | 1 | require 'net/https' | |
| 2 | require 'yaml' | ||||
| 3 | require 'date' | ||||
| 4 | require 'time' | ||||
| 5 | |||||
| 6 | begin | ||||
| 7 | require 'xmlsimple' | ||||
| 8 | rescue LoadError | ||||
| 9 | begin | ||||
| 10 | require 'rubygems' | ||||
| 11 | require 'xmlsimple' | ||||
| 12 | rescue LoadError | ||||
| 13 | abort <<-ERROR | ||||
| 14 | The 'xml-simple' library could not be loaded. If you have RubyGems installed | ||||
| 15 | you can install xml-simple by doing "gem install xml-simple". | ||||
| 16 | ERROR | ||||
| 17 | end | ||||
| 18 | end | ||||
| 19 | |||||
| 20 | begin | ||||
| 21 | require 'activeresource' | ||||
| 22 | rescue LoadError | ||||
| 23 | begin | ||||
| 24 | require 'rubygems' | ||||
| 25 | require 'activeresource' | ||||
| 26 | rescue LoadError | ||||
| 27 | abort <<-ERROR | ||||
| 28 | The 'activeresource' library could not be loaded. If you have RubyGems | ||||
| 29 | installed you can install ActiveResource by doing "gem install activeresource". | ||||
| 30 | ERROR | ||||
| 31 | end | ||||
| 32 | end | ||||
| 33 | |||||
| 34 | # = A Ruby library for working with the Basecamp web-services API. | ||||
| 35 | # | ||||
| 36 | # For more information about the Basecamp web-services API, visit: | ||||
| 37 | # | ||||
| 38 | # http://developer.37signals.com/basecamp | ||||
| 39 | # | ||||
| 40 | # NOTE: not all of Basecamp's web-services are accessible via REST. This | ||||
| 41 | # library provides access to RESTful services via ActiveResource. Services not | ||||
| 42 | # yet upgraded to REST are accessed via the Basecamp class. Continue reading | ||||
| 43 | # for more details. | ||||
| 44 | # | ||||
| 45 | # | ||||
| 46 | # == Establishing a Connection | ||||
| 47 | # | ||||
| 48 | # The first thing you need to do is establish a connection to Basecamp. This | ||||
| 49 | # requires your Basecamp site address and your login credentials. Example: | ||||
| 50 | # | ||||
| 51 | # Basecamp.establish_connection!('you.grouphub.com', 'username', 'password') | ||||
| 52 | # | ||||
| 53 | # This is the same whether you're accessing using the ActiveResource interface, | ||||
| 54 | # or the legacy interface. | ||||
| 55 | # | ||||
| 56 | # | ||||
| 57 | # == Using the REST interface via ActiveResource | ||||
| 58 | # | ||||
| 59 | # The REST interface is accessed via ActiveResource, a popular Ruby library | ||||
| 60 | # that implements object-relational mapping for REST web-services. For more | ||||
| 61 | # information on working with ActiveResource, see: | ||||
| 62 | # | ||||
| 63 | # * http://api.rubyonrails.org/files/activeresource/README.html | ||||
| 64 | # * http://api.rubyonrails.org/classes/ActiveResource/Base.html | ||||
| 65 | # | ||||
| 66 | # === Finding a Resource | ||||
| 67 | # | ||||
| 68 | # Find a specific resource using the +find+ method. Attributes of the resource | ||||
| 69 | # are available as instance methods on the resulting object. For example, to | ||||
| 70 | # find a message with the ID of 8675309 and access its title attribute, you | ||||
| 71 | # would do the following: | ||||
| 72 | # | ||||
| 73 | # m = Basecamp::Message.find(8675309) | ||||
| 74 | # m.title # => 'Jenny' | ||||
| 75 | # | ||||
| 76 | # === Creating a Resource | ||||
| 77 | # | ||||
| 78 | # Create a resource by making a new instance of that resource, setting its | ||||
| 79 | # attributes, and saving it. If the resource requires a prefix to identify | ||||
| 80 | # it (as is the case with resources that belong to a sub-resource, such as a | ||||
| 81 | # project), it should be specified when instantiating the object. Examples: | ||||
| 82 | # | ||||
| 83 | # m = Basecamp::Message.new(:project_id => 1037) | ||||
| 84 | # m.category_id = 7301 | ||||
| 85 | # m.title = 'Message in a bottle' | ||||
| 86 | # m.body = 'Another lonely day, with no one here but me' | ||||
| 87 | # m.save # => true | ||||
| 88 | # | ||||
| 89 | # c = Basecamp::Comment.new(:post_id => 25874) | ||||
| 90 | # c.body = 'Did you get those TPS reports?' | ||||
| 91 | # c.save # => true | ||||
| 92 | # | ||||
| 93 | # You can also create a resource using the +create+ method, which will create | ||||
| 94 | # and save it in one step. Example: | ||||
| 95 | # | ||||
| 96 | # Basecamp::TodoItem.create(:todo_list_id => 3422, :contents => 'Do it') | ||||
| 97 | # | ||||
| 98 | # === Updating a Resource | ||||
| 99 | # | ||||
| 100 | # To update a resource, first find it by its id, change its attributes, and | ||||
| 101 | # save it. Example: | ||||
| 102 | # | ||||
| 103 | # m = Basecamp::Message.find(8675309) | ||||
| 104 | # m.body = 'Changed' | ||||
| 105 | # m.save # => true | ||||
| 106 | # | ||||
| 107 | # === Deleting a Resource | ||||
| 108 | # | ||||
| 109 | # To delete a resource, use the +delete+ method with the ID of the resource | ||||
| 110 | # you want to delete. Example: | ||||
| 111 | # | ||||
| 112 | # Basecamp::Message.delete(1037) | ||||
| 113 | # | ||||
| 114 | # === Attaching Files to a Resource | ||||
| 115 | # | ||||
| 116 | # If the resource accepts file attachments, the +attachments+ parameter should | ||||
| 117 | # be an array of Basecamp::Attachment objects. Example: | ||||
| 118 | # | ||||
| 119 | # a1 = Basecamp::Atachment.create('primary', File.read('primary.doc')) | ||||
| 120 | # a2 = Basecamp::Atachment.create('another', File.read('another.doc')) | ||||
| 121 | # | ||||
| 122 | # m = Basecamp::Message.new(:project_id => 1037) | ||||
| 123 | # ... | ||||
| 124 | # m.attachments = [a1, a2] | ||||
| 125 | # m.save # => true | ||||
| 126 | # | ||||
| 127 | # | ||||
| 128 | # = Using the non-REST inteface | ||||
| 129 | # | ||||
| 130 | # The non-REST interface is accessed via instance methods on the Basecamp | ||||
| 131 | # class. Ensure you've established a connection, then create a new Basecamp | ||||
| 132 | # instance and call methods on it. Examples: | ||||
| 133 | # | ||||
| 134 | # basecamp = Basecamp.new | ||||
| 135 | # | ||||
| 136 | # basecamp.projects.length # => 5 | ||||
| 137 | # basecamp.person(93832) # => #<Record(person)..> | ||||
| 138 | # basecamp.file_categories(123) # => [#<Record(file-category)>,#<Record..>] | ||||
| 139 | # | ||||
| 140 | ## Object attributes are accessible as methods. Example: | ||||
| 141 | # | ||||
| 142 | # person = basecamp.person(93832) | ||||
| 143 | # person.first_name # => "Jason" | ||||
| 144 | # | ||||
| 145 | class Basecamp | ||||
| 146 | class Connection #:nodoc: | ||||
| 147 | def initialize(master) | ||||
| 148 | @master = master | ||||
| 149 | @connection = Net::HTTP.new(master.site, master.use_ssl ? 443 : 80) | ||||
| 150 | @connection.use_ssl = master.use_ssl | ||||
| 151 | @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if master.use_ssl | ||||
| 152 | end | ||||
| 153 | |||||
| 154 | def post(path, body, headers = {}) | ||||
| 155 | request = Net::HTTP::Post.new(path, headers.merge('Accept' => 'application/xml')) | ||||
| 156 | request.basic_auth(@master.user, @master.password) | ||||
| 157 | @connection.request(request, body) | ||||
| 158 | end | ||||
| 159 | end | ||||
| 160 | |||||
| 161 | class Resource < ActiveResource::Base #:nodoc: | ||||
| 162 | class << self | ||||
| 163 | def parent_resources(*parents) | ||||
| 164 | @parent_resources = parents | ||||
| 165 | end | ||||
| 166 | |||||
| 167 | def element_name | ||||
| 168 | name.split(/::/).last.underscore | ||||
| 169 | end | ||||
| 170 | |||||
| 171 | def prefix_source | ||||
| 172 | @parent_resources.map { |resource| "/#{resource.to_s.pluralize}/:#{resource}_id/" }.join | ||||
| 173 | end | ||||
| 174 | |||||
| 175 | def prefix(options={}) | ||||
| 176 | if options.any? | ||||
| 177 | options.map { |name, value| "/#{name.to_s.chomp('_id').pluralize}/#{value}/" }.join | ||||
| 178 | else | ||||
| 179 | super | ||||
| 180 | end | ||||
| 181 | end | ||||
| 182 | end | ||||
| 183 | |||||
| 184 | def prefix_options | ||||
| 185 | id ? {} : super | ||||
| 186 | end | ||||
| 187 | end | ||||
| 188 | |||||
| 189 | class Project < Resource | ||||
| 190 | end | ||||
| 191 | |||||
| 192 | class Message < Resource | ||||
| 193 | parent_resources :project | ||||
| 194 | self.element_name = 'post' | ||||
| 195 | |||||
| 196 | # Returns the most recent 25 messages in the given project (and category, | ||||
| 197 | # if specified). If you need to retrieve older messages, use the archive | ||||
| 198 | # method instead. Example: | ||||
| 199 | # | ||||
| 200 | # Basecamp::Message.list(1037) | ||||
| 201 | # Basecamp::Message.list(1037, :category_id => 7301) | ||||
| 202 | # | ||||
| 203 | def self.list(project_id, options = {}) | ||||
| 204 | find(:all, :params => options.merge(:project_id => project_id)) | ||||
| 205 | end | ||||
| 206 | |||||
| 207 | # Returns a summary of all messages in the given project (and category, if | ||||
| 208 | # specified). The summary is simply the title and category of the message, | ||||
| 209 | # as well as the number of attachments (if any). Example: | ||||
| 210 | # | ||||
| 211 | # Basecamp::Message.archive(1037) | ||||
| 212 | # Basecamp::Message.archive(1037, :category_id => 7301) | ||||
| 213 | # | ||||
| 214 | def self.archive(project_id, options = {}) | ||||
| 215 | find(:all, :params => options.merge(:project_id => project_id), :from => :archive) | ||||
| 216 | end | ||||
| 217 | |||||
| 218 | def comments(options = {}) | ||||
| 219 | @comments ||= Comment.find(:all, :params => options.merge(:post_id => id)) | ||||
| 220 | end | ||||
| 221 | end | ||||
| 222 | |||||
| 223 | # == Creating Comments for Multiple Resources | ||||
| 224 | # | ||||
| 225 | # Comments can be created for messages, milestones, and to-dos, identified | ||||
| 226 | # by the <tt>post_id</tt>, <tt>milestone_id</tt>, and <tt>todo_item_id</tt> | ||||
| 227 | # params respectively. | ||||
| 228 | # | ||||
| 229 | # For example, to create a comment on the message with id #8675309: | ||||
| 230 | # | ||||
| 231 | # c = Basecamp::Comment.new(:post_id => 8675309) | ||||
| 232 | # c.body = 'Great tune' | ||||
| 233 | # c.save # => true | ||||
| 234 | # | ||||
| 235 | # Similarly, to create a comment on a milestone: | ||||
| 236 | # | ||||
| 237 | # c = Basecamp::Comment.new(:milestone_id => 8473647) | ||||
| 238 | # c.body = 'Is this done yet?' | ||||
| 239 | # c.save # => true | ||||
| 240 | # | ||||
| 241 | class Comment < Resource | ||||
| 242 | parent_resources :post, :milestone, :todo_item | ||||
| 243 | end | ||||
| 244 | |||||
| 245 | class TodoList < Resource | ||||
| 246 | parent_resources :project | ||||
| 247 | |||||
| 248 | # Returns all lists for a project. If complete is true, only completed lists | ||||
| 249 | # are returned. If complete is false, only uncompleted lists are returned. | ||||
| 250 | def self.all(project_id, complete=nil) | ||||
| 251 | filter = case complete | ||||
| 252 | when nil then "all" | ||||
| 253 | when true then "finished" | ||||
| 254 | when false then "pending" | ||||
| 255 | else raise ArgumentError, "invalid value for `complete'" | ||||
| 256 | end | ||||
| 257 | |||||
| 258 | find(:all, :params => { :project_id => project_id, :filter => filter }) | ||||
| 259 | end | ||||
| 260 | |||||
| 261 | def todo_items(options={}) | ||||
| 262 | @todo_items ||= TodoItem.find(:all, :params => options.merge(:todo_list_id => id)) | ||||
| 263 | end | ||||
| 264 | end | ||||
| 265 | |||||
| 266 | class TodoItem < Resource | ||||
| 267 | parent_resources :todo_list | ||||
| 268 | |||||
| 269 | def todo_list(options={}) | ||||
| 270 | @todo_list ||= TodoList.find(todo_list_id, options) | ||||
| 271 | end | ||||
| 272 | |||||
| 273 | def time_entries(options={}) | ||||
| 274 | @time_entries ||= TimeEntry.find(:all, :params => options.merge(:todo_item_id => id)) | ||||
| 275 | end | ||||
| 276 | |||||
| 277 | def comments(options = {}) | ||||
| 278 | @comments ||= Comment.find(:all, :params => options.merge(:todo_item_id => id)) | ||||
| 279 | end | ||||
| 280 | |||||
| 281 | def complete! | ||||
| 282 | put(:complete) | ||||
| 283 | end | ||||
| 284 | |||||
| 285 | def uncomplete! | ||||
| 286 | put(:uncomplete) | ||||
| 287 | end | ||||
| 288 | end | ||||
| 289 | |||||
| 290 | class TimeEntry < Resource | ||||
| 291 | parent_resources :project, :todo_item | ||||
| 292 | |||||
| 293 | def self.all(project_id, page=0) | ||||
| 294 | find(:all, :params => { :project_id => project_id, :page => page }) | ||||
| 295 | end | ||||
| 296 | |||||
| 297 | def self.report(options={}) | ||||
| 298 | find(:all, :from => :report, :params => options) | ||||
| 299 | end | ||||
| 300 | |||||
| 301 | def todo_item(options={}) | ||||
| 302 | @todo_item ||= todo_item_id && TodoItem.find(todo_item_id, options) | ||||
| 303 | end | ||||
| 304 | end | ||||
| 305 | |||||
| 306 | class Attachment | ||||
| 307 | attr_accessor :id, :filename, :content, :content_type | ||||
| 308 | |||||
| 309 | def self.create(filename, content) | ||||
| 310 | returning new(filename, content) do |attachment| | ||||
| 311 | attachment.save | ||||
| 312 | end | ||||
| 313 | end | ||||
| 314 | |||||
| 315 | def initialize(filename, content, content_type = 'application/octet-stream') | ||||
| 316 | @filename, @content, @content_type = filename, content, content_type | ||||
| 317 | end | ||||
| 318 | |||||
| 319 | def attributes | ||||
| 320 | { :file => id, :original_filename => filename, :content_type => content_type } | ||||
| 321 | end | ||||
| 322 | |||||
| 323 | def to_xml(options = {}) | ||||
| 324 | { :file => attributes }.to_xml(options) | ||||
| 325 | end | ||||
| 326 | |||||
| 327 | def inspect | ||||
| 328 | to_s | ||||
| 329 | end | ||||
| 330 | |||||
| 331 | def save | ||||
| 332 | response = Basecamp.connection.post('/upload', content, 'Content-Type' => content_type) | ||||
| 333 | |||||
| 334 | if response.code == '200' | ||||
| 335 | self.id = Hash.from_xml(response.body)['upload']['id'] | ||||
| 336 | true | ||||
| 337 | else | ||||
| 338 | raise "Could not save attachment: #{response.message} (#{response.code})" | ||||
| 339 | end | ||||
| 340 | end | ||||
| 341 | end | ||||
| 342 | |||||
| 343 | class Record #:nodoc: | ||||
| 344 | attr_reader :type | ||||
| 345 | |||||
| 346 | def initialize(type, hash) | ||||
| 347 | @type, @hash = type, hash | ||||
| 348 | end | ||||
| 349 | |||||
| 350 | def [](name) | ||||
| 351 | name = dashify(name) | ||||
| 352 | |||||
| 353 | case @hash[name] | ||||
| 354 | when Hash then | ||||
| 355 | @hash[name] = if (@hash[name].keys.length == 1 && @hash[name].values.first.is_a?(Array)) | ||||
| 356 | @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } | ||||
| 357 | else | ||||
| 358 | Record.new(name, @hash[name]) | ||||
| 359 | end | ||||
| 360 | else | ||||
| 361 | @hash[name] | ||||
| 362 | end | ||||
| 363 | end | ||||
| 364 | |||||
| 365 | def id | ||||
| 366 | @hash['id'] | ||||
| 367 | end | ||||
| 368 | |||||
| 369 | def attributes | ||||
| 370 | @hash.keys | ||||
| 371 | end | ||||
| 372 | |||||
| 373 | def respond_to?(sym) | ||||
| 374 | super || @hash.has_key?(dashify(sym)) | ||||
| 375 | end | ||||
| 376 | |||||
| 377 | def method_missing(sym, *args) | ||||
| 378 | if args.empty? && !block_given? && respond_to?(sym) | ||||
| 379 | self[sym] | ||||
| 380 | else | ||||
| 381 | super | ||||
| 382 | end | ||||
| 383 | end | ||||
| 384 | |||||
| 385 | def to_s | ||||
| 386 | "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>" | ||||
| 387 | end | ||||
| 388 | |||||
| 389 | def inspect | ||||
| 390 | to_s | ||||
| 391 | end | ||||
| 392 | |||||
| 393 | private | ||||
| 394 | |||||
| 395 | def dashify(name) | ||||
| 396 | name.to_s.tr("_", "-") | ||||
| 397 | end | ||||
| 398 | end | ||||
| 399 | |||||
| 400 | attr_accessor :use_xml | ||||
| 401 | |||||
| 402 | class << self | ||||
| 403 | attr_reader :site, :user, :password, :use_ssl | ||||
| 404 | |||||
| 405 | def establish_connection!(site, user, password, use_ssl = false) | ||||
| 406 | @site = site | ||||
| 407 | @user = user | ||||
| 408 | @password = password | ||||
| 409 | @use_ssl = use_ssl | ||||
| 410 | |||||
| 411 | Resource.user = user | ||||
| 412 | Resource.password = password | ||||
| 413 | Resource.site = (use_ssl ? "https" : "http") + "://" + site | ||||
| 414 | |||||
| 415 | @connection = Connection.new(self) | ||||
| 416 | end | ||||
| 417 | |||||
| 418 | def connection | ||||
| 419 | @connection || raise('No connection established') | ||||
| 420 | end | ||||
| 421 | end | ||||
| 422 | |||||
| 423 | def initialize | ||||
| 424 | @use_xml = false | ||||
| 425 | end | ||||
| 426 | |||||
| 427 | # ========================================================================== | ||||
| 428 | # GENERAL | ||||
| 429 | # ========================================================================== | ||||
| 430 | |||||
| 431 | # Return the list of all accessible projects | ||||
| 432 | def projects | ||||
| 433 | records "project", "/project/list" | ||||
| 434 | end | ||||
| 435 | |||||
| 436 | # Returns the list of message categories for the given project | ||||
| 437 | def message_categories(project_id) | ||||
| 438 | records "post-category", "/projects/#{project_id}/post_categories" | ||||
| 439 | end | ||||
| 440 | |||||
| 441 | # Returns the list of file categories for the given project | ||||
| 442 | def file_categories(project_id) | ||||
| 443 | records "attachment-category", "/projects/#{project_id}/attachment_categories" | ||||
| 444 | end | ||||
| 445 | |||||
| 446 | # ========================================================================== | ||||
| 447 | # CONTACT MANAGEMENT | ||||
| 448 | # ========================================================================== | ||||
| 449 | |||||
| 450 | # Return information for the company with the given id | ||||
| 451 | def company(id) | ||||
| 452 | record "/contacts/company/#{id}" | ||||
| 453 | end | ||||
| 454 | |||||
| 455 | # Return an array of the people in the given company. If the project-id is | ||||
| 456 | # given, only people who have access to the given project will be returned. | ||||
| 457 | def people(company_id, project_id=nil) | ||||
| 458 | url = project_id ? "/projects/#{project_id}" : "" | ||||
| 459 | url << "/contacts/people/#{company_id}" | ||||
| 460 | records "person", url | ||||
| 461 | end | ||||
| 462 | |||||
| 463 | # Return information about the person with the given id | ||||
| 464 | def person(id) | ||||
| 465 | record "/contacts/person/#{id}" | ||||
| 466 | end | ||||
| 467 | |||||
| 468 | # ========================================================================== | ||||
| 469 | # MILESTONES | ||||
| 470 | # ========================================================================== | ||||
| 471 | |||||
| 472 | # Complete the milestone with the given id | ||||
| 473 | def complete_milestone(id) | ||||
| 474 | record "/milestones/complete/#{id}" | ||||
| 475 | end | ||||
| 476 | |||||
| 477 | # Create a new milestone for the given project. +data+ must be hash of the | ||||
| 478 | # values to set, including +title+, +deadline+, +responsible_party+, and | ||||
| 479 | # +notify+. | ||||
| 480 | def create_milestone(project_id, data) | ||||
| 481 | create_milestones(project_id, [data]).first | ||||
| 482 | end | ||||
| 483 | |||||
| 484 | # As #create_milestone, but can create multiple milestones in a single | ||||
| 485 | # request. The +milestones+ parameter must be an array of milestone values as | ||||
| 486 | # descrbed in #create_milestone. | ||||
| 487 | def create_milestones(project_id, milestones) | ||||
| 488 | records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones | ||||
| 489 | end | ||||
| 490 | |||||
| 491 | # Destroys the milestone with the given id. | ||||
| 492 | def delete_milestone(id) | ||||
| 493 | record "/milestones/delete/#{id}" | ||||
| 494 | end | ||||
| 495 | |||||
| 496 | # Returns a list of all milestones for the given project, optionally filtered | ||||
| 497 | # by whether they are completed, late, or upcoming. | ||||
| 498 | def milestones(project_id, find="all") | ||||
| 499 | records "milestone", "/projects/#{project_id}/milestones/list", :find => find | ||||
| 500 | end | ||||
| 501 | |||||
| 502 | # Uncomplete the milestone with the given id | ||||
| 503 | def uncomplete_milestone(id) | ||||
| 504 | record "/milestones/uncomplete/#{id}" | ||||
| 505 | end | ||||
| 506 | |||||
| 507 | # Updates an existing milestone. | ||||
| 508 | def update_milestone(id, data, move=false, move_off_weekends=false) | ||||
| 509 | record "/milestones/update/#{id}", :milestone => data, | ||||
| 510 | :move_upcoming_milestones => move, | ||||
| 511 | :move_upcoming_milestones_off_weekends => move_off_weekends | ||||
| 512 | end | ||||
| 513 | |||||
| 514 | private | ||||
| 515 | |||||
| 516 | # Make a raw web-service request to Basecamp. This will return a Hash of | ||||
| 517 | # Arrays of the response, and may seem a little odd to the uninitiated. | ||||
| 518 | def request(path, parameters = {}) | ||||
| 519 | response = Basecamp.connection.post(path, convert_body(parameters), "Content-Type" => content_type) | ||||
| 520 | |||||
| 521 | if response.code.to_i / 100 == 2 | ||||
| 522 | result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'contentkey' => '__content__', 'forcecontent' => true) | ||||
| 523 | typecast_value(result) | ||||
| 524 | else | ||||
| 525 | raise "#{response.message} (#{response.code})" | ||||
| 526 | end | ||||
| 527 | end | ||||
| 528 | |||||
| 529 | # A convenience method for wrapping the result of a query in a Record | ||||
| 530 | # object. This assumes that the result is a singleton, not a collection. | ||||
| 531 | def record(path, parameters={}) | ||||
| 532 | result = request(path, parameters) | ||||
| 533 | (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil | ||||
| 534 | end | ||||
| 535 | |||||
| 536 | # A convenience method for wrapping the result of a query in Record | ||||
| 537 | # objects. This assumes that the result is a collection--any singleton | ||||
| 538 | # result will be wrapped in an array. | ||||
| 539 | def records(node, path, parameters={}) | ||||
| 540 | result = request(path, parameters).values.first or return [] | ||||
| 541 | result = result[node] or return [] | ||||
| 542 | result = [result] unless Array === result | ||||
| 543 | result.map { |row| Record.new(node, row) } | ||||
| 544 | end | ||||
| 545 | |||||
| 546 | def convert_body(body) | ||||
| 547 | body = use_xml ? body.to_legacy_xml : body.to_yaml | ||||
| 548 | end | ||||
| 549 | |||||
| 550 | def content_type | ||||
| 551 | use_xml ? "application/xml" : "application/x-yaml" | ||||
| 552 | end | ||||
| 553 | |||||
| 554 | def typecast_value(value) | ||||
| 555 | case value | ||||
| 556 | when Hash | ||||
| 557 | if value.has_key?("__content__") | ||||
| 558 | content = translate_entities(value["__content__"]).strip | ||||
| 559 | case value["type"] | ||||
| 560 | when "integer" then content.to_i | ||||
| 561 | when "boolean" then content == "true" | ||||
| 562 | when "datetime" then Time.parse(content) | ||||
| 563 | when "date" then Date.parse(content) | ||||
| 564 | else content | ||||
| 565 | end | ||||
| 566 | # a special case to work-around a bug in XmlSimple. When you have an empty | ||||
| 567 | # tag that has an attribute, XmlSimple will not add the __content__ key | ||||
| 568 | # to the returned hash. Thus, we check for the presense of the 'type' | ||||
| 569 | # attribute to look for empty, typed tags, and simply return nil for | ||||
| 570 | # their value. | ||||
| 571 | elsif value.keys == %w(type) | ||||
| 572 | nil | ||||
| 573 | elsif value["nil"] == "true" | ||||
| 574 | nil | ||||
| 575 | # another special case, introduced by the latest rails, where an array | ||||
| 576 | # type now exists. This is parsed by XmlSimple as a two-key hash, where | ||||
| 577 | # one key is 'type' and the other is the actual array value. | ||||
| 578 | elsif value.keys.length == 2 && value["type"] == "array" | ||||
| 579 | value.delete("type") | ||||
| 580 | typecast_value(value) | ||||
| 581 | else | ||||
| 582 | value.empty? ? nil : value.inject({}) do |h,(k,v)| | ||||
| 583 | h[k] = typecast_value(v) | ||||
| 584 | h | ||||
| 585 | end | ||||
| 586 | end | ||||
| 587 | when Array | ||||
| 588 | value.map! { |i| typecast_value(i) } | ||||
| 589 | case value.length | ||||
| 590 | when 0 then nil | ||||
| 591 | when 1 then value.first | ||||
| 592 | else value | ||||
| 593 | end | ||||
| 594 | else | ||||
| 595 | raise "can't typecast #{value.inspect}" | ||||
| 596 | end | ||||
| 597 | end | ||||
| 598 | |||||
| 599 | def translate_entities(value) | ||||
| 600 | value.gsub(/</, "<"). | ||||
| 601 | gsub(/>/, ">"). | ||||
| 602 | gsub(/"/, '"'). | ||||
| 603 | gsub(/'/, "'"). | ||||
| 604 | gsub(/&/, "&") | ||||
| 605 | end | ||||
| 606 | end | ||||
| 607 | |||||
| 608 | # A minor hack to let Xml-Simple serialize symbolic keys in hashes | ||||
| 609 | class Symbol | ||||
| 610 | def [](*args) | ||||
| 611 | to_s[*args] | ||||
| 612 | end | ||||
| 613 | end | ||||
| 614 | |||||
| 615 | class Hash | ||||
| 616 | def to_legacy_xml | ||||
| 617 | XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true) | ||||
| 618 | end | ||||
| 619 | end | ||||
