diff --git a/Aspire.sln b/Aspire.sln index aea73c665fa..a227dfa8483 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -159,6 +159,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.MySqlConnector.Tests EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestProject.IntegrationServiceA", "tests\testproject\TestProject.IntegrationServiceA\TestProject.IntegrationServiceA.csproj", "{DCF2D47A-921A-4900-B5B2-CF97B3531CE8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "nats", "nats", "{91F22EEA-EB23-425A-9B32-9438A0809F4B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nats.ApiService", "playground\nats\Nats.ApiService\Nats.ApiService.csproj", "{74F89D52-645B-4283-98AF-D04DA794EE37}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Nats.AppHost", "playground\nats\Nats.AppHost\Nats.AppHost.csproj", "{A0BB6F6B-9543-4525-81D6-95DC07BD3D02}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.NATS.Net", "src\Components\Aspire.NATS.Net\Aspire.NATS.Net.csproj", "{303E6308-740C-4673-96D2-0E81934C9E69}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{2136E31D-2CBB-41BB-8618-716FF8E46E9E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigurationSchemaGenerator", "src\Tools\ConfigurationSchemaGenerator\ConfigurationSchemaGenerator.csproj", "{39FA2A64-012F-4EB9-A14F-E8AC54C975F6}" @@ -237,7 +245,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PostgresEndToEnd.ApiService EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "mysql", "mysql", "{621991F1-854A-4743-835B-10CAF11A0CFF}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MySqlDb.AppHost", "Playground\mysql\MySqlDb.AppHost\MySqlDb.AppHost.csproj", "{7E2AD00B-60E0-46C2-8640-7217D678F312}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MySqlDb.AppHost", "playground\mysql\MySqlDb.AppHost\MySqlDb.AppHost.csproj", "{7E2AD00B-60E0-46C2-8640-7217D678F312}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MySql.ApiService", "playground\mysql\MySql.ApiService\MySql.ApiService.csproj", "{F699F3AD-2AD9-454B-BA40-82AC3D6250FE}" EndProject @@ -265,8 +273,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureStorageEndToEnd.AppHos EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureStorageEndToEnd.ApiService", "playground\AzureStorageEndToEnd\AzureStorageEndToEnd.ApiService\AzureStorageEndToEnd.ApiService.csproj", "{921CB408-5E37-4354-B4CF-EAE517F633DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.NATS.Net.Tests", "tests\Aspire.NATS.Net.Tests\Aspire.NATS.Net.Tests.csproj", "{C774BE00-EE93-4148-B866-8F0F2BA1E473}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Dashboard.Components.Tests", "tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj", "{0870A667-FB0C-4758-AEAF-9E5F092AD7C1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nats.Backend", "playground\nats\Nats.Backend\Nats.Backend.csproj", "{C4833DEC-0A4F-4504-B8D0-06C60B84119C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nats.Common", "playground\nats\Nats.Common\Nats.Common.csproj", "{9CA94707-E801-444F-A582-D5BD0104CF9B}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Testing", "Testing", "{A7C6452C-FEDB-4883-9AE7-29892D260AA3}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Testing", "src\Aspire.Hosting.Testing\Aspire.Hosting.Testing.csproj", "{3216CF59-84B0-46FF-8572-D0AFB0155423}" @@ -581,6 +595,18 @@ Global {DCF2D47A-921A-4900-B5B2-CF97B3531CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU {DCF2D47A-921A-4900-B5B2-CF97B3531CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU {DCF2D47A-921A-4900-B5B2-CF97B3531CE8}.Release|Any CPU.Build.0 = Release|Any CPU + {74F89D52-645B-4283-98AF-D04DA794EE37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74F89D52-645B-4283-98AF-D04DA794EE37}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74F89D52-645B-4283-98AF-D04DA794EE37}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74F89D52-645B-4283-98AF-D04DA794EE37}.Release|Any CPU.Build.0 = Release|Any CPU + {A0BB6F6B-9543-4525-81D6-95DC07BD3D02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0BB6F6B-9543-4525-81D6-95DC07BD3D02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0BB6F6B-9543-4525-81D6-95DC07BD3D02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0BB6F6B-9543-4525-81D6-95DC07BD3D02}.Release|Any CPU.Build.0 = Release|Any CPU + {303E6308-740C-4673-96D2-0E81934C9E69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {303E6308-740C-4673-96D2-0E81934C9E69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {303E6308-740C-4673-96D2-0E81934C9E69}.Release|Any CPU.ActiveCfg = Release|Any CPU + {303E6308-740C-4673-96D2-0E81934C9E69}.Release|Any CPU.Build.0 = Release|Any CPU {39FA2A64-012F-4EB9-A14F-E8AC54C975F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {39FA2A64-012F-4EB9-A14F-E8AC54C975F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {39FA2A64-012F-4EB9-A14F-E8AC54C975F6}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -745,10 +771,22 @@ Global {921CB408-5E37-4354-B4CF-EAE517F633DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {921CB408-5E37-4354-B4CF-EAE517F633DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {921CB408-5E37-4354-B4CF-EAE517F633DC}.Release|Any CPU.Build.0 = Release|Any CPU + {C774BE00-EE93-4148-B866-8F0F2BA1E473}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C774BE00-EE93-4148-B866-8F0F2BA1E473}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C774BE00-EE93-4148-B866-8F0F2BA1E473}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C774BE00-EE93-4148-B866-8F0F2BA1E473}.Release|Any CPU.Build.0 = Release|Any CPU {0870A667-FB0C-4758-AEAF-9E5F092AD7C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0870A667-FB0C-4758-AEAF-9E5F092AD7C1}.Debug|Any CPU.Build.0 = Debug|Any CPU {0870A667-FB0C-4758-AEAF-9E5F092AD7C1}.Release|Any CPU.ActiveCfg = Release|Any CPU {0870A667-FB0C-4758-AEAF-9E5F092AD7C1}.Release|Any CPU.Build.0 = Release|Any CPU + {C4833DEC-0A4F-4504-B8D0-06C60B84119C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4833DEC-0A4F-4504-B8D0-06C60B84119C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4833DEC-0A4F-4504-B8D0-06C60B84119C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4833DEC-0A4F-4504-B8D0-06C60B84119C}.Release|Any CPU.Build.0 = Release|Any CPU + {9CA94707-E801-444F-A582-D5BD0104CF9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CA94707-E801-444F-A582-D5BD0104CF9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CA94707-E801-444F-A582-D5BD0104CF9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CA94707-E801-444F-A582-D5BD0104CF9B}.Release|Any CPU.Build.0 = Release|Any CPU {3216CF59-84B0-46FF-8572-D0AFB0155423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3216CF59-84B0-46FF-8572-D0AFB0155423}.Debug|Any CPU.Build.0 = Debug|Any CPU {3216CF59-84B0-46FF-8572-D0AFB0155423}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -891,6 +929,10 @@ Global {CA283D7F-EB95-4353-B196-C409965D2B42} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {C8079F06-304F-49B1-A0C1-45AA3782A923} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {DCF2D47A-921A-4900-B5B2-CF97B3531CE8} = {975F6F41-B455-451D-A312-098DE4A167B6} + {91F22EEA-EB23-425A-9B32-9438A0809F4B} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {74F89D52-645B-4283-98AF-D04DA794EE37} = {91F22EEA-EB23-425A-9B32-9438A0809F4B} + {A0BB6F6B-9543-4525-81D6-95DC07BD3D02} = {91F22EEA-EB23-425A-9B32-9438A0809F4B} + {303E6308-740C-4673-96D2-0E81934C9E69} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {39FA2A64-012F-4EB9-A14F-E8AC54C975F6} = {2136E31D-2CBB-41BB-8618-716FF8E46E9E} {20A5A907-A135-4735-B4BF-E13514F360E3} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {E592E447-BA3C-44FA-86C1-EBEDC864A644} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} @@ -943,7 +985,10 @@ Global {8AA07A14-A4A7-45EC-B0F6-4690B516B16D} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} {157A434E-E3CA-4080-96CF-903CC3DF66E9} = {8AA07A14-A4A7-45EC-B0F6-4690B516B16D} {921CB408-5E37-4354-B4CF-EAE517F633DC} = {8AA07A14-A4A7-45EC-B0F6-4690B516B16D} + {C774BE00-EE93-4148-B866-8F0F2BA1E473} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {0870A667-FB0C-4758-AEAF-9E5F092AD7C1} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} + {C4833DEC-0A4F-4504-B8D0-06C60B84119C} = {91F22EEA-EB23-425A-9B32-9438A0809F4B} + {9CA94707-E801-444F-A582-D5BD0104CF9B} = {91F22EEA-EB23-425A-9B32-9438A0809F4B} {3216CF59-84B0-46FF-8572-D0AFB0155423} = {A7C6452C-FEDB-4883-9AE7-29892D260AA3} {BE46B5B3-DFD4-4565-A2CD-7D95C623B03D} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {8B1802BC-6CB0-4027-850C-2AED42A82C9E} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} diff --git a/Directory.Packages.props b/Directory.Packages.props index b0439754dd1..886ee2e7049 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -90,6 +90,7 @@ + diff --git a/playground/nats/Nats.ApiService/Nats.ApiService.csproj b/playground/nats/Nats.ApiService/Nats.ApiService.csproj new file mode 100644 index 00000000000..9458666178c --- /dev/null +++ b/playground/nats/Nats.ApiService/Nats.ApiService.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/playground/nats/Nats.ApiService/Nats.ApiService.http b/playground/nats/Nats.ApiService/Nats.ApiService.http new file mode 100644 index 00000000000..df2adc01f55 --- /dev/null +++ b/playground/nats/Nats.ApiService/Nats.ApiService.http @@ -0,0 +1,51 @@ +@HostAddress = http://localhost:5156 + +POST {{HostAddress}}/ping/ + +### + +POST {{HostAddress}}/stream/ +Content-Type: application/json + +{ + "name": "EVENTS", + "description": "Events Description", + "subjects": ["events.>"], + "max_msgs": 10 +} + +### + +GET {{HostAddress}}/stream/EVENTS +Accept: application/json + +### + +POST {{HostAddress}}/publish/ +Content-Type: application/json + +{ + "subject": "events.mouse.click", + "name": "Mouse Event", + "description": "Mouse Event Description", + "priority": 0.1 +} + +### + +POST {{HostAddress}}/publish/ +Content-Type: application/json + +{ + "subject": "events.key.a", + "name": "Key Event", + "description": "Key Event Description", + "priority": 0.5 +} + +### + +GET {{HostAddress}}/consume/EVENTS +Accept: application/json + +### diff --git a/playground/nats/Nats.ApiService/Program.cs b/playground/nats/Nats.ApiService/Program.cs new file mode 100644 index 00000000000..f08be8be0cc --- /dev/null +++ b/playground/nats/Nats.ApiService/Program.cs @@ -0,0 +1,85 @@ +using NATS.Client.Core; +using NATS.Client.JetStream; +using NATS.Client.JetStream.Models; +using Nats.Common; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.AddServiceDefaults(); + +builder.AddNatsClient("nats", configureOptions: opts => +{ + var jsonRegistry = new NatsJsonContextSerializerRegistry(AppJsonContext.Default); + return opts with { SerializerRegistry = jsonRegistry }; +}); + +builder.AddNatsJetStream(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapGet("/ping", async (INatsConnection nats) => +{ + var rtt = await nats.PingAsync(); + return Results.Json(new { rtt, nats.ServerInfo }); +}); + +app.MapPost("/stream", async (StreamConfig config, INatsJSContext jetStream) => +{ + var stream = await jetStream.CreateStreamAsync(config); + var name = stream.Info.Config.Name; + return Results.Created($"/stream/{name}", name); +}); + +app.MapGet("/stream/{name}", async (string name, INatsJSContext jetStream) => +{ + var stream = await jetStream.GetStreamAsync(name); + return Results.Ok(stream.Info); +}); + +app.MapPost("/publish/", async (AppEvent @event, INatsJSContext jetStream) => +{ + try + { + var ack = await jetStream.PublishAsync(@event.Subject, @event); + ack.EnsureSuccess(); + } + catch (NatsJSPublishNoResponseException) + { + return Results.Problem("Make sure the stream is created before publishing."); + } + + return Results.Created(); +}); + +app.MapGet("/consume/{name}", async (string name, INatsJSContext jetStream) => +{ + var stream = await jetStream.GetStreamAsync(name); + var consumer = await stream.CreateOrderedConsumerAsync(); + + var events = new List(); + await foreach(var msg in consumer.ConsumeAsync()) + { + events.Add(msg.Data!); + + if (msg.Metadata?.NumPending == 0) + { + break; + } + } + + return Results.Ok(events); +}); + +app.Run(); diff --git a/playground/nats/Nats.ApiService/Properties/launchSettings.json b/playground/nats/Nats.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000000..352240ccf9e --- /dev/null +++ b/playground/nats/Nats.ApiService/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:12538", + "sslPort": 44308 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5156", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7060;http://localhost:5156", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/nats/Nats.ApiService/appsettings.Development.json b/playground/nats/Nats.ApiService/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/nats/Nats.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/nats/Nats.ApiService/appsettings.json b/playground/nats/Nats.ApiService/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/playground/nats/Nats.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/playground/nats/Nats.AppHost/Directory.Build.props b/playground/nats/Nats.AppHost/Directory.Build.props new file mode 100644 index 00000000000..b9b39c05e81 --- /dev/null +++ b/playground/nats/Nats.AppHost/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/playground/nats/Nats.AppHost/Directory.Build.targets b/playground/nats/Nats.AppHost/Directory.Build.targets new file mode 100644 index 00000000000..b7ba77268f8 --- /dev/null +++ b/playground/nats/Nats.AppHost/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/playground/nats/Nats.AppHost/Nats.AppHost.csproj b/playground/nats/Nats.AppHost/Nats.AppHost.csproj new file mode 100644 index 00000000000..c3170c453a6 --- /dev/null +++ b/playground/nats/Nats.AppHost/Nats.AppHost.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + enable + enable + true + Nats.AppHosting + + + + + + + + + + + + + + + diff --git a/playground/nats/Nats.AppHost/Program.cs b/playground/nats/Nats.AppHost/Program.cs new file mode 100644 index 00000000000..100b340df3d --- /dev/null +++ b/playground/nats/Nats.AppHost/Program.cs @@ -0,0 +1,12 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var nats = builder.AddNats("nats") + .WithJetStream(); + +builder.AddProject("api") + .WithReference(nats); + +builder.AddProject("backend") + .WithReference(nats); + +builder.Build().Run(); diff --git a/playground/nats/Nats.AppHost/Properties/launchSettings.json b/playground/nats/Nats.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..8f20c37b3fd --- /dev/null +++ b/playground/nats/Nats.AppHost/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "profiles": { + "run": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:18888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json" +} diff --git a/playground/nats/Nats.AppHost/aspire-manifest.json b/playground/nats/Nats.AppHost/aspire-manifest.json new file mode 100644 index 00000000000..f1a6652ff3d --- /dev/null +++ b/playground/nats/Nats.AppHost/aspire-manifest.json @@ -0,0 +1,62 @@ +{ + "resources": { + "nats": { + "type": "container.v0", + "connectionString": "nats://{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", + "image": "nats:2", + "args": [ + "-js" + ], + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "containerPort": 4222 + } + } + }, + "api": { + "type": "project.v0", + "path": "../Nats.ApiService/Nats.ApiService.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "ConnectionStrings__nats": "{nats.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + }, + "backend": { + "type": "project.v0", + "path": "../Nats.Backend/Nats.Backend.csproj", + "env": { + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true", + "OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true", + "ConnectionStrings__nats": "{nats.connectionString}" + }, + "bindings": { + "http": { + "scheme": "http", + "protocol": "tcp", + "transport": "http" + }, + "https": { + "scheme": "https", + "protocol": "tcp", + "transport": "http" + } + } + } + } +} \ No newline at end of file diff --git a/playground/nats/Nats.Backend/Nats.Backend.csproj b/playground/nats/Nats.Backend/Nats.Backend.csproj new file mode 100644 index 00000000000..63b7c0d24fe --- /dev/null +++ b/playground/nats/Nats.Backend/Nats.Backend.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + diff --git a/playground/nats/Nats.Backend/Program.cs b/playground/nats/Nats.Backend/Program.cs new file mode 100644 index 00000000000..189e8e17411 --- /dev/null +++ b/playground/nats/Nats.Backend/Program.cs @@ -0,0 +1,45 @@ +using NATS.Client.Core; +using Nats.Common; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddNatsClient("nats", configureOptions: opts => +{ + var jsonRegistry = new NatsJsonContextSerializerRegistry(AppJsonContext.Default); + return opts with { SerializerRegistry = jsonRegistry }; +}); + +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.Run(); + +public class AppEventsBackendService(INatsConnection nats, ILogger logger) : IHostedService +{ + private readonly CancellationTokenSource _cts = new(); + private Task? _subscription; + + public Task StartAsync(CancellationToken cancellationToken) + { + _subscription = Task.Run(async () => + { + await foreach (var msg in nats.SubscribeAsync("events.>", cancellationToken: _cts.Token).ConfigureAwait(false)) + { + logger.LogInformation("Processing event: {Data}", msg.Data); + } + }, cancellationToken); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync(); + if (_subscription != null) + { + await _subscription; + } + } +} diff --git a/playground/nats/Nats.Backend/Properties/launchSettings.json b/playground/nats/Nats.Backend/Properties/launchSettings.json new file mode 100644 index 00000000000..c245832a4f4 --- /dev/null +++ b/playground/nats/Nats.Backend/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7061;http://localhost:5157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/nats/Nats.Common/AppEvent.cs b/playground/nats/Nats.Common/AppEvent.cs new file mode 100644 index 00000000000..185a4ffbefc --- /dev/null +++ b/playground/nats/Nats.Common/AppEvent.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Nats.Common; + +public record AppEvent(string Subject, string Name, string Description, decimal Priority); + +[JsonSerializable(typeof(AppEvent))] +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +public partial class AppJsonContext : JsonSerializerContext +{ +} diff --git a/playground/nats/Nats.Common/Nats.Common.csproj b/playground/nats/Nats.Common/Nats.Common.csproj new file mode 100644 index 00000000000..3a635329525 --- /dev/null +++ b/playground/nats/Nats.Common/Nats.Common.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/spelling.dic b/spelling.dic index aa0c9108f1a..fa2eff6f45b 100644 --- a/spelling.dic +++ b/spelling.dic @@ -32,6 +32,7 @@ microsoft mongodb mssql mysql +nats noreferrer noopener npgsql diff --git a/src/Aspire.Hosting/Nats/NatsBuilderExtensions.cs b/src/Aspire.Hosting/Nats/NatsBuilderExtensions.cs new file mode 100644 index 00000000000..db2a486f350 --- /dev/null +++ b/src/Aspire.Hosting/Nats/NatsBuilderExtensions.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding NATS resources to the application model. +/// +public static class NatsBuilderExtensions +{ + /// + /// Adds a NATS server resource to the application model. A container is used for local development. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port for NATS server. + /// A reference to the . + public static IResourceBuilder AddNats(this IDistributedApplicationBuilder builder, string name, int? port = null) + { + var nats = new NatsServerResource(name); + return builder.AddResource(nats) + .WithEndpoint(containerPort: 4222, hostPort: port, name: NatsServerResource.PrimaryEndpointName) + .WithAnnotation(new ContainerImageAnnotation { Image = "nats" }) + .WithImageTag("2") + .PublishAsContainer(); + } + + /// + /// Adds JetStream support to the NATS server resource. + /// + /// NATS resource builder. + /// Optional mount path providing persistence between restarts. + /// A reference to the . + public static IResourceBuilder WithJetStream(this IResourceBuilder builder, string? srcMountPath = null) + { + var args = new List { "-js" }; + if (srcMountPath != null) + { + args.Add("-sd"); + args.Add("/data"); + builder.WithBindMount(srcMountPath, "/data"); + } + + return builder.WithArgs(args.ToArray()); + } +} diff --git a/src/Aspire.Hosting/Nats/NatsServerResource.cs b/src/Aspire.Hosting/Nats/NatsServerResource.cs new file mode 100644 index 00000000000..36aecef11c0 --- /dev/null +++ b/src/Aspire.Hosting/Nats/NatsServerResource.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a NATS server container. +/// +/// The name of the resource. + +public class NatsServerResource(string name) : ContainerResource(name), IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "tcp"; + internal const string PrimaryNatsSchemeName = "nats"; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary endpoint for the NATS server. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// Gets the connection string expression for the NATS server for the manifest. + /// + public string? ConnectionStringExpression => $"{PrimaryNatsSchemeName}://{PrimaryEndpoint.GetExpression(EndpointProperty.Host)}:{PrimaryEndpoint.GetExpression(EndpointProperty.Port)}"; + + /// + /// Gets the connection string (NATS_URL) for the NATS server. + /// + /// A connection string for the NATS server in the form "nats://host:port". + + public string GetConnectionString() + { + return $"{PrimaryNatsSchemeName}://{PrimaryEndpoint.Host}:{PrimaryEndpoint.Port}"; + } +} diff --git a/src/Components/Aspire.NATS.Net/Aspire.NATS.Net.csproj b/src/Components/Aspire.NATS.Net/Aspire.NATS.Net.csproj new file mode 100644 index 00000000000..38ad39930a3 --- /dev/null +++ b/src/Components/Aspire.NATS.Net/Aspire.NATS.Net.csproj @@ -0,0 +1,24 @@ + + + + $(NetCurrent) + true + $(ComponentDatabasePackageTags) nats messaging + A NATS client that integrates with Aspire, including health checks, metrics, logging, and telemetry. + $(SharedDir)nats-icon.png + + + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.NATS.Net/AspireNatsClientExtensions.cs b/src/Components/Aspire.NATS.Net/AspireNatsClientExtensions.cs new file mode 100644 index 00000000000..e403d1923db --- /dev/null +++ b/src/Components/Aspire.NATS.Net/AspireNatsClientExtensions.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.NATS.Net; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using NATS.Client.Core; +using NATS.Client.JetStream; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for connecting NATS server with NATS client +/// +public static class AspireNatsClientExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Nats:Client"; + + /// + /// Registers service for connecting NATS server with NATS client. + /// Configures health check and logging for the NATS client. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate that can be used for customizing NATS options that aren't exposed as standard configuration. + /// Thrown if mandatory is null. + /// Thrown when mandatory is not provided. + public static void AddNatsClient(this IHostApplicationBuilder builder, string connectionName, Action? configureSettings = null, Func? configureOptions = null) + => AddNatsClient(builder, configurationSectionName: DefaultConfigSectionName, connectionName: connectionName, serviceKey: null, configureSettings: configureSettings, configureOptions: configureOptions); + + /// + /// Registers as a keyed service for given for connecting NATS server with NATS client. + /// Configures health check and logging for the NATS client. + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate that can be used for customizing NATS options that aren't exposed as standard configuration. + /// Thrown when or is null. + /// Thrown if mandatory is empty. + /// Thrown when mandatory is not provided. + public static void AddKeyedNatsClient(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Func? configureOptions = null) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + AddNatsClient(builder, configurationSectionName: $"{DefaultConfigSectionName}:{name}", connectionName: name, serviceKey: name, configureSettings: configureSettings, configureOptions: configureOptions); + } + + private static void AddNatsClient(this IHostApplicationBuilder builder, string configurationSectionName, string connectionName, object? serviceKey, Action? configureSettings, Func? configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + + NatsClientSettings settings = new(); + builder.Configuration.GetSection(configurationSectionName).Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + NatsConnection Factory(IServiceProvider provider) + { + var options = NatsOpts.Default with + { + LoggerFactory = provider.GetRequiredService(), + }; + + if (configureOptions != null) + { + options = configureOptions(options); + } + + if (settings.ConnectionString == null) + { + throw new InvalidOperationException($"NATS connection string not found: {connectionName}"); + } + + options = options with { Url = settings.ConnectionString }; + + return new NatsConnection(options); + } + + if (serviceKey == null) + { + builder.Services.TryAddSingleton(Factory); + builder.Services.TryAddSingleton(static provider => provider.GetRequiredService()); + } + else + { + builder.Services.TryAddKeyedSingleton(serviceKey, (provider, _) => Factory(provider)); + builder.Services.TryAddKeyedSingleton(serviceKey, static (provider, key) => provider.GetRequiredKeyedService(key)); + } + + if (settings.HealthChecks) + { + builder.TryAddHealthCheck(new HealthCheckRegistration( + serviceKey is null ? "NATS" : $"NATS_{connectionName}", + sp => new NatsHealthCheck(serviceKey is null + ? sp.GetRequiredService() + : sp.GetRequiredKeyedService(serviceKey)), + failureStatus: default, + tags: default, + timeout: default)); + } + } + + /// + /// Registers service for NATS JetStream operations. + /// + /// The to read config from and add services to. + /// Thrown if mandatory is null. + public static void AddNatsJetStream(this IHostApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddSingleton(static provider => + { + return new NatsJSContextFactory().CreateContext(provider.GetService()!); + }); + } +} diff --git a/src/Components/Aspire.NATS.Net/AssemblyInfo.cs b/src/Components/Aspire.NATS.Net/AssemblyInfo.cs new file mode 100644 index 00000000000..fa49817fdad --- /dev/null +++ b/src/Components/Aspire.NATS.Net/AssemblyInfo.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.NATS.Net; + +[assembly: ConfigurationSchema("Aspire:NATS:Net", typeof(NatsClientSettings))] + +[assembly: LoggingCategories("NATS")] diff --git a/src/Components/Aspire.NATS.Net/ConfigurationSchema.json b/src/Components/Aspire.NATS.Net/ConfigurationSchema.json new file mode 100644 index 00000000000..e5f2e7bca53 --- /dev/null +++ b/src/Components/Aspire.NATS.Net/ConfigurationSchema.json @@ -0,0 +1,39 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "NATS": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "properties": { + "Aspire": { + "type": "object", + "properties": { + "NATS": { + "type": "object", + "properties": { + "Net": { + "type": "object", + "properties": { + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string of the NATS cluster to connect to." + }, + "HealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the NATS health check is enabled or not.", + "default": true + } + }, + "description": "Provides the client configuration settings for connecting to a NATS cluster." + } + } + } + } + } + }, + "type": "object" +} diff --git a/src/Components/Aspire.NATS.Net/NatsClientSettings.cs b/src/Components/Aspire.NATS.Net/NatsClientSettings.cs new file mode 100644 index 00000000000..5b91f6ff060 --- /dev/null +++ b/src/Components/Aspire.NATS.Net/NatsClientSettings.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.NATS.Net; + +/// +/// Provides the client configuration settings for connecting to a NATS cluster. +/// +public sealed class NatsClientSettings +{ + /// + /// Gets or sets the connection string of the NATS cluster to connect to. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the NATS health check is enabled or not. + /// + /// + /// The default value is . + /// + public bool HealthChecks { get; set; } = true; +} diff --git a/src/Components/Aspire.NATS.Net/NatsHealthCheck.cs b/src/Components/Aspire.NATS.Net/NatsHealthCheck.cs new file mode 100644 index 00000000000..60abdefcc10 --- /dev/null +++ b/src/Components/Aspire.NATS.Net/NatsHealthCheck.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using NATS.Client.Core; + +namespace Aspire.NATS.Net; + +internal sealed class NatsHealthCheck(INatsConnection connection) : IHealthCheck +{ + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var result = connection.ConnectionState switch + { + NatsConnectionState.Open => HealthCheckResult.Healthy(), + NatsConnectionState.Connecting or NatsConnectionState.Reconnecting => HealthCheckResult.Degraded(), + NatsConnectionState.Closed => HealthCheckResult.Unhealthy(), + _ => new HealthCheckResult(context.Registration.FailureStatus) + }; + + return Task.FromResult(result); + } +} diff --git a/src/Components/Aspire.NATS.Net/README.md b/src/Components/Aspire.NATS.Net/README.md new file mode 100644 index 00000000000..c980f3cdc68 --- /dev/null +++ b/src/Components/Aspire.NATS.Net/README.md @@ -0,0 +1,110 @@ +# Aspire.NATS.Net library + +Registers [INatsConnection](https://nats-io.github.io/nats.net.v2/api/NATS.Client.Core.INatsConnection.html) in the DI container for connecting NATS server. Enables corresponding health check, metrics, logging and telemetry. + +## Getting started + +### Prerequisites + +- NATS server and the server URL for accessing the server. + +### Install the package + +Install the .NET Aspire NATS library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.NATS.Net +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddNatsClient` extension method to register a `INatsConnection` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddNatsClient("nats"); +``` + +You can then retrieve a `INatsConnection` instance using dependency injection. For example, to retrieve a connection from a Web API controller: + +```csharp +private readonly INatsConnection _connection; + +public ProductsController(INatsConnection connection) +{ + _connection = connection; +} +``` + +## Configuration + +The .NET Aspire NATS component provides multiple options to configure the NATS connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddNats()`: + +```csharp +builder.AddNats("myConnection"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myConnection": "nats://nats:4222" + } +} +``` + +See the [ConnectionString documentation](https://docs.nats.io/using-nats/developer/connecting#nats-url) for more information on how to format this connection string. + +### Use configuration providers + +The .NET Aspire NATS component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `NatsClientSettings` from configuration by using the `Aspire:Nats:Client` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Nats": { + "Client": { + "HealthChecks": false + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code: + +```csharp + builder.AddNats("nats", settings => settings.HealthChecks = false); +``` + +## AppHost extensions + +In your AppHost project, register a NATS server and consume the connection using the following methods: + +```csharp +var nats = builder.AddNats("nats"); + +var myService = builder.AddProject() + .WithReference(nats); +``` + +The `WithReference` method configures a connection in the `MyService` project named `nats`. In the _Program.cs_ file of `MyService`, the NATS connection can be consumed using: + +```csharp +builder.AddNatsClient("nats"); +``` + +## Additional documentation + +* https://nats-io.github.io/nats.net.v2/documentation/intro.html +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 351f36cb6e1..9568c4244da 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -26,6 +26,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | Oracle.EntityFrameworkCore | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Confluent.Kafka | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | | Pomelo.EntityFrameworkCore.MySql | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| NATS.Net | ✅ | ✅ | ✅ | ✅ | ✅ | | | ✅ | Nomenclature used in the table above: diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index 1201bcb42a7..f3183112356 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -199,6 +199,14 @@ Aspire.MySqlConnector: - "db.client.connections.timeouts" - "db.client.connections.usage" +Aspire.NATS.Net: +- Log categories: + - "NATS" +- Activity source names: + - none (currently not supported by NATS.Net library) +- Metric names: + - none (currently not supported by NATS.Net library) + Aspire.Npgsql: - Log categories: - "Npgsql.Command" diff --git a/src/Shared/nats-icon.png b/src/Shared/nats-icon.png new file mode 100644 index 00000000000..95044a73197 Binary files /dev/null and b/src/Shared/nats-icon.png differ diff --git a/tests/Aspire.Hosting.Tests/Nats/AddNatsTests.cs b/tests/Aspire.Hosting.Tests/Nats/AddNatsTests.cs new file mode 100644 index 00000000000..136537f3ae0 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Nats/AddNatsTests.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; +using System.Net.Sockets; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests.Nats; + +public class AddNatsTests +{ + [Fact] + public void AddNatsContainerWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddNats("nats"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("nats", containerResource.Name); + + var manifestAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(manifestAnnotation.Callback); + + var endpoint = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(4222, endpoint.ContainerPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("tcp", endpoint.Name); + Assert.Null(endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("tcp", endpoint.Transport); + Assert.Equal("tcp", endpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("2", containerAnnotation.Tag); + Assert.Equal("nats", containerAnnotation.Image); + Assert.Null(containerAnnotation.Registry); + } + + [Fact] + public void AddNatsContainerAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddNats("nats", 1234).WithJetStream(srcMountPath: "/tmp/dev-data"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("nats", containerResource.Name); + + var manifestAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(manifestAnnotation.Callback); + + var mountAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("/tmp/dev-data", mountAnnotation.Source); + Assert.Equal("/data", mountAnnotation.Target); + + var argsAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(argsAnnotation.Callback); + var args = new List(); + argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); + Assert.Equal("-js -sd /data".Split(' '), args); + + var endpoint = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(4222, endpoint.ContainerPort); + Assert.False(endpoint.IsExternal); + Assert.Equal("tcp", endpoint.Name); + Assert.Equal(1234, endpoint.Port); + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + Assert.Equal("tcp", endpoint.Transport); + Assert.Equal("tcp", endpoint.UriScheme); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("2", containerAnnotation.Tag); + Assert.Equal("nats", containerAnnotation.Image); + Assert.Null(containerAnnotation.Registry); + } + + [Fact] + public void WithNatsContainerOnMultipleResources() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddNats("nats1"); + builder.AddNats("nats2"); + + Assert.Equal(2, builder.Resources.OfType().Count()); + } + + [Fact] + public async Task VerifyManifest() + { + var appBuilder = DistributedApplication.CreateBuilder(); + var nats = appBuilder.AddNats("nats"); + + var manifest = await ManifestUtils.GetManifest(nats.Resource); + + var expectedManifest = """ + { + "type": "container.v0", + "connectionString": "nats://{nats.bindings.tcp.host}:{nats.bindings.tcp.port}", + "image": "nats:2", + "bindings": { + "tcp": { + "scheme": "tcp", + "protocol": "tcp", + "transport": "tcp", + "containerPort": 4222 + } + } + } + """; + Assert.Equal(expectedManifest, manifest.ToString()); + } +} diff --git a/tests/Aspire.NATS.Net.Tests/Aspire.NATS.Net.Tests.csproj b/tests/Aspire.NATS.Net.Tests/Aspire.NATS.Net.Tests.csproj new file mode 100644 index 00000000000..a25c05a63af --- /dev/null +++ b/tests/Aspire.NATS.Net.Tests/Aspire.NATS.Net.Tests.csproj @@ -0,0 +1,14 @@ + + + + $(NetCurrent) + + + + + + + + + + diff --git a/tests/Aspire.NATS.Net.Tests/AspireNatsClientExtensionsTests.cs b/tests/Aspire.NATS.Net.Tests/AspireNatsClientExtensionsTests.cs new file mode 100644 index 00000000000..c5c3eac9367 --- /dev/null +++ b/tests/Aspire.NATS.Net.Tests/AspireNatsClientExtensionsTests.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NATS.Client.Core; +using Xunit; + +namespace Aspire.NATS.Net.Tests; + +public class AspireNatsClientExtensionsTests +{ + private const string ConnectionString = "nats://apire-host:4222"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadsFromConnectionStringsCorrectly(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:nats", ConnectionString) + ]); + + if (useKeyed) + { + builder.AddKeyedNatsClient("nats"); + } + else + { + builder.AddNatsClient("nats"); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("nats") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString, connection.Opts.Url); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionStringCanBeSetInCode(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:nats", "unused") + ]); + + static void SetConnectionString(NatsClientSettings settings) => settings.ConnectionString = ConnectionString; + if (useKeyed) + { + builder.AddKeyedNatsClient("nats", SetConnectionString); + } + else + { + builder.AddNatsClient("nats", SetConnectionString); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("nats") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString, connection.Opts.Url); + // the connection string from config should not be used since code set it explicitly + Assert.DoesNotContain("unused", connection.Opts.Url); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionNameWinsOverConfigSection(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + var key = useKeyed ? "nats" : null; + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair(ConformanceTests.CreateConfigKey("Aspire:Nats:Client", key, "ConnectionString"), "unused"), + new KeyValuePair("ConnectionStrings:nats", ConnectionString) + ]); + + if (useKeyed) + { + builder.AddKeyedNatsClient("nats"); + } + else + { + builder.AddNatsClient("nats"); + } + + var host = builder.Build(); + var connection = useKeyed ? + host.Services.GetRequiredKeyedService("nats") : + host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString, connection.Opts.Url); + // the connection string from config should not be used since it was found in ConnectionStrings + Assert.DoesNotContain("unused", connection.Opts.Url); + } +} diff --git a/tests/Aspire.NATS.Net.Tests/ConfigurationTests.cs b/tests/Aspire.NATS.Net.Tests/ConfigurationTests.cs new file mode 100644 index 00000000000..e655a498062 --- /dev/null +++ b/tests/Aspire.NATS.Net.Tests/ConfigurationTests.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.NATS.Net.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ConnectionStringIsNullByDefault() + => Assert.Null(new NatsClientSettings().ConnectionString); + + [Fact] + public void HealthCheckIsEnabledByDefault() + => Assert.True(new NatsClientSettings().HealthChecks); +} diff --git a/tests/Aspire.NATS.Net.Tests/ConformanceTests.cs b/tests/Aspire.NATS.Net.Tests/ConformanceTests.cs new file mode 100644 index 00000000000..122e359713e --- /dev/null +++ b/tests/Aspire.NATS.Net.Tests/ConformanceTests.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NATS.Client.Core; + +namespace Aspire.NATS.Net.Tests; + +public class ConformanceTests : ConformanceTests +{ + private const string ConnectionSting = "nats://apire-host:4222"; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + protected override string ActivitySourceName => "Nats.Client"; + protected override string[] RequiredLogCategories => ["NATS.Client"]; + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddNatsClient("nats", configure); + } + else + { + builder.AddKeyedNatsClient(key, configure); + } + } + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[1] + { + new KeyValuePair(CreateConfigKey("Aspire:Nats:Client", key, "ConnectionString"), ConnectionSting) + }); + + protected override bool CanCreateClientWithoutConnectingToServer => false; + + protected override void TriggerActivity(INatsConnection service) + { + } + + protected override void SetHealthCheck(NatsClientSettings options, bool enabled) + => options.HealthChecks = enabled; + + protected override void SetTracing(NatsClientSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetMetrics(NatsClientSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Nats": { + "Client": { + "ConnectionString": "YOUR_CONNECTION_STRING", + "HealthChecks": false, + "Tracing": true, + "Metrics": true + } + } + } + } + """; +}