Skip to content

Commit 3e73dde

Browse files
committed
Initial commit
0 parents  commit 3e73dde

9 files changed

+622
-0
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# go-bunq
2+
3+
A Go wrapper for working with the [public bunq API](https://www.bunq.com/en/api).

bunq.go

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package bunq
2+
3+
import (
4+
"crypto/rsa"
5+
"net/http"
6+
"time"
7+
8+
"github.com/satori/go.uuid"
9+
)
10+
11+
const (
12+
baseURL = "https://api.bunq.com"
13+
apiVersion = "v1"
14+
clientVersion = "1.0.0"
15+
userAgent = "go-bunq/" + clientVersion
16+
timestampLayout = "2006-01-02 15:04:05.000000"
17+
)
18+
19+
// Client is the API client for the public bunq API.
20+
type Client struct {
21+
HTTPClient *http.Client
22+
BaseURL string
23+
APIKey string
24+
Token string
25+
PrivateKey *rsa.PrivateKey
26+
}
27+
28+
// NewClient returns a new Client.
29+
func NewClient() *Client {
30+
return &Client{
31+
HTTPClient: http.DefaultClient,
32+
BaseURL: baseURL,
33+
}
34+
}
35+
36+
func setCommonHeaders(r *http.Request) {
37+
r.Header.Set("Cache-Control", "no-cache")
38+
r.Header.Set("User-Agent", userAgent)
39+
r.Header.Set("X-Bunq-Client-Request-Id", uuid.NewV4().String())
40+
r.Header.Set("X-Bunq-Geolocation", "0 0 0 0 NL")
41+
r.Header.Set("X-Bunq-Language", "en_US")
42+
r.Header.Set("X-Bunq-Region", "en_US")
43+
}
44+
45+
func parseTimestamp(value string) (time.Time, error) {
46+
return time.Parse(timestampLayout, value)
47+
}

crypto.go

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package bunq
2+
3+
import (
4+
"crypto"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"crypto/sha256"
8+
"crypto/x509"
9+
"encoding/base64"
10+
"encoding/pem"
11+
"errors"
12+
"fmt"
13+
"io"
14+
"io/ioutil"
15+
"net/http"
16+
"sort"
17+
)
18+
19+
type header struct {
20+
Key string
21+
Value string
22+
}
23+
24+
// byKey implements sort.Interface for []header based on the key field.
25+
type byKey []header
26+
27+
func (k byKey) Len() int { return len(k) }
28+
func (k byKey) Swap(i, j int) { k[i], k[j] = k[j], k[i] }
29+
func (k byKey) Less(i, j int) bool { return k[i].Key < k[j].Key }
30+
31+
func (c *Client) addSignature(req *http.Request, endpoint, body string) error {
32+
if c.PrivateKey == nil {
33+
return errors.New("bunq: private key cannot be nil")
34+
}
35+
var headers []header
36+
for key, val := range req.Header {
37+
for j := range val {
38+
headers = append(headers, header{
39+
Key: key,
40+
Value: val[j],
41+
})
42+
}
43+
}
44+
sort.Sort(byKey(headers))
45+
46+
message := endpoint + "\n"
47+
for i := range headers {
48+
if string([]byte(headers[i].Key)[:7]) == "X-Bunq-" || headers[i].Key == "User-Agent" || headers[i].Key == "Cache-Control" {
49+
message += headers[i].Key + ": " + headers[i].Value + "\n"
50+
}
51+
}
52+
message += "\n" + body
53+
54+
h := sha256.New()
55+
_, err := h.Write([]byte(message))
56+
if err != nil {
57+
return err
58+
}
59+
60+
signature, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, h.Sum(nil))
61+
if err != nil {
62+
return err
63+
}
64+
65+
req.Header.Set("X-Bunq-Client-Signature", base64.StdEncoding.EncodeToString(signature))
66+
67+
return nil
68+
}
69+
70+
// SetPrivateKey reads and parses private key data into a private key.
71+
func (c *Client) SetPrivateKey(r io.Reader) error {
72+
pemData, err := ioutil.ReadAll(r)
73+
if err != nil {
74+
return fmt.Errorf("bunq: error reading PEM data: %v", err)
75+
}
76+
77+
pemBlock, _ := pem.Decode(pemData)
78+
if pemBlock == nil {
79+
return errors.New("bunq: no PEM block found in data")
80+
}
81+
if pemBlock.Type != "RSA PRIVATE KEY" {
82+
return errors.New("bunq: invalid key type found, expected `RSA PRIVATE KEY`")
83+
}
84+
privKey, err := x509.ParsePKCS1PrivateKey(pemBlock.Bytes)
85+
if err != nil {
86+
return fmt.Errorf("bunq: error parsing PEM block into private key")
87+
}
88+
c.PrivateKey = privKey
89+
90+
return nil
91+
}
92+
93+
func (c *Client) publicKey() ([]byte, error) {
94+
if c.PrivateKey == nil {
95+
return nil, errors.New("private key cannot be nil")
96+
}
97+
pubKeyDer, err := x509.MarshalPKIXPublicKey(c.PrivateKey.Public())
98+
if err != nil {
99+
return nil, fmt.Errorf("error serializing public key to DER-encoded PKIX format: %v", err)
100+
}
101+
pubKeyPemBlock := pem.Block{
102+
Type: "PUBLIC KEY",
103+
Headers: nil,
104+
Bytes: pubKeyDer,
105+
}
106+
107+
return pem.EncodeToMemory(&pubKeyPemBlock), nil
108+
}

device_server.go

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package bunq
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"net"
9+
"net/http"
10+
"time"
11+
)
12+
13+
type deviceServerResponse struct {
14+
Response []struct {
15+
ID *struct {
16+
ID int `json:"id"`
17+
} `json:"Id,omitempty"`
18+
DeviceServer *struct {
19+
ID int `json:"id"`
20+
Created string `json:"created"`
21+
Updated string `json:"updated"`
22+
Description string `json:"description"`
23+
IP string `json:"ip"`
24+
Status string `json:"status"`
25+
} `json:"DeviceServer,omitempty"`
26+
} `json:"Response"`
27+
}
28+
29+
// A DeviceServer represents a DeviceServe at the bunq API.
30+
type DeviceServer struct {
31+
ID int
32+
CreatedAt time.Time
33+
UpdatedAt time.Time
34+
Description string
35+
IP net.IP
36+
Status string
37+
}
38+
39+
// CreateDeviceServer creates a DeviceServer resource at the bunq API.
40+
func (c *Client) CreateDeviceServer(description string, permittedIPs []net.IP) (*DeviceServer, error) {
41+
var ips []string
42+
for i := range permittedIPs {
43+
ips = append(ips, permittedIPs[i].String())
44+
}
45+
body := struct {
46+
Description string `json:"description"`
47+
Secret string `json:"secret"`
48+
PermittedIPs []string `json:"permitted_ips,omitempty"`
49+
}{description, c.APIKey, ips}
50+
51+
bodyJSON, err := json.Marshal(body)
52+
if err != nil {
53+
return nil, fmt.Errorf("bunq: could not encode request body into JSON: %v", err)
54+
}
55+
56+
endpoint := apiVersion + "/device-server"
57+
req, err := http.NewRequest("POST", fmt.Sprintf("%v/%v", c.BaseURL, endpoint), bytes.NewReader(bodyJSON))
58+
if err != nil {
59+
return nil, fmt.Errorf("bunq: could not create new request: %v", err)
60+
}
61+
setCommonHeaders(req)
62+
req.Header.Set("X-Bunq-Client-Authentication", c.Token)
63+
if err = c.addSignature(req, "POST /"+endpoint, string(bodyJSON)); err != nil {
64+
return nil, fmt.Errorf("bunq: could not add signature: %v", err)
65+
}
66+
67+
resp, err := c.HTTPClient.Do(req)
68+
if err != nil {
69+
return nil, fmt.Errorf("bunq: could not send HTTP request: %v", err)
70+
}
71+
defer resp.Body.Close()
72+
73+
if resp.StatusCode != http.StatusOK {
74+
return nil, fmt.Errorf("bunq: request was unsuccessful: %v", decodeError(resp.Body))
75+
}
76+
77+
var apiResp deviceServerResponse
78+
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
79+
return nil, fmt.Errorf("bunq: could not decode HTTP response: %v", err)
80+
}
81+
82+
if len(apiResp.Response) == 0 {
83+
return nil, errors.New("bunq: api response did not contain results")
84+
}
85+
86+
deviceServer := &DeviceServer{}
87+
for i := range apiResp.Response {
88+
if apiResp.Response[i].ID != nil {
89+
deviceServer.ID = apiResp.Response[i].ID.ID
90+
continue
91+
}
92+
if apiResp.Response[i].DeviceServer != nil {
93+
deviceServer.ID = apiResp.Response[i].ID.ID
94+
deviceServer.Description = apiResp.Response[i].DeviceServer.Description
95+
createdAt, err := parseTimestamp(apiResp.Response[i].DeviceServer.Created)
96+
if err != nil {
97+
return nil, fmt.Errorf("bunq: could not parse created timestamp: %v", err)
98+
}
99+
deviceServer.CreatedAt = createdAt
100+
updatedAt, err := parseTimestamp(apiResp.Response[i].DeviceServer.Updated)
101+
if err != nil {
102+
return nil, fmt.Errorf("bunq: could not parse updated timestamp: %v", err)
103+
}
104+
deviceServer.UpdatedAt = updatedAt
105+
continue
106+
}
107+
}
108+
109+
return deviceServer, nil
110+
}

device_server_test.go

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package bunq
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"net/http"
7+
"net/http/httptest"
8+
"reflect"
9+
"strings"
10+
"testing"
11+
)
12+
13+
func TestCreateDeviceServer(t *testing.T) {
14+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
fmt.Fprintln(w, `{"Response":[{"Id":{"id":83}}]}`)
16+
}))
17+
defer ts.Close()
18+
19+
client := NewClient()
20+
client.BaseURL = ts.URL
21+
22+
privKey := `-----BEGIN RSA PRIVATE KEY-----
23+
MIIEpAIBAAKCAQEA74uJ/q21yZk67NzktTL3khNpOnfsKVa7Xev/0W8rvPmhxPzT
24+
N1OmrAFDzbVLtxdJ5T9yHtzEb0qfsESVPCayaa9tqfOm3Bg1qa5SWrSgCEW2BvPD
25+
t12HDBVeelh4L3NFnj6ulLhfvi86+a2DHfdUx9OLt4huOpQxFstxLAa3zvJhH7OH
26+
GotwzXyvJhMuJBUUSx7ZWenY2yz632D6N7+0t2DQyhtZ1s5e4SCtNV/1si+7KzXs
27+
PVgi6slIYdUqaiAZJF0YGgQovGkr0s8povNm5KgR4Ovqv2M3In+hO04YqnLTHrrn
28+
5VSL49kQGWphzTrp9T5wwn5/ZwPnnB7YcDAOEwIDAQABAoIBAHl3eHH8A8JGQOr6
29+
175KKd+YmDNdvBL6N+hYU1AP303kB3OsAC597HYr7gXReKNO29mzYlrj93e3j2IC
30+
ZOordSzCGAml02anoA56pqf4D24iazr7QLMqaeBmtZG0ar0k5phnkH85PtNhf7Y7
31+
ldEMKaFqU96s/7gUjQ/R+YEppur4Yb/eXFfvYFUbFuc3CHQyUradKBJxKh4H7Lys
32+
hRPpHVOQkCGYwiTrJz/9/bL86tnI/nbug59nXhTe3aai0qaHTeMJ/G2xbUkXefoh
33+
c5xKCTBkw1KgZ24ZUYm6gJdFPRNYA5SieZWt3mIfLQ6zQc/k614kxRcew0fGW4Ri
34+
5+sVSoECgYEA+hsdDBvAYaSDgHhs2XV/vX9kuUJiDB+3OGmXWQ3YN8mzv/tBTQPX
35+
8goSwEOVKeiZfCKvpP2bJSwv4sBNujSDXJCrHZBJwf55Wj/kcgdp12D/rifHsFQv
36+
rJ1WJjpSlchcbQ1PdQtQP7hO5QcjK6U7m5To1kiDcZYfkWqcATY9tU8CgYEA9TC2
37+
TzD9pzjJBT86u2PO2IOfwkxV6HiEWA5OPPRNaPybKk4a+fvnaufG00zLrOwRj4Tp
38+
asMqlYACY/x8xFIB5QxNCYQTLvMlaSovgr+rSkm+bGrYmHk/ITy4AbUiy8ioZvTm
39+
t5CfcaO7WmrlV+dcJ0ftzE2UTX0BJyaNtzIUcf0CgYEA7+DbbkabsMsCGVDnTXaF
40+
qzGpYIpL0ccFiwSzVYWS0IcTcNnCGuTJ1GpW67KmOUjPFSGLh1p52CBWWUwKAMLn
41+
DvvuMu+13mt85tOK/tcfa6Sr9dRPkU5dX1iUTRv5I5HFHA79G4xbTpIukTnUQMM8
42+
tY8P9p4b+/B5nJY8xGjKrL8CgYEAxDLYj4HqV1dPNA2ml7CEIikhO78Nt1pIvJWl
43+
8YykLPCF0VJyr7rtMVSKeyamjJbSbn+ysCW/+6VVRGEUDZx5u6keNBElsJoMQ5zo
44+
K73n+SgNYoAVFd1fsN7/dw5U67CDYO9zd0wY6jxUfUOwhaiyyxP5q1Qg6eivdX6a
45+
RA+k4JkCgYBjaTlXVWFC45HKuttYNpIGPUwowiqzcmx8rx17ymrI8qxxhSAA+nf1
46+
N3xaYHI6thoqfyq4JxMBxvBYDQBKhnCfLxk9AO2O/Uq7OOiRhWqtV4SdMD7Hb7O9
47+
GZ4h0C1AqVJNAxfUH5p7vKxp4E73SYVry88zdHkFj6nYfgXkasBBVA==
48+
-----END RSA PRIVATE KEY-----`
49+
r := strings.NewReader(privKey)
50+
if err := client.SetPrivateKey(r); err != nil {
51+
t.Fatal(err)
52+
}
53+
54+
got, err := client.CreateDeviceServer("Foobar", []net.IP{})
55+
if err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
exp := &DeviceServer{
60+
ID: 83,
61+
}
62+
63+
if eq := reflect.DeepEqual(exp, got); !eq {
64+
t.Errorf("Expected: `%#v`, got: `%#v`", exp, got)
65+
}
66+
67+
}

error.go

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package bunq
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
)
8+
9+
// Error represents an error returned by the bunq API.
10+
type Error struct {
11+
ErrorDescription string
12+
ErrorDescriptionTranslated string
13+
}
14+
15+
// Errors is an array of Error structs.
16+
type Errors []Error
17+
18+
func (e Errors) Error() string {
19+
var errs []string
20+
for i := range e {
21+
errs = append(errs, e[i].ErrorDescription)
22+
}
23+
24+
return fmt.Sprintf("%v", errs)
25+
}
26+
27+
func decodeError(r io.Reader) error {
28+
var apiError struct {
29+
Error []struct {
30+
ErrorDescription string `json:"error_description"`
31+
ErrorDescriptionTranslated string `json:"error_description_translated"`
32+
} `json:"Error"`
33+
}
34+
err := json.NewDecoder(r).Decode(&apiError)
35+
if err != nil {
36+
return fmt.Errorf("bunq: could not decode errors from json: %v", err)
37+
}
38+
39+
var errors Errors
40+
for i := range apiError.Error {
41+
errors = append(errors, Error{
42+
ErrorDescription: apiError.Error[i].ErrorDescription,
43+
ErrorDescriptionTranslated: apiError.Error[i].ErrorDescriptionTranslated,
44+
})
45+
}
46+
47+
return errors
48+
}

0 commit comments

Comments
 (0)