diff --git a/Aspire.slnx b/Aspire.slnx index b267e9afedc..4b4f436faea 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -189,6 +189,9 @@ + + + diff --git a/playground/files/Files.AppHost/Files.AppHost.csproj b/playground/files/Files.AppHost/Files.AppHost.csproj new file mode 100644 index 00000000000..91e3411b5d1 --- /dev/null +++ b/playground/files/Files.AppHost/Files.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + + + + + + + + + + + \ No newline at end of file diff --git a/playground/files/Files.AppHost/Program.cs b/playground/files/Files.AppHost/Program.cs new file mode 100644 index 00000000000..1085f76e8a7 --- /dev/null +++ b/playground/files/Files.AppHost/Program.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +// Add a files resource with a source directory containing static content +builder.AddFiles("static-content") + .WithSource("../content"); + +#if !SKIP_DASHBOARD_REFERENCE +// This project is only added in playground projects to support development/debugging +// of the dashboard. It is not required in end developer code. Comment out this code +// or build with `/p:SkipDashboardReference=true`, to test end developer +// dashboard launch experience, Refer to Directory.Build.props for the path to +// the dashboard binary (defaults to the Aspire.Dashboard bin output in the +// artifacts dir). +builder.AddProject(KnownResourceNames.AspireDashboard); +#endif + +builder.Build().Run(); \ No newline at end of file diff --git a/playground/files/Files.AppHost/Properties/launchSettings.json b/playground/files/Files.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..83f6fa5386a --- /dev/null +++ b/playground/files/Files.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17222;http://localhost:15222", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21222", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22222" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15222", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19222", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20222" + } + } + } +} \ No newline at end of file diff --git a/playground/files/content/index.html b/playground/files/content/index.html new file mode 100644 index 00000000000..d29a438d32b --- /dev/null +++ b/playground/files/content/index.html @@ -0,0 +1,67 @@ + + + + + + Files Resource Demo + + + +
+

🗂️ Aspire Files Resource Demo

+ +

This is a simple HTML file that demonstrates the new Files resource in Aspire.Hosting.

+ +
+

📁 What is the Files Resource?

+

The Files resource is a lightweight primitive for representing and managing collections of files in Aspire applications. It's useful for:

+
    +
  • Configuration files
  • +
  • Static content and assets
  • +
  • Templates
  • +
  • Documentation
  • +
  • Any file-based resources your application needs
  • +
+
+ +
+

🛠️ How to Use

+

In your AppHost, you can add files resources like this:

+ builder.AddFiles("static-content").WithSource("content"); +
+ +

This file is located in the content/ directory and is referenced by the Files resource in the playground application.

+
+ + \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/FilesCallbackAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/FilesCallbackAnnotation.cs new file mode 100644 index 00000000000..113bf2d3841 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/FilesCallbackAnnotation.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents an annotation that provides a callback to enumerate files asynchronously. +/// +[DebuggerDisplay("Type = {GetType().Name,nq}")] +public sealed class FilesCallbackAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// A callback that returns an async enumerable of resource files. + public FilesCallbackAnnotation(Func> callback) + { + ArgumentNullException.ThrowIfNull(callback); + Callback = callback; + } + + /// + /// Gets the callback that returns an async enumerable of resource files. + /// + public Func> Callback { get; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/FilesResource.cs b/src/Aspire.Hosting/ApplicationModel/FilesResource.cs new file mode 100644 index 00000000000..e991f0453f2 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/FilesResource.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a files resource that can be used by an application. +/// +/// The name of the resource. +/// The collection of file paths associated with this resource. +public class FilesResource(string name, IEnumerable files) : Resource(name), IResourceWithFiles, IResourceWithoutLifetime +{ + private readonly List _files = files?.ToList() ?? []; + + /// + /// Gets the collection of file paths associated with this resource. + /// + public IEnumerable Files => _files; + + /// + /// Adds a file path to the resource. + /// + /// The file path to add. + public void AddFile(string filePath) + { + ArgumentNullException.ThrowIfNull(filePath); + _files.Add(filePath); + } + + /// + /// Adds multiple file paths to the resource. + /// + /// The file paths to add. + public void AddFiles(IEnumerable filePaths) + { + ArgumentNullException.ThrowIfNull(filePaths); + _files.AddRange(filePaths); + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceWithFiles.cs b/src/Aspire.Hosting/ApplicationModel/IResourceWithFiles.cs new file mode 100644 index 00000000000..7559f76b422 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IResourceWithFiles.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a resource that can work with files. +/// +public interface IResourceWithFiles +{ + /// + /// Gets the collection of file paths associated with this resource. + /// + IEnumerable Files { get; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceFile.cs b/src/Aspire.Hosting/ApplicationModel/ResourceFile.cs new file mode 100644 index 00000000000..2f5b989eb79 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceFile.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a file resource with both full and relative paths. +/// +[DebuggerDisplay("FullPath = {FullPath}, RelativePath = {RelativePath}")] +public sealed class ResourceFile +{ + /// + /// Initializes a new instance of the class. + /// + /// The full path to the file on the local system. + /// The normalized path relative to the root of the file set. + public ResourceFile(string fullPath, string relativePath) + { + ArgumentNullException.ThrowIfNull(fullPath); + ArgumentNullException.ThrowIfNull(relativePath); + + FullPath = fullPath; + RelativePath = relativePath; + } + + /// + /// Gets the full path to the file on the local system. + /// + public string FullPath { get; } + + /// + /// Gets the normalized path relative to the root of the file set. + /// + public string RelativePath { get; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/FilesResourceBuilderExtensions.cs b/src/Aspire.Hosting/FilesResourceBuilderExtensions.cs new file mode 100644 index 00000000000..64327ab81fd --- /dev/null +++ b/src/Aspire.Hosting/FilesResourceBuilderExtensions.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using System.Runtime.CompilerServices; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding files resources to an application. +/// +public static class FilesResourceBuilderExtensions +{ + /// + /// Adds a files resource to the application. + /// + /// The distributed application builder. + /// The name of the files resource. + /// A resource builder for the files resource. + public static IResourceBuilder AddFiles(this IDistributedApplicationBuilder builder, [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + + var filesResource = new FilesResource(name, []); + return builder.AddResource(filesResource); + } + + /// + /// Adds a source directory or file to an existing files resource. + /// + /// The resource builder for the files resource. + /// The source path (directory or file) to associate with this resource. + /// The resource builder for the files resource. + public static IResourceBuilder WithSource(this IResourceBuilder builder, string source) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + // Add the source immediately to maintain existing behavior + builder.Resource.AddFile(source); + + // Add callback annotation that will enumerate files from the source + builder.WithAnnotation(new FilesCallbackAnnotation(cancellationToken => EnumerateFilesAsync(source, cancellationToken))); + + return builder; + } + + private static async IAsyncEnumerable EnumerateFilesAsync(string source, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Get the absolute path for consistent path calculations + var absoluteSource = Path.GetFullPath(source); + + // If the source is a directory, enumerate all files in it + if (Directory.Exists(absoluteSource)) + { + // Return the directory itself as a resource file + var directoryRelativePath = Path.GetFileName(absoluteSource); + yield return new ResourceFile(absoluteSource, directoryRelativePath); + + var files = Directory.GetFiles(absoluteSource, "*", SearchOption.AllDirectories); + foreach (var file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Calculate relative path from the source directory + var relativePath = Path.GetRelativePath(absoluteSource, file); + // Normalize path separators + relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + + yield return new ResourceFile(file, relativePath); + } + } + else if (File.Exists(absoluteSource)) + { + // If it's a file, just return it with its filename as relative path + var fileName = Path.GetFileName(absoluteSource); + yield return new ResourceFile(absoluteSource, fileName); + } + + await Task.CompletedTask.ConfigureAwait(false); // Satisfy async enumerable requirements + } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Tests/FilesResourceTests.cs b/tests/Aspire.Hosting.Tests/FilesResourceTests.cs new file mode 100644 index 00000000000..3133641aeef --- /dev/null +++ b/tests/Aspire.Hosting.Tests/FilesResourceTests.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; + +namespace Aspire.Hosting.Tests; + +public class FilesResourceTests +{ + [Fact] + public void AddFilesResourceWithNoInitialFiles() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddFiles("emptyfiles"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var filesResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("emptyfiles", filesResource.Name); + Assert.Empty(filesResource.Files); + } + + [Fact] + public void WithSourceAddsSourceToResource() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddFiles("myfiles") + .WithSource("/path/to/directory") + .WithSource("/path/to/file.txt"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var filesResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("myfiles", filesResource.Name); + Assert.Equal(2, filesResource.Files.Count()); + Assert.Contains("/path/to/directory", filesResource.Files); + Assert.Contains("/path/to/file.txt", filesResource.Files); + } + + [Fact] + public void FilesResourceImplementsIResourceWithFiles() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddFiles("myfiles").WithSource("/path/to/file1.txt"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var filesResource = Assert.Single(appModel.Resources.OfType()); + + Assert.IsAssignableFrom(filesResource); + var resourceWithFiles = (IResourceWithFiles)filesResource; + Assert.Single(resourceWithFiles.Files, "/path/to/file1.txt"); + } + + [Fact] + public void FilesResourceImplementsIResourceWithoutLifetime() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddFiles("myfiles"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var filesResource = Assert.Single(appModel.Resources.OfType()); + + Assert.IsAssignableFrom(filesResource); + } + + [Fact] + public void WithSourceAddsFilesCallbackAnnotation() + { + var appBuilder = DistributedApplication.CreateBuilder(); + + appBuilder.AddFiles("myfiles") + .WithSource("/path/to/directory"); + + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var filesResource = Assert.Single(appModel.Resources.OfType()); + + var callbackAnnotation = Assert.Single(filesResource.Annotations.OfType()); + Assert.NotNull(callbackAnnotation.Callback); + } + + [Fact] + public void ResourceFileHasCorrectProperties() + { + var fullPath = "/full/path/to/file.txt"; + var relativePath = "folder/file.txt"; + + var resourceFile = new Aspire.Hosting.ApplicationModel.ResourceFile(fullPath, relativePath); + + Assert.Equal(fullPath, resourceFile.FullPath); + Assert.Equal(relativePath, resourceFile.RelativePath); + } + + [Fact] + public void ResourceFileConstructorThrowsOnNullArguments() + { + Assert.Throws(() => new Aspire.Hosting.ApplicationModel.ResourceFile(null!, "relative")); + Assert.Throws(() => new Aspire.Hosting.ApplicationModel.ResourceFile("full", null!)); + } + + [Fact] + public void FilesResourceAddFileThrowsOnNull() + { + var filesResource = new FilesResource("test", []); + + Assert.Throws(() => filesResource.AddFile(null!)); + } + + [Fact] + public void FilesResourceAddFilesThrowsOnNull() + { + var filesResource = new FilesResource("test", []); + + Assert.Throws(() => filesResource.AddFiles(null!)); + } +} \ No newline at end of file