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
|
||||
{
|
||||
InvoiceId = "INV123456",
|
||||
Status = PaymentStatus.Confirmed,
|
||||
Status = PaymentStatus.Paid,
|
||||
Amount = 100.00m,
|
||||
TransactionHash = "tx123456789"
|
||||
};
|
||||
@ -407,7 +407,7 @@ public class OrdersControllerTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IdentityReference = identityReference,
|
||||
Status = OrderStatus.Pending,
|
||||
Status = OrderStatus.PendingPayment,
|
||||
ShippingName = "Test Customer",
|
||||
ShippingAddress = "Test Address",
|
||||
ShippingCity = "Test City",
|
||||
|
||||
@ -78,12 +78,9 @@ public static class TestDataBuilder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = user,
|
||||
Email = $"{user}@test.com",
|
||||
PasswordHash = "hashed-password",
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
@ -94,12 +91,11 @@ public static class TestDataBuilder
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = orderId,
|
||||
Currency = currency,
|
||||
Amount = 0.0025m,
|
||||
CryptoAddress = $"bc1q{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 39)}",
|
||||
RequiredAmount = 0.0025m,
|
||||
WalletAddress = $"bc1q{Guid.NewGuid().ToString().Replace("-", "").Substring(0, 39)}",
|
||||
Status = status,
|
||||
ExchangeRate = 40000.00m,
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProductId = productId,
|
||||
PhotoUrl = $"/uploads/products/{Guid.NewGuid()}.jpg",
|
||||
FileName = fileName,
|
||||
FilePath = $"/uploads/products/{fileName}",
|
||||
AltText = "Test product photo",
|
||||
DisplayOrder = displayOrder,
|
||||
SortOrder = sortOrder,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
@ -15,7 +15,6 @@ public class CategoryServiceTests : IDisposable
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly ICategoryService _categoryService;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public CategoryServiceTests()
|
||||
{
|
||||
@ -25,15 +24,8 @@ public class CategoryServiceTests : IDisposable
|
||||
.Options;
|
||||
_context = new LittleShopContext(options);
|
||||
|
||||
// Set up AutoMapper
|
||||
var mappingConfig = new MapperConfiguration(mc =>
|
||||
{
|
||||
mc.AddProfile(new MappingProfile());
|
||||
});
|
||||
_mapper = mappingConfig.CreateMapper();
|
||||
|
||||
// Create service
|
||||
_categoryService = new CategoryService(_context, _mapper);
|
||||
_categoryService = new CategoryService(_context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -5,9 +5,9 @@ using LittleShop.Models;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using LittleShop.Mapping;
|
||||
|
||||
namespace LittleShop.Tests.Unit;
|
||||
|
||||
@ -15,7 +15,7 @@ public class ProductServiceTests : IDisposable
|
||||
{
|
||||
private readonly LittleShopContext _context;
|
||||
private readonly IProductService _productService;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly Mock<IWebHostEnvironment> _mockEnvironment;
|
||||
|
||||
public ProductServiceTests()
|
||||
{
|
||||
@ -25,15 +25,12 @@ public class ProductServiceTests : IDisposable
|
||||
.Options;
|
||||
_context = new LittleShopContext(options);
|
||||
|
||||
// Set up AutoMapper
|
||||
var mappingConfig = new MapperConfiguration(mc =>
|
||||
{
|
||||
mc.AddProfile(new MappingProfile());
|
||||
});
|
||||
_mapper = mappingConfig.CreateMapper();
|
||||
// Set up mock environment
|
||||
_mockEnvironment = new Mock<IWebHostEnvironment>();
|
||||
_mockEnvironment.Setup(e => e.WebRootPath).Returns("/test/wwwroot");
|
||||
|
||||
// Create service
|
||||
_productService = new ProductService(_context, _mapper);
|
||||
_productService = new ProductService(_context, _mockEnvironment.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -242,7 +239,7 @@ public class ProductServiceTests : IDisposable
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.PhotoUrl.Should().Be("/uploads/test-photo.jpg");
|
||||
result.FilePath.Should().Contain("test-photo.jpg");
|
||||
result.AltText.Should().Be("Test Photo");
|
||||
|
||||
// Verify in database
|
||||
|
||||
@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleBotClient", "TeleBot\Te
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Tests", "LittleShop.Tests\LittleShop.Tests.csproj", "{96E261C3-BBEB-4FC5-B006-DCC0B514F6D9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LittleShop.Client", "LittleShop.Client\LittleShop.Client.csproj", "{AFBCF1FA-EB99-4B90-89F5-6CB72AE3B3B0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
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