diff --git a/api/docs.go b/api/docs.go index 72147d3f..73c2603e 100644 --- a/api/docs.go +++ b/api/docs.go @@ -3958,6 +3958,56 @@ const docTemplate = `{ } } }, + "post": { + "description": "Creates transactions from the list of submitted transaction data. The response code is the highest response code number that a single transaction creation would have caused. If it is not equal to 201, at least one transaction has an error.", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Create transactions", + "parameters": [ + { + "description": "Transactions", + "name": "transactions", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TransactionCreate" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + } + } + }, "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ @@ -4882,7 +4932,7 @@ const docTemplate = `{ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true @@ -4911,6 +4961,22 @@ const docTemplate = `{ } } }, + "controllers.TransactionCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created transactions", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TransactionResponseV3" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string" + } + } + }, "controllers.TransactionListResponse": { "type": "object", "properties": { @@ -4960,6 +5026,23 @@ const docTemplate = `{ } } }, + "controllers.TransactionResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "The transaction data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/controllers.TransactionV3" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string" + } + } + }, "controllers.TransactionV2": { "type": "object", "properties": { @@ -5033,7 +5116,7 @@ const docTemplate = `{ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true @@ -5135,7 +5218,7 @@ const docTemplate = `{ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true @@ -5792,7 +5875,7 @@ const docTemplate = `{ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true diff --git a/api/swagger.json b/api/swagger.json index 916a99b1..7ec7cdab 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -3947,6 +3947,56 @@ } } }, + "post": { + "description": "Creates transactions from the list of submitted transaction data. The response code is the highest response code number that a single transaction creation would have caused. If it is not equal to 201, at least one transaction has an error.", + "produces": [ + "application/json" + ], + "tags": [ + "Transactions" + ], + "summary": "Create transactions", + "parameters": [ + { + "description": "Transactions", + "name": "transactions", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.TransactionCreate" + } + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/controllers.TransactionCreateResponseV3" + } + } + } + }, "options": { "description": "Returns an empty response with the HTTP Header \"allow\" set to the allowed HTTP verbs", "tags": [ @@ -4871,7 +4921,7 @@ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true @@ -4900,6 +4950,22 @@ } } }, + "controllers.TransactionCreateResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "List of created transactions", + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TransactionResponseV3" + } + }, + "error": { + "description": "The error, if any occurred", + "type": "string" + } + } + }, "controllers.TransactionListResponse": { "type": "object", "properties": { @@ -4949,6 +5015,23 @@ } } }, + "controllers.TransactionResponseV3": { + "type": "object", + "properties": { + "data": { + "description": "The transaction data, if creation was successful", + "allOf": [ + { + "$ref": "#/definitions/controllers.TransactionV3" + } + ] + }, + "error": { + "description": "The error, if any occurred for this transaction", + "type": "string" + } + } + }, "controllers.TransactionV2": { "type": "object", "properties": { @@ -5022,7 +5105,7 @@ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true @@ -5124,7 +5207,7 @@ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true @@ -5781,7 +5864,7 @@ "example": "Lunch" }, "reconciled": { - "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead.", + "description": "DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0", "type": "boolean", "default": false, "example": true diff --git a/api/swagger.yaml b/api/swagger.yaml index ab2b98a9..ff89caa1 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -648,7 +648,7 @@ definitions: default: false description: DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource - and reconciledDestination instead. + and reconciledDestination instead. This field will be removed in 4.0.0 example: true type: boolean reconciledDestination: @@ -670,6 +670,17 @@ definitions: example: "2022-04-17T20:14:01.048145Z" type: string type: object + controllers.TransactionCreateResponseV3: + properties: + data: + description: List of created transactions + items: + $ref: '#/definitions/controllers.TransactionResponseV3' + type: array + error: + description: The error, if any occurred + type: string + type: object controllers.TransactionListResponse: properties: data: @@ -700,6 +711,16 @@ definitions: - $ref: '#/definitions/controllers.Transaction' description: Data for the transaction type: object + controllers.TransactionResponseV3: + properties: + data: + allOf: + - $ref: '#/definitions/controllers.TransactionV3' + description: The transaction data, if creation was successful + error: + description: The error, if any occurred for this transaction + type: string + type: object controllers.TransactionV2: properties: amount: @@ -765,7 +786,7 @@ definitions: default: false description: DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource - and reconciledDestination instead. + and reconciledDestination instead. This field will be removed in 4.0.0 example: true type: boolean reconciledDestination: @@ -852,7 +873,7 @@ definitions: default: false description: DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource - and reconciledDestination instead. + and reconciledDestination instead. This field will be removed in 4.0.0 example: true type: boolean reconciledDestination: @@ -1375,7 +1396,7 @@ definitions: default: false description: DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource - and reconciledDestination instead. + and reconciledDestination instead. This field will be removed in 4.0.0 example: true type: boolean reconciledDestination: @@ -4221,6 +4242,42 @@ paths: summary: Allowed HTTP verbs tags: - Transactions + post: + description: Creates transactions from the list of submitted transaction data. + The response code is the highest response code number that a single transaction + creation would have caused. If it is not equal to 201, at least one transaction + has an error. + parameters: + - description: Transactions + in: body + name: transactions + required: true + schema: + items: + $ref: '#/definitions/models.TransactionCreate' + type: array + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.TransactionCreateResponseV3' + "400": + description: Bad Request + schema: + $ref: '#/definitions/controllers.TransactionCreateResponseV3' + "404": + description: Not Found + schema: + $ref: '#/definitions/controllers.TransactionCreateResponseV3' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/controllers.TransactionCreateResponseV3' + summary: Create transactions + tags: + - Transactions /version: get: description: Returns the software version of the API diff --git a/pkg/controllers/account_v1.go b/pkg/controllers/account_v1.go index b3be5d08..1768a422 100644 --- a/pkg/controllers/account_v1.go +++ b/pkg/controllers/account_v1.go @@ -169,7 +169,7 @@ func (co Controller) OptionsAccountDetail(c *gin.Context) { func (co Controller) CreateAccount(c *gin.Context) { var accountCreate models.AccountCreate - if err := httputil.BindData(c, &accountCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &accountCreate); err != nil { return } @@ -314,7 +314,7 @@ func (co Controller) UpdateAccount(c *gin.Context) { } var data models.Account - if err := httputil.BindData(c, &data.AccountCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &data.AccountCreate); err != nil { return } diff --git a/pkg/controllers/allocation.go b/pkg/controllers/allocation.go index e848295e..46fb6819 100644 --- a/pkg/controllers/allocation.go +++ b/pkg/controllers/allocation.go @@ -143,7 +143,7 @@ func (co Controller) OptionsAllocationDetail(c *gin.Context) { func (co Controller) CreateAllocation(c *gin.Context) { var create models.AllocationCreate - err := httputil.BindData(c, &create) + err := httputil.BindDataHandleErrors(c, &create) if err != nil { return } @@ -282,7 +282,7 @@ func (co Controller) UpdateAllocation(c *gin.Context) { } var data models.Allocation - if err := httputil.BindData(c, &data.AllocationCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &data.AllocationCreate); err != nil { return } diff --git a/pkg/controllers/budget.go b/pkg/controllers/budget.go index 7ff0bdb1..52c32d57 100644 --- a/pkg/controllers/budget.go +++ b/pkg/controllers/budget.go @@ -238,7 +238,7 @@ func (co Controller) OptionsBudgetMonthAllocations(c *gin.Context) { func (co Controller) CreateBudget(c *gin.Context) { var bCreate models.BudgetCreate - if err := httputil.BindData(c, &bCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &bCreate); err != nil { return } @@ -499,7 +499,7 @@ func (co Controller) UpdateBudget(c *gin.Context) { } var data models.Budget - if err := httputil.BindData(c, &data.BudgetCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &data.BudgetCreate); err != nil { return } @@ -634,7 +634,7 @@ func (co Controller) SetAllocationsMonth(c *gin.Context) { // Get the mode to set new allocations in var data BudgetAllocationMode - if err := httputil.BindData(c, &data); err != nil { + if err := httputil.BindDataHandleErrors(c, &data); err != nil { return } diff --git a/pkg/controllers/category.go b/pkg/controllers/category.go index b7919524..eb032f78 100644 --- a/pkg/controllers/category.go +++ b/pkg/controllers/category.go @@ -157,7 +157,7 @@ func (co Controller) OptionsCategoryDetail(c *gin.Context) { func (co Controller) CreateCategory(c *gin.Context) { var create models.CategoryCreate - err := httputil.BindData(c, &create) + err := httputil.BindDataHandleErrors(c, &create) if err != nil { return } @@ -294,7 +294,7 @@ func (co Controller) UpdateCategory(c *gin.Context) { } var data models.Category - if err := httputil.BindData(c, &data.CategoryCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &data.CategoryCreate); err != nil { return } diff --git a/pkg/controllers/envelope.go b/pkg/controllers/envelope.go index b1959f78..3022e137 100644 --- a/pkg/controllers/envelope.go +++ b/pkg/controllers/envelope.go @@ -151,7 +151,7 @@ func (co Controller) OptionsEnvelopeDetail(c *gin.Context) { func (co Controller) CreateEnvelope(c *gin.Context) { var create models.EnvelopeCreate - err := httputil.BindData(c, &create) + err := httputil.BindDataHandleErrors(c, &create) if err != nil { return } @@ -340,7 +340,7 @@ func (co Controller) UpdateEnvelope(c *gin.Context) { } var data models.Envelope - if err := httputil.BindData(c, &data.EnvelopeCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &data.EnvelopeCreate); err != nil { return } diff --git a/pkg/controllers/match_rule.go b/pkg/controllers/match_rule.go index bad50638..dfdd1a99 100644 --- a/pkg/controllers/match_rule.go +++ b/pkg/controllers/match_rule.go @@ -133,7 +133,7 @@ func (co Controller) OptionsMatchRuleDetail(c *gin.Context) { func (co Controller) CreateMatchRules(c *gin.Context) { var matchRules []models.MatchRuleCreate - if err := httputil.BindData(c, &matchRules); err != nil { + if err := httputil.BindDataHandleErrors(c, &matchRules); err != nil { return } @@ -272,7 +272,7 @@ func (co Controller) UpdateMatchRule(c *gin.Context) { } var data models.MatchRule - if err := httputil.BindData(c, &data.MatchRuleCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &data.MatchRuleCreate); err != nil { return } diff --git a/pkg/controllers/month.go b/pkg/controllers/month.go index 80a0b948..4821871f 100644 --- a/pkg/controllers/month.go +++ b/pkg/controllers/month.go @@ -160,7 +160,7 @@ func (co Controller) SetAllocations(c *gin.Context) { // Get the mode to set new allocations in var data BudgetAllocationMode - if err := httputil.BindData(c, &data); err != nil { + if err := httputil.BindDataHandleErrors(c, &data); err != nil { return } diff --git a/pkg/controllers/month_config.go b/pkg/controllers/month_config.go index 459ae4cf..c862b652 100644 --- a/pkg/controllers/month_config.go +++ b/pkg/controllers/month_config.go @@ -271,7 +271,7 @@ func (co Controller) CreateMonthConfig(c *gin.Context) { } var mConfig models.MonthConfig - if err = httputil.BindData(c, &mConfig.MonthConfigCreate); err != nil { + if err = httputil.BindDataHandleErrors(c, &mConfig.MonthConfigCreate); err != nil { return } @@ -346,7 +346,7 @@ func (co Controller) UpdateMonthConfig(c *gin.Context) { } var data models.MonthConfig - if err = httputil.BindData(c, &data.MonthConfigCreate); err != nil { + if err = httputil.BindDataHandleErrors(c, &data.MonthConfigCreate); err != nil { return } diff --git a/pkg/controllers/rename_rule.go b/pkg/controllers/rename_rule.go index a5bec07b..16e3729e 100644 --- a/pkg/controllers/rename_rule.go +++ b/pkg/controllers/rename_rule.go @@ -117,7 +117,7 @@ func (co Controller) OptionsRenameRuleDetail(c *gin.Context) { func (co Controller) CreateRenameRules(c *gin.Context) { var renameRules []models.MatchRule - if err := httputil.BindData(c, &renameRules); err != nil { + if err := httputil.BindDataHandleErrors(c, &renameRules); err != nil { return } @@ -259,7 +259,7 @@ func (co Controller) UpdateRenameRule(c *gin.Context) { } var data models.MatchRule - if err := httputil.BindData(c, &data); err != nil { + if err := httputil.BindDataHandleErrors(c, &data); err != nil { return } diff --git a/pkg/controllers/test_options_test.go b/pkg/controllers/test_options_test.go index b783f28a..77ee4af7 100644 --- a/pkg/controllers/test_options_test.go +++ b/pkg/controllers/test_options_test.go @@ -27,7 +27,7 @@ func (suite *TestSuiteStandard) TestOptionsHeaderResources() { {"http://example.com/v2/transactions", "OPTIONS, POST"}, {"http://example.com/v2/match-rules", "OPTIONS, GET, POST"}, {"http://example.com/v2/accounts", "OPTIONS, GET"}, - {"http://example.com/v3/transactions", "OPTIONS, GET"}, + {"http://example.com/v3/transactions", "OPTIONS, GET, POST"}, } for _, tt := range optionsHeaderTests { diff --git a/pkg/controllers/transaction_v1.go b/pkg/controllers/transaction_v1.go index 8ffeb62d..61363107 100644 --- a/pkg/controllers/transaction_v1.go +++ b/pkg/controllers/transaction_v1.go @@ -146,7 +146,7 @@ func (co Controller) OptionsTransactionDetail(c *gin.Context) { func (co Controller) CreateTransaction(c *gin.Context) { var transactionCreate models.TransactionCreate - if err := httputil.BindData(c, &transactionCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &transactionCreate); err != nil { return } @@ -343,7 +343,7 @@ func (co Controller) UpdateTransaction(c *gin.Context) { } var data models.Transaction - if err := httputil.BindData(c, &data.TransactionCreate); err != nil { + if err := httputil.BindDataHandleErrors(c, &data.TransactionCreate); err != nil { return } diff --git a/pkg/controllers/transaction_v2.go b/pkg/controllers/transaction_v2.go index b5b2f62b..b4737dfc 100644 --- a/pkg/controllers/transaction_v2.go +++ b/pkg/controllers/transaction_v2.go @@ -74,7 +74,7 @@ func (co Controller) OptionsTransactionsV2(c *gin.Context) { func (co Controller) CreateTransactionsV2(c *gin.Context) { var transactions []models.Transaction - if err := httputil.BindData(c, &transactions); err != nil { + if err := httputil.BindDataHandleErrors(c, &transactions); err != nil { return } diff --git a/pkg/controllers/transaction_v3.go b/pkg/controllers/transaction_v3.go index a20dbf99..1e488a45 100644 --- a/pkg/controllers/transaction_v3.go +++ b/pkg/controllers/transaction_v3.go @@ -22,6 +22,16 @@ type TransactionListResponseV3 struct { Pagination *Pagination `json:"pagination"` // Pagination information } +type TransactionCreateResponseV3 struct { + Error *string `json:"error"` // The error, if any occurred + Data []TransactionResponseV3 `json:"data"` // List of created transactions +} + +type TransactionResponseV3 struct { + Error *string `json:"error"` // The error, if any occurred for this transaction + Data *TransactionV3 `json:"data"` // The transaction data, if creation was successful +} + // TransactionV3 is the representation of a Transaction in API v3. type TransactionV3 struct { models.Transaction @@ -76,6 +86,7 @@ func (co Controller) RegisterTransactionRoutesV3(r *gin.RouterGroup) { { r.OPTIONS("", co.OptionsTransactionsV3) r.GET("", co.GetTransactionsV3) + r.POST("", co.CreateTransactionsV3) } } @@ -87,7 +98,7 @@ func (co Controller) RegisterTransactionRoutesV3(r *gin.RouterGroup) { // @Success 204 // @Router /v3/transactions [options] func (co Controller) OptionsTransactionsV3(c *gin.Context) { - httputil.OptionsGet(c) + httputil.OptionsGetPost(c) } // GetTransactions returns transactions filtered by the query parameters @@ -248,3 +259,63 @@ func (co Controller) GetTransactionsV3(c *gin.Context) { }, }) } + +// CreateTransactionsV3 creates transactions +// +// @Summary Create transactions +// @Description Creates transactions from the list of submitted transaction data. The response code is the highest response code number that a single transaction creation would have caused. If it is not equal to 201, at least one transaction has an error. +// @Tags Transactions +// @Produce json +// @Success 201 {object} TransactionCreateResponseV3 +// @Failure 400 {object} TransactionCreateResponseV3 +// @Failure 404 {object} TransactionCreateResponseV3 +// @Failure 500 {object} TransactionCreateResponseV3 +// @Param transactions body []models.TransactionCreate true "Transactions" +// @Router /v3/transactions [post] +func (co Controller) CreateTransactionsV3(c *gin.Context) { + var transactions []models.Transaction + + // Bind data and return error if not possible + err := httputil.BindData(c, &transactions) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionCreateResponseV3{ + Error: &e, + }) + return + } + + // The final http status. Will be modified when errors occur + status := http.StatusCreated + r := TransactionCreateResponseV3{} + + for _, t := range transactions { + t, err := co.createTransaction(c, t) + + // Append the error + if !err.Nil() { + e := err.Error() + r.Data = append(r.Data, TransactionResponseV3{Error: &e}) + + // The final status code is the highest HTTP status code number since this also + // represents the priority we + if err.Status > status { + status = err.Status + } + continue + } + + // Append the transaction + tObject, err := co.getTransactionV3(c, t.ID) + if !err.Nil() { + e := err.Error() + c.JSON(err.Status, TransactionCreateResponseV3{ + Error: &e, + }) + return + } + r.Data = append(r.Data, TransactionResponseV3{Data: &tObject}) + } + + c.JSON(status, r) +} diff --git a/pkg/controllers/transaction_v3_test.go b/pkg/controllers/transaction_v3_test.go index 1916b4ef..7dfbe1e0 100644 --- a/pkg/controllers/transaction_v3_test.go +++ b/pkg/controllers/transaction_v3_test.go @@ -7,8 +7,10 @@ import ( "time" "github.com/envelope-zero/backend/v3/pkg/controllers" + "github.com/envelope-zero/backend/v3/pkg/httperrors" "github.com/envelope-zero/backend/v3/pkg/models" "github.com/envelope-zero/backend/v3/test" + "github.com/google/uuid" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) @@ -199,3 +201,93 @@ func (suite *TestSuiteStandard) TestGetTransactionsFilterV3() { }) } } + +func (suite *TestSuiteStandard) TestTransactionsCreateV3InvalidBody() { + r := test.Request(suite.controller, suite.T(), http.MethodPost, "http://example.com/v3/transactions", `{ Invalid request": Body }`) + assertHTTPStatus(suite.T(), &r, http.StatusBadRequest) + + var tr controllers.TransactionCreateResponseV3 + suite.decodeResponse(&r, &tr) + + assert.Equal(suite.T(), httperrors.ErrInvalidBody.Error(), *tr.Error) + assert.Nil(suite.T(), tr.Data) +} + +func (suite *TestSuiteStandard) TestTransactionsCreateV3() { + budget := suite.createTestBudget(models.BudgetCreate{}) + internalAccount := suite.createTestAccount(models.AccountCreate{External: false, BudgetID: budget.Data.ID, Name: "TestTransactionsCreateV3 Internal"}) + externalAccount := suite.createTestAccount(models.AccountCreate{External: true, BudgetID: budget.Data.ID, Name: "TestTransactionsCreateV3 External"}) + + tests := []struct { + name string + transactions []models.TransactionCreate + expectedStatus int + expectedError *error // Error expected in the response + expectedErrors []string // Errors expected for the individual transactions + }{ + { + "One success, one fail", + []models.TransactionCreate{ + { + BudgetID: uuid.New(), + Amount: decimal.NewFromFloat(17.23), + Note: "v3 non-existing budget ID", + }, + { + BudgetID: budget.Data.ID, + SourceAccountID: internalAccount.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + Amount: decimal.NewFromFloat(57.01), + }, + }, + http.StatusNotFound, + nil, + []string{ + "there is no Budget with this ID", + "", + }, + }, + { + "Both succeed", + []models.TransactionCreate{ + { + BudgetID: budget.Data.ID, + SourceAccountID: internalAccount.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + Amount: decimal.NewFromFloat(17.23), + }, + { + BudgetID: budget.Data.ID, + SourceAccountID: internalAccount.Data.ID, + DestinationAccountID: externalAccount.Data.ID, + Amount: decimal.NewFromFloat(57.01), + }, + }, + http.StatusCreated, + nil, + []string{ + "", + "", + }, + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + r := test.Request(suite.controller, t, http.MethodPost, "http://example.com/v3/transactions", tt.transactions) + assertHTTPStatus(t, &r, tt.expectedStatus) + + var tr controllers.TransactionCreateResponseV3 + suite.decodeResponse(&r, &tr) + + for i, transaction := range tr.Data { + if tt.expectedErrors[i] == "" { + assert.Equal(t, fmt.Sprintf("http://example.com/v3/transactions/%s", transaction.Data.ID), transaction.Data.Links.Self) + } else { + // This needs to be in the else to prevent nil pointer errors since we're dereferencing pointers + assert.Equal(t, tt.expectedErrors[i], *transaction.Error) + } + } + }) + } +} diff --git a/pkg/httperrors/errors.go b/pkg/httperrors/errors.go index f577522e..0cf28a00 100644 --- a/pkg/httperrors/errors.go +++ b/pkg/httperrors/errors.go @@ -19,6 +19,7 @@ import ( var ( ErrInvalidQueryString = errors.New("the query string contains unparseable data. Please check the values") + ErrInvalidBody = errors.New("the body of your request contains invalid or un-parseable data. Please check and try again") ErrInvalidUUID = errors.New("the specified resource ID is not a valid UUID") ErrNoResource = errors.New("there is no resource for the ID you specified") ErrDatabaseClosed = errors.New("there is a problem with the database connection, please try again later") diff --git a/pkg/httputil/request.go b/pkg/httputil/request.go index 75c6909b..189d9cc0 100644 --- a/pkg/httputil/request.go +++ b/pkg/httputil/request.go @@ -12,8 +12,10 @@ import ( "github.com/rs/zerolog/log" ) -// BindData binds the data from the request to the struct passed in the interface. -func BindData(c *gin.Context, data interface{}) error { +// BindDataHandleErrors binds the data from the request to the struct passed in the interface. +// +// This function is deprecated. Use BindData(*gin.Context, any) httperrors.Error. +func BindDataHandleErrors(c *gin.Context, data interface{}) error { if err := c.ShouldBindJSON(&data); err != nil { if errors.Is(io.EOF, err) { e := errors.New("request body must not be empty") @@ -30,6 +32,26 @@ func BindData(c *gin.Context, data interface{}) error { return nil } +// BindData binds the data from the request to the struct passed in the interface. +func BindData(c *gin.Context, data interface{}) httperrors.Error { + if err := c.ShouldBindJSON(&data); err != nil { + if errors.Is(io.EOF, err) { + return httperrors.Error{ + Status: http.StatusBadRequest, + Err: httperrors.ErrRequestBodyEmpty, + } + } + + log.Error().Str("request-id", requestid.Get(c)).Msgf("%T: %v", err, err.Error()) + return httperrors.Error{ + Status: http.StatusBadRequest, + Err: httperrors.ErrInvalidBody, + } + } + + return httperrors.Error{} +} + // This is needed because gin does not support form binding to uuid.UUID currently. // Follow https://github.com/gin-gonic/gin/pull/3045 to see when this gets resolved. // diff --git a/pkg/httputil/request_test.go b/pkg/httputil/request_test.go index 70c54727..9ffc95a9 100644 --- a/pkg/httputil/request_test.go +++ b/pkg/httputil/request_test.go @@ -6,13 +6,14 @@ import ( "net/http/httptest" "testing" + "github.com/envelope-zero/backend/v3/pkg/httperrors" "github.com/envelope-zero/backend/v3/pkg/httputil" "github.com/envelope-zero/backend/v3/test" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) -func TestBindData(t *testing.T) { +func TestBindDataHandleErrors(t *testing.T) { w := httptest.NewRecorder() c, r := gin.CreateTestContext(w) @@ -21,7 +22,7 @@ func TestBindData(t *testing.T) { Name string `json:"name"` } - _ = httputil.BindData(c, &o) + _ = httputil.BindDataHandleErrors(c, &o) }) c.Request, _ = http.NewRequest(http.MethodGet, "http://example.com/", bytes.NewBuffer([]byte(`{ "name": "Drink more water!" }`))) @@ -30,7 +31,7 @@ func TestBindData(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Binding failed: %s", w.Body.String()) } -func TestBindBrokenData(t *testing.T) { +func TestBindDataHandleErrorsBrokenData(t *testing.T) { w := httptest.NewRecorder() c, r := gin.CreateTestContext(w) @@ -39,7 +40,7 @@ func TestBindBrokenData(t *testing.T) { Name string `json:"name"` } - _ = httputil.BindData(c, &o) + _ = httputil.BindDataHandleErrors(c, &o) }) c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/", bytes.NewBuffer([]byte(`{ broken json: "Drink more water!" }`))) @@ -49,7 +50,7 @@ func TestBindBrokenData(t *testing.T) { assert.Contains(t, test.DecodeError(t, w.Body.Bytes()), "the body of your request contains invalid or un-parseable data") } -func TestBindEmptyBody(t *testing.T) { +func TestBindHandleErrorsEmptyBody(t *testing.T) { w := httptest.NewRecorder() c, r := gin.CreateTestContext(w) @@ -58,7 +59,7 @@ func TestBindEmptyBody(t *testing.T) { Name string `json:"name"` } - _ = httputil.BindData(c, &o) + _ = httputil.BindDataHandleErrors(c, &o) }) c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/", bytes.NewBuffer([]byte(""))) @@ -68,6 +69,60 @@ func TestBindEmptyBody(t *testing.T) { assert.Contains(t, test.DecodeError(t, w.Body.Bytes()), "request body must not be empty") } +// TestBindData verifies that BindData succeeds on valid data. +func TestBindData(t *testing.T) { + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.GET("/", func(ctx *gin.Context) { + var o struct { + Name string `json:"name"` + } + + err := httputil.BindData(c, &o) + assert.True(t, err.Nil()) + }) + + c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/", bytes.NewBuffer([]byte(`{ "name": "Drink more water!" }`))) + r.ServeHTTP(w, c.Request) +} + +// TestBindDataInvalidBody verifies that BindData returns the correct error on an invalid body. +func TestBindDataInvalidBody(t *testing.T) { + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.GET("/", func(ctx *gin.Context) { + var o struct { + Name string `json:"name"` + } + + err := httputil.BindData(c, &o) + assert.Equal(t, httperrors.Error{Status: http.StatusBadRequest, Err: httperrors.ErrInvalidBody}, err) + }) + + c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/", bytes.NewBuffer([]byte(`{ invalid json: "Drink more water! }`))) + r.ServeHTTP(w, c.Request) +} + +// TestBindDataEmptyBody verifies that BindData returns the correct error on an empty body. +func TestBindDataEmptyBody(t *testing.T) { + w := httptest.NewRecorder() + c, r := gin.CreateTestContext(w) + + r.GET("/", func(ctx *gin.Context) { + var o struct { + Name string `json:"name"` + } + + err := httputil.BindData(c, &o) + assert.Equal(t, httperrors.Error{Status: http.StatusBadRequest, Err: httperrors.ErrRequestBodyEmpty}, err) + }) + + c.Request, _ = http.NewRequest(http.MethodGet, "https://example.com/", bytes.NewBuffer([]byte(""))) + r.ServeHTTP(w, c.Request) +} + func TestUUIDFromString(t *testing.T) { w := httptest.NewRecorder() c, r := gin.CreateTestContext(w) diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index 82158bee..fcb65ead 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -29,7 +29,7 @@ type TransactionCreate struct { SourceAccountID uuid.UUID `json:"sourceAccountId" gorm:"check:source_destination_different,source_account_id != destination_account_id" example:"fd81dc45-a3a2-468e-a6fa-b2618f30aa45"` // ID of the source account DestinationAccountID uuid.UUID `json:"destinationAccountId" example:"8e16b456-a719-48ce-9fec-e115cfa7cbcc"` // ID of the destination account EnvelopeID *uuid.UUID `json:"envelopeId" example:"2649c965-7999-4873-ae16-89d5d5fa972e"` // ID of the envelope - Reconciled bool `json:"reconciled" example:"true" default:"false"` // DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. + Reconciled bool `json:"reconciled" example:"true" default:"false"` // DEPRECATED. Do not use, this field does not work as intended. See https://github.com/envelope-zero/backend/issues/528. Use reconciledSource and reconciledDestination instead. This field will be removed in 4.0.0 ReconciledSource bool `json:"reconciledSource" example:"true" default:"false"` // Is the transaction reconciled in the source account? ReconciledDestination bool `json:"reconciledDestination" example:"true" default:"false"` // Is the transaction reconciled in the destination account?