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
18 changes: 14 additions & 4 deletions src/Orbit.Api/Middleware/ValidationExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Text.Json;
using FluentValidation;
using Microsoft.AspNetCore.Diagnostics;
using Orbit.Api.Extensions;
using Orbit.Application.Common;

namespace Orbit.Api.Middleware;

Expand All @@ -19,11 +19,20 @@ public async ValueTask<bool> TryHandleAsync(
httpContext.Response.ContentType = "application/json";
httpContext.Response.Headers[HttpContextExtensions.RequestIdHeaderName] = httpContext.GetRequestId();

var errors = validationException.Errors
var languageResolver = httpContext.RequestServices.GetRequiredService<IRequestLanguageResolver>();
var isPtBr = await languageResolver.IsPortugueseAsync(httpContext, cancellationToken);
var errorDetails = validationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
g => g.Select(e => new
{
code = e.ErrorCode,
message = e.LocalizedMessage(isPtBr)
}).ToArray());
var errors = errorDetails.ToDictionary(
g => g.Key,
g => g.Value.Select(e => e.message).ToArray());

LogValidationFailed(
logger,
Expand All @@ -36,7 +45,8 @@ public async ValueTask<bool> TryHandleAsync(
type = "ValidationFailure",
status = 400,
requestId = httpContext.GetRequestId(),
errors
errors,
errorDetails
};

await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ public static void AddHabitIdsRules<T>(IRuleBuilder<T, IReadOnlyList<Guid>> rule
rule
.NotEmpty()
.Must(ids => ids is null || ids.Count <= AppConstants.MaxAccountabilityHabitsPerUser)
.WithMessage($"You can link at most {AppConstants.MaxAccountabilityHabitsPerUser} habits per pair.")
.WithCopy(ValidationErrorCodes.AccountabilityHabitLimit)
.Must(ids => ids is null || ids.All(id => id != Guid.Empty))
.WithMessage("Habit ids must not be empty.")
.WithCopy(ValidationErrorCodes.HabitIdsRequired)
.Must(ids => ids is null || ids.Distinct().Count() == ids.Count)
.WithMessage("Habit ids must not contain duplicates.");
.WithCopy(ValidationErrorCodes.HabitIdsUnique);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Orbit.Application.Common;
using FluentValidation;
using Orbit.Application.ApiKeys.Commands;

Expand All @@ -14,6 +15,6 @@ public ConfirmApiKeyCreationChallengeCommandValidator()
.NotEmpty()
.Length(6)
.Matches(@"^\d{6}$")
.WithMessage("Code must be a 6-digit number");
.WithCopy(ValidationErrorCodes.VerificationCodeFormat);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Orbit.Application.Common;
using FluentValidation;
using Orbit.Application.ApiKeys.Commands;
using Orbit.Domain.Models;
Expand All @@ -13,18 +14,18 @@ public CreateApiKeyValidator()

RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("API key name is required.")
.WithCopy(ValidationErrorCodes.ApiKeyNameRequired)
.MaximumLength(50)
.WithMessage("API key name must be 50 characters or less.");
.WithCopy(ValidationErrorCodes.ApiKeyNameLength);

RuleForEach(x => x.Scopes)
.NotEmpty()
.WithMessage("API key scopes must be non-empty strings.")
.WithCopy(ValidationErrorCodes.ApiKeyScopesRequired)
.Must(scope => string.IsNullOrWhiteSpace(scope) || AgentScopes.All.Contains(scope.Trim()))
.WithMessage("API key scope '{PropertyValue}' is not a recognized scope.");
.WithCopy(ValidationErrorCodes.ApiKeyScopeInvalid);

RuleFor(x => x.ExpiresAtUtc)
.Must(expiresAtUtc => !expiresAtUtc.HasValue || expiresAtUtc.Value > DateTime.UtcNow)
.WithMessage("API key expiry must be in the future.");
.WithCopy(ValidationErrorCodes.ApiKeyExpiryFuture);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Orbit.Application.Common;
using FluentValidation;
using Orbit.Application.Auth.Commands;

Expand All @@ -14,6 +15,6 @@ public ConfirmAccountDeletionCommandValidator()
.NotEmpty()
.Length(6)
.Matches(@"^\d{6}$")
.WithMessage("Code must be a 6-digit number");
.WithCopy(ValidationErrorCodes.VerificationCodeFormat);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ public GoogleAuthCommandValidator()
{
RuleFor(x => x.AccessToken)
.NotEmpty()
.WithMessage("Access token is required.");
.WithCopy(ValidationErrorCodes.AccessTokenRequired);

RuleFor(x => x.Language)
.NotEmpty()
.MaximumLength(AppConstants.MaxLanguageLength)
.Must(lang => AppConstants.SupportedLanguages.Contains(lang))
.WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}");
.WithCopy(ValidationErrorCodes.LanguageSupported);
}
}
3 changes: 2 additions & 1 deletion src/Orbit.Application/Auth/Validators/RefreshTokenRules.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Orbit.Application.Common;
using FluentValidation;

namespace Orbit.Application.Auth.Validators;
Expand All @@ -15,6 +16,6 @@ public static void AddRefreshTokenRules<T>(IRuleBuilder<T, string> rule)
rule
.NotEmpty()
.Must(token => IsWellFormed(token))
.WithMessage("Refresh token format is invalid.");
.WithCopy(ValidationErrorCodes.RefreshTokenFormat);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ public VerifyCodeCommandValidator()
.NotEmpty()
.Length(6)
.Matches(@"^\d{6}$")
.WithMessage("Code must be a 6-digit number");
.WithCopy(ValidationErrorCodes.VerificationCodeFormat);

RuleFor(x => x.Language)
.NotEmpty()
.MaximumLength(AppConstants.MaxLanguageLength)
.Must(lang => AppConstants.SupportedLanguages.Contains(lang))
.WithMessage($"Language must be one of: {string.Join(", ", AppConstants.SupportedLanguages)}");
.WithCopy(ValidationErrorCodes.LanguageSupported);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ public SetSelectedCalendarsCommandValidator()
RuleFor(x => x.CalendarIds)
.NotNull()
.Must(ids => ids.Count <= AppConstants.MaxSelectedCalendars)
.WithMessage($"You can select at most {AppConstants.MaxSelectedCalendars} calendars.");
.WithCopy(ValidationErrorCodes.CalendarSelectionLimit);

RuleForEach(x => x.CalendarIds)
.NotEmpty()
.WithMessage("Calendar ids must be non-empty.");
.WithCopy(ValidationErrorCodes.CalendarIdsRequired);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,34 @@ public CreateChallengeCommandValidator()
RuleFor(x => x.TargetCount)
.NotNull().GreaterThan(0)
.When(x => x.Type == ChallengeType.CoopGoal)
.WithMessage("A goal challenge requires a target count greater than 0.");
.WithCopy(ValidationErrorCodes.ChallengeTargetPositive);

RuleFor(x => x.TargetCount)
.Null()
.When(x => x.Type == ChallengeType.StreakTogether)
.WithMessage("A streak challenge cannot have a target count.");
.WithCopy(ValidationErrorCodes.StreakChallengeTargetAbsent);

RuleFor(x => x.PeriodEndUtc)
.NotNull()
.When(x => x.Type == ChallengeType.CoopGoal)
.WithMessage("A goal challenge requires an end date.");
.WithCopy(ValidationErrorCodes.ChallengeEndDateRequired);

RuleFor(x => x.PeriodEndUtc)
.GreaterThanOrEqualTo(x => x.PeriodStartUtc)
.When(x => x.PeriodEndUtc.HasValue);

RuleFor(x => x.LinkedHabitIds)
.NotEmpty()
.WithMessage("Link at least one of your habits to the challenge.");
.WithCopy(ValidationErrorCodes.ChallengeHabitsRequired);

RuleFor(x => x.LinkedHabitIds)
.Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant)
.WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits.");
.WithCopy(ValidationErrorCodes.ChallengeHabitLimit);

RuleForEach(x => x.LinkedHabitIds).NotEmpty();

RuleFor(x => x.InvitedFriendUserIds)
.Must(ids => ids.Count <= AppConstants.MaxChallengeParticipants - 1)
.WithMessage($"You can invite at most {AppConstants.MaxChallengeParticipants - 1} friends.");
.WithCopy(ValidationErrorCodes.ChallengeInviteLimit);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ public JoinChallengeCommandValidator()

RuleFor(x => x.LinkedHabitIds)
.NotEmpty()
.WithMessage("Link at least one of your habits to the challenge.");
.WithCopy(ValidationErrorCodes.ChallengeHabitsRequired);

RuleFor(x => x.LinkedHabitIds)
.Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant)
.WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits.");
.WithCopy(ValidationErrorCodes.ChallengeHabitLimit);

RuleForEach(x => x.LinkedHabitIds).NotEmpty();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ public SetChallengeHabitsCommandValidator()

RuleFor(x => x.HabitIds)
.NotEmpty()
.WithMessage("Link at least one of your habits to the challenge.");
.WithCopy(ValidationErrorCodes.ChallengeHabitsRequired);

RuleFor(x => x.HabitIds)
.Must(ids => ids.Count <= AppConstants.MaxHabitsPerChallengeParticipant)
.WithMessage($"You can link at most {AppConstants.MaxHabitsPerChallengeParticipant} habits.");
.WithCopy(ValidationErrorCodes.ChallengeHabitLimit);

RuleForEach(x => x.HabitIds).NotEmpty();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,21 @@ public ProcessUserChatCommandValidator()

RuleFor(x => x.Message)
.NotEmpty()
.WithMessage("Message cannot be empty.")
.WithCopy(ValidationErrorCodes.ChatMessageRequired)
.MaximumLength(AppConstants.MaxChatMessageLength)
.WithMessage($"Message cannot exceed {AppConstants.MaxChatMessageLength} characters.");
.WithCopy(ValidationErrorCodes.ChatMessageLength);

RuleFor(x => x.History)
.Must(history => history is null || history.Count <= AppConstants.MaxChatHistoryMessages)
.WithMessage($"Chat history cannot exceed {AppConstants.MaxChatHistoryMessages} messages.");
.WithCopy(ValidationErrorCodes.ChatHistoryLimit);

RuleForEach(x => x.History)
.Must(message => ChatHistoryMessage.IsSupportedRole(message.Role))
.WithMessage("Chat history contains an invalid role.");
.WithCopy(ValidationErrorCodes.ChatHistoryRole);

RuleForEach(x => x.History)
.Must(message => !string.IsNullOrWhiteSpace(message.Content) &&
message.Content.Length <= AppConstants.MaxChatHistoryMessageLength)
.WithMessage($"Chat history messages must be between 1 and {AppConstants.MaxChatHistoryMessageLength} characters.");
.WithCopy(ValidationErrorCodes.ChatHistoryMessageLength);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ public ResolveClarificationRequestValidator()
RuleFor(x => x.Value)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.WithMessage(ErrorMessages.ClarificationValueEmpty.Message)
.WithErrorCode(ErrorMessages.ClarificationValueEmpty.Code)
.WithCopy(ErrorMessages.ClarificationValueEmpty.Code)
.MaximumLength(AppConstants.MaxClarificationValueLength)
.WithMessage(ErrorMessages.ClarificationValueTooLong.Format(AppConstants.MaxClarificationValueLength).Message)
.WithErrorCode(ErrorMessages.ClarificationValueTooLong.Code)
.WithCopy(ErrorMessages.ClarificationValueTooLong.Code, AppConstants.MaxClarificationValueLength)
.Must(BeJsonObject)
.WithMessage(ErrorMessages.ClarificationValueNotJsonObject.Message)
.WithErrorCode(ErrorMessages.ClarificationValueNotJsonObject.Code);
.WithCopy(ErrorMessages.ClarificationValueNotJsonObject.Code);
}

private static bool BeJsonObject(string value)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Orbit.Application.Common;
using FluentValidation;

namespace Orbit.Application.Chat.Validators;
Expand All @@ -9,11 +10,11 @@ public RevisePendingOperationRequestValidator()
RuleFor(request => request.PreviewFingerprint).NotEmpty().MaximumLength(256);
RuleFor(request => request.Items).NotNull()
.Must(items => items is not null && items.Count <= 500)
.WithMessage("At most 500 items may be revised.");
.WithCopy(ValidationErrorCodes.RevisionItemLimit);
RuleFor(request => request.Items)
.Must(items => items is not null && items.Select(item => item.ItemId)
.Distinct(StringComparer.Ordinal).Count() == items.Count)
.WithMessage("Item IDs must be unique.");
.WithCopy(ValidationErrorCodes.RevisionItemIdsUnique);
RuleForEach(request => request.Items).ChildRules(item =>
item.RuleFor(value => value.ItemId).NotEmpty().MaximumLength(100));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ public TranscribeAudioCommandValidator()
{
RuleFor(x => x.Audio)
.Must(audio => audio.Length > 0)
.WithMessage("Audio file is required.")
.WithCopy(ValidationErrorCodes.AudioRequired)
.Must(audio => audio.Length <= AppConstants.MaxAudioBytes)
.WithMessage($"Audio exceeds the maximum size of {AppConstants.MaxAudioBytes / (1024 * 1024)}MB.");
.WithCopy(ValidationErrorCodes.AudioSizeLimit);

RuleFor(x => x.FileName)
.Must(HasAllowedExtension)
.WithMessage(x => $"Audio format '{Path.GetExtension(x.FileName)}' is not supported.");
.WithCopy(ValidationErrorCodes.AudioFormat, x => [Path.GetExtension(x.FileName)]);
}

private static bool HasAllowedExtension(string fileName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ public CreateChecklistTemplateCommandValidator()
RuleFor(x => x.Items)
.Must(items => items.Count <= AppConstants.MaxChecklistItems)
.When(x => x.Items is not null)
.WithMessage($"A checklist template can have at most {AppConstants.MaxChecklistItems} items.");
.WithCopy(ValidationErrorCodes.TemplateItemLimit);

RuleForEach(x => x.Items)
.NotEmpty()
.WithMessage("Checklist template items must not be empty.")
.WithCopy(ValidationErrorCodes.TemplateItemRequired)
.MaximumLength(AppConstants.MaxChecklistItemTextLength)
.WithMessage($"Checklist template item text must not exceed {AppConstants.MaxChecklistItemTextLength} characters.");
.WithCopy(ValidationErrorCodes.TemplateItemLength);
}
}
Loading
Loading