@@ -16,6 +16,8 @@ public class PlayerController(
1616 IValidator < PlayerRequestModel > validator
1717) : ControllerBase
1818{
19+ private const string NotFoundTitle = "Not Found" ;
20+
1921 /* -------------------------------------------------------------------------
2022 * HTTP POST
2123 * ---------------------------------------------------------------------- */
@@ -30,20 +32,24 @@ IValidator<PlayerRequestModel> validator
3032 [ HttpPost ( Name = "Create" ) ]
3133 [ Consumes ( MediaTypeNames . Application . Json ) ]
3234 [ ProducesResponseType < PlayerResponseModel > ( StatusCodes . Status201Created ) ]
33- [ ProducesResponseType ( StatusCodes . Status400BadRequest ) ]
34- [ ProducesResponseType ( StatusCodes . Status409Conflict ) ]
35+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status400BadRequest ) ]
36+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status409Conflict ) ]
3537 public async Task < IResult > PostAsync ( [ FromBody ] PlayerRequestModel player )
3638 {
3739 var validation = await validator . ValidateAsync ( player ) ;
3840
3941 if ( ! validation . IsValid )
4042 {
4143 var errors = validation
42- . Errors . Select ( error => new { error . PropertyName , error . ErrorMessage } )
43- . ToArray ( ) ;
44+ . Errors . GroupBy ( e => e . PropertyName )
45+ . ToDictionary ( g => g . Key , g => g . Select ( e => e . ErrorMessage ) . ToArray ( ) ) ;
4446
4547 logger . LogWarning ( "POST /players validation failed: {@Errors}" , errors ) ;
46- return TypedResults . BadRequest ( errors ) ;
48+ return TypedResults . ValidationProblem (
49+ errors ,
50+ detail : "See the errors field for details." ,
51+ instance : HttpContext ? . Request ? . Path . ToString ( )
52+ ) ;
4753 }
4854
4955 if ( await playerService . RetrieveBySquadNumberAsync ( player . SquadNumber ) != null )
@@ -52,7 +58,16 @@ public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
5258 "POST /players failed: Player with Squad Number {SquadNumber} already exists" ,
5359 player . SquadNumber
5460 ) ;
55- return TypedResults . Conflict ( ) ;
61+ return TypedResults . Conflict (
62+ new ProblemDetails
63+ {
64+ Type = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409" ,
65+ Title = "Conflict" ,
66+ Status = StatusCodes . Status409Conflict ,
67+ Detail = $ "Player with Squad Number '{ player . SquadNumber } ' already exists.",
68+ Instance = HttpContext ? . Request ? . Path . ToString ( )
69+ }
70+ ) ;
5671 }
5772
5873 var result = await playerService . CreateAsync ( player ) ;
@@ -76,7 +91,7 @@ public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
7691 /// <response code="404">Not Found</response>
7792 [ HttpGet ( Name = "Retrieve" ) ]
7893 [ ProducesResponseType < PlayerResponseModel > ( StatusCodes . Status200OK ) ]
79- [ ProducesResponseType ( StatusCodes . Status404NotFound ) ]
94+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status404NotFound ) ]
8095 public async Task < IResult > GetAsync ( )
8196 {
8297 var players = await playerService . RetrieveAsync ( ) ;
@@ -89,7 +104,12 @@ public async Task<IResult> GetAsync()
89104 else
90105 {
91106 logger . LogWarning ( "GET /players not found" ) ;
92- return TypedResults . NotFound ( ) ;
107+ return TypedResults . Problem (
108+ statusCode : StatusCodes . Status404NotFound ,
109+ title : NotFoundTitle ,
110+ detail : "No players were found." ,
111+ instance : HttpContext ? . Request ? . Path . ToString ( )
112+ ) ;
93113 }
94114 }
95115
@@ -102,7 +122,7 @@ public async Task<IResult> GetAsync()
102122 [ Authorize ]
103123 [ HttpGet ( "{id:Guid}" , Name = "RetrieveById" ) ]
104124 [ ProducesResponseType < PlayerResponseModel > ( StatusCodes . Status200OK ) ]
105- [ ProducesResponseType ( StatusCodes . Status404NotFound ) ]
125+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status404NotFound ) ]
106126 public async Task < IResult > GetByIdAsync ( [ FromRoute ] Guid id )
107127 {
108128 var player = await playerService . RetrieveByIdAsync ( id ) ;
@@ -114,7 +134,12 @@ public async Task<IResult> GetByIdAsync([FromRoute] Guid id)
114134 else
115135 {
116136 logger . LogWarning ( "GET /players/{Id} not found" , id ) ;
117- return TypedResults . NotFound ( ) ;
137+ return TypedResults . Problem (
138+ statusCode : StatusCodes . Status404NotFound ,
139+ title : NotFoundTitle ,
140+ detail : $ "Player with Id '{ id } ' was not found.",
141+ instance : HttpContext ? . Request ? . Path . ToString ( )
142+ ) ;
118143 }
119144 }
120145
@@ -124,25 +149,30 @@ public async Task<IResult> GetByIdAsync([FromRoute] Guid id)
124149 /// <param name="squadNumber">The Squad Number of the Player</param>
125150 /// <response code="200">OK</response>
126151 /// <response code="404">Not Found</response>
127- [ HttpGet ( "{squadNumber:int}" , Name = "RetrieveBySquadNumber" ) ]
152+ [ HttpGet ( "squadNumber/ {squadNumber:int}" , Name = "RetrieveBySquadNumber" ) ]
128153 [ ProducesResponseType < PlayerResponseModel > ( StatusCodes . Status200OK ) ]
129- [ ProducesResponseType ( StatusCodes . Status404NotFound ) ]
154+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status404NotFound ) ]
130155 public async Task < IResult > GetBySquadNumberAsync ( [ FromRoute ] int squadNumber )
131156 {
132157 var player = await playerService . RetrieveBySquadNumberAsync ( squadNumber ) ;
133158 if ( player != null )
134159 {
135160 logger . LogInformation (
136- "GET /players/{SquadNumber} retrieved: {@Player}" ,
161+ "GET /players/squadNumber/ {SquadNumber} retrieved: {@Player}" ,
137162 squadNumber ,
138163 player
139164 ) ;
140165 return TypedResults . Ok ( player ) ;
141166 }
142167 else
143168 {
144- logger . LogWarning ( "GET /players/{SquadNumber} not found" , squadNumber ) ;
145- return TypedResults . NotFound ( ) ;
169+ logger . LogWarning ( "GET /players/squadNumber/{SquadNumber} not found" , squadNumber ) ;
170+ return TypedResults . Problem (
171+ statusCode : StatusCodes . Status404NotFound ,
172+ title : NotFoundTitle ,
173+ detail : $ "Player with Squad Number '{ squadNumber } ' was not found.",
174+ instance : HttpContext ? . Request ? . Path . ToString ( )
175+ ) ;
146176 }
147177 }
148178
@@ -159,11 +189,11 @@ public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
159189 /// <response code="204">No Content</response>
160190 /// <response code="400">Bad Request</response>
161191 /// <response code="404">Not Found</response>
162- [ HttpPut ( "{squadNumber:int}" , Name = "Update" ) ]
192+ [ HttpPut ( "squadNumber/ {squadNumber:int}" , Name = "Update" ) ]
163193 [ Consumes ( MediaTypeNames . Application . Json ) ]
164194 [ ProducesResponseType ( StatusCodes . Status204NoContent ) ]
165- [ ProducesResponseType ( StatusCodes . Status400BadRequest ) ]
166- [ ProducesResponseType ( StatusCodes . Status404NotFound ) ]
195+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status400BadRequest ) ]
196+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status404NotFound ) ]
167197 public async Task < IResult > PutAsync (
168198 [ FromRoute ] int squadNumber ,
169199 [ FromBody ] PlayerRequestModel player
@@ -173,24 +203,56 @@ [FromBody] PlayerRequestModel player
173203 if ( ! validation . IsValid )
174204 {
175205 var errors = validation
176- . Errors . Select ( error => new { error . PropertyName , error . ErrorMessage } )
177- . ToArray ( ) ;
206+ . Errors . GroupBy ( e => e . PropertyName )
207+ . ToDictionary ( g => g . Key , g => g . Select ( e => e . ErrorMessage ) . ToArray ( ) ) ;
178208
179209 logger . LogWarning (
180- "PUT /players/{SquadNumber} validation failed: {@Errors}" ,
210+ "PUT /players/squadNumber/ {SquadNumber} validation failed: {@Errors}" ,
181211 squadNumber ,
182212 errors
183213 ) ;
184- return TypedResults . BadRequest ( errors ) ;
214+ return TypedResults . ValidationProblem (
215+ errors ,
216+ detail : "See the errors field for details." ,
217+ instance : HttpContext ? . Request ? . Path . ToString ( )
218+ ) ;
219+ }
220+ if ( player . SquadNumber != squadNumber )
221+ {
222+ logger . LogWarning (
223+ "PutAsync squad number mismatch: route {SquadNumber} != body {PlayerSquadNumber}" ,
224+ squadNumber ,
225+ player . SquadNumber
226+ ) ;
227+ return TypedResults . Problem (
228+ statusCode : StatusCodes . Status400BadRequest ,
229+ title : "Bad Request" ,
230+ detail : "Squad number in the route does not match squad number in the request body." ,
231+ instance : HttpContext ? . Request ? . Path . ToString ( )
232+ ) ;
185233 }
186234 if ( await playerService . RetrieveBySquadNumberAsync ( squadNumber ) == null )
187235 {
188- logger . LogWarning ( "PUT /players/{SquadNumber} not found" , squadNumber ) ;
189- return TypedResults . NotFound ( ) ;
236+ logger . LogWarning ( "PUT /players/squadNumber/{SquadNumber} not found" , squadNumber ) ;
237+ return TypedResults . Problem (
238+ statusCode : StatusCodes . Status404NotFound ,
239+ title : NotFoundTitle ,
240+ detail : $ "Player with Squad Number '{ squadNumber } ' was not found.",
241+ instance : HttpContext ? . Request ? . Path . ToString ( )
242+ ) ;
190243 }
191244 await playerService . UpdateAsync ( player ) ;
192- // codeql[cs/log-forging] Serilog structured logging with @ destructuring automatically escapes control characters
193- logger . LogInformation ( "PUT /players/{SquadNumber} updated: {@Player}" , squadNumber , player ) ;
245+ // Sanitize user-provided player data before logging to prevent log forging
246+ var sanitizedPlayerString = player
247+ . ToString ( )
248+ ? . Replace ( Environment . NewLine , string . Empty )
249+ . Replace ( "\r " , string . Empty )
250+ . Replace ( "\n " , string . Empty ) ;
251+ logger . LogInformation (
252+ "PUT /players/squadNumber/{SquadNumber} updated: {Player}" ,
253+ squadNumber ,
254+ sanitizedPlayerString
255+ ) ;
194256 return TypedResults . NoContent ( ) ;
195257 }
196258
@@ -204,20 +266,25 @@ [FromBody] PlayerRequestModel player
204266 /// <param name="squadNumber">The Squad Number of the Player</param>
205267 /// <response code="204">No Content</response>
206268 /// <response code="404">Not Found</response>
207- [ HttpDelete ( "{squadNumber:int}" , Name = "Delete" ) ]
269+ [ HttpDelete ( "squadNumber/ {squadNumber:int}" , Name = "Delete" ) ]
208270 [ ProducesResponseType ( StatusCodes . Status204NoContent ) ]
209- [ ProducesResponseType ( StatusCodes . Status404NotFound ) ]
271+ [ ProducesResponseType < ProblemDetails > ( StatusCodes . Status404NotFound ) ]
210272 public async Task < IResult > DeleteAsync ( [ FromRoute ] int squadNumber )
211273 {
212274 if ( await playerService . RetrieveBySquadNumberAsync ( squadNumber ) == null )
213275 {
214- logger . LogWarning ( "DELETE /players/{SquadNumber} not found" , squadNumber ) ;
215- return TypedResults . NotFound ( ) ;
276+ logger . LogWarning ( "DELETE /players/squadNumber/{SquadNumber} not found" , squadNumber ) ;
277+ return TypedResults . Problem (
278+ statusCode : StatusCodes . Status404NotFound ,
279+ title : "Not Found" ,
280+ detail : $ "Player with Squad Number '{ squadNumber } ' was not found.",
281+ instance : HttpContext ? . Request ? . Path . ToString ( )
282+ ) ;
216283 }
217284 else
218285 {
219286 await playerService . DeleteAsync ( squadNumber ) ;
220- logger . LogInformation ( "DELETE /players/{SquadNumber} deleted" , squadNumber ) ;
287+ logger . LogInformation ( "DELETE /players/squadNumber/ {SquadNumber} deleted" , squadNumber ) ;
221288 return TypedResults . NoContent ( ) ;
222289 }
223290 }
0 commit comments