-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adding the library to the repo, including a basic README + CI
- Loading branch information
Showing
10 changed files
with
625 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
allowRemediationCommits: | ||
individual: true | ||
require: | ||
members: false |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
name: Test | ||
|
||
on: push | ||
|
||
jobs: | ||
|
||
test: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v3 | ||
|
||
- name: Set up Go | ||
uses: actions/setup-go@v3 | ||
with: | ||
go-version: 1.20 | ||
|
||
- name: Verify dependencies | ||
run: go mod verify | ||
|
||
- name: Build | ||
run: go build -v ./... | ||
|
||
- name: Run go vet | ||
run: go vet ./... | ||
|
||
- name: Run tests | ||
run: go test -race -vet=off ./... | ||
|
||
- name: golangci-lint | ||
uses: golangci/golangci-lint-action@v3 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
/*.key | ||
/*.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
# Aplos API Client | ||
|
||
[![GoDoc](https://pkg.go.dev/badge/github.com/Silicon-Ally/aplos?status.svg)](https://pkg.go.dev/github.com/Silicon-Ally/aplos?tab=doc) | ||
|
||
Note: This is a pre-v1.0.0 library, expect the API surface to change. | ||
|
||
This repo provides a minimal [Aplos API](https://www.aplos.com/api) client in Go, including authentication and a few basic read-only endpoints. Aplos is an online platform for nonprofits + churches to manage their general operations. | ||
|
||
The covered API surface is currently quite minimal—if there's API endpoints or parameters that would be useful to you, feel free to file an issue! | ||
|
||
## Usage | ||
|
||
```golang | ||
|
||
import "github.com/Silicon-Ally/aplos" | ||
|
||
... | ||
|
||
See [the `examples/` directory](/examples) for examples of using the API client. | ||
|
||
## Contributing | ||
|
||
Contribution guidelines can be found [on our website](https://siliconally.org/oss/contributor-guidelines). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,318 @@ | ||
// Package aplos provides basic support for the Aplos API, see https://www.aplos.com/api | ||
// This package is very much still under development. | ||
package aplos | ||
|
||
import ( | ||
"context" | ||
"crypto/rsa" | ||
"crypto/x509" | ||
"encoding/base64" | ||
"encoding/json" | ||
"fmt" | ||
"io/ioutil" | ||
"net/http" | ||
"net/url" | ||
"strconv" | ||
"time" | ||
|
||
"golang.org/x/net/context/ctxhttp" | ||
"golang.org/x/oauth2" | ||
) | ||
|
||
// LoadPrivateKeyFromFile loads a base64-encoded, PKCS8-formatted RSA key file | ||
// from disk. This is the format returned from the Aplos UI when creating and | ||
// downloading an API key. | ||
func LoadPrivateKeyFromFile(fp string) (*rsa.PrivateKey, error) { | ||
// One could use os.Open + base64.NewDecoder to stream the file, but for a key | ||
// file, which is a fixed size, there's no harm in just loading the whole thing | ||
// into memory straight away. | ||
b64EncDat, err := ioutil.ReadFile(fp) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to read key file: %w", err) | ||
} | ||
|
||
dat, err := base64.StdEncoding.DecodeString(string(b64EncDat)) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to base64 decode: %w", err) | ||
} | ||
|
||
return LoadPrivateKey(dat) | ||
} | ||
|
||
// LoadPrivateKey parses PKCS8-formatted bytes into an RSA key. | ||
func LoadPrivateKey(dat []byte) (*rsa.PrivateKey, error) { | ||
key, err := x509.ParsePKCS8PrivateKey(dat) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to parse key as PKCS8: %w", err) | ||
} | ||
|
||
k, ok := key.(*rsa.PrivateKey) | ||
if !ok { | ||
return nil, fmt.Errorf("key was not an RSA key, was %T", key) | ||
} | ||
|
||
return k, nil | ||
} | ||
|
||
// Client is an authenticated API client for connecting to Aplos. | ||
type Client struct { | ||
http *http.Client | ||
} | ||
|
||
// Transaction represents a single transaction recorded in a register. | ||
type Transaction struct { | ||
ID int | ||
Memo string | ||
Date Date | ||
IDNumber int `json:"id_number"` | ||
Created Time | ||
Amount float64 | ||
InClosedPeriod bool `json:"in_closed_period"` | ||
|
||
// Lines is only populated in the "get single transaction details" endpoint, e.g. GET /.../v1/transactions/{transactionID} | ||
Lines []TransactionLine | ||
} | ||
|
||
// TransactionLine is a single line in a larger transaction, like a journal entry. | ||
type TransactionLine struct { | ||
ID int | ||
Amount float64 | ||
Account Account | ||
Fund Fund | ||
} | ||
|
||
type Account struct { | ||
AccountNumber int `json:"account_number"` | ||
Name string | ||
|
||
// Populated in ListAccounts | ||
Category string | ||
AccountGroup *AccountGroup `json:"account_group"` | ||
IsEnabled bool `json:"is_enabled"` | ||
Type string | ||
Activity string | ||
} | ||
|
||
type AccountGroup struct { | ||
ID int | ||
Name string | ||
Seq int | ||
} | ||
|
||
type Fund struct { | ||
ID int | ||
Name string | ||
} | ||
|
||
type getTransactionResponse struct { | ||
Version string | ||
Status int | ||
Data getTransactionResponseData | ||
} | ||
|
||
type getTransactionResponseData struct { | ||
Transaction Transaction | ||
} | ||
|
||
func (c *Client) Transaction(ctx context.Context, id int) (*Transaction, error) { | ||
resp, err := ctxhttp.Get(ctx, c.http, "https://www.aplos.com/hermes/api/v1/transactions/"+strconv.Itoa(id)) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to list accounts: %w", err) | ||
} | ||
defer resp.Body.Close() | ||
|
||
var gResp getTransactionResponse | ||
if err := json.NewDecoder(resp.Body).Decode(&gResp); err != nil { | ||
return nil, fmt.Errorf("failed to decode get transaction response: %w", err) | ||
} | ||
|
||
return &gResp.Data.Transaction, nil | ||
} | ||
|
||
type listAccountsResponse struct { | ||
Version string | ||
Status int | ||
Data listAccountsResponseData | ||
} | ||
|
||
type listAccountsResponseData struct { | ||
Accounts []Account | ||
} | ||
|
||
type listAccountsOpts struct { | ||
accountName *string | ||
} | ||
|
||
func WithAccountName(acctName string) ListAccountOption { | ||
return func(o *listAccountsOpts) { | ||
o.accountName = &acctName | ||
} | ||
} | ||
|
||
type ListAccountOption func(*listAccountsOpts) | ||
|
||
// Accounts returns a list of accounts satisfying the given options. | ||
func (c *Client) Accounts(ctx context.Context, opts ...ListAccountOption) ([]Account, error) { | ||
o := &listAccountsOpts{} | ||
for _, opt := range opts { | ||
opt(o) | ||
} | ||
|
||
q := url.Values{} | ||
if o.accountName != nil { | ||
q.Add("f_name", *o.accountName) | ||
} | ||
|
||
resp, err := ctxhttp.Get(ctx, c.http, "https://www.aplos.com/hermes/api/v1/accounts?"+q.Encode()) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to list accounts: %w", err) | ||
} | ||
defer resp.Body.Close() | ||
|
||
var lResp listAccountsResponse | ||
if err := json.NewDecoder(resp.Body).Decode(&lResp); err != nil { | ||
return nil, fmt.Errorf("failed to decode list accounts response: %w", err) | ||
} | ||
|
||
return lResp.Data.Accounts, nil | ||
} | ||
|
||
type listTransactionsResponse struct { | ||
Version string | ||
Status int | ||
Data listTransactionsResponseData | ||
} | ||
|
||
type listTransactionsResponseData struct { | ||
Transactions []Transaction | ||
} | ||
|
||
type listTransactionsOpts struct { | ||
accountNumber *int | ||
rangeStart *Date | ||
rangeEnd *Date | ||
} | ||
|
||
func WithAccountNumber(acctNumber int) ListTransactionOption { | ||
return func(o *listTransactionsOpts) { | ||
o.accountNumber = &acctNumber | ||
} | ||
} | ||
|
||
func WithRangeStart(year int, month time.Month, day int) ListTransactionOption { | ||
return func(o *listTransactionsOpts) { | ||
o.rangeStart = &Date{Year: year, Month: month, Day: day} | ||
} | ||
} | ||
|
||
func WithRangeEnd(year int, month time.Month, day int) ListTransactionOption { | ||
return func(o *listTransactionsOpts) { | ||
o.rangeEnd = &Date{Year: year, Month: month, Day: day} | ||
} | ||
} | ||
|
||
type ListTransactionOption func(*listTransactionsOpts) | ||
|
||
// Transactions returns a list of transactions satisfying the given options. | ||
func (c *Client) Transactions(ctx context.Context, opts ...ListTransactionOption) ([]Transaction, error) { | ||
o := &listTransactionsOpts{} | ||
for _, opt := range opts { | ||
opt(o) | ||
} | ||
|
||
q := url.Values{} | ||
if o.accountNumber != nil { | ||
q.Add("f_accountnumber", strconv.Itoa(*o.accountNumber)) | ||
} | ||
if o.rangeStart != nil { | ||
q.Add("f_rangestart", o.rangeStart.String()) | ||
} | ||
if o.rangeEnd != nil { | ||
q.Add("f_rangeend", o.rangeEnd.String()) | ||
} | ||
|
||
resp, err := ctxhttp.Get(ctx, c.http, "https://www.aplos.com/hermes/api/v1/transactions?"+q.Encode()) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to list transactions: %w", err) | ||
} | ||
defer resp.Body.Close() | ||
|
||
var lResp listTransactionsResponse | ||
if err := json.NewDecoder(resp.Body).Decode(&lResp); err != nil { | ||
return nil, fmt.Errorf("failed to decode list transactions response: %w", err) | ||
} | ||
|
||
return lResp.Data.Transactions, nil | ||
} | ||
|
||
// New returns an Aplos API client initialized with the given key credentials. | ||
// If the credentials are invalid (expired, mismatched, malformed, etc), this | ||
// call with fail. | ||
func New(clientID string, pk *rsa.PrivateKey) (*Client, error) { | ||
ts, err := newTokenSource(clientID, pk) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to get access token: %w", err) | ||
} | ||
|
||
return &Client{ | ||
http: oauth2.NewClient(context.Background(), ts), | ||
}, nil | ||
} | ||
|
||
func newTokenSource(clientID string, key *rsa.PrivateKey) (oauth2.TokenSource, error) { | ||
t := &ts{key: key, clientID: clientID} | ||
tkn, err := t.Token() | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to get token: %w", err) | ||
} | ||
return oauth2.ReuseTokenSource(tkn, t), nil | ||
} | ||
|
||
type ts struct { | ||
clientID string | ||
key *rsa.PrivateKey | ||
} | ||
|
||
type authResponse struct { | ||
Version string | ||
Status int | ||
Data authResponseData | ||
} | ||
|
||
type authResponseData struct { | ||
Expires Time | ||
Token string | ||
} | ||
|
||
// Token performs the Aplos authentication handshake of downloaded the | ||
// encrypted access token for our Client ID and decrypting it with our private | ||
// key credentials. For more details, see the Aplos API Authentication docs: | ||
// https://www.aplos.com/api/authentication | ||
func (t *ts) Token() (*oauth2.Token, error) { | ||
resp, err := http.Get("https://www.aplos.com/hermes/api/v1/auth/" + t.clientID) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to query auth endpoint: %w", err) | ||
} | ||
defer resp.Body.Close() | ||
|
||
var authResp authResponse | ||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { | ||
return nil, fmt.Errorf("failed to decode auth response: %w", err) | ||
} | ||
|
||
encToken, err := base64.StdEncoding.DecodeString(authResp.Data.Token) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to base64 decode encrypted token: %w", err) | ||
} | ||
|
||
dec, err := rsa.DecryptPKCS1v15(nil, t.key, encToken) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to decrypt token: %w", err) | ||
} | ||
|
||
return &oauth2.Token{ | ||
AccessToken: string(dec), | ||
TokenType: "Bearer", | ||
Expiry: authResp.Data.Expires.Time, | ||
}, nil | ||
} |
Oops, something went wrong.