refactor: modularize Beam into new projects and interfaces
- Introduced modularity by splitting Beam into new projects: Beam.Abstractions, Beam.Models, and Beam.Downloaders. - Refactored existing classes into appropriate namespaces and projects. - Replaced specific implementations with abstractions (e.g., SourceLinkBuilder to LinkBuilder, State to IState, etc.). - Updated interfaces: added ITemplate, IArticleData, IDownloadReport, and others for improved extensibility. - Removed deprecated classes like SourceLinkBuilder and StateChangerFactory. - Enhanced link handling in downloaders by refactoring to use `string` over `SourceLink`. - Consolidated shared logic under Beam.Abstractions.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Beam {
|
||||
public class ApiCall(HttpClient client, string uri, HttpMethod method, KeyValuePair<string, string[]>[] headers, object? requestData, object? body, params HashSet<HttpStatusCode> successCodes) {
|
||||
public HttpClient Client { get; } = client;
|
||||
public object? RequestData { get; } = requestData;
|
||||
public object? Body { get; }
|
||||
public string Uri { get; } = uri;
|
||||
public HttpMethod Method { get; } = method;
|
||||
public KeyValuePair<string, string[]>[] Headers { get; private set; } = headers;
|
||||
public HashSet<HttpStatusCode> SuccessCodes { get; } = successCodes;
|
||||
|
||||
private string? ContentType = "application/json";
|
||||
|
||||
public async Task<ApiResponse> GetResponse(ILogger<ApiResponse>? logger, (int @try, int max)? tries = null, CancellationToken ct = default) {
|
||||
SanitizeHeaders();
|
||||
|
||||
var request = new HttpRequestMessage(Method, Uri);
|
||||
request.Content = body is null ? request.Content :
|
||||
body is string stringBody ? new StringContent(stringBody) : JsonContent.Create(body);
|
||||
|
||||
if (request.Content is not null)
|
||||
request.Content.Headers.ContentType = ContentType is null ? null : new System.Net.Http.Headers.MediaTypeHeaderValue(ContentType);
|
||||
|
||||
foreach (var header in Headers)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
logger?.LogInformation("Fetching '{}' with method '{}', content-type '{}', and headers '{}'", Uri, Method, ContentType, JsonSerializer.Serialize(request.Headers.ToDictionary()));
|
||||
var response = await Client.SendAsync(request, ct);
|
||||
|
||||
if (tries is not null && tries?.@try < tries?.max && !SuccessCodes.Contains(response.StatusCode)) {
|
||||
await Task.Delay((int)Math.Min(Math.Pow(2, tries.Value.@try), 60) * 1000, ct);
|
||||
return await GetResponse(logger, (tries.Value.@try + 1, tries.Value.max), ct);
|
||||
}
|
||||
|
||||
return await ApiResponse.CreateAsync(response, logger, RequestData, ct);
|
||||
}
|
||||
|
||||
private void SanitizeHeaders() {
|
||||
Dictionary<string, string[]> headers = [];
|
||||
foreach(var kvp in Headers) {
|
||||
if (kvp.Value.Length == 0)
|
||||
continue;
|
||||
|
||||
if (kvp.Key == "Content-Type") {
|
||||
ContentType = kvp.Value[0];
|
||||
} else {
|
||||
headers[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
Headers = headers.ToArray();
|
||||
}
|
||||
|
||||
public static async Task<ApiResponse> Get(HttpClient client, string url, ILoggerFactory factory)
|
||||
=> await new ApiCall(client, url, HttpMethod.Get, [], null, null).GetResponse(factory.CreateLogger<ApiResponse>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Beam.Abstractions;
|
||||
|
||||
namespace Beam {
|
||||
public class ApiCallBuilder(HttpClient client) {
|
||||
HttpClient Client = client;
|
||||
string Uri;
|
||||
object? Data;
|
||||
object? Body;
|
||||
HttpMethod Method = HttpMethod.Get;
|
||||
List<KeyValuePair<string, List<string>>> Headers = [];
|
||||
HashSet<HttpStatusCode> SuccessCodes = [HttpStatusCode.OK];
|
||||
|
||||
public ApiCallBuilder WithUri(string uri) {
|
||||
Uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder WithUri(Uri uri) {
|
||||
return WithUri(uri.AbsoluteUri);
|
||||
}
|
||||
|
||||
public ApiCallBuilder WithRequestData(object? data) {
|
||||
Data = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder WithBody(object? data) {
|
||||
Body = data;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder WithMethod(HttpMethod method) {
|
||||
Method = method;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder WithHeaders(IEnumerable<(string Key, IEnumerable<string> Value)> headers) {
|
||||
Headers = headers.Select((x) => new KeyValuePair<string, List<string>>(x.Key, x.Value.ToList())).ToList();
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder WithSuccessCodes(params HashSet<HttpStatusCode> codes) {
|
||||
SuccessCodes = codes;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder WithSuccessCodes(params IEnumerable<int> codes) {
|
||||
SuccessCodes = codes.Cast<HttpStatusCode>().ToHashSet();
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder AddHeader(string key, string value) {
|
||||
if (Headers.Any((x) => x.Key == key))
|
||||
Headers.FirstOrDefault((x) => x.Key == key).Value.Add(value);
|
||||
else
|
||||
Headers.Add(new KeyValuePair<string, List<string>>(key, [value]));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallBuilder AddBearer(string value)
|
||||
=> AddHeader("Authorization", "Bearer " + value);
|
||||
|
||||
public ApiCall Build() {
|
||||
if (Uri is null)
|
||||
throw new InvalidOperationException();
|
||||
if (Method is null)
|
||||
throw new InvalidOperationException();
|
||||
if (Headers is null)
|
||||
Headers = [];
|
||||
|
||||
return new ApiCall(Client, Uri, Method, Headers.Select((x) => new KeyValuePair<string, string[]>(x.Key, x.Value.ToArray())).ToArray(), Data, Body, SuccessCodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// ApiCalls.cs
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Beam {
|
||||
/// <summary>
|
||||
/// Executes a batch of <see cref="ApiCall"/>s using either sequential or parallel strategy.
|
||||
/// </summary>
|
||||
public sealed class ApiCalls {
|
||||
private readonly IReadOnlyList<ApiCall> _calls;
|
||||
private readonly int _maxDegree;
|
||||
|
||||
internal ApiCalls(IReadOnlyList<ApiCall> calls, int? maxDegree) {
|
||||
_calls = calls ?? throw new ArgumentNullException(nameof(calls));
|
||||
_maxDegree = Math.Max(1, maxDegree ?? 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs every call and returns the ordered list of <see cref="ApiResponse"/>s.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ApiResponse>> ExecuteAsync(
|
||||
ILogger<ApiResponse>? logger = null,
|
||||
(int @try, int max)? tries = null,
|
||||
CancellationToken ct = default) {
|
||||
if (_maxDegree == 1) {
|
||||
// sequential
|
||||
var sequential = new List<ApiResponse>(_calls.Count);
|
||||
foreach (var call in _calls)
|
||||
sequential.Add(await call.GetResponse(logger, tries, ct));
|
||||
return sequential;
|
||||
}
|
||||
|
||||
// parallel
|
||||
var bag = new ConcurrentBag<(int idx, ApiResponse res)>();
|
||||
await Parallel.ForEachAsync(
|
||||
_calls.Select((c, i) => (call: c, idx: i)),
|
||||
new ParallelOptions { MaxDegreeOfParallelism = _maxDegree, CancellationToken = ct },
|
||||
async (item, token) => {
|
||||
var response = await item.call.GetResponse(logger, tries, token);
|
||||
bag.Add((item.idx, response));
|
||||
});
|
||||
|
||||
// keep original ordering
|
||||
return bag.OrderBy(x => x.idx).Select(x => x.res).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// ApiCallsBuilder.cs
|
||||
using System.Net;
|
||||
|
||||
namespace Beam {
|
||||
/// <summary>
|
||||
/// Fluent builder for <see cref="ApiCalls"/>.
|
||||
/// </summary>
|
||||
public sealed class ApiCallsBuilder {
|
||||
private readonly List<ApiCall> _calls = [];
|
||||
private int? _parallelism = 1; // default = sequential
|
||||
|
||||
public ApiCallsBuilder Add(ApiCall call) {
|
||||
_calls.Add(call ?? throw new ArgumentNullException(nameof(call)));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCallsBuilder AddRange(IEnumerable<ApiCall> calls) {
|
||||
_calls.AddRange(calls ?? throw new ArgumentNullException(nameof(calls)));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Adds the same <paramref name="prototype"/> call <paramref name="times"/> times.</summary>
|
||||
public ApiCallsBuilder Repeat(ApiCall prototype, int times) {
|
||||
if (times < 1) throw new ArgumentOutOfRangeException(nameof(times));
|
||||
for (var i = 0; i < times; i++)
|
||||
Add(prototype);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Run with the specified degree of parallelism (≥ 1).</summary>
|
||||
public ApiCallsBuilder UseParallel(int maxDegree) => SetDegree(Math.Max(1, maxDegree));
|
||||
|
||||
/// <summary>Run sequentially (same as <c>UseParallel(1)</c>).</summary>
|
||||
public ApiCallsBuilder UseSequential() => SetDegree(1);
|
||||
|
||||
private ApiCallsBuilder SetDegree(int degree) {
|
||||
_parallelism = degree;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApiCalls Build() {
|
||||
if (_calls.Count == 0)
|
||||
throw new InvalidOperationException("At least one ApiCall is required.");
|
||||
return new ApiCalls(_calls, _parallelism);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Beam {
|
||||
/// <summary>
|
||||
/// Wrapper that lets the response body be read any number of times (even concurrently).
|
||||
/// </summary>
|
||||
public sealed class ApiResponse {
|
||||
private readonly byte[] _buffer;
|
||||
|
||||
private ApiResponse(HttpResponseMessage response, byte[] buffer, ILogger<ApiResponse>? logger, object? requestData = null) {
|
||||
Response = response;
|
||||
_buffer = buffer;
|
||||
Logger = logger;
|
||||
RequestData = requestData;
|
||||
}
|
||||
|
||||
public HttpResponseMessage Response { get; }
|
||||
public object? RequestData { get; }
|
||||
public ILogger<ApiResponse>? Logger { get; }
|
||||
|
||||
/* ---------- creation ---------- */
|
||||
|
||||
public static async Task<ApiResponse> CreateAsync(
|
||||
HttpResponseMessage response,
|
||||
ILogger<ApiResponse>? logger = null,
|
||||
object? requestData = null,
|
||||
CancellationToken ct = default) {
|
||||
if (response is null) throw new ArgumentNullException(nameof(response));
|
||||
|
||||
var buffer = response.Content is null
|
||||
? []
|
||||
: await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return new ApiResponse(response, buffer, logger, requestData);
|
||||
}
|
||||
|
||||
/* ---------- status helpers ---------- */
|
||||
|
||||
public bool Is404 => Response.StatusCode == HttpStatusCode.NotFound;
|
||||
public bool Is403 => Response.StatusCode == HttpStatusCode.Forbidden;
|
||||
public bool Is500 => Response.StatusCode == HttpStatusCode.InternalServerError;
|
||||
public bool Is400 => Response.StatusCode == HttpStatusCode.BadRequest;
|
||||
public bool Is200 => Response.IsSuccessStatusCode;
|
||||
|
||||
public ApiResponse OnError(Action<HttpStatusCode> errorHandler) {
|
||||
if (!Is200) errorHandler(Response.StatusCode);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* ---------- content helpers ---------- */
|
||||
|
||||
public Task<T?> AsSerializedObject<T>(CancellationToken ct = default) {
|
||||
if (!Is200) throw new InvalidOperationException();
|
||||
if (Response.Content?.Headers.ContentType?.MediaType != "application/json")
|
||||
Logger?.LogWarning("Content-Type is not JSON, yet JSON deserialization was requested.");
|
||||
|
||||
return Task.FromResult(JsonSerializer.Deserialize<T>(_buffer));
|
||||
}
|
||||
|
||||
public Task<T?> AsDynamicObject<T>(T _, CancellationToken ct = default)
|
||||
=> AsSerializedObject<T>(ct);
|
||||
|
||||
public Task<string> AsString(CancellationToken ct = default) {
|
||||
if (!Is200) Logger?.LogWarning("Non-success response; attempting to read content.");
|
||||
return Task.FromResult(Encoding.UTF8.GetString(_buffer));
|
||||
}
|
||||
|
||||
public Task<byte[]> AsBinary(CancellationToken ct = default) {
|
||||
if (!Is200) Logger?.LogWarning("Non-success response; attempting to read content.");
|
||||
return Task.FromResult(_buffer);
|
||||
}
|
||||
|
||||
public Task<Stream> AsStream(CancellationToken ct = default) {
|
||||
if (!Is200) Logger?.LogWarning("Non-success response; attempting to read content.");
|
||||
return Task.FromResult<Stream>(new MemoryStream(_buffer, writable: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user