Skip to content
Open
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
2 changes: 2 additions & 0 deletions sample/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageVersion Include="Ardalis.SmartEnum" Version="8.2.0" />
<PackageVersion Include="Ardalis.Specification" Version="9.3.1" />
<PackageVersion Include="Ardalis.Specification.EntityFrameworkCore" Version="9.3.1" />
<PackageVersion Include="AspNetCore.Localizer.Json" Version="1.0.2" />
<PackageVersion Include="Azure.Identity" Version="1.13.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="FastEndpoints" Version="7.0.1" />
Expand All @@ -28,6 +29,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Localization" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
Expand Down
27 changes: 27 additions & 0 deletions sample/src/NimblePros.SampleToDo.Core/Localization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Localization;

/// <summary>
/// Exposes the current <see cref="IStringLocalizer"/> to static code
/// (Vogen Validate, domain events, etc.) without pulling the whole
/// DI container into Core.
/// </summary>
public static class Localization
{
/// <summary>
/// Set by <c>Program.cs</c> during app startup.
/// </summary>
public static ILocalizationContext? Current { get; set; }

public sealed class LocalizationContext : ILocalizationContext
{
public IStringLocalizer Localizer { get; }

public LocalizationContext(IStringLocalizer localizer) =>
Localizer = localizer;
}
}

public interface ILocalizationContext
{
IStringLocalizer Localizer { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageReference Include="Ardalis.SmartEnum" />
<PackageReference Include="Ardalis.Specification" />
<PackageReference Include="Mediator.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="NimblePros.SharedKernel" />
<PackageReference Include="Vogen" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NimblePros.SampleToDo.Core.ProjectAggregate.Events;
using NimblePros.SampleToDo.Core.ContributorAggregate.Events;
using NimblePros.SampleToDo.Core.ProjectAggregate.Events;

namespace NimblePros.SampleToDo.Core.ProjectAggregate;

Expand Down Expand Up @@ -27,6 +28,7 @@ public Project AddItem(ToDoItem newItem)

public Project UpdateName(ProjectName newName)
{
if (Name.Equals(newName)) return this;
Name = newName;
return this;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Core/ProjectAggregate/ProjectMessages.cs
namespace NimblePros.SampleToDo.Core.ProjectAggregate;

/// <summary>
/// Strongly-typed, localisable message accessors for the Project aggregate.
/// The implementation is deliberately **static** so it can be called from Vogen’s
/// static <c>Validate</c> method.
/// </summary>
public static class ProjectErrorMessages
{
// -----------------------------------------------------------------
// 1. Public entry points – these are what you call from the domain
// -----------------------------------------------------------------
public static string CoreProjectNameEmpty => Get(nameof(CoreProjectNameEmpty));
public static string CoreProjectNameTooLong(int maxLength) => Get(nameof(CoreProjectNameTooLong), maxLength);
public static string CoreToDoItemDescriptionEmpty => Get(nameof(CoreToDoItemDescriptionEmpty));
public static string CoreToDoItemDescriptionTooLong(int maxLength) => Get(nameof(CoreToDoItemDescriptionTooLong), maxLength);
public static string CoreToDoItemTitleEmpty => Get(nameof(CoreToDoItemTitleEmpty));
public static string CoreToDoItemTitleTooLong(int maxLength) => Get(nameof(CoreToDoItemTitleTooLong), maxLength);

// -----------------------------------------------------------------
// 2. Private helper that forwards to the current localizer
// -----------------------------------------------------------------
private static string Get(string key, params object[] args)
{
// The static holder is set once in Program.cs (Web project)
var localizer = Localization.Current?.Localizer;

if (localizer is not null)
{
// Uses the standard {0}, {1}… placeholders defined in JSON/RESX
var localized = localizer[key, args];
return localized.ResourceNotFound ? Fallback(key, args) : localized;
}

// No DI container available (e.g. unit-tests) → fallback to English
return Fallback(key, args);
}

// -----------------------------------------------------------------
// 3. Hard-coded English fallback (never throws, always returns a string)
// -----------------------------------------------------------------
private static string Fallback(string key, object[] args) => key switch
{
nameof(CoreProjectNameEmpty) => "Name cannot be empty",
nameof(CoreProjectNameTooLong) => FormattableString.Invariant($"Name cannot be longer than {args[0]} characters"),
nameof(CoreToDoItemDescriptionEmpty) => "Description cannot be empty",
nameof(CoreToDoItemDescriptionTooLong) => FormattableString.Invariant($"Description cannot be longer than {args[0]} characters"),
nameof(CoreToDoItemTitleEmpty) => "Title cannot be empty",
nameof(CoreToDoItemTitleTooLong) => FormattableString.Invariant($"Title cannot be longer than {args[0]} characters"),
_ => $"[{key}]"
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ public partial struct ProjectName
public const int MaxLength = 100;
private static Validation Validate(in string name) =>
string.IsNullOrEmpty(name)
? Validation.Invalid("Name cannot be empty")
? Validation.Invalid(ProjectErrorMessages.CoreProjectNameEmpty)
: name.Length > MaxLength
? Validation.Invalid($"Name cannot be longer than {MaxLength} characters")
? Validation.Invalid(ProjectErrorMessages.CoreProjectNameTooLong(MaxLength))
: Validation.Ok;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public IncompleteItemsSearchSpec(string searchString)
{
Query
.Where(item => !item.IsDone &&
(item.Title.Contains(searchString) ||
item.Description.Contains(searchString)));
(item.Title.Value.Contains(searchString) ||
item.Description.Value.Contains(searchString)));
}
}
28 changes: 24 additions & 4 deletions sample/src/NimblePros.SampleToDo.Core/ProjectAggregate/ToDoItem.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NimblePros.SampleToDo.Core.ContributorAggregate;
using System.Xml.Linq;
using NimblePros.SampleToDo.Core.ContributorAggregate;
using NimblePros.SampleToDo.Core.ProjectAggregate.Events;

namespace NimblePros.SampleToDo.Core.ProjectAggregate;
Expand All @@ -8,14 +9,19 @@ public class ToDoItem : EntityBase<ToDoItem, ToDoItemId>
public ToDoItem() : this(Priority.Backlog)
{
}
public ToDoItem(ToDoItemTitle title, ToDoItemDescription description) : this(Priority.Backlog)
{
Title = title;
Description = description;
}

public ToDoItem(Priority priority)
{
Priority = priority;
}

public string Title { get; set; } = string.Empty; // TODO: Use Value Object
public string Description { get; set; } = string.Empty; // TODO: Use Value Object
public ToDoItemTitle Title { get; private set; }
public ToDoItemDescription Description { get; private set; }
public ContributorId? ContributorId { get; private set; } // tasks don't have anyone assigned when first created
public bool IsDone { get; private set; }

Expand Down Expand Up @@ -48,9 +54,23 @@ public ToDoItem RemoveContributor()
return this;
}

public ToDoItem UpdateTitle(ToDoItemTitle newTitle)
{
if (Title.Equals(newTitle)) return this;
Title = newTitle;
return this;
}

public ToDoItem UpdateDescription(ToDoItemDescription newDescription)
{
if (Description.Equals(newDescription)) return this;
Description = newDescription;
return this;
}

public override string ToString()
{
string status = IsDone ? "Done!" : "Not done.";
return $"{Id}: Status: {status} - {Title} - Priority: {Priority}";
return $"{Id}: Status: {status} - {Title.Value} - Priority: {Priority}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Vogen;

namespace NimblePros.SampleToDo.Core.ProjectAggregate;

[ValueObject<string>(conversions: Conversions.SystemTextJson)]
public partial struct ToDoItemDescription
{
public const int MaxLength = 200;
private static Validation Validate(in string description) =>
string.IsNullOrEmpty(description)
? Validation.Invalid(ProjectErrorMessages.CoreToDoItemDescriptionEmpty)
: description.Length > MaxLength
? Validation.Invalid(ProjectErrorMessages.CoreToDoItemDescriptionTooLong(MaxLength))
: Validation.Ok;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Vogen;

namespace NimblePros.SampleToDo.Core.ProjectAggregate;

[ValueObject<string>(conversions: Conversions.SystemTextJson)]
public partial struct ToDoItemTitle
{
public const int MaxLength = 100;
private static Validation Validate(in string title) =>
string.IsNullOrEmpty(title)
? Validation.Invalid(ProjectErrorMessages.CoreToDoItemTitleEmpty)
: title.Length > MaxLength
? Validation.Invalid(ProjectErrorMessages.CoreToDoItemTitleTooLong(MaxLength))
: Validation.Ok;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ public void Configure(EntityTypeBuilder<ToDoItem> builder)
.HasValueGenerator<VogenIdValueGenerator<AppDbContext, ToDoItem, ToDoItemId>>()
.HasVogenConversion()
.IsRequired();
builder.Property(t => t.Title)
.HasMaxLength(DataSchemaConstants.DEFAULT_NAME_LENGTH)

builder.Property(p => p.Title)
.HasVogenConversion()
.HasMaxLength(ToDoItemTitle.MaxLength)
.IsRequired();

builder.Property(p => p.Description)
.HasVogenConversion()
.HasMaxLength(ToDoItemDescription.MaxLength)
.IsRequired();
builder.Property(t => t.Description)
.HasMaxLength(200);

builder.Property(t => t.ContributorId)
.HasConversion(
v => v.HasValue ? v.Value.Value : (int?)null, // to db
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ namespace NimblePros.SampleToDo.Infrastructure.Data.Config;
[EfCoreConverter<ContributorName>]
[EfCoreConverter<ProjectName>]
[EfCoreConverter<ProjectId>]
[EfCoreConverter<ToDoItemTitle>]
[EfCoreConverter<ToDoItemDescription>]
internal partial class VogenEfCoreConverters;
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public async Task<IEnumerable<ToDoItemDto>> ListAsync(int projectId)
var projectParameter = new SqlParameter("@projectId", System.Data.SqlDbType.Int);
var result = await _db.ToDoItems.FromSqlRaw("SELECT Id, Title, Description, IsDone, ContributorId FROM ToDoItems WHERE ProjectId = @ProjectId",
projectParameter) // don't fetch other big columns
.Select(x => new ToDoItemDto(x.Id, x.Title, x.Description, x.IsDone, x.ContributorId))
.Select(x => new ToDoItemDto(x.Id, x.Title.Value, x.Description.Value, x.IsDone, x.ContributorId))
.ToListAsync();

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ namespace NimblePros.SampleToDo.UseCases.Projects.AddToDoItem;
/// <param name="Description"></param>
public record AddToDoItemCommand(ProjectId ProjectId,
ContributorId? ContributorId,
string Title,
string Description) : ICommand<Result<ToDoItemId>>;
ToDoItemTitle Title,
ToDoItemDescription Description) : ICommand<Result<ToDoItemId>>;
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,9 @@ public async ValueTask<Result<ToDoItemId>> Handle(AddToDoItemCommand request,
return Result.NotFound();
}

var newItem = new ToDoItem()
{
Title = request.Title!,
Description = request.Description!
};
var newItem = new ToDoItem(title: request.Title!, description: request.Description!);

if(request.ContributorId.HasValue)
if (request.ContributorId.HasValue)
{
newItem.AddContributor(request.ContributorId.Value);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public async ValueTask<Result<ProjectWithAllItemsDto>> Handle(GetProjectWithAllI
if (project == null) return Result.NotFound();

var items = project.Items
.Select(i => new ToDoItemDto(i.Id, i.Title, i.Description, i.IsDone, i.ContributorId)).ToList();
.Select(i => new ToDoItemDto(i.Id, i.Title.Value, i.Description.Value, i.IsDone, i.ContributorId)).ToList();
return new ProjectWithAllItemsDto(project.Id, project.Name, items, project.Status.ToString())
;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Microsoft.Build.CentralPackageVersions" Version="2.1.3" />

<PropertyGroup>
<PreserveCompilationContext>true</PreserveCompilationContext>
<OutputType>Exe</OutputType>
Expand All @@ -10,11 +10,11 @@
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<!--<DocumentationFile>bin\swagger-docs.xml</DocumentationFile>-->
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Ardalis.ListStartupServices" />
<PackageReference Include="Ardalis.Result" />
<PackageReference Include="Ardalis.Result.AspNetCore" />
<PackageReference Include="AspNetCore.Localizer.Json" />
<PackageReference Include="FastEndpoints" />
<PackageReference Include="FastEndpoints.ApiExplorer" />
<PackageReference Include="FastEndpoints.Swagger" />
Expand All @@ -32,11 +32,11 @@
<!--<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />-->
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\NimblePros.SampleToDo.Infrastructure\NimblePros.SampleToDo.Infrastructure.csproj" />
<ProjectReference Include="..\NimblePros.SampleToDo.ServiceDefaults\NimblePros.SampleToDo.ServiceDefaults.csproj" />
<ProjectReference Include="..\NimblePros.SampleToDo.UseCases\NimblePros.SampleToDo.UseCases.csproj" />
</ItemGroup>

</Project>
39 changes: 37 additions & 2 deletions sample/src/NimblePros.SampleToDo.Web/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using FluentValidation;
using System.Text;
using AspNetCore.Localizer.Json.Extensions;
using FluentValidation;
using Microsoft.Extensions.Localization;
using NimblePros.Metronome;
using NimblePros.SampleToDo.Core.ProjectAggregate;
using NimblePros.SampleToDo.Infrastructure.Data;
using NimblePros.SampleToDo.Web.Configurations;
using NimblePros.SampleToDo.Web.Projects;
Expand Down Expand Up @@ -60,8 +64,39 @@ private static async Task Main(string[] args)
// track db and external service calls
builder.Services.AddMetronome();

// Add localization with the JSON path
builder.Services.AddLocalization(options => options.ResourcesPath = "i18n");

// ----- Add JSON-specific localization
builder.Services.AddJsonLocalization(options =>
{
options.ResourcesPath = "i18n"; // Path to the JSON files (e.g., i18n/en/Project.json)
options.CacheDuration = TimeSpan.FromHours(1); // Optional: Cache for performance
options.FileEncoding = Encoding.UTF8; //Optional: Specify file encoding
});

// ----- Register the typed localizer for the Project aggregate -----
builder.Services.AddSingleton<IStringLocalizer>(sp =>
{
var factory = sp.GetRequiredService<IStringLocalizerFactory>();
// Creates a localizer for 'ProjectAggregate' – loads from i18n/{culture}/Project.json
return factory.Create(typeof(ProjectErrorMessages));
});

var app = builder.Build();


// ----- Set the static holder for Core access -----
var localizer = app.Services.GetRequiredService<IStringLocalizer>();
Localization.Current = new Localization.LocalizationContext(localizer);

// ----- Add request localization middleware (before app.Run()) -----
var supportedCultures = new[] { "en", "fr", "fa" }; // Add your cultures
var localizationOptions = new RequestLocalizationOptions()
.SetDefaultCulture(supportedCultures[0])
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
app.UseRequestLocalization(localizationOptions);

// Verify validators are registered properly in development
if (app.Environment.IsDevelopment())
{
Expand Down
Loading
Loading