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
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
</PackageVersion>
<!-- Microsoft -->
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2"/>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<!-- Shouldly -->
<PackageVersion Include="Shouldly" Version="4.3.0" />
<!-- xUnit -->
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
Expand Down
4 changes: 4 additions & 0 deletions StringTemplates.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@
<File Path=".github/CODEOWNERS" />
<File Path="README.md" />
</Folder>
<Folder Name="/solution/git/workflows/">
<File Path=".github/workflows/run_tests.yml" />
</Folder>
<Folder Name="/src/">
<Project Path="src\StringTemplates\StringTemplates.csproj" Type="Classic C#" />
</Folder>
<Folder Name="/src/plugins/" />
<Folder Name="/tests/">
<Project Path="tests\StringTemplates.IntegrationTests\StringTemplates.IntegrationTests.csproj" Type="Classic C#" />
<Project Path="tests\StringTemplates.UnitTests\StringTemplates.UnitTests.csproj" Type="Classic C#" />
</Folder>
</Solution>
19 changes: 19 additions & 0 deletions examples/StringTemplates.Demo/Program.cs
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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<Dictionary<string, object>> templateService) =>
Results.Ok(templateService.ReplacePlaceholders(request.Template, request.Model)));

app.UseHttpsRedirection();

app.Run();

// Needed so WebApplicationFactory<T> in tests can find the entry point
public partial class Program;

internal record SystemRequest(string Template);
internal record DictionaryRequest(string Template, Dictionary<string, object> Model);
10 changes: 7 additions & 3 deletions examples/StringTemplates.Demo/StringTemplates.Demo.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\StringTemplates\StringTemplates.csproj" />
</ItemGroup>

</Project>
18 changes: 18 additions & 0 deletions examples/StringTemplates.Demo/StringTemplates.http
Original file line number Diff line number Diff line change
@@ -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."
}
###
###
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions src/StringTemplates/StringTemplates.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
<Copyright>Copyright (c) Stratis Dermanoutsos 2025</Copyright>
<PackageLicenseFile>LICENSE</PackageLicenseFile>

<PackageProjectUrl>https://github.com/Stratis-Dermanoutsos/StringTemplates</PackageProjectUrl>
<RepositoryUrl>https://github.com/Stratis-Dermanoutsos/StringTemplates</RepositoryUrl>
<PackageProjectUrl>https://github.com/Stratis-OSS/StringTemplates.Net</PackageProjectUrl>
<RepositoryUrl>https://github.com/Stratis-OSS/StringTemplates.Net</RepositoryUrl>

<Description>The best way to handle templates and replace placeholders.</Description>
<PackageTags>open-source oss csharp dotnet strings templates placeholders</PackageTags>
Expand Down
14 changes: 14 additions & 0 deletions tests/StringTemplates.IntegrationTests/ApiFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc.Testing;

namespace StringTemplates.IntegrationTests;

public class ApiFactory : WebApplicationFactory<Program>
{
public HttpClient CreateHttpsClient()
{
return CreateClient(new WebApplicationFactoryClientOptions
{
BaseAddress = new Uri("https://localhost")
});
}
}
Original file line number Diff line number Diff line change
@@ -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<string> PostForStringAsync(string path, object payload)
{
var resp = await Client.PostAsJsonAsync(path, payload);
resp.EnsureSuccessStatusCode();

var jsonString = await resp.Content.ReadFromJsonAsync<string>();
return jsonString ?? await resp.Content.ReadAsStringAsync();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Shouldly;
using StringTemplates.IntegrationTests.Services.Abstractions;

namespace StringTemplates.IntegrationTests.Services;

public class DictionaryServiceTests(ApiFactory factory) : ApiTests(factory), IClassFixture<ApiFactory>
{
[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<string, object>
{
["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<string, object>
{
["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<string, object>
{
["Ref"] = "ORD-123"
}
};

// Act
var result = await PostForStringAsync("dictionary", payload);

// Assert
result.ShouldBe("Pay {{#Dictionary#Amount#Dictionary#}} EUR. Ref: ORD-123");
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiFactory>
{
[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);
}
}
Loading