Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
27 changes: 27 additions & 0 deletions src/Termii/SenderIds/GetSenderIdsRequest.cs
Original file line number Diff line number Diff line change
@@ -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<string>();

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)}";
}
}
12 changes: 12 additions & 0 deletions src/Termii/SenderIds/ITermiiSenderIdClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Termii;

public interface ITermiiSenderIdClient
{
Task<SenderIdListResponse> GetAsync(
GetSenderIdsRequest? request = null,
CancellationToken cancellationToken = default);

Task<RequestSenderIdResponse> RequestAsync(
RequestSenderIdRequest request,
CancellationToken cancellationToken = default);
}
22 changes: 22 additions & 0 deletions src/Termii/SenderIds/RequestSenderIdRequest.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
12 changes: 12 additions & 0 deletions src/Termii/SenderIds/RequestSenderIdResponse.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
30 changes: 30 additions & 0 deletions src/Termii/SenderIds/SenderIdListResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;

namespace Termii;

public sealed class SenderIdListResponse
{
[JsonPropertyName("content")]
public IReadOnlyCollection<SenderIdRecord> Content { get; set; } = Array.Empty<SenderIdRecord>();

[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; }
}
24 changes: 24 additions & 0 deletions src/Termii/SenderIds/SenderIdRecord.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
42 changes: 42 additions & 0 deletions src/Termii/SenderIds/TermiiSenderIdClient.cs
Original file line number Diff line number Diff line change
@@ -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<SenderIdListResponse> GetAsync(
GetSenderIdsRequest? request = null,
CancellationToken cancellationToken = default)
{
return _pipeline.SendJsonAsync<SenderIdListResponse>(
HttpMethod.Get,
(request ?? new GetSenderIdsRequest()).ToPath(),
body: null,
TermiiAuthenticationLocation.Query,
cancellationToken);
}

public Task<RequestSenderIdResponse> RequestAsync(
RequestSenderIdRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}

request.Validate();

return _pipeline.SendJsonAsync<RequestSenderIdResponse>(
HttpMethod.Post,
"/api/sender-id/request",
request,
TermiiAuthenticationLocation.Body,
cancellationToken);
}
}
3 changes: 3 additions & 0 deletions src/Termii/TermiiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
110 changes: 110 additions & 0 deletions tests/Termii.Tests/TermiiSenderIdClientTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => client.SenderIds.RequestAsync(
new RequestSenderIdRequest
{
SenderId = "",
UseCase = "Transactional alerts",
Company = "Teesoft Tech",
},
CancellationToken.None));
}
}
Loading