186 lines
7.1 KiB
C#
186 lines
7.1 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Localization;
|
|
using StellaOps.Platform.WebService.Constants;
|
|
using StellaOps.Platform.WebService.Services;
|
|
|
|
namespace StellaOps.Platform.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// REST API endpoints for translation management and serving UI bundles.
|
|
/// </summary>
|
|
public static class LocalizationEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
// Anonymous UI bundle endpoint (same pattern as /platform/envsettings.json)
|
|
app.MapGet("/platform/i18n/{locale}.json", async (
|
|
string locale,
|
|
HttpContext context,
|
|
PlatformTranslationService translationService,
|
|
CancellationToken ct) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
|
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct);
|
|
|
|
// Cache for 5 minutes — translations don't change often
|
|
context.Response.Headers.CacheControl = "public, max-age=300";
|
|
return Results.Ok(bundle);
|
|
})
|
|
.WithTags("Localization")
|
|
.WithName("GetUiTranslationBundle")
|
|
.WithSummary("Gets the merged translation bundle for the UI")
|
|
.WithDescription(
|
|
"Returns all translations for the specified locale, merging embedded defaults with DB overrides. " +
|
|
"Anonymous access, cacheable. Used by the Angular frontend at boot time.")
|
|
.AllowAnonymous();
|
|
|
|
// Authenticated API group
|
|
var group = app.MapGroup("/api/v1/platform/localization")
|
|
.WithTags("Localization")
|
|
.RequireAuthorization(PlatformPolicies.PreferencesRead)
|
|
.RequireTenant();
|
|
|
|
// Get all translations for a locale
|
|
group.MapGet("/bundles/{locale}", async (
|
|
string locale,
|
|
HttpContext context,
|
|
PlatformTranslationService translationService,
|
|
CancellationToken ct) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
|
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
locale,
|
|
strings = bundle,
|
|
count = bundle.Count
|
|
});
|
|
})
|
|
.WithName("GetTranslationBundle")
|
|
.WithSummary("Gets all translations for a locale")
|
|
.WithDescription(
|
|
"Returns the merged set of all translations for the specified locale, " +
|
|
"combining embedded defaults with system and tenant DB overrides in priority order.");
|
|
|
|
// Get translations filtered by namespace
|
|
group.MapGet("/bundles/{locale}/{ns}", async (
|
|
string locale,
|
|
string ns,
|
|
HttpContext context,
|
|
PlatformTranslationService translationService,
|
|
CancellationToken ct) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
|
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ns, ct);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
locale,
|
|
@namespace = ns,
|
|
strings = bundle,
|
|
count = bundle.Count
|
|
});
|
|
})
|
|
.WithName("GetTranslationBundleByNamespace")
|
|
.WithSummary("Gets translations for a locale filtered by namespace prefix");
|
|
|
|
// Get available locales
|
|
group.MapGet("/locales", async (
|
|
HttpContext context,
|
|
PlatformTranslationService translationService,
|
|
CancellationToken ct) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
|
var locales = await translationService.GetAllLocalesAsync(tenantId, ct);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
locales,
|
|
count = locales.Count
|
|
});
|
|
})
|
|
.WithName("GetAvailableLocales")
|
|
.WithSummary("Gets all available locales");
|
|
|
|
// Upsert translations (admin)
|
|
group.MapPut("/bundles", async (
|
|
UpsertTranslationsRequest request,
|
|
HttpContext context,
|
|
ITranslationStore store,
|
|
TranslationRegistry registry,
|
|
CancellationToken ct) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
|
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Locale))
|
|
{
|
|
return Results.BadRequest(new { error = "Locale is required." });
|
|
}
|
|
|
|
if (request.Strings is null || request.Strings.Count == 0)
|
|
{
|
|
return Results.BadRequest(new { error = "At least one translation string is required." });
|
|
}
|
|
|
|
await store.UpsertBatchAsync(tenantId, request.Locale, request.Strings, actor, ct);
|
|
|
|
// Also merge into the in-memory registry for immediate effect
|
|
registry.MergeBundles(request.Locale, request.Strings);
|
|
|
|
return Results.Ok(new
|
|
{
|
|
locale = request.Locale,
|
|
upserted = request.Strings.Count,
|
|
message = "Translations updated successfully."
|
|
});
|
|
})
|
|
.WithName("UpsertTranslations")
|
|
.WithSummary("Creates or updates translation strings")
|
|
.WithDescription(
|
|
"Upserts translation key-value pairs for a locale. DB values override embedded defaults. " +
|
|
"Changes take immediate effect in the in-memory registry.")
|
|
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
|
|
|
// Delete a translation (admin)
|
|
group.MapDelete("/strings/{locale}/{key}", async (
|
|
string locale,
|
|
string key,
|
|
HttpContext context,
|
|
ITranslationStore store,
|
|
CancellationToken ct) =>
|
|
{
|
|
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
|
|
|
var deleted = await store.DeleteAsync(tenantId, locale, key, ct);
|
|
if (!deleted)
|
|
{
|
|
return Results.NotFound(new { error = $"Translation '{key}' not found for locale '{locale}'." });
|
|
}
|
|
|
|
return Results.Ok(new { message = $"Translation '{key}' deleted for locale '{locale}'." });
|
|
})
|
|
.WithName("DeleteTranslation")
|
|
.WithSummary("Deletes a translation override")
|
|
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
|
|
|
return app;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to upsert translations.
|
|
/// </summary>
|
|
public sealed record UpsertTranslationsRequest
|
|
{
|
|
/// <summary>Target locale (e.g., "en-US", "de-DE").</summary>
|
|
public required string Locale { get; init; }
|
|
|
|
/// <summary>Key-value pairs to upsert.</summary>
|
|
public required Dictionary<string, string> Strings { get; init; }
|
|
}
|