Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions SW.Bitween.Api/Resources/Adapters/AdapterListing.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SW.Bitween.Domain;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.Adapters;

/// <summary>One adapter of a kind, and the versions of it that are published.</summary>
/// <param name="Key">The adapter id, as a subscription stores it.</param>
/// <param name="Native">In-process, so it has no published versions of its own.</param>
/// <param name="VersionPaths">
/// Published version files, as paths relative to the remote adapter root — the trailing segment is
/// the version number.
/// </param>
public record AdapterEntry(string Key, bool Native, IReadOnlyList<string> VersionPaths);

/// <summary>
/// The list of adapters of one kind: the in-process ones plus whatever is published to storage.
/// </summary>
/// <remarks>
/// Shared by <see cref="SearchVersioned"/>, which answers with the shape the older UI reads, and
/// <see cref="Catalog"/>, which answers with the same list plus each adapter's startup properties.
/// The grouping of version files under their adapter is fiddly enough that a second copy of it
/// would be a second thing to get wrong.
/// </remarks>
public class AdapterListing(
ServerlessOptions serverlessOptions,
ICloudFilesService cloudFilesService,
NativeAdapterDiscoveryService nativeAdapterDiscovery,
BitweenDbContext dbContext)
{
/// <param name="prefix">The plural, lowercase kind: <c>receivers</c>, <c>handlers</c>, …</param>
/// <returns>Native adapters first, then the published ones.</returns>
public async Task<List<AdapterEntry>> List(string prefix)
{
var index = serverlessOptions.AdapterRemotePath.Length + 1;

var native = (await nativeAdapterDiscovery.GetNativeAdapters(prefix).ExceptRetiring(dbContext))
.Select(key => new AdapterEntry(key, true, []))
.ToList();

var files = (await cloudFilesService.ListAsync($"{serverlessOptions.AdapterRemotePath}/infolink6.{prefix}"))
.Where(item => item.Size > 0)
.ToList();

var published = files
.GroupBy(i =>
{
var lastSection = i.Key.Split("/").Last();
var isSemver = Semver.IsVersionNumber(lastSection);
return isSemver ? i.Key.Split("/").ElementAt(^2) : lastSection;
})
.Select(g => new AdapterEntry(
g.Key,
false,
g.Where(v => v.Key != g.Key && Semver.IsVersionNumber(v.Key.Split("/").Last()))
.Select(v => v.Key[index..])
.ToList()));

return native.Concat(published).ToList();
}
}
74 changes: 74 additions & 0 deletions SW.Bitween.Api/Resources/Adapters/Catalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SW.Bitween.Domain;
using SW.Bitween.Model;
using SW.PrimitiveTypes;

namespace SW.Bitween.Resources.Adapters;

/// <summary>
/// Every adapter of one kind, each with the startup properties it expects.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="SearchVersioned"/> answers with the adapters alone, which left a caller that needs to
/// draw a form per adapter to ask <see cref="GetStartupValues"/> once per row. On a screen listing
/// all four kinds that is around ninety requests, six of which a browser will run at a time, so the
/// last of them waits behind fifteen rounds of queueing — and every one of those requests carries
/// its own permission check. Answering the whole kind at once makes it four requests for the screen.
/// </para>
/// <para>
/// <see cref="SearchVersioned"/> is deliberately left as it is: the older UI reads it, does not need
/// the properties, and should not start paying for them.
/// </para>
/// </remarks>
[HandlerName("Catalog")]
public class Catalog(
AdapterListing listing,
AdapterStartupValues startupValues,
BitweenDbContext dbContext,
RequestContext requestContext) : IQueryHandler<AdapterSearchRequest, object>
{
public async Task<object> Handle(AdapterSearchRequest request)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View);

var adapters = await listing.List(request.Prefix);

// Asked for all at once and left unthrottled on purpose. The native ones answer by
// reflection and should not be made to queue, and how many published adapters may be
// running at a time is capped in ServerlessAdapterDescriber — process-wide, which is the
// only place it can be, since this handler knows nothing of the other requests in flight.
var described = await Task.WhenAll(adapters.Select(async a => (a.Key, Values: await Describe(a.Key))));
var byKey = described.ToDictionary(d => d.Key, d => d.Values);

return adapters.Select(a => new
{
a.Key,
a.Native,
// Just the version numbers. VersionPaths carries each one as a path, which is what the
// older shape passed through and what made a version read as an object rather than
// "1.2.3" to anything trying to label it.
Versions = a.VersionPaths.Select(v => v.Split('/').Last()).ToList(),
StartupValues = byKey[a.Key]
});
}

private async Task<IDictionary<string, StartupValue>> Describe(string adapterId)
{
try
{
return await startupValues.Describe(adapterId);
}
catch (Exception)
{
// One adapter that cannot be described — its runtime is missing locally, say — must not
// blank out the rest of the catalogue, including the native ones that resolved
// perfectly well. It comes back with no properties, as it did when the caller was
// asking row by row and swallowing the failure itself.
return new Dictionary<string, StartupValue>();
}
}
}
24 changes: 6 additions & 18 deletions SW.Bitween.Api/Resources/Adapters/GetProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ namespace SW.Bitween.Resources.Adapters
[HandlerName("properties")]
public class GetProperties : IGetHandler<string,object>
{
private readonly IServerlessService serverless;
private readonly AdapterStartupValues startupValues;
private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery;
private readonly BitweenDbContext dbContext;
private readonly RequestContext requestContext;

public GetProperties(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery,
public GetProperties(AdapterStartupValues startupValues, NativeAdapterDiscoveryService nativeAdapterDiscovery,
BitweenDbContext dbContext, RequestContext requestContext)
{
this.serverless = serverless;
this.startupValues = startupValues;
_nativeAdapterDiscovery = nativeAdapterDiscovery;
this.dbContext = dbContext;
this.requestContext = requestContext;
Expand All @@ -37,21 +37,9 @@ async public Task<object> Handle(string key)
return _nativeAdapterDiscovery.GetExpectedStartupValues(decodedKey);
}

// Handle serverless adapters
try
{
await serverless.StartAsync(decodedKey, null);
}
catch (KeyNotFoundException ex)
{
throw new BitweenException(
$"Adapter '{decodedKey}' metadata is incomplete or the adapter package is not installed. " +
$"Missing metadata key: {ex.Message}", ex);
}

var expected = await serverless.GetExpectedStartupValues();
if (expected == null)
return new Dictionary<string, string>();
// Handle serverless adapters. The native branch above returns a different shape —
// each key's default rather than a "key (default)" label — so it is left as it was.
var expected = await startupValues.Describe(decodedKey);

return expected
.ToList()
Expand Down
42 changes: 4 additions & 38 deletions SW.Bitween.Api/Resources/Adapters/GetStartupValues.cs
Original file line number Diff line number Diff line change
@@ -1,64 +1,30 @@
using SW.Bitween.Domain;
using SW.PrimitiveTypes;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace SW.Bitween.Resources.Adapters
{
[HandlerName(nameof(GetStartupValues))]
public class GetStartupValues : IGetHandler<string, IDictionary<string, StartupValue>>
{
private readonly IServerlessService serverless;
private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery;
private readonly AdapterStartupValues startupValues;
private readonly BitweenDbContext dbContext;
private readonly RequestContext requestContext;

public GetStartupValues(IServerlessService serverless, NativeAdapterDiscoveryService nativeAdapterDiscovery,
public GetStartupValues(AdapterStartupValues startupValues,
BitweenDbContext dbContext, RequestContext requestContext)
{
this.serverless = serverless;
_nativeAdapterDiscovery = nativeAdapterDiscovery;
this.startupValues = startupValues;
this.dbContext = dbContext;
this.requestContext = requestContext;
}



public async Task<IDictionary<string, StartupValue>> Handle(string key)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Subscriptions.View);

var decodedKey = Uri.UnescapeDataString(key);

IDictionary<string, StartupValue> startupValues = new Dictionary<string, StartupValue>();

// Check if it's a native adapter
if (decodedKey.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase))
{
startupValues= _nativeAdapterDiscovery.GetStartupValues(decodedKey);
}
else
{
// Handle serverless adapters
try
{
await serverless.StartAsync(decodedKey, null);
}
catch (KeyNotFoundException ex)
{
throw new BitweenException(
$"Adapter '{decodedKey}' metadata is incomplete or the adapter package is not installed. " +
$"Missing metadata key: {ex.Message}", ex);
}

startupValues = await serverless.GetExpectedStartupValues();
}

return startupValues ?? new Dictionary<string, StartupValue>();
return await startupValues.Describe(System.Uri.UnescapeDataString(key));
}
}

}
66 changes: 14 additions & 52 deletions SW.Bitween.Api/Resources/Adapters/SearchVersioned.cs
Original file line number Diff line number Diff line change
@@ -1,78 +1,40 @@
using System;
using System.Collections.Generic;
using SW.PrimitiveTypes;
using System.Threading.Tasks;
using System.Linq;
using System.Threading.Tasks;
using SW.Bitween.Domain;
using SW.PrimitiveTypes;
using SW.Bitween.Model;

namespace SW.Bitween.Resources.Adapters
{
[HandlerName("Versioned")]
public class SearchVersioned : IQueryHandler<AdapterSearchRequest,object>
public class SearchVersioned : IQueryHandler<AdapterSearchRequest, object>
{
private readonly ServerlessOptions _serverlessOptions;
private readonly ICloudFilesService _cloudFilesService;
private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery;
private readonly AdapterListing _listing;
private readonly BitweenDbContext _dbContext;
private readonly RequestContext _requestContext;

public SearchVersioned(ServerlessOptions serverlessOptions, ICloudFilesService cloudFilesService,
NativeAdapterDiscoveryService nativeAdapterDiscovery, BitweenDbContext dbContext,
public SearchVersioned(AdapterListing listing, BitweenDbContext dbContext,
RequestContext requestContext)
{
_serverlessOptions = serverlessOptions;
_cloudFilesService = cloudFilesService;
_nativeAdapterDiscovery = nativeAdapterDiscovery;
_listing = listing;
_dbContext = dbContext;
_requestContext = requestContext;
}


public async Task<object> Handle(AdapterSearchRequest request)
{
await _requestContext.EnsurePermission(_dbContext, Model.Permissions.Subscriptions.View);

var index = _serverlessOptions.AdapterRemotePath.Length + 1;
var adapters = await _listing.List(request.Prefix);

// Get native adapters first (they don't have versions)
var nativeAdapters = (await _nativeAdapterDiscovery.GetNativeAdapters(request.Prefix)
.ExceptRetiring(_dbContext))
.Select(key => new
{
Key = key,
Versions = new List<object>() // Native adapters have no versions
})
.ToList();

// Get external adapters from storage
var cloudFilesList =
(await _cloudFilesService.ListAsync(
$"{_serverlessOptions.AdapterRemotePath}/infolink6.{request.Prefix}"))
.Where(item => item.Size > 0)
.ToList();

var grouped = cloudFilesList
.GroupBy(i =>
{
var lastSection = i.Key.Split("/").Last();
var isSemver = Semver.IsVersionNumber(lastSection);
var key = isSemver ? i.Key.Split("/").ElementAt(^2) : lastSection;

return key;
});

var externalAdapters = grouped.Select(i => new
// Versions as a list of objects with a Key, which is the shape this endpoint has
// always answered with. Catalog is where the tidier shape lives.
return adapters.Select(a => new
{
i.Key,
Versions = i.Where(v => v.Key != i.Key && Semver.IsVersionNumber(v.Key.Split("/").Last()))
.Select(v => new
{
Key = v.Key[index..]
}).ToList()
a.Key,
Versions = a.VersionPaths.Select(v => (object)new { Key = v }).ToList()
});

// Return native adapters first, then external
return nativeAdapters.Concat<object>(externalAdapters);
}
}
}
}
21 changes: 4 additions & 17 deletions SW.Bitween.Api/Resources/Subscriptions/Get.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,23 @@
using System.Linq;
using System.Threading.Tasks;
using SW.Bitween.Model;
using System;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;

namespace SW.Bitween.Resources.Subscriptions
{
public class Get : IGetHandler<int, object>
{
private readonly BitweenDbContext dbContext;
private readonly RequestContext requestContext;
private readonly NativeAdapterDiscoveryService _nativeAdapterDiscovery;
private readonly IServiceProvider _serviceProvider;
private readonly AdapterStartupValues _startupValues;

private const string PrivateSentinel = "__private__";

public Get(BitweenDbContext dbContext, NativeAdapterDiscoveryService nativeAdapterDiscovery, IServiceProvider serviceProvider, RequestContext requestContext)
public Get(BitweenDbContext dbContext, AdapterStartupValues startupValues, RequestContext requestContext)
{
this.dbContext = dbContext;
this.requestContext = requestContext;
_nativeAdapterDiscovery = nativeAdapterDiscovery;
_serviceProvider = serviceProvider;
_startupValues = startupValues;
}

public async Task<object> Handle(int key)
Expand Down Expand Up @@ -96,16 +92,7 @@ private async Task<ICollection<KeyAndValue>> MaskPrivateProps(string adapterId,

try
{
if (adapterId.StartsWith(NativeAdapterDiscoveryService.NativePrefix, StringComparison.OrdinalIgnoreCase))
{
startupValues = _nativeAdapterDiscovery.GetStartupValues(adapterId);
}
else
{
var serverless = _serviceProvider.GetRequiredService<IServerlessService>();
await serverless.StartAsync(adapterId, null);
startupValues = await serverless.GetExpectedStartupValues();
}
startupValues = await _startupValues.Describe(adapterId);
}
catch
{
Expand Down
Loading
Loading