jira-ruby 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (82) hide show
  1. data/README.rdoc +259 -0
  2. data/Rakefile +9 -0
  3. data/example.rb +47 -4
  4. data/jira-ruby.gemspec +5 -3
  5. data/lib/jira.rb +21 -7
  6. data/lib/jira/base.rb +466 -0
  7. data/lib/jira/base_factory.rb +49 -0
  8. data/lib/jira/client.rb +79 -8
  9. data/lib/jira/has_many_proxy.rb +43 -0
  10. data/lib/jira/http_error.rb +16 -0
  11. data/lib/jira/resource/attachment.rb +12 -0
  12. data/lib/jira/resource/comment.rb +14 -0
  13. data/lib/jira/resource/component.rb +4 -9
  14. data/lib/jira/resource/issue.rb +49 -5
  15. data/lib/jira/resource/issuetype.rb +10 -0
  16. data/lib/jira/resource/priority.rb +10 -0
  17. data/lib/jira/resource/project.rb +24 -3
  18. data/lib/jira/resource/status.rb +10 -0
  19. data/lib/jira/resource/user.rb +14 -0
  20. data/lib/jira/resource/version.rb +10 -0
  21. data/lib/jira/resource/worklog.rb +16 -0
  22. data/lib/jira/version.rb +2 -2
  23. data/spec/integration/attachment_spec.rb +26 -0
  24. data/spec/integration/comment_spec.rb +55 -0
  25. data/spec/integration/component_spec.rb +25 -52
  26. data/spec/integration/issue_spec.rb +50 -47
  27. data/spec/integration/issuetype_spec.rb +27 -0
  28. data/spec/integration/priority_spec.rb +27 -0
  29. data/spec/integration/project_spec.rb +32 -24
  30. data/spec/integration/status_spec.rb +27 -0
  31. data/spec/integration/user_spec.rb +25 -0
  32. data/spec/integration/version_spec.rb +43 -0
  33. data/spec/integration/worklog_spec.rb +55 -0
  34. data/spec/jira/base_factory_spec.rb +46 -0
  35. data/spec/jira/base_spec.rb +555 -0
  36. data/spec/jira/client_spec.rb +12 -12
  37. data/spec/jira/has_many_proxy_spec.rb +45 -0
  38. data/spec/jira/{resource/http_error_spec.rb → http_error_spec.rb} +1 -1
  39. data/spec/jira/resource/attachment_spec.rb +20 -0
  40. data/spec/jira/resource/issue_spec.rb +83 -0
  41. data/spec/jira/resource/project_factory_spec.rb +3 -3
  42. data/spec/jira/resource/project_spec.rb +28 -0
  43. data/spec/jira/resource/worklog_spec.rb +24 -0
  44. data/spec/mock_responses/attachment/10000.json +20 -0
  45. data/spec/mock_responses/component/10000.invalid.put.json +5 -0
  46. data/spec/mock_responses/issue.json +1108 -0
  47. data/spec/mock_responses/issue/10002.invalid.put.json +6 -0
  48. data/spec/mock_responses/issue/10002.json +13 -1
  49. data/spec/mock_responses/issue/10002.put.missing_field_update.json +6 -0
  50. data/spec/mock_responses/issue/10002/comment.json +65 -0
  51. data/spec/mock_responses/issue/10002/comment.post.json +29 -0
  52. data/spec/mock_responses/issue/10002/comment/10000.json +29 -0
  53. data/spec/mock_responses/issue/10002/comment/10000.put.json +29 -0
  54. data/spec/mock_responses/issue/10002/worklog.json +98 -0
  55. data/spec/mock_responses/issue/10002/worklog.post.json +30 -0
  56. data/spec/mock_responses/issue/10002/worklog/10000.json +31 -0
  57. data/spec/mock_responses/issue/10002/worklog/10000.put.json +30 -0
  58. data/spec/mock_responses/issuetype.json +42 -0
  59. data/spec/mock_responses/issuetype/5.json +8 -0
  60. data/spec/mock_responses/priority.json +42 -0
  61. data/spec/mock_responses/priority/1.json +8 -0
  62. data/spec/mock_responses/project/SAMPLEPROJECT.issues.json +1108 -0
  63. data/spec/mock_responses/project/SAMPLEPROJECT.json +15 -1
  64. data/spec/mock_responses/status.json +37 -0
  65. data/spec/mock_responses/status/1.json +7 -0
  66. data/spec/mock_responses/user?username=admin.json +17 -0
  67. data/spec/mock_responses/version.post.json +7 -0
  68. data/spec/mock_responses/version/10000.invalid.put.json +5 -0
  69. data/spec/mock_responses/version/10000.json +11 -0
  70. data/spec/mock_responses/version/10000.put.json +7 -0
  71. data/spec/spec_helper.rb +7 -12
  72. data/spec/support/matchers/have_attributes.rb +11 -0
  73. data/spec/support/matchers/have_many.rb +9 -0
  74. data/spec/support/matchers/have_one.rb +5 -0
  75. data/spec/support/shared_examples/integration.rb +174 -0
  76. metadata +139 -24
  77. data/README.markdown +0 -81
  78. data/lib/jira/resource/base.rb +0 -148
  79. data/lib/jira/resource/base_factory.rb +0 -44
  80. data/lib/jira/resource/http_error.rb +0 -17
  81. data/spec/jira/resource/base_factory_spec.rb +0 -36
  82. data/spec/jira/resource/base_spec.rb +0 -292
data/README.rdoc ADDED
@@ -0,0 +1,259 @@
1
+ = JIRA 5 API Gem
2
+
3
+ This gem provides access to the Atlassian JIRA version 5 REST API (a.k.a REST
4
+ API version 2).
5
+
6
+ == Example usage
7
+
8
+ client = JIRA::Client.new(CONSUMER_KEY, CONSUMER_SECRET)
9
+
10
+ project = client.Project.find('SAMPLEPROJECT')
11
+
12
+ project.issues.each do |issue|
13
+ puts "#{issue.id} - #{issue.summary}"
14
+ end
15
+
16
+ issue.comments.each {|comment| ... }
17
+
18
+ comment = issue.comments.build({'body':'My new comment'})
19
+ comment.save
20
+ comment.delete
21
+
22
+ == Links to JIRA REST API documentation
23
+
24
+ * {Overview}[https://developer.atlassian.com/display/JIRADEV/JIRA+REST+APIs]
25
+ * {Reference}[http://docs.atlassian.com/jira/REST/5.0-rc1/]
26
+
27
+
28
+ == Setting up the JIRA SDK
29
+
30
+ On Mac OS,
31
+
32
+ brew install atlassian-plugin-sdk
33
+
34
+ Otherwise:
35
+
36
+ * Download the SDK from https://developer.atlassian.com/ (You will need
37
+ an Atlassian login for this)
38
+ * Unpack the dowloaded archive
39
+ * From within the archive directory, run:
40
+
41
+ ./bin/atlas-run-standalone --product jira --version 5.0-rc2
42
+
43
+ Once this is running, you should be able to connect to
44
+ http://localhost:2990/ and login to the JIRA admin system using `admin:admin`
45
+
46
+ You'll need to create a dummy project and probably some issues to test using
47
+ this library.
48
+
49
+ == Configuring JIRA to use OAuth
50
+
51
+ From the JIRA API tutorial
52
+
53
+ The first step is to register a new consumer in JIRA. This is done through
54
+ the Application Links administration screens in JIRA. Create a new
55
+ Application Link.
56
+ {Administration/Plugins/Application Links}[http://localhost:2990/jira/plugins/servlet/applinks/listApplicationLinks]
57
+
58
+ When creating the Application Link use a placeholder URL or the correct URL
59
+ to your client (e.g. `http://localhost:3000`), if your client can be reached
60
+ via HTTP and choose the Generic Application type. After this Application Link
61
+ has been created, edit the configuration and go to the incoming
62
+ authentication configuration screen and select OAuth. Enter in this the
63
+ public key and the consumer key which your client will use when making
64
+ requests to JIRA.
65
+
66
+ This public key and consumer key will need to be generated by the Gem user, using OpenSSL
67
+ or similar to generate the public key and the provided rake task to generate the consumer
68
+ key.
69
+
70
+ After you have entered all the information click OK and ensure OAuth authentication is
71
+ enabled.
72
+
73
+ == Using the API Gem in your Rails application
74
+
75
+ The gem requires the consumer key and public certificate file (which
76
+ are generated in their respective rake tasks) to initialize an access token for
77
+ using the JIRA API.
78
+
79
+ Below is an example for setting up a rails application for OAuth authorization.
80
+
81
+ # app/controllers/application_controller.rb
82
+ class ApplicationController < ActionController::Base
83
+ protect_from_forgery
84
+
85
+ rescue_from JIRA::Client::UninitializedAccessTokenError do
86
+ redirect_to new_jira_session_url
87
+ end
88
+
89
+ private
90
+
91
+ def get_jira_client
92
+ options = {
93
+ :private_key_file => "rsakey.pem"
94
+ }
95
+ @jira_client = JIRA::Client.new('test', '', options)
96
+
97
+ # Add AccessToken if authorised previously.
98
+ if session[:jira_auth]
99
+ @jira_client.set_access_token(
100
+ session[:jira_auth][:access_token],
101
+ session[:jira_auth][:access_key]
102
+ )
103
+ end
104
+ end
105
+ end
106
+
107
+ Create a controller for handling the OAuth conversation.
108
+
109
+ # app/controllers/jira_sessions_controller.rb
110
+ require 'jira'
111
+
112
+ class JiraSessionsController < ApplicationController
113
+
114
+ before_filter :get_jira_client
115
+
116
+ def new
117
+ request_token = @jira_client.request_token
118
+ session[:request_token] = request_token.token
119
+ session[:request_secret] = request_token.secret
120
+
121
+ redirect_to request_token.authorize_url
122
+ end
123
+
124
+ def authorize
125
+ request_token = @jira_client.set_request_token(
126
+ session[:request_token], session[:request_secret]
127
+ )
128
+ access_token = @jira_client.init_access_token(
129
+ :oauth_verifier => params[:oauth_verifier]
130
+ )
131
+
132
+ session[:jira_auth] = {
133
+ :access_token => access_token.token,
134
+ :access_key => access_token.secret
135
+ }
136
+
137
+ session.delete(:request_token)
138
+ session.delete(:request_secret)
139
+
140
+ redirect_to projects_path
141
+ end
142
+
143
+ def destroy
144
+ session.data.delete(:jira_auth)
145
+ end
146
+ end
147
+
148
+ Create your own controllers for the JIRA resources you wish to access.
149
+
150
+ # app/controllers/issues_controller.rb
151
+ class IssuesController < ApplicationController
152
+ before_filter :get_jira_client
153
+ def index
154
+ @issues = @jira_client.Issue.all
155
+ end
156
+
157
+ def show
158
+ @issue = @jira_client.Issue.find(params[:id])
159
+ end
160
+ end
161
+
162
+ == Using the API Gem in your Sinatra application
163
+
164
+ Here's the same example as a Sinatra application:
165
+
166
+ require 'jira'
167
+ class App < Sinatra::Base
168
+ enable :sessions
169
+
170
+ # This section gets called before every request. Here, we set up the
171
+ # OAuth consumer details including the consumer key, private key,
172
+ # site uri, and the request token, access token, and authorize paths
173
+ before do
174
+ options = {
175
+ :site => 'http://localhost:2990',
176
+ :signature_method => 'RSA-SHA1',
177
+ :request_token_path => "/jira/plugins/servlet/oauth/request-token",
178
+ :authorize_path => "/jira/plugins/servlet/oauth/authorize",
179
+ :access_token_path => "/jira/plugins/servlet/oauth/access-token",
180
+ :private_key_file => "rsakey.pem",
181
+ :rest_base_path => "/jira/rest/api/2"
182
+ }
183
+
184
+ @jira_client = JIRA::Client.new('jira-ruby-example', '', options)
185
+ @jira_client.consumer.http.set_debug_output($stderr)
186
+
187
+ # Add AccessToken if authorised previously.
188
+ if session[:jira_auth]
189
+ @jira_client.set_access_token(
190
+ session[:jira_auth][:access_token],
191
+ session[:jira_auth][:access_key]
192
+ )
193
+ end
194
+ end
195
+
196
+
197
+ # Starting point: http://<yourserver>/
198
+ # This will serve up a login link if you're not logged in. If you are, it'll show some user info and a
199
+ # signout link
200
+ get '/' do
201
+ if !session[:jira_auth]
202
+ # not logged in
203
+ <<-eos
204
+ <h1>jira-ruby (JIRA 5 Ruby Gem) demo </h1>You're not signed in. Why don't you
205
+ <a href=/signin>sign in</a> first.
206
+ eos
207
+ else
208
+ #logged in
209
+ @issues = @jira_client.Issue.all
210
+
211
+ # HTTP response inlined with bind data below...
212
+ <<-eos
213
+ You're now signed in. There #{@issues.count == 1 ? "is" : "are"} #{@issues.count}
214
+ issue#{@issues.count == 1 ? "" : "s"} in this JIRA instance. <a href='/signout'>Signout</a>
215
+ eos
216
+ end
217
+ end
218
+
219
+ # http://<yourserver>/signin
220
+ # Initiates the OAuth dance by first requesting a token then redirecting to
221
+ # http://<yourserver>/auth to get the @access_token
222
+ get '/signin' do
223
+ request_token = @jira_client.request_token
224
+ session[:request_token] = request_token.token
225
+ session[:request_secret] = request_token.secret
226
+
227
+ redirect request_token.authorize_url
228
+ end
229
+
230
+ # http://<yourserver>/callback
231
+ # Retrieves the @access_token then stores it inside a session cookie. In a real app,
232
+ # you'll want to persist the token in a datastore associated with the user.
233
+ get "/callback/" do
234
+ request_token = @jira_client.set_request_token(
235
+ session[:request_token], session[:request_secret]
236
+ )
237
+ access_token = @jira_client.init_access_token(
238
+ :oauth_verifier => params[:oauth_verifier]
239
+ )
240
+
241
+ session[:jira_auth] = {
242
+ :access_token => access_token.token,
243
+ :access_key => access_token.secret
244
+ }
245
+
246
+ session.delete(:request_token)
247
+ session.delete(:request_secret)
248
+
249
+ redirect "/"
250
+ end
251
+
252
+ # http://<yourserver>/signout
253
+ # Expires the session
254
+ get "/signout" do
255
+ session.delete(:jira_auth)
256
+ redirect "/"
257
+ end
258
+ end
259
+
data/Rakefile CHANGED
@@ -2,8 +2,17 @@ require "bundler/gem_tasks"
2
2
 
3
3
  require 'rubygems'
4
4
  require 'rspec/core/rake_task'
5
+ require 'rake/rdoctask'
6
+
7
+ Dir.glob('lib/tasks/*.rake').each { |r| import r }
5
8
 
6
9
  task :default => [:spec]
7
10
 
8
11
  desc "Run RSpec tests"
9
12
  RSpec::Core::RakeTask.new(:spec)
13
+
14
+ Rake::RDocTask.new(:doc) do |rd|
15
+ rd.main = 'README.rdoc'
16
+ rd.rdoc_dir = 'doc'
17
+ rd.rdoc_files.include('README.rdoc', 'lib/**/*.rb')
18
+ end
data/example.rb CHANGED
@@ -7,7 +7,7 @@ options = {
7
7
 
8
8
  CONSUMER_KEY = 'test'
9
9
 
10
- client = Jira::Client.new(CONSUMER_KEY, '', options)
10
+ client = JIRA::Client.new(CONSUMER_KEY, '', options)
11
11
 
12
12
  if ARGV.length == 0
13
13
  # If not passed any command line arguments, open a browser and prompt the
@@ -42,6 +42,15 @@ pp issue
42
42
  # # ------------------------------
43
43
  # project = client.Project.find('SAMPLEPROJECT')
44
44
  # pp project
45
+ # project.issues.each do |issue|
46
+ # puts "#{issue.id} - #{issue.fields['summary']}"
47
+ # end
48
+ #
49
+ # # List all Issues
50
+ # # ---------------
51
+ # client.Issue.all.each do |issue|
52
+ # puts "#{issue.id} - #{issue.fields['summary']}"
53
+ # end
45
54
  #
46
55
  # # Delete an issue
47
56
  # # ---------------
@@ -58,9 +67,43 @@ pp issue
58
67
  # issue.save({"fields"=>{"summary"=>"blarg from in example.rb","project"=>{"id"=>"10001"},"issuetype"=>{"id"=>"3"}}})
59
68
  # issue.fetch
60
69
  # pp issue
61
- #
62
- # Update an issue
63
- # ---------------
70
+ #
71
+ # # Update an issue
72
+ # # ---------------
64
73
  # issue = client.Issue.find("10002")
65
74
  # issue.save({"fields"=>{"summary"=>"EVEN MOOOOOOARRR NINJAAAA!"}})
66
75
  # pp issue
76
+ #
77
+ # # Find a user
78
+ # # -----------
79
+ # user = client.User.find('admin')
80
+ # pp user
81
+ #
82
+ # # Get all issue types
83
+ # # -------------------
84
+ # issuetypes = client.Issuetype.all
85
+ # pp issuetypes
86
+ #
87
+ # # Get a single issue type
88
+ # # -----------------------
89
+ # issuetype = client.Issuetype.find('5')
90
+ # pp issuetype
91
+ #
92
+ # # Get all comments for an issue
93
+ # # -----------------------------
94
+ # issue.comments.each do |comment|
95
+ # pp comment
96
+ # end
97
+ #
98
+ # # Build and Save a comment
99
+ # # ------------------------
100
+ # comment = issue.comments.build
101
+ # comment.save!(:body => "New comment from example script")
102
+ #
103
+ # # Delete a comment from the collection
104
+ # # ------------------------------------
105
+ # issue.comments.last.delete
106
+ #
107
+ # # Update an existing comment
108
+ # # --------------------------
109
+ # issue.comments.first.save({"body" => "an updated comment frome example.rb"})
data/jira-ruby.gemspec CHANGED
@@ -4,11 +4,11 @@ require "jira/version"
4
4
 
5
5
  Gem::Specification.new do |s|
6
6
  s.name = "jira-ruby"
7
- s.version = Jira::VERSION
7
+ s.version = JIRA::VERSION
8
8
  s.authors = ["Trineo Ltd"]
9
9
  s.homepage = "http://trineo.co.nz"
10
- s.summary = %q{Ruby Gem for use with the Atlassian Jira 5 REST API}
11
- s.description = %q{API for Jira 5}
10
+ s.summary = %q{Ruby Gem for use with the Atlassian JIRA 5 REST API}
11
+ s.description = %q{API for JIRA 5}
12
12
 
13
13
  s.rubyforge_project = "jira-ruby"
14
14
 
@@ -24,5 +24,7 @@ Gem::Specification.new do |s|
24
24
  s.add_development_dependency "oauth"
25
25
  s.add_runtime_dependency "railties"
26
26
  s.add_development_dependency "railties"
27
+ s.add_runtime_dependency "activesupport"
28
+ s.add_development_dependency "activesupport"
27
29
  s.add_development_dependency "webmock"
28
30
  end
data/lib/jira.rb CHANGED
@@ -1,12 +1,26 @@
1
- Dir[File.join(File.dirname(__FILE__),'tasks/*.rake')].each { |f| load f } if defined?(Rake)
2
-
3
1
  $: << File.expand_path(File.dirname(__FILE__))
4
- require 'jira/resource/base'
5
- require 'jira/resource/base_factory'
6
- require 'jira/resource/http_error'
7
2
 
8
- require 'jira/resource/issue'
9
- require 'jira/resource/project'
3
+ require 'active_support/inflector'
4
+ ActiveSupport::Inflector.inflections do |inflector|
5
+ inflector.singular 'status', 'status'
6
+ end
7
+
8
+ require 'jira/base'
9
+ require 'jira/base_factory'
10
+ require 'jira/has_many_proxy'
11
+ require 'jira/http_error'
12
+
13
+ require 'jira/resource/user'
14
+ require 'jira/resource/attachment'
10
15
  require 'jira/resource/component'
16
+ require 'jira/resource/issuetype'
17
+ require 'jira/resource/version'
18
+ require 'jira/resource/project'
19
+ require 'jira/resource/priority'
20
+ require 'jira/resource/status'
21
+ require 'jira/resource/comment'
22
+ require 'jira/resource/worklog'
23
+ require 'jira/resource/issue'
11
24
 
12
25
  require 'jira/client'
26
+
data/lib/jira/base.rb ADDED
@@ -0,0 +1,466 @@
1
+ require 'active_support/core_ext/string'
2
+ require 'active_support/inflector'
3
+
4
+ module JIRA
5
+
6
+ # This class provides the basic object <-> REST mapping for all JIRA::Resource subclasses,
7
+ # i.e. the Create, Retrieve, Update, Delete lifecycle methods.
8
+ #
9
+ # == Lifecycle methods
10
+ #
11
+ # Note that not all lifecycle
12
+ # methods are available for all resources, for example some resources cannot be updated
13
+ # or deleted.
14
+ #
15
+ # === Retrieving all resources
16
+ #
17
+ # client.Resource.all
18
+ #
19
+ # === Retrieving a single resource
20
+ #
21
+ # client.Resource.find(id)
22
+ #
23
+ # === Creating a resource
24
+ #
25
+ # resource = client.Resource.build({'name' => '')
26
+ # resource.save
27
+ #
28
+ # === Updating a resource
29
+ #
30
+ # resource = client.Resource.find(id)
31
+ # resource.save('updated_attribute' => 'new value')
32
+ #
33
+ # === Deleting a resource
34
+ #
35
+ # resource = client.Resource.find(id)
36
+ # resource.delete
37
+ #
38
+ # == Nested resources
39
+ #
40
+ # Some resources are not defined in the top level of the URL namespace
41
+ # within the JIRA API, but are always nested under the context of another
42
+ # resource. For example, a JIRA::Resource::Comment always belongs to a
43
+ # JIRA::Resource::Issue.
44
+ #
45
+ # These resources must be indexed and built from an instance of the class
46
+ # they are nested under:
47
+ #
48
+ # issue = client.Issue.find(id)
49
+ # comments = issue.comments
50
+ # new_comment = issue.comments.build
51
+ #
52
+ class Base
53
+
54
+ # A reference to the JIRA::Client used to initialize this resource.
55
+ attr_reader :client
56
+
57
+ # Returns true if this instance has been fetched from the server
58
+ attr_accessor :expanded
59
+
60
+ # Returns true if this instance has been deleted from the server
61
+ attr_accessor :deleted
62
+
63
+ # The hash of attributes belonging to this instance. An exact
64
+ # representation of the JSON returned from the JIRA API
65
+ attr_accessor :attrs
66
+
67
+ alias :expanded? :expanded
68
+ alias :deleted? :deleted
69
+
70
+ def initialize(client, options = {})
71
+ @client = client
72
+ @attrs = options[:attrs] || {}
73
+ @expanded = options[:expanded] || false
74
+ @deleted = false
75
+
76
+ # If this class has any belongs_to relationships, a value for
77
+ # each of them must be passed in to the initializer.
78
+ self.class.belongs_to_relationships.each do |relation|
79
+ if options[relation]
80
+ instance_variable_set("@#{relation.to_s}", options[relation])
81
+ instance_variable_set("@#{relation.to_s}_id", options[relation].key_value)
82
+ elsif options["#{relation}_id".to_sym]
83
+ instance_variable_set("@#{relation.to_s}_id", options["#{relation}_id".to_sym])
84
+ else
85
+ raise ArgumentError.new("Required option #{relation.inspect} missing") unless options[relation]
86
+ end
87
+ end
88
+ end
89
+
90
+ # The class methods are never called directly, they are always
91
+ # invoked from a BaseFactory subclass instance.
92
+ def self.all(client, options = {})
93
+ response = client.get(collection_path(client))
94
+ json = parse_json(response.body)
95
+ puts collection_attributes_are_nested
96
+ if collection_attributes_are_nested
97
+ json = json[endpoint_name.pluralize]
98
+ end
99
+ json.map do |attrs|
100
+ self.new(client, {:attrs => attrs}.merge(options))
101
+ end
102
+ end
103
+
104
+ # Finds and retrieves a resource with the given ID.
105
+ def self.find(client, key, options = {})
106
+ instance = self.new(client, options)
107
+ instance.attrs[key_attribute.to_s] = key
108
+ instance.fetch
109
+ instance
110
+ end
111
+
112
+ # Builds a new instance of the resource with the given attributes.
113
+ # These attributes will be posted to the JIRA Api if save is called.
114
+ def self.build(client, attrs)
115
+ self.new(client, :attrs => attrs)
116
+ end
117
+
118
+ # Returns the name of this resource for use in URL components.
119
+ # E.g.
120
+ # JIRA::Resource::Issue.endpoint_name
121
+ # # => issue
122
+ def self.endpoint_name
123
+ self.name.split('::').last.downcase
124
+ end
125
+
126
+ # Returns the full path for a collection of this resource.
127
+ # E.g.
128
+ # JIRA::Resource::Issue.collection_path
129
+ # # => /jira/rest/api/2/issue
130
+ def self.collection_path(client, prefix = '/')
131
+ client.options[:rest_base_path] + prefix + self.endpoint_name
132
+ end
133
+
134
+ # Returns the singular path for the resource with the given key.
135
+ # E.g.
136
+ # JIRA::Resource::Issue.singular_path('123')
137
+ # # => /jira/rest/api/2/issue/123
138
+ #
139
+ # If a prefix parameter is provided it will be injected between the base
140
+ # path and the endpoint.
141
+ # E.g.
142
+ # JIRA::Resource::Comment.singular_path('456','/issue/123/')
143
+ # # => /jira/rest/api/2/issue/123/comment/456
144
+ def self.singular_path(client, key, prefix = '/')
145
+ collection_path(client, prefix) + '/' + key
146
+ end
147
+
148
+ # Returns the attribute name of the attribute used for find.
149
+ # Defaults to :id unless overridden.
150
+ def self.key_attribute
151
+ :id
152
+ end
153
+
154
+ def self.parse_json(string) # :nodoc:
155
+ JSON.parse(string)
156
+ end
157
+
158
+ # Declares that this class contains a singular instance of another resource
159
+ # within the JSON returned from the JIRA API.
160
+ #
161
+ # class Example < JIRA::Base
162
+ # has_one :child
163
+ # end
164
+ #
165
+ # example = client.Example.find(1)
166
+ # example.child # Returns a JIRA::Resource::Child
167
+ #
168
+ # The following options can be used to override the default behaviour of the
169
+ # relationship:
170
+ #
171
+ # [:attribute_key] The relationship will by default reference a JSON key on the
172
+ # object with the same name as the relationship.
173
+ #
174
+ # has_one :child # => {"id":"123",{"child":{"id":"456"}}}
175
+ #
176
+ # Use this option if the key in the JSON is named differently.
177
+ #
178
+ # # Respond to resource.child, but return the value of resource.attrs['kid']
179
+ # has_one :child, :attribute_key => 'kid' # => {"id":"123",{"kid":{"id":"456"}}}
180
+ #
181
+ # [:class] The class of the child instance will be inferred from the name of the
182
+ # relationship. E.g. <tt>has_one :child</tt> will return a <tt>JIRA::Resource::Child</tt>.
183
+ # Use this option to override the inferred class.
184
+ #
185
+ # has_one :child, :class => JIRA::Resource::Kid
186
+ # [:nested_under] In some cases, the JSON return from JIRA is nested deeply for particular
187
+ # relationships. This option allows the nesting to be specified.
188
+ #
189
+ # # Specify a single depth of nesting.
190
+ # has_one :child, :nested_under => 'foo'
191
+ # # => Looks for {"foo":{"child":{}}}
192
+ # # Specify deeply nested JSON
193
+ # has_one :child, :nested_under => ['foo', 'bar', 'baz']
194
+ # # => Looks for {"foo":{"bar":{"baz":{"child":{}}}}}
195
+ def self.has_one(resource, options = {})
196
+ attribute_key = options[:attribute_key] || resource.to_s
197
+ child_class = options[:class] || ('JIRA::Resource::' + resource.to_s.classify).constantize
198
+ define_method(resource) do
199
+ attribute = maybe_nested_attribute(attribute_key, options[:nested_under])
200
+ return nil unless attribute
201
+ child_class.new(client, :attrs => attribute)
202
+ end
203
+ end
204
+
205
+ # Declares that this class contains a collection of another resource
206
+ # within the JSON returned from the JIRA API.
207
+ #
208
+ # class Example < JIRA::Base
209
+ # has_many :children
210
+ # end
211
+ #
212
+ # example = client.Example.find(1)
213
+ # example.children # Returns an instance of Jira::Resource::HasManyProxy,
214
+ # # which behaves exactly like an array of
215
+ # # Jira::Resource::Child
216
+ #
217
+ # The following options can be used to override the default behaviour of the
218
+ # relationship:
219
+ #
220
+ # [:attribute_key] The relationship will by default reference a JSON key on the
221
+ # object with the same name as the relationship.
222
+ #
223
+ # has_many :children # => {"id":"123",{"children":[{"id":"456"},{"id":"789"}]}}
224
+ #
225
+ # Use this option if the key in the JSON is named differently.
226
+ #
227
+ # # Respond to resource.children, but return the value of resource.attrs['kids']
228
+ # has_many :children, :attribute_key => 'kids' # => {"id":"123",{"kids":[{"id":"456"},{"id":"789"}]}}
229
+ #
230
+ # [:class] The class of the child instance will be inferred from the name of the
231
+ # relationship. E.g. <tt>has_many :children</tt> will return an instance
232
+ # of <tt>JIRA::Resource::HasManyProxy</tt> containing the collection of
233
+ # <tt>JIRA::Resource::Child</tt>.
234
+ # Use this option to override the inferred class.
235
+ #
236
+ # has_many :children, :class => JIRA::Resource::Kid
237
+ # [:nested_under] In some cases, the JSON return from JIRA is nested deeply for particular
238
+ # relationships. This option allows the nesting to be specified.
239
+ #
240
+ # # Specify a single depth of nesting.
241
+ # has_many :children, :nested_under => 'foo'
242
+ # # => Looks for {"foo":{"children":{}}}
243
+ # # Specify deeply nested JSON
244
+ # has_many :children, :nested_under => ['foo', 'bar', 'baz']
245
+ # # => Looks for {"foo":{"bar":{"baz":{"children":{}}}}}
246
+ def self.has_many(collection, options = {})
247
+ attribute_key = options[:attribute_key] || collection.to_s
248
+ child_class = options[:class] || ('JIRA::Resource::' + collection.to_s.classify).constantize
249
+ self_class_basename = self.name.split('::').last.downcase.to_sym
250
+ define_method(collection) do
251
+ child_class_options = {self_class_basename => self}
252
+ attribute = maybe_nested_attribute(attribute_key, options[:nested_under]) || []
253
+ collection = attribute.map do |child_attributes|
254
+ child_class.new(client, child_class_options.merge(:attrs => child_attributes))
255
+ end
256
+ HasManyProxy.new(self, child_class, collection)
257
+ end
258
+ end
259
+
260
+ def self.belongs_to_relationships
261
+ @belongs_to_relationships ||= []
262
+ end
263
+
264
+ def self.belongs_to(resource)
265
+ belongs_to_relationships.push(resource)
266
+ attr_reader resource
267
+ attr_reader "#{resource}_id"
268
+ end
269
+
270
+ def self.collection_attributes_are_nested
271
+ @collection_attributes_are_nested ||= false
272
+ end
273
+
274
+ def self.nested_collections(value)
275
+ @collection_attributes_are_nested = value
276
+ end
277
+
278
+ # Returns a symbol for the given instance, for example
279
+ # JIRA::Resource::Issue returns :issue
280
+ def to_sym
281
+ self.class.endpoint_name.to_sym
282
+ end
283
+
284
+ # Checks if method_name is set in the attributes hash
285
+ # and returns true when found, otherwise proxies the
286
+ # call to the superclass.
287
+ def respond_to?(method_name)
288
+ if attrs.keys.include? method_name.to_s
289
+ true
290
+ else
291
+ super(method_name)
292
+ end
293
+ end
294
+
295
+ # Overrides method_missing to check the attribute hash
296
+ # for resources matching method_name and proxies the call
297
+ # to the superclass if no match is found.
298
+ def method_missing(method_name, *args, &block)
299
+ if attrs.keys.include? method_name.to_s
300
+ attrs[method_name.to_s]
301
+ else
302
+ super(method_name)
303
+ end
304
+ end
305
+
306
+ # Each resource has a unique key attribute, this method returns the value
307
+ # of that key for this instance.
308
+ def key_value
309
+ @attrs[self.class.key_attribute.to_s]
310
+ end
311
+
312
+ def collection_path(prefix = "/")
313
+ # Just proxy this to the class method
314
+ self.class.collection_path(client, prefix)
315
+ end
316
+
317
+ # This returns the URL path component that is specific to this instance,
318
+ # for example for Issue id 123 it returns '/issue/123'. For an unsaved
319
+ # issue it returns '/issue'
320
+ def path_component
321
+ path_component = "/#{self.class.endpoint_name}"
322
+ if key_value
323
+ path_component += '/' + key_value
324
+ end
325
+ path_component
326
+ end
327
+
328
+ # Fetches the attributes for the specified resource from JIRA unless
329
+ # the resource is already expanded and the optional force reload flag
330
+ # is not set
331
+ def fetch(reload = false)
332
+ return if expanded? && !reload
333
+ response = client.get(url)
334
+ set_attrs_from_response(response)
335
+ @expanded = true
336
+ end
337
+
338
+ # Saves the specified resource attributes by sending either a POST or PUT
339
+ # request to JIRA, depending on resource.new_record?
340
+ #
341
+ # Accepts an attributes hash of the values to be saved. Will throw a
342
+ # JIRA::HTTPError if the request fails (response is not HTTP 2xx).
343
+ def save!(attrs)
344
+ http_method = new_record? ? :post : :put
345
+ response = client.send(http_method, url, attrs.to_json)
346
+ set_attrs(attrs, false)
347
+ set_attrs_from_response(response)
348
+ @expanded = false
349
+ true
350
+ end
351
+
352
+ # Saves the specified resource attributes by sending either a POST or PUT
353
+ # request to JIRA, depending on resource.new_record?
354
+ #
355
+ # Accepts an attributes hash of the values to be saved. Will return false
356
+ # if the request fails.
357
+ def save(attrs)
358
+ begin
359
+ save_status = save!(attrs)
360
+ rescue JIRA::HTTPError => exception
361
+ set_attrs_from_response(exception.response) rescue JSON::ParserError # Merge error status generated by JIRA REST API
362
+ save_status = false
363
+ end
364
+ save_status
365
+ end
366
+
367
+ # Sets the attributes hash from a HTTPResponse object from JIRA if it is
368
+ # not nil or is not a json response.
369
+ def set_attrs_from_response(response)
370
+ unless response.body.nil? or response.body.length < 2
371
+ json = self.class.parse_json(response.body)
372
+ set_attrs(json)
373
+ end
374
+ end
375
+
376
+ # Set the current attributes from a hash. If clobber is true, any existing
377
+ # hash values will be clobbered by the new hash, otherwise the hash will
378
+ # be deeply merged into attrs. The target paramater is for internal use only
379
+ # and should not be used.
380
+ def set_attrs(hash, clobber=true, target = nil)
381
+ target ||= @attrs
382
+ if clobber
383
+ target.merge!(hash)
384
+ hash
385
+ else
386
+ hash.each do |k, v|
387
+ if v.is_a?(Hash)
388
+ set_attrs(v, clobber, target[k])
389
+ else
390
+ target[k] = v
391
+ end
392
+ end
393
+ end
394
+ end
395
+
396
+ # Sends a delete request to the JIRA Api and sets the deleted instance
397
+ # variable on the object to true.
398
+ def delete
399
+ client.delete(url)
400
+ @deleted = true
401
+ end
402
+
403
+ def has_errors?
404
+ respond_to?('errors')
405
+ end
406
+
407
+ def url
408
+ prefix = '/'
409
+ unless self.class.belongs_to_relationships.empty?
410
+ prefix = self.class.belongs_to_relationships.inject(prefix) do |prefix_so_far, relationship|
411
+ prefix_so_far + relationship.to_s + "/" + self.send("#{relationship.to_s}_id") + '/'
412
+ end
413
+ end
414
+ if @attrs['self']
415
+ @attrs['self']
416
+ elsif key_value
417
+ self.class.singular_path(client, key_value.to_s, prefix)
418
+ else
419
+ self.class.collection_path(client, prefix)
420
+ end
421
+ end
422
+
423
+ def to_s
424
+ "#<#{self.class.name}:#{object_id} @attrs=#{@attrs.inspect}>"
425
+ end
426
+
427
+ # Returns a JSON representation of the current attributes hash.
428
+ def to_json
429
+ attrs.to_json
430
+ end
431
+
432
+ # Determines if the resource is newly created by checking whether its
433
+ # key_value is set. If it is nil, the record is new and the method
434
+ # will return true.
435
+ def new_record?
436
+ key_value.nil?
437
+ end
438
+
439
+ protected
440
+
441
+ # This allows conditional lookup of possibly nested attributes. Example usage:
442
+ #
443
+ # maybe_nested_attribute('foo') # => @attrs['foo']
444
+ # maybe_nested_attribute('foo', 'bar') # => @attrs['bar']['foo']
445
+ # maybe_nested_attribute('foo', ['bar', 'baz']) # => @attrs['bar']['baz']['foo']
446
+ #
447
+ def maybe_nested_attribute(attribute_name, nested_under = nil)
448
+ self.class.maybe_nested_attribute(@attrs, attribute_name, nested_under)
449
+ end
450
+
451
+ def self.maybe_nested_attribute(attributes, attribute_name, nested_under = nil)
452
+ return attributes[attribute_name] if nested_under.nil?
453
+ if nested_under.instance_of? Array
454
+ final = nested_under.inject(attributes) do |parent, key|
455
+ break if parent.nil?
456
+ parent[key]
457
+ end
458
+ return nil if final.nil?
459
+ final[attribute_name]
460
+ else
461
+ return attributes[nested_under][attribute_name]
462
+ end
463
+ end
464
+
465
+ end
466
+ end