diff --git a/MiGu.Server/MiGu.Server.csproj b/MiGu.Server/MiGu.Server.csproj index 3e818bc..8bde202 100644 --- a/MiGu.Server/MiGu.Server.csproj +++ b/MiGu.Server/MiGu.Server.csproj @@ -20,6 +20,8 @@ + + diff --git a/MiGu.Server/OpenApi/SimpleLiteOpenApiDocumentFilter.cs b/MiGu.Server/OpenApi/SimpleLiteOpenApiDocumentFilter.cs new file mode 100644 index 0000000..07d1468 --- /dev/null +++ b/MiGu.Server/OpenApi/SimpleLiteOpenApiDocumentFilter.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace MiGu.Server.OpenApi; + +/// +/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。 +/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json +/// +public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter +{ + private static readonly Dictionary VerbMap = new(StringComparer.OrdinalIgnoreCase) + { + ["get"] = OperationType.Get, + ["post"] = OperationType.Post, + ["put"] = OperationType.Put, + ["patch"] = OperationType.Patch, + ["delete"] = OperationType.Delete, + ["head"] = OperationType.Head, + ["options"] = OperationType.Options, + ["trace"] = OperationType.Trace + }; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var json = TryLoadJson(); + if (json == null) return; + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("tags", out var tagsEl) && tagsEl.ValueKind == JsonValueKind.Array) + { + swaggerDoc.Tags ??= new List(); + foreach (var tag in tagsEl.EnumerateArray()) + { + if (!tag.TryGetProperty("name", out var nameEl)) continue; + var name = nameEl.GetString(); + if (string.IsNullOrEmpty(name) || swaggerDoc.Tags.Any(t => t.Name == name)) continue; + var desc = tag.TryGetProperty("description", out var d) ? d.GetString() : null; + swaggerDoc.Tags.Add(new OpenApiTag { Name = name, Description = desc }); + } + } + + if (!root.TryGetProperty("paths", out var pathsEl)) return; + + foreach (var pathProp in pathsEl.EnumerateObject()) + { + var fullPath = pathProp.Name.StartsWith("/api/sl", StringComparison.Ordinal) + ? pathProp.Name + : "/api/sl" + pathProp.Name; + + var pathItem = new OpenApiPathItem(); + foreach (var opProp in pathProp.Value.EnumerateObject()) + { + if (!VerbMap.TryGetValue(opProp.Name, out var verb)) continue; + pathItem.Operations[verb] = ParseOperation(opProp.Value); + } + + if (pathItem.Operations.Count > 0) + swaggerDoc.Paths[fullPath] = pathItem; + } + } + + private static OpenApiOperation ParseOperation(JsonElement el) + { + var op = new OpenApiOperation + { + Summary = el.TryGetProperty("summary", out var s) ? s.GetString() : null, + Description = el.TryGetProperty("description", out var d) ? d.GetString() : null + }; + + if (el.TryGetProperty("tags", out var tags) && tags.ValueKind == JsonValueKind.Array) + { + foreach (var t in tags.EnumerateArray()) + { + var name = t.GetString(); + if (!string.IsNullOrEmpty(name)) + op.Tags.Add(new OpenApiTag { Name = name }); + } + } + + if (el.TryGetProperty("parameters", out var parameters) && parameters.ValueKind == JsonValueKind.Array) + { + foreach (var p in parameters.EnumerateArray()) + { + var param = new OpenApiParameter + { + Name = p.TryGetProperty("name", out var n) ? n.GetString() : null, + In = p.TryGetProperty("in", out var loc) ? ParameterLocationFrom(loc.GetString()) : null, + Required = p.TryGetProperty("required", out var req) && req.GetBoolean(), + Description = p.TryGetProperty("description", out var pd) ? pd.GetString() : null + }; + if (p.TryGetProperty("schema", out var schema)) + param.Schema = ParseSchema(schema); + op.Parameters.Add(param); + } + } + + if (el.TryGetProperty("requestBody", out var body)) + op.RequestBody = ParseRequestBody(body); + + if (el.TryGetProperty("responses", out var responses)) + { + foreach (var resp in responses.EnumerateObject()) + { + op.Responses[resp.Name] = new OpenApiResponse + { + Description = resp.Value.TryGetProperty("description", out var rd) + ? rd.GetString() ?? "" + : "" + }; + } + } + + return op; + } + + private static OpenApiRequestBody? ParseRequestBody(JsonElement el) + { + if (!el.TryGetProperty("content", out var content)) return null; + var body = new OpenApiRequestBody(); + foreach (var ct in content.EnumerateObject()) + { + var media = new OpenApiMediaType(); + if (ct.Value.TryGetProperty("schema", out var schema)) + media.Schema = ParseSchema(schema); + body.Content[ct.Name] = media; + } + return body.Content.Count > 0 ? body : null; + } + + private static OpenApiSchema ParseSchema(JsonElement el) + { + var schema = new OpenApiSchema(); + if (el.TryGetProperty("type", out var t)) schema.Type = t.GetString(); + if (el.TryGetProperty("description", out var d)) schema.Description = d.GetString(); + if (el.TryGetProperty("$ref", out var r)) + { + var refId = r.GetString()?.TrimStart('#', '/'); + if (!string.IsNullOrEmpty(refId)) + schema.Reference = new OpenApiReference { Id = refId, Type = ReferenceType.Schema }; + } + return schema; + } + + private static ParameterLocation? ParameterLocationFrom(string? loc) => loc switch + { + "query" => ParameterLocation.Query, + "path" => ParameterLocation.Path, + "header" => ParameterLocation.Header, + "cookie" => ParameterLocation.Cookie, + _ => null + }; + + private static string? TryLoadJson() + { + foreach (var candidate in ResolveCandidatePaths()) + { + try + { + if (File.Exists(candidate)) + return File.ReadAllText(candidate); + } + catch { /* next */ } + } + return null; + } + + private static IEnumerable ResolveCandidatePaths() + { + var roots = new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() } + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var root in roots) + { + yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simplelite-projection.json")); + yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json")); + yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json")); + } + } +} diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index 1cea473..312fc6f 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -6,6 +6,7 @@ using Microsoft.OpenApi.Models; using MiGu.Server.Auth; using MiGu.Server.Configs; using MiGu.Server.Launcher; +using MiGu.Server.OpenApi; using Yarp.ReverseProxy.Transforms; static string? FindSourceContentRoot(string startDir) @@ -97,7 +98,13 @@ builder.Services.AddControllers() builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" }); + c.SwaggerDoc("v1", new() + { + Title = "MiGu.Server + SimpleLite", + Version = "v1", + Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。" + }); + c.DocumentFilter(); // Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。 c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {