From b422f461611ff5645dc545a25598ca700a52e7c9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 29 Mar 2026 13:12:43 +0000 Subject: [PATCH 1/4] Add Marketplace module with NuGet.org integration and CLI install command New module that provides a web UI to browse, search, and discover SimpleModule-compatible NuGet packages, plus a `sm install` CLI command to add packages to the host project. Features: - Browse page with card grid, search, category filtering, and sort options - Detail page with version history, install instructions, and metadata - NuGet V3 Search API integration with in-memory response caching - Category mapping from NuGet tags (Auth, Storage, UI, etc.) - Installed package detection by reading host .csproj PackageReferences - `sm install [--version]` CLI command using dotnet add --- SimpleModule.slnx | 5 + .../Commands/Install/InstallCommand.cs | 80 +++++++ .../Commands/Install/InstallSettings.cs | 15 ++ cli/SimpleModule.Cli/Program.cs | 5 + .../IMarketplaceContracts.cs | 8 + .../MarketplaceCategory.cs | 14 ++ .../MarketplacePackage.cs | 19 ++ .../MarketplacePackageDetail.cs | 30 +++ .../MarketplaceSearchRequest.cs | 10 + .../MarketplaceSearchResult.cs | 10 + .../MarketplaceSortOption.cs | 9 + .../SimpleModule.Marketplace.Contracts.csproj | 9 + .../CategoryMapper.cs | 53 +++++ .../Endpoints/Marketplace/GetByIdEndpoint.cs | 19 ++ .../Endpoints/Marketplace/SearchEndpoint.cs | 34 +++ .../InstalledPackageDetector.cs | 71 ++++++ .../MarketplaceConstants.cs | 7 + .../MarketplaceModule.cs | 56 +++++ .../MarketplaceModuleOptions.cs | 13 + .../NuGetMarketplaceService.cs | 223 ++++++++++++++++++ .../SimpleModule.Marketplace/Pages/index.ts | 4 + .../SimpleModule.Marketplace.csproj | 15 ++ .../SimpleModule.Marketplace/Views/Browse.tsx | 170 +++++++++++++ .../Views/BrowseEndpoint.cs | 49 ++++ .../SimpleModule.Marketplace/Views/Detail.tsx | 197 ++++++++++++++++ .../Views/DetailEndpoint.cs | 30 +++ .../SimpleModule.Marketplace/Views/types.ts | 25 ++ .../src/SimpleModule.Marketplace/package.json | 14 ++ .../src/SimpleModule.Marketplace/types.ts | 51 ++++ .../SimpleModule.Marketplace/vite.config.ts | 3 + .../GlobalUsings.cs | 1 + .../MarketplaceBrowseEndpointTests.cs | 27 +++ .../Integration/MarketplaceEndpointTests.cs | 55 +++++ .../SimpleModule.Marketplace.Tests.csproj | 22 ++ .../Unit/NuGetMarketplaceServiceTests.cs | 49 ++++ package-lock.json | 12 + .../SimpleModule.Host.csproj | 1 + 37 files changed, 1415 insertions(+) create mode 100644 cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs create mode 100644 cli/SimpleModule.Cli/Commands/Install/InstallSettings.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/IMarketplaceContracts.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceCategory.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackage.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchRequest.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchResult.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSortOption.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace.Contracts/SimpleModule.Marketplace.Contracts.csproj create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/CategoryMapper.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/GetByIdEndpoint.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/SearchEndpoint.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceConstants.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Pages/index.ts create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/SimpleModule.Marketplace.csproj create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Views/BrowseEndpoint.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Views/DetailEndpoint.cs create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/package.json create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/types.ts create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/vite.config.ts create mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/GlobalUsings.cs create mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs create mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs create mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/SimpleModule.Marketplace.Tests.csproj create mode 100644 modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/NuGetMarketplaceServiceTests.cs diff --git a/SimpleModule.slnx b/SimpleModule.slnx index c004fc10..717a6b8b 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -74,6 +74,11 @@ + + + + + diff --git a/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs b/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs new file mode 100644 index 00000000..eaa35f94 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Install; + +public sealed class InstallCommand : Command +{ + public override int Execute(CommandContext context, InstallSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine( + "[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]" + ); + return 1; + } + + var hostCsproj = solution.ApiCsprojPath; + if (!File.Exists(hostCsproj)) + { + AnsiConsole.MarkupLine( + $"[red]Host project not found at {Markup.Escape(hostCsproj)}[/]" + ); + return 1; + } + + AnsiConsole.MarkupLine( + $"Installing [green]{Markup.Escape(settings.PackageId)}[/] into [blue]{Markup.Escape(Path.GetFileName(hostCsproj))}[/]..." + ); + + var args = $"add \"{hostCsproj}\" package {settings.PackageId}"; + if (!string.IsNullOrWhiteSpace(settings.Version)) + { + args += $" --version {settings.Version}"; + } + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }, + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + AnsiConsole.MarkupLine("[red]Failed to install package.[/]"); + if (!string.IsNullOrWhiteSpace(error)) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(error.Trim())}[/]"); + } + + return 1; + } + + if (!string.IsNullOrWhiteSpace(output)) + { + AnsiConsole.WriteLine(output.Trim()); + } + + AnsiConsole.MarkupLine( + $"[green]Successfully installed {Markup.Escape(settings.PackageId)}![/]" + ); + AnsiConsole.MarkupLine("[dim]Run 'dotnet build' to discover the new module.[/]"); + + return 0; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Install/InstallSettings.cs b/cli/SimpleModule.Cli/Commands/Install/InstallSettings.cs new file mode 100644 index 00000000..bd4e6dc2 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Install/InstallSettings.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Install; + +public sealed class InstallSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("The NuGet package ID to install (e.g. SimpleModule.MyModule)")] + public string PackageId { get; set; } = string.Empty; + + [CommandOption("--version ")] + [Description("Specific package version to install")] + public string? Version { get; set; } +} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 35f68d75..896ea1da 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -1,4 +1,5 @@ using SimpleModule.Cli.Commands.Doctor; +using SimpleModule.Cli.Commands.Install; using SimpleModule.Cli.Commands.New; using Spectre.Console.Cli; @@ -25,6 +26,10 @@ } ); + config + .AddCommand("install") + .WithDescription("Install a SimpleModule package from NuGet"); + config .AddCommand("doctor") .WithDescription("Validate project structure and conventions"); diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/IMarketplaceContracts.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/IMarketplaceContracts.cs new file mode 100644 index 00000000..0fe12245 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/IMarketplaceContracts.cs @@ -0,0 +1,8 @@ +namespace SimpleModule.Marketplace.Contracts; + +public interface IMarketplaceContracts +{ + Task SearchPackagesAsync(MarketplaceSearchRequest request); + Task GetPackageDetailsAsync(string packageId); + Task> GetInstalledPackageIdsAsync(); +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceCategory.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceCategory.cs new file mode 100644 index 00000000..1d31b6d2 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceCategory.cs @@ -0,0 +1,14 @@ +namespace SimpleModule.Marketplace.Contracts; + +public enum MarketplaceCategory +{ + All, + Auth, + Storage, + UI, + Analytics, + Integration, + Communication, + Monitoring, + Other, +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackage.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackage.cs new file mode 100644 index 00000000..08722cb2 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackage.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core; + +namespace SimpleModule.Marketplace.Contracts; + +[Dto] +public class MarketplacePackage +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Authors { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public long TotalDownloads { get; set; } + public List Tags { get; set; } = []; + public string LatestVersion { get; set; } = string.Empty; + public string ProjectLink { get; set; } = string.Empty; + public MarketplaceCategory Category { get; set; } + public bool IsInstalled { get; set; } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs new file mode 100644 index 00000000..863938d0 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs @@ -0,0 +1,30 @@ +using SimpleModule.Core; + +namespace SimpleModule.Marketplace.Contracts; + +[Dto] +public class MarketplacePackageDetail +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Authors { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; + public long TotalDownloads { get; set; } + public List Tags { get; set; } = []; + public string LatestVersion { get; set; } = string.Empty; + public string ProjectLink { get; set; } = string.Empty; + public string LicenseLink { get; set; } = string.Empty; + public MarketplaceCategory Category { get; set; } + public bool IsInstalled { get; set; } + public List Versions { get; set; } = []; + public List Dependencies { get; set; } = []; +} + +[Dto] +public class MarketplacePackageVersion +{ + public string Version { get; set; } = string.Empty; + public long Downloads { get; set; } + public DateTime Published { get; set; } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchRequest.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchRequest.cs new file mode 100644 index 00000000..898712e6 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchRequest.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Marketplace.Contracts; + +public class MarketplaceSearchRequest +{ + public string? Query { get; set; } + public MarketplaceCategory? Category { get; set; } + public MarketplaceSortOption SortBy { get; set; } = MarketplaceSortOption.Relevance; + public int Skip { get; set; } + public int Take { get; set; } = 20; +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchResult.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchResult.cs new file mode 100644 index 00000000..0ade4f57 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSearchResult.cs @@ -0,0 +1,10 @@ +using SimpleModule.Core; + +namespace SimpleModule.Marketplace.Contracts; + +[Dto] +public class MarketplaceSearchResult +{ + public int TotalHits { get; set; } + public List Packages { get; set; } = []; +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSortOption.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSortOption.cs new file mode 100644 index 00000000..2f18d489 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplaceSortOption.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Marketplace.Contracts; + +public enum MarketplaceSortOption +{ + Relevance, + Downloads, + RecentlyUpdated, + Alphabetical, +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/SimpleModule.Marketplace.Contracts.csproj b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/SimpleModule.Marketplace.Contracts.csproj new file mode 100644 index 00000000..b33451b0 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/SimpleModule.Marketplace.Contracts.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Library + + + + + diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/CategoryMapper.cs b/modules/Marketplace/src/SimpleModule.Marketplace/CategoryMapper.cs new file mode 100644 index 00000000..c331e408 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/CategoryMapper.cs @@ -0,0 +1,53 @@ +using SimpleModule.Marketplace.Contracts; + +namespace SimpleModule.Marketplace; + +public static class CategoryMapper +{ + private static readonly Dictionary TagCategoryMap = + new(StringComparer.OrdinalIgnoreCase) + { + ["auth"] = MarketplaceCategory.Auth, + ["authentication"] = MarketplaceCategory.Auth, + ["authorization"] = MarketplaceCategory.Auth, + ["identity"] = MarketplaceCategory.Auth, + ["openiddict"] = MarketplaceCategory.Auth, + ["oauth"] = MarketplaceCategory.Auth, + ["storage"] = MarketplaceCategory.Storage, + ["blob"] = MarketplaceCategory.Storage, + ["file-storage"] = MarketplaceCategory.Storage, + ["s3"] = MarketplaceCategory.Storage, + ["azure-storage"] = MarketplaceCategory.Storage, + ["ui"] = MarketplaceCategory.UI, + ["components"] = MarketplaceCategory.UI, + ["blazor"] = MarketplaceCategory.UI, + ["react"] = MarketplaceCategory.UI, + ["analytics"] = MarketplaceCategory.Analytics, + ["reporting"] = MarketplaceCategory.Analytics, + ["dashboard"] = MarketplaceCategory.Analytics, + ["integration"] = MarketplaceCategory.Integration, + ["api"] = MarketplaceCategory.Integration, + ["webhook"] = MarketplaceCategory.Integration, + ["email"] = MarketplaceCategory.Communication, + ["sms"] = MarketplaceCategory.Communication, + ["notification"] = MarketplaceCategory.Communication, + ["messaging"] = MarketplaceCategory.Communication, + ["monitoring"] = MarketplaceCategory.Monitoring, + ["logging"] = MarketplaceCategory.Monitoring, + ["health-check"] = MarketplaceCategory.Monitoring, + ["audit"] = MarketplaceCategory.Monitoring, + }; + + public static MarketplaceCategory MapCategory(IEnumerable tags) + { + foreach (var tag in tags) + { + if (TagCategoryMap.TryGetValue(tag, out var category)) + { + return category; + } + } + + return MarketplaceCategory.Other; + } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/GetByIdEndpoint.cs b/modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/GetByIdEndpoint.cs new file mode 100644 index 00000000..d34729f3 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/GetByIdEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Endpoints; +using SimpleModule.Marketplace.Contracts; + +namespace SimpleModule.Marketplace.Endpoints.Marketplace; + +public class GetByIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/{id}", + (IMarketplaceContracts marketplace, string id) => + CrudEndpoints.GetById(() => marketplace.GetPackageDetailsAsync(id)) + ) + .AllowAnonymous(); +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/SearchEndpoint.cs b/modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/SearchEndpoint.cs new file mode 100644 index 00000000..16082ace --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Endpoints/Marketplace/SearchEndpoint.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Marketplace.Contracts; + +namespace SimpleModule.Marketplace.Endpoints.Marketplace; + +public class SearchEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet( + "/", + ( + IMarketplaceContracts marketplace, + string? q, + MarketplaceCategory? category, + MarketplaceSortOption? sort, + int? skip, + int? take + ) => + marketplace.SearchPackagesAsync( + new MarketplaceSearchRequest + { + Query = q, + Category = category, + SortBy = sort ?? MarketplaceSortOption.Relevance, + Skip = skip ?? 0, + Take = take ?? 20, + } + ) + ) + .AllowAnonymous(); +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs b/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs new file mode 100644 index 00000000..bbdae251 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs @@ -0,0 +1,71 @@ +using System.Xml; +using System.Xml.Linq; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace SimpleModule.Marketplace; + +public partial class InstalledPackageDetector( + IWebHostEnvironment environment, + IMemoryCache cache, + ILogger logger +) +{ + private const string CacheKey = "Marketplace:InstalledPackages"; + + public Task> GetInstalledPackageIdsAsync() + { + return Task.FromResult( + cache.GetOrCreate( + CacheKey, + entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); + return ReadInstalledPackages(); + } + ) ?? [] + ); + } + + private HashSet ReadInstalledPackages() + { + var contentRoot = environment.ContentRootPath; + var csprojFiles = Directory.GetFiles(contentRoot, "*.csproj", SearchOption.TopDirectoryOnly); + + var packages = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var csproj in csprojFiles) + { + try + { + var doc = XDocument.Load(csproj); + var packageRefs = doc + .Descendants("PackageReference") + .Select(e => e.Attribute("Include")?.Value) + .Where(v => v is not null); + + foreach (var packageId in packageRefs) + { + packages.Add(packageId!); + } + } + catch (XmlException ex) + { + LogCsprojParseError(logger, csproj, ex); + } + } + + return packages; + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to parse csproj file {CsprojPath}" + )] + private static partial void LogCsprojParseError( + ILogger logger, + string csprojPath, + XmlException exception + ); +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceConstants.cs b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceConstants.cs new file mode 100644 index 00000000..d5d7f0db --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceConstants.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.Marketplace; + +public static class MarketplaceConstants +{ + public const string ModuleName = "Marketplace"; + public const string RoutePrefix = "/api/marketplace"; +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs new file mode 100644 index 00000000..9a9b4fb3 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; +using SimpleModule.Core.Menu; + +namespace SimpleModule.Marketplace; + +[Module( + MarketplaceConstants.ModuleName, + RoutePrefix = MarketplaceConstants.RoutePrefix, + ViewPrefix = "/marketplace" +)] +public class MarketplaceModule : IModule +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddHttpClient( + MarketplaceConstants.ModuleName, + client => + { + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.DefaultRequestHeaders.Add("User-Agent", "SimpleModule-Marketplace"); + } + ); + + services.AddMemoryCache(); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Marketplace", + Url = "/marketplace/browse", + Icon = + """""", + Order = 40, + Section = MenuSection.Navbar, + RequiresAuth = false, + } + ); + menus.Add( + new MenuItem + { + Label = "Marketplace", + Url = "/marketplace/browse", + Icon = + """""", + Order = 40, + Section = MenuSection.AppSidebar, + RequiresAuth = false, + } + ); + } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs new file mode 100644 index 00000000..1b5f1ed5 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs @@ -0,0 +1,13 @@ +using SimpleModule.Core; + +namespace SimpleModule.Marketplace; + +public class MarketplaceModuleOptions : IModuleOptions +{ + public string NuGetSearchBaseAddress { get; set; } = "https://api.nuget.org/v3/search"; + public string NuGetRegistrationBaseAddress { get; set; } = + "https://api.nuget.org/v3/registration5-gz-semver2"; + public string PackageTag { get; set; } = "simplemodule"; + public int SearchCacheDurationMinutes { get; set; } = 5; + public int DetailCacheDurationMinutes { get; set; } = 10; +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs new file mode 100644 index 00000000..f95c8286 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs @@ -0,0 +1,223 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using SimpleModule.Marketplace.Contracts; + +namespace SimpleModule.Marketplace; + +public class NuGetMarketplaceService( + IHttpClientFactory httpClientFactory, + IOptions options, + InstalledPackageDetector installedPackageDetector, + IMemoryCache cache +) : IMarketplaceContracts +{ + private static readonly JsonSerializerOptions JsonOptions = + new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public async Task SearchPackagesAsync(MarketplaceSearchRequest request) + { + var cacheKey = + $"Marketplace:Search:{request.Query}:{request.Category}:{request.SortBy}:{request.Skip}:{request.Take}"; + + var cached = await cache.GetOrCreateAsync( + cacheKey, + async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes( + options.Value.SearchCacheDurationMinutes + ); + return await FetchSearchResultsAsync(request); + } + ); + + return cached ?? new MarketplaceSearchResult(); + } + + public async Task GetPackageDetailsAsync(string packageId) + { + var cacheKey = $"Marketplace:Detail:{packageId}"; + + return await cache.GetOrCreateAsync( + cacheKey, + async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes( + options.Value.DetailCacheDurationMinutes + ); + return await FetchPackageDetailsAsync(packageId); + } + ); + } + + public Task> GetInstalledPackageIdsAsync() => + installedPackageDetector.GetInstalledPackageIdsAsync(); + + private async Task FetchSearchResultsAsync( + MarketplaceSearchRequest request + ) + { + var client = httpClientFactory.CreateClient(MarketplaceConstants.ModuleName); + var tag = options.Value.PackageTag; + var query = string.IsNullOrWhiteSpace(request.Query) + ? $"tag:{tag}" + : $"tag:{tag} {request.Query}"; + + var url = + $"{options.Value.NuGetSearchBaseAddress}?q={Uri.EscapeDataString(query)}&skip={request.Skip}&take={request.Take}"; + + var response = await client.GetFromJsonAsync(url, JsonOptions); + if (response is null) + { + return new MarketplaceSearchResult(); + } + + var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync(); + + var packages = response.Data.Select(d => MapToPackage(d, installedIds)).ToList(); + + if (request.Category is not null and not MarketplaceCategory.All) + { + packages = packages.Where(p => p.Category == request.Category).ToList(); + } + + packages = request.SortBy switch + { + MarketplaceSortOption.Downloads => + [ + .. packages.OrderByDescending(p => p.TotalDownloads), + ], + MarketplaceSortOption.Alphabetical => [.. packages.OrderBy(p => p.Title)], + MarketplaceSortOption.RecentlyUpdated => packages, + _ => packages, + }; + + return new MarketplaceSearchResult { TotalHits = response.TotalHits, Packages = packages }; + } + + private async Task FetchPackageDetailsAsync(string packageId) + { + var client = httpClientFactory.CreateClient(MarketplaceConstants.ModuleName); + var tag = options.Value.PackageTag; + var searchAddress = + $"{options.Value.NuGetSearchBaseAddress}?q=packageid:{Uri.EscapeDataString(packageId)} tag:{tag}&take=1"; + + var searchResponse = await client.GetFromJsonAsync( + searchAddress, + JsonOptions + ); + var packageData = searchResponse?.Data.FirstOrDefault(); + if (packageData is null) + { + return null; + } + + var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync(); + + return new MarketplacePackageDetail + { + Id = packageData.Id ?? string.Empty, + Title = packageData.Title ?? packageData.Id ?? string.Empty, + Description = packageData.Description ?? string.Empty, + Authors = string.Join(", ", packageData.Authors ?? []), + Icon = packageData.IconAddress ?? string.Empty, + TotalDownloads = packageData.TotalDownloads, + Tags = packageData.Tags ?? [], + LatestVersion = packageData.Version ?? string.Empty, + ProjectLink = packageData.ProjectAddress ?? string.Empty, + LicenseLink = packageData.LicenseAddress ?? string.Empty, + Category = CategoryMapper.MapCategory(packageData.Tags ?? []), + IsInstalled = installedIds.Contains(packageData.Id ?? string.Empty), + Versions = (packageData.Versions ?? []) + .Select(v => new MarketplacePackageVersion + { + Version = v.Version ?? string.Empty, + Downloads = v.Downloads, + Published = default, + }) + .ToList(), + Dependencies = [], + }; + } + + private static MarketplacePackage MapToPackage( + NuGetPackageData data, + HashSet installedIds + ) + { + return new MarketplacePackage + { + Id = data.Id ?? string.Empty, + Title = data.Title ?? data.Id ?? string.Empty, + Description = data.Description ?? string.Empty, + Authors = string.Join(", ", data.Authors ?? []), + Icon = data.IconAddress ?? string.Empty, + TotalDownloads = data.TotalDownloads, + Tags = data.Tags ?? [], + LatestVersion = data.Version ?? string.Empty, + ProjectLink = data.ProjectAddress ?? string.Empty, + Category = CategoryMapper.MapCategory(data.Tags ?? []), + IsInstalled = installedIds.Contains(data.Id ?? string.Empty), + }; + } +} + +// NuGet V3 Search API response models — instantiated by JSON deserialization +[JsonSerializable(typeof(NuGetSearchResponse))] +internal sealed partial class NuGetJsonContext : JsonSerializerContext; + +internal sealed record NuGetSearchResponse +{ + [JsonPropertyName("totalHits")] + public int TotalHits { get; init; } + + [JsonPropertyName("data")] + public List Data { get; init; } = []; +} + +internal sealed record NuGetPackageData +{ + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("version")] + public string? Version { get; init; } + + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("authors")] + public List? Authors { get; init; } + + [JsonPropertyName("iconUrl")] + public string? IconAddress { get; init; } + + [JsonPropertyName("totalDownloads")] + public long TotalDownloads { get; init; } + + [JsonPropertyName("tags")] + public List? Tags { get; init; } + + [JsonPropertyName("projectUrl")] + public string? ProjectAddress { get; init; } + + [JsonPropertyName("licenseUrl")] + public string? LicenseAddress { get; init; } + + [JsonPropertyName("versions")] + public List? Versions { get; init; } +} + +internal sealed record NuGetVersionData +{ + [JsonPropertyName("version")] + public string? Version { get; init; } + + [JsonPropertyName("downloads")] + public long Downloads { get; init; } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Pages/index.ts b/modules/Marketplace/src/SimpleModule.Marketplace/Pages/index.ts new file mode 100644 index 00000000..17bf1249 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Pages/index.ts @@ -0,0 +1,4 @@ +export const pages: Record = { + 'Marketplace/Browse': () => import('../Views/Browse'), + 'Marketplace/Detail': () => import('../Views/Detail'), +}; diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/SimpleModule.Marketplace.csproj b/modules/Marketplace/src/SimpleModule.Marketplace/SimpleModule.Marketplace.csproj new file mode 100644 index 00000000..a0aaba25 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/SimpleModule.Marketplace.csproj @@ -0,0 +1,15 @@ + + + net10.0 + + + + + + + + + %(Filename)Endpoint.cs + + + diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx new file mode 100644 index 00000000..45f6451c --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx @@ -0,0 +1,170 @@ +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Input, + PageShell, +} from '@simplemodule/ui'; +import { useState } from 'react'; +import type { MarketplacePackage } from './types'; + +const categories = [ + 'All', + 'Auth', + 'Storage', + 'UI', + 'Analytics', + 'Integration', + 'Communication', + 'Monitoring', + 'Other', +]; +const sortOptions = [ + { value: 'Relevance', label: 'Relevance' }, + { value: 'Downloads', label: 'Most Downloads' }, + { value: 'Alphabetical', label: 'A-Z' }, +]; + +interface Props { + packages: MarketplacePackage[]; + totalHits: number; + query: string; + selectedCategory: string; + selectedSort: string; +} + +function formatDownloads(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return count.toString(); +} + +export default function Browse({ + packages, + totalHits, + query, + selectedCategory, + selectedSort, +}: Props) { + const [search, setSearch] = useState(query); + + function navigate(params: Record) { + const current = new URLSearchParams(); + if (query) current.set('q', query); + if (selectedCategory !== 'All') current.set('category', selectedCategory); + if (selectedSort !== 'Relevance') current.set('sort', selectedSort); + + for (const [key, value] of Object.entries(params)) { + if (value && value !== 'All' && value !== 'Relevance') { + current.set(key, value); + } else { + current.delete(key); + } + } + + router.get(`/marketplace/browse?${current.toString()}`); + } + + function handleSearch(e: React.FormEvent) { + e.preventDefault(); + navigate({ q: search }); + } + + return ( + +
+
+ setSearch(e.target.value)} + className="flex-1" + /> + +
+ +
+
+ {categories.map((cat) => ( + + ))} +
+ + +
+ +
+ {packages.map((pkg) => ( + router.get(`/marketplace/${pkg.id}`)} + > + +
+ {pkg.icon ? ( + + ) : ( +
+ + + +
+ )} +
+ {pkg.title} +

v{pkg.latestVersion}

+
+
+
+ +

{pkg.description}

+
+ {pkg.category} + {pkg.isInstalled && Installed} + + {formatDownloads(pkg.totalDownloads)} downloads + +
+
+
+ ))} +
+ + {packages.length === 0 && ( +
+

No modules found

+

Try adjusting your search or filters.

+
+ )} +
+
+ ); +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/BrowseEndpoint.cs b/modules/Marketplace/src/SimpleModule.Marketplace/Views/BrowseEndpoint.cs new file mode 100644 index 00000000..a9c5f20d --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/BrowseEndpoint.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Marketplace.Contracts; + +namespace SimpleModule.Marketplace.Views; + +[ViewPage("Marketplace/Browse")] +public class BrowseEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/browse", + async ( + IMarketplaceContracts marketplace, + string? q, + MarketplaceCategory? category, + MarketplaceSortOption? sort + ) => + { + var result = await marketplace.SearchPackagesAsync( + new MarketplaceSearchRequest + { + Query = q, + Category = category, + SortBy = sort ?? MarketplaceSortOption.Relevance, + Take = 50, + } + ); + + return Inertia.Render( + "Marketplace/Browse", + new + { + packages = result.Packages, + totalHits = result.TotalHits, + query = q ?? string.Empty, + selectedCategory = category?.ToString() ?? "All", + selectedSort = sort?.ToString() ?? "Relevance", + } + ); + } + ) + .AllowAnonymous(); + } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx new file mode 100644 index 00000000..417a85d7 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx @@ -0,0 +1,197 @@ +import { router } from '@inertiajs/react'; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + PageShell, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simplemodule/ui'; +import { useState } from 'react'; +import type { MarketplacePackageDetail } from './types'; + +interface Props { + package: MarketplacePackageDetail; +} + +function formatDownloads(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return count.toString(); +} + +export default function Detail({ package: pkg }: Props) { + const [copied, setCopied] = useState(false); + const installCommand = `sm install ${pkg.id}`; + + function handleCopy() { + navigator.clipboard.writeText(installCommand); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + +
+
+ + +
+ {pkg.icon ? ( + + ) : ( +
+ + + +
+ )} +
+ {pkg.title} +

by {pkg.authors}

+
+ {pkg.category} + v{pkg.latestVersion} + {pkg.isInstalled && Installed} +
+
+
+
+ +

{pkg.description}

+
+
+ + {pkg.versions.length > 0 && ( + + + Versions + + + + + + Version + Downloads + + + + {pkg.versions.slice(0, 10).map((v) => ( + + {v.version} + {formatDownloads(v.downloads)} + + ))} + +
+
+
+ )} +
+ +
+ + + Install + + +
+ {installCommand} + +
+

+ Run this command in your SimpleModule project root. +

+
+
+ + + + Info + + +
+ Downloads + {formatDownloads(pkg.totalDownloads)} +
+
+ Latest version + {pkg.latestVersion} +
+ {pkg.projectLink && ( +
+ Project + + View + +
+ )} + {pkg.licenseLink && ( +
+ License + + View + +
+ )} +
+
+ + {pkg.tags.length > 0 && ( + + + Tags + + +
+ {pkg.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ )} + + +
+
+
+ ); +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/DetailEndpoint.cs b/modules/Marketplace/src/SimpleModule.Marketplace/Views/DetailEndpoint.cs new file mode 100644 index 00000000..9482065b --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/DetailEndpoint.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Marketplace.Contracts; + +namespace SimpleModule.Marketplace.Views; + +[ViewPage("Marketplace/Detail")] +public class DetailEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + "/{id}", + async (IMarketplaceContracts marketplace, string id) => + { + var package = await marketplace.GetPackageDetailsAsync(id); + if (package is null) + { + return Results.NotFound(); + } + + return Inertia.Render("Marketplace/Detail", new { package }); + } + ) + .AllowAnonymous(); + } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts b/modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts new file mode 100644 index 00000000..9378f27b --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts @@ -0,0 +1,25 @@ +export interface MarketplacePackage { + id: string; + title: string; + description: string; + authors: string; + icon: string; + totalDownloads: number; + tags: string[]; + latestVersion: string; + projectLink: string; + category: string; + isInstalled: boolean; +} + +export interface MarketplacePackageDetail extends MarketplacePackage { + licenseLink: string; + versions: MarketplacePackageVersion[]; + dependencies: string[]; +} + +export interface MarketplacePackageVersion { + version: string; + downloads: number; + published: string; +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/package.json b/modules/Marketplace/src/SimpleModule.Marketplace/package.json new file mode 100644 index 00000000..9fc15037 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "name": "@simplemodule/marketplace", + "version": "0.0.0", + "scripts": { + "build": "cross-env VITE_MODE=prod vite build", + "build:dev": "cross-env VITE_MODE=dev vite build", + "watch": "cross-env VITE_MODE=dev vite build --watch" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/types.ts b/modules/Marketplace/src/SimpleModule.Marketplace/types.ts new file mode 100644 index 00000000..31c0fc0a --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/types.ts @@ -0,0 +1,51 @@ +// Auto-generated from [Dto] types — do not edit +export interface MarketplacePackage { + id: string; + title: string; + description: string; + authors: string; + icon: string; + totalDownloads: number; + tags: string[]; + latestVersion: string; + projectLink: string; + category: any; + isInstalled: boolean; +} + +export interface MarketplacePackageDetail { + id: string; + title: string; + description: string; + authors: string; + icon: string; + totalDownloads: number; + tags: string[]; + latestVersion: string; + projectLink: string; + licenseLink: string; + category: any; + isInstalled: boolean; + versions: MarketplacePackageVersion[]; + dependencies: string[]; +} + +export interface MarketplacePackageVersion { + version: string; + downloads: number; + published: string; +} + +export interface MarketplaceSearchResult { + totalHits: number; + packages: MarketplacePackage[]; +} + +export interface MarketplaceSearchRequest { + query: string; + category: any | null; + sortBy: any; + skip: number; + take: number; +} + diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/vite.config.ts b/modules/Marketplace/src/SimpleModule.Marketplace/vite.config.ts new file mode 100644 index 00000000..a3d37c71 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/vite.config.ts @@ -0,0 +1,3 @@ +import { defineModuleConfig } from '@simplemodule/client/module'; + +export default defineModuleConfig(__dirname); diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/GlobalUsings.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs new file mode 100644 index 00000000..608d8094 --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs @@ -0,0 +1,27 @@ +using System.Net; +using FluentAssertions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.Marketplace.Tests.Integration; + +public class MarketplaceBrowseEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + + public MarketplaceBrowseEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task Browse_EndpointIsRegistered_DoesNotReturn404() + { + var response = await _client.GetAsync("/marketplace/browse"); + + // The endpoint should be registered (not 404/405). + // It may return 500 if the external NuGet API is unreachable in CI, + // but it should never be a 404 (route not found). + response.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); + } +} diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs new file mode 100644 index 00000000..9d4914a0 --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs @@ -0,0 +1,55 @@ +using System.Net; +using FluentAssertions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.Marketplace.Tests.Integration; + +public class MarketplaceEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + + public MarketplaceEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task Search_EndpointIsRegistered_DoesNotReturn404() + { + var response = await _client.GetAsync("/api/marketplace"); + + // The endpoint should be registered (not 404/405). + // May return 500 if the external NuGet API is unreachable in CI. + response.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + public async Task Search_WithQuery_EndpointIsRegistered() + { + var response = await _client.GetAsync("/api/marketplace?q=test"); + + response.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + public async Task GetById_EndpointIsRegistered() + { + var response = await _client.GetAsync("/api/marketplace/some-package"); + + // The endpoint should be registered. It returns 404 (NotFound result) + // if the package doesn't exist on NuGet, or 500 if the API is unreachable. + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + public async Task Search_AllowsAnonymousAccess() + { + // No auth client — should not get 401/403 + var response = await _client.GetAsync("/api/marketplace"); + + response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized); + response.StatusCode.Should().NotBe(HttpStatusCode.Forbidden); + } +} diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/SimpleModule.Marketplace.Tests.csproj b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/SimpleModule.Marketplace.Tests.csproj new file mode 100644 index 00000000..78f05516 --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/SimpleModule.Marketplace.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/NuGetMarketplaceServiceTests.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/NuGetMarketplaceServiceTests.cs new file mode 100644 index 00000000..f2476b56 --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/NuGetMarketplaceServiceTests.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using SimpleModule.Marketplace.Contracts; + +namespace SimpleModule.Marketplace.Tests.Unit; + +public class CategoryMapperTests +{ + [Theory] + [InlineData("auth", MarketplaceCategory.Auth)] + [InlineData("authentication", MarketplaceCategory.Auth)] + [InlineData("identity", MarketplaceCategory.Auth)] + [InlineData("storage", MarketplaceCategory.Storage)] + [InlineData("blob", MarketplaceCategory.Storage)] + [InlineData("ui", MarketplaceCategory.UI)] + [InlineData("components", MarketplaceCategory.UI)] + [InlineData("analytics", MarketplaceCategory.Analytics)] + [InlineData("email", MarketplaceCategory.Communication)] + [InlineData("monitoring", MarketplaceCategory.Monitoring)] + [InlineData("webhook", MarketplaceCategory.Integration)] + public void MapCategory_KnownTag_ReturnsExpectedCategory( + string tag, + MarketplaceCategory expected + ) + { + var result = CategoryMapper.MapCategory([tag]); + result.Should().Be(expected); + } + + [Fact] + public void MapCategory_UnknownTag_ReturnsOther() + { + var result = CategoryMapper.MapCategory(["something-unknown"]); + result.Should().Be(MarketplaceCategory.Other); + } + + [Fact] + public void MapCategory_EmptyTags_ReturnsOther() + { + var result = CategoryMapper.MapCategory([]); + result.Should().Be(MarketplaceCategory.Other); + } + + [Fact] + public void MapCategory_MultipleTags_ReturnsFirstMatch() + { + var result = CategoryMapper.MapCategory(["something", "auth", "storage"]); + result.Should().Be(MarketplaceCategory.Auth); + } +} diff --git a/package-lock.json b/package-lock.json index fcc27843..8bca764e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,6 +105,14 @@ "react-dom": "^19.0.0" } }, + "modules/Marketplace/src/SimpleModule.Marketplace": { + "name": "@simplemodule/marketplace", + "version": "0.0.0", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/OpenIddict/src/OpenIddict": { "name": "@simplemodule/openiddict", "version": "0.0.0", @@ -3488,6 +3496,10 @@ "resolved": "modules/FileStorage/src/SimpleModule.FileStorage", "link": true }, + "node_modules/@simplemodule/marketplace": { + "resolved": "modules/Marketplace/src/SimpleModule.Marketplace", + "link": true + }, "node_modules/@simplemodule/openiddict": { "resolved": "modules/OpenIddict/src/SimpleModule.OpenIddict", "link": true diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj index 98a2a828..e85dc158 100644 --- a/template/SimpleModule.Host/SimpleModule.Host.csproj +++ b/template/SimpleModule.Host/SimpleModule.Host.csproj @@ -28,6 +28,7 @@ + From b70b269939685663563b9c295d7bb0ba29edd237 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 29 Mar 2026 15:49:40 +0000 Subject: [PATCH 2/4] Simplify Marketplace module: fix bugs, reduce duplication, improve caching - Fix CLI InstallCommand stdout/stderr deadlock (read streams concurrently) - Fix Browse.tsx navigate() using stale query prop instead of live search state - Fix NuGet cache key to exclude category/sort (filter post-cache, correct TotalHits) - Remove dead NuGetJsonContext and unused JsonOptions - Extract formatDownloads into shared Views/utils.ts - Extract duplicate SVG icon to constant in MarketplaceModule.cs - Make MarketplacePackageDetail inherit from MarketplacePackage - Remove always-default Published field from MarketplacePackageVersion - Delete hand-written Views/types.ts, use generated types.ts - Rename test file to match class name (CategoryMapperTests) --- .../Commands/Install/InstallCommand.cs | 6 +- .../MarketplacePackageDetail.cs | 14 +--- .../MarketplaceModule.cs | 9 +- .../NuGetMarketplaceService.cs | 83 +++++++++---------- .../SimpleModule.Marketplace/Views/Browse.tsx | 11 +-- .../SimpleModule.Marketplace/Views/Detail.tsx | 9 +- .../SimpleModule.Marketplace/Views/types.ts | 25 ------ .../SimpleModule.Marketplace/Views/utils.ts | 5 ++ .../src/SimpleModule.Marketplace/types.ts | 12 --- ...ServiceTests.cs => CategoryMapperTests.cs} | 0 10 files changed, 59 insertions(+), 115 deletions(-) delete mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts create mode 100644 modules/Marketplace/src/SimpleModule.Marketplace/Views/utils.ts rename modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/{NuGetMarketplaceServiceTests.cs => CategoryMapperTests.cs} (100%) diff --git a/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs b/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs index eaa35f94..a5f96092 100644 --- a/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs @@ -50,9 +50,11 @@ public override int Execute(CommandContext context, InstallSettings settings) }; process.Start(); - var output = process.StandardOutput.ReadToEnd(); - var error = process.StandardError.ReadToEnd(); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); process.WaitForExit(); + var output = outputTask.GetAwaiter().GetResult(); + var error = errorTask.GetAwaiter().GetResult(); if (process.ExitCode != 0) { diff --git a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs index 863938d0..862b599f 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs @@ -3,20 +3,9 @@ namespace SimpleModule.Marketplace.Contracts; [Dto] -public class MarketplacePackageDetail +public class MarketplacePackageDetail : MarketplacePackage { - public string Id { get; set; } = string.Empty; - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Authors { get; set; } = string.Empty; - public string Icon { get; set; } = string.Empty; - public long TotalDownloads { get; set; } - public List Tags { get; set; } = []; - public string LatestVersion { get; set; } = string.Empty; - public string ProjectLink { get; set; } = string.Empty; public string LicenseLink { get; set; } = string.Empty; - public MarketplaceCategory Category { get; set; } - public bool IsInstalled { get; set; } public List Versions { get; set; } = []; public List Dependencies { get; set; } = []; } @@ -26,5 +15,4 @@ public class MarketplacePackageVersion { public string Version { get; set; } = string.Empty; public long Downloads { get; set; } - public DateTime Published { get; set; } } diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs index 9a9b4fb3..0247fb62 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs @@ -12,6 +12,9 @@ namespace SimpleModule.Marketplace; )] public class MarketplaceModule : IModule { + private const string MenuIcon = + """"""; + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddHttpClient( @@ -33,8 +36,7 @@ public void ConfigureMenu(IMenuBuilder menus) { Label = "Marketplace", Url = "/marketplace/browse", - Icon = - """""", + Icon = MenuIcon, Order = 40, Section = MenuSection.Navbar, RequiresAuth = false, @@ -45,8 +47,7 @@ public void ConfigureMenu(IMenuBuilder menus) { Label = "Marketplace", Url = "/marketplace/browse", - Icon = - """""", + Icon = MenuIcon, Order = 40, Section = MenuSection.AppSidebar, RequiresAuth = false, diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs index f95c8286..f4c6dad3 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs @@ -1,5 +1,5 @@ +using System.Diagnostics.CodeAnalysis; using System.Net.Http.Json; -using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -14,13 +14,9 @@ public class NuGetMarketplaceService( IMemoryCache cache ) : IMarketplaceContracts { - private static readonly JsonSerializerOptions JsonOptions = - new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - public async Task SearchPackagesAsync(MarketplaceSearchRequest request) { - var cacheKey = - $"Marketplace:Search:{request.Query}:{request.Category}:{request.SortBy}:{request.Skip}:{request.Take}"; + var cacheKey = $"Marketplace:Search:{request.Query}:{request.Skip}:{request.Take}"; var cached = await cache.GetOrCreateAsync( cacheKey, @@ -33,7 +29,26 @@ public async Task SearchPackagesAsync(MarketplaceSearch } ); - return cached ?? new MarketplaceSearchResult(); + var result = cached ?? new MarketplaceSearchResult(); + + var packages = result.Packages; + + if (request.Category is not null and not MarketplaceCategory.All) + { + packages = packages.Where(p => p.Category == request.Category).ToList(); + } + + packages = request.SortBy switch + { + MarketplaceSortOption.Downloads => + [ + .. packages.OrderByDescending(p => p.TotalDownloads), + ], + MarketplaceSortOption.Alphabetical => [.. packages.OrderBy(p => p.Title)], + _ => packages, + }; + + return new MarketplaceSearchResult { TotalHits = packages.Count, Packages = packages }; } public async Task GetPackageDetailsAsync(string packageId) @@ -68,7 +83,7 @@ MarketplaceSearchRequest request var url = $"{options.Value.NuGetSearchBaseAddress}?q={Uri.EscapeDataString(query)}&skip={request.Skip}&take={request.Take}"; - var response = await client.GetFromJsonAsync(url, JsonOptions); + var response = await client.GetFromJsonAsync(url); if (response is null) { return new MarketplaceSearchResult(); @@ -78,22 +93,6 @@ MarketplaceSearchRequest request var packages = response.Data.Select(d => MapToPackage(d, installedIds)).ToList(); - if (request.Category is not null and not MarketplaceCategory.All) - { - packages = packages.Where(p => p.Category == request.Category).ToList(); - } - - packages = request.SortBy switch - { - MarketplaceSortOption.Downloads => - [ - .. packages.OrderByDescending(p => p.TotalDownloads), - ], - MarketplaceSortOption.Alphabetical => [.. packages.OrderBy(p => p.Title)], - MarketplaceSortOption.RecentlyUpdated => packages, - _ => packages, - }; - return new MarketplaceSearchResult { TotalHits = response.TotalHits, Packages = packages }; } @@ -104,10 +103,7 @@ .. packages.OrderByDescending(p => p.TotalDownloads), var searchAddress = $"{options.Value.NuGetSearchBaseAddress}?q=packageid:{Uri.EscapeDataString(packageId)} tag:{tag}&take=1"; - var searchResponse = await client.GetFromJsonAsync( - searchAddress, - JsonOptions - ); + var searchResponse = await client.GetFromJsonAsync(searchAddress); var packageData = searchResponse?.Data.FirstOrDefault(); if (packageData is null) { @@ -115,27 +111,27 @@ .. packages.OrderByDescending(p => p.TotalDownloads), } var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync(); + var basePackage = MapToPackage(packageData, installedIds); return new MarketplacePackageDetail { - Id = packageData.Id ?? string.Empty, - Title = packageData.Title ?? packageData.Id ?? string.Empty, - Description = packageData.Description ?? string.Empty, - Authors = string.Join(", ", packageData.Authors ?? []), - Icon = packageData.IconAddress ?? string.Empty, - TotalDownloads = packageData.TotalDownloads, - Tags = packageData.Tags ?? [], - LatestVersion = packageData.Version ?? string.Empty, - ProjectLink = packageData.ProjectAddress ?? string.Empty, + Id = basePackage.Id, + Title = basePackage.Title, + Description = basePackage.Description, + Authors = basePackage.Authors, + Icon = basePackage.Icon, + TotalDownloads = basePackage.TotalDownloads, + Tags = basePackage.Tags, + LatestVersion = basePackage.LatestVersion, + ProjectLink = basePackage.ProjectLink, + Category = basePackage.Category, + IsInstalled = basePackage.IsInstalled, LicenseLink = packageData.LicenseAddress ?? string.Empty, - Category = CategoryMapper.MapCategory(packageData.Tags ?? []), - IsInstalled = installedIds.Contains(packageData.Id ?? string.Empty), Versions = (packageData.Versions ?? []) .Select(v => new MarketplacePackageVersion { Version = v.Version ?? string.Empty, Downloads = v.Downloads, - Published = default, }) .ToList(), Dependencies = [], @@ -164,10 +160,7 @@ HashSet installedIds } } -// NuGet V3 Search API response models — instantiated by JSON deserialization -[JsonSerializable(typeof(NuGetSearchResponse))] -internal sealed partial class NuGetJsonContext : JsonSerializerContext; - +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization")] internal sealed record NuGetSearchResponse { [JsonPropertyName("totalHits")] @@ -177,6 +170,7 @@ internal sealed record NuGetSearchResponse public List Data { get; init; } = []; } +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization")] internal sealed record NuGetPackageData { [JsonPropertyName("id")] @@ -213,6 +207,7 @@ internal sealed record NuGetPackageData public List? Versions { get; init; } } +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization")] internal sealed record NuGetVersionData { [JsonPropertyName("version")] diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx index 45f6451c..533a6553 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx @@ -10,7 +10,8 @@ import { PageShell, } from '@simplemodule/ui'; import { useState } from 'react'; -import type { MarketplacePackage } from './types'; +import type { MarketplacePackage } from '../types'; +import { formatDownloads } from './utils'; const categories = [ 'All', @@ -37,12 +38,6 @@ interface Props { selectedSort: string; } -function formatDownloads(count: number): string { - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; - return count.toString(); -} - export default function Browse({ packages, totalHits, @@ -54,7 +49,7 @@ export default function Browse({ function navigate(params: Record) { const current = new URLSearchParams(); - if (query) current.set('q', query); + if (search) current.set('q', search); if (selectedCategory !== 'All') current.set('category', selectedCategory); if (selectedSort !== 'Relevance') current.set('sort', selectedSort); diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx index 417a85d7..087792cb 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx @@ -15,18 +15,13 @@ import { TableRow, } from '@simplemodule/ui'; import { useState } from 'react'; -import type { MarketplacePackageDetail } from './types'; +import type { MarketplacePackageDetail } from '../types'; +import { formatDownloads } from './utils'; interface Props { package: MarketplacePackageDetail; } -function formatDownloads(count: number): string { - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; - return count.toString(); -} - export default function Detail({ package: pkg }: Props) { const [copied, setCopied] = useState(false); const installCommand = `sm install ${pkg.id}`; diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts b/modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts deleted file mode 100644 index 9378f27b..00000000 --- a/modules/Marketplace/src/SimpleModule.Marketplace/Views/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface MarketplacePackage { - id: string; - title: string; - description: string; - authors: string; - icon: string; - totalDownloads: number; - tags: string[]; - latestVersion: string; - projectLink: string; - category: string; - isInstalled: boolean; -} - -export interface MarketplacePackageDetail extends MarketplacePackage { - licenseLink: string; - versions: MarketplacePackageVersion[]; - dependencies: string[]; -} - -export interface MarketplacePackageVersion { - version: string; - downloads: number; - published: string; -} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/Views/utils.ts b/modules/Marketplace/src/SimpleModule.Marketplace/Views/utils.ts new file mode 100644 index 00000000..0219a644 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/utils.ts @@ -0,0 +1,5 @@ +export function formatDownloads(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return count.toString(); +} diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/types.ts b/modules/Marketplace/src/SimpleModule.Marketplace/types.ts index 31c0fc0a..77f16f67 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/types.ts +++ b/modules/Marketplace/src/SimpleModule.Marketplace/types.ts @@ -14,18 +14,7 @@ export interface MarketplacePackage { } export interface MarketplacePackageDetail { - id: string; - title: string; - description: string; - authors: string; - icon: string; - totalDownloads: number; - tags: string[]; - latestVersion: string; - projectLink: string; licenseLink: string; - category: any; - isInstalled: boolean; versions: MarketplacePackageVersion[]; dependencies: string[]; } @@ -33,7 +22,6 @@ export interface MarketplacePackageDetail { export interface MarketplacePackageVersion { version: string; downloads: number; - published: string; } export interface MarketplaceSearchResult { diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/NuGetMarketplaceServiceTests.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/CategoryMapperTests.cs similarity index 100% rename from modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/NuGetMarketplaceServiceTests.cs rename to modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/CategoryMapperTests.cs From efda9657293bcec47be07b19566764873eed2f30 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 29 Mar 2026 15:52:04 +0000 Subject: [PATCH 3/4] Register InstalledPackageDetector in DI and fix test fixture convention - Add services.AddSingleton() to MarketplaceModule (IMarketplaceContracts is auto-wired by source generator, but internal services need manual registration) - Fix integration tests to store _factory per codebase convention --- .../MarketplaceModule.cs | 1 + .../MarketplaceBrowseEndpointTests.cs | 7 ++++--- .../Integration/MarketplaceEndpointTests.cs | 17 ++++++++++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs index 0247fb62..39aceb3b 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs @@ -27,6 +27,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config ); services.AddMemoryCache(); + services.AddSingleton(); } public void ConfigureMenu(IMenuBuilder menus) diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs index 608d8094..8381949d 100644 --- a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs @@ -6,17 +6,18 @@ namespace SimpleModule.Marketplace.Tests.Integration; public class MarketplaceBrowseEndpointTests : IClassFixture { - private readonly HttpClient _client; + private readonly SimpleModuleWebApplicationFactory _factory; public MarketplaceBrowseEndpointTests(SimpleModuleWebApplicationFactory factory) { - _client = factory.CreateClient(); + _factory = factory; } [Fact] public async Task Browse_EndpointIsRegistered_DoesNotReturn404() { - var response = await _client.GetAsync("/marketplace/browse"); + var client = _factory.CreateClient(); + var response = await client.GetAsync("/marketplace/browse"); // The endpoint should be registered (not 404/405). // It may return 500 if the external NuGet API is unreachable in CI, diff --git a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs index 9d4914a0..a3cfe3ae 100644 --- a/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs @@ -6,17 +6,18 @@ namespace SimpleModule.Marketplace.Tests.Integration; public class MarketplaceEndpointTests : IClassFixture { - private readonly HttpClient _client; + private readonly SimpleModuleWebApplicationFactory _factory; public MarketplaceEndpointTests(SimpleModuleWebApplicationFactory factory) { - _client = factory.CreateClient(); + _factory = factory; } [Fact] public async Task Search_EndpointIsRegistered_DoesNotReturn404() { - var response = await _client.GetAsync("/api/marketplace"); + var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/marketplace"); // The endpoint should be registered (not 404/405). // May return 500 if the external NuGet API is unreachable in CI. @@ -27,7 +28,8 @@ public async Task Search_EndpointIsRegistered_DoesNotReturn404() [Fact] public async Task Search_WithQuery_EndpointIsRegistered() { - var response = await _client.GetAsync("/api/marketplace?q=test"); + var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/marketplace?q=test"); response.StatusCode.Should().NotBe(HttpStatusCode.NotFound); response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); @@ -36,7 +38,8 @@ public async Task Search_WithQuery_EndpointIsRegistered() [Fact] public async Task GetById_EndpointIsRegistered() { - var response = await _client.GetAsync("/api/marketplace/some-package"); + var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/marketplace/some-package"); // The endpoint should be registered. It returns 404 (NotFound result) // if the package doesn't exist on NuGet, or 500 if the API is unreachable. @@ -46,8 +49,8 @@ public async Task GetById_EndpointIsRegistered() [Fact] public async Task Search_AllowsAnonymousAccess() { - // No auth client — should not get 401/403 - var response = await _client.GetAsync("/api/marketplace"); + var client = _factory.CreateClient(); + var response = await client.GetAsync("/api/marketplace"); response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized); response.StatusCode.Should().NotBe(HttpStatusCode.Forbidden); From 37fd707ab225a1ab2ce2e48389c82efbcf36c43c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 29 Mar 2026 16:21:57 +0000 Subject: [PATCH 4/4] Fix NuGet API URL and add error handling for network failures - Use correct NuGet search endpoint (azuresearch-usnc.nuget.org/query) instead of non-existent api.nuget.org/v3/search - Wrap HTTP calls in try/catch for HttpRequestException so the API returns empty results instead of 500 when NuGet is unreachable --- .../MarketplaceModuleOptions.cs | 2 +- .../NuGetMarketplaceService.cs | 70 ++++++++++++------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs index 1b5f1ed5..7a045aec 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModuleOptions.cs @@ -4,7 +4,7 @@ namespace SimpleModule.Marketplace; public class MarketplaceModuleOptions : IModuleOptions { - public string NuGetSearchBaseAddress { get; set; } = "https://api.nuget.org/v3/search"; + public string NuGetSearchBaseAddress { get; set; } = "https://azuresearch-usnc.nuget.org/query"; public string NuGetRegistrationBaseAddress { get; set; } = "https://api.nuget.org/v3/registration5-gz-semver2"; public string PackageTag { get; set; } = "simplemodule"; diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs index f4c6dad3..f7dafaa8 100644 --- a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs +++ b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs @@ -74,41 +74,56 @@ private async Task FetchSearchResultsAsync( MarketplaceSearchRequest request ) { - var client = httpClientFactory.CreateClient(MarketplaceConstants.ModuleName); - var tag = options.Value.PackageTag; - var query = string.IsNullOrWhiteSpace(request.Query) - ? $"tag:{tag}" - : $"tag:{tag} {request.Query}"; + try + { + var client = httpClientFactory.CreateClient(MarketplaceConstants.ModuleName); + var tag = options.Value.PackageTag; + var query = string.IsNullOrWhiteSpace(request.Query) + ? $"tag:{tag}" + : $"tag:{tag} {request.Query}"; - var url = - $"{options.Value.NuGetSearchBaseAddress}?q={Uri.EscapeDataString(query)}&skip={request.Skip}&take={request.Take}"; + var url = + $"{options.Value.NuGetSearchBaseAddress}?q={Uri.EscapeDataString(query)}&skip={request.Skip}&take={request.Take}"; - var response = await client.GetFromJsonAsync(url); - if (response is null) - { - return new MarketplaceSearchResult(); - } + var response = await client.GetFromJsonAsync(url); + if (response is null) + { + return new MarketplaceSearchResult(); + } - var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync(); + var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync(); - var packages = response.Data.Select(d => MapToPackage(d, installedIds)).ToList(); + var packages = response.Data.Select(d => MapToPackage(d, installedIds)).ToList(); - return new MarketplaceSearchResult { TotalHits = response.TotalHits, Packages = packages }; + return new MarketplaceSearchResult + { + TotalHits = response.TotalHits, + Packages = packages, + }; + } + catch (HttpRequestException) + { + return new MarketplaceSearchResult(); + } } private async Task FetchPackageDetailsAsync(string packageId) { - var client = httpClientFactory.CreateClient(MarketplaceConstants.ModuleName); - var tag = options.Value.PackageTag; - var searchAddress = - $"{options.Value.NuGetSearchBaseAddress}?q=packageid:{Uri.EscapeDataString(packageId)} tag:{tag}&take=1"; - - var searchResponse = await client.GetFromJsonAsync(searchAddress); - var packageData = searchResponse?.Data.FirstOrDefault(); - if (packageData is null) + try { - return null; - } + var client = httpClientFactory.CreateClient(MarketplaceConstants.ModuleName); + var tag = options.Value.PackageTag; + var searchAddress = + $"{options.Value.NuGetSearchBaseAddress}?q=packageid:{Uri.EscapeDataString(packageId)} tag:{tag}&take=1"; + + var searchResponse = await client.GetFromJsonAsync( + searchAddress + ); + var packageData = searchResponse?.Data.FirstOrDefault(); + if (packageData is null) + { + return null; + } var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync(); var basePackage = MapToPackage(packageData, installedIds); @@ -136,6 +151,11 @@ MarketplaceSearchRequest request .ToList(), Dependencies = [], }; + } + catch (HttpRequestException) + { + return null; + } } private static MarketplacePackage MapToPackage(