# HTTP Client Platform — Activation Boundary and ENV SSOT Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the HTTP Client platform an genuinely optional capability that is off unless `APP_HTTPCLIENT_ENABLED=true`, binds its settings strictly and only when on, and has a single declared environment surface that a test proves is complete. **Architecture:** The repository already solved this problem once, for the HTTP Fileserver platform. `CaSkeletonApplication` excludes `dev.caskeleton.bootstrap.autoconfigure.*` from its component scan, so a configuration in that package is reachable *only* through an entry listed in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. Putting the master switch on that single entry makes "off" a structural fact: the class is never processed, so nothing it imports is discovered, no settings are bound and no runtime resource is created. HTTP Client is migrated onto that same shape — one gated `@AutoConfiguration`, importing the existing nine configurations, which move out of the scanned package with it. The settings become one strictly-bound record tree rooted at `app.httpclient`, with clients and dynamic targets as *indexed lists carrying their own `name`* rather than maps keyed by name, so the environment form is unambiguous and duplicate/colliding names are a startup failure rather than a silent overwrite. **Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle multi-module, JUnit 5 + AssertJ, `ApplicationContextRunner`, `Binder` + `NoUnboundElementsBindHandler`. ## Deviations found during execution Two things in this plan turned out to be wrong when the tests were run. Both are recorded here rather than edited away, because the reason each was wrong is the useful part. **1. `NoUnboundElementsBindHandler` cannot police environment variables.** Spring binds `APP_HTTPCLIENT_CLIENTS_0_BASE_URL` to `app.httpclient.clients[0].base-url` by mapping the requested property name back to an environment name, but it *enumerates* the same variable as `app.httpclient.clients[0].base.url` — underscores become dots, never hyphens. The strict handler compares against that enumeration, so pointing it at the system environment reports every correctly spelled hyphenated key as unbound while still saying nothing about a genuinely misspelled one. (The Fileserver platform does not hit this only because its prefix, `app.fileserver-platform`, makes the dotted forms fall outside the prefix entirely — an accident, not a design.) The fix is `HttpClientEnvironmentKeys`: derive the accepted variable names from the record tree and reject an `APP_HTTPCLIENT_` variable that is not among them. Strict binding still covers every other property source. This is stronger than the plan's original intent, not weaker — the handler could never have caught a misspelled environment variable at all. **2. The field manifest does not live in `docs/registries/`.** That directory is a fail-closed catalog of exactly eight contract registries, enforced by `ContractRegistrySchemaGovernanceTest`, with a fixed per-row schema (`owner_branch`, `compatibility_impact`, `required_test`). A field-to-variable mapping has none of that shape, and adding a ninth file would have meant loosening a gate rather than satisfying one. The manifest is `docs/httpclient/env-fields.yaml`; every path below that says otherwise is superseded. ## Global Constraints - Commit policy is `human-only`. Agents do not stage, commit, amend or push. Every "Commit" step in this plan is a **stop point where the human commits**; the agent reports the intended message. - Owning leaf for all Java changes: `app-bootstrap`, Gradle path `:app-bootstrap` (`src/config/architecture/modules.json` is the SSOT). Focused test: `cd src && ./gradlew :app-bootstrap:test --console=plain`. - No new project dependency edges. This plan moves and gates existing wiring; it must not make `app-bootstrap` depend on anything it does not already depend on. - Package root for new bootstrap code: `dev.caskeleton.bootstrap.autoconfigure.httpclient`. - Configuration prefix: `app.httpclient`. Canonical environment form: `APP_HTTPCLIENT_*`. - One public top-level type per file, file name equal to the type name (`verifyOneTypePerFile`, code-conventions I6). - `verifyEnvKeys` enforces a three-way lock-step between `src/.env`, `src/app-bootstrap/src/main/resources/application.yml` and `docs/registries/env-keys.yaml`: every required (`${VAR}` with no default) placeholder must exist in `.env`; every `.env` key must be referenced by some `application.yml` placeholder; every `APP_` key in `.env` must be registered in `env-keys.yaml`. Only `APP_HTTPCLIENT_ENABLED` goes through that gate — see Task 3 for why the per-client surface is registered in a separate field manifest instead. - The activation contract, verbatim from the review: - toggle missing or `false` → zero HTTP properties/binder/provider/registry/gateway/endpoint/ thread/resource beans; - toggle present but not a strict boolean (blank, `yes`, `1`) → **not** silently enabled; - `true` → strict bind, then full validation, then runtime resources; - `true` with no clients → startup failure carrying the code `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`; - the actuator endpoint is registered only under the master flag. ## Out of scope for this plan This plan is review step 1 and step 2 only. It does **not** address the call-execution kernel (P0 #3, #5, #6), dynamic-target DNS pinning (P0 #4), the Reactor Stable contract row (P0 #7), the HTTP/3 provider (P0 #8), or any P1/P2 item. Those are separate plans and each needs its own working software. What this plan must not do is make any of them harder: the settings tree it introduces is the input a later `ValidatedClientPlan` compiler will consume. ## File Structure **Moved** (`git mv`, package statement and imports updated, contents otherwise unchanged unless a task says so) — from `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/httpclient/` to `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/`: | File | Responsibility after the move | | --- | --- | | `HttpClientTransportAutoConfiguration.java` | Stable transport providers. Imported, never scanned. | | `HttpClientSecurityAutoConfiguration.java` | TLS material and policy validator. Imported. | | `HttpClientAuthenticationAutoConfiguration.java` | Credential providers. Imported. | | `HttpClientObservationAutoConfiguration.java` | Tag policy and execution support. Imported. | | `HttpClientResilienceAutoConfiguration.java` | Resilience registry. Imported. **Loses its `Clock` bean.** | | `HttpClientProfileAutoConfiguration.java` | Profile factory, startup validator, runtime registry. Imported. | | `HttpServiceClientAutoConfiguration.java` | Caller-facing gateways and typed registries. Imported. | | `DynamicTargetAutoConfiguration.java` | Dynamic target policies, resolvers, gateway. Imported. | | `HttpClientManagementAutoConfiguration.java` | Actuator endpoint. Imported. | | `HttpClientActuatorEndpoint.java` | The endpoint itself. | | `HttpClientProfileFactory.java` | Settings → `ClientProfile`. Signature changes in Task 2. | | `HttpClientStartupValidator.java` | Unchanged. | **Created:** | File | Responsibility | | --- | --- | | `.../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java` | The one entry point. Master switch, `@Import` of the nine configurations, settings bean. | | `.../autoconfigure/httpclient/HttpClientPlatformSettings.java` | The whole `app.httpclient` tree as one record, indexed clients and dynamic targets, aggregate validation in compact constructors. | | `.../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java` | Strict bind of the above, inside the gate. | | `docs/registries/httpclient-env-fields.yaml` | Field-path ↔ ENV-template manifest for the per-client surface. | **Deleted** (their content is absorbed by `HttpClientPlatformSettings`): - `.../bootstrap/httpclient/HttpClientsProperties.java` - `.../bootstrap/httpclient/HttpClientsPropertiesBinder.java` - `.../bootstrap/httpclient/DynamicTargetProperties.java` **Modified:** | File | Change | | --- | --- | | `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | Add the new entry. | | `src/app-bootstrap/src/main/resources/application.yml` | Add the `app.httpclient.enabled` placeholder only. | | `src/.env` | Add `APP_HTTPCLIENT_ENABLED=false`. | | `docs/registries/env-keys.yaml` | Register `APP_HTTPCLIENT_ENABLED`. | | `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java` | Stop asserting an empty registry exists while disabled; assert the beans are absent. | | `docs/httpclient/configuration-reference.md` | New prefix, master switch, indexed form. | | `scripts/verify-httpclient-docs.py` | Read the new settings type. | **Test files created:** - `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java` - `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java` - `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java` **Test files moved:** `HttpClientAutoConfigurationTest.java` and `UnsafeStartupConfigurationTest.java` into the new test package, rewritten to run through the single auto-configuration. --- ### Task 1: Master activation boundary **Files:** - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java` - Move: the twelve files listed above into `.../bootstrap/autoconfigure/httpclient/` - Modify: `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` - Modify: `.../autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java` (remove the `Clock` bean) - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java` **Interfaces:** - Consumes: nothing from earlier tasks. - Produces: `HttpClientPlatformAutoConfiguration` (public class, no-arg constructor). Task 2 adds a `HttpClientPlatformSettings httpClientPlatformSettings(Environment)` bean method to it. Task 4 relies on `HttpClientPlatformSettings.PREFIX` being `"app.httpclient"`. **Why the `Clock` bean must go.** `HttpClientResilienceAutoConfiguration` currently declares `@Bean @ConditionalOnMissingBean(Clock.class) Clock httpClientClock()`. Once the whole capability is gated, that bean would vanish whenever HTTP Client is off — and Redis, idempotency and the Fileserver all inject `Clock`. The application context is unaffected because `dev.caskeleton.bootstrap.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in a scanned package, so the httpclient copy is redundant *in the application* and dangerous *in the gate*. Isolated `ApplicationContextRunner` tests must supply their own, exactly as `FileserverPlatformAutoConfigurationTest` supplies a `MeterRegistry`. - [ ] **Step 1: Write the failing activation test** Create `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java`: ```java package dev.caskeleton.bootstrap.autoconfigure.httpclient; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry; import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicCredentialBinding; import dev.caskeleton.adapter.outbound.httpclient.dynamic.DynamicTargetGateway; import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry; import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry; import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway; import dev.caskeleton.adapter.outbound.httpclient.security.TlsMaterialProvider; import dev.caskeleton.adapter.outbound.httpclient.service.HttpServiceRegistry; import java.time.Clock; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** * The HTTP Client platform exists only where a deployment asked for it. * *
Every case here was reachable before this boundary existed: a service that never made an * outbound call still built transport providers, credential providers, a resilience registry and * five caller-facing gateways, and still read — and could still be failed by — HTTP configuration * it had never written. */ class HttpClientPlatformActivationTest { private final ApplicationContextRunner runner = new ApplicationContextRunner() .withConfiguration( AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)) .withUserConfiguration(SupportingBeans.class); @Test @DisplayName("absent toggle holds no HTTP bean at all") void theCapabilityIsAbsentUntilItIsExplicitlyEnabled() { runner.run( context -> { assertThat(context).hasNotFailed(); assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); assertThat(context).doesNotHaveBean(GenericHttpGateway.class); assertThat(context).doesNotHaveBean(HttpServiceRegistry.class); assertThat(context).doesNotHaveBean(DynamicTargetGateway.class); assertThat(context).doesNotHaveBean(ResilienceRegistry.class); assertThat(context).doesNotHaveBean(CredentialProviderRegistry.class); assertThat(context).doesNotHaveBean(TlsMaterialProvider.class); assertThat(context).doesNotHaveBean(HttpClientActuatorEndpoint.class); }); } @Test @DisplayName("false toggle with a full valid profile still holds nothing") void aValidProfileDoesNothingWhileTheSwitchIsOff() { runner .withPropertyValues("app.httpclient.enabled=false") .withPropertyValues(validPaymentClient()) .run( context -> { assertThat(context).hasNotFailed(); assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); }); } @Test @DisplayName("malformed detail settings cannot fail a deployment that never enabled the platform") void detailSettingsAreNotBoundWhileTheCapabilityIsOff() { runner .withPropertyValues( "app.httpclient.clients[0].name=payment", "app.httpclient.clients[0].timeout.total-call=not-a-duration", "app.httpclient.clients[0].transport=NOT_A_TRANSPORT", "app.httpclient.clients[0].request.max-body-bytes=not-a-number") .run( context -> { assertThat(context).hasNotFailed(); assertThat(context).doesNotHaveBean(HttpClientPlatformSettings.class); }); } @Test @DisplayName("a non-boolean toggle does not silently enable the platform") void aToggleThatIsNotABooleanDoesNotEnableTheCapability() { for (String unusable : List.of("yes", "1", "TRUE ", "")) { runner .withPropertyValues("app.httpclient.enabled=" + unusable) .withPropertyValues(validPaymentClient()) .run( context -> { assertThat(context).hasNotFailed(); assertThat(context).doesNotHaveBean(ClientRuntimeRegistry.class); }); } } @Test @DisplayName("enabling it assembles exactly the declared runtimes") void enablingItAssemblesTheDeclaredRuntimes() { runner .withPropertyValues("app.httpclient.enabled=true") .withPropertyValues(validPaymentClient()) .run( context -> { assertThat(context).hasNotFailed(); assertThat(context).hasSingleBean(ClientRuntimeRegistry.class); assertThat(context).hasSingleBean(GenericHttpGateway.class); assertThat(context).hasSingleBean(HttpServiceRegistry.class); assertThat(context.getBean(ClientRuntimeRegistry.class).names()) .singleElement() .satisfies(name -> assertThat(name.value()).isEqualTo("payment")); }); } private static String[] validPaymentClient() { return new String[] { "app.httpclient.clients[0].name=payment", "app.httpclient.clients[0].base-url=https://payment.test", "app.httpclient.clients[0].allowed-hosts[0]=payment.test", "app.httpclient.clients[0].allowed-ports[0]=443", "app.httpclient.clients[0].request.max-body-bytes=1048576", "app.httpclient.clients[0].tls.profile-id=payment" }; } /** * The collaborators the composition root normally supplies. * *
The {@code Clock} is the interesting one: it used to come from the HTTP Client's own
* resilience configuration, which meant every deployment inherited a clock from a capability it
* might not use. It now comes from the composition root, and this fixture stands in for it.
*/
@Configuration(proxyBeanMethods = false)
static class SupportingBeans {
@Bean
Clock clock() {
return Clock.systemUTC();
}
@Bean
List This is an auto-configuration rather than a component-scanned {@code @Configuration}, and it
* lives in a package the composition root's scan explicitly excludes. When the master switch is
* absent or false the class is never processed, so none of the configurations it imports are
* discovered either — no properties are bound, no transport provider is constructed, no connection
* pool, TLS context, credential or gateway exists, and a malformed HTTP setting in a deployment
* that never wanted outbound HTTP cannot fail its startup.
*
* The previous shape had nine independently annotated {@code @Configuration} classes inside the
* scanned package. Gating a wrapper around them would have changed nothing: the component scanner
* finds each child on its own. The children therefore had to move out of the scan with the switch,
* which is why this is a package move and not an annotation.
*/
@AutoConfiguration
@ConditionalOnProperty(
prefix = HttpClientPlatformSettings.PREFIX,
name = "enabled",
havingValue = "true")
@Import({
HttpClientResilienceAutoConfiguration.class,
HttpClientSecurityAutoConfiguration.class,
HttpClientAuthenticationAutoConfiguration.class,
HttpClientObservationAutoConfiguration.class,
HttpClientTransportAutoConfiguration.class,
HttpClientProfileAutoConfiguration.class,
HttpServiceClientAutoConfiguration.class,
DynamicTargetAutoConfiguration.class,
HttpClientManagementAutoConfiguration.class
})
public class HttpClientPlatformAutoConfiguration {}
```
- [ ] **Step 6: Register the entry**
Append to
`src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`:
```text
dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration
```
- [ ] **Step 7: Run the activation test**
```bash
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformActivationTest' --console=plain
```
Expected: PASS for the four OFF cases. `enablingItAssemblesTheDeclaredRuntimes` is expected to fail
until Task 2 introduces the indexed property names; keep it `@Disabled("Task 2")` if it blocks the
loop, and remove the annotation in Task 2 Step 6.
- [ ] **Step 8: Prove nothing else regressed**
```bash
cd src && ./gradlew :app-bootstrap:test --console=plain
```
Expected: `OptionalAdapterBeanGatingTest` fails — it asserts `context.getBean(ClientRuntimeRegistry)`
returns an empty registry while "disabled". That assertion encodes the wrong contract and is fixed in
Task 4. Every other failure is a real regression and must be fixed here.
- [ ] **Step 9: Commit (human)**
Intended message:
```text
refactor(bootstrap): move the HTTP Client platform behind a single gated auto-configuration
```
---
### Task 2: Strict indexed settings and aggregate validation
**Files:**
- Create: `.../autoconfigure/httpclient/HttpClientPlatformSettings.java`
- Create: `.../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java`
- Delete: `.../autoconfigure/httpclient/HttpClientsProperties.java`,
`.../autoconfigure/httpclient/HttpClientsPropertiesBinder.java`,
`.../autoconfigure/httpclient/DynamicTargetProperties.java`
- Modify: `.../autoconfigure/httpclient/HttpClientProfileFactory.java`,
`.../autoconfigure/httpclient/HttpClientProfileAutoConfiguration.java`,
`.../autoconfigure/httpclient/DynamicTargetAutoConfiguration.java`,
`.../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java`
- Test: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java`
**Interfaces:**
- Consumes: `HttpClientPlatformAutoConfiguration` from Task 1.
- Produces:
- `HttpClientPlatformSettings` — record with `boolean enabled`, `List {@code payment-api} and {@code payment_api} both render as {@code PAYMENT_API}. Under the
* previous map-keyed shape one would have overwritten the other with nothing said about it.
*/
@Test
@DisplayName("client names that collide once normalised for the environment are refused")
void environmentColludingClientNamesAreRefused() {
assertThatThrownBy(
() ->
new HttpClientPlatformSettings(
true, List.of(client("payment-api"), client("payment_api")), List.of()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("normalise");
}
@Test
@DisplayName("a client without a name is refused")
void anUnnamedClientIsRefused() {
assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(client(" ")), List.of()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("name");
}
private static HttpClientPlatformSettings.ClientSettings client(String name) {
return new HttpClientPlatformSettings.ClientSettings(
name,
"TRUSTED",
"https://payment.test",
List.of("payment.test"),
List.of(443),
"REST_CLIENT",
"APACHE",
List.of("HTTP_1_1"),
new HttpClientPlatformSettings.Pool(
50, 25, 100,
java.time.Duration.ofMillis(200),
java.time.Duration.ofSeconds(30),
java.time.Duration.ofMinutes(5),
java.time.Duration.ofSeconds(5),
java.time.Duration.ofSeconds(15),
java.time.Duration.ofSeconds(5),
false,
false),
new HttpClientPlatformSettings.Timeout(
java.time.Duration.ofMillis(300),
java.time.Duration.ofMillis(500),
java.time.Duration.ofSeconds(1),
java.time.Duration.ofMillis(500),
java.time.Duration.ofSeconds(1),
java.time.Duration.ofSeconds(2),
java.time.Duration.ofSeconds(3),
java.time.Duration.ofSeconds(4),
java.time.Duration.ofSeconds(30)),
new HttpClientPlatformSettings.Redirect(false, 0, false),
new HttpClientPlatformSettings.Request(1048576L, false),
new HttpClientPlatformSettings.Response(
5242880L, 10485760L, List.of("application/json")),
new HttpClientPlatformSettings.Authentication("NONE", null, List.of(), null, null, null),
new HttpClientPlatformSettings.Retry(
"none",
1,
java.time.Duration.ofMillis(50),
java.time.Duration.ofMillis(200),
"FULL",
"HONOR",
null),
new HttpClientPlatformSettings.Observability(true, false, false),
new HttpClientPlatformSettings.Tls(
"payment", List.of("TLSv1.3"), true, false, false, null, null),
new HttpClientPlatformSettings.Proxy(
false, "", 0, "HTTP", null, java.time.Duration.ofMillis(500), false),
null);
}
}
```
- [ ] **Step 2: Run it to verify it fails**
```bash
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformSettingsTest' --console=plain
```
Expected: compilation failure — `HttpClientPlatformSettings` does not exist.
- [ ] **Step 3: Write the settings record**
Create `.../autoconfigure/httpclient/HttpClientPlatformSettings.java`. Copy the nested records
`Pool`, `Timeout`, `Redirect`, `Request`, `Response`, `Authentication`, `Retry`, `Observability`,
`Tls`, `Proxy` verbatim from the deleted `HttpClientsProperties`, keeping every `@DefaultValue`
unchanged, and add the root plus the two indexed element types:
```java
package dev.caskeleton.bootstrap.autoconfigure.httpclient;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* The whole {@code app.httpclient} surface, bound once, strictly, and only while the platform is on.
*
* Deliberately not annotated {@code @ConfigurationProperties}: the composition root's
* {@code @ConfigurationPropertiesScan} is not selective, so an annotated class would be registered
* and bound in every deployment — including one that never enables outbound HTTP, which is exactly
* the coupling the master switch exists to remove.
*
* Clients and dynamic targets are indexed lists carrying their own {@code name} rather than maps
* keyed by name. A map key becomes a segment of the environment variable, and the relaxed binder
* normalises that segment, so {@code payment-api} and {@code payment_api} would resolve to one
* entry with nothing said about the one that was lost.
*/
public record HttpClientPlatformSettings(
@DefaultValue("false") boolean enabled,
@DefaultValue List The normalised form is what an operator would have to type as an environment variable, so
* two names that share it cannot both be configured from the environment even though they are
* distinct as properties.
*/
private static void requireDistinctNames(List Unknown keys under the prefix are refused rather than ignored. Silently dropping
* {@code app.httpclient.clients[0].timeuot.total-call} leaves the client running the default
* four-second budget while the configuration file says otherwise, which is the kind of divergence an
* outbound call platform should never make an operator discover from an incident.
*/
final class HttpClientPlatformSettingsBinder {
private HttpClientPlatformSettingsBinder() {}
static HttpClientPlatformSettings bind(Environment environment) {
BindHandler strict = new NoUnboundElementsBindHandler(BindHandler.DEFAULT);
return Binder.get(environment)
.bind(HttpClientPlatformSettings.PREFIX, Bindable.of(HttpClientPlatformSettings.class), strict)
.orElseThrow(
() ->
new IllegalStateException(
HttpClientPlatformSettings.PREFIX
+ " could not be bound although the platform is enabled"));
}
}
```
- [ ] **Step 6: Rewire the consumers**
In `HttpClientPlatformAutoConfiguration`, add:
```java
@Bean
@ConditionalOnMissingBean
HttpClientPlatformSettings httpClientPlatformSettings(Environment environment) {
return HttpClientPlatformSettingsBinder.bind(environment);
}
```
In `HttpClientProfileAutoConfiguration`, delete the `httpClientsProperties` bean method and change
`httpClientRuntimeRegistry` to take `HttpClientPlatformSettings properties`. In
`DynamicTargetAutoConfiguration`, delete the `dynamicTargetProperties` bean method and derive both
maps from `HttpClientPlatformSettings#dynamicTargets`, keying by
`new DynamicTargetPolicyName(target.name())`. In `HttpClientProfileFactory`, change
`create` and `toProfile` to the signatures declared in the Interfaces block; the body is otherwise
unchanged apart from reading `client.name()` instead of a map key.
Finally, in `HttpClientPlatformActivationTest`, switch the property names to the indexed form and
remove the `@Disabled("Task 2")` annotation from `enablingItAssemblesTheDeclaredRuntimes`.
- [ ] **Step 7: Add the strictness and aggregate cases to the activation test**
Append to `HttpClientPlatformActivationTest`:
```java
@Test
@DisplayName("an unknown key under the prefix is refused rather than ignored")
void anUnknownKeyUnderThePrefixIsRefused() {
runner
.withPropertyValues("app.httpclient.enabled=true")
.withPropertyValues(validPaymentClient())
.withPropertyValues("app.httpclient.clients[0].timeuot.total-call=9s")
.run(context -> assertThat(context).hasFailed());
}
@Test
@DisplayName("an enabled platform with no client fails startup with the declared code")
void anEnabledPlatformWithoutAnyClientFailsStartup() {
runner
.withPropertyValues("app.httpclient.enabled=true")
.run(
context ->
assertThat(context)
.hasFailed()
.getFailure()
.hasStackTraceContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS"));
}
```
- [ ] **Step 8: Run the whole module**
```bash
cd src && ./gradlew :app-bootstrap:test --console=plain
```
Expected: the new tests pass; `OptionalAdapterBeanGatingTest` still fails on the stale assertion
fixed in Task 4.
- [ ] **Step 9: Commit (human)**
```text
feat(bootstrap): bind the HTTP Client platform strictly from an indexed settings tree
```
---
### Task 3: ENV SSOT and the field manifest
**Files:**
- Modify: `src/.env`
- Modify: `src/app-bootstrap/src/main/resources/application.yml`
- Modify: `docs/registries/env-keys.yaml`
- Create: `docs/registries/httpclient-env-fields.yaml`
- Test: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java`
**Interfaces:**
- Consumes: `HttpClientPlatformSettings` from Task 2.
- Produces: `docs/registries/httpclient-env-fields.yaml`, a flat YAML list of
`- field: "Every setting is managed through the environment" is a claim about two things at once: that
* each field has an environment form, and that each documented environment name still maps to a
* field. Nothing enforced either. A field added to a nested record acquired no documentation, and a
* documented name whose field was renamed kept being published to operators who would set it and
* see nothing happen.
*
* The names are fed in as real environment variables through a
* {@link SystemEnvironmentPropertySource}, not as hand-translated property names, so what is under
* test is the mapping the runtime actually performs. Binding strictly settles the rest: an element
* the binder cannot place fails here rather than in production.
*/
class HttpClientPlatformEnvManifestTest {
@Test
@DisplayName("every leaf field of the settings tree is in the manifest, and vice versa")
void theManifestAndTheSettingsTreeAgree() {
Map A {@code List} of records becomes an indexed segment; a {@code List} of scalars becomes an
* indexed leaf. Both are rendered with a literal {@code N} so the manifest describes a template
* rather than one deployment's cardinality.
*/
private static Map