// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text.Json.Nodes;
using Aspire.Hosting.CodeGeneration.TypeScript.Tests.TestTypes;
using Aspire.Hosting.RemoteHost;
using Aspire.TypeSystem;
namespace Aspire.Hosting.CodeGeneration.Go.Tests;
public class GoApiExportTests
{
// The test types are compiled into this assembly via Compile Include.
private const string TestTypesAssemblyName = "Aspire.Hosting.CodeGeneration.Go.Tests";
private const string HostingAssemblyName = "Aspire.Hosting";
private const string PackageVersion = "13.5.0";
private const string InputCollectionTypeId = "Aspire.Hosting/Aspire.Hosting.InteractionInputCollection";
private static readonly Lazy<AtsContext> s_context = new(CreateContext);
private static readonly Lazy<IReadOnlyDictionary<string, string>> s_generated = new(GenerateSdk);
private static readonly Lazy<IReadOnlyList<GoDeclaration>> s_generatedDeclarations = new(ScanGeneratedSdk);
private static readonly Lazy<GoApiModel> s_everything = new(ExportEverything);
[Fact]
public async Task ApiExportWriter_WithGoSurface_ProducesFocusedCanonicalJson()
{
var json = GoApiExportWriter.WriteToJson(CreateFocusedModel(), indented: true);
await Verify(json, extension: "json")
.UseFileName("GoApiExportTests.FocusedApiExport");
}
[Fact]
public void ApiExportWriter_WithCrLfDeclaration_NormalizesLineEndings()
{
var json = GoApiExportWriter.WriteToJson(CreateFocusedModel());
var declaration = Assert.Single(JsonNode.Parse(json)!["declarations"]!.AsArray());
Assert.Equal("type RedisResource interface {\n\thandleReference\n}", (string?)declaration!["content"]);
}
[Fact]
public async Task ApiExport_WithHosting_ProjectsGoOnlySurface()
{
var model = s_everything.Value;
var inputCollection = Assert.Single(
model.Modules.Single().Items,
item => item.TypeId == InputCollectionTypeId);
var json = RenderFocused(
model,
("function:CreateBuilder", null),
("interface:DistributedApplicationBuilder", ["AddTestRedis", "Build", "Err"]),
("interface:DistributedApplication", ["Err"]),
(inputCollection.Id, ["Get", "Required", "Value", "RequiredValue", "Err"]),
("interface:TestRedisResource", ["GetStatusAsync", "GetTags", "WithPersistence", "WithUnionDependency", "WithValidator", "Err"]),
("options:WithPersistenceOptions", null),
("dto:TestConfigDto", null),
("enum:TestResourceStatus", null),
("variable:TestConfigs", null),
("type:AtsErrorCode", null));
await Verify(json, extension: "json")
.UseFileName("GoApiExportTests.GoOnlySurface");
}
[Fact]
public async Task ApiExport_ForPackageThatAugmentsHosting_ListsOnlyItsOwnMembers()
{
var model = BuildModel(TestTypesAssemblyName);
var json = RenderFocused(
model,
($"augmentation:{TestTypesAssemblyName}:DistributedApplicationBuilder", null),
("interface:TestVaultResource", null));
await Verify(json, extension: "json")
.UseFileName("GoApiExportTests.AugmentingPackage");
}
[Fact]
public void ApiExport_WritesEnvelopeAndGeneratorIdentity()
{
var export = JsonNode.Parse(GoApiExportWriter.WriteToJson(BuildModel(TestTypesAssemblyName)))!;
var assembly = typeof(AtsGoCodeGenerator).Assembly;
Assert.Equal(1, (int?)export["schemaVersion"]);
Assert.Equal("go", (string?)export["language"]);
Assert.Equal(assembly.GetName().Name, (string?)export["generator"]!["name"]);
Assert.False(string.IsNullOrEmpty((string?)export["generator"]!["version"]));
Assert.Equal(TestTypesAssemblyName, (string?)export["package"]!["name"]);
Assert.Equal(PackageVersion, (string?)export["package"]!["version"]);
Assert.Equal("apphost/modules/aspire", (string?)export["package"]!["importPath"]);
var module = Assert.Single(export["modules"]!.AsArray());
Assert.Equal(TestTypesAssemblyName, (string?)module!["name"]);
Assert.NotEmpty(module["items"]!.AsArray());
Assert.NotEmpty(export["declarations"]!.AsArray());
}
[Fact]
public void ApiExport_WithOnlyTestAssemblyOwned_AugmentsHostingTypesAndDeclaresThemOpaquely()
{
var model = BuildModel(TestTypesAssemblyName);
var items = model.Modules.Single().Items;
var builder = Assert.Single(items, item => item.Kind == GoApiItemKind.Augmentation && item.Name == "DistributedApplicationBuilder");
Assert.Equal($"augmentation:{TestTypesAssemblyName}:DistributedApplicationBuilder", builder.Id);
Assert.Equal(HostingAssemblyName, builder.OwningAssemblyName);
Assert.All(builder.Members, member => Assert.Equal(TestTypesAssemblyName, member.OwningAssemblyName));
Assert.All(
items.Where(item => item.Kind != GoApiItemKind.Augmentation),
item => Assert.Equal(TestTypesAssemblyName, item.OwningAssemblyName));
// The builder entry point and the hand-written runtime belong to Aspire.Hosting, so the only kinds
// a package that contributes handle members, options and values documents are these.
Assert.Equal(
[GoApiItemKind.Interface, GoApiItemKind.Augmentation, GoApiItemKind.Dto, GoApiItemKind.Options, GoApiItemKind.Enum, GoApiItemKind.Variable],
items.Select(item => item.Kind).Distinct().Order());
Assert.Contains(model.Declarations, declaration => declaration.Id == $"{HostingAssemblyName}:opaque:DistributedApplicationBuilder");
Assert.Contains(model.Declarations, declaration => declaration.Id == "aspire:runtime:base");
}
[Fact]
public void ApiExport_WithOnlyHostingOwned_ExcludesTestAssemblyMembers()
{
var model = BuildModel(HostingAssemblyName);
var items = model.Modules.Single().Items;
Assert.Contains(items, item => item.Id == "function:CreateBuilder");
// The test assembly's handle types only appear as augmentations that carry Aspire.Hosting's members.
Assert.All(
items.Where(item => item.Kind != GoApiItemKind.Augmentation),
item => Assert.NotEqual(TestTypesAssemblyName, item.OwningAssemblyName));
Assert.All(
items.SelectMany(item => item.Members).Where(member => member.CapabilityId is not null),
member => Assert.Equal(HostingAssemblyName, member.OwningAssemblyName));
var builder = Assert.Single(items, item => item.Id == "interface:DistributedApplicationBuilder");
Assert.Single(builder.Members, member => member.Name == "Build");
}
[Fact]
public void ApiExport_WithHosting_IncludesGoOnlyMembers()
{
var items = s_everything.Value.Modules.Single().Items;
var builder = Assert.Single(items, item => item.Id == "interface:DistributedApplicationBuilder");
var build = Assert.Single(builder.Members, member => member.Name == "Build");
Assert.Equal("Build() (DistributedApplication, error)", build.Declaration);
Assert.Equal("(DistributedApplication, error)", build.ReturnType);
Assert.Equal(GoApiErrorModel.Returned, build.ErrorModel);
Assert.True(build.IsSynthesized);
var inputCollection = Assert.Single(items, item => item.TypeId == InputCollectionTypeId);
Assert.Equal(
[
"Get(name string) (*InteractionInput, error)",
"Required(name string) (*InteractionInput, error)",
"Value(name string) (string, error)",
"RequiredValue(name string) (string, error)",
"Err() error"
],
inputCollection.Members.Where(member => member.IsSynthesized).Select(member => member.Declaration));
// Err comes from the runtime's handleReference contract, so every handle interface has it, marker
// interfaces included.
Assert.All(
items.Where(item => item.Kind == GoApiItemKind.Interface),
item => Assert.Single(item.Members, member => member.Declaration == "Err() error" && member.IsSynthesized));
// ToMap is written on every struct the generator declares.
Assert.All(
items.Where(item => item.Kind is GoApiItemKind.Dto or GoApiItemKind.Options),
item => Assert.Single(item.Members, member => member.Declaration == "ToMap() map[string]any" && member.IsSynthesized));
}
[Fact]
public void ApiExport_WithOptionalParameters_FlattensThemIntoTrailingOptions()
{
var items = s_everything.Value.Modules.Single().Items;
var persistence = FindMember(items, "TestRedisResource", "WithPersistence");
Assert.Equal("WithPersistence(options ...*WithPersistenceOptions) TestRedisResource", persistence.Declaration);
Assert.Empty(persistence.Parameters);
Assert.Equal(GoApiErrorModel.Deferred, persistence.ErrorModel);
Assert.True(persistence.ReturnsReceiver);
var options = Assert.IsType<GoApiOptions>(persistence.Options);
Assert.Equal("WithPersistenceOptions", options.TypeName);
Assert.False(options.IsDirect);
var mode = Assert.Single(options.Fields);
Assert.Equal(("Mode", "*TestPersistenceMode", "mode"), (mode.Name, mode.DeclaredType, mode.JsonName));
Assert.True(mode.IsOptional);
// A lone optional DTO named options is passed through as the variadic itself.
var httpCommand = FindMember(items, "ContainerResource", "WithHttpCommand");
Assert.Equal("WithHttpCommand(path string, displayName string, options ...*HttpCommandExportOptions) ContainerResource", httpCommand.Declaration);
Assert.Equal(("HttpCommandExportOptions", true), (httpCommand.Options!.TypeName, httpCommand.Options.IsDirect));
Assert.Empty(httpCommand.Options.Fields);
// Go has one trailing variadic, so an optional DTO next to another optional keeps its options struct.
var prompt = FindMember(items, "InteractionService", "PromptInput");
Assert.Equal("PromptInputOptions", prompt.Options!.TypeName);
Assert.False(prompt.Options.IsDirect);
Assert.Contains(prompt.Options.Fields, field => field is { Name: "Options", DeclaredType: "*InteractionInputsDialogOptions", JsonName: "options" });
}
[Fact]
public void ApiExport_WithCallbacksAndUnions_ProjectsGoTypes()
{
var items = s_everything.Value.Modules.Single().Items;
var validator = Assert.Single(FindMember(items, "TestRedisResource", "WithValidator").Parameters);
Assert.Equal(("validator", "func(arg TestResourceContext) bool", true), (validator.Name, validator.DeclaredType, validator.IsCallback));
var optionalCallback = FindMember(items, "TestRedisResource", "WithOptionalCallback");
Assert.All(optionalCallback.Options!.Fields, field => Assert.True(field.IsCallback));
Assert.All(optionalCallback.Options.Fields, field => Assert.Null(field.JsonName));
var union = Assert.Single(FindMember(items, "TestRedisResource", "WithUnionDependency").Parameters);
Assert.Equal("any", union.DeclaredType);
Assert.NotEmpty(union.AllowedTypes);
var getter = FindMember(items, "TestRedisResource", "GetTags");
Assert.Equal("GetTags() *List[string]", getter.Declaration);
Assert.Equal(GoApiErrorModel.None, getter.ErrorModel);
}
[Fact]
public void ApiExport_WithHosting_HasUniqueIds()
{
var model = s_everything.Value;
var duplicateMembers = model.Modules.Single().Items
.SelectMany(item => item.Members
.GroupBy(member => member.Id, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => $"{item.Id}: {group.Key}"))
.ToList();
Assert.Empty(duplicateMembers);
Assert.Equal(model.Modules.Single().Items.Count, model.Modules.Single().Items.Select(item => item.Id).Distinct(StringComparer.Ordinal).Count());
Assert.Equal(model.Declarations.Count, model.Declarations.Select(declaration => declaration.Id).Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public void ApiExport_IsDeterministicAndLineEndingNeutral()
{
var first = GoApiExportWriter.WriteToJson(BuildModel(HostingAssemblyName, TestTypesAssemblyName));
var second = GoApiExportWriter.WriteToJson(BuildModel(TestTypesAssemblyName, HostingAssemblyName));
Assert.Equal(first, second);
// The runtime sources can be checked out with CRLF, so every string in the export has to come out
// with LF endings. Declarations span several lines, which proves there is something to normalize.
var strings = Strings(JsonNode.Parse(first)).ToList();
Assert.Contains(strings, text => text.Contains('\n'));
Assert.All(strings, text => Assert.Equal(text.ReplaceLineEndings("\n"), text));
static IEnumerable<string> Strings(JsonNode? node) => node switch
{
JsonObject json => json.SelectMany(property => Strings(property.Value)),
JsonArray array => array.SelectMany(Strings),
JsonValue value when value.TryGetValue<string>(out var text) => [text],
_ => []
};
}
[Fact]
public void ApiExport_WithHosting_ExportsEveryGeneratedTypeAndFunction()
{
var items = s_everything.Value.Modules.Single().Items;
var generated = s_generatedDeclarations.Value;
AssertSameNames(
generated.Where(declaration => declaration.Kind == GoDeclarationKind.Type).Select(declaration => declaration.Name),
items.Where(IsTypeItem).Select(item => item.Name));
AssertSameNames(
generated.Where(declaration => declaration.Kind == GoDeclarationKind.Function).Select(declaration => declaration.Name),
items.Where(item => item.Kind == GoApiItemKind.Function).Select(item => item.Name));
}
[Fact]
public void ApiExport_WithHosting_ExportsEveryGeneratedMemberOfExportedTypes()
{
var items = s_everything.Value.Modules.Single().Items;
var typeItems = items.Where(item => IsTypeItem(item) && item.Kind != GoApiItemKind.Interface).ToList();
var typeNames = typeItems.Select(item => item.Name).ToHashSet(StringComparer.Ordinal);
// The members of a handle interface are methods on an unexported struct, which the interface parity
// test covers.
AssertSameNames(
s_generatedDeclarations.Value
.Where(declaration => declaration.Kind != GoDeclarationKind.Type && typeNames.Contains(declaration.Owner))
.Select(declaration => $"{declaration.Owner}.{declaration.Name}"),
typeItems.SelectMany(item => item.Members.Select(member => $"{item.Name}.{member.Name}")));
}
[Fact]
public void ApiExport_WithHosting_MatchesGeneratedInterfaceMethods()
{
var interfaces = s_everything.Value.Modules.Single().Items.Where(item => item.Kind == GoApiItemKind.Interface).ToList();
var generatedMethods = ReadGeneratedInterfaceMethods(s_generated.Value["aspire.go"]);
// Marker interfaces declare no Err line of their own because handleReference requires it, so Err is
// compared separately.
var mismatches = new List<string>();
foreach (var item in interfaces)
{
var exported = item.Members.Select(member => member.Declaration).Where(declaration => declaration != "Err() error").ToList();
if (!generatedMethods.TryGetValue(item.Name, out var generatedLines))
{
mismatches.Add($"{item.Name}: not generated");
continue;
}
var generated = generatedLines.Where(declaration => declaration != "Err() error").ToList();
if (!generated.SequenceEqual(exported))
{
var index = Enumerable.Range(0, Math.Max(generated.Count, exported.Count))
.First(i => i >= generated.Count || i >= exported.Count || generated[i] != exported[i]);
mismatches.Add($"{item.Name} @{index}: generated '{generated.ElementAtOrDefault(index)}' vs exported '{exported.ElementAtOrDefault(index)}'");
}
}
Assert.Empty(mismatches);
Assert.NotEmpty(interfaces);
}
[Fact]
public void ApiExport_WithHosting_ExportsVariablesExactlyAsGenerated()
{
var variables = s_everything.Value.Modules.Single().Items.Where(item => item.Kind == GoApiItemKind.Variable).ToList();
var generated = s_generated.Value["aspire.go"].ReplaceLineEndings("\n");
// Exported declarations carry no comments (documentation is exported as member summaries), so they
// are compared with the comment lines of the generated code removed.
var generatedCode = string.Join('\n', generated.Split('\n').Where(line => !line.TrimStart().StartsWith("//", StringComparison.Ordinal)));
// Variables are written as `var Name = struct {` at the start of a line.
var generatedNames = generated.Split('\n')
.Where(line => line.StartsWith("var ", StringComparison.Ordinal) && line.EndsWith(" = struct {", StringComparison.Ordinal))
.Select(line => line["var ".Length..line.IndexOf(' ', "var ".Length)])
.Order(StringComparer.Ordinal);
Assert.Equal(generatedNames, variables.Select(variable => variable.Name).Order(StringComparer.Ordinal));
Assert.All(variables, variable => Assert.Contains(variable.Declaration, generatedCode, StringComparison.Ordinal));
Assert.All(variables, variable => Assert.NotEmpty(variable.Members));
}
[Fact]
public void ApiExport_ThroughTheExportFilter_NamesPackageMembersAsFullGenerationDoes()
{
// CodeGenerationService exports the context that AtsContextFilter.FilterForApiExport returns, and
// the generator derives every Go name from the context it is given. The test assembly declares no
// name that another assembly in the context also declares, so its types and members have to be
// named exactly as the full generation names them.
var filtered = AtsContextFilter.FilterForApiExport(s_context.Value, [TestTypesAssemblyName]);
var exported = new AtsGoCodeGenerator()
.BuildApiModel(filtered, TestTypesAssemblyName, PackageVersion, [TestTypesAssemblyName], CancellationToken.None)
.Modules.Single().Items;
// The full generation declares one interface per type, with the members of every package. What
// the package export lists as an augmentation is the package's share of that interface, and a
// member with no capability behind it (Err, ToMap, a field) belongs to the package that owns its type.
static bool IsOwnedByPackage(GoApiItem item, GoApiMember member)
=> member.CapabilityId is null
? item.OwningAssemblyName == TestTypesAssemblyName
: member.OwningAssemblyName == TestTypesAssemblyName;
// PipelineContext and TestCallbackContext take the same cancellation token options, so the generator
// declares a single SetCancellationTokenOptions for both. The full generation gives it to Aspire.Hosting,
// which comes first, while the filtered context holds only the package's capabilities, so the export
// gives it to the package. The name and the fields are the same either way, which is what is compared.
var full = s_everything.Value.Modules.Single().Items;
var sharedOptions = Assert.Single(full, item => item.Id == "options:SetCancellationTokenOptions");
Assert.Equal(HostingAssemblyName, sharedOptions.OwningAssemblyName);
Assert.Equal(TestTypesAssemblyName, Assert.Single(exported, item => item.Id == sharedOptions.Id).OwningAssemblyName);
var expected = full
.Select(item => (Item: item, Members: item.Members.Where(member => IsOwnedByPackage(item, member)).ToList()))
.Where(owned => owned.Item.OwningAssemblyName == TestTypesAssemblyName || owned.Members.Count > 0)
.SelectMany(owned => DescribeDeclarations(owned.Item, owned.Members))
.Concat(DescribeDeclarations(sharedOptions, sharedOptions.Members));
var actual = exported.SelectMany(item => DescribeDeclarations(item, item.Members));
Assert.Equal(expected.Order(StringComparer.Ordinal), actual.Order(StringComparer.Ordinal));
// Both sides come from the same model, so an export that lists nothing would also be equal.
Assert.Contains(exported, item => item.Id == "interface:TestRedisResource");
Assert.Contains(exported, item => item.Kind == GoApiItemKind.Augmentation && item.Members.Any(member => member.Name == "AddTestRedis"));
}
[Fact]
public void ApiReferenceExporter_ExportsGoSchemaForRequestedPackage()
{
IApiReferenceExporter exporter = new AtsGoApiReferenceExporter();
var export = exporter.ExportApi(
s_context.Value,
new ApiReferenceExportOptions(TestTypesAssemblyName, PackageVersion, [TestTypesAssemblyName]),
CancellationToken.None);
Assert.Equal("Go", exporter.Language);
Assert.Equal(GoApiExportWriter.WriteToJson(BuildModel(TestTypesAssemblyName)), export.GetRawText());
}
[Fact]
public void ApiReferenceExporter_HonorsCancellation()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
IApiReferenceExporter exporter = new AtsGoApiReferenceExporter();
Assert.Throws<OperationCanceledException>(() => exporter.ExportApi(
s_context.Value,
new ApiReferenceExportOptions(TestTypesAssemblyName, PackageVersion, [TestTypesAssemblyName]),
cancellation.Token));
}
[Fact]
public void ApiReferenceExporter_IsNotTheCodeGenerator()
{
Assert.IsNotAssignableFrom<IApiReferenceExporter>(new AtsGoCodeGenerator());
}
[Fact]
public void Scan_ReadsExportedDeclarationsAndDocumentation()
{
const string source = """
package aspire
// Greeter says hello.
//
// Remarks stay on their own lines.
type Greeter struct {
// Name is shown.
Name string `json:"name,omitempty"`
hidden int
*embedded
}
func (g *Greeter) ToMap() map[string]any {
return nil
}
// Mode selects the mode.
type Mode string
const (
// ModeFast is fast.
ModeFast Mode = "fast"
ModeSlow Mode = "slow"
)
// NewGreeter creates a greeter.
func NewGreeter(name string, opts ...*GreeterOptions) (*Greeter, error) {
return nil, nil
}
func (l *List[T]) Count() (int, error) { return 0, nil }
type Application interface { handleReference }
type hiddenType struct {
Visible string
}
func hiddenFunc() {}
""";
var declarations = GoDeclarationScanner.Scan(source, includeBodyDocs: true);
Assert.Equal(
[
(GoDeclarationKind.Type, "", "Greeter", "type Greeter struct"),
(GoDeclarationKind.Field, "Greeter", "Name", "Name string"),
(GoDeclarationKind.Method, "Greeter", "ToMap", "ToMap() map[string]any"),
(GoDeclarationKind.Type, "", "Mode", "type Mode string"),
(GoDeclarationKind.Constant, "Mode", "ModeFast", "ModeFast Mode = \"fast\""),
(GoDeclarationKind.Constant, "Mode", "ModeSlow", "ModeSlow Mode = \"slow\""),
(GoDeclarationKind.Function, "", "NewGreeter", "func NewGreeter(name string, opts ...*GreeterOptions) (*Greeter, error)"),
(GoDeclarationKind.Method, "List", "Count", "Count() (int, error)"),
(GoDeclarationKind.Type, "", "Application", "type Application interface")
],
declarations.Select(declaration => (declaration.Kind, declaration.Owner, declaration.Name, declaration.Signature)));
var greeter = declarations[0];
Assert.Equal("Greeter says hello.", greeter.Summary);
Assert.Equal("Remarks stay on their own lines.", greeter.Remarks);
Assert.Equal("Name is shown.", declarations[1].Summary);
Assert.Equal("name,omitempty", declarations[1].Value![(declarations[1].Value!.IndexOf('"') + 1)..declarations[1].Value!.LastIndexOf('"')]);
Assert.Equal("fast", declarations[4].Value);
Assert.Equal("ModeFast is fast.", declarations[4].Summary);
Assert.Equal("*List[T]", declarations[7].Receiver);
Assert.Equal("NewGreeter creates a greeter.", declarations[6].Summary);
}
[Fact]
public void Scan_WithoutBodyDocs_LeavesFieldAndConstantDocumentationEmpty()
{
const string source = """
type Mode string
const (
// Group heading.
ModeFast Mode = "fast"
)
""";
var constant = Assert.Single(GoDeclarationScanner.Scan(source, includeBodyDocs: false), declaration => declaration.Kind == GoDeclarationKind.Constant);
Assert.Null(constant.Summary);
}
[Fact]
public void Scan_ReportsExportedDeclarationsWithNonAsciiUppercaseNames()
{
// U+00C9 and U+00DC are upper case letters outside ASCII, so Go exports the names they start;
// U+00E9 is lower case, so the names it starts stay unexported.
var source = string.Join(
'\n',
"type \u00C9tat struct {",
"\t\u00C9tage string",
"\t\u00E9tageCache string",
"}",
"",
"func (e *\u00C9tat) \u00C9crire() error { return nil }",
"",
"func \u00DCberFunc() {}",
"",
"func \u00E9teindre() {}",
"",
"type \u00E9tatPrive struct{}");
var declarations = GoDeclarationScanner.Scan(source, includeBodyDocs: false);
Assert.Equal(
[
(GoDeclarationKind.Type, "", "\u00C9tat", "type \u00C9tat struct"),
(GoDeclarationKind.Field, "\u00C9tat", "\u00C9tage", "\u00C9tage string"),
(GoDeclarationKind.Method, "\u00C9tat", "\u00C9crire", "\u00C9crire() error"),
(GoDeclarationKind.Function, "", "\u00DCberFunc", "func \u00DCberFunc()")
],
declarations.Select(declaration => (declaration.Kind, declaration.Owner, declaration.Name, declaration.Signature)));
}
[Fact]
public void Scan_OfGeneratedRuntime_ReportsTheErrMethodHandlesInherit()
{
var declarations = GoDeclarationScanner.Scan(s_generated.Value["base.go"], includeBodyDocs: false);
var err = Assert.Single(declarations, declaration => declaration is { Kind: GoDeclarationKind.Method, Name: "Err", Owner: "handleWrapperBase" });
Assert.Equal("Err() error", err.Signature);
Assert.False(string.IsNullOrEmpty(err.Summary));
}
[Theory]
[InlineData("var Default = newClient()", "var Default = newClient()")]
[InlineData("var (\n Default = newClient()\n)", "var (")]
[InlineData("type (\n Handle struct{}\n)", "type (")]
[InlineData("const Version = \"1\"", "const Version = \"1\"")]
[InlineData("func Run(ctx context.Context,\n name string) error {\n}", "func Run(ctx context.Context,")]
[InlineData("func (c *Client) Run(\n name string,\n) error {\n}", "func (c *Client) Run(")]
[InlineData("type Handle interface {\n Err() error\n}", "type Handle interface {")]
[InlineData("type Handle interface { Err() error }", "type Handle interface { Err() error }")]
[InlineData("package aspire\r\n\r\nconst Version = \"1\"\r\n", "const Version = \"1\"")]
public void ScanRuntime_OfAnUnmodeledExportedConstruct_ThrowsNamingTheLine(string source, string line)
{
var exception = Assert.Throws<InvalidOperationException>(() => GoDeclarationScanner.ScanRuntime(source));
Assert.Equal($"The Go source declares an exported construct the API export does not model: {line}", exception.Message);
}
[Fact]
public void ScanRuntime_OfModeledAndUnexportedConstructs_ReportsTheModeledOnes()
{
const string source = """
type Mode string
const (
ModeFast Mode = "fast"
)
var cache = map[string]int{}
const limit = 3
type reader interface {
Read() error
}
type Anything interface{}
func (m Mode) Valid() bool { return true }
func run(name string,
count int) {
}
""";
Assert.Equal(
["Mode", "ModeFast", "Anything", "Valid"],
GoDeclarationScanner.ScanRuntime(source).Select(declaration => declaration.Name));
}
[Theory]
[InlineData("Count() (int, error)", "(int, error)")]
[InlineData("Err() error", "error")]
[InlineData("Build()", null)]
[InlineData("WithValidator(validator func(arg TestResourceContext) bool) TestRedisResource", "TestRedisResource")]
[InlineData("func RefExpr(format string, valueProviders ...any) *ReferenceExpression", "*ReferenceExpression")]
public void GetReturnType_ReadsTheResultsOfASignature(string signature, string? expected)
{
Assert.Equal(expected, GoDeclarationScanner.GetReturnType(signature));
}
[Fact]
public void ReadTypeDeclaration_KeepsTheShapeOfAnUnexportedType()
{
var source = string.Join(
'\n',
"// handleType is the ATS type ID carried on a handle envelope.",
"type handleType = string",
"",
"// handle represents a reference to a server-side object.",
"type handle struct {",
"\tHandleID string `json:\"$handle\"`",
"\t// TypeID is the ATS type ID.",
"\tTypeID handleType `json:\"$type\"` // Not part of the tag.",
"}",
"",
"type handleReference interface {",
"\tgetHandle() *handle",
"\tErr() error // Reports the deferred failure.",
"}",
"",
"type handleSource interface { handleReference }");
Assert.Equal("type handleType = string", GoDeclarationScanner.ReadTypeDeclaration(source, "handleType"));
Assert.Equal(
string.Join('\n', "type handle struct {", "\tHandleID string `json:\"$handle\"`", "\tTypeID handleType `json:\"$type\"`", "}"),
GoDeclarationScanner.ReadTypeDeclaration(source, "handle"));
Assert.Equal(
string.Join('\n', "type handleReference interface {", "\tgetHandle() *handle", "\tErr() error", "}"),
GoDeclarationScanner.ReadTypeDeclaration(source, "handleReference"));
Assert.Equal("type handleSource interface { handleReference }", GoDeclarationScanner.ReadTypeDeclaration(source, "handleSource"));
}
[Fact]
public void ReadTypeDeclaration_OfATypeTheSourceDoesNotDeclare_Throws()
{
var exception = Assert.Throws<InvalidOperationException>(
() => GoDeclarationScanner.ReadTypeDeclaration("type handle struct {\n}", "handleReference"));
Assert.Contains("handleReference", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void ApiExport_WithHosting_DeclaresTheTypesItsFragmentsEmbed()
{
AssertDeclarationsAreSelfContained(s_everything.Value);
}
[Fact]
public void ApiExport_ForPackage_DeclaresTheTypesItsFragmentsEmbed()
{
AssertDeclarationsAreSelfContained(BuildModel(TestTypesAssemblyName));
}
[Fact]
public void ApiExport_WithHosting_ReportsHowRuntimeMethodsFail()
{
var items = s_everything.Value.Modules.Single().Items;
var methods = items
.Where(item => item.Kind == GoApiItemKind.Type)
.SelectMany(item => item.Members)
.Where(member => member.Kind == GoApiMemberKind.Method)
.ToList();
Assert.NotEmpty(methods);
Assert.All(methods, method => Assert.True(method.ErrorModel is not null, $"{method.Id} has no error model."));
Assert.Equal(GoApiErrorModel.Returned, FindTypeMember(items, "List", "Count").ErrorModel);
Assert.Equal(GoApiErrorModel.Returned, FindTypeMember(items, "List", "Add").ErrorModel);
Assert.Equal(GoApiErrorModel.Returned, FindTypeMember(items, "Dict", "Get").ErrorModel);
Assert.Equal(GoApiErrorModel.Returned, FindTypeMember(items, "ReferenceExpression", "GetValue").ErrorModel);
Assert.Equal(GoApiErrorModel.None, FindTypeMember(items, "CancellationToken", "IsCancelled").ErrorModel);
// Err reports a failure that a handle deferred, so, as on a handle interface, calling it cannot fail.
Assert.Equal(GoApiErrorModel.None, FindTypeMember(items, "ReferenceExpression", "Err").ErrorModel);
}
private static AtsContext CreateContext()
=> AtsCapabilityScanner.ScanAssemblies([typeof(DistributedApplication).Assembly, typeof(TestRedisResource).Assembly]).ToAtsContext();
private static IReadOnlyDictionary<string, string> GenerateSdk()
=> new AtsGoCodeGenerator().GenerateDistributedApplication(s_context.Value);
private static IReadOnlyList<GoDeclaration> ScanGeneratedSdk()
=> new[] { "transport.go", "base.go", "aspire.go" }
.SelectMany(file => GoDeclarationScanner.Scan(s_generated.Value[file], includeBodyDocs: false))
.ToList();
private static GoApiModel BuildModel(params string[] exportingAssemblyNames)
=> new AtsGoCodeGenerator().BuildApiModel(s_context.Value, TestTypesAssemblyName, PackageVersion, exportingAssemblyNames, CancellationToken.None);
// The generated SDK holds every type the model references, whoever owns it, so the parity tests export
// for all of the owners that show up in a first pass.
private static GoApiModel ExportEverything()
{
var first = BuildModel(HostingAssemblyName, TestTypesAssemblyName);
var owners = first.Declarations.Select(declaration => declaration.OwningAssemblyName)
.Concat(first.Modules.SelectMany(module => module.Items).Select(item => item.OwningAssemblyName))
.Append(HostingAssemblyName)
.Append(TestTypesAssemblyName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return BuildModel(owners);
}
private static bool IsTypeItem(GoApiItem item)
=> item.Kind is GoApiItemKind.Interface or GoApiItemKind.Dto or GoApiItemKind.Options or GoApiItemKind.Enum or GoApiItemKind.Type;
private static GoApiMember FindMember(IReadOnlyList<GoApiItem> items, string interfaceName, string memberName)
{
var item = Assert.Single(items, item => item.Id == $"interface:{interfaceName}");
return Assert.Single(item.Members, member => member.Name == memberName);
}
private static GoApiMember FindTypeMember(IReadOnlyList<GoApiItem> items, string typeName, string memberName)
{
var item = Assert.Single(items, item => item.Id == $"type:{typeName}");
return Assert.Single(item.Members, member => member.Name == memberName);
}
// The declaration of an item and of the given members, each keyed by the Go name the item declares,
// so an interface and an augmentation of the same type describe alike, whoever owns the type.
private static IEnumerable<string> DescribeDeclarations(GoApiItem item, IEnumerable<GoApiMember> members)
=> members.Select(member => $"{item.Name}: {member.Declaration}").Prepend($"{item.Name}: {item.Declaration}");
// The declarations are Go source, so a type that one of them embeds has to be declared by one of them.
// The generator embeds a type as a bare name on its own line:
// type Redis interface {
// handleReference
// Err() error
// }
// and declares a type with a header at the start of a line, where the name ends at a space or at a
// type parameter list: `type handle struct {`, `type handleType = string`, `type List[T any] struct {`.
private static void AssertDeclarationsAreSelfContained(GoApiModel model)
{
static bool IsIdentifierCharacter(char character) => char.IsLetterOrDigit(character) || character == '_';
var lines = model.Declarations
.SelectMany(declaration => declaration.Content.ReplaceLineEndings("\n").Split('\n'))
.ToList();
var declared = lines
.Where(line => line.StartsWith("type ", StringComparison.Ordinal))
.Select(line => new string(line["type ".Length..].TakeWhile(IsIdentifierCharacter).ToArray()))
.ToHashSet(StringComparer.Ordinal);
var embedded = lines
.Where(line => line.Length > 1 && line[0] == '\t' && line.Skip(1).All(IsIdentifierCharacter))
.Select(line => line[1..])
.ToHashSet(StringComparer.Ordinal);
// Every handle interface embeds handleReference, so an export that embeds nothing has not been read.
Assert.Contains("handleReference", embedded);
Assert.Empty(embedded.Except(declared).Order(StringComparer.Ordinal));
// handleReference.getHandle returns a *handle, whose TypeID is a handleType.
Assert.Contains("handle", declared);
Assert.Contains("handleType", declared);
}
private static void AssertSameNames(IEnumerable<string> generated, IEnumerable<string> exported)
{
var generatedNames = generated.ToHashSet(StringComparer.Ordinal);
var exportedNames = exported.ToHashSet(StringComparer.Ordinal);
Assert.Empty(generatedNames.Except(exportedNames).Order(StringComparer.Ordinal));
Assert.Empty(exportedNames.Except(generatedNames).Order(StringComparer.Ordinal));
Assert.NotEmpty(generatedNames);
}
// Reads the exported handle interfaces out of the generated SDK. The generator writes them as
// type Redis interface {
// handleReference
// WithPersistence(options ...*WithPersistenceOptions) Redis
// Err() error
// }
// and a handle-only interface on one line: type DistributedApplication interface { handleReference }.
private static Dictionary<string, List<string>> ReadGeneratedInterfaceMethods(string source)
{
const string OneLineSuffix = " interface { handleReference }";
const string MultiLineSuffix = " interface {";
var interfaces = new Dictionary<string, List<string>>(StringComparer.Ordinal);
List<string>? current = null;
foreach (var line in source.ReplaceLineEndings("\n").Split('\n'))
{
if (current is null)
{
if (!line.StartsWith("type ", StringComparison.Ordinal))
{
continue;
}
if (line.EndsWith(OneLineSuffix, StringComparison.Ordinal))
{
interfaces[line["type ".Length..^OneLineSuffix.Length]] = [];
}
else if (line.EndsWith(MultiLineSuffix, StringComparison.Ordinal))
{
current = [];
interfaces[line["type ".Length..^MultiLineSuffix.Length]] = current;
}
continue;
}
if (line == "}")
{
current = null;
continue;
}
var trimmed = line.Trim();
if (trimmed.Length > 0 && !trimmed.StartsWith("//", StringComparison.Ordinal) && trimmed != "handleReference")
{
current.Add(trimmed);
}
}
return interfaces;
}
// Trims an export to the requested items so a snapshot stays reviewable. A null member selection keeps
// every member of the item.
private static string RenderFocused(GoApiModel model, params (string ItemId, string[]? MemberNames)[] selection)
{
var items = model.Modules.Single().Items.ToDictionary(item => item.Id, StringComparer.Ordinal);
var focused = new List<GoApiItem>();
foreach (var (itemId, memberNames) in selection)
{
var item = items.TryGetValue(itemId, out var found)
? found
: throw new InvalidOperationException($"The export has no item '{itemId}'. Items: {string.Join(", ", items.Keys.Where(key => key.Contains(itemId[(itemId.IndexOf(':') + 1)..], StringComparison.Ordinal)))}");
focused.Add(memberNames is null
? item
: item with { Members = memberNames.Select(name => Assert.Single(item.Members, member => member.Name == name)).ToList() });
}
return GoApiExportWriter.WriteToJson(
model with
{
// The informational version changes with every build.
Generator = new GoApiGeneratorIdentity(model.Generator.Name, "1.0.0"),
Modules = [new GoApiModule { Name = model.Modules.Single().Name, Items = focused }],
Declarations = []
},
indented: true);
}
private static GoApiModel CreateFocusedModel()
{
const string owner = "Aspire.Hosting.Redis";
const string redisTypeId = "Aspire.Hosting.Redis/Aspire.Hosting.Redis.RedisResource";
return new GoApiModel
{
SchemaVersion = 1,
Language = "go",
Generator = new GoApiGeneratorIdentity("Aspire.Hosting.CodeGeneration.Go", "13.5.0"),
Package = new GoApiPackageIdentity(owner, PackageVersion, "apphost/modules/aspire"),
Modules =
[
new GoApiModule
{
Name = owner,
Summary = "Redis hosting integration.",
Items =
[
new GoApiItem
{
Id = "interface:RedisResource",
TypeId = redisTypeId,
Kind = GoApiItemKind.Interface,
Name = "RedisResource",
Declaration = "type RedisResource interface",
OwningAssemblyName = owner,
Summary = "A Redis resource.",
Remarks = "Add one with AddRedis.",
Implements = ["Resource"],
Members =
[
new GoApiMember
{
Id = "RedisResource.WithPersistence",
Kind = GoApiMemberKind.Method,
Name = "WithPersistence",
Declaration = "WithPersistence(interval float64, options ...*WithPersistenceOptions) RedisResource",
Summary = "Configures persistence.",
Returns = "The same builder.",
IsExperimental = true,
DeprecationMessage = "Use WithDataVolume.",
CapabilityId = "Aspire.Hosting.Redis/withPersistence",
TargetTypeId = redisTypeId,
CapabilityKind = "method",
OwningAssemblyName = owner,
Parameters =
[
new GoApiParameter { Name = "interval", DeclaredType = "float64", Summary = "The snapshot interval." }
],
Options = new GoApiOptions
{
TypeName = "WithPersistenceOptions",
Fields =
[
new GoApiParameter
{
Name = "KeysChangedThreshold",
DeclaredType = "*float64",
IsOptional = true,
JsonName = "keysChangedThreshold"
}
]
},
ReturnType = "RedisResource",
ErrorModel = GoApiErrorModel.Deferred,
ReturnsReceiver = true,
ReturnsBuilder = true
},
new GoApiMember
{
Id = "RedisResource.WithCallback",
Kind = GoApiMemberKind.Method,
Name = "WithCallback",
Declaration = "WithCallback(callback func(arg RedisContext), input any) (string, error)",
CapabilityId = "Aspire.Hosting.Redis/withCallback",
TargetTypeId = redisTypeId,
CapabilityKind = "instanceMethod",
OwningAssemblyName = owner,
Parameters =
[
new GoApiParameter { Name = "callback", DeclaredType = "func(arg RedisContext)", IsCallback = true },
new GoApiParameter { Name = "input", DeclaredType = "any", JsonName = "in", AllowedTypes = ["string", "*ReferenceExpression"] }
],
ReturnType = "(string, error)",
ErrorModel = GoApiErrorModel.Returned
},
new GoApiMember
{
Id = "RedisResource.Err",
Kind = GoApiMemberKind.Method,
Name = "Err",
Declaration = "Err() error",
Summary = "Err returns the first error recorded on this wrapper.",
ReturnType = "error",
ErrorModel = GoApiErrorModel.None,
IsSynthesized = true
}
]
},
new GoApiItem
{
Id = "options:WithPersistenceOptions",
TypeId = $"{owner}/WithPersistenceOptions",
Kind = GoApiItemKind.Options,
Name = "WithPersistenceOptions",
Declaration = "type WithPersistenceOptions struct",
OwningAssemblyName = owner,
UsedBy = ["Aspire.Hosting.Redis/withPersistence"],
Members =
[
new GoApiMember
{
Id = "WithPersistenceOptions.KeysChangedThreshold",
Kind = GoApiMemberKind.Field,
Name = "KeysChangedThreshold",
Declaration = "KeysChangedThreshold *float64 `json:\"keysChangedThreshold,omitempty\"`",
JsonName = "keysChangedThreshold"
}
]
},
new GoApiItem
{
Id = "enum:RedisKind",
TypeId = $"{owner}/RedisKind",
Kind = GoApiItemKind.Enum,
Name = "RedisKind",
Declaration = "type RedisKind string",
OwningAssemblyName = owner,
IsMarker = false,
Members =
[
new GoApiMember
{
Id = "RedisKind.RedisKindValkey",
Kind = GoApiMemberKind.Constant,
Name = "RedisKindValkey",
Declaration = "RedisKindValkey RedisKind = \"valkey\"",
Value = "valkey"
}
]
},
new GoApiItem
{
Id = "augmentation:Aspire.Hosting.Redis:Resource",
TypeId = "Aspire.Hosting/Aspire.Hosting.ApplicationModel.IResource",
Kind = GoApiItemKind.Augmentation,
Name = "Resource",
Declaration = "type Resource interface",
OwningAssemblyName = "Aspire.Hosting"
}
]
}
],
Declarations =
[
new GoApiDeclaration
{
Id = "Aspire.Hosting.Redis:interface:RedisResource",
Content = "type RedisResource interface {\r\n\thandleReference\r\n}",
OwningAssemblyName = owner
}
]
};
}
}