Skip to content

Commit 9ab9eea

Browse files
authored
feat!: move accounts to top level, add self link (#138)
1 parent 239a529 commit 9ab9eea

19 files changed

+650
-1052
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,5 @@ repos:
3333
name: Create swagger docs.go
3434
language: system
3535
pass_filenames: false
36-
entry: swag init --parseDependency
36+
entry: swag init --parseDependency --output ./swag
3737
files: ^internal/.*

.prettierignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
docs/**
1+
swag/**

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ COPY go.mod go.sum ./
77
RUN go mod download
88

99
COPY internal ./internal
10-
COPY docs ./docs
10+
COPY swag ./swag
1111
COPY main.go Makefile ./
1212

1313
ARG VERSION=0.0.0

docs/api.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# API Design documentation
2+
3+
This document contains the API design. It is aimed at developers and to support administrators in debugging issues.
4+
5+
## API Responses
6+
7+
All API responses either have an emty body (for HTTP 204 and HTTP 404 responses) or the body consists of only JSON.
8+
9+
All API responses have **either** a `data` or an `error` top level key. They can’t appear at the same time.
10+
11+
The `error` key always has a value of type `string`, containing a human readable error message. Those error messages are intended to be passed to the user of the application.
12+
13+
The `data` key is either a list of objects (for collection endpoints) or a single object (for resource endpoints).
14+
15+
Unset attributes are not contained in the objects that the API returns. Unless an attribute is defined in here to be always contained in API responses, it is optional
16+
17+
### Reserved keys
18+
19+
The objects in the `data` key have several reserved keys that are read-only:
20+
21+
- `createdAt`: An RFC3339 timestamp of the time when the resource was created. Always present.
22+
- `updatedAt`: An RFC3339 timestamp of the time when the resource was updated. Always present.
23+
- `deletedAt`: An RFC3339 timestamp of the time when the resource was deleted.
24+
- `id`: The ID of the object. IDs are unique for the backend instance. Always present.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,4 @@ require (
7575

7676
replace github.com/envelope-zero/backend/internal/controllers => ./internal/controllers
7777

78-
replace github.com/envelope-zero/backend/docs => ./docs
78+
replace github.com/envelope-zero/backend/swag => ./swag

internal/controllers/account.go

Lines changed: 82 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ import (
1010
)
1111

1212
type AccountListResponse struct {
13-
Data []models.Account `json:"data"`
13+
Data []Account `json:"data"`
1414
}
1515

1616
type AccountResponse struct {
17-
Data models.Account `json:"data"`
18-
Links AccountLinks `json:"links"`
17+
Data Account `json:"data"`
18+
}
19+
20+
type Account struct {
21+
models.Account
22+
Links AccountLinks `json:"links"`
1923
}
2024

2125
type AccountLinks struct {
22-
Transactions string `json:"transactions" example:"https://example.com/api/v1/budgets/3/accounts/17/transactions"`
26+
Self string `json:"self" example:"https://example.com/api/v1/accounts/17"`
2327
}
2428

2529
// RegisterAccountRoutes registers the routes for accounts with
@@ -39,25 +43,6 @@ func RegisterAccountRoutes(r *gin.RouterGroup) {
3943
r.PATCH("/:accountId", UpdateAccount)
4044
r.DELETE("/:accountId", DeleteAccount)
4145
}
42-
43-
// Transactions
44-
{
45-
r.OPTIONS("/:accountId/transactions", OptionsAccountTransactions)
46-
r.GET("/:accountId/transactions", GetAccountTransactions)
47-
}
48-
}
49-
50-
// @Summary Allowed HTTP verbs
51-
// @Description Returns an empty response with the HTTP Header "allow" set to the allowed HTTP verbs
52-
// @Tags Accounts
53-
// @Success 204
54-
// @Failure 400 {object} httputil.HTTPError
55-
// @Failure 404
56-
// @Param budgetId path uint64 true "ID of the budget"
57-
// @Param accountId path uint64 true "ID of the account"
58-
// @Router /v1/budgets/{budgetId}/accounts/{accountId}/transactions [options]
59-
func OptionsAccountTransactions(c *gin.Context) {
60-
httputil.OptionsGet(c)
6146
}
6247

6348
// @Summary Allowed HTTP verbs
@@ -66,8 +51,7 @@ func OptionsAccountTransactions(c *gin.Context) {
6651
// @Success 204
6752
// @Failure 400 {object} httputil.HTTPError
6853
// @Failure 404
69-
// @Param budgetId path uint64 true "ID of the budget"
70-
// @Router /v1/budgets/{budgetId}/accounts [options]
54+
// @Router /v1/accounts [options]
7155
func OptionsAccountList(c *gin.Context) {
7256
httputil.OptionsGetPost(c)
7357
}
@@ -78,63 +62,39 @@ func OptionsAccountList(c *gin.Context) {
7862
// @Success 204
7963
// @Failure 400 {object} httputil.HTTPError
8064
// @Failure 404
81-
// @Param budgetId path uint64 true "ID of the budget"
8265
// @Param accountId path uint64 true "ID of the account"
83-
// @Router /v1/budgets/{budgetId}/accounts/{accountId} [options]
66+
// @Router /v1/accounts/{accountId} [options]
8467
func OptionsAccountDetail(c *gin.Context) {
8568
httputil.OptionsGetPatchDelete(c)
8669
}
8770

88-
// @Summary List all transactions for an account
89-
// @Description Returns a list of all transactions for the account requested
90-
// @Tags Accounts
91-
// @Produce json
92-
// @Success 200 {object} TransactionListResponse
93-
// @Failure 400 {object} httputil.HTTPError
94-
// @Failure 404
95-
// @Failure 500 {object} httputil.HTTPError
96-
// @Param budgetId path uint64 true "ID of the budget"
97-
// @Param accountId path uint64 true "ID of the account"
98-
// @Router /v1/budgets/{budgetId}/accounts/{accountId}/transactions [get]
99-
func GetAccountTransactions(c *gin.Context) {
100-
account, err := getAccountResource(c)
101-
if err != nil {
102-
return
103-
}
104-
105-
c.JSON(http.StatusOK, TransactionListResponse{
106-
Data: account.Transactions(),
107-
})
108-
}
109-
11071
// @Summary Create account
11172
// @Description Create a new account for a specific budget
11273
// @Tags Accounts
11374
// @Produce json
11475
// @Success 201 {object} AccountResponse
11576
// @Failure 400 {object} httputil.HTTPError
11677
// @Failure 404
117-
// @Failure 500 {object} httputil.HTTPError
118-
// @Param budgetId path uint64 true "ID of the budget"
119-
// @Param account body models.AccountCreate true "Account"
120-
// @Router /v1/budgets/{budgetId}/accounts [post]
78+
// @Failure 500 {object} httputil.HTTPError
79+
// @Param account body models.AccountCreate true "Account"
80+
// @Router /v1/accounts [post]
12181
func CreateAccount(c *gin.Context) {
122-
var data models.Account
82+
var account models.Account
12383

124-
if status, err := httputil.BindData(c, &data); err != nil {
125-
httputil.NewError(c, status, err)
84+
if err := httputil.BindData(c, &account); err != nil {
12685
return
12786
}
12887

129-
budget, err := getBudgetResource(c)
88+
// Check if the budget that the account shoud belong to exists
89+
_, err := getBudget(c, account.BudgetID)
13090
if err != nil {
13191
return
13292
}
13393

134-
data.BudgetID = budget.ID
135-
models.DB.Create(&data)
94+
models.DB.Create(&account)
13695

137-
c.JSON(http.StatusCreated, AccountResponse{Data: data})
96+
accountObject, _ := getAccountObject(c, account.ID)
97+
c.JSON(http.StatusCreated, AccountResponse{Data: accountObject})
13898
}
13999

140100
// @Summary List accounts
@@ -144,29 +104,24 @@ func CreateAccount(c *gin.Context) {
144104
// @Success 200 {object} AccountListResponse
145105
// @Failure 400 {object} httputil.HTTPError
146106
// @Failure 404
147-
// @Failure 500 {object} httputil.HTTPError
148-
// @Param budgetId path uint64 true "ID of the budget"
149-
// @Router /v1/budgets/{budgetId}/accounts [get]
107+
// @Failure 500 {object} httputil.HTTPError
108+
// @Router /v1/accounts [get]
150109
func GetAccounts(c *gin.Context) {
151110
var accounts []models.Account
152111

153-
// Check if the budget exists at all
154-
budget, err := getBudgetResource(c)
155-
if err != nil {
156-
return
157-
}
112+
models.DB.Where(&models.Account{}).Find(&accounts)
158113

159-
models.DB.Where(&models.Account{
160-
AccountCreate: models.AccountCreate{
161-
BudgetID: budget.ID,
162-
},
163-
}).Find(&accounts)
114+
// When there are no accounts, we want an empty list, not null
115+
// Therefore, we use make to create a slice with zero elements
116+
// which will be marshalled to an empty JSON array
117+
accountObjects := make([]Account, 0)
164118

165-
for i, account := range accounts {
166-
accounts[i] = account.WithCalculations()
119+
for _, account := range accounts {
120+
o, _ := getAccountObject(c, account.ID)
121+
accountObjects = append(accountObjects, o)
167122
}
168123

169-
c.JSON(http.StatusOK, AccountListResponse{Data: accounts})
124+
c.JSON(http.StatusOK, AccountListResponse{Data: accountObjects})
170125
}
171126

172127
// @Summary Get account
@@ -177,16 +132,20 @@ func GetAccounts(c *gin.Context) {
177132
// @Failure 400 {object} httputil.HTTPError
178133
// @Failure 404
179134
// @Failure 500 {object} httputil.HTTPError
180-
// @Param budgetId path uint64 true "ID of the budget"
181-
// @Param accountId path uint64 true "ID of the account"
182-
// @Router /v1/budgets/{budgetId}/accounts/{accountId} [get]
135+
// @Param accountId path uint64 true "ID of the account"
136+
// @Router /v1/accounts/{accountId} [get]
183137
func GetAccount(c *gin.Context) {
184-
_, err := getAccountResource(c)
138+
id, err := httputil.ParseID(c, "accountId")
139+
if err != nil {
140+
return
141+
}
142+
143+
accountObject, err := getAccountObject(c, id)
185144
if err != nil {
186145
return
187146
}
188147

189-
c.JSON(http.StatusOK, newAccountResponse(c))
148+
c.JSON(http.StatusOK, AccountResponse{Data: accountObject})
190149
}
191150

192151
// @Summary Update account
@@ -197,24 +156,28 @@ func GetAccount(c *gin.Context) {
197156
// @Failure 400 {object} httputil.HTTPError
198157
// @Failure 404
199158
// @Failure 500 {object} httputil.HTTPError
200-
// @Param budgetId path uint64 true "ID of the budget"
201-
// @Param accountId path uint64 true "ID of the account"
159+
// @Param accountId path uint64 true "ID of the account"
202160
// @Param account body models.AccountCreate true "Account"
203-
// @Router /v1/budgets/{budgetId}/accounts/{accountId} [patch]
161+
// @Router /v1/accounts/{accountId} [patch]
204162
func UpdateAccount(c *gin.Context) {
205-
account, err := getAccountResource(c)
163+
id, err := httputil.ParseID(c, "accountId")
164+
if err != nil {
165+
return
166+
}
167+
168+
account, err := getAccountResource(c, id)
206169
if err != nil {
207170
return
208171
}
209172

210173
var data models.Account
211-
if status, err := httputil.BindData(c, &data); err != nil {
212-
httputil.NewError(c, status, err)
174+
if err := httputil.BindData(c, &data); err != nil {
213175
return
214176
}
215177

216178
models.DB.Model(&account).Updates(data)
217-
c.JSON(http.StatusOK, newAccountResponse(c))
179+
accountObject, _ := getAccountObject(c, account.ID)
180+
c.JSON(http.StatusOK, AccountResponse{Data: accountObject})
218181
}
219182

220183
// @Summary Delete account
@@ -225,11 +188,15 @@ func UpdateAccount(c *gin.Context) {
225188
// @Failure 400 {object} httputil.HTTPError
226189
// @Failure 404
227190
// @Failure 500 {object} httputil.HTTPError
228-
// @Param budgetId path uint64 true "ID of the budget"
229191
// @Param accountId path uint64 true "ID of the account"
230-
// @Router /v1/budgets/{budgetId}/accounts/{accountId} [delete]
192+
// @Router /v1/accounts/{accountId} [delete]
231193
func DeleteAccount(c *gin.Context) {
232-
account, err := getAccountResource(c)
194+
id, err := httputil.ParseID(c, "accountId")
195+
if err != nil {
196+
return
197+
}
198+
199+
account, err := getAccountResource(c, id)
233200
if err != nil {
234201
return
235202
}
@@ -239,28 +206,15 @@ func DeleteAccount(c *gin.Context) {
239206
c.JSON(http.StatusNoContent, gin.H{})
240207
}
241208

242-
// getAccountResource verifies that the request URI is valid for the account and returns it.
243-
func getAccountResource(c *gin.Context) (models.Account, error) {
209+
// getAccountResource is the internal helper to verify permissions and return an account.
210+
func getAccountResource(c *gin.Context, id uint64) (models.Account, error) {
244211
var account models.Account
245212

246-
budget, err := getBudgetResource(c)
247-
if err != nil {
248-
return models.Account{}, err
249-
}
250-
251-
accountID, err := httputil.ParseID(c, "accountId")
252-
if err != nil {
253-
return models.Account{}, err
254-
}
255-
256-
err = models.DB.First(&account, &models.Account{
257-
AccountCreate: models.AccountCreate{
258-
BudgetID: budget.ID,
259-
},
213+
err := models.DB.Where(&models.Account{
260214
Model: models.Model{
261-
ID: accountID,
215+
ID: id,
262216
},
263-
}).Error
217+
}).First(&account).Error
264218
if err != nil {
265219
httputil.FetchErrorHandler(c, err)
266220
return models.Account{}, err
@@ -269,18 +223,26 @@ func getAccountResource(c *gin.Context) (models.Account, error) {
269223
return account, nil
270224
}
271225

272-
// newAccountResponse creates a response object for an account.
273-
func newAccountResponse(c *gin.Context) AccountResponse {
274-
// When this function is called, all parent resources have already been validated
275-
budget, _ := getBudgetResource(c)
276-
account, _ := getAccountResource(c)
226+
func getAccountObject(c *gin.Context, id uint64) (Account, error) {
227+
resource, err := getAccountResource(c, id)
228+
if err != nil {
229+
return Account{}, err
230+
}
277231

278-
url := httputil.RequestPathV1(c) + fmt.Sprintf("/budgets/%d/accounts/%d", budget.ID, account.ID)
232+
return Account{
233+
resource.WithCalculations(),
234+
getAccountLinks(c, resource.ID),
235+
}, nil
236+
}
279237

280-
return AccountResponse{
281-
Data: account.WithCalculations(),
282-
Links: AccountLinks{
283-
Transactions: url + "/transactions",
284-
},
238+
// getAccountLinks returns an AccountLinks struct.
239+
//
240+
// This function is only needed for newAccountResponse as we cannot create an instance of Account
241+
// with mixed named and unnamed parameters.
242+
func getAccountLinks(c *gin.Context, id uint64) AccountLinks {
243+
url := httputil.RequestPathV1(c) + fmt.Sprintf("/accounts/%d", id)
244+
245+
return AccountLinks{
246+
Self: url,
285247
}
286248
}

0 commit comments

Comments
 (0)