Skip to content

Commit

Permalink
fix: linkedin announce api changes (#4428)
Browse files Browse the repository at this point in the history
Closes #4421 

I chose to keep `getProfileID` as `getProfileIDLegacy` and use it as a
fallback if `getProfileSub` fails because of permission scope.

In this way, it's not a breaking change because one that has only a
deprecated permissions such as `r_liteprofile` will still be able to hit
`v2/me`

this logic is encapsulated in the new function `getProfileURN`, that
resolves the user identifier and returns it formatted as a URN

---------

Co-authored-by: Gabriel F Cipriano <gabriel.cipriano@farme.com.br>
  • Loading branch information
gabrielcipriano and Gabriel F Cipriano committed Nov 18, 2023
1 parent 11e5682 commit 59a3eeb
Show file tree
Hide file tree
Showing 2 changed files with 109 additions and 10 deletions.
82 changes: 73 additions & 9 deletions internal/pipe/linkedin/client.go
Expand Up @@ -3,14 +3,18 @@ package linkedin
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/caarlos0/log"
"github.com/goreleaser/goreleaser/pkg/context"
"golang.org/x/oauth2"
)

var ErrLinkedinForbidden = errors.New("forbidden. please check your permissions")

type oauthClientConfig struct {
Context *context.Context
AccessToken string
Expand Down Expand Up @@ -55,20 +59,26 @@ func createLinkedInClient(cfg oauthClientConfig) (client, error) {
}, nil
}

// getProfileID returns the Current Member's ID
// getProfileIDLegacy returns the Current Member's ID
// it's legacy because it uses deprecated v2/me endpoint, that requires old permissions such as r_liteprofile
// POST Share API requires a Profile ID in the 'owner' field
// Format must be in: 'urn:li:person:PROFILE_ID'
// https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api#retrieve-current-members-profile
func (c client) getProfileID() (string, error) {
func (c client) getProfileIDLegacy() (string, error) {
resp, err := c.client.Get(c.baseURL + "/v2/me")
if err != nil {
return "", fmt.Errorf("could not GET /v2/me: %w", err)
}

if resp.StatusCode == http.StatusForbidden {
return "", ErrLinkedinForbidden
}

value, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("could not read response body: %w", err)
}
defer resp.Body.Close()

var result map[string]interface{}
err = json.Unmarshal(value, &result)
Expand All @@ -83,21 +93,74 @@ func (c client) getProfileID() (string, error) {
return "", fmt.Errorf("could not find 'id' in result: %w", err)
}

// getProfileSub returns the Current Member's sub (formally ID) - requires 'profile' permission
// POST Share API requires a Profile ID in the 'owner' field
// Format must be in: 'urn:li:person:PROFILE_SUB'
// https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#api-request-to-retreive-member-details
func (c client) getProfileSub() (string, error) {
resp, err := c.client.Get(c.baseURL + "/v2/userinfo")
if err != nil {
return "", fmt.Errorf("could not GET /v2/userinfo: %w", err)
}

if resp.StatusCode == http.StatusForbidden {
return "", ErrLinkedinForbidden
}

value, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("could not read response body: %w", err)
}
defer resp.Body.Close()

var result map[string]interface{}
err = json.Unmarshal(value, &result)
if err != nil {
return "", fmt.Errorf("could not unmarshal: %w", err)
}

if v, ok := result["sub"]; ok {
return v.(string), nil
}

return "", fmt.Errorf("could not find 'sub' in result: %w", err)
}

// Person or Organization URN - urn:li:person:PROFILE_IDENTIFIER
// Owner of the share. Required on create.
// tries to get the profile sub (formally id) first, if it fails, it tries to get the profile id (legacy)
// https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#schema
func (c client) getProfileURN() (string, error) {
// To build the URN, we need to get the profile sub (formally id)
profileSub, err := c.getProfileSub()
if err != nil {
if !errors.Is(err, ErrLinkedinForbidden) {
return "", fmt.Errorf("could not get profile sub: %w", err)
}

log.Debug("could not get linkedin profile sub due to permission, getting profile id (legacy)")

profileSub, err = c.getProfileIDLegacy()
if err != nil {
return "", fmt.Errorf("could not get profile id: %w", err)
}
}

return fmt.Sprintf("urn:li:person:%s", profileSub), nil
}

func (c client) Share(message string) (string, error) {
// To get Owner of the share, we need to get profile id
profileID, err := c.getProfileID()
// To get Owner of the share, we need to get the profile URN
profileURN, err := c.getProfileURN()
if err != nil {
return "", fmt.Errorf("could not get profile id: %w", err)
return "", fmt.Errorf("could not get profile URN: %w", err)
}

req := postShareRequest{
Text: postShareText{
Text: message,
},
// Person or Organization URN
// Owner of the share. Required on create.
// https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#schema
Owner: fmt.Sprintf("urn:li:person:%s", profileID),
Owner: profileURN,
}

reqBytes, err := json.Marshal(req)
Expand All @@ -116,6 +179,7 @@ func (c client) Share(message string) (string, error) {
if err != nil {
return "", fmt.Errorf("could not read from body: %w", err)
}
defer resp.Body.Close()

var result map[string]interface{}
err = json.Unmarshal(body, &result)
Expand Down
37 changes: 36 additions & 1 deletion internal/pipe/linkedin/client_test.go
Expand Up @@ -58,7 +58,7 @@ func TestClient_Share(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
_, _ = io.WriteString(rw, `
{
"id": "foo",
"sub": "foo",
"activity": "123456789"
}
`)
Expand All @@ -83,3 +83,38 @@ func TestClient_Share(t *testing.T) {
wantLink := "https://www.linkedin.com/feed/update/123456789"
require.Equal(t, wantLink, link)
}

func TestClientLegacyProfile_Share(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/v2/userinfo" {
rw.WriteHeader(http.StatusForbidden)
return
}
// this is the response from /v2/me (legacy as a fallback)
_, _ = io.WriteString(rw, `
{
"id": "foo",
"activity": "123456789"
}
`)
}))
defer server.Close()

c, err := createLinkedInClient(oauthClientConfig{
Context: testctx.New(),
AccessToken: "foo",
})
if err != nil {
t.Fatalf("could not create client: %v", err)
}

c.baseURL = server.URL

link, err := c.Share("test")
if err != nil {
t.Fatalf("could not share: %v", err)
}

wantLink := "https://www.linkedin.com/feed/update/123456789"
require.Equal(t, wantLink, link)
}

0 comments on commit 59a3eeb

Please sign in to comment.