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
81 changes: 11 additions & 70 deletions backend/LexBoxApi/Controllers/SyncController.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using LexBoxApi.Auth.Attributes;
using LexBoxApi.Services;
using LexCore.Auth;
using LexCore.Exceptions;
using LexCore.ServiceInterfaces;
using LexCore.Sync;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -25,26 +24,15 @@ public class SyncController(
public async Task<ActionResult<ProjectSyncStatus>> GetSyncStatus(Guid projectId)
{
if (!await permissionService.CanViewProject(projectId)) return Forbid();
var result = await fwHeadlessClient.CrdtSyncStatus(projectId);
if (result is null) return NotFound();
return Ok(result);
return Ok(await fwHeadlessClient.CrdtSyncStatus(projectId));
}

[HttpPost("trigger/{projectId}")]
[RequireScope(LexboxAuthScope.SendAndReceive, exclusive: false)]
public async Task<ActionResult> TriggerSync(Guid projectId)
{
if (!await permissionService.CanSyncProject(projectId)) return Forbid();
var (started, statusCode, error) = await fwHeadlessClient.SyncMercurialAndHarmony(projectId);
if (!started)
{
return statusCode switch
{
System.Net.HttpStatusCode.Locked => Problem(error ?? "Project is blocked from syncing", statusCode: StatusCodes.Status423Locked),
System.Net.HttpStatusCode.NotFound => Problem(error ?? "Project not found", statusCode: StatusCodes.Status404NotFound),
_ => Problem(error ?? "Failed to trigger sync", statusCode: (int)statusCode)
};
}
await fwHeadlessClient.SyncMercurialAndHarmony(projectId);
return Ok();
}

Expand Down Expand Up @@ -77,19 +65,8 @@ public async Task<ActionResult<SyncJobResult>> AwaitSyncFinished(Guid projectId)
public async Task<IActionResult> SyncHarmony(Guid projectId)
{
await permissionService.AssertCanSyncProject(projectId);
try
{
var result = await fwHeadlessClient.SyncHarmony(projectId);
if (result is not null)
{
return Problem(result);
}
return Ok();
}
catch (Exception e)
{
return Problem(e.Message);
}
await fwHeadlessClient.SyncHarmony(projectId);
return Ok();
}

/// <summary>
Expand All @@ -110,19 +87,8 @@ public async Task<IActionResult> SyncHarmony(Guid projectId)
public async Task<IActionResult> RegenerateProjectSnapshot(Guid projectId, [FromQuery] Guid? commitId = null, [FromQuery] bool preserveAllFieldWorksCommits = false)
{
await permissionService.AssertCanSyncProject(projectId);
try
{
var (success, statusCode, error) = await fwHeadlessClient.RegenerateProjectSnapshot(projectId, commitId, preserveAllFieldWorksCommits);
if (!success)
{
return Problem(error, statusCode: (int)statusCode);
}
return Ok();
}
catch (Exception e)
{
return Problem(e.Message);
}
await fwHeadlessClient.RegenerateProjectSnapshot(projectId, commitId, preserveAllFieldWorksCommits);
return Ok();
}

/// <summary>
Expand Down Expand Up @@ -161,19 +127,8 @@ public async Task<ActionResult> BlockProject(Guid? projectId = null, string? pro

await permissionService.AssertCanSyncProject(id.Value);

try
{
await fwHeadlessClient.BlockProject(id.Value, reason);
return Ok();
}
catch (NotFoundException)
{
return NotFound();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
await fwHeadlessClient.BlockProject(id.Value, reason);
return Ok();
}

[HttpPost("unblock")]
Expand All @@ -192,19 +147,8 @@ public async Task<ActionResult> UnblockProject(Guid? projectId = null, string? p

await permissionService.AssertCanSyncProject(id.Value);

try
{
await fwHeadlessClient.UnblockProject(id.Value);
return Ok();
}
catch (NotFoundException)
{
return NotFound();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
await fwHeadlessClient.UnblockProject(id.Value);
return Ok();
}

[HttpGet("block-status")]
Expand All @@ -224,10 +168,7 @@ public async Task<ActionResult<SyncBlockStatus>> GetBlockStatus(Guid? projectId
// Check permissions with the resolved projectId
await permissionService.AssertCanViewProject(id.Value);

var status = await fwHeadlessClient.GetBlockStatus(id.Value);
if (status is null)
return NotFound();
return status;
return await fwHeadlessClient.GetBlockStatus(id.Value);
}

private static bool IsProjectIdOrCodeProvided(Guid? projectId, string? projectCode)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using LexCore.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.Features;

Expand All @@ -19,6 +20,9 @@ public async Task HandleExceptionAsync(ErrorContext errorContext, Func<ErrorCont
RouteValues = features.Get<IRouteValuesFeature>()?.RouteValues
});
}
// the developer page always answers 500, so apply the FwHeadless status mapping here too, as in production
if (errorContext.Exception is FwHeadlessException)
httpContext.Response.StatusCode = ExceptionStatusCodes.Select(errorContext.Exception);
await next(errorContext);
}
}
31 changes: 31 additions & 0 deletions backend/LexBoxApi/ErrorHandling/ExceptionStatusCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using LexCore.Exceptions;

namespace LexBoxApi.ErrorHandling;

public static class ExceptionStatusCodes
{
/// <summary>
/// The response status for an unhandled exception, used as the exception handler's StatusCodeSelector.
/// </summary>
public static int Select(Exception exception)
{
return exception switch
{
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
FwHeadlessException fwHeadless => FromFwHeadless((int)fwHeadless.StatusCode),
_ => StatusCodes.Status500InternalServerError
};
}

// FwHeadless's client errors describe the caller's request, so they pass through.
// Its auth failures are between Lexbox and FwHeadless (they must not tell the caller they aren't logged in),
// and its server errors are ours, not the caller's, so both become 502.
private static int FromFwHeadless(int statusCode)
{
return statusCode is >= 400 and < 500
and not StatusCodes.Status401Unauthorized
and not StatusCodes.Status403Forbidden
? statusCode
: StatusCodes.Status502BadGateway;
}
}
10 changes: 4 additions & 6 deletions backend/LexBoxApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,16 @@
builder.Services.AddSingleton<IDeveloperPageExceptionFilter, AddExceptionFeatureDevExceptionFilter>();
builder.Services.AddExceptionHandler((options) =>
{
options.StatusCodeSelector = exception =>
{
if (exception is UnauthorizedAccessException)
return StatusCodes.Status401Unauthorized;
return StatusCodes.Status500InternalServerError;
};
options.StatusCodeSelector = ExceptionStatusCodes.Select;
});
builder.Services.AddProblemDetails(o =>
{
o.CustomizeProblemDetails = context =>
{
var exceptionHandlerFeature = context.HttpContext.Features.Get<IExceptionHandlerFeature>();
// the exception handler leaves detail empty; FwHeadless's message is meant for the caller
if (exceptionHandlerFeature?.Error is FwHeadlessException fwHeadlessException)
context.ProblemDetails.Detail = fwHeadlessException.Message;
Comment thread
myieye marked this conversation as resolved.
Resolved
if (exceptionHandlerFeature?.Error is not IExceptionWithCode exceptionWithCode) return;
context.ProblemDetails.Extensions["app-error-code"] = exceptionWithCode.Code;
};
Expand Down
88 changes: 47 additions & 41 deletions backend/LexBoxApi/Services/FwHeadlessClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,22 @@
using System.Text.Json;
using LexCore.Exceptions;
using LexCore.Sync;
using Microsoft.AspNetCore.Mvc;

namespace LexBoxApi.Services;

public class FwHeadlessClient(HttpClient httpClient, ILogger<FwHeadlessClient> logger)
{
public async Task<(bool success, HttpStatusCode statusCode, string? error)> SyncMercurialAndHarmony(Guid projectId)
public async Task SyncMercurialAndHarmony(Guid projectId)
{
var response = await httpClient.PostAsync($"/api/merge/execute?projectId={projectId}", null);
string? error = null;
if (!response.IsSuccessStatusCode)
{
logger.LogError("Failed to sync Mercurial and Harmony: {StatusCode} {StatusDescription}, projectId: {ProjectId}",
response.StatusCode,
response.ReasonPhrase,
projectId);
try
{
var problemDetails = JsonSerializer.Deserialize<ProblemDetails>(await response.Content.ReadAsStringAsync());
error = problemDetails?.Detail ?? problemDetails?.Title;
}
catch
{
error = response.ReasonPhrase;
}
throw await FailedResponse(response);
}

return (response.IsSuccessStatusCode, response.StatusCode, error);
}

/// <summary>
Expand All @@ -38,7 +26,7 @@ public class FwHeadlessClient(HttpClient httpClient, ILogger<FwHeadlessClient> l
public async Task<Guid[]> ReadyLexiconProjects(Guid[] projectIds)
{
var response = await httpClient.PostAsJsonAsync("/api/lexicon/projects/ready", projectIds);
response.EnsureSuccessStatusCode();
if (!response.IsSuccessStatusCode) throw await FailedResponse(response);
return await response.Content.ReadFromJsonAsync<Guid[]>() ?? [];
}

Expand All @@ -65,17 +53,18 @@ public async Task<SyncJobResult> AwaitStatus(Guid projectId, CancellationToken c
return jobResult;
}

public async Task<ProjectSyncStatus?> CrdtSyncStatus(Guid projectId)
public async Task<ProjectSyncStatus> CrdtSyncStatus(Guid projectId)
{
var response = await httpClient.GetAsync($"/api/merge/status?projectId={projectId}");
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<ProjectSyncStatus>();
return await response.Content.ReadFromJsonAsync<ProjectSyncStatus>()
?? throw new FwHeadlessException(HttpStatusCode.BadGateway, "FwHeadless returned no sync status");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
logger.LogError("Failed to get CRDT sync status: {StatusCode} {StatusDescription}, projectId: {ProjectId}, response: {Response}",
response.StatusCode,
response.ReasonPhrase,
projectId,
await response.Content.ReadAsStringAsync());
return null;
throw await FailedResponse(response);
}

public async Task DeleteRepo(Guid projectId, CancellationToken cancellationToken = default)
Expand All @@ -90,7 +79,7 @@ public async Task DeleteRepo(Guid projectId, CancellationToken cancellationToken
response.ReasonPhrase,
projectId,
await response.Content.ReadAsStringAsync(cancellationToken));
throw new InvalidOperationException($"Failed to delete repo {projectId}: {response.StatusCode} {response.ReasonPhrase}");
throw await FailedResponse(response, cancellationToken);
}

public async Task DeleteProject(Guid projectId, CancellationToken cancellationToken = default)
Expand All @@ -105,10 +94,10 @@ public async Task DeleteProject(Guid projectId, CancellationToken cancellationTo
response.ReasonPhrase,
projectId,
await response.Content.ReadAsStringAsync(cancellationToken));
throw new InvalidOperationException($"Failed to delete project {projectId}: {response.StatusCode} {response.ReasonPhrase}");
throw await FailedResponse(response, cancellationToken);
}

public async Task<(bool success, HttpStatusCode statusCode, string? error)> RegenerateProjectSnapshot(Guid projectId, Guid? commitId = null, bool preserveAllFieldWorksCommits = false)
public async Task RegenerateProjectSnapshot(Guid projectId, Guid? commitId = null, bool preserveAllFieldWorksCommits = false)
{
var url = $"/api/merge/regenerate-snapshot?projectId={projectId}";
if (commitId.HasValue)
Expand All @@ -126,13 +115,11 @@ public async Task DeleteProject(Guid projectId, CancellationToken cancellationTo
response.ReasonPhrase,
projectId,
responseBody);
return (false, response.StatusCode, responseBody);
throw await FailedResponse(response);
}

return (true, response.StatusCode, null);
}

internal async Task<string?> SyncHarmony(Guid projectId)
internal async Task SyncHarmony(Guid projectId)
{
var response = await httpClient.PostAsync($"/api/merge/sync-harmony?projectId={projectId}", null);
if (!response.IsSuccessStatusCode)
Expand All @@ -143,10 +130,8 @@ public async Task DeleteProject(Guid projectId, CancellationToken cancellationTo
response.ReasonPhrase,
projectId,
responseBody);
return responseBody;
throw await FailedResponse(response);
}

return null;
}

public async Task BlockProject(Guid projectId, string? reason = null)
Expand All @@ -164,11 +149,7 @@ public async Task BlockProject(Guid projectId, string? reason = null)
response.ReasonPhrase,
projectId,
responseBody);

if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotFoundException($"Project not found in FwHeadless: {projectId}", "Project");

throw new InvalidOperationException($"Failed to block project: {response.StatusCode} {response.ReasonPhrase}");
throw await FailedResponse(response);
}
}

Expand All @@ -184,27 +165,52 @@ public async Task UnblockProject(Guid projectId)
response.ReasonPhrase,
projectId,
responseBody);

if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotFoundException($"Project not found in FwHeadless: {projectId}", "Project");

throw new InvalidOperationException($"Failed to unblock project: {response.StatusCode} {response.ReasonPhrase}");
throw await FailedResponse(response);
}
}

public async Task<SyncBlockStatus?> GetBlockStatus(Guid projectId)
public async Task<SyncBlockStatus> GetBlockStatus(Guid projectId)
{
var url = $"/api/merge/block-status?projectId={projectId}";
var response = await httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<SyncBlockStatus>();
return await response.Content.ReadFromJsonAsync<SyncBlockStatus>()
?? throw new FwHeadlessException(HttpStatusCode.BadGateway, "FwHeadless returned no block status");

logger.LogError("Failed to get block status: {StatusCode} {StatusDescription}, projectId: {ProjectId}, response: {Response}",
response.StatusCode,
response.ReasonPhrase,
projectId,
await response.Content.ReadAsStringAsync());
return null;
throw await FailedResponse(response);
}

private static async Task<FwHeadlessException> FailedResponse(HttpResponseMessage response, CancellationToken cancellationToken = default)
{
return new FwHeadlessException(response.StatusCode, ReadDetail(await response.Content.ReadAsStringAsync(cancellationToken)));
}

/// <summary>
/// The message in a FwHeadless error body: a problem+json detail (or title), a JSON string such as a NotFound&lt;string&gt;, or plain text.
/// </summary>
private static string? ReadDetail(string body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try
{
using var json = JsonDocument.Parse(body);
var root = json.RootElement;
if (root.ValueKind == JsonValueKind.String) return root.GetString();
if (root.ValueKind == JsonValueKind.Object)
{
if (root.TryGetProperty("detail", out var detail) && detail.ValueKind == JsonValueKind.String) return detail.GetString();
if (root.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String) return title.GetString();
}
}
catch (JsonException)
{
// plain text
}
return body;
}
}
Loading
Loading