Stub generators

This commit is contained in:
2026-07-18 04:46:41 +03:00
parent ac36a3ddc7
commit cfb7f83899
6 changed files with 290 additions and 6 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ using System.Text;
namespace Esiur.Data
{
internal class TruComposite : Tru
public class TruComposite : Tru
{
public Tru[] SubTypes;
+41
View File
@@ -2,6 +2,7 @@ using System.Diagnostics;
using Esiur.CLI.Authentication;
using Esiur.CLI.Client;
using Esiur.CLI.Configuration;
using Esiur.CLI.Generation;
using Esiur.CLI.Rendering;
namespace Esiur.CLI;
@@ -76,6 +77,7 @@ public sealed class CliApplication
"query" => await QueryAsync(tokens, configuration, global, cancellation.Token),
"describe" => await DescribeAsync(tokens, configuration, global, cancellation.Token),
"get" => await GetAsync(tokens, configuration, global, cancellation.Token),
"generate" => await GenerateAsync(tokens, configuration, global, cancellation.Token),
_ => throw new CliException($"Unknown command \"{command}\".", ExitCodes.InvalidArguments),
};
}
@@ -302,6 +304,44 @@ public sealed class CliApplication
return ExitCodes.Success;
}
async Task<int> GenerateAsync(
List<string> tokens, CliConfiguration configuration, GlobalOptions global,
CancellationToken cancellationToken)
{
if (tokens.Count < 2)
throw new CliException("Usage: esiur generate typescript <path> [--out <file>]", ExitCodes.InvalidArguments);
var language = Take(tokens).ToLowerInvariant();
if (language != "typescript")
throw new CliException($"Unsupported target language \"{language}\". Supported: typescript.", ExitCodes.InvalidArguments);
var path = Take(tokens);
var outFile = TakeOption(tokens, "--out");
EnsureEmpty(tokens);
var settings = ConfigurationResolver.Resolve(configuration, global);
using var timeout = CreateTimeout(settings.Timeout, cancellationToken);
try
{
await using var session = await sessions.ConnectAsync(settings, false, input, error, timeout.Token);
var resource = await resources.ResolveAsync(session, path, timeout.Token);
var source = TypeScriptStubGenerator.Generate(resource.Instance.Definition, ResourceInspectionService.NormalizePath(path));
if (outFile is null)
{
await output.WriteAsync(source);
}
else
{
var directory = Path.GetDirectoryName(Path.GetFullPath(outFile));
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
await File.WriteAllTextAsync(outFile, source, timeout.Token);
await output.WriteLineAsync($"Wrote {outFile}");
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new CliException("The operation timed out.", ExitCodes.Timeout);
}
return ExitCodes.Success;
}
static CancellationTokenSource CreateTimeout(TimeSpan duration, CancellationToken parent)
{
var source = CancellationTokenSource.CreateLinkedTokenSource(parent);
@@ -386,6 +426,7 @@ public sealed class CliApplication
query <path> [--recursive|--depth <number>] [--type <name>]
describe <path> [--values|--schema-only]
get <path> <property> [property...]
generate typescript <path> [--out <file>]
Global options:
--profile <name> Use a saved profile
+15 -3
View File
@@ -60,7 +60,8 @@ public sealed class EsiurSessionFactory(ICredentialService credentials) : IEsiur
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
if (settings.Timeout > TimeSpan.Zero) timeout.CancelAfter(settings.Timeout);
var endpoint = EndpointParser.ConnectionEndpoint(settings.Endpoint);
var (endpoint, webSocketUri) = EndpointParser.Parse(settings.Endpoint);
if (webSocketUri is not null) context.WebSocketUri = webSocketUri;
var connectTask = AwaitReply(warehouse.Get<EpConnection>(endpoint, context), timeout.Token);
var connection = await connectTask;
if (connection is null)
@@ -126,10 +127,21 @@ public sealed class EsiurSession : IAsyncDisposable
public static class EndpointParser
{
public static string ConnectionEndpoint(string endpoint)
/// <summary>
/// Splits an endpoint into the <c>ep://host:port</c> string used to identify
/// the connection to <see cref="Warehouse.Get{T}"/>, and — for <c>ws(s)://</c>
/// endpoints with a path — the literal <see cref="Uri"/> to dial as
/// <see cref="Esiur.Protocol.EpConnectionContext.WebSocketUri"/> instead of
/// deriving the socket URL from host/port alone.
/// </summary>
public static (string ConnectEndpoint, Uri? WebSocketUri) Parse(string endpoint)
{
ConfigurationResolver.ValidateEndpoint(endpoint);
var uri = new Uri(endpoint);
return $"ep://{uri.Authority}";
var connectEndpoint = $"ep://{uri.Authority}";
var isWebSocketScheme = uri.Scheme is "ws" or "wss";
return isWebSocketScheme ? (connectEndpoint, uri) : (connectEndpoint, null);
}
public static string ConnectionEndpoint(string endpoint) => Parse(endpoint).ConnectEndpoint;
}
@@ -60,13 +60,22 @@ public static class ConfigurationResolver
profile);
}
static readonly string[] ValidSchemes = ["ep", "eps", "ws", "wss"];
/// <summary>
/// Accepts <c>ep(s)://host:port</c> (a bare Esiur endpoint, connects at the
/// WebSocket root) as well as <c>ws(s)://host:port/path</c> (for hosts like
/// ASP.NET Core's <c>MapEsiur("/esiur")</c> that mount the WebSocket route
/// somewhere other than root) — see <see cref="EndpointParser"/> for how
/// the two forms are dialed.
/// </summary>
public static void ValidateEndpoint(string endpoint)
{
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)
|| !string.Equals(uri.Scheme, "ep", StringComparison.OrdinalIgnoreCase)
|| !ValidSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(uri.Host)
|| !string.IsNullOrEmpty(uri.UserInfo))
throw new CliException($"Endpoint \"{endpoint}\" is not a valid ep:// endpoint.", ExitCodes.InvalidArguments);
throw new CliException($"Endpoint \"{endpoint}\" is not a valid ep:// or ws:// endpoint.", ExitCodes.InvalidArguments);
}
}
@@ -0,0 +1,142 @@
using System.Text;
using System.Text.RegularExpressions;
using Esiur.Data;
using Esiur.Data.Types;
namespace Esiur.CLI.Generation;
/// <summary>
/// Emits TypeScript interfaces (and enums) for a resource's <see cref="TypeDef"/>
/// and every type it transitively references, for use with esiur-ts's
/// `connection.get&lt;T&gt;(path)` / `warehouse.get&lt;T&gt;(url, ...)`. Since
/// esiur-ts resolves resources dynamically at runtime (TypeDefs are fetched
/// over the wire, not compiled in), the generated output is pure type-level
/// TypeScript — no runtime code is needed for the interfaces themselves.
/// </summary>
public static class TypeScriptStubGenerator
{
public static string Generate(TypeDef root, string sourcePath)
{
var discovered = new Dictionary<ulong, TypeDef>();
var order = new List<TypeDef>();
Collect(root, discovered, order);
var names = AssignNames(order);
var usesDecimal128 = order.Any(typeDef =>
typeDef.Properties.Any(p => TypeScriptTypeMapper.UsesDecimal128(p.ValueType))
|| typeDef.Functions.Any(f => TypeScriptTypeMapper.UsesDecimal128(f.ReturnType)
|| (f.Arguments ?? []).Any(a => TypeScriptTypeMapper.UsesDecimal128(a.Type)))
|| typeDef.Events.Any(e => TypeScriptTypeMapper.UsesDecimal128(e.ArgumentType))
|| typeDef.Constants.Any(c => TypeScriptTypeMapper.UsesDecimal128(c.ValueType)));
var sb = new StringBuilder();
sb.AppendLine($"// Generated by `esiur generate typescript {sourcePath}` — do not edit by hand.");
sb.AppendLine($"// Regenerate with: esiur generate typescript {sourcePath} --out <file>");
if (usesDecimal128) sb.AppendLine("import type { Decimal128 } from \"esiur\";");
sb.AppendLine();
foreach (var typeDef in order) EmitType(sb, typeDef, names);
return sb.ToString();
}
static void Collect(TypeDef typeDef, Dictionary<ulong, TypeDef> discovered, List<TypeDef> order)
{
if (!discovered.TryAdd(typeDef.Id, typeDef)) return;
order.Add(typeDef);
foreach (var property in typeDef.Properties) CollectFromTru(property.ValueType, discovered, order);
foreach (var function in typeDef.Functions)
{
foreach (var argument in function.Arguments ?? [])
CollectFromTru(argument.Type, discovered, order);
CollectFromTru(function.ReturnType, discovered, order);
}
foreach (var @event in typeDef.Events) CollectFromTru(@event.ArgumentType, discovered, order);
foreach (var constant in typeDef.Constants) CollectFromTru(constant.ValueType, discovered, order);
}
static void CollectFromTru(Tru? type, Dictionary<ulong, TypeDef> discovered, List<TypeDef> order)
{
switch (type)
{
case TruTypeDef { TypeDef: not null } reference:
Collect(reference.TypeDef, discovered, order);
break;
case TruComposite composite:
foreach (var sub in composite.SubTypes) CollectFromTru(sub, discovered, order);
break;
}
}
/// <summary>
/// A TypeDef's <c>Name</c> is often a fully-qualified CLR name (e.g.
/// <c>MyApp.Server.Counter</c>), not a valid TypeScript identifier. Use the
/// last segment as the interface/enum name, falling back to the full name
/// (sanitized) on collision so two distinct types never collide.
/// </summary>
static Dictionary<ulong, string> AssignNames(List<TypeDef> order)
{
var used = new HashSet<string>(StringComparer.Ordinal);
var names = new Dictionary<ulong, string>();
foreach (var typeDef in order)
{
var shortName = Sanitize(LastSegment(typeDef.Name));
var name = used.Add(shortName) ? shortName : Sanitize(typeDef.Name);
used.Add(name);
names[typeDef.Id] = name;
}
return names;
}
static string LastSegment(string fullName)
{
var lastDot = fullName.LastIndexOf('.');
return lastDot >= 0 ? fullName[(lastDot + 1)..] : fullName;
}
static string Sanitize(string name)
{
var cleaned = Regex.Replace(name, "[^A-Za-z0-9_$]", "_");
return cleaned.Length > 0 && char.IsDigit(cleaned[0]) ? "_" + cleaned : cleaned;
}
static void EmitType(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary<ulong, string> names)
{
if (typeDef.Kind == TypeDefKind.Enum) EmitEnum(sb, typeDef, names);
else EmitInterface(sb, typeDef, names);
}
static void EmitEnum(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary<ulong, string> names)
{
sb.AppendLine($"export enum {names[typeDef.Id]} {{");
foreach (var constant in typeDef.Constants.OrderBy(c => c.Index))
sb.AppendLine($" {constant.Name} = {Convert.ToInt64(constant.Value)},");
sb.AppendLine("}");
sb.AppendLine();
}
static void EmitInterface(StringBuilder sb, TypeDef typeDef, IReadOnlyDictionary<ulong, string> names)
{
sb.AppendLine($"export interface {names[typeDef.Id]} {{");
foreach (var property in typeDef.Properties.OrderBy(p => p.Index))
{
var readonlyModifier = property.ReadOnly || property.Constant ? "readonly " : "";
sb.AppendLine(
$" {readonlyModifier}{property.Name}: {TypeScriptTypeMapper.Map(property.ValueType, names)};");
}
foreach (var function in typeDef.Functions.OrderBy(f => f.Index))
{
var args = string.Join(", ", (function.Arguments ?? [])
.OrderBy(a => a.Index)
.Select(a => $"{a.Name}{(a.Optional ? "?" : "")}: {TypeScriptTypeMapper.Map(a.Type, names)}"));
var returnType = TypeScriptTypeMapper.Map(function.ReturnType, names);
sb.AppendLine($" {function.Name}({args}): PromiseLike<{returnType}>;");
}
sb.AppendLine("}");
sb.AppendLine();
}
}
@@ -0,0 +1,80 @@
using Esiur.Data;
namespace Esiur.CLI.Generation;
/// <summary>
/// Maps a wire-level <see cref="Tru"/> type representation onto the closest
/// TypeScript type that esiur-ts's runtime actually decodes it into (e.g.
/// 64-bit integers become `bigint`, not `number` — see
/// esiur-ts/src/data/DataDeserializer.ts) so generated stubs match runtime
/// values, not just wire shape.
/// </summary>
public static class TypeScriptTypeMapper
{
/// <param name="names">Maps a referenced TypeDef's id to the identifier name
/// it was declared under (see <see cref="TypeScriptStubGenerator"/>) — a
/// TypeDef's own <c>Name</c> is often a fully-qualified CLR name like
/// <c>MyApp.Server.Counter</c>, not a valid TypeScript identifier.</param>
public static string Map(Tru? type, IReadOnlyDictionary<ulong, string> names)
{
if (type is null) return "unknown";
var mapped = MapCore(type, names);
return type.Nullable && mapped != "void" ? $"{mapped} | null" : mapped;
}
static string MapCore(Tru type, IReadOnlyDictionary<ulong, string> names) => type switch
{
TruTypeDef { TypeDef: not null } reference =>
names.TryGetValue(reference.TypeDef.Id, out var name) ? name : "unknown",
TruTypeDef => "unknown",
TruComposite composite => MapComposite(composite, names),
TruPrimitive primitive => MapPrimitive(primitive.Identifier),
_ => "unknown",
};
static string MapComposite(TruComposite composite, IReadOnlyDictionary<ulong, string> names) =>
composite.Identifier switch
{
TruIdentifier.TypedList => $"{Map(composite.SubTypes[0], names)}[]",
TruIdentifier.TypedMap =>
$"Map<{Map(composite.SubTypes[0], names)}, {Map(composite.SubTypes[1], names)}>",
TruIdentifier.Tuple2 or TruIdentifier.Tuple3 or TruIdentifier.Tuple4
or TruIdentifier.Tuple5 or TruIdentifier.Tuple6 or TruIdentifier.Tuple7 =>
$"[{string.Join(", ", composite.SubTypes.Select(sub => Map(sub, names)))}]",
_ => "unknown",
};
static string MapPrimitive(TruIdentifier identifier) => identifier switch
{
TruIdentifier.Void => "void",
TruIdentifier.Dynamic => "unknown",
TruIdentifier.Bool => "boolean",
TruIdentifier.Char => "string",
TruIdentifier.UInt8 or TruIdentifier.Int8
or TruIdentifier.UInt16 or TruIdentifier.Int16
or TruIdentifier.UInt32 or TruIdentifier.Int32
or TruIdentifier.Float32 or TruIdentifier.Float64 => "number",
// esiur-ts decodes 64-bit integers as `bigint` to avoid precision loss.
TruIdentifier.Int64 or TruIdentifier.UInt64 => "bigint",
// esiur-ts decodes Decimal as its own Decimal128 class (exported from "esiur").
TruIdentifier.Decimal => "Decimal128",
TruIdentifier.String => "string",
TruIdentifier.DateTime => "Date",
TruIdentifier.RawData => "Uint8Array",
TruIdentifier.Resource => "unknown",
TruIdentifier.Record => "unknown",
TruIdentifier.List => "unknown[]",
TruIdentifier.Map => "Map<unknown, unknown>",
_ => "unknown",
};
/// <summary>True when any type reachable from <paramref name="type"/> maps to Decimal128,
/// so the emitter knows whether to import it.</summary>
public static bool UsesDecimal128(Tru? type) => type switch
{
null => false,
TruPrimitive primitive => primitive.Identifier == TruIdentifier.Decimal,
TruComposite composite => composite.SubTypes.Any(UsesDecimal128),
_ => false,
};
}