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..a5f96092 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Install/InstallCommand.cs @@ -0,0 +1,82 @@ +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 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) + { + 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..862b599f --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace.Contracts/MarketplacePackageDetail.cs @@ -0,0 +1,18 @@ +using SimpleModule.Core; + +namespace SimpleModule.Marketplace.Contracts; + +[Dto] +public class MarketplacePackageDetail : MarketplacePackage +{ + public string LicenseLink { get; set; } = string.Empty; + 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; } +} 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..39aceb3b --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs @@ -0,0 +1,58 @@ +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 +{ + private const string MenuIcon = + """"""; + + 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(); + services.AddSingleton(); + } + + public void ConfigureMenu(IMenuBuilder menus) + { + menus.Add( + new MenuItem + { + Label = "Marketplace", + Url = "/marketplace/browse", + Icon = MenuIcon, + Order = 40, + Section = MenuSection.Navbar, + RequiresAuth = false, + } + ); + menus.Add( + new MenuItem + { + Label = "Marketplace", + Url = "/marketplace/browse", + Icon = MenuIcon, + 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..7a045aec --- /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://azuresearch-usnc.nuget.org/query"; + 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..f7dafaa8 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs @@ -0,0 +1,238 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.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 +{ + public async Task SearchPackagesAsync(MarketplaceSearchRequest request) + { + var cacheKey = $"Marketplace:Search:{request.Query}:{request.Skip}:{request.Take}"; + + var cached = await cache.GetOrCreateAsync( + cacheKey, + async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes( + options.Value.SearchCacheDurationMinutes + ); + return await FetchSearchResultsAsync(request); + } + ); + + 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) + { + 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 + ) + { + 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 response = await client.GetFromJsonAsync(url); + if (response is null) + { + return new MarketplaceSearchResult(); + } + + var installedIds = await installedPackageDetector.GetInstalledPackageIdsAsync(); + + var packages = response.Data.Select(d => MapToPackage(d, installedIds)).ToList(); + + return new MarketplaceSearchResult + { + TotalHits = response.TotalHits, + Packages = packages, + }; + } + catch (HttpRequestException) + { + return new MarketplaceSearchResult(); + } + } + + private async Task FetchPackageDetailsAsync(string packageId) + { + try + { + 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); + + return new MarketplacePackageDetail + { + 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, + Versions = (packageData.Versions ?? []) + .Select(v => new MarketplacePackageVersion + { + Version = v.Version ?? string.Empty, + Downloads = v.Downloads, + }) + .ToList(), + Dependencies = [], + }; + } + catch (HttpRequestException) + { + return null; + } + } + + 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), + }; + } +} + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization")] +internal sealed record NuGetSearchResponse +{ + [JsonPropertyName("totalHits")] + public int TotalHits { get; init; } + + [JsonPropertyName("data")] + public List Data { get; init; } = []; +} + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization")] +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; } +} + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by JSON deserialization")] +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..533a6553 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Browse.tsx @@ -0,0 +1,165 @@ +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'; +import { formatDownloads } from './utils'; + +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; +} + +export default function Browse({ + packages, + totalHits, + query, + selectedCategory, + selectedSort, +}: Props) { + const [search, setSearch] = useState(query); + + function navigate(params: Record) { + const current = new URLSearchParams(); + if (search) current.set('q', search); + 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..087792cb --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/Views/Detail.tsx @@ -0,0 +1,192 @@ +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'; +import { formatDownloads } from './utils'; + +interface Props { + package: MarketplacePackageDetail; +} + +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/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/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..77f16f67 --- /dev/null +++ b/modules/Marketplace/src/SimpleModule.Marketplace/types.ts @@ -0,0 +1,39 @@ +// 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 { + licenseLink: string; + versions: MarketplacePackageVersion[]; + dependencies: string[]; +} + +export interface MarketplacePackageVersion { + version: string; + downloads: number; +} + +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..8381949d --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceBrowseEndpointTests.cs @@ -0,0 +1,28 @@ +using System.Net; +using FluentAssertions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.Marketplace.Tests.Integration; + +public class MarketplaceBrowseEndpointTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public MarketplaceBrowseEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Browse_EndpointIsRegistered_DoesNotReturn404() + { + 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, + // 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..a3cfe3ae --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Integration/MarketplaceEndpointTests.cs @@ -0,0 +1,58 @@ +using System.Net; +using FluentAssertions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.Marketplace.Tests.Integration; + +public class MarketplaceEndpointTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public MarketplaceEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Search_EndpointIsRegistered_DoesNotReturn404() + { + 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. + response.StatusCode.Should().NotBe(HttpStatusCode.NotFound); + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + public async Task Search_WithQuery_EndpointIsRegistered() + { + 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); + } + + [Fact] + public async Task GetById_EndpointIsRegistered() + { + 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. + response.StatusCode.Should().NotBe(HttpStatusCode.MethodNotAllowed); + } + + [Fact] + public async Task Search_AllowsAnonymousAccess() + { + var client = _factory.CreateClient(); + 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/CategoryMapperTests.cs b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/CategoryMapperTests.cs new file mode 100644 index 00000000..f2476b56 --- /dev/null +++ b/modules/Marketplace/tests/SimpleModule.Marketplace.Tests/Unit/CategoryMapperTests.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 @@ +