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