diff --git a/DysonNetwork.Pass/Account/AccountController.cs b/DysonNetwork.Pass/Account/AccountController.cs index d843a44..f556598 100644 --- a/DysonNetwork.Pass/Account/AccountController.cs +++ b/DysonNetwork.Pass/Account/AccountController.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Wallet; +using DysonNetwork.Shared.Error; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -28,7 +29,7 @@ public class AccountController( .Include(e => e.Contacts.Where(c => c.IsPublic)) .Where(a => a.Name == name) .FirstOrDefaultAsync(); - if (account is null) return new NotFoundResult(); + if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)); var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id); account.PerkSubscription = perk?.ToReference(); @@ -45,7 +46,7 @@ public class AccountController( .Include(e => e.Badges) .Where(a => a.Name == name) .FirstOrDefaultAsync(); - return account is null ? NotFound() : account.Badges.ToList(); + return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList(); } public class AccountCreateRequest @@ -81,7 +82,11 @@ public class AccountController( [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> CreateAccount([FromBody] AccountCreateRequest request) { - if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); + if (!await auth.ValidateCaptcha(request.CaptchaToken)) + return BadRequest(ApiError.Validation(new Dictionary + { + [nameof(request.CaptchaToken)] = ["Invalid captcha token."] + }, traceId: HttpContext.TraceIdentifier)); try { @@ -96,7 +101,14 @@ public class AccountController( } catch (Exception ex) { - return BadRequest(ex.Message); + return BadRequest(new ApiError + { + Code = "BAD_REQUEST", + Message = "Failed to create account.", + Detail = ex.Message, + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); } } @@ -109,10 +121,22 @@ public class AccountController( [HttpPost("recovery/password")] public async Task RequestResetPassword([FromBody] RecoveryPasswordRequest request) { - if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); + if (!await auth.ValidateCaptcha(request.CaptchaToken)) + return BadRequest(ApiError.Validation(new Dictionary + { + [nameof(request.CaptchaToken)] = new[] { "Invalid captcha token." } + }, traceId: HttpContext.TraceIdentifier)); var account = await accounts.LookupAccount(request.Account); - if (account is null) return BadRequest("Unable to find the account."); + if (account is null) + return BadRequest(new ApiError + { + Code = "NOT_FOUND", + Message = "Unable to find the account.", + Detail = request.Account, + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); try { @@ -120,7 +144,13 @@ public class AccountController( } catch (InvalidOperationException) { - return BadRequest("You already requested password reset within 24 hours."); + return BadRequest(new ApiError + { + Code = "TOO_MANY_REQUESTS", + Message = "You already requested password reset within 24 hours.", + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); } return Ok(); @@ -139,7 +169,15 @@ public class AccountController( public async Task> GetOtherStatus(string name) { var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name); - if (account is null) return BadRequest(); + if (account is null) + return BadRequest(new ApiError + { + Code = "NOT_FOUND", + Message = "Account not found.", + Detail = name, + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); var status = await events.GetStatus(account.Id); status.IsInvisible = false; // Keep the invisible field not available for other users return Ok(status); @@ -156,11 +194,27 @@ public class AccountController( month ??= currentDate.Month; year ??= currentDate.Year; - if (month is < 1 or > 12) return BadRequest("Invalid month."); - if (year < 1) return BadRequest("Invalid year."); + if (month is < 1 or > 12) + return BadRequest(ApiError.Validation(new Dictionary + { + [nameof(month)] = new[] { "Month must be between 1 and 12." } + }, traceId: HttpContext.TraceIdentifier)); + if (year < 1) + return BadRequest(ApiError.Validation(new Dictionary + { + [nameof(year)] = new[] { "Year must be a positive integer." } + }, traceId: HttpContext.TraceIdentifier)); var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name); - if (account is null) return BadRequest(); + if (account is null) + return BadRequest(new ApiError + { + Code = "not_found", + Message = "Account not found.", + Detail = name, + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true); return Ok(calendar); diff --git a/DysonNetwork.Pass/Account/AccountCurrentController.cs b/DysonNetwork.Pass/Account/AccountCurrentController.cs index 3bc1866..a7ab814 100644 --- a/DysonNetwork.Pass/Account/AccountCurrentController.cs +++ b/DysonNetwork.Pass/Account/AccountCurrentController.cs @@ -3,6 +3,7 @@ using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Wallet; using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Error; using DysonNetwork.Shared.Proto; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -92,7 +93,14 @@ public class AccountCurrentController( var profile = await db.AccountProfiles .Where(p => p.Account.Id == userId) .FirstOrDefaultAsync(); - if (profile is null) return BadRequest("Unable to get your account."); + if (profile is null) + return BadRequest(new ApiError + { + Code = "NOT_FOUND", + Message = "Unable to get your account.", + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); if (request.FirstName is not null) profile.FirstName = request.FirstName; if (request.MiddleName is not null) profile.MiddleName = request.MiddleName; @@ -160,7 +168,13 @@ public class AccountCurrentController( } catch (InvalidOperationException) { - return BadRequest("You already requested account deletion within 24 hours."); + return BadRequest(new ApiError + { + Code = "TOO_MANY_REQUESTS", + Message = "You already requested account deletion within 24 hours.", + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); } return Ok(); @@ -186,7 +200,7 @@ public class AccountCurrentController( .Where(e => e.ClearedAt == null || e.ClearedAt > now) .OrderByDescending(e => e.CreatedAt) .FirstOrDefaultAsync(); - if (status is null) return NotFound(); + if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier)); status.Attitude = request.Attitude; status.IsInvisible = request.IsInvisible; @@ -254,7 +268,7 @@ public class AccountCurrentController( .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(); - return result is null ? NotFound() : Ok(result); + return result is null ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier)) : Ok(result); } [HttpPost("check-in")] @@ -269,15 +283,30 @@ public class AccountCurrentController( { var isAvailable = await events.CheckInDailyIsAvailable(currentUser); if (!isAvailable) - return BadRequest("Check-in is not available for today."); + return BadRequest(new ApiError + { + Code = "BAD_REQUEST", + Message = "Check-in is not available for today.", + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); } else { if (currentUser.PerkSubscription is null) - return StatusCode(403, "You need to have a subscription to check-in backdated."); + return StatusCode(403, ApiError.Unauthorized( + message: "You need to have a subscription to check-in backdated.", + forbidden: true, + traceId: HttpContext.TraceIdentifier)); var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value); if (!isAvailable) - return BadRequest("Check-in is not available for this date."); + return BadRequest(new ApiError + { + Code = "BAD_REQUEST", + Message = "Check-in is not available for this date.", + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); } try @@ -286,15 +315,31 @@ public class AccountCurrentController( return needsCaptcha switch { true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423, - "Captcha is required for this check-in." + new ApiError + { + Code = "CAPTCHA_REQUIRED", + Message = "Captcha is required for this check-in.", + Status = 423, + TraceId = HttpContext.TraceIdentifier + } ), - true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."), + true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(new Dictionary + { + ["captchaToken"] = new[] { "Invalid captcha token." } + }, traceId: HttpContext.TraceIdentifier)), _ => await events.CheckInDaily(currentUser, backdated) }; } catch (InvalidOperationException ex) { - return BadRequest(ex.Message); + return BadRequest(new ApiError + { + Code = "BAD_REQUEST", + Message = "Check-in failed.", + Detail = ex.Message, + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); } } @@ -308,8 +353,16 @@ public class AccountCurrentController( month ??= currentDate.Month; year ??= currentDate.Year; - if (month is < 1 or > 12) return BadRequest("Invalid month."); - if (year < 1) return BadRequest("Invalid year."); + if (month is < 1 or > 12) + return BadRequest(ApiError.Validation(new Dictionary + { + [nameof(month)] = new[] { "Month must be between 1 and 12." } + }, traceId: HttpContext.TraceIdentifier)); + if (year < 1) + return BadRequest(ApiError.Validation(new Dictionary + { + [nameof(year)] = new[] { "Year must be a positive integer." } + }, traceId: HttpContext.TraceIdentifier)); var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value); return Ok(calendar); @@ -365,7 +418,13 @@ public class AccountCurrentController( { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) - return BadRequest($"Auth factor with type {request.Type} is already exists."); + return BadRequest(new ApiError + { + Code = "ALREADY_EXISTS", + Message = $"Auth factor with type {request.Type} already exists.", + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret); return Ok(factor); @@ -380,7 +439,7 @@ public class AccountCurrentController( var factor = await db.AccountAuthFactors .Where(f => f.AccountId == currentUser.Id && f.Id == id) .FirstOrDefaultAsync(); - if (factor is null) return NotFound(); + if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier)); try { @@ -389,7 +448,14 @@ public class AccountCurrentController( } catch (Exception ex) { - return BadRequest(ex.Message); + return BadRequest(new ApiError + { + Code = "BAD_REQUEST", + Message = "Failed to enable auth factor.", + Detail = ex.Message, + Status = 400, + TraceId = HttpContext.TraceIdentifier + }); } } diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj index 907c25b..280ffa9 100644 --- a/DysonNetwork.Shared/DysonNetwork.Shared.csproj +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -36,4 +36,8 @@ + + + + diff --git a/DysonNetwork.Shared/Error/ApiError.cs b/DysonNetwork.Shared/Error/ApiError.cs new file mode 100644 index 0000000..3c229d3 --- /dev/null +++ b/DysonNetwork.Shared/Error/ApiError.cs @@ -0,0 +1,135 @@ +using System.Text.Json.Serialization; + +namespace DysonNetwork.Shared.Error; + +/// +/// Standardized error payload to return to clients. +/// Inspired by RFC7807 (problem+json) with app-specific fields. +/// +public class ApiError +{ + /// + /// Application-specific error code (e.g., "VALIDATION_ERROR", "NOT_FOUND", "SERVER_ERROR"). + /// + [JsonPropertyName("code")] + public string Code { get; set; } = "UNKNOWN_ERROR"; + + /// + /// Short, human-readable message for the error. + /// + [JsonPropertyName("message")] + public string Message { get; set; } = "An unexpected error occurred."; + + /// + /// HTTP status code to be used by the server when sending this error. + /// Optional to keep the model transport-agnostic. + /// + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Status { get; set; } + + /// + /// More detailed description of the error. + /// + [JsonPropertyName("detail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Detail { get; set; } + + /// + /// Server trace identifier (e.g., from HttpContext.TraceIdentifier) to help debugging. + /// + [JsonPropertyName("traceId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TraceId { get; set; } + + /// + /// Field-level validation errors: key is the field name, value is an array of messages. + /// + [JsonPropertyName("errors")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Errors { get; set; } + + /// + /// Arbitrary additional metadata for clients. + /// + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Meta { get; set; } + + /// + /// Factory for a validation error payload. + /// + public static ApiError Validation( + Dictionary errors, + string? message = null, + int status = 400, + string code = "VALIDATION_ERROR", + string? traceId = null) + { + return new ApiError + { + Code = code, + Message = message ?? "One or more validation errors occurred.", + Status = status, + Errors = errors, + TraceId = traceId + }; + } + + /// + /// Factory for a not-found error payload. + /// + public static ApiError NotFound( + string resource, + string? message = null, + int status = 404, + string code = "NOT_FOUND", + string? traceId = null) + { + return new ApiError + { + Code = code, + Message = message ?? $"The requested resource '{resource}' was not found.", + Status = status, + Detail = resource, + TraceId = traceId + }; + } + + /// + /// Factory for a generic server error payload. + /// + public static ApiError Server( + string? message = null, + int status = 500, + string code = "SERVER_ERROR", + string? traceId = null, + string? detail = null) + { + return new ApiError + { + Code = code, + Message = message ?? "An internal server error occurred.", + Status = status, + TraceId = traceId, + Detail = detail + }; + } + + /// + /// Factory for an unauthorized/forbidden error payload. + /// + public static ApiError Unauthorized( + string? message = null, + bool forbidden = false, + string? traceId = null) + { + return new ApiError + { + Code = forbidden ? "FORBIDDEN" : "UNAUTHORIZED", + Message = message ?? (forbidden ? "You do not have permission to perform this action." : "Authentication is required."), + Status = forbidden ? 403 : 401, + TraceId = traceId + }; + } +}