style: use file-scoped namespace in GetPackageManifestRequestHandler

Matches the convention used by the other request handlers in this
folder (ReportVersionsRequestHandler.cs, AppDebugRequestHandler.cs,
etc). Addresses PR #1441 review comment.
This commit is contained in:
jkdevito 2026-07-08 23:16:06 -05:00
parent f0025ce56c
commit 2d8e42f566

View file

@ -6,254 +6,253 @@ using Newtonsoft.Json;
using PepperDash.Core.Web.RequestHandlers; using PepperDash.Core.Web.RequestHandlers;
using PepperDash.Essentials.Core.Config; using PepperDash.Essentials.Core.Config;
namespace PepperDash.Essentials.Core.Web.RequestHandlers namespace PepperDash.Essentials.Core.Web.RequestHandlers;
/// <summary>
/// Represents a GetPackageManifestRequestHandler
/// </summary>
public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler
{ {
/// <summary> /// <summary>
/// Represents a GetPackageManifestRequestHandler /// Constructor
/// </summary> /// </summary>
public class GetPackageManifestRequestHandler : WebApiBaseRequestHandler /// <remarks>
/// base(true) enables CORS support by default
/// </remarks>
public GetPackageManifestRequestHandler()
: base(true)
{ {
/// <summary> }
/// Constructor
/// </summary> /// <summary>
/// <remarks> /// Handles GET method requests
/// base(true) enables CORS support by default /// </summary>
/// </remarks> /// <param name="context"></param>
public GetPackageManifestRequestHandler() protected override void HandleGet(HttpCwsContext context)
: base(true) {
try
{ {
var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData();
PopulateEssentials(result);
PopulatePackages(result);
var js = JsonConvert.SerializeObject(result, Formatting.Indented);
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = System.Text.Encoding.UTF8;
context.Response.Write(js, false);
context.Response.End();
} }
catch (Exception ex)
/// <summary>
/// Handles GET method requests
/// </summary>
/// <param name="context"></param>
protected override void HandleGet(HttpCwsContext context)
{ {
try PepperDash.Core.Debug.LogMessage(ex, "Exception handling GET /packageManifest request");
{ context.Response.StatusCode = 500;
var result = CloneVersionData(ConfigReader.ConfigObject?.Versions) ?? new VersionData(); context.Response.StatusDescription = "Internal Server Error";
context.Response.End();
PopulateEssentials(result);
PopulatePackages(result);
var js = JsonConvert.SerializeObject(result, Formatting.Indented);
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.ContentType = "application/json";
context.Response.ContentEncoding = System.Text.Encoding.UTF8;
context.Response.Write(js, false);
context.Response.End();
}
catch (Exception ex)
{
PepperDash.Core.Debug.LogMessage(ex, "Exception handling GET /packageManifest request");
context.Response.StatusCode = 500;
context.Response.StatusDescription = "Internal Server Error";
context.Response.End();
}
}
/// <summary>
/// Deep-copies the config's VersionData so the live config object is never mutated
/// </summary>
private static VersionData CloneVersionData(VersionData source)
{
if (source == null)
{
return null;
}
var json = JsonConvert.SerializeObject(source);
return JsonConvert.DeserializeObject<VersionData>(json);
}
/// <summary>
/// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly
/// </summary>
private static void PopulateEssentials(VersionData result)
{
var essentials = result.Essentials ?? new PackageVersion();
essentials.Version = Global.AssemblyVersion;
// The main program assembly (PackageId "PepperDashEssentials") is what's actually published
// to NuGet, but this handler lives in PepperDash.Essentials.Core, which can't reference that
// project's types directly (Essentials -> Core, not the reverse). PluginLoader.EssentialsAssembly.Assembly
// is unreliable (often left null - see PluginLoader.SetEssentialsAssembly), so look it up
// directly from the loaded AppDomain by its Directory.Build.props-embedded PackageId metadata
// (every project's .csproj sets its own PackageId explicitly), falling back to this handler's
// own (Core) assembly if it can't be found.
var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase))
?? typeof(GetPackageManifestRequestHandler).Assembly;
var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl"));
if (!string.IsNullOrEmpty(repoUrl))
{
essentials.RepoUrl = repoUrl;
}
var name = GetAssemblyProduct(essentialsAssembly);
if (!string.IsNullOrEmpty(name))
{
essentials.Name = name;
}
// Prefer the PackageId embedded via Directory.Build.props' AssemblyMetadata item (the
// authoritative source, matching the actual published PackageId) over any config-supplied
// or hardcoded value.
var reflectedPackageId = GetAssemblyMetadataValue(essentialsAssembly, "PackageId");
if (!string.IsNullOrEmpty(reflectedPackageId))
{
essentials.PackageId = reflectedPackageId;
}
else if (string.IsNullOrEmpty(essentials.PackageId))
{
essentials.PackageId = "PepperDashEssentials";
}
result.Essentials = essentials;
}
/// <summary>
/// Merges reflection data from loaded plugin assemblies with the config's packages list
/// </summary>
private static void PopulatePackages(VersionData result)
{
// Filter out null entries defensively - the packages list is deserialized from user-editable
// config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint.
var configPackages = (result.Packages ?? new System.Collections.Generic.List<PackageVersion>())
.Where(p => p != null)
.ToList();
var matchedConfigPackages = new System.Collections.Generic.HashSet<PackageVersion>();
var mergedPackages = new System.Collections.Generic.List<PackageVersion>();
foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null))
{
var reflectedVersion = loaded.Version;
if (string.IsNullOrEmpty(reflectedVersion))
{
// Never emit an entry with no version - the extension's parser drops entries
// whose version isn't a string.
continue;
}
var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl"));
var reflectedName = GetAssemblyProduct(loaded.Assembly);
// Plugins built from a Directory.Build.props that embeds
// <AssemblyMetadata Include="PackageId" Value="$(PackageId)" /> carry their PackageId
// directly - this is authoritative and should be preferred over the title/name fallback chain.
var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId");
var assemblyTitle = GetAssemblyTitle(loaded.Assembly);
var assemblyName = loaded.Assembly.GetName().Name;
var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName);
var match = configPackages.FirstOrDefault(p =>
!matchedConfigPackages.Contains(p) &&
!string.IsNullOrEmpty(p.PackageId) &&
(string.Equals(p.PackageId, reflectedPackageId, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase)));
if (match != null)
{
matchedConfigPackages.Add(match);
mergedPackages.Add(new PackageVersion
{
Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName,
RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl,
PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId,
Version = reflectedVersion
});
}
else
{
// Loaded but not present (or not matched) in config - emit the reflected PackageId
// when the assembly carries one, otherwise leave it null as before.
mergedPackages.Add(new PackageVersion
{
Name = reflectedName,
RepoUrl = reflectedRepoUrl,
PackageId = reflectedPackageId,
Version = reflectedVersion,
});
}
}
// Configured but not currently loaded - pass through unchanged
mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p)));
result.Packages = mergedPackages;
}
private static string GetAssemblyMetadataValue(Assembly assembly, string key)
{
if (assembly == null)
{
return null;
}
var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false)
.Cast<AssemblyMetadataAttribute>()
.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase));
return match?.Value;
}
private static string GetAssemblyProduct(Assembly assembly)
{
if (assembly == null)
{
return null;
}
var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false)
.FirstOrDefault() as AssemblyProductAttribute;
return attribute?.Product;
}
private static string GetAssemblyTitle(Assembly assembly)
{
if (assembly == null)
{
return null;
}
var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false)
.FirstOrDefault() as AssemblyTitleAttribute;
return attribute?.Title;
}
private static string StripTrailingSeriesSuffix(string assemblyName)
{
const string suffix = ".4Series";
if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return assemblyName;
}
return assemblyName.Substring(0, assemblyName.Length - suffix.Length);
}
private static string TrimTrailingGit(string repoUrl)
{
const string suffix = ".git";
if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return repoUrl;
}
return repoUrl.Substring(0, repoUrl.Length - suffix.Length);
} }
} }
/// <summary>
/// Deep-copies the config's VersionData so the live config object is never mutated
/// </summary>
private static VersionData CloneVersionData(VersionData source)
{
if (source == null)
{
return null;
}
var json = JsonConvert.SerializeObject(source);
return JsonConvert.DeserializeObject<VersionData>(json);
}
/// <summary>
/// Enriches (or creates) the essentials entry from the loaded PepperDash.Essentials.Core assembly
/// </summary>
private static void PopulateEssentials(VersionData result)
{
var essentials = result.Essentials ?? new PackageVersion();
essentials.Version = Global.AssemblyVersion;
// The main program assembly (PackageId "PepperDashEssentials") is what's actually published
// to NuGet, but this handler lives in PepperDash.Essentials.Core, which can't reference that
// project's types directly (Essentials -> Core, not the reverse). PluginLoader.EssentialsAssembly.Assembly
// is unreliable (often left null - see PluginLoader.SetEssentialsAssembly), so look it up
// directly from the loaded AppDomain by its Directory.Build.props-embedded PackageId metadata
// (every project's .csproj sets its own PackageId explicitly), falling back to this handler's
// own (Core) assembly if it can't be found.
var essentialsAssembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => string.Equals(GetAssemblyMetadataValue(a, "PackageId"), "PepperDashEssentials", StringComparison.OrdinalIgnoreCase))
?? typeof(GetPackageManifestRequestHandler).Assembly;
var repoUrl = TrimTrailingGit(GetAssemblyMetadataValue(essentialsAssembly, "RepositoryUrl"));
if (!string.IsNullOrEmpty(repoUrl))
{
essentials.RepoUrl = repoUrl;
}
var name = GetAssemblyProduct(essentialsAssembly);
if (!string.IsNullOrEmpty(name))
{
essentials.Name = name;
}
// Prefer the PackageId embedded via Directory.Build.props' AssemblyMetadata item (the
// authoritative source, matching the actual published PackageId) over any config-supplied
// or hardcoded value.
var reflectedPackageId = GetAssemblyMetadataValue(essentialsAssembly, "PackageId");
if (!string.IsNullOrEmpty(reflectedPackageId))
{
essentials.PackageId = reflectedPackageId;
}
else if (string.IsNullOrEmpty(essentials.PackageId))
{
essentials.PackageId = "PepperDashEssentials";
}
result.Essentials = essentials;
}
/// <summary>
/// Merges reflection data from loaded plugin assemblies with the config's packages list
/// </summary>
private static void PopulatePackages(VersionData result)
{
// Filter out null entries defensively - the packages list is deserialized from user-editable
// config JSON, so a malformed "packages": [null, ...] shouldn't throw and 500 the endpoint.
var configPackages = (result.Packages ?? new System.Collections.Generic.List<PackageVersion>())
.Where(p => p != null)
.ToList();
var matchedConfigPackages = new System.Collections.Generic.HashSet<PackageVersion>();
var mergedPackages = new System.Collections.Generic.List<PackageVersion>();
foreach (var loaded in PluginLoader.EssentialsPluginAssemblies.Where(a => a.Assembly != null))
{
var reflectedVersion = loaded.Version;
if (string.IsNullOrEmpty(reflectedVersion))
{
// Never emit an entry with no version - the extension's parser drops entries
// whose version isn't a string.
continue;
}
var reflectedRepoUrl = TrimTrailingGit(GetAssemblyMetadataValue(loaded.Assembly, "RepositoryUrl"));
var reflectedName = GetAssemblyProduct(loaded.Assembly);
// Plugins built from a Directory.Build.props that embeds
// <AssemblyMetadata Include="PackageId" Value="$(PackageId)" /> carry their PackageId
// directly - this is authoritative and should be preferred over the title/name fallback chain.
var reflectedPackageId = GetAssemblyMetadataValue(loaded.Assembly, "PackageId");
var assemblyTitle = GetAssemblyTitle(loaded.Assembly);
var assemblyName = loaded.Assembly.GetName().Name;
var assemblyNameNoSeriesSuffix = StripTrailingSeriesSuffix(assemblyName);
var match = configPackages.FirstOrDefault(p =>
!matchedConfigPackages.Contains(p) &&
!string.IsNullOrEmpty(p.PackageId) &&
(string.Equals(p.PackageId, reflectedPackageId, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyTitle, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(p.PackageId, assemblyNameNoSeriesSuffix, StringComparison.OrdinalIgnoreCase)));
if (match != null)
{
matchedConfigPackages.Add(match);
mergedPackages.Add(new PackageVersion
{
Name = !string.IsNullOrEmpty(match.Name) ? match.Name : reflectedName,
RepoUrl = !string.IsNullOrEmpty(match.RepoUrl) ? match.RepoUrl : reflectedRepoUrl,
PackageId = !string.IsNullOrEmpty(reflectedPackageId) ? reflectedPackageId : match.PackageId,
Version = reflectedVersion
});
}
else
{
// Loaded but not present (or not matched) in config - emit the reflected PackageId
// when the assembly carries one, otherwise leave it null as before.
mergedPackages.Add(new PackageVersion
{
Name = reflectedName,
RepoUrl = reflectedRepoUrl,
PackageId = reflectedPackageId,
Version = reflectedVersion,
});
}
}
// Configured but not currently loaded - pass through unchanged
mergedPackages.AddRange(configPackages.Where(p => !matchedConfigPackages.Contains(p)));
result.Packages = mergedPackages;
}
private static string GetAssemblyMetadataValue(Assembly assembly, string key)
{
if (assembly == null)
{
return null;
}
var match = assembly.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false)
.Cast<AssemblyMetadataAttribute>()
.FirstOrDefault(a => string.Equals(a.Key, key, StringComparison.OrdinalIgnoreCase));
return match?.Value;
}
private static string GetAssemblyProduct(Assembly assembly)
{
if (assembly == null)
{
return null;
}
var attribute = assembly.GetCustomAttributes(typeof(AssemblyProductAttribute), false)
.FirstOrDefault() as AssemblyProductAttribute;
return attribute?.Product;
}
private static string GetAssemblyTitle(Assembly assembly)
{
if (assembly == null)
{
return null;
}
var attribute = assembly.GetCustomAttributes(typeof(AssemblyTitleAttribute), false)
.FirstOrDefault() as AssemblyTitleAttribute;
return attribute?.Title;
}
private static string StripTrailingSeriesSuffix(string assemblyName)
{
const string suffix = ".4Series";
if (string.IsNullOrEmpty(assemblyName) || !assemblyName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return assemblyName;
}
return assemblyName.Substring(0, assemblyName.Length - suffix.Length);
}
private static string TrimTrailingGit(string repoUrl)
{
const string suffix = ".git";
if (string.IsNullOrEmpty(repoUrl) || !repoUrl.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return repoUrl;
}
return repoUrl.Substring(0, repoUrl.Length - suffix.Length);
}
} }