[FA-4] Adds an event bus infrastructure, a RabbitMQ implementation and rewires existing mutations on NovelService to utilize it.

This commit is contained in:
gamer147
2025-11-19 21:45:33 -05:00
parent 716087e4a4
commit e9423bfa66
42 changed files with 1037 additions and 263 deletions

View File

@@ -8,6 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>

View File

@@ -1,107 +1,37 @@
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.Shared.Services.EventBus;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.GraphQL;
public class Mutation
{
// TODO Make this kick off a job in the background somehow. Probably want to think of how jobs will work across services
// Also of course need to make it a proper 'upsert'
public async Task<Novel> ImportNovel(string novelUrl, NovelServiceDbContext dbContext,
IEnumerable<ISourceAdapter> adapters)
public async Task<NovelUpdateRequestedEvent> ImportNovel(string novelUrl, IEventBus eventBus)
{
NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in adapters)
var importNovelRequestEvent = new NovelUpdateRequestedEvent()
{
if (await sourceAdapter.CanProcessNovel(novelUrl))
{
metadata = await sourceAdapter.GetMetadata(novelUrl);
}
}
if (metadata == null)
{
throw new NotSupportedException("The provided novel url is currently unsupported.");
}
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.System
});
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.External
});
var addedNovel = dbContext.Novels.Add(new Novel()
{
Author = new Person()
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
ExternalUrl = metadata.AuthorUrl,
},
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
Chapters = metadata.Chapters.Select(chapter =>
{
return new Chapter()
{
Order = chapter.Order,
Url = chapter.Url,
Revision = chapter.Revision,
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
Body = new LocalizationKey()
{
Texts = new List<LocalizationText>()
}
NovelUrl = novelUrl
};
}).ToList(),
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = sourceTags.Concat(systemTags).ToList(),
Source = new Source()
{
Name = metadata.SourceDescriptor.Name,
Url = metadata.SourceDescriptor.Url,
Key = metadata.SourceDescriptor.Key,
}
});
await dbContext.SaveChangesAsync();
return addedNovel.Entity;
await eventBus.Publish(importNovelRequestEvent);
return importNovelRequestEvent;
}
public async Task<Chapter> FetchChapterContents(uint novelId,
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
uint chapterNumber,
NovelServiceDbContext dbContext,
IEnumerable<ISourceAdapter> sourceAdapters)
IEventBus eventBus)
{
var novel = await dbContext.Novels.Where(novel => novel.Id == novelId)
.Include(novel => novel.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source)
.FirstOrDefaultAsync();
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
var adapter = sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url);
chapter.Body.Texts.Add(new LocalizationText()
var chapterPullEvent = new ChapterPullRequestedEvent()
{
Text = rawChapter,
Language = novel.RawLanguage
});
await dbContext.SaveChangesAsync();
return chapter;
NovelId = novelId,
ChapterNumber = chapterNumber
};
await eventBus.Publish(chapterPullEvent);
return chapterPullEvent;
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.NovelService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -12,7 +13,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace FictionArchive.Service.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
[Migration("20251118045235_Initial")]
[Migration("20251120012317_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -27,11 +28,9 @@ namespace FictionArchive.Service.NovelService.Migrations
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -41,16 +40,44 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasKey("Id");
b.ToTable("LocalizationKey");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("TranslateTo")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EngineId");
b.HasIndex("KeyRequestedForTranslationId");
b.ToTable("LocalizationRequests");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -61,8 +88,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
@@ -88,8 +115,8 @@ namespace FictionArchive.Service.NovelService.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("BodyId")
.HasColumnType("bigint");
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -97,8 +124,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
@@ -137,8 +164,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
@@ -147,8 +174,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
@@ -190,8 +217,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
@@ -232,12 +259,13 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
@@ -283,10 +311,6 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
@@ -314,6 +338,25 @@ namespace FictionArchive.Service.NovelService.Migrations
b.ToTable("NovelNovelTag");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine")
.WithMany()
.HasForeignKey("EngineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation")
.WithMany()
.HasForeignKey("KeyRequestedForTranslationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Engine");
b.Navigation("KeyRequestedForTranslation");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null)
@@ -402,6 +445,17 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Name");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
@@ -13,33 +14,16 @@ namespace FictionArchive.Service.NovelService.Migrations
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LocalizationKey",
name: "LocalizationKeys",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationKey", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Person",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
ExternalUrl = table.Column<string>(type: "text", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Person", x => x.Id);
table.PrimaryKey("PK_LocalizationKeys", x => x.Id);
});
migrationBuilder.CreateTable(
@@ -66,7 +50,6 @@ namespace FictionArchive.Service.NovelService.Migrations
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayName = table.Column<string>(type: "text", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
@@ -75,6 +58,112 @@ namespace FictionArchive.Service.NovelService.Migrations
table.PrimaryKey("PK_TranslationEngines", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Person",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NameId = table.Column<Guid>(type: "uuid", nullable: false),
ExternalUrl = table.Column<string>(type: "text", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Person", x => x.Id);
table.ForeignKey(
name: "FK_Person_LocalizationKeys_NameId",
column: x => x.NameId,
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayNameId = table.Column<Guid>(type: "uuid", nullable: false),
TagType = table.Column<int>(type: "integer", nullable: false),
SourceId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
table.ForeignKey(
name: "FK_Tags_LocalizationKeys_DisplayNameId",
column: x => x.DisplayNameId,
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Tags_Sources_SourceId",
column: x => x.SourceId,
principalTable: "Sources",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "LocalizationRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
KeyRequestedForTranslationId = table.Column<Guid>(type: "uuid", nullable: false),
TranslateTo = table.Column<int>(type: "integer", nullable: false),
EngineId = table.Column<long>(type: "bigint", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationRequests", x => x.Id);
table.ForeignKey(
name: "FK_LocalizationRequests_LocalizationKeys_KeyRequestedForTransl~",
column: x => x.KeyRequestedForTranslationId,
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LocalizationRequests_TranslationEngines_EngineId",
column: x => x.EngineId,
principalTable: "TranslationEngines",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LocalizationText",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Language = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
TranslationEngineId = table.Column<long>(type: "bigint", nullable: true),
LocalizationKeyId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationText", x => x.Id);
table.ForeignKey(
name: "FK_LocalizationText_LocalizationKeys_LocalizationKeyId",
column: x => x.LocalizationKeyId,
principalTable: "LocalizationKeys",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LocalizationText_TranslationEngines_TranslationEngineId",
column: x => x.TranslationEngineId,
principalTable: "TranslationEngines",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Novels",
columns: table => new
@@ -88,8 +177,8 @@ namespace FictionArchive.Service.NovelService.Migrations
StatusOverride = table.Column<int>(type: "integer", nullable: true),
SourceId = table.Column<long>(type: "bigint", nullable: false),
ExternalId = table.Column<string>(type: "text", nullable: false),
NameId = table.Column<long>(type: "bigint", nullable: false),
DescriptionId = table.Column<long>(type: "bigint", nullable: false),
NameId = table.Column<Guid>(type: "uuid", nullable: false),
DescriptionId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
@@ -97,15 +186,15 @@ namespace FictionArchive.Service.NovelService.Migrations
{
table.PrimaryKey("PK_Novels", x => x.Id);
table.ForeignKey(
name: "FK_Novels_LocalizationKey_DescriptionId",
name: "FK_Novels_LocalizationKeys_DescriptionId",
column: x => x.DescriptionId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Novels_LocalizationKey_NameId",
name: "FK_Novels_LocalizationKeys_NameId",
column: x => x.NameId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
@@ -122,63 +211,6 @@ namespace FictionArchive.Service.NovelService.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayNameId = table.Column<long>(type: "bigint", nullable: false),
TagType = table.Column<int>(type: "integer", nullable: false),
SourceId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
table.ForeignKey(
name: "FK_Tags_LocalizationKey_DisplayNameId",
column: x => x.DisplayNameId,
principalTable: "LocalizationKey",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Tags_Sources_SourceId",
column: x => x.SourceId,
principalTable: "Sources",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "LocalizationText",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Language = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
TranslationEngineId = table.Column<long>(type: "bigint", nullable: true),
LocalizationKeyId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationText", x => x.Id);
table.ForeignKey(
name: "FK_LocalizationText_LocalizationKey_LocalizationKeyId",
column: x => x.LocalizationKeyId,
principalTable: "LocalizationKey",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LocalizationText_TranslationEngines_TranslationEngineId",
column: x => x.TranslationEngineId,
principalTable: "TranslationEngines",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Chapter",
columns: table => new
@@ -188,8 +220,8 @@ namespace FictionArchive.Service.NovelService.Migrations
Revision = table.Column<long>(type: "bigint", nullable: false),
Order = table.Column<long>(type: "bigint", nullable: false),
Url = table.Column<string>(type: "text", nullable: true),
NameId = table.Column<long>(type: "bigint", nullable: false),
BodyId = table.Column<long>(type: "bigint", nullable: false),
NameId = table.Column<Guid>(type: "uuid", nullable: false),
BodyId = table.Column<Guid>(type: "uuid", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: true),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false)
@@ -198,15 +230,15 @@ namespace FictionArchive.Service.NovelService.Migrations
{
table.PrimaryKey("PK_Chapter", x => x.Id);
table.ForeignKey(
name: "FK_Chapter_LocalizationKey_BodyId",
name: "FK_Chapter_LocalizationKeys_BodyId",
column: x => x.BodyId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Chapter_LocalizationKey_NameId",
name: "FK_Chapter_LocalizationKeys_NameId",
column: x => x.NameId,
principalTable: "LocalizationKey",
principalTable: "LocalizationKeys",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
@@ -255,6 +287,16 @@ namespace FictionArchive.Service.NovelService.Migrations
table: "Chapter",
column: "NovelId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationRequests_EngineId",
table: "LocalizationRequests",
column: "EngineId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationRequests_KeyRequestedForTranslationId",
table: "LocalizationRequests",
column: "KeyRequestedForTranslationId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationText_LocalizationKeyId",
table: "LocalizationText",
@@ -290,6 +332,11 @@ namespace FictionArchive.Service.NovelService.Migrations
table: "Novels",
column: "SourceId");
migrationBuilder.CreateIndex(
name: "IX_Person_NameId",
table: "Person",
column: "NameId");
migrationBuilder.CreateIndex(
name: "IX_Tags_DisplayNameId",
table: "Tags",
@@ -307,6 +354,9 @@ namespace FictionArchive.Service.NovelService.Migrations
migrationBuilder.DropTable(
name: "Chapter");
migrationBuilder.DropTable(
name: "LocalizationRequests");
migrationBuilder.DropTable(
name: "LocalizationText");
@@ -326,10 +376,10 @@ namespace FictionArchive.Service.NovelService.Migrations
name: "Person");
migrationBuilder.DropTable(
name: "LocalizationKey");
name: "Sources");
migrationBuilder.DropTable(
name: "Sources");
name: "LocalizationKeys");
}
}
}

View File

@@ -1,4 +1,5 @@
// <auto-generated />
using System;
using FictionArchive.Service.NovelService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -24,11 +25,9 @@ namespace FictionArchive.Service.NovelService.Migrations
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -38,16 +37,44 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasKey("Id");
b.ToTable("LocalizationKey");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("TranslateTo")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EngineId");
b.HasIndex("KeyRequestedForTranslationId");
b.ToTable("LocalizationRequests");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -58,8 +85,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
@@ -85,8 +112,8 @@ namespace FictionArchive.Service.NovelService.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("BodyId")
.HasColumnType("bigint");
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
@@ -94,8 +121,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
@@ -134,8 +161,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
@@ -144,8 +171,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
@@ -187,8 +214,8 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
@@ -229,12 +256,13 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
@@ -280,10 +308,6 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
@@ -311,6 +335,25 @@ namespace FictionArchive.Service.NovelService.Migrations
b.ToTable("NovelNovelTag");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine")
.WithMany()
.HasForeignKey("EngineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation")
.WithMany()
.HasForeignKey("KeyRequestedForTranslationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Engine");
b.Navigation("KeyRequestedForTranslation");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null)
@@ -399,6 +442,17 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name")
.WithMany()
.HasForeignKey("NameId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Name");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)

View File

@@ -0,0 +1,9 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class ChapterPullRequestedEvent : IntegrationEvent
{
public uint NovelId { get; set; }
public uint ChapterNumber { get; set; }
}

View File

@@ -0,0 +1,8 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class NovelUpdateRequestedEvent : IntegrationEvent
{
public string NovelUrl { get; set; }
}

View File

@@ -0,0 +1,17 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.
/// </summary>
public Guid? TranslationRequestId { get; set; }
/// <summary>
/// The resulting text.
/// </summary>
public string? TranslatedText { get; set; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public string Body { get; set; }
public string TranslationEngineKey { get; set; }
}

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationKey : BaseEntity<uint>
public class LocalizationKey : BaseEntity<Guid>
{
public List<LocalizationText> Texts { get; set; }

View File

@@ -0,0 +1,12 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationRequest : BaseEntity<Guid>
{
public LocalizationKey KeyRequestedForTranslation { get; set; }
public Language TranslateTo { get; set; }
public TranslationEngine Engine { get; set; }
}

View File

@@ -4,7 +4,7 @@ using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationText : BaseEntity<uint>
public class LocalizationText : BaseEntity<Guid>
{
public Language Language { get; set; }
public string Text { get; set; }

View File

@@ -5,5 +5,4 @@ namespace FictionArchive.Service.NovelService.Models.Novels;
public class TranslationEngine : BaseEntity<uint>
{
public string Key { get; set; }
public string DisplayName { get; set; }
}

View File

@@ -1,8 +1,11 @@
using FictionArchive.Service.NovelService.GraphQL;
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.EventHandlers;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.Shared.Services.GraphQL;
using Microsoft.EntityFrameworkCore;
@@ -16,6 +19,18 @@ public class Program
builder.Services.AddMemoryCache();
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCompletedEvent, TranslationRequestCompletedEventHandler>()
.Subscribe<NovelUpdateRequestedEvent, NovelUpdateRequestedEventHandler>()
.Subscribe<ChapterPullRequestedEvent, ChapterPullRequestedEventHandler>();
#endregion
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>();
@@ -41,6 +56,8 @@ public class Program
})
.AddHttpMessageHandler<NovelpiaAuthMessageHandler>();
builder.Services.AddTransient<NovelUpdateService>();
#endregion
builder.Services.AddHealthChecks();

View File

@@ -0,0 +1,19 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class ChapterPullRequestedEventHandler : IIntegrationEventHandler<ChapterPullRequestedEvent>
{
private readonly NovelUpdateService _novelUpdateService;
public ChapterPullRequestedEventHandler(NovelUpdateService novelUpdateService)
{
_novelUpdateService = novelUpdateService;
}
public async Task Handle(ChapterPullRequestedEvent @event)
{
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.ChapterNumber);
}
}

View File

@@ -0,0 +1,23 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class NovelUpdateRequestedEventHandler : IIntegrationEventHandler<NovelUpdateRequestedEvent>
{
private readonly ILogger<NovelUpdateRequestedEventHandler> _logger;
private readonly IEventBus _eventBus;
private readonly NovelUpdateService _novelUpdateService;
public NovelUpdateRequestedEventHandler(ILogger<NovelUpdateRequestedEventHandler> logger, IEventBus eventBus, NovelUpdateService novelUpdateService)
{
_logger = logger;
_eventBus = eventBus;
_novelUpdateService = novelUpdateService;
}
public async Task Handle(NovelUpdateRequestedEvent @event)
{
await _novelUpdateService.ImportNovel(@event.NovelUrl);
}
}

View File

@@ -0,0 +1,39 @@
using FictionArchive.Service.NovelService.Models.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.Services.EventBus;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Services.EventHandlers;
public class TranslationRequestCompletedEventHandler : IIntegrationEventHandler<TranslationRequestCompletedEvent>
{
private readonly ILogger<TranslationRequestCompletedEventHandler> _logger;
private readonly NovelServiceDbContext _dbContext;
public TranslationRequestCompletedEventHandler(ILogger<TranslationRequestCompletedEventHandler> logger, NovelServiceDbContext dbContext)
{
_logger = logger;
_dbContext = dbContext;
}
public async Task Handle(TranslationRequestCompletedEvent @event)
{
var localizationRequest = await _dbContext.LocalizationRequests.Include(r => r.KeyRequestedForTranslation)
.ThenInclude(lk => lk.Texts)
.FirstOrDefaultAsync(lk => lk.Id == @event.TranslationRequestId);
if (localizationRequest == null)
{
// Not one of our requests, discard it
return;
}
localizationRequest.KeyRequestedForTranslation.Texts.Add(new LocalizationText()
{
Language = localizationRequest.TranslateTo,
Text = @event.TranslatedText,
TranslationEngine = localizationRequest.Engine
});
_dbContext.LocalizationRequests.Remove(localizationRequest);
await _dbContext.SaveChangesAsync();
}
}

View File

@@ -1,3 +1,4 @@
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.Shared.Services.Database;
using Microsoft.EntityFrameworkCore;
@@ -11,4 +12,6 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; }
public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
}

View File

@@ -0,0 +1,111 @@
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Services;
public class NovelUpdateService
{
private readonly NovelServiceDbContext _dbContext;
private readonly ILogger<NovelUpdateService> _logger;
private readonly IEnumerable<ISourceAdapter> _sourceAdapters;
public NovelUpdateService(NovelServiceDbContext dbContext, ILogger<NovelUpdateService> logger, IEnumerable<ISourceAdapter> sourceAdapters)
{
_dbContext = dbContext;
_logger = logger;
_sourceAdapters = sourceAdapters;
}
public async Task<Novel> ImportNovel(string novelUrl)
{
NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
{
if (await sourceAdapter.CanProcessNovel(novelUrl))
{
metadata = await sourceAdapter.GetMetadata(novelUrl);
}
}
if (metadata == null)
{
throw new NotSupportedException("The provided novel url is currently unsupported.");
}
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.System
});
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.External
});
var addedNovel = _dbContext.Novels.Add(new Novel()
{
Author = new Person()
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
ExternalUrl = metadata.AuthorUrl,
},
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
Chapters = metadata.Chapters.Select(chapter =>
{
return new Chapter()
{
Order = chapter.Order,
Url = chapter.Url,
Revision = chapter.Revision,
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
Body = new LocalizationKey()
{
Texts = new List<LocalizationText>()
}
};
}).ToList(),
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = sourceTags.Concat(systemTags).ToList(),
Source = new Source()
{
Name = metadata.SourceDescriptor.Name,
Url = metadata.SourceDescriptor.Url,
Key = metadata.SourceDescriptor.Key,
}
});
await _dbContext.SaveChangesAsync();
return addedNovel.Entity;
}
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
{
var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId)
.Include(novel => novel.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source)
.FirstOrDefaultAsync();
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url);
chapter.Body.Texts.Add(new LocalizationText()
{
Text = rawChapter,
Language = novel.RawLanguage
});
await _dbContext.SaveChangesAsync();
return chapter;
}
}

View File

@@ -12,5 +12,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "NovelService"
},
"AllowedHosts": "*"
}

View File

@@ -22,6 +22,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
@@ -31,8 +33,4 @@
<ProjectReference Include="..\FictionArchive.Common\FictionArchive.Common.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Services\Messaging\Interfaces\" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,7 @@ namespace FictionArchive.Service.Shared.Models;
public abstract class BaseEntity<TKey> : IAuditable
{
public uint Id { get; set; }
public TKey Id { get; set; }
public Instant CreatedTime { get; set; }
public Instant LastUpdatedTime { get; set; }
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.DependencyInjection;
namespace FictionArchive.Service.Shared.Services.EventBus;
public class EventBusBuilder<TEventBus> where TEventBus : class, IEventBus
{
private readonly IServiceCollection _services;
private readonly SubscriptionManager _subscriptionManager;
public EventBusBuilder(IServiceCollection services)
{
_services = services;
_services.AddSingleton<IEventBus, TEventBus>();
_subscriptionManager = new SubscriptionManager();
_services.AddSingleton<SubscriptionManager>(_subscriptionManager);
}
public EventBusBuilder<TEventBus> Subscribe<TEvent, TEventHandler>() where TEvent : IntegrationEvent where TEventHandler : class, IIntegrationEventHandler<TEvent>
{
_services.AddKeyedTransient<IIntegrationEventHandler, TEventHandler>(typeof(TEvent).Name);
_subscriptionManager.RegisterSubscription<TEvent>();
return this;
}
}

View File

@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
namespace FictionArchive.Service.Shared.Services.EventBus;
public static class EventBusExtensions
{
public static EventBusBuilder<TEventBus> AddEventBus<TEventBus>(this IServiceCollection services)
where TEventBus : class, IEventBus
{
return new EventBusBuilder<TEventBus>(services);
}
}

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IEventBus
{
Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IntegrationEvent;
}

View File

@@ -0,0 +1,12 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IIntegrationEventHandler<in TEvent> : IIntegrationEventHandler where TEvent : IntegrationEvent
{
Task Handle(TEvent @event);
Task IIntegrationEventHandler.Handle(IntegrationEvent @event) => Handle((TEvent)@event);
}
public interface IIntegrationEventHandler
{
Task Handle(IntegrationEvent @event);
}

View File

@@ -0,0 +1,35 @@
using RabbitMQ.Client;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQConnectionProvider
{
private readonly IConnectionFactory _connectionFactory;
private IConnection Connection { get; set; }
private IChannel DefaultChannel { get; set; }
public RabbitMQConnectionProvider(IConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IConnection> GetConnectionAsync()
{
if (Connection == null)
{
Connection = await _connectionFactory.CreateConnectionAsync();
}
return Connection;
}
public async Task<IChannel> GetDefaultChannelAsync()
{
if (DefaultChannel == null)
{
DefaultChannel = await (await GetConnectionAsync()).CreateChannelAsync();
}
return DefaultChannel;
}
}

View File

@@ -0,0 +1,124 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Serialization.JsonNet;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQEventBus : IEventBus, IHostedService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly RabbitMQConnectionProvider _connectionProvider;
private readonly RabbitMQOptions _options;
private readonly SubscriptionManager _subscriptionManager;
private readonly ILogger<RabbitMQEventBus> _logger;
private readonly JsonSerializerSettings _jsonSerializerSettings;
private const string ExchangeName = "fiction-archive-event-bus";
public RabbitMQEventBus(IServiceScopeFactory serviceScopeFactory, RabbitMQConnectionProvider connectionProvider, IOptions<RabbitMQOptions> options, SubscriptionManager subscriptionManager, ILogger<RabbitMQEventBus> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_connectionProvider = connectionProvider;
_subscriptionManager = subscriptionManager;
_logger = logger;
_options = options.Value;
_jsonSerializerSettings = new JsonSerializerSettings().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}
public async Task Publish<TEvent>(TEvent integrationEvent) where TEvent : IntegrationEvent
{
var routingKey = typeof(TEvent).Name;
var channel = await _connectionProvider.GetDefaultChannelAsync();
// Set integration event values
integrationEvent.CreatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
integrationEvent.EventId = Guid.NewGuid();
var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(integrationEvent));
await channel.BasicPublishAsync(ExchangeName, routingKey, true, body);
_logger.LogInformation("Published event {EventName}", routingKey);
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_ = Task.Factory.StartNew(async () =>
{
try
{
var channel = await _connectionProvider.GetDefaultChannelAsync();
await channel.ExchangeDeclareAsync(ExchangeName, ExchangeType.Direct,
cancellationToken: cancellationToken);
await channel.QueueDeclareAsync(_options.ClientIdentifier, true, false, false,
cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += (sender, @event) =>
{
return OnReceivedEvent(sender, @event, channel);
};
foreach (var subscription in _subscriptionManager.Subscriptions)
{
await channel.QueueBindAsync(_options.ClientIdentifier, ExchangeName, subscription.Key,
cancellationToken: cancellationToken);
_logger.LogInformation("Subscribed to {SubscriptionKey}", subscription.Key);
}
await channel.BasicConsumeAsync(_options.ClientIdentifier, false, consumer, cancellationToken: cancellationToken);
_logger.LogInformation("RabbitMQ EventBus started.");
}
catch (Exception e)
{
_logger.LogError(e, "An error occurred while starting the RabbitMQ EventBus");
}
}, cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
private async Task OnReceivedEvent(object sender, BasicDeliverEventArgs @event, IChannel channel)
{
var eventName = @event.RoutingKey;
_logger.LogInformation("Received event {EventName}", eventName);
try
{
if (!_subscriptionManager.Subscriptions.ContainsKey(eventName))
{
_logger.LogWarning("Received event without subscription entry.");
return;
}
var eventBody = Encoding.UTF8.GetString(@event.Body.Span);
var eventObject = JsonConvert.DeserializeObject(eventBody, _subscriptionManager.Subscriptions[eventName], _jsonSerializerSettings) as IntegrationEvent;
using var scope = _serviceScopeFactory.CreateScope();
foreach (var service in scope.ServiceProvider.GetKeyedServices<IIntegrationEventHandler>(eventName))
{
await service.Handle(eventObject);
}
_logger.LogInformation("Finished handling event with name {EventName}", eventName);
}
catch (Exception e)
{
_logger.LogError(e, "An error occurred while handling an event.");
}
finally
{
await channel.BasicAckAsync(@event.DeliveryTag, false);
}
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public static class RabbitMQExtensions
{
public static EventBusBuilder<RabbitMQEventBus> AddRabbitMQ(this IServiceCollection services, Action<RabbitMQOptions> configure)
{
services.Configure(configure);
services.AddSingleton<IConnectionFactory, ConnectionFactory>(provider =>
{
var options = provider.GetService<IOptions<RabbitMQOptions>>();
ConnectionFactory factory = new ConnectionFactory();
factory.Uri = new Uri(options.Value.ConnectionString);
return factory;
});
services.AddSingleton<RabbitMQConnectionProvider>();
services.AddHostedService<RabbitMQEventBus>();
return services.AddEventBus<RabbitMQEventBus>();
}
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.Shared.Services.EventBus.Implementations;
public class RabbitMQOptions
{
public string ConnectionString { get; set; }
public string ClientIdentifier { get; set; }
}

View File

@@ -0,0 +1,9 @@
using NodaTime;
namespace FictionArchive.Service.Shared.Services.EventBus;
public abstract class IntegrationEvent
{
public Guid EventId { get; set; }
public Instant CreatedAt { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace FictionArchive.Service.Shared.Services.EventBus;
public class SubscriptionManager
{
public Dictionary<string, Type> Subscriptions { get; } = new Dictionary<string, Type>();
public void RegisterSubscription<TEvent>()
{
Subscriptions.Add(typeof(TEvent).Name, typeof(TEvent));
}
}

View File

@@ -1,6 +1,8 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database;
using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
@@ -8,23 +10,10 @@ namespace FictionArchive.Service.TranslationService.GraphQL;
public class Mutation
{
public async Task<string> TranslateText(string text, Language from, Language to, string translationEngineKey, IEnumerable<ITranslationEngine> translationEngines, TranslationServiceDbContext dbContext)
public async Task<TranslationResult> TranslateText(string text, Language from, Language to, string translationEngineKey, TranslationEngineService translationEngineService)
{
var engine = translationEngines.FirstOrDefault(engine => engine.Descriptor.Key == translationEngineKey);
var translation = await engine.GetTranslation(text, from, to);
var result = await translationEngineService.Translate(from, to, text, translationEngineKey);
dbContext.TranslationRequests.Add(new TranslationRequest()
{
OriginalText = text,
BilledCharacterCount = 0, // FILL ME
From = from,
To = to,
Status = translation != null ? TranslationRequestStatus.Success : TranslationRequestStatus.Failed,
TranslatedText = translation,
TranslationEngineKey = translationEngineKey
});
await dbContext.SaveChangesAsync();
return translation;
return result;
}
}

View File

@@ -0,0 +1,18 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.TranslationService.Models.Enums;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCompletedEvent : IntegrationEvent
{
/// <summary>
/// Maps this event back to a triggering request.
/// </summary>
public Guid? TranslationRequestId { get; set; }
/// <summary>
/// The resulting text.
/// </summary>
public string? TranslatedText { get; set; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.TranslationService.Models.IntegrationEvents;
public class TranslationRequestCreatedEvent : IntegrationEvent
{
public Guid TranslationRequestId { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public string Body { get; set; }
public string TranslationEngineKey { get; set; }
}

View File

@@ -0,0 +1,15 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.TranslationService.Models.Enums;
namespace FictionArchive.Service.TranslationService.Models;
public class TranslationResult
{
public required string OriginalText { get; set; }
public string? TranslatedText { get; set; }
public Language From { get; set; }
public Language To { get; set; }
public required string TranslationEngineKey { get; set; }
public TranslationRequestStatus Status { get; set; }
public uint BilledCharacterCount { get; set; }
}

View File

@@ -1,11 +1,16 @@
using DeepL;
using FictionArchive.Common.Extensions;
using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.Shared.Services.GraphQL;
using FictionArchive.Service.TranslationService.GraphQL;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
using FictionArchive.Service.TranslationService.Services;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.EventHandlers;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate;
using RabbitMQ.Client;
namespace FictionArchive.Service.TranslationService;
@@ -18,6 +23,17 @@ public class Program
builder.Services.AddHealthChecks();
#region Event Bus
builder.Services.AddRabbitMQ(opt =>
{
builder.Configuration.GetSection("RabbitMQ").Bind(opt);
})
.Subscribe<TranslationRequestCreatedEvent, TranslationRequestCreatedEventHandler>();
#endregion
#region Database
builder.Services.RegisterDbContext<TranslationServiceDbContext>(builder.Configuration.GetConnectionString("DefaultConnection"));
@@ -38,6 +54,8 @@ public class Program
});
builder.Services.AddTransient<ITranslationEngine, DeepLTranslationEngine>();
builder.Services.AddTransient<TranslationEngineService>();
#endregion
var app = builder.Build();

View File

@@ -0,0 +1,31 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
namespace FictionArchive.Service.TranslationService.Services.EventHandlers;
public class TranslationRequestCreatedEventHandler : IIntegrationEventHandler<TranslationRequestCreatedEvent>
{
private readonly ILogger<TranslationRequestCreatedEventHandler> _logger;
private readonly TranslationEngineService _translationEngineService;
private readonly IEventBus _eventBus;
public TranslationRequestCreatedEventHandler(ILogger<TranslationRequestCreatedEventHandler> logger, TranslationEngineService translationEngineService)
{
_logger = logger;
_translationEngineService = translationEngineService;
}
public async Task Handle(TranslationRequestCreatedEvent @event)
{
var result = await _translationEngineService.Translate(@event.From, @event.To, @event.Body, @event.TranslationEngineKey);
if (result.Status == TranslationRequestStatus.Success)
{
await _eventBus.Publish(new TranslationRequestCompletedEvent()
{
TranslatedText = result.TranslatedText,
TranslationRequestId = @event.TranslationRequestId,
});
}
}
}

View File

@@ -0,0 +1,47 @@
using System.Text;
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Database;
using FictionArchive.Service.TranslationService.Models.Enums;
using FictionArchive.Service.TranslationService.Models.IntegrationEvents;
using FictionArchive.Service.TranslationService.Services.Database;
using FictionArchive.Service.TranslationService.Services.TranslationEngines;
using RabbitMQ.Client;
namespace FictionArchive.Service.TranslationService.Services;
public class TranslationEngineService
{
private readonly IEnumerable<ITranslationEngine> _translationEngines;
private readonly IEventBus _eventBus;
private readonly TranslationServiceDbContext _dbContext;
public TranslationEngineService(IEnumerable<ITranslationEngine> translationEngines, TranslationServiceDbContext dbContext, IEventBus eventBus)
{
_translationEngines = translationEngines;
_dbContext = dbContext;
_eventBus = eventBus;
}
public async Task<TranslationResult> Translate(Language from, Language to, string text, string translationEngineKey)
{
var engine = _translationEngines.FirstOrDefault(engine => engine.Descriptor.Key == translationEngineKey);
var translation = await engine.GetTranslation(text, from, to);
_dbContext.TranslationRequests.Add(new TranslationRequest()
{
OriginalText = text,
BilledCharacterCount = translation.BilledCharacterCount, // FILL ME
From = from,
To = to,
Status = translation != null ? TranslationRequestStatus.Success : TranslationRequestStatus.Failed,
TranslatedText = translation.TranslatedText,
TranslationEngineKey = translationEngineKey
});
await _dbContext.SaveChangesAsync();
return translation;
}
}

View File

@@ -1,6 +1,7 @@
using DeepL;
using DeepL.Model;
using FictionArchive.Service.TranslationService.Models;
using FictionArchive.Service.TranslationService.Models.Enums;
using Language = FictionArchive.Common.Enums.Language;
namespace FictionArchive.Service.TranslationService.Services.TranslationEngines.DeepLTranslate;
@@ -31,11 +32,20 @@ public class DeepLTranslationEngine : ITranslationEngine
}
}
public async Task<string?> GetTranslation(string body, Language from, Language to)
public async Task<TranslationResult> GetTranslation(string body, Language from, Language to)
{
TextResult translationResult = await _deepLClient.TranslateTextAsync(body, GetLanguageCode(from), GetLanguageCode(to));
_logger.LogInformation("Translated text. Usage statistics: CHARACTERS BILLED {TranslationResultBilledCharacters}", translationResult.BilledCharacters);
return translationResult.Text;
return new TranslationResult()
{
OriginalText = body,
From = from,
To = to,
TranslationEngineKey = Key,
BilledCharacterCount = (uint)translationResult.BilledCharacters,
Status = TranslationRequestStatus.Success,
TranslatedText = translationResult.Text
};
}
private string GetLanguageCode(Language language)

View File

@@ -6,5 +6,5 @@ namespace FictionArchive.Service.TranslationService.Services.TranslationEngines;
public interface ITranslationEngine
{
public TranslationEngineDescriptor Descriptor { get; }
public Task<string?> GetTranslation(string body, Language from, Language to);
public Task<TranslationResult> GetTranslation(string body, Language from, Language to);
}

View File

@@ -11,5 +11,9 @@
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
},
"RabbitMQ": {
"ConnectionString": "amqp://localhost",
"ClientIdentifier": "TranslationService"
},
"AllowedHosts": "*"
}