Skip to content

Commit

Permalink
add remote resource support (#2)
Browse files Browse the repository at this point in the history
* add remote resources support.
add method to upload all pending local resources, and call from SyncWithResourceUpload helper method

* allow multiple object adapters

* add resource example in the sample project

* expose api to list all resources

* test AllResources api, change `AddLocalResource` to return a HarmonyResource

* try uploading resources to the remote server, log an error on failure but don't cancel and mark the upload as pending
  • Loading branch information
hahn-kev authored Dec 17, 2024
1 parent f400e26 commit 8a0bd5d
Show file tree
Hide file tree
Showing 25 changed files with 824 additions and 9 deletions.
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<PackageVersion Include="System.IO.Hashing" Version="9.0.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="FluentAssertions" Version="7.0.0-alpha.6" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="JetBrains.Profiler.SelfApi" Version="2.5.12" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
Expand Down
3 changes: 3 additions & 0 deletions src/SIL.Harmony.Core/EntityNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace SIL.Harmony.Core;

public class EntityNotFoundException(string message) : Exception(message);
27 changes: 27 additions & 0 deletions src/SIL.Harmony.Core/IRemoteResourceService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace SIL.Harmony.Core;

/// <summary>
/// interface to facilitate downloading of resources, typically implemented in application code
/// the remote Id is opaque to the CRDT lib and could be a URL or some other identifier provided by the backend
/// the local path returned for the application code to use as required, it could be a URL if needed also.
/// </summary>
public interface IRemoteResourceService
{
/// <summary>
/// instructs application code to download a resource from the remote server
/// the service is responsible for downloading the resource and returning the local path
/// </summary>
/// <param name="remoteId">ID used to identify the remote resource, could be a URL</param>
/// <param name="localResourceCachePath">path defined by the CRDT config where the resource should be stored</param>
/// <returns>download result containing the path to the downloaded file, this is stored in the local db and not synced</returns>
Task<DownloadResult> DownloadResource(string remoteId, string localResourceCachePath);
/// <summary>
/// upload a resource to the remote server
/// </summary>
/// <param name="localPath">full path to the resource on the local machine</param>
/// <returns>an upload result with the remote id, the id will be stored and transmitted to other clients so they can also download the resource</returns>
Task<UploadResult> UploadResource(string localPath);
}

public record DownloadResult(string LocalPath);
public record UploadResult(string RemoteId);
15 changes: 15 additions & 0 deletions src/SIL.Harmony.Sample/Changes/AddWordImageChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using SIL.Harmony.Changes;
using SIL.Harmony.Entities;
using SIL.Harmony.Sample.Models;

namespace SIL.Harmony.Sample.Changes;

public class AddWordImageChange(Guid entityId, Guid imageId) : EditChange<Word>(entityId), ISelfNamedType<AddWordImageChange>
{
public Guid ImageId { get; } = imageId;

public override async ValueTask ApplyChange(Word entity, ChangeContext context)
{
if (!await context.IsObjectDeleted(ImageId)) entity.ImageResourceId = ImageId;
}
}
2 changes: 2 additions & 0 deletions src/SIL.Harmony.Sample/CrdtSampleKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi
services.AddCrdtData<SampleDbContext>(config =>
{
config.EnableProjectedTables = true;
config.AddRemoteResourceEntity();
config.ChangeTypeListBuilder
.Add<NewWordChange>()
.Add<NewDefinitionChange>()
Expand All @@ -44,6 +45,7 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi
.Add<SetWordTextChange>()
.Add<SetWordNoteChange>()
.Add<AddAntonymReferenceChange>()
.Add<AddWordImageChange>()
.Add<SetOrderChange<Definition>>()
.Add<SetDefinitionPartOfSpeechChange>()
.Add<DeleteChange<Word>>()
Expand Down
9 changes: 8 additions & 1 deletion src/SIL.Harmony.Sample/Models/Word.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ public class Word : IObjectBase<Word>
public Guid Id { get; init; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? AntonymId { get; set; }
public Guid? ImageResourceId { get; set; }

public Guid[] GetReferences()
{
return AntonymId is null ? [] : [AntonymId.Value];
return Refs().ToArray();

IEnumerable<Guid> Refs()
{
if (AntonymId.HasValue) yield return AntonymId.Value;
if (ImageResourceId.HasValue) yield return ImageResourceId.Value;
}
}

public void RemoveReference(Guid id, Commit commit)
Expand Down
35 changes: 35 additions & 0 deletions src/SIL.Harmony.Tests/DbContextTests.VerifyModel.verified.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,40 @@
Relational:TableName: Snapshots
Relational:ViewName:
Relational:ViewSchema:
EntityType: LocalResource
Properties:
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
LocalPath (string) Required
Keys:
Id PK
Annotations:
DiscriminatorProperty:
Relational:FunctionName:
Relational:Schema:
Relational:SqlQuery:
Relational:TableName: LocalResource
Relational:ViewName:
Relational:ViewSchema:
EntityType: RemoteResource
Properties:
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
DeletedAt (DateTimeOffset?)
RemoteId (string)
SnapshotId (no field, Guid?) Shadow FK Index
Keys:
Id PK
Foreign keys:
RemoteResource {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull
Indexes:
SnapshotId Unique
Annotations:
DiscriminatorProperty:
Relational:FunctionName:
Relational:Schema:
Relational:SqlQuery:
Relational:TableName: RemoteResource
Relational:ViewName:
Relational:ViewSchema:
EntityType: Definition
Properties:
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
Expand Down Expand Up @@ -133,6 +167,7 @@
Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
AntonymId (Guid?)
DeletedAt (DateTimeOffset?)
ImageResourceId (Guid?)
Note (string)
SnapshotId (no field, Guid?) Shadow FK Index
Text (string) Required
Expand Down
210 changes: 210 additions & 0 deletions src/SIL.Harmony.Tests/ResourceTests/RemoteResourcesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;
using SIL.Harmony.Resource;

namespace SIL.Harmony.Tests.ResourceTests;

public class RemoteResourcesTests : DataModelTestBase
{
private RemoteServiceMock _remoteServiceMock = new();
private ResourceService _resourceService => _services.GetRequiredService<ResourceService>();

public RemoteResourcesTests()
{
}

private string CreateFile(string contents, [CallerMemberName] string fileName = "")
{
var filePath = Path.GetFullPath(fileName + ".txt");
File.WriteAllText(filePath, contents);
return filePath;
}

private async Task<(Guid resourceId, string remoteId)> SetupRemoteResource(string fileContents)
{
var remoteId = _remoteServiceMock.CreateRemoteResource(fileContents);
var resourceId = Guid.NewGuid();
await DataModel.AddChange(_localClientId, new CreateRemoteResourceChange(resourceId, remoteId));
return (resourceId, remoteId);
}

private async Task<(Guid resourceId, string localPath)> SetupLocalFile(string contents, [CallerMemberName] string fileName = "")
{
var file = CreateFile(contents, fileName);
//because resource service is null the file is not uploaded
var crdtResource = await _resourceService.AddLocalResource(file, _localClientId, resourceService: null);
return (crdtResource.Id, file);
}

[Fact]
public async Task CreatingAResourceResultsInPendingLocalResources()
{
var (_, file) = await SetupLocalFile("contents");

//act
var pending = await _resourceService.ListResourcesPendingUpload();


pending.Should().ContainSingle().Which.LocalPath.Should().Be(file);
}

[Fact]
public async Task ResourcesNotLocalShouldShowUpAsNotDownloaded()
{
var (resourceId, remoteId) = await SetupRemoteResource("test");

//act
var pending = await _resourceService.ListResourcesPendingDownload();


var remoteResource = pending.Should().ContainSingle().Subject;
remoteResource.RemoteId.Should().Be(remoteId);
remoteResource.Id.Should().Be(resourceId);
}

[Fact]
public async Task CanUploadFileToRemote()
{
var fileContents = "resource";
var localFile = CreateFile(fileContents);

//act
var crdtResource = await _resourceService.AddLocalResource(localFile, _localClientId, resourceService: _remoteServiceMock);


var resource = await DataModel.GetLatest<RemoteResource>(crdtResource.Id);
ArgumentNullException.ThrowIfNull(resource);
ArgumentNullException.ThrowIfNull(resource.RemoteId);
_remoteServiceMock.ReadFile(resource.RemoteId).Should().Be(fileContents);
var pendingUpload = await _resourceService.ListResourcesPendingUpload();
pendingUpload.Should().BeEmpty();
}

[Fact]
public async Task IfUploadingFailsTheResourceIsStillAddedAsPendingUpload()
{
var fileContents = "resource";
var localFile = CreateFile(fileContents);

//todo setup a mock that throws an exception when uploading
_remoteServiceMock.ThrowOnUpload(localFile);

//act
var crdtResource = await _resourceService.AddLocalResource(localFile, _localClientId, resourceService: _remoteServiceMock);

var harmonyResource = await _resourceService.GetResource(crdtResource.Id);
harmonyResource.Should().NotBeNull();
harmonyResource.Id.Should().Be(crdtResource.Id);
harmonyResource.RemoteId.Should().BeNull();
harmonyResource.LocalPath.Should().Be(localFile);
var pendingUpload = await _resourceService.ListResourcesPendingUpload();
pendingUpload.Should().ContainSingle().Which.Id.Should().Be(crdtResource.Id);
}

[Fact]
public async Task WillUploadMultiplePendingLocalFilesAtOnce()
{
await SetupLocalFile("file1", "file1");
await SetupLocalFile("file2", "file2");

//act
await _resourceService.UploadPendingResources(_localClientId, _remoteServiceMock);


_remoteServiceMock.ListRemoteFiles()
.Select(Path.GetFileName)
.Should()
.Contain(["file1.txt", "file2.txt"]);
}

[Fact]
public async Task CanDownloadFileFromRemote()
{
var fileContents = "resource";
var (resourceId, _) = await SetupRemoteResource(fileContents);

//act
var localResource = await _resourceService.DownloadResource(resourceId, _remoteServiceMock);


localResource.Id.Should().Be(resourceId);
var actualFileContents = await File.ReadAllTextAsync(localResource.LocalPath);
actualFileContents.Should().Be(fileContents);
var pendingDownloads = await _resourceService.ListResourcesPendingDownload();
pendingDownloads.Should().BeEmpty();
}

[Fact]
public async Task CanGetALocalResourceGivenAnId()
{
var file = CreateFile("resource");
//because resource service is null the file is not uploaded
var crdtResource = await _resourceService.AddLocalResource(file, _localClientId, resourceService: null);

//act
var localResource = await _resourceService.GetLocalResource(crdtResource.Id);


localResource.Should().NotBeNull();
localResource!.LocalPath.Should().Be(file);
}

[Fact]
public async Task LocalResourceIsNullIfNotDownloaded()
{
var (resourceId, _) = await SetupRemoteResource("test");
var localResource = await _resourceService.GetLocalResource(resourceId);
localResource.Should().BeNull();
}

[Fact]
public async Task CanListAllResources()
{
var (localResourceId, localResourcePath) = await SetupLocalFile("localOnly", "localOnly.txt");
var (remoteResourceId, remoteId) = await SetupRemoteResource("remoteOnly");
var localAndRemoteResource = await _resourceService.AddLocalResource(CreateFile("localAndRemove"), _localClientId, resourceService: _remoteServiceMock);

var crdtResources = await _resourceService.AllResources();
crdtResources.Should().BeEquivalentTo(
[
new HarmonyResource
{
Id = localResourceId,
LocalPath = localResourcePath,
RemoteId = null
},
new HarmonyResource
{
Id = remoteResourceId,
LocalPath = null,
RemoteId = remoteId
},
localAndRemoteResource
]);
}

[Fact]
public async Task CanGetAResourceGivenAnId()
{
var (localResourceId, localResourcePath) = await SetupLocalFile("localOnly", "localOnly.txt");
var (remoteResourceId, remoteId) = await SetupRemoteResource("remoteOnly");
var localAndRemoteResource = await _resourceService.AddLocalResource(CreateFile("localAndRemove"),
_localClientId,
resourceService: _remoteServiceMock);

(await _resourceService.GetResource(localResourceId)).Should().BeEquivalentTo(new HarmonyResource
{
Id = localResourceId,
LocalPath = localResourcePath,
RemoteId = null
});
(await _resourceService.GetResource(remoteResourceId)).Should().BeEquivalentTo(new HarmonyResource
{
Id = remoteResourceId,
LocalPath = null,
RemoteId = remoteId
});
(await _resourceService.GetResource(localAndRemoteResource.Id)).Should().BeEquivalentTo(localAndRemoteResource);
(await _resourceService.GetResource(Guid.NewGuid())).Should().BeNull();
}
}
Loading

0 comments on commit 8a0bd5d

Please sign in to comment.