diff --git a/Directory.Packages.props b/Directory.Packages.props index 1096307..9c91dff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,14 +7,14 @@ - - + + + - @@ -22,5 +22,6 @@ + \ No newline at end of file diff --git a/global.json b/global.json index e2138e0..7761349 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.200" + "version": "8.0.301" } } \ No newline at end of file diff --git a/profanity-filter.sln b/profanity-filter.sln index 455ee08..b2c742a 100644 --- a/profanity-filter.sln +++ b/profanity-filter.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.8.34004.107 @@ -29,12 +28,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{28623E11-D15 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{EAC63754-09D3-49C9-ACDF-ECF73AA1D922}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProfanityFilter.WebApi", "src\ProfanityFilter.WebApi\ProfanityFilter.WebApi.csproj", "{163A89FA-5F97-4332-B5DA-3E7BDDA97E7C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {163A89FA-5F97-4332-B5DA-3E7BDDA97E7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {163A89FA-5F97-4332-B5DA-3E7BDDA97E7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {163A89FA-5F97-4332-B5DA-3E7BDDA97E7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {163A89FA-5F97-4332-B5DA-3E7BDDA97E7C}.Release|Any CPU.Build.0 = Release|Any CPU {6FBF4FCA-D1E5-4BE3-8BA5-48EF90D8E966}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6FBF4FCA-D1E5-4BE3-8BA5-48EF90D8E966}.Debug|Any CPU.Build.0 = Debug|Any CPU {6FBF4FCA-D1E5-4BE3-8BA5-48EF90D8E966}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -56,6 +61,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {163A89FA-5F97-4332-B5DA-3E7BDDA97E7C} = {28623E11-D15F-4448-82F6-B86A7D59B1D4} {6FBF4FCA-D1E5-4BE3-8BA5-48EF90D8E966} = {28623E11-D15F-4448-82F6-B86A7D59B1D4} {F2750329-F469-4BD8-A0D3-7E1CE2D62E19} = {28623E11-D15F-4448-82F6-B86A7D59B1D4} {A1F889D2-290B-42C2-A61C-877C97F9D5EB} = {EAC63754-09D3-49C9-ACDF-ECF73AA1D922} diff --git a/src/ProfanityFilter.Action/ProfanityFilter.Action.csproj b/src/ProfanityFilter.Action/ProfanityFilter.Action.csproj index 3f9dcc0..53d51d4 100644 --- a/src/ProfanityFilter.Action/ProfanityFilter.Action.csproj +++ b/src/ProfanityFilter.Action/ProfanityFilter.Action.csproj @@ -30,7 +30,6 @@ - diff --git a/src/ProfanityFilter.Services/Data/ProfaneContentReader.cs b/src/ProfanityFilter.Services/Data/ProfaneContentReader.cs index a9306f9..5cd9e1c 100644 --- a/src/ProfanityFilter.Services/Data/ProfaneContentReader.cs +++ b/src/ProfanityFilter.Services/Data/ProfaneContentReader.cs @@ -10,7 +10,7 @@ internal sealed class ProfaneContentReader EnsureWorkingDirectory(); var builder = new GlobOptionsBuilder() - .WithPattern("Data/*.txt"); + .WithPattern("**/Data/*.txt"); return builder.Build(); }); diff --git a/src/ProfanityFilter.Services/DefaultProfaneContentFilterService.cs b/src/ProfanityFilter.Services/DefaultProfaneContentFilterService.cs index 8931155..1ecab88 100644 --- a/src/ProfanityFilter.Services/DefaultProfaneContentFilterService.cs +++ b/src/ProfanityFilter.Services/DefaultProfaneContentFilterService.cs @@ -7,12 +7,8 @@ internal sealed class DefaultProfaneContentFilterService(IMemoryCache cache) : I { private const string ProfaneListKey = nameof(ProfaneListKey); - /// - /// Reads all profane words from their respective sources asynchronously. - /// - /// A representing the asynchronous operation that - /// returns a readonly dictionary of all profane words. - private async Task> ReadAllProfaneWordsAsync() + /// + public async Task> ReadAllProfaneWordsAsync() { return await cache.GetOrCreateAsync(ProfaneListKey, async entry => { @@ -62,7 +58,7 @@ await Parallel.ForEachAsync(fileNames, } /// - async ValueTask IProfaneContentFilterService.FilterProfanityAsync( + public async ValueTask FilterProfanityAsync( string content, FilterParameters parameters) { diff --git a/src/ProfanityFilter.Services/Filters/ProfaneSourceFilter.cs b/src/ProfanityFilter.Services/Filters/ProfaneSourceFilter.cs index ee44518..c623f22 100644 --- a/src/ProfanityFilter.Services/Filters/ProfaneSourceFilter.cs +++ b/src/ProfanityFilter.Services/Filters/ProfaneSourceFilter.cs @@ -16,6 +16,7 @@ public record class ProfaneSourceFilter( /// Gets the string representation of the set /// represented as a regular expression pattern. /// + [JsonIgnore] public string RegexPattern { get; } = $"\\b({string.Join('|', ProfaneWords)})\\b"; } diff --git a/src/ProfanityFilter.Services/Filters/ReplacementStrategy.cs b/src/ProfanityFilter.Services/Filters/ReplacementStrategy.cs index 9c288d9..f593319 100644 --- a/src/ProfanityFilter.Services/Filters/ReplacementStrategy.cs +++ b/src/ProfanityFilter.Services/Filters/ReplacementStrategy.cs @@ -42,7 +42,7 @@ public enum ReplacementStrategy MiddleAsterisk, /// - /// Represents the first letter then asterisk replacement strategy, which replaces the + /// Represents the first letter then asterisk replacement strategy, which replaces /// everything after the first letter of the profanity with asterisk. /// FirstLetterThenAsterisk, diff --git a/src/ProfanityFilter.Services/GlobalUsings.cs b/src/ProfanityFilter.Services/GlobalUsings.cs index 2e8af19..179bc6c 100644 --- a/src/ProfanityFilter.Services/GlobalUsings.cs +++ b/src/ProfanityFilter.Services/GlobalUsings.cs @@ -6,6 +6,7 @@ global using System.Diagnostics.CodeAnalysis; global using System.Text; global using System.Text.RegularExpressions; +global using System.Text.Json.Serialization; global using Microsoft.Extensions.Caching.Memory; global using Microsoft.Extensions.DependencyInjection; diff --git a/src/ProfanityFilter.Services/IProfaneContentFilterService.cs b/src/ProfanityFilter.Services/IProfaneContentFilterService.cs index 536d02c..ad7afb1 100644 --- a/src/ProfanityFilter.Services/IProfaneContentFilterService.cs +++ b/src/ProfanityFilter.Services/IProfaneContentFilterService.cs @@ -18,4 +18,11 @@ public interface IProfaneContentFilterService ValueTask FilterProfanityAsync( string content, FilterParameters parameters); + + /// + /// Reads all profane words from their respective sources asynchronously. + /// + /// A representing the asynchronous operation that + /// returns a readonly dictionary of all profane words. + Task> ReadAllProfaneWordsAsync(); } diff --git a/src/ProfanityFilter.WebApi/Endpoints/ProfanityFilterEndpointExtensions.cs b/src/ProfanityFilter.WebApi/Endpoints/ProfanityFilterEndpointExtensions.cs new file mode 100644 index 0000000..c56ccc9 --- /dev/null +++ b/src/ProfanityFilter.WebApi/Endpoints/ProfanityFilterEndpointExtensions.cs @@ -0,0 +1,120 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.WebApi.Endpoints; + +internal static class ProfanityFilterEndpointExtensions +{ + internal static WebApplication MapProfanityFilterEndpoints(this WebApplication app) + { + var profanity = app.MapGroup("profanity"); + + profanity.MapPost("filter", OnApplyFilterAsync) + .WithOpenApi() + .WithRequestTimeout(TimeSpan.FromSeconds(10)) + .WithSummary(""" + Use this endpoint to attempt applying a profanity-filter. The response is returned as Markdown. + """) + .WithHttpLogging(HttpLoggingFields.All); + + profanity.MapGet("strategies", OnGetStrategies) + .WithOpenApi() + .WithRequestTimeout(TimeSpan.FromSeconds(10)) + .CacheOutput() + .WithSummary(""" + Returns an array of the possible replacement strategies available. See https://github.com/IEvangelist/profanity-filter?tab=readme-ov-file#-replacement-strategies + """) + .WithHttpLogging(HttpLoggingFields.All); + + var data = profanity.MapGroup("data"); + + data.MapGet("names", OnGetDataNamesAsync) + .WithOpenApi() + .WithRequestTimeout(TimeSpan.FromSeconds(10)) + .CacheOutput() + .WithSummary(""" + Returns an array of the data names. + """) + .WithHttpLogging(HttpLoggingFields.All); + + data.MapGet("{name}", OnGetDataByNameAsync) + .WithOpenApi() + .WithRequestTimeout(TimeSpan.FromSeconds(10)) + .CacheOutput() + .WithSummary(""" + Returns an array of the profane words for a given data name. + """) + .WithHttpLogging(HttpLoggingFields.All); + + return app; + } + + private static async Task OnApplyFilterAsync( + [FromBody] ProfanityFilterRequest request, + [FromServices] IProfaneContentFilterService filterService) + { + if (request is null || string.IsNullOrWhiteSpace(request.Text)) + { + return Results.BadRequest($""" + You need to provide a valid request. + """); + } + + var parameters = new FilterParameters( + request.Strategy, FilterTarget.Body); + + var filterResult = + await filterService.FilterProfanityAsync(request.Text, parameters); + + var response = new ProfanityFilterResponse( + ContainsProfanity: filterResult.IsFiltered, + InputText: filterResult.Input, + FilteredText: filterResult.FinalOutput, + ReplacementStrategy: request.Strategy, + FiltrationSteps: [.. filterResult.Steps?.Where(static s => s.IsFiltered)], + Matches: [.. filterResult.Matches] + ); + + return TypedResults.Json( + response, + SourceGenerationContext.Default.ProfanityFilterResponse); + } + + private static IResult OnGetStrategies() => + TypedResults.Json([ + .. Enum.GetValues() + ], + SourceGenerationContext.Default.StrategyResponseArray + ); + + private static async Task OnGetDataNamesAsync( + [FromServices] IProfaneContentFilterService filterService) + { + var map = await filterService.ReadAllProfaneWordsAsync(); + + return TypedResults.Json([ + .. map.Keys.Select(static key => Path.GetFileNameWithoutExtension(key)) + ], + SourceGenerationContext.Default.StringArray); + } + + private static async Task OnGetDataByNameAsync( + [FromRoute] string name, + [FromServices] IProfaneContentFilterService filterService) + { + var map = await filterService.ReadAllProfaneWordsAsync(); + + foreach (var (key, value) in map) + { + if (Path.GetFileNameWithoutExtension(key) == name) + { + return TypedResults.Json([ + .. value.ProfaneWords + ], + SourceGenerationContext.Default.StringArray); + } + } + + return TypedResults.NotFound(); + } +} diff --git a/src/ProfanityFilter.WebApi/GlobalUsings.cs b/src/ProfanityFilter.WebApi/GlobalUsings.cs new file mode 100644 index 0000000..081e6f9 --- /dev/null +++ b/src/ProfanityFilter.WebApi/GlobalUsings.cs @@ -0,0 +1,19 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +global using System.Diagnostics.CodeAnalysis; + +global using System.Text.Json; +global using System.Text.Json.Serialization; + +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.HttpLogging; + +global using ProfanityFilter.Services; +global using ProfanityFilter.Services.Extensions; +global using ProfanityFilter.Services.Filters; +global using ProfanityFilter.Services.Results; + +global using ProfanityFilter.WebApi.Endpoints; +global using ProfanityFilter.WebApi.Models; +global using ProfanityFilter.WebApi.Serialization; diff --git a/src/ProfanityFilter.WebApi/Models/ProfanityFilterRequest.cs b/src/ProfanityFilter.WebApi/Models/ProfanityFilterRequest.cs new file mode 100644 index 0000000..f4561f9 --- /dev/null +++ b/src/ProfanityFilter.WebApi/Models/ProfanityFilterRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.WebApi.Models; + +/// +/// A representation of a profanity-filter request. +/// +/// The text to evaluate for profanity. +/// The desired replacement strategy to use. Defaults to *. +public sealed record class ProfanityFilterRequest( + string Text, + ReplacementStrategy Strategy = ReplacementStrategy.Asterisk); diff --git a/src/ProfanityFilter.WebApi/Models/ProfanityFilterResponse.cs b/src/ProfanityFilter.WebApi/Models/ProfanityFilterResponse.cs new file mode 100644 index 0000000..14b0341 --- /dev/null +++ b/src/ProfanityFilter.WebApi/Models/ProfanityFilterResponse.cs @@ -0,0 +1,27 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.WebApi.Models; + +/// +/// A representation of a profanity-filter response object. +/// +/// A boolean value indicating whether or +/// not the the contains profanity. +/// The original input text. +/// The final output text, after filters have been applied. +/// The replacement strategy used. +/// An array of steps representing the +/// filtration process, step-by-step. +public sealed record class ProfanityFilterResponse( + [property:MemberNotNullWhen( + true, + nameof(ProfanityFilterResponse.FilteredText), + nameof(ProfanityFilterResponse.FiltrationSteps), + nameof(ProfanityFilterResponse.Matches))] + bool ContainsProfanity, + string InputText, + string? FilteredText, + ReplacementStrategy ReplacementStrategy, + ProfanityFilterStep[]? FiltrationSteps = default, + string[]? Matches = default); diff --git a/src/ProfanityFilter.WebApi/Models/ProfanityFilterStep.cs b/src/ProfanityFilter.WebApi/Models/ProfanityFilterStep.cs new file mode 100644 index 0000000..ff71c0e --- /dev/null +++ b/src/ProfanityFilter.WebApi/Models/ProfanityFilterStep.cs @@ -0,0 +1,20 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.WebApi.Models; + +/// +/// A representation of a profanity-filter step, detailing +/// each step applied to a filter operation. +/// +/// The input before the step ran. +/// The output after the step ran. +/// The profane source data for the step. For example, Data/SwearWordList.txt. +public sealed record class ProfanityFilterStep( + string Input, + string Output, + string ProfaneSourceData) +{ + public static implicit operator ProfanityFilterStep(FilterStep step) => + new(step.Input, step.Output!, step.ProfaneSourceData); +} diff --git a/src/ProfanityFilter.WebApi/Models/StrategyResponse.cs b/src/ProfanityFilter.WebApi/Models/StrategyResponse.cs new file mode 100644 index 0000000..fb1aecc --- /dev/null +++ b/src/ProfanityFilter.WebApi/Models/StrategyResponse.cs @@ -0,0 +1,43 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.WebApi.Models; + +/// +/// A representation of a strategy response object. +/// +/// The name of the strategy. +/// The int value of the strategy. +/// The description of the strategy. +public sealed record class StrategyResponse( + string StrategyName, + int StrategyValue, + string Description) +{ + public static implicit operator StrategyResponse(ReplacementStrategy strategy) + { + var description = strategy switch + { + ReplacementStrategy.Asterisk => "Replaces the profanity word with asterisk.", + ReplacementStrategy.Emoji => "Replaces the profanity word with an emoji.", + ReplacementStrategy.AngerEmoji => "Replaces the profanity word with the one of the anger emoji.", + ReplacementStrategy.MiddleSwearEmoji => "Represents a replacement strategy where the middle of the swear word is replaced with an emoji.", + ReplacementStrategy.RandomAsterisk => "Represents a replacement strategy where the profanity is replaced with a random number of asterisk.", + ReplacementStrategy.MiddleAsterisk => "Represents the middle asterisk replacement strategy, which replaces the characters in the middle of the profanity with asterisk.", + ReplacementStrategy.FirstLetterThenAsterisk => "Represents the first letter then asterisk replacement strategy, which replaces everything after the first letter of the profanity with asterisk.", + ReplacementStrategy.VowelAsterisk => "Represents a replacement strategy where vowels in a profane wordare replaced with asterisk.", + ReplacementStrategy.Bleep => "Represents a replacement strategy where the profanity is replaced with athe word \"bleep\".", + ReplacementStrategy.RedactedRectangle => "Represents a replacement strategy where the profane word has each letterreplaced with the rectangle symbol .", + ReplacementStrategy.StrikeThrough => "Represents a replacement strategy where the profane word is ~~struck through~~.", + ReplacementStrategy.Underscores => "Represents a replacement strategy where the profane word is replaced by underscores.", + ReplacementStrategy.Grawlix => "Represents a replacement strategy where the profane word is replaced by grawlix, for example \"#$@!\".", + ReplacementStrategy.BoldGrawlix => "Represents a replacement strategy where the profane word is replaced by bold grawlix, for example \"#$@!\".", + _ => "", + }; + + return new StrategyResponse( + StrategyName: Enum.GetName(strategy) ?? strategy.ToString(), + StrategyValue: (int)strategy, + Description: description); + } +} diff --git a/src/ProfanityFilter.WebApi/ProfanityFilter.WebApi.csproj b/src/ProfanityFilter.WebApi/ProfanityFilter.WebApi.csproj new file mode 100644 index 0000000..3914f90 --- /dev/null +++ b/src/ProfanityFilter.WebApi/ProfanityFilter.WebApi.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + linux-x64 + True + c381930b-4a15-4c4d-8d19-b5070cbb9fd6 + Linux + ..\.. + + + + + + + + + + 8081 + + + + + + + + diff --git a/src/ProfanityFilter.WebApi/Program.cs b/src/ProfanityFilter.WebApi/Program.cs new file mode 100644 index 0000000..dbdc0da --- /dev/null +++ b/src/ProfanityFilter.WebApi/Program.cs @@ -0,0 +1,26 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +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.Services.AddProfanityFilterServices(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.MapProfanityFilterEndpoints(); + +app.Run(); diff --git a/src/ProfanityFilter.WebApi/Serialization/SourceGeneratorContext.cs b/src/ProfanityFilter.WebApi/Serialization/SourceGeneratorContext.cs new file mode 100644 index 0000000..e7de915 --- /dev/null +++ b/src/ProfanityFilter.WebApi/Serialization/SourceGeneratorContext.cs @@ -0,0 +1,20 @@ +// Copyright (c) David Pine. All rights reserved. +// Licensed under the MIT License. + +namespace ProfanityFilter.WebApi.Serialization; + +[JsonSourceGenerationOptions( + defaults: JsonSerializerDefaults.Web, + WriteIndented = true, + UseStringEnumConverter = true, + AllowTrailingCommas = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PropertyNameCaseInsensitive = false, + IncludeFields = true)] +[JsonSerializable(typeof(ProfanityFilterRequest))] +[JsonSerializable(typeof(ProfanityFilterResponse))] +[JsonSerializable(typeof(StrategyResponse))] +[JsonSerializable(typeof(StrategyResponse[]))] +internal partial class SourceGenerationContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/src/ProfanityFilter.WebApi/appsettings.Development.json b/src/ProfanityFilter.WebApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/ProfanityFilter.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ProfanityFilter.WebApi/appsettings.json b/src/ProfanityFilter.WebApi/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/ProfanityFilter.WebApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tests/ProfanityFilter.Action.Tests/ProfanityFilter.Action.Tests.csproj b/tests/ProfanityFilter.Action.Tests/ProfanityFilter.Action.Tests.csproj index b5ac64a..b7161ce 100644 --- a/tests/ProfanityFilter.Action.Tests/ProfanityFilter.Action.Tests.csproj +++ b/tests/ProfanityFilter.Action.Tests/ProfanityFilter.Action.Tests.csproj @@ -16,7 +16,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/ProfanityFilter.Action.Tests/TestProfanityFilterService.cs b/tests/ProfanityFilter.Action.Tests/TestProfanityFilterService.cs index b83122c..cccaf22 100644 --- a/tests/ProfanityFilter.Action.Tests/TestProfanityFilterService.cs +++ b/tests/ProfanityFilter.Action.Tests/TestProfanityFilterService.cs @@ -9,4 +9,9 @@ public ValueTask FilterProfanityAsync(string content, FilterParame { throw new NotImplementedException(); } + + public Task> ReadAllProfaneWordsAsync() + { + throw new NotImplementedException(); + } } diff --git a/tests/ProfanityFilter.Services.Tests/ProfanityFilter.Services.Tests.csproj b/tests/ProfanityFilter.Services.Tests/ProfanityFilter.Services.Tests.csproj index 716b401..d6d9a30 100644 --- a/tests/ProfanityFilter.Services.Tests/ProfanityFilter.Services.Tests.csproj +++ b/tests/ProfanityFilter.Services.Tests/ProfanityFilter.Services.Tests.csproj @@ -16,7 +16,10 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive +