Skip to content

Commit 1addee2

Browse files
jamesmblairJames Blairclaude
authored
DC-241: Fix CO household ID type and support arrays in AppConfig (#121)
* DC-241: Set CO household ID type to Phone via environment variable CO was falling back to the base appsettings.json default of Email because AppConfig does not support array values. This adds the StateHouseholdId__PreferredHouseholdIdTypes__0 env var to the CO ECS task definition so households are resolved by phone as intended. * DC-241: Support JSON arrays in AppConfig configuration provider The AppConfig freeform provider previously skipped arrays with a warning, making it impossible to override array-based settings like StateHouseholdId:PreferredHouseholdIdTypes via AppConfig. Arrays are now flattened using indexed keys (e.g. Key:0, Key:1) matching the ASP.NET Core JSON configuration convention. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: James Blair <jblair@codeforamerica.org> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 84fd04a commit 1addee2

3 files changed

Lines changed: 164 additions & 9 deletions

File tree

src/SEBT.Portal.Infrastructure/Configuration/AppConfigAgentConfigurationProvider.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,12 @@ private void FlattenJsonElement(JsonElement element, Dictionary<string, string?>
231231
FlattenJsonObject(element, result, key);
232232
break;
233233
case JsonValueKind.Array:
234-
// Arrays are not supported in configuration
235-
_logger?.LogWarning("JSON arrays are not supported in configuration. Skipping key: {Key}", key);
234+
var index = 0;
235+
foreach (var item in element.EnumerateArray())
236+
{
237+
FlattenJsonElement(item, result, $"{key}:{index}");
238+
index++;
239+
}
236240
break;
237241
case JsonValueKind.String:
238242
result[key] = element.GetString();

test/SEBT.Portal.Tests/Unit/Configuration/AppConfigAgentConfigurationProviderTests.cs

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ public void Load_WithNullValues_ShouldHandleCorrectly()
321321
}
322322

323323
[Fact]
324-
public void Load_WithArrays_ShouldSkipArrays()
324+
public void Load_WithStringArray_ShouldFlattenWithIndexedKeys()
325325
{
326326
// Arrange
327327
var profile = new AppConfigAgentProfile
@@ -336,7 +336,7 @@ public void Load_WithArrays_ShouldSkipArrays()
336336
var configJson = new
337337
{
338338
Key1 = "value1",
339-
ArrayKey = new[] { "item1", "item2" }
339+
ArrayKey = new[] { "item1", "item2", "item3" }
340340
};
341341

342342
_mockHttpHandler
@@ -351,8 +351,158 @@ public void Load_WithArrays_ShouldSkipArrays()
351351
// Assert
352352
Assert.True(provider.TryGet("Key1", out var key1));
353353
Assert.Equal("value1", key1);
354-
// arrays should be skipped
355-
Assert.False(provider.TryGet("ArrayKey", out _));
354+
Assert.True(provider.TryGet("ArrayKey:0", out var item0));
355+
Assert.Equal("item1", item0);
356+
Assert.True(provider.TryGet("ArrayKey:1", out var item1));
357+
Assert.Equal("item2", item1);
358+
Assert.True(provider.TryGet("ArrayKey:2", out var item2));
359+
Assert.Equal("item3", item2);
360+
}
361+
362+
[Fact]
363+
public void Load_WithNestedConfigArray_ShouldFlattenWithSectionAndIndex()
364+
{
365+
// Arrange — mirrors the real StateHouseholdId config structure
366+
var profile = new AppConfigAgentProfile
367+
{
368+
BaseUrl = "http://localhost:2772",
369+
ApplicationId = "test-app",
370+
EnvironmentId = "test-env",
371+
ProfileId = "test-profile",
372+
IsFeatureFlag = false
373+
};
374+
375+
var configJson = new
376+
{
377+
StateHouseholdId = new
378+
{
379+
PreferredHouseholdIdTypes = new[] { "Phone" }
380+
}
381+
};
382+
383+
_mockHttpHandler
384+
.When("http://localhost:2772/applications/test-app/environments/test-env/configurations/test-profile")
385+
.Respond(HttpStatusCode.OK, "application/json", JsonSerializer.Serialize(configJson));
386+
387+
var provider = new AppConfigAgentConfigurationProvider(_httpClient, profile, _logger, ownsHttpClient: false);
388+
389+
// Act
390+
provider.Load();
391+
392+
// Assert
393+
Assert.True(provider.TryGet("StateHouseholdId:PreferredHouseholdIdTypes:0", out var type0));
394+
Assert.Equal("Phone", type0);
395+
}
396+
397+
[Fact]
398+
public void Load_WithArrayOfObjects_ShouldFlattenWithIndexAndPropertyKeys()
399+
{
400+
// Arrange
401+
var profile = new AppConfigAgentProfile
402+
{
403+
BaseUrl = "http://localhost:2772",
404+
ApplicationId = "test-app",
405+
EnvironmentId = "test-env",
406+
ProfileId = "test-profile",
407+
IsFeatureFlag = false
408+
};
409+
410+
var configJson = new
411+
{
412+
Items = new[]
413+
{
414+
new { Name = "first", Value = 1 },
415+
new { Name = "second", Value = 2 }
416+
}
417+
};
418+
419+
_mockHttpHandler
420+
.When("http://localhost:2772/applications/test-app/environments/test-env/configurations/test-profile")
421+
.Respond(HttpStatusCode.OK, "application/json", JsonSerializer.Serialize(configJson));
422+
423+
var provider = new AppConfigAgentConfigurationProvider(_httpClient, profile, _logger, ownsHttpClient: false);
424+
425+
// Act
426+
provider.Load();
427+
428+
// Assert
429+
Assert.True(provider.TryGet("Items:0:Name", out var name0));
430+
Assert.Equal("first", name0);
431+
Assert.True(provider.TryGet("Items:0:Value", out var value0));
432+
Assert.Equal("1", value0);
433+
Assert.True(provider.TryGet("Items:1:Name", out var name1));
434+
Assert.Equal("second", name1);
435+
Assert.True(provider.TryGet("Items:1:Value", out var value1));
436+
Assert.Equal("2", value1);
437+
}
438+
439+
[Fact]
440+
public void Load_WithEmptyArray_ShouldProduceNoKeys()
441+
{
442+
// Arrange
443+
var profile = new AppConfigAgentProfile
444+
{
445+
BaseUrl = "http://localhost:2772",
446+
ApplicationId = "test-app",
447+
EnvironmentId = "test-env",
448+
ProfileId = "test-profile",
449+
IsFeatureFlag = false
450+
};
451+
452+
// Use raw JSON since anonymous types can't express empty typed arrays cleanly
453+
var configJson = """{"Key1": "value1", "EmptyArray": []}""";
454+
455+
_mockHttpHandler
456+
.When("http://localhost:2772/applications/test-app/environments/test-env/configurations/test-profile")
457+
.Respond(HttpStatusCode.OK, "application/json", configJson);
458+
459+
var provider = new AppConfigAgentConfigurationProvider(_httpClient, profile, _logger, ownsHttpClient: false);
460+
461+
// Act
462+
provider.Load();
463+
464+
// Assert
465+
Assert.True(provider.TryGet("Key1", out var key1));
466+
Assert.Equal("value1", key1);
467+
Assert.False(provider.TryGet("EmptyArray:0", out _));
468+
}
469+
470+
[Fact]
471+
public void Load_WithMixedTypeArray_ShouldFlattenAllElementTypes()
472+
{
473+
// Arrange
474+
var profile = new AppConfigAgentProfile
475+
{
476+
BaseUrl = "http://localhost:2772",
477+
ApplicationId = "test-app",
478+
EnvironmentId = "test-env",
479+
ProfileId = "test-profile",
480+
IsFeatureFlag = false
481+
};
482+
483+
// Mixed types require raw JSON
484+
var configJson = """{"Mixed": ["text", 42, true, false, null]}""";
485+
486+
_mockHttpHandler
487+
.When("http://localhost:2772/applications/test-app/environments/test-env/configurations/test-profile")
488+
.Respond(HttpStatusCode.OK, "application/json", configJson);
489+
490+
var provider = new AppConfigAgentConfigurationProvider(_httpClient, profile, _logger, ownsHttpClient: false);
491+
492+
// Act
493+
provider.Load();
494+
495+
// Assert
496+
Assert.True(provider.TryGet("Mixed:0", out var v0));
497+
Assert.Equal("text", v0);
498+
Assert.True(provider.TryGet("Mixed:1", out var v1));
499+
Assert.Equal("42", v1);
500+
Assert.True(provider.TryGet("Mixed:2", out var v2));
501+
Assert.Equal("true", v2);
502+
Assert.True(provider.TryGet("Mixed:3", out var v3));
503+
Assert.Equal("false", v3);
504+
Assert.True(provider.TryGet("Mixed:4", out var v4));
505+
Assert.Null(v4);
356506
}
357507

358508
[Fact]

tofu/config/dev-co/main.tf

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,10 @@ module "app" {
109109
enable_appconfig = true
110110

111111
state_api_environment_variables = {
112-
"Oidc__DiscoveryEndpoint" = var.oidc_discovery_endpoint
113-
"Oidc__CallbackRedirectUri" = "https://${var.domain}/callback"
114-
"Oidc__LanguageParam" = "en"
112+
"Oidc__DiscoveryEndpoint" = var.oidc_discovery_endpoint
113+
"Oidc__CallbackRedirectUri" = "https://${var.domain}/callback"
114+
"Oidc__LanguageParam" = "en"
115+
"StateHouseholdId__PreferredHouseholdIdTypes__0" = "Phone"
115116
}
116117

117118
state_api_environment_secrets = {

0 commit comments

Comments
 (0)