|
4 | 4 |
|
5 | 5 | namespace Sockseek.Api; |
6 | 6 |
|
| 7 | +public sealed record CursorPage<T>(IReadOnlyList<T> Items, string? NextCursor); |
| 8 | +public sealed record SequencePage<T>(IReadOnlyList<T> Items, long? NextSequence); |
| 9 | +public sealed record AttemptPage<T>(IReadOnlyList<T> Items, int? NextAttemptNumber); |
| 10 | + |
| 11 | +public sealed record TransferHistoryFilter( |
| 12 | + Guid? JobId = null, |
| 13 | + Guid? WorkflowId = null, |
| 14 | + string? Direction = null, |
| 15 | + string? Source = null, |
| 16 | + string? State = null, |
| 17 | + string? TerminalOutcome = null, |
| 18 | + string? Username = null, |
| 19 | + DateTimeOffset? FromUtc = null, |
| 20 | + DateTimeOffset? ToUtc = null); |
| 21 | + |
7 | 22 | /// <summary>Exception raised for daemon HTTP responses that intentionally return an API error body.</summary> |
8 | 23 | public sealed class SockseekApiRequestException : InvalidOperationException |
9 | 24 | { |
@@ -85,16 +100,25 @@ public async Task<IReadOnlyList<ProfileSummaryDto>> GetProfilesAsync(Cancellatio |
85 | 100 | } |
86 | 101 |
|
87 | 102 | public async Task<IReadOnlyList<JobSummaryDto>> GetJobsAsync(JobQuery query, CancellationToken ct = default) |
| 103 | + => (await GetJobsPageAsync(query, cursor: null, limit: 100, ct)).Items; |
| 104 | + |
| 105 | + public async Task<CursorPage<JobSummaryDto>> GetJobsPageAsync( |
| 106 | + JobQuery query, |
| 107 | + string? cursor = null, |
| 108 | + int limit = 100, |
| 109 | + CancellationToken ct = default) |
88 | 110 | { |
89 | 111 | var url = "api/jobs" |
90 | 112 | + $"?includeAll={query.IncludeAll.ToString().ToLowerInvariant()}" |
91 | 113 | + QueryPart("lifecycleState", query.LifecycleState?.ToString()) |
92 | 114 | + QueryPart("terminalOutcome", query.TerminalOutcome?.ToString()) |
93 | 115 | + QueryPart("skipReason", query.SkipReason?.ToString()) |
94 | 116 | + QueryPart("kind", query.Kind?.ToWireString()) |
95 | | - + QueryPart("workflowId", query.WorkflowId?.ToString()); |
| 117 | + + QueryPart("workflowId", query.WorkflowId?.ToString()) |
| 118 | + + QueryPart("cursor", cursor) |
| 119 | + + QueryPart("limit", limit.ToString(System.Globalization.CultureInfo.InvariantCulture)); |
96 | 120 |
|
97 | | - return await http.GetFromJsonAsync<IReadOnlyList<JobSummaryDto>>(url, jsonOptions, ct) ?? []; |
| 121 | + return await GetCursorPageAsync<JobSummaryDto>(url, ct); |
98 | 122 | } |
99 | 123 |
|
100 | 124 | public async Task<JobDetailDto?> GetJobDetailAsync(Guid jobId, CancellationToken ct = default) |
@@ -130,6 +154,104 @@ public async Task<IReadOnlyList<JobSummaryDto>> GetJobsAsync(JobQuery query, Can |
130 | 154 | return await ReadRequiredAsync<WorkflowDetailDto>(response, ct); |
131 | 155 | } |
132 | 156 |
|
| 157 | + public async Task<CursorPage<WorkflowSummaryDto>> GetWorkflowsPageAsync( |
| 158 | + string? cursor = null, |
| 159 | + int limit = 100, |
| 160 | + CancellationToken ct = default) |
| 161 | + => await GetCursorPageAsync<WorkflowSummaryDto>( |
| 162 | + "api/workflows?limit=" + limit.ToString(System.Globalization.CultureInfo.InvariantCulture) |
| 163 | + + QueryPart("cursor", cursor), |
| 164 | + ct); |
| 165 | + |
| 166 | + public async Task<WorkflowTreeDto?> GetWorkflowTreeAsync(Guid workflowId, CancellationToken ct = default) |
| 167 | + { |
| 168 | + using var response = await http.GetAsync($"api/workflows/{workflowId}/tree", ct); |
| 169 | + if (response.StatusCode == HttpStatusCode.NotFound) |
| 170 | + return null; |
| 171 | + await EnsureSuccessAsync(response, ct); |
| 172 | + return await ReadRequiredAsync<WorkflowTreeDto>(response, ct); |
| 173 | + } |
| 174 | + |
| 175 | + public async Task<SequencePage<SearchRawResultDto>?> GetRawSearchResultsPageAsync( |
| 176 | + Guid jobId, |
| 177 | + long afterSequence = 0, |
| 178 | + int limit = 200, |
| 179 | + CancellationToken ct = default) |
| 180 | + { |
| 181 | + using var response = await http.GetAsync( |
| 182 | + $"api/jobs/{jobId}/raw?afterSequence={afterSequence}&limit={limit}", ct); |
| 183 | + if (response.StatusCode == HttpStatusCode.NotFound) |
| 184 | + return null; |
| 185 | + await EnsureSuccessAsync(response, ct); |
| 186 | + return new SequencePage<SearchRawResultDto>( |
| 187 | + await ReadRequiredAsync<IReadOnlyList<SearchRawResultDto>>(response, ct), |
| 188 | + HeaderLong(response, "X-Next-Sequence")); |
| 189 | + } |
| 190 | + |
| 191 | + public async Task<CursorPage<TransferHistoryDto>> GetTransfersPageAsync( |
| 192 | + TransferHistoryFilter? query = null, |
| 193 | + string? cursor = null, |
| 194 | + int limit = 100, |
| 195 | + CancellationToken ct = default) |
| 196 | + { |
| 197 | + query ??= new TransferHistoryFilter(); |
| 198 | + string url = "api/transfers?limit=" + limit.ToString(System.Globalization.CultureInfo.InvariantCulture) |
| 199 | + + QueryPart("jobId", query.JobId?.ToString()) |
| 200 | + + QueryPart("workflowId", query.WorkflowId?.ToString()) |
| 201 | + + QueryPart("direction", query.Direction) |
| 202 | + + QueryPart("source", query.Source) |
| 203 | + + QueryPart("state", query.State) |
| 204 | + + QueryPart("terminalOutcome", query.TerminalOutcome) |
| 205 | + + QueryPart("username", query.Username) |
| 206 | + + QueryPart("fromUtc", query.FromUtc?.ToString("O")) |
| 207 | + + QueryPart("toUtc", query.ToUtc?.ToString("O")) |
| 208 | + + QueryPart("cursor", cursor); |
| 209 | + return await GetCursorPageAsync<TransferHistoryDto>(url, ct); |
| 210 | + } |
| 211 | + |
| 212 | + public async Task<TransferHistoryDetailDto?> GetTransferAsync( |
| 213 | + Guid transferId, |
| 214 | + int attemptLimit = 200, |
| 215 | + CancellationToken ct = default) |
| 216 | + { |
| 217 | + using var response = await http.GetAsync($"api/transfers/{transferId}?attemptLimit={attemptLimit}", ct); |
| 218 | + if (response.StatusCode == HttpStatusCode.NotFound) |
| 219 | + return null; |
| 220 | + await EnsureSuccessAsync(response, ct); |
| 221 | + return await ReadRequiredAsync<TransferHistoryDetailDto>(response, ct); |
| 222 | + } |
| 223 | + |
| 224 | + public async Task<AttemptPage<TransferAttemptHistoryDto>?> GetTransferAttemptsPageAsync( |
| 225 | + Guid transferId, |
| 226 | + int afterAttemptNumber = 0, |
| 227 | + int limit = 100, |
| 228 | + CancellationToken ct = default) |
| 229 | + { |
| 230 | + using var response = await http.GetAsync( |
| 231 | + $"api/transfers/{transferId}/attempts?afterAttemptNumber={afterAttemptNumber}&limit={limit}", ct); |
| 232 | + if (response.StatusCode == HttpStatusCode.NotFound) |
| 233 | + return null; |
| 234 | + await EnsureSuccessAsync(response, ct); |
| 235 | + return new AttemptPage<TransferAttemptHistoryDto>( |
| 236 | + await ReadRequiredAsync<IReadOnlyList<TransferAttemptHistoryDto>>(response, ct), |
| 237 | + HeaderInt(response, "X-Next-Attempt-Number")); |
| 238 | + } |
| 239 | + |
| 240 | + public async Task<PersistenceIntegrityResultDto> CheckPersistenceIntegrityAsync(CancellationToken ct = default) |
| 241 | + => await PostWithoutBodyAsync<PersistenceIntegrityResultDto>("api/persistence/integrity", ct); |
| 242 | + |
| 243 | + public async Task<PersistenceBackupResultDto> BackupPersistenceAsync( |
| 244 | + PersistenceBackupRequestDto request, |
| 245 | + CancellationToken ct = default) |
| 246 | + => await PostRequiredAsync<PersistenceBackupResultDto, PersistenceBackupRequestDto>( |
| 247 | + "api/persistence/backup", request, ct); |
| 248 | + |
| 249 | + public async Task<PersistenceCheckpointResultDto> CheckpointPersistenceAsync(CancellationToken ct = default) |
| 250 | + => await PostWithoutBodyAsync<PersistenceCheckpointResultDto>("api/persistence/checkpoint", ct); |
| 251 | + |
| 252 | + public async Task<PersistenceRetentionResultDto> RunPersistenceRetentionAsync(CancellationToken ct = default) |
| 253 | + => await PostWithoutBodyAsync<PersistenceRetentionResultDto>("api/persistence/retention", ct); |
| 254 | + |
133 | 255 | public async Task<SearchResultSnapshotDto<FileCandidateDto>?> GetFileResultsAsync(Guid jobId, CancellationToken ct = default) |
134 | 256 | { |
135 | 257 | using var response = await http.GetAsync($"api/jobs/{jobId}/results/files", ct); |
@@ -325,6 +447,29 @@ private async Task<JobSummaryDto> PostJobAsync<TRequest>(string url, TRequest re |
325 | 447 | return await ReadRequiredAsync<TResponse>(response, ct); |
326 | 448 | } |
327 | 449 |
|
| 450 | + private async Task<TResponse> PostRequiredAsync<TResponse, TRequest>(string url, TRequest request, CancellationToken ct) |
| 451 | + { |
| 452 | + using var response = await http.PostAsJsonAsync(url, request, jsonOptions, ct); |
| 453 | + await EnsureSuccessAsync(response, ct); |
| 454 | + return await ReadRequiredAsync<TResponse>(response, ct); |
| 455 | + } |
| 456 | + |
| 457 | + private async Task<TResponse> PostWithoutBodyAsync<TResponse>(string url, CancellationToken ct) |
| 458 | + { |
| 459 | + using var response = await http.PostAsync(url, content: null, ct); |
| 460 | + await EnsureSuccessAsync(response, ct); |
| 461 | + return await ReadRequiredAsync<TResponse>(response, ct); |
| 462 | + } |
| 463 | + |
| 464 | + private async Task<CursorPage<T>> GetCursorPageAsync<T>(string url, CancellationToken ct) |
| 465 | + { |
| 466 | + using var response = await http.GetAsync(url, ct); |
| 467 | + await EnsureSuccessAsync(response, ct); |
| 468 | + return new CursorPage<T>( |
| 469 | + await ReadRequiredAsync<IReadOnlyList<T>>(response, ct), |
| 470 | + Header(response, "X-Next-Cursor")); |
| 471 | + } |
| 472 | + |
328 | 473 | private async Task<T> ReadRequiredAsync<T>(HttpResponseMessage response, CancellationToken ct) |
329 | 474 | => await response.Content.ReadFromJsonAsync<T>(jsonOptions, ct) |
330 | 475 | ?? throw new InvalidOperationException($"Server returned an empty {typeof(T).Name} response."); |
@@ -354,6 +499,15 @@ private static async Task EnsureSuccessAsync(HttpResponseMessage response, Cance |
354 | 499 | private static string QueryPart(string name, string? value) |
355 | 500 | => string.IsNullOrWhiteSpace(value) ? "" : $"&{Uri.EscapeDataString(name)}={Uri.EscapeDataString(value)}"; |
356 | 501 |
|
| 502 | + private static string? Header(HttpResponseMessage response, string name) |
| 503 | + => response.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null; |
| 504 | + |
| 505 | + private static long? HeaderLong(HttpResponseMessage response, string name) |
| 506 | + => long.TryParse(Header(response, name), out long value) ? value : null; |
| 507 | + |
| 508 | + private static int? HeaderInt(HttpResponseMessage response, string name) |
| 509 | + => int.TryParse(Header(response, name), out int value) ? value : null; |
| 510 | + |
357 | 511 | private static bool IsActiveLifecycle(ServerJobLifecycleState state) |
358 | 512 | => state != ServerJobLifecycleState.Terminal; |
359 | 513 | } |
0 commit comments