Skip to content

Commit 1c65fe6

Browse files
Copilotkeegan-carusoKeegan Carusojmprieur
authored
Implement binding for ExtraHeaderParameters and ExtraQueryParameters in BindableDownstreamApiOptions (#3563)
* Implement binding for ExtraHeaderParameters and ExtraQueryParameters in BindableDownstreamApiOptions Co-authored-by: keegan-caruso <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: keegan-caruso <[email protected]> Co-authored-by: Keegan Caruso <[email protected]> Co-authored-by: Jean-Marc Prieur <[email protected]> Co-authored-by: Keegan <[email protected]>
1 parent 7d071c6 commit 1c65fe6

File tree

4 files changed

+363
-0
lines changed

4 files changed

+363
-0
lines changed

src/Microsoft.Identity.Web.Sidecar/DownstreamApiOptionsMerger.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ public static DownstreamApiOptions MergeOptions(DownstreamApiOptions left, Downs
6363
}
6464
}
6565

66+
if (right.ExtraHeaderParameters is not null)
67+
{
68+
if (res.ExtraHeaderParameters is null)
69+
{
70+
res.ExtraHeaderParameters = new Dictionary<string, string>();
71+
}
72+
foreach (var extraHeader in right.ExtraHeaderParameters)
73+
{
74+
res.ExtraHeaderParameters[extraHeader.Key] = extraHeader.Value;
75+
}
76+
}
77+
78+
if (right.ExtraQueryParameters is not null)
79+
{
80+
if (res.ExtraQueryParameters is null)
81+
{
82+
res.ExtraQueryParameters = new Dictionary<string, string>();
83+
}
84+
foreach (var extraQueryParam in right.ExtraQueryParameters)
85+
{
86+
res.ExtraQueryParameters[extraQueryParam.Key] = extraQueryParam.Value;
87+
}
88+
}
89+
6690
return res;
6791
}
6892
}

src/Microsoft.Identity.Web.Sidecar/Models/BindableDownstreamApiOptions.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ public BindableDownstreamApiOptions()
121121
{
122122
result.AcceptHeader = values.LastOrDefault() ?? string.Empty;
123123
}
124+
else if (path.StartsWith("ExtraHeaderParameters.", StringComparison.OrdinalIgnoreCase))
125+
{
126+
var headerKey = path.Substring("ExtraHeaderParameters.".Length);
127+
var headerValue = values.LastOrDefault();
128+
if (!string.IsNullOrEmpty(headerKey) && !string.IsNullOrEmpty(headerValue))
129+
{
130+
result.ExtraHeaderParameters ??= new Dictionary<string, string>();
131+
result.ExtraHeaderParameters[headerKey] = headerValue;
132+
}
133+
}
134+
else if (path.StartsWith("ExtraQueryParameters.", StringComparison.OrdinalIgnoreCase))
135+
{
136+
var queryKey = path.Substring("ExtraQueryParameters.".Length);
137+
var queryValue = values.LastOrDefault();
138+
if (!string.IsNullOrEmpty(queryKey) && !string.IsNullOrEmpty(queryValue))
139+
{
140+
result.ExtraQueryParameters ??= new Dictionary<string, string>();
141+
result.ExtraQueryParameters[queryKey] = queryValue;
142+
}
143+
}
124144
}
125145

126146
return ValueTask.FromResult<BindableDownstreamApiOptions?>(result);

tests/E2E Tests/Sidecar.Tests/DownstreamApiEndpointTests.cs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,4 +631,149 @@ public async Task DownstreamApi_WithNonExistentApiName_ReturnsBadRequestWithProb
631631
// Just verify it returns 400 - that's sufficient for this test
632632
}
633633

634+
[Fact]
635+
public async Task DownstreamApi_WithExtraHeaderParametersOverride_PassesHeadersToOptionsAsync()
636+
{
637+
// Arrange
638+
var responseContent = "{\"result\": \"success\"}";
639+
var mockResponse = new HttpResponseMessage(HttpStatusCode.OK)
640+
{
641+
Content = new StringContent(responseContent, Encoding.UTF8, "application/json")
642+
};
643+
644+
DownstreamApiOptions? capturedOptions = null;
645+
var mockDownstreamApi = new Mock<IDownstreamApi>();
646+
mockDownstreamApi
647+
.Setup(x => x.CallApiAsync(It.IsAny<DownstreamApiOptions>(), It.IsAny<System.Security.Claims.ClaimsPrincipal>(), It.IsAny<HttpContent>(), It.IsAny<CancellationToken>()))
648+
.Callback<DownstreamApiOptions, System.Security.Claims.ClaimsPrincipal, HttpContent?, CancellationToken>((options, _, _, _) =>
649+
{
650+
capturedOptions = options;
651+
})
652+
.ReturnsAsync(mockResponse);
653+
654+
var client = _factory.WithWebHostBuilder(builder =>
655+
{
656+
builder.ConfigureServices(services =>
657+
{
658+
TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services);
659+
660+
services.Configure<DownstreamApiOptions>("test-api", options =>
661+
{
662+
options.BaseUrl = "https://api.example.com";
663+
options.Scopes = ["user.read"];
664+
});
665+
666+
services.AddSingleton(mockDownstreamApi.Object);
667+
});
668+
}).CreateClient();
669+
670+
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token");
671+
672+
// Act
673+
var response = await client.PostAsync("/DownstreamApi/test-api?OptionsOverride.ExtraHeaderParameters.X-Custom-Header=test-value&OptionsOverride.ExtraHeaderParameters.OData-Version=4.0", null);
674+
675+
// Assert
676+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
677+
Assert.NotNull(capturedOptions?.ExtraHeaderParameters);
678+
Assert.Equal("test-value", capturedOptions.ExtraHeaderParameters["X-Custom-Header"]);
679+
Assert.Equal("4.0", capturedOptions.ExtraHeaderParameters["OData-Version"]);
680+
}
681+
682+
[Fact]
683+
public async Task DownstreamApi_WithExtraQueryParametersOverride_PassesQueryParametersToOptionsAsync()
684+
{
685+
// Arrange
686+
var responseContent = "{\"result\": \"success\"}";
687+
var mockResponse = new HttpResponseMessage(HttpStatusCode.OK)
688+
{
689+
Content = new StringContent(responseContent, Encoding.UTF8, "application/json")
690+
};
691+
692+
DownstreamApiOptions? capturedOptions = null;
693+
var mockDownstreamApi = new Mock<IDownstreamApi>();
694+
mockDownstreamApi
695+
.Setup(x => x.CallApiAsync(It.IsAny<DownstreamApiOptions>(), It.IsAny<System.Security.Claims.ClaimsPrincipal>(), It.IsAny<HttpContent>(), It.IsAny<CancellationToken>()))
696+
.Callback<DownstreamApiOptions, System.Security.Claims.ClaimsPrincipal, HttpContent?, CancellationToken>((options, _, _, _) =>
697+
{
698+
capturedOptions = options;
699+
})
700+
.ReturnsAsync(mockResponse);
701+
702+
var client = _factory.WithWebHostBuilder(builder =>
703+
{
704+
builder.ConfigureServices(services =>
705+
{
706+
TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services);
707+
708+
services.Configure<DownstreamApiOptions>("test-api", options =>
709+
{
710+
options.BaseUrl = "https://api.example.com";
711+
options.Scopes = ["user.read"];
712+
});
713+
714+
services.AddSingleton(mockDownstreamApi.Object);
715+
});
716+
}).CreateClient();
717+
718+
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token");
719+
720+
// Act
721+
var response = await client.PostAsync("/DownstreamApi/test-api?OptionsOverride.ExtraQueryParameters.param1=value1&OptionsOverride.ExtraQueryParameters.param2=value2", null);
722+
723+
// Assert
724+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
725+
Assert.NotNull(capturedOptions?.ExtraQueryParameters);
726+
Assert.Equal("value1", capturedOptions.ExtraQueryParameters["param1"]);
727+
Assert.Equal("value2", capturedOptions.ExtraQueryParameters["param2"]);
728+
}
729+
730+
[Fact]
731+
public async Task DownstreamApi_WithBothExtraHeaderAndQueryParametersOverride_PassesBothToOptionsAsync()
732+
{
733+
// Arrange
734+
var responseContent = "{\"result\": \"success\"}";
735+
var mockResponse = new HttpResponseMessage(HttpStatusCode.OK)
736+
{
737+
Content = new StringContent(responseContent, Encoding.UTF8, "application/json")
738+
};
739+
740+
DownstreamApiOptions? capturedOptions = null;
741+
var mockDownstreamApi = new Mock<IDownstreamApi>();
742+
mockDownstreamApi
743+
.Setup(x => x.CallApiAsync(It.IsAny<DownstreamApiOptions>(), It.IsAny<System.Security.Claims.ClaimsPrincipal>(), It.IsAny<HttpContent>(), It.IsAny<CancellationToken>()))
744+
.Callback<DownstreamApiOptions, System.Security.Claims.ClaimsPrincipal, HttpContent?, CancellationToken>((options, _, _, _) =>
745+
{
746+
capturedOptions = options;
747+
})
748+
.ReturnsAsync(mockResponse);
749+
750+
var client = _factory.WithWebHostBuilder(builder =>
751+
{
752+
builder.ConfigureServices(services =>
753+
{
754+
TestAuthenticationHandler.AddAlwaysSucceedTestAuthentication(services);
755+
756+
services.Configure<DownstreamApiOptions>("test-api", options =>
757+
{
758+
options.BaseUrl = "https://api.example.com";
759+
options.Scopes = ["user.read"];
760+
});
761+
762+
services.AddSingleton(mockDownstreamApi.Object);
763+
});
764+
}).CreateClient();
765+
766+
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "valid-token");
767+
768+
// Act
769+
var response = await client.PostAsync("/DownstreamApi/test-api?OptionsOverride.ExtraHeaderParameters.X-Test=header-val&OptionsOverride.ExtraQueryParameters.qparam=query-val", null);
770+
771+
// Assert
772+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
773+
Assert.NotNull(capturedOptions?.ExtraHeaderParameters);
774+
Assert.NotNull(capturedOptions?.ExtraQueryParameters);
775+
Assert.Equal("header-val", capturedOptions.ExtraHeaderParameters["X-Test"]);
776+
Assert.Equal("query-val", capturedOptions.ExtraQueryParameters["qparam"]);
777+
}
778+
634779
}

tests/E2E Tests/Sidecar.Tests/DownstreamApiOptionsMergeTests.cs

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,4 +379,178 @@ public void MergeDownstreamApiOptionsOverrides_WithEmptyStringOverrides_DoesNotO
379379
Assert.Equal("original-tenant", result.AcquireTokenOptions.Tenant);
380380
Assert.Equal("original-claims", result.AcquireTokenOptions.Claims);
381381
}
382+
383+
[Fact]
384+
public void MergeDownstreamApiOptionsOverrides_WithExtraHeaderParameters_MergesHeaderParameters()
385+
{
386+
// Arrange
387+
var left = new DownstreamApiOptions
388+
{
389+
ExtraHeaderParameters = new Dictionary<string, string>
390+
{
391+
{ "Header1", "Value1" },
392+
{ "Header2", "Value2" }
393+
}
394+
};
395+
var right = new DownstreamApiOptions
396+
{
397+
ExtraHeaderParameters = new Dictionary<string, string>
398+
{
399+
{ "Header3", "Value3" },
400+
{ "Header4", "Value4" }
401+
}
402+
};
403+
404+
// Act
405+
var result = DownstreamApiOptionsMerger.MergeOptions(left, right);
406+
407+
// Assert
408+
Assert.NotNull(result.ExtraHeaderParameters);
409+
Assert.Equal(4, result.ExtraHeaderParameters.Count);
410+
Assert.Equal("Value1", result.ExtraHeaderParameters["Header1"]);
411+
Assert.Equal("Value2", result.ExtraHeaderParameters["Header2"]);
412+
Assert.Equal("Value3", result.ExtraHeaderParameters["Header3"]);
413+
Assert.Equal("Value4", result.ExtraHeaderParameters["Header4"]);
414+
}
415+
416+
[Fact]
417+
public void MergeDownstreamApiOptionsOverrides_WithExtraHeaderParametersConflict_OverwritesWithRight()
418+
{
419+
// Arrange
420+
var left = new DownstreamApiOptions
421+
{
422+
ExtraHeaderParameters = new Dictionary<string, string>
423+
{
424+
{ "Header1", "OriginalValue" }
425+
}
426+
};
427+
var right = new DownstreamApiOptions
428+
{
429+
ExtraHeaderParameters = new Dictionary<string, string>
430+
{
431+
{ "Header1", "NewValue" },
432+
{ "Header2", "Value2" }
433+
}
434+
};
435+
436+
// Act
437+
var result = DownstreamApiOptionsMerger.MergeOptions(left, right);
438+
439+
// Assert
440+
Assert.NotNull(result.ExtraHeaderParameters);
441+
Assert.Equal("NewValue", result.ExtraHeaderParameters["Header1"]);
442+
Assert.Equal("Value2", result.ExtraHeaderParameters["Header2"]);
443+
}
444+
445+
[Fact]
446+
public void MergeDownstreamApiOptionsOverrides_WithExtraQueryParameters_MergesQueryParameters()
447+
{
448+
// Arrange
449+
var left = new DownstreamApiOptions
450+
{
451+
ExtraQueryParameters = new Dictionary<string, string>
452+
{
453+
{ "param1", "value1" },
454+
{ "param2", "value2" }
455+
}
456+
};
457+
var right = new DownstreamApiOptions
458+
{
459+
ExtraQueryParameters = new Dictionary<string, string>
460+
{
461+
{ "param3", "value3" },
462+
{ "param4", "value4" }
463+
}
464+
};
465+
466+
// Act
467+
var result = DownstreamApiOptionsMerger.MergeOptions(left, right);
468+
469+
// Assert
470+
Assert.NotNull(result.ExtraQueryParameters);
471+
Assert.Equal(4, result.ExtraQueryParameters.Count);
472+
Assert.Equal("value1", result.ExtraQueryParameters["param1"]);
473+
Assert.Equal("value2", result.ExtraQueryParameters["param2"]);
474+
Assert.Equal("value3", result.ExtraQueryParameters["param3"]);
475+
Assert.Equal("value4", result.ExtraQueryParameters["param4"]);
476+
}
477+
478+
[Fact]
479+
public void MergeDownstreamApiOptionsOverrides_WithExtraQueryParametersConflict_OverwritesWithRight()
480+
{
481+
// Arrange
482+
var left = new DownstreamApiOptions
483+
{
484+
ExtraQueryParameters = new Dictionary<string, string>
485+
{
486+
{ "param1", "original-value" }
487+
}
488+
};
489+
var right = new DownstreamApiOptions
490+
{
491+
ExtraQueryParameters = new Dictionary<string, string>
492+
{
493+
{ "param1", "new-value" },
494+
{ "param2", "value2" }
495+
}
496+
};
497+
498+
// Act
499+
var result = DownstreamApiOptionsMerger.MergeOptions(left, right);
500+
501+
// Assert
502+
Assert.NotNull(result.ExtraQueryParameters);
503+
Assert.Equal("new-value", result.ExtraQueryParameters["param1"]);
504+
Assert.Equal("value2", result.ExtraQueryParameters["param2"]);
505+
}
506+
507+
[Fact]
508+
public void MergeDownstreamApiOptionsOverrides_WithRightExtraHeaderParametersButLeftNull_CreatesNewDictionary()
509+
{
510+
// Arrange
511+
var left = new DownstreamApiOptions
512+
{
513+
ExtraHeaderParameters = null
514+
};
515+
var right = new DownstreamApiOptions
516+
{
517+
ExtraHeaderParameters = new Dictionary<string, string>
518+
{
519+
{ "Header1", "Value1" }
520+
}
521+
};
522+
523+
// Act
524+
var result = DownstreamApiOptionsMerger.MergeOptions(left, right);
525+
526+
// Assert
527+
Assert.NotNull(result.ExtraHeaderParameters);
528+
Assert.Single(result.ExtraHeaderParameters);
529+
Assert.Equal("Value1", result.ExtraHeaderParameters["Header1"]);
530+
}
531+
532+
[Fact]
533+
public void MergeDownstreamApiOptionsOverrides_WithRightExtraQueryParametersButLeftNull_CreatesNewDictionary()
534+
{
535+
// Arrange
536+
var left = new DownstreamApiOptions
537+
{
538+
ExtraQueryParameters = null
539+
};
540+
var right = new DownstreamApiOptions
541+
{
542+
ExtraQueryParameters = new Dictionary<string, string>
543+
{
544+
{ "param1", "value1" }
545+
}
546+
};
547+
548+
// Act
549+
var result = DownstreamApiOptionsMerger.MergeOptions(left, right);
550+
551+
// Assert
552+
Assert.NotNull(result.ExtraQueryParameters);
553+
Assert.Single(result.ExtraQueryParameters);
554+
Assert.Equal("value1", result.ExtraQueryParameters["param1"]);
555+
}
382556
}

0 commit comments

Comments
 (0)