using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.Json.Serialization; using System.Text.Json.Nodes; using WebDrop.Api; using WebDrop.Core; // Build-time reflection only; production serialization remains source-generated and AOT-safe. var root = Path.GetFullPath(args.FirstOrDefault(a => a != "--check") ?? "."); string[] sources = ["server/src/WebDrop.Api/Json/ApiJson.cs", "server/src/WebDrop.Core/Channels/Dtos.cs", "server/src/WebDrop.Core/Channels/Settings.cs", "server/src/WebDrop.Core/Common/Enums.cs", "server/src/WebDrop.Core/Spaces/Workspace.cs", "server/src/WebDrop.Core/Accounts/AccountProfile.cs", "server/src/WebDrop.Api/Passkeys/PasskeyDtos.cs", "server/src/WebDrop.Core/Spaces/WorkspaceCrypto.cs", "server/src/WebDrop.Api/Endpoints/Endpoints.WorkspaceCrypto.cs"]; var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", sources.Select(p => p + "\n" + File.ReadAllText(Path.Combine(root, p)).Replace("\r\n", "\n") + "\n"))))); var pending = new SortedDictionary(); var done = new HashSet(); foreach (var entry in typeof(ApiJson).GetCustomAttributesData().Where(a => a.AttributeType == typeof(JsonSerializableAttribute))) Add((Type)entry.ConstructorArguments[0].Value!); var output = new StringBuilder($"// Generated from backend DTOs; do not edit.\n// backend-contract-sha256: {digest}\n\n"); var nullable = new NullabilityInfoContext(); var definitions = new JsonObject(); while (pending.Count > 0) { var type = pending.First().Value; pending.Remove(type.Name); if (!done.Add(type)) continue; if (type.IsEnum) { var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute()?.Name ?? f.Name) + "'"); output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};"); output.AppendLine($"export const {type.Name} = {{ {string.Join(", ", type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + (f.GetCustomAttribute()?.Name ?? f.Name) + "'"))} }} as const;\n"); definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute()?.Name ?? f.Name) as JsonNode).ToArray()) }; continue; } output.AppendLine($"export interface {type.Name} {{"); var properties = new JsonObject(); var required = new JsonArray(); foreach (var prop in type.GetProperties().OrderBy(p => p.GetCustomAttribute()?.Name ?? p.Name, StringComparer.Ordinal)) { var ignore = prop.GetCustomAttribute(); if (ignore?.Condition == JsonIgnoreCondition.Always) continue; var jsonName = prop.GetCustomAttribute()?.Name; if (jsonName is null) throw new InvalidOperationException($"Wire property needs an explicit JSON name: {type.Name}.{prop.Name}"); var isNullable = Nullable.GetUnderlyingType(prop.PropertyType) is not null || nullable.Create(prop).ReadState == NullabilityState.Nullable; var optional = isNullable && ignore?.Condition != JsonIgnoreCondition.Never; output.AppendLine($" {jsonName}{(optional ? "?" : "")}: {Ts(prop.PropertyType)}{(isNullable ? " | null" : "")};"); properties[jsonName] = isNullable ? new JsonObject { ["anyOf"] = new JsonArray(Schema(prop.PropertyType), new JsonObject { ["type"] = "null" }) } : Schema(prop.PropertyType); if (!optional) required.Add(jsonName); } output.AppendLine("}"); definitions[type.Name] = new JsonObject { ["type"] = "object", ["properties"] = properties, ["required"] = required }; } // 不在 DTO 里、但前端要用的取值:错误码、令牌角色、空间权限、分享状态。一律由后端定义、生成常量 output.AppendLine("/** 错误响应里的 error 取值 */"); output.AppendLine($"export const ErrorCode = {{ {string.Join(", ", Enum.GetValues().Select(c => c + ": '" + c.ToWire() + "'"))} }} as const;"); output.AppendLine("export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];\n"); output.AppendLine("/** X-WD-Role 请求头:这次请求出示哪张令牌 */"); output.AppendLine($"export const TokenRole = {{ {string.Join(", ", Enum.GetValues().Where(r => r != Role.None).Select(r => r + ": '" + r.ToString().ToLowerInvariant() + "'"))} }} as const;"); output.AppendLine("export type TokenRole = (typeof TokenRole)[keyof typeof TokenRole];\n"); output.AppendLine("/** 空间权限(身份组授予) */"); output.AppendLine($"export const WorkspacePermission = {{ {string.Join(", ", typeof(WorkspacePermission).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy).Where(f => f.IsLiteral && f.FieldType == typeof(string) && f.Name != "Colors").Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;"); output.AppendLine("export type WorkspacePermission = (typeof WorkspacePermission)[keyof typeof WorkspacePermission];\n"); output.AppendLine("/** 便捷分享的状态 */"); output.AppendLine($"export const ShareState = {{ {string.Join(", ", typeof(ShareState).GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;"); output.AppendLine("export type ShareState = (typeof ShareState)[keyof typeof ShareState];"); var path = Path.Combine(root, "web/src/api/generated.ts"); var text = output.ToString().Replace("\r\n", "\n"); if (args.Contains("--check")) { if (!File.Exists(path) || File.ReadAllText(path).Replace("\r\n", "\n") != text) throw new InvalidOperationException("Generated API types are stale. Run ContractGenerator."); } else { Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, text, new UTF8Encoding(false)); } var schemaPath = Path.Combine(root, "contracts/api.schemas.json"); var schemaText = new JsonObject { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", ["x-backend-contract-sha256"] = digest, ["$defs"] = definitions }.ToJsonString(new() { WriteIndented = true }).Replace("\r\n", "\n") + "\n"; if (args.Contains("--check")) { if (!File.Exists(schemaPath) || File.ReadAllText(schemaPath).Replace("\r\n", "\n") != schemaText) throw new InvalidOperationException("Generated API schemas are stale."); } else File.WriteAllText(schemaPath, schemaText, new UTF8Encoding(false)); Console.WriteLine($"Backend API types {(args.Contains("--check") ? "verified" : "generated")}."); void Add(Type type) { type = Nullable.GetUnderlyingType(type) ?? type; if (type.IsArray) { Add(type.GetElementType()!); return; } if (type.IsGenericType) { foreach (var arg in type.GetGenericArguments()) Add(arg); return; } if ((type.Namespace is "WebDrop.Api" or "WebDrop.Core") && !done.Contains(type)) pending[type.Name] = type; } string Ts(Type type) { type = Nullable.GetUnderlyingType(type) ?? type; if (type == typeof(string)) return "string"; if (type == typeof(bool)) return "boolean"; if (type.IsPrimitive || type == typeof(decimal)) return "number"; if (type.IsArray) { Add(type.GetElementType()!); return Ts(type.GetElementType()!) + "[]"; } if (type.IsGenericType && type.GetGenericArguments().Length == 1) { var item = type.GetGenericArguments()[0]; Add(item); return Ts(item) + "[]"; } Add(type); return type.Name; } JsonObject Schema(Type type) { type = Nullable.GetUnderlyingType(type) ?? type; if (type == typeof(string)) return new() { ["type"] = "string" }; if (type == typeof(bool)) return new() { ["type"] = "boolean" }; if (type.IsPrimitive || type == typeof(decimal)) return new() { ["type"] = type == typeof(float) || type == typeof(double) || type == typeof(decimal) ? "number" : "integer" }; if (type.IsArray) return new() { ["type"] = "array", ["items"] = Schema(type.GetElementType()!) }; if (type.IsGenericType && type.GetGenericArguments().Length == 1) return new() { ["type"] = "array", ["items"] = Schema(type.GetGenericArguments()[0]) }; return new() { ["$ref"] = "#/$defs/" + type.Name }; }