/
client.go
198 lines (161 loc) · 5.48 KB
/
client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
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
}
type client struct {
client *http.Client
baseURL string
}
type postShareText struct {
Text string `json:"text"`
}
type postShareRequest struct {
Text postShareText `json:"text"`
Owner string `json:"owner"`
}
func createLinkedInClient(cfg oauthClientConfig) (client, error) {
if cfg.Context == nil {
return client{}, fmt.Errorf("context is nil")
}
if cfg.AccessToken == "" {
return client{}, fmt.Errorf("empty access token")
}
config := oauth2.Config{}
c := config.Client(cfg.Context, &oauth2.Token{
AccessToken: cfg.AccessToken,
})
if c == nil {
return client{}, fmt.Errorf("client is nil")
}
return client{
client: c,
baseURL: "https://api.linkedin.com",
}, nil
}
// 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) 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)
if err != nil {
return "", fmt.Errorf("could not unmarshal: %w", err)
}
if v, ok := result["id"]; ok {
return v.(string), nil
}
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 the profile URN
profileURN, err := c.getProfileURN()
if err != nil {
return "", fmt.Errorf("could not get profile URN: %w", err)
}
req := postShareRequest{
Text: postShareText{
Text: message,
},
Owner: profileURN,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("could not marshal request: %w", err)
}
// Filling only required 'owner' and 'text' field is OK
// https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#sample-request-3
resp, err := c.client.Post(c.baseURL+"/v2/shares", "application/json", bytes.NewReader(reqBytes))
if err != nil {
return "", fmt.Errorf("could not POST /v2/shares: %w", err)
}
body, err := io.ReadAll(resp.Body)
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)
if err != nil {
return "", fmt.Errorf("could not unmarshal: %w", err)
}
// Activity URN
// URN of the activity associated with this share. Activities act as a wrapper around
// shares and articles to represent content in the LinkedIn feed. Read only.
if v, ok := result["activity"]; ok {
return fmt.Sprintf("https://www.linkedin.com/feed/update/%s", v.(string)), nil
}
return "", fmt.Errorf("could not find 'activity' in result: %w", err)
}