using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
///
/// Api 层的入口:服务注册、中间件、端点映射。新增资源时在 里加一行。
/// Api 只调用 Application;错误码到 HTTP 状态的映射只在这一层做一次()。
///
public static class ApiModule
{
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
{
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.Configure(options =>
{
JsonSerializerOptions json = options.SerializerOptions;
json.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
json.DictionaryKeyPolicy = null;
json.DefaultIgnoreCondition = JsonIgnoreCondition.Never;
json.Converters.Add(new Rfc3339Converter());
json.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default);
});
// 框架自己产生的错误(请求体格式不对、路由不存在、未处理的异常)也统一成 problem+json,并带上 code 和 trace_id
services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
{
int status = context.ProblemDetails.Status ?? context.HttpContext.Response.StatusCode;
context.ProblemDetails.Extensions["code"] = DefaultCode(status);
context.ProblemDetails.Extensions["trace_id"] = System.Diagnostics.Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
if (status >= 500)
{
context.ProblemDetails.Detail = null;
}
});
services.AddOpenApi("v1");
return services;
}
///
/// 跨域(可选):只对 /api/public 和 /api/admin 生效,只放行名单里的来源,且不带凭据(Cookie 不会跨域),
/// 所以跨域的前端只能用 Bearer 令牌。需要放在认证和路由之前。
///
public static IApplicationBuilder UseMoShiroCors(this IApplicationBuilder app, string? origins)
{
string[] allowed = (origins ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (allowed.Length == 0)
{
return app;
}
bool any = allowed.Contains("*");
return app.Use(async (http, next) =>
{
string? origin = http.Request.Headers.Origin;
if (origin is null || !http.Request.Path.StartsWithSegments("/api") || !(any || allowed.Contains(origin.TrimEnd('/'), StringComparer.OrdinalIgnoreCase)))
{
await next(http);
return;
}
http.Response.Headers.AccessControlAllowOrigin = any ? "*" : origin;
http.Response.Headers.Vary = "Origin";
http.Response.Headers.AccessControlExposeHeaders = "Retry-After";
if (HttpMethods.IsOptions(http.Request.Method) && http.Request.Headers.ContainsKey("Access-Control-Request-Method"))
{
http.Response.Headers.AccessControlAllowMethods = "GET, POST, PATCH, PUT, DELETE, OPTIONS";
http.Response.Headers.AccessControlAllowHeaders = "Authorization, Content-Type, X-CSRF-Token";
http.Response.Headers.AccessControlMaxAge = "600";
http.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}
await next(http);
});
}
/// 认证中间件:解析 Bearer 令牌或会话 Cookie。需要放在路由之后、端点之前。
public static IApplicationBuilder UseMoShiroApi(this IApplicationBuilder app)
{
app.UseExceptionHandler();
app.UseStatusCodePages();
app.Use(PrincipalResolver.ResolveAsync);
return app;
}
public static IEndpointRouteBuilder MapMoShiroApi(this IEndpointRouteBuilder app)
{
RouteGroupBuilder publicGroup = app.MapGroup("/api/public").AddEndpointFilter(PublicGuard.InvokeAsync);
QueryEndpoints.Map(publicGroup);
LiveEndpoints.Map(publicGroup);
HostEndpoints.Map(publicGroup);
NodeEndpoints.Map(publicGroup);
CatalogEndpoints.Map(publicGroup);
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
TaskEndpoints.Map(guarded);
DataEndpoints.Map(guarded);
AuditEndpoints.Map(guarded);
TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded);
ReleaseKeyEndpoints.Map(guarded);
LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app);
BuildEndpoints.MapOffer(app);
app.MapOpenApi("/openapi/v1.json");
app.MapStaticSite();
return app;
}
private static string DefaultCode(int status) => status switch
{
400 => "validation_failed",
401 => "unauthorized",
403 => "forbidden",
404 => "not_found",
405 => "method_not_allowed",
413 => "payload_too_large",
415 => "unsupported_media_type",
429 => "too_many_requests",
503 => "storage_unavailable",
_ => "internal_error",
};
}