diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63abea0..eb0e0fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-_No changes yet_
+### Added
+
+- `Validation` utility class for specifications as MSISDN
+
+### Changed
+
+- Channel implementations (SMS, WhatsApp, ...) are extensions to the `ILinkMobilityClient` interface.
+
## [v0.1.1] - 2026-03-13
diff --git a/src/LinkMobility/ILinkMobilityClient.cs b/src/LinkMobility/ILinkMobilityClient.cs
index 1d80245..22bc798 100644
--- a/src/LinkMobility/ILinkMobilityClient.cs
+++ b/src/LinkMobility/ILinkMobilityClient.cs
@@ -9,17 +9,14 @@ namespace AMWD.Net.Api.LinkMobility
public interface ILinkMobilityClient
{
///
- /// Sends a text message to a list of recipients.
+ /// Performs a POST request to the LINK mobility API.
///
+ /// The type of the response.
+ /// The type of the request.
+ /// The path of the API endpoint.
/// The request data.
+ /// Optional query parameters.
/// A cancellation token to propagate notification that operations should be canceled.
- Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default);
-
- ///
- /// Sends a binary message to a list of recipients.
- ///
- /// The request data.
- /// A cancellation token to propagate notification that operations should be canceled.
- Task SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default);
+ Task PostAsync(string requestPath, TRequest? request, IQueryParameter? queryParams = null, CancellationToken cancellationToken = default);
}
}
diff --git a/src/LinkMobility/LinkMobilityClient.Sms.cs b/src/LinkMobility/LinkMobilityClient.Sms.cs
deleted file mode 100644
index dc9c51b..0000000
--- a/src/LinkMobility/LinkMobilityClient.Sms.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace AMWD.Net.Api.LinkMobility
-{
- ///
- /// Implementation of text messaging (SMS). API
- ///
- public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable
- {
- ///
- public Task SendTextMessage(SendTextMessageRequest request, CancellationToken cancellationToken = default)
- {
- if (request == null)
- throw new ArgumentNullException(nameof(request));
-
- if (string.IsNullOrWhiteSpace(request.MessageContent))
- throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
-
- if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
- throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
-
- foreach (string recipient in request.RecipientAddressList)
- {
- if (!IsValidMSISDN(recipient))
- throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
- }
-
- return PostAsync("/smsmessaging/text", request, cancellationToken: cancellationToken);
- }
-
- ///
- public Task SendBinaryMessage(SendBinaryMessageRequest request, CancellationToken cancellationToken = default)
- {
- if (request == null)
- throw new ArgumentNullException(nameof(request));
-
- if (request.MessageContent?.Count > 0)
- {
- // Validate that the string is a valid Base64 string
- // Might throw a ArgumentNullException or FormatException
- foreach (string str in request.MessageContent)
- Convert.FromBase64String(str);
- }
-
- if (request.RecipientAddressList == null || request.RecipientAddressList.Count == 0)
- throw new ArgumentException("At least one recipient must be provided.", nameof(request.RecipientAddressList));
-
- foreach (string recipient in request.RecipientAddressList)
- {
- if (!IsValidMSISDN(recipient))
- throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(request.RecipientAddressList));
- }
-
- return PostAsync("/smsmessaging/binary", request, cancellationToken: cancellationToken);
- }
-
- // https://en.wikipedia.org/wiki/MSISDN
- private static bool IsValidMSISDN(string msisdn)
- {
- if (string.IsNullOrWhiteSpace(msisdn))
- return false;
-
- return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled);
- }
- }
-}
diff --git a/src/LinkMobility/LinkMobilityClient.cs b/src/LinkMobility/LinkMobilityClient.cs
index 25e7662..8a5f673 100644
--- a/src/LinkMobility/LinkMobilityClient.cs
+++ b/src/LinkMobility/LinkMobilityClient.cs
@@ -13,7 +13,7 @@ namespace AMWD.Net.Api.LinkMobility
///
/// Provides a client for interacting with the Link Mobility API.
///
- public partial class LinkMobilityClient : ILinkMobilityClient, IDisposable
+ public class LinkMobilityClient : ILinkMobilityClient, IDisposable
{
private readonly ClientOptions _clientOptions;
private readonly HttpClient _httpClient;
@@ -78,6 +78,52 @@ namespace AMWD.Net.Api.LinkMobility
GC.SuppressFinalize(this);
}
+ ///
+ public async Task PostAsync(string requestPath, TRequest? request, IQueryParameter? queryParams = null, CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ ValidateRequestPath(requestPath);
+
+ string requestUrl = BuildRequestUrl(requestPath, queryParams);
+ var httpContent = ConvertRequest(request);
+
+ var httpRequest = new HttpRequestMessage
+ {
+ Method = HttpMethod.Post,
+ RequestUri = new Uri(requestUrl, UriKind.Relative),
+ Content = httpContent,
+ };
+
+ var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
+ var response = await GetResponse(httpResponse, cancellationToken).ConfigureAwait(false);
+ return response;
+ }
+
+ private string BuildRequestUrl(string requestPath, IQueryParameter? queryParams = null)
+ {
+ string path = requestPath.Trim().TrimStart('/');
+ var param = new Dictionary();
+
+ if (_clientOptions.DefaultQueryParams.Count > 0)
+ {
+ foreach (var kvp in _clientOptions.DefaultQueryParams)
+ param[kvp.Key] = kvp.Value;
+ }
+
+ var customQueryParams = queryParams?.GetQueryParameters();
+ if (customQueryParams?.Count > 0)
+ {
+ foreach (var kvp in customQueryParams)
+ param[kvp.Key] = kvp.Value;
+ }
+
+ if (param.Count == 0)
+ return path;
+
+ string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
+ return $"{path}?{queryString}";
+ }
+
private void ValidateClientOptions()
{
if (string.IsNullOrWhiteSpace(_clientOptions.BaseUrl))
@@ -148,51 +194,6 @@ namespace AMWD.Net.Api.LinkMobility
}
}
- private async Task PostAsync(string requestPath, TRequest? request, IQueryParameter? queryParams = null, CancellationToken cancellationToken = default)
- {
- ThrowIfDisposed();
- ValidateRequestPath(requestPath);
-
- string requestUrl = BuildRequestUrl(requestPath, queryParams);
- var httpContent = ConvertRequest(request);
-
- var httpRequest = new HttpRequestMessage
- {
- Method = HttpMethod.Post,
- RequestUri = new Uri(requestUrl, UriKind.Relative),
- Content = httpContent,
- };
-
- var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
- var response = await GetResponse(httpResponse, cancellationToken).ConfigureAwait(false);
- return response;
- }
-
- private string BuildRequestUrl(string requestPath, IQueryParameter? queryParams = null)
- {
- string path = requestPath.Trim().TrimStart('/');
- var param = new Dictionary();
-
- if (_clientOptions.DefaultQueryParams.Count > 0)
- {
- foreach (var kvp in _clientOptions.DefaultQueryParams)
- param[kvp.Key] = kvp.Value;
- }
-
- var customQueryParams = queryParams?.GetQueryParameters();
- if (customQueryParams?.Count > 0)
- {
- foreach (var kvp in customQueryParams)
- param[kvp.Key] = kvp.Value;
- }
-
- if (param.Count == 0)
- return path;
-
- string queryString = string.Join("&", param.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
- return $"{path}?{queryString}";
- }
-
private static HttpContent? ConvertRequest(T request)
{
if (request == null)
diff --git a/src/LinkMobility/Models/IncomingMessageNotification.cs b/src/LinkMobility/Models/IncomingMessageNotification.cs
index df13c52..552b527 100644
--- a/src/LinkMobility/Models/IncomingMessageNotification.cs
+++ b/src/LinkMobility/Models/IncomingMessageNotification.cs
@@ -56,7 +56,7 @@ namespace AMWD.Net.Api.LinkMobility
///
/// , :
///
- /// – defines the number format of the mobile originated .
+ /// - defines the number format of the mobile originated .
/// International numbers always includes the country prefix.
///
[JsonProperty("senderAddressType")]
diff --git a/src/LinkMobility/Requests/SendBinaryMessageRequest.cs b/src/LinkMobility/Requests/SendBinaryMessageRequest.cs
index b3276b3..f1939dd 100644
--- a/src/LinkMobility/Requests/SendBinaryMessageRequest.cs
+++ b/src/LinkMobility/Requests/SendBinaryMessageRequest.cs
@@ -8,9 +8,11 @@
///
/// Initializes a new instance of the class.
///
- /// The recipient list.
- public SendBinaryMessageRequest(IReadOnlyCollection recipientAddressList)
+ /// A binary message as base64 encoded lines.
+ /// A list of recipient numbers.
+ public SendBinaryMessageRequest(IReadOnlyCollection messageContent, IReadOnlyCollection recipientAddressList)
{
+ MessageContent = messageContent;
RecipientAddressList = recipientAddressList;
}
@@ -41,7 +43,7 @@
/// The binary data is transmitted without being changed (using 8 bit alphabet).
///
[JsonProperty("messageContent")]
- public IReadOnlyCollection? MessageContent { get; set; }
+ public IReadOnlyCollection MessageContent { get; set; }
///
/// Optional.
diff --git a/src/LinkMobility/Requests/SendTextMessageRequest.cs b/src/LinkMobility/Requests/SendTextMessageRequest.cs
index c232052..c8d07e9 100644
--- a/src/LinkMobility/Requests/SendTextMessageRequest.cs
+++ b/src/LinkMobility/Requests/SendTextMessageRequest.cs
@@ -8,8 +8,8 @@
///
/// Initializes a new instance of the class.
///
- /// The message.
- /// The recipient list.
+ /// A text message.
+ /// A list of recipient numbers.
public SendTextMessageRequest(string messageContent, IReadOnlyCollection recipientAddressList)
{
MessageContent = messageContent;
diff --git a/src/LinkMobility/Responses/SendMessageResponse.cs b/src/LinkMobility/Responses/SendMessageResponse.cs
index b5d5edd..f708ead 100644
--- a/src/LinkMobility/Responses/SendMessageResponse.cs
+++ b/src/LinkMobility/Responses/SendMessageResponse.cs
@@ -11,12 +11,6 @@
[JsonProperty("clientMessageId")]
public string? ClientMessageId { get; set; }
- ///
- /// The actual number of generated SMS.
- ///
- [JsonProperty("smsCount")]
- public int? SmsCount { get; set; }
-
///
/// Status code.
///
diff --git a/src/LinkMobility/Responses/SendTextMessageResponse.cs b/src/LinkMobility/Responses/SendTextMessageResponse.cs
new file mode 100644
index 0000000..a8e6d8e
--- /dev/null
+++ b/src/LinkMobility/Responses/SendTextMessageResponse.cs
@@ -0,0 +1,14 @@
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Response of a text message sent to a list of recipients.
+ ///
+ public class SendTextMessageResponse : SendMessageResponse
+ {
+ ///
+ /// The actual number of generated SMS.
+ ///
+ [JsonProperty("smsCount")]
+ public int? SmsCount { get; set; }
+ }
+}
diff --git a/src/LinkMobility/TextMessageExtensions.cs b/src/LinkMobility/TextMessageExtensions.cs
new file mode 100644
index 0000000..45dd4c9
--- /dev/null
+++ b/src/LinkMobility/TextMessageExtensions.cs
@@ -0,0 +1,78 @@
+using System.Threading;
+using System.Threading.Tasks;
+using AMWD.Net.Api.LinkMobility.Utils;
+
+namespace AMWD.Net.Api.LinkMobility
+{
+ ///
+ /// Implementation of text messaging (SMS). API
+ ///
+ public static class TextMessageExtensions
+ {
+ ///
+ /// Sends a text message to a list of recipients.
+ ///
+ /// The instance.
+ /// The request data.
+ /// A cancellation token to propagate notification that operations should be canceled.
+ public static Task SendTextMessage(this ILinkMobilityClient client, SendTextMessageRequest request, CancellationToken cancellationToken = default)
+ {
+ if (request == null)
+ throw new ArgumentNullException(nameof(request));
+
+ if (string.IsNullOrWhiteSpace(request.MessageContent))
+ throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
+
+ ValidateRecipientList(request.RecipientAddressList);
+ ValidateContentCategory(request.ContentCategory);
+
+ return client.PostAsync("/smsmessaging/text", request, cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Sends a binary message to a list of recipients.
+ ///
+ /// The instance.
+ /// The request data.
+ /// A cancellation token to propagate notification that operations should be canceled.
+ public static Task SendBinaryMessage(this ILinkMobilityClient client, SendBinaryMessageRequest request, CancellationToken cancellationToken = default)
+ {
+ if (request == null)
+ throw new ArgumentNullException(nameof(request));
+
+ if (request.MessageContent == null || request.MessageContent.Count == 0)
+ throw new ArgumentException("A message must be provided.", nameof(request.MessageContent));
+
+ // Easiest way to validate that the string is a valid Base64 string.
+ // Might throw a ArgumentNullException or FormatException.
+ foreach (string str in request.MessageContent)
+ Convert.FromBase64String(str);
+
+ ValidateRecipientList(request.RecipientAddressList);
+ ValidateContentCategory(request.ContentCategory);
+
+ return client.PostAsync("/smsmessaging/binary", request, cancellationToken: cancellationToken);
+ }
+
+ private static void ValidateRecipientList(IReadOnlyCollection? recipientAddressList)
+ {
+ if (recipientAddressList == null || recipientAddressList.Count == 0)
+ throw new ArgumentException("At least one recipient must be provided.", nameof(recipientAddressList));
+
+ foreach (string recipient in recipientAddressList)
+ {
+ if (!Validation.IsValidMSISDN(recipient))
+ throw new ArgumentException($"Recipient address '{recipient}' is not a valid MSISDN format.", nameof(recipientAddressList));
+ }
+ }
+
+ private static void ValidateContentCategory(ContentCategory? contentCategory)
+ {
+ if (!contentCategory.HasValue)
+ return;
+
+ if (contentCategory.Value != ContentCategory.Informational && contentCategory.Value != ContentCategory.Advertisement)
+ throw new ArgumentException($"Content category '{contentCategory.Value}' is not valid.", nameof(contentCategory));
+ }
+ }
+}
diff --git a/src/LinkMobility/Utils/Validation.cs b/src/LinkMobility/Utils/Validation.cs
new file mode 100644
index 0000000..6cd199f
--- /dev/null
+++ b/src/LinkMobility/Utils/Validation.cs
@@ -0,0 +1,25 @@
+using System.Text.RegularExpressions;
+
+namespace AMWD.Net.Api.LinkMobility.Utils
+{
+ ///
+ /// Validation helper for LINK Mobility API requirements.
+ ///
+ public static class Validation
+ {
+ ///
+ /// Validates whether the provided string is a valid MSISDN (E.164 formatted).
+ ///
+ /// See Wikipedia: MSISDN for more information.
+ ///
+ /// The string to validate.
+ /// for a valid MSISDN number, otherwise.
+ public static bool IsValidMSISDN(string msisdn)
+ {
+ if (string.IsNullOrWhiteSpace(msisdn))
+ return false;
+
+ return Regex.IsMatch(msisdn, @"^[1-9][0-9]{7,14}$", RegexOptions.Compiled);
+ }
+ }
+}
diff --git a/test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs b/test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs
index 2060b67..28dc8d1 100644
--- a/test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs
+++ b/test/LinkMobility.Tests/Helpers/HttpMessageHandlerMock.cs
@@ -39,6 +39,8 @@ namespace LinkMobility.Tests.Helpers
public Queue Responses { get; } = new();
public Mock Mock { get; }
+
+ public IProtectedMock Protected => Mock.Protected();
}
internal class HttpRequestMessageCallback
diff --git a/test/LinkMobility.Tests/LinkMobilityClientTest.cs b/test/LinkMobility.Tests/LinkMobilityClientTest.cs
index ed5a887..8e10922 100644
--- a/test/LinkMobility.Tests/LinkMobilityClientTest.cs
+++ b/test/LinkMobility.Tests/LinkMobilityClientTest.cs
@@ -145,7 +145,7 @@ namespace LinkMobility.Tests
var client = GetClient();
// Act
- var response = await ReflectionHelper.InvokePrivateMethodAsync(client, "PostAsync", "test", _request, null, TestContext.CancellationToken);
+ var response = await client.PostAsync("test", _request, null, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
@@ -188,7 +188,7 @@ namespace LinkMobility.Tests
var client = GetClient();
// Act
- var response = await ReflectionHelper.InvokePrivateMethodAsync(client, "PostAsync", "params/path", _request, queryParams, TestContext.CancellationToken);
+ var response = await client.PostAsync("params/path", _request, queryParams, TestContext.CancellationToken);
// Assert
Assert.IsNotNull(response);
@@ -256,7 +256,7 @@ namespace LinkMobility.Tests
public void ShouldAssertClientOptions()
{
// Arrange + Act
- var client = GetClient();
+ _ = GetClient();
// Assert
VerifyNoOtherCalls();
@@ -320,7 +320,7 @@ namespace LinkMobility.Tests
// Act & Assert
await Assert.ThrowsExactlyAsync(async () =>
{
- await ReflectionHelper.InvokePrivateMethodAsync