7ed05abdb8
- 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.
403 lines
18 KiB
C#
403 lines
18 KiB
C#
using System.Text;
|
||
using Beam.Abstractions;
|
||
using Beam.Models;
|
||
using static Beam.Exceptions.Exceptions;
|
||
|
||
namespace Beam.Data {
|
||
/// <summary>
|
||
/// Describes where a <see cref="Parameter"/> token should be inserted relative to the run‑time value
|
||
/// that ultimately replaces it when building a <see cref="SourceLink"/>.
|
||
/// </summary>
|
||
[Flags]
|
||
public enum Position {
|
||
/// <summary>
|
||
/// The parameter name is written <em>before</em> its run‑time value
|
||
/// (e.g. <c>id42</c> when the name is <c>id</c> and the value is <c>42</c>).
|
||
/// </summary>
|
||
Before = 0b01,
|
||
|
||
/// <summary>
|
||
/// The parameter name is written <em>after</em> its run‑time value
|
||
/// (e.g. <c>42id</c>).
|
||
/// </summary>
|
||
After = 0b10,
|
||
|
||
/// <summary>
|
||
/// The parameter name is written both before and after the value
|
||
/// (e.g. <c>id42id</c>).
|
||
/// </summary>
|
||
BeforeAndAfter = 0b11,
|
||
|
||
/// <summary>
|
||
/// The parameter is optional, and is omitted if missing.
|
||
/// </summary>
|
||
Optional = 0b100,
|
||
|
||
/// <summary>
|
||
/// The parameter is a query parameter, and should be decorated with <c>?</c> and <c>&</c>
|
||
/// </summary>
|
||
Query = 0b1000,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Represents a single placeholder that will be substituted when a link is built.
|
||
/// </summary>
|
||
/// <param name="name">Identifier that appears in the final link according to <paramref name="position"/>.</param>
|
||
/// <param name="position">Controls whether <paramref name="name"/> is written before, after, or on both sides of the value.</param>
|
||
public class Parameter(string name, Position position = Position.Before) {
|
||
/// <summary>
|
||
/// Gets or sets the identifier that frames the value when the link is rendered.
|
||
/// </summary>
|
||
public string Name { get; set; } = name;
|
||
|
||
/// <summary>
|
||
/// Gets or sets the position at which <see cref="Name"/> is emitted relative to the run‑time value.
|
||
/// </summary>
|
||
public Position Position { get; set; } = position;
|
||
|
||
/// <summary>
|
||
/// Creates a shallow copy whose mutable members are detached from the original instance.
|
||
/// </summary>
|
||
/// <returns>A new <see cref="Parameter"/> with identical <see cref="Name"/> and <see cref="Position"/>.</returns>
|
||
public Parameter Clone()
|
||
=> new(Name, Position);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Describes one path segment of a URL and the collection of <see cref="Parameter"/> tokens that belong to it.
|
||
/// </summary>
|
||
/// <param name="name">Literal part of the segment that precedes the parameters (may be empty).</param>
|
||
/// <param name="separator">Optional string placed <em>between</em> adjacent parameters when more than one is present.</param>
|
||
/// <param name="suffix">Optional string appended <em>after</em> the last parameter in the segment.</param>
|
||
public class LinkSegment(string name, string separator = "", string suffix = "") {
|
||
/// <summary>
|
||
/// Gets or sets the literal path component for this segment.
|
||
/// </summary>
|
||
public string Name { get; set; } = name;
|
||
|
||
/// <summary>
|
||
/// Gets or sets the collection of parameters that appear within the segment.
|
||
/// </summary>
|
||
public List<Parameter> Parameters { get; set; } = [];
|
||
|
||
/// <summary>
|
||
/// Gets or sets the string inserted between parameters when the segment is rendered.
|
||
/// </summary>
|
||
public string Separator { get; set; } = separator;
|
||
|
||
/// <summary>
|
||
/// Gets or sets the string appended after the last parameter when the segment is rendered.
|
||
/// </summary>
|
||
public string Suffix { get; set; } = suffix;
|
||
|
||
/// <summary>
|
||
/// Produces a deep copy whose <see cref="Parameters"/> list contains cloned <see cref="Parameter"/> objects.
|
||
/// </summary>
|
||
public LinkSegment Clone()
|
||
=> new LinkSegment(Name, Separator, Suffix) {
|
||
Parameters = [.. Parameters.Select(static x => x.Clone())]
|
||
};
|
||
|
||
/// <summary>
|
||
/// Replaces <see cref="Parameters"/> with a new set whose items all use <see cref="Position.Before"/>.
|
||
/// </summary>
|
||
/// <param name="parameters">Parameter identifiers to add.</param>
|
||
/// <returns>This instance for fluent calls.</returns>
|
||
public LinkSegment WithParameters(params string[] parameters) {
|
||
Parameters = parameters.Select(static x => new Parameter(x)).ToList();
|
||
return this;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replaces <see cref="Parameters"/> with a new set using explicit name/position tuples.
|
||
/// </summary>
|
||
/// <param name="parameters">Tuples of parameter identifier and desired position.</param>
|
||
/// <returns>This instance for fluent calls.</returns>
|
||
public LinkSegment WithParameters(params (string, Position)[] parameters) {
|
||
Parameters = parameters.Select(static x => new Parameter(x.Item1, x.Item2)).ToList();
|
||
return this;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fluent helper for composing strongly‑typed, template‑driven source links.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The builder captures a static template (protocol, host, path segments and their parameters) and is later supplied with
|
||
/// a flat array of values that populate all parameters in left‑to‑right order.
|
||
/// </remarks>
|
||
/// <param name="host">DNS host name (e.g. <c>api.example.com</c>).</param>
|
||
/// <param name="protocol">Transport protocol; defaults to <c>https</c>.</param>
|
||
public class LinkBuilder(string host, string protocol = "https") : ILinkBuilder {
|
||
/// <summary>
|
||
/// Gets or sets the scheme part of the URL (e.g. <c>https</c>, <c>http</c>).
|
||
/// </summary>
|
||
public string Protocol { get; set; } = protocol;
|
||
|
||
/// <summary>
|
||
/// Gets or sets the host portion of the URL.
|
||
/// </summary>
|
||
public string Host { get; set; } = host;
|
||
|
||
/// <summary>
|
||
/// Gets or sets the ordered collection of path segments.
|
||
/// </summary>
|
||
public List<LinkSegment> Segments { get; set; } = [];
|
||
|
||
/// <summary>
|
||
/// Produces a deep copy whose <see cref="Segments"/> and contained collections are detached from the original.
|
||
/// </summary>
|
||
public LinkBuilder Clone()
|
||
=> new LinkBuilder(Host, Protocol) {
|
||
Segments = [.. Segments.Select(static x => x.Clone())]
|
||
};
|
||
|
||
#region Helpers – suffix & separator
|
||
/// <summary>
|
||
/// Returns the suffix of the <paramref name="segmentIndex"/>‑th segment.
|
||
/// </summary>
|
||
/// <param name="segmentIndex">Zero‑based segment index.</param>
|
||
/// <exception cref="ArgumentOutOfRangeException">If <paramref name="segmentIndex"/> is outside the valid range.</exception>
|
||
public string GetSuffix(int segmentIndex) {
|
||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(segmentIndex, Segments.Count);
|
||
ArgumentOutOfRangeException.ThrowIfNegative(segmentIndex);
|
||
return Segments[segmentIndex].Suffix;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the suffix of the last segment.
|
||
/// </summary>
|
||
public string GetSuffix()
|
||
=> GetSuffix(Segments.Count - 1);
|
||
|
||
/// <summary>
|
||
/// Returns the separator used by the <paramref name="segmentIndex"/>‑th segment.
|
||
/// </summary>
|
||
/// <param name="segmentIndex">Zero‑based segment index.</param>
|
||
/// <exception cref="ArgumentOutOfRangeException">If <paramref name="segmentIndex"/> is outside the valid range.</exception>
|
||
public string GetSeparator(int segmentIndex) {
|
||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(segmentIndex, Segments.Count);
|
||
ArgumentOutOfRangeException.ThrowIfNegative(segmentIndex);
|
||
return Segments[segmentIndex].Separator;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the separator of the last segment.
|
||
/// </summary>
|
||
public string GetSeparator()
|
||
=> GetSeparator(Segments.Count - 1);
|
||
|
||
/// <summary>
|
||
/// Assigns a new suffix to the <paramref name="segmentIndex"/>‑th segment.
|
||
/// </summary>
|
||
/// <param name="segmentIndex">Zero‑based segment index.</param>
|
||
/// <param name="suffix">String appended after the segment's parameters.</param>
|
||
/// <exception cref="ArgumentOutOfRangeException">If <paramref name="segmentIndex"/> is outside the valid range.</exception>
|
||
public void SetSuffix(int segmentIndex, string suffix) {
|
||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(segmentIndex, Segments.Count);
|
||
ArgumentOutOfRangeException.ThrowIfNegative(segmentIndex);
|
||
Segments[segmentIndex].Suffix = suffix;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Assigns a new suffix to the last segment.
|
||
/// </summary>
|
||
public void SetSuffix(string suffix)
|
||
=> SetSuffix(Segments.Count - 1, suffix);
|
||
|
||
/// <summary>
|
||
/// Assigns a new separator to the <paramref name="segmentIndex"/>‑th segment.
|
||
/// </summary>
|
||
/// <param name="segmentIndex">Zero‑based segment index.</param>
|
||
/// <param name="separator">String inserted between parameters belonging to the same segment.</param>
|
||
/// <exception cref="ArgumentOutOfRangeException">If <paramref name="segmentIndex"/> is outside the valid range.</exception>
|
||
public void SetSeparator(int segmentIndex, string separator) {
|
||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(segmentIndex, Segments.Count);
|
||
ArgumentOutOfRangeException.ThrowIfNegative(segmentIndex);
|
||
Segments[segmentIndex].Separator = separator;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Assigns a new separator to the last segment.
|
||
/// </summary>
|
||
public void SetSeparator(string separator)
|
||
=> SetSeparator(Segments.Count - 1, separator);
|
||
#endregion
|
||
|
||
#region Segment manipulation
|
||
/// <summary>
|
||
/// Appends a new segment.
|
||
/// </summary>
|
||
/// <param name="name">Literal portion of the segment.</param>
|
||
/// <param name="separator">Optional separator for subsequent parameters; <see langword="null"/> keeps the current default.</param>
|
||
/// <exception cref="ArgumentException">If <paramref name="name"/> is <see langword="null"/>, empty, or whitespace.</exception>
|
||
public void AddSegment(string name, string? separator = null) {
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||
Segments.Add(new LinkSegment(name, separator));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replaces the whole <see cref="Segments"/> collection with the supplied <paramref name="segments"/>, each represented as a <see cref="LinkSegment"/>.
|
||
/// </summary>
|
||
/// <returns>This instance for fluent calls.</returns>
|
||
public LinkBuilder WithSegments(params IEnumerable<string> segments) {
|
||
Segments = segments.Select(static x => new LinkSegment(x)).ToList();
|
||
return this;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replaces the <see cref="Segments"/> collection with <paramref name="count"/> empty segments.
|
||
/// </summary>
|
||
/// <param name="count">Number of segments to create.</param>
|
||
public LinkBuilder WithSegments(int count)
|
||
=> WithSegments(Enumerable.Repeat("", count));
|
||
#endregion
|
||
|
||
#region Parameter manipulation (fluent)
|
||
/// <summary>
|
||
/// Replaces parameters of the <paramref name="i"/>‑th segment using the supplied identifiers.
|
||
/// </summary>
|
||
public LinkBuilder WithParameters(int i, params string[] parameters) {
|
||
Segments[i].WithParameters(parameters);
|
||
return this;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replaces parameters of the <paramref name="i"/>‑th segment using explicit name/position tuples.
|
||
/// </summary>
|
||
public LinkBuilder WithParameters(int i, params (string, Position)[] parameters) {
|
||
Segments[i].WithParameters(parameters);
|
||
return this;
|
||
}
|
||
#endregion
|
||
|
||
#region Parameter manipulation (imperative)
|
||
/// <summary>
|
||
/// Adds new parameters to the specified segment without clearing existing ones.
|
||
/// </summary>
|
||
/// <param name="segmentIndex">Zero‑based segment index.</param>
|
||
/// <param name="parameters">Identifiers of parameters to add.</param>
|
||
/// <exception cref="ArgumentOutOfRangeException">If <paramref name="segmentIndex"/> is invalid.</exception>
|
||
/// <exception cref="ArgumentException">If any parameter identifier is <see langword="null"/>, empty or whitespace.</exception>
|
||
public void AddParameters(int segmentIndex, params string[] parameters) {
|
||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(segmentIndex, Segments.Count);
|
||
ArgumentOutOfRangeException.ThrowIfNegative(segmentIndex);
|
||
var seg = Segments[segmentIndex];
|
||
foreach (var parameter in parameters) {
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(parameter);
|
||
seg.Parameters.Add(new Parameter(parameter));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Adds parameters to the last segment.
|
||
/// </summary>
|
||
public void AddParameters(params string[] parameters)
|
||
=> AddParameters(Segments.Count - 1, parameters);
|
||
|
||
/// <summary>
|
||
/// Replaces the entire parameter list of the specified segment.
|
||
/// </summary>
|
||
public void SetParameters(int segmentIndex, params string[] parameters) {
|
||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(segmentIndex, Segments.Count);
|
||
ArgumentOutOfRangeException.ThrowIfNegative(segmentIndex);
|
||
var seg = Segments[segmentIndex];
|
||
seg.Parameters.Clear();
|
||
AddParameters(segmentIndex, parameters);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replaces the parameter list of the last segment.
|
||
/// </summary>
|
||
public void SetParameters(params string[] parameters)
|
||
=> SetParameters(Segments.Count - 1, parameters);
|
||
#endregion
|
||
|
||
/// <summary>
|
||
/// Returns the total number of <see cref="Parameter"/> tokens across all segments.
|
||
/// </summary>
|
||
public int GetParameterCount() {
|
||
int count = 0;
|
||
foreach (var segment in Segments) {
|
||
count += segment.Parameters.Count;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
public string Build(IReadOnlyState state)
|
||
=> Build(state.GetState().ToArray<object>());
|
||
|
||
#region Build
|
||
/// <summary>
|
||
/// Produces a concrete <see cref="SourceLink"/> using values from an external <see cref="State"/> object.
|
||
/// </summary>
|
||
/// <param name="parameterValues">Object providing positional values.</param>
|
||
public string Build(State parameterValues)
|
||
=> Build(parameterValues.GetState());
|
||
|
||
/// <summary>
|
||
/// Produces a concrete <see cref="SourceLink"/> by substituting <paramref name="parameterValues"/> into the template.
|
||
/// </summary>
|
||
/// <param name="parameterValues">Flat array of values that will be written in the order that parameters appear when segments are enumerated left‑to‑right. Any optional parameters must still appear as null if missing.</param>
|
||
/// <returns>The completed <see cref="SourceLink"/>.</returns>
|
||
/// <exception cref="ArgumentOutOfRangeException">If the supplied value count does not match <see cref="GetParameterCount"/>().</exception>
|
||
public string Build(params object?[] parameterValues) {
|
||
ArgumentOutOfRangeException.ThrowIfNotEqual(parameterValues.Length, GetParameterCount());
|
||
|
||
StringBuilder link = new();
|
||
link.Append(Protocol);
|
||
link.Append("://");
|
||
link.Append(Host);
|
||
|
||
int pvC = 0;
|
||
foreach (var segment in Segments) {
|
||
link.Append('/');
|
||
link.Append(segment.Name);
|
||
bool startedQueryString = false;
|
||
for (int i = 0; i < segment.Parameters.Count; i++) {
|
||
if (parameterValues[pvC] is null)
|
||
if (segment.Parameters[i].Position.HasFlag(Position.Optional))
|
||
continue;
|
||
else
|
||
throw new ArgumentException(string.Format(link_builder_argument_missing, pvC, segment.Parameters[i].Name));
|
||
|
||
if (segment.Parameters[i].Position.HasFlag(Position.Query) && Segments[^1] != segment)
|
||
throw new ArgumentException(string.Format(link_builder_query_only_at_last, i));
|
||
|
||
if (segment.Parameters[i].Position.HasFlag(Position.Query))
|
||
if (!startedQueryString) {
|
||
link.Append('?');
|
||
startedQueryString = true;
|
||
} else
|
||
link.Append('&');
|
||
|
||
if (segment.Parameters[i].Position.HasFlag(Position.Before))
|
||
link.Append(segment.Parameters[i].Name);
|
||
|
||
if (segment.Parameters[i].Position.HasFlag(Position.Query))
|
||
link.Append('=');
|
||
|
||
if (parameterValues[pvC] is not null)
|
||
link.Append(parameterValues[pvC++]);
|
||
else if (!segment.Parameters[i].Position.HasFlag(Position.Optional))
|
||
throw new ArgumentException(string.Format(link_builder_argument_missing, pvC, segment.Parameters[i].Name));
|
||
|
||
if (segment.Parameters[i].Position.HasFlag(Position.Query | Position.After))
|
||
throw new ArgumentException(string.Format(link_builder_incompatible_flag, nameof(Position.Query), nameof(Position.After)));
|
||
|
||
if (segment.Parameters[i].Position.HasFlag(Position.After))
|
||
link.Append(segment.Parameters[i].Name);
|
||
|
||
if (i + 1 < segment.Parameters.Count && segment.Separator is not null)
|
||
link.Append(segment.Separator);
|
||
}
|
||
|
||
link.Append(segment.Suffix);
|
||
}
|
||
|
||
return link.ToString();
|
||
}
|
||
#endregion
|
||
}
|
||
}
|