mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
Stub generators
This commit is contained in:
@@ -7,7 +7,7 @@ using System.Text;
|
||||
|
||||
namespace Esiur.Data
|
||||
{
|
||||
internal class TruComposite : Tru
|
||||
public class TruComposite : Tru
|
||||
{
|
||||
public Tru[] SubTypes;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(path)` / `warehouse.get<T>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user