basecamp 0.0.2

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