Files
WebDrop/tools/ContractGenerator/Program.cs
T

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 };
}