diff --git a/lib/linked_in/api.rb b/lib/linked_in/api.rb index 84aaf432..667b7a64 100644 --- a/lib/linked_in/api.rb +++ b/lib/linked_in/api.rb @@ -34,5 +34,6 @@ module Api autoload :Jobs, "linked_in/api/jobs" autoload :ShareAndSocialStream, "linked_in/api/share_and_social_stream" autoload :Communications, "linked_in/api/communications" + autoload :V2, "linked_in/api/v2" end end diff --git a/lib/linked_in/api/v2.rb b/lib/linked_in/api/v2.rb new file mode 100644 index 00000000..87ae7a20 --- /dev/null +++ b/lib/linked_in/api/v2.rb @@ -0,0 +1,91 @@ +module LinkedIn + module Api + + # V2 Consumer API + # + # @see https://docs.microsoft.com/en-us/linkedin/consumer/ + module V2 + + # Obtain profile information for a member. Currently, the method only + # accesses the authenticated user. + # + # Permissions: r_liteprofile r_emailaddress + # + # @see https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin?context=linkedin/consumer/context#retrieving-member-profiles + # + # @return [void] + def v2_profile + path = '/me' + v2_get(path) + end + + # Share content for the authenticated user + # + # Permissions: w_member_share + # + # @see https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin + # + # @param [String] urn User's URN (UID) returned from OAuth access token + # request + # @param [Hash] share The body we want to submit to LinkedIn. At least a + # comment is required + # + # @macro share_input_fields + # @return [void] + def v2_add_share(urn, share = {}) + if !urn.instance_of?(String) || urn.empty? + raise LinkedIn::Errors::UnavailableError, 'LinkedIn API: URN required' + elsif share[:comment].nil? + raise LinkedIn::Errors::UnavailableError, + 'LinkedIn API: Comment required' + end + + path = '/ugcPosts' + v2_post(path, MultiJson.dump(share_payload(urn, share))) + end + + private + + def share_payload(urn, share) + payload = { author: "urn:li:person:#{urn}", + lifecycleState: 'PUBLISHED', + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC' + } + } + + return add_url_to_payload(payload, share) if share[:url] + + add_comment_to_payload(payload, share) + end + + def add_url_to_payload(payload, share) + media = { status: 'READY', originalUrl: share[:url] } + if share[:description] + media[:description] = { text: share[:description] } + end + if share[:title] + media[:title] = { text: share[:title] } + end + payload[:specificContent] = { + 'com.linkedin.ugc.ShareContent' => { + shareCommentary: { text: share[:comment] }, + shareMediaCategory: 'ARTICLE', + media: [media] + } + } + payload + end + + def add_comment_to_payload(payload, share) + payload[:specificContent] = { + 'com.linkedin.ugc.ShareContent' => { + shareCommentary: { text: share[:comment] }, + shareMediaCategory: 'NONE' + } + } + payload + end + end + end +end diff --git a/lib/linked_in/client.rb b/lib/linked_in/client.rb index 645f196d..22983324 100644 --- a/lib/linked_in/client.rb +++ b/lib/linked_in/client.rb @@ -5,6 +5,7 @@ module LinkedIn class Client include Helpers::Request include Helpers::Authorization + include Helpers::V2Request include Api::QueryHelpers include Api::People include Api::Groups @@ -12,6 +13,7 @@ class Client include Api::Jobs include Api::ShareAndSocialStream include Api::Communications + include Api::V2 include Search attr_reader :consumer_token, :consumer_secret, :consumer_options diff --git a/lib/linked_in/helpers.rb b/lib/linked_in/helpers.rb index b8bbf315..374554ed 100644 --- a/lib/linked_in/helpers.rb +++ b/lib/linked_in/helpers.rb @@ -2,5 +2,6 @@ module LinkedIn module Helpers autoload :Authorization, "linked_in/helpers/authorization" autoload :Request, "linked_in/helpers/request" + autoload :V2Request, "linked_in/helpers/v2_request" end end diff --git a/lib/linked_in/helpers/v2_request.rb b/lib/linked_in/helpers/v2_request.rb new file mode 100644 index 00000000..091d18d5 --- /dev/null +++ b/lib/linked_in/helpers/v2_request.rb @@ -0,0 +1,80 @@ +module LinkedIn + module Helpers + + module V2Request + + DEFAULT_HEADERS = { + 'x-li-format' => 'json', + 'Content-Type' => 'application/json', + 'X-Restli-Protocol-Version' => '2.0.0' + } + + API_PATH = '/v2' + + protected + + def v2_get(path, options = {}) + response = access_token.get("#{API_PATH}#{path}", + headers: DEFAULT_HEADERS.merge(options)) + raise_errors(response) + response.body + end + + def v2_post(path, body = '', options = {}) + options = { body: body, headers: DEFAULT_HEADERS.merge(options) } + # response is OAuth2::Response + # response.response is Faraday::Response + # sending back response.response makes it easier to access the env + response = access_token.post("#{API_PATH}#{path}", options).response + raise_errors(response) + response + end + + private + + def raise_errors(response) + # Even if the json answer contains the HTTP status code, LinkedIn also sets this code + # in the HTTP answer (thankfully). + case response.status.to_i + when 401 + data = Mash.from_json(response.body) + raise LinkedIn::Errors::UnauthorizedError.new(data), "(#{data.status}): #{data.message}" + when 400 + data = Mash.from_json(response.body) + raise LinkedIn::Errors::GeneralError.new(data), "(#{data.status}): #{data.message}" + when 403 + data = Mash.from_json(response.body) + raise LinkedIn::Errors::AccessDeniedError.new(data), "(#{data.status}): #{data.message}" + when 404 + raise LinkedIn::Errors::NotFoundError, "(#{response.status}): #{response.message}" + when 500 + raise LinkedIn::Errors::InformLinkedInError, "LinkedIn had an internal error. Please let them know in the forum. (#{response.status}): #{response.message}" + when 502..503 + raise LinkedIn::Errors::UnavailableError, "(#{response.status}): #{response.message}" + end + end + + + # Stolen from Rack::Util.build_query + def to_query(params) + params.map { |k, v| + if v.class == Array + to_query(v.map { |x| [k, x] }) + else + v.nil? ? escape(k) : "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" + end + }.join("&") + end + + def to_uri(path, options) + uri = URI.parse(path) + + if options && options != {} + uri.query = to_query(options) + end + uri.to_s + end + end + + end +end diff --git a/spec/cases/v2_spec.rb b/spec/cases/v2_spec.rb new file mode 100644 index 00000000..b61ff8df --- /dev/null +++ b/spec/cases/v2_spec.rb @@ -0,0 +1,222 @@ +require 'helper' + +describe LinkedIn::Api::V2 do + let(:token) { '77j2rfbjbmkcdh' } + let(:consumer_options) do + { site: 'https://api.linkedin.com', raise_errors: false } + end + + let(:client) { LinkedIn::Client.new('token', 'secret') } + let(:consumer) { OAuth2::Client.new('token', 'secret', consumer_options) } + + let(:headers) do + { + 'Accept'=>'*/*', + 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Authorization'=>"Bearer #{token}", + 'Content-Type' => 'application/json', + 'User-Agent'=>'Faraday v0.15.4', + 'X-Li-Format' => 'json', + 'X-Restli-Protocol-Version' => '2.0.0' + } + end + + before do + LinkedIn.default_profile_fields = nil + client.stub(:consumer).and_return(consumer) + client.authorize_from_access(token) + end + + describe '#v2_profile' do + let(:api_url) { 'https://api.linkedin.com/v2/me' } + + context "when LinkedIn returns 201 status code" do + before { stub_request(:get, api_url) } + + it "should send a request" do + client.v2_profile + + expect(a_request(:get, api_url).with(headers: headers, body: nil) + ).to have_been_made.once + end + end + + context 'when LinkedIn returns 403 status code' do + before { stub_request(:get, api_url).to_return(body: '{}', status: 403) } + + it 'returns 403 status code' do + expect do + client.v2_profile + end.to raise_error(LinkedIn::Errors::AccessDeniedError) + end + end + end + + describe '#v2_add_share' do + let(:urn) { '1234567890' } + let(:comment) { 'Testing, 1, 2, 3' } + let(:url) { 'http://example.com/share' } + let(:title) { 'Foobar Title' } + let(:body) do + { author: "urn:li:person:#{urn}", lifecycleState: 'PUBLISHED', + visibility: { 'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC' } } + end + + context "when comment only" do + before do + body[:specificContent] = { + 'com.linkedin.ugc.ShareContent' => { + shareCommentary: { text: comment }, + shareMediaCategory: 'NONE' + } + } + end + + context "when LinkedIn returns 201 status code" do + before do + stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .to_return(body: '{}', status: 201) + end + + it "should send a request" do + client.v2_add_share(urn, comment: comment) + + expect(a_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .with(body: body, headers: headers) + ).to have_been_made.once + end + end + + context "when LinkedIn returns 403 status code" do + before do + stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .to_return(body: '{}', status: 403) + end + + it "should raise AccessDeniedError" do + expect do + client.v2_add_share(urn, comment: comment) + end.to raise_error(LinkedIn::Errors::AccessDeniedError) + end + end + end + + context "when comment, url, and title" do + before do + body[:specificContent] = { + 'com.linkedin.ugc.ShareContent' => { + media: [ + { status: 'READY', originalUrl: url, title: { text: title } } + ], + shareCommentary: { text: comment }, + shareMediaCategory: 'ARTICLE' + } + } + stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .to_return(body: '{}', status: 201) + end + + it "should send a request" do + client.v2_add_share(urn, comment: comment, url: url, title: title) + + expect(a_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .with(body: body, headers: headers) + ).to have_been_made.once + end + end + + context "when url and comment" do + before do + body[:specificContent] = { + 'com.linkedin.ugc.ShareContent' => { + media: [{ status: 'READY', originalUrl: url }], + shareCommentary: { text: comment }, + shareMediaCategory: 'ARTICLE' + } + } + stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .to_return(body: '{}', status: 201) + end + + it "should send a request" do + client.v2_add_share(urn, url: url, comment: comment) + + expect(a_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .with(body: body, headers: headers) + ).to have_been_made.once + end + end + + context "when comment and title" do + before do + body[:specificContent] = { + 'com.linkedin.ugc.ShareContent' => { + shareCommentary: { text: comment }, + shareMediaCategory: 'NONE' + } + } + + stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .to_return(body: '{}', status: 201) + end + + it "should send a request with comment only" do + client.v2_add_share(urn, title: title, comment: comment) + + expect(a_request(:post, 'https://api.linkedin.com/v2/ugcPosts') + .with(body: body, headers: headers) + ).to have_been_made.once + end + end + + context "when url and title only" do + before { stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') } + + it "should raise error" do + expect do + client.v2_add_share(urn, url: url, title: title) + end.to raise_error(LinkedIn::Errors::UnavailableError) + end + end + + context "when url only" do + before { stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') } + + it "should raise error" do + expect do + client.v2_add_share(urn, url: url) + end.to raise_error(LinkedIn::Errors::UnavailableError) + end + end + + context "when title only" do + before { stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') } + + it "should raise error" do + expect do + client.v2_add_share(urn, title: title) + end.to raise_error(LinkedIn::Errors::UnavailableError) + end + end + + context "when no urn" do + before { stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') } + + it "should raise error" do + expect do + client.v2_add_share(comment: comment) + end.to raise_error(LinkedIn::Errors::UnavailableError) + end + end + + context "when urn is blank" do + before { stub_request(:post, 'https://api.linkedin.com/v2/ugcPosts') } + + it "should raise error" do + expect do + client.v2_add_share('', comment: comment) + end.to raise_error(LinkedIn::Errors::UnavailableError) + end + end + end +end