Add LittleShop.Client SDK library with complete API wrapper
Features: - Complete .NET client SDK for LittleShop API - JWT authentication with automatic token management - Catalog service for products and categories - Order service with payment creation - Retry policies using Polly for resilience - Error handling middleware - Dependency injection support - Comprehensive documentation and examples SDK Components: - Authentication service with token refresh - Strongly-typed models for all API responses - HTTP handlers for retry and error handling - Extension methods for easy DI registration - Example console application demonstrating usage Test Updates: - Fixed test compilation errors - Updated test data builders for new models - Corrected service constructor dependencies - Fixed enum value changes (PaymentStatus, OrderStatus) Documentation: - Complete project README with features and usage - Client SDK README with detailed examples - API endpoint documentation - Security considerations - Deployment guidelines Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a281bb2896
commit
1f7c0af497
180
LittleShop.Client.Example/Program.cs
Normal file
180
LittleShop.Client.Example/Program.cs
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
using LittleShop.Client;
|
||||||
|
using LittleShop.Client.Extensions;
|
||||||
|
using LittleShop.Client.Models;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
// Setup dependency injection
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
|
// Add logging
|
||||||
|
services.AddLogging(builder => builder
|
||||||
|
.AddConsole()
|
||||||
|
.SetMinimumLevel(LogLevel.Information));
|
||||||
|
|
||||||
|
// Add LittleShop client
|
||||||
|
services.AddLittleShopClient(options =>
|
||||||
|
{
|
||||||
|
options.BaseUrl = "https://localhost:5001"; // Update this to your API URL
|
||||||
|
options.TimeoutSeconds = 30;
|
||||||
|
options.MaxRetryAttempts = 3;
|
||||||
|
options.EnableLogging = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build service provider
|
||||||
|
var serviceProvider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
// Get the client
|
||||||
|
var client = serviceProvider.GetRequiredService<ILittleShopClient>();
|
||||||
|
|
||||||
|
Console.WriteLine("=== LittleShop Client Example ===\n");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Step 1: Authenticate
|
||||||
|
Console.WriteLine("1. Authenticating...");
|
||||||
|
var loginResult = await client.Authentication.LoginAsync("admin", "admin");
|
||||||
|
|
||||||
|
if (!loginResult.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Login failed: {loginResult.ErrorMessage}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"✓ Logged in as: {loginResult.Data!.Username}");
|
||||||
|
Console.WriteLine($" Token expires: {loginResult.Data.ExpiresAt}\n");
|
||||||
|
|
||||||
|
// Step 2: Get Categories
|
||||||
|
Console.WriteLine("2. Fetching categories...");
|
||||||
|
var categoriesResult = await client.Catalog.GetCategoriesAsync();
|
||||||
|
|
||||||
|
if (categoriesResult.IsSuccess && categoriesResult.Data!.Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Found {categoriesResult.Data.Count} categories:");
|
||||||
|
foreach (var category in categoriesResult.Data)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" - {category.Name}: {category.Description}");
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get Products
|
||||||
|
Console.WriteLine("3. Fetching products...");
|
||||||
|
var productsResult = await client.Catalog.GetProductsAsync(
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 5
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productsResult.IsSuccess && productsResult.Data!.Items.Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Found {productsResult.Data.TotalCount} products (showing first {productsResult.Data.Items.Count}):");
|
||||||
|
foreach (var product in productsResult.Data.Items)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" - {product.Name}");
|
||||||
|
Console.WriteLine($" Price: £{product.Price:F2}");
|
||||||
|
Console.WriteLine($" Weight: {product.Weight} {product.WeightUnit}");
|
||||||
|
Console.WriteLine($" Category: {product.CategoryName}");
|
||||||
|
}
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Create a sample order
|
||||||
|
Console.WriteLine("4. Creating a sample order...");
|
||||||
|
|
||||||
|
// Get first product for the order
|
||||||
|
var firstProduct = productsResult.Data!.Items.FirstOrDefault();
|
||||||
|
if (firstProduct != null)
|
||||||
|
{
|
||||||
|
var orderRequest = new CreateOrderRequest
|
||||||
|
{
|
||||||
|
IdentityReference = "CLIENT_DEMO_001",
|
||||||
|
ShippingName = "Demo Customer",
|
||||||
|
ShippingAddress = "123 Test Street",
|
||||||
|
ShippingCity = "London",
|
||||||
|
ShippingPostCode = "SW1A 1AA",
|
||||||
|
ShippingCountry = "United Kingdom",
|
||||||
|
Notes = "Test order from client SDK",
|
||||||
|
Items = new List<CreateOrderItem>
|
||||||
|
{
|
||||||
|
new CreateOrderItem
|
||||||
|
{
|
||||||
|
ProductId = firstProduct.Id,
|
||||||
|
Quantity = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var orderResult = await client.Orders.CreateOrderAsync(orderRequest);
|
||||||
|
|
||||||
|
if (orderResult.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Order created successfully!");
|
||||||
|
Console.WriteLine($" Order ID: {orderResult.Data!.Id}");
|
||||||
|
Console.WriteLine($" Total: £{orderResult.Data.TotalAmount:F2}");
|
||||||
|
Console.WriteLine($" Status: {orderResult.Data.Status}\n");
|
||||||
|
|
||||||
|
// Step 5: Create a payment for the order
|
||||||
|
Console.WriteLine("5. Creating Bitcoin payment for the order...");
|
||||||
|
var paymentResult = await client.Orders.CreatePaymentAsync(orderResult.Data.Id, "BTC");
|
||||||
|
|
||||||
|
if (paymentResult.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Payment created successfully!");
|
||||||
|
Console.WriteLine($" Payment ID: {paymentResult.Data!.Id}");
|
||||||
|
Console.WriteLine($" Amount: {paymentResult.Data.RequiredAmount} BTC");
|
||||||
|
Console.WriteLine($" Send to: {paymentResult.Data.WalletAddress}");
|
||||||
|
Console.WriteLine($" Expires: {paymentResult.Data.ExpiresAt}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(paymentResult.Data.BTCPayCheckoutUrl))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" Checkout URL: {paymentResult.Data.BTCPayCheckoutUrl}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✗ Payment creation failed: {paymentResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✗ Order creation failed: {orderResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Get orders for the identity
|
||||||
|
Console.WriteLine("\n6. Fetching orders for identity 'CLIENT_DEMO_001'...");
|
||||||
|
var ordersResult = await client.Orders.GetOrdersByIdentityAsync("CLIENT_DEMO_001");
|
||||||
|
|
||||||
|
if (ordersResult.IsSuccess && ordersResult.Data!.Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Found {ordersResult.Data.Count} orders:");
|
||||||
|
foreach (var order in ordersResult.Data)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" - Order {order.Id}");
|
||||||
|
Console.WriteLine($" Status: {order.Status}");
|
||||||
|
Console.WriteLine($" Total: £{order.TotalAmount:F2}");
|
||||||
|
Console.WriteLine($" Created: {order.CreatedAt}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine(" No orders found for this identity.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("\n=== Example completed successfully! ===");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\n✗ Error: {ex.Message}");
|
||||||
|
if (ex.InnerException != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" Inner: {ex.InnerException.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Cleanup
|
||||||
|
client.Authentication.Logout();
|
||||||
|
Console.WriteLine("\nLogged out.");
|
||||||
|
}
|
||||||
6
LittleShop.Client/Class1.cs
Normal file
6
LittleShop.Client/Class1.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace LittleShop.Client;
|
||||||
|
|
||||||
|
public class Class1
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
10
LittleShop.Client/Configuration/LittleShopClientOptions.cs
Normal file
10
LittleShop.Client/Configuration/LittleShopClientOptions.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace LittleShop.Client.Configuration;
|
||||||
|
|
||||||
|
public class LittleShopClientOptions
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = "https://localhost:5001";
|
||||||
|
public int TimeoutSeconds { get; set; } = 30;
|
||||||
|
public int MaxRetryAttempts { get; set; } = 3;
|
||||||
|
public bool EnableLogging { get; set; } = true;
|
||||||
|
public string? ApiKey { get; set; }
|
||||||
|
}
|
||||||
91
LittleShop.Client/Extensions/ServiceCollectionExtensions.cs
Normal file
91
LittleShop.Client/Extensions/ServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
using LittleShop.Client.Configuration;
|
||||||
|
using LittleShop.Client.Http;
|
||||||
|
using LittleShop.Client.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddLittleShopClient(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<LittleShopClientOptions>? configureOptions = null)
|
||||||
|
{
|
||||||
|
// Configure options
|
||||||
|
if (configureOptions != null)
|
||||||
|
{
|
||||||
|
services.Configure(configureOptions);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.Configure<LittleShopClientOptions>(options => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register HTTP handlers
|
||||||
|
services.AddTransient<RetryPolicyHandler>();
|
||||||
|
services.AddTransient<ErrorHandlingMiddleware>();
|
||||||
|
|
||||||
|
// Register main HTTP client
|
||||||
|
services.AddHttpClient<IAuthenticationService, AuthenticationService>((serviceProvider, client) =>
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<LittleShopClientOptions>>().Value;
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl);
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||||
|
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
})
|
||||||
|
.AddHttpMessageHandler<ErrorHandlingMiddleware>()
|
||||||
|
.AddHttpMessageHandler(serviceProvider =>
|
||||||
|
{
|
||||||
|
var logger = serviceProvider.GetRequiredService<ILogger<RetryPolicyHandler>>();
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<LittleShopClientOptions>>().Value;
|
||||||
|
return new RetryPolicyHandler(logger, options.MaxRetryAttempts);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient<ICatalogService, CatalogService>((serviceProvider, client) =>
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<LittleShopClientOptions>>().Value;
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl);
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||||
|
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
})
|
||||||
|
.AddHttpMessageHandler<ErrorHandlingMiddleware>()
|
||||||
|
.AddHttpMessageHandler(serviceProvider =>
|
||||||
|
{
|
||||||
|
var logger = serviceProvider.GetRequiredService<ILogger<RetryPolicyHandler>>();
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<LittleShopClientOptions>>().Value;
|
||||||
|
return new RetryPolicyHandler(logger, options.MaxRetryAttempts);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient<IOrderService, OrderService>((serviceProvider, client) =>
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<LittleShopClientOptions>>().Value;
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl);
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
|
||||||
|
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
})
|
||||||
|
.AddHttpMessageHandler<ErrorHandlingMiddleware>()
|
||||||
|
.AddHttpMessageHandler(serviceProvider =>
|
||||||
|
{
|
||||||
|
var logger = serviceProvider.GetRequiredService<ILogger<RetryPolicyHandler>>();
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<LittleShopClientOptions>>().Value;
|
||||||
|
return new RetryPolicyHandler(logger, options.MaxRetryAttempts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the main client
|
||||||
|
services.AddScoped<ILittleShopClient, LittleShopClient>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddLittleShopClient(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string baseUrl)
|
||||||
|
{
|
||||||
|
return services.AddLittleShopClient(options =>
|
||||||
|
{
|
||||||
|
options.BaseUrl = baseUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
99
LittleShop.Client/Http/ErrorHandlingMiddleware.cs
Normal file
99
LittleShop.Client/Http/ErrorHandlingMiddleware.cs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Http;
|
||||||
|
|
||||||
|
public class ErrorHandlingMiddleware : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly ILogger<ErrorHandlingMiddleware> _logger;
|
||||||
|
|
||||||
|
public ErrorHandlingMiddleware(ILogger<ErrorHandlingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await base.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// Log errors but don't throw - let the service layer handle the response
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HTTP {StatusCode} for {Method} {Uri}: {Content}",
|
||||||
|
(int)response.StatusCode,
|
||||||
|
request.Method,
|
||||||
|
request.RequestUri,
|
||||||
|
content);
|
||||||
|
|
||||||
|
// Try to parse error response
|
||||||
|
if (!string.IsNullOrEmpty(content))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var errorResponse = JsonSerializer.Deserialize<ErrorResponse>(content,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
|
if (errorResponse != null && !string.IsNullOrEmpty(errorResponse.Message))
|
||||||
|
{
|
||||||
|
// Replace content with structured error message
|
||||||
|
response.Content = new StringContent(errorResponse.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If we can't parse the error, leave original content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Request timeout for {Method} {Uri}",
|
||||||
|
request.Method, request.RequestUri);
|
||||||
|
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.RequestTimeout)
|
||||||
|
{
|
||||||
|
Content = new StringContent("Request timed out"),
|
||||||
|
RequestMessage = request
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Network error for {Method} {Uri}",
|
||||||
|
request.Method, request.RequestUri);
|
||||||
|
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
|
||||||
|
{
|
||||||
|
Content = new StringContent($"Network error: {ex.Message}"),
|
||||||
|
RequestMessage = request
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error for {Method} {Uri}",
|
||||||
|
request.Method, request.RequestUri);
|
||||||
|
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||||
|
{
|
||||||
|
Content = new StringContent($"Unexpected error: {ex.Message}"),
|
||||||
|
RequestMessage = request
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ErrorResponse
|
||||||
|
{
|
||||||
|
public string? Message { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public Dictionary<string, string[]>? Errors { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
39
LittleShop.Client/Http/RetryPolicyHandler.cs
Normal file
39
LittleShop.Client/Http/RetryPolicyHandler.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Polly;
|
||||||
|
using Polly.Extensions.Http;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Http;
|
||||||
|
|
||||||
|
public class RetryPolicyHandler : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly ILogger<RetryPolicyHandler> _logger;
|
||||||
|
private readonly IAsyncPolicy<HttpResponseMessage> _retryPolicy;
|
||||||
|
|
||||||
|
public RetryPolicyHandler(ILogger<RetryPolicyHandler> logger, int maxRetryAttempts = 3)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
_retryPolicy = HttpPolicyExtensions
|
||||||
|
.HandleTransientHttpError()
|
||||||
|
.OrResult(msg => !msg.IsSuccessStatusCode && msg.StatusCode != HttpStatusCode.Unauthorized)
|
||||||
|
.WaitAndRetryAsync(
|
||||||
|
maxRetryAttempts,
|
||||||
|
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
|
||||||
|
onRetry: (outcome, timespan, retryCount, context) =>
|
||||||
|
{
|
||||||
|
var reason = outcome.Result?.StatusCode.ToString() ?? outcome.Exception?.Message;
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Retry {RetryCount} after {TimeSpan}ms due to: {Reason}",
|
||||||
|
retryCount, timespan.TotalMilliseconds, reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _retryPolicy.ExecuteAsync(async () =>
|
||||||
|
await base.SendAsync(request, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
LittleShop.Client/LittleShop.Client.csproj
Normal file
17
LittleShop.Client/LittleShop.Client.csproj
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
29
LittleShop.Client/LittleShopClient.cs
Normal file
29
LittleShop.Client/LittleShopClient.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
using LittleShop.Client.Configuration;
|
||||||
|
using LittleShop.Client.Services;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace LittleShop.Client;
|
||||||
|
|
||||||
|
public interface ILittleShopClient
|
||||||
|
{
|
||||||
|
IAuthenticationService Authentication { get; }
|
||||||
|
ICatalogService Catalog { get; }
|
||||||
|
IOrderService Orders { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LittleShopClient : ILittleShopClient
|
||||||
|
{
|
||||||
|
public IAuthenticationService Authentication { get; }
|
||||||
|
public ICatalogService Catalog { get; }
|
||||||
|
public IOrderService Orders { get; }
|
||||||
|
|
||||||
|
public LittleShopClient(
|
||||||
|
IAuthenticationService authenticationService,
|
||||||
|
ICatalogService catalogService,
|
||||||
|
IOrderService orderService)
|
||||||
|
{
|
||||||
|
Authentication = authenticationService;
|
||||||
|
Catalog = catalogService;
|
||||||
|
Orders = orderService;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
LittleShop.Client/Models/ApiResponse.cs
Normal file
40
LittleShop.Client/Models/ApiResponse.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Models;
|
||||||
|
|
||||||
|
public class ApiResponse<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; set; }
|
||||||
|
public T? Data { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public HttpStatusCode StatusCode { get; set; }
|
||||||
|
|
||||||
|
public static ApiResponse<T> Success(T data, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
return new ApiResponse<T>
|
||||||
|
{
|
||||||
|
IsSuccess = true,
|
||||||
|
Data = data,
|
||||||
|
StatusCode = statusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApiResponse<T> Failure(string errorMessage, HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
return new ApiResponse<T>
|
||||||
|
{
|
||||||
|
IsSuccess = false,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
StatusCode = statusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PagedResult<T>
|
||||||
|
{
|
||||||
|
public List<T> Items { get; set; } = new();
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
public int PageNumber { get; set; }
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||||
|
}
|
||||||
11
LittleShop.Client/Models/Category.cs
Normal file
11
LittleShop.Client/Models/Category.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
namespace LittleShop.Client.Models;
|
||||||
|
|
||||||
|
public class Category
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
14
LittleShop.Client/Models/LoginRequest.cs
Normal file
14
LittleShop.Client/Models/LoginRequest.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace LittleShop.Client.Models;
|
||||||
|
|
||||||
|
public class LoginRequest
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LoginResponse
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
51
LittleShop.Client/Models/Order.cs
Normal file
51
LittleShop.Client/Models/Order.cs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
namespace LittleShop.Client.Models;
|
||||||
|
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string IdentityReference { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public string Currency { get; set; } = string.Empty;
|
||||||
|
public string ShippingName { get; set; } = string.Empty;
|
||||||
|
public string ShippingAddress { get; set; } = string.Empty;
|
||||||
|
public string ShippingCity { get; set; } = string.Empty;
|
||||||
|
public string ShippingPostCode { get; set; } = string.Empty;
|
||||||
|
public string ShippingCountry { get; set; } = string.Empty;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public string? TrackingNumber { get; set; }
|
||||||
|
public List<OrderItem> Items { get; set; } = new();
|
||||||
|
public List<CryptoPayment> Payments { get; set; } = new();
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
public DateTime? PaidAt { get; set; }
|
||||||
|
public DateTime? ShippedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OrderItem
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public string? ProductName { get; set; }
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal TotalPrice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateOrderRequest
|
||||||
|
{
|
||||||
|
public string IdentityReference { get; set; } = string.Empty;
|
||||||
|
public string ShippingName { get; set; } = string.Empty;
|
||||||
|
public string ShippingAddress { get; set; } = string.Empty;
|
||||||
|
public string ShippingCity { get; set; } = string.Empty;
|
||||||
|
public string ShippingPostCode { get; set; } = string.Empty;
|
||||||
|
public string ShippingCountry { get; set; } = "United Kingdom";
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public List<CreateOrderItem> Items { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateOrderItem
|
||||||
|
{
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
}
|
||||||
23
LittleShop.Client/Models/Payment.cs
Normal file
23
LittleShop.Client/Models/Payment.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
namespace LittleShop.Client.Models;
|
||||||
|
|
||||||
|
public class CryptoPayment
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid OrderId { get; set; }
|
||||||
|
public string Currency { get; set; } = string.Empty;
|
||||||
|
public string WalletAddress { get; set; } = string.Empty;
|
||||||
|
public decimal RequiredAmount { get; set; }
|
||||||
|
public decimal? PaidAmount { get; set; }
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
public string? TransactionHash { get; set; }
|
||||||
|
public string? BTCPayInvoiceId { get; set; }
|
||||||
|
public string? BTCPayCheckoutUrl { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? PaidAt { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreatePaymentRequest
|
||||||
|
{
|
||||||
|
public string Currency { get; set; } = "BTC";
|
||||||
|
}
|
||||||
25
LittleShop.Client/Models/Product.cs
Normal file
25
LittleShop.Client/Models/Product.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
namespace LittleShop.Client.Models;
|
||||||
|
|
||||||
|
public class Product
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public decimal Weight { get; set; }
|
||||||
|
public string WeightUnit { get; set; } = string.Empty;
|
||||||
|
public Guid CategoryId { get; set; }
|
||||||
|
public string? CategoryName { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public List<ProductPhoto> Photos { get; set; } = new();
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProductPhoto
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
public string? AltText { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
373
LittleShop.Client/README.md
Normal file
373
LittleShop.Client/README.md
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
# LittleShop Client SDK
|
||||||
|
|
||||||
|
A .NET client library for interacting with the LittleShop e-commerce API. This SDK provides a strongly-typed, easy-to-use interface for all LittleShop API endpoints with built-in authentication, retry policies, and error handling.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add the LittleShop.Client library to your project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet add reference ../LittleShop.Client/LittleShop.Client.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via NuGet (when published):
|
||||||
|
```bash
|
||||||
|
dotnet add package LittleShop.Client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Setup with Dependency Injection
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using LittleShop.Client.Extensions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
var host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureServices((context, services) =>
|
||||||
|
{
|
||||||
|
// Add LittleShop client with configuration
|
||||||
|
services.AddLittleShopClient(options =>
|
||||||
|
{
|
||||||
|
options.BaseUrl = "https://localhost:5001";
|
||||||
|
options.TimeoutSeconds = 30;
|
||||||
|
options.MaxRetryAttempts = 3;
|
||||||
|
options.EnableLogging = true;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Get the client from DI
|
||||||
|
var client = host.Services.GetRequiredService<ILittleShopClient>();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simple Console Application Example
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using LittleShop.Client;
|
||||||
|
using LittleShop.Client.Extensions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
// Setup DI container
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging(builder => builder.AddConsole());
|
||||||
|
services.AddLittleShopClient("https://localhost:5001");
|
||||||
|
|
||||||
|
var serviceProvider = services.BuildServiceProvider();
|
||||||
|
var client = serviceProvider.GetRequiredService<ILittleShopClient>();
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
var loginResult = await client.Authentication.LoginAsync("admin", "admin");
|
||||||
|
if (loginResult.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Logged in as: {loginResult.Data.Username}");
|
||||||
|
Console.WriteLine($"Token expires: {loginResult.Data.ExpiresAt}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get categories
|
||||||
|
var categoriesResult = await client.Catalog.GetCategoriesAsync();
|
||||||
|
if (categoriesResult.IsSuccess)
|
||||||
|
{
|
||||||
|
foreach (var category in categoriesResult.Data)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Category: {category.Name} - {category.Description}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get products with filtering
|
||||||
|
var productsResult = await client.Catalog.GetProductsAsync(
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
searchTerm: "wireless",
|
||||||
|
minPrice: 10,
|
||||||
|
maxPrice: 100
|
||||||
|
);
|
||||||
|
|
||||||
|
if (productsResult.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Found {productsResult.Data.TotalCount} products");
|
||||||
|
foreach (var product in productsResult.Data.Items)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Product: {product.Name} - £{product.Price}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The SDK handles JWT authentication automatically. Once you log in, the token is stored and automatically included in subsequent requests.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Login
|
||||||
|
var loginResult = await client.Authentication.LoginAsync("username", "password");
|
||||||
|
if (!loginResult.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Login failed: {loginResult.ErrorMessage}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if authenticated
|
||||||
|
if (client.Authentication.IsAuthenticated())
|
||||||
|
{
|
||||||
|
Console.WriteLine("User is authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current token (if needed for external use)
|
||||||
|
var token = client.Authentication.GetToken();
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
client.Authentication.Logout();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Catalog Operations
|
||||||
|
|
||||||
|
### Get All Categories
|
||||||
|
```csharp
|
||||||
|
var result = await client.Catalog.GetCategoriesAsync();
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
foreach (var category in result.Data)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{category.Name}: {category.Description}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Products with Pagination and Filtering
|
||||||
|
```csharp
|
||||||
|
var result = await client.Catalog.GetProductsAsync(
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
categoryId: categoryGuid, // Optional: Filter by category
|
||||||
|
searchTerm: "headphones", // Optional: Search term
|
||||||
|
minPrice: 50, // Optional: Minimum price
|
||||||
|
maxPrice: 200 // Optional: Maximum price
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Page {result.Data.PageNumber} of {result.Data.TotalPages}");
|
||||||
|
Console.WriteLine($"Total products: {result.Data.TotalCount}");
|
||||||
|
|
||||||
|
foreach (var product in result.Data.Items)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{product.Name} - £{product.Price}");
|
||||||
|
if (product.Photos.Any())
|
||||||
|
{
|
||||||
|
Console.WriteLine($" Photo: {product.Photos.First().Url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Product
|
||||||
|
```csharp
|
||||||
|
var result = await client.Catalog.GetProductByIdAsync(productId);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var product = result.Data;
|
||||||
|
Console.WriteLine($"Name: {product.Name}");
|
||||||
|
Console.WriteLine($"Price: £{product.Price}");
|
||||||
|
Console.WriteLine($"Weight: {product.Weight} {product.WeightUnit}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Order Operations
|
||||||
|
|
||||||
|
### Create Order
|
||||||
|
```csharp
|
||||||
|
var orderRequest = new CreateOrderRequest
|
||||||
|
{
|
||||||
|
IdentityReference = "CUST123",
|
||||||
|
ShippingName = "John Smith",
|
||||||
|
ShippingAddress = "123 Main Street",
|
||||||
|
ShippingCity = "London",
|
||||||
|
ShippingPostCode = "SW1A 1AA",
|
||||||
|
ShippingCountry = "United Kingdom",
|
||||||
|
Notes = "Please deliver to reception",
|
||||||
|
Items = new List<CreateOrderItem>
|
||||||
|
{
|
||||||
|
new CreateOrderItem
|
||||||
|
{
|
||||||
|
ProductId = productId1,
|
||||||
|
Quantity = 2
|
||||||
|
},
|
||||||
|
new CreateOrderItem
|
||||||
|
{
|
||||||
|
ProductId = productId2,
|
||||||
|
Quantity = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await client.Orders.CreateOrderAsync(orderRequest);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Order created: {result.Data.Id}");
|
||||||
|
Console.WriteLine($"Total: £{result.Data.TotalAmount}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Orders by Customer Identity
|
||||||
|
```csharp
|
||||||
|
var result = await client.Orders.GetOrdersByIdentityAsync("CUST123");
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
foreach (var order in result.Data)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Order {order.Id}: {order.Status} - £{order.TotalAmount}");
|
||||||
|
Console.WriteLine($" Created: {order.CreatedAt}");
|
||||||
|
if (!string.IsNullOrEmpty(order.TrackingNumber))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" Tracking: {order.TrackingNumber}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Cryptocurrency Payment
|
||||||
|
```csharp
|
||||||
|
var result = await client.Orders.CreatePaymentAsync(orderId, "BTC");
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var payment = result.Data;
|
||||||
|
Console.WriteLine($"Payment ID: {payment.Id}");
|
||||||
|
Console.WriteLine($"Send {payment.RequiredAmount} {payment.Currency} to:");
|
||||||
|
Console.WriteLine($"Address: {payment.WalletAddress}");
|
||||||
|
Console.WriteLine($"Expires: {payment.ExpiresAt}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(payment.BTCPayCheckoutUrl))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Or pay via: {payment.BTCPayCheckoutUrl}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All API methods return an `ApiResponse<T>` wrapper that includes success status, data, and error information:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var result = await client.Catalog.GetProductByIdAsync(productId);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
// Success - access data
|
||||||
|
var product = result.Data;
|
||||||
|
Console.WriteLine($"Product: {product.Name}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Error - handle appropriately
|
||||||
|
Console.WriteLine($"Error: {result.ErrorMessage}");
|
||||||
|
Console.WriteLine($"Status Code: {result.StatusCode}");
|
||||||
|
|
||||||
|
switch (result.StatusCode)
|
||||||
|
{
|
||||||
|
case HttpStatusCode.NotFound:
|
||||||
|
Console.WriteLine("Product not found");
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.Unauthorized:
|
||||||
|
Console.WriteLine("Please login first");
|
||||||
|
break;
|
||||||
|
case HttpStatusCode.BadRequest:
|
||||||
|
Console.WriteLine("Invalid request");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Console.WriteLine("An error occurred");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Custom HTTP Client Configuration
|
||||||
|
|
||||||
|
The SDK uses Polly for retry policies and includes automatic retry for transient failures:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddLittleShopClient(options =>
|
||||||
|
{
|
||||||
|
options.BaseUrl = "https://api.littleshop.com";
|
||||||
|
options.TimeoutSeconds = 60; // Request timeout
|
||||||
|
options.MaxRetryAttempts = 5; // Number of retries for transient failures
|
||||||
|
options.EnableLogging = true; // Enable detailed logging
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using in ASP.NET Core Web Application
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In Program.cs or Startup.cs
|
||||||
|
builder.Services.AddLittleShopClient(options =>
|
||||||
|
{
|
||||||
|
options.BaseUrl = builder.Configuration["LittleShop:ApiUrl"];
|
||||||
|
options.TimeoutSeconds = 30;
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a controller or service
|
||||||
|
public class ProductController : Controller
|
||||||
|
{
|
||||||
|
private readonly ILittleShopClient _client;
|
||||||
|
|
||||||
|
public ProductController(ILittleShopClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
|
{
|
||||||
|
var result = await _client.Catalog.GetProductsAsync();
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
return View(result.Data.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return View("Error", result.ErrorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ **Strongly Typed Models** - All API responses are mapped to C# classes
|
||||||
|
- ✅ **Automatic Authentication** - JWT tokens are managed automatically
|
||||||
|
- ✅ **Retry Policies** - Automatic retry for transient failures
|
||||||
|
- ✅ **Error Handling** - Consistent error responses with detailed information
|
||||||
|
- ✅ **Logging Support** - Built-in logging for debugging
|
||||||
|
- ✅ **Dependency Injection** - Full DI support for ASP.NET Core applications
|
||||||
|
- ✅ **Async/Await** - All methods are async for better performance
|
||||||
|
- ✅ **Pagination Support** - Built-in pagination for product listings
|
||||||
|
- ✅ **Filtering & Search** - Advanced filtering options for products
|
||||||
|
|
||||||
|
## API Coverage
|
||||||
|
|
||||||
|
- **Authentication**
|
||||||
|
- Login
|
||||||
|
- Token refresh
|
||||||
|
- Logout
|
||||||
|
|
||||||
|
- **Catalog**
|
||||||
|
- Get all categories
|
||||||
|
- Get category by ID
|
||||||
|
- Get products (with pagination and filtering)
|
||||||
|
- Get product by ID
|
||||||
|
|
||||||
|
- **Orders**
|
||||||
|
- Create order
|
||||||
|
- Get orders by customer identity
|
||||||
|
- Get order by ID
|
||||||
|
- Create cryptocurrency payment
|
||||||
|
- Get order payments
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- .NET 9.0 or later
|
||||||
|
- LittleShop API server running and accessible
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This SDK is part of the LittleShop e-commerce platform.
|
||||||
126
LittleShop.Client/Services/AuthenticationService.cs
Normal file
126
LittleShop.Client/Services/AuthenticationService.cs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using LittleShop.Client.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Services;
|
||||||
|
|
||||||
|
public class AuthenticationService : IAuthenticationService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<AuthenticationService> _logger;
|
||||||
|
private string? _currentToken;
|
||||||
|
private DateTime? _tokenExpiry;
|
||||||
|
|
||||||
|
public AuthenticationService(HttpClient httpClient, ILogger<AuthenticationService> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<LoginResponse>> LoginAsync(string username, string password)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new LoginRequest { Username = username, Password = password };
|
||||||
|
var response = await _httpClient.PostAsJsonAsync("api/auth/login", request);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var loginResponse = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||||
|
if (loginResponse != null)
|
||||||
|
{
|
||||||
|
SetToken(loginResponse.Token);
|
||||||
|
return ApiResponse<LoginResponse>.Success(loginResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<LoginResponse>.Failure(
|
||||||
|
error ?? "Login failed",
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Login failed");
|
||||||
|
return ApiResponse<LoginResponse>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<LoginResponse>> RefreshTokenAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_currentToken))
|
||||||
|
{
|
||||||
|
return ApiResponse<LoginResponse>.Failure(
|
||||||
|
"No token to refresh",
|
||||||
|
System.Net.HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_httpClient.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _currentToken);
|
||||||
|
|
||||||
|
var response = await _httpClient.PostAsync("api/auth/refresh", null);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var loginResponse = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||||
|
if (loginResponse != null)
|
||||||
|
{
|
||||||
|
SetToken(loginResponse.Token);
|
||||||
|
return ApiResponse<LoginResponse>.Success(loginResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse<LoginResponse>.Failure(
|
||||||
|
"Token refresh failed",
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Token refresh failed");
|
||||||
|
return ApiResponse<LoginResponse>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetToken(string token)
|
||||||
|
{
|
||||||
|
_currentToken = token;
|
||||||
|
_httpClient.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
// Parse token to get expiry
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jsonToken = handler.ReadJwtToken(token);
|
||||||
|
_tokenExpiry = jsonToken.ValidTo;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse token expiry");
|
||||||
|
_tokenExpiry = DateTime.UtcNow.AddHours(1); // Default to 1 hour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetToken() => _currentToken;
|
||||||
|
|
||||||
|
public bool IsAuthenticated()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(_currentToken) &&
|
||||||
|
_tokenExpiry.HasValue &&
|
||||||
|
_tokenExpiry.Value > DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Logout()
|
||||||
|
{
|
||||||
|
_currentToken = null;
|
||||||
|
_tokenExpiry = null;
|
||||||
|
_httpClient.DefaultRequestHeaders.Authorization = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
LittleShop.Client/Services/CatalogService.cs
Normal file
138
LittleShop.Client/Services/CatalogService.cs
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using LittleShop.Client.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Services;
|
||||||
|
|
||||||
|
public class CatalogService : ICatalogService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<CatalogService> _logger;
|
||||||
|
|
||||||
|
public CatalogService(HttpClient httpClient, ILogger<CatalogService> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<List<Category>>> GetCategoriesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync("api/catalog/categories");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var categories = await response.Content.ReadFromJsonAsync<List<Category>>();
|
||||||
|
return ApiResponse<List<Category>>.Success(categories ?? new List<Category>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<List<Category>>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get categories");
|
||||||
|
return ApiResponse<List<Category>>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<Category>> GetCategoryByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync($"api/catalog/categories/{id}");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var category = await response.Content.ReadFromJsonAsync<Category>();
|
||||||
|
if (category != null)
|
||||||
|
return ApiResponse<Category>.Success(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<Category>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get category {CategoryId}", id);
|
||||||
|
return ApiResponse<Category>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<PagedResult<Product>>> GetProductsAsync(
|
||||||
|
int pageNumber = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
Guid? categoryId = null,
|
||||||
|
string? searchTerm = null,
|
||||||
|
decimal? minPrice = null,
|
||||||
|
decimal? maxPrice = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var queryParams = new List<string>
|
||||||
|
{
|
||||||
|
$"pageNumber={pageNumber}",
|
||||||
|
$"pageSize={pageSize}"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (categoryId.HasValue)
|
||||||
|
queryParams.Add($"categoryId={categoryId.Value}");
|
||||||
|
if (!string.IsNullOrEmpty(searchTerm))
|
||||||
|
queryParams.Add($"search={Uri.EscapeDataString(searchTerm)}");
|
||||||
|
if (minPrice.HasValue)
|
||||||
|
queryParams.Add($"minPrice={minPrice.Value}");
|
||||||
|
if (maxPrice.HasValue)
|
||||||
|
queryParams.Add($"maxPrice={maxPrice.Value}");
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams);
|
||||||
|
var response = await _httpClient.GetAsync($"api/catalog/products?{queryString}");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<PagedResult<Product>>();
|
||||||
|
if (result != null)
|
||||||
|
return ApiResponse<PagedResult<Product>>.Success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<PagedResult<Product>>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get products");
|
||||||
|
return ApiResponse<PagedResult<Product>>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<Product>> GetProductByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync($"api/catalog/products/{id}");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var product = await response.Content.ReadFromJsonAsync<Product>();
|
||||||
|
if (product != null)
|
||||||
|
return ApiResponse<Product>.Success(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<Product>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get product {ProductId}", id);
|
||||||
|
return ApiResponse<Product>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
LittleShop.Client/Services/IAuthenticationService.cs
Normal file
13
LittleShop.Client/Services/IAuthenticationService.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
using LittleShop.Client.Models;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Services;
|
||||||
|
|
||||||
|
public interface IAuthenticationService
|
||||||
|
{
|
||||||
|
Task<ApiResponse<LoginResponse>> LoginAsync(string username, string password);
|
||||||
|
Task<ApiResponse<LoginResponse>> RefreshTokenAsync();
|
||||||
|
void SetToken(string token);
|
||||||
|
string? GetToken();
|
||||||
|
bool IsAuthenticated();
|
||||||
|
void Logout();
|
||||||
|
}
|
||||||
17
LittleShop.Client/Services/ICatalogService.cs
Normal file
17
LittleShop.Client/Services/ICatalogService.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using LittleShop.Client.Models;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Services;
|
||||||
|
|
||||||
|
public interface ICatalogService
|
||||||
|
{
|
||||||
|
Task<ApiResponse<List<Category>>> GetCategoriesAsync();
|
||||||
|
Task<ApiResponse<Category>> GetCategoryByIdAsync(Guid id);
|
||||||
|
Task<ApiResponse<PagedResult<Product>>> GetProductsAsync(
|
||||||
|
int pageNumber = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
Guid? categoryId = null,
|
||||||
|
string? searchTerm = null,
|
||||||
|
decimal? minPrice = null,
|
||||||
|
decimal? maxPrice = null);
|
||||||
|
Task<ApiResponse<Product>> GetProductByIdAsync(Guid id);
|
||||||
|
}
|
||||||
12
LittleShop.Client/Services/IOrderService.cs
Normal file
12
LittleShop.Client/Services/IOrderService.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using LittleShop.Client.Models;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Services;
|
||||||
|
|
||||||
|
public interface IOrderService
|
||||||
|
{
|
||||||
|
Task<ApiResponse<Order>> CreateOrderAsync(CreateOrderRequest request);
|
||||||
|
Task<ApiResponse<List<Order>>> GetOrdersByIdentityAsync(string identityReference);
|
||||||
|
Task<ApiResponse<Order>> GetOrderByIdAsync(Guid id);
|
||||||
|
Task<ApiResponse<CryptoPayment>> CreatePaymentAsync(Guid orderId, string currency);
|
||||||
|
Task<ApiResponse<List<CryptoPayment>>> GetOrderPaymentsAsync(Guid orderId);
|
||||||
|
}
|
||||||
143
LittleShop.Client/Services/OrderService.cs
Normal file
143
LittleShop.Client/Services/OrderService.cs
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using LittleShop.Client.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace LittleShop.Client.Services;
|
||||||
|
|
||||||
|
public class OrderService : IOrderService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<OrderService> _logger;
|
||||||
|
|
||||||
|
public OrderService(HttpClient httpClient, ILogger<OrderService> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<Order>> CreateOrderAsync(CreateOrderRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync("api/orders", request);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var order = await response.Content.ReadFromJsonAsync<Order>();
|
||||||
|
if (order != null)
|
||||||
|
return ApiResponse<Order>.Success(order, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<Order>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create order");
|
||||||
|
return ApiResponse<Order>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<List<Order>>> GetOrdersByIdentityAsync(string identityReference)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(
|
||||||
|
$"api/orders/by-identity/{Uri.EscapeDataString(identityReference)}");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var orders = await response.Content.ReadFromJsonAsync<List<Order>>();
|
||||||
|
return ApiResponse<List<Order>>.Success(orders ?? new List<Order>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<List<Order>>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get orders for identity {Identity}", identityReference);
|
||||||
|
return ApiResponse<List<Order>>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<Order>> GetOrderByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync($"api/orders/{id}");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var order = await response.Content.ReadFromJsonAsync<Order>();
|
||||||
|
if (order != null)
|
||||||
|
return ApiResponse<Order>.Success(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<Order>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get order {OrderId}", id);
|
||||||
|
return ApiResponse<Order>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<CryptoPayment>> CreatePaymentAsync(Guid orderId, string currency)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new CreatePaymentRequest { Currency = currency };
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(
|
||||||
|
$"api/orders/{orderId}/payments", request);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var payment = await response.Content.ReadFromJsonAsync<CryptoPayment>();
|
||||||
|
if (payment != null)
|
||||||
|
return ApiResponse<CryptoPayment>.Success(payment, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<CryptoPayment>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create payment for order {OrderId}", orderId);
|
||||||
|
return ApiResponse<CryptoPayment>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse<List<CryptoPayment>>> GetOrderPaymentsAsync(Guid orderId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync($"api/orders/{orderId}/payments");
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var payments = await response.Content.ReadFromJsonAsync<List<CryptoPayment>>();
|
||||||
|
return ApiResponse<List<CryptoPayment>>.Success(payments ?? new List<CryptoPayment>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await response.Content.ReadAsStringAsync();
|
||||||
|
return ApiResponse<List<CryptoPayment>>.Failure(error, response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get payments for order {OrderId}", orderId);
|
||||||
|
return ApiResponse<List<CryptoPayment>>.Failure(
|
||||||
|
ex.Message,
|
||||||
|
System.Net.HttpStatusCode.InternalServerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -289,7 +289,7 @@ public class OrdersControllerTests : IClassFixture<TestWebApplicationFactory>
|
|||||||
var webhookDto = new PaymentWebhookDto
|
var webhookDto = new PaymentWebhookDto
|
||||||
{
|
{
|
||||||
InvoiceId = "INV123456",
|
InvoiceId = "INV123456",
|
||||||
Status = PaymentStatus.Confirmed,
|
Status = PaymentStatus.Paid,
|
||||||
Amount = 100.00m,
|
Amount = 100.00m,
|
||||||
TransactionHash = "tx123456789"
|
TransactionHash = "tx123456789"
|
||||||
};
|
};
|
||||||
@ -407,7 +407,7 @@ public class OrdersControllerTests : IClassFixture<TestWebApplicationFactory>
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
IdentityReference = identityReference,
|
IdentityReference = identityReference,
|
||||||
Status = OrderStatus.Pending,
|
Status = OrderStatus.PendingPayment,
|
||||||
ShippingName = "Test Customer",
|
ShippingName = "Test Customer",
|
||||||
ShippingAddress = "Test Address",
|
ShippingAddress = "Test Address",
|
||||||
ShippingCity = "Test City",
|
ShippingCity = "Test City",
|
||||||
|
|||||||
@ -78,12 +78,9 @@ public static class TestDataBuilder
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Username = user,
|
Username = user,
|
||||||
Email = $"{user}@test.com",
|
|
||||||
PasswordHash = "hashed-password",
|
PasswordHash = "hashed-password",
|
||||||
Role = role,
|
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow
|
||||||
UpdatedAt = DateTime.UtcNow
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,12 +91,11 @@ public static class TestDataBuilder
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
OrderId = orderId,
|
OrderId = orderId,
|
||||||
Currency = currency,
|
Currency = currency,
|
||||||
Amount = 0.0025m,
|
RequiredAmount = 0.0025m,
|
||||||
CryptoAddress = $"bc1q{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 39)}",
|
WalletAddress = $"bc1q{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 39)}",
|
||||||
Status = status,
|
Status = status,
|
||||||
ExchangeRate = 40000.00m,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
ExpiresAt = DateTime.UtcNow.AddHours(1)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,15 +157,17 @@ public static class TestDataBuilder
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ProductPhoto CreateProductPhoto(Guid productId, int displayOrder = 1)
|
public static ProductPhoto CreateProductPhoto(Guid productId, int sortOrder = 1)
|
||||||
{
|
{
|
||||||
|
var fileName = $"{Guid.NewGuid()}.jpg";
|
||||||
return new ProductPhoto
|
return new ProductPhoto
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ProductId = productId,
|
ProductId = productId,
|
||||||
PhotoUrl = $"/uploads/products/{Guid.NewGuid()}.jpg",
|
FileName = fileName,
|
||||||
|
FilePath = $"/uploads/products/{fileName}",
|
||||||
AltText = "Test product photo",
|
AltText = "Test product photo",
|
||||||
DisplayOrder = displayOrder,
|
SortOrder = sortOrder,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ public class CategoryServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly LittleShopContext _context;
|
private readonly LittleShopContext _context;
|
||||||
private readonly ICategoryService _categoryService;
|
private readonly ICategoryService _categoryService;
|
||||||
private readonly IMapper _mapper;
|
|
||||||
|
|
||||||
public CategoryServiceTests()
|
public CategoryServiceTests()
|
||||||
{
|
{
|
||||||
@ -25,15 +24,8 @@ public class CategoryServiceTests : IDisposable
|
|||||||
.Options;
|
.Options;
|
||||||
_context = new LittleShopContext(options);
|
_context = new LittleShopContext(options);
|
||||||
|
|
||||||
// Set up AutoMapper
|
|
||||||
var mappingConfig = new MapperConfiguration(mc =>
|
|
||||||
{
|
|
||||||
mc.AddProfile(new MappingProfile());
|
|
||||||
});
|
|
||||||
_mapper = mappingConfig.CreateMapper();
|
|
||||||
|
|
||||||
// Create service
|
// Create service
|
||||||
_categoryService = new CategoryService(_context, _mapper);
|
_categoryService = new CategoryService(_context);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@ -5,9 +5,9 @@ using LittleShop.Models;
|
|||||||
using LittleShop.Services;
|
using LittleShop.Services;
|
||||||
using LittleShop.Enums;
|
using LittleShop.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using AutoMapper;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using LittleShop.Mapping;
|
|
||||||
|
|
||||||
namespace LittleShop.Tests.Unit;
|
namespace LittleShop.Tests.Unit;
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ public class ProductServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly LittleShopContext _context;
|
private readonly LittleShopContext _context;
|
||||||
private readonly IProductService _productService;
|
private readonly IProductService _productService;
|
||||||
private readonly IMapper _mapper;
|
private readonly Mock<IWebHostEnvironment> _mockEnvironment;
|
||||||
|
|
||||||
public ProductServiceTests()
|
public ProductServiceTests()
|
||||||
{
|
{
|
||||||
@ -25,15 +25,12 @@ public class ProductServiceTests : IDisposable
|
|||||||
.Options;
|
.Options;
|
||||||
_context = new LittleShopContext(options);
|
_context = new LittleShopContext(options);
|
||||||
|
|
||||||
// Set up AutoMapper
|
// Set up mock environment
|
||||||
var mappingConfig = new MapperConfiguration(mc =>
|
_mockEnvironment = new Mock<IWebHostEnvironment>();
|
||||||
{
|
_mockEnvironment.Setup(e => e.WebRootPath).Returns("/test/wwwroot");
|
||||||
mc.AddProfile(new MappingProfile());
|
|
||||||
});
|
|
||||||
_mapper = mappingConfig.CreateMapper();
|
|
||||||
|
|
||||||
// Create service
|
// Create service
|
||||||
_productService = new ProductService(_context, _mapper);
|
_productService = new ProductService(_context, _mockEnvironment.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@ -242,7 +239,7 @@ public class ProductServiceTests : IDisposable
|
|||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.PhotoUrl.Should().Be("/uploads/test-photo.jpg");
|
result.FilePath.Should().Contain("test-photo.jpg");
|
||||||
result.AltText.Should().Be("Test Photo");
|
result.AltText.Should().Be("Test Photo");
|
||||||
|
|
||||||
// Verify in database
|
// Verify in database
|
||||||
|
|||||||
@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleBotClient", "TeleBot\Te
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Tests", "LittleShop.Tests\LittleShop.Tests.csproj", "{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Tests", "LittleShop.Tests\LittleShop.Tests.csproj", "{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Client", "LittleShop.Client\LittleShop.Client.csproj", "{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -71,6 +73,18 @@ Global
|
|||||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x64.Build.0 = Release|Any CPU
|
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x86.ActiveCfg = Release|Any CPU
|
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x86.Build.0 = Release|Any CPU
|
{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
281
PROJECT_README.md
Normal file
281
PROJECT_README.md
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
# LittleShop E-Commerce Platform
|
||||||
|
|
||||||
|
A complete e-commerce platform built with ASP.NET Core 9.0, featuring multi-cryptocurrency payment support and a privacy-focused design.
|
||||||
|
|
||||||
|
## 🎯 Features
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
- **Product Management**: Categories, products with photos, weight-based pricing
|
||||||
|
- **Order Management**: Complete order workflow from creation to delivery
|
||||||
|
- **Multi-Cryptocurrency Payments**: Bitcoin, Monero, USDT, Litecoin, Ethereum, Zcash, Dash, Dogecoin
|
||||||
|
- **Shipping Management**: Weight-based shipping rates with Royal Mail integration
|
||||||
|
- **Admin Panel**: Full administrative interface for managing the store
|
||||||
|
- **API**: RESTful API with JWT authentication for client applications
|
||||||
|
|
||||||
|
### Security & Privacy
|
||||||
|
- **No KYC Requirements**: Privacy-focused design with minimal data collection
|
||||||
|
- **Dual Authentication**: Cookie-based for admin panel, JWT for API
|
||||||
|
- **Self-Hosted Payments**: BTCPay Server integration for cryptocurrency processing
|
||||||
|
- **Secure Password Storage**: PBKDF2 with 100,000 iterations
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- .NET 9.0 SDK
|
||||||
|
- SQLite (included)
|
||||||
|
- BTCPay Server instance (for payments)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/littleshop.git
|
||||||
|
cd littleshop
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Restore dependencies:
|
||||||
|
```bash
|
||||||
|
dotnet restore
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the application:
|
||||||
|
```bash
|
||||||
|
dotnet run --project LittleShop/LittleShop.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Access the application:
|
||||||
|
- Admin Panel: https://localhost:5001/Admin
|
||||||
|
- API Documentation: https://localhost:5001/swagger
|
||||||
|
- Default credentials: `admin` / `admin`
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
LittleShop/
|
||||||
|
├── LittleShop/ # Main web application
|
||||||
|
│ ├── Areas/Admin/ # Admin panel MVC
|
||||||
|
│ ├── Controllers/ # API controllers
|
||||||
|
│ ├── Services/ # Business logic
|
||||||
|
│ ├── Models/ # Database entities
|
||||||
|
│ ├── DTOs/ # Data transfer objects
|
||||||
|
│ └── Data/ # Entity Framework context
|
||||||
|
├── LittleShop.Client/ # .NET client SDK
|
||||||
|
│ ├── Services/ # API client services
|
||||||
|
│ ├── Models/ # Client models
|
||||||
|
│ └── Http/ # HTTP handlers
|
||||||
|
└── LittleShop.Tests/ # Test suite
|
||||||
|
├── Unit/ # Unit tests
|
||||||
|
├── Integration/ # API integration tests
|
||||||
|
├── Security/ # Security tests
|
||||||
|
└── UI/ # UI automation tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💻 Using the Client SDK
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
```bash
|
||||||
|
dotnet add reference LittleShop.Client/LittleShop.Client.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
```csharp
|
||||||
|
using LittleShop.Client.Extensions;
|
||||||
|
|
||||||
|
// Configure services
|
||||||
|
services.AddLittleShopClient(options =>
|
||||||
|
{
|
||||||
|
options.BaseUrl = "https://localhost:5001";
|
||||||
|
options.TimeoutSeconds = 30;
|
||||||
|
options.MaxRetryAttempts = 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the client
|
||||||
|
var client = serviceProvider.GetRequiredService<ILittleShopClient>();
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
await client.Authentication.LoginAsync("admin", "admin");
|
||||||
|
|
||||||
|
// Get products
|
||||||
|
var products = await client.Catalog.GetProductsAsync();
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
var order = await client.Orders.CreateOrderAsync(new CreateOrderRequest
|
||||||
|
{
|
||||||
|
IdentityReference = "CUST001",
|
||||||
|
ShippingName = "John Doe",
|
||||||
|
ShippingAddress = "123 Main St",
|
||||||
|
ShippingCity = "London",
|
||||||
|
ShippingPostCode = "SW1A 1AA",
|
||||||
|
ShippingCountry = "United Kingdom",
|
||||||
|
Items = new[] { new CreateOrderItem { ProductId = productId, Quantity = 1 } }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/login` - Login with username/password
|
||||||
|
- `POST /api/auth/refresh` - Refresh JWT token
|
||||||
|
|
||||||
|
### Catalog (Requires Authentication)
|
||||||
|
- `GET /api/catalog/categories` - Get all categories
|
||||||
|
- `GET /api/catalog/categories/{id}` - Get category by ID
|
||||||
|
- `GET /api/catalog/products` - Get products with filtering
|
||||||
|
- `GET /api/catalog/products/{id}` - Get product by ID
|
||||||
|
|
||||||
|
### Orders (Requires Authentication)
|
||||||
|
- `POST /api/orders` - Create new order
|
||||||
|
- `GET /api/orders/by-identity/{id}` - Get orders by customer identity
|
||||||
|
- `GET /api/orders/{id}` - Get order by ID
|
||||||
|
- `POST /api/orders/{id}/payments` - Create crypto payment
|
||||||
|
- `POST /api/orders/payments/webhook` - BTCPay webhook endpoint
|
||||||
|
|
||||||
|
## 🗄️ Database Schema
|
||||||
|
|
||||||
|
### Core Tables
|
||||||
|
- **Users**: Staff/admin accounts only
|
||||||
|
- **Categories**: Product categories
|
||||||
|
- **Products**: Product catalog with pricing and weight
|
||||||
|
- **ProductPhotos**: Product images with sorting
|
||||||
|
- **Orders**: Customer orders with shipping details
|
||||||
|
- **OrderItems**: Individual items in orders
|
||||||
|
- **CryptoPayments**: Cryptocurrency payment records
|
||||||
|
- **ShippingRates**: Weight-based shipping calculations
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run specific test categories:
|
||||||
|
```bash
|
||||||
|
# Unit tests only
|
||||||
|
dotnet test --filter Category=Unit
|
||||||
|
|
||||||
|
# Integration tests
|
||||||
|
dotnet test --filter Category=Integration
|
||||||
|
|
||||||
|
# Security tests
|
||||||
|
dotnet test --filter Category=Security
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
- ✅ Unit tests for all services
|
||||||
|
- ✅ Integration tests for all API endpoints
|
||||||
|
- ✅ Security tests for authentication enforcement
|
||||||
|
- ✅ UI automation tests with Playwright
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### appsettings.json
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Data Source=littleshop.db"
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
|
||||||
|
"Issuer": "LittleShop",
|
||||||
|
"Audience": "LittleShop",
|
||||||
|
"ExpiryMinutes": 60
|
||||||
|
},
|
||||||
|
"BTCPayServer": {
|
||||||
|
"Url": "https://your-btcpay.com",
|
||||||
|
"StoreId": "your-store-id",
|
||||||
|
"ApiKey": "your-api-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚢 Deployment
|
||||||
|
|
||||||
|
### Production Checklist
|
||||||
|
1. Update connection strings
|
||||||
|
2. Configure BTCPay Server
|
||||||
|
3. Set strong JWT secret key
|
||||||
|
4. Enable HTTPS only
|
||||||
|
5. Configure CORS for your domain
|
||||||
|
6. Set up SSL certificates
|
||||||
|
7. Configure logging
|
||||||
|
8. Set up database backups
|
||||||
|
|
||||||
|
### Docker Support
|
||||||
|
```dockerfile
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 80 443
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet restore
|
||||||
|
RUN dotnet build -c Release
|
||||||
|
RUN dotnet publish -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "LittleShop.dll"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Sample Data
|
||||||
|
|
||||||
|
The application includes sample data seeder that creates:
|
||||||
|
- 3 Categories (Electronics, Clothing, Books)
|
||||||
|
- 5 Products with various prices
|
||||||
|
- 5 Shipping rates (Royal Mail options)
|
||||||
|
- 5 Sample orders in different statuses
|
||||||
|
- 3 Crypto payments demonstrating payment flow
|
||||||
|
|
||||||
|
## 🛡️ Security Considerations
|
||||||
|
|
||||||
|
- **Authentication Required**: All API endpoints require JWT authentication
|
||||||
|
- **No Public Endpoints**: Client applications must authenticate first
|
||||||
|
- **Password Security**: PBKDF2 with salt and 100,000 iterations
|
||||||
|
- **Input Validation**: FluentValidation on all inputs
|
||||||
|
- **SQL Injection Protection**: Entity Framework Core with parameterized queries
|
||||||
|
- **XSS Protection**: Razor view encoding and validation
|
||||||
|
- **CORS**: Configured for specific domains in production
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
This project is proprietary software. All rights reserved.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests for new functionality
|
||||||
|
5. Ensure all tests pass
|
||||||
|
6. Submit a pull request
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues, questions, or suggestions:
|
||||||
|
- Open an issue on GitHub
|
||||||
|
- Contact: support@littleshop.com
|
||||||
|
|
||||||
|
## 🏗️ Built With
|
||||||
|
|
||||||
|
- **ASP.NET Core 9.0** - Web framework
|
||||||
|
- **Entity Framework Core** - ORM
|
||||||
|
- **SQLite** - Database
|
||||||
|
- **Bootstrap 5** - UI framework
|
||||||
|
- **JWT** - API authentication
|
||||||
|
- **BTCPay Server** - Cryptocurrency payments
|
||||||
|
- **xUnit** - Testing framework
|
||||||
|
- **Playwright** - UI automation
|
||||||
|
- **Serilog** - Logging
|
||||||
|
|
||||||
|
## 📈 Version History
|
||||||
|
|
||||||
|
- **v1.0.0** - Initial release with core e-commerce functionality
|
||||||
|
- Product catalog management
|
||||||
|
- Order processing workflow
|
||||||
|
- Multi-cryptocurrency payments
|
||||||
|
- Admin panel and API
|
||||||
|
- Client SDK library
|
||||||
|
- Comprehensive test coverage
|
||||||
38
commit-message.txt
Normal file
38
commit-message.txt
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
Implement complete e-commerce functionality with shipping and order management
|
||||||
|
|
||||||
|
Features Added:
|
||||||
|
- Standard e-commerce properties (Price, Weight, shipping fields)
|
||||||
|
- Order management with Create/Edit views and shipping information
|
||||||
|
- ShippingRates system for weight-based shipping calculations
|
||||||
|
- Comprehensive test coverage with JWT authentication tests
|
||||||
|
- Sample data seeder with 5 orders demonstrating full workflow
|
||||||
|
- Photo upload functionality for products
|
||||||
|
- Multi-cryptocurrency payment support (BTC, XMR, USDT, etc.)
|
||||||
|
|
||||||
|
Database Changes:
|
||||||
|
- Added ShippingRates table
|
||||||
|
- Added shipping fields to Orders (Name, Address, City, PostCode, Country)
|
||||||
|
- Renamed properties to standard names (BasePrice to Price, ProductWeight to Weight)
|
||||||
|
- Added UpdatedAt timestamps to models
|
||||||
|
|
||||||
|
UI Improvements:
|
||||||
|
- Added Create/Edit views for Orders
|
||||||
|
- Added ShippingRates management UI
|
||||||
|
- Updated navigation menu with Shipping option
|
||||||
|
- Enhanced Order Details view with shipping information
|
||||||
|
|
||||||
|
Sample Data:
|
||||||
|
- 3 Categories (Electronics, Clothing, Books)
|
||||||
|
- 5 Products with various prices
|
||||||
|
- 5 Shipping rates (Royal Mail options)
|
||||||
|
- 5 Orders in different statuses (Pending to Delivered)
|
||||||
|
- 3 Crypto payments demonstrating payment flow
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- All API endpoints secured with JWT authentication
|
||||||
|
- No public endpoints - client apps must authenticate
|
||||||
|
- Privacy-focused design with minimal data collection
|
||||||
|
|
||||||
|
Generated with Claude Code
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||||
Loading…
Reference in New Issue
Block a user