/
errors.go
257 lines (217 loc) · 10.3 KB
/
errors.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
package httperrors
import (
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"time"
"github.com/envelope-zero/backend/v3/pkg/models"
"github.com/gin-contrib/requestid"
"github.com/gin-gonic/gin"
"github.com/glebarez/go-sqlite"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
var (
ErrAccountIDParameter = errors.New("the accountId parameter must be set")
ErrAccountNameNotUnique = errors.New("the account name must be unique for the budget")
ErrCategoryNameNotUnique = errors.New("the category name must be unique for the budget")
ErrCleanupConfirmation = errors.New("the confirmation for the cleanup API call was incorrect")
ErrDatabaseClosed = errors.New("there is a problem with the database connection, please try again later")
ErrDatabaseReadOnly = errors.New("the database is currently in read-only mode, please try again later")
ErrEnvelopeNameNotUniqe = errors.New("the envelope name must be unique for the category")
ErrFileEmpty = errors.New("the file you uploaded is empty or invalid")
ErrInvalidBody = errors.New("the body of your request contains invalid or un-parseable data. Please check and try again")
ErrInvalidMonth = errors.New("could not parse the specified month, did you use YYYY-MM format?")
ErrInvalidQueryString = errors.New("the query string contains unparseable data. Please check the values")
ErrInvalidUUID = errors.New("the specified resource ID is not a valid UUID")
ErrMonthNotSetInQuery = errors.New("the month query parameter must be set")
ErrMultipleAllocations = errors.New("you can not create multiple allocations for the same month")
ErrNoFilePost = errors.New("you must send a file to this endpoint")
ErrNoResource = errors.New("there is no resource for the ID you specified")
ErrReferenceResourceDoesNotExist = errors.New("a resource you are referencing in another resource does not exist")
ErrRequestBodyEmpty = errors.New("the request body must not be empty")
ErrSourceEqualsDestination = errors.New("source and destination accounts for a transaction must be different")
)
// Generate a struct containing the HTTP error on the fly.
func New(c *gin.Context, status int, msgAndArgs ...any) {
// Format msgAndArgs in a final string.
// This is taken almost exactly from https://github.com/stretchr/testify/blob/181cea6eab8b2de7071383eca4be32a424db38dd/assert/assertions.go#L181
msg := ""
if len(msgAndArgs) == 1 {
// If the only argument is a pointer to a string
if msgAsStr, ok := msgAndArgs[0].(*string); ok {
msg = *msgAsStr
}
// If it is a string
if msgAsStr, ok := msgAndArgs[0].(string); ok {
msg = msgAsStr
}
msg = fmt.Sprintf("%+v", msg)
}
if len(msgAndArgs) > 1 {
msg = fmt.Sprintf(msgAndArgs[0].(string), msgAndArgs[1:]...)
}
c.JSON(status, HTTPError{
Error: msg,
})
}
func InvalidUUID(c *gin.Context) {
New(c, http.StatusBadRequest, ErrInvalidUUID.Error())
}
func InvalidQueryString(c *gin.Context) {
New(c, http.StatusBadRequest, ErrInvalidQueryString.Error())
}
func InvalidMonth(c *gin.Context) {
New(c, http.StatusBadRequest, ErrInvalidMonth.Error())
}
// GenericDBError wraps DBError with a more specific error message for not found errors.
func GenericDBError[T models.Model](r T, c *gin.Context, err error) Error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Error{Status: http.StatusNotFound, Err: fmt.Errorf("there is no %s with this ID", r.Self())}
}
return Parse(c, err)
}
// DBError returns an error message and status code appropriate to the error that has occurred.
func DBError(c *gin.Context, err error) Error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Error{Status: http.StatusNotFound, Err: ErrNoResource}
}
// Availability month is set before the month of the transaction
if strings.Contains(err.Error(), "availability month must not be earlier than the month of the transaction") {
return Error{Status: http.StatusBadRequest, Err: err}
}
// Account cannot be on budget because transactions have envelopes
if strings.Contains(err.Error(), "the account cannot be set to on budget because") {
return Error{Status: http.StatusBadRequest, Err: err}
}
// Account name must be unique per Budget
if strings.Contains(err.Error(), "UNIQUE constraint failed: accounts.name, accounts.budget_id") {
return Error{Status: http.StatusBadRequest, Err: ErrAccountNameNotUnique}
}
// Category names need to be unique per budget
if strings.Contains(err.Error(), "UNIQUE constraint failed: categories.name, categories.budget_id") {
return Error{Status: http.StatusBadRequest, Err: ErrCategoryNameNotUnique}
}
// Unique envelope names per category
if strings.Contains(err.Error(), "UNIQUE constraint failed: envelopes.name, envelopes.category_id") {
return Error{Status: http.StatusBadRequest, Err: ErrEnvelopeNameNotUniqe}
}
// Only one allocation per envelope per month
if strings.Contains(err.Error(), "UNIQUE constraint failed: allocations.month, allocations.envelope_id") {
return Error{Status: http.StatusBadRequest, Err: ErrMultipleAllocations}
}
// Source and destination accounts need to be different
if strings.Contains(err.Error(), "CHECK constraint failed: source_destination_different") {
return Error{Status: http.StatusBadRequest, Err: ErrSourceEqualsDestination}
}
// General message when a field references a non-existing resource
if strings.Contains(err.Error(), "constraint failed: FOREIGN KEY constraint failed") {
return Error{Status: http.StatusBadRequest, Err: ErrReferenceResourceDoesNotExist}
}
// Database is read only or file has been deleted
if strings.Contains(err.Error(), "attempt to write a readonly database (1032)") {
log.Error().Msgf("Database is in read-only mode. This might be due to the file having been deleted: %#v", err)
return Error{Status: http.StatusInternalServerError, Err: ErrDatabaseReadOnly}
}
// A general error we do not know more about
log.Error().Msgf("%T: %v", err, err.Error())
return Error{Status: http.StatusInternalServerError, Err: fmt.Errorf("an error occurred on the server during your request, please contact your server administrator. The request id is '%v', send this to your server administrator to help them finding the problem", requestid.Get(c))}
}
// Parse parses an error and returns an appropriate Error struct.
//
// If the error is not well known, it is logged and a generic message
// with the Request ID returned. This is done to prevent leaking sensitive
// data.
func Parse(c *gin.Context, err error) Error {
// No record found => 404
if errors.Is(err, gorm.ErrRecordNotFound) {
return Error{
Status: http.StatusNotFound,
Err: ErrNoResource,
}
}
if errors.Is(err, models.ErrGoalAmountNotPositive) {
return Error{Status: http.StatusBadRequest, Err: err}
}
// Allocation is 0
if errors.Is(err, models.ErrAllocationZero) {
return Error{
Status: http.StatusBadRequest,
Err: err,
}
}
// Database error
if reflect.TypeOf(err) == reflect.TypeOf(&sqlite.Error{}) {
return DBError(c, err)
}
if reflect.TypeOf(err) == reflect.TypeOf(&time.ParseError{}) {
return Error{
Status: http.StatusBadRequest,
Err: fmt.Errorf("could not parse: %w", err),
}
}
// Database connection has not been opened or has been closed already
if strings.Contains(err.Error(), "sql: database is closed") {
log.Error().Msgf("Database connection is closed: %#v", err)
return Error{
Status: http.StatusInternalServerError,
Err: ErrDatabaseClosed,
}
}
// End of file reached when reading
if errors.Is(io.EOF, err) {
return Error{Status: http.StatusBadRequest, Err: ErrRequestBodyEmpty}
}
// UUID is invalid length
if uuid.IsInvalidLengthError(err) {
return Error{Status: http.StatusBadRequest, Err: ErrInvalidUUID}
}
// Time could not be parsed. Return the error string as lets the user
// know the exact issue
if reflect.TypeOf(err) == reflect.TypeOf(&time.ParseError{}) {
return Error{Status: http.StatusBadRequest, Err: err}
}
// File is empty or otherwise not complete
if err.Error() == "unexpected EOF" {
return Error{Status: http.StatusBadRequest, Err: ErrFileEmpty}
}
log.Error().Str("request-id", requestid.Get(c)).Msgf("%T: %v", err, err.Error())
return Error{Status: http.StatusInternalServerError, Err: fmt.Errorf("an error occurred on the server during your request, please contact your server administrator. The request id is '%v', send this to your server administrator to help them finding the problem", requestid.Get(c))}
}
// Handler handles errors for fetching data from the database.
//
// This function is deprecated. Use Parse and handle HTTP responses
// in the calling function.
func Handler(c *gin.Context, err error) {
// No record found => 404
if errors.Is(err, gorm.ErrRecordNotFound) {
// Allow the specification of more exact messages when no resource is found
msg := "there is no resource for the ID you specified"
New(c, http.StatusNotFound, msg)
// Database error
} else if reflect.TypeOf(err) == reflect.TypeOf(&sqlite.Error{}) {
status := DBError(c, err)
New(c, status.Status, status.Error())
} else if errors.Is(err, models.ErrAllocationZero) {
New(c, http.StatusBadRequest, err.Error())
// Database connection has not been opened or has been closed already
} else if strings.Contains(err.Error(), "sql: database is closed") {
log.Error().Msgf("Database connection is closed: %#v", err)
New(c, http.StatusInternalServerError, "There is a problem with the database connection, please try again later.")
// End of file reached when reading
} else if errors.Is(io.EOF, err) {
New(c, http.StatusBadRequest, "The request body must not be empty")
// Time could not be parsed. Return the error string as tells
// the problem very well
} else if reflect.TypeOf(err) == reflect.TypeOf(&time.ParseError{}) {
New(c, http.StatusBadRequest, err.Error())
// All other errors
} else {
log.Error().Str("request-id", requestid.Get(c)).Msgf("%T: %v", err, err.Error())
New(c, http.StatusInternalServerError, fmt.Sprintf("An error occurred on the server during your request, please contact your server administrator. The request id is '%v', send this to your server administrator to help them finding the problem", requestid.Get(c)))
}
}