Skip to content

Commit 1c81a08

Browse files
authored
feat!: budgets with new API layout (#140)
1 parent 9ab9eea commit 1c81a08

File tree

17 files changed

+313
-304
lines changed

17 files changed

+313
-304
lines changed

docs/api.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22

33
This document contains the API design. It is aimed at developers and to support administrators in debugging issues.
44

5-
## API Responses
5+
## High level guarantees
6+
7+
- Any resource will be available at the endpoints `/{resource}` for the collection and `/{resource}/{id}` for a single resource.
8+
- Filtering on collection endpoints is implemented with URL paramaters (“query strings“)
9+
- Collections always support the HTTP methods `GET` (read resources) and `POST` (create new resource)
10+
- Resources support the HTTP methods `GET` (read resource), `PATCH` (update resource) and `DELETE` (delete resource)
11+
12+
## API responses
613

714
All API responses either have an emty body (for HTTP 204 and HTTP 404 responses) or the body consists of only JSON.
815

@@ -12,13 +19,15 @@ The `error` key always has a value of type `string`, containing a human readable
1219

1320
The `data` key is either a list of objects (for collection endpoints) or a single object (for resource endpoints).
1421

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
22+
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 with tse string `Always present`, it is optional.
1623

17-
### Reserved keys
24+
## API resources
1825

19-
The objects in the `data` key have several reserved keys that are read-only:
26+
API resources share the following **read only** attributes in the `data` key:
2027

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.
28+
- `createdAt` (string): An RFC3339 timestamp of the time when the resource was created. Always present.
29+
- `updatedAt` (string): An RFC3339 timestamp of the time when the resource was updated. Always present.
30+
- `deletedAt` (string): An RFC3339 timestamp of the time when the resource was deleted.
31+
- `id` (number): The ID of the object. IDs are unique for the backend instance. Always present.
32+
- `links` (object): A map of related resources. Always present.
33+
- `self` (string): The full URL of the resource itself. Always present.

internal/controllers/account.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func OptionsAccountDetail(c *gin.Context) {
6969
}
7070

7171
// @Summary Create account
72-
// @Description Create a new account for a specific budget
72+
// @Description Create a new account
7373
// @Tags Accounts
7474
// @Produce json
7575
// @Success 201 {object} AccountResponse
@@ -86,7 +86,7 @@ func CreateAccount(c *gin.Context) {
8686
}
8787

8888
// Check if the budget that the account shoud belong to exists
89-
_, err := getBudget(c, account.BudgetID)
89+
_, err := getBudgetResource(c, account.BudgetID)
9090
if err != nil {
9191
return
9292
}
@@ -98,7 +98,7 @@ func CreateAccount(c *gin.Context) {
9898
}
9999

100100
// @Summary List accounts
101-
// @Description Returns a list of all accounts for the budget
101+
// @Description Returns a list of all accounts
102102
// @Tags Accounts
103103
// @Produce json
104104
// @Success 200 {object} AccountListResponse
@@ -109,7 +109,7 @@ func CreateAccount(c *gin.Context) {
109109
func GetAccounts(c *gin.Context) {
110110
var accounts []models.Account
111111

112-
models.DB.Where(&models.Account{}).Find(&accounts)
112+
models.DB.Find(&accounts)
113113

114114
// When there are no accounts, we want an empty list, not null
115115
// Therefore, we use make to create a slice with zero elements
@@ -237,7 +237,7 @@ func getAccountObject(c *gin.Context, id uint64) (Account, error) {
237237

238238
// getAccountLinks returns an AccountLinks struct.
239239
//
240-
// This function is only needed for newAccountResponse as we cannot create an instance of Account
240+
// This function is only needed for getAccountObject as we cannot create an instance of Account
241241
// with mixed named and unnamed parameters.
242242
func getAccountLinks(c *gin.Context, id uint64) AccountLinks {
243243
url := httputil.RequestPathV1(c) + fmt.Sprintf("/accounts/%d", id)

internal/controllers/account_test.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,8 @@ func TestGetAccounts(t *testing.T) {
4242
assert.Equal(t, true, externalAccount.External)
4343

4444
for _, account := range response.Data {
45-
diff := time.Now().Sub(account.CreatedAt)
46-
assert.LessOrEqual(t, diff, test.TOLERANCE)
47-
48-
diff = time.Now().Sub(account.UpdatedAt)
49-
assert.LessOrEqual(t, diff, test.TOLERANCE)
45+
assert.LessOrEqual(t, time.Since(account.CreatedAt), test.TOLERANCE)
46+
assert.LessOrEqual(t, time.Since(account.UpdatedAt), test.TOLERANCE)
5047
}
5148

5249
if !decimal.NewFromFloat(-30).Equal(bankAccount.Balance) {

internal/controllers/allocation_test.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,8 @@ func TestGetAllocations(t *testing.T) {
4141
assert.Fail(t, "Allocation amount does not equal 20.99", response.Data[0].Amount)
4242
}
4343

44-
diff := time.Now().Sub(response.Data[0].CreatedAt)
45-
assert.LessOrEqual(t, diff, test.TOLERANCE)
46-
47-
diff = time.Now().Sub(response.Data[0].UpdatedAt)
48-
assert.LessOrEqual(t, diff, test.TOLERANCE)
44+
assert.LessOrEqual(t, time.Since(response.Data[0].CreatedAt), test.TOLERANCE)
45+
assert.LessOrEqual(t, time.Since(response.Data[0].UpdatedAt), test.TOLERANCE)
4946
}
5047

5148
func TestNoAllocationNotFound(t *testing.T) {

internal/controllers/budget.go

Lines changed: 91 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,20 @@ import (
1212
)
1313

1414
type BudgetListResponse struct {
15-
Data []models.Budget `json:"data"`
15+
Data []Budget `json:"data"`
1616
}
1717

1818
type BudgetResponse struct {
19-
Data models.Budget `json:"data"`
20-
Links BudgetLinks `json:"links"`
19+
Data Budget `json:"data"`
20+
}
21+
22+
type Budget struct {
23+
models.Budget
24+
Links BudgetLinks `json:"links"`
2125
}
2226

2327
type BudgetLinks struct {
28+
Self string `json:"self" example:"https://example.com/api/v1/budgets/4"`
2429
Accounts string `json:"accounts" example:"https://example.com/api/v1/accounts?budget=2"`
2530
Categories string `json:"categories" example:"https://example.com/api/v1/budgets/2/categories"`
2631
Transactions string `json:"transactions" example:"https://example.com/api/v1/budgets/2/transactions"`
@@ -88,14 +93,16 @@ func OptionsBudgetDetail(c *gin.Context) {
8893
// @Param budget body models.BudgetCreate true "Budget"
8994
// @Router /v1/budgets [post]
9095
func CreateBudget(c *gin.Context) {
91-
var data models.Budget
96+
var budget models.Budget
9297

93-
if err := httputil.BindData(c, &data); err != nil {
98+
if err := httputil.BindData(c, &budget); err != nil {
9499
return
95100
}
96101

97-
models.DB.Create(&data)
98-
c.JSON(http.StatusCreated, BudgetResponse{Data: data})
102+
models.DB.Create(&budget)
103+
104+
budgetObject, _ := getBudgetObject(c, budget.ID)
105+
c.JSON(http.StatusCreated, BudgetResponse{Data: budgetObject})
99106
}
100107

101108
// @Summary List all budgets
@@ -109,9 +116,17 @@ func GetBudgets(c *gin.Context) {
109116
var budgets []models.Budget
110117
models.DB.Find(&budgets)
111118

112-
c.JSON(http.StatusOK, BudgetListResponse{
113-
Data: budgets,
114-
})
119+
// When there are no budgets, we want an empty list, not null
120+
// Therefore, we use make to create a slice with zero elements
121+
// which will be marshalled to an empty JSON array
122+
budgetObjects := make([]Budget, 0)
123+
124+
for _, budget := range budgets {
125+
o, _ := getBudgetObject(c, budget.ID)
126+
budgetObjects = append(budgetObjects, o)
127+
}
128+
129+
c.JSON(http.StatusOK, BudgetListResponse{Data: budgetObjects})
115130
}
116131

117132
// @Summary Get a budget
@@ -125,12 +140,17 @@ func GetBudgets(c *gin.Context) {
125140
// @Param budgetId path uint64 true "ID of the budget"
126141
// @Router /v1/budgets/{budgetId} [get]
127142
func GetBudget(c *gin.Context) {
128-
_, err := getBudgetResource(c)
143+
id, err := httputil.ParseID(c, "budgetId")
144+
if err != nil {
145+
return
146+
}
147+
148+
budgetObject, err := getBudgetObject(c, id)
129149
if err != nil {
130150
return
131151
}
132152

133-
c.JSON(http.StatusOK, newBudgetResponse(c))
153+
c.JSON(http.StatusOK, BudgetResponse{Data: budgetObject})
134154
}
135155

136156
// @Summary Get Budget month data
@@ -145,7 +165,12 @@ func GetBudget(c *gin.Context) {
145165
// @Param month path string true "The month in YYYY-MM format"
146166
// @Router /v1/budgets/{budgetId}/{month} [get]
147167
func GetBudgetMonth(c *gin.Context) {
148-
budget, err := getBudgetResource(c)
168+
id, err := httputil.ParseID(c, "budgetId")
169+
if err != nil {
170+
return
171+
}
172+
173+
budget, err := getBudgetResource(c, id)
149174
if err != nil {
150175
return
151176
}
@@ -160,9 +185,21 @@ func GetBudgetMonth(c *gin.Context) {
160185
return
161186
}
162187

163-
envelopes, err := getEnvelopeResources(c)
164-
if err != nil {
165-
return
188+
var envelopes []models.Envelope
189+
190+
categories, _ := getCategoryResources(c, budget.ID)
191+
192+
// Get envelopes for all categories
193+
for _, category := range categories {
194+
var e []models.Envelope
195+
196+
models.DB.Where(&models.Envelope{
197+
EnvelopeCreate: models.EnvelopeCreate{
198+
CategoryID: category.ID,
199+
},
200+
}).Find(&e)
201+
202+
envelopes = append(envelopes, e...)
166203
}
167204

168205
var envelopeMonths []models.EnvelopeMonth
@@ -191,7 +228,12 @@ func GetBudgetMonth(c *gin.Context) {
191228
// @Param budget body models.BudgetCreate true "Budget"
192229
// @Router /v1/budgets/{budgetId} [patch]
193230
func UpdateBudget(c *gin.Context) {
194-
budget, err := getBudgetResource(c)
231+
id, err := httputil.ParseID(c, "budgetId")
232+
if err != nil {
233+
return
234+
}
235+
236+
budget, err := getBudgetResource(c, id)
195237
if err != nil {
196238
return
197239
}
@@ -202,7 +244,8 @@ func UpdateBudget(c *gin.Context) {
202244
}
203245

204246
models.DB.Model(&budget).Updates(data)
205-
c.JSON(http.StatusOK, newBudgetResponse(c))
247+
budgetObject, _ := getBudgetObject(c, budget.ID)
248+
c.JSON(http.StatusOK, BudgetResponse{Data: budgetObject})
206249
}
207250

208251
// @Summary Delete a budget
@@ -215,41 +258,23 @@ func UpdateBudget(c *gin.Context) {
215258
// @Param budgetId path uint64 true "ID of the budget"
216259
// @Router /v1/budgets/{budgetId} [delete]
217260
func DeleteBudget(c *gin.Context) {
218-
budget, err := getBudgetResource(c)
261+
id, err := httputil.ParseID(c, "budgetId")
219262
if err != nil {
220263
return
221264
}
222265

223-
models.DB.Delete(&budget)
224-
225-
c.JSON(http.StatusNoContent, gin.H{})
226-
}
227-
228-
// getBudgetResource verifies that the budget from the URL parameters exists and returns it.
229-
func getBudgetResource(c *gin.Context) (models.Budget, error) {
230-
var budget models.Budget
231-
232-
budgetID, err := httputil.ParseID(c, "budgetId")
266+
budget, err := getBudgetResource(c, id)
233267
if err != nil {
234-
return models.Budget{}, err
268+
return
235269
}
236270

237-
// Check that the budget exists. If not, return a 404
238-
err = models.DB.Where(&models.Budget{
239-
Model: models.Model{
240-
ID: budgetID,
241-
},
242-
}).First(&budget).Error
243-
if err != nil {
244-
httputil.FetchErrorHandler(c, err)
245-
return models.Budget{}, err
246-
}
271+
models.DB.Delete(&budget)
247272

248-
return budget, nil
273+
c.JSON(http.StatusNoContent, gin.H{})
249274
}
250275

251-
// getBudget is the internal helper to verify permissions and return an account.
252-
func getBudget(c *gin.Context, id uint64) (models.Budget, error) {
276+
// getBudgetResource is the internal helper to verify permissions and return a budget.
277+
func getBudgetResource(c *gin.Context, id uint64) (models.Budget, error) {
253278
var budget models.Budget
254279

255280
err := models.DB.Where(&models.Budget{
@@ -265,19 +290,30 @@ func getBudget(c *gin.Context, id uint64) (models.Budget, error) {
265290
return budget, nil
266291
}
267292

268-
func newBudgetResponse(c *gin.Context) BudgetResponse {
269-
// When this function is called, the resource has already been validated
270-
budget, _ := getBudgetResource(c)
293+
func getBudgetObject(c *gin.Context, id uint64) (Budget, error) {
294+
resource, err := getBudgetResource(c, id)
295+
if err != nil {
296+
return Budget{}, err
297+
}
271298

272-
url := httputil.RequestPathV1(c) + fmt.Sprintf("/budgets/%d", budget.ID)
299+
return Budget{
300+
resource,
301+
getBudgetLinks(c, resource.ID),
302+
}, nil
303+
}
273304

274-
return BudgetResponse{
275-
Data: budget,
276-
Links: BudgetLinks{
277-
Accounts: httputil.RequestPathV1(c) + fmt.Sprintf("/accounts?budget=%d", budget.ID),
278-
Categories: url + "/transactions",
279-
Transactions: url + "/transactions",
280-
Month: url + "/YYYY-MM",
281-
},
305+
// getBudgetLinks returns a BudgetLinks struct.
306+
//
307+
// This function is only needed for getBudgetObject as we cannot create an instance of Budget
308+
// with mixed named and unnamed parameters.
309+
func getBudgetLinks(c *gin.Context, id uint64) BudgetLinks {
310+
url := httputil.RequestPathV1(c) + fmt.Sprintf("/budgets/%d", id)
311+
312+
return BudgetLinks{
313+
Self: url,
314+
Accounts: httputil.RequestPathV1(c) + fmt.Sprintf("/accounts?budget=%d", id),
315+
Categories: url + "/categories",
316+
Transactions: url + "/transactions",
317+
Month: url + "/YYYY-MM",
282318
}
283319
}

0 commit comments

Comments
 (0)