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
3 changes: 3 additions & 0 deletions mnestix-proxy.Tests/IntegrationTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
{
{ "CustomerEndpointsSecurity:ApiKey", "verySecureApiKeyMock" },
{ "Features:AllowRetrievingAllShellsAndSubmodels", "true" },
{ "Features:AasDiscoveryMiddleware", "false" },
{ "Features:AasRegistryMiddleware", "false" },
{ "ReverseProxy:Clusters:mnestixApiCluster:Destinations:destination1:Address", _downstreamUrl },
{ "ReverseProxy:Clusters:aasRepoCluster:Destinations:destination1:Address", _downstreamUrl },
{ "ReverseProxy:Clusters:submodelRepoCluster:Destinations:destination1:Address", _downstreamUrl },
{ "ReverseProxy:Clusters:discoveryCluster:Destinations:destination1:Address", _downstreamUrl },
{ "ReverseProxy:Clusters:aasRegistryCluster:Destinations:destination1:Address", _downstreamUrl },
};

if (_customSettings != null)
Expand Down

Large diffs are not rendered by default.

101 changes: 101 additions & 0 deletions mnestix-proxy.Tests/TestMockService/RegistryMockService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;

namespace mnestix_proxy.Tests.TestMockService
{
/// <summary>
/// A lightweight HTTP mock server for the AAS Registry that records every request it receives.
/// Tests can inspect ReceivedRequests to verify the middleware made the expected side-calls.
/// </summary>
public class RegistryMockService : IDisposable
{
private IHost? _host;
public string? Url;

private readonly List<ReceivedRequest> _receivedRequests = [];
private readonly object _lock = new();

/// <summary>
/// When set, overrides the default status code for all responses. Use to simulate registry errors.
/// </summary>
public int? ForcedStatusCode { get; set; }

public IReadOnlyList<ReceivedRequest> ReceivedRequests
{
get { lock (_lock) { return [.. _receivedRequests]; } }
}

public void Clear()
{
lock (_lock) { _receivedRequests.Clear(); }
}

public RegistryMockService()
{
StartServer();
}

private void StartServer()
{
if (_host != null) return;
_host = Host.CreateDefaultBuilder()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseKestrel()
.UseUrls("http://127.0.0.1:0")
.Configure(app =>
{
app.Run(async context =>
{
var method = context.Request.Method;
var path = context.Request.Path.Value ?? string.Empty;

var body = string.Empty;
if (context.Request.ContentLength > 0)
{
using var reader = new StreamReader(context.Request.Body);
body = await reader.ReadToEndAsync();
}

lock (_lock)
{
_receivedRequests.Add(new ReceivedRequest(method, path, body));
}

if (path.StartsWith("/shell-descriptors", StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = ForcedStatusCode ?? method switch
{
"POST" => StatusCodes.Status201Created,
"DELETE" => StatusCodes.Status204NoContent,
_ => StatusCodes.Status200OK
};
}
else
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
});
});
})
.Start();

var address = _host.Services?
.GetRequiredService<IServer>()?
.Features?
.Get<IServerAddressesFeature>()?
.Addresses
.First();

if (address == null) return;
Url = address.TrimEnd('/');
}

public void Dispose()
{
_host?.Dispose();
}
}

public record ReceivedRequest(string Method, string Path, string? Body);
}
15 changes: 15 additions & 0 deletions mnestix-proxy/Configuration/RegistryServiceOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace mnestix_proxy.Configuration
{
public class RegistryServiceOptions
{
/// <summary>
/// Name of the configuration section in appsettings.json
/// </summary>
public const string Options = "ReverseProxy:Clusters:aasRegistryCluster:Destinations:destination1";

/// <summary>
/// The base address of the AAS Registry service.
/// </summary>
public string Address { get; set; } = string.Empty;
}
}
162 changes: 162 additions & 0 deletions mnestix-proxy/Middleware/AasRegistryServiceMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using mnestix_proxy.Services.Clients;
using mnestix_proxy.Services.Shared;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text;

namespace mnestix_proxy.Middleware
{
/// <summary>
/// This middleware class is responsible for registering and deregistering AAS shell descriptors
/// in the AAS Registry whenever shells are created, updated, or deleted via the repository.
/// </summary>
public static class AasRegistryServiceMiddleware
{
internal static Func<HttpContext, Func<Task>, Task> ConfigureAasRegistryHandling()
{
return (context, next) =>
{
var registryClient = context.RequestServices.GetRequiredService<IRegistryClient>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger(nameof(AasRegistryServiceMiddleware));

switch (context.Request.Method)
{
case "PUT" or "POST" when context.Request.Path.HasValue &&
context.Request.Path.StartsWithSegments("/repo"):
HandlePutToRepo(context, registryClient, logger);
break;
case "DELETE" when context.Request.Path.HasValue &&
context.Request.Path.StartsWithSegments("/repo"):
_ = HandleDeleteFromRepoAsync(context, registryClient, logger);
break;
}

return next();
};
}

private static void HandlePutToRepo(HttpContext context, IRegistryClient registryClient, ILogger logger)
{
context.Request.EnableBuffering();
using (var reader
= new StreamReader(context.Request.Body, Encoding.UTF8, true, 1024, true))
{
var requestBody = new JObject();
try
{
var bodyStr = reader.ReadToEndAsync();
requestBody = JObject.Parse(bodyStr.Result);
}
catch (JsonReaderException)
{
// we do not want to break the request here.
// if the request cannot be parsed it might be a single value for a submodel element which the repo will handle correctly.
}

var modelType = requestBody["modelType"]?.Value<string>();
if (modelType is "AssetAdministrationShell")
{
var assetId = requestBody["assetInformation"]?["globalAssetId"]?.Value<string>();
var aasId = requestBody["id"]?.Value<string>();

if (aasId != null && assetId != null)
{
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
var descriptor = BuildShellDescriptor(requestBody, aasId, baseUrl);

_ = registryClient.RegisterOrUpdateShellDescriptor(aasId: aasId, shellDescriptorJson: descriptor.ToString())
.ContinueWith(t =>
{
if (t.IsFaulted)
logger.LogError(t.Exception, "Unexpected error registering AAS {AasId} in registry.", aasId);
else if (!t.Result.isSuccess)
logger.LogWarning("Failed to register AAS {AasId} in registry: {Result}", aasId, t.Result.Result);
}, TaskScheduler.Default);
}
}
}

// Rewind so the core request body is not lost when the proxy forwards the request
context.Request.Body.Position = 0;
}

private static async Task HandleDeleteFromRepoAsync(HttpContext context, IRegistryClient registryClient, ILogger logger)
{
// DELETE /repo/shells/{base64AasId} — the AAS ID is encoded in the URL path
var segments = context.Request.Path.Value?.Split('/') ?? [];

// Expected path segments: ["", "repo", "shells", "{base64AasId}"]
if (segments.Length >= 4 && segments[2].Equals("shells", StringComparison.OrdinalIgnoreCase))
{
var b64AasId = segments[3];
if (!string.IsNullOrEmpty(b64AasId))
{
try
{
var aasId = Base64StringDeAndEncoder.DecodeFrom64(b64AasId);
var (isSuccess, result) = await registryClient.DeleteShellDescriptor(aasIdentifier: aasId);
if (!isSuccess)
logger.LogWarning("Failed to delete shell descriptor for AAS {AasId}: {Result}", aasId, result);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error deleting shell descriptor for AAS ID segment {B64AasId}.", b64AasId);
}
}
}
}

/// <summary>
/// Converts an AssetAdministrationShell body (AAS Repository format) into an
/// AssetAdministrationShellDescriptor (AAS Registry format), mapping all available
/// fields and appending an endpoint pointing back to this proxy.
/// </summary>
private static JObject BuildShellDescriptor(JObject aasBody, string aasId, string proxyBaseUrl)
{
var assetInfo = aasBody["assetInformation"];

var descriptor = new JObject
{
["id"] = aasId,
["globalAssetId"] = assetInfo?["globalAssetId"]
};

// Pass-through optional metadata fields
if (aasBody["idShort"] is { } idShort)
descriptor["idShort"] = idShort;
if (aasBody["description"] is { } description)
descriptor["description"] = description;
if (aasBody["displayName"] is { } displayName)
descriptor["displayName"] = displayName;
if (aasBody["administration"] is { } administration)
descriptor["administration"] = administration;

// Flatten assetInformation fields to the descriptor top level
if (assetInfo?["assetKind"] is { } assetKind)
descriptor["assetKind"] = assetKind;
if (assetInfo?["assetType"] is { } assetType)
descriptor["assetType"] = assetType;
if (assetInfo?["specificAssetIds"] is { } specificAssetIds)
descriptor["specificAssetIds"] = specificAssetIds;

// Endpoint pointing to this proxy's repo path for the AAS
var href = $"{proxyBaseUrl}/repo/shells/{Base64StringDeAndEncoder.EncodeTo64(aasId)}";
descriptor["endpoints"] = new JArray
{
new JObject
{
["protocolInformation"] = new JObject
{
["href"] = href,
["endpointProtocol"] = "HTTP",
["endpointProtocolVersion"] = new JArray { "1.1" }
},
["interface"] = "AAS-3.0"
}
};

return descriptor;
}
}
}
17 changes: 15 additions & 2 deletions mnestix-proxy/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public static void Main(string[] args)
builder.Services.Configure<DiscoveryServiceOptions>(
builder.Configuration.GetSection(DiscoveryServiceOptions.Options));

// Registry Client settings
builder.Services.AddTransient<IRegistryClient, RegistryClient>();
builder.Services.Configure<RegistryServiceOptions>(
builder.Configuration.GetSection(RegistryServiceOptions.Options));

builder.Services.AddAuthenticationServices(builder.Configuration);

// Adds authorization handler
Expand Down Expand Up @@ -64,12 +69,20 @@ public static void Main(string[] args)
proxyPipeline.Use(PathRestrictionMiddleware.PathRestrictionHandling());
}

// AAS registry
// AAS Discovery
_ = bool.TryParse(builder.Configuration["Features:AasDiscoveryMiddleware"],
out var aasDiscoveryMiddleware);
if (aasDiscoveryMiddleware)
{
proxyPipeline.Use(AasDiscoveryServiceMiddleware.ConfigureAasDiscoveryHandling());
}

// AAS Registry
_ = bool.TryParse(builder.Configuration["Features:AasRegistryMiddleware"],
out var aasRegistryMiddleware);
if (aasRegistryMiddleware)
{
proxyPipeline.Use(AasDiscoveryServiceMiddleware.ConfigureAasDiscoveryHandling());
proxyPipeline.Use(AasRegistryServiceMiddleware.ConfigureAasRegistryHandling());
}

// MQTT Eventing
Expand Down
8 changes: 8 additions & 0 deletions mnestix-proxy/Services/Clients/IRegistryClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace mnestix_proxy.Services.Clients
{
public interface IRegistryClient
{
Task<(bool isSuccess, string Result)> RegisterOrUpdateShellDescriptor(string aasId, string shellDescriptorJson);
Task<(bool isSuccess, string Result)> DeleteShellDescriptor(string aasIdentifier);
}
}
Loading
Loading