Skip to content

Commit

Permalink
add remote resources support.
Browse files Browse the repository at this point in the history
add method to upload all pending local resources, and call from SyncWithResourceUpload helper method
  • Loading branch information
hahn-kev committed Jun 5, 2024
1 parent f4ac121 commit 5f64c2a
Show file tree
Hide file tree
Showing 15 changed files with 585 additions and 93 deletions.
3 changes: 3 additions & 0 deletions src/Crdt.Core/EntityNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Crdt.Core;

public class EntityNotFoundException(string message) : Exception(message);
27 changes: 27 additions & 0 deletions src/Crdt.Core/IResourceService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Crdt.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 IResourceService
{
/// <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);
3 changes: 2 additions & 1 deletion src/Crdt.Sample/CrdtSampleKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi
config =>
{
config.EnableProjectedTables = enableProjectedTables;
config.AddRemoteResourceEntity();
config.ChangeTypeListBuilder
.Add<NewWordChange>()
.Add<NewDefinitionChange>()
Expand All @@ -47,4 +48,4 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi
});
return services;
}
}
}
138 changes: 138 additions & 0 deletions src/Crdt.Tests/ResourceTests/RemoteResourcesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Runtime.CompilerServices;
using Crdt.Resource;
using FluentAssertions.Formatting;

namespace Crdt.Tests.ResourceTests;

public class RemoteResourcesTests : DataModelTestBase
{
private RemoteServiceMock _remoteServiceMock = new();

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 resourceId = await DataModel.AddLocalResource(file, _localClientId, resourceService: null);
return (resourceId, file);
}

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

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


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

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

//act
var pending = await DataModel.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 resourceId =
await DataModel.AddLocalResource(localFile, _localClientId, resourceService: _remoteServiceMock);


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

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

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


_remoteServiceMock.ListFiles()
.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 DataModel.DownloadResource(resourceId, _remoteServiceMock);


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

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

//act
var localResource = await DataModel.GetLocalResource(resourceId);


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

[Fact]
public async Task LocalResourceIsNullIfNotDownloaded()
{
var (resourceId, _) = await SetupRemoteResource("test");
var localResource = await DataModel.GetLocalResource(resourceId);
localResource.Should().BeNull();
}
}
45 changes: 45 additions & 0 deletions src/Crdt.Tests/ResourceTests/RemoteServiceMock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Crdt.Core;

namespace Crdt.Tests.ResourceTests;

public class RemoteServiceMock : IResourceService
{
public static readonly string RemotePath = Directory.CreateTempSubdirectory("RemoteServiceMock").FullName;

/// <summary>
/// directly creates a remote resource
/// </summary>
/// <returns>the remote id</returns>
public string CreateRemoteResource(string contents)
{
var filePath = Path.Combine(RemotePath, Guid.NewGuid().ToString("N") + ".txt");
File.WriteAllText(filePath, contents);
return filePath;
}

public Task<DownloadResult> DownloadResource(string remoteId, string localResourceCachePath)
{
var fileName = Path.GetFileName(remoteId);
var localPath = Path.Combine(localResourceCachePath, fileName);
Directory.CreateDirectory(localResourceCachePath);
File.Copy(remoteId, localPath);
return Task.FromResult(new DownloadResult(localPath));
}

public Task<UploadResult> UploadResource(string localPath)
{
var remoteId = Path.Combine(RemotePath, Path.GetFileName(localPath));
File.Copy(localPath, remoteId);
return Task.FromResult(new UploadResult(remoteId));
}

public string ReadFile(string remoteId)
{
return File.ReadAllText(remoteId);
}

public IEnumerable<string> ListFiles()
{
return Directory.GetFiles(RemotePath);
}
}
20 changes: 20 additions & 0 deletions src/Crdt/CrdtConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Crdt.Changes;
using Crdt.Db;
using Crdt.Entities;
using Crdt.Resource;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

Expand Down Expand Up @@ -44,6 +45,25 @@ private void JsonTypeModifier(JsonTypeInfo typeInfo)
}
}
}

public bool RemoteResourcesEnabled { get; private set; }
public string LocalResourceCachePath { get; set; } = Path.GetFullPath("./localResourceCache");
public void AddRemoteResourceEntity(string? cachePath = null)
{
RemoteResourcesEnabled = true;
LocalResourceCachePath = cachePath ?? LocalResourceCachePath;
ObjectTypeListBuilder.Add<RemoteResource>();
ChangeTypeListBuilder.Add<RemoteResourceUploadedChange>();
ChangeTypeListBuilder.Add<CreateRemoteResourceChange>();
ChangeTypeListBuilder.Add<CreateRemoteResourcePendingUploadChange>();
ChangeTypeListBuilder.Add<DeleteChange<RemoteResource>>();
ObjectTypeListBuilder.AddDbModelConfig(builder =>
{
var entity = builder.Entity<LocalResource>();
entity.HasKey(lr => lr.Id);
entity.Property(lr => lr.LocalPath);
});
}
}

public class ChangeTypeListBuilder
Expand Down
Loading

0 comments on commit 5f64c2a

Please sign in to comment.