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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/API_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions src/Termii/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Termii.Tests")]
5 changes: 5 additions & 0 deletions src/Termii/Termii.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>
8 changes: 8 additions & 0 deletions src/Termii/TermiiAuthenticationLocation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Termii;

internal enum TermiiAuthenticationLocation
{
None,
Query,
Body,
}
58 changes: 57 additions & 1 deletion src/Termii/TermiiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,69 @@ namespace Termii;
/// <summary>
/// Main entry point for the Termii SDK.
/// </summary>
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<HttpResponseMessage> 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,
};
}
}
105 changes: 105 additions & 0 deletions src/Termii/TermiiJsonHttpPipeline.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> 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());
}
}
9 changes: 8 additions & 1 deletion src/Termii/TermiiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@ 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))
{
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.");
}
}
}
34 changes: 34 additions & 0 deletions src/Termii/TermiiServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Extensions.DependencyInjection;

namespace Termii;

public static class TermiiServiceCollectionExtensions
{
public static IHttpClientBuilder AddTermii(
this IServiceCollection services,
Action<TermiiOptions> 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<TermiiClient>((serviceProvider, httpClient) =>
{
var termiiOptions = serviceProvider.GetRequiredService<TermiiOptions>();
httpClient.BaseAddress = termiiOptions.BaseUrl;
httpClient.Timeout = termiiOptions.Timeout;
});
}
}
1 change: 1 addition & 0 deletions tests/Termii.Tests/Termii.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
Expand Down
93 changes: 93 additions & 0 deletions tests/Termii.Tests/TermiiClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Termii;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Text.Json;
using Xunit;

namespace Termii.Tests;
Expand Down Expand Up @@ -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<TermiiClient>();

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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;

return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}"),
});
}
}
Loading