jira-ruby 0.0.2 → 0.0.3

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 (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