Skip to content

Commit b933c69

Browse files
beyondnetPeruclaude
andcommitted
feat(tracker): consume Core advisory architecture endpoints via BFF gateway
Extend the Tracker->Core integration to the Core's existing advisory surface (ADR-0104), mirroring CoreEvaluationGateway conventions (same HttpClient/ options, envelope unwrapping, MockFallback, logging). Advisory Core responses are treated as opaque JsonElement payloads (non-binding). - CoreArchitectureGateway (ICoreArchitectureGateway): ListTopologies, GetTopology, RecommendTopology(signals), EvaluatePhaseArtifacts(phase, topologies, declaredArtifacts); deterministic fallback stubs when Core unavailable and MockFallback is on, shaped error otherwise - BFF endpoints under /api/architecture: GET topologies, GET topologies/{id}, POST recommend-topology, POST evaluate-phase-artifacts (ArchitectureRead) - AddHttpClient<ICoreArchitectureGateway> configured like the eval gateway - 6 gateway unit tests (path/verb/body + envelope + fallback) Build clean; 74/74 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent edcd124 commit b933c69

5 files changed

Lines changed: 547 additions & 0 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using Tracker.Presentation.Auth;
2+
using Tracker.Presentation.Integration;
3+
4+
namespace Tracker.Presentation.Endpoints.Integration;
5+
6+
public static class CoreArchitectureEndpoints
7+
{
8+
public static void MapCoreArchitectureEndpoints(this IEndpointRouteBuilder app)
9+
{
10+
var group = app.MapGroup("/architecture")
11+
.WithTags("Core Architecture");
12+
13+
group.MapGet("/topologies", async (
14+
ICoreArchitectureGateway gateway,
15+
ITrackerUserContext user,
16+
CancellationToken ct) =>
17+
{
18+
try
19+
{
20+
return Results.Ok(await gateway.ListTopologiesAsync(ct));
21+
}
22+
catch (CoreArchitectureGatewayException ex)
23+
{
24+
return ToProblem(ex);
25+
}
26+
})
27+
.WithName("ListCoreTopologies")
28+
.RequireTrackerPermission(TrackerPermissions.ArchitectureRead)
29+
.Produces(200)
30+
.Produces(401)
31+
.Produces(403)
32+
.Produces(502);
33+
34+
group.MapGet("/topologies/{id}", async (
35+
string id,
36+
ICoreArchitectureGateway gateway,
37+
ITrackerUserContext user,
38+
CancellationToken ct) =>
39+
{
40+
try
41+
{
42+
return Results.Ok(await gateway.GetTopologyAsync(id, ct));
43+
}
44+
catch (CoreArchitectureGatewayException ex)
45+
{
46+
return ToProblem(ex);
47+
}
48+
})
49+
.WithName("GetCoreTopology")
50+
.RequireTrackerPermission(TrackerPermissions.ArchitectureRead)
51+
.Produces(200)
52+
.Produces(401)
53+
.Produces(403)
54+
.Produces(502);
55+
56+
group.MapPost("/recommend-topology", async (
57+
RecommendTopologyRequest request,
58+
ICoreArchitectureGateway gateway,
59+
ITrackerUserContext user,
60+
CancellationToken ct) =>
61+
{
62+
try
63+
{
64+
var signals = request.Signals ?? new Dictionary<string, object?>();
65+
return Results.Ok(await gateway.RecommendTopologyAsync(signals, ct));
66+
}
67+
catch (CoreArchitectureGatewayException ex)
68+
{
69+
return ToProblem(ex);
70+
}
71+
})
72+
.WithName("RecommendCoreTopology")
73+
.RequireTrackerPermission(TrackerPermissions.ArchitectureRead)
74+
.Produces(200)
75+
.Produces(401)
76+
.Produces(403)
77+
.Produces(502);
78+
79+
group.MapPost("/evaluate-phase-artifacts", async (
80+
EvaluatePhaseArtifactsRequest request,
81+
ICoreArchitectureGateway gateway,
82+
ITrackerUserContext user,
83+
CancellationToken ct) =>
84+
{
85+
try
86+
{
87+
var result = await gateway.EvaluatePhaseArtifactsAsync(
88+
request.Phase ?? string.Empty,
89+
request.Topologies ?? [],
90+
request.DeclaredArtifacts ?? [],
91+
ct);
92+
return Results.Ok(result);
93+
}
94+
catch (CoreArchitectureGatewayException ex)
95+
{
96+
return ToProblem(ex);
97+
}
98+
})
99+
.WithName("EvaluateCorePhaseArtifacts")
100+
.RequireTrackerPermission(TrackerPermissions.ArchitectureRead)
101+
.Produces(200)
102+
.Produces(401)
103+
.Produces(403)
104+
.Produces(502);
105+
}
106+
107+
private static IResult ToProblem(CoreArchitectureGatewayException ex)
108+
{
109+
return Results.Json(
110+
new { code = ex.Code, message = ex.Message },
111+
statusCode: ex.StatusCode);
112+
}
113+
}

src/apps/tracker-api/Tracker.Presentation/Endpoints/Integration/CoreEvaluationDtos.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ public sealed class CoreEvaluationError
8282
public string Message { get; init; } = string.Empty;
8383
}
8484

85+
// Body for POST /api/architecture/recommend-topology. Signals are arbitrary
86+
// technical inputs the Core weighs; the Tracker forwards them opaquely.
87+
public sealed class RecommendTopologyRequest
88+
{
89+
public Dictionary<string, object?>? Signals { get; init; }
90+
}
91+
92+
// Body for POST /api/architecture/evaluate-phase-artifacts.
93+
public sealed class EvaluatePhaseArtifactsRequest
94+
{
95+
public string? Phase { get; init; }
96+
public string[]? Topologies { get; init; }
97+
public string[]? DeclaredArtifacts { get; init; }
98+
}
99+
85100
// Mirrors the canonical Evolith Core EvaluationResult (ADR-0101): an
86101
// `overallVerdict` (PASS/FAIL/…) plus per-kind `results` (here we map the `gate`
87102
// kind). Unwrapped from the ADR-0073 `{ success, data }` envelope upstream.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
using System.Net.Http.Json;
2+
using System.Text.Json;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace Tracker.Presentation.Integration;
6+
7+
/// <summary>
8+
/// Reads the Evolith Core's EXISTING advisory architecture surface (topology
9+
/// manifests, topology recommendation, phase-artifact evaluation). The Core is
10+
/// stateless and these responses are advisory / non-binding; the gateway treats
11+
/// them as opaque JSON payloads, unwrapping only the transport envelope
12+
/// (<c>data/result/value/payload</c>) before handing the inner element back.
13+
/// </summary>
14+
public interface ICoreArchitectureGateway
15+
{
16+
Task<JsonElement> ListTopologiesAsync(CancellationToken cancellationToken);
17+
18+
Task<JsonElement> GetTopologyAsync(string id, CancellationToken cancellationToken);
19+
20+
Task<JsonElement> RecommendTopologyAsync(
21+
IReadOnlyDictionary<string, object?> signals,
22+
CancellationToken cancellationToken);
23+
24+
Task<JsonElement> EvaluatePhaseArtifactsAsync(
25+
string phase,
26+
IReadOnlyList<string> topologies,
27+
IReadOnlyList<string> declaredArtifacts,
28+
CancellationToken cancellationToken);
29+
}
30+
31+
public sealed class CoreArchitectureGateway(
32+
HttpClient httpClient,
33+
IOptions<CoreEvaluationOptions> options,
34+
ILogger<CoreArchitectureGateway> logger) : ICoreArchitectureGateway
35+
{
36+
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
37+
private readonly CoreEvaluationOptions _options = options.Value;
38+
39+
public Task<JsonElement> ListTopologiesAsync(CancellationToken cancellationToken)
40+
{
41+
return GetAsync(
42+
"architecture/topologies",
43+
() => CloneJson("[]"),
44+
cancellationToken);
45+
}
46+
47+
public Task<JsonElement> GetTopologyAsync(string id, CancellationToken cancellationToken)
48+
{
49+
return GetAsync(
50+
$"architecture/topologies/{Uri.EscapeDataString(id)}",
51+
() => CloneJson("{\"source\":\"tracker-bff-mock-fallback\"}"),
52+
cancellationToken);
53+
}
54+
55+
public Task<JsonElement> RecommendTopologyAsync(
56+
IReadOnlyDictionary<string, object?> signals,
57+
CancellationToken cancellationToken)
58+
{
59+
var payload = new Dictionary<string, object?> { ["signals"] = signals };
60+
return PostAsync(
61+
"architecture/recommend-topology",
62+
payload,
63+
() => CloneJson(
64+
"{\"recommended\":\"modular-monolith\",\"rationale\":\"mock-fallback\",\"source\":\"tracker-bff-mock-fallback\"}"),
65+
cancellationToken);
66+
}
67+
68+
public Task<JsonElement> EvaluatePhaseArtifactsAsync(
69+
string phase,
70+
IReadOnlyList<string> topologies,
71+
IReadOnlyList<string> declaredArtifacts,
72+
CancellationToken cancellationToken)
73+
{
74+
var payload = new Dictionary<string, object?>
75+
{
76+
["phase"] = phase,
77+
["topologies"] = topologies,
78+
["declaredArtifacts"] = declaredArtifacts
79+
};
80+
return PostAsync(
81+
"architecture/evaluate-phase-artifacts",
82+
payload,
83+
() => CloneJson(
84+
"{\"completeness\":0,\"required\":[],\"present\":[],\"missing\":[],\"source\":\"tracker-bff-mock-fallback\"}"),
85+
cancellationToken);
86+
}
87+
88+
private async Task<JsonElement> GetAsync(
89+
string path,
90+
Func<JsonElement> fallback,
91+
CancellationToken cancellationToken)
92+
{
93+
try
94+
{
95+
using var response = await httpClient.GetAsync(path, cancellationToken);
96+
return await HandleResponseAsync(response, path, fallback, cancellationToken);
97+
}
98+
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
99+
{
100+
return HandleException(ex, path, fallback);
101+
}
102+
}
103+
104+
private async Task<JsonElement> PostAsync(
105+
string path,
106+
object payload,
107+
Func<JsonElement> fallback,
108+
CancellationToken cancellationToken)
109+
{
110+
try
111+
{
112+
using var response = await httpClient.PostAsJsonAsync(path, payload, JsonOptions, cancellationToken);
113+
return await HandleResponseAsync(response, path, fallback, cancellationToken);
114+
}
115+
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
116+
{
117+
return HandleException(ex, path, fallback);
118+
}
119+
}
120+
121+
private async Task<JsonElement> HandleResponseAsync(
122+
HttpResponseMessage response,
123+
string path,
124+
Func<JsonElement> fallback,
125+
CancellationToken cancellationToken)
126+
{
127+
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
128+
129+
if (!response.IsSuccessStatusCode)
130+
{
131+
if (_options.MockFallback)
132+
{
133+
logger.LogWarning(
134+
"Core architecture request {Path} failed with {StatusCode}; returning configured mock fallback. Body: {Body}",
135+
path,
136+
(int)response.StatusCode,
137+
responseJson);
138+
return fallback();
139+
}
140+
141+
throw new CoreArchitectureGatewayException(
142+
"CoreArchitecture.HttpError",
143+
$"Core architecture request '{path}' failed with HTTP {(int)response.StatusCode}.",
144+
(int)response.StatusCode);
145+
}
146+
147+
using var document = JsonDocument.Parse(responseJson);
148+
return ExtractEnvelopePayload(document.RootElement).Clone();
149+
}
150+
151+
private JsonElement HandleException(Exception ex, string path, Func<JsonElement> fallback)
152+
{
153+
if (_options.MockFallback)
154+
{
155+
logger.LogWarning(ex, "Core architecture request {Path} unavailable; returning configured mock fallback.", path);
156+
return fallback();
157+
}
158+
159+
throw new CoreArchitectureGatewayException(
160+
"CoreArchitecture.Unavailable",
161+
ex.Message,
162+
502);
163+
}
164+
165+
// Mirrors CoreEvaluationGateway.ExtractEnvelopePayload: unwrap the transport
166+
// envelope so callers see the inner advisory payload, not the { success, data }
167+
// wrapper (ADR-0073).
168+
private static JsonElement ExtractEnvelopePayload(JsonElement root)
169+
{
170+
foreach (var propertyName in new[] { "data", "result", "value", "payload" })
171+
{
172+
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(propertyName, out var payload))
173+
{
174+
return payload;
175+
}
176+
}
177+
178+
return root;
179+
}
180+
181+
private static JsonElement CloneJson(string json)
182+
{
183+
using var document = JsonDocument.Parse(json);
184+
return document.RootElement.Clone();
185+
}
186+
}
187+
188+
/// <summary>
189+
/// Shaped error surfaced when the Core architecture surface is unavailable or
190+
/// errors and MockFallback is disabled. Endpoints translate this into a problem
191+
/// response.
192+
/// </summary>
193+
public sealed class CoreArchitectureGatewayException(string code, string message, int statusCode)
194+
: Exception(message)
195+
{
196+
public string Code { get; } = code;
197+
198+
public int StatusCode { get; } = statusCode;
199+
}

src/apps/tracker-api/Tracker.Presentation/Program.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@
3131
}
3232
});
3333

34+
// Advisory architecture surface reuses the SAME Core base address + options as
35+
// the evaluation gateway (CoreApi section, /api/v1 base, optional Bearer key).
36+
builder.Services.AddHttpClient<ICoreArchitectureGateway, CoreArchitectureGateway>((sp, client) =>
37+
{
38+
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<CoreEvaluationOptions>>().Value;
39+
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/");
40+
client.Timeout = TimeSpan.FromMilliseconds(options.TimeoutMs);
41+
if (!string.IsNullOrWhiteSpace(options.ApiKey))
42+
{
43+
client.DefaultRequestHeaders.Authorization =
44+
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.ApiKey);
45+
}
46+
});
47+
3448
builder.Services.AddEndpointsApiExplorer();
3549
builder.Services.AddSwaggerGen(c =>
3650
{
@@ -72,6 +86,7 @@
7286
api.MapArchitectureReferenceEndpoints();
7387
api.MapCoreEvaluationEndpoints();
7488
api.MapCoreEvaluationTransactionEndpoints();
89+
api.MapCoreArchitectureEndpoints();
7590

7691
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
7792
api.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));

0 commit comments

Comments
 (0)