diff --git a/src/Tgstation.Server.Host/Controllers/IRequestSwarmRegistrationParser.cs b/src/Tgstation.Server.Host/Controllers/IRequestSwarmRegistrationParser.cs
new file mode 100644
index 0000000000..a91734f1a2
--- /dev/null
+++ b/src/Tgstation.Server.Host/Controllers/IRequestSwarmRegistrationParser.cs
@@ -0,0 +1,19 @@
+using System;
+
+using Microsoft.AspNetCore.Http;
+
+namespace Tgstation.Server.Host.Controllers
+{
+ ///
+ /// Parses the swarm registration header from a .
+ ///
+ public interface IRequestSwarmRegistrationParser
+ {
+ ///
+ /// Gets the swarm registration from the headers of a given .
+ ///
+ /// The , must contain a valid .
+ /// The parsed registration ID.
+ Guid GetRequestRegistrationId(HttpRequest request);
+ }
+}
diff --git a/src/Tgstation.Server.Host/Controllers/RequestSwarmRegistrationParser.cs b/src/Tgstation.Server.Host/Controllers/RequestSwarmRegistrationParser.cs
new file mode 100644
index 0000000000..5e15059d3b
--- /dev/null
+++ b/src/Tgstation.Server.Host/Controllers/RequestSwarmRegistrationParser.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Linq;
+
+using Microsoft.AspNetCore.Http;
+
+using Tgstation.Server.Host.Swarm;
+
+namespace Tgstation.Server.Host.Controllers
+{
+ ///
+ sealed class RequestSwarmRegistrationParser : IRequestSwarmRegistrationParser
+ {
+ ///
+ public Guid GetRequestRegistrationId(HttpRequest request)
+ {
+ if (request == null)
+ throw new ArgumentNullException(nameof(request));
+
+ return Guid.Parse(request.Headers[SwarmConstants.RegistrationIdHeader].First());
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Controllers/SwarmController.cs b/src/Tgstation.Server.Host/Controllers/SwarmController.cs
index 7f75ccd17f..c339a45a67 100644
--- a/src/Tgstation.Server.Host/Controllers/SwarmController.cs
+++ b/src/Tgstation.Server.Host/Controllers/SwarmController.cs
@@ -29,13 +29,18 @@ namespace Tgstation.Server.Host.Controllers
///
/// Get the current registration from the .
///
- Guid RequestRegistrationId => Guid.Parse(Request.Headers[SwarmConstants.RegistrationIdHeader].First());
+ internal Guid RequestRegistrationId => requestRegistrationParser.GetRequestRegistrationId(Request);
///
/// The for the .
///
readonly ISwarmOperations swarmOperations;
+ ///
+ /// The for the .
+ ///
+ readonly IRequestSwarmRegistrationParser requestRegistrationParser;
+
///
/// The for the .
///
@@ -55,16 +60,19 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the class.
///
/// The value of .
+ /// The value of .
/// The value of .
/// The containing the value of .
/// The value of .
public SwarmController(
ISwarmOperations swarmOperations,
+ IRequestSwarmRegistrationParser requestRegistrationParser,
IAssemblyInformationProvider assemblyInformationProvider,
IOptions swarmConfigurationOptions,
ILogger logger)
{
this.swarmOperations = swarmOperations ?? throw new ArgumentNullException(nameof(swarmOperations));
+ this.requestRegistrationParser = requestRegistrationParser ?? throw new ArgumentNullException(nameof(requestRegistrationParser));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions));
this.logger = logger;
diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs
index 33a3cd3ed2..be4b8e1a83 100644
--- a/src/Tgstation.Server.Host/Core/Application.cs
+++ b/src/Tgstation.Server.Host/Core/Application.cs
@@ -360,6 +360,7 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton(x => x.GetRequiredService());
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// configure root services
services.AddSingleton();
diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs
index 4bea820982..beddc9e9cb 100644
--- a/src/Tgstation.Server.Host/Swarm/SwarmService.cs
+++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs
@@ -51,7 +51,7 @@ namespace Tgstation.Server.Host.Swarm
///
/// See for the swarm system.
///
- static readonly JsonSerializerSettings SerializerSettings = new ()
+ internal static JsonSerializerSettings SerializerSettings { get; } = new ()
{
ContractResolver = new DefaultContractResolver
{
diff --git a/tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs b/tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs
new file mode 100644
index 0000000000..afdaf505b5
--- /dev/null
+++ b/tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Routing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Moq;
+
+using Newtonsoft.Json;
+
+using Tgstation.Server.Common;
+using Tgstation.Server.Host.Configuration;
+using Tgstation.Server.Host.Controllers;
+using Tgstation.Server.Host.Swarm;
+
+namespace Tgstation.Server.Host.Tests.Swarm
+{
+ sealed class SwarmRpcMapper : IRequestSwarmRegistrationParser
+ {
+ List<(SwarmConfiguration, TestableSwarmNode)> configToControllers;
+ Guid? incomingRegistrationId;
+
+ public SwarmRpcMapper(Mock clientMock)
+ {
+ clientMock
+ .Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny()))
+ .Returns(MapRequest);
+ }
+
+ public Guid GetRequestRegistrationId(HttpRequest request)
+ {
+ Assert.IsTrue(incomingRegistrationId.HasValue);
+ var result = incomingRegistrationId.Value;
+ incomingRegistrationId = null;
+ return result;
+ }
+
+ public void Register(List<(SwarmConfiguration, TestableSwarmNode)> configToControllers)
+ {
+ this.configToControllers = configToControllers;
+ }
+
+ async Task MapRequest(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var (config, node) = configToControllers.FirstOrDefault(
+ pair => pair.Item1.Address.IsBaseOf(request.RequestUri));
+
+ if (config == default)
+ Assert.Fail($"Invalid node address: {request.RequestUri}");
+
+ if (!node.Initialized)
+ {
+ throw new HttpRequestException("Can't connect to uninitialized node!");
+ }
+
+ var controller = node.Controller;
+
+ Type targetAttribute = null;
+ bool isDataRequest = false;
+ switch (request.Method.Method.ToUpperInvariant())
+ {
+ case "GET":
+ targetAttribute = typeof(HttpGetAttribute);
+ break;
+ case "POST":
+ targetAttribute = typeof(HttpPostAttribute);
+ isDataRequest = true;
+ break;
+ case "PUT":
+ targetAttribute = typeof(HttpPutAttribute);
+ isDataRequest = true;
+ break;
+ case "DELETE":
+ targetAttribute = typeof(HttpDeleteAttribute);
+ break;
+ case "PATCH":
+ targetAttribute = typeof(HttpPatchAttribute);
+ isDataRequest = true;
+ break;
+ default:
+ Assert.Fail($"Unknown request method: {request.Method.Method}");
+ break;
+ }
+
+ var stringUrl = request.RequestUri.ToString();
+ var rootIndex = stringUrl.IndexOf(SwarmConstants.ControllerRoute);
+ if (rootIndex == -1)
+ Assert.Fail($"Invalid Swarm route: {stringUrl}");
+
+ var route = stringUrl[(rootIndex + SwarmConstants.ControllerRoute.Length)..].TrimStart('/');
+
+ var controllerMethod = controller
+ .GetType()
+ .GetMethods()
+ .Select(method => (method, (HttpMethodAttribute)method.GetCustomAttribute(targetAttribute)))
+ .Where(pair => pair.Item2 != null
+ && pair.Item2.HttpMethods.Count() == 1
+ && pair.Item2.HttpMethods.All(supportedMethod => supportedMethod.Equals(request.Method.Method))
+ && pair.Item2.Template == route)
+ .Select(pair => pair.method)
+ .SingleOrDefault();
+
+ if (controllerMethod == default)
+ Assert.Fail($"SwarmController has no method with attribute {targetAttribute}!");
+
+ // We're not testing OnActionExecutingAsync, that's covered by integration.
+ if (request.Headers.TryGetValues(SwarmConstants.RegistrationIdHeader, out var values) && values.Count() == 1)
+ node.RpcMapper.incomingRegistrationId = Guid.Parse(values.First());
+
+ var args = new List