Files
Beam/Beam.Data/LinkBuilder.cs

403 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 runtime 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 runtime 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 runtime 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 runtime 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 stronglytyped, templatedriven 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 lefttoright 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">Zerobased 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">Zerobased 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">Zerobased 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">Zerobased 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">Zerobased 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().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 lefttoright. 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
}
}