feat(esg): {param} path-template substitution with consumed-param removal — implements the spec'd /recipes/{id} form

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 04:14:13 -04:00
parent d0b27b8957
commit eb7a913d22
3 changed files with 134 additions and 7 deletions
@@ -1114,4 +1114,79 @@ public class ExternalSystemClientTests
Assert.True(result.Success);
}
// ── ExternalSystemGateway: {param} path-template substitution ──
[Fact]
public async Task PathTemplate_SubstitutesAndConsumesParameter()
{
// A `{id}` placeholder in the method path is replaced with the escaped
// parameter value and the consumed name is removed from the query string.
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("getRecipe", "GET", "/recipes/{id}") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
await client.CallAsync("TestAPI", "getRecipe", new Dictionary<string, object?>
{
["id"] = "R 1",
["filter"] = "x",
});
var uri = handler.LastUri!.AbsoluteUri;
// The value is substituted into the path (escaped) and consumed — it must
// NOT reappear in the query string; the remaining parameter still does.
Assert.Equal("https://api.example.com/recipes/R%201?filter=x", uri);
Assert.DoesNotContain("id=", uri);
}
[Fact]
public async Task PathTemplate_Post_ConsumedParamExcludedFromBody()
{
// A path-template parameter consumed by a POST path must not also be
// serialized into the JSON body.
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("addLine", "POST", "/orders/{orderId}/lines") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
await client.CallAsync("TestAPI", "addLine", new Dictionary<string, object?>
{
["orderId"] = 5,
["qty"] = 2,
});
Assert.Equal("https://api.example.com/orders/5/lines", handler.LastUri!.ToString());
Assert.Contains("\"qty\":2", handler.LastBody);
Assert.DoesNotContain("orderId", handler.LastBody);
}
[Fact]
public async Task PathTemplate_MissingParameter_ThrowsClearError()
{
// A `{id}` placeholder with no matching parameter is an authoring error —
// reject it with a clear ArgumentException naming the placeholder.
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 };
var method = new ExternalSystemMethod("getRecipe", "GET", "/recipes/{id}") { Id = 1, ExternalSystemDefinitionId = 1 };
StubResolution(system, method);
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(new RequestCapturingHandler(HttpStatusCode.OK, "{}")));
var client = new ExternalSystemClient(
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => client.CallAsync("TestAPI", "getRecipe"));
Assert.Contains("id", ex.Message);
}
}