This commit is contained in:
2025-10-27 17:39:18 -04:00
commit 31f723bea4
1579 changed files with 642409 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
using System.Net.Http.Json;
namespace Prefab.Shared.Catalog.Categories;
public sealed class CategoryClientHttp(HttpClient httpClient) : ICategoryClient
{
public async Task<GetCategories.Response> GetCategories(CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync("api/catalog/categories", cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new RemoteProblemException(response.StatusCode, body);
}
return await response.Content.ReadFromJsonAsync<GetCategories.Response>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Received an empty response when requesting categories.");
}
public async Task<CreateCategory.Response> CreateCategory(CreateCategory.Request request, CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync("api/catalog/categories", request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new RemoteProblemException(response.StatusCode, body);
}
return await response.Content.ReadFromJsonAsync<CreateCategory.Response>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Received an empty response when creating a category.");
}
}

View File

@@ -0,0 +1,11 @@
namespace Prefab.Shared.Catalog.Categories;
public class CreateCategory
{
public record Request(
string Name,
string Description);
public record Response(
Guid Id);
}

View File

@@ -0,0 +1,8 @@
namespace Prefab.Shared.Catalog.Categories;
public abstract class GetCategories
{
public record Response(IReadOnlyList<ListItem> Categories);
public record ListItem(Guid Id, string Name, string ParentName);
}

View File

@@ -0,0 +1,7 @@
namespace Prefab.Shared.Catalog.Categories;
public interface ICategoryClient
{
Task<GetCategories.Response> GetCategories(CancellationToken cancellationToken);
Task<CreateCategory.Response> CreateCategory(CreateCategory.Request request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using Prefab.Shared.Catalog.Categories;
namespace Prefab.Shared.Catalog;
public interface IModuleClient
{
public ICategoryClient Category { get; }
public Products.IProductClient Product { get; }
public Products.IPriceQuoteClient PriceQuote { get; }
}

View File

@@ -0,0 +1,23 @@
using Prefab.Shared.Catalog.Categories;
using Prefab.Shared.Catalog.Products;
namespace Prefab.Shared.Catalog;
/// <summary>
/// Provides HTTP-based access to module-related operations, exposing product and category management functionality
/// through their respective clients.
/// </summary>
/// <param name="categoryClient">The category client used to perform category-related operations. Cannot be null.</param>
/// <param name="productClient">The product client used to retrieve product configuration. Cannot be null.</param>
/// <param name="priceQuoteClient">The client used to request price quotations. Cannot be null.</param>
public sealed class ModuleClientHttp(
ICategoryClient categoryClient,
IProductClient productClient,
IPriceQuoteClient priceQuoteClient) : IModuleClient
{
public ICategoryClient Category { get; } = categoryClient;
public IProductClient Product { get; } = productClient;
public IPriceQuoteClient PriceQuote { get; } = priceQuoteClient;
}

View File

@@ -0,0 +1,8 @@
namespace Prefab.Shared.Catalog.Products;
public abstract class GetCategory
{
public sealed record Request(string Slug);
public sealed record Response(GetCategoryModels.CategoryDetailDto Category);
}

View File

@@ -0,0 +1,50 @@
namespace Prefab.Shared.Catalog.Products;
public abstract class GetCategoryModels
{
public sealed record Request(
string Slug,
int? Page,
int? PageSize,
string? Sort,
string? Direction);
public sealed record Response(CategoryProductsResponse Result);
public sealed record CategoryProductsResponse(
CategoryDetailDto Category,
IReadOnlyList<ProductCardDto> Products,
int Total,
int Page,
int PageSize);
public sealed record CategoryDetailDto(
Guid Id,
string Name,
string Slug,
string? Description,
string? HeroImageUrl,
string? Icon,
bool IsLeaf,
IReadOnlyList<CategoryNodeDto> Children);
public sealed record CategoryNodeDto(
Guid Id,
string Name,
string Slug,
string? Description,
string? HeroImageUrl,
string? Icon,
int DisplayOrder,
bool IsFeatured,
bool IsLeaf,
IReadOnlyList<CategoryNodeDto> Children);
public sealed record ProductCardDto(
Guid Id,
string Name,
string Slug,
decimal? FromPrice,
string? PrimaryImageUrl,
DateTimeOffset LastModifiedOn);
}

View File

@@ -0,0 +1,8 @@
namespace Prefab.Shared.Catalog.Products;
public abstract class GetCategoryTree
{
public sealed record Request(int? Depth);
public sealed record Response(IReadOnlyList<GetCategoryModels.CategoryNodeDto> Categories);
}

View File

@@ -0,0 +1,77 @@
namespace Prefab.Shared.Catalog.Products;
public abstract class GetProductDetail
{
public enum OptionDataType
{
Choice = 0,
Number = 1
}
public enum PriceDeltaKind
{
Absolute = 0,
Percent = 1
}
public sealed record Response(ProductDetailDto Product);
public sealed record ProductDetailDto(
Guid Id,
string Name,
string Slug,
string? Description,
decimal? Price,
IReadOnlyList<OptionDefinitionDto> Options,
IReadOnlyList<SpecDto> Specs,
IReadOnlyDictionary<string, string>? GenericAttributes);
public sealed record OptionDefinitionDto(
Guid Id,
string Code,
string Name,
int DataType,
bool IsVariantAxis,
string? Unit,
decimal? Min,
decimal? Max,
decimal? Step,
decimal? PricePerUnit,
IReadOnlyList<OptionTierDto> Tiers,
IReadOnlyList<OptionValueDto> Values,
IReadOnlyList<RuleSetDto>? Rules);
public sealed record OptionValueDto(
Guid Id,
string Code,
string Label,
decimal? PriceDelta,
int PriceDeltaKind,
IReadOnlyList<RuleSetDto>? Rules);
public sealed record OptionTierDto(
decimal FromInclusive,
decimal? ToInclusive,
decimal UnitRate,
decimal? FlatDelta);
public sealed record RuleSetDto(
string Effect,
string Mode,
IReadOnlyList<ConditionDto> Conditions);
public sealed record ConditionDto(
string LeftOptionCode,
string Operator,
string? RightValueCode,
string? RightList,
decimal? RightNumber,
decimal? RightMin,
decimal? RightMax);
public sealed record SpecDto(
string Name,
string? Value,
decimal? NumericValue,
string? UnitCode);
}

View File

@@ -0,0 +1,19 @@
namespace Prefab.Shared.Catalog.Products;
public interface IProductClient
{
Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken);
Task<GetCategory.Response> GetCategory(string slug, CancellationToken cancellationToken);
Task<GetCategoryModels.Response> GetCategoryModels(string slug, int? page, int? pageSize, string? sort, string? direction, CancellationToken cancellationToken);
Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken);
Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken);
}
public interface IPriceQuoteClient
{
Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,117 @@
using System.Net.Http.Json;
namespace Prefab.Shared.Catalog.Products;
public sealed class ProductClientHttp(HttpClient httpClient) : IProductClient, IPriceQuoteClient
{
public async Task<GetCategoryTree.Response> GetCategoryTree(int? depth, CancellationToken cancellationToken)
{
var trimmedDepth = depth is > 0 ? depth : null;
var path = "api/catalog/categories/tree";
if (trimmedDepth.HasValue)
{
path = $"{path}?depth={trimmedDepth.Value}";
}
using var response = await httpClient.GetAsync(path, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new RemoteProblemException(response.StatusCode, body);
}
return await response.Content.ReadFromJsonAsync<GetCategoryTree.Response>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Category tree response payload was empty.");
}
public async Task<GetCategory.Response> GetCategory(string slug, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
using var response = await httpClient.GetAsync($"api/catalog/categories/{slug.Trim()}", cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new RemoteProblemException(response.StatusCode, body);
}
return await response.Content.ReadFromJsonAsync<GetCategory.Response>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Category detail response payload was empty.");
}
public async Task<GetCategoryModels.Response> GetCategoryModels(string slug, int? page, int? pageSize, string? sort, string? direction, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
var trimmedSlug = slug.Trim();
var segments = new List<string>();
if (page is > 0)
{
segments.Add($"page={page.Value}");
}
if (pageSize is > 0)
{
segments.Add($"pageSize={pageSize.Value}");
}
if (!string.IsNullOrWhiteSpace(sort))
{
segments.Add($"sort={Uri.EscapeDataString(sort.Trim())}");
}
if (!string.IsNullOrWhiteSpace(direction))
{
segments.Add($"dir={Uri.EscapeDataString(direction.Trim())}");
}
var path = $"api/catalog/categories/{trimmedSlug}/models";
if (segments.Count > 0)
{
path = $"{path}?{string.Join('&', segments)}";
}
using var response = await httpClient.GetAsync(path, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new RemoteProblemException(response.StatusCode, body);
}
return await response.Content.ReadFromJsonAsync<GetCategoryModels.Response>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Category products response payload was empty.");
}
public async Task<GetProductDetail.Response> GetProductDetail(string slug, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(slug);
using var response = await httpClient.GetAsync($"api/catalog/products/{slug.Trim()}", cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new RemoteProblemException(response.StatusCode, body);
}
return await response.Content.ReadFromJsonAsync<GetProductDetail.Response>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Product response payload was empty.");
}
public Task<QuotePrice.Response> QuotePrice(QuotePrice.Request request, CancellationToken cancellationToken) =>
Quote(request, cancellationToken);
public async Task<QuotePrice.Response> Quote(QuotePrice.Request request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
using var response = await httpClient.PostAsJsonAsync("api/catalog/price-quotes", request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new RemoteProblemException(response.StatusCode, body);
}
return await response.Content.ReadFromJsonAsync<QuotePrice.Response>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Quote response payload was empty.");
}
}

View File

@@ -0,0 +1,26 @@
namespace Prefab.Shared.Catalog.Products;
public abstract class QuotePrice
{
public sealed record Request(
Guid? ProductId,
string? Sku,
IReadOnlyList<Selection> Selections)
{
public IReadOnlyList<Selection> Selections { get; init; } = Selections ?? Array.Empty<Selection>();
}
public sealed record Selection(
Guid OptionDefinitionId,
Guid? OptionValueId,
decimal? NumericValue);
public sealed record Response(
decimal UnitPrice,
IReadOnlyList<QuoteBreakdown> Breakdown);
public sealed record QuoteBreakdown(
string Option,
string Chosen,
decimal Delta);
}

View File

@@ -0,0 +1,11 @@
namespace Prefab.Shared;
/// <summary>
/// Provides configuration options for a module client, including transport selection and base address settings.
/// </summary>
public sealed class ModuleClientOptions
{
public ModuleClientTransport Transport { get; set; } = ModuleClientTransport.InProcess;
public Uri? BaseAddress { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace Prefab.Shared;
/// <summary>
/// Specifies the transport mechanism used by a module client to communicate with its host or services.
/// </summary>
/// <remarks>Use this enumeration to select the appropriate transport for module client operations. The choice of
/// transport may affect performance, compatibility, and deployment scenarios. For example, 'InProcess' is typically
/// used when the client and host run within the same process, while 'Http' enables communication over HTTP, which may
/// be required for remote or cross-process scenarios.</remarks>
public enum ModuleClientTransport
{
/// <summary>
/// Indicates that the operation or component runs within the current process.
/// </summary>
InProcess,
/// <summary>
/// Provides functionality for working with HTTP protocols, requests, and responses.
/// </summary>
Http
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Prefab.Base\Prefab.Base.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using System.Net;
namespace Prefab.Shared;
/// <summary>
/// Represents a non-success HTTP response returned by a downstream service.
/// </summary>
public sealed class RemoteProblemException(HttpStatusCode statusCode, string? responseBody)
: Exception($"Remote request failed with status code {(int)statusCode}.")
{
public HttpStatusCode StatusCode { get; } = statusCode;
public string? ResponseBody { get; } = responseBody;
}