Passed
Push — main ( 2d22a4...4648c5 )
by Acho
01:50
created

smobilpay.*Client.Ping   A

Complexity

Conditions 4

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 11
dl 0
loc 17
rs 9.85
c 0
b 0
f 0
nop 2
1
package smobilpay
2
3
import (
4
	"bytes"
5
	"context"
6
	"crypto/hmac"
7
	"crypto/sha1"
8
	"encoding/base64"
9
	"encoding/json"
10
	"fmt"
11
	"io"
12
	"io/ioutil"
13
	"net/http"
14
	"net/url"
15
	"sort"
16
	"strings"
17
	"time"
18
)
19
20
type service struct {
21
	client *Client
22
}
23
24
// Client is the smobilpay API client.
25
// Do not instantiate this client with Client{}. Use the New method instead.
26
type Client struct {
27
	httpClient   *http.Client
28
	common       service
29
	baseURL      string
30
	accessToken  string
31
	accessSecret string
32
33
	Topup *topupService
34
	Bill  *billService
35
}
36
37
// New creates and returns a new *Client from a slice of Option.
38
func New(options ...Option) *Client {
39
	config := defaultClientConfig()
40
41
	for _, option := range options {
42
		option.apply(config)
43
	}
44
45
	client := &Client{
46
		httpClient:   config.httpClient,
47
		accessToken:  config.accessToken,
48
		accessSecret: config.accessSecret,
49
		baseURL:      config.baseURL,
50
	}
51
52
	client.common.client = client
53
	client.Topup = (*topupService)(&client.common)
54
	client.Bill = (*billService)(&client.common)
55
	return client
56
}
57
58
// Ping checks if the API is available
59
//
60
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
61
func (client *Client) Ping(ctx context.Context, options ...RequestOption) (*PingStatus, *Response, error) {
62
	request, err := client.newRequest(ctx, options, http.MethodGet, "/ping", nil)
63
	if err != nil {
64
		return nil, nil, err
65
	}
66
67
	response, err := client.do(request)
68
	if err != nil {
69
		return nil, response, err
70
	}
71
72
	status := new(PingStatus)
73
	if err = json.Unmarshal(*response.Body, status); err != nil {
74
		return nil, response, err
75
	}
76
77
	return status, response, nil
78
}
79
80
// Quote initializes a transaction
81
//
82
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
83
func (client *Client) Quote(ctx context.Context, params *QuoteParams, options ...RequestOption) (*Quote, *Response, error) {
84
	request, err := client.newRequest(ctx, options, http.MethodPost, "/quotestd", map[string]string{
85
		"payItemId": params.PayItemID,
86
		"amount":    params.Amount,
87
	})
88
	if err != nil {
89
		return nil, nil, err
90
	}
91
92
	response, err := client.do(request)
93
	if err != nil {
94
		return nil, response, err
95
	}
96
97
	packages := new(Quote)
98
	if err = json.Unmarshal(*response.Body, packages); err != nil {
99
		return nil, response, err
100
	}
101
102
	return packages, response, nil
103
}
104
105
// Collect confirms a transaction
106
//
107
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
108
func (client *Client) Collect(ctx context.Context, params *CollectParams, options ...RequestOption) (*Transaction, *Response, error) {
109
	request, err := client.newRequest(ctx, options, http.MethodPost, "/collectstd", params.toPayload())
110
	if err != nil {
111
		return nil, nil, err
112
	}
113
114
	response, err := client.do(request)
115
	if err != nil {
116
		return nil, response, err
117
	}
118
119
	transaction := new(Transaction)
120
	if err = json.Unmarshal(*response.Body, transaction); err != nil {
121
		return nil, response, err
122
	}
123
124
	return transaction, response, nil
125
}
126
127
// Verify gets the current collection status
128
//
129
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
130
func (client *Client) Verify(ctx context.Context, paymentTransactionNumber string, options ...RequestOption) (*Transaction, *Response, error) {
131
	request, err := client.newRequest(ctx, options, http.MethodGet, fmt.Sprintf("/verifytx?ptn=%s", paymentTransactionNumber), nil)
132
	if err != nil {
133
		return nil, nil, err
134
	}
135
136
	response, err := client.do(request)
137
	if err != nil {
138
		return nil, response, err
139
	}
140
141
	var transactions []*Transaction
142
	if err = json.Unmarshal(*response.Body, &transactions); err != nil {
143
		return nil, response, err
144
	}
145
146
	if len(transactions) == 0 {
147
		return nil, response, fmt.Errorf("cannot verify transaction with payment transaction number [%s]", paymentTransactionNumber)
148
	}
149
150
	return transactions[0], response, nil
151
}
152
153
// TransactionHistory gets the history of transactions
154
//
155
// https://apidocs.smobilpay.com/s3papi/API-Reference.2066448558.html
156
func (client *Client) TransactionHistory(ctx context.Context, from time.Time, to time.Time, options ...RequestOption) ([]*Transaction, *Response, error) {
157
	request, err := client.newRequest(
158
		ctx,
159
		options,
160
		http.MethodGet, fmt.Sprintf("/historystd?timestamp_from=%s&timestamp_to=%s", from.Format("2006-01-02T15:04:05.999Z"), to.Format("2006-01-02T15:04:05.999Z")),
161
		nil,
162
	)
163
	if err != nil {
164
		return nil, nil, err
165
	}
166
167
	response, err := client.do(request)
168
	if err != nil {
169
		return nil, response, err
170
	}
171
172
	var transactions []*Transaction
173
	if err = json.Unmarshal(*response.Body, &transactions); err != nil {
174
		return nil, response, err
175
	}
176
177
	return transactions, response, nil
178
}
179
180
func (client *Client) makeRequestConfig(options []RequestOption) *requestConfig {
181
	config := defaultRequestConfig()
182
183
	for _, option := range options {
184
		option.apply(config)
185
	}
186
187
	return config
188
}
189
190
// newRequest creates an API request. A relative URL can be provided in uri,
191
// in which case it is resolved relative to the BaseURL of the Client.
192
// URI's should always be specified without a preceding slash.
193
func (client *Client) newRequest(ctx context.Context, options []RequestOption, method, uri string, body map[string]string) (*http.Request, error) {
194
	config := client.makeRequestConfig(options)
195
196
	var buf io.ReadWriter
197
	if body != nil {
198
		buf = &bytes.Buffer{}
199
		enc := json.NewEncoder(buf)
200
		enc.SetEscapeHTML(false)
201
		err := enc.Encode(body)
202
		if err != nil {
203
			return nil, err
204
		}
205
	}
206
207
	req, err := http.NewRequestWithContext(ctx, method, client.baseURL+uri, buf)
208
	if err != nil {
209
		return nil, err
210
	}
211
212
	req.Header.Set("Content-Type", "application/json")
213
	req.Header.Set("Accept", "application/json")
214
	req.Header.Set("Authorization", client.getAuthHeader(req, config, client.authPayload(req, body)))
215
216
	return req, nil
217
}
218
219
func (client *Client) authPayload(request *http.Request, body map[string]string) map[string]string {
220
	payload := map[string]string{}
221
	for key, value := range request.URL.Query() {
222
		payload[key] = value[0]
223
	}
224
225
	for key, value := range body {
226
		payload[key] = value
227
	}
228
229
	return payload
230
}
231
232
func (client *Client) getBaseHmacAuthString(request *http.Request) string {
233
	return fmt.Sprintf(
234
		"%s&%s",
235
		strings.ToUpper(request.Method),
236
		url.QueryEscape(
237
			fmt.Sprintf("%s://%s%s", request.URL.Scheme, request.URL.Host, request.URL.Path),
238
		),
239
	)
240
}
241
242
func (client *Client) urlEncode(params url.Values) string {
243
	keys := make([]string, 0, len(params))
244
	for k := range params {
245
		keys = append(keys, k)
246
	}
247
	sort.Strings(keys)
248
249
	var buf strings.Builder
250
	for _, k := range keys {
251
		if buf.Len() > 0 {
252
			buf.WriteByte('&')
253
		}
254
		buf.WriteString(k)
255
		buf.WriteByte('=')
256
		buf.WriteString(params[k][0])
257
	}
258
259
	return buf.String()
260
}
261
262
func (client *Client) getPayloadHmacAuthString(config *requestConfig, payload map[string]string) string {
263
	params := url.Values{}
264
	params.Add("s3pAuth_nonce", config.nonce)
265
	params.Add("s3pAuth_signature_method", "HMAC-SHA1")
266
	params.Add("s3pAuth_timestamp", config.timestampString())
267
	params.Add("s3pAuth_token", client.accessToken)
268
269
	for key, value := range payload {
270
		params.Add(key, strings.TrimSpace(value))
271
	}
272
273
	return client.urlEncode(params)
274
}
275
276
func (client *Client) getAuthHeader(request *http.Request, config *requestConfig, payload map[string]string) string {
277
	return fmt.Sprintf(
278
		"s3pAuth,s3pAuth_nonce=\"%s\",s3pAuth_signature=\"%s\",s3pAuth_signature_method=\"HMAC-SHA1\",s3pAuth_timestamp=\"%s\",s3pAuth_token=\"%s\"",
279
		config.nonce,
280
		client.computeHmac(client.getBaseHmacAuthString(request)+"&"+url.QueryEscape(client.getPayloadHmacAuthString(config, payload))),
281
		config.timestampString(),
282
		client.accessToken,
283
	)
284
}
285
286
func (client *Client) computeHmac(message string) string {
287
	key := []byte(client.accessSecret)
288
	h := hmac.New(sha1.New, key)
289
	_, _ = h.Write([]byte(message))
290
	return base64.StdEncoding.EncodeToString(h.Sum(nil))
291
}
292
293
// do carries out an HTTP request and returns a Response
294
func (client *Client) do(req *http.Request) (*Response, error) {
295
	if req == nil {
296
		return nil, fmt.Errorf("%T cannot be nil", req)
297
	}
298
299
	httpResponse, err := client.httpClient.Do(req)
300
	if err != nil {
301
		return nil, err
302
	}
303
304
	defer func() { _ = httpResponse.Body.Close() }()
305
306
	resp, err := client.newResponse(httpResponse)
307
	if err != nil {
308
		return resp, err
309
	}
310
311
	_, err = io.Copy(ioutil.Discard, httpResponse.Body)
312
	if err != nil {
313
		return resp, err
314
	}
315
316
	return resp, nil
317
}
318
319
// newResponse converts an *http.Response to *Response
320
func (client *Client) newResponse(httpResponse *http.Response) (*Response, error) {
321
	if httpResponse == nil {
322
		return nil, fmt.Errorf("%T cannot be nil", httpResponse)
323
	}
324
325
	resp := new(Response)
326
	resp.HTTPResponse = httpResponse
327
328
	buf, err := ioutil.ReadAll(resp.HTTPResponse.Body)
329
	if err != nil {
330
		return nil, err
331
	}
332
	resp.Body = &buf
333
334
	return resp, resp.Error()
335
}
336