diff --git a/Directory.Packages.props b/Directory.Packages.props
index d504b5a..e0b2223 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,7 @@
+
diff --git a/playground/ProfanityFilter.Api/GlobalUsings.cs b/playground/ProfanityFilter.Api/GlobalUsings.cs
new file mode 100644
index 0000000..256f9f1
--- /dev/null
+++ b/playground/ProfanityFilter.Api/GlobalUsings.cs
@@ -0,0 +1,6 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+global using Microsoft.AspNetCore.Mvc;
+global using ProfanityFilter.Client;
+global using ProfanityFilter.Shared.Api;
\ No newline at end of file
diff --git a/playground/ProfanityFilter.Api/ProfanityFilter.Api.csproj b/playground/ProfanityFilter.Api/ProfanityFilter.Api.csproj
new file mode 100644
index 0000000..42891c7
--- /dev/null
+++ b/playground/ProfanityFilter.Api/ProfanityFilter.Api.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(DefaultTargetFramework)
+ enable
+ enable
+ 82eaa4db-aed7-4828-8fec-bb9d542765f4
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/ProfanityFilter.Api/ProfanityFilter.Api.http b/playground/ProfanityFilter.Api/ProfanityFilter.Api.http
new file mode 100644
index 0000000..7f82f91
--- /dev/null
+++ b/playground/ProfanityFilter.Api/ProfanityFilter.Api.http
@@ -0,0 +1,6 @@
+@ProfanityFilter.Api_HostAddress = http://localhost:5225
+
+GET {{ProfanityFilter.Api_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/playground/ProfanityFilter.Api/Program.cs b/playground/ProfanityFilter.Api/Program.cs
new file mode 100644
index 0000000..423b819
--- /dev/null
+++ b/playground/ProfanityFilter.Api/Program.cs
@@ -0,0 +1,37 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.AddProfanityFilterClient("profanity-filter");
+
+// Add services to the container.
+// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
+builder.Services.AddOpenApi();
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.MapOpenApi();
+}
+
+app.UseSwagger();
+app.UseSwaggerUI();
+app.UseStaticFiles();
+
+app.UseHttpsRedirection();
+
+app.MapPost(
+ pattern: "/filter",
+ handler: static async (
+ [FromBody] ProfanityFilterRequest request,
+ [FromServices] IRestClient client) =>
+ await client.ApplyFilterAsync(request))
+ .WithName("Filter");
+
+app.Run();
diff --git a/playground/ProfanityFilter.Api/appsettings.Development.json b/playground/ProfanityFilter.Api/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/playground/ProfanityFilter.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/playground/ProfanityFilter.Api/appsettings.json b/playground/ProfanityFilter.Api/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/playground/ProfanityFilter.Api/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/playground/ProfanityFilter.AppHost/ProfanityFilter.AppHost.csproj b/playground/ProfanityFilter.AppHost/ProfanityFilter.AppHost.csproj
index c173f97..5368196 100644
--- a/playground/ProfanityFilter.AppHost/ProfanityFilter.AppHost.csproj
+++ b/playground/ProfanityFilter.AppHost/ProfanityFilter.AppHost.csproj
@@ -17,6 +17,7 @@
+
diff --git a/playground/ProfanityFilter.AppHost/Program.cs b/playground/ProfanityFilter.AppHost/Program.cs
index e7eb995..2530345 100644
--- a/playground/ProfanityFilter.AppHost/Program.cs
+++ b/playground/ProfanityFilter.AppHost/Program.cs
@@ -3,7 +3,11 @@
var builder = DistributedApplication.CreateBuilder(args);
-_ = builder.AddProfanityFilter("profanity-filter")
- .WithDataBindMount("./CustomData");
+var filter = builder.AddProfanityFilter("profanity-filter")
+ .WithCustomDataBindMount("./CustomData");
+
+builder.AddProject("api")
+ .WithReference(filter)
+ .WaitFor(filter);
builder.Build().Run();
diff --git a/profanity-filter.sln b/profanity-filter.sln
index 007f8a4..f5dacf1 100644
--- a/profanity-filter.sln
+++ b/profanity-filter.sln
@@ -42,6 +42,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "playground", "playground",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProfanityFilter.AppHost", "playground\ProfanityFilter.AppHost\ProfanityFilter.AppHost.csproj", "{B5E3CB82-3306-4DD6-96BB-17995027FCA3}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProfanityFilter.Api", "playground\ProfanityFilter.Api\ProfanityFilter.Api.csproj", "{5380A680-6C10-4EA5-92F2-FA7DBF78900C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProfanityFilter.Client.Tests", "tests\ProfanityFilter.Client.Tests\ProfanityFilter.Client.Tests.csproj", "{2DDAB9F2-3BC8-4191-B345-7DCA606BD46E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProfanityFilter.Shared.Tests", "tests\ProfanityFilter.Shared.Tests\ProfanityFilter.Shared.Tests.csproj", "{A742317F-FFB8-4A42-B9DC-8F79DA3B8EC6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -84,6 +90,18 @@ Global
{B5E3CB82-3306-4DD6-96BB-17995027FCA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5E3CB82-3306-4DD6-96BB-17995027FCA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5E3CB82-3306-4DD6-96BB-17995027FCA3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5380A680-6C10-4EA5-92F2-FA7DBF78900C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5380A680-6C10-4EA5-92F2-FA7DBF78900C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5380A680-6C10-4EA5-92F2-FA7DBF78900C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5380A680-6C10-4EA5-92F2-FA7DBF78900C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2DDAB9F2-3BC8-4191-B345-7DCA606BD46E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2DDAB9F2-3BC8-4191-B345-7DCA606BD46E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2DDAB9F2-3BC8-4191-B345-7DCA606BD46E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2DDAB9F2-3BC8-4191-B345-7DCA606BD46E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A742317F-FFB8-4A42-B9DC-8F79DA3B8EC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A742317F-FFB8-4A42-B9DC-8F79DA3B8EC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A742317F-FFB8-4A42-B9DC-8F79DA3B8EC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A742317F-FFB8-4A42-B9DC-8F79DA3B8EC6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -98,6 +116,9 @@ Global
{482EDC8C-D8F1-4934-8274-F24D14D9E4B0} = {28623E11-D15F-4448-82F6-B86A7D59B1D4}
{ED750950-FD39-4FF9-B3DC-48FC171164F7} = {28623E11-D15F-4448-82F6-B86A7D59B1D4}
{B5E3CB82-3306-4DD6-96BB-17995027FCA3} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {5380A680-6C10-4EA5-92F2-FA7DBF78900C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
+ {2DDAB9F2-3BC8-4191-B345-7DCA606BD46E} = {EAC63754-09D3-49C9-ACDF-ECF73AA1D922}
+ {A742317F-FFB8-4A42-B9DC-8F79DA3B8EC6} = {EAC63754-09D3-49C9-ACDF-ECF73AA1D922}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {72DBDAF8-D0CD-4A55-A944-0E1787FFA5C6}
diff --git a/src/ProfanityFilter.Client/DefaultRealtimeClient.cs b/src/ProfanityFilter.Client/DefaultRealtimeClient.cs
index 3a5ad60..1ea8e0c 100644
--- a/src/ProfanityFilter.Client/DefaultRealtimeClient.cs
+++ b/src/ProfanityFilter.Client/DefaultRealtimeClient.cs
@@ -4,18 +4,54 @@
namespace ProfanityFilter.Client;
internal sealed class DefaultRealtimeClient(
+ IConfiguration configuration,
IOptions options,
ILogger logger) : IRealtimeClient
{
- private readonly HubConnection _connection = new HubConnectionBuilder()
- .WithUrl(options.Value.RealtimeUri)
- .WithAutomaticReconnect()
- .WithStatefulReconnect()
- .Build();
+ private readonly Uri _hubUrl = new UriBuilder(
+ options.Value.ApiBaseAddress ?? throw new ArgumentException("""
+ The API base address must be provided.
+ """))
+ {
+ Path = "profanity/hub"
+ }.Uri;
+
+ private HubConnection? _connection = null;
+
+ [MemberNotNull(nameof(_connection))]
+ private void EnsureInitialized()
+ {
+ _connection ??= new HubConnectionBuilder()
+ .WithUrl(_hubUrl, options =>
+ {
+ // Only apply these options when running in a container.
+ if (!configuration.IsRunningInContainer())
+ {
+ return;
+ }
+
+ options.UseDefaultCredentials = true;
+ options.HttpMessageHandlerFactory = handler =>
+ {
+ if (handler is HttpClientHandler clientHandler)
+ {
+ clientHandler.ServerCertificateCustomValidationCallback =
+ HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
+ }
+
+ return handler;
+ };
+ })
+ .WithAutomaticReconnect()
+ .WithStatefulReconnect()
+ .Build();
+ }
///
async ValueTask IRealtimeClient.StartAsync(CancellationToken cancellationToken)
{
+ EnsureInitialized();
+
_connection.Closed += OnHubConnectionClosedAsync;
_connection.Reconnected += OnHubConnectionReconnectedAsync;
_connection.Reconnecting += OnHubConnectionReconnectingAsync;
@@ -26,6 +62,8 @@ async ValueTask IRealtimeClient.StartAsync(CancellationToken cancellationToken)
///
async ValueTask IRealtimeClient.StopAsync(CancellationToken cancellationToken)
{
+ EnsureInitialized();
+
_connection.Closed -= OnHubConnectionClosedAsync;
_connection.Reconnected -= OnHubConnectionReconnectedAsync;
_connection.Reconnecting -= OnHubConnectionReconnectingAsync;
@@ -55,34 +93,21 @@ private Task OnHubConnectionReconnectedAsync(string? arg)
}
///
- async IAsyncEnumerable IRealtimeClient.LiveStreamAsync(
- IAsyncEnumerable liveRequests,
- [EnumeratorCancellation] CancellationToken cancellationToken)
+ bool IRealtimeClient.IsConnected => _connection is
{
- var channel = Channel.CreateUnbounded();
+ State: HubConnectionState.Connected
+ };
- _connection.On(
- "live", response => channel.Writer.TryWrite(response));
-
- var sendTask = Task.Run(async () =>
- {
- await foreach (var request in liveRequests.WithCancellation(cancellationToken))
- {
- await _connection.SendAsync("LiveStream", request, cancellationToken);
- }
-
- channel.Writer.Complete();
- },
- cancellationToken);
-
- while (await channel.Reader.WaitToReadAsync(cancellationToken))
- {
- while (channel.Reader.TryRead(out var response))
- {
- yield return response;
- }
- }
+ ///
+ IAsyncEnumerable IRealtimeClient.StreamAsync(
+ IAsyncEnumerable liveRequests,
+ CancellationToken cancellationToken)
+ {
+ EnsureInitialized();
- await sendTask;
+ return _connection.StreamAsync(
+ methodName: "live",
+ arg1: liveRequests,
+ cancellationToken: cancellationToken);
}
}
\ No newline at end of file
diff --git a/src/ProfanityFilter.Client/DefaultRestClient.cs b/src/ProfanityFilter.Client/DefaultRestClient.cs
index f1283e9..f64980a 100644
--- a/src/ProfanityFilter.Client/DefaultRestClient.cs
+++ b/src/ProfanityFilter.Client/DefaultRestClient.cs
@@ -10,7 +10,7 @@ internal sealed class DefaultRestClient(HttpClient client) : IRestClient
async Task> IRestClient.ApplyFilterAsync(ProfanityFilterRequest request)
{
using var response = await client.PostAsJsonAsync(
- "filter", request, JsonSerializationContext.Default.ProfanityFilterRequest);
+ "profanity/filter", request, JsonSerializationContext.Default.ProfanityFilterRequest);
response.EnsureSuccessStatusCode();
@@ -23,25 +23,25 @@ async Task> IRestClient.ApplyFilterAsync(Profani
///
Task> IRestClient.GetDataByNameAsync(string name)
{
- return GetMaybeAsync($"data/{name}", JsonSerializationContext.Default.StringArray);
+ return GetMaybeAsync($"profanity/data/{name}", JsonSerializationContext.Default.StringArray);
}
///
Task> IRestClient.GetDataNamesAsync()
{
- return GetMaybeAsync("data", JsonSerializationContext.Default.StringArray);
+ return GetMaybeAsync("profanity/data", JsonSerializationContext.Default.StringArray);
}
///
Task> IRestClient.GetStrategiesAsync()
{
- return GetMaybeAsync("strategies", JsonSerializationContext.Default.StrategyResponseArray);
+ return GetMaybeAsync("profanity/strategies", JsonSerializationContext.Default.StrategyResponseArray);
}
///
Task> IRestClient.GetTargetsAsync()
{
- return GetMaybeAsync("targets", JsonSerializationContext.Default.FilterTargetResponseArray);
+ return GetMaybeAsync("profanity/targets", JsonSerializationContext.Default.FilterTargetResponseArray);
}
///
diff --git a/src/ProfanityFilter.Client/Extensions/ProfanityFilterClientExtensions.cs b/src/ProfanityFilter.Client/Extensions/ProfanityFilterClientExtensions.cs
index 81dde4c..2ac7daf 100644
--- a/src/ProfanityFilter.Client/Extensions/ProfanityFilterClientExtensions.cs
+++ b/src/ProfanityFilter.Client/Extensions/ProfanityFilterClientExtensions.cs
@@ -57,18 +57,40 @@ private static void AddProfanityFilterClient(
configSection.Bind(options);
namedConfigSection.Bind(options);
- if (builder.Configuration.GetConnectionString(connectionName) is string connectionString &&
- Uri.TryCreate(connectionString, UriKind.Absolute, out var baseAddress))
+ builder.Services.AddOptions()
+ .Bind(configSection)
+ .Bind(namedConfigSection);
+
+ //builder.Services.Configure(configSection);
+ //builder.Services.Configure(namedConfigSection);
+
+ var connectionString = builder.Configuration.GetConnectionString(connectionName)
+ ?? configSection["ApiBaseAddress"]
+ ?? namedConfigSection["ApiBaseAddress"];
+
+ if (connectionString is string potentialUri &&
+ Uri.TryCreate(potentialUri, UriKind.Absolute, out var baseAddress))
{
+ builder.Services.Configure(
+ configureOptions: o => o.ApiBaseAddress = baseAddress);
+
options.ApiBaseAddress = baseAddress;
}
- configure?.Invoke(options);
+ if (configure is not null)
+ {
+ configure.Invoke(options);
+ builder.Services.PostConfigure(configure);
+ }
- builder.Services.AddOptions();
builder.Services.AddLogging();
+ builder.Services.AddHttpClient((sp, client) =>
+ {
+ var options = sp.GetRequiredService>();
+
+ client.BaseAddress = options.Value.ApiBaseAddress;
+ });
- builder.Services.AddScoped();
builder.Services.AddScoped();
if (serviceKey is null)
diff --git a/src/ProfanityFilter.Client/GlobalUsings.cs b/src/ProfanityFilter.Client/GlobalUsings.cs
index 87c076b..3d45226 100644
--- a/src/ProfanityFilter.Client/GlobalUsings.cs
+++ b/src/ProfanityFilter.Client/GlobalUsings.cs
@@ -1,21 +1,15 @@
// Copyright (c) David Pine. All rights reserved.
// Licensed under the MIT License.
-global using System;
-global using System.Collections.Frozen;
-global using System.ComponentModel.DataAnnotations;
global using System.Diagnostics.CodeAnalysis;
global using System.Net.Http.Json;
-global using System.Runtime.CompilerServices;
-global using System.Text.Json;
-global using System.Text.Json.Serialization;
global using System.Text.Json.Serialization.Metadata;
-global using System.Threading.Channels;
global using Microsoft.AspNetCore.SignalR.Client;
+global using Microsoft.Extensions.Configuration;
+global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;
-global using Microsoft.Extensions.Hosting;
global using ProfanityFilter.Client;
global using ProfanityFilter.Client.Options;
diff --git a/src/ProfanityFilter.Client/IRealtimeClient.cs b/src/ProfanityFilter.Client/IRealtimeClient.cs
index d3d18ce..a62a495 100644
--- a/src/ProfanityFilter.Client/IRealtimeClient.cs
+++ b/src/ProfanityFilter.Client/IRealtimeClient.cs
@@ -8,6 +8,11 @@ namespace ProfanityFilter.Client;
///
public interface IRealtimeClient
{
+ ///
+ /// Gets a value indicating whether the client is connected.
+ ///
+ bool IsConnected { get; }
+
///
/// Starts the real-time client asynchronously.
///
@@ -28,7 +33,7 @@ public interface IRealtimeClient
/// An asynchronous stream of profanity filter requests.
/// A token to cancel the operation.
/// An asynchronous stream of profanity filter responses.
- IAsyncEnumerable LiveStreamAsync(
+ IAsyncEnumerable StreamAsync(
IAsyncEnumerable liveRequests,
CancellationToken cancellationToken);
}
diff --git a/src/ProfanityFilter.Client/Options/ProfanityFilterOptions.cs b/src/ProfanityFilter.Client/Options/ProfanityFilterOptions.cs
index 33cf4c8..b9f6ea9 100644
--- a/src/ProfanityFilter.Client/Options/ProfanityFilterOptions.cs
+++ b/src/ProfanityFilter.Client/Options/ProfanityFilterOptions.cs
@@ -23,16 +23,6 @@ public sealed class ProfanityFilterOptions
///
public Uri? ApiBaseAddress { get; set; }
- ///
- /// Gets the URI for the profanity REST API.
- ///
- public string RestUri => $"{ApiBaseAddress}/profanity";
-
- ///
- /// Gets the URI for the real-time profanity hub.
- ///
- public string RealtimeUri => $"{RestUri}/hub";
-
///
/// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.
///
diff --git a/src/ProfanityFilter.Client/ProfanityFilter.Client.csproj b/src/ProfanityFilter.Client/ProfanityFilter.Client.csproj
index f1c457d..80f0e47 100644
--- a/src/ProfanityFilter.Client/ProfanityFilter.Client.csproj
+++ b/src/ProfanityFilter.Client/ProfanityFilter.Client.csproj
@@ -6,18 +6,24 @@
enable
-
+
+
+
+
+
+
+
diff --git a/src/ProfanityFilter.Hosting/GlobalUsings.cs b/src/ProfanityFilter.Hosting/GlobalUsings.cs
index fb9a6b7..b94e354 100644
--- a/src/ProfanityFilter.Hosting/GlobalUsings.cs
+++ b/src/ProfanityFilter.Hosting/GlobalUsings.cs
@@ -2,8 +2,6 @@
// Licensed under the MIT License.
global using System.Diagnostics;
-
-global using Aspire.Hosting;
global using Aspire.Hosting.ApplicationModel;
global using Microsoft.Extensions.DependencyInjection;
diff --git a/src/ProfanityFilter.Hosting/ProfanityFilterContainerImageDetails.cs b/src/ProfanityFilter.Hosting/ProfanityFilterContainerImageDetails.cs
index 2ef2480..4ead3ae 100644
--- a/src/ProfanityFilter.Hosting/ProfanityFilterContainerImageDetails.cs
+++ b/src/ProfanityFilter.Hosting/ProfanityFilterContainerImageDetails.cs
@@ -17,6 +17,6 @@ internal static class ProfanityFilterContainerImageDetails
/// ievangelist/profanity-filter-api
internal const string Image = "ievangelist/profanity-filter-api";
- /// 2.0.9
- internal const string Tag = "2.0.9";
+ /// 2.0.10
+ internal const string Tag = "2.0.10";
}
diff --git a/src/ProfanityFilter.Hosting/ProfanityFilterResourceBuilderExtensions.cs b/src/ProfanityFilter.Hosting/ProfanityFilterResourceBuilderExtensions.cs
index 17d726f..6dfd7cf 100644
--- a/src/ProfanityFilter.Hosting/ProfanityFilterResourceBuilderExtensions.cs
+++ b/src/ProfanityFilter.Hosting/ProfanityFilterResourceBuilderExtensions.cs
@@ -19,6 +19,20 @@ public static class ProfanityFilterResourceBuilderExtensions
/// The name of the resource.
/// The optional password resource builder.
/// An for the profanity filter resource.
+ ///
+ /// Add a profanity filter container to the application model and reference it in a .NET project. Additionally, in this
+ /// example a bind mount is added to the container to allow data to be persisted across container restarts.
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// var profanityFilter = builder.AddProfanityFilter("profanity-filter");
+ ///
+ /// var api = builder.AddProject<Projects.Api>("api")
+ /// .WithReference(profanityFilter);
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
public static IResourceBuilder AddProfanityFilter(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
@@ -41,7 +55,7 @@ public static IResourceBuilder AddProfanityFilter(
}
///
- /// Adds a bind mount for the data folder to a .
+ /// Adds a bind mount for the /app/CustomData folder in the .
/// Added data files should be newline delimited and have a *.txt file extension.
///
/// The resource builder.
@@ -53,8 +67,8 @@ public static IResourceBuilder AddProfanityFilter(
///
/// var builder = DistributedApplication.CreateBuilder(args);
///
- /// var profanityFilter = builder.AddElasticsearch("profanity-filter")
- /// .WithDataBindMount("./data");
+ /// var profanityFilter = builder.AddProfanityFilter("profanity-filter")
+ /// .WithDataBindMount("./CustomData");
///
/// var api = builder.AddProject<Projects.Api>("api")
/// .WithReference(profanityFilter);
@@ -62,7 +76,7 @@ public static IResourceBuilder AddProfanityFilter(
/// builder.Build().Run();
///
///
- public static IResourceBuilder WithDataBindMount(
+ public static IResourceBuilder WithCustomDataBindMount(
this IResourceBuilder builder,
string source)
{
diff --git a/src/ProfanityFilter.Services/GlobalUsings.cs b/src/ProfanityFilter.Services/GlobalUsings.cs
index 1b5342b..6430fe5 100644
--- a/src/ProfanityFilter.Services/GlobalUsings.cs
+++ b/src/ProfanityFilter.Services/GlobalUsings.cs
@@ -3,15 +3,11 @@
global using System.Collections.Concurrent;
global using System.Collections.Frozen;
-global using System.Diagnostics.CodeAnalysis;
global using System.Text;
-global using System.Text.Json;
-global using System.Text.Json.Serialization;
global using System.Text.RegularExpressions;
global using Microsoft.Extensions.Caching.Memory;
global using Microsoft.Extensions.Logging;
-global using Microsoft.Extensions.Configuration;
global using Pathological.Globbing.Extensions;
global using Pathological.Globbing.Options;
@@ -22,9 +18,6 @@
global using ProfanityFilter.Services.Filters;
global using ProfanityFilter.Services.Internals;
global using ProfanityFilter.Shared;
-global using ProfanityFilter.Shared.Api;
-global using ProfanityFilter.Shared.Extensions;
-global using ProfanityFilter.Shared.Optional;
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(
assemblyName: "ProfanityFilter.Services.Tests")]
diff --git a/src/ProfanityFilter.Services/ProfanityFilter.Services.csproj b/src/ProfanityFilter.Services/ProfanityFilter.Services.csproj
index 207c665..c983b28 100644
--- a/src/ProfanityFilter.Services/ProfanityFilter.Services.csproj
+++ b/src/ProfanityFilter.Services/ProfanityFilter.Services.csproj
@@ -7,12 +7,6 @@
$(NoWarn);NU5104
-
-
- Shared\%(RecursiveDir)%(Filename)%(Extension)
-
-
-
Always
@@ -51,4 +45,8 @@
+
+
+
+
diff --git a/src/ProfanityFilter.Shared/Extensions/ConfigurationExtensions.cs b/src/ProfanityFilter.Shared/Extensions/ConfigurationExtensions.cs
index 700bc25..9c723fc 100644
--- a/src/ProfanityFilter.Shared/Extensions/ConfigurationExtensions.cs
+++ b/src/ProfanityFilter.Shared/Extensions/ConfigurationExtensions.cs
@@ -7,8 +7,17 @@ namespace Microsoft.Extensions.Configuration;
public static class ConfigurationExtensions
{
+ ///
+ /// Determines whether the application is running inside a container.
+ ///
+ /// The configuration instance to check.
+ ///
+ /// true if the application is running inside a container; otherwise, false.
+ ///
public static bool IsRunningInContainer(this IConfiguration configuration)
{
- return configuration.GetValue("DOTNET_RUNNING_IN_CONTAINER");
+ return configuration.GetValue("DOTNET_RUNNING_IN_CONTAINER")
+ || Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")
+ is "true" or "TRUE" or "True" or "1";
}
}
diff --git a/src/ProfanityFilter.Shared/Extensions/MaybeExtensions.cs b/src/ProfanityFilter.Shared/Extensions/MaybeExtensions.cs
index 7a1d7aa..15c2aad 100644
--- a/src/ProfanityFilter.Shared/Extensions/MaybeExtensions.cs
+++ b/src/ProfanityFilter.Shared/Extensions/MaybeExtensions.cs
@@ -27,7 +27,8 @@ public static IMaybe AsMaybe(this T? value) => value is null
/// The function to transform the input value to the output value.
/// An instance of containing the transformed value.
public static IMaybe Chain(
- this TIn @this, Func func) =>
+ this IMaybe @this,
+ Func func) =>
@this switch
{
Something s => new Something(func.Invoke(s.Value)),
diff --git a/src/ProfanityFilter.Shared/GlobalUsings.cs b/src/ProfanityFilter.Shared/GlobalUsings.cs
index 75292e7..269a32f 100644
--- a/src/ProfanityFilter.Shared/GlobalUsings.cs
+++ b/src/ProfanityFilter.Shared/GlobalUsings.cs
@@ -6,7 +6,5 @@
global using System.Text.Json;
global using System.Text.Json.Serialization;
-global using Microsoft.Extensions.Configuration;
-
global using ProfanityFilter.Shared.Api;
global using ProfanityFilter.Shared.Optional;
\ No newline at end of file
diff --git a/src/ProfanityFilter.Shared/Optional/Nothing.cs b/src/ProfanityFilter.Shared/Optional/Nothing.cs
index beb2b4d..5533d4a 100644
--- a/src/ProfanityFilter.Shared/Optional/Nothing.cs
+++ b/src/ProfanityFilter.Shared/Optional/Nothing.cs
@@ -7,4 +7,19 @@ namespace ProfanityFilter.Shared.Optional;
/// Represents a type that signifies the absence of a value.
///
/// The type of the value that is absent.
-public readonly record struct Nothing : IMaybe;
+public readonly record struct Nothing : IMaybe
+{
+ ///
+ /// Implicitly converts the to its contained value of type .
+ ///
+ /// The instance to convert.
+ /// The value contained in the .
+ public static implicit operator T?(Nothing nothing) => default;
+
+ ///
+ /// Implicitly converts the to a new .
+ ///
+ /// The value.
+ /// The new instance of .
+ public static implicit operator Nothing(T _) => new();
+}
diff --git a/src/ProfanityFilter.Shared/Optional/Something.cs b/src/ProfanityFilter.Shared/Optional/Something.cs
index 21e829a..af0ccd9 100644
--- a/src/ProfanityFilter.Shared/Optional/Something.cs
+++ b/src/ProfanityFilter.Shared/Optional/Something.cs
@@ -8,4 +8,19 @@ namespace ProfanityFilter.Shared.Optional;
///
/// The type of the value.
/// The value contained in the .
-public readonly record struct Something(T Value) : IMaybe;
+public readonly record struct Something(T Value) : IMaybe
+{
+ ///
+ /// Implicitly converts the to its contained value of type .
+ ///
+ /// The instance to convert.
+ /// The value contained in the .
+ public static implicit operator T(Something something) => something.Value;
+
+ ///
+ /// Implicitly converts the to a new .
+ ///
+ /// The value.
+ /// The new instance of .
+ public static implicit operator Something(T value) => new(value);
+}
diff --git a/src/ProfanityFilter.WebApi/Components/Pages/Home.razor.cs b/src/ProfanityFilter.WebApi/Components/Pages/Home.razor.cs
index 3404982..5857793 100644
--- a/src/ProfanityFilter.WebApi/Components/Pages/Home.razor.cs
+++ b/src/ProfanityFilter.WebApi/Components/Pages/Home.razor.cs
@@ -1,4 +1,7 @@
-namespace ProfanityFilter.WebApi.Components.Pages;
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+namespace ProfanityFilter.WebApi.Components.Pages;
[StreamRendering]
public sealed partial class Home : IAsyncDisposable
@@ -13,7 +16,6 @@ public sealed partial class Home : IAsyncDisposable
private readonly ReplacementStrategy[] _strategies = Enum.GetValues();
private readonly CancellationTokenSource _cts = new();
- private HubConnection? _hub;
private ReplacementStrategy _selectedStrategy;
private string? _text = "Content to filter...";
private bool _isLoading = false;
@@ -23,7 +25,7 @@ public sealed partial class Home : IAsyncDisposable
public required ILogger Logger { get; set; }
[Inject]
- public required BaseAddressResolver BaseAddressResolver { get; set; }
+ public required IRealtimeClient RealtimeClient { get; set; }
[Inject]
public required ILocalStorageService LocalStorage { get; set; }
@@ -59,91 +61,17 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
_selectedStrategy = selectedStrategy;
StateHasChanged();
}
+
+ await RealtimeClient.StartAsync();
- if (_hub is null)
- {
- var baseAddress = BaseAddressResolver.GetBaseAddress();
-
- Logger.LogInformation("Connecting to: {Address}/profanity/hub", baseAddress);
-
- var uri = new UriBuilder(baseAddress)
- {
- Path = "/profanity/hub"
- };
-
- _hub = new HubConnectionBuilder()
- .WithUrl(uri.Uri, options =>
- {
- if (!BaseAddressResolver.IsRunningInContainer)
- {
- return;
- }
-
- options.UseDefaultCredentials = true;
- options.HttpMessageHandlerFactory = handler =>
- {
- if (handler is HttpClientHandler clientHandler)
- {
- clientHandler.ServerCertificateCustomValidationCallback =
- HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
- }
-
- return handler;
- };
- })
- .WithAutomaticReconnect()
- .WithStatefulReconnect()
- .Build();
-
- _hub.Closed += OnHubConnectionClosedAsync;
- _hub.Reconnected += OnHubConnectionReconnectedAsync;
- _hub.Reconnecting += OnHubConnectionReconnectingAsync;
-
- await _hub.StartAsync();
-
- Logger.HubStarted();
-
- _ = StartLiveUpdateStreamAsync();
- }
- }
-
- private Task OnHubConnectionClosedAsync(Exception? exception)
- {
- Logger.HubConnectionClosed(exception);
-
- return Task.CompletedTask;
- }
-
- private Task OnHubConnectionReconnectingAsync(Exception? exception)
- {
- Logger.HubReconnecting(exception);
-
- return Task.CompletedTask;
- }
-
- private Task OnHubConnectionReconnectedAsync(string? arg)
- {
- Logger.HubReconnected(arg);
+ Logger.HubStarted();
- return Task.CompletedTask;
- }
-
- [MemberNotNullWhen(false, nameof(_hub))]
- private bool IsHubNotConnected()
- {
- if (_hub is null or not { State: HubConnectionState.Connected })
- {
- Logger.NotConnectedToHub();
-
- return true;
- }
-
- return false;
+ _ = StartLiveUpdateStreamAsync();
}
private async Task StartLiveUpdateStreamAsync()
{
- if (IsHubNotConnected() || _liveRequests is null)
+ if (!RealtimeClient.IsConnected || _liveRequests is null)
{
return;
}
@@ -156,8 +84,7 @@ private async Task StartLiveUpdateStreamAsync()
var liveRequests = _liveRequests.ToAsyncEnumerable();
// Consumer responses...
- await foreach (var response in _hub.StreamAsync(
- methodName: "live", arg1: liveRequests, cancellationToken: _cts.Token))
+ await foreach (var response in RealtimeClient.StreamAsync(liveRequests, cancellationToken: _cts.Token))
{
if (response is null)
{
@@ -215,14 +142,9 @@ public async ValueTask DisposeAsync()
await _cts.CancelAsync();
_cts.Dispose();
- if (_hub is not null)
+ if (RealtimeClient is not null)
{
- _hub.Closed -= OnHubConnectionClosedAsync;
- _hub.Reconnected -= OnHubConnectionReconnectedAsync;
- _hub.Reconnecting -= OnHubConnectionReconnectingAsync;
-
- await _hub.StopAsync();
- await _hub.DisposeAsync();
+ await RealtimeClient.StopAsync();
}
}
}
diff --git a/src/ProfanityFilter.WebApi/GlobalUsings.cs b/src/ProfanityFilter.WebApi/GlobalUsings.cs
index 62d5009..06fd4e5 100644
--- a/src/ProfanityFilter.WebApi/GlobalUsings.cs
+++ b/src/ProfanityFilter.WebApi/GlobalUsings.cs
@@ -2,9 +2,9 @@
// Licensed under the MIT License.
global using System.Diagnostics;
-global using System.Diagnostics.CodeAnalysis;
global using System.Reactive.Linq;
global using System.Runtime.CompilerServices;
+global using System.Text.Json;
global using System.Timers;
global using Markdig;
@@ -12,20 +12,18 @@
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.DataProtection;
global using Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption;
-global using Microsoft.AspNetCore.Hosting.Server;
-global using Microsoft.AspNetCore.Hosting.Server.Features;
global using Microsoft.AspNetCore.Http.Connections;
global using Microsoft.AspNetCore.Http.HttpResults;
global using Microsoft.AspNetCore.HttpLogging;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.AspNetCore.Mvc.ModelBinding;
global using Microsoft.AspNetCore.SignalR;
-global using Microsoft.AspNetCore.SignalR.Client;
global using Microsoft.Extensions.Caching.Memory;
global using Microsoft.Extensions.Compliance.Classification;
global using Microsoft.Extensions.Compliance.Redaction;
global using Microsoft.JSInterop;
+global using ProfanityFilter.Client;
global using ProfanityFilter.Services;
global using ProfanityFilter.Shared;
global using ProfanityFilter.Shared.Api;
@@ -33,6 +31,5 @@
global using ProfanityFilter.WebApi.Components;
global using ProfanityFilter.WebApi.Endpoints;
global using ProfanityFilter.WebApi.Hubs;
-global using ProfanityFilter.WebApi.Services;
global using SystemTimer = System.Timers.Timer;
\ No newline at end of file
diff --git a/src/ProfanityFilter.WebApi/ProfanityFilter.WebApi.csproj b/src/ProfanityFilter.WebApi/ProfanityFilter.WebApi.csproj
index 79485a2..b4e8d2f 100644
--- a/src/ProfanityFilter.WebApi/ProfanityFilter.WebApi.csproj
+++ b/src/ProfanityFilter.WebApi/ProfanityFilter.WebApi.csproj
@@ -38,6 +38,7 @@
+
diff --git a/src/ProfanityFilter.WebApi/Program.cs b/src/ProfanityFilter.WebApi/Program.cs
index 84fdb8a..e54c1a2 100644
--- a/src/ProfanityFilter.WebApi/Program.cs
+++ b/src/ProfanityFilter.WebApi/Program.cs
@@ -3,14 +3,7 @@
var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddScoped(sp =>
-{
- var server = sp.GetRequiredService();
-
- return server.Features.Get()
- ?? throw new InvalidOperationException("There's no IServerAddressesFeature in the IServer.");
-});
-builder.Services.AddScoped();
+builder.AddProfanityFilterClient("profanity-filter");
builder.Services.AddRedaction(static redaction =>
redaction.SetRedactor(
@@ -24,8 +17,7 @@
builder.Services.AddLocalStorageServices();
builder.Services.AddMemoryCache();
-builder.Services.AddSignalR(
- static options => options.EnableDetailedErrors = true);
+builder.Services.AddSignalR(static options => options.EnableDetailedErrors = true);
builder.Services.AddAntiforgery();
builder.Services.AddDataProtection()
@@ -42,10 +34,7 @@
.AddInteractiveServerComponents();
builder.Services.ConfigureHttpJsonOptions(
- static options =>
- options.SerializerOptions.TypeInfoResolverChain.Insert(
- 0,
- JsonSerializationContext.Default));
+ static options => AssignJsonSerializerContext(options.SerializerOptions));
var app = builder.Build();
@@ -60,3 +49,8 @@
.AddInteractiveServerRenderMode();
app.Run();
+
+static void AssignJsonSerializerContext(JsonSerializerOptions options)
+{
+ options.TypeInfoResolverChain.Insert(0, JsonSerializationContext.Default);
+}
diff --git a/src/ProfanityFilter.WebApi/Properties/launchSettings.json b/src/ProfanityFilter.WebApi/Properties/launchSettings.json
new file mode 100644
index 0000000..b394beb
--- /dev/null
+++ b/src/ProfanityFilter.WebApi/Properties/launchSettings.json
@@ -0,0 +1,35 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+ "applicationUrl": "http://localhost:5276",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+ "applicationUrl": "https://localhost:7097;http://localhost:5276",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "Container (.NET SDK)": {
+ "commandName": "SdkContainer",
+ "launchBrowser": true,
+ "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
+ "environmentVariables": {
+ "ASPNETCORE_HTTPS_PORTS": "8081",
+ "ASPNETCORE_HTTP_PORTS": "8080"
+ },
+ "publishAllPorts": true,
+ "useSSL": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ProfanityFilter.WebApi/Services/BaseAddressResolver.cs b/src/ProfanityFilter.WebApi/Services/BaseAddressResolver.cs
deleted file mode 100644
index fcce824..0000000
--- a/src/ProfanityFilter.WebApi/Services/BaseAddressResolver.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (c) David Pine. All rights reserved.
-// Licensed under the MIT License.
-
-namespace ProfanityFilter.WebApi.Services;
-
-public sealed class BaseAddressResolver(
- IConfiguration configuration,
- IServerAddressesFeature serverAddresses,
- NavigationManager navigationManager)
-{
- public bool IsRunningInContainer => configuration.IsRunningInContainer();
-
- public string GetBaseAddress()
- {
- return IsRunningInContainer switch
- {
- // When running in a container, use the internal container port.
- true => "https://localhost:8081",
-
- // Otherwise, rely on the server address or the base URI.
- _ => serverAddresses.Addresses.FirstOrDefault(address => address.StartsWith("https"))
- ?? navigationManager.BaseUri
- };
- }
-}
diff --git a/src/ProfanityFilter.WebApi/appsettings.Development.json b/src/ProfanityFilter.WebApi/appsettings.Development.json
index 770d3e9..83f446a 100644
--- a/src/ProfanityFilter.WebApi/appsettings.Development.json
+++ b/src/ProfanityFilter.WebApi/appsettings.Development.json
@@ -5,5 +5,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
+ },
+ "ProfanityFilter": {
+ "ApiBaseAddress": "https://localhost:7097"
}
}
diff --git a/src/ProfanityFilter.WebApi/appsettings.json b/src/ProfanityFilter.WebApi/appsettings.json
index c5f5d9e..55d51a9 100644
--- a/src/ProfanityFilter.WebApi/appsettings.json
+++ b/src/ProfanityFilter.WebApi/appsettings.json
@@ -6,5 +6,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "ProfanityFilter": {
+ "ApiBaseAddress": "https://localhost:8081"
+ }
}
diff --git a/tests/ProfanityFilter.Client.Tests/GlobalUsings.cs b/tests/ProfanityFilter.Client.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..cd3d266
--- /dev/null
+++ b/tests/ProfanityFilter.Client.Tests/GlobalUsings.cs
@@ -0,0 +1,7 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+global using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+global using ProfanityFilter.Shared.Api;
+global using ProfanityFilter.Shared.Optional;
\ No newline at end of file
diff --git a/tests/ProfanityFilter.Client.Tests/ProfanityFilter.Client.Tests.csproj b/tests/ProfanityFilter.Client.Tests/ProfanityFilter.Client.Tests.csproj
new file mode 100644
index 0000000..a3a0754
--- /dev/null
+++ b/tests/ProfanityFilter.Client.Tests/ProfanityFilter.Client.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ $(DefaultTargetFramework)
+ false
+ true
+ exe
+ true
+ linux-x64
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/ProfanityFilter.Client.Tests/ProfanityFilterClientTests.cs b/tests/ProfanityFilter.Client.Tests/ProfanityFilterClientTests.cs
new file mode 100644
index 0000000..3c05bdd
--- /dev/null
+++ b/tests/ProfanityFilter.Client.Tests/ProfanityFilterClientTests.cs
@@ -0,0 +1,33 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+namespace ProfanityFilter.Client.Tests;
+
+[TestClass]
+public class ProfanityFilterClientTests
+{
+ [TestMethod]
+ public void DeconstructCorrectlyMaterializesNullClients()
+ {
+ var sut = new ProfanityFilterClient(null!, null!);
+
+ var (rest, realtime) = sut;
+
+ Assert.IsNull(rest);
+ Assert.IsNull(realtime);
+ }
+
+ [TestMethod]
+ public void DeconstructCorrectlyMaterializesTargetClients()
+ {
+ var sut = new ProfanityFilterClient(
+ new TestRestClient(), new TestRealTimeClient());
+
+ var (rest, realtime) = sut;
+
+ Assert.IsNotNull(rest);
+ Assert.IsInstanceOfType(rest);
+ Assert.IsNotNull(realtime);
+ Assert.IsInstanceOfType(realtime);
+ }
+}
diff --git a/tests/ProfanityFilter.Client.Tests/TestRealTimeClient.cs b/tests/ProfanityFilter.Client.Tests/TestRealTimeClient.cs
new file mode 100644
index 0000000..048a3af
--- /dev/null
+++ b/tests/ProfanityFilter.Client.Tests/TestRealTimeClient.cs
@@ -0,0 +1,22 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+namespace ProfanityFilter.Client.Tests;
+
+internal class TestRealTimeClient : IRealtimeClient
+{
+ IAsyncEnumerable IRealtimeClient.StreamAsync(IAsyncEnumerable liveRequests, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ ValueTask IRealtimeClient.StartAsync(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ ValueTask IRealtimeClient.StopAsync(CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/tests/ProfanityFilter.Client.Tests/TestRestClient.cs b/tests/ProfanityFilter.Client.Tests/TestRestClient.cs
new file mode 100644
index 0000000..dc30280
--- /dev/null
+++ b/tests/ProfanityFilter.Client.Tests/TestRestClient.cs
@@ -0,0 +1,32 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+namespace ProfanityFilter.Client.Tests;
+
+internal class TestRestClient : IRestClient
+{
+ Task> IRestClient.ApplyFilterAsync(ProfanityFilterRequest request)
+ {
+ throw new NotImplementedException();
+ }
+
+ Task> IRestClient.GetDataByNameAsync(string name)
+ {
+ throw new NotImplementedException();
+ }
+
+ Task> IRestClient.GetDataNamesAsync()
+ {
+ throw new NotImplementedException();
+ }
+
+ Task> IRestClient.GetStrategiesAsync()
+ {
+ throw new NotImplementedException();
+ }
+
+ Task> IRestClient.GetTargetsAsync()
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/tests/ProfanityFilter.Shared.Tests/GlobalUsings.cs b/tests/ProfanityFilter.Shared.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..38634bf
--- /dev/null
+++ b/tests/ProfanityFilter.Shared.Tests/GlobalUsings.cs
@@ -0,0 +1,7 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+global using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+global using ProfanityFilter.Shared.Extensions;
+global using ProfanityFilter.Shared.Optional;
\ No newline at end of file
diff --git a/tests/ProfanityFilter.Shared.Tests/MaybeExtensionsTests.cs b/tests/ProfanityFilter.Shared.Tests/MaybeExtensionsTests.cs
new file mode 100644
index 0000000..ae04ab9
--- /dev/null
+++ b/tests/ProfanityFilter.Shared.Tests/MaybeExtensionsTests.cs
@@ -0,0 +1,52 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+namespace ProfanityFilter.Shared.Tests;
+
+[TestClass]
+public class MaybeExtensionsTests
+{
+ [TestMethod]
+ public void AsMaybeWithNonNullValueReturnsSomething()
+ {
+ var value = "test";
+ var result = value.AsMaybe();
+
+ Assert.IsInstanceOfType>(result);
+ Assert.AreEqual(value, ((Something)result).Value);
+ }
+
+ [TestMethod]
+ public void AsMaybeWithNullValueReturnsNothing()
+ {
+ string? value = null;
+ var result = value.AsMaybe();
+
+ Assert.IsInstanceOfType>(result);
+ }
+
+ [TestMethod]
+ public void ChainWithSomethingReturnsTransformedSomething()
+ {
+ var initial = new Something(5);
+ var result = initial.Chain(x => x * 2).Chain(x => x * 3.1);
+
+ Assert.IsInstanceOfType>(result);
+ Assert.AreEqual(31d, ((Something)result).Value);
+
+ var stringSomething = result.Chain(x => (x / 2).ToString());
+
+ Assert.IsInstanceOfType>(stringSomething);
+ Assert.AreEqual("15.5", ((Something)stringSomething).Value);
+ }
+
+ [TestMethod]
+ public void ChainWithNothingReturnsNothing()
+ {
+ var initial = new Nothing();
+ var result = initial.Chain(x => x * 2);
+
+ Assert.IsInstanceOfType>(result);
+ }
+}
+
diff --git a/tests/ProfanityFilter.Shared.Tests/NothingTests.cs b/tests/ProfanityFilter.Shared.Tests/NothingTests.cs
new file mode 100644
index 0000000..3611a56
--- /dev/null
+++ b/tests/ProfanityFilter.Shared.Tests/NothingTests.cs
@@ -0,0 +1,17 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+namespace ProfanityFilter.Shared.Tests;
+
+[TestClass]
+public sealed class NothingTests
+{
+ [TestMethod]
+ public void NothingIsRepresentedAsSuch()
+ {
+ var sut = new Nothing();
+
+ Assert.IsInstanceOfType>(sut);
+ Assert.IsNotInstanceOfType(sut);
+ }
+}
diff --git a/tests/ProfanityFilter.Shared.Tests/ProfanityFilter.Shared.Tests.csproj b/tests/ProfanityFilter.Shared.Tests/ProfanityFilter.Shared.Tests.csproj
new file mode 100644
index 0000000..862f4c3
--- /dev/null
+++ b/tests/ProfanityFilter.Shared.Tests/ProfanityFilter.Shared.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ $(DefaultTargetFramework)
+ false
+ true
+ exe
+ true
+ linux-x64
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/tests/ProfanityFilter.Shared.Tests/SomethingTests.cs b/tests/ProfanityFilter.Shared.Tests/SomethingTests.cs
new file mode 100644
index 0000000..3eca075
--- /dev/null
+++ b/tests/ProfanityFilter.Shared.Tests/SomethingTests.cs
@@ -0,0 +1,33 @@
+// Copyright (c) David Pine. All rights reserved.
+// Licensed under the MIT License.
+
+namespace ProfanityFilter.Shared.Tests;
+
+[TestClass]
+public sealed class SomethingTests
+{
+ [TestMethod]
+ public void SomethingShouldContainValue()
+ {
+ // Arrange
+ var expectedValue = "test value";
+ var something = new Something(expectedValue);
+
+ // Act
+ var actualValue = something.Value;
+
+ // Assert
+ Assert.AreEqual(expectedValue, actualValue);
+ }
+
+ [TestMethod]
+ public void SomethingShouldBeAssignableToIMaybe()
+ {
+ // Arrange
+ var something = new Something(42);
+
+ // Act & Assert
+ Assert.IsInstanceOfType>(something);
+ Assert.AreEqual(42, something.Value);
+ }
+}