Skip to content

Commit cd5ac55

Browse files
committed
Add Termii HTTP pipeline
1 parent a641311 commit cd5ac55

11 files changed

Lines changed: 324 additions & 3 deletions

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ var client = new TermiiClient(new TermiiOptions
2626

2727
The default base URL is `https://api.ng.termii.com`.
2828

29+
For ASP.NET Core applications, register the SDK with dependency injection:
30+
31+
```csharp
32+
builder.Services.AddTermii(options =>
33+
{
34+
options.ApiKey = builder.Configuration["Termii:ApiKey"]!;
35+
});
36+
```
37+
2938
## Examples
3039

3140
Run the examples project after setting your Termii API key:

docs/API_COVERAGE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This matrix tracks the Termii API surface against SDK implementation work.
55
| Area | Capability | SDK status | Issue |
66
| --- | --- | --- | --- |
77
| Foundation | Solution, projects, examples, basic tests | In progress | #1 |
8-
| Foundation | Client configuration, authentication, HTTP pipeline | Planned | #2 |
8+
| Foundation | Client configuration, authentication, HTTP pipeline | In progress | #2 |
99
| Foundation | Error handling and validation | Planned | #6 |
1010
| Messaging | Send SMS and channel messages | Planned | #7 |
1111
| Messaging | Sender ID APIs | Planned | #3 |
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("Termii.Tests")]

src/Termii/Termii.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@
99
<GenerateDocumentationFile>true</GenerateDocumentationFile>
1010
<NoWarn>$(NoWarn);1591</NoWarn>
1111
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
15+
<PackageReference Include="System.Text.Json" Version="8.0.5" />
16+
</ItemGroup>
1217
</Project>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Termii;
2+
3+
internal enum TermiiAuthenticationLocation
4+
{
5+
None,
6+
Query,
7+
Body,
8+
}

src/Termii/TermiiClient.cs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,69 @@ namespace Termii;
33
/// <summary>
44
/// Main entry point for the Termii SDK.
55
/// </summary>
6-
public sealed class TermiiClient
6+
public sealed class TermiiClient : IDisposable
77
{
8+
private readonly HttpClient _httpClient;
9+
private readonly bool _ownsHttpClient;
10+
private readonly TermiiJsonHttpPipeline _pipeline;
11+
812
public TermiiClient(TermiiOptions options)
13+
: this(CreateDefaultHttpClient(options), options, ownsHttpClient: true)
14+
{
15+
}
16+
17+
public TermiiClient(HttpClient httpClient, TermiiOptions options)
18+
: this(httpClient, options, ownsHttpClient: false)
19+
{
20+
}
21+
22+
private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttpClient)
923
{
1024
Options = options ?? throw new ArgumentNullException(nameof(options));
1125
Options.Validate();
26+
27+
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
28+
_ownsHttpClient = ownsHttpClient;
29+
_httpClient.BaseAddress ??= Options.BaseUrl;
30+
31+
if (_ownsHttpClient)
32+
{
33+
_httpClient.Timeout = Options.Timeout;
34+
}
35+
36+
_pipeline = new TermiiJsonHttpPipeline(_httpClient, Options);
1237
}
1338

1439
public TermiiOptions Options { get; }
40+
41+
public void Dispose()
42+
{
43+
if (_ownsHttpClient)
44+
{
45+
_httpClient.Dispose();
46+
}
47+
}
48+
49+
internal Task<HttpResponseMessage> SendAsync(
50+
HttpMethod method,
51+
string path,
52+
object? body = null,
53+
TermiiAuthenticationLocation authenticationLocation = TermiiAuthenticationLocation.Query,
54+
CancellationToken cancellationToken = default)
55+
{
56+
return _pipeline.SendAsync(method, path, body, authenticationLocation, cancellationToken);
57+
}
58+
59+
private static HttpClient CreateDefaultHttpClient(TermiiOptions options)
60+
{
61+
if (options is null)
62+
{
63+
throw new ArgumentNullException(nameof(options));
64+
}
65+
66+
return new HttpClient
67+
{
68+
BaseAddress = options.BaseUrl,
69+
};
70+
}
1571
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Net.Http.Headers;
2+
using System.Text;
3+
using System.Text.Json;
4+
5+
namespace Termii;
6+
7+
internal sealed class TermiiJsonHttpPipeline
8+
{
9+
private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web)
10+
{
11+
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
12+
};
13+
14+
private readonly HttpClient _httpClient;
15+
private readonly TermiiOptions _options;
16+
17+
public TermiiJsonHttpPipeline(HttpClient httpClient, TermiiOptions options)
18+
{
19+
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
20+
_options = options ?? throw new ArgumentNullException(nameof(options));
21+
}
22+
23+
public Task<HttpResponseMessage> SendAsync(
24+
HttpMethod method,
25+
string path,
26+
object? body,
27+
TermiiAuthenticationLocation authenticationLocation,
28+
CancellationToken cancellationToken)
29+
{
30+
if (method is null)
31+
{
32+
throw new ArgumentNullException(nameof(method));
33+
}
34+
35+
if (string.IsNullOrWhiteSpace(path))
36+
{
37+
throw new ArgumentException("A request path is required.", nameof(path));
38+
}
39+
40+
var requestUri = authenticationLocation == TermiiAuthenticationLocation.Query
41+
? AppendApiKey(path)
42+
: path;
43+
44+
var request = new HttpRequestMessage(method, requestUri);
45+
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
46+
47+
if (body is not null || authenticationLocation == TermiiAuthenticationLocation.Body)
48+
{
49+
request.Content = CreateJsonContent(body, authenticationLocation);
50+
}
51+
52+
return _httpClient.SendAsync(request, cancellationToken);
53+
}
54+
55+
private string AppendApiKey(string path)
56+
{
57+
var separator = path.IndexOf("?", StringComparison.Ordinal) >= 0 ? "&" : "?";
58+
59+
return $"{path}{separator}api_key={Uri.EscapeDataString(_options.ApiKey)}";
60+
}
61+
62+
private HttpContent CreateJsonContent(object? body, TermiiAuthenticationLocation authenticationLocation)
63+
{
64+
var json = authenticationLocation == TermiiAuthenticationLocation.Body
65+
? SerializeBodyWithApiKey(body)
66+
: JsonSerializer.Serialize(body, JsonSerializerOptions);
67+
68+
return new StringContent(json, Encoding.UTF8, "application/json");
69+
}
70+
71+
private string SerializeBodyWithApiKey(object? body)
72+
{
73+
using var stream = new MemoryStream();
74+
75+
using (var writer = new Utf8JsonWriter(stream))
76+
{
77+
writer.WriteStartObject();
78+
writer.WriteString("api_key", _options.ApiKey);
79+
80+
if (body is not null)
81+
{
82+
using var document = JsonDocument.Parse(JsonSerializer.Serialize(body, JsonSerializerOptions));
83+
84+
if (document.RootElement.ValueKind != JsonValueKind.Object)
85+
{
86+
throw new InvalidOperationException("Termii JSON request bodies must serialize to an object.");
87+
}
88+
89+
foreach (var property in document.RootElement.EnumerateObject())
90+
{
91+
if (string.Equals(property.Name, "api_key", StringComparison.OrdinalIgnoreCase))
92+
{
93+
continue;
94+
}
95+
96+
property.WriteTo(writer);
97+
}
98+
}
99+
100+
writer.WriteEndObject();
101+
}
102+
103+
return Encoding.UTF8.GetString(stream.ToArray());
104+
}
105+
}

src/Termii/TermiiOptions.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,23 @@ public sealed class TermiiOptions
1111

1212
public Uri BaseUrl { get; set; } = new(DefaultBaseUrl);
1313

14+
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100);
15+
1416
internal void Validate()
1517
{
1618
if (string.IsNullOrWhiteSpace(ApiKey))
1719
{
1820
throw new InvalidOperationException("A Termii API key is required.");
1921
}
2022

21-
if (!BaseUrl.IsAbsoluteUri)
23+
if (BaseUrl is null || !BaseUrl.IsAbsoluteUri)
2224
{
2325
throw new InvalidOperationException("The Termii base URL must be absolute.");
2426
}
27+
28+
if (Timeout <= TimeSpan.Zero)
29+
{
30+
throw new InvalidOperationException("The Termii timeout must be greater than zero.");
31+
}
2532
}
2633
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
3+
namespace Termii;
4+
5+
public static class TermiiServiceCollectionExtensions
6+
{
7+
public static IHttpClientBuilder AddTermii(
8+
this IServiceCollection services,
9+
Action<TermiiOptions> configureOptions)
10+
{
11+
if (services is null)
12+
{
13+
throw new ArgumentNullException(nameof(services));
14+
}
15+
16+
if (configureOptions is null)
17+
{
18+
throw new ArgumentNullException(nameof(configureOptions));
19+
}
20+
21+
var options = new TermiiOptions();
22+
configureOptions(options);
23+
options.Validate();
24+
25+
services.AddSingleton(options);
26+
27+
return services.AddHttpClient<TermiiClient>((serviceProvider, httpClient) =>
28+
{
29+
var termiiOptions = serviceProvider.GetRequiredService<TermiiOptions>();
30+
httpClient.BaseAddress = termiiOptions.BaseUrl;
31+
httpClient.Timeout = termiiOptions.Timeout;
32+
});
33+
}
34+
}

tests/Termii.Tests/Termii.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
</PropertyGroup>
66

77
<ItemGroup>
8+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
89
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
910
<PackageReference Include="xunit" Version="2.9.2" />
1011
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">

0 commit comments

Comments
 (0)