| File: Configuration\PackageEntry.cs | Web Access |
| Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire) |
// 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; using System.Text.Json.Serialization; namespace Aspire.Cli.Configuration; /// <summary> /// One entry in the <c>aspire.config.json</c> <c>packages</c> dictionary. /// /// <para>Two wire shapes are accepted:</para> /// /// <list type="bullet"> /// <item> /// <b>Short form (string)</b> — NuGet by default. An empty string means the SDK version; a /// non-empty string is an explicit NuGet package version. As a legacy shorthand, a value that /// ends in <c>.csproj</c> is treated as a local project reference (path to the <c>.csproj</c>), /// mirroring <see cref="AspireConfigFile.FromLegacy"/> and the original settings.json behavior. /// <code> /// "Aspire.Hosting.Redis": "", // NuGet, SDK version /// "Aspire.Hosting.Kafka": "9.2.0", // NuGet, explicit version /// "Aspire.Hosting.Local": "../Local/Local.csproj" // project reference (legacy shorthand) /// </code> /// </item> /// <item> /// <b>Long form (object)</b> — requires a <c>source</c> discriminator. Each source has its /// own required fields. /// <code> /// "Aspire.Hosting.Redis": { "source": "nuget", "version": "9.2.0" } /// "Aspire.Hosting.Local": { "source": "project", "path": "../Local/Local.csproj" } /// "@spike/aspire-kafka": { "source": "npm", "path": "./kafka-integration/host.ts" } /// </code> /// </item> /// </list> /// /// On write-back, a NuGet entry is emitted as the short string form when possible. Project and /// npm entries are always emitted as objects. Legacy null values also use the SDK version. /// </summary> [JsonConverter(typeof(PackageEntryConverter))] internal sealed class PackageEntry { /// <summary> /// The source ecosystem this entry came from. /// </summary> public required IntegrationSource Source { get; init; } /// <summary> /// NuGet package version. <c>null</c> means "use the SDK version" (short form <c>""</c>). /// Non-null only when <see cref="Source"/> is <see cref="IntegrationSource.Nuget"/>. /// </summary> public string? Version { get; init; } /// <summary> /// On-disk path associated with this entry: /// the <c>.csproj</c> path for <see cref="IntegrationSource.Project"/>, or the integration /// host entry point (e.g. <c>./kafka-integration/host.ts</c>) for <see cref="IntegrationSource.Npm"/>. /// Stored as it was written in the config — relative or absolute. Resolved to absolute in /// <see cref="AspireConfigFile.GetIntegrationReferences"/>. /// </summary> public string? Path { get; init; } /// <summary> /// Short-hand for the common case: create a NuGet entry with an explicit version. /// </summary> public static PackageEntry Nuget(string? version) => new() { Source = IntegrationSource.Nuget, Version = version }; /// <summary> /// Short-hand: create a local .NET project reference entry. /// </summary> public static PackageEntry Project(string path) => new() { Source = IntegrationSource.Project, Path = path }; /// <summary> /// Short-hand: create an npm integration host entry. /// </summary> public static PackageEntry Npm(string path) => new() { Source = IntegrationSource.Npm, Path = path }; } /// <summary> /// Reads the string-or-object polymorphic shape for <see cref="PackageEntry"/>. A bare string /// token is interpreted as a NuGet version (empty = SDK version), except for the legacy shorthand /// where a value ending in <c>.csproj</c> is treated as a local project reference. An object token /// requires a <c>source</c> discriminator and per-source fields. /// </summary> internal sealed class PackageEntryConverter : JsonConverter<PackageEntry> { public override bool HandleNull => true; public override PackageEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // Legacy packages such as { "Aspire.Hosting.Redis": null } use the SDK version. // Handle null here instead of leaving a null dictionary entry for callers to dereference. if (reader.TokenType == JsonTokenType.Null) { return PackageEntry.Nuget(null); } if (reader.TokenType == JsonTokenType.String) { var stringValue = reader.GetString()?.Trim(); // Empty string means "use the SDK version" — stored as null so the caller can // substitute the effective SDK version at resolution time. if (string.IsNullOrWhiteSpace(stringValue)) { return PackageEntry.Nuget(null); } // Legacy shorthand: a string value ending in ".csproj" is a local project reference, // not a NuGet version. This mirrors AspireConfigFile.FromLegacy and the original // settings.json behavior (AspireJsonConfiguration.GetIntegrationReferences), so configs // written with the bare-string project path (e.g. "MyExt/MyExt.csproj") keep working. if (stringValue.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) { return PackageEntry.Project(stringValue); } return PackageEntry.Nuget(stringValue); } if (reader.TokenType != JsonTokenType.StartObject) { throw new JsonException( $"Package entry must be a string (NuGet version short form) or an object with a \"source\" discriminator. Got token: {reader.TokenType}."); } string? source = null; string? version = null; string? path = null; while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) { if (reader.TokenType != JsonTokenType.PropertyName) { throw new JsonException($"Unexpected token in package entry: {reader.TokenType}."); } var propertyName = reader.GetString(); reader.Read(); switch (propertyName) { case "source": source = reader.GetString(); break; case "version": version = reader.GetString(); break; case "path": path = reader.GetString(); break; default: // Unknown fields are ignored — forward compatibility. reader.Skip(); break; } } if (string.IsNullOrWhiteSpace(source)) { throw new JsonException( "Package entry object is missing the required \"source\" discriminator. Expected one of: \"nuget\", \"project\", \"npm\"."); } switch (source.ToLowerInvariant()) { case "nuget": return PackageEntry.Nuget(string.IsNullOrWhiteSpace(version) ? null : version.Trim()); case "project": if (string.IsNullOrWhiteSpace(path)) { throw new JsonException( "Package entry with source \"project\" is missing the required \"path\" field (absolute or relative path to a .csproj)."); } return PackageEntry.Project(path); case "npm": if (string.IsNullOrWhiteSpace(path)) { throw new JsonException( "Package entry with source \"npm\" is missing the required \"path\" field (path to the integration host entry point, e.g. \"./kafka-integration/host.ts\")."); } return PackageEntry.Npm(path); default: throw new JsonException( $"Package entry has unknown source \"{source}\". Expected one of: \"nuget\", \"project\", \"npm\"."); } } public override void Write(Utf8JsonWriter writer, PackageEntry value, JsonSerializerOptions options) { if (value is null) { writer.WriteStringValue(string.Empty); return; } switch (value.Source) { case IntegrationSource.Nuget: // Round-trip to the short string form: empty means SDK version. writer.WriteStringValue(value.Version ?? string.Empty); break; case IntegrationSource.Project: writer.WriteStartObject(); writer.WriteString("source", "project"); writer.WriteString("path", value.Path ?? string.Empty); writer.WriteEndObject(); break; case IntegrationSource.Npm: writer.WriteStartObject(); writer.WriteString("source", "npm"); writer.WriteString("path", value.Path ?? string.Empty); writer.WriteEndObject(); break; default: throw new JsonException($"Unknown package entry source: {value.Source}."); } } }