Browse Source

CW-STORY-04.4: integrate Open Food Facts

OpenFoodFactsProductDataProvider registered as a typed HttpClient,
giving CW-STORY-04.3's barcode flow a real provider. Response models
stay internal to Infrastructure; failures are caught narrowly and
logged via a source-generated LoggerMessage delegate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 1 week ago
parent
commit
3c7331d3c9
7 changed files with 222 additions and 7 deletions
  1. +7
    -5
      docs/scrum-backlog.md
  2. +0
    -2
      src/CartWise.Application/DTOs/ProductLookupResult.cs
  3. +8
    -0
      src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs
  4. +80
    -0
      src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsProductDataProvider.cs
  5. +30
    -0
      src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsResponse.cs
  6. +3
    -0
      src/CartWise.Web/appsettings.json
  7. +94
    -0
      tests/CartWise.Application.Tests/OpenFoodFactsProductDataProviderTests.cs

+ 7
- 5
docs/scrum-backlog.md View File

@@ -499,11 +499,13 @@ Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` t
- Provider errors are logged safely

**Tasks**
- [ ] Implement `OpenFoodFactsProductDataProvider`
- [ ] Map provider responses to internal DTOs
- [ ] Add configuration and secrets support
- [ ] Add error handling and logging
- [ ] Add tests with mocked provider behavior
- [x] Implement `OpenFoodFactsProductDataProvider`
- [x] Map provider responses to internal DTOs
- [x] Add configuration and secrets support
- [x] Add error handling and logging
- [x] Add tests with mocked provider behavior

**Status:** Done — `OpenFoodFactsProductDataProvider` (`CartWise.Infrastructure.Integrations.OpenFoodFacts`) is registered as a typed `HttpClient` (`AddHttpClient<IProductDataProvider, OpenFoodFactsProductDataProvider>`), so `CW-STORY-04.3`'s `ProductService.ResolveBarcodeAsync` now actually has a provider in its `IEnumerable<IProductDataProvider>` instead of an empty list. Response models (`OpenFoodFactsResponse`/`OpenFoodFactsProduct`) are `internal` to Infrastructure — never referenced outside it, satisfying "never bind Razor pages directly to provider-specific response models." Base URL is configurable via `OpenFoodFacts:BaseUrl` in `appsettings.json` (no API key needed for OFF's public lookup endpoint, but the config-driven shape is ready for a future key-requiring provider like USDA). HTTP/JSON failures are caught narrowly (`HttpRequestException`, `JsonException`, `TaskCanceledException`) and logged via a source-generated `[LoggerMessage]` delegate rather than the ad-hoc `ILogger.LogWarning(...)` extension — a small perf-conscious pattern worth reusing in `CW-STORY-08.3`. A lightweight quantity parser extracts `SizeValue`/`SizeUnit` from OFF's free-text `quantity` field (e.g. `"16 oz"`), returning `null`/`null` rather than guessing when it doesn't parse. No dedicated Infrastructure test project exists (AGENTS.md's structure only lists Domain/Application/Web test projects), so — consistent with how `HouseholdServiceTests`/`ShoppingListServiceTests` already exercise real Infrastructure code — the 4 new tests live in `CartWise.Application.Tests`, using a hand-rolled fake `HttpMessageHandler` (found/not-found/HTTP-failure/malformed-JSON) rather than pulling in a mocking library.

### `CW-STORY-04.5` Build scan page
**Release:** Deferred


+ 0
- 2
src/CartWise.Application/DTOs/ProductLookupResult.cs View File

@@ -12,7 +12,5 @@ public class ProductLookupResult

public string? ImageUrl { get; init; }

public string SourceName { get; init; } = string.Empty;

public string? SourceExternalId { get; init; }
}

+ 8
- 0
src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs View File

@@ -1,6 +1,7 @@
using CartWise.Application.Interfaces;
using CartWise.Infrastructure.Data;
using CartWise.Infrastructure.Identity;
using CartWise.Infrastructure.Integrations.OpenFoodFacts;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -25,6 +26,13 @@ public static class InfrastructureServiceCollectionExtensions
.AddEntityFrameworkStores<CartWiseDbContext>()
.AddDefaultTokenProviders();

var openFoodFactsBaseUrl = configuration["OpenFoodFacts:BaseUrl"] ?? "https://world.openfoodfacts.org/";
services.AddHttpClient<IProductDataProvider, OpenFoodFactsProductDataProvider>(client =>
{
client.BaseAddress = new Uri(openFoodFactsBaseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});

return services;
}
}

+ 80
- 0
src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsProductDataProvider.cs View File

@@ -0,0 +1,80 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using CartWise.Application.DTOs;
using CartWise.Application.Interfaces;
using Microsoft.Extensions.Logging;

namespace CartWise.Infrastructure.Integrations.OpenFoodFacts;

public partial class OpenFoodFactsProductDataProvider : IProductDataProvider
{
private readonly HttpClient _httpClient;
private readonly ILogger<OpenFoodFactsProductDataProvider> _logger;

public OpenFoodFactsProductDataProvider(HttpClient httpClient, ILogger<OpenFoodFactsProductDataProvider> logger)
{
_httpClient = httpClient;
_logger = logger;
}

public string Name => "OpenFoodFacts";

public async Task<ProductLookupResult?> FindByBarcodeAsync(string barcode, CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetFromJsonAsync<OpenFoodFactsResponse>($"api/v2/product/{barcode}.json", cancellationToken);

if (response is null || response.Status != 1 || response.Product is null || string.IsNullOrWhiteSpace(response.Product.ProductName))
{
return null;
}

var (sizeValue, sizeUnit) = ParseQuantity(response.Product.Quantity);

return new ProductLookupResult
{
Name = response.Product.ProductName,
BrandName = response.Product.Brands?.Split(',').FirstOrDefault()?.Trim(),
SizeValue = sizeValue,
SizeUnit = sizeUnit,
ImageUrl = response.Product.ImageUrl,
SourceExternalId = response.Product.Code ?? barcode
};
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
LogLookupFailed(_logger, ex, barcode);
return null;
}
}

[LoggerMessage(Level = LogLevel.Warning, Message = "Open Food Facts lookup failed for barcode {Barcode}")]
private static partial void LogLookupFailed(ILogger logger, Exception exception, string barcode);

public Task<IReadOnlyList<ProductSearchResult>> SearchAsync(string query, CancellationToken cancellationToken = default)
{
// Open Food Facts text search isn't wired up for MVP; barcode lookup is the primary flow.
return Task.FromResult<IReadOnlyList<ProductSearchResult>>([]);
}

private static (decimal? Value, string? Unit) ParseQuantity(string? quantity)
{
if (string.IsNullOrWhiteSpace(quantity))
{
return (null, null);
}

var match = QuantityPattern().Match(quantity);
if (!match.Success || !decimal.TryParse(match.Groups["value"].Value, out var value))
{
return (null, null);
}

return (value, match.Groups["unit"].Value.Trim());
}

[GeneratedRegex(@"^\s*(?<value>\d+(\.\d+)?)\s*(?<unit>[a-zA-Z]+)")]
private static partial Regex QuantityPattern();
}

+ 30
- 0
src/CartWise.Infrastructure/Integrations/OpenFoodFacts/OpenFoodFactsResponse.cs View File

@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;

namespace CartWise.Infrastructure.Integrations.OpenFoodFacts;

internal sealed class OpenFoodFactsResponse
{
[JsonPropertyName("status")]
public int Status { get; set; }

[JsonPropertyName("product")]
public OpenFoodFactsProduct? Product { get; set; }
}

internal sealed class OpenFoodFactsProduct
{
[JsonPropertyName("product_name")]
public string? ProductName { get; set; }

[JsonPropertyName("brands")]
public string? Brands { get; set; }

[JsonPropertyName("quantity")]
public string? Quantity { get; set; }

[JsonPropertyName("image_url")]
public string? ImageUrl { get; set; }

[JsonPropertyName("code")]
public string? Code { get; set; }
}

+ 3
- 0
src/CartWise.Web/appsettings.json View File

@@ -2,6 +2,9 @@
"ConnectionStrings": {
"DefaultConnection": "Data Source=App_Data/cartwise.db"
},
"OpenFoodFacts": {
"BaseUrl": "https://world.openfoodfacts.org/"
},
"Logging": {
"LogLevel": {
"Default": "Information",


+ 94
- 0
tests/CartWise.Application.Tests/OpenFoodFactsProductDataProviderTests.cs View File

@@ -0,0 +1,94 @@
using System.Net;
using CartWise.Infrastructure.Integrations.OpenFoodFacts;
using Microsoft.Extensions.Logging.Abstractions;

namespace CartWise.Application.Tests;

public class OpenFoodFactsProductDataProviderTests
{
[Fact]
public async Task FindByBarcodeAsync_ReturnsMappedResultWhenFound()
{
const string json = """
{
"status": 1,
"product": {
"product_name": "Peanut Butter",
"brands": "Jif, Some Co-Brand",
"quantity": "16 oz",
"image_url": "https://example.com/image.jpg",
"code": "051500255516"
}
}
""";
var provider = CreateProvider(HttpStatusCode.OK, json);

var result = await provider.FindByBarcodeAsync("051500255516");

Assert.NotNull(result);
Assert.Equal("Peanut Butter", result!.Name);
Assert.Equal("Jif", result.BrandName);
Assert.Equal(16m, result.SizeValue);
Assert.Equal("oz", result.SizeUnit);
Assert.Equal("051500255516", result.SourceExternalId);
}

[Fact]
public async Task FindByBarcodeAsync_ReturnsNullWhenStatusIsNotFound()
{
const string json = """{ "status": 0 }""";
var provider = CreateProvider(HttpStatusCode.OK, json);

var result = await provider.FindByBarcodeAsync("000000000000");

Assert.Null(result);
}

[Fact]
public async Task FindByBarcodeAsync_ReturnsNullOnHttpFailure()
{
var provider = CreateProvider(HttpStatusCode.InternalServerError, "error");

var result = await provider.FindByBarcodeAsync("051500255516");

Assert.Null(result);
}

[Fact]
public async Task FindByBarcodeAsync_ReturnsNullOnMalformedJson()
{
var provider = CreateProvider(HttpStatusCode.OK, "{not valid json");

var result = await provider.FindByBarcodeAsync("051500255516");

Assert.Null(result);
}

private static OpenFoodFactsProductDataProvider CreateProvider(HttpStatusCode statusCode, string content)
{
var handler = new FakeHttpMessageHandler(statusCode, content);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://off.example.com/") };
return new OpenFoodFactsProductDataProvider(httpClient, NullLogger<OpenFoodFactsProductDataProvider>.Instance);
}

private sealed class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly string _content;

public FakeHttpMessageHandler(HttpStatusCode statusCode, string content)
{
_statusCode = statusCode;
_content = content;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(_content)
};
return Task.FromResult(response);
}
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.