Skip to content

Commit

Permalink
CrdtMerge API web service (#1136)
Browse files Browse the repository at this point in the history
Create a new web service called CrdtMerge, which offers a single API
endpoint, `/sync?projectCode=(code)`. It will eventually take a
`projectName` parameter as well. This does a Send/Receive first to
ensure that any changes from others have been pulled into the .fwdata
file. It then synchronizes the CRDT view of the project with the .fwdata
file, and then does another Send/Receive to push the CRDT changes (now
in .fwdata) up to the project on Lexbox so that other FieldWorks users
can receive them.

This PR does not contain a Kubernetes deployment for CrdtMerge; that
will come in a follow-up PR.

---------

Co-authored-by: Kevin Hahn <kevin_hahn@sil.org>
  • Loading branch information
rmunn and hahn-kev authored Oct 25, 2024
1 parent 2b38c07 commit 4b27266
Show file tree
Hide file tree
Showing 24 changed files with 476 additions and 129 deletions.
7 changes: 7 additions & 0 deletions LexBox.sln
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LcmDebugger", "backend\LfNe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniLcm.Tests", "backend\FwLite\MiniLcm.Tests\MiniLcm.Tests.csproj", "{00AE5440-0E36-4488-935B-5B11301BA57D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrdtMerge", "backend\CrdtMerge\CrdtMerge.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -139,6 +141,10 @@ Global
{00AE5440-0E36-4488-935B-5B11301BA57D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00AE5440-0E36-4488-935B-5B11301BA57D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00AE5440-0E36-4488-935B-5B11301BA57D}.Release|Any CPU.Build.0 = Release|Any CPU
{ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECBA46AB-AF87-4D4D-9716-FD77264B817F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -158,6 +164,7 @@ Global
{5352D1CC-14C5-4589-9389-731F55E4FFDF} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
{5A9011D8-6EC1-4550-BDD7-AFF00DB2B921} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
{00AE5440-0E36-4488-935B-5B11301BA57D} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
{ECBA46AB-AF87-4D4D-9716-FD77264B817F} = {7B6E21C4-5AF4-4505-B7D9-59A3886C5090}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {440AE83C-6DB0-4F18-B2C1-BCD33F0645B6}
Expand Down
29 changes: 29 additions & 0 deletions backend/CrdtMerge/CrdtMerge.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Mercurial4ChorusDestDir>$(MSBuildProjectDirectory)</Mercurial4ChorusDestDir>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0-rc.1.24452.1" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.22" />
<PackageReference Include="SIL.ChorusPlugin.LfMergeBridge" Version="4.2.0-beta0027" />
<PackageReference Include="SIL.Chorus.Mercurial" Version="6.5.1.*" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../harmony/src/SIL.Harmony/SIL.Harmony.csproj" />
<ProjectReference Include="../FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj" />
<ProjectReference Include="../FwLite/LcmCrdt/LcmCrdt.csproj" />
<ProjectReference Include="../FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj" />
<ProjectReference Include="../FixFwData/FixFwData.csproj" />
</ItemGroup>

<ItemGroup>
<Content Include="Mercurial\**" CopyToOutputDirectory="Always" />
<Content Include="MercurialExtensions\**" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>
17 changes: 17 additions & 0 deletions backend/CrdtMerge/CrdtMergeConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;

namespace CrdtMerge;

public class CrdtMergeConfig
{
[Required, Url, RegularExpression(@"^.+/$", ErrorMessage = "Must end with '/'")]
public required string LexboxUrl { get; init; }
public string HgWebUrl => $"{LexboxUrl}hg/";
[Required]
public required string LexboxUsername { get; init; }
[Required]
public required string LexboxPassword { get; init; }
[Required]
public required string ProjectStorageRoot { get; init; }
public string FdoDataModelVersion { get; init; } = "7000072";
}
23 changes: 23 additions & 0 deletions backend/CrdtMerge/CrdtMergeKernel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using FwDataMiniLcmBridge;
using FwLiteProjectSync;
using LcmCrdt;

namespace CrdtMerge;

public static class CrdtMergeKernel
{
public static void AddCrdtMerge(this IServiceCollection services)
{
services
.AddLogging(builder => builder.AddConsole().AddDebug().AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning));
services.AddOptions<CrdtMergeConfig>()
.BindConfiguration("SendReceiveConfig")
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddScoped<SendReceiveService>();
services
.AddLcmCrdtClient()
.AddFwDataBridge()
.AddFwLiteProjectSync();
}
};
85 changes: 85 additions & 0 deletions backend/CrdtMerge/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using CrdtMerge;
using FwDataMiniLcmBridge;
using FwLiteProjectSync;
using LcmCrdt;
using Microsoft.Extensions.Options;
using MiniLcm;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

builder.Services.AddCrdtMerge();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
//access at /scalar/v1
app.MapScalarApiReference();
}

app.UseHttpsRedirection();

app.MapPost("/sync", ExecuteMergeRequest);

app.Run();

static async Task<CrdtFwdataProjectSyncService.SyncResult> ExecuteMergeRequest(
ILogger<Program> logger,
IServiceProvider services,
SendReceiveService srService,
IOptions<CrdtMergeConfig> config,
FwDataFactory fwDataFactory,
ProjectsService projectsService,
CrdtFwdataProjectSyncService syncService,
string projectCode,
// string projectName, // TODO: Add this to the API eventually
bool dryRun = false)
{
logger.LogInformation("About to execute sync request for {projectCode}", projectCode);
if (dryRun)
{
logger.LogInformation("Dry run, not actually syncing");
return new(0, 0);
}

// TODO: Instead of projectCode here, we'll evetually look up project ID and use $"{projectName}-{projectId}" as the project folder
var projectFolder = Path.Join(config.Value.ProjectStorageRoot, projectCode);
if (!Directory.Exists(projectFolder)) Directory.CreateDirectory(projectFolder);

// TODO: add projectName parameter and use it instead of projectCode here
var crdtFile = Path.Join(projectFolder, $"{projectCode}.sqlite");

var fwDataProject = new FwDataProject(projectCode, projectFolder); // TODO: use projectName (once we have it) instead of projectCode here
logger.LogDebug("crdtFile: {crdtFile}", crdtFile);
logger.LogDebug("fwDataFile: {fwDataFile}", fwDataProject.FilePath);

if (File.Exists(fwDataProject.FilePath))
{
var srResult = srService.SendReceive(fwDataProject);
logger.LogInformation("Send/Receive result: {srResult}", srResult.Output);
}
else
{
var srResult = srService.Clone(fwDataProject);
logger.LogInformation("Send/Receive result: {srResult}", srResult.Output);
}
var fwdataApi = fwDataFactory.GetFwDataMiniLcmApi(fwDataProject, true);
// var crdtProject = projectsService.GetProject(crdtProjectName);
var crdtProject = File.Exists(crdtFile) ?
new CrdtProject(projectCode, crdtFile) : // TODO: use projectName (once we have it) instead of projectCode here
await projectsService.CreateProject(new(projectCode, fwdataApi.ProjectId, SeedNewProjectData: false, Path: projectFolder));
var miniLcmApi = await services.OpenCrdtProject(crdtProject);
var result = await syncService.Sync(miniLcmApi, fwdataApi, dryRun);
logger.LogInformation("Sync result, CrdtChanges: {CrdtChanges}, FwdataChanges: {FwdataChanges}", result.CrdtChanges, result.FwdataChanges);
var srResult2 = srService.SendReceive(fwDataProject);
logger.LogInformation("Send/Receive result after CRDT sync: {srResult2}", srResult2.Output);
return result;
}

21 changes: 21 additions & 0 deletions backend/CrdtMerge/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5275",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7003;http://localhost:5275",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
89 changes: 89 additions & 0 deletions backend/CrdtMerge/SendReceiveHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using FwDataMiniLcmBridge;
using SIL.Progress;

namespace CrdtMerge;

public static class SendReceiveHelpers
{
public record ProjectPath(string Code, string Dir)
{
public string FwDataFile { get; } = Path.Join(Dir, $"{Code}.fwdata");
}

public record SendReceiveAuth(string Username, string Password)
{
public SendReceiveAuth(CrdtMergeConfig config) : this(config.LexboxUsername, config.LexboxPassword) { }
};

public record LfMergeBridgeResult(string Output, string ProgressMessages);

private static LfMergeBridgeResult CallLfMergeBridge(string method, IDictionary<string, string> flexBridgeOptions)
{
var progress = new StringBuilderProgress();
LfMergeBridge.LfMergeBridge.Execute(method, progress, flexBridgeOptions.ToDictionary(), out var lfMergeBridgeOutputForClient);
return new LfMergeBridgeResult(lfMergeBridgeOutputForClient, progress.ToString());
}

private static Uri BuildSendReceiveUrl(string baseUrl, string projectCode, SendReceiveAuth? auth)
{
var baseUri = new Uri(baseUrl);
var projectUri = new Uri(baseUri, projectCode);
if (auth == null) return projectUri;
// Stop Chorus from saving passwords, since we're not a GUI app (and it calls Windows-only APIs anyway)
var chorusSettings = new Chorus.Model.ServerSettingsModel
{
RememberPassword = false,
Username = auth.Username,
Password = auth.Password
};
// Chorus relies too much on its global ServerSettingsModel.PasswordForSession variable
chorusSettings.SaveUserSettings();
// TODO: Consider a global S/R lock because of Chorus's PasswordForSession behavior
var builder = new UriBuilder(projectUri);
builder.UserName = auth.Username;
builder.Password = auth.Password;
return builder.Uri;
}

public static LfMergeBridgeResult SendReceive(FwDataProject project, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072", string? commitMessage = null)
{
// If projectCode not given, calculate it from the fwdataPath
var fwdataInfo = new FileInfo(project.FilePath);
if (fwdataInfo.Directory is null) throw new InvalidOperationException(
$"Not allowed to Send/Receive root-level directories like C:\\, was '{project.FilePath}'");

var repoUrl = BuildSendReceiveUrl(baseUrl, project.Name, auth);

var flexBridgeOptions = new Dictionary<string, string>
{
{ "fullPathToProject", fwdataInfo.Directory.FullName },
{ "fwdataFilename", fwdataInfo.Name },
{ "fdoDataModelVersion", fdoDataModelVersion },
{ "languageDepotRepoName", "LexBox" },
{ "languageDepotRepoUri", repoUrl.AbsoluteUri },
{ "deleteRepoIfNoSuchBranch", "false" },
{ "user", "LexBox" },
};
if (commitMessage is not null) flexBridgeOptions["commitMessage"] = commitMessage;
return CallLfMergeBridge("Language_Forge_Send_Receive", flexBridgeOptions);
}

public static LfMergeBridgeResult CloneProject(FwDataProject project, string baseUrl = "http://localhost", SendReceiveAuth? auth = null, string fdoDataModelVersion = "7000072")
{
// If projectCode not given, calculate it from the fwdataPath
var fwdataInfo = new FileInfo(project.FilePath);
if (fwdataInfo.Directory is null) throw new InvalidOperationException($"Not allowed to Send/Receive root-level directories like C:\\ '{project.FilePath}'");

var repoUrl = BuildSendReceiveUrl(baseUrl, project.Name, auth);

var flexBridgeOptions = new Dictionary<string, string>
{
{ "fullPathToProject", fwdataInfo.Directory.FullName },
{ "fdoDataModelVersion", fdoDataModelVersion },
{ "languageDepotRepoName", "LexBox" },
{ "languageDepotRepoUri", repoUrl.ToString() },
{ "deleteRepoIfNoSuchBranch", "false" },
};
return CallLfMergeBridge("Language_Forge_Clone", flexBridgeOptions);
}
}
28 changes: 28 additions & 0 deletions backend/CrdtMerge/SendReceiveService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using FwDataMiniLcmBridge;
using Microsoft.Extensions.Options;

namespace CrdtMerge;

public class SendReceiveService(IOptions<CrdtMergeConfig> config)
{
public SendReceiveHelpers.LfMergeBridgeResult SendReceive(FwDataProject project, string? commitMessage = null)
{
return SendReceiveHelpers.SendReceive(
project: project,
baseUrl: config.Value.HgWebUrl,
auth: new SendReceiveHelpers.SendReceiveAuth(config.Value),
fdoDataModelVersion: config.Value.FdoDataModelVersion,
commitMessage: commitMessage
);
}

public SendReceiveHelpers.LfMergeBridgeResult Clone(FwDataProject project)
{
return SendReceiveHelpers.CloneProject(
project: project,
baseUrl: config.Value.HgWebUrl,
auth: new SendReceiveHelpers.SendReceiveAuth(config.Value),
fdoDataModelVersion: config.Value.FdoDataModelVersion
);
}
}
15 changes: 15 additions & 0 deletions backend/CrdtMerge/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"SendReceiveConfig": {
"ProjectStorageRoot": "../../hgweb/repos",
"LexboxUrl": "http://localhost/",
"LexboxUsername": "admin",
"LexboxPassword": "pass",
"FdoDataModelVersion": "7000072"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
12 changes: 12 additions & 0 deletions backend/CrdtMerge/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"CrdtMergeConfig": {
"LexboxUsername": null
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public class MockFwProjectList(IOptions<FwDataBridgeConfig> config, MockFwProjec
{
public override IEnumerable<IProjectIdentifier> EnumerateProjects()
{
return loader.Projects.Keys.Select(k => new FwDataProject(k, k + ".fwdata"));
return loader.Projects.Keys.Select(k => new FwDataProject(k, config.Value.ProjectsFolder));
}

public override FwDataProject? GetProject(string name)
Expand Down
Loading

0 comments on commit 4b27266

Please sign in to comment.