Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/Basket.API/Extensions/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using eShop.Basket.API.Repositories;
using eShop.Basket.API.IntegrationEvents.EventHandling;
using eShop.Basket.API.IntegrationEvents.EventHandling.Events;
using eShop.Basket.API.Services;

namespace eShop.Basket.API.Extensions;

Expand All @@ -14,6 +15,8 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder)
builder.AddRedisClient("redis");

builder.Services.AddSingleton<IBasketRepository, RedisBasketRepository>();
builder.Services.AddHttpClient<CatalogClient>(client =>
client.BaseAddress = new("https+http://catalog-api"));

builder.AddRabbitMqEventBus("eventbus")
.AddSubscription<OrderStartedIntegrationEvent, OrderStartedIntegrationEventHandler>()
Expand Down
40 changes: 39 additions & 1 deletion src/Basket.API/Grpc/BasketService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
using eShop.Basket.API.Repositories;
using eShop.Basket.API.Extensions;
using eShop.Basket.API.Model;
using eShop.Basket.API.Services;
using Polly.CircuitBreaker;
using Polly.Timeout;

namespace eShop.Basket.API.Grpc;

public class BasketService(
IBasketRepository repository,
ILogger<BasketService> logger) : Basket.BasketBase
ILogger<BasketService> logger,
CatalogClient catalog) : Basket.BasketBase
{
[AllowAnonymous]
public override async Task<CustomerBasketResponse> GetBasket(GetBasketRequest request, ServerCallContext context)
Expand Down Expand Up @@ -46,6 +50,40 @@ public override async Task<CustomerBasketResponse> UpdateBasket(UpdateBasketRequ
logger.LogDebug("Begin UpdateBasket call from method {Method} for basket id {Id}", context.Method, userId);
}

// Bound the batch lookup and reject ambiguous or invalid basket lines before any I/O.
var productIds = request.Items.Select(item => item.ProductId).ToArray();
if (request.Items.Count > 100 ||
request.Items.Any(item => item.ProductId <= 0 || item.Quantity <= 0) ||
productIds.Distinct().Count() != productIds.Length)
{
throw new RpcException(new Status(StatusCode.InvalidArgument,
"A basket supports up to 100 unique products with positive IDs and quantities."));
}

// Clearing a basket should remain possible even when Catalog is unavailable.
if (productIds.Length > 0)
{
HashSet<int> existingIds;
try
{
existingIds = await catalog.GetProductIdsAsync(productIds, context.CancellationToken);
}
catch (Exception exception) when (exception is HttpRequestException or JsonException or
TimeoutRejectedException or BrokenCircuitException ||
exception is OperationCanceledException && !context.CancellationToken.IsCancellationRequested)
{
logger.LogWarning(exception, "Catalog lookup failed while updating a basket");
throw new RpcException(new Status(StatusCode.Unavailable,
"Catalog is temporarily unavailable. Your basket has not been changed. Please retry."));
}

if (productIds.Any(id => !existingIds.Contains(id)))
{
throw new RpcException(new Status(StatusCode.FailedPrecondition,
"One or more products no longer exist in the catalog. Refresh your basket and try again."));
}
}

var customerBasket = MapToCustomerBasket(userId, request);
var response = await repository.UpdateBasketAsync(customerBasket);
if (response is null)
Expand Down
13 changes: 13 additions & 0 deletions src/Basket.API/Model/BasketPreview.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace eShop.Basket.API.Model;

/// <summary>A read-only line estimate. Creating a preview never writes a basket or reserves stock.</summary>
public record BasketPreview(int ProductId, string ProductName, int Quantity, decimal UnitPrice, decimal TotalPrice)
{
public static BasketPreview Create(BasketItem item)
{
Validator.ValidateObject(item, new ValidationContext(item), validateAllProperties: true);

return new BasketPreview(item.ProductId, item.ProductName, item.Quantity,
item.UnitPrice, item.UnitPrice * item.Quantity);
}
}
2 changes: 1 addition & 1 deletion src/Basket.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
var builder = WebApplication.CreateBuilder(args);

builder.AddBasicServiceDefaults();
builder.AddServiceDefaults();
builder.AddApplicationServices();

builder.Services.AddGrpc();
Expand Down
24 changes: 24 additions & 0 deletions src/Basket.API/Services/CatalogClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;

namespace eShop.Basket.API.Services;

public class CatalogClient(HttpClient httpClient)
{
public async Task<HashSet<int>> GetProductIdsAsync(IEnumerable<int> productIds, CancellationToken cancellationToken)
{
var query = string.Join("&", productIds.Select(id => $"ids={id}"));
var products = await httpClient.GetFromJsonAsync(
$"/api/catalog/items/by?api-version=2.0&{query}",
CatalogJsonContext.Default.CatalogProductArray,
cancellationToken);

return products?.Select(product => product.Id).ToHashSet()
?? throw new JsonException("Catalog returned a null product list.");
}
}

public record CatalogProduct(int Id);

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(CatalogProduct[]))]
internal partial class CatalogJsonContext : JsonSerializerContext;
39 changes: 39 additions & 0 deletions src/Catalog.API/Apis/CatalogApi.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using eShop.Basket.API.Model;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
Expand Down Expand Up @@ -38,6 +39,11 @@ public static IEndpointRouteBuilder MapCatalogApi(this IEndpointRouteBuilder app
.WithSummary("Get catalog item")
.WithDescription("Get an item from the catalog")
.WithTags("Items");
api.MapGet("/items/{id:int}/basket-preview", GetBasketPreview)
.WithName("PreviewBasketItem")
.WithSummary("Preview a basket line")
.WithDescription("Calculate a line estimate using Basket-owned C# validation and pricing code in-process. Does not write a basket or reserve stock.")
.WithTags("Items");
v1.MapGet("/items/by/{name:minlength(1)}", GetItemsByName)
.WithName("GetItemsByName")
.WithSummary("Get catalog items by name")
Expand Down Expand Up @@ -235,6 +241,39 @@ public static async Task<Results<Ok<CatalogItem>, NotFound, BadRequest<ProblemDe
return TypedResults.Ok(item);
}

public static async Task<Results<Ok<BasketPreview>, NotFound, BadRequest<ProblemDetails>>> GetBasketPreview(
[AsParameters] CatalogServices services,
int id,
int quantity = 1)
{
if (id <= 0)
{
return TypedResults.BadRequest(new ProblemDetails { Detail = "Id is not valid" });
}

var item = await services.Context.CatalogItems.AsNoTracking().SingleOrDefaultAsync(item => item.Id == id);
if (item is null)
{
return TypedResults.NotFound();
}

try
{
// Direct C# call into the Basket assembly, not an HTTP request or an integration event.
return TypedResults.Ok(BasketPreview.Create(new BasketItem
{
ProductId = item.Id,
ProductName = item.Name,
UnitPrice = item.Price,
Quantity = quantity
}));
}
catch (ValidationException exception)
{
return TypedResults.BadRequest(new ProblemDetails { Detail = exception.Message });
}
}

[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
public static async Task<Ok<PaginatedItems<CatalogItem>>> GetItemsByName(
[AsParameters] PaginationRequest paginationRequest,
Expand Down
9 changes: 9 additions & 0 deletions src/Catalog.API/Catalog.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
</ItemGroup>

<ItemGroup>
<!-- Deliberate demo coupling: Catalog calls Basket's preview code in-process. -->
<ProjectReference Include="..\Basket.API\Basket.API.csproj" />
<ProjectReference Include="..\EventBusRabbitMQ\EventBusRabbitMQ.csproj" />
<ProjectReference Include="..\IntegrationEventLogEF\IntegrationEventLogEF.csproj" />
<ProjectReference Include="..\eShop.ServiceDefaults\eShop.ServiceDefaults.csproj" />
Expand All @@ -50,4 +52,11 @@
<InternalsVisibleTo Include="Catalog.FunctionalTests" />
</ItemGroup>

<!-- Reuse Basket's assembly, not the configuration of its independently hosted service. -->
<Target Name="ExcludeBasketHostSettingsFromPublish" BeforeTargets="_HandleFileConflictsForPublish">
<ItemGroup>
<ResolvedFileToPublish Remove="$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '..', 'Basket.API'))appsettings*.json" />
</ItemGroup>
</Target>

</Project>
120 changes: 120 additions & 0 deletions src/Catalog.API/Catalog.API.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,76 @@
}
}
},
"/api/catalog/items/{id}/basket-preview": {
"get": {
"tags": [
"Items"
],
"summary": "Preview a basket line",
"description": "Calculate a line estimate using Basket-owned C# validation and pricing code in-process. Does not write a basket or reserve stock.",
"operationId": "PreviewBasketItem",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": "integer",
"format": "int32"
}
},
{
"name": "quantity",
"in": "query",
"schema": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32",
"default": 1
}
},
{
"name": "api-version",
"in": "query",
"description": "The API version, in the format 'major.minor'.",
"required": true,
"schema": {
"type": "string",
"example": "1.0"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BasketPreview"
}
}
}
},
"404": {
"description": "Not Found"
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/catalog/items/{id}/pic": {
"get": {
"tags": [
Expand Down Expand Up @@ -955,6 +1025,56 @@
},
"components": {
"schemas": {
"BasketPreview": {
"required": [
"productId",
"productName",
"quantity",
"unitPrice",
"totalPrice"
],
"type": "object",
"properties": {
"productId": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32"
},
"productName": {
"type": [
"null",
"string"
]
},
"quantity": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32"
},
"unitPrice": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
"type": [
"number",
"string"
],
"format": "double"
},
"totalPrice": {
"pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
"type": [
"number",
"string"
],
"format": "double"
}
}
},
"CatalogBrand": {
"required": [
"brand"
Expand Down
Loading
Loading