diff --git a/lib/jira-ruby.rb b/lib/jira-ruby.rb index a5ccd060..3be7f703 100644 --- a/lib/jira-ruby.rb +++ b/lib/jira-ruby.rb @@ -23,6 +23,7 @@ require 'jira/resource/status_category' require 'jira/resource/transition' require 'jira/resource/project' +require 'jira/resource/properties' require 'jira/resource/priority' require 'jira/resource/comment' require 'jira/resource/worklog' diff --git a/lib/jira/client.rb b/lib/jira/client.rb index 0035c903..68344ce8 100644 --- a/lib/jira/client.rb +++ b/lib/jira/client.rb @@ -302,6 +302,10 @@ def Agile JIRA::Resource::AgileFactory.new(self) end + def Properties + JIRA::Resource::PropertiesFactory.new(self) + end + # HTTP methods without a body # Make an HTTP DELETE request @@ -351,7 +355,9 @@ def post(path, body = '', headers = {}) # @raise [JIRA::HTTPError] If the response is not an HTTP success code def post_multipart(path, file, headers = {}) puts "post multipart: #{path} - [#{file}]" if @http_debug - @request_client.request_multipart(path, file, merge_default_headers(headers)) + res = @request_client.request_multipart(path, file, merge_default_headers(headers)) + puts "response: #{res}" if @http_debug + res end # Make an HTTP PUT request @@ -375,7 +381,9 @@ def put(path, body = '', headers = {}) # @raise [JIRA::HTTPError] If the response is not an HTTP success code def request(http_method, path, body = '', headers = {}) puts "#{http_method}: #{path} - [#{body}]" if @http_debug - @request_client.request(http_method, path, body, headers) + res = @request_client.request(http_method, path, body, headers) + puts "response: #{res}" if @http_debug + res end # @private diff --git a/lib/jira/resource/attachment.rb b/lib/jira/resource/attachment.rb index 20577acb..abe59fa6 100644 --- a/lib/jira/resource/attachment.rb +++ b/lib/jira/resource/attachment.rb @@ -146,10 +146,14 @@ def download_contents(headers = {}) # @raise [JIRA::HTTPError] if failed def save!(attrs, path = url) file = attrs['file'] || attrs[:file] # Keep supporting 'file' parameter as a string for backward compatibility - mime_type = attrs[:mimeType] || 'application/binary' + # If :filename does not exist or is nil, that is fine as it will force + # Upload to determine the filename automatically from file. + # Breaking the filename out allows this to support any IO-based file parameter. + fname = attrs['filename'] || attrs[:filename] + mime_type = attrs['mimeType'] || attrs[:mimeType] || 'application/binary' headers = { 'X-Atlassian-Token' => 'nocheck' } - data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, file) } + data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, fname) } response = client.post_multipart(path, data, headers) @@ -159,6 +163,17 @@ def save!(attrs, path = url) true end + def http_download + # Actually fetch the attachment + # Note: Jira handles attachment's weird! + # Typically, they respond with a redirect location that should not have the same authentication + client.get(attrs['content']) + rescue JIRA::HTTPError => e + raise e unless e.response.code =~ /\A3\d\d\z/ && e.response['location'].present? + + Net::HTTP.get_response(URI(e.response['location'])) + end + private def set_attributes(attributes, response) diff --git a/lib/jira/resource/comment.rb b/lib/jira/resource/comment.rb index d1475f67..e27db9ba 100644 --- a/lib/jira/resource/comment.rb +++ b/lib/jira/resource/comment.rb @@ -9,6 +9,18 @@ class Comment < JIRA::Base belongs_to :issue nested_collections true + + def self.all(client, options = {}) + issue = options[:issue] + raise ArgumentError, 'parent issue is required' unless issue + + response = client.get("#{issue.url}/#{endpoint_name}") + json = parse_json(response.body) + json = json[endpoint_name.pluralize] + json.map do |attrs| + new(client, { attrs: }.merge(options)) + end + end end end end diff --git a/lib/jira/resource/createmeta.rb b/lib/jira/resource/createmeta.rb index 6f53b0c6..c37eb7e2 100644 --- a/lib/jira/resource/createmeta.rb +++ b/lib/jira/resource/createmeta.rb @@ -12,22 +12,22 @@ def self.endpoint_name def self.all(client, params = {}) if params.key?(:projectKeys) - values = Array(params[:projectKeys]).map { |i| (i.is_a?(JIRA::Resource::Project) ? i.key : i) } + values = Array(params[:projectKeys]).map { |i| i.is_a?(JIRA::Resource::Project) ? i.key : i } params[:projectKeys] = values.join(',') end if params.key?(:projectIds) - values = Array(params[:projectIds]).map { |i| (i.is_a?(JIRA::Resource::Project) ? i.id : i) } + values = Array(params[:projectIds]).map { |i| i.is_a?(JIRA::Resource::Project) ? i.id : i } params[:projectIds] = values.join(',') end if params.key?(:issuetypeNames) - values = Array(params[:issuetypeNames]).map { |i| (i.is_a?(JIRA::Resource::Issuetype) ? i.name : i) } + values = Array(params[:issuetypeNames]).map { |i| i.is_a?(JIRA::Resource::Issuetype) ? i.name : i } params[:issuetypeNames] = values.join(',') end if params.key?(:issuetypeIds) - values = Array(params[:issuetypeIds]).map { |i| (i.is_a?(JIRA::Resource::Issuetype) ? i.id : i) } + values = Array(params[:issuetypeIds]).map { |i| i.is_a?(JIRA::Resource::Issuetype) ? i.id : i } params[:issuetypeIds] = values.join(',') end diff --git a/lib/jira/resource/issue.rb b/lib/jira/resource/issue.rb index fe25fb53..c2299b4a 100644 --- a/lib/jira/resource/issue.rb +++ b/lib/jira/resource/issue.rb @@ -57,6 +57,7 @@ class Issue < JIRA::Base has_many :issuelinks, nested_under: 'fields' has_many :remotelink, class: JIRA::Resource::Remotelink has_many :watchers, attribute_key: 'watches', nested_under: %w[fields watches] + has_many :properties, class: JIRA::Resource::Properties # Get collection of issues. # @param client [JIRA::Client] diff --git a/lib/jira/resource/properties.rb b/lib/jira/resource/properties.rb new file mode 100644 index 00000000..950bd1cd --- /dev/null +++ b/lib/jira/resource/properties.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'active_support/inflector' + +module JIRA + module Resource + class PropertiesFactory < JIRA::BaseFactory # :nodoc: + end + + class Properties < JIRA::Base + belongs_to :issue + + def self.key_attribute + :key + end + + def self.all(client, options = {}) + issue = options[:issue] + raise ArgumentError, 'parent issue is required' unless issue + + response = client.get("#{issue.url}/#{endpoint_name}") + json = parse_json(response.body) + json[key_attribute.to_s.pluralize].map do |attrs| + ## Net get the individual property + self_response = client.get(attrs['self']) + property = parse_json(self_response.body) + ## Make sure to build the new resource via the issue.properties in order to support the has_many proxy + issue.properties.build(property) + end + end + + ## Override save so we can handle the required attrs (and default 'value' when appropriate) + def save!(attrs = {}, path = nil) + if attrs.is_a?(Hash) && attrs.key?(self.class.key_attribute.to_s) + raise ArgumentError, "Use of 'value' is required when '#{self.class.key_attribute}' is provided" \ + unless attrs.key?('value') + + set_attrs(self.class.key_attribute.to_s => attrs[self.class.key_attribute.to_s]) + end + + raise ArgumentError, "'key' is required on a new record" if new_record? + + path ||= patched_url + ## We can take either the 'value' element from the hash, OR use the entire attrs as the value + value = attrs.is_a?(Hash) && attrs.key?('value') ? attrs['value'] : attrs + value = '' if value.nil? + ## Note: this API endpoint requires a non-empty JSON body for the value of the property + ## Note2: this API endpoint does not return a body, so no need to call set_attrs_from_response + client.send(:put, path, value.to_json) + set_attrs({ 'value' => value }, false) + @expanded = false + true + end + end + end +end diff --git a/lib/jira/resource/worklog.rb b/lib/jira/resource/worklog.rb index 325d8534..281d43f8 100644 --- a/lib/jira/resource/worklog.rb +++ b/lib/jira/resource/worklog.rb @@ -10,6 +10,18 @@ class Worklog < JIRA::Base has_one :update_author, class: JIRA::Resource::User, attribute_key: 'updateAuthor' belongs_to :issue nested_collections true + + def self.all(client, options = {}) + issue = options[:issue] + raise ArgumentError, 'parent issue is required' unless issue + + response = client.get("#{issue.url}/#{endpoint_name}") + json = parse_json(response.body) + json = json[endpoint_name.pluralize] + json.map do |attrs| + new(client, { attrs: }.merge(options)) + end + end end end end diff --git a/spec/integration/properties_spec.rb b/spec/integration/properties_spec.rb new file mode 100644 index 00000000..bdb889da --- /dev/null +++ b/spec/integration/properties_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe JIRA::Resource::Properties do + with_each_client do |site_url, client| + context 'when accessing singular resource' do + let(:client) { client } + let(:site_url) { site_url } + let(:key) { 'xyz' } + let(:target) { described_class.new(client, attrs: { 'key' => 'xyz' }, issue_id: '10002') } + let(:belongs_to) { JIRA::Resource::Issue.new(client, attrs: { 'id' => '10002' }) } + let(:expected_attributes) { { 'key' => key, 'value' => 'supercalifragilistic' } } + let(:attributes_for_put) { { 'value' => 'expialidocious' } } + let(:expected_attributes_from_put) { { 'key' => key, 'value' => 'expialidocious' } } + + it_behaves_like 'a resource' + it_behaves_like 'a resource with a singular GET endpoint' + it_behaves_like 'a resource with a DELETE endpoint' + it_behaves_like 'a resource with a PUT endpoint' + end + + context 'when accessing collection' do + let(:client) { client } + let(:site_url) { site_url } + let(:key) { 'xyz' } + let(:belongs_to) { JIRA::Resource::Issue.new(client, attrs: { 'id' => '10002' }) } + let(:expected_collection_length) { 2 } + let(:expected_attributes) { { 'key' => key, 'value' => 'supercalifragilistic' } } + + before do + ## Since properties collections do subsequent queries on each individual properties records, + ## we need to additionally define stub requests for each of the individual records + additional_targets = [ + described_class.new(client, attrs: { 'key' => 'foo' }, issue_id: '10002'), + described_class.new(client, attrs: { 'key' => 'xyz' }, issue_id: '10002') + ] + additional_targets.each do |target| + req_url = site_url + target.url + stub_request(:get, req_url).to_return(status: 200, body: get_mock_from_url(:get, req_url)) + end + end + + it_behaves_like 'a resource with a collection GET endpoint' + end + end +end diff --git a/spec/integration/transition_spec.rb b/spec/integration/transition_spec.rb index d8a6518b..6f25e500 100644 --- a/spec/integration/transition_spec.rb +++ b/spec/integration/transition_spec.rb @@ -38,9 +38,10 @@ describe 'POST endpoint' do it 'saves a new resource' do - stub_request(:post, /#{described_class.collection_path(client, prefix)}$/) + req_url = build_url + stub_request(:post, req_url) .with(body: attributes_for_post.to_json) - .to_return(status: 200, body: get_mock_from_path(:post)) + .to_return(status: 200, body: get_mock_from_url(:post, req_url)) subject = build_receiver.build expect(subject.save(attributes_for_post)).to be_truthy end diff --git a/spec/integration/webhook_spec.rb b/spec/integration/webhook_spec.rb index 85eba564..ee8772b8 100644 --- a/spec/integration/webhook_spec.rb +++ b/spec/integration/webhook_spec.rb @@ -1,7 +1,9 @@ require 'spec_helper' describe JIRA::Resource::Webhook do - with_each_client do |site_url, client| + ## This endpoint uses a different base path, so override this client's rest_base_path option + ## so this test can still use the SharedExampleGroups + with_each_client(rest_base_path: described_class.const_get(:REST_BASE_PATH)) do |site_url, client| let(:client) { client } let(:site_url) { site_url } @@ -20,7 +22,7 @@ it 'returns a collection of components' do stub_request(:get, site_url + described_class.singular_path(client, key)) - .to_return(status: 200, body: get_mock_response('webhook/webhook.json')) + .to_return(status: 200, body: get_mock_response('webhook/2.json')) webhook = client.Webhook.find(key) diff --git a/spec/jira/resource/issue_spec.rb b/spec/jira/resource/issue_spec.rb index 596f1b5a..04a90321 100644 --- a/spec/jira/resource/issue_spec.rb +++ b/spec/jira/resource/issue_spec.rb @@ -244,7 +244,8 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc: 'comment' => { 'comments' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }] }, 'attachment' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }], 'worklog' => { 'worklogs' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }] } - } + }, + 'properties' => [{ 'foo' => 'bar' }, { 'baz' => 'flum' }] }) end @@ -284,6 +285,9 @@ class JIRAResourceDelegation < SimpleDelegator # :nodoc: expect(subject).to have_many(:worklogs, JIRA::Resource::Worklog) expect(subject.worklogs.length).to eq(2) + + expect(subject).to have_many(:properties, JIRA::Resource::Properties) + expect(subject.properties.length).to eq(2) end end end diff --git a/spec/mock_responses/issue/10002/properties.json b/spec/mock_responses/issue/10002/properties.json new file mode 100644 index 00000000..6e7f68e9 --- /dev/null +++ b/spec/mock_responses/issue/10002/properties.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "key": "xyz", + "self": "http://localhost:2990/jira/rest/api/2/issue/10002/properties/xyz" + }, + { + "key": "foo", + "self": "http://localhost:2990/jira/rest/api/2/issue/10002/properties/foo" + } + ] +} diff --git a/spec/mock_responses/issue/10002/properties/foo.json b/spec/mock_responses/issue/10002/properties/foo.json new file mode 100644 index 00000000..08e55872 --- /dev/null +++ b/spec/mock_responses/issue/10002/properties/foo.json @@ -0,0 +1,4 @@ +{ + "key": "foo", + "value": "bar" +} diff --git a/spec/mock_responses/issue/10002/properties/xyz.json b/spec/mock_responses/issue/10002/properties/xyz.json new file mode 100644 index 00000000..f1241678 --- /dev/null +++ b/spec/mock_responses/issue/10002/properties/xyz.json @@ -0,0 +1,4 @@ +{ + "key": "xyz", + "value": "supercalifragilistic" +} diff --git a/spec/mock_responses/issue/10002/properties/xyz.put.json b/spec/mock_responses/issue/10002/properties/xyz.put.json new file mode 100644 index 00000000..4645648a --- /dev/null +++ b/spec/mock_responses/issue/10002/properties/xyz.put.json @@ -0,0 +1,4 @@ +{ + "key": "xyz", + "value": "expialidocious" +} diff --git a/spec/mock_responses/jira/rest/webhooks/1.0/webhook.json b/spec/mock_responses/jira/rest/webhooks/1.0/webhook.json deleted file mode 100644 index 11b2e543..00000000 --- a/spec/mock_responses/jira/rest/webhooks/1.0/webhook.json +++ /dev/null @@ -1,11 +0,0 @@ -[{"name":"from API", - "url":"http://localhost:3000/webhooks/1", - "excludeBody":false, - "filters":{"issue-related-events-section":""}, - "events":[], - "enabled":true, - "self":"http://localhost:2990/jira/rest/webhooks/1.0/webhook/2", - "lastUpdatedUser":"admin", - "lastUpdatedDisplayName":"admin", - "lastUpdated":1453306520188 - }] \ No newline at end of file diff --git a/spec/mock_responses/jira/rest/webhooks/1.0/webhook/2.json b/spec/mock_responses/webhook/2.json similarity index 100% rename from spec/mock_responses/jira/rest/webhooks/1.0/webhook/2.json rename to spec/mock_responses/webhook/2.json diff --git a/spec/mock_responses/webhook/webhook.json b/spec/mock_responses/webhook/webhook.json deleted file mode 100644 index 96bd6a12..00000000 --- a/spec/mock_responses/webhook/webhook.json +++ /dev/null @@ -1,11 +0,0 @@ -{"name":"from API", - "url":"http://localhost:3000/webhooks/1", - "excludeBody":false, - "filters":{"issue-related-events-section":""}, - "events":[], - "enabled":true, - "self":"http://localhost:2990/jira/rest/webhooks/1.0/webhook/2", - "lastUpdatedUser":"admin", - "lastUpdatedDisplayName":"admin", - "lastUpdated":1453306520188 - } \ No newline at end of file diff --git a/spec/support/clients_helper.rb b/spec/support/clients_helper.rb index 9edd4042..70b65a38 100644 --- a/spec/support/clients_helper.rb +++ b/spec/support/clients_helper.rb @@ -1,12 +1,12 @@ module ClientsHelper - def with_each_client(&) + def with_each_client(opts = {}, &) clients = {} - oauth_client = JIRA::Client.new(consumer_key: 'foo', consumer_secret: 'bar') + oauth_client = JIRA::Client.new({ consumer_key: 'foo', consumer_secret: 'bar' }.merge(opts)) oauth_client.set_access_token('abc', '123') clients['http://localhost:2990'] = oauth_client - basic_client = JIRA::Client.new(username: 'foo', password: 'bar', auth_type: :basic, use_ssl: false) + basic_client = JIRA::Client.new({ username: 'foo', password: 'bar', auth_type: :basic, use_ssl: false }.merge(opts)) clients['http://localhost:2990'] = basic_client clients.each(&) diff --git a/spec/support/shared_examples/integration.rb b/spec/support/shared_examples/integration.rb index 376bef72..217255e4 100644 --- a/spec/support/shared_examples/integration.rb +++ b/spec/support/shared_examples/integration.rb @@ -1,20 +1,18 @@ require 'cgi' -def get_mock_from_path(method, options = {}) - prefix = if defined? belongs_to - "#{belongs_to.path_component}/" - else - '' - end - - url = if options[:url] - options[:url] - elsif options[:key] - described_class.singular_path(client, options[:key], prefix) - else - described_class.collection_path(client, prefix) - end - file_path = url.sub(client.options[:rest_base_path], '') +def build_url(options = {}) + prefix = defined?(belongs_to) ? "#{belongs_to.path_component}/" : '/' + path = if options.key?(:key) + described_class.singular_path(client, options[:key], prefix) + else + described_class.collection_path(client, prefix) + end + site_url + path +end + +def get_mock_from_url(method, url, options = {}) + # Remove site_url and rest api portion of the url + file_path = url.sub(site_url + client.options[:rest_base_path], '') file_path = "#{file_path}.#{options[:suffix]}" if options[:suffix] file_path = "#{file_path}.#{method}" unless method == :get value_if_not_found = options.key?(:value_if_not_found) ? options[:value_if_not_found] : false @@ -63,8 +61,8 @@ def build_receiver shared_examples 'a resource with a collection GET endpoint' do it 'gets the collection' do - stub_request(:get, site_url + described_class.collection_path(client)) - .to_return(status: 200, body: get_mock_from_path(:get)) + req_url = build_url + stub_request(:get, req_url).to_return(status: 200, body: get_mock_from_url(:get, req_url)) collection = build_receiver.all expect(collection.length).to eq(expected_collection_length) @@ -74,10 +72,8 @@ def build_receiver shared_examples 'a resource with JQL inputs and a collection GET endpoint' do it 'gets the collection' do - stub_request( - :get, - "#{site_url}#{client.options[:rest_base_path]}/search/jql?jql=#{CGI.escape(jql_query_string)}" - ).to_return(status: 200, body: get_mock_response('issue.json')) + req_url = "#{site_url}#{client.options[:rest_base_path]}/search/jql?jql=#{CGI.escape(jql_query_string)}" + stub_request(:get, req_url).to_return(status: 200, body: get_mock_response('issue.json')) collection = build_receiver.jql(jql_query_string) @@ -90,8 +86,8 @@ def build_receiver it 'GETs a single resource' do # E.g., for JIRA::Resource::Project, we need to call # client.Project.find() - stub_request(:get, site_url + described_class.singular_path(client, key, prefix)) - .to_return(status: 200, body: get_mock_from_path(:get, key:)) + req_url = build_url(key:) + stub_request(:get, req_url).to_return(status: 200, body: get_mock_from_url(:get, req_url)) subject = client.send(class_basename).find(key, options) expect(subject).to have_attributes(expected_attributes) @@ -100,8 +96,8 @@ def build_receiver it 'builds and fetches a single resource' do # E.g., for JIRA::Resource::Project, we need to call # client.Project.build('key' => 'ABC123') - stub_request(:get, site_url + described_class.singular_path(client, key, prefix)) - .to_return(status: 200, body: get_mock_from_path(:get, key:)) + req_url = build_url(key:) + stub_request(:get, req_url).to_return(status: 200, body: get_mock_from_url(:get, req_url)) subject = build_receiver.build(described_class.key_attribute.to_s => key) subject.fetch @@ -110,7 +106,7 @@ def build_receiver end it 'handles a 404' do - stub_request(:get, site_url + described_class.singular_path(client, '99999', prefix)) + stub_request(:get, build_url(key: '99999')) .to_return(status: 404, body: "{\"errorMessages\":[\"#{class_basename} Does Not Exist\"],\"errors\": {}}") expect do client.send(class_basename).find('99999', options) @@ -122,8 +118,8 @@ def build_receiver it 'deletes a resource' do # E.g., for JIRA::Resource::Project, we need to call # client.Project.delete() - stub_request(:delete, site_url + described_class.singular_path(client, key, prefix)) - .to_return(status: 204, body: nil) + req_url = build_url(key:) + stub_request(:delete, req_url).to_return(status: 204, body: nil) subject = build_receiver.build(described_class.key_attribute.to_s => key) expect(subject.delete).to be_truthy @@ -132,8 +128,8 @@ def build_receiver shared_examples 'a resource with a POST endpoint' do it 'saves a new resource' do - stub_request(:post, site_url + described_class.collection_path(client, prefix)) - .to_return(status: 201, body: get_mock_from_path(:post)) + req_url = build_url + stub_request(:post, req_url).to_return(status: 201, body: get_mock_from_url(:post, req_url)) subject = build_receiver.build expect(subject.save(attributes_for_post)).to be_truthy expected_attributes_from_post.each do |method_name, value| @@ -144,10 +140,10 @@ def build_receiver shared_examples 'a resource with a PUT endpoint' do it 'saves an existing component' do - stub_request(:get, site_url + described_class.singular_path(client, key, prefix)) - .to_return(status: 200, body: get_mock_from_path(:get, key:)) - stub_request(:put, site_url + described_class.singular_path(client, key, prefix)) - .to_return(status: 200, body: get_mock_from_path(:put, key:, value_if_not_found: nil)) + req_url = build_url(key:) + stub_request(:get, req_url).to_return(status: 200, body: get_mock_from_url(:get, req_url)) + stub_request(:put, req_url) + .to_return(status: 200, body: get_mock_from_url(:put, req_url, value_if_not_found: nil)) subject = build_receiver.build(described_class.key_attribute.to_s => key) subject.fetch expect(subject.save(attributes_for_put)).to be_truthy @@ -159,10 +155,10 @@ def build_receiver shared_examples 'a resource with a PUT endpoint that rejects invalid fields' do it 'fails to save with an invalid field' do - stub_request(:get, site_url + described_class.singular_path(client, key)) - .to_return(status: 200, body: get_mock_from_path(:get, key:)) - stub_request(:put, site_url + described_class.singular_path(client, key)) - .to_return(status: 400, body: get_mock_from_path(:put, key:, suffix: 'invalid')) + req_url = build_url(key:) + stub_request(:get, req_url).to_return(status: 200, body: get_mock_from_url(:get, req_url)) + stub_request(:put, req_url) + .to_return(status: 400, body: get_mock_from_url(:put, req_url, suffix: 'invalid')) subject = client.send(class_basename).build(described_class.key_attribute.to_s => key) subject.fetch