diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 3619bf9..445683f 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -177,7 +177,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. ## Phase 7 — URL-fetch ingestion -### [ ] 7.1 `IImageUrlFetcher` +### [x] 7.1 `IImageUrlFetcher` - Constructor takes `HttpClient` (via `IHttpClientFactory`) configured with `UrlFetch.TimeoutSeconds` and `MaxRedirects`. - `Task FetchAsync(Uri, CancellationToken)` — streams response, aborts if content exceeds `MaxBytes`. - Throws a typed exception (`ImageFetchException`) for timeout / non-2xx / too-large / redirect-loop. diff --git a/src/FrameProcessor/Program.cs b/src/FrameProcessor/Program.cs index e0712a3..7afac52 100644 --- a/src/FrameProcessor/Program.cs +++ b/src/FrameProcessor/Program.cs @@ -2,6 +2,8 @@ using FrameProcessor.Configuration; using FrameProcessor.ImagePipeline; using FrameProcessor.Mqtt; using FrameProcessor.Storage; +using FrameProcessor.UrlFetch; +using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); @@ -42,6 +44,21 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddHttpClient((sp, client) => +{ + var opts = sp.GetRequiredService>().Value; + client.Timeout = TimeSpan.FromSeconds(opts.TimeoutSeconds); +}) +.ConfigurePrimaryHttpMessageHandler(sp => +{ + var opts = sp.GetRequiredService>().Value; + return new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = opts.MaxRedirects, + }; +}); + var app = builder.Build(); // Eagerly resolve FramesRegistry so an invalid frames.json fails startup fast. diff --git a/src/FrameProcessor/UrlFetch/IImageUrlFetcher.cs b/src/FrameProcessor/UrlFetch/IImageUrlFetcher.cs new file mode 100644 index 0000000..85f40c3 --- /dev/null +++ b/src/FrameProcessor/UrlFetch/IImageUrlFetcher.cs @@ -0,0 +1,20 @@ +namespace FrameProcessor.UrlFetch; + +/// +/// Fetches a remote image into an in-memory stream for the URL-ingestion endpoint +/// (SPEC.md §4.2). Enforces the guardrails from SPEC.md §8 — max response size, +/// timeout, and max redirect hops — surfacing failures as +/// so callers can map them to 502 Bad Gateway. +/// +public interface IImageUrlFetcher +{ + /// + /// Fetch the image at . The returned stream is positioned + /// at zero and owned by the caller. + /// + /// + /// Timeout, non-2xx status, response exceeding the configured maximum, or + /// excessive redirect hops. + /// + Task FetchAsync(Uri uri, CancellationToken cancellationToken); +} diff --git a/src/FrameProcessor/UrlFetch/ImageFetchException.cs b/src/FrameProcessor/UrlFetch/ImageFetchException.cs new file mode 100644 index 0000000..0a58ea1 --- /dev/null +++ b/src/FrameProcessor/UrlFetch/ImageFetchException.cs @@ -0,0 +1,18 @@ +namespace FrameProcessor.UrlFetch; + +/// +/// Thrown by when a remote image fetch fails in a way +/// the caller should surface as 502 Bad Gateway per SPEC.md §4.2 — timeout, +/// non-2xx status, response exceeding UrlFetch.MaxBytes, or redirect-loop / +/// excess redirect hops. +/// +public sealed class ImageFetchException : Exception +{ + public ImageFetchException(string message) : base(message) + { + } + + public ImageFetchException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/FrameProcessor/UrlFetch/ImageUrlFetcher.cs b/src/FrameProcessor/UrlFetch/ImageUrlFetcher.cs new file mode 100644 index 0000000..55a8d92 --- /dev/null +++ b/src/FrameProcessor/UrlFetch/ImageUrlFetcher.cs @@ -0,0 +1,84 @@ +using FrameProcessor.Configuration; +using Microsoft.Extensions.Options; + +namespace FrameProcessor.UrlFetch; + +/// +/// implementation backed by an +/// from . The client is configured with +/// ; redirect-hop enforcement lives on the +/// primary message handler (see Program.cs). +/// +public sealed class ImageUrlFetcher : IImageUrlFetcher +{ + private const int CopyBufferSize = 81_920; + + private readonly HttpClient _httpClient; + private readonly IOptions _options; + + public ImageUrlFetcher(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options; + } + + public async Task FetchAsync(Uri uri, CancellationToken cancellationToken) + { + HttpResponseMessage response; + try + { + response = await _httpClient + .GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new ImageFetchException($"Timed out fetching {uri}", ex); + } + catch (HttpRequestException ex) + { + // HttpClient surfaces redirect-limit overflows here as well as transport errors. + throw new ImageFetchException($"Request to {uri} failed: {ex.Message}", ex); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + throw new ImageFetchException( + $"Request to {uri} returned HTTP {(int)response.StatusCode}"); + } + + var maxBytes = _options.Value.MaxBytes; + if (response.Content.Headers.ContentLength is long advertised && advertised > maxBytes) + { + throw new ImageFetchException( + $"Response Content-Length {advertised} exceeds maximum {maxBytes} bytes"); + } + + await using var source = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + var buffer = new MemoryStream(); + var chunk = new byte[CopyBufferSize]; + long total = 0; + int read; + while ((read = await source.ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken) + .ConfigureAwait(false)) > 0) + { + total += read; + if (total > maxBytes) + { + buffer.Dispose(); + throw new ImageFetchException( + $"Response exceeded maximum size of {maxBytes} bytes"); + } + buffer.Write(chunk, 0, read); + } + + buffer.Position = 0; + return buffer; + } + } +}