-
Notifications
You must be signed in to change notification settings - Fork 1
/
client.go
240 lines (213 loc) · 5.75 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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
package paypal
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
"go.opentelemetry.io/otel/trace"
)
type Client struct {
base, id, secret string
t *Token
hc *http.Client
}
func NewClient(base, id, secret string) *Client {
return &Client{
base: base,
id: id,
secret: secret,
hc: &http.Client{
Transport: otelhttp.NewTransport(
http.DefaultTransport,
otelhttp.WithSpanNameFormatter(formatSpanName),
otelhttp.WithSpanOptions(
trace.WithAttributes(semconv.PeerServiceKey.String("paypal")),
),
),
},
}
}
func formatSpanName(_ string, r *http.Request) string {
op := GetOperation(r.Context())
if op == "" {
// Fallback to the default name
op = r.Method
}
return "PayPal " + op
}
type operationKey struct{}
func WithOperation(ctx context.Context, op string) context.Context {
return context.WithValue(ctx, operationKey{}, op)
}
func GetOperation(ctx context.Context) string {
return ctx.Value(operationKey{}).(string)
}
type Token struct {
Scope string `json:"scope"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
AppID string `json:"app_id"`
Nonce string `json:"nonce"`
ExpiresIn int `json:"expires_in"`
expiresAt time.Time
}
func (t *Token) Valid() bool {
if t == nil {
return false
}
return t.expiresAt.After(time.Now())
}
// Auth requests a new token from PayPal server.
// See https://developer.paypal.com/api/rest/authentication/.
func (c *Client) Auth(ctx context.Context) (res *Token, err error) {
ctx = WithOperation(ctx, "Auth")
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.base+"/v1/oauth2/token", strings.NewReader("grant_type=client_credentials"))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.SetBasicAuth(c.id, c.secret)
start := time.Now()
if res, err = doJSON[Token](ctx, c, req); err != nil {
return
}
// Minus 2 seconds is to prevent expiration due to network latency.
res.expiresAt = start.Add(time.Duration(res.ExpiresIn-2) * time.Second)
return
}
func (c *Client) checkToken(ctx context.Context) (err error) {
if !c.t.Valid() {
c.t, err = c.Auth(ctx)
}
return
}
// JSON performs the request with the data marshaled to JSON format,
// unmarshals the response body into a new R,
// and automatically refreshes the client's access token.
func JSON[R any](ctx context.Context, c *Client, method, path string, data any,
) (res *R, err error) {
url := c.base + path
req, err := NewJSONRequest(ctx, method, url, data)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
if err = c.checkToken(ctx); err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+c.t.AccessToken)
return doJSON[R](ctx, c, req)
}
// JSONNop is similar to [JSON] but with the response body discarded.
func JSONNop(ctx context.Context, c *Client, method, path string, data any) (err error) {
url := c.base + path
req, err := NewJSONRequest(ctx, method, url, data)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
if err = c.checkToken(ctx); err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+c.t.AccessToken)
hres, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("do: %w", err)
}
defer hres.Body.Close()
if hres.StatusCode < 400 {
return nil
}
if hres.ContentLength == 0 {
return &Error{
StatusCode: hres.StatusCode,
}
}
e, err := RespJSON[Error](hres)
if err != nil {
return fmt.Errorf("unmarshal error: %w", err)
}
e.StatusCode = hres.StatusCode
return e
}
func doJSON[R any](ctx context.Context, c *Client, req *http.Request) (res *R, err error) {
hres, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("do: %w", err)
}
if hres.StatusCode < 400 {
return RespJSON[R](hres)
}
e, err := RespJSON[Error](hres)
if err != nil {
return nil, fmt.Errorf("unmarshal error: %w", err)
}
e.StatusCode = hres.StatusCode
return nil, e
}
// Error is the PayPal API error response.
// See https://developer.paypal.com/api/rest/responses/.
type Error struct {
StatusCode int
Name string `json:"name"`
Message string `json:"message"`
DebugID string `json:"debug_id"`
Details []*ErrorDetail `json:"details"`
Links []*Link `json:"links"`
// For identity errors
Err string `json:"error"`
ErrDesc string `json:"error_description"`
}
func (e *Error) Error() string {
if e.Err != "" {
return e.Err + ": " + e.ErrDesc
}
return fmt.Sprintf("%s: %s (%s)", e.Name, e.Message, e.DebugID)
}
type ErrorDetail struct {
Field string `json:"field"`
Value string `json:"value"`
Location string `json:"location"`
Issue string `json:"issue"`
Description string `json:"description"`
}
// Link is a HATEOAS link.
// See https://developer.paypal.com/api/rest/responses/#link-hateoaslinks.
type Link struct {
HRef string `json:"href"`
Rel string `json:"rel"`
Method string `json:"method"`
}
// NewJSONRequest returns a new [http.Request] with the given data marshaled to JSON format.
func NewJSONRequest(ctx context.Context, method, url string, data any,
) (res *http.Request, err error) {
var r io.Reader
if data != nil {
bs, err := json.Marshal(data)
if err != nil {
return nil, err
}
r = bytes.NewReader(bs)
}
res, err = http.NewRequestWithContext(ctx, method, url, r)
if err != nil {
return
}
res.Header.Set("Content-Type", "application/json")
return
}
// RespJSON unmarshals the response body into a new 'R' and then closes the body.
func RespJSON[R any](r *http.Response) (res *R, err error) {
defer r.Body.Close()
bs, err := io.ReadAll(r.Body)
if err != nil {
return
}
res = new(R)
err = json.Unmarshal(bs, res)
return
}