Skip to content

Commit 63e561f

Browse files
committed
Added source-generated JSON context and typed request DTOs to eliminate AOT/trimming warnings in deploy API serialization.
Refactored DeployApiClient.cs to use JsonTypeInfo payload/response handling and typed payloads; removed dynamic options and reflection-based resolver. Updated DeployConfigStore.cs to serialize config via the generated context with indented output.
1 parent eac5e1b commit 63e561f

3 files changed

Lines changed: 244 additions & 58 deletions

File tree

Ivory.Infrastructure/Deploy/DeployApiClient.cs

Lines changed: 121 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Generic;
12
using System.Net.Http;
23
using System.Net.Http.Headers;
34
using System.Net.Http.Json;
@@ -18,39 +19,29 @@ public DeployApiClient(IHttpClientFactory httpClientFactory)
1819
_httpClientFactory = httpClientFactory;
1920
}
2021

21-
private readonly JsonSerializerOptions _jsonOptions = new()
22-
{
23-
PropertyNameCaseInsensitive = true,
24-
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
25-
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: true) },
26-
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
27-
};
28-
29-
private readonly JsonSerializerOptions _jsonOptionsNoEnum = new()
30-
{
31-
PropertyNameCaseInsensitive = true,
32-
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
33-
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
34-
};
35-
3622
public Task<LoginResult> LoginAsync(DeploySession session, string? tokenName, CancellationToken cancellationToken = default)
3723
{
3824
return SendAsync<LoginResult>(
3925
session,
4026
HttpMethod.Post,
4127
"/cli/login",
42-
new { name = tokenName },
43-
cancellationToken);
28+
new LoginRequest { Name = tokenName },
29+
GetTypeInfo<LoginResult>(),
30+
cancellationToken,
31+
payloadTypeInfo: GetTypeInfo<LoginRequest>());
4432
}
4533

46-
public Task<IReadOnlyList<OrgSummary>> GetOrgsAsync(DeploySession session, CancellationToken cancellationToken = default)
34+
public async Task<IReadOnlyList<OrgSummary>> GetOrgsAsync(DeploySession session, CancellationToken cancellationToken = default)
4735
{
48-
return SendAsync<IReadOnlyList<OrgSummary>>(
36+
var result = await SendAsync<List<OrgSummary>>(
4937
session,
5038
HttpMethod.Get,
5139
"/orgs",
5240
null,
53-
cancellationToken);
41+
GetTypeInfo<List<OrgSummary>>(),
42+
cancellationToken).ConfigureAwait(false);
43+
44+
return result;
5445
}
5546

5647
public Task<OrgSummary> CreateOrgAsync(DeploySession session, string name, CancellationToken cancellationToken = default)
@@ -59,19 +50,24 @@ public Task<OrgSummary> CreateOrgAsync(DeploySession session, string name, Cance
5950
session,
6051
HttpMethod.Post,
6152
"/orgs",
62-
new { name },
63-
cancellationToken);
53+
new CreateOrgRequest { Name = name },
54+
GetTypeInfo<OrgSummary>(),
55+
cancellationToken,
56+
payloadTypeInfo: GetTypeInfo<CreateOrgRequest>());
6457
}
6558

66-
public Task<IReadOnlyList<ProjectSummary>> GetProjectsAsync(DeploySession session, string orgName, CancellationToken cancellationToken = default)
59+
public async Task<IReadOnlyList<ProjectSummary>> GetProjectsAsync(DeploySession session, string orgName, CancellationToken cancellationToken = default)
6760
{
6861
var org = EnsureOrg(orgName);
69-
return SendAsync<IReadOnlyList<ProjectSummary>>(
62+
var result = await SendAsync<List<ProjectSummary>>(
7063
session,
7164
HttpMethod.Get,
7265
$"/orgs/{Segment(org)}/projects",
7366
null,
74-
cancellationToken);
67+
GetTypeInfo<List<ProjectSummary>>(),
68+
cancellationToken).ConfigureAwait(false);
69+
70+
return result;
7571
}
7672

7773
public Task<ProjectSummary> CreateProjectAsync(DeploySession session, string orgName, string name, CancellationToken cancellationToken = default)
@@ -81,8 +77,10 @@ public Task<ProjectSummary> CreateProjectAsync(DeploySession session, string org
8177
session,
8278
HttpMethod.Post,
8379
$"/orgs/{Segment(org)}/projects",
84-
new { name },
85-
cancellationToken);
80+
new CreateProjectRequest { Name = name },
81+
GetTypeInfo<ProjectSummary>(),
82+
cancellationToken,
83+
payloadTypeInfo: GetTypeInfo<CreateProjectRequest>());
8684
}
8785

8886
public Task<DeploymentCreated> CreateDeploymentAsync(
@@ -100,16 +98,18 @@ public Task<DeploymentCreated> CreateDeploymentAsync(
10098
session,
10199
HttpMethod.Post,
102100
"/cli/deploy",
103-
new
101+
new CreateDeploymentRequest
104102
{
105-
orgName = org,
106-
projectName = project,
107-
environment,
108-
branch,
109-
commitSha,
110-
artifactLocation
103+
OrgName = org,
104+
ProjectName = project,
105+
Environment = environment,
106+
Branch = branch,
107+
CommitSha = commitSha,
108+
ArtifactLocation = artifactLocation
111109
},
112-
cancellationToken);
110+
GetTypeInfo<DeploymentCreated>(),
111+
cancellationToken,
112+
payloadTypeInfo: GetTypeInfo<CreateDeploymentRequest>());
113113
}
114114

115115
public async Task<UploadedArtifact> UploadArtifactAsync(
@@ -166,7 +166,7 @@ public async Task<UploadedArtifact> UploadArtifactAsync(
166166
}
167167

168168
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
169-
var result = await JsonSerializer.DeserializeAsync<UploadedArtifact>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
169+
var result = await JsonSerializer.DeserializeAsync(responseStream, GetTypeInfo<UploadedArtifact>(), cancellationToken).ConfigureAwait(false);
170170

171171
if (result is null)
172172
{
@@ -183,6 +183,7 @@ public Task<DeploymentLogInfo> GetLogsAsync(DeploySession session, Guid deployme
183183
HttpMethod.Get,
184184
$"/cli/logs/{deploymentId}",
185185
null,
186+
GetTypeInfo<DeploymentLogInfo>(),
186187
cancellationToken);
187188
}
188189

@@ -195,6 +196,7 @@ public Task<EnvConfigResult> GetEnvironmentAsync(DeploySession session, string o
195196
HttpMethod.Get,
196197
$"/cli/env/{Segment(org)}/{Segment(project)}/{envSegment}",
197198
null,
199+
GetTypeInfo<EnvConfigResult>(),
198200
cancellationToken);
199201
}
200202

@@ -214,13 +216,13 @@ public async Task UpsertConfigAsync(
214216
{
215217
var (org, project) = EnsureNames(orgName, projectName);
216218
var envSegment = environment.ToString();
217-
await SendAsync<object>(
219+
await SendAsync(
218220
session,
219221
HttpMethod.Post,
220222
$"/orgs/{Segment(org)}/projects/{Segment(project)}/config/{envSegment}",
221223
request,
222224
cancellationToken,
223-
useEnumStrings: false).ConfigureAwait(false);
225+
payloadTypeInfo: GetTypeInfo<ConfigUpsertRequest>(useEnumStrings: false)).ConfigureAwait(false);
224226
}
225227

226228
public Task<RollbackResult> RollbackAsync(DeploySession session, string orgName, string projectName, Guid targetDeploymentId, CancellationToken cancellationToken = default)
@@ -230,13 +232,15 @@ public Task<RollbackResult> RollbackAsync(DeploySession session, string orgName,
230232
session,
231233
HttpMethod.Post,
232234
"/cli/rollback",
233-
new
235+
new RollbackRequest
234236
{
235-
orgName = org,
236-
projectName = project,
237-
targetDeploymentId
237+
OrgName = org,
238+
ProjectName = project,
239+
TargetDeploymentId = targetDeploymentId
238240
},
239-
cancellationToken);
241+
GetTypeInfo<RollbackResult>(),
242+
cancellationToken,
243+
payloadTypeInfo: GetTypeInfo<RollbackRequest>());
240244
}
241245

242246
public async Task<RegisterResult> RegisterUserAsync(string apiBaseUrl, string email, string password, CancellationToken cancellationToken = default)
@@ -250,9 +254,11 @@ public async Task<RegisterResult> RegisterUserAsync(string apiBaseUrl, string em
250254
new DeploySession(apiBaseUrl, string.Empty), // no auth header needed for register
251255
HttpMethod.Post,
252256
"/users/register",
253-
new { email, password },
254-
cancellationToken,
255-
includeUserHeader: false).ConfigureAwait(false);
257+
new RegisterUserRequest { Email = email, Password = password },
258+
responseTypeInfo: GetTypeInfo<RegisterResult>(),
259+
cancellationToken: cancellationToken,
260+
includeUserHeader: false,
261+
payloadTypeInfo: GetTypeInfo<RegisterUserRequest>()).ConfigureAwait(false);
256262
}
257263

258264
private async Task<IReadOnlyList<DomainInfo>> SendDomainsAsync(DeploySession session, string orgName, string projectName, CancellationToken cancellationToken)
@@ -262,12 +268,21 @@ private async Task<IReadOnlyList<DomainInfo>> SendDomainsAsync(DeploySession ses
262268
HttpMethod.Get,
263269
$"/cli/domains/{Segment(orgName)}/{Segment(projectName)}",
264270
null,
271+
GetTypeInfo<List<DomainInfo>>(),
265272
cancellationToken).ConfigureAwait(false);
266273

267274
return domains;
268275
}
269276

270-
private async Task<T> SendAsync<T>(DeploySession session, HttpMethod method, string path, object? payload, CancellationToken cancellationToken, bool includeUserHeader = true, bool useEnumStrings = true)
277+
private async Task<T> SendAsync<T>(
278+
DeploySession session,
279+
HttpMethod method,
280+
string path,
281+
object? payload,
282+
JsonTypeInfo<T> responseTypeInfo,
283+
CancellationToken cancellationToken,
284+
bool includeUserHeader = true,
285+
JsonTypeInfo? payloadTypeInfo = null)
271286
{
272287
var baseUri = BuildBaseUri(session.ApiBaseUrl);
273288
var requestUri = new Uri(baseUri, path);
@@ -280,8 +295,12 @@ private async Task<T> SendAsync<T>(DeploySession session, HttpMethod method, str
280295

281296
if (payload is not null)
282297
{
283-
var opts = useEnumStrings ? _jsonOptions : _jsonOptionsNoEnum;
284-
request.Content = JsonContent.Create(payload, options: opts);
298+
if (payloadTypeInfo is null)
299+
{
300+
throw new InvalidOperationException($"No JSON type info registered for payload type '{payload.GetType().Name}'.");
301+
}
302+
303+
request.Content = JsonContent.Create(payload, payloadTypeInfo);
285304
}
286305

287306
using var client = _httpClientFactory.CreateClient(HttpClientNames.Deploy);
@@ -305,7 +324,7 @@ private async Task<T> SendAsync<T>(DeploySession session, HttpMethod method, str
305324
}
306325

307326
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
308-
var result = await JsonSerializer.DeserializeAsync<T>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
327+
var result = await JsonSerializer.DeserializeAsync(stream, responseTypeInfo, cancellationToken).ConfigureAwait(false);
309328

310329
if (result is null)
311330
{
@@ -315,6 +334,50 @@ private async Task<T> SendAsync<T>(DeploySession session, HttpMethod method, str
315334
return result;
316335
}
317336

337+
private async Task SendAsync(
338+
DeploySession session,
339+
HttpMethod method,
340+
string path,
341+
object? payload,
342+
CancellationToken cancellationToken,
343+
bool includeUserHeader = true,
344+
JsonTypeInfo? payloadTypeInfo = null)
345+
{
346+
var baseUri = BuildBaseUri(session.ApiBaseUrl);
347+
var requestUri = new Uri(baseUri, path);
348+
349+
using var request = new HttpRequestMessage(method, requestUri);
350+
if (includeUserHeader)
351+
{
352+
request.Headers.Add("X-User-Email", session.UserEmail);
353+
}
354+
355+
if (payload is not null)
356+
{
357+
if (payloadTypeInfo is null)
358+
{
359+
throw new InvalidOperationException($"No JSON type info registered for payload type '{payload.GetType().Name}'.");
360+
}
361+
362+
request.Content = JsonContent.Create(payload, payloadTypeInfo);
363+
}
364+
365+
using var client = _httpClientFactory.CreateClient(HttpClientNames.Deploy);
366+
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
367+
368+
if (!response.IsSuccessStatusCode)
369+
{
370+
var detail = await SafeReadAsync(response, cancellationToken).ConfigureAwait(false);
371+
var message = $"API request failed ({(int)response.StatusCode} {response.ReasonPhrase}).";
372+
if (!string.IsNullOrWhiteSpace(detail))
373+
{
374+
message += $" {detail}";
375+
}
376+
377+
throw new InvalidOperationException(message);
378+
}
379+
}
380+
318381
private static string EnsureOrg(string orgName)
319382
{
320383
if (string.IsNullOrWhiteSpace(orgName))
@@ -347,6 +410,13 @@ private static Uri BuildBaseUri(string baseUrl)
347410
return uri;
348411
}
349412

413+
private static JsonTypeInfo<T> GetTypeInfo<T>(bool useEnumStrings = true)
414+
{
415+
var context = useEnumStrings ? (JsonSerializerContext)DeployJsonContext.Default : DeployJsonNumericContext.Default;
416+
return (JsonTypeInfo<T>?)context.GetTypeInfo(typeof(T))
417+
?? throw new InvalidOperationException($"No JSON type info registered for '{typeof(T).Name}'.");
418+
}
419+
350420
private static async Task<string> SafeReadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
351421
{
352422
try
@@ -358,4 +428,5 @@ private static async Task<string> SafeReadAsync(HttpResponseMessage response, Ca
358428
return string.Empty;
359429
}
360430
}
431+
361432
}

Ivory.Infrastructure/Deploy/DeployConfigStore.cs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,6 @@ namespace Ivory.Infrastructure.Deploy;
77
public sealed class DeployConfigStore : IDeployConfigStore
88
{
99
private readonly string _configPath;
10-
private readonly JsonSerializerOptions _options = new()
11-
{
12-
PropertyNameCaseInsensitive = true,
13-
WriteIndented = true,
14-
TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver()
15-
};
1610

1711
public DeployConfigStore()
1812
{
@@ -30,7 +24,7 @@ public async Task<DeployCliConfig> LoadAsync(CancellationToken cancellationToken
3024
try
3125
{
3226
await using var stream = File.OpenRead(_configPath);
33-
var config = await JsonSerializer.DeserializeAsync<DeployCliConfig>(stream, _options, cancellationToken).ConfigureAwait(false);
27+
var config = await JsonSerializer.DeserializeAsync(stream, DeployJsonContext.Default.DeployCliConfig, cancellationToken).ConfigureAwait(false);
3428
return config ?? new DeployCliConfig();
3529
}
3630
catch
@@ -45,6 +39,8 @@ public async Task SaveAsync(DeployCliConfig config, CancellationToken cancellati
4539
Directory.CreateDirectory(Path.GetDirectoryName(_configPath)!);
4640

4741
await using var stream = File.Create(_configPath);
48-
await JsonSerializer.SerializeAsync(stream, config, _options, cancellationToken).ConfigureAwait(false);
42+
await using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
43+
JsonSerializer.Serialize(writer, config, DeployJsonContext.Default.DeployCliConfig);
44+
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
4945
}
5046
}

0 commit comments

Comments
 (0)