Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,16 @@ This project uses famous football player names (A-Z) as release codenames:

### Added

- `model/player_model.go`: added `binding` struct tags to `Player` for field-level validation via Gin's built-in validator (`go-playground/validator`) — required fields: `firstName`, `lastName`, `dateOfBirth`, `position`, `abbrPosition`, `team`, `league`; range constraint on `squadNumber` (`min=1,max=99`); `omitempty` on `middleName`; `binding:"-"` on `ID` (#257)
- `controller/player_controller.go`: POST and PUT handlers now return `422 Unprocessable Entity` for validation failures and `400 Bad Request` for malformed JSON, distinguished via `errors.As(err, &validator.ValidationErrors{})` (#257)
- `tests/main_test.go`: added `TestRequestPOSTPlayersValidationResponseStatusUnprocessableEntity` and `TestRequestPUTPlayerBySquadNumberValidationResponseStatusUnprocessableEntity` table-driven tests covering missing required fields and out-of-range `squadNumber` (#257)

### Changed

- `tests/player_fake.go`: `MakeUnknownPlayer()` now returns a fully populated player so PUT 404-by-lookup tests pass binding validation before reaching the service layer (#257)
- `controller/player_controller.go`: replaced `BindJSON` with `ShouldBindJSON` in Post and Put handlers to take full control over error responses (#257)
- `docs/`: regenerated Swagger docs (`swag init`) to include `@Failure 422 "Unprocessable Entity"` on POST and PUT endpoints (#257)

### Removed

### Fixed
Expand Down
32 changes: 26 additions & 6 deletions controller/player_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"strings"

"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/nanotaboada/go-samples-gin-restful/model"
"github.com/nanotaboada/go-samples-gin-restful/service"
Expand Down Expand Up @@ -56,15 +57,22 @@ func isUniqueConstraintError(err error) bool {
// @Success 201 "Created"
// @Failure 400 "Bad Request"
// @Failure 409 "Conflict"
// @Failure 422 "Unprocessable Entity"
// @Failure 500 "Internal Server Error"
// @Router /players [post]
func (c *PlayerController) Post(context *gin.Context) {
var player model.Player
// BindJSON deserialises the request body into the struct and validates
// required fields declared with the `binding:"required"` tag. On failure
// it writes a 400 response automatically; we still return to stop execution.
if err := context.BindJSON(&player); err != nil {
context.Status(http.StatusBadRequest)
// ShouldBindJSON deserialises the request body without writing a response
// automatically, giving us full control over the status code.
// validator.ValidationErrors signals a field-level constraint failure → 422.
// Any other error (EOF, syntax) is a malformed request → 400.
if err := context.ShouldBindJSON(&player); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
context.Status(http.StatusUnprocessableEntity)
} else {
context.Status(http.StatusBadRequest)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return
}
// UUID is always generated server-side; any client-provided ID is overwritten.
Expand Down Expand Up @@ -183,6 +191,7 @@ func (c *PlayerController) GetBySquadNumber(context *gin.Context) {
// @Success 204 "No Content"
// @Failure 400 "Bad Request"
// @Failure 404 "Not Found"
// @Failure 422 "Unprocessable Entity"
// @Failure 500 "Internal Server Error"
// @Router /players/squadnumber/{squadnumber} [put]
func (c *PlayerController) Put(context *gin.Context) {
Expand All @@ -192,9 +201,20 @@ func (c *PlayerController) Put(context *gin.Context) {
return
}
var player model.Player
// ShouldBindJSON gives us control over the response code.
// validator.ValidationErrors → 422; parse/syntax errors → 400.
if err = context.ShouldBindJSON(&player); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
context.Status(http.StatusUnprocessableEntity)
} else {
context.Status(http.StatusBadRequest)
}
return
}
// Guard against mismatched URL and body: the squad number in the URL must
// equal the one in the JSON body, otherwise the request is ambiguous → 400.
if err = context.BindJSON(&player); err != nil || player.SquadNumber != squadNumber {
if player.SquadNumber != squadNumber {
context.Status(http.StatusBadRequest)
return
}
Expand Down
19 changes: 18 additions & 1 deletion docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ const docTemplate = `{
"409": {
"description": "Conflict"
},
"422": {
"description": "Unprocessable Entity"
},
"500": {
"description": "Internal Server Error"
}
Expand Down Expand Up @@ -146,6 +149,9 @@ const docTemplate = `{
"404": {
"description": "Not Found"
},
"422": {
"description": "Unprocessable Entity"
},
"500": {
"description": "Internal Server Error"
}
Expand Down Expand Up @@ -219,6 +225,15 @@ const docTemplate = `{
"definitions": {
"model.Player": {
"type": "object",
"required": [
"abbrPosition",
"dateOfBirth",
"firstName",
"lastName",
"league",
"position",
"team"
],
"properties": {
"abbrPosition": {
"description": "The abbreviated form of the Player's position",
Expand Down Expand Up @@ -254,7 +269,9 @@ const docTemplate = `{
},
"squadNumber": {
"description": "User-facing unique identifier; DB-enforced uniqueness",
"type": "integer"
"type": "integer",
"maximum": 99,
"minimum": 1
},
"starting11": {
"description": "Indicates whether the Player is in the starting 11",
Expand Down
19 changes: 18 additions & 1 deletion docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"409": {
"description": "Conflict"
},
"422": {
"description": "Unprocessable Entity"
},
"500": {
"description": "Internal Server Error"
}
Expand Down Expand Up @@ -135,6 +138,9 @@
"404": {
"description": "Not Found"
},
"422": {
"description": "Unprocessable Entity"
},
"500": {
"description": "Internal Server Error"
}
Expand Down Expand Up @@ -208,6 +214,15 @@
"definitions": {
"model.Player": {
"type": "object",
"required": [
"abbrPosition",
"dateOfBirth",
"firstName",
"lastName",
"league",
"position",
"team"
],
"properties": {
"abbrPosition": {
"description": "The abbreviated form of the Player's position",
Expand Down Expand Up @@ -243,7 +258,9 @@
},
"squadNumber": {
"description": "User-facing unique identifier; DB-enforced uniqueness",
"type": "integer"
"type": "integer",
"maximum": 99,
"minimum": 1
},
"starting11": {
"description": "Indicates whether the Player is in the starting 11",
Expand Down
14 changes: 14 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,23 @@ definitions:
type: string
squadNumber:
description: User-facing unique identifier; DB-enforced uniqueness
maximum: 99
minimum: 1
type: integer
starting11:
description: Indicates whether the Player is in the starting 11
type: boolean
team:
description: The team to which the Player belongs
type: string
required:
- abbrPosition
- dateOfBirth
- firstName
- lastName
- league
- position
- team
type: object
info:
contact: {}
Expand Down Expand Up @@ -71,6 +81,8 @@ paths:
description: Bad Request
"409":
description: Conflict
"422":
description: Unprocessable Entity
"500":
description: Internal Server Error
summary: Creates a Player
Expand Down Expand Up @@ -163,6 +175,8 @@ paths:
description: Bad Request
"404":
description: Not Found
"422":
description: Unprocessable Entity
"500":
description: Internal Server Error
summary: Updates (entirely) a Player by its Squad Number
Expand Down
29 changes: 17 additions & 12 deletions model/player_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package model
//
// # Struct tags
//
// Each field carries two sets of struct tags that Go reads at runtime via
// Each field carries three sets of struct tags that Go reads at runtime via
// reflection:
//
// - `json:"..."` — controls marshalling to/from JSON.
Expand All @@ -19,22 +19,27 @@ package model
// the type is string — UUIDs are assigned by the application, not the DB).
// `uniqueIndex` creates a unique index in SQLite, enforced at the DB level.
//
// - `binding:"..."` — controls Gin's request binding validation (backed by
// go-playground/validator). `required` rejects zero/empty values; `min`
// and `max` enforce numeric ranges; `omitempty` skips validation when the
// field is absent; `-` excludes the field from binding entirely.
//
// # ID design
//
// ID is a string (not an integer auto-increment) because it stores a UUID v4,
// generated server-side on POST. This keeps the internal key opaque and stable
// across environments. Clients use squadNumber to identify players in PUT and
// DELETE requests; the UUID is available via the UUID lookup endpoint.
type Player struct {
ID string `json:"id" gorm:"column:id;primaryKey"` // Internal UUID (server-generated, opaque to clients)
FirstName string `json:"firstName" gorm:"column:firstName"` // The first name of the Player
MiddleName string `json:"middleName" gorm:"column:middleName"` // The middle name of the Player, if any
LastName string `json:"lastName" gorm:"column:lastName"` // The last name of the Player
DateOfBirth string `json:"dateOfBirth" gorm:"column:dateOfBirth"` // The date of birth of the Player
SquadNumber int `json:"squadNumber" gorm:"column:squadNumber;uniqueIndex"` // User-facing unique identifier; DB-enforced uniqueness
Position string `json:"position" gorm:"column:position"` // The playing position of the Player
AbbrPosition string `json:"abbrPosition" gorm:"column:abbrPosition"` // The abbreviated form of the Player's position
Team string `json:"team" gorm:"column:team"` // The team to which the Player belongs
League string `json:"league" gorm:"column:league"` // The league where the team plays
Starting11 bool `json:"starting11" gorm:"column:starting11"` // Indicates whether the Player is in the starting 11
ID string `json:"id" gorm:"column:id;primaryKey" binding:"-"` // Internal UUID (server-generated, opaque to clients)
FirstName string `json:"firstName" gorm:"column:firstName" binding:"required"` // The first name of the Player
MiddleName string `json:"middleName" gorm:"column:middleName" binding:"omitempty"` // The middle name of the Player, if any
LastName string `json:"lastName" gorm:"column:lastName" binding:"required"` // The last name of the Player
DateOfBirth string `json:"dateOfBirth" gorm:"column:dateOfBirth" binding:"required"` // The date of birth of the Player
SquadNumber int `json:"squadNumber" gorm:"column:squadNumber;uniqueIndex" binding:"min=1,max=99"` // User-facing unique identifier; DB-enforced uniqueness
Position string `json:"position" gorm:"column:position" binding:"required"` // The playing position of the Player
AbbrPosition string `json:"abbrPosition" gorm:"column:abbrPosition" binding:"required"` // The abbreviated form of the Player's position
Team string `json:"team" gorm:"column:team" binding:"required"` // The team to which the Player belongs
League string `json:"league" gorm:"column:league" binding:"required"` // The league where the team plays
Starting11 bool `json:"starting11" gorm:"column:starting11"` // Indicates whether the Player is in the starting 11
}
Loading
Loading