93 lines
6.2 KiB
C#
93 lines
6.2 KiB
C#
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/ApiJson.cs", "server/src/WebDrop.Core/Dtos.cs", "server/src/WebDrop.Core/Settings.cs", "server/src/WebDrop.Core/Enums.cs", "server/src/WebDrop.Core/Workspace.cs", "server/src/WebDrop.Core/AccountProfile.cs", "server/src/WebDrop.Api/PasskeyDtos.cs", "server/src/WebDrop.Core/WorkspaceCrypto.cs", "server/src/WebDrop.Api/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<string, Type>();
|
|
var done = new HashSet<Type>();
|
|
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<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) + "'");
|
|
output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};\n");
|
|
definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.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<JsonPropertyNameAttribute>()?.Name ?? p.Name, StringComparer.Ordinal))
|
|
{
|
|
var ignore = prop.GetCustomAttribute<JsonIgnoreAttribute>();
|
|
if (ignore?.Condition == JsonIgnoreCondition.Always) continue;
|
|
var jsonName = prop.GetCustomAttribute<JsonPropertyNameAttribute>()?.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 };
|
|
}
|
|
var path = Path.Combine(root, "web/src/core-bridge/generated/api.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 };
|
|
}
|