DRAFT: nazarovsa/persistence (#2)

Reviewed-on: #2
Co-authored-by: Sergey Nazarov <insight.appdev@gmail.com>
Co-committed-by: Sergey Nazarov <insight.appdev@gmail.com>
This commit is contained in:
Sergey Nazarov 2024-03-29 13:24:04 +00:00 committed by nazarovsa
parent 1f5cc40e54
commit ced8c15efb
40 changed files with 600 additions and 234 deletions

View File

@ -24,8 +24,15 @@
<PackageVersion Include="Rebus.Serilog" Version="8.0.0" />
</ItemGroup>
<ItemGroup Label="Microsoft">
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="$(MicrosoftVersion)" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="$(MicrosoftVersion)" />
</ItemGroup>
<ItemGroup Label="EntityFramework">
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
</ItemGroup>
<ItemGroup Label="Tests">
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />

View File

@ -22,6 +22,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{6E4D9F
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nocr.TextMatcher.AppServices.UnitTests", "tests\Nocr.TextMatcher.AppServices.UnitTests\Nocr.TextMatcher.AppServices.UnitTests.csproj", "{B721E055-84AF-44C6-973D-33241FD2EA7C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nocr.TextMatcher.Contracts", "src\Nocr.TextMatcher.Contracts\Nocr.TextMatcher.Contracts.csproj", "{D6EC25FA-B8B9-4B21-BC4B-16E604B16E02}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nocr.TextMatcher.Persistence", "src\Nocr.TextMatcher.Persistence\Nocr.TextMatcher.Persistence.csproj", "{D578EE54-B55A-4B45-859D-7F343C73EEF5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Contracts", "Contracts", "{0B8E28B3-EECC-4981-A87F-6D74C4F23371}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nocr.TextMatcher.Domain", "src\Nocr.TextMatcher.Domain\Nocr.TextMatcher.Domain.csproj", "{301BBDEA-ACF8-404D-83FA-AA26A8153D35}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -52,8 +60,23 @@ Global
{B721E055-84AF-44C6-973D-33241FD2EA7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B721E055-84AF-44C6-973D-33241FD2EA7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B721E055-84AF-44C6-973D-33241FD2EA7C}.Release|Any CPU.Build.0 = Release|Any CPU
{D6EC25FA-B8B9-4B21-BC4B-16E604B16E02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D6EC25FA-B8B9-4B21-BC4B-16E604B16E02}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D6EC25FA-B8B9-4B21-BC4B-16E604B16E02}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D6EC25FA-B8B9-4B21-BC4B-16E604B16E02}.Release|Any CPU.Build.0 = Release|Any CPU
{D578EE54-B55A-4B45-859D-7F343C73EEF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D578EE54-B55A-4B45-859D-7F343C73EEF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D578EE54-B55A-4B45-859D-7F343C73EEF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D578EE54-B55A-4B45-859D-7F343C73EEF5}.Release|Any CPU.Build.0 = Release|Any CPU
{301BBDEA-ACF8-404D-83FA-AA26A8153D35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{301BBDEA-ACF8-404D-83FA-AA26A8153D35}.Debug|Any CPU.Build.0 = Debug|Any CPU
{301BBDEA-ACF8-404D-83FA-AA26A8153D35}.Release|Any CPU.ActiveCfg = Release|Any CPU
{301BBDEA-ACF8-404D-83FA-AA26A8153D35}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B721E055-84AF-44C6-973D-33241FD2EA7C} = {6E4D9F75-861F-4C00-A5C8-00D1BEE5A659}
{4028666B-FAE8-4DB9-8D10-B00F5F31BDCC} = {0B8E28B3-EECC-4981-A87F-6D74C4F23371}
{A6332064-40EE-498A-826D-638BC295A185} = {0B8E28B3-EECC-4981-A87F-6D74C4F23371}
{D6EC25FA-B8B9-4B21-BC4B-16E604B16E02} = {0B8E28B3-EECC-4981-A87F-6D74C4F23371}
EndGlobalSection
EndGlobal

View File

@ -8,4 +8,8 @@
<PackageReference Include="RestEase" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Nocr.TextMatcher.Contracts\Nocr.TextMatcher.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@ -1,6 +1,8 @@
using Nocr.TextMatcher.Contracts;
namespace Nocr.TextMatcher.Api.Contracts.TextMatches.Dto;
public sealed class TextMatchData
public sealed class TextSubscriptionData
{
public long Id { get; set; }
@ -27,7 +29,7 @@ public sealed class TextMatchData
/// <summary>
/// Тип матча
/// </summary>
public TextMatchRule Rule { get; set; }
public TextSubscriptionRule Rule { get; set; }
/// <summary>
/// Дата создания

View File

@ -1,27 +0,0 @@
using Nocr.TextMatcher.Api.Contracts.TextMatches.Dto;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Requests;
using RestEase;
namespace Nocr.TextMatcher.Api.Contracts.TextMatches;
[BasePath(WebRoutes.TextMatches.Path)]
public interface ITextMatchesController
{
[Post]
Task<long> Create([Body] CreateTextMatchRequest request, CancellationToken cancellationToken = default);
[Get(WebRoutes.TextMatches.ById)]
Task<TextMatchData?> GetById([Path] long id, CancellationToken cancellationToken = default);
[Get(WebRoutes.TextMatches.ByUserId)]
Task<TextMatchData[]> GetByUserId([Path] long userId, CancellationToken cancellationToken = default);
[Delete(WebRoutes.TextMatches.ById)]
public Task Delete([Path] long id, CancellationToken cancellationToken = default);
[Patch(WebRoutes.TextMatches.Activate)]
Task Activate([Path] long id, CancellationToken cancellationToken = default);
[Patch(WebRoutes.TextMatches.Disable)]
Task Disable([Path] long id, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,27 @@
using Nocr.TextMatcher.Api.Contracts.TextMatches.Dto;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Requests;
using RestEase;
namespace Nocr.TextMatcher.Api.Contracts.TextMatches;
[BasePath(WebRoutes.TextSubscriptions.Path)]
public interface ITextSubscriptionsController
{
[Post]
Task<long> Create([Body] CreateTextSubscriptionRequest request, CancellationToken cancellationToken = default);
[Get(WebRoutes.TextSubscriptions.ById)]
Task<TextSubscriptionData?> GetById([Path] long id, CancellationToken cancellationToken = default);
[Get(WebRoutes.TextSubscriptions.ByUserId)]
Task<TextSubscriptionData[]> GetByUserId([Path] long userId, CancellationToken cancellationToken = default);
[Delete(WebRoutes.TextSubscriptions.ById)]
public Task Delete([Path] long id, CancellationToken cancellationToken = default);
[Patch(WebRoutes.TextSubscriptions.Activate)]
Task Activate([Path] long id, CancellationToken cancellationToken = default);
[Patch(WebRoutes.TextSubscriptions.Disable)]
Task Disable([Path] long id, CancellationToken cancellationToken = default);
}

View File

@ -1,6 +1,8 @@
using Nocr.TextMatcher.Contracts;
namespace Nocr.TextMatcher.Api.Contracts.TextMatches.Requests;
public class CreateTextMatchRequest
public class CreateTextSubscriptionRequest
{
/// <summary>
/// Идентификатор пользователя в системе
@ -20,5 +22,5 @@ public class CreateTextMatchRequest
/// <summary>
/// Тип совпадения
/// </summary>
public TextMatchRule Rule { get; set; }
public TextSubscriptionRule Rule { get; set; }
}

View File

@ -4,7 +4,7 @@ public static class WebRoutes
{
public const string BasePath = "/api";
public static class TextMatches
public static class TextSubscriptions
{
public const string Path = BasePath + "/" + "text-matches";

View File

@ -0,0 +1,11 @@
namespace Nocr.TextMatcher.AppServices;
public static class Constants
{
public static class RoutingKeys
{
public const string MatchedSubscriptions = "nocr.text.matcher.matched";
public const string Subscriptions = "nocr.text.matcher.subscriptions";
}
}

View File

@ -15,6 +15,8 @@
<ProjectReference Include="..\Nocr.TextMatcher.Api.Contracts\Nocr.TextMatcher.Api.Contracts.csproj" />
<ProjectReference Include="..\Nocr.TextMatcher.Async.Api.Contracts\Nocr.TextMatcher.Async.Api.Contracts.csproj" />
<ProjectReference Include="..\Nocr.TextMatcher.Core\Nocr.TextMatcher.Core.csproj" />
<ProjectReference Include="..\Nocr.TextMatcher.Persistence\Nocr.TextMatcher.Persistence.csproj" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Nocr.TextMatcher.AppServices.TextMatchers;
using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
using Nocr.TextMatcher.AppServices.TextMatches.Services;
using Nocr.TextMatcher.AppServices.TextSubscriptions;
using Rebus.Config;
namespace Nocr.TextMatcher.AppServices;
@ -15,8 +15,8 @@ public static class ServiceCollectionExtensions
// Add registrations here
services.AddRebusHandler<MessageReceivedHandler>();
services.AddScoped<ITextMatchService, TextMatchService>();
services.AddSingleton<ITextMatchRepository, InMemoryTextMatchRepository>();
services.AddScoped<ITextSubscriptionsService, TextSubscriptionsService>();
services.AddScoped<ITextSubscriptionRepository, TextSubscriptionRepository>();
return services;
}

View File

@ -1,16 +0,0 @@
namespace Nocr.TextMatcher.AppServices.TextMatches.Repositories;
public interface ITextMatchRepository
{
Task<long> Create(TextMatch textMatch, CancellationToken cancellationToken = default);
Task Delete(long id, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextMatch>> Get(CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextMatch>> GetByUserId(long userId, CancellationToken cancellationToken = default);
Task<TextMatch?> GetById(long id, CancellationToken cancellationToken = default);
Task Update(TextMatch textMatch, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,16 @@
namespace Nocr.TextMatcher.AppServices.TextMatches.Repositories;
public interface ITextSubscriptionRepository
{
Task<long> Create(TextSubscription textSubscription, CancellationToken cancellationToken = default);
Task Delete(long id, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextSubscription>> Get(CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextSubscription>> GetByUserId(long userId, CancellationToken cancellationToken = default);
Task<TextSubscription?> GetById(long id, CancellationToken cancellationToken = default);
Task Update(TextSubscription subscription, CancellationToken cancellationToken = default);
}

View File

@ -1,61 +0,0 @@
using Nocr.TextMatcher.Api.Contracts.TextMatches;
namespace Nocr.TextMatcher.AppServices.TextMatches.Repositories;
public sealed class InMemoryTextMatchRepository : ITextMatchRepository
{
private long _id = 0;
private List<TextMatch> _textMatches = new List<TextMatch>();
public InMemoryTextMatchRepository()
{
var seed = new[]
{
TextMatch.Initialize(1, "baraholka_tbi", "телевизор", TextMatchRule.Full, DateTimeOffset.UtcNow),
TextMatch.Initialize(1, "baraholka_tbi", "macbook mac", TextMatchRule.AnyWord, DateTimeOffset.UtcNow),
TextMatch.Initialize(1, "baraholka_tbi", "гитар", TextMatchRule.Full, DateTimeOffset.UtcNow),
TextMatch.Initialize(1, "baraholka_tbi", "обувь ботинки туфли", TextMatchRule.AnyWord, DateTimeOffset.UtcNow),
TextMatch.Initialize(1, "baraholka_tbi", "одежда платья брюки рубашка рубашки", TextMatchRule.AnyWord, DateTimeOffset.UtcNow),
};
_textMatches.AddRange(seed);
}
public Task<long> Create(TextMatch textMatch, CancellationToken cancellationToken = default)
{
var id = Interlocked.Increment(ref _id);
textMatch.Id = id;
_textMatches.Add(textMatch);
return Task.FromResult(id);
}
public Task Delete(long id, CancellationToken cancellationToken = default)
{
var index = _textMatches.FindIndex(x => x.Id == id);
_textMatches.RemoveAt(index);
return Task.CompletedTask;
}
public Task<IReadOnlyCollection<TextMatch>> Get(CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyCollection<TextMatch>>(_textMatches);
}
public Task<IReadOnlyCollection<TextMatch>> GetByUserId(long userId, CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyCollection<TextMatch>>(_textMatches.Where(x => x.UserId == userId).ToArray());
}
public Task<TextMatch?> GetById(long id, CancellationToken cancellationToken)
{
return Task.FromResult(_textMatches.FirstOrDefault(x => x.Id == id));
}
public Task Update(TextMatch textMatch, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Nocr.TextMatcher.Persistence;
namespace Nocr.TextMatcher.AppServices.TextMatches.Repositories;
public sealed class TextSubscriptionRepository : ITextSubscriptionRepository
{
private readonly TextMatcherContext _db;
public TextSubscriptionRepository(TextMatcherContext db)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
}
public async Task<long> Create(TextSubscription textSubscription, CancellationToken cancellationToken = default)
{
await _db.TextSubscriptions.AddAsync(textSubscription, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
return textSubscription.Id;
}
public async Task Delete(long id, CancellationToken cancellationToken = default)
{
await _db.TextSubscriptions.Where(x => x.Id == id).ExecuteDeleteAsync(cancellationToken);
}
public async Task<IReadOnlyCollection<TextSubscription>> Get(CancellationToken cancellationToken = default)
{
return await _db.TextSubscriptions.AsNoTracking().ToListAsync(cancellationToken);
}
public async Task<IReadOnlyCollection<TextSubscription>> GetByUserId(long userId, CancellationToken cancellationToken = default)
{
return await _db.TextSubscriptions.Where(x => x.UserId == userId).AsNoTracking().ToListAsync(cancellationToken);
}
public async Task<TextSubscription?> GetById(long id, CancellationToken cancellationToken = default)
{
return await _db.TextSubscriptions.FirstOrDefaultAsync(x => x.Id == id, cancellationToken: cancellationToken);
}
public async Task Update(TextSubscription subscription, CancellationToken cancellationToken = default)
{
_db.TextSubscriptions.Update(subscription);
await _db.SaveChangesAsync(cancellationToken);
}
}

View File

@ -1,14 +1,14 @@
using Nocr.TextMatcher.Api.Contracts.TextMatches;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Dto;
using Nocr.TextMatcher.Contracts;
namespace Nocr.TextMatcher.AppServices.TextMatches.Services;
public interface ITextMatchService
public interface ITextSubscriptionsService
{
Task<long> Create(long userId, string chatUsername, string template, TextMatchRule rule,
Task<long> Create(long userId, string chatUsername, string template, TextSubscriptionRule rule,
CancellationToken cancellationToken = default);
Task<TextMatchData?> GetById(long id, CancellationToken cancellationToken = default);
Task<TextSubscriptionData?> GetById(long id, CancellationToken cancellationToken = default);
Task Delete(long id, CancellationToken cancellationToken = default);
@ -16,7 +16,7 @@ public interface ITextMatchService
Task Disable(long id, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextMatchData>> Get(CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextSubscriptionData>> Get(CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextMatchData>> GetByUserId(long userId, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<TextSubscriptionData>> GetByUserId(long userId, CancellationToken cancellationToken = default);
}

View File

@ -1,19 +1,19 @@
using Nocr.TextMatcher.Api.Contracts.TextMatches;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Dto;
using Nocr.TextMatcher.AppServices.TextMatches.Repositories;
using Nocr.TextMatcher.Async.Api.Contracts;
using Nocr.TextMatcher.Contracts;
using Nocr.TextMatcher.Core.Dates;
using Rebus.Bus;
namespace Nocr.TextMatcher.AppServices.TextMatches.Services;
public sealed class TextMatchService : ITextMatchService
public sealed class TextSubscriptionsService : ITextSubscriptionsService
{
private readonly IBus _bus;
private readonly ITextMatchRepository _repository;
private readonly ITextSubscriptionRepository _repository;
private readonly ICurrentDateProvider _dateProvider;
public TextMatchService(IBus bus, ITextMatchRepository repository,
public TextSubscriptionsService(IBus bus, ITextSubscriptionRepository repository,
ICurrentDateProvider dateProvider)
{
_bus = bus ?? throw new ArgumentNullException(nameof(bus));
@ -21,24 +21,23 @@ public sealed class TextMatchService : ITextMatchService
_dateProvider = dateProvider ?? throw new ArgumentNullException(nameof(dateProvider));
}
public async Task<long> Create(long userId, string chatUsername, string template, TextMatchRule rule,
public async Task<long> Create(long userId, string chatUsername, string template, TextSubscriptionRule rule,
CancellationToken cancellationToken = default)
{
var textMatch = TextMatch.Initialize(userId, chatUsername, template, rule, _dateProvider.UtcNow);
var textMatch = TextSubscription.Initialize(userId, chatUsername, template, rule, _dateProvider.UtcNow);
await _repository.Create(textMatch, cancellationToken);
var @event = new TextMatchCreated
var @event = new TextSubscriptionCreated
{
ChatUsername = textMatch.ChatUsername
};
// TODO:
await _bus.Advanced.Topics.Publish("nocr.text.matcher.matches", @event);
await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.Subscriptions, @event);
return textMatch.Id;
}
public async Task<TextMatchData?> GetById(long id, CancellationToken cancellationToken)
public async Task<TextSubscriptionData?> GetById(long id, CancellationToken cancellationToken)
{
var textMatch = await _repository.GetById(id, cancellationToken);
if (textMatch == null)
@ -78,31 +77,31 @@ public sealed class TextMatchService : ITextMatchService
await _repository.Update(textMatch, cancellationToken);
}
public async Task<IReadOnlyCollection<TextMatchData>> Get(CancellationToken cancellationToken = default)
public async Task<IReadOnlyCollection<TextSubscriptionData>> Get(CancellationToken cancellationToken = default)
{
var matches = await _repository.Get(cancellationToken);
return matches.Select(MapToTextMatchData).ToArray();
}
public async Task<IReadOnlyCollection<TextMatchData>> GetByUserId(long userId,
public async Task<IReadOnlyCollection<TextSubscriptionData>> GetByUserId(long userId,
CancellationToken cancellationToken = default)
{
var matches = await _repository.GetByUserId(userId, cancellationToken);
return matches.Select(MapToTextMatchData).ToArray();
}
private TextMatchData MapToTextMatchData(TextMatch textMatch)
private TextSubscriptionData MapToTextMatchData(TextSubscription textSubscription)
{
return new TextMatchData
return new TextSubscriptionData
{
Id = textMatch.Id,
ChatUsername = textMatch.ChatUsername,
Active = textMatch.Active,
Template = textMatch.Template,
UserId = textMatch.UserId,
Rule = textMatch.Rule,
CreatedDateTime = textMatch.CreatedDateTime
Id = textSubscription.Id,
ChatUsername = textSubscription.ChatUsername,
Active = textSubscription.Active,
Template = textSubscription.Template,
UserId = textSubscription.UserId,
Rule = textSubscription.Rule,
CreatedDateTime = textSubscription.CreatedDateTime
};
}
}

View File

@ -6,23 +6,23 @@ using Nocr.TextMatcher.Core.Dates;
using Rebus.Bus;
using Rebus.Handlers;
namespace Nocr.TextMatcher.AppServices.TextMatchers;
namespace Nocr.TextMatcher.AppServices.TextSubscriptions;
public sealed class MessageReceivedHandler : IHandleMessages<MessageReceived>
{
private readonly ILogger<MessageReceivedHandler> _logger;
private readonly IBus _bus;
private readonly ITextMatchRepository _textMatchService;
private readonly ITextSubscriptionRepository _textSubscriptionService;
private readonly ICurrentDateProvider _dateProvider;
public MessageReceivedHandler(ILogger<MessageReceivedHandler> logger,
IBus bus,
ITextMatchRepository textMatchRepository,
ITextSubscriptionRepository textSubscriptionRepository,
ICurrentDateProvider dateProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_bus = bus ?? throw new ArgumentNullException(nameof(bus));
_textMatchService = textMatchRepository ?? throw new ArgumentNullException(nameof(textMatchRepository));
_textSubscriptionService = textSubscriptionRepository ?? throw new ArgumentNullException(nameof(textSubscriptionRepository));
_dateProvider = dateProvider ?? throw new ArgumentNullException(nameof(dateProvider));
}
@ -30,17 +30,17 @@ public sealed class MessageReceivedHandler : IHandleMessages<MessageReceived>
{
_logger.LogInformation("Received message: {@Message}", message);
var matches = await _textMatchService.Get();
var matches = await _textSubscriptionService.Get();
foreach (var match in matches.Where(x => x.Active))
{
if (match.IsMatches(message.ChatUsername, message.Text))
{
_logger.LogInformation("Message {@Message} matched {@Match}", message, match);
var @event = new TextMatchMatched
var @event = new TextSubscriptionMatched
{
MatchId = match.Id,
MatchUserId = match.UserId,
SubscriptionId = match.Id,
SubscriptionUserId = match.UserId,
ChatUsername = match.ChatUsername,
Rule = (int)match.Rule,
Template = match.Template,
@ -49,9 +49,8 @@ public sealed class MessageReceivedHandler : IHandleMessages<MessageReceived>
OccuredDateTime = message.OccuredDateTime,
PublishedDateTime = _dateProvider.UtcNow
};
// TODO:
await _bus.Advanced.Topics.Publish("nocr.text.matcher.matched", @event);
await _bus.Advanced.Topics.Publish(Constants.RoutingKeys.MatchedSubscriptions, @event);
}
}
}

View File

@ -1,6 +1,6 @@
namespace Nocr.TextMatcher.Async.Api.Contracts;
public class TextMatchCreated : IEvent
public class TextSubscriptionCreated : IEvent
{
public Guid Id => Guid.NewGuid();

View File

@ -1,18 +1,18 @@
namespace Nocr.TextMatcher.Async.Api.Contracts;
public class TextMatchMatched : IEvent
public class TextSubscriptionMatched : IEvent
{
public Guid Id => Guid.NewGuid();
/// <summary>
/// Идентификатор матча
/// </summary>
public long MatchId { get; set; }
public long SubscriptionId { get; set; }
/// <summary>
/// Идентификатор владельца матча
/// </summary>
public long MatchUserId { get; set; }
public long SubscriptionUserId { get; set; }
/// <summary>
/// Username чата

View File

@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>true</IsPackable>
</PropertyGroup>
</Project>

View File

@ -1,6 +1,6 @@
namespace Nocr.TextMatcher.Api.Contracts.TextMatches;
namespace Nocr.TextMatcher.Contracts;
public enum TextMatchRule
public enum TextSubscriptionRule
{
// Substring
Full = 1,

View File

@ -2,11 +2,28 @@ namespace Nocr.TextMatcher.Core.Options;
public sealed class RebusRabbitMqOptions
{
/// <summary>
/// Строка подключение
/// </summary>
public string ConnectionString { get; set; }
/// <summary>
/// Имя входящей очереди
/// </summary>
public string InputQueueName { get; set; }
/// <summary>
/// Имя direct exchange
/// </summary>
public string DirectExchangeName { get; set; }
/// <summary>
/// Имя topics exchange
/// </summary>
public string TopicsExchangeName { get; set; }
/// <summary>
/// Список подписок
/// </summary>
public string[] Subscriptions { get; set; } = Array.Empty<string>();
}

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Nocr.TextMatcher.Contracts\Nocr.TextMatcher.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@ -1,9 +1,9 @@
using System.Text.RegularExpressions;
using Nocr.TextMatcher.Api.Contracts.TextMatches;
using Nocr.TextMatcher.Contracts;
namespace Nocr.TextMatcher.AppServices.TextMatches;
public sealed class TextMatch
public sealed class TextSubscription
{
public long Id { get; set; }
@ -13,16 +13,16 @@ public sealed class TextMatch
public string Template { get; private set; }
public TextMatchRule Rule { get; private set; }
public TextSubscriptionRule Rule { get; private set; }
public bool Active { get; private set; }
public DateTimeOffset CreatedDateTime { get; private set; }
private TextMatch(long userId,
private TextSubscription(long userId,
string chatUsername,
string template,
TextMatchRule rule,
TextSubscriptionRule rule,
DateTimeOffset createdDateTime,
bool active)
{
@ -34,10 +34,10 @@ public sealed class TextMatch
Active = active;
}
public static TextMatch Initialize(long userId,
public static TextSubscription Initialize(long userId,
string chatUsername,
string template,
TextMatchRule rule,
TextSubscriptionRule rule,
DateTimeOffset createdDateTime)
{
if (userId <= 0)
@ -52,14 +52,14 @@ public sealed class TextMatch
if (chatUsername.StartsWith("@"))
throw new ArgumentException("Chat username should be without @", nameof(chatUsername));
return new TextMatch(userId, chatUsername, template, rule, createdDateTime, true);
return new TextSubscription(userId, chatUsername, template, rule, createdDateTime, true);
}
public void Disable()
{
if (!Active)
{
throw new InvalidOperationException("Failed to disable inactive match");
throw new InvalidOperationException("Failed to disable inactive subscription");
}
Active = false;
@ -69,7 +69,7 @@ public sealed class TextMatch
{
if (Active)
{
throw new InvalidOperationException("Failed to activate inactive match");
throw new InvalidOperationException("Failed to activate inactive subscription");
}
Active = true;
@ -82,12 +82,12 @@ public sealed class TextMatch
switch (Rule)
{
case TextMatchRule.Full:
case TextSubscriptionRule.Full:
return text.Contains(Template, StringComparison.OrdinalIgnoreCase);
case TextMatchRule.AnyWord:
case TextSubscriptionRule.AnyWord:
var anyWords = Regex.Split(Template, @"\s+");
return anyWords.Any(word => text.Contains(word, StringComparison.OrdinalIgnoreCase));
case TextMatchRule.AllWords:
case TextSubscriptionRule.AllWords:
var allWords = Regex.Split(Template, @"\s+");
return allWords.All(word => text.Contains(word, StringComparison.OrdinalIgnoreCase));
default:

View File

@ -1,57 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Nocr.TextMatcher.Api.Contracts;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Dto;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Requests;
using Nocr.TextMatcher.AppServices.TextMatches.Services;
namespace Nocr.TextMatcher.Host.Controllers;
[ApiController]
[Route(WebRoutes.TextMatches.Path)]
public class TextMatchController : ControllerBase
{
private readonly ITextMatchService _textMatchService;
public TextMatchController(ITextMatchService textMatchService)
{
_textMatchService = textMatchService ?? throw new ArgumentNullException(nameof(textMatchService));
}
[HttpPost]
public Task<long> Create([FromBody] CreateTextMatchRequest request, CancellationToken cancellationToken = default)
{
return _textMatchService.Create(request.UserId, request.ChatUsername, request.Template, request.Rule, cancellationToken);
}
[HttpGet(WebRoutes.TextMatches.ById)]
public Task<TextMatchData?> GetById([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textMatchService.GetById(id, cancellationToken);
}
[HttpDelete(WebRoutes.TextMatches.ById)]
public Task Delete([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textMatchService.Delete(id, cancellationToken);
}
[HttpGet(WebRoutes.TextMatches.ByUserId)]
public Task<IReadOnlyCollection<TextMatchData>> GetByUserId([FromRoute] long userId,
CancellationToken cancellationToken = default)
{
return _textMatchService.GetByUserId(userId, cancellationToken);
}
[HttpPatch(WebRoutes.TextMatches.Activate)]
public Task Activate([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textMatchService.Activate(id, cancellationToken);
}
[HttpPatch(WebRoutes.TextMatches.Disable)]
public Task Disable([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textMatchService.Disable(id, cancellationToken);
}
}

View File

@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Mvc;
using Nocr.TextMatcher.Api.Contracts;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Dto;
using Nocr.TextMatcher.Api.Contracts.TextMatches.Requests;
using Nocr.TextMatcher.AppServices.TextMatches.Services;
namespace Nocr.TextMatcher.Host.Controllers;
[ApiController]
[Route(WebRoutes.TextSubscriptions.Path)]
public class TextSubscriptionsController : ControllerBase
{
private readonly ITextSubscriptionsService _textSubscriptionsService;
public TextSubscriptionsController(ITextSubscriptionsService textSubscriptionsService)
{
_textSubscriptionsService = textSubscriptionsService ?? throw new ArgumentNullException(nameof(textSubscriptionsService));
}
[HttpPost]
public Task<long> Create([FromBody] CreateTextSubscriptionRequest request, CancellationToken cancellationToken = default)
{
return _textSubscriptionsService.Create(request.UserId, request.ChatUsername, request.Template, request.Rule, cancellationToken);
}
[HttpGet(WebRoutes.TextSubscriptions.ById)]
public Task<TextSubscriptionData?> GetById([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textSubscriptionsService.GetById(id, cancellationToken);
}
[HttpDelete(WebRoutes.TextSubscriptions.ById)]
public Task Delete([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textSubscriptionsService.Delete(id, cancellationToken);
}
[HttpGet(WebRoutes.TextSubscriptions.ByUserId)]
public Task<IReadOnlyCollection<TextSubscriptionData>> GetByUserId([FromRoute] long userId,
CancellationToken cancellationToken = default)
{
return _textSubscriptionsService.GetByUserId(userId, cancellationToken);
}
[HttpPatch(WebRoutes.TextSubscriptions.Activate)]
public Task Activate([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textSubscriptionsService.Activate(id, cancellationToken);
}
[HttpPatch(WebRoutes.TextSubscriptions.Disable)]
public Task Disable([FromRoute] long id, CancellationToken cancellationToken = default)
{
return _textSubscriptionsService.Disable(id, cancellationToken);
}
}

View File

@ -1,7 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Nocr.TextMatcher.AppServices;
using Nocr.TextMatcher.Core.Dates;
using Nocr.TextMatcher.Core.Options;
using Nocr.TextMatcher.Persistence;
using Rebus.Bus;
using Rebus.Config;
using Rebus.Routing.TypeBased;
@ -28,6 +30,15 @@ public class Startup
services.AddSwaggerGen();
services.AddAppServices();
services.AddDbContext<TextMatcherContext>(
(ctx, context) =>
{
context.UseMySql(Configuration.GetConnectionString(nameof(TextMatcherContext)),
new MariaDbServerVersion(MariaDbServerVersion.LatestSupportedServerVersion),
builder => builder.MigrationsAssembly(typeof(TextMatcherContext).Assembly.FullName))
.UseLoggerFactory(ctx.GetRequiredService<ILoggerFactory>());
}
);
services.Configure<RebusRabbitMqOptions>(Configuration.GetSection(nameof(RebusRabbitMqOptions)));
services.AddRebus((builder, ctx) =>
@ -54,7 +65,11 @@ public class Startup
app.UseRouting();
app.UseEndpoints(builder => builder.MapControllers());
var rabbitMqOptions = app.ApplicationServices.GetRequiredService<IOptions<RebusRabbitMqOptions>>();
var bus = app.ApplicationServices.GetRequiredService<IBus>();
bus.Advanced.Topics.Subscribe("nocr.telegram.listener").GetAwaiter().GetResult();
foreach (var subscription in rabbitMqOptions.Value.Subscriptions)
{
bus.Advanced.Topics.Subscribe(subscription).GetAwaiter().GetResult();
}
}
}

View File

@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Nocr.TextMatcher.Api.Contracts\Nocr.TextMatcher.Api.Contracts.csproj" />
<ProjectReference Include="..\Nocr.TextMatcher.AppServices\Nocr.TextMatcher.AppServices.csproj" />
<ProjectReference Include="..\Nocr.TextMatcher.Persistence\Nocr.TextMatcher.Persistence.csproj" />
</ItemGroup>
<ItemGroup>

View File

@ -11,5 +11,8 @@
},
"RebusRabbitMqOptions": {
"ConnectionString": "amqp://admin:admin@localhost:5672/"
},
"ConnectionStrings": {
"TextMatcherContext": "server=localhost;port=3306;database=nocr_text_matcher;uid=root;pwd=toor"
}
}

View File

@ -13,6 +13,9 @@
"RebusRabbitMqOptions": {
"InputQueueName": "nocr.text.matcher.queue",
"DirectExchangeName": "nocr.direct",
"TopicsExchangeName": "nocr.topics"
"TopicsExchangeName": "nocr.topics",
"Subscriptions": [
"nocr.telegram.listener"
]
}
}

View File

@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace Nocr.TextMatcher.Persistence;
public class DesignTimeTextMatcherContextFactory : IDesignTimeDbContextFactory<TextMatcherContext>
{
public TextMatcherContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<TextMatcherContext>();
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
var connectionString = configuration.GetConnectionString("MariaLocal");
optionsBuilder.UseMySql(connectionString,
new MariaDbServerVersion(MariaDbServerVersion.LatestSupportedServerVersion));
return new TextMatcherContext(optionsBuilder.Options);
}
}

View File

@ -0,0 +1,65 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nocr.TextMatcher.Persistence;
#nullable disable
namespace Nocr.TextMatcher.Persistence.Migrations
{
[DbContext(typeof(TextMatcherContext))]
[Migration("20240328201810_InitialMigration")]
partial class InitialMigration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextMatches.TextSubscription", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("ChatUsername")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTimeOffset>("CreatedDateTime")
.HasColumnType("datetime(6)");
b.Property<int>("Rule")
.HasColumnType("int");
b.Property<string>("Template")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("TextSubscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nocr.TextMatcher.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "TextSubscriptions",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
ChatUsername = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Template = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Rule = table.Column<int>(type: "int", nullable: false),
Active = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedDateTime = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TextSubscriptions", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TextSubscriptions");
}
}
}

View File

@ -0,0 +1,62 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nocr.TextMatcher.Persistence;
#nullable disable
namespace Nocr.TextMatcher.Persistence.Migrations
{
[DbContext(typeof(TextMatcherContext))]
partial class TextMatcherContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("Nocr.TextMatcher.AppServices.TextMatches.TextSubscription", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
b.Property<bool>("Active")
.HasColumnType("tinyint(1)");
b.Property<string>("ChatUsername")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<DateTimeOffset>("CreatedDateTime")
.HasColumnType("datetime(6)");
b.Property<int>("Rule")
.HasColumnType("int");
b.Property<string>("Template")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("TextSubscriptions");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Nocr.TextMatcher.Domain\Nocr.TextMatcher.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="appsettings.json" />
<Content Include="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Nocr.TextMatcher.AppServices.TextMatches;
namespace Nocr.TextMatcher.Persistence;
public class TextMatcherContext : DbContext
{
public DbSet<TextSubscription> TextSubscriptions { get; set; }
public TextMatcherContext(DbContextOptions options)
: base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new TextSubscriptionConfiguration());
base.OnModelCreating(modelBuilder);
}
}

View File

@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Nocr.TextMatcher.AppServices.TextMatches;
namespace Nocr.TextMatcher.Persistence;
public class TextSubscriptionConfiguration : IEntityTypeConfiguration<TextSubscription>
{
public void Configure(EntityTypeBuilder<TextSubscription> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x=> x.ChatUsername).IsRequired().HasMaxLength(1024);
builder.Property(x => x.Template).IsRequired().HasMaxLength(1024);
}
}

View File

@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"MariaLocal": "server=localhost;port=3306;database=nocr_text_matcher;uid=root;pwd=toor"
}
}

View File

@ -1,10 +1,10 @@
using Nocr.TextMatcher.Api.Contracts.TextMatches;
using Nocr.TextMatcher.AppServices.TextMatches;
using Nocr.TextMatcher.Contracts;
using Xunit;
namespace Nocr.TextMatcher.AppServices.UnitTests;
public class TextMatchTests
public class TextSubscriptionTests
{
private const long UserId = 1;
@ -14,7 +14,7 @@ public class TextMatchTests
public void IsMatches_SameChatId_FullRule_MatchesText()
{
// Arrange
var match = TextMatch.Initialize(UserId, "Барахолка", "велосипед", TextMatchRule.Full, CreatedDateTime);
var match = TextSubscription.Initialize(UserId, "Барахолка", "велосипед", TextSubscriptionRule.Full, CreatedDateTime);
var text = "Продам снежный велосипед 100 лари. Гудаури.";
// Act
@ -28,7 +28,7 @@ public class TextMatchTests
public void IsMatches_SameChatId_AnyWord_MatchesText()
{
// Arrange
var match = TextMatch.Initialize(UserId, "Барахолка", "iphone айфон", TextMatchRule.AnyWord,
var match = TextSubscription.Initialize(UserId, "Барахолка", "iphone айфон", TextSubscriptionRule.AnyWord,
CreatedDateTime);
var text = "Продам айфон велосипед 100 лари. Гудаури.";
@ -43,7 +43,7 @@ public class TextMatchTests
public void IsMatches_SameChatId_AllWords_MatchesText()
{
// Arrange
var match = TextMatch.Initialize(UserId, "Барахолка", "iphone айфон", TextMatchRule.AnyWord,
var match = TextSubscription.Initialize(UserId, "Барахолка", "iphone айфон", TextSubscriptionRule.AnyWord,
CreatedDateTime);
var text = "Гомарджоба. Продам iphone (айфон) 1000 лари. Гудаури.";
@ -58,7 +58,7 @@ public class TextMatchTests
public void IsMatches_DifferentChatIdAndUserName_NotMatchesText()
{
// Arrange
var match = TextMatch.Initialize(UserId, "Барахолка", "iphone", TextMatchRule.Full, CreatedDateTime);
var match = TextSubscription.Initialize(UserId, "Барахолка", "iphone", TextSubscriptionRule.Full, CreatedDateTime);
// Act
var result = match.IsMatches(string.Empty, "iphone");