From cd5ac551c27920bd62bce69bcaa79b0ecce4f465 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 11:43:56 +0100 Subject: [PATCH 01/20] Add Termii HTTP pipeline --- README.md | 9 ++ docs/API_COVERAGE.md | 2 +- src/Termii/Properties/AssemblyInfo.cs | 3 + src/Termii/Termii.csproj | 5 + src/Termii/TermiiAuthenticationLocation.cs | 8 ++ src/Termii/TermiiClient.cs | 58 +++++++++- src/Termii/TermiiJsonHttpPipeline.cs | 105 ++++++++++++++++++ src/Termii/TermiiOptions.cs | 9 +- .../TermiiServiceCollectionExtensions.cs | 34 ++++++ tests/Termii.Tests/Termii.Tests.csproj | 1 + tests/Termii.Tests/TermiiClientTests.cs | 93 ++++++++++++++++ 11 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 src/Termii/Properties/AssemblyInfo.cs create mode 100644 src/Termii/TermiiAuthenticationLocation.cs create mode 100644 src/Termii/TermiiJsonHttpPipeline.cs create mode 100644 src/Termii/TermiiServiceCollectionExtensions.cs diff --git a/README.md b/README.md index 3e02b29..153a19e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,15 @@ var client = new TermiiClient(new TermiiOptions The default base URL is `https://api.ng.termii.com`. +For ASP.NET Core applications, register the SDK with dependency injection: + +```csharp +builder.Services.AddTermii(options => +{ + options.ApiKey = builder.Configuration["Termii:ApiKey"]!; +}); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 44234db..bebd356 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -5,7 +5,7 @@ This matrix tracks the Termii API surface against SDK implementation work. | Area | Capability | SDK status | Issue | | --- | --- | --- | --- | | Foundation | Solution, projects, examples, basic tests | In progress | #1 | -| Foundation | Client configuration, authentication, HTTP pipeline | Planned | #2 | +| Foundation | Client configuration, authentication, HTTP pipeline | In progress | #2 | | Foundation | Error handling and validation | Planned | #6 | | Messaging | Send SMS and channel messages | Planned | #7 | | Messaging | Sender ID APIs | Planned | #3 | diff --git a/src/Termii/Properties/AssemblyInfo.cs b/src/Termii/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..6e3734c --- /dev/null +++ b/src/Termii/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Termii.Tests")] diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj index cc7e907..4f665f2 100644 --- a/src/Termii/Termii.csproj +++ b/src/Termii/Termii.csproj @@ -9,4 +9,9 @@ true $(NoWarn);1591 + + + + + diff --git a/src/Termii/TermiiAuthenticationLocation.cs b/src/Termii/TermiiAuthenticationLocation.cs new file mode 100644 index 0000000..d1701f7 --- /dev/null +++ b/src/Termii/TermiiAuthenticationLocation.cs @@ -0,0 +1,8 @@ +namespace Termii; + +internal enum TermiiAuthenticationLocation +{ + None, + Query, + Body, +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index dfe9b6f..907315f 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -3,13 +3,69 @@ namespace Termii; /// /// Main entry point for the Termii SDK. /// -public sealed class TermiiClient +public sealed class TermiiClient : IDisposable { + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly TermiiJsonHttpPipeline _pipeline; + public TermiiClient(TermiiOptions options) + : this(CreateDefaultHttpClient(options), options, ownsHttpClient: true) + { + } + + public TermiiClient(HttpClient httpClient, TermiiOptions options) + : this(httpClient, options, ownsHttpClient: false) + { + } + + private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttpClient) { Options = options ?? throw new ArgumentNullException(nameof(options)); Options.Validate(); + + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _ownsHttpClient = ownsHttpClient; + _httpClient.BaseAddress ??= Options.BaseUrl; + + if (_ownsHttpClient) + { + _httpClient.Timeout = Options.Timeout; + } + + _pipeline = new TermiiJsonHttpPipeline(_httpClient, Options); } public TermiiOptions Options { get; } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + internal Task SendAsync( + HttpMethod method, + string path, + object? body = null, + TermiiAuthenticationLocation authenticationLocation = TermiiAuthenticationLocation.Query, + CancellationToken cancellationToken = default) + { + return _pipeline.SendAsync(method, path, body, authenticationLocation, cancellationToken); + } + + private static HttpClient CreateDefaultHttpClient(TermiiOptions options) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + return new HttpClient + { + BaseAddress = options.BaseUrl, + }; + } } diff --git a/src/Termii/TermiiJsonHttpPipeline.cs b/src/Termii/TermiiJsonHttpPipeline.cs new file mode 100644 index 0000000..9c454ab --- /dev/null +++ b/src/Termii/TermiiJsonHttpPipeline.cs @@ -0,0 +1,105 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Termii; + +internal sealed class TermiiJsonHttpPipeline +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient _httpClient; + private readonly TermiiOptions _options; + + public TermiiJsonHttpPipeline(HttpClient httpClient, TermiiOptions options) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public Task SendAsync( + HttpMethod method, + string path, + object? body, + TermiiAuthenticationLocation authenticationLocation, + CancellationToken cancellationToken) + { + if (method is null) + { + throw new ArgumentNullException(nameof(method)); + } + + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("A request path is required.", nameof(path)); + } + + var requestUri = authenticationLocation == TermiiAuthenticationLocation.Query + ? AppendApiKey(path) + : path; + + var request = new HttpRequestMessage(method, requestUri); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (body is not null || authenticationLocation == TermiiAuthenticationLocation.Body) + { + request.Content = CreateJsonContent(body, authenticationLocation); + } + + return _httpClient.SendAsync(request, cancellationToken); + } + + private string AppendApiKey(string path) + { + var separator = path.IndexOf("?", StringComparison.Ordinal) >= 0 ? "&" : "?"; + + return $"{path}{separator}api_key={Uri.EscapeDataString(_options.ApiKey)}"; + } + + private HttpContent CreateJsonContent(object? body, TermiiAuthenticationLocation authenticationLocation) + { + var json = authenticationLocation == TermiiAuthenticationLocation.Body + ? SerializeBodyWithApiKey(body) + : JsonSerializer.Serialize(body, JsonSerializerOptions); + + return new StringContent(json, Encoding.UTF8, "application/json"); + } + + private string SerializeBodyWithApiKey(object? body) + { + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("api_key", _options.ApiKey); + + if (body is not null) + { + using var document = JsonDocument.Parse(JsonSerializer.Serialize(body, JsonSerializerOptions)); + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Termii JSON request bodies must serialize to an object."); + } + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (string.Equals(property.Name, "api_key", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + property.WriteTo(writer); + } + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } +} diff --git a/src/Termii/TermiiOptions.cs b/src/Termii/TermiiOptions.cs index 6e3ea21..fe7558c 100644 --- a/src/Termii/TermiiOptions.cs +++ b/src/Termii/TermiiOptions.cs @@ -11,6 +11,8 @@ public sealed class TermiiOptions public Uri BaseUrl { get; set; } = new(DefaultBaseUrl); + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100); + internal void Validate() { if (string.IsNullOrWhiteSpace(ApiKey)) @@ -18,9 +20,14 @@ internal void Validate() throw new InvalidOperationException("A Termii API key is required."); } - if (!BaseUrl.IsAbsoluteUri) + if (BaseUrl is null || !BaseUrl.IsAbsoluteUri) { throw new InvalidOperationException("The Termii base URL must be absolute."); } + + if (Timeout <= TimeSpan.Zero) + { + throw new InvalidOperationException("The Termii timeout must be greater than zero."); + } } } diff --git a/src/Termii/TermiiServiceCollectionExtensions.cs b/src/Termii/TermiiServiceCollectionExtensions.cs new file mode 100644 index 0000000..ac7c45e --- /dev/null +++ b/src/Termii/TermiiServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Termii; + +public static class TermiiServiceCollectionExtensions +{ + public static IHttpClientBuilder AddTermii( + this IServiceCollection services, + Action configureOptions) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configureOptions is null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + var options = new TermiiOptions(); + configureOptions(options); + options.Validate(); + + services.AddSingleton(options); + + return services.AddHttpClient((serviceProvider, httpClient) => + { + var termiiOptions = serviceProvider.GetRequiredService(); + httpClient.BaseAddress = termiiOptions.BaseUrl; + httpClient.Timeout = termiiOptions.Timeout; + }); + } +} diff --git a/tests/Termii.Tests/Termii.Tests.csproj b/tests/Termii.Tests/Termii.Tests.csproj index 219cfb4..04c274a 100644 --- a/tests/Termii.Tests/Termii.Tests.csproj +++ b/tests/Termii.Tests/Termii.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/tests/Termii.Tests/TermiiClientTests.cs b/tests/Termii.Tests/TermiiClientTests.cs index 46efaab..883a2d5 100644 --- a/tests/Termii.Tests/TermiiClientTests.cs +++ b/tests/Termii.Tests/TermiiClientTests.cs @@ -1,4 +1,7 @@ using Termii; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Text.Json; using Xunit; namespace Termii.Tests; @@ -26,4 +29,94 @@ public void ConstructorAcceptsValidOptions() Assert.Equal("test-api-key", client.Options.ApiKey); Assert.Equal(TermiiOptions.DefaultBaseUrl, client.Options.BaseUrl.ToString().TrimEnd('/')); } + + [Fact] + public async Task SendAsyncAddsApiKeyToQueryString() + { + using var handler = new RecordingHttpMessageHandler(); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://example.test"), + }; + var client = new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = "test key", + BaseUrl = new Uri("https://example.test"), + }); + + using var response = await client.SendAsync(HttpMethod.Get, "/api/sms/inbox", cancellationToken: CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpMethod.Get, handler.Request!.Method); + Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.Request.RequestUri!.AbsoluteUri); + Assert.Null(handler.Request.Content); + } + + [Fact] + public async Task SendAsyncAddsApiKeyToJsonBody() + { + using var handler = new RecordingHttpMessageHandler(); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://example.test"), + }; + var client = new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = "test-api-key", + BaseUrl = new Uri("https://example.test"), + }); + + using var response = await client.SendAsync( + HttpMethod.Post, + "/api/sms/send", + new { to = "2348012345678", from = "Termii", sms = "Hello" }, + TermiiAuthenticationLocation.Body, + CancellationToken.None); + + var json = await handler.Request!.Content!.ReadAsStringAsync(CancellationToken.None); + using var document = JsonDocument.Parse(json); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpMethod.Post, handler.Request.Method); + Assert.Equal("https://example.test/api/sms/send", handler.Request.RequestUri!.ToString()); + Assert.Equal("test-api-key", document.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", document.RootElement.GetProperty("to").GetString()); + Assert.Equal("Termii", document.RootElement.GetProperty("from").GetString()); + Assert.Equal("Hello", document.RootElement.GetProperty("sms").GetString()); + } + + [Fact] + public void AddTermiiRegistersConfiguredClient() + { + var services = new ServiceCollection(); + + services.AddTermii(options => + { + options.ApiKey = "test-api-key"; + options.BaseUrl = new Uri("https://example.test"); + options.Timeout = TimeSpan.FromSeconds(30); + }); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + Assert.Equal("test-api-key", client.Options.ApiKey); + Assert.Equal("https://example.test/", client.Options.BaseUrl.ToString()); + Assert.Equal(TimeSpan.FromSeconds(30), client.Options.Timeout); + } +} + +internal sealed class RecordingHttpMessageHandler : HttpMessageHandler, IDisposable +{ + public HttpRequestMessage? Request { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}"), + }); + } } From 7dab5c1719ab84086050991f457ac692548e0a63 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 12:15:07 +0100 Subject: [PATCH 02/20] Document Termii API coverage matrix --- docs/API_COVERAGE.md | 105 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index bebd356..bf49631 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -1,21 +1,92 @@ # Termii API Coverage -This matrix tracks the Termii API surface against SDK implementation work. - -| Area | Capability | SDK status | Issue | -| --- | --- | --- | --- | -| Foundation | Solution, projects, examples, basic tests | In progress | #1 | -| Foundation | Client configuration, authentication, HTTP pipeline | In progress | #2 | -| Foundation | Error handling and validation | Planned | #6 | -| Messaging | Send SMS and channel messages | Planned | #7 | -| Messaging | Sender ID APIs | Planned | #3 | -| Messaging | Number API messaging | Planned | #5 | -| Token | Send, verify, and generate OTP | Planned | #4 | -| Insights | Balance, DND, number status, message history | Planned | #9 | -| Release | NuGet package metadata and publishing workflow | Planned | #8 | -| Release | GitHub Release and NuGet publication | Planned | #13 | - -References: +This document tracks the Termii API surface against SDK implementation work. It should be updated in the same PR that adds or changes endpoint support. + +## Sources - Termii developer docs: https://developer.termii.com/ -- Postman collection: https://termii.s3.us-west-1.amazonaws.com/upload/files/UozvGXj5czYEeY4OmE2f.json +- Termii Messaging docs: https://developer.termii.com/messaging +- Termii Token docs: https://developer.termii.com/token +- Termii Insights docs: https://developer.termii.com/insights +- Termii Postman collection: https://termii.s3.us-west-1.amazonaws.com/upload/files/UozvGXj5czYEeY4OmE2f.json + +The Termii docs describe a REST/JSON API and state that each account has its own base URL. The current documentation pages list an update date of April 14, 2026 for the main product areas. + +## Status Legend + +| Status | Meaning | +| --- | --- | +| Implemented | SDK behavior exists, has tests, and is documented. | +| In progress | Work is actively represented by an open issue or PR. | +| Planned | Endpoint is in scope but not implemented yet. | +| Deferred | Endpoint exists in Termii docs, but is outside the first SDK milestone. | +| Needs verification | Endpoint appears in one source but needs confirmation against docs or live behavior before implementation. | + +## Foundation Coverage + +| Capability | SDK status | Tracking | +| --- | --- | --- | +| Solution, SDK project, unit tests, integration test shell, examples project | Implemented | #1, PR #15 | +| Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 | +| API coverage matrix | In progress | #14 | +| Error handling and request validation | Planned | #6 | +| Reusable fake HTTP test helpers and opt-in live test conventions | Planned | #10 | +| README endpoint examples | Planned | #12 | +| CI build/test/package validation | Planned | #11 | +| NuGet package metadata and publishing workflow | Planned | #8 | +| First GitHub Release and NuGet publish | Planned | #13 | + +## Endpoint Coverage + +| Area | Capability | Method | Path | Auth placement | SDK surface | SDK status | Tracking | Test status | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | +| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | +| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Planned | #7 | Planned unit + optional integration | +| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Planned | #7 | Planned unit + optional integration | +| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Planned | #7 | Planned unit + optional integration | +| Messaging | Send via Number API | POST | `/api/sms/number/send` | Query or JSON body, docs/examples vary | `TermiiClient.Messaging` or `TermiiClient.Numbers` | Planned | #5 | Planned unit + optional integration | +| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| Token | Send voice OTP/call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | +| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | +| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | +| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | +| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Needs verification | #9 | Planned unit + optional integration | + +## Deferred Coverage + +The following documented APIs are useful but should come after the first SDK milestone unless a consumer need pushes them forward. + +| Area | Capability | Method | Path | Status | Notes | +| --- | --- | --- | --- | --- | --- | +| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. | +| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. | +| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Deferred | Part of campaign/phonebook management, not core messaging. | +| Campaigns | Create phonebook | POST | `/api/phonebooks` | Deferred | Part of campaign/phonebook management. | +| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | +| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | +| Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. | +| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Deferred | This is SDK model/documentation support rather than an outbound Termii API call. | + +## Postman Collection Reconciliation + +The provided Postman collection currently lists these groups: + +| Collection group | Requests observed | Coverage decision | +| --- | --- | --- | +| Switch | SenderId, Numbers, Send | Covered by #3, #5, and #7. | +| Token | Send Token, Verify Token, In-App Token | Covered by #4. | +| Insight | history analytics, Search, Balance, Inbox API | Covered by #9, with `/api/sms/history/analytics` marked for verification against live behavior/docs. | + +## Implementation Rules + +- Every implemented endpoint should have request-building unit tests using fake HTTP handlers. +- Live integration tests must be opt-in and gated by environment variables. +- Public models should be typed, nullable-aware, and tolerant of undocumented optional response fields. +- Endpoints that require account activation, WhatsApp devices, sender approval, or spendable credits should not run in CI by default. +- README examples should be added or updated with each endpoint group. From 3cd58e9e51fd591943894d244dc01dc721990fc1 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 12:29:46 +0100 Subject: [PATCH 03/20] Add Termii API error handling --- docs/API_COVERAGE.md | 4 +- src/Termii/TermiiApiError.cs | 59 +++++++++++++++ src/Termii/TermiiApiException.cs | 44 +++++++++++ src/Termii/TermiiJsonHttpPipeline.cs | 29 +++++++- tests/Termii.Tests/TermiiClientTests.cs | 98 ++++++++++++++++++++++++- 5 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 src/Termii/TermiiApiError.cs create mode 100644 src/Termii/TermiiApiException.cs diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index bf49631..8710664 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -28,8 +28,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | --- | --- | --- | | Solution, SDK project, unit tests, integration test shell, examples project | Implemented | #1, PR #15 | | Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 | -| API coverage matrix | In progress | #14 | -| Error handling and request validation | Planned | #6 | +| API coverage matrix | Implemented | #14, PR #18 | +| Error handling and request validation | In progress | #6 | | Reusable fake HTTP test helpers and opt-in live test conventions | Planned | #10 | | README endpoint examples | Planned | #12 | | CI build/test/package validation | Planned | #11 | diff --git a/src/Termii/TermiiApiError.cs b/src/Termii/TermiiApiError.cs new file mode 100644 index 0000000..a288252 --- /dev/null +++ b/src/Termii/TermiiApiError.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace Termii; + +internal sealed class TermiiApiError +{ + public string? Message { get; private set; } + + public string? Code { get; private set; } + + public static TermiiApiError Parse(string rawResponseBody) + { + if (string.IsNullOrWhiteSpace(rawResponseBody)) + { + return new TermiiApiError(); + } + + try + { + using var document = JsonDocument.Parse(rawResponseBody); + + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return new TermiiApiError(); + } + + return new TermiiApiError + { + Message = ReadString(document.RootElement, "message") + ?? ReadString(document.RootElement, "error") + ?? ReadString(document.RootElement, "errors") + ?? ReadString(document.RootElement, "status"), + Code = ReadString(document.RootElement, "code") + ?? ReadString(document.RootElement, "error_code"), + }; + } + catch (JsonException) + { + return new TermiiApiError(); + } + } + + private static string? ReadString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var property)) + { + return null; + } + + return property.ValueKind switch + { + JsonValueKind.String => property.GetString(), + JsonValueKind.Number => property.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => property.GetRawText(), + }; + } +} diff --git a/src/Termii/TermiiApiException.cs b/src/Termii/TermiiApiException.cs new file mode 100644 index 0000000..d434b61 --- /dev/null +++ b/src/Termii/TermiiApiException.cs @@ -0,0 +1,44 @@ +using System.Net; + +namespace Termii; + +public sealed class TermiiApiException : Exception +{ + public TermiiApiException( + HttpStatusCode statusCode, + string? termiiMessage, + string? termiiCode, + string rawResponseBody) + : base(CreateMessage(statusCode, termiiMessage, termiiCode)) + { + StatusCode = statusCode; + TermiiMessage = termiiMessage; + TermiiCode = termiiCode; + RawResponseBody = rawResponseBody; + } + + public HttpStatusCode StatusCode { get; } + + public string? TermiiMessage { get; } + + public string? TermiiCode { get; } + + public string RawResponseBody { get; } + + private static string CreateMessage(HttpStatusCode statusCode, string? termiiMessage, string? termiiCode) + { + var message = $"Termii API request failed with HTTP {(int)statusCode} ({statusCode})."; + + if (!string.IsNullOrWhiteSpace(termiiCode)) + { + message += $" Code: {termiiCode}."; + } + + if (!string.IsNullOrWhiteSpace(termiiMessage)) + { + message += $" Message: {termiiMessage}."; + } + + return message; + } +} diff --git a/src/Termii/TermiiJsonHttpPipeline.cs b/src/Termii/TermiiJsonHttpPipeline.cs index 9c454ab..c08500e 100644 --- a/src/Termii/TermiiJsonHttpPipeline.cs +++ b/src/Termii/TermiiJsonHttpPipeline.cs @@ -20,7 +20,7 @@ public TermiiJsonHttpPipeline(HttpClient httpClient, TermiiOptions options) _options = options ?? throw new ArgumentNullException(nameof(options)); } - public Task SendAsync( + public async Task SendAsync( HttpMethod method, string path, object? body, @@ -37,6 +37,16 @@ public Task SendAsync( throw new ArgumentException("A request path is required.", nameof(path)); } + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + throw new ArgumentException("Termii request paths must start with '/'.", nameof(path)); + } + + if (Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri) && uri.IsAbsoluteUri) + { + throw new ArgumentException("Termii request paths must be relative.", nameof(path)); + } + var requestUri = authenticationLocation == TermiiAuthenticationLocation.Query ? AppendApiKey(path) : path; @@ -49,7 +59,22 @@ public Task SendAsync( request.Content = CreateJsonContent(body, authenticationLocation); } - return _httpClient.SendAsync(request, cancellationToken); + var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + return response; + } + + var statusCode = response.StatusCode; + var rawResponseBody = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var error = TermiiApiError.Parse(rawResponseBody); + + response.Dispose(); + + throw new TermiiApiException(statusCode, error.Message, error.Code, rawResponseBody); } private string AppendApiKey(string path) diff --git a/tests/Termii.Tests/TermiiClientTests.cs b/tests/Termii.Tests/TermiiClientTests.cs index 883a2d5..7ccedec 100644 --- a/tests/Termii.Tests/TermiiClientTests.cs +++ b/tests/Termii.Tests/TermiiClientTests.cs @@ -85,6 +85,86 @@ public async Task SendAsyncAddsApiKeyToJsonBody() Assert.Equal("Hello", document.RootElement.GetProperty("sms").GetString()); } + [Fact] + public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses() + { + using var handler = new RecordingHttpMessageHandler( + HttpStatusCode.BadRequest, + """{"message":"Invalid sender ID","code":"sender_id_invalid"}"""); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://example.test"), + }; + var client = new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = "secret-api-key", + BaseUrl = new Uri("https://example.test"), + }); + + var exception = await Assert.ThrowsAsync(() => client.SendAsync( + HttpMethod.Post, + "/api/sms/send", + new { to = "2348012345678", from = "Termii", sms = "Hello" }, + TermiiAuthenticationLocation.Body, + CancellationToken.None)); + + Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode); + Assert.Equal("Invalid sender ID", exception.TermiiMessage); + Assert.Equal("sender_id_invalid", exception.TermiiCode); + Assert.Equal("""{"message":"Invalid sender ID","code":"sender_id_invalid"}""", exception.RawResponseBody); + Assert.DoesNotContain("secret-api-key", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses() + { + using var handler = new RecordingHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable"); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://example.test"), + }; + var client = new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = "secret-api-key", + BaseUrl = new Uri("https://example.test"), + }); + + var exception = await Assert.ThrowsAsync(() => client.SendAsync( + HttpMethod.Get, + "/api/get-balance", + cancellationToken: CancellationToken.None)); + + Assert.Equal(HttpStatusCode.InternalServerError, exception.StatusCode); + Assert.Null(exception.TermiiMessage); + Assert.Null(exception.TermiiCode); + Assert.Equal("Service unavailable", exception.RawResponseBody); + Assert.DoesNotContain("secret-api-key", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("api/sms/send")] + [InlineData("https://example.test/api/sms/send")] + public async Task SendAsyncRejectsInvalidPaths(string path) + { + using var handler = new RecordingHttpMessageHandler(); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://example.test"), + }; + var client = new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = "test-api-key", + BaseUrl = new Uri("https://example.test"), + }); + + await Assert.ThrowsAnyAsync(() => client.SendAsync( + HttpMethod.Get, + path, + cancellationToken: CancellationToken.None)); + } + [Fact] public void AddTermiiRegistersConfiguredClient() { @@ -108,15 +188,29 @@ public void AddTermiiRegistersConfiguredClient() internal sealed class RecordingHttpMessageHandler : HttpMessageHandler, IDisposable { + private readonly HttpStatusCode _statusCode; + private readonly string _responseBody; + + public RecordingHttpMessageHandler() + : this(HttpStatusCode.OK, "{}") + { + } + + public RecordingHttpMessageHandler(HttpStatusCode statusCode, string responseBody) + { + _statusCode = statusCode; + _responseBody = responseBody; + } + public HttpRequestMessage? Request { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Request = request; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + return Task.FromResult(new HttpResponseMessage(_statusCode) { - Content = new StringContent("{}"), + Content = new StringContent(_responseBody), }); } } From 74efd4d769ac6e2873f7dee31c889b0480ffc20c Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 19:09:46 +0100 Subject: [PATCH 04/20] Add reusable SDK test infrastructure --- .gitignore | 1 + README.md | 1 + docs/API_COVERAGE.md | 4 +- .../IntegrationTestEnvironment.cs | 34 ++++++ .../TermiiClientIntegrationTests.cs | 17 +-- .../Infrastructure/TestHttpMessageHandler.cs | 44 ++++++++ .../Infrastructure/TestRequestExtensions.cs | 20 ++++ .../Infrastructure/TestTermiiClientFactory.cs | 23 ++++ tests/Termii.Tests/TermiiClientTests.cs | 106 ++++-------------- 9 files changed, 146 insertions(+), 104 deletions(-) create mode 100644 tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs create mode 100644 tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs create mode 100644 tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs create mode 100644 tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs diff --git a/.gitignore b/.gitignore index eb36e42..203e37c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ obj/ .vs/ .vscode/ .idea/ +.DS_Store TestResults/ coverage/ *.user diff --git a/README.md b/README.md index 153a19e..6134386 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Integration tests are opt-in and must not call live Termii endpoints unless the ```bash export TERMII_API_KEY="your-termii-api-key" export TERMII_BASE_URL="https://api.ng.termii.com" +export TERMII_TEST_PHONE_NUMBER="2348012345678" dotnet test tests/Termii.IntegrationTests ``` diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 8710664..4eb700a 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -29,8 +29,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Solution, SDK project, unit tests, integration test shell, examples project | Implemented | #1, PR #15 | | Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 | | API coverage matrix | Implemented | #14, PR #18 | -| Error handling and request validation | In progress | #6 | -| Reusable fake HTTP test helpers and opt-in live test conventions | Planned | #10 | +| Error handling and request validation | Implemented | #6, PR #19 | +| Reusable fake HTTP test helpers and opt-in live test conventions | In progress | #10 | | README endpoint examples | Planned | #12 | | CI build/test/package validation | Planned | #11 | | NuGet package metadata and publishing workflow | Planned | #8 | diff --git a/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs new file mode 100644 index 0000000..4f70b90 --- /dev/null +++ b/tests/Termii.IntegrationTests/IntegrationTestEnvironment.cs @@ -0,0 +1,34 @@ +using Termii; + +namespace Termii.IntegrationTests; + +internal static class IntegrationTestEnvironment +{ + public static string? ApiKey => Environment.GetEnvironmentVariable("TERMII_API_KEY"); + + public static string? BaseUrl => Environment.GetEnvironmentVariable("TERMII_BASE_URL"); + + public static string? TestPhoneNumber => Environment.GetEnvironmentVariable("TERMII_TEST_PHONE_NUMBER"); + + public static bool HasCredentials => !string.IsNullOrWhiteSpace(ApiKey); + + public static TermiiOptions CreateOptions() + { + if (string.IsNullOrWhiteSpace(ApiKey)) + { + throw new InvalidOperationException("TERMII_API_KEY is required for live Termii integration tests."); + } + + var options = new TermiiOptions + { + ApiKey = ApiKey, + }; + + if (!string.IsNullOrWhiteSpace(BaseUrl)) + { + options.BaseUrl = new Uri(BaseUrl); + } + + return options; + } +} diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index df2c1d8..8828c90 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -8,25 +8,12 @@ public sealed class TermiiClientIntegrationTests [Fact] public void CanCreateClientFromIntegrationEnvironment() { - var apiKey = Environment.GetEnvironmentVariable("TERMII_API_KEY"); - - if (string.IsNullOrWhiteSpace(apiKey)) + if (!IntegrationTestEnvironment.HasCredentials) { return; } - var baseUrl = Environment.GetEnvironmentVariable("TERMII_BASE_URL"); - var options = new TermiiOptions - { - ApiKey = apiKey, - }; - - if (!string.IsNullOrWhiteSpace(baseUrl)) - { - options.BaseUrl = new Uri(baseUrl); - } - - var client = new TermiiClient(options); + var client = new TermiiClient(IntegrationTestEnvironment.CreateOptions()); Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey)); Assert.True(client.Options.BaseUrl.IsAbsoluteUri); diff --git a/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs new file mode 100644 index 0000000..e13fd22 --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestHttpMessageHandler.cs @@ -0,0 +1,44 @@ +using System.Net; + +namespace Termii.Tests.Infrastructure; + +internal sealed class TestHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue _responses = new(); + + public TestHttpMessageHandler() + : this(HttpStatusCode.OK, "{}") + { + } + + public TestHttpMessageHandler(HttpStatusCode statusCode, string responseBody) + { + Enqueue(statusCode, responseBody); + } + + public IReadOnlyList Requests => _requests; + + public HttpRequestMessage? LastRequest => _requests.Count == 0 ? null : _requests[^1]; + + private readonly List _requests = new(); + + public void Enqueue(HttpStatusCode statusCode, string responseBody) + { + _responses.Enqueue(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody), + }); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _requests.Add(request); + + if (_responses.Count == 0) + { + throw new InvalidOperationException("No fake HTTP response was queued for the request."); + } + + return Task.FromResult(_responses.Dequeue()); + } +} diff --git a/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs new file mode 100644 index 0000000..9db82a3 --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestRequestExtensions.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace Termii.Tests.Infrastructure; + +internal static class TestRequestExtensions +{ + public static async Task ReadJsonBodyAsync( + this HttpRequestMessage request, + CancellationToken cancellationToken = default) + { + if (request.Content is null) + { + throw new InvalidOperationException("The request does not have a body."); + } + + var json = await request.Content.ReadAsStringAsync(cancellationToken); + + return JsonDocument.Parse(json); + } +} diff --git a/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs new file mode 100644 index 0000000..473cbcc --- /dev/null +++ b/tests/Termii.Tests/Infrastructure/TestTermiiClientFactory.cs @@ -0,0 +1,23 @@ +using Termii; + +namespace Termii.Tests.Infrastructure; + +internal static class TestTermiiClientFactory +{ + public static TermiiClient Create( + TestHttpMessageHandler handler, + string apiKey = "test-api-key", + string baseUrl = "https://example.test") + { + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri(baseUrl), + }; + + return new TermiiClient(httpClient, new TermiiOptions + { + ApiKey = apiKey, + BaseUrl = new Uri(baseUrl), + }); + } +} diff --git a/tests/Termii.Tests/TermiiClientTests.cs b/tests/Termii.Tests/TermiiClientTests.cs index 7ccedec..e525fc6 100644 --- a/tests/Termii.Tests/TermiiClientTests.cs +++ b/tests/Termii.Tests/TermiiClientTests.cs @@ -1,7 +1,7 @@ using Termii; +using Termii.Tests.Infrastructure; using Microsoft.Extensions.DependencyInjection; using System.Net; -using System.Text.Json; using Xunit; namespace Termii.Tests; @@ -33,38 +33,22 @@ public void ConstructorAcceptsValidOptions() [Fact] public async Task SendAsyncAddsApiKeyToQueryString() { - using var handler = new RecordingHttpMessageHandler(); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "test key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler, apiKey: "test key"); using var response = await client.SendAsync(HttpMethod.Get, "/api/sms/inbox", cancellationToken: CancellationToken.None); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(HttpMethod.Get, handler.Request!.Method); - Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.Request.RequestUri!.AbsoluteUri); - Assert.Null(handler.Request.Content); + Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method); + Assert.Equal("https://example.test/api/sms/inbox?api_key=test%20key", handler.LastRequest.RequestUri!.AbsoluteUri); + Assert.Null(handler.LastRequest.Content); } [Fact] public async Task SendAsyncAddsApiKeyToJsonBody() { - using var handler = new RecordingHttpMessageHandler(); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "test-api-key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); using var response = await client.SendAsync( HttpMethod.Post, @@ -73,12 +57,13 @@ public async Task SendAsyncAddsApiKeyToJsonBody() TermiiAuthenticationLocation.Body, CancellationToken.None); - var json = await handler.Request!.Content!.ReadAsStringAsync(CancellationToken.None); - using var document = JsonDocument.Parse(json); + var request = handler.LastRequest; + Assert.NotNull(request); + using var document = await request.ReadJsonBodyAsync(CancellationToken.None); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(HttpMethod.Post, handler.Request.Method); - Assert.Equal("https://example.test/api/sms/send", handler.Request.RequestUri!.ToString()); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); Assert.Equal("test-api-key", document.RootElement.GetProperty("api_key").GetString()); Assert.Equal("2348012345678", document.RootElement.GetProperty("to").GetString()); Assert.Equal("Termii", document.RootElement.GetProperty("from").GetString()); @@ -88,18 +73,10 @@ public async Task SendAsyncAddsApiKeyToJsonBody() [Fact] public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses() { - using var handler = new RecordingHttpMessageHandler( + using var handler = new TestHttpMessageHandler( HttpStatusCode.BadRequest, """{"message":"Invalid sender ID","code":"sender_id_invalid"}"""); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "secret-api-key", - BaseUrl = new Uri("https://example.test"), - }); + var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key"); var exception = await Assert.ThrowsAsync(() => client.SendAsync( HttpMethod.Post, @@ -118,16 +95,8 @@ public async Task SendAsyncThrowsTermiiApiExceptionForJsonErrorResponses() [Fact] public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses() { - using var handler = new RecordingHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable"); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "secret-api-key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(HttpStatusCode.InternalServerError, "Service unavailable"); + var client = TestTermiiClientFactory.Create(handler, apiKey: "secret-api-key"); var exception = await Assert.ThrowsAsync(() => client.SendAsync( HttpMethod.Get, @@ -148,16 +117,8 @@ public async Task SendAsyncThrowsTermiiApiExceptionForPlainTextErrorResponses() [InlineData("https://example.test/api/sms/send")] public async Task SendAsyncRejectsInvalidPaths(string path) { - using var handler = new RecordingHttpMessageHandler(); - using var httpClient = new HttpClient(handler) - { - BaseAddress = new Uri("https://example.test"), - }; - var client = new TermiiClient(httpClient, new TermiiOptions - { - ApiKey = "test-api-key", - BaseUrl = new Uri("https://example.test"), - }); + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); await Assert.ThrowsAnyAsync(() => client.SendAsync( HttpMethod.Get, @@ -185,32 +146,3 @@ public void AddTermiiRegistersConfiguredClient() Assert.Equal(TimeSpan.FromSeconds(30), client.Options.Timeout); } } - -internal sealed class RecordingHttpMessageHandler : HttpMessageHandler, IDisposable -{ - private readonly HttpStatusCode _statusCode; - private readonly string _responseBody; - - public RecordingHttpMessageHandler() - : this(HttpStatusCode.OK, "{}") - { - } - - public RecordingHttpMessageHandler(HttpStatusCode statusCode, string responseBody) - { - _statusCode = statusCode; - _responseBody = responseBody; - } - - public HttpRequestMessage? Request { get; private set; } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Request = request; - - return Task.FromResult(new HttpResponseMessage(_statusCode) - { - Content = new StringContent(_responseBody), - }); - } -} From 6e216e2cdeaa9d532e3c593507f10b1f4c304428 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 19:19:44 +0100 Subject: [PATCH 05/20] Add Termii messaging API client --- README.md | 13 ++ docs/API_COVERAGE.md | 8 +- .../Messaging/ITermiiMessagingClient.cs | 12 ++ .../Messaging/SendBulkMessageRequest.cs | 37 +++++ src/Termii/Messaging/SendMessageRequest.cs | 31 ++++ src/Termii/Messaging/TermiiMessageChannel.cs | 9 ++ src/Termii/Messaging/TermiiMessageMedia.cs | 12 ++ src/Termii/Messaging/TermiiMessageResponse.cs | 24 +++ src/Termii/Messaging/TermiiMessageType.cs | 7 + src/Termii/Messaging/TermiiMessagingClient.cs | 109 +++++++++++++ .../TermiiMessagingEnumExtensions.cs | 26 +++ src/Termii/TermiiClient.cs | 3 + src/Termii/TermiiJsonHttpPipeline.cs | 28 ++++ src/Termii/TermiiRequestValidation.cs | 12 ++ .../TermiiClientIntegrationTests.cs | 1 + .../TermiiMessagingClientTests.cs | 150 ++++++++++++++++++ 16 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 src/Termii/Messaging/ITermiiMessagingClient.cs create mode 100644 src/Termii/Messaging/SendBulkMessageRequest.cs create mode 100644 src/Termii/Messaging/SendMessageRequest.cs create mode 100644 src/Termii/Messaging/TermiiMessageChannel.cs create mode 100644 src/Termii/Messaging/TermiiMessageMedia.cs create mode 100644 src/Termii/Messaging/TermiiMessageResponse.cs create mode 100644 src/Termii/Messaging/TermiiMessageType.cs create mode 100644 src/Termii/Messaging/TermiiMessagingClient.cs create mode 100644 src/Termii/Messaging/TermiiMessagingEnumExtensions.cs create mode 100644 src/Termii/TermiiRequestValidation.cs create mode 100644 tests/Termii.Tests/TermiiMessagingClientTests.cs diff --git a/README.md b/README.md index 6134386..d76eda1 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ builder.Services.AddTermii(options => }); ``` +Send a message: + +```csharp +var response = await client.Messaging.SendAsync(new SendMessageRequest +{ + To = "2348012345678", + From = "Termii", + Sms = "Hello from .NET", + Channel = TermiiMessageChannel.Generic, + Type = TermiiMessageType.Plain +}); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 4eb700a..b8e0dc9 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -30,7 +30,7 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Client configuration, `HttpClient` pipeline, API-key injection, DI registration | Implemented | #2, PR #17 | | API coverage matrix | Implemented | #14, PR #18 | | Error handling and request validation | Implemented | #6, PR #19 | -| Reusable fake HTTP test helpers and opt-in live test conventions | In progress | #10 | +| Reusable fake HTTP test helpers and opt-in live test conventions | Implemented | #10, PR #20 | | README endpoint examples | Planned | #12 | | CI build/test/package validation | Planned | #11 | | NuGet package metadata and publishing workflow | Planned | #8 | @@ -42,9 +42,9 @@ The Termii docs describe a REST/JSON API and state that each account has its own | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | | Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | -| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Planned | #7 | Planned unit + optional integration | -| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Planned | #7 | Planned unit + optional integration | -| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Planned | #7 | Planned unit + optional integration | +| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | In progress | #7 | Unit tests added | +| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | In progress | #7 | Unit tests added | +| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | In progress | #7 | Unit tests added | | Messaging | Send via Number API | POST | `/api/sms/number/send` | Query or JSON body, docs/examples vary | `TermiiClient.Messaging` or `TermiiClient.Numbers` | Planned | #5 | Planned unit + optional integration | | Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | | Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | diff --git a/src/Termii/Messaging/ITermiiMessagingClient.cs b/src/Termii/Messaging/ITermiiMessagingClient.cs new file mode 100644 index 0000000..9a68176 --- /dev/null +++ b/src/Termii/Messaging/ITermiiMessagingClient.cs @@ -0,0 +1,12 @@ +namespace Termii; + +public interface ITermiiMessagingClient +{ + Task SendAsync( + SendMessageRequest request, + CancellationToken cancellationToken = default); + + Task SendBulkAsync( + SendBulkMessageRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Messaging/SendBulkMessageRequest.cs b/src/Termii/Messaging/SendBulkMessageRequest.cs new file mode 100644 index 0000000..fe4db96 --- /dev/null +++ b/src/Termii/Messaging/SendBulkMessageRequest.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendBulkMessageRequest +{ + [JsonPropertyName("to")] + public IReadOnlyCollection To { get; set; } = Array.Empty(); + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain; + + [JsonIgnore] + public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic; + + internal void Validate() + { + if (To is null || To.Count == 0) + { + throw new ArgumentException("At least one recipient phone number is required.", nameof(To)); + } + + foreach (var recipient in To) + { + TermiiRequestValidation.Required(recipient, nameof(To)); + } + + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Messaging/SendMessageRequest.cs b/src/Termii/Messaging/SendMessageRequest.cs new file mode 100644 index 0000000..f4e7bae --- /dev/null +++ b/src/Termii/Messaging/SendMessageRequest.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendMessageRequest +{ + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain; + + [JsonIgnore] + public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic; + + [JsonPropertyName("media")] + public TermiiMessageMedia? Media { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Messaging/TermiiMessageChannel.cs b/src/Termii/Messaging/TermiiMessageChannel.cs new file mode 100644 index 0000000..a33bf92 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageChannel.cs @@ -0,0 +1,9 @@ +namespace Termii; + +public enum TermiiMessageChannel +{ + Generic, + Dnd, + WhatsApp, + Voice, +} diff --git a/src/Termii/Messaging/TermiiMessageMedia.cs b/src/Termii/Messaging/TermiiMessageMedia.cs new file mode 100644 index 0000000..fa36a60 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageMedia.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class TermiiMessageMedia +{ + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("caption")] + public string? Caption { get; set; } +} diff --git a/src/Termii/Messaging/TermiiMessageResponse.cs b/src/Termii/Messaging/TermiiMessageResponse.cs new file mode 100644 index 0000000..7707115 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageResponse.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class TermiiMessageResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("balance")] + public decimal? Balance { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("user")] + public string? User { get; set; } + + [JsonPropertyName("message_id_str")] + public string? MessageIdString { get; set; } +} diff --git a/src/Termii/Messaging/TermiiMessageType.cs b/src/Termii/Messaging/TermiiMessageType.cs new file mode 100644 index 0000000..eefc107 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessageType.cs @@ -0,0 +1,7 @@ +namespace Termii; + +public enum TermiiMessageType +{ + Plain, + Unicode, +} diff --git a/src/Termii/Messaging/TermiiMessagingClient.cs b/src/Termii/Messaging/TermiiMessagingClient.cs new file mode 100644 index 0000000..0c7fea0 --- /dev/null +++ b/src/Termii/Messaging/TermiiMessagingClient.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +internal sealed class TermiiMessagingClient : ITermiiMessagingClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiMessagingClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendAsync( + SendMessageRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/send", + new SendMessagePayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendBulkAsync( + SendBulkMessageRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/send/bulk", + new SendBulkMessagePayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + private sealed class SendMessagePayload + { + public SendMessagePayload(SendMessageRequest request) + { + To = request.To; + From = request.From; + Sms = request.Sms; + Type = request.Type.ToWireValue(); + Channel = request.Channel.ToWireValue(); + Media = request.Media; + } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("sms")] + public string Sms { get; } + + [JsonPropertyName("type")] + public string Type { get; } + + [JsonPropertyName("channel")] + public string Channel { get; } + + [JsonPropertyName("media")] + public TermiiMessageMedia? Media { get; } + } + + private sealed class SendBulkMessagePayload + { + public SendBulkMessagePayload(SendBulkMessageRequest request) + { + To = request.To; + From = request.From; + Sms = request.Sms; + Type = request.Type.ToWireValue(); + Channel = request.Channel.ToWireValue(); + } + + [JsonPropertyName("to")] + public IReadOnlyCollection To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("sms")] + public string Sms { get; } + + [JsonPropertyName("type")] + public string Type { get; } + + [JsonPropertyName("channel")] + public string Channel { get; } + } +} diff --git a/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs b/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs new file mode 100644 index 0000000..ad1441e --- /dev/null +++ b/src/Termii/Messaging/TermiiMessagingEnumExtensions.cs @@ -0,0 +1,26 @@ +namespace Termii; + +internal static class TermiiMessagingEnumExtensions +{ + public static string ToWireValue(this TermiiMessageChannel channel) + { + return channel switch + { + TermiiMessageChannel.Generic => "generic", + TermiiMessageChannel.Dnd => "dnd", + TermiiMessageChannel.WhatsApp => "whatsapp", + TermiiMessageChannel.Voice => "voice", + _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, "Unsupported Termii message channel."), + }; + } + + public static string ToWireValue(this TermiiMessageType type) + { + return type switch + { + TermiiMessageType.Plain => "plain", + TermiiMessageType.Unicode => "unicode", + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported Termii message type."), + }; + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index 907315f..a57e19f 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -34,10 +34,13 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp } _pipeline = new TermiiJsonHttpPipeline(_httpClient, Options); + Messaging = new TermiiMessagingClient(_pipeline); } public TermiiOptions Options { get; } + public ITermiiMessagingClient Messaging { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/src/Termii/TermiiJsonHttpPipeline.cs b/src/Termii/TermiiJsonHttpPipeline.cs index c08500e..3f156fa 100644 --- a/src/Termii/TermiiJsonHttpPipeline.cs +++ b/src/Termii/TermiiJsonHttpPipeline.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using System.Text.Json.Serialization; using System.Text; using System.Text.Json; @@ -9,6 +10,7 @@ internal sealed class TermiiJsonHttpPipeline private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString, }; private readonly HttpClient _httpClient; @@ -77,6 +79,32 @@ public async Task SendAsync( throw new TermiiApiException(statusCode, error.Message, error.Code, rawResponseBody); } + public async Task SendJsonAsync( + HttpMethod method, + string path, + object? body, + TermiiAuthenticationLocation authenticationLocation, + CancellationToken cancellationToken) + { + using var response = await SendAsync(method, path, body, authenticationLocation, cancellationToken) + .ConfigureAwait(false); + + if (response.Content is null) + { + throw new InvalidOperationException("The Termii API returned an empty response."); + } + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var value = JsonSerializer.Deserialize(json, JsonSerializerOptions); + + if (value is null) + { + throw new InvalidOperationException("The Termii API returned an empty response."); + } + + return value; + } + private string AppendApiKey(string path) { var separator = path.IndexOf("?", StringComparison.Ordinal) >= 0 ? "&" : "?"; diff --git a/src/Termii/TermiiRequestValidation.cs b/src/Termii/TermiiRequestValidation.cs new file mode 100644 index 0000000..20ea8c1 --- /dev/null +++ b/src/Termii/TermiiRequestValidation.cs @@ -0,0 +1,12 @@ +namespace Termii; + +internal static class TermiiRequestValidation +{ + public static void Required(string? value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"{parameterName} is required.", parameterName); + } + } +} diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 8828c90..7a7fcd6 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -17,5 +17,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey)); Assert.True(client.Options.BaseUrl.IsAbsoluteUri); + Assert.NotNull(client.Messaging); } } diff --git a/tests/Termii.Tests/TermiiMessagingClientTests.cs b/tests/Termii.Tests/TermiiMessagingClientTests.cs new file mode 100644 index 0000000..b9844f5 --- /dev/null +++ b/tests/Termii.Tests/TermiiMessagingClientTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiMessagingClientTests +{ + [Fact] + public async Task SendAsyncPostsMessageBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "code": "ok", + "balance": "42.50", + "message_id": "12345", + "message": "Successfully Sent", + "user": "termii-user", + "message_id_str": "12345" + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendAsync( + new SendMessageRequest + { + To = "2348012345678", + From = "Termii", + Sms = "Hello from the SDK", + Type = TermiiMessageType.Unicode, + Channel = TermiiMessageChannel.Dnd, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("Hello from the SDK", body.RootElement.GetProperty("sms").GetString()); + Assert.Equal("unicode", body.RootElement.GetProperty("type").GetString()); + Assert.Equal("dnd", body.RootElement.GetProperty("channel").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal(42.50m, response.Balance); + Assert.Equal("12345", response.MessageId); + Assert.Equal("Successfully Sent", response.Message); + Assert.Equal("termii-user", response.User); + Assert.Equal("12345", response.MessageIdString); + } + + [Fact] + public async Task SendAsyncIncludesWhatsAppMedia() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Messaging.SendAsync( + new SendMessageRequest + { + To = "2348012345678", + From = "Termii", + Sms = "A document for you", + Channel = TermiiMessageChannel.WhatsApp, + Media = new TermiiMessageMedia + { + Url = "https://example.test/file.pdf", + Caption = "Termii SDK", + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + var media = body.RootElement.GetProperty("media"); + + Assert.Equal("whatsapp", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("https://example.test/file.pdf", media.GetProperty("url").GetString()); + Assert.Equal("Termii SDK", media.GetProperty("caption").GetString()); + } + + [Fact] + public async Task SendBulkAsyncPostsBulkMessageBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Messaging.SendBulkAsync( + new SendBulkMessageRequest + { + To = new[] { "2348012345678", "2348098765432" }, + From = "Termii", + Sms = "Hello everyone", + Channel = TermiiMessageChannel.Generic, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/send/bulk", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to")[0].GetString()); + Assert.Equal("2348098765432", body.RootElement.GetProperty("to")[1].GetString()); + Assert.Equal("generic", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("plain", body.RootElement.GetProperty("type").GetString()); + } + + [Fact] + public async Task SendAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendAsync( + new SendMessageRequest + { + To = "2348012345678", + From = "", + Sms = "Hello", + }, + CancellationToken.None)); + } + + [Fact] + public async Task SendBulkAsyncRejectsMissingRecipients() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendBulkAsync( + new SendBulkMessageRequest + { + To = Array.Empty(), + From = "Termii", + Sms = "Hello", + }, + CancellationToken.None)); + } +} From 61985e3935c27886784bee721ef10346bf0329ca Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 19:43:53 +0100 Subject: [PATCH 06/20] Add CI test and package validation workflow --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++++----- docs/API_COVERAGE.md | 2 +- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a1c81b..3062bbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,15 @@ on: push: branches: - main + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build: + test: + name: Build, test, and pack runs-on: ubuntu-latest steps: @@ -20,10 +26,30 @@ jobs: dotnet-version: 8.0.x - name: Restore - run: dotnet restore + run: dotnet restore Termii.SDK.sln - name: Build - run: dotnet build --configuration Release --no-restore + run: dotnet build Termii.SDK.sln --configuration Release --no-restore + + - name: Unit tests + run: dotnet test tests/Termii.Tests/Termii.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=unit-tests.trx" + + - name: Integration tests + run: dotnet test tests/Termii.IntegrationTests/Termii.IntegrationTests.csproj --configuration Release --no-build --logger "trx;LogFileName=integration-tests.trx" + + - name: Pack + run: dotnet pack src/Termii/Termii.csproj --configuration Release --no-build --output artifacts/packages - - name: Test - run: dotnet test --configuration Release --no-build + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: | + **/TestResults/*.trx + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: artifacts/packages/*.nupkg diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index b8e0dc9..5351925 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -32,7 +32,7 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Error handling and request validation | Implemented | #6, PR #19 | | Reusable fake HTTP test helpers and opt-in live test conventions | Implemented | #10, PR #20 | | README endpoint examples | Planned | #12 | -| CI build/test/package validation | Planned | #11 | +| CI build/test/package validation | In progress | #11 | | NuGet package metadata and publishing workflow | Planned | #8 | | First GitHub Release and NuGet publish | Planned | #13 | From f4e779b97f7967941000057ea03c7ff394130b51 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 20:08:08 +0100 Subject: [PATCH 07/20] Prepare NuGet package metadata --- .github/workflows/ci.yml | 4 ++- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++++++++ Directory.Build.props | 7 ++++ LICENSE | 21 ++++++++++++ README.md | 12 +++++++ docs/API_COVERAGE.md | 10 +++--- docs/RELEASING.md | 27 ++++++++++++++++ src/Termii/Termii.csproj | 9 +++++- 8 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 LICENSE create mode 100644 docs/RELEASING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3062bbf..fe51272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,4 +52,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: nuget-package - path: artifacts/packages/*.nupkg + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97d6a96 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + publish: + description: Publish to NuGet when NUGET_API_KEY is configured. + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + package: + name: Build, test, pack, and publish + runs-on: ubuntu-latest + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore Termii.SDK.sln + + - name: Build + run: dotnet build Termii.SDK.sln --configuration Release --no-restore + + - name: Unit tests + run: dotnet test tests/Termii.Tests/Termii.Tests.csproj --configuration Release --no-build --logger "trx;LogFileName=unit-tests.trx" + + - name: Integration tests + run: dotnet test tests/Termii.IntegrationTests/Termii.IntegrationTests.csproj --configuration Release --no-build --logger "trx;LogFileName=integration-tests.trx" + + - name: Pack + run: dotnet pack src/Termii/Termii.csproj --configuration Release --no-build --output artifacts/packages + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: | + artifacts/packages/*.nupkg + artifacts/packages/*.snupkg + + - name: Publish to NuGet + if: ${{ (github.event_name == 'push' || inputs.publish) && env.NUGET_API_KEY != '' }} + run: dotnet nuget push "artifacts/packages/*.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate diff --git a/Directory.Build.props b/Directory.Build.props index bdc37b5..3c476b7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,6 +8,13 @@ git https://github.com/teesofttech/Termii.SDK MIT + README.md + false + true + true + true + snupkg + true Teesoft Tech Teesoft Tech diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4015f4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Teesoft Tech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d76eda1..6935849 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Termii .NET SDK +[![CI](https://github.com/teesofttech/Termii.SDK/actions/workflows/ci.yml/badge.svg)](https://github.com/teesofttech/Termii.SDK/actions/workflows/ci.yml) + A .NET SDK for the Termii messaging, token, and insights APIs. > This SDK is in early development. The first milestones establish the client, tests, examples, API coverage matrix, and package structure before endpoint support is added feature by feature. @@ -15,6 +17,12 @@ The initial repository setup includes: ## Usage +Install the package: + +```bash +dotnet add package Termii.SDK +``` + ```csharp using Termii; @@ -88,3 +96,7 @@ Development is tracked through GitHub issues: - CI, documentation, NuGet packaging, and GitHub Releases. Official Termii documentation: https://developer.termii.com/ + +## License + +This project is licensed under the MIT License. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 5351925..e153d47 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -32,8 +32,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Error handling and request validation | Implemented | #6, PR #19 | | Reusable fake HTTP test helpers and opt-in live test conventions | Implemented | #10, PR #20 | | README endpoint examples | Planned | #12 | -| CI build/test/package validation | In progress | #11 | -| NuGet package metadata and publishing workflow | Planned | #8 | +| CI build/test/package validation | Implemented | #11, PR #22 | +| NuGet package metadata and publishing workflow | In progress | #8 | | First GitHub Release and NuGet publish | Planned | #13 | ## Endpoint Coverage @@ -42,9 +42,9 @@ The Termii docs describe a REST/JSON API and state that each account has its own | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | | Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | -| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | In progress | #7 | Unit tests added | -| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | In progress | #7 | Unit tests added | -| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | In progress | #7 | Unit tests added | +| Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | +| Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | +| Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send via Number API | POST | `/api/sms/number/send` | Query or JSON body, docs/examples vary | `TermiiClient.Messaging` or `TermiiClient.Numbers` | Planned | #5 | Planned unit + optional integration | | Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | | Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..52d189e --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,27 @@ +# Releasing + +This project publishes the SDK as a NuGet package from version tags. + +## Versioning + +- Use semantic versioning. +- Keep `VersionPrefix` in `src/Termii/Termii.csproj` aligned with the release tag. +- Use preview suffixes for pre-release packages, for example `0.1.0-preview.1`. + +## Release Checklist + +1. Confirm all intended issues for the release are merged. +2. Confirm CI is green on `main`. +3. Update package metadata and README examples if needed. +4. Update `VersionPrefix` in `src/Termii/Termii.csproj`. +5. Create and push a tag like `v0.1.0`. +6. Confirm the release workflow creates the package artifact. +7. Confirm NuGet publishing succeeds when the `NUGET_API_KEY` secret is configured. + +## Required Secrets + +| Secret | Purpose | +| --- | --- | +| `NUGET_API_KEY` | API key used by the release workflow to publish to NuGet.org. | + +The release workflow skips the publish step when `NUGET_API_KEY` is not set, but still builds, tests, packs, and uploads the package artifact. diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj index 4f665f2..1ed4ac4 100644 --- a/src/Termii/Termii.csproj +++ b/src/Termii/Termii.csproj @@ -3,8 +3,10 @@ netstandard2.0;net8.0 Termii Termii - Termii + Termii.SDK + 0.1.0 A .NET SDK for the Termii messaging, token, and insights APIs. + Initial SDK package metadata and release workflow preparation. termii;sms;otp;messaging;dotnet;sdk true $(NoWarn);1591 @@ -12,6 +14,11 @@ + + + + + From 93ae6c728d287921049d98c0119f2bc38b455800 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 22:07:34 +0100 Subject: [PATCH 08/20] Add Termii sender ID API client --- README.md | 6 + docs/API_COVERAGE.md | 4 +- src/Termii/SenderIds/GetSenderIdsRequest.cs | 27 +++++ src/Termii/SenderIds/ITermiiSenderIdClient.cs | 12 ++ .../SenderIds/RequestSenderIdRequest.cs | 22 ++++ .../SenderIds/RequestSenderIdResponse.cs | 12 ++ src/Termii/SenderIds/SenderIdListResponse.cs | 30 +++++ src/Termii/SenderIds/SenderIdRecord.cs | 24 ++++ src/Termii/SenderIds/TermiiSenderIdClient.cs | 42 +++++++ src/Termii/TermiiClient.cs | 3 + .../TermiiClientIntegrationTests.cs | 1 + .../Termii.Tests/TermiiSenderIdClientTests.cs | 110 ++++++++++++++++++ 12 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/Termii/SenderIds/GetSenderIdsRequest.cs create mode 100644 src/Termii/SenderIds/ITermiiSenderIdClient.cs create mode 100644 src/Termii/SenderIds/RequestSenderIdRequest.cs create mode 100644 src/Termii/SenderIds/RequestSenderIdResponse.cs create mode 100644 src/Termii/SenderIds/SenderIdListResponse.cs create mode 100644 src/Termii/SenderIds/SenderIdRecord.cs create mode 100644 src/Termii/SenderIds/TermiiSenderIdClient.cs create mode 100644 tests/Termii.Tests/TermiiSenderIdClientTests.cs diff --git a/README.md b/README.md index 6935849..85ca782 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ var response = await client.Messaging.SendAsync(new SendMessageRequest }); ``` +Fetch sender IDs: + +```csharp +var senderIds = await client.SenderIds.GetAsync(); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index e153d47..6f09efb 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -40,8 +40,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Area | Capability | Method | Path | Auth placement | SDK surface | SDK status | Tracking | Test status | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | -| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Planned | #3 | Planned unit + optional integration | +| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | In progress | #3 | Unit tests added | +| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | In progress | #3 | Unit tests added | | Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | diff --git a/src/Termii/SenderIds/GetSenderIdsRequest.cs b/src/Termii/SenderIds/GetSenderIdsRequest.cs new file mode 100644 index 0000000..6096906 --- /dev/null +++ b/src/Termii/SenderIds/GetSenderIdsRequest.cs @@ -0,0 +1,27 @@ +namespace Termii; + +public sealed class GetSenderIdsRequest +{ + public string? SenderId { get; set; } + + public string? Status { get; set; } + + internal string ToPath() + { + var query = new List(); + + if (!string.IsNullOrWhiteSpace(SenderId)) + { + query.Add($"sender_id={Uri.EscapeDataString(SenderId)}"); + } + + if (!string.IsNullOrWhiteSpace(Status)) + { + query.Add($"status={Uri.EscapeDataString(Status)}"); + } + + return query.Count == 0 + ? "/api/sender-id" + : $"/api/sender-id?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/SenderIds/ITermiiSenderIdClient.cs b/src/Termii/SenderIds/ITermiiSenderIdClient.cs new file mode 100644 index 0000000..c9382ca --- /dev/null +++ b/src/Termii/SenderIds/ITermiiSenderIdClient.cs @@ -0,0 +1,12 @@ +namespace Termii; + +public interface ITermiiSenderIdClient +{ + Task GetAsync( + GetSenderIdsRequest? request = null, + CancellationToken cancellationToken = default); + + Task RequestAsync( + RequestSenderIdRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/SenderIds/RequestSenderIdRequest.cs b/src/Termii/SenderIds/RequestSenderIdRequest.cs new file mode 100644 index 0000000..e1850d3 --- /dev/null +++ b/src/Termii/SenderIds/RequestSenderIdRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class RequestSenderIdRequest +{ + [JsonPropertyName("sender_id")] + public string SenderId { get; set; } = string.Empty; + + [JsonPropertyName("use_case")] + public string UseCase { get; set; } = string.Empty; + + [JsonPropertyName("company")] + public string Company { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(SenderId, nameof(SenderId)); + TermiiRequestValidation.Required(UseCase, nameof(UseCase)); + TermiiRequestValidation.Required(Company, nameof(Company)); + } +} diff --git a/src/Termii/SenderIds/RequestSenderIdResponse.cs b/src/Termii/SenderIds/RequestSenderIdResponse.cs new file mode 100644 index 0000000..2f2c056 --- /dev/null +++ b/src/Termii/SenderIds/RequestSenderIdResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class RequestSenderIdResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/src/Termii/SenderIds/SenderIdListResponse.cs b/src/Termii/SenderIds/SenderIdListResponse.cs new file mode 100644 index 0000000..e14922e --- /dev/null +++ b/src/Termii/SenderIds/SenderIdListResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SenderIdListResponse +{ + [JsonPropertyName("content")] + public IReadOnlyCollection Content { get; set; } = Array.Empty(); + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("first")] + public bool? First { get; set; } + + [JsonPropertyName("last")] + public bool? Last { get; set; } + + [JsonPropertyName("empty")] + public bool? Empty { get; set; } +} diff --git a/src/Termii/SenderIds/SenderIdRecord.cs b/src/Termii/SenderIds/SenderIdRecord.cs new file mode 100644 index 0000000..746bb2c --- /dev/null +++ b/src/Termii/SenderIds/SenderIdRecord.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SenderIdRecord +{ + [JsonPropertyName("sender_id")] + public string? SenderId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("company")] + public string? Company { get; set; } + + [JsonPropertyName("usecase")] + public string? UseCase { get; set; } + + [JsonPropertyName("createdAt")] + public string? CreatedAt { get; set; } +} diff --git a/src/Termii/SenderIds/TermiiSenderIdClient.cs b/src/Termii/SenderIds/TermiiSenderIdClient.cs new file mode 100644 index 0000000..0affe28 --- /dev/null +++ b/src/Termii/SenderIds/TermiiSenderIdClient.cs @@ -0,0 +1,42 @@ +namespace Termii; + +internal sealed class TermiiSenderIdClient : ITermiiSenderIdClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiSenderIdClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetAsync( + GetSenderIdsRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetSenderIdsRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task RequestAsync( + RequestSenderIdRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sender-id/request", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index a57e19f..f546976 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -35,12 +35,15 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp _pipeline = new TermiiJsonHttpPipeline(_httpClient, Options); Messaging = new TermiiMessagingClient(_pipeline); + SenderIds = new TermiiSenderIdClient(_pipeline); } public TermiiOptions Options { get; } public ITermiiMessagingClient Messaging { get; } + public ITermiiSenderIdClient SenderIds { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 7a7fcd6..1d1065c 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -18,5 +18,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.False(string.IsNullOrWhiteSpace(client.Options.ApiKey)); Assert.True(client.Options.BaseUrl.IsAbsoluteUri); Assert.NotNull(client.Messaging); + Assert.NotNull(client.SenderIds); } } diff --git a/tests/Termii.Tests/TermiiSenderIdClientTests.cs b/tests/Termii.Tests/TermiiSenderIdClientTests.cs new file mode 100644 index 0000000..6fdeb27 --- /dev/null +++ b/tests/Termii.Tests/TermiiSenderIdClientTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiSenderIdClientTests +{ + [Fact] + public async Task GetAsyncAddsApiKeyAndDeserializesSenderIds() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "sender_id": "Termii", + "status": "active", + "country": "NG", + "company": "Teesoft Tech", + "usecase": "Transactional alerts", + "createdAt": "2026-06-13 10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0, + "first": true, + "last": true, + "empty": false + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.SenderIds.GetAsync( + new GetSenderIdsRequest + { + SenderId = "Termii", + Status = "active", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var senderId = Assert.Single(response.Content); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/sender-id?sender_id=Termii&status=active&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(1, response.TotalElements); + Assert.Equal("Termii", senderId.SenderId); + Assert.Equal("active", senderId.Status); + Assert.Equal("NG", senderId.Country); + Assert.Equal("Teesoft Tech", senderId.Company); + Assert.Equal("Transactional alerts", senderId.UseCase); + Assert.Equal("2026-06-13 10:00:00", senderId.CreatedAt); + } + + [Fact] + public async Task RequestAsyncPostsRequestBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Sender ID request received"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.SenderIds.RequestAsync( + new RequestSenderIdRequest + { + SenderId = "Termii", + UseCase = "Transactional alerts", + Company = "Teesoft Tech", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sender-id/request", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("sender_id").GetString()); + Assert.Equal("Transactional alerts", body.RootElement.GetProperty("use_case").GetString()); + Assert.Equal("Teesoft Tech", body.RootElement.GetProperty("company").GetString()); + Assert.Equal("ok", response.Code); + Assert.Equal("Sender ID request received", response.Message); + } + + [Fact] + public async Task RequestAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.SenderIds.RequestAsync( + new RequestSenderIdRequest + { + SenderId = "", + UseCase = "Transactional alerts", + Company = "Teesoft Tech", + }, + CancellationToken.None)); + } +} From 0dd8544954783e388d422f41ebf941929cacc09a Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 22:23:14 +0100 Subject: [PATCH 09/20] Add Termii Number API client --- README.md | 18 +++++ docs/API_COVERAGE.md | 8 +-- examples/Termii.Examples/Program.cs | 20 ++++++ src/Termii/Numbers/ITermiiNumberClient.cs | 8 +++ .../Numbers/SendNumberMessageRequest.cs | 18 +++++ src/Termii/Numbers/TermiiNumberClient.cs | 30 ++++++++ src/Termii/TermiiClient.cs | 3 + .../TermiiClientIntegrationTests.cs | 1 + tests/Termii.Tests/TermiiNumberClientTests.cs | 68 +++++++++++++++++++ 9 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 src/Termii/Numbers/ITermiiNumberClient.cs create mode 100644 src/Termii/Numbers/SendNumberMessageRequest.cs create mode 100644 src/Termii/Numbers/TermiiNumberClient.cs create mode 100644 tests/Termii.Tests/TermiiNumberClientTests.cs diff --git a/README.md b/README.md index 85ca782..229defc 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,16 @@ Fetch sender IDs: var senderIds = await client.SenderIds.GetAsync(); ``` +Send through the Number API: + +```csharp +var numberMessage = await client.Numbers.SendAsync(new SendNumberMessageRequest +{ + To = "2348012345678", + Sms = "Hello from a dedicated Termii number" +}); +``` + ## Examples Run the examples project after setting your Termii API key: @@ -71,6 +81,14 @@ export TERMII_API_KEY="your-termii-api-key" dotnet run --project examples/Termii.Examples ``` +To send a live Number API example message, opt in explicitly: + +```bash +export TERMII_SEND_NUMBER_MESSAGE="true" +export TERMII_EXAMPLE_PHONE_NUMBER="2348012345678" +dotnet run --project examples/Termii.Examples +``` + ## Tests Run the local test suite: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 6f09efb..0d4055a 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -33,19 +33,19 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Reusable fake HTTP test helpers and opt-in live test conventions | Implemented | #10, PR #20 | | README endpoint examples | Planned | #12 | | CI build/test/package validation | Implemented | #11, PR #22 | -| NuGet package metadata and publishing workflow | In progress | #8 | +| NuGet package metadata and publishing workflow | Implemented | #8, PR #23 | | First GitHub Release and NuGet publish | Planned | #13 | ## Endpoint Coverage | Area | Capability | Method | Path | Auth placement | SDK surface | SDK status | Tracking | Test status | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | In progress | #3 | Unit tests added | -| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | In progress | #3 | Unit tests added | +| Messaging | Fetch sender IDs | GET | `/api/sender-id` | Query | `TermiiClient.SenderIds` | Implemented | #3, PR #24 | Unit tests added | +| Messaging | Request sender ID | POST | `/api/sender-id/request` | JSON body | `TermiiClient.SenderIds` | Implemented | #3, PR #24 | Unit tests added | | Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | -| Messaging | Send via Number API | POST | `/api/sms/number/send` | Query or JSON body, docs/examples vary | `TermiiClient.Messaging` or `TermiiClient.Numbers` | Planned | #5 | Planned unit + optional integration | +| Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | In progress | #5 | Unit tests added | | Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | | Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | | Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | diff --git a/examples/Termii.Examples/Program.cs b/examples/Termii.Examples/Program.cs index 31b4108..a5e551c 100644 --- a/examples/Termii.Examples/Program.cs +++ b/examples/Termii.Examples/Program.cs @@ -14,3 +14,23 @@ }); Console.WriteLine($"Termii client configured for {client.Options.BaseUrl}."); + +var phoneNumber = Environment.GetEnvironmentVariable("TERMII_EXAMPLE_PHONE_NUMBER"); +var sendNumberMessage = string.Equals( + Environment.GetEnvironmentVariable("TERMII_SEND_NUMBER_MESSAGE"), + "true", + StringComparison.OrdinalIgnoreCase); + +if (!sendNumberMessage || string.IsNullOrWhiteSpace(phoneNumber)) +{ + Console.WriteLine("Set TERMII_SEND_NUMBER_MESSAGE=true and TERMII_EXAMPLE_PHONE_NUMBER to send a Number API message."); + return; +} + +var response = await client.Numbers.SendAsync(new SendNumberMessageRequest +{ + To = phoneNumber, + Sms = "Hello from Termii.SDK", +}); + +Console.WriteLine($"Number API response: {response.Message ?? response.Code ?? "sent"}"); diff --git a/src/Termii/Numbers/ITermiiNumberClient.cs b/src/Termii/Numbers/ITermiiNumberClient.cs new file mode 100644 index 0000000..6ee5b19 --- /dev/null +++ b/src/Termii/Numbers/ITermiiNumberClient.cs @@ -0,0 +1,8 @@ +namespace Termii; + +public interface ITermiiNumberClient +{ + Task SendAsync( + SendNumberMessageRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Numbers/SendNumberMessageRequest.cs b/src/Termii/Numbers/SendNumberMessageRequest.cs new file mode 100644 index 0000000..3c8bc07 --- /dev/null +++ b/src/Termii/Numbers/SendNumberMessageRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendNumberMessageRequest +{ + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Numbers/TermiiNumberClient.cs b/src/Termii/Numbers/TermiiNumberClient.cs new file mode 100644 index 0000000..90bc525 --- /dev/null +++ b/src/Termii/Numbers/TermiiNumberClient.cs @@ -0,0 +1,30 @@ +namespace Termii; + +internal sealed class TermiiNumberClient : ITermiiNumberClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiNumberClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendAsync( + SendNumberMessageRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/number/send", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index f546976..a919b83 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -36,6 +36,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp _pipeline = new TermiiJsonHttpPipeline(_httpClient, Options); Messaging = new TermiiMessagingClient(_pipeline); SenderIds = new TermiiSenderIdClient(_pipeline); + Numbers = new TermiiNumberClient(_pipeline); } public TermiiOptions Options { get; } @@ -44,6 +45,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiSenderIdClient SenderIds { get; } + public ITermiiNumberClient Numbers { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 1d1065c..70b64f3 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -19,5 +19,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.True(client.Options.BaseUrl.IsAbsoluteUri); Assert.NotNull(client.Messaging); Assert.NotNull(client.SenderIds); + Assert.NotNull(client.Numbers); } } diff --git a/tests/Termii.Tests/TermiiNumberClientTests.cs b/tests/Termii.Tests/TermiiNumberClientTests.cs new file mode 100644 index 0000000..ebdb952 --- /dev/null +++ b/tests/Termii.Tests/TermiiNumberClientTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiNumberClientTests +{ + [Fact] + public async Task SendAsyncPostsNumberMessageBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "code": "ok", + "balance": "38.75", + "message_id": "number-12345", + "message": "Successfully Sent", + "user": "termii-user", + "message_id_str": "number-12345" + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Numbers.SendAsync( + new SendNumberMessageRequest + { + To = "2348012345678", + Sms = "Hello from the Number API", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/number/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString()); + Assert.Equal("Hello from the Number API", body.RootElement.GetProperty("sms").GetString()); + Assert.False(body.RootElement.TryGetProperty("from", out _)); + + Assert.Equal("ok", response.Code); + Assert.Equal(38.75m, response.Balance); + Assert.Equal("number-12345", response.MessageId); + Assert.Equal("Successfully Sent", response.Message); + Assert.Equal("termii-user", response.User); + Assert.Equal("number-12345", response.MessageIdString); + } + + [Fact] + public async Task SendAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Numbers.SendAsync( + new SendNumberMessageRequest + { + To = "2348012345678", + Sms = "", + }, + CancellationToken.None)); + } +} From a2e8e89fcb375569df97e89de0eacc1d7a6ac5df Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 22:31:34 +0100 Subject: [PATCH 10/20] Add Termii Token API client --- README.md | 16 ++ docs/API_COVERAGE.md | 15 +- src/Termii/TermiiClient.cs | 3 + src/Termii/TermiiRequestValidation.cs | 19 ++ src/Termii/Tokens/GenerateTokenRequest.cs | 29 +++ src/Termii/Tokens/GenerateTokenResponse.cs | 18 ++ src/Termii/Tokens/ITermiiTokenClient.cs | 32 +++ src/Termii/Tokens/SendEmailTokenRequest.cs | 22 ++ src/Termii/Tokens/SendTokenRequest.cs | 44 ++++ src/Termii/Tokens/SendTokenResponse.cs | 27 ++ src/Termii/Tokens/SendVoiceTokenRequest.cs | 26 ++ src/Termii/Tokens/SendWhatsAppTokenRequest.cs | 25 ++ src/Termii/Tokens/TermiiTokenClient.cs | 246 ++++++++++++++++++ .../Tokens/TermiiTokenEnumExtensions.cs | 14 + src/Termii/Tokens/TermiiTokenPinType.cs | 7 + src/Termii/Tokens/VerifyTokenRequest.cs | 18 ++ src/Termii/Tokens/VerifyTokenResponse.cs | 15 ++ src/Termii/Tokens/VoiceCallTokenRequest.cs | 18 ++ .../TermiiClientIntegrationTests.cs | 1 + tests/Termii.Tests/TermiiTokenClientTests.cs | 231 ++++++++++++++++ 20 files changed, 819 insertions(+), 7 deletions(-) create mode 100644 src/Termii/Tokens/GenerateTokenRequest.cs create mode 100644 src/Termii/Tokens/GenerateTokenResponse.cs create mode 100644 src/Termii/Tokens/ITermiiTokenClient.cs create mode 100644 src/Termii/Tokens/SendEmailTokenRequest.cs create mode 100644 src/Termii/Tokens/SendTokenRequest.cs create mode 100644 src/Termii/Tokens/SendTokenResponse.cs create mode 100644 src/Termii/Tokens/SendVoiceTokenRequest.cs create mode 100644 src/Termii/Tokens/SendWhatsAppTokenRequest.cs create mode 100644 src/Termii/Tokens/TermiiTokenClient.cs create mode 100644 src/Termii/Tokens/TermiiTokenEnumExtensions.cs create mode 100644 src/Termii/Tokens/TermiiTokenPinType.cs create mode 100644 src/Termii/Tokens/VerifyTokenRequest.cs create mode 100644 src/Termii/Tokens/VerifyTokenResponse.cs create mode 100644 src/Termii/Tokens/VoiceCallTokenRequest.cs create mode 100644 tests/Termii.Tests/TermiiTokenClientTests.cs diff --git a/README.md b/README.md index 229defc..e425e66 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,22 @@ var numberMessage = await client.Numbers.SendAsync(new SendNumberMessageRequest }); ``` +Send an OTP token: + +```csharp +var token = await client.Tokens.SendAsync(new SendTokenRequest +{ + To = "2348012345678", + From = "Termii", + Channel = TermiiMessageChannel.Generic, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + PinPlaceholder = "< 123456 >", + MessageText = "Your verification code is < 123456 >" +}); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 0d4055a..f8765c4 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -45,13 +45,14 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | -| Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | In progress | #5 | Unit tests added | -| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Send voice OTP/call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | -| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Token` | Planned | #4 | Planned unit + optional integration | +| Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added | +| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | +| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | +| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | +| Token | Send voice OTP | POST | `/api/sms/otp/send/voice` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | +| Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | +| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | +| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | | Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | | Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | | Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index a919b83..fbd11b4 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -37,6 +37,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp Messaging = new TermiiMessagingClient(_pipeline); SenderIds = new TermiiSenderIdClient(_pipeline); Numbers = new TermiiNumberClient(_pipeline); + Tokens = new TermiiTokenClient(_pipeline); } public TermiiOptions Options { get; } @@ -47,6 +48,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiNumberClient Numbers { get; } + public ITermiiTokenClient Tokens { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/src/Termii/TermiiRequestValidation.cs b/src/Termii/TermiiRequestValidation.cs index 20ea8c1..992a603 100644 --- a/src/Termii/TermiiRequestValidation.cs +++ b/src/Termii/TermiiRequestValidation.cs @@ -9,4 +9,23 @@ public static void Required(string? value, string parameterName) throw new ArgumentException($"{parameterName} is required.", parameterName); } } + + public static void Positive(int value, string parameterName) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(parameterName, value, $"{parameterName} must be greater than zero."); + } + } + + public static void Range(int value, int minimum, int maximum, string parameterName) + { + if (value < minimum || value > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + $"{parameterName} must be between {minimum} and {maximum}."); + } + } } diff --git a/src/Termii/Tokens/GenerateTokenRequest.cs b/src/Termii/Tokens/GenerateTokenRequest.cs new file mode 100644 index 0000000..944d6f2 --- /dev/null +++ b/src/Termii/Tokens/GenerateTokenRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GenerateTokenRequest +{ + [JsonIgnore] + public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric; + + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/GenerateTokenResponse.cs b/src/Termii/Tokens/GenerateTokenResponse.cs new file mode 100644 index 0000000..8298cd2 --- /dev/null +++ b/src/Termii/Tokens/GenerateTokenResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GenerateTokenResponse +{ + [JsonPropertyName("phone_number_other")] + public string? PhoneNumberOther { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("otp")] + public string? Otp { get; set; } + + [JsonPropertyName("pin_id")] + public string? PinId { get; set; } +} diff --git a/src/Termii/Tokens/ITermiiTokenClient.cs b/src/Termii/Tokens/ITermiiTokenClient.cs new file mode 100644 index 0000000..b072182 --- /dev/null +++ b/src/Termii/Tokens/ITermiiTokenClient.cs @@ -0,0 +1,32 @@ +namespace Termii; + +public interface ITermiiTokenClient +{ + Task SendAsync( + SendTokenRequest request, + CancellationToken cancellationToken = default); + + Task VerifyAsync( + VerifyTokenRequest request, + CancellationToken cancellationToken = default); + + Task GenerateAsync( + GenerateTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendVoiceAsync( + SendVoiceTokenRequest request, + CancellationToken cancellationToken = default); + + Task CallAsync( + VoiceCallTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendEmailAsync( + SendEmailTokenRequest request, + CancellationToken cancellationToken = default); + + Task SendWhatsAppAsync( + SendWhatsAppTokenRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Tokens/SendEmailTokenRequest.cs b/src/Termii/Tokens/SendEmailTokenRequest.cs new file mode 100644 index 0000000..eb538ad --- /dev/null +++ b/src/Termii/Tokens/SendEmailTokenRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendEmailTokenRequest +{ + [JsonPropertyName("email_address")] + public string EmailAddress { get; set; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("email_configuration_id")] + public string EmailConfigurationId { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress)); + TermiiRequestValidation.Required(Code, nameof(Code)); + TermiiRequestValidation.Required(EmailConfigurationId, nameof(EmailConfigurationId)); + } +} diff --git a/src/Termii/Tokens/SendTokenRequest.cs b/src/Termii/Tokens/SendTokenRequest.cs new file mode 100644 index 0000000..4c23176 --- /dev/null +++ b/src/Termii/Tokens/SendTokenRequest.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendTokenRequest +{ + [JsonIgnore] + public TermiiTokenPinType PinType { get; set; } = TermiiTokenPinType.Numeric; + + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageChannel Channel { get; set; } = TermiiMessageChannel.Generic; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + [JsonPropertyName("pin_placeholder")] + public string PinPlaceholder { get; set; } = string.Empty; + + [JsonPropertyName("message_text")] + public string MessageText { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(PinPlaceholder, nameof(PinPlaceholder)); + TermiiRequestValidation.Required(MessageText, nameof(MessageText)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/SendTokenResponse.cs b/src/Termii/Tokens/SendTokenResponse.cs new file mode 100644 index 0000000..da457f0 --- /dev/null +++ b/src/Termii/Tokens/SendTokenResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendTokenResponse +{ + [JsonPropertyName("smsStatus")] + public string? SmsStatus { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("to")] + public string? To { get; set; } + + [JsonPropertyName("pinId")] + public string? PinId { get; set; } + + [JsonPropertyName("pin_id")] + public string? PinIdSnakeCase { get; set; } + + [JsonPropertyName("message_id_str")] + public string? MessageIdString { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } +} diff --git a/src/Termii/Tokens/SendVoiceTokenRequest.cs b/src/Termii/Tokens/SendVoiceTokenRequest.cs new file mode 100644 index 0000000..e3f32ee --- /dev/null +++ b/src/Termii/Tokens/SendVoiceTokenRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendVoiceTokenRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; set; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; set; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Positive(PinAttempts, nameof(PinAttempts)); + TermiiRequestValidation.Range(PinTimeToLive, 0, 60, nameof(PinTimeToLive)); + TermiiRequestValidation.Range(PinLength, 4, 8, nameof(PinLength)); + } +} diff --git a/src/Termii/Tokens/SendWhatsAppTokenRequest.cs b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs new file mode 100644 index 0000000..777bb6a --- /dev/null +++ b/src/Termii/Tokens/SendWhatsAppTokenRequest.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendWhatsAppTokenRequest +{ + [JsonPropertyName("to")] + public string To { get; set; } = string.Empty; + + [JsonPropertyName("from")] + public string From { get; set; } = string.Empty; + + [JsonPropertyName("sms")] + public string Sms { get; set; } = string.Empty; + + [JsonIgnore] + public TermiiMessageType Type { get; set; } = TermiiMessageType.Plain; + + internal void Validate() + { + TermiiRequestValidation.Required(To, nameof(To)); + TermiiRequestValidation.Required(From, nameof(From)); + TermiiRequestValidation.Required(Sms, nameof(Sms)); + } +} diff --git a/src/Termii/Tokens/TermiiTokenClient.cs b/src/Termii/Tokens/TermiiTokenClient.cs new file mode 100644 index 0000000..e0a92da --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenClient.cs @@ -0,0 +1,246 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +internal sealed class TermiiTokenClient : ITermiiTokenClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiTokenClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendAsync( + SendTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/send", + new SendTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task VerifyAsync( + VerifyTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/verify", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task GenerateAsync( + GenerateTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/generate", + new GenerateTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendVoiceAsync( + SendVoiceTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/send/voice", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task CallAsync( + VoiceCallTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/otp/call", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendEmailAsync( + SendEmailTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/email/otp/send", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendWhatsAppAsync( + SendWhatsAppTokenRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/send", + new SendWhatsAppTokenPayload(request), + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + private sealed class SendTokenPayload + { + public SendTokenPayload(SendTokenRequest request) + { + MessageType = request.PinType.ToWireValue(); + PinType = request.PinType.ToWireValue(); + To = request.To; + From = request.From; + Channel = request.Channel.ToWireValue(); + PinAttempts = request.PinAttempts; + PinTimeToLive = request.PinTimeToLive; + PinLength = request.PinLength; + PinPlaceholder = request.PinPlaceholder; + MessageText = request.MessageText; + } + + [JsonPropertyName("message_type")] + public string MessageType { get; } + + [JsonPropertyName("pin_type")] + public string PinType { get; } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("channel")] + public string Channel { get; } + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; } + + [JsonPropertyName("pin_placeholder")] + public string PinPlaceholder { get; } + + [JsonPropertyName("message_text")] + public string MessageText { get; } + } + + private sealed class GenerateTokenPayload + { + public GenerateTokenPayload(GenerateTokenRequest request) + { + PinType = request.PinType.ToWireValue(); + PhoneNumber = request.PhoneNumber; + PinAttempts = request.PinAttempts; + PinTimeToLive = request.PinTimeToLive; + PinLength = request.PinLength; + } + + [JsonPropertyName("pin_type")] + public string PinType { get; } + + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; } + + [JsonPropertyName("pin_attempts")] + public int PinAttempts { get; } + + [JsonPropertyName("pin_time_to_live")] + public int PinTimeToLive { get; } + + [JsonPropertyName("pin_length")] + public int PinLength { get; } + } + + private sealed class SendWhatsAppTokenPayload + { + public SendWhatsAppTokenPayload(SendWhatsAppTokenRequest request) + { + To = request.To; + From = request.From; + Sms = request.Sms; + Type = request.Type.ToWireValue(); + } + + [JsonPropertyName("to")] + public string To { get; } + + [JsonPropertyName("from")] + public string From { get; } + + [JsonPropertyName("sms")] + public string Sms { get; } + + [JsonPropertyName("type")] + public string Type { get; } + + [JsonPropertyName("channel")] + public string Channel => "whatsapp_otp"; + } +} diff --git a/src/Termii/Tokens/TermiiTokenEnumExtensions.cs b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs new file mode 100644 index 0000000..aed901a --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenEnumExtensions.cs @@ -0,0 +1,14 @@ +namespace Termii; + +internal static class TermiiTokenEnumExtensions +{ + public static string ToWireValue(this TermiiTokenPinType pinType) + { + return pinType switch + { + TermiiTokenPinType.Numeric => "NUMERIC", + TermiiTokenPinType.Alphanumeric => "ALPHANUMERIC", + _ => throw new ArgumentOutOfRangeException(nameof(pinType), pinType, "Unsupported Termii token PIN type."), + }; + } +} diff --git a/src/Termii/Tokens/TermiiTokenPinType.cs b/src/Termii/Tokens/TermiiTokenPinType.cs new file mode 100644 index 0000000..fb0dc87 --- /dev/null +++ b/src/Termii/Tokens/TermiiTokenPinType.cs @@ -0,0 +1,7 @@ +namespace Termii; + +public enum TermiiTokenPinType +{ + Numeric, + Alphanumeric, +} diff --git a/src/Termii/Tokens/VerifyTokenRequest.cs b/src/Termii/Tokens/VerifyTokenRequest.cs new file mode 100644 index 0000000..2798c54 --- /dev/null +++ b/src/Termii/Tokens/VerifyTokenRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VerifyTokenRequest +{ + [JsonPropertyName("pin_id")] + public string PinId { get; set; } = string.Empty; + + [JsonPropertyName("pin")] + public string Pin { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(PinId, nameof(PinId)); + TermiiRequestValidation.Required(Pin, nameof(Pin)); + } +} diff --git a/src/Termii/Tokens/VerifyTokenResponse.cs b/src/Termii/Tokens/VerifyTokenResponse.cs new file mode 100644 index 0000000..16b5ad7 --- /dev/null +++ b/src/Termii/Tokens/VerifyTokenResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VerifyTokenResponse +{ + [JsonPropertyName("pinId")] + public string? PinId { get; set; } + + [JsonPropertyName("verified")] + public string? Verified { get; set; } + + [JsonPropertyName("msisdn")] + public string? Msisdn { get; set; } +} diff --git a/src/Termii/Tokens/VoiceCallTokenRequest.cs b/src/Termii/Tokens/VoiceCallTokenRequest.cs new file mode 100644 index 0000000..2d3324b --- /dev/null +++ b/src/Termii/Tokens/VoiceCallTokenRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class VoiceCallTokenRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + internal void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Required(Code, nameof(Code)); + } +} diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 70b64f3..30c163c 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -20,5 +20,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.Messaging); Assert.NotNull(client.SenderIds); Assert.NotNull(client.Numbers); + Assert.NotNull(client.Tokens); } } diff --git a/tests/Termii.Tests/TermiiTokenClientTests.cs b/tests/Termii.Tests/TermiiTokenClientTests.cs new file mode 100644 index 0000000..56c2229 --- /dev/null +++ b/tests/Termii.Tests/TermiiTokenClientTests.cs @@ -0,0 +1,231 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiTokenClientTests +{ + [Fact] + public async Task SendAsyncPostsTokenBodyAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"smsStatus":"Message Sent","to":"2348012345678","pinId":"pin-123","message_id_str":"msg-123","status":"success"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.SendAsync( + new SendTokenRequest + { + To = "2348012345678", + From = "Termii", + Channel = TermiiMessageChannel.Dnd, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + PinPlaceholder = "< 1234 >", + MessageText = "Your pin is < 1234 >", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/otp/send", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("NUMERIC", body.RootElement.GetProperty("message_type").GetString()); + Assert.Equal("NUMERIC", body.RootElement.GetProperty("pin_type").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("to").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("dnd", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal(3, body.RootElement.GetProperty("pin_attempts").GetInt32()); + Assert.Equal(10, body.RootElement.GetProperty("pin_time_to_live").GetInt32()); + Assert.Equal(6, body.RootElement.GetProperty("pin_length").GetInt32()); + Assert.Equal("< 1234 >", body.RootElement.GetProperty("pin_placeholder").GetString()); + Assert.Equal("Your pin is < 1234 >", body.RootElement.GetProperty("message_text").GetString()); + + Assert.Equal("Message Sent", response.SmsStatus); + Assert.Equal("pin-123", response.PinId); + Assert.Equal("msg-123", response.MessageIdString); + Assert.Equal("success", response.Status); + } + + [Fact] + public async Task VerifyAsyncPostsPinDetails() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"pinId":"pin-123","verified":"true","msisdn":"2348012345678"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.VerifyAsync( + new VerifyTokenRequest + { + PinId = "pin-123", + Pin = "123456", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/verify", request.RequestUri!.ToString()); + Assert.Equal("pin-123", body.RootElement.GetProperty("pin_id").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("pin").GetString()); + Assert.Equal("true", response.Verified); + Assert.Equal("2348012345678", response.Msisdn); + } + + [Fact] + public async Task GenerateAsyncPostsInAppTokenBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"phone_number":"2348012345678","otp":"123456","pin_id":"pin-123"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Tokens.GenerateAsync( + new GenerateTokenRequest + { + PinType = TermiiTokenPinType.Alphanumeric, + PhoneNumber = "2348012345678", + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/generate", request.RequestUri!.ToString()); + Assert.Equal("ALPHANUMERIC", body.RootElement.GetProperty("pin_type").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("123456", response.Otp); + Assert.Equal("pin-123", response.PinId); + } + + [Fact] + public async Task SendVoiceAsyncPostsVoiceTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendVoiceAsync( + new SendVoiceTokenRequest + { + PhoneNumber = "2348012345678", + PinAttempts = 2, + PinTimeToLive = 5, + PinLength = 4, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/send/voice", request.RequestUri!.ToString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal(2, body.RootElement.GetProperty("pin_attempts").GetInt32()); + } + + [Fact] + public async Task CallAsyncPostsVoiceCallTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.CallAsync( + new VoiceCallTokenRequest + { + PhoneNumber = "2348012345678", + Code = "123456", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/otp/call", request.RequestUri!.ToString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("code").GetString()); + } + + [Fact] + public async Task SendEmailAsyncPostsEmailTokenBody() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendEmailAsync( + new SendEmailTokenRequest + { + EmailAddress = "person@example.com", + Code = "123456", + EmailConfigurationId = "email-config", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/email/otp/send", request.RequestUri!.ToString()); + Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString()); + Assert.Equal("123456", body.RootElement.GetProperty("code").GetString()); + Assert.Equal("email-config", body.RootElement.GetProperty("email_configuration_id").GetString()); + } + + [Fact] + public async Task SendWhatsAppAsyncPostsWhatsAppOtpMessage() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await client.Tokens.SendWhatsAppAsync( + new SendWhatsAppTokenRequest + { + To = "2348012345678", + From = "Termii", + Sms = "Your code is 123456", + Type = TermiiMessageType.Unicode, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal("https://example.test/api/sms/send", request.RequestUri!.ToString()); + Assert.Equal("whatsapp_otp", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("unicode", body.RootElement.GetProperty("type").GetString()); + Assert.Equal("Your code is 123456", body.RootElement.GetProperty("sms").GetString()); + } + + [Fact] + public async Task SendAsyncRejectsInvalidPinLength() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Tokens.SendAsync( + new SendTokenRequest + { + To = "2348012345678", + From = "Termii", + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 3, + PinPlaceholder = "< 1234 >", + MessageText = "Your pin is < 1234 >", + }, + CancellationToken.None)); + } +} From 5126ad324ff618879d45cef963603617214e1952 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sat, 13 Jun 2026 22:38:08 +0100 Subject: [PATCH 11/20] Add Termii Insights API client --- README.md | 6 + docs/API_COVERAGE.md | 22 +-- src/Termii/Insights/CheckDndRequest.cs | 16 ++ src/Termii/Insights/CheckDndResponse.cs | 25 +++ src/Termii/Insights/GetBalanceResponse.cs | 19 +++ .../Insights/GetMessageHistoryRequest.cs | 35 ++++ src/Termii/Insights/ITermiiInsightsClient.cs | 18 ++ src/Termii/Insights/MessageHistoryRecord.cs | 43 +++++ src/Termii/Insights/MessageHistoryResponse.cs | 34 ++++ src/Termii/Insights/QueryNumberRequest.cs | 19 +++ src/Termii/Insights/QueryNumberResponse.cs | 37 ++++ src/Termii/Insights/TermiiInsightsClient.cs | 67 ++++++++ .../Insights/TermiiInsightsQueryString.cs | 27 +++ src/Termii/TermiiClient.cs | 3 + .../TermiiClientIntegrationTests.cs | 1 + .../Termii.Tests/TermiiInsightsClientTests.cs | 158 ++++++++++++++++++ 16 files changed, 519 insertions(+), 11 deletions(-) create mode 100644 src/Termii/Insights/CheckDndRequest.cs create mode 100644 src/Termii/Insights/CheckDndResponse.cs create mode 100644 src/Termii/Insights/GetBalanceResponse.cs create mode 100644 src/Termii/Insights/GetMessageHistoryRequest.cs create mode 100644 src/Termii/Insights/ITermiiInsightsClient.cs create mode 100644 src/Termii/Insights/MessageHistoryRecord.cs create mode 100644 src/Termii/Insights/MessageHistoryResponse.cs create mode 100644 src/Termii/Insights/QueryNumberRequest.cs create mode 100644 src/Termii/Insights/QueryNumberResponse.cs create mode 100644 src/Termii/Insights/TermiiInsightsClient.cs create mode 100644 src/Termii/Insights/TermiiInsightsQueryString.cs create mode 100644 tests/Termii.Tests/TermiiInsightsClientTests.cs diff --git a/README.md b/README.md index e425e66..fa5e987 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ var token = await client.Tokens.SendAsync(new SendTokenRequest }); ``` +Check account balance: + +```csharp +var balance = await client.Insights.GetBalanceAsync(); +``` + ## Examples Run the examples project after setting your Termii API key: diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index f8765c4..2b17c02 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -46,17 +46,17 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added | -| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send voice OTP | POST | `/api/sms/otp/send/voice` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | In progress | #4 | Unit tests added | -| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | -| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | -| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | -| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Planned | #9 | Planned unit + optional integration | +| Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Generate in-app OTP token | POST | `/api/sms/otp/generate` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send voice OTP | POST | `/api/sms/otp/send/voice` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | +| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | +| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | +| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | +| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | | Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Needs verification | #9 | Planned unit + optional integration | ## Deferred Coverage diff --git a/src/Termii/Insights/CheckDndRequest.cs b/src/Termii/Insights/CheckDndRequest.cs new file mode 100644 index 0000000..6583920 --- /dev/null +++ b/src/Termii/Insights/CheckDndRequest.cs @@ -0,0 +1,16 @@ +namespace Termii; + +public sealed class CheckDndRequest +{ + public string PhoneNumber { get; set; } = string.Empty; + + internal string ToPath() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + + return TermiiInsightsQueryString.Append("/api/check/dnd", query); + } +} diff --git a/src/Termii/Insights/CheckDndResponse.cs b/src/Termii/Insights/CheckDndResponse.cs new file mode 100644 index 0000000..ad683b6 --- /dev/null +++ b/src/Termii/Insights/CheckDndResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CheckDndResponse +{ + [JsonPropertyName("number")] + public string? Number { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("dnd_active")] + public bool? DndActive { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/GetBalanceResponse.cs b/src/Termii/Insights/GetBalanceResponse.cs new file mode 100644 index 0000000..590a5e0 --- /dev/null +++ b/src/Termii/Insights/GetBalanceResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class GetBalanceResponse +{ + [JsonPropertyName("user")] + public string? User { get; set; } + + [JsonPropertyName("balance")] + public decimal? Balance { get; set; } + + [JsonPropertyName("currency")] + public string? Currency { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/GetMessageHistoryRequest.cs b/src/Termii/Insights/GetMessageHistoryRequest.cs new file mode 100644 index 0000000..7ad4778 --- /dev/null +++ b/src/Termii/Insights/GetMessageHistoryRequest.cs @@ -0,0 +1,35 @@ +namespace Termii; + +public sealed class GetMessageHistoryRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + public string? Sender { get; set; } + + public string? Receiver { get; set; } + + public string? MessageId { get; set; } + + public string? Status { get; set; } + + public string? FromDate { get; set; } + + public string? ToDate { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "page", Page); + TermiiInsightsQueryString.AddIfPresent(query, "size", Size); + TermiiInsightsQueryString.AddIfPresent(query, "sender", Sender); + TermiiInsightsQueryString.AddIfPresent(query, "receiver", Receiver); + TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId); + TermiiInsightsQueryString.AddIfPresent(query, "status", Status); + TermiiInsightsQueryString.AddIfPresent(query, "from", FromDate); + TermiiInsightsQueryString.AddIfPresent(query, "to", ToDate); + + return TermiiInsightsQueryString.Append("/api/sms/inbox", query); + } +} diff --git a/src/Termii/Insights/ITermiiInsightsClient.cs b/src/Termii/Insights/ITermiiInsightsClient.cs new file mode 100644 index 0000000..fe99310 --- /dev/null +++ b/src/Termii/Insights/ITermiiInsightsClient.cs @@ -0,0 +1,18 @@ +namespace Termii; + +public interface ITermiiInsightsClient +{ + Task GetBalanceAsync(CancellationToken cancellationToken = default); + + Task CheckDndAsync( + CheckDndRequest request, + CancellationToken cancellationToken = default); + + Task QueryNumberAsync( + QueryNumberRequest request, + CancellationToken cancellationToken = default); + + Task GetMessageHistoryAsync( + GetMessageHistoryRequest? request = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Insights/MessageHistoryRecord.cs b/src/Termii/Insights/MessageHistoryRecord.cs new file mode 100644 index 0000000..878abec --- /dev/null +++ b/src/Termii/Insights/MessageHistoryRecord.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageHistoryRecord +{ + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("sender")] + public string? Sender { get; set; } + + [JsonPropertyName("receiver")] + public string? Receiver { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("amount")] + public decimal? Amount { get; set; } + + [JsonPropertyName("reroute")] + public int? Reroute { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("sms_type")] + public string? SmsType { get; set; } + + [JsonPropertyName("send_by")] + public string? SentBy { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/MessageHistoryResponse.cs b/src/Termii/Insights/MessageHistoryResponse.cs new file mode 100644 index 0000000..e365ef6 --- /dev/null +++ b/src/Termii/Insights/MessageHistoryResponse.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageHistoryResponse +{ + [JsonPropertyName("content")] + public IReadOnlyList Content { get; set; } = Array.Empty(); + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("last")] + public bool? Last { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonPropertyName("first")] + public bool? First { get; set; } + + [JsonPropertyName("empty")] + public bool? Empty { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/QueryNumberRequest.cs b/src/Termii/Insights/QueryNumberRequest.cs new file mode 100644 index 0000000..e00f1b1 --- /dev/null +++ b/src/Termii/Insights/QueryNumberRequest.cs @@ -0,0 +1,19 @@ +namespace Termii; + +public sealed class QueryNumberRequest +{ + public string PhoneNumber { get; set; } = string.Empty; + + public string? CountryCode { get; set; } + + internal string ToPath() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + TermiiInsightsQueryString.AddIfPresent(query, "country_code", CountryCode); + + return TermiiInsightsQueryString.Append("/api/insight/number/query", query); + } +} diff --git a/src/Termii/Insights/QueryNumberResponse.cs b/src/Termii/Insights/QueryNumberResponse.cs new file mode 100644 index 0000000..a108030 --- /dev/null +++ b/src/Termii/Insights/QueryNumberResponse.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class QueryNumberResponse +{ + [JsonPropertyName("number")] + public string? Number { get; set; } + + [JsonPropertyName("phone_number")] + public string? PhoneNumber { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("network_code")] + public string? NetworkCode { get; set; } + + [JsonPropertyName("country_code")] + public string? CountryCode { get; set; } + + [JsonPropertyName("country_name")] + public string? CountryName { get; set; } + + [JsonPropertyName("ported")] + public bool? Ported { get; set; } + + [JsonPropertyName("roaming")] + public bool? Roaming { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/TermiiInsightsClient.cs b/src/Termii/Insights/TermiiInsightsClient.cs new file mode 100644 index 0000000..024190b --- /dev/null +++ b/src/Termii/Insights/TermiiInsightsClient.cs @@ -0,0 +1,67 @@ +namespace Termii; + +internal sealed class TermiiInsightsClient : ITermiiInsightsClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiInsightsClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetBalanceAsync(CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + "/api/get-balance", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task CheckDndAsync( + CheckDndRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + request.ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task QueryNumberAsync( + QueryNumberRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + request.ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task GetMessageHistoryAsync( + GetMessageHistoryRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetMessageHistoryRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } +} diff --git a/src/Termii/Insights/TermiiInsightsQueryString.cs b/src/Termii/Insights/TermiiInsightsQueryString.cs new file mode 100644 index 0000000..b86862c --- /dev/null +++ b/src/Termii/Insights/TermiiInsightsQueryString.cs @@ -0,0 +1,27 @@ +namespace Termii; + +internal static class TermiiInsightsQueryString +{ + public static void AddIfPresent(List query, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + query.Add($"{name}={Uri.EscapeDataString(value)}"); + } + } + + public static void AddIfPresent(List query, string name, int? value) + { + if (value.HasValue) + { + query.Add($"{name}={value.Value}"); + } + } + + public static string Append(string path, List query) + { + return query.Count == 0 + ? path + : $"{path}?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index fbd11b4..cd4a3bd 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -38,6 +38,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp SenderIds = new TermiiSenderIdClient(_pipeline); Numbers = new TermiiNumberClient(_pipeline); Tokens = new TermiiTokenClient(_pipeline); + Insights = new TermiiInsightsClient(_pipeline); } public TermiiOptions Options { get; } @@ -50,6 +51,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiTokenClient Tokens { get; } + public ITermiiInsightsClient Insights { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 30c163c..0f3b046 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -21,5 +21,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.SenderIds); Assert.NotNull(client.Numbers); Assert.NotNull(client.Tokens); + Assert.NotNull(client.Insights); } } diff --git a/tests/Termii.Tests/TermiiInsightsClientTests.cs b/tests/Termii.Tests/TermiiInsightsClientTests.cs new file mode 100644 index 0000000..041b450 --- /dev/null +++ b/tests/Termii.Tests/TermiiInsightsClientTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiInsightsClientTests +{ + [Fact] + public async Task GetBalanceAsyncAddsApiKeyAndDeserializesBalance() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"user":"termii-user","balance":"125.50","currency":"NGN"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetBalanceAsync(CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("https://example.test/api/get-balance?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("termii-user", response.User); + Assert.Equal(125.50m, response.Balance); + Assert.Equal("NGN", response.Currency); + } + + [Fact] + public async Task CheckDndAsyncAddsPhoneNumberAndApiKey() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"phone_number":"2348012345678","status":"DND","network":"MTN","dnd_active":true}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.CheckDndAsync( + new CheckDndRequest + { + PhoneNumber = "2348012345678", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/check/dnd?phone_number=2348012345678&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal("2348012345678", response.PhoneNumber); + Assert.Equal("DND", response.Status); + Assert.Equal("MTN", response.Network); + Assert.True(response.DndActive); + } + + [Fact] + public async Task QueryNumberAsyncAddsCountryCodeAndDeserializesStatus() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"number":"2348012345678","status":"active","network":"MTN","country_code":"NG","country_name":"Nigeria","ported":false}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.QueryNumberAsync( + new QueryNumberRequest + { + PhoneNumber = "2348012345678", + CountryCode = "NG", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/insight/number/query?phone_number=2348012345678&country_code=NG&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal("active", response.Status); + Assert.Equal("MTN", response.Network); + Assert.Equal("Nigeria", response.CountryName); + Assert.False(response.Ported); + } + + [Fact] + public async Task GetMessageHistoryAsyncAddsFiltersAndDeserializesContent() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "message_id": "msg-123", + "sender": "Termii", + "receiver": "2348012345678", + "message": "Hello", + "amount": "4.25", + "reroute": 0, + "status": "sent", + "sms_type": "plain", + "send_by": "api", + "created_at": "2026-06-13 10:00:00", + "updated_at": "2026-06-13 10:01:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0, + "first": true, + "last": true, + "empty": false + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetMessageHistoryAsync( + new GetMessageHistoryRequest + { + Page = 0, + Size = 15, + Sender = "Termii", + Receiver = "2348012345678", + Status = "sent", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var message = Assert.Single(response.Content); + + Assert.Equal( + "https://example.test/api/sms/inbox?page=0&size=15&sender=Termii&receiver=2348012345678&status=sent&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal(1, response.TotalElements); + Assert.Equal("msg-123", message.MessageId); + Assert.Equal("Termii", message.Sender); + Assert.Equal("2348012345678", message.Receiver); + Assert.Equal(4.25m, message.Amount); + Assert.Equal("sent", message.Status); + } + + [Fact] + public async Task CheckDndAsyncRejectsMissingPhoneNumber() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Insights.CheckDndAsync( + new CheckDndRequest + { + PhoneNumber = "", + }, + CancellationToken.None)); + } +} From 654d4eeb14c433d0e54ccf4fcfe6fbd47526ab33 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sat, 13 Jun 2026 22:45:44 +0100 Subject: [PATCH 12/20] Prepare v0.2.0 release (#28) --- docs/RELEASING.md | 4 ++-- src/Termii/Termii.csproj | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 52d189e..c1cc6a0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -13,8 +13,8 @@ This project publishes the SDK as a NuGet package from version tags. 1. Confirm all intended issues for the release are merged. 2. Confirm CI is green on `main`. 3. Update package metadata and README examples if needed. -4. Update `VersionPrefix` in `src/Termii/Termii.csproj`. -5. Create and push a tag like `v0.1.0`. +4. Update `VersionPrefix` and `PackageReleaseNotes` in `src/Termii/Termii.csproj`. +5. Create and push a tag like `v0.2.0`. 6. Confirm the release workflow creates the package artifact. 7. Confirm NuGet publishing succeeds when the `NUGET_API_KEY` secret is configured. diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj index 1ed4ac4..182fe91 100644 --- a/src/Termii/Termii.csproj +++ b/src/Termii/Termii.csproj @@ -4,9 +4,9 @@ Termii Termii Termii.SDK - 0.1.0 + 0.2.0 A .NET SDK for the Termii messaging, token, and insights APIs. - Initial SDK package metadata and release workflow preparation. + Add messaging, sender ID, Number API, Token API, and Insights API client support with unit and integration smoke coverage. termii;sms;otp;messaging;dotnet;sdk true $(NoWarn);1591 From d70d1b02e2476f20ad6dbdc517c07c03907d29b0 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sat, 13 Jun 2026 22:54:57 +0100 Subject: [PATCH 13/20] Improve README developer experience (#29) --- README.md | 233 ++++++++++++++++++++++++---- docs/API_COVERAGE.md | 12 +- examples/Termii.Examples/Program.cs | 33 ++-- 3 files changed, 228 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index fa5e987..fe908fd 100644 --- a/README.md +++ b/README.md @@ -2,39 +2,45 @@ [![CI](https://github.com/teesofttech/Termii.SDK/actions/workflows/ci.yml/badge.svg)](https://github.com/teesofttech/Termii.SDK/actions/workflows/ci.yml) -A .NET SDK for the Termii messaging, token, and insights APIs. +A .NET SDK for the Termii messaging, sender ID, Number API, Token API, and Insights APIs. -> This SDK is in early development. The first milestones establish the client, tests, examples, API coverage matrix, and package structure before endpoint support is added feature by feature. +## Installation -## Current Status - -The initial repository setup includes: - -- A `Termii` SDK project targeting `netstandard2.0` and `net8.0`. -- A lightweight unit test project. -- An examples project for developer-facing usage. -- An API coverage matrix in `docs/API_COVERAGE.md`. - -## Usage - -Install the package: +Install from NuGet: ```bash dotnet add package Termii.SDK ``` +Then import the SDK namespace: + ```csharp using Termii; +``` +## Configuration + +Create a client manually: + +```csharp var client = new TermiiClient(new TermiiOptions { ApiKey = "your-termii-api-key" }); ``` -The default base URL is `https://api.ng.termii.com`. +The default base URL is `https://api.ng.termii.com`. Override it only when your Termii account or environment requires a different base URL: + +```csharp +var client = new TermiiClient(new TermiiOptions +{ + ApiKey = "your-termii-api-key", + BaseUrl = new Uri("https://api.ng.termii.com"), + Timeout = TimeSpan.FromSeconds(30) +}); +``` -For ASP.NET Core applications, register the SDK with dependency injection: +For ASP.NET Core applications, register `TermiiClient` with dependency injection: ```csharp builder.Services.AddTermii(options => @@ -43,7 +49,35 @@ builder.Services.AddTermii(options => }); ``` -Send a message: +Use it from a service or endpoint: + +```csharp +public sealed class NotificationService +{ + private readonly TermiiClient _termii; + + public NotificationService(TermiiClient termii) + { + _termii = termii; + } + + public Task SendAsync(string phoneNumber, string message) + { + return _termii.Messaging.SendAsync(new SendMessageRequest + { + To = phoneNumber, + From = "Termii", + Sms = message, + Channel = TermiiMessageChannel.Generic, + Type = TermiiMessageType.Plain + }); + } +} +``` + +## Messaging + +Send a single SMS: ```csharp var response = await client.Messaging.SendAsync(new SendMessageRequest @@ -56,22 +90,50 @@ var response = await client.Messaging.SendAsync(new SendMessageRequest }); ``` -Fetch sender IDs: +Send a bulk SMS: ```csharp -var senderIds = await client.SenderIds.GetAsync(); +var response = await client.Messaging.SendBulkAsync(new SendBulkMessageRequest +{ + To = new[] { "2348012345678", "2348098765432" }, + From = "Termii", + Sms = "Hello everyone", + Channel = TermiiMessageChannel.Generic, + Type = TermiiMessageType.Plain +}); ``` Send through the Number API: ```csharp -var numberMessage = await client.Numbers.SendAsync(new SendNumberMessageRequest +var response = await client.Numbers.SendAsync(new SendNumberMessageRequest { To = "2348012345678", Sms = "Hello from a dedicated Termii number" }); ``` +## Sender IDs + +Fetch sender IDs: + +```csharp +var senderIds = await client.SenderIds.GetAsync(); +``` + +Request a sender ID: + +```csharp +var request = await client.SenderIds.RequestAsync(new RequestSenderIdRequest +{ + SenderId = "Termii", + UseCase = "Transactional alerts", + Company = "Example Ltd" +}); +``` + +## Tokens and OTP + Send an OTP token: ```csharp @@ -88,12 +150,115 @@ var token = await client.Tokens.SendAsync(new SendTokenRequest }); ``` +Verify an OTP token: + +```csharp +var verification = await client.Tokens.VerifyAsync(new VerifyTokenRequest +{ + PinId = token.PinId!, + Pin = "123456" +}); +``` + +Generate an in-app OTP: + +```csharp +var generated = await client.Tokens.GenerateAsync(new GenerateTokenRequest +{ + PhoneNumber = "2348012345678", + PinType = TermiiTokenPinType.Numeric, + PinAttempts = 3, + PinTimeToLive = 10, + PinLength = 6 +}); +``` + +The token client also supports voice OTP, voice call OTP, email OTP, and WhatsApp OTP through `SendVoiceAsync`, `CallAsync`, `SendEmailAsync`, and `SendWhatsAppAsync`. + +## Insights + Check account balance: ```csharp var balance = await client.Insights.GetBalanceAsync(); ``` +Check DND status: + +```csharp +var dnd = await client.Insights.CheckDndAsync(new CheckDndRequest +{ + PhoneNumber = "2348012345678" +}); +``` + +Query number intelligence: + +```csharp +var number = await client.Insights.QueryNumberAsync(new QueryNumberRequest +{ + PhoneNumber = "2348012345678", + CountryCode = "NG" +}); +``` + +Fetch message history: + +```csharp +var history = await client.Insights.GetMessageHistoryAsync(new GetMessageHistoryRequest +{ + Page = 0, + Size = 15, + Sender = "Termii", + Receiver = "2348012345678" +}); +``` + +## Error Handling + +The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: + +```csharp +try +{ + await client.Messaging.SendAsync(new SendMessageRequest + { + To = "2348012345678", + From = "Termii", + Sms = "Hello from .NET", + Channel = TermiiMessageChannel.Generic + }); +} +catch (TermiiApiException ex) +{ + Console.WriteLine($"HTTP status: {(int)ex.StatusCode}"); + Console.WriteLine($"Termii code: {ex.TermiiCode}"); + Console.WriteLine($"Termii message: {ex.TermiiMessage}"); +} +``` + +Request models also validate required fields before sending. Missing required values throw `ArgumentException`, and invalid numeric ranges throw `ArgumentOutOfRangeException`. + +## Supported APIs + +Implemented in the current SDK: + +- Messaging: send single, WhatsApp conversational, and bulk messages. +- Sender IDs: list and request sender IDs. +- Number API: send message through a dedicated Termii number. +- Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. +- Insights: balance, DND status, number intelligence, and message history. + +Deferred or not yet implemented: + +- WhatsApp template/device message APIs. +- Campaign phonebook APIs. +- Product notification email APIs. +- Webhook event models. +- `/api/sms/history/analytics`, pending additional verification. + +See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. + ## Examples Run the examples project after setting your Termii API key: @@ -103,10 +268,17 @@ export TERMII_API_KEY="your-termii-api-key" dotnet run --project examples/Termii.Examples ``` -To send a live Number API example message, opt in explicitly: +Run a live balance example: ```bash -export TERMII_SEND_NUMBER_MESSAGE="true" +export TERMII_EXAMPLE_ACTION="balance" +dotnet run --project examples/Termii.Examples +``` + +Send a live Number API example message: + +```bash +export TERMII_EXAMPLE_ACTION="number-message" export TERMII_EXAMPLE_PHONE_NUMBER="2348012345678" dotnet run --project examples/Termii.Examples ``` @@ -128,20 +300,15 @@ export TERMII_TEST_PHONE_NUMBER="2348012345678" dotnet test tests/Termii.IntegrationTests ``` -As endpoint support is added, integration tests should remain credential-gated so normal CI can run without network access or Termii credits. - -## Roadmap +## Release -Development is tracked through GitHub issues: +Releases are published from version tags through GitHub Actions. See [docs/RELEASING.md](docs/RELEASING.md) for the checklist. -- SDK foundation and HTTP pipeline. -- Messaging APIs. -- Sender ID and number APIs. -- Token/OTP APIs. -- Insights APIs. -- CI, documentation, NuGet packaging, and GitHub Releases. +## Links -Official Termii documentation: https://developer.termii.com/ +- Termii developer docs: https://developer.termii.com/ +- NuGet package: https://www.nuget.org/packages/Termii.SDK +- API coverage: [docs/API_COVERAGE.md](docs/API_COVERAGE.md) ## License diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 2b17c02..00fb79a 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -31,10 +31,10 @@ The Termii docs describe a REST/JSON API and state that each account has its own | API coverage matrix | Implemented | #14, PR #18 | | Error handling and request validation | Implemented | #6, PR #19 | | Reusable fake HTTP test helpers and opt-in live test conventions | Implemented | #10, PR #20 | -| README endpoint examples | Planned | #12 | +| README endpoint examples | In progress | #12 | | CI build/test/package validation | Implemented | #11, PR #22 | | NuGet package metadata and publishing workflow | Implemented | #8, PR #23 | -| First GitHub Release and NuGet publish | Planned | #13 | +| First GitHub Release and NuGet publish | Implemented | #13, PR #28, v0.2.0 | ## Endpoint Coverage @@ -53,10 +53,10 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Token | Send voice call token | POST | `/api/sms/otp/call` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | | Token | Send email OTP token | POST | `/api/email/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | | Token | Send WhatsApp OTP token | POST | `/api/sms/send` | JSON body with `channel=whatsapp_otp` | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | -| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | -| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | -| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | -| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | In progress | #9 | Unit tests added | +| Insights | Get balance | GET | `/api/get-balance` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | +| Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | +| Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | +| Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Needs verification | #9 | Planned unit + optional integration | ## Deferred Coverage diff --git a/examples/Termii.Examples/Program.cs b/examples/Termii.Examples/Program.cs index a5e551c..d08e950 100644 --- a/examples/Termii.Examples/Program.cs +++ b/examples/Termii.Examples/Program.cs @@ -15,22 +15,33 @@ Console.WriteLine($"Termii client configured for {client.Options.BaseUrl}."); +var action = Environment.GetEnvironmentVariable("TERMII_EXAMPLE_ACTION"); var phoneNumber = Environment.GetEnvironmentVariable("TERMII_EXAMPLE_PHONE_NUMBER"); -var sendNumberMessage = string.Equals( - Environment.GetEnvironmentVariable("TERMII_SEND_NUMBER_MESSAGE"), - "true", - StringComparison.OrdinalIgnoreCase); -if (!sendNumberMessage || string.IsNullOrWhiteSpace(phoneNumber)) +if (string.Equals(action, "balance", StringComparison.OrdinalIgnoreCase)) { - Console.WriteLine("Set TERMII_SEND_NUMBER_MESSAGE=true and TERMII_EXAMPLE_PHONE_NUMBER to send a Number API message."); + var balance = await client.Insights.GetBalanceAsync(); + + Console.WriteLine($"Balance: {balance.Balance} {balance.Currency}".Trim()); return; } -var response = await client.Numbers.SendAsync(new SendNumberMessageRequest +if (string.Equals(action, "number-message", StringComparison.OrdinalIgnoreCase)) { - To = phoneNumber, - Sms = "Hello from Termii.SDK", -}); + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + Console.WriteLine("Set TERMII_EXAMPLE_PHONE_NUMBER to send a Number API message."); + return; + } + + var response = await client.Numbers.SendAsync(new SendNumberMessageRequest + { + To = phoneNumber, + Sms = "Hello from Termii.SDK", + }); + + Console.WriteLine($"Number API response: {response.Message ?? response.Code ?? "sent"}"); + return; +} -Console.WriteLine($"Number API response: {response.Message ?? response.Code ?? "sent"}"); +Console.WriteLine("Set TERMII_EXAMPLE_ACTION=balance or TERMII_EXAMPLE_ACTION=number-message to run a live example."); From 1d08d7dcd29ab7aa120a76b296c03a28c9fe2025 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sat, 13 Jun 2026 23:01:28 +0100 Subject: [PATCH 14/20] Add Insights message analytics endpoint (#35) --- README.md | 14 ++++++-- docs/API_COVERAGE.md | 4 +-- .../Insights/GetMessageAnalyticsRequest.cs | 23 +++++++++++++ src/Termii/Insights/ITermiiInsightsClient.cs | 4 +++ .../Insights/MessageAnalyticsResponse.cs | 25 +++++++++++++++ src/Termii/Insights/TermiiInsightsClient.cs | 12 +++++++ .../Termii.Tests/TermiiInsightsClientTests.cs | 32 +++++++++++++++++++ 7 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 src/Termii/Insights/GetMessageAnalyticsRequest.cs create mode 100644 src/Termii/Insights/MessageAnalyticsResponse.cs diff --git a/README.md b/README.md index fe908fd..15066a5 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,17 @@ var history = await client.Insights.GetMessageHistoryAsync(new GetMessageHistory }); ``` +Fetch message analytics: + +```csharp +var analytics = await client.Insights.GetMessageAnalyticsAsync(new GetMessageAnalyticsRequest +{ + PhoneNumber = "2348012345678", + DateFrom = "2026-06-01", + DateTo = "2026-06-13" +}); +``` + ## Error Handling The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: @@ -247,7 +258,7 @@ Implemented in the current SDK: - Sender IDs: list and request sender IDs. - Number API: send message through a dedicated Termii number. - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. -- Insights: balance, DND status, number intelligence, and message history. +- Insights: balance, DND status, number intelligence, message history, and message analytics. Deferred or not yet implemented: @@ -255,7 +266,6 @@ Deferred or not yet implemented: - Campaign phonebook APIs. - Product notification email APIs. - Webhook event models. -- `/api/sms/history/analytics`, pending additional verification. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 00fb79a..755e848 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -57,7 +57,7 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | -| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Needs verification | #9 | Planned unit + optional integration | +| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | In progress | #34 | Unit tests added | ## Deferred Coverage @@ -82,7 +82,7 @@ The provided Postman collection currently lists these groups: | --- | --- | --- | | Switch | SenderId, Numbers, Send | Covered by #3, #5, and #7. | | Token | Send Token, Verify Token, In-App Token | Covered by #4. | -| Insight | history analytics, Search, Balance, Inbox API | Covered by #9, with `/api/sms/history/analytics` marked for verification against live behavior/docs. | +| Insight | history analytics, Search, Balance, Inbox API | Covered by #9 and #34. | ## Implementation Rules diff --git a/src/Termii/Insights/GetMessageAnalyticsRequest.cs b/src/Termii/Insights/GetMessageAnalyticsRequest.cs new file mode 100644 index 0000000..df43302 --- /dev/null +++ b/src/Termii/Insights/GetMessageAnalyticsRequest.cs @@ -0,0 +1,23 @@ +namespace Termii; + +public sealed class GetMessageAnalyticsRequest +{ + public string? MessageId { get; set; } + + public string? DateFrom { get; set; } + + public string? DateTo { get; set; } + + public string? PhoneNumber { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId); + TermiiInsightsQueryString.AddIfPresent(query, "date_from", DateFrom); + TermiiInsightsQueryString.AddIfPresent(query, "date_to", DateTo); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + + return TermiiInsightsQueryString.Append("/api/sms/history/analytics", query); + } +} diff --git a/src/Termii/Insights/ITermiiInsightsClient.cs b/src/Termii/Insights/ITermiiInsightsClient.cs index fe99310..52121e8 100644 --- a/src/Termii/Insights/ITermiiInsightsClient.cs +++ b/src/Termii/Insights/ITermiiInsightsClient.cs @@ -15,4 +15,8 @@ Task QueryNumberAsync( Task GetMessageHistoryAsync( GetMessageHistoryRequest? request = null, CancellationToken cancellationToken = default); + + Task GetMessageAnalyticsAsync( + GetMessageAnalyticsRequest? request = null, + CancellationToken cancellationToken = default); } diff --git a/src/Termii/Insights/MessageAnalyticsResponse.cs b/src/Termii/Insights/MessageAnalyticsResponse.cs new file mode 100644 index 0000000..baf92f8 --- /dev/null +++ b/src/Termii/Insights/MessageAnalyticsResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageAnalyticsResponse +{ + [JsonPropertyName("sent")] + public int? Sent { get; set; } + + [JsonPropertyName("delivered")] + public int? Delivered { get; set; } + + [JsonPropertyName("failed")] + public int? Failed { get; set; } + + [JsonPropertyName("pending")] + public int? Pending { get; set; } + + [JsonPropertyName("rejected")] + public int? Rejected { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/TermiiInsightsClient.cs b/src/Termii/Insights/TermiiInsightsClient.cs index 024190b..1359550 100644 --- a/src/Termii/Insights/TermiiInsightsClient.cs +++ b/src/Termii/Insights/TermiiInsightsClient.cs @@ -64,4 +64,16 @@ public Task GetMessageHistoryAsync( TermiiAuthenticationLocation.Query, cancellationToken); } + + public Task GetMessageAnalyticsAsync( + GetMessageAnalyticsRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetMessageAnalyticsRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } } diff --git a/tests/Termii.Tests/TermiiInsightsClientTests.cs b/tests/Termii.Tests/TermiiInsightsClientTests.cs index 041b450..825d69a 100644 --- a/tests/Termii.Tests/TermiiInsightsClientTests.cs +++ b/tests/Termii.Tests/TermiiInsightsClientTests.cs @@ -142,6 +142,38 @@ public async Task GetMessageHistoryAsyncAddsFiltersAndDeserializesContent() Assert.Equal("sent", message.Status); } + [Fact] + public async Task GetMessageAnalyticsAsyncAddsFiltersAndDeserializesFlexibleResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"sent":12,"delivered":10,"failed":1,"pending":1,"unknown_metric":5}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetMessageAnalyticsAsync( + new GetMessageAnalyticsRequest + { + MessageId = "msg-123", + DateFrom = "2026-06-01", + DateTo = "2026-06-13", + PhoneNumber = "2348012345678", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/sms/history/analytics?message_id=msg-123&date_from=2026-06-01&date_to=2026-06-13&phone_number=2348012345678&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal(12, response.Sent); + Assert.Equal(10, response.Delivered); + Assert.Equal(1, response.Failed); + Assert.Equal(1, response.Pending); + Assert.NotNull(response.AdditionalData); + Assert.Equal(5, response.AdditionalData["unknown_metric"].GetInt32()); + } + [Fact] public async Task CheckDndAsyncRejectsMissingPhoneNumber() { From 6866752f4a13fd3a7064252e74bf9d3495e87311 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sun, 14 Jun 2026 03:52:12 +0100 Subject: [PATCH 15/20] Add webhook event models and documentation (#36) * Add Termii webhook event models * Document framework compatibility --- README.md | 27 ++++++- docs/API_COVERAGE.md | 2 +- src/Termii/Termii.csproj | 4 +- src/Termii/Webhooks/TermiiWebhookEvent.cs | 70 +++++++++++++++++ tests/Termii.Tests/TermiiWebhookEventTests.cs | 77 +++++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/Termii/Webhooks/TermiiWebhookEvent.cs create mode 100644 tests/Termii.Tests/TermiiWebhookEventTests.cs diff --git a/README.md b/README.md index 15066a5..62ade0b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,14 @@ Then import the SDK namespace: using Termii; ``` +## Compatibility + +The SDK targets `netstandard2.0` and `net8.0`. + +- .NET Core 3.1 applications use the `netstandard2.0` asset. +- .NET 5, .NET 6, .NET 7, .NET 8, .NET 9, and .NET 10 applications can consume the package. +- Modern .NET applications use the most compatible asset NuGet selects for the application target. + ## Configuration Create a client manually: @@ -225,6 +233,24 @@ var analytics = await client.Insights.GetMessageAnalyticsAsync(new GetMessageAna }); ``` +## Webhooks + +Termii can send delivery/report callbacks to an endpoint you own. The SDK includes receiver-side models that can be used with ASP.NET Core model binding: + +```csharp +app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) => +{ + if (webhookEvent.Status == "delivered") + { + Console.WriteLine($"Delivered message {webhookEvent.MessageId}"); + } + + return Results.Ok(); +}); +``` + +Webhook payloads can vary by event type and Termii account configuration. Unknown fields are preserved in `TermiiWebhookEvent.AdditionalData`. + ## Error Handling The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: @@ -265,7 +291,6 @@ Deferred or not yet implemented: - WhatsApp template/device message APIs. - Campaign phonebook APIs. - Product notification email APIs. -- Webhook event models. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 755e848..8cbacfe 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -72,7 +72,7 @@ The following documented APIs are useful but should come after the first SDK mil | Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | | Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | | Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. | -| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Deferred | This is SDK model/documentation support rather than an outbound Termii API call. | +| Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | ## Postman Collection Reconciliation diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj index 182fe91..ec2172f 100644 --- a/src/Termii/Termii.csproj +++ b/src/Termii/Termii.csproj @@ -5,9 +5,9 @@ Termii Termii.SDK 0.2.0 - A .NET SDK for the Termii messaging, token, and insights APIs. + A .NET SDK for the Termii messaging, token, and insights APIs, compatible with .NET Core 3.1 through .NET 10. Add messaging, sender ID, Number API, Token API, and Insights API client support with unit and integration smoke coverage. - termii;sms;otp;messaging;dotnet;sdk + termii;sms;otp;messaging;dotnet;sdk;netstandard2.0;netcoreapp3.1;net10 true $(NoWarn);1591 diff --git a/src/Termii/Webhooks/TermiiWebhookEvent.cs b/src/Termii/Webhooks/TermiiWebhookEvent.cs new file mode 100644 index 0000000..0c71ea7 --- /dev/null +++ b/src/Termii/Webhooks/TermiiWebhookEvent.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class TermiiWebhookEvent +{ + [JsonPropertyName("event")] + public string? Event { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("message_id_str")] + public string? MessageIdString { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("to")] + public string? To { get; set; } + + [JsonPropertyName("from")] + public string? From { get; set; } + + [JsonPropertyName("sender")] + public string? Sender { get; set; } + + [JsonPropertyName("receiver")] + public string? Receiver { get; set; } + + [JsonPropertyName("channel")] + public string? Channel { get; set; } + + [JsonPropertyName("network")] + public string? Network { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("amount")] + public decimal? Amount { get; set; } + + [JsonPropertyName("error_code")] + public string? ErrorCode { get; set; } + + [JsonPropertyName("error_message")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonPropertyName("sent_at")] + public string? SentAt { get; set; } + + [JsonPropertyName("delivered_at")] + public string? DeliveredAt { get; set; } + + [JsonPropertyName("done_date")] + public string? DoneDate { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/tests/Termii.Tests/TermiiWebhookEventTests.cs b/tests/Termii.Tests/TermiiWebhookEventTests.cs new file mode 100644 index 0000000..823f0a9 --- /dev/null +++ b/tests/Termii.Tests/TermiiWebhookEventTests.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Termii; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiWebhookEventTests +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + }; + + [Fact] + public void CanDeserializeDeliveryReportPayload() + { + var webhookEvent = JsonSerializer.Deserialize( + """ + { + "event": "message.status", + "message_id": "msg-123", + "message_id_str": "msg-123", + "status": "delivered", + "to": "2348012345678", + "from": "Termii", + "channel": "generic", + "network": "MTN", + "amount": "4.25", + "delivered_at": "2026-06-14 09:00:00", + "provider_reference": "provider-123" + } + """, + JsonSerializerOptions); + + Assert.NotNull(webhookEvent); + Assert.Equal("message.status", webhookEvent.Event); + Assert.Equal("msg-123", webhookEvent.MessageId); + Assert.Equal("delivered", webhookEvent.Status); + Assert.Equal("2348012345678", webhookEvent.To); + Assert.Equal("Termii", webhookEvent.From); + Assert.Equal("generic", webhookEvent.Channel); + Assert.Equal("MTN", webhookEvent.Network); + Assert.Equal(4.25m, webhookEvent.Amount); + Assert.Equal("2026-06-14 09:00:00", webhookEvent.DeliveredAt); + Assert.NotNull(webhookEvent.AdditionalData); + Assert.Equal("provider-123", webhookEvent.AdditionalData["provider_reference"].GetString()); + } + + [Fact] + public void CanDeserializeFailedReportPayload() + { + var webhookEvent = JsonSerializer.Deserialize( + """ + { + "type": "delivery_report", + "message_id": "msg-456", + "status": "failed", + "receiver": "2348012345678", + "sender": "Termii", + "error_code": "3001", + "error_message": "Insufficient balance", + "done_date": "2026-06-14 09:05:00" + } + """, + JsonSerializerOptions); + + Assert.NotNull(webhookEvent); + Assert.Equal("delivery_report", webhookEvent.Type); + Assert.Equal("msg-456", webhookEvent.MessageId); + Assert.Equal("failed", webhookEvent.Status); + Assert.Equal("2348012345678", webhookEvent.Receiver); + Assert.Equal("Termii", webhookEvent.Sender); + Assert.Equal("3001", webhookEvent.ErrorCode); + Assert.Equal("Insufficient balance", webhookEvent.ErrorMessage); + Assert.Equal("2026-06-14 09:05:00", webhookEvent.DoneDate); + } +} From 96442921d96d92212410acaa94d89e7685bb79e4 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sun, 14 Jun 2026 04:00:53 +0100 Subject: [PATCH 16/20] Prepare v0.3.0 release (#37) --- src/Termii/Termii.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj index ec2172f..8b202f0 100644 --- a/src/Termii/Termii.csproj +++ b/src/Termii/Termii.csproj @@ -4,9 +4,9 @@ Termii Termii Termii.SDK - 0.2.0 + 0.3.0 A .NET SDK for the Termii messaging, token, and insights APIs, compatible with .NET Core 3.1 through .NET 10. - Add messaging, sender ID, Number API, Token API, and Insights API client support with unit and integration smoke coverage. + Add webhook event models, message analytics support, and explicit .NET Core 3.1 through .NET 10 compatibility documentation. termii;sms;otp;messaging;dotnet;sdk;netstandard2.0;netcoreapp3.1;net10 true $(NoWarn);1591 From af4f8bd66c45b60bf630f7a0ecc32f2cfdd737e3 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sun, 14 Jun 2026 04:14:01 +0100 Subject: [PATCH 17/20] Add Campaign phonebook APIs (#38) --- README.md | 24 ++- docs/API_COVERAGE.md | 8 +- .../Campaigns/CreatePhonebookRequest.cs | 17 ++ src/Termii/Campaigns/GetPhonebooksRequest.cs | 20 +++ src/Termii/Campaigns/ITermiiCampaignClient.cs | 21 +++ src/Termii/Campaigns/PhonebookListResponse.cs | 28 ++++ .../Campaigns/PhonebookOperationResponse.cs | 19 +++ src/Termii/Campaigns/PhonebookRecord.cs | 34 ++++ src/Termii/Campaigns/PhonebookResponse.cs | 22 +++ src/Termii/Campaigns/TermiiCampaignClient.cs | 78 +++++++++ .../Campaigns/TermiiCampaignQueryString.cs | 27 ++++ .../Campaigns/UpdatePhonebookRequest.cs | 12 ++ src/Termii/TermiiClient.cs | 3 + .../TermiiClientIntegrationTests.cs | 1 + .../Termii.Tests/TermiiCampaignClientTests.cs | 148 ++++++++++++++++++ 15 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 src/Termii/Campaigns/CreatePhonebookRequest.cs create mode 100644 src/Termii/Campaigns/GetPhonebooksRequest.cs create mode 100644 src/Termii/Campaigns/ITermiiCampaignClient.cs create mode 100644 src/Termii/Campaigns/PhonebookListResponse.cs create mode 100644 src/Termii/Campaigns/PhonebookOperationResponse.cs create mode 100644 src/Termii/Campaigns/PhonebookRecord.cs create mode 100644 src/Termii/Campaigns/PhonebookResponse.cs create mode 100644 src/Termii/Campaigns/TermiiCampaignClient.cs create mode 100644 src/Termii/Campaigns/TermiiCampaignQueryString.cs create mode 100644 src/Termii/Campaigns/UpdatePhonebookRequest.cs create mode 100644 tests/Termii.Tests/TermiiCampaignClientTests.cs diff --git a/README.md b/README.md index 62ade0b..4ca9239 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,28 @@ app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) => Webhook payloads can vary by event type and Termii account configuration. Unknown fields are preserved in `TermiiWebhookEvent.AdditionalData`. +## Campaigns + +Fetch campaign phonebooks: + +```csharp +var phonebooks = await client.Campaigns.GetPhonebooksAsync(new GetPhonebooksRequest +{ + Page = 0, + Size = 15 +}); +``` + +Create a phonebook: + +```csharp +var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookRequest +{ + PhonebookName = "Customers", + Description = "Customer contacts" +}); +``` + ## Error Handling The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: @@ -285,11 +307,11 @@ Implemented in the current SDK: - Number API: send message through a dedicated Termii number. - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. - Insights: balance, DND status, number intelligence, message history, and message analytics. +- Campaigns: list, create, update, and delete phonebooks. Deferred or not yet implemented: - WhatsApp template/device message APIs. -- Campaign phonebook APIs. - Product notification email APIs. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 8cbacfe..8792d26 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -58,6 +58,10 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | In progress | #34 | Unit tests added | +| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | ## Deferred Coverage @@ -67,10 +71,6 @@ The following documented APIs are useful but should come after the first SDK mil | --- | --- | --- | --- | --- | --- | | Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. | | Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. | -| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Deferred | Part of campaign/phonebook management, not core messaging. | -| Campaigns | Create phonebook | POST | `/api/phonebooks` | Deferred | Part of campaign/phonebook management. | -| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | -| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | | Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. | | Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | diff --git a/src/Termii/Campaigns/CreatePhonebookRequest.cs b/src/Termii/Campaigns/CreatePhonebookRequest.cs new file mode 100644 index 0000000..42e4b92 --- /dev/null +++ b/src/Termii/Campaigns/CreatePhonebookRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CreatePhonebookRequest +{ + [JsonPropertyName("phonebook_name")] + public string PhonebookName { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhonebookName, nameof(PhonebookName)); + } +} diff --git a/src/Termii/Campaigns/GetPhonebooksRequest.cs b/src/Termii/Campaigns/GetPhonebooksRequest.cs new file mode 100644 index 0000000..68dea34 --- /dev/null +++ b/src/Termii/Campaigns/GetPhonebooksRequest.cs @@ -0,0 +1,20 @@ +namespace Termii; + +public sealed class GetPhonebooksRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + public string? Name { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiCampaignQueryString.AddIfPresent(query, "page", Page); + TermiiCampaignQueryString.AddIfPresent(query, "size", Size); + TermiiCampaignQueryString.AddIfPresent(query, "name", Name); + + return TermiiCampaignQueryString.Append("/api/phonebooks", query); + } +} diff --git a/src/Termii/Campaigns/ITermiiCampaignClient.cs b/src/Termii/Campaigns/ITermiiCampaignClient.cs new file mode 100644 index 0000000..b70e29d --- /dev/null +++ b/src/Termii/Campaigns/ITermiiCampaignClient.cs @@ -0,0 +1,21 @@ +namespace Termii; + +public interface ITermiiCampaignClient +{ + Task GetPhonebooksAsync( + GetPhonebooksRequest? request = null, + CancellationToken cancellationToken = default); + + Task CreatePhonebookAsync( + CreatePhonebookRequest request, + CancellationToken cancellationToken = default); + + Task UpdatePhonebookAsync( + string phonebookId, + UpdatePhonebookRequest request, + CancellationToken cancellationToken = default); + + Task DeletePhonebookAsync( + string phonebookId, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Campaigns/PhonebookListResponse.cs b/src/Termii/Campaigns/PhonebookListResponse.cs new file mode 100644 index 0000000..af4770c --- /dev/null +++ b/src/Termii/Campaigns/PhonebookListResponse.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookListResponse +{ + [JsonPropertyName("content")] + public IReadOnlyList Content { get; set; } = Array.Empty(); + + [JsonPropertyName("data")] + public IReadOnlyList? Data { get; set; } + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookOperationResponse.cs b/src/Termii/Campaigns/PhonebookOperationResponse.cs new file mode 100644 index 0000000..1336c13 --- /dev/null +++ b/src/Termii/Campaigns/PhonebookOperationResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookOperationResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookRecord.cs b/src/Termii/Campaigns/PhonebookRecord.cs new file mode 100644 index 0000000..f13bc31 --- /dev/null +++ b/src/Termii/Campaigns/PhonebookRecord.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookRecord +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("phonebook_id")] + public string? PhonebookId { get; set; } + + [JsonPropertyName("phonebook_name")] + public string? PhonebookName { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("total_contacts")] + public int? TotalContacts { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookResponse.cs b/src/Termii/Campaigns/PhonebookResponse.cs new file mode 100644 index 0000000..6ca11ee --- /dev/null +++ b/src/Termii/Campaigns/PhonebookResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("data")] + public PhonebookRecord? Data { get; set; } + + [JsonPropertyName("phonebook")] + public PhonebookRecord? Phonebook { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/TermiiCampaignClient.cs b/src/Termii/Campaigns/TermiiCampaignClient.cs new file mode 100644 index 0000000..275caf5 --- /dev/null +++ b/src/Termii/Campaigns/TermiiCampaignClient.cs @@ -0,0 +1,78 @@ +namespace Termii; + +internal sealed class TermiiCampaignClient : ITermiiCampaignClient +{ + private static readonly HttpMethod Patch = new("PATCH"); + + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiCampaignClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetPhonebooksAsync( + GetPhonebooksRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetPhonebooksRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task CreatePhonebookAsync( + CreatePhonebookRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/phonebooks", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task UpdatePhonebookAsync( + string phonebookId, + UpdatePhonebookRequest request, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + Patch, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task DeletePhonebookAsync( + string phonebookId, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + return _pipeline.SendJsonAsync( + HttpMethod.Delete, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } +} diff --git a/src/Termii/Campaigns/TermiiCampaignQueryString.cs b/src/Termii/Campaigns/TermiiCampaignQueryString.cs new file mode 100644 index 0000000..afbbc32 --- /dev/null +++ b/src/Termii/Campaigns/TermiiCampaignQueryString.cs @@ -0,0 +1,27 @@ +namespace Termii; + +internal static class TermiiCampaignQueryString +{ + public static void AddIfPresent(List query, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + query.Add($"{name}={Uri.EscapeDataString(value)}"); + } + } + + public static void AddIfPresent(List query, string name, int? value) + { + if (value.HasValue) + { + query.Add($"{name}={value.Value}"); + } + } + + public static string Append(string path, List query) + { + return query.Count == 0 + ? path + : $"{path}?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/Campaigns/UpdatePhonebookRequest.cs b/src/Termii/Campaigns/UpdatePhonebookRequest.cs new file mode 100644 index 0000000..cf6e7c8 --- /dev/null +++ b/src/Termii/Campaigns/UpdatePhonebookRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class UpdatePhonebookRequest +{ + [JsonPropertyName("phonebook_name")] + public string? PhonebookName { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index cd4a3bd..65d346b 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -39,6 +39,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp Numbers = new TermiiNumberClient(_pipeline); Tokens = new TermiiTokenClient(_pipeline); Insights = new TermiiInsightsClient(_pipeline); + Campaigns = new TermiiCampaignClient(_pipeline); } public TermiiOptions Options { get; } @@ -53,6 +54,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiInsightsClient Insights { get; } + public ITermiiCampaignClient Campaigns { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 0f3b046..b8d6f83 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -22,5 +22,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.Numbers); Assert.NotNull(client.Tokens); Assert.NotNull(client.Insights); + Assert.NotNull(client.Campaigns); } } diff --git a/tests/Termii.Tests/TermiiCampaignClientTests.cs b/tests/Termii.Tests/TermiiCampaignClientTests.cs new file mode 100644 index 0000000..0e56582 --- /dev/null +++ b/tests/Termii.Tests/TermiiCampaignClientTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiCampaignClientTests +{ + [Fact] + public async Task GetPhonebooksAsyncAddsFiltersAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "phonebook_id": "pb-123", + "phonebook_name": "Customers", + "description": "Customer contacts", + "total_contacts": 42, + "created_at": "2026-06-14 10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0 + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.GetPhonebooksAsync( + new GetPhonebooksRequest + { + Page = 0, + Size = 15, + Name = "Customers", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var phonebook = Assert.Single(response.Content); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/phonebooks?page=0&size=15&name=Customers&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(1, response.TotalElements); + Assert.Equal("pb-123", phonebook.PhonebookId); + Assert.Equal("Customers", phonebook.PhonebookName); + Assert.Equal(42, phonebook.TotalContacts); + } + + [Fact] + public async Task CreatePhonebookAsyncPostsJsonBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Phonebook created","data":{"phonebook_id":"pb-123","phonebook_name":"Customers"}}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.CreatePhonebookAsync( + new CreatePhonebookRequest + { + PhonebookName = "Customers", + Description = "Customer contacts", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/phonebooks", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Customers", body.RootElement.GetProperty("phonebook_name").GetString()); + Assert.Equal("Customer contacts", body.RootElement.GetProperty("description").GetString()); + Assert.Equal("ok", response.Code); + Assert.Equal("pb-123", response.Data!.PhonebookId); + } + + [Fact] + public async Task UpdatePhonebookAsyncPatchesEscapedPhonebookPath() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Phonebook updated","phonebook":{"id":"pb 123","name":"Customers 2026"}}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.UpdatePhonebookAsync( + "pb 123", + new UpdatePhonebookRequest + { + PhonebookName = "Customers 2026", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(new HttpMethod("PATCH"), request.Method); + Assert.Equal("https://example.test/api/phonebooks/pb%20123", request.RequestUri!.AbsoluteUri); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Customers 2026", body.RootElement.GetProperty("phonebook_name").GetString()); + Assert.Equal("Phonebook updated", response.Message); + Assert.Equal("Customers 2026", response.Phonebook!.Name); + } + + [Fact] + public async Task DeletePhonebookAsyncAddsApiKeyToQueryString() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"status":"success","message":"Phonebook deleted"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.DeletePhonebookAsync("pb-123", CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal(HttpMethod.Delete, request.Method); + Assert.Equal("https://example.test/api/phonebooks/pb-123?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("success", response.Status); + Assert.Equal("Phonebook deleted", response.Message); + } + + [Fact] + public async Task CreatePhonebookAsyncRejectsMissingName() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Campaigns.CreatePhonebookAsync( + new CreatePhonebookRequest + { + PhonebookName = "", + }, + CancellationToken.None)); + } +} From 8e9c70097e14fb062c85c86cd20cb205e3e3fdaf Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sun, 14 Jun 2026 04:18:36 +0100 Subject: [PATCH 18/20] Add product email API (#39) --- README.md | 15 +++- docs/API_COVERAGE.md | 2 +- src/Termii/Emails/ITermiiEmailClient.cs | 8 +++ src/Termii/Emails/SendProductEmailRequest.cs | 31 ++++++++ src/Termii/Emails/SendProductEmailResponse.cs | 22 ++++++ src/Termii/Emails/TermiiEmailClient.cs | 30 ++++++++ src/Termii/TermiiClient.cs | 3 + .../TermiiClientIntegrationTests.cs | 1 + tests/Termii.Tests/TermiiEmailClientTests.cs | 70 +++++++++++++++++++ 9 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/Termii/Emails/ITermiiEmailClient.cs create mode 100644 src/Termii/Emails/SendProductEmailRequest.cs create mode 100644 src/Termii/Emails/SendProductEmailResponse.cs create mode 100644 src/Termii/Emails/TermiiEmailClient.cs create mode 100644 tests/Termii.Tests/TermiiEmailClientTests.cs diff --git a/README.md b/README.md index 4ca9239..bb50e79 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,19 @@ var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookR }); ``` +## Product Emails + +Send a product notification email: + +```csharp +var email = await client.Emails.SendProductEmailAsync(new SendProductEmailRequest +{ + EmailAddress = "person@example.com", + TemplateId = "template-123", + Subject = "Order update" +}); +``` + ## Error Handling The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: @@ -308,11 +321,11 @@ Implemented in the current SDK: - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. - Insights: balance, DND status, number intelligence, message history, and message analytics. - Campaigns: list, create, update, and delete phonebooks. +- Product emails: send template-based notification emails. Deferred or not yet implemented: - WhatsApp template/device message APIs. -- Product notification email APIs. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 8792d26..3824162 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -62,6 +62,7 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | | Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | | Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | In progress | #31 | Unit tests added | ## Deferred Coverage @@ -71,7 +72,6 @@ The following documented APIs are useful but should come after the first SDK mil | --- | --- | --- | --- | --- | --- | | Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. | | Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. | -| Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. | | Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | ## Postman Collection Reconciliation diff --git a/src/Termii/Emails/ITermiiEmailClient.cs b/src/Termii/Emails/ITermiiEmailClient.cs new file mode 100644 index 0000000..a8ff5bb --- /dev/null +++ b/src/Termii/Emails/ITermiiEmailClient.cs @@ -0,0 +1,8 @@ +namespace Termii; + +public interface ITermiiEmailClient +{ + Task SendProductEmailAsync( + SendProductEmailRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Emails/SendProductEmailRequest.cs b/src/Termii/Emails/SendProductEmailRequest.cs new file mode 100644 index 0000000..842dc94 --- /dev/null +++ b/src/Termii/Emails/SendProductEmailRequest.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendProductEmailRequest +{ + [JsonPropertyName("email_address")] + public string EmailAddress { get; set; } = string.Empty; + + [JsonPropertyName("template_id")] + public string TemplateId { get; set; } = string.Empty; + + [JsonPropertyName("subject")] + public string? Subject { get; set; } + + [JsonPropertyName("from")] + public string? From { get; set; } + + [JsonPropertyName("sender_name")] + public string? SenderName { get; set; } + + [JsonPropertyName("data")] + public Dictionary? Data { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(EmailAddress, nameof(EmailAddress)); + TermiiRequestValidation.Required(TemplateId, nameof(TemplateId)); + } +} diff --git a/src/Termii/Emails/SendProductEmailResponse.cs b/src/Termii/Emails/SendProductEmailResponse.cs new file mode 100644 index 0000000..5e72a7d --- /dev/null +++ b/src/Termii/Emails/SendProductEmailResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendProductEmailResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Emails/TermiiEmailClient.cs b/src/Termii/Emails/TermiiEmailClient.cs new file mode 100644 index 0000000..b0867c2 --- /dev/null +++ b/src/Termii/Emails/TermiiEmailClient.cs @@ -0,0 +1,30 @@ +namespace Termii; + +internal sealed class TermiiEmailClient : ITermiiEmailClient +{ + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiEmailClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task SendProductEmailAsync( + SendProductEmailRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/templates/send-email", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index 65d346b..68f9572 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -40,6 +40,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp Tokens = new TermiiTokenClient(_pipeline); Insights = new TermiiInsightsClient(_pipeline); Campaigns = new TermiiCampaignClient(_pipeline); + Emails = new TermiiEmailClient(_pipeline); } public TermiiOptions Options { get; } @@ -56,6 +57,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiCampaignClient Campaigns { get; } + public ITermiiEmailClient Emails { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index b8d6f83..820f433 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -23,5 +23,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.Tokens); Assert.NotNull(client.Insights); Assert.NotNull(client.Campaigns); + Assert.NotNull(client.Emails); } } diff --git a/tests/Termii.Tests/TermiiEmailClientTests.cs b/tests/Termii.Tests/TermiiEmailClientTests.cs new file mode 100644 index 0000000..8194c0e --- /dev/null +++ b/tests/Termii.Tests/TermiiEmailClientTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Text.Json; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiEmailClientTests +{ + [Fact] + public async Task SendProductEmailAsyncPostsTemplateEmailBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Email accepted","message_id":"email-123","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Emails.SendProductEmailAsync( + new SendProductEmailRequest + { + EmailAddress = "person@example.com", + TemplateId = "template-123", + Subject = "Order update", + From = "noreply@example.com", + SenderName = "Example Store", + Data = new Dictionary + { + ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(), + ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(), + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/templates/send-email", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("person@example.com", body.RootElement.GetProperty("email_address").GetString()); + Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("Order update", body.RootElement.GetProperty("subject").GetString()); + Assert.Equal("noreply@example.com", body.RootElement.GetProperty("from").GetString()); + Assert.Equal("Example Store", body.RootElement.GetProperty("sender_name").GetString()); + Assert.Equal("Ada", body.RootElement.GetProperty("data").GetProperty("first_name").GetString()); + Assert.Equal("ORD-123", body.RootElement.GetProperty("data").GetProperty("order_id").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal("Email accepted", response.Message); + Assert.Equal("email-123", response.MessageId); + Assert.Equal("queued", response.Status); + } + + [Fact] + public async Task SendProductEmailAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Emails.SendProductEmailAsync( + new SendProductEmailRequest + { + EmailAddress = "", + TemplateId = "template-123", + }, + CancellationToken.None)); + } +} From 39ce31d879491d009e7db7c661931e1eed2170e6 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sun, 14 Jun 2026 04:27:19 +0100 Subject: [PATCH 19/20] Add WhatsApp template APIs (#40) --- README.md | 17 ++- docs/API_COVERAGE.md | 20 +-- .../Messaging/ITermiiMessagingClient.cs | 8 ++ .../SendWhatsAppTemplateMediaRequest.cs | 21 +++ .../Messaging/SendWhatsAppTemplateRequest.cs | 35 +++++ src/Termii/Messaging/TermiiMessagingClient.cs | 38 +++++ .../Messaging/WhatsAppTemplateComponent.cs | 13 ++ src/Termii/Messaging/WhatsAppTemplateMedia.cs | 24 ++++ .../Messaging/WhatsAppTemplateResponse.cs | 22 +++ .../TermiiWhatsAppTemplateClientTests.cs | 132 ++++++++++++++++++ 10 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs create mode 100644 src/Termii/Messaging/SendWhatsAppTemplateRequest.cs create mode 100644 src/Termii/Messaging/WhatsAppTemplateComponent.cs create mode 100644 src/Termii/Messaging/WhatsAppTemplateMedia.cs create mode 100644 src/Termii/Messaging/WhatsAppTemplateResponse.cs create mode 100644 tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs diff --git a/README.md b/README.md index bb50e79..2684b68 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,18 @@ var response = await client.Numbers.SendAsync(new SendNumberMessageRequest }); ``` +Send a WhatsApp template message: + +```csharp +var template = await client.Messaging.SendWhatsAppTemplateAsync(new SendWhatsAppTemplateRequest +{ + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + Language = "en" +}); +``` + ## Sender IDs Fetch sender IDs: @@ -316,6 +328,7 @@ Request models also validate required fields before sending. Missing required va Implemented in the current SDK: - Messaging: send single, WhatsApp conversational, and bulk messages. +- WhatsApp templates: send template messages with or without media. - Sender IDs: list and request sender IDs. - Number API: send message through a dedicated Termii number. - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. @@ -323,10 +336,6 @@ Implemented in the current SDK: - Campaigns: list, create, update, and delete phonebooks. - Product emails: send template-based notification emails. -Deferred or not yet implemented: - -- WhatsApp template/device message APIs. - See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. ## Examples diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 3824162..a74600d 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -45,6 +45,8 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Messaging | Send SMS/channel message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send WhatsApp conversational message | POST | `/api/sms/send` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | | Messaging | Send bulk message | POST | `/api/sms/send/bulk` | JSON body | `TermiiClient.Messaging` | Implemented | #7, PR #21 | Unit tests added | +| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added | +| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | JSON body | `TermiiClient.Messaging` | In progress | #30 | Unit tests added | | Messaging | Send via Number API | POST | `/api/sms/number/send` | JSON body | `TermiiClient.Numbers` | Implemented | #5, PR #25 | Unit tests added | | Token | Send OTP token | POST | `/api/sms/otp/send` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | | Token | Verify OTP token | POST | `/api/sms/otp/verify` | JSON body | `TermiiClient.Tokens` | Implemented | #4, PR #26 | Unit tests added | @@ -57,21 +59,19 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | -| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | In progress | #34 | Unit tests added | -| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | -| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | In progress | #31 | Unit tests added | +| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Implemented | #34, PR #35 | Unit tests added | +| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | +| Email | Send product notification email | POST | `/api/templates/send-email` | JSON body | `TermiiClient.Emails` | Implemented | #31, PR #39 | Unit tests added | -## Deferred Coverage +## Receiver-Side Coverage -The following documented APIs are useful but should come after the first SDK milestone unless a consumer need pushes them forward. +The following SDK support is not an outbound Termii API call. | Area | Capability | Method | Path | Status | Notes | | --- | --- | --- | --- | --- | --- | -| Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. | -| Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. | | Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | ## Postman Collection Reconciliation diff --git a/src/Termii/Messaging/ITermiiMessagingClient.cs b/src/Termii/Messaging/ITermiiMessagingClient.cs index 9a68176..c990964 100644 --- a/src/Termii/Messaging/ITermiiMessagingClient.cs +++ b/src/Termii/Messaging/ITermiiMessagingClient.cs @@ -9,4 +9,12 @@ Task SendAsync( Task SendBulkAsync( SendBulkMessageRequest request, CancellationToken cancellationToken = default); + + Task SendWhatsAppTemplateAsync( + SendWhatsAppTemplateRequest request, + CancellationToken cancellationToken = default); + + Task SendWhatsAppTemplateMediaAsync( + SendWhatsAppTemplateMediaRequest request, + CancellationToken cancellationToken = default); } diff --git a/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs new file mode 100644 index 0000000..0ec5098 --- /dev/null +++ b/src/Termii/Messaging/SendWhatsAppTemplateMediaRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendWhatsAppTemplateMediaRequest : SendWhatsAppTemplateRequest +{ + [JsonPropertyName("media")] + public WhatsAppTemplateMedia Media { get; set; } = new(); + + internal override void Validate() + { + base.Validate(); + + if (Media is null) + { + throw new ArgumentNullException(nameof(Media)); + } + + Media.Validate(); + } +} diff --git a/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs new file mode 100644 index 0000000..647fef7 --- /dev/null +++ b/src/Termii/Messaging/SendWhatsAppTemplateRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public class SendWhatsAppTemplateRequest +{ + [JsonPropertyName("phone_number")] + public string PhoneNumber { get; set; } = string.Empty; + + [JsonPropertyName("device_id")] + public string DeviceId { get; set; } = string.Empty; + + [JsonPropertyName("template_id")] + public string TemplateId { get; set; } = string.Empty; + + [JsonPropertyName("template_name")] + public string? TemplateName { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("variables")] + public Dictionary? Variables { get; set; } + + [JsonPropertyName("components")] + public IReadOnlyCollection? Components { get; set; } + + internal virtual void Validate() + { + TermiiRequestValidation.Required(PhoneNumber, nameof(PhoneNumber)); + TermiiRequestValidation.Required(DeviceId, nameof(DeviceId)); + TermiiRequestValidation.Required(TemplateId, nameof(TemplateId)); + } +} diff --git a/src/Termii/Messaging/TermiiMessagingClient.cs b/src/Termii/Messaging/TermiiMessagingClient.cs index 0c7fea0..02f1abb 100644 --- a/src/Termii/Messaging/TermiiMessagingClient.cs +++ b/src/Termii/Messaging/TermiiMessagingClient.cs @@ -49,6 +49,44 @@ public Task SendBulkAsync( cancellationToken); } + public Task SendWhatsAppTemplateAsync( + SendWhatsAppTemplateRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/send/template", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task SendWhatsAppTemplateMediaAsync( + SendWhatsAppTemplateMediaRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/send/template/media", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + private sealed class SendMessagePayload { public SendMessagePayload(SendMessageRequest request) diff --git a/src/Termii/Messaging/WhatsAppTemplateComponent.cs b/src/Termii/Messaging/WhatsAppTemplateComponent.cs new file mode 100644 index 0000000..1c07ab6 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateComponent.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateComponent +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("parameters")] + public IReadOnlyCollection>? Parameters { get; set; } +} diff --git a/src/Termii/Messaging/WhatsAppTemplateMedia.cs b/src/Termii/Messaging/WhatsAppTemplateMedia.cs new file mode 100644 index 0000000..c3d9746 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateMedia.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateMedia +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("caption")] + public string? Caption { get; set; } + + [JsonPropertyName("filename")] + public string? FileName { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(Type, nameof(Type)); + TermiiRequestValidation.Required(Url, nameof(Url)); + } +} diff --git a/src/Termii/Messaging/WhatsAppTemplateResponse.cs b/src/Termii/Messaging/WhatsAppTemplateResponse.cs new file mode 100644 index 0000000..677e1d9 --- /dev/null +++ b/src/Termii/Messaging/WhatsAppTemplateResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class WhatsAppTemplateResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("message_id")] + public string? MessageId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs new file mode 100644 index 0000000..ae50c00 --- /dev/null +++ b/tests/Termii.Tests/TermiiWhatsAppTemplateClientTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text.Json; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiWhatsAppTemplateClientTests +{ + [Fact] + public async Task SendWhatsAppTemplateAsyncPostsTemplateBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Template accepted","message_id":"template-123","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendWhatsAppTemplateAsync( + new SendWhatsAppTemplateRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + TemplateName = "order_update", + Language = "en", + Variables = new Dictionary + { + ["first_name"] = JsonDocument.Parse("\"Ada\"").RootElement.Clone(), + ["order_id"] = JsonDocument.Parse("\"ORD-123\"").RootElement.Clone(), + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/send/template", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("2348012345678", body.RootElement.GetProperty("phone_number").GetString()); + Assert.Equal("device-123", body.RootElement.GetProperty("device_id").GetString()); + Assert.Equal("template-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("order_update", body.RootElement.GetProperty("template_name").GetString()); + Assert.Equal("en", body.RootElement.GetProperty("language").GetString()); + Assert.Equal("Ada", body.RootElement.GetProperty("variables").GetProperty("first_name").GetString()); + + Assert.Equal("ok", response.Code); + Assert.Equal("Template accepted", response.Message); + Assert.Equal("template-123", response.MessageId); + Assert.Equal("queued", response.Status); + } + + [Fact] + public async Task SendWhatsAppTemplateMediaAsyncPostsMediaTemplateBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Media template accepted","status":"queued"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Messaging.SendWhatsAppTemplateMediaAsync( + new SendWhatsAppTemplateMediaRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-media-123", + Language = "en", + Media = new WhatsAppTemplateMedia + { + Type = "document", + Url = "https://example.test/invoice.pdf", + Caption = "Invoice", + FileName = "invoice.pdf", + }, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + var media = body.RootElement.GetProperty("media"); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/send/template/media", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("template-media-123", body.RootElement.GetProperty("template_id").GetString()); + Assert.Equal("document", media.GetProperty("type").GetString()); + Assert.Equal("https://example.test/invoice.pdf", media.GetProperty("url").GetString()); + Assert.Equal("Invoice", media.GetProperty("caption").GetString()); + Assert.Equal("invoice.pdf", media.GetProperty("filename").GetString()); + Assert.Equal("Media template accepted", response.Message); + } + + [Fact] + public async Task SendWhatsAppTemplateAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateAsync( + new SendWhatsAppTemplateRequest + { + PhoneNumber = "", + DeviceId = "device-123", + TemplateId = "template-123", + }, + CancellationToken.None)); + } + + [Fact] + public async Task SendWhatsAppTemplateMediaAsyncRejectsMissingMediaUrl() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Messaging.SendWhatsAppTemplateMediaAsync( + new SendWhatsAppTemplateMediaRequest + { + PhoneNumber = "2348012345678", + DeviceId = "device-123", + TemplateId = "template-123", + Media = new WhatsAppTemplateMedia + { + Type = "image", + Url = "", + }, + }, + CancellationToken.None)); + } +} From 4a028063dff9c7518dfaa69a5304c0afedf556c2 Mon Sep 17 00:00:00 2001 From: ESANJU BABATUNDE Date: Sun, 14 Jun 2026 07:51:25 +0100 Subject: [PATCH 20/20] Prepare v0.4.0 release (#41) --- src/Termii/Termii.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Termii/Termii.csproj b/src/Termii/Termii.csproj index 8b202f0..4c1a259 100644 --- a/src/Termii/Termii.csproj +++ b/src/Termii/Termii.csproj @@ -4,9 +4,9 @@ Termii Termii Termii.SDK - 0.3.0 + 0.4.0 A .NET SDK for the Termii messaging, token, and insights APIs, compatible with .NET Core 3.1 through .NET 10. - Add webhook event models, message analytics support, and explicit .NET Core 3.1 through .NET 10 compatibility documentation. + Add Campaign phonebook APIs, product notification email API, and WhatsApp template message APIs. termii;sms;otp;messaging;dotnet;sdk;netstandard2.0;netcoreapp3.1;net10 true $(NoWarn);1591