1 | package mollie |
||
2 | |||
3 | import ( |
||
4 | "bytes" |
||
5 | "context" |
||
6 | "encoding/json" |
||
7 | "errors" |
||
8 | "fmt" |
||
9 | "io" |
||
10 | "net/http" |
||
11 | "net/url" |
||
12 | "os" |
||
13 | "regexp" |
||
14 | "runtime" |
||
15 | "strings" |
||
16 | |||
17 | "github.com/VictorAvelar/mollie-api-go/v4/pkg/idempotency" |
||
18 | "github.com/google/go-querystring/query" |
||
19 | ) |
||
20 | |||
21 | // Constants holding values for client initialization and request instantiation. |
||
22 | const ( |
||
23 | BaseURL string = "https://api.mollie.com/" |
||
24 | AuthHeader string = "Authorization" |
||
25 | TokenType string = "Bearer" |
||
26 | APITokenEnv string = "MOLLIE_API_TOKEN" |
||
27 | OrgTokenEnv string = "MOLLIE_ORG_TOKEN" |
||
28 | RequestContentType string = "application/json" |
||
29 | IdempotencyKeyHeader string = "Idempotency-Key" |
||
30 | ClientName string = "MollieGoClient" |
||
31 | Version string = "4.0.0" |
||
32 | ) |
||
33 | |||
34 | var ( |
||
35 | accessTokenExpr = regexp.MustCompile(`(?m)^access_`) |
||
36 | errEmptyAuthKey = errors.New("you must provide a non-empty authentication key") |
||
37 | errBadBaseURL = errors.New("malformed base url, it must contain a trailing slash") |
||
38 | |||
39 | goData = strings.Join([]string{runtime.GOOS, runtime.GOARCH, runtime.Version()}, "/") |
||
40 | ) |
||
41 | |||
42 | // Client manages communication with Mollie's API. |
||
43 | type Client struct { |
||
44 | BaseURL *url.URL |
||
45 | authentication string |
||
46 | userAgent string |
||
47 | client *http.Client |
||
48 | common service // Reuse a single struct instead of allocating one for each service on the heap. |
||
49 | config *Config |
||
50 | // Tools |
||
51 | idempotencyKeyProvider idempotency.KeyGenerator |
||
52 | // Services |
||
53 | Payments *PaymentsService |
||
54 | Chargebacks *ChargebacksService |
||
55 | PaymentMethods *PaymentMethodsService |
||
56 | Invoices *InvoicesService |
||
57 | Organizations *OrganizationsService |
||
58 | Profiles *ProfilesService |
||
59 | Refunds *RefundsService |
||
60 | Shipments *ShipmentsService |
||
61 | Orders *OrdersService |
||
62 | Settlements *SettlementsService |
||
63 | Captures *CapturesService |
||
64 | Subscriptions *SubscriptionsService |
||
65 | Customers *CustomersService |
||
66 | Wallets *WalletsService |
||
67 | Mandates *MandatesService |
||
68 | Permissions *PermissionsService |
||
69 | Onboarding *OnboardingService |
||
70 | PaymentLinks *PaymentLinksService |
||
71 | Clients *ClientsService |
||
72 | Balances *BalancesService |
||
73 | ClientLinks *ClientLinksService |
||
74 | Terminals *TerminalsService |
||
75 | } |
||
76 | |||
77 | type service struct { |
||
78 | client *Client |
||
79 | } |
||
80 | |||
81 | func (c *Client) get(ctx context.Context, uri string, options interface{}) (res *Response, err error) { |
||
82 | 1 | if options != nil { |
|
83 | 1 | v, _ := query.Values(options) |
|
84 | 1 | uri = fmt.Sprintf("%s?%s", uri, v.Encode()) |
|
85 | } |
||
86 | |||
87 | 1 | req, err := c.NewAPIRequest(ctx, http.MethodGet, uri, nil) |
|
88 | 1 | if err != nil { |
|
89 | 1 | return |
|
90 | } |
||
91 | |||
92 | 1 | return c.Do(req) |
|
93 | } |
||
94 | |||
95 | func (c *Client) post(ctx context.Context, uri string, body interface{}, options interface{}) ( |
||
96 | res *Response, |
||
97 | err error, |
||
98 | ) { |
||
99 | 1 | if options != nil { |
|
100 | 1 | v, _ := query.Values(options) |
|
101 | 1 | uri = fmt.Sprintf("%s?%s", uri, v.Encode()) |
|
102 | } |
||
103 | |||
104 | 1 | req, err := c.NewAPIRequest(ctx, http.MethodPost, uri, body) |
|
105 | 1 | if err != nil { |
|
106 | 1 | return |
|
107 | } |
||
108 | |||
109 | 1 | return c.Do(req) |
|
110 | } |
||
111 | |||
112 | func (c *Client) patch(ctx context.Context, uri string, body interface{}) ( |
||
113 | res *Response, |
||
114 | err error, |
||
115 | ) { |
||
116 | 1 | req, err := c.NewAPIRequest(ctx, http.MethodPatch, uri, body) |
|
117 | if err != nil { |
||
118 | return |
||
119 | } |
||
120 | |||
121 | 1 | return c.Do(req) |
|
122 | 1 | } |
|
123 | 1 | ||
124 | func (c *Client) delete(ctx context.Context, uri string) (res *Response, err error) { |
||
125 | req, err := c.NewAPIRequest(ctx, http.MethodDelete, uri, nil) |
||
126 | 1 | if err != nil { |
|
127 | return |
||
128 | } |
||
129 | |||
130 | 1 | return c.Do(req) |
|
131 | } |
||
132 | |||
133 | // WithAuthenticationValue offers a convenient setter for any of the valid authentication |
||
134 | // tokens provided by Mollie. |
||
135 | 1 | // |
|
136 | 1 | // Ideally your API key will be provided from and environment variable or |
|
137 | 1 | // a secret management engine. |
|
138 | // This should only be used when environment variables are "impossible" to be used. |
||
139 | func (c *Client) WithAuthenticationValue(k string) error { |
||
140 | 1 | if k == "" { |
|
141 | return errEmptyAuthKey |
||
142 | } |
||
143 | |||
144 | c.authentication = strings.TrimSpace(k) |
||
145 | |||
146 | return nil |
||
147 | } |
||
148 | |||
149 | // HasAccessToken will return true when the provided authentication token |
||
150 | 1 | // complies with the access token REGEXP match check. |
|
151 | 1 | // This will enable TestMode inside the request body. |
|
152 | // |
||
153 | // See: https://github.com/VictorAvelar/mollie-api-go/issues/123 |
||
154 | 1 | func (c *Client) HasAccessToken() bool { |
|
155 | return accessTokenExpr.Match([]byte(c.authentication)) |
||
156 | 1 | } |
|
157 | |||
158 | // SetIdempotencyKeyGenerator allows you to pass your own idempotency |
||
159 | // key generator. |
||
160 | func (c *Client) SetIdempotencyKeyGenerator(kg idempotency.KeyGenerator) { |
||
161 | c.idempotencyKeyProvider = kg |
||
162 | } |
||
163 | |||
164 | // NewAPIRequest is a wrapper around the http.NewRequest function. |
||
165 | 1 | // |
|
166 | // It will setup the authentication headers/parameters according to the client config. |
||
167 | func (c *Client) NewAPIRequest(ctx context.Context, method string, uri string, body interface{}) ( |
||
168 | req *http.Request, |
||
169 | err error, |
||
170 | ) { |
||
171 | 1 | //nolint: contextcheck |
|
172 | if !strings.HasSuffix(c.BaseURL.Path, "/") { |
||
173 | return nil, errBadBaseURL |
||
174 | } |
||
175 | |||
176 | url, err := c.BaseURL.Parse(uri) |
||
177 | if err != nil { |
||
178 | return nil, fmt.Errorf("url_parsing_error: %w", err) |
||
0 ignored issues
–
show
introduced
by
![]() |
|||
179 | } |
||
180 | |||
181 | if c.config.testing && c.HasAccessToken() { |
||
182 | 1 | qp := url.Query() |
|
183 | 1 | qp.Add("testmode", "true") |
|
184 | url.RawQuery = qp.Encode() |
||
185 | } |
||
186 | 1 | ||
187 | 1 | var buf io.ReadWriter |
|
188 | 1 | if body != nil { |
|
189 | buf = new(bytes.Buffer) |
||
190 | enc := json.NewEncoder(buf) |
||
191 | 1 | enc.SetEscapeHTML(false) |
|
192 | 1 | ||
193 | 1 | err := enc.Encode(body) |
|
194 | 1 | if err != nil { |
|
195 | return nil, fmt.Errorf("encoding_error: %w", err) |
||
0 ignored issues
–
show
|
|||
196 | } |
||
197 | 1 | } |
|
198 | 1 | ||
199 | 1 | if ctx == nil { |
|
200 | 1 | ctx = context.Background() |
|
201 | 1 | } |
|
202 | |||
203 | 1 | req, err = http.NewRequestWithContext(ctx, method, url.String(), buf) |
|
204 | 1 | if err != nil { |
|
205 | 1 | return nil, fmt.Errorf("new_request: %w", err) |
|
0 ignored issues
–
show
|
|||
206 | } |
||
207 | |||
208 | c.addRequestHeaders(req) |
||
209 | 1 | ||
210 | 1 | return req, nil |
|
211 | } |
||
212 | |||
213 | 1 | func (c *Client) addRequestHeaders(req *http.Request) { |
|
214 | 1 | req.Header.Add(AuthHeader, strings.Join([]string{TokenType, c.authentication}, " ")) |
|
215 | 1 | req.Header.Set("Content-Type", RequestContentType) |
|
216 | req.Header.Set("Accept", RequestContentType) |
||
217 | req.Header.Set("User-Agent", c.userAgent) |
||
218 | 1 | ||
219 | if c.config.reqIdempotency && |
||
220 | 1 | c.idempotencyKeyProvider != nil && |
|
221 | req.Method == http.MethodPost { |
||
222 | req.Header.Set(IdempotencyKeyHeader, c.idempotencyKeyProvider.Generate()) |
||
223 | } |
||
224 | 1 | } |
|
225 | 1 | ||
226 | 1 | // Do sends an API request and returns the API response or returned as an |
|
227 | 1 | // error if an API error has occurred. |
|
228 | func (c *Client) Do(req *http.Request) (*Response, error) { |
||
229 | 1 | resp, err := c.client.Do(req) |
|
230 | if err != nil { |
||
231 | return nil, fmt.Errorf("http_error: %w", err) |
||
0 ignored issues
–
show
|
|||
232 | 1 | } |
|
233 | defer resp.Body.Close() |
||
234 | |||
235 | response, err := newResponse(resp) |
||
236 | if err != nil { |
||
237 | return response, err |
||
238 | } |
||
239 | 1 | ||
240 | 1 | err = CheckResponse(response) |
|
241 | 1 | if err != nil { |
|
242 | return response, err |
||
243 | 1 | } |
|
244 | |||
245 | 1 | return response, nil |
|
246 | 1 | } |
|
247 | |||
248 | // NewClient returns a new Mollie HTTP API client. |
||
249 | // You can pass a previously build http client, if none is provided then |
||
250 | 1 | // http.DefaultClient will be used. |
|
251 | 1 | // |
|
252 | 1 | // NewClient will lookup the environment for values to assign to the |
|
253 | // API token (`MOLLIE_API_TOKEN`) and the Organization token (`MOLLIE_ORG_TOKEN`) |
||
254 | // according to the provided Config object. |
||
255 | 1 | // |
|
256 | // You can also set the token values programmatically by using the Client |
||
257 | // WithAPIKey and WithOrganizationKey functions. |
||
258 | func NewClient(baseClient *http.Client, conf *Config) (mollie *Client, err error) { |
||
259 | if baseClient == nil { |
||
260 | baseClient = http.DefaultClient |
||
261 | } |
||
262 | |||
263 | uri, _ := url.Parse(BaseURL) |
||
264 | |||
265 | mollie = &Client{ |
||
266 | BaseURL: uri, |
||
267 | client: baseClient, |
||
268 | config: conf, |
||
269 | 1 | idempotencyKeyProvider: nil, |
|
270 | 1 | } |
|
271 | |||
272 | mollie.common.client = mollie |
||
273 | 1 | ||
274 | if mollie.config.reqIdempotency { |
||
275 | 1 | mollie.common.client.idempotencyKeyProvider = idempotency.NewStdGenerator() |
|
276 | } |
||
277 | |||
278 | // services for resources |
||
279 | mollie.Payments = (*PaymentsService)(&mollie.common) |
||
280 | mollie.Chargebacks = (*ChargebacksService)(&mollie.common) |
||
281 | mollie.PaymentMethods = (*PaymentMethodsService)(&mollie.common) |
||
282 | 1 | mollie.Invoices = (*InvoicesService)(&mollie.common) |
|
283 | mollie.Organizations = (*OrganizationsService)(&mollie.common) |
||
284 | 1 | mollie.Profiles = (*ProfilesService)(&mollie.common) |
|
285 | 1 | mollie.Refunds = (*RefundsService)(&mollie.common) |
|
286 | mollie.Shipments = (*ShipmentsService)(&mollie.common) |
||
287 | mollie.Orders = (*OrdersService)(&mollie.common) |
||
288 | mollie.Captures = (*CapturesService)(&mollie.common) |
||
289 | 1 | mollie.Settlements = (*SettlementsService)(&mollie.common) |
|
290 | 1 | mollie.Subscriptions = (*SubscriptionsService)(&mollie.common) |
|
291 | 1 | mollie.Customers = (*CustomersService)(&mollie.common) |
|
292 | 1 | mollie.Wallets = (*WalletsService)(&mollie.common) |
|
293 | 1 | mollie.Mandates = (*MandatesService)(&mollie.common) |
|
294 | 1 | mollie.Permissions = (*PermissionsService)(&mollie.common) |
|
295 | 1 | mollie.Onboarding = (*OnboardingService)(&mollie.common) |
|
296 | 1 | mollie.PaymentLinks = (*PaymentLinksService)(&mollie.common) |
|
297 | 1 | mollie.Clients = (*ClientsService)(&mollie.common) |
|
298 | 1 | mollie.Balances = (*BalancesService)(&mollie.common) |
|
299 | 1 | mollie.ClientLinks = (*ClientLinksService)(&mollie.common) |
|
300 | 1 | mollie.Terminals = (*TerminalsService)(&mollie.common) |
|
301 | 1 | ||
302 | 1 | mollie.userAgent = strings.Join([]string{ |
|
303 | 1 | ClientName, |
|
304 | 1 | Version, |
|
305 | 1 | goData, |
|
306 | 1 | }, "/") |
|
307 | 1 | ||
308 | 1 | // Parse authorization from specified environment variable |
|
309 | 1 | tkn, ok := os.LookupEnv(mollie.config.auth) |
|
310 | 1 | if ok { |
|
311 | mollie.authentication = tkn |
||
312 | 1 | } |
|
313 | |||
314 | return mollie, nil |
||
315 | } |
||
316 | |||
317 | /* |
||
318 | Constructor for Error. |
||
319 | 1 | */ |
|
320 | 1 | func newError(rsp *Response) error { |
|
321 | 1 | baseErr := &BaseError{} |
|
322 | |||
323 | if rsp.ContentLength > 0 { |
||
324 | 1 | err := json.Unmarshal(rsp.content, baseErr) |
|
325 | if err != nil { |
||
326 | return err |
||
327 | } |
||
328 | } else { |
||
329 | baseErr.Status = rsp.StatusCode |
||
330 | baseErr.Title = rsp.Status |
||
331 | 1 | baseErr.Detail = string(rsp.content) |
|
332 | } |
||
333 | 1 | ||
334 | 1 | return baseErr |
|
335 | 1 | } |
|
336 | |||
337 | // Response is a Mollie API response. This wraps the standard http.Response |
||
338 | // returned from Mollie and provides convenient access to things like |
||
339 | 1 | // pagination links. |
|
340 | 1 | type Response struct { |
|
341 | 1 | *http.Response |
|
342 | content []byte |
||
343 | } |
||
344 | 1 | ||
345 | func newResponse(rsp *http.Response) (*Response, error) { |
||
346 | res := Response{Response: rsp} |
||
347 | |||
348 | data, err := io.ReadAll(rsp.Body) |
||
349 | if err != nil { |
||
350 | return &res, err |
||
351 | } |
||
352 | |||
353 | res.content = data |
||
354 | |||
355 | rsp.Body = io.NopCloser(bytes.NewBuffer(data)) |
||
356 | 1 | res.Response = rsp |
|
357 | |||
358 | 1 | return &res, nil |
|
359 | 1 | } |
|
360 | |||
361 | // CheckResponse checks the API response for errors, and returns them if |
||
362 | // present. A response is considered an error if it has a status code outside |
||
363 | 1 | // the 200 range. |
|
364 | // API error responses are expected to have either no response |
||
365 | 1 | // body, or a JSON response body. |
|
366 | 1 | func CheckResponse(r *Response) error { |
|
367 | if r.StatusCode >= http.StatusMultipleChoices { |
||
368 | 1 | return newError(r) |
|
369 | } |
||
370 | |||
371 | return nil |
||
372 | } |
||
373 |