diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index a904504fa..083738e2d 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -157,6 +157,12 @@ + + + + + + @@ -236,6 +242,7 @@ + @@ -253,6 +260,7 @@ + @@ -293,12 +301,13 @@ + - + @@ -316,6 +325,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 6c0a70586..446b46d40 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,8 @@ + + @@ -139,4 +141,4 @@ - + \ No newline at end of file diff --git a/examples/seaweedfs/README.md b/examples/seaweedfs/README.md new file mode 100644 index 000000000..4ece3095a --- /dev/null +++ b/examples/seaweedfs/README.md @@ -0,0 +1,54 @@ +# SeaweedFS Aspire Integration Example + +This example demonstrates how to integrate and consume a SeaweedFS cluster within a .NET Aspire distributed application. It showcases both the **S3-Compatible API** and the **Native Filer API** in a single cohesive setup. + +## Project Structure + +* **SeaweedFS.AppHost:** The Aspire orchestrator. It spins up the SeaweedFS Docker container, enables the S3 Gateway and Data Volumes, and injects the dynamic connection strings into the API. +* **SeaweedFS.ServiceDefaults:** Standard Aspire telemetry, resilience, and health check configurations. +* **SeaweedFS.ApiService:** A minimal API application that registers both the `IAmazonS3` client and the `SeaweedFSFilerClient` to interact with the storage cluster. + +## Running the Example + +1. Ensure you have [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Podman running on your machine. +2. Open a terminal in this directory (`aspire/seaweedfs/`). +3. Run the AppHost: + +```dotnetcli +dotnet run --project SeaweedFS.AppHost + +``` + +4. Open the **Aspire Dashboard** URL provided in the console output. +5. Wait for both the `seaweedfs` container and the `apiservice` to show as **Healthy**. + +## Exploring the Endpoints + +The `SeaweedFS.ApiService` exposes endpoints to test both storage approaches. You can trigger them using Swagger (if enabled) or using tools like `curl` or Postman. + +### S3 Endpoints (AWS SDK Compatibility) + +These endpoints use the injected `IAmazonS3` client. + +* **Create a Bucket:** +`POST /s3/buckets?bucketName=my-bucket` +* **Upload a File:** +`POST /s3/upload?bucketName=my-bucket&key=test.txt` +*(Send raw text in the body)* +* **Download a File:** +`GET /s3/download?bucketName=my-bucket&key=test.txt` + +### Filer Endpoints (Native SeaweedFS API) + +These endpoints use the injected `SeaweedFSFilerClient` performing direct HTTP calls to the cluster. + +* **Upload a File to Root:** +`POST /filer/upload?fileName=native-file.txt` +*(Send raw text in the body)* +* **List Directory Contents:** +`GET /filer/list` +*(Returns a JSON representation of the Filer's directory structure)* + +## Viewing Data in SeaweedFS + +Since `SeaweedFS.AppHost` maps a data volume using `.WithDataVolume()`, any file you upload using the endpoints above will be persisted in the Docker volume. If you stop the AppHost and run it again, your data will still be accessible. \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.ApiService/Program.cs b/examples/seaweedfs/SeaweedFS.ApiService/Program.cs new file mode 100644 index 000000000..b4d5c02c7 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.ApiService/Program.cs @@ -0,0 +1,119 @@ +using Amazon.S3; +using Amazon.S3.Model; +using CommunityToolkit.Aspire.SeaweedFS.Client; +using Microsoft.AspNetCore.Mvc; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add service defaults (OpenTelemetry, HealthChecks, etc.) +builder.AddServiceDefaults(); + +// Add essential web services +builder.Services.AddProblemDetails(); + +// ========================================================================= +// 🌊 REGISTER SEAWEEDFS CLIENTS +// ========================================================================= +// Basic Setup (Endpoints and credentials are automatically injected by AppHost) +builder.AddSeaweedFSS3Client("seaweedfs"); +builder.AddSeaweedFSFilerClient("seaweedfs"); + +/* // ------------------------------------------------------------------------- +// ADVANCED SETUP EXAMPLES +// Uncomment and use the blocks below to override client settings programmatically. +// ------------------------------------------------------------------------- + +builder.AddSeaweedFSS3Client("seaweedfs", settings => +{ + // The Endpoint is injected automatically, but you can override protocol rules: + settings.UseSsl = false; + settings.ForcePathStyle = true; // Required by SeaweedFS architecture + + // Deep configuration of the underlying AWS SDK: + settings.ConfigureS3Config = s3Config => + { + s3Config.Timeout = TimeSpan.FromSeconds(30); + s3Config.MaxErrorRetry = 3; + }; +}); + +builder.AddSeaweedFSFilerClient("seaweedfs", settings => +{ + // Example of disabling health checks for the Filer specifically + settings.DisableHealthChecks = true; +}); +*/ + +WebApplication app = builder.Build(); + +app.UseExceptionHandler(); + +// ========================================== +// 🌊 S3 ENDPOINTS (AWS Compatibility) +// ========================================== +RouteGroupBuilder s3Group = app.MapGroup("/s3").WithTags("S3 API"); + +s3Group.MapPost("/buckets", async ([FromQuery] string bucketName, IAmazonS3 s3Client) => +{ + await s3Client.PutBucketAsync(new PutBucketRequest { BucketName = bucketName }); + return Results.Ok(new { Message = "Bucket successfully created via S3 API." }); +}); + +s3Group.MapPost("/upload", async ([FromQuery] string bucketName, [FromQuery] string key, [FromBody] string content, IAmazonS3 s3Client) => +{ + await s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = bucketName, + Key = key, + ContentBody = content + }); + return Results.Ok(new { Message = "File successfully uploaded to bucket via S3 API." }); +}); + +s3Group.MapGet("/download", async ([FromQuery] string bucketName, [FromQuery] string key, IAmazonS3 s3Client) => +{ + GetObjectResponse response = await s3Client.GetObjectAsync(bucketName, key); + using StreamReader reader = new(response.ResponseStream); + string content = await reader.ReadToEndAsync(); + + return Results.Ok(new + { + Bucket = System.Text.Encodings.Web.HtmlEncoder.Default.Encode(bucketName), + Key = System.Text.Encodings.Web.HtmlEncoder.Default.Encode(key), + Content = content + }); +}); + +// ========================================== +// 📁 FILER ENDPOINTS (Native API) +// ========================================== +RouteGroupBuilder filerGroup = app.MapGroup("/filer").WithTags("Filer API"); + +filerGroup.MapGet("/list", async (SeaweedFSFilerClient filerClient) => +{ + HttpRequestMessage request = new(HttpMethod.Get, "/"); + request.Headers.Add("Accept", "application/json"); + + HttpResponseMessage response = await filerClient.HttpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + string content = await response.Content.ReadAsStringAsync(); + return Results.Content(content, "application/json"); +}); + +filerGroup.MapPost("/upload", async ([FromQuery] string fileName, [FromBody] string content, SeaweedFSFilerClient filerClient) => +{ + StringContent stringContent = new(content); + + string safeFileName = Uri.EscapeDataString(fileName.TrimStart('/')); + + // Uploads the file directly to the root of the Filer + HttpResponseMessage response = await filerClient.HttpClient.PutAsync($"/{safeFileName}", stringContent); + response.EnsureSuccessStatusCode(); + + return Results.Ok(new { Message = "File successfully uploaded via native Filer API." }); +}); + +app.MapDefaultEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.ApiService/Properties/launchSettings.json b/examples/seaweedfs/SeaweedFS.ApiService/Properties/launchSettings.json new file mode 100644 index 000000000..9713d2083 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.ApiService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "SeaweedFS.ApiService": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:4530;http://localhost:4531" + } + } +} \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.ApiService/SeaweedFS.ApiService.csproj b/examples/seaweedfs/SeaweedFS.ApiService/SeaweedFS.ApiService.csproj new file mode 100644 index 000000000..9bc3cfa96 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.ApiService/SeaweedFS.ApiService.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/examples/seaweedfs/SeaweedFS.ApiService/appsettings.json b/examples/seaweedfs/SeaweedFS.ApiService/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/apphost.mts b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/apphost.mts new file mode 100644 index 000000000..df0b815ce --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/apphost.mts @@ -0,0 +1,18 @@ +import { createBuilder } from "./.aspire/modules/aspire.mjs"; + +const builder = await createBuilder(); + +// 1. S3-Compatible API Gateway +const seaweedS3 = await builder.addSeaweedFS("seaweedfs-s3"); +await seaweedS3.withS3(); +await seaweedS3.withDataVolume(); + +// 2. Native Filer API Only +const seaweedFiler = await builder.addSeaweedFS("seaweedfs-filer"); +await seaweedFiler.withFiler(); + +// Get connection strings to ensure the expressions are evaluated properly by ATS +const _s3ConnectionString = await seaweedS3.connectionStringExpression(); +const _filerConnectionString = await seaweedFiler.connectionStringExpression(); + +await builder.build().run(); \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/aspire.config.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/aspire.config.json new file mode 100644 index 000000000..418390b79 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/aspire.config.json @@ -0,0 +1,17 @@ +{ + "appHost": { + "path": "apphost.mts", + "language": "typescript/nodejs" + }, + "sdk": { + "version": "13.4.0-preview.1.26275.15" + }, + "profiles": { + "http": { + "applicationUrl": "http://localhost:15333" + } + }, + "packages": { + "CommunityToolkit.Aspire.Hosting.SeaweedFS": "../../../src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj" + } +} \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/eslint.config.mjs b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/eslint.config.mjs new file mode 100644 index 000000000..159a14ac6 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/eslint.config.mjs @@ -0,0 +1,17 @@ +// @ts-check + +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig({ + files: ['apphost.mts'], + extends: [tseslint.configs.base], + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + rules: { + '@typescript-eslint/no-floating-promises': ['error', { checkThenables: true }], + }, +}); \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package-lock.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package-lock.json new file mode 100644 index 000000000..b55ba1742 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package-lock.json @@ -0,0 +1,2034 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validationapphost", + "version": "1.0.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.1" + }, + "devDependencies": { + "@types/node": "^25", + "eslint": "^10", + "nodemon": "^3.1.14", + "tsx": "^4.22.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package.json new file mode 100644 index 000000000..624958522 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/package.json @@ -0,0 +1,28 @@ +{ + "name": "validationapphost", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint apphost.mts", + "predev": "npm run lint", + "dev": "aspire run", + "prebuild": "npm run lint", + "build": "tsc", + "watch": "tsc --watch" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "devDependencies": { + "@types/node": "^25", + "eslint": "^10", + "nodemon": "^3.1.14", + "tsx": "^4.22.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1" + } +} diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.json b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.json new file mode 100644 index 000000000..c34e4c0b3 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "apphost.mts", + ".aspire/modules/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.tsbuildinfo b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.tsbuildinfo new file mode 100644 index 000000000..2d90f95ef --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost.TypeScript/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./apphost.ts"],"errors":true,"version":"6.0.3"} \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.AppHost/AppHost.cs b/examples/seaweedfs/SeaweedFS.AppHost/AppHost.cs new file mode 100644 index 000000000..400a4a7e6 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost/AppHost.cs @@ -0,0 +1,37 @@ +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +// ========================================================================= +// 🌊 SEAWEEDFS CONFIGURATION EXAMPLES +// ========================================================================= +// The integration provides flexible options to configure APIs, ports, and security. +// +// 1. STANDARD S3 SETUP (Default ports dynamically mapped by Aspire) +// builder.AddSeaweedFS("seaweedfs").WithS3().WithDataVolume(); +// +// 2. NATIVE FILER SETUP (No S3 overhead) +// builder.AddSeaweedFS("seaweedfs").WithFiler().WithDataVolume(); +// +// 3. ADVANCED SETUP (Custom ports, credentials, and persistence) +// var accessKey = builder.AddParameter("s3-access-key", "my-custom-admin"); +// var secretKey = builder.AddParameter("s3-secret-key", "my-super-secret", secret: true); +// +// builder.AddSeaweedFS("seaweedfs") +// .WithHostPort(9333) // Locks the Master API host port +// .WithS3(s3Port: 8333) // Locks the S3 Gateway host port +// .WithFiler(filerPort: 8888) // Locks the Filer API host port +// .WithAccessKey(accessKey) // Applies custom S3 Access Key +// .WithSecretKey(secretKey) // Applies custom S3 Secret Key +// .WithDataVolume("my-seaweed-data"); // Uses a named docker volume +// ========================================================================= + +IResourceBuilder seaweedfs = builder.AddSeaweedFS("seaweedfs") + .WithS3(); // Use .WithS3() for AWS compatibility or .WithFiler() for native API + //.WithDataVolume(); // Uncomment to enable persistent storage across restarts + +// Adds the API and injects the SeaweedFS cluster connection string +builder.AddProject("apiservice") + .WithReference(seaweedfs) + .WaitFor(seaweedfs) + .WithHttpHealthCheck("/health"); + +builder.Build().Run(); \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.AppHost/SeaweedFS.AppHost.csproj b/examples/seaweedfs/SeaweedFS.AppHost/SeaweedFS.AppHost.csproj new file mode 100644 index 000000000..4dfa8ff95 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost/SeaweedFS.AppHost.csproj @@ -0,0 +1,13 @@ + + + + Exe + 3cd96cb6-5c49-48f1-8c89-c1f210bb205d + + + + + + + + \ No newline at end of file diff --git a/examples/seaweedfs/SeaweedFS.AppHost/appsettings.json b/examples/seaweedfs/SeaweedFS.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/seaweedfs/SeaweedFS.ServiceDefaults/Extensions.cs b/examples/seaweedfs/SeaweedFS.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..6eac296dd --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.ServiceDefaults/Extensions.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/examples/seaweedfs/SeaweedFS.ServiceDefaults/SeaweedFS.ServiceDefaults.csproj b/examples/seaweedfs/SeaweedFS.ServiceDefaults/SeaweedFS.ServiceDefaults.csproj new file mode 100644 index 000000000..7a2fb7cf6 --- /dev/null +++ b/examples/seaweedfs/SeaweedFS.ServiceDefaults/SeaweedFS.ServiceDefaults.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj new file mode 100644 index 000000000..d113c6a0d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/CommunityToolkit.Aspire.Hosting.SeaweedFS.csproj @@ -0,0 +1,16 @@ + + + + seaweedfs hosting cloud storage s3 + Provides extension methods and resource definitions for the Aspire AppHost to support running SeaweedFS containers with an S3-compatible API. + + + + + + + + + + + \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/README.md b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/README.md new file mode 100644 index 000000000..66439bd39 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/README.md @@ -0,0 +1,67 @@ +# CommunityToolkit.Aspire.Hosting.SeaweedFS library + +Provides extension methods and resource definitions for the Aspire AppHost to support running [SeaweedFS](https://github.com/seaweedfs/seaweedfs) containers with flexible API configurations (Native Master/Volume/Filer APIs or an S3-compatible Gateway). + +## Getting Started + +### Install the package + +In your AppHost project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.SeaweedFS + +``` + +### Example usage + +Then, in the *Program.cs* file of `AppHost`, add a SeaweedFS resource, opt-in to the desired APIs, and consume the connection in your dependent projects: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +// Example 1: Standard usage enabling the S3-Compatible API Gateway +// Note: Enabling S3 implicitly enables and exposes the Filer API underneath. +var seaweedS3 = builder.AddSeaweedFS("seaweedfs") + .WithS3() + .WithDataVolume(); + +// Example 2: Native usage enabling only the Filer API (No S3 API running) +var seaweedNative = builder.AddSeaweedFS("seaweed-native") + .WithFiler() + .WithDataVolume(); + +// Reference the SeaweedFS resource in a project. +var apiService = builder.AddProject("apiservice") + .WithReference(seaweedS3); + +builder.Build().Run(); + +``` + +When you use `.WithReference()`, the hosting library dynamically builds the connection string based on your opt-ins. For instance, if you chained `.WithS3()`, the connection string will inject the S3 endpoint, credentials, and the explicit `FilerEndpoint` to be consumed by the Aspire SeaweedFS Client package automatically. + +## Configuring SeaweedFS + +SeaweedFS provides several modular extension methods to configure its container based on your needs: + +### API Activation (Opt-In) + +* `WithS3(int? s3Port)`: Enables the SeaweedFS S3-Compatible API Gateway (defaults to target port `8333`). This implicitly enables the Filer API since it is required by the S3 gateway. +* `WithFiler(int? filerPort)`: Enables the SeaweedFS Native Filer API (defaults to target port `8888`) without starting the S3 gateway. +* `WithHostPort(int? port)`: Configures the explicit host port for the core Master API (defaults to target port `9333`). + +### Credentials & Security + +* `WithAccessKey(IResourceBuilder accessKey)`: Configures a custom Access Key parameter for the S3 identity mapping wrapper. +* `WithSecretKey(IResourceBuilder secretKey)`: Configures a custom Secret Key parameter for the S3 identity mapping wrapper. +* `WithS3ConfigFile(string configFilePath)`: Overrides the dynamic configuration injection and enforces using a specific custom local `s3.json` configuration file mounted directly into the container. + +### Persistence + +* `WithDataBindMount(string source, bool isReadOnly)`: Binds a local host directory to the container's `/data` folder to persist your cluster data across restarts. +* `WithDataVolume(string? name, bool isReadOnly)`: Uses a named Docker volume to persist SeaweedFS cluster data. + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSBuilderExtensions.cs new file mode 100644 index 000000000..dfc7b2d6c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSBuilderExtensions.cs @@ -0,0 +1,284 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.SeaweedFS; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +#pragma warning disable IDE0130 +namespace Aspire.Hosting; +#pragma warning restore IDE0130 + +/// +/// Provides extension methods for adding SeaweedFS resources to an . +/// +public static class SeaweedFSBuilderExtensions +{ + private const int DefaultS3Port = 8333; + private const int DefaultMasterPort = 9333; + private const int DefaultFilerPort = 8888; + private const int DefaultVolumePort = 8080; + + private const string AccessKeyEnvVarName = "S3_ACCESS_KEY"; + private const string SecretKeyEnvVarName = "S3_SECRET_KEY"; + + /// + /// Adds a base SeaweedFS container (Master and Volume APIs) to the application model. + /// Chain with .WithS3() or .WithFiler() to enable additional APIs. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// A reference to the . +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder AddSeaweedFS( + this IDistributedApplicationBuilder builder, + [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + // Properly name the access key to avoid collisions and explicitly add it to the builder + // so it appears in the manifest generated by azd. + ParameterResource accessKeyParameter = new($"{name}-accessKey", _ => "admin"); + builder.AddResource(accessKeyParameter); + + ParameterResource secretKeyParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-secretKey"); + + SeaweedFSContainerResource resource = new(name, accessKeyParameter, secretKeyParameter); + + IResourceBuilder builderWithResource = builder.AddResource(resource) + .WithImage(SeaweedFSContainerImageTags.Image, SeaweedFSContainerImageTags.Tag) + .WithImageRegistry(SeaweedFSContainerImageTags.Registry) + .WithHttpEndpoint(targetPort: DefaultMasterPort, name: SeaweedFSContainerResource.MasterEndpointName) + .WithHttpEndpoint(targetPort: DefaultVolumePort, name: SeaweedFSContainerResource.VolumeEndpointName); + + // Configure strict, topology-aware readiness verification + string healthCheckKey = $"{name}_check"; + builder.Services.AddHealthChecks() + .Add(new HealthCheckRegistration( + name: healthCheckKey, + factory: sp => new SeaweedFSHealthCheck(resource), + failureStatus: HealthStatus.Unhealthy, + tags: ["seaweedfs", "storage", "cluster"], + timeout: TimeSpan.FromSeconds(10))); + + builderWithResource.WithHealthCheck(healthCheckKey); + + // Core pipeline arguments map + builderWithResource.WithArgs(context => + { + bool isFilerEnabled = builderWithResource.Resource.Annotations.OfType().Any(); + bool isS3Enabled = builderWithResource.Resource.Annotations.OfType().Any(); + SeaweedFSCustomS3ConfigAnnotation? customConfig = builderWithResource.Resource.Annotations.OfType().SingleOrDefault(); + + if (isS3Enabled && !isFilerEnabled) + { + isFilerEnabled = true; + } + + string args = "weed server -dir=\"/data\""; + if (isFilerEnabled) args += " -filer"; + + if (customConfig is not null) + { + args += " -s3 -s3.config=\"/etc/seaweedfs/s3-custom.json\""; + context.Args.Clear(); + context.Args.Add("server"); + context.Args.Add("-dir=/data"); + context.Args.Add("-s3"); + context.Args.Add("-s3.config=/etc/seaweedfs/s3-custom.json"); + context.Args.Add("-filer"); + } + else if (isS3Enabled) + { + args += " -s3 -s3.config=\"/tmp/s3.json\""; + + // Robust shell script escaping using 'EOF' Heredoc to avoid escaping breakages + string shellScript = "cat << EOF > /tmp/s3.json\n" + + "{\"identities\":[{\"name\":\"aspire-user\",\"credentials\":[{\"accessKey\":\"" + $"${AccessKeyEnvVarName}" + "\",\"secretKey\":\"" + $"${SecretKeyEnvVarName}" + "\"}],\"actions\":[\"Admin\",\"Read\",\"Write\",\"List\",\"Tagging\"]}]}\n" + + "EOF\n" + + $"exec {args}"; + + context.Args.Clear(); + context.Args.Add("-c"); + context.Args.Add(shellScript); + } + else + { + context.Args.Clear(); + context.Args.Add("server"); + context.Args.Add("-dir=/data"); + if (isFilerEnabled) context.Args.Add("-filer"); + } + }); + + return builderWithResource; + } + + /// + /// Enables the SeaweedFS S3-Compatible API Gateway. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithS3( + this IResourceBuilder builder, + int? s3Port = null) + { + ArgumentNullException.ThrowIfNull(builder); + + // Implicitly expose the Filer endpoint since the S3 gateway relies on it natively. + // This ensures the endpoint is registered in the Aspire application model. + if (!builder.Resource.Annotations.OfType().Any()) + { + builder.WithFiler(); + } + + builder.WithAnnotation(new SeaweedFSS3Annotation()); + builder.WithHttpEndpoint(targetPort: DefaultS3Port, port: s3Port, name: SeaweedFSContainerResource.S3EndpointName); + + // Ensure implicit initialization of entrypoint behavior based on the required shell deployment + builder.WithEntrypoint("sh"); + + builder.WithEnvironment(AccessKeyEnvVarName, builder.Resource.AccessKey); + builder.WithEnvironment(SecretKeyEnvVarName, builder.Resource.SecretKey); + + return builder; + } + + /// + /// Enables the SeaweedFS Native Filer API. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithFiler( + this IResourceBuilder builder, + int? filerPort = null) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.WithAnnotation(new SeaweedFSFilerAnnotation()); + builder.WithHttpEndpoint(targetPort: DefaultFilerPort, port: filerPort, name: SeaweedFSContainerResource.FilerEndpointName); + + if (!builder.Resource.Annotations.OfType().Any()) + { + builder.WithEntrypoint("weed"); + } + + return builder; + } + + /// + /// Configures the Master API host port that the SeaweedFS resource is exposed on. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithHostPort(this IResourceBuilder builder, int? port) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithEndpoint(SeaweedFSContainerResource.MasterEndpointName, endpoint => + { + endpoint.Port = port; + }); + } + + /// + /// Configures the Access Key that the SeaweedFS S3 resource uses. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithAccessKey( + this IResourceBuilder builder, + IResourceBuilder accessKey) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(accessKey); + + builder.Resource.AccessKey = accessKey.Resource; + return builder; + } + + /// + /// Configures the Secret Key that the SeaweedFS S3 resource uses. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithSecretKey( + this IResourceBuilder builder, + IResourceBuilder secretKey) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(secretKey); + + builder.Resource.SecretKey = secretKey.Resource; + return builder; + } + + /// + /// Configures the SeaweedFS S3 API to use a specific external security configuration file. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithS3ConfigFile( + this IResourceBuilder builder, + string configFilePath) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + + // Prevent the container from enabling the -s3 arg without actually mapping the S3 port. + if (!builder.Resource.Annotations.OfType().Any()) + { + throw new InvalidOperationException("You must enable the S3 Gateway using .WithS3() before configuring a custom S3 config file."); + } + + string containerPath = "/etc/seaweedfs/s3-custom.json"; + + // Safely annotate the pipeline to resolve logic within 'WithArgs' rather than forcing raw overrides + builder.WithAnnotation(new SeaweedFSCustomS3ConfigAnnotation(configFilePath)); + builder.WithEntrypoint("weed"); + + return builder.WithBindMount(configFilePath, containerPath, isReadOnly: true); + } + + /// + /// Adds a bind mount for the SeaweedFS data folder. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithDataBindMount( + this IResourceBuilder builder, + string source, + bool isReadOnly = false) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + return builder.WithBindMount(source, "/data", isReadOnly); + } + + /// + /// Adds a named volume for the SeaweedFS data folder. + /// +#pragma warning disable ASPIREATS001 + [AspireExport] +#pragma warning restore ASPIREATS001 + public static IResourceBuilder WithDataVolume( + this IResourceBuilder builder, + string? name = null, + bool isReadOnly = false) + { + ArgumentNullException.ThrowIfNull(builder); + + string volumeName = name ?? VolumeNameGenerator.Generate(builder, "data"); + + return builder.WithVolume(volumeName, "/data", isReadOnly); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerImageTags.cs new file mode 100644 index 000000000..e7a6b622b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerImageTags.cs @@ -0,0 +1,13 @@ +namespace CommunityToolkit.Aspire.Hosting.SeaweedFS; + +internal static class SeaweedFSContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + + /// chrislusf/seaweedfs + public const string Image = "chrislusf/seaweedfs"; + + /// 4.27 + public const string Tag = "4.27"; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerResource.cs new file mode 100644 index 000000000..20237672f --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSContainerResource.cs @@ -0,0 +1,111 @@ +using Aspire.Hosting.ApplicationModel; + +#pragma warning disable IDE0130 +namespace Aspire.Hosting; +#pragma warning restore IDE0130 + +/// +/// A resource that represents a SeaweedFS container. +/// +/// The resource name. +/// The parameter containing the S3 access key. +/// The parameter containing the S3 secret key. +public sealed class SeaweedFSContainerResource(string name, ParameterResource accessKey, ParameterResource secretKey) + : ContainerResource(name), IResourceWithConnectionString +{ + internal const string S3EndpointName = "s3"; + internal const string MasterEndpointName = "master"; + internal const string FilerEndpointName = "filer"; + internal const string VolumeEndpointName = "volume"; + + /// Gets the S3 Access Key. + public ParameterResource AccessKey { get; internal set; } = accessKey; + + /// Gets the S3 Secret Key. + public ParameterResource SecretKey { get; internal set; } = secretKey; + + /// Gets the primary endpoint for the SeaweedFS S3 API. Will throw if S3 is not enabled. + public EndpointReference PrimaryEndpoint => new(this, S3EndpointName); + + /// Gets the endpoint for the SeaweedFS Master API. + public EndpointReference MasterEndpoint => new(this, MasterEndpointName); + + /// Gets the endpoint for the SeaweedFS Filer API. Will throw if Filer is not enabled. + public EndpointReference FilerEndpoint => new(this, FilerEndpointName); + + /// Gets the connection string expression for the SeaweedFS resource. + public ReferenceExpression ConnectionStringExpression => GetConnectionString(); + + /// + /// Gets the connection string asynchronously. + /// + /// A to observe while waiting for the task to complete. + /// A connection string for the SeaweedFS server. + public ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (this.TryGetLastAnnotation(out ConnectionStringRedirectAnnotation? connectionStringAnnotation)) + { + return connectionStringAnnotation.Resource.GetConnectionStringAsync(cancellationToken); + } + + return ConnectionStringExpression.GetValueAsync(cancellationToken); + } + + private ReferenceExpression GetConnectionString() + { + ReferenceExpressionBuilder builder = new(); + + bool hasS3 = Annotations.OfType().Any(); + bool hasFiler = Annotations.OfType().Any(); + + EndpointReference targetEndpoint = hasS3 ? PrimaryEndpoint : MasterEndpoint; + + builder.Append($"Endpoint=http://{targetEndpoint.Property(EndpointProperty.Host)}:{targetEndpoint.Property(EndpointProperty.Port)}"); + + if (hasS3) + { + builder.Append($";AccessKey={AccessKey}"); + builder.Append($";SecretKey={SecretKey}"); + } + + if (hasFiler) + { + builder.Append($";FilerEndpoint=http://{FilerEndpoint.Property(EndpointProperty.Host)}:{FilerEndpoint.Property(EndpointProperty.Port)}"); + } + + return builder.Build(); + } + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + bool hasS3 = Annotations.OfType().Any(); + bool hasFiler = Annotations.OfType().Any(); + + EndpointReference targetEndpoint = hasS3 ? PrimaryEndpoint : MasterEndpoint; + + yield return new("Host", ReferenceExpression.Create($"{targetEndpoint.Property(EndpointProperty.Host)}")); + yield return new("Port", ReferenceExpression.Create($"{targetEndpoint.Property(EndpointProperty.Port)}")); + + yield return new("MasterUrl", ReferenceExpression.Create($"http://{MasterEndpoint.Property(EndpointProperty.Host)}:{MasterEndpoint.Property(EndpointProperty.Port)}")); + + if (hasS3) + { + yield return new("AccessKey", ReferenceExpression.Create($"{AccessKey}")); + yield return new("SecretKey", ReferenceExpression.Create($"{SecretKey}")); + yield return new("S3Url", ReferenceExpression.Create($"http://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}")); + } + + if (hasFiler) + { + yield return new("FilerUrl", ReferenceExpression.Create($"http://{FilerEndpoint.Property(EndpointProperty.Host)}:{FilerEndpoint.Property(EndpointProperty.Port)}")); + } + } +} + +// Internal annotations used safely during the resource compilation lifecycle +internal sealed class SeaweedFSS3Annotation : IResourceAnnotation { } +internal sealed class SeaweedFSFilerAnnotation : IResourceAnnotation { } +internal sealed class SeaweedFSCustomS3ConfigAnnotation(string hostPath) : IResourceAnnotation +{ + public string HostPath { get; } = hostPath; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSHealthCheck.cs b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSHealthCheck.cs new file mode 100644 index 000000000..59b9a2fa6 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.SeaweedFS/SeaweedFSHealthCheck.cs @@ -0,0 +1,69 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Text.Json; + +namespace CommunityToolkit.Aspire.Hosting.SeaweedFS; + +/// +/// A smart readiness probe that ensures the SeaweedFS cluster is not marked as healthy +/// until the Volume Server has fully mapped the data nodes to the Master Server. +/// +internal sealed class SeaweedFSHealthCheck(SeaweedFSContainerResource resource) : IHealthCheck +{ + // Use a shared HttpClient to prevent socket exhaustion during periodic health checks. + // PooledConnectionLifetime prevents DNS caching issues in dynamic container environments. + private static readonly HttpClient s_httpClient = new(new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }) + { + Timeout = TimeSpan.FromSeconds(5) + }; + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + EndpointReference masterEndpoint = resource.GetEndpoint(SeaweedFSContainerResource.MasterEndpointName); + + // Evaluates the endpoint. If the container is still booting network interfaces, it will throw safely. + string dirStatusUrl = $"{masterEndpoint.Url}/dir/status"; + + // Explicitly request JSON to prevent the SeaweedFS Master API from + // returning HTML in edge cases or future versions, which would break the JSON parser. + using HttpRequestMessage request = new(HttpMethod.Get, dirStatusUrl); + request.Headers.Add("Accept", "application/json"); + + HttpResponseMessage response = await s_httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return HealthCheckResult.Unhealthy($"Master API is unreachable. HTTP {response.StatusCode}."); + } + + string json = await response.Content.ReadAsStringAsync(cancellationToken); + using JsonDocument document = JsonDocument.Parse(json); + JsonElement root = document.RootElement; + + // Validates the Eventual Consistency of the cluster topology + if (root.TryGetProperty("Topology", out JsonElement topology) && topology.TryGetProperty("Max", out JsonElement max)) + { + if (max.GetInt64() == 0) + { + return HealthCheckResult.Unhealthy("Master is online, but waiting for Volume Server to map data nodes..."); + } + } + else + { + return HealthCheckResult.Unhealthy("Invalid topology response from Master."); + } + + return HealthCheckResult.Healthy("SeaweedFS Cluster is fully operational and data volumes are registered."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Failed to connect to SeaweedFS Master API.", ex); + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/CommunityToolkit.Aspire.SeaweedFS.Client.csproj b/src/CommunityToolkit.Aspire.SeaweedFS.Client/CommunityToolkit.Aspire.SeaweedFS.Client.csproj new file mode 100644 index 000000000..e77aaf4ca --- /dev/null +++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/CommunityToolkit.Aspire.SeaweedFS.Client.csproj @@ -0,0 +1,23 @@ + + + + seaweedfs client cloud storage s3 + An Aspire client integration for SeaweedFS using the AWSSDK.S3 client. + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/README.md b/src/CommunityToolkit.Aspire.SeaweedFS.Client/README.md new file mode 100644 index 000000000..70e6f49a3 --- /dev/null +++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/README.md @@ -0,0 +1,130 @@ +# CommunityToolkit.Aspire.SeaweedFS.Client library + +Provides an Aspire client integration for SeaweedFS. It supports both the S3-compatible API using the standard `AWSSDK.S3` client, and the Native Filer API through a strongly-typed `HttpClient`. + +## Usage example + +In the *Program.cs* file of your project, you can register the SeaweedFS clients depending on the APIs you enabled in your AppHost. + +### Registering the S3 Client +Call the `AddSeaweedFSS3Client` extension method to register an `IAmazonS3` client for use via the dependency injection container. + +```csharp +builder.AddSeaweedFSS3Client("seaweedfs"); + +``` + +### Registering the Native Filer Client + +Call the `AddSeaweedFSFilerClient` extension method to register a `SeaweedFSFilerClient` (which wraps a configured `HttpClient` pointing directly to the Filer node). + +```csharp +builder.AddSeaweedFSFilerClient("seaweedfs"); + +``` + +## Configuration + +The Aspire SeaweedFS Client integration provides multiple options to configure the server connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling the builder methods. The connection string dynamically handles multiple endpoints (Master, S3, Filer) injected by the AppHost. + +```json +{ + "ConnectionStrings": { + "seaweedfs": "Endpoint=http://localhost:8333;FilerEndpoint=http://localhost:8888;AccessKey=admin;SecretKey=admin-secret;UseSsl=false" + } +} + +``` + +### Use configuration providers + +The client supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `SeaweedFSClientSettings` from configuration by using the `Aspire:SeaweedFS:Client` key. + +Example `appsettings.json` that configures both endpoints and enables SSL: + +```json +{ + "Aspire": { + "SeaweedFS": { + "Client": { + "Endpoint": "http://localhost:8333", + "FilerEndpoint": "http://localhost:8888", + "AccessKey": "admin", + "SecretKey": "admin-secret", + "ForcePathStyle": true, + "UseSsl": false, + "DisableHealthChecks": false + } + } + } +} + +``` + +> **Note:** `ForcePathStyle` defaults to `true` as it is strictly required by the SeaweedFS architecture to correctly route S3 bucket requests. + +### Use inline delegates + +You can also pass the `Action configureSettings` delegate to set up some or all the options inline. This is particularly useful for applying advanced S3 configurations: + +```csharp +builder.AddSeaweedFSS3Client("seaweedfs", configureSettings: settings => +{ + settings.AccessKey = "admin"; + settings.SecretKey = "admin-secret"; + settings.ConfigureS3Config = s3Config => + { + s3Config.Timeout = TimeSpan.FromSeconds(30); + s3Config.MaxErrorRetry = 3; + }; +}); + +``` + +## Consuming the Clients + +Once registered, you can inject the clients into your services. + +**Using the S3 API:** + +```csharp +using Amazon.S3; +using Amazon.S3.Model; + +public class StorageService(IAmazonS3 s3Client) +{ + public async Task CreateBucketAsync(string bucketName) + { + await s3Client.PutBucketAsync(new PutBucketRequest { BucketName = bucketName }); + } +} + +``` + +**Using the Native Filer API:** + +```csharp +using CommunityToolkit.Aspire.SeaweedFS.Client; + +public class NativeFileService(SeaweedFSFilerClient filerClient) +{ + public async Task ListFilesAsync() + { + // Requires Accept header for JSON responses from Filer + var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Add("Accept", "application/json"); + + var response = await filerClient.HttpClient.SendAsync(request); + return await response.Content.ReadAsStringAsync(); + } +} + +``` + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientBuilderExtensionMethods.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientBuilderExtensionMethods.cs new file mode 100644 index 000000000..fc83714f2 --- /dev/null +++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientBuilderExtensionMethods.cs @@ -0,0 +1,162 @@ +using Amazon.Runtime; +using Amazon.S3; +using CommunityToolkit.Aspire.SeaweedFS.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +#pragma warning disable IDE0130 +namespace Microsoft.Extensions.Hosting; +#pragma warning restore IDE0130 + +/// +/// Extension methods for connecting a SeaweedFS cluster to an Aspire application. +/// +public static class SeaweedFSClientBuilderExtensionMethods +{ + private const string DefaultConfigSectionName = "Aspire:SeaweedFS:Client"; + + /// + /// Registers a client for interacting with the SeaweedFS S3 API. + /// + public static void AddSeaweedFSS3Client( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + string? configurationSectionName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + string sectionName = configurationSectionName ?? DefaultConfigSectionName; + + SeaweedFSClientSettings settings = new(); + builder.Configuration.GetSection(sectionName).Bind(settings); + builder.Configuration.GetSection($"{sectionName}:{connectionName}").Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ParseConnectionString(connectionString); + } + + configureSettings?.Invoke(settings); + + builder.Services.AddKeyedSingleton(connectionName, (sp, key) => + { + if (settings.Endpoint is null) + { + throw new InvalidOperationException($"A valid absolute SeaweedFS endpoint URI must be provided. Ensure a valid connection string is registered for '{connectionName}'."); + } + + // Dynamically override the endpoint scheme based on the explicit UseSsl property + UriBuilder uriBuilder = new(settings.Endpoint) + { + Scheme = settings.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp + }; + + // Remove default ports if they clash with the chosen scheme to ensure clean presigned URLs + if ((settings.UseSsl && uriBuilder.Port == 80) || (!settings.UseSsl && uriBuilder.Port == 443)) + { + uriBuilder.Port = -1; + } + + AmazonS3Config config = new() + { + ServiceURL = uriBuilder.Uri.GetLeftPart(UriPartial.Authority), + ForcePathStyle = settings.ForcePathStyle, + UseHttp = !settings.UseSsl + }; + + settings.ConfigureS3Config?.Invoke(config); + + AWSCredentials credentials = (!string.IsNullOrWhiteSpace(settings.AccessKey) && !string.IsNullOrWhiteSpace(settings.SecretKey)) + ? new BasicAWSCredentials(settings.AccessKey, settings.SecretKey) + : new AnonymousAWSCredentials(); + + return new AmazonS3Client(credentials, config); + }); + + if (connectionName.Equals("seaweedfs", StringComparison.OrdinalIgnoreCase)) + { + builder.Services.TryAddSingleton(sp => sp.GetRequiredKeyedService(connectionName)); + } + + if (!settings.DisableHealthChecks) + { + builder.Services.AddHealthChecks() + .Add(new HealthCheckRegistration( + name: $"seaweedfs_s3_{connectionName}", + factory: sp => new SeaweedFSS3HealthCheck(sp.GetRequiredKeyedService(connectionName)), + failureStatus: HealthStatus.Unhealthy, + tags: ["seaweedfs", "storage", "s3"], + timeout: TimeSpan.FromSeconds(10))); + } + } + + /// + /// Registers a for interacting with the SeaweedFS Native Filer API. + /// + public static void AddSeaweedFSFilerClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + string? configurationSectionName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(connectionName); + + string sectionName = configurationSectionName ?? DefaultConfigSectionName; + + SeaweedFSClientSettings settings = new(); + builder.Configuration.GetSection(sectionName).Bind(settings); + builder.Configuration.GetSection($"{sectionName}:{connectionName}").Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ParseConnectionString(connectionString); + } + + configureSettings?.Invoke(settings); + + // Fallback for FilerEndpoint or generic Endpoint + Uri baseAddress = settings.FilerEndpoint ?? settings.Endpoint ?? new Uri($"http://{connectionName}"); + + UriBuilder uriBuilder = new(baseAddress) + { + Scheme = settings.UseSsl ? Uri.UriSchemeHttps : Uri.UriSchemeHttp + }; + + if ((settings.UseSsl && uriBuilder.Port == 80) || (!settings.UseSsl && uriBuilder.Port == 443)) + { + uriBuilder.Port = -1; + } + + // 1. Register the Named HttpClient + builder.Services.AddHttpClient(connectionName, client => + { + client.BaseAddress = uriBuilder.Uri; + }); + + // 2. Register the Keyed Service + builder.Services.AddKeyedTransient(connectionName, (sp, key) => + { + IHttpClientFactory httpClientFactory = sp.GetRequiredService(); + return new SeaweedFSFilerClient(httpClientFactory.CreateClient(connectionName)); + }); + + // TryAddTransient ensures the FIRST registered Filer becomes the default resolvable instance. + builder.Services.TryAddTransient(sp => sp.GetRequiredKeyedService(connectionName)); + + if (!settings.DisableHealthChecks) + { + builder.Services.AddHealthChecks() + .Add(new HealthCheckRegistration( + name: $"seaweedfs_filer_{connectionName}", + factory: sp => new SeaweedFSFilerHealthCheck(sp.GetRequiredKeyedService(connectionName)), + failureStatus: HealthStatus.Unhealthy, + tags: ["seaweedfs", "storage", "filer"], + timeout: TimeSpan.FromSeconds(10))); + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientSettings.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientSettings.cs new file mode 100644 index 000000000..3ebb26b35 --- /dev/null +++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSClientSettings.cs @@ -0,0 +1,122 @@ +using Amazon.S3; +using System.Data.Common; + +#pragma warning disable IDE0130 +namespace Microsoft.Extensions.Hosting; +#pragma warning restore IDE0130 + +/// +/// Provides the settings for configuring the SeaweedFS S3 client. +/// +public sealed class SeaweedFSClientSettings +{ + private const string ConnectionStringEndpoint = "Endpoint"; + private const string AccessKeyName = "AccessKey"; + private const string SecretKeyName = "SecretKey"; + private const string ConnectionStringFilerEndpoint = "FilerEndpoint"; + private const string ConnectionStringFilerUrl = "FilerUrl"; + private const string UseSslKey = "UseSsl"; + + /// + /// Gets or sets the endpoint URL of the SeaweedFS S3 API. + /// + public Uri? Endpoint { get; set; } + + /// + /// Gets or sets the endpoint URL for the SeaweedFS Filer API. + /// + public Uri? FilerEndpoint { get; set; } + + /// + /// Gets or sets the S3 Access Key. + /// + public string? AccessKey { get; set; } + + /// + /// Gets or sets the S3 Secret Key. + /// + public string? SecretKey { get; set; } + + /// + /// Gets or sets a value indicating whether to force path style URLs for S3 objects. + /// Defaults to true, which is strictly required by the SeaweedFS architecture. + /// + public bool ForcePathStyle { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to use SSL/TLS (HTTPS) for requests. + /// Defaults to false. + /// + public bool UseSsl { get; set; } = false; + + /// + /// Gets or sets a boolean value that indicates whether the S3 health check is disabled or not. + /// + public bool DisableHealthChecks { get; set; } = false; + + /// + /// Gets or sets an action to deeply configure the underlying AmazonS3Config (e.g., retries, telemetry, proxies). + /// + public Action? ConfigureS3Config { get; set; } + + internal void ParseConnectionString(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return; + } + + // If the connection string is an absolute URI (e.g., "http://...", "https://..."), use it as the endpoint. + if (Uri.TryCreate(connectionString, UriKind.Absolute, out Uri? uri)) + { + Endpoint = uri; + return; + } + + try + { + DbConnectionStringBuilder connectionBuilder = new() + { + ConnectionString = connectionString + }; + + if (connectionBuilder.TryGetValue(ConnectionStringEndpoint, out object? endpoint) && + Uri.TryCreate(endpoint.ToString()?.Trim(), UriKind.Absolute, out Uri? serviceUri)) + { + Endpoint = serviceUri; + } + + if (connectionBuilder.TryGetValue(UseSslKey, out object? useSslObj) && + useSslObj is string useSslString && + bool.TryParse(useSslString, out bool useSslParsed)) + { + UseSsl = useSslParsed; + } + + if (connectionBuilder.TryGetValue(ConnectionStringFilerEndpoint, out object? filerEndpoint) && + Uri.TryCreate(filerEndpoint.ToString()?.Trim(), UriKind.Absolute, out Uri? filerServiceUri)) + { + FilerEndpoint = filerServiceUri; + } + else if (connectionBuilder.TryGetValue(ConnectionStringFilerUrl, out object? filerUrl) && + Uri.TryCreate(filerUrl.ToString()?.Trim(), UriKind.Absolute, out Uri? filerUrlUri)) + { + FilerEndpoint = filerUrlUri; + } + + if (connectionBuilder.TryGetValue(AccessKeyName, out object? accessKeyValue) && accessKeyValue is string accessKey) + { + AccessKey = accessKey; + } + + if (connectionBuilder.TryGetValue(SecretKeyName, out object? secretKeyValue) && secretKeyValue is string secretKey) + { + SecretKey = secretKey; + } + } + catch (ArgumentException) + { + // Ignore badly formed connection strings gracefully. + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerClient.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerClient.cs new file mode 100644 index 000000000..3de2098e3 --- /dev/null +++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerClient.cs @@ -0,0 +1,31 @@ +namespace CommunityToolkit.Aspire.SeaweedFS.Client; + +/// +/// A strongly-typed HTTP client for interacting with the SeaweedFS Native Filer API. +/// +/// +/// The provides a direct interface to the SeaweedFS Filer node, +/// allowing for file system operations such as listing, creating, and managing files/directories. +/// +/// Usage example: +/// +/// public class MyService(SeaweedFSFilerClient filerClient) +/// { +/// public async Task ListFilesAsync() +/// { +/// var response = await filerClient.HttpClient.GetAsync("/"); +/// // ... +/// } +/// } +/// +/// +/// +/// The configured instance, typically managed by . +public sealed class SeaweedFSFilerClient(HttpClient httpClient) +{ + /// + /// Gets the underlying configured for the SeaweedFS Filer API. + /// + /// The configured instance. + public HttpClient HttpClient { get; } = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerHealthCheck.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerHealthCheck.cs new file mode 100644 index 000000000..a305b031d --- /dev/null +++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSFilerHealthCheck.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CommunityToolkit.Aspire.SeaweedFS.Client; + +/// +/// Represents a health check for the SeaweedFS Filer API. +/// +internal sealed class SeaweedFSFilerHealthCheck(SeaweedFSFilerClient filerClient) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + // Send a lightweight GET request to the root of the Filer API. + // Using Accept: application/json ensures we get a small JSON directory listing rather than HTML payload. + using HttpRequestMessage request = new(HttpMethod.Get, "/"); + request.Headers.Add("Accept", "application/json"); + + HttpResponseMessage response = await filerClient.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return HealthCheckResult.Healthy(); + } + + return new HealthCheckResult(context.Registration.FailureStatus, $"SeaweedFS Filer API responded with HTTP {response.StatusCode}."); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, "SeaweedFS Filer API health check failed.", ex); + } + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSS3HealthCheck.cs b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSS3HealthCheck.cs new file mode 100644 index 000000000..a184b441c --- /dev/null +++ b/src/CommunityToolkit.Aspire.SeaweedFS.Client/SeaweedFSS3HealthCheck.cs @@ -0,0 +1,24 @@ +using Amazon.S3; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CommunityToolkit.Aspire.SeaweedFS.Client; + +/// +/// Represents a health check for the SeaweedFS S3 Gateway. +/// +internal sealed class SeaweedFSS3HealthCheck(IAmazonS3 s3Client) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + // A lightweight operation to verify S3 connectivity without mutating data. + await s3Client.ListBucketsAsync(cancellationToken); + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, "SeaweedFS S3 Gateway health check failed.", ex); + } + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/AppHostTests.cs new file mode 100644 index 000000000..d6dadd5d3 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/AppHostTests.cs @@ -0,0 +1,91 @@ +using CommunityToolkit.Aspire.Testing; +using System.Net.Http.Json; +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ResourceStartsAndApiCanPerformS3Operations() + { + const string apiResourceName = "apiservice"; + const string seaweedResourceName = "seaweedfs"; + + CancellationTokenSource cts = new(TimeSpan.FromMinutes(3)); + + // Wait for both the database container and the API to become fully healthy + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(seaweedResourceName, cts.Token).WaitAsync(cts.Token); + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(apiResourceName, cts.Token).WaitAsync(cts.Token); + + HttpClient httpClient = fixture.CreateHttpClient(apiResourceName); + + // 1. Create a Bucket via the S3 API endpoint (With Retry for Eventual Consistency) + const string bucketName = "e2e-test-bucket"; + HttpResponseMessage? createBucketResponse = null; + + // Retry loop to handle the S3 Gateway topology mapping delay right after boot + for (int i = 0; i < 15; i++) + { + createBucketResponse = await httpClient.PostAsync($"/s3/buckets?bucketName={bucketName}", null, cts.Token); + if (createBucketResponse.IsSuccessStatusCode) + { + break; + } + await Task.Delay(1000, cts.Token); // Waits 1 second before trying again + } + + Assert.NotNull(createBucketResponse); + Assert.Equal(HttpStatusCode.OK, createBucketResponse.StatusCode); + + // 2. Upload a file via the S3 API endpoint + const string s3ObjectKey = "hello-s3.txt"; + const string s3FileContent = "Integration test content via S3"; + StringContent s3StringContent = new($"\"{s3FileContent}\"", Encoding.UTF8, "application/json"); + + HttpResponseMessage uploadS3Response = await httpClient.PostAsync($"/s3/upload?bucketName={bucketName}&key={s3ObjectKey}", s3StringContent, cts.Token); + Assert.Equal(HttpStatusCode.OK, uploadS3Response.StatusCode); + + // 3. Download and verify the file via the S3 API endpoint + HttpResponseMessage downloadS3Response = await httpClient.GetAsync($"/s3/download?bucketName={bucketName}&key={s3ObjectKey}", cts.Token); + Assert.Equal(HttpStatusCode.OK, downloadS3Response.StatusCode); + + // Wait, parse and assert the custom anonymous object returned by the API + S3DownloadResult? s3Result = await downloadS3Response.Content.ReadFromJsonAsync(cancellationToken: cts.Token); + Assert.NotNull(s3Result); + Assert.Equal(s3FileContent, s3Result.Content); + } + + [Fact] + public async Task ResourceStartsAndApiCanPerformFilerOperations() + { + const string apiResourceName = "apiservice"; + const string seaweedResourceName = "seaweedfs"; + + CancellationTokenSource cts = new(TimeSpan.FromMinutes(3)); + + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(seaweedResourceName, cts.Token).WaitAsync(cts.Token); + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(apiResourceName, cts.Token).WaitAsync(cts.Token); + + HttpClient httpClient = fixture.CreateHttpClient(apiResourceName); + + // 1. Upload a file via the native Filer API endpoint + const string filerFileName = "hello-filer.txt"; + const string filerFileContent = "Integration test content via Filer"; + StringContent filerStringContent = new($"\"{filerFileContent}\"", Encoding.UTF8, "application/json"); + + HttpResponseMessage uploadFilerResponse = await httpClient.PostAsync($"/filer/upload?fileName={filerFileName}", filerStringContent, cts.Token); + Assert.Equal(HttpStatusCode.OK, uploadFilerResponse.StatusCode); + + // 2. List the root directory of the Filer and ensure the file is there + HttpResponseMessage listFilerResponse = await httpClient.GetAsync("/filer/list", cts.Token); + Assert.Equal(HttpStatusCode.OK, listFilerResponse.StatusCode); + + string listResult = await listFilerResponse.Content.ReadAsStringAsync(cts.Token); + Assert.Contains(filerFileName, listResult); + } + + // Helper record to parse the API response + private sealed record S3DownloadResult(string Bucket, string Key, string Content); +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests.csproj new file mode 100644 index 000000000..cc28e9d58 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests.csproj @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/ResourceCreationTests.cs new file mode 100644 index 000000000..48272ed19 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/ResourceCreationTests.cs @@ -0,0 +1,116 @@ +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests; + +public class ResourceCreationTests +{ + [Fact] + public void AddSeaweedFS_CreatesResourceWithDefaultEndpoints() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddSeaweedFS("seaweed"); + + using DistributedApplication app = builder.Build(); + + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + + SeaweedFSContainerResource resource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("seaweed", resource.Name); + + // Assert that only Master and Volume endpoints are created by default + EndpointAnnotation? masterEndpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.MasterEndpointName); + EndpointAnnotation? volumeEndpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.VolumeEndpointName); + + Assert.NotNull(masterEndpoint); + Assert.Equal(9333, masterEndpoint.TargetPort); + + Assert.NotNull(volumeEndpoint); + Assert.Equal(8080, volumeEndpoint.TargetPort); + + // Assert that Opt-In endpoints are not present + Assert.Null(resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.FilerEndpointName)); + Assert.Null(resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.S3EndpointName)); + } + + [Fact] + public void AddSeaweedFS_HasHealthCheck() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddSeaweedFS("seaweed"); + + using DistributedApplication app = builder.Build(); + + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + SeaweedFSContainerResource? resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + bool result = resource.TryGetAnnotationsOfType(out IEnumerable? annotations); + + Assert.True(result); + Assert.NotNull(annotations); + Assert.Single(annotations); + } + + [Fact] + public void WithFiler_AddsFilerEndpointAndAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddSeaweedFS("seaweed").WithFiler(); + + using DistributedApplication app = builder.Build(); + + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + SeaweedFSContainerResource resource = appModel.Resources.OfType().Single(); + + EndpointAnnotation? filerEndpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.FilerEndpointName); + + Assert.NotNull(filerEndpoint); + Assert.Equal(8888, filerEndpoint.TargetPort); + + Assert.Contains(resource.Annotations, a => a is SeaweedFSFilerAnnotation); + } + + [Fact] + public void WithS3_AddsS3EndpointAndAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddSeaweedFS("seaweed").WithS3(); + + using DistributedApplication app = builder.Build(); + + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + SeaweedFSContainerResource resource = appModel.Resources.OfType().Single(); + + EndpointAnnotation? s3Endpoint = resource.Annotations.OfType().SingleOrDefault(e => e.Name == SeaweedFSContainerResource.S3EndpointName); + + Assert.NotNull(s3Endpoint); + Assert.Equal(8333, s3Endpoint.TargetPort); + + Assert.Contains(resource.Annotations, a => a is SeaweedFSS3Annotation); + } + + [Fact] + public void WithDataVolume_AddsVolumeAnnotation() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + builder.AddSeaweedFS("seaweed").WithDataVolume(); + + using DistributedApplication app = builder.Build(); + + DistributedApplicationModel appModel = app.Services.GetRequiredService(); + SeaweedFSContainerResource resource = appModel.Resources.OfType().Single(); + + ContainerMountAnnotation? volumeAnnotation = resource.Annotations.OfType().SingleOrDefault(a => a.Target == "/data"); + + Assert.NotNull(volumeAnnotation); + Assert.Equal(ContainerMountType.Volume, volumeAnnotation.Type); + Assert.False(string.IsNullOrWhiteSpace(volumeAnnotation.Source)); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSFunctionalTests.cs new file mode 100644 index 000000000..84283b21e --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSFunctionalTests.cs @@ -0,0 +1,253 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Aspire.Hosting; +using Aspire.Hosting.Utils; +using CommunityToolkit.Aspire.Testing; +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests; + +[RequiresDocker] +public class SeaweedFSFunctionalTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task S3Api_GetsCreatedAndUsable() + { + using IDistributedApplicationTestingBuilder builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + IResourceBuilder seaweedfs = builder.AddSeaweedFS("seaweedfs").WithS3(); + + await using DistributedApplication app = await builder.BuildAsync(); + await app.StartAsync(); + + ResourceNotificationService rns = app.Services.GetRequiredService(); + await rns.WaitForResourceHealthyAsync(seaweedfs.Resource.Name); + + EndpointReference s3Endpoint = seaweedfs.GetEndpoint(SeaweedFSContainerResource.S3EndpointName); + string? accessKey = await seaweedfs.Resource.AccessKey.GetValueAsync(default); + string? secretKey = await seaweedfs.Resource.SecretKey.GetValueAsync(default); + + Assert.NotNull(accessKey); + Assert.NotNull(secretKey); + + AmazonS3Config s3Config = new() + { + ServiceURL = s3Endpoint.Url, + ForcePathStyle = true, + UseHttp = true, + Timeout = TimeSpan.FromSeconds(5), + MaxErrorRetry = 0 + }; + + using AmazonS3Client s3Client = new(accessKey, secretKey, s3Config); + + const string bucketName = "test-bucket"; + const string objectKey = "test-file.txt"; + const string fileContent = "Hello from SeaweedFS S3 API!"; + + await ExecuteWithRetryAsync(async () => + { + PutBucketResponse putBucketResponse = await s3Client.PutBucketAsync(new PutBucketRequest + { + BucketName = bucketName + }); + Assert.Equal(HttpStatusCode.OK, putBucketResponse.HttpStatusCode); + + PutObjectResponse putObjectResponse = await s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = bucketName, + Key = objectKey, + ContentBody = fileContent + }); + Assert.Equal(HttpStatusCode.OK, putObjectResponse.HttpStatusCode); + }); + + GetObjectResponse getObjectResponse = await s3Client.GetObjectAsync(bucketName, objectKey); + using StreamReader reader = new(getObjectResponse.ResponseStream); + string downloadedContent = await reader.ReadToEndAsync(); + + Assert.Equal(fileContent, downloadedContent); + } + + [Fact] + public async Task FilerApi_GetsCreatedAndUsable() + { + using IDistributedApplicationTestingBuilder builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + IResourceBuilder seaweedfs = builder.AddSeaweedFS("seaweedfs").WithFiler(); + + await using DistributedApplication app = await builder.BuildAsync(); + await app.StartAsync(); + + ResourceNotificationService rns = app.Services.GetRequiredService(); + await rns.WaitForResourceHealthyAsync(seaweedfs.Resource.Name); + + EndpointReference filerEndpoint = seaweedfs.GetEndpoint(SeaweedFSContainerResource.FilerEndpointName); + + using HttpClient httpClient = new() { BaseAddress = new Uri(filerEndpoint.Url) }; + + const string fileName = "/my-native-file.txt"; + const string fileContent = "Hello from SeaweedFS Native Filer API!"; + + await ExecuteWithRetryAsync(async () => + { + using StringContent content = new(fileContent, Encoding.UTF8, "text/plain"); + HttpResponseMessage response = await httpClient.PutAsync(fileName, content); + response.EnsureSuccessStatusCode(); + }); + + string downloadedContent = await GetStringWithRetryAsync(httpClient, fileName); + + Assert.Equal(fileContent, downloadedContent); + } + + [Theory] + [InlineData(true)] // Test with Docker Volume + [InlineData(false)] // Test with Bind Mount + public async Task WithData_ShouldPersistStateBetweenUsages(bool useVolume) + { + string? volumeName = null; + string? bindMountPath = null; + const string fileName = "/persisted-file.txt"; + const string fileContent = "This data should survive a restart."; + + try + { + // --- First run: Write data --- + using (IDistributedApplicationTestingBuilder builder1 = TestDistributedApplicationBuilder.Create(testOutputHelper)) + { + IResourceBuilder seaweed1 = builder1.AddSeaweedFS("seaweedfs").WithFiler(); + + if (useVolume) + { + volumeName = VolumeNameGenerator.Generate(seaweed1, nameof(WithData_ShouldPersistStateBetweenUsages)); + DockerUtils.AttemptDeleteDockerVolume(volumeName, throwOnFailure: false); + seaweed1.WithDataVolume(volumeName); + } + else + { + bindMountPath = Directory.CreateTempSubdirectory().FullName; + seaweed1.WithDataBindMount(bindMountPath); + } + + using DistributedApplication app1 = builder1.Build(); + await app1.StartAsync(); + + ResourceNotificationService rns1 = app1.Services.GetRequiredService(); + await rns1.WaitForResourceHealthyAsync(seaweed1.Resource.Name); + + EndpointReference filerEndpoint1 = seaweed1.GetEndpoint(SeaweedFSContainerResource.FilerEndpointName); + using HttpClient httpClient1 = new() { BaseAddress = new Uri(filerEndpoint1.Url) }; + + await ExecuteWithRetryAsync(async () => + { + using StringContent content = new(fileContent, Encoding.UTF8, "text/plain"); + HttpResponseMessage response = await httpClient1.PutAsync(fileName, content); + response.EnsureSuccessStatusCode(); + }); + + await app1.StopAsync(); // Stops the container and flushes the data safely + } + + // --- Second run: Read data --- + using IDistributedApplicationTestingBuilder builder2 = TestDistributedApplicationBuilder.Create(testOutputHelper); + IResourceBuilder seaweed2 = builder2.AddSeaweedFS("seaweedfs").WithFiler(); + + if (useVolume) + { + seaweed2.WithDataVolume(volumeName); + } + else + { + seaweed2.WithDataBindMount(bindMountPath!); + } + + using DistributedApplication app2 = builder2.Build(); + await app2.StartAsync(); + + ResourceNotificationService rns2 = app2.Services.GetRequiredService(); + await rns2.WaitForResourceHealthyAsync(seaweed2.Resource.Name); + + EndpointReference filerEndpoint2 = seaweed2.GetEndpoint(SeaweedFSContainerResource.FilerEndpointName); + using HttpClient httpClient2 = new() { BaseAddress = new Uri(filerEndpoint2.Url) }; + + string downloadedContent = await GetStringWithRetryAsync(httpClient2, fileName); + + Assert.Equal(fileContent, downloadedContent); + + await app2.StopAsync(); + } + finally + { + // Cleanup cloud-native engine resources + if (volumeName is not null) + { + DockerUtils.AttemptDeleteDockerVolume(volumeName); + } + + if (bindMountPath is not null) + { + try + { + Directory.Delete(bindMountPath, recursive: true); + } + catch + { + // Ignore cleanup errors in pipeline tests + } + } + } + } + + /// + /// Helper to retry actions, mitigating internal eventual consistency delays + /// in SeaweedFS cluster topology (Volume mapping to Master) just after boot. + /// + private static async Task ExecuteWithRetryAsync(Func action, int maxRetries = 15) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + await action(); + return; + } + catch (Exception) + { + if (i == maxRetries - 1) + { + throw; + } + await Task.Delay(1000); // Wait 1 second before retrying + } + } + } + + /// + /// Helper to retry GET requests and extract the string content. + /// + private static async Task GetStringWithRetryAsync(HttpClient client, string url, int maxRetries = 15) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + HttpResponseMessage response = await client.GetAsync(url); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(); + } + } + catch (Exception) + { + if (i == maxRetries - 1) + { + throw; + } + } + await Task.Delay(1000); + } + + throw new InvalidOperationException("Retry loop failed to fetch the string."); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSPublicApiTests.cs new file mode 100644 index 000000000..0ba53ad82 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/SeaweedFSPublicApiTests.cs @@ -0,0 +1,224 @@ +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests; + +public class SeaweedFSPublicApiTests +{ + [Fact] + public void AddSeaweedFS_ThrowsWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + const string name = "seaweedfs"; + + IResourceBuilder action() => builder.AddSeaweedFS(name); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void AddSeaweedFS_ThrowsWhenNameIsNullOrEmpty(string? name) + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + IResourceBuilder action() => builder.AddSeaweedFS(name!); + + ArgumentException exception = Assert.ThrowsAny((Func>)action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void WithS3_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + IResourceBuilder action() => builder.WithS3(); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithFiler_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + IResourceBuilder action() => builder.WithFiler(); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithHostPort_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + IResourceBuilder action() => builder.WithHostPort(9333); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithAccessKey_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder(); + IResourceBuilder parameter = builderApp.AddParameter("test"); + + IResourceBuilder action() => builder.WithAccessKey(parameter); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithAccessKey_ThrowsWhenAccessKeyIsNull() + { + IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder(); + IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs"); + + IResourceBuilder action() => builder.WithAccessKey(null!); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal("accessKey", exception.ParamName); + } + + [Fact] + public void WithSecretKey_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder(); + IResourceBuilder parameter = builderApp.AddParameter("test"); + + IResourceBuilder action() => builder.WithSecretKey(parameter); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithSecretKey_ThrowsWhenSecretKeyIsNull() + { + IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder(); + IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs"); + + IResourceBuilder action() => builder.WithSecretKey(null!); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal("secretKey", exception.ParamName); + } + + [Fact] + public void WithS3ConfigFile_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + IResourceBuilder action() => builder.WithS3ConfigFile("config.json"); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void WithS3ConfigFile_ThrowsWhenConfigFilePathIsNullOrWhitespace(string? configFilePath) + { + IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder(); + IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs"); + + IResourceBuilder action() => builder.WithS3ConfigFile(configFilePath!); + + ArgumentException exception = Assert.ThrowsAny((Func>)action); + Assert.Equal(nameof(configFilePath), exception.ParamName); + } + + [Fact] + public void WithDataBindMount_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + IResourceBuilder action() => builder.WithDataBindMount("/data"); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithDataBindMount_ThrowsWhenSourceIsNull() + { + IDistributedApplicationBuilder builderApp = DistributedApplication.CreateBuilder(); + IResourceBuilder builder = builderApp.AddSeaweedFS("seaweedfs"); + + IResourceBuilder action() => builder.WithDataBindMount(null!); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal("source", exception.ParamName); + } + + [Fact] + public void WithDataVolume_ThrowsWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + IResourceBuilder action() => builder.WithDataVolume(); + + ArgumentNullException exception = Assert.Throws((Func>)action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void VerifySeaweedFSContainerResource_WithHostPort() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + builder.AddSeaweedFS("seaweed") + .WithHostPort(1000); + + SeaweedFSContainerResource resource = Assert.Single(builder.Resources.OfType()); + EndpointAnnotation endpoint = Assert.Single(resource.Annotations.OfType(), x => x.Name == SeaweedFSContainerResource.MasterEndpointName); + + Assert.Equal(1000, endpoint.Port); + } + + [Fact] + public async Task VerifyConnectionString_PointsToMaster_WhenS3IsNotEnabled() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + IResourceBuilder seaweed = builder.AddSeaweedFS("seaweed") + .WithEndpoint(SeaweedFSContainerResource.MasterEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 9333)); + + string? connectionString = await seaweed.Resource.GetConnectionStringAsync(); + + Assert.Equal("Endpoint=http://localhost:9333", connectionString); + } + + [Fact] + public async Task VerifyConnectionString_PointsToS3AndIncludesCredentials_WhenS3IsEnabled() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + IResourceBuilder accessKey = builder.AddParameter("access", "my-access"); + IResourceBuilder secretKey = builder.AddParameter("secret", "my-secret"); + + IResourceBuilder seaweed = builder.AddSeaweedFS("seaweed") + .WithAccessKey(accessKey) + .WithSecretKey(secretKey) + .WithS3() + // Mock the S3 endpoint allocation + .WithEndpoint(SeaweedFSContainerResource.S3EndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8333)) + // Mock the Filer endpoint allocation (implicitly added by WithS3) to prevent async evaluation hangs + .WithEndpoint(SeaweedFSContainerResource.FilerEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 8888)); + + string? connectionString = await seaweed.Resource.GetConnectionStringAsync(); + + // Assert dynamically against the reference expression to evaluate parameters just like the real AppHost + // Now includes the FilerEndpoint appended dynamically by the Resource + string? expected = await ReferenceExpression.Create($"Endpoint=http://localhost:8333;AccessKey={accessKey.Resource};SecretKey={secretKey.Resource};FilerEndpoint=http://localhost:8888").GetValueAsync(CancellationToken.None); + + Assert.Equal(expected, connectionString); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/TypeScriptAppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/TypeScriptAppHostTests.cs new file mode 100644 index 000000000..595f3d763 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests/TypeScriptAppHostTests.cs @@ -0,0 +1,20 @@ +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.SeaweedFS.Tests; + +[RequiresDocker] +public class TypeScriptAppHostTests +{ + [Fact] + public async Task TypeScriptAppHostCompilesAndStarts() + { + // This will automatically generate the Node SDK, run the apphost.mts, + // and wait for the resources to report a healthy state. + await TypeScriptAppHostTest.Run( + appHostProject: "SeaweedFS.AppHost.TypeScript", + packageName: "CommunityToolkit.Aspire.Hosting.SeaweedFS", + exampleName: "seaweedfs", + waitForResources: ["seaweedfs-s3", "seaweedfs-filer"], + cancellationToken: TestContext.Current.CancellationToken); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests.csproj b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests.csproj new file mode 100644 index 000000000..5f35a6148 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/ConfigurationTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/ConfigurationTests.cs new file mode 100644 index 000000000..3a82a5877 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/ConfigurationTests.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ParseConnectionString_DbConnectionStringWithoutKeys_SetsEndpointOnly() + { + var settings = new SeaweedFSClientSettings(); + settings.ParseConnectionString("Endpoint=https://seaweedfs.local:8333"); + + Assert.NotNull(settings.Endpoint); + Assert.Equal("https://seaweedfs.local:8333/", settings.Endpoint.ToString()); + Assert.Null(settings.AccessKey); + Assert.Null(settings.SecretKey); + } + + [Fact] + public void ParseConnectionString_DbConnectionString_SetsFilerEndpoint() + { + var settings = new SeaweedFSClientSettings(); + settings.ParseConnectionString("FilerEndpoint=http://localhost:8888;AccessKey=admin"); + + Assert.NotNull(settings.FilerEndpoint); + Assert.Equal("http://localhost:8888/", settings.FilerEndpoint.ToString()); + } + + [Fact] + public void ParseConnectionString_DbConnectionString_SetsFilerUrl() + { + // Tests the implicit fallback property injected by the Aspire AppHost (IResourceWithConnectionString) + var settings = new SeaweedFSClientSettings(); + settings.ParseConnectionString("FilerUrl=http://seaweedfs:8888;SecretKey=secret"); + + Assert.NotNull(settings.FilerEndpoint); + Assert.Equal("http://seaweedfs:8888/", settings.FilerEndpoint.ToString()); + } + + + [Fact] + public void DefaultValuesAreCorrect() + { + var settings = new SeaweedFSClientSettings(); + + Assert.Null(settings.Endpoint); + Assert.Null(settings.AccessKey); + Assert.Null(settings.SecretKey); + + Assert.True(settings.ForcePathStyle); + Assert.False(settings.DisableHealthChecks); + Assert.False(settings.UseSsl); + } + + [Fact] + public void ParseConnectionString_NullOrEmpty_DoesNotThrow() + { + var settings = new SeaweedFSClientSettings(); + + settings.ParseConnectionString(null); + Assert.Null(settings.Endpoint); + + settings.ParseConnectionString(""); + Assert.Null(settings.Endpoint); + + settings.ParseConnectionString(" "); + Assert.Null(settings.Endpoint); + } + + [Fact] + public void ParseConnectionString_AbsoluteUri_SetsEndpoint() + { + var settings = new SeaweedFSClientSettings(); + settings.ParseConnectionString("http://localhost:8333"); + + Assert.NotNull(settings.Endpoint); + Assert.Equal("http://localhost:8333/", settings.Endpoint.ToString()); + } + + [Fact] + public void ParseConnectionString_DbConnectionString_SetsProperties() + { + var settings = new SeaweedFSClientSettings(); + settings.ParseConnectionString("Endpoint=http://localhost:8333;AccessKey=admin;SecretKey=secret"); + + Assert.NotNull(settings.Endpoint); + Assert.Equal("http://localhost:8333/", settings.Endpoint.ToString()); + Assert.Equal("admin", settings.AccessKey); + Assert.Equal("secret", settings.SecretKey); + } + + [Fact] + public void ParseConnectionString_UseSslCaseInsensitive() + { + var settings = new SeaweedFSClientSettings(); + settings.ParseConnectionString("Endpoint=http://localhost:8333;UseSsl=True;AccessKey=key;SecretKey=secret"); + + Assert.True(settings.UseSsl); + } + + [Fact] + public void ParseConnectionString_ExplicitUseSslFalse() + { + var settings = new SeaweedFSClientSettings(); + settings.ParseConnectionString("Endpoint=https://localhost:8333;UseSsl=false;AccessKey=key;SecretKey=secret"); + + Assert.NotNull(settings.Endpoint); + Assert.False(settings.UseSsl); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientConformanceTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientConformanceTests.cs new file mode 100644 index 000000000..1ea2333e7 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientConformanceTests.cs @@ -0,0 +1,99 @@ +using Amazon.S3; +using Aspire.Components.ConformanceTests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests; + +/// +/// Conformance tests for the SeaweedFS S3 Client integration. +/// Ensures the component adheres to the strict architectural guidelines of .NET Aspire. +/// +public class SeaweedFSClientConformanceTests : ConformanceTests +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => string.Empty; + + protected override string[] RequiredLogCategories => []; + + protected override bool CanConnectToServer => false; + + protected override bool SupportsKeyedRegistrations => true; + + protected override string ConfigurationSectionName => "Aspire:SeaweedFS:Client"; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + { + string endpoint = "http://localhost:8333"; + const string accessKey = "admin"; + const string secretKey = "admin-secret"; + + string connString = $"Endpoint={endpoint};AccessKey={accessKey};SecretKey={secretKey};UseSsl=false"; + + configuration.AddInMemoryCollection( + [ + new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "Endpoint"), endpoint), + new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "AccessKey"), accessKey), + new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "SecretKey"), secretKey), + new KeyValuePair(CreateConfigKey(ConfigurationSectionName, key, suffix: "UseSsl"), "false"), + new KeyValuePair($"ConnectionStrings:{key ?? "seaweedfs"}", connString) + ]); + } + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddSeaweedFSS3Client("seaweedfs", configure); + } + else + { + builder.AddSeaweedFSS3Client(key, configure); + } + } + + protected override void SetHealthCheck(SeaweedFSClientSettings options, bool enabled) + { + options.DisableHealthChecks = !enabled; + } + + protected override void SetMetrics(SeaweedFSClientSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void SetTracing(SeaweedFSClientSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void TriggerActivity(IAmazonS3 service) + { + using CancellationTokenSource source = new(TimeSpan.FromMilliseconds(100)); + try + { + service.ListBucketsAsync(source.Token).Wait(); + } + catch + { + } + } + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "SeaweedFS": { + "Client": { + "Endpoint": "http://localhost:8333", + "AccessKey": "admin", + "SecretKey": "admin-secret", + "ForcePathStyle": true, + "UseSsl": false, + "DisableHealthChecks": false + } + } + } + } + """; +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientTests.cs new file mode 100644 index 000000000..6ff78fa2f --- /dev/null +++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSClientTests.cs @@ -0,0 +1,150 @@ +using Amazon.S3; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests; + +public class SeaweedFSClientTests +{ + [Fact] + public void AddSeaweedFSS3Client_ThrowsArgumentNullException_WhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + void action() => builder.AddSeaweedFSS3Client("seaweedfs"); + + ArgumentNullException exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddSeaweedFSS3Client_ThrowsArgumentException_WhenConnectionNameIsNull() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + + void action() => builder.AddSeaweedFSS3Client(null!); + + ArgumentNullException exception = Assert.Throws(action); + Assert.Equal("connectionName", exception.ParamName); + } + + [Fact] + public void AddSeaweedFSS3Client_ThrowsInvalidOperationException_WhenEndpointIsMissing() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + + void action() + { + builder.AddSeaweedFSS3Client("seaweedfs"); + + using IHost host = builder.Build(); + _ = host.Services.GetRequiredKeyedService("seaweedfs"); + } + + InvalidOperationException exception = Assert.Throws(action); + Assert.Contains("A valid absolute SeaweedFS endpoint URI must be provided", exception.Message); + } + + [Fact] + public void AddSeaweedFSS3Client_RegistersKeyedAndStandardServices() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333" }, + { "ConnectionStrings:cluster2", "Endpoint=http://localhost:9333" } + }); + + builder.AddSeaweedFSS3Client("seaweedfs"); + builder.AddSeaweedFSS3Client("cluster2"); + + using IHost host = builder.Build(); + + IAmazonS3 client1 = host.Services.GetRequiredKeyedService("seaweedfs"); + IAmazonS3 client2 = host.Services.GetRequiredKeyedService("cluster2"); + + Assert.NotNull(client1); + Assert.NotNull(client2); + Assert.NotSame(client1, client2); + + IAmazonS3 defaultClient = host.Services.GetRequiredService(); + Assert.NotNull(defaultClient); + Assert.Same(client1, defaultClient); + } + + [Fact] + public void AddSeaweedFSS3Client_ConfiguresS3ClientCorrectly() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333" } + }); + + builder.AddSeaweedFSS3Client("seaweedfs"); + + using IHost host = builder.Build(); + IAmazonS3 client = host.Services.GetRequiredKeyedService("seaweedfs"); + + AmazonS3Config s3Config = Assert.IsType(client.Config); + + Assert.Equal("http://localhost:8333/", s3Config.ServiceURL); + Assert.True(s3Config.ForcePathStyle); + Assert.True(s3Config.UseHttp); + } + + [Fact] + public void AddSeaweedFSS3Client_OverridesSchemeWithUseSsl() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + // Even if HTTP is used in the string, UseSsl=true should override it to HTTPS + { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333;UseSsl=true" } + }); + + builder.AddSeaweedFSS3Client("seaweedfs"); + + using IHost host = builder.Build(); + IAmazonS3 client = host.Services.GetRequiredKeyedService("seaweedfs"); + + AmazonS3Config s3Config = Assert.IsType(client.Config); + + Assert.Equal("https://localhost:8333/", s3Config.ServiceURL); + Assert.False(s3Config.UseHttp); // S3 uses HTTPS natively + } + + [Fact] + public void AddSeaweedFSS3Client_InjectsAnonymousCredentials_WhenKeysAreMissing() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333" } + }); + + builder.AddSeaweedFSS3Client("seaweedfs"); + + using IHost host = builder.Build(); + AmazonS3Client client = (AmazonS3Client)host.Services.GetRequiredService(); + + Assert.NotNull(client); + } + + [Fact] + public void AddSeaweedFSS3Client_AppliesProvidedCredentials_FromConnectionString() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:8333;AccessKey=my-admin;SecretKey=my-secret" } + }); + + builder.AddSeaweedFSS3Client("seaweedfs"); + + using IHost host = builder.Build(); + AmazonS3Client client = (AmazonS3Client)host.Services.GetRequiredService(); + + Assert.NotNull(client); + } +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSFilerClientTests.cs b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSFilerClientTests.cs new file mode 100644 index 000000000..0136b750c --- /dev/null +++ b/tests/CommunityToolkit.Aspire.SeaweedFS.Client.Tests/SeaweedFSFilerClientTests.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.SeaweedFS.Client.Tests; + +public class SeaweedFSFilerClientTests +{ + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenHttpClientIsNull() + { + // Validates the primary constructor runtime check + ArgumentNullException exception = Assert.Throws(() => new SeaweedFSFilerClient(null!)); + Assert.Equal("httpClient", exception.ParamName); + } + + [Fact] + public void AddSeaweedFSFilerClient_ThrowsArgumentNullException_WhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + + void action() => builder.AddSeaweedFSFilerClient("seaweedfs"); + + ArgumentNullException exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddSeaweedFSFilerClient_ThrowsArgumentException_WhenConnectionNameIsNull() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + + void action() => builder.AddSeaweedFSFilerClient(null!); + + ArgumentNullException exception = Assert.Throws(action); + Assert.Equal("connectionName", exception.ParamName); + } + + [Fact] + public void AddSeaweedFSFilerClient_RegistersHttpClientWithCorrectBaseAddress() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + { "ConnectionStrings:seaweedfs", "FilerEndpoint=http://localhost:8888" } + }); + + builder.AddSeaweedFSFilerClient("seaweedfs"); + + using IHost host = builder.Build(); + SeaweedFSFilerClient client = host.Services.GetRequiredService(); + + Assert.NotNull(client); + Assert.NotNull(client.HttpClient); + Assert.Equal("http://localhost:8888/", client.HttpClient.BaseAddress?.ToString()); + } + + [Fact] + public void AddSeaweedFSFilerClient_FallbackToStandardEndpoint_WhenFilerEndpointIsMissing() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + // Simulates a legacy or simplified connection string where only 'Endpoint' is defined + { "ConnectionStrings:seaweedfs", "Endpoint=http://localhost:9999" } + }); + + builder.AddSeaweedFSFilerClient("seaweedfs"); + + using IHost host = builder.Build(); + SeaweedFSFilerClient client = host.Services.GetRequiredService(); + + Assert.NotNull(client); + Assert.NotNull(client.HttpClient); + Assert.Equal("http://localhost:9999/", client.HttpClient.BaseAddress?.ToString()); + } + + [Fact] + public void AddSeaweedFSFilerClient_UsesServiceDiscovery_WhenNoEndpointIsConfigured() + { + HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(null); + + // No connection string or endpoint is added to the configuration. + // It must fallback seamlessly to the Aspire Service Discovery protocol. + builder.AddSeaweedFSFilerClient("my-filer-cluster"); + + using IHost host = builder.Build(); + SeaweedFSFilerClient client = host.Services.GetRequiredService(); + + Assert.NotNull(client); + Assert.NotNull(client.HttpClient); + Assert.Equal("http://my-filer-cluster/", client.HttpClient.BaseAddress?.ToString()); + } +} \ No newline at end of file