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("{}"),
+ });
+ }
}