diff --git a/.editorconfig b/.editorconfig index e15d309..ddcbe2b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -286,7 +286,7 @@ charset = unset trim_trailing_whitespace = true # Project files -[{*.csproj,*.slnx,Directory.**.props}] +[{**/*.csproj,*.slnx,Directory.**.props}] # Indentation and spacing indent_size = 2 diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..f76c244 --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,29 @@ +name: Run tests + +on: + pull_request: + branches: [ "main" ] + push: + branches: [ "main" ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Run tests + run: dotnet test --no-build --configuration Release --verbosity normal diff --git a/Directory.Packages.props b/Directory.Packages.props index 91493f3..ca8b9bf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,8 +11,11 @@ + + + diff --git a/StringTemplates.slnx b/StringTemplates.slnx index 6dc68fa..125b180 100644 --- a/StringTemplates.slnx +++ b/StringTemplates.slnx @@ -12,11 +12,15 @@ + + + + \ No newline at end of file diff --git a/examples/StringTemplates.Demo/Program.cs b/examples/StringTemplates.Demo/Program.cs index 29b61d5..a6eb318 100644 --- a/examples/StringTemplates.Demo/Program.cs +++ b/examples/StringTemplates.Demo/Program.cs @@ -1,6 +1,12 @@ +using Microsoft.AspNetCore.Mvc; +using StringTemplates.Extensions; +using StringTemplates.Services; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. +builder.Services.AddStringTemplates(); + // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); @@ -12,9 +18,22 @@ app.MapOpenApi(); } +// Map endpoints +app.MapPost("system", ( + [FromBody] SystemRequest request, + [FromServices] ITemplateService templateService) => + Results.Ok(templateService.ReplacePlaceholders(request.Template))); +app.MapPost("dictionary", ( + [FromBody] DictionaryRequest request, + [FromServices] ITemplateService> templateService) => + Results.Ok(templateService.ReplacePlaceholders(request.Template, request.Model))); + app.UseHttpsRedirection(); app.Run(); // Needed so WebApplicationFactory in tests can find the entry point public partial class Program; + +internal record SystemRequest(string Template); +internal record DictionaryRequest(string Template, Dictionary Model); diff --git a/examples/StringTemplates.Demo/StringTemplates.Demo.csproj b/examples/StringTemplates.Demo/StringTemplates.Demo.csproj index a16bf28..263b40d 100644 --- a/examples/StringTemplates.Demo/StringTemplates.Demo.csproj +++ b/examples/StringTemplates.Demo/StringTemplates.Demo.csproj @@ -1,7 +1,11 @@ - - - + + + + + + + diff --git a/examples/StringTemplates.Demo/StringTemplates.http b/examples/StringTemplates.Demo/StringTemplates.http new file mode 100644 index 0000000..5368867 --- /dev/null +++ b/examples/StringTemplates.Demo/StringTemplates.http @@ -0,0 +1,18 @@ +### POST 1 request to use system template +POST https://localhost:7228/system +Content-Type: application/json + +{ + "template": "The year is \u007B\u007B#System#Year#System#\u007D\u007D." +} +### + +### POST 2 request to use system template +POST https://localhost:7228/system +Content-Type: application/json + +{ + "template": "The time and date now is \u007B\u007B#System#Time.Now#System#\u007D\u007D of \u007B\u007B#System#Day.OfMonth#System#\u007D\u007Dth of \u007B\u007B#System#Month#System#\u007D\u007D \u007B\u007B#System#Year#System#\u007D\u007D." +} +### +### diff --git a/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs b/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs index 9d181dc..1e3d918 100644 --- a/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs +++ b/src/StringTemplates/Services/Plugins/SystemTemplatePlugin.cs @@ -24,6 +24,7 @@ public sealed class SystemTemplatePlugin : ITemplatePlugin "DateTime.Now" => DateTime.Now.ToString(CultureInfo.InvariantCulture), "DateTime.UtcNow" => DateTime.UtcNow.ToString(CultureInfo.InvariantCulture), "Day" => DateTime.Now.DayOfWeek.ToString(), + "Day.OfMonth" => DateTime.Now.Day.ToString(), "Month" => DateTime.Now.ToString("MMMM", CultureInfo.InvariantCulture), "Time.Now" => DateTime.Now.ToString("T", CultureInfo.InvariantCulture), "Time.UtcNow" => DateTime.UtcNow.ToString("T", CultureInfo.InvariantCulture), diff --git a/src/StringTemplates/StringTemplates.csproj b/src/StringTemplates/StringTemplates.csproj index 7bad09f..2b9425b 100644 --- a/src/StringTemplates/StringTemplates.csproj +++ b/src/StringTemplates/StringTemplates.csproj @@ -11,8 +11,8 @@ Copyright (c) Stratis Dermanoutsos 2025 LICENSE - https://github.com/Stratis-Dermanoutsos/StringTemplates - https://github.com/Stratis-Dermanoutsos/StringTemplates + https://github.com/Stratis-OSS/StringTemplates.Net + https://github.com/Stratis-OSS/StringTemplates.Net The best way to handle templates and replace placeholders. open-source oss csharp dotnet strings templates placeholders diff --git a/tests/StringTemplates.IntegrationTests/ApiFactory.cs b/tests/StringTemplates.IntegrationTests/ApiFactory.cs new file mode 100644 index 0000000..d632876 --- /dev/null +++ b/tests/StringTemplates.IntegrationTests/ApiFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace StringTemplates.IntegrationTests; + +public class ApiFactory : WebApplicationFactory +{ + public HttpClient CreateHttpsClient() + { + return CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost") + }); + } +} diff --git a/tests/StringTemplates.IntegrationTests/Services/Abstractions/ApiTests.cs b/tests/StringTemplates.IntegrationTests/Services/Abstractions/ApiTests.cs new file mode 100644 index 0000000..d123f77 --- /dev/null +++ b/tests/StringTemplates.IntegrationTests/Services/Abstractions/ApiTests.cs @@ -0,0 +1,17 @@ +using System.Net.Http.Json; + +namespace StringTemplates.IntegrationTests.Services.Abstractions; + +public abstract class ApiTests(ApiFactory factory) +{ + protected readonly HttpClient Client = factory.CreateHttpsClient(); + + protected async Task PostForStringAsync(string path, object payload) + { + var resp = await Client.PostAsJsonAsync(path, payload); + resp.EnsureSuccessStatusCode(); + + var jsonString = await resp.Content.ReadFromJsonAsync(); + return jsonString ?? await resp.Content.ReadAsStringAsync(); + } +} diff --git a/tests/StringTemplates.IntegrationTests/Services/DictionaryServiceTests.cs b/tests/StringTemplates.IntegrationTests/Services/DictionaryServiceTests.cs new file mode 100644 index 0000000..785369e --- /dev/null +++ b/tests/StringTemplates.IntegrationTests/Services/DictionaryServiceTests.cs @@ -0,0 +1,70 @@ +using Shouldly; +using StringTemplates.IntegrationTests.Services.Abstractions; + +namespace StringTemplates.IntegrationTests.Services; + +public class DictionaryServiceTests(ApiFactory factory) : ApiTests(factory), IClassFixture +{ + [Fact] + public async Task Dictionary_Replaces_String_And_Int() + { + // Arrange + var payload = new + { + template = "Hello {{#Dictionary#Name#Dictionary#}}, age {{#Dictionary#Age#Dictionary#}}!", + model = new Dictionary + { + ["Name"] = "Stratis", + ["Age"] = 30 + } + }; + + // Act + var result = await PostForStringAsync("dictionary", payload); + + // Assert + result.ShouldBe("Hello Stratis, age 30!"); + } + + [Fact] + public async Task Dictionary_Replaces_Multiple_Keys_And_Repeats() + { + // Arrange + var payload = new + { + template = "User: {{#Dictionary#User#Dictionary#}} ({{#Dictionary#User#Dictionary#}}), Active={{#Dictionary#Active#Dictionary#}}", + model = new Dictionary + { + ["User"] = "demo", + ["Active"] = true + } + }; + + // Act + var result = await PostForStringAsync("dictionary", payload); + + // Assert + result.ShouldBe("User: demo (demo), Active=True"); + } + + [Fact] + public async Task Dictionary_Unknown_Key_Remains() + { + // Arrange + var template = "Pay {{#Dictionary#Amount#Dictionary#}} EUR. Ref: {{#Dictionary#Ref#Dictionary#}}"; + var payload = new + { + template, + model = new Dictionary + { + ["Ref"] = "ORD-123" + } + }; + + // Act + var result = await PostForStringAsync("dictionary", payload); + + // Assert + result.ShouldBe("Pay {{#Dictionary#Amount#Dictionary#}} EUR. Ref: ORD-123"); + } +} diff --git a/tests/StringTemplates.IntegrationTests/Services/SystemServiceTests.cs b/tests/StringTemplates.IntegrationTests/Services/SystemServiceTests.cs new file mode 100644 index 0000000..5b84634 --- /dev/null +++ b/tests/StringTemplates.IntegrationTests/Services/SystemServiceTests.cs @@ -0,0 +1,121 @@ +using System.Globalization; +using Shouldly; +using StringTemplates.IntegrationTests.Services.Abstractions; + +namespace StringTemplates.IntegrationTests.Services; + +public class SystemServiceTests(ApiFactory factory) : ApiTests(factory), IClassFixture +{ + [Fact] + public async Task System_Date_Now_Replaced() + { + // Arrange + var expected = DateTime.Now.ToString("d", CultureInfo.InvariantCulture); + var payload = new { template = "Today is {{#System#Date.Now#System#}}." }; + + // Act + var result = await PostForStringAsync("system", payload); + + // Assert + result.ShouldBe($"Today is {expected}."); + } + + [Fact] + public async Task System_Date_UtcNow_Replaced() + { + // Arrange + var expected = DateTime.UtcNow.ToString("d", CultureInfo.InvariantCulture); + var payload = new { template = "UTC date: {{#System#Date.UtcNow#System#}}" }; + + // Act + var result = await PostForStringAsync("system", payload); + + // Assert + result.ShouldBe($"UTC date: {expected}"); + } + + [Fact] + public async Task System_DateTime_Now_Format_Is_Correct() + { + // Arrange + var payload = new { template = "[{{#System#DateTime.Now#System#}}]" }; + + // Act + var result = await PostForStringAsync("system", payload); + + // Assert + result.ShouldMatch(@"^\[\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}\]$"); + } + + [Fact] + public async Task System_DateTime_UtcNow_Format_Is_Correct() + { + // Arrange + var payload = new { template = "<{{#System#DateTime.UtcNow#System#}}>" }; + + // Act + var result = await PostForStringAsync("system", payload); + + // Assert + result.ShouldMatch(@"^<\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}>$"); + } + + [Fact] + public async Task System_Day_And_Month_Replaced() + { + // Arrange + var expectedDay = DateTime.Now.DayOfWeek.ToString(); + var expectedMonth = DateTime.Now.ToString("MMMM", CultureInfo.InvariantCulture); + + var dayPayload = new { template = "Happy {{#System#Day#System#}}!" }; + var monthPayload = new { template = "It is {{#System#Month#System#}} now." }; + + // Act + var dayResult = await PostForStringAsync("system", dayPayload); + var monthResult = await PostForStringAsync("system", monthPayload); + + // Assert + dayResult.ShouldBe($"Happy {expectedDay}!"); + monthResult.ShouldBe($"It is {expectedMonth} now."); + } + + [Fact] + public async Task System_Time_Now_Format_Is_Correct() + { + // Arrange + var payload = new { template = "time={{#System#Time.Now#System#}}" }; + + // Act + var result = await PostForStringAsync("system", payload); + + // Assert + result.ShouldMatch(@"^time=\d{2}:\d{2}:\d{2}$"); + } + + [Fact] + public async Task System_Time_UtcNow_Format_Is_Correct() + { + // Arrange + var payload = new { template = "utc={{#System#Time.UtcNow#System#}}" }; + + // Act + var result = await PostForStringAsync("system", payload); + + // Assert + result.ShouldMatch(@"^utc=\d{2}:\d{2}:\d{2}$"); + } + + [Fact] + public async Task System_Unknown_Placeholder_Remains() + { + // Arrange + var template = "Unknown -> {{#System#Does.Not.Exist#System#}} <- keep it"; + var payload = new { template }; + + // Act + var result = await PostForStringAsync("system", payload); + + // Assert + result.ShouldBe(template); + } +} diff --git a/tests/StringTemplates.IntegrationTests/StringTemplates.IntegrationTests.csproj b/tests/StringTemplates.IntegrationTests/StringTemplates.IntegrationTests.csproj new file mode 100644 index 0000000..1483151 --- /dev/null +++ b/tests/StringTemplates.IntegrationTests/StringTemplates.IntegrationTests.csproj @@ -0,0 +1,25 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs b/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs index c12acc1..04f65ae 100644 --- a/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs +++ b/tests/StringTemplates.UnitTests/Services/Plugins/DictionaryTemplatePluginTests.cs @@ -7,7 +7,7 @@ namespace StringTemplates.UnitTests.Services.Plugins; public class DictionaryTemplatePluginTests { [Fact] - public void Replaces_Single_Key_In_Sentence() + public void Dictionary_Replaces_Single_Key() { // Arrange var dict = new Dictionary @@ -25,7 +25,7 @@ public void Replaces_Single_Key_In_Sentence() } [Fact] - public void Replaces_Multiple_Keys_In_Paragraph() + public void Dictionary_Replaces_Multiple_Keys() { // Arrange var dict = new Dictionary @@ -50,7 +50,7 @@ public void Replaces_Multiple_Keys_In_Paragraph() } [Fact] - public void Replaces_Mixed_Types_And_Repeated_Keys() + public void Dictionary_Replaces_Mixed_Types_And_Repeats() { // Arrange var dict = new Dictionary @@ -78,7 +78,7 @@ public void Replaces_Mixed_Types_And_Repeated_Keys() } [Fact] - public void Leaves_Unknown_Keys_Untouched_While_Replacing_Known_Ones() + public void Dictionary_Unknown_Key_Remains() { // Arrange var dict = new Dictionary @@ -101,7 +101,7 @@ public void Leaves_Unknown_Keys_Untouched_While_Replacing_Known_Ones() } [Fact] - public void Replaces_Values_In_Long_Form_Text() + public void Dictionary_Replaces_Values_In_Long_Text() { // Arrange var start = new DateTime(2025, 09, 16, 8, 30, 0); @@ -128,7 +128,7 @@ public void Replaces_Values_In_Long_Form_Text() } [Fact] - public void Leaves_Template_As_Is_When_Dictionary_Is_Null() + public void Dictionary_Leaves_Template_As_Is_When_Model_Is_Null() { // Arrange Dictionary? dict = null; diff --git a/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs b/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs index 504f834..cd4bb56 100644 --- a/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs +++ b/tests/StringTemplates.UnitTests/Services/Plugins/SystemTemplatePluginTests.cs @@ -8,7 +8,7 @@ namespace StringTemplates.UnitTests.Services.Plugins; public class SystemTemplatePluginTests { [Fact] - public void Replaces_Placeholder_Date_Now() + public void System_Date_Now_Replaced() { // Arrange var expected = DateTime.Now.ToString("d", CultureInfo.InvariantCulture); @@ -23,7 +23,7 @@ public void Replaces_Placeholder_Date_Now() } [Fact] - public void Replaces_Placeholder_Date_UtcNow() + public void System_Date_UtcNow_Replaced() { // Arrange var expected = DateTime.UtcNow.ToString("d", CultureInfo.InvariantCulture); @@ -38,7 +38,7 @@ public void Replaces_Placeholder_Date_UtcNow() } [Fact] - public void Replaces_Placeholder_DateTime_Now() + public void System_DateTime_Now_Format_Is_Correct() { // Arrange var template = "[{{#System#DateTime.Now#System#}}]"; @@ -53,7 +53,7 @@ public void Replaces_Placeholder_DateTime_Now() } [Fact] - public void Replaces_Placeholder_DateTime_UtcNow() + public void System_DateTime_UtcNow_Format_Is_Correct() { // Arrange var template = "<{{#System#DateTime.UtcNow#System#}}>"; @@ -68,7 +68,7 @@ public void Replaces_Placeholder_DateTime_UtcNow() } [Fact] - public void Replaces_Placeholder_Day() + public void System_Day_Replaced() { // Arrange var expected = DateTime.Now.DayOfWeek.ToString(); @@ -83,7 +83,7 @@ public void Replaces_Placeholder_Day() } [Fact] - public void Replaces_Placeholder_Month() + public void System_Month_Replaced() { // Arrange var expected = DateTime.Now.ToString("MMMM", CultureInfo.InvariantCulture); @@ -98,7 +98,7 @@ public void Replaces_Placeholder_Month() } [Fact] - public void Replaces_Placeholder_Time_Now() + public void System_Time_Now_Format_Is_Correct() { // Arrange var template = "time={{#System#Time.Now#System#}}"; @@ -113,7 +113,7 @@ public void Replaces_Placeholder_Time_Now() } [Fact] - public void Replaces_Placeholder_Time_UtcNow() + public void System_Time_UtcNow_Format_Is_Correct() { // Arrange var template = "utc={{#System#Time.UtcNow#System#}}"; @@ -127,9 +127,8 @@ public void Replaces_Placeholder_Time_UtcNow() Assert.True(m.Success, $"Result '{result}' did not match expected time format."); } - [Fact] - public void Replaces_Placeholder_Year() + public void System_Year_Replaced() { // Arrange var expectedYear = DateTime.Now.Year.ToString(); @@ -144,7 +143,7 @@ public void Replaces_Placeholder_Year() } [Fact] - public void Leaves_Unknown_Placeholder_Untouched() + public void System_Unknown_Placeholder_Remains() { // Arrange var template = "Unknown -> {{#System#Does.Not.Exist#System#}} <- keep it";