Files
tech-log-backend/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md
T

1334 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
*
* <p>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.
*
* <p>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<DynamicCredentialBinding> dynamicCredentialBindings() {
return List.of();
}
}
}
```
- [ ] **Step 2: Run it and confirm it fails to compile**
```bash
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformActivationTest' --console=plain
```
Expected: compilation failure — `HttpClientPlatformAutoConfiguration` and
`HttpClientPlatformSettings` do not exist. That is the correct red state; Task 1 makes the
activation cases pass and Task 2 makes the settings type real. Until Task 2 lands, temporarily
reference `dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientsProperties` in place of
`HttpClientPlatformSettings` and use the map-shaped `http-clients.payment.*` property names, then
switch both back in Task 2 Step 6.
- [ ] **Step 3: Move the package**
```bash
cd src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap
mkdir -p autoconfigure/httpclient
git mv httpclient/*.java autoconfigure/httpclient/
```
Then in every moved file change the package declaration to
`package dev.caskeleton.bootstrap.autoconfigure.httpclient;` and fix any now-unresolved import.
Do the same for the two existing tests:
```bash
cd src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap
mkdir -p autoconfigure/httpclient
git mv httpclient/HttpClientAutoConfigurationTest.java autoconfigure/httpclient/
git mv httpclient/UnsafeStartupConfigurationTest.java autoconfigure/httpclient/
```
- [ ] **Step 4: Delete the `Clock` bean**
In `.../autoconfigure/httpclient/HttpClientResilienceAutoConfiguration.java`, remove the
`httpClientClock()` method and the now-unused `ConditionalOnMissingBean` import if nothing else
uses it. Leave `ResilienceRegistry httpClientResilienceRegistry(Clock clock)` taking `Clock` as a
parameter — it is now supplied by the composition root.
- [ ] **Step 5: Write the master auto-configuration**
Create `.../autoconfigure/httpclient/HttpClientPlatformAutoConfiguration.java`:
```java
package dev.caskeleton.bootstrap.autoconfigure.httpclient;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Import;
/**
* The one entry point through which the HTTP Client platform exists at all.
*
* <p>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.
*
* <p>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<ClientSettings> clients`,
`List<DynamicTargetSettings> dynamicTargets`; constant `String PREFIX = "app.httpclient"`.
- `HttpClientPlatformSettings.ClientSettings``String name` plus every field previously on
`HttpClientsProperties.ClientProperties`, same names and defaults.
- `HttpClientPlatformSettings.DynamicTargetSettings``String name` plus every field previously on
`DynamicTargetProperties.PolicyProperties`.
- `HttpClientPlatformSettingsBinder.bind(Environment)``HttpClientPlatformSettings`,
package-private static.
- `HttpClientProfileFactory.create(HttpClientPlatformSettings)`
`Map<ClientProfileName, ClientProfile>`, replacing `create(HttpClientsProperties)`.
- `HttpClientProfileFactory.toProfile(HttpClientPlatformSettings.ClientSettings)`
`ClientProfile`, replacing `toProfile(String, ClientProperties)` — the name now comes from the
settings object.
**Why indexed lists rather than maps.** A map keyed by client name renders in the environment as
`APP_HTTPCLIENT_CLIENTS_<NAME>_...`, and the relaxed binder normalises that segment: two distinct
names that differ only by a hyphen, an underscore or case collapse onto the same variable, so one
profile silently overwrites the other. Carrying the name as a *value* under a numeric index removes
the ambiguity, and the compact constructor can then reject the collision explicitly instead of
letting the last writer win.
- [ ] **Step 1: Write the failing settings test**
Create `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformSettingsTest.java`:
```java
package dev.caskeleton.bootstrap.autoconfigure.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class HttpClientPlatformSettingsTest {
@Test
@DisplayName("an enabled platform with no client is a startup failure, not an idle platform")
void anEnabledPlatformWithoutAnyClientIsRefused() {
assertThatThrownBy(() -> new HttpClientPlatformSettings(true, List.of(), List.of()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS");
}
@Test
@DisplayName("a disabled platform with no client is the normal case")
void aDisabledPlatformWithoutAnyClientIsFine() {
assertThat(new HttpClientPlatformSettings(false, List.of(), List.of()).clients()).isEmpty();
}
@Test
@DisplayName("two clients with the same name are refused")
void duplicateClientNamesAreRefused() {
assertThatThrownBy(
() ->
new HttpClientPlatformSettings(
true, List.of(client("payment"), client("payment")), List.of()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("payment");
}
/**
* Two names that are distinct as properties but identical as environment variables.
*
* <p>{@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.
*
* <p>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.
*
* <p>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<ClientSettings> clients,
@DefaultValue List<DynamicTargetSettings> dynamicTargets) {
/** Configuration prefix; the canonical environment form is {@code APP_HTTPCLIENT_*}. */
public static final String PREFIX = "app.httpclient";
public HttpClientPlatformSettings {
clients = List.copyOf(clients == null ? List.of() : clients);
dynamicTargets = List.copyOf(dynamicTargets == null ? List.of() : dynamicTargets);
if (enabled && clients.isEmpty()) {
throw new IllegalStateException(
"HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS: "
+ PREFIX
+ ".enabled is true but no client is declared under "
+ PREFIX
+ ".clients[*]. An active platform with nothing to call holds transport providers, "
+ "gateways and a resilience registry that no caller can reach.");
}
requireDistinctNames(
clients.stream().map(ClientSettings::name).toList(), PREFIX + ".clients");
requireDistinctNames(
dynamicTargets.stream().map(DynamicTargetSettings::name).toList(),
PREFIX + ".dynamic-targets");
}
/**
* Rejects blank, duplicate and environment-colliding names.
*
* <p>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<String> names, String where) {
List<String> seen = new ArrayList<>();
Map<String, String> byNormalisedForm = new LinkedHashMap<>();
for (String name : names) {
if (name == null || name.isBlank()) {
throw new IllegalStateException(where + "[*].name must be non-blank");
}
if (seen.contains(name)) {
throw new IllegalStateException(where + " declares '" + name + "' more than once");
}
seen.add(name);
String normalised = name.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
String previous = byNormalisedForm.putIfAbsent(normalised, name);
if (previous != null) {
throw new IllegalStateException(
where
+ " declares '"
+ previous
+ "' and '"
+ name
+ "', which normalise to the same environment variable segment '"
+ normalised
+ "'. One would silently replace the other.");
}
}
}
/** One Named Client Profile. Every field keeps the name and default it had under the old map. */
public record ClientSettings(
String name,
@DefaultValue("TRUSTED") String mode,
String baseUrl,
@DefaultValue List<String> allowedHosts,
@DefaultValue List<Integer> allowedPorts,
@DefaultValue("REST_CLIENT") String api,
@DefaultValue("APACHE") String transport,
@DefaultValue({"HTTP_1_1"}) List<String> protocols,
@DefaultValue Pool pool,
@DefaultValue Timeout timeout,
@DefaultValue Redirect redirect,
@DefaultValue Request request,
@DefaultValue Response response,
@DefaultValue Authentication authentication,
@DefaultValue Retry retry,
@DefaultValue Observability observability,
@DefaultValue Tls tls,
@DefaultValue Proxy proxy,
String experimentalAcknowledgement) {}
/** One Dynamic Target policy. */
public record DynamicTargetSettings(
String name,
@DefaultValue({"https"}) List<String> allowedSchemes,
@DefaultValue({"443"}) List<Integer> allowedPorts,
@DefaultValue List<String> allowedHostSuffixes,
@DefaultValue List<String> allowedHosts,
@DefaultValue("0") int maxRedirectHops,
@DefaultValue("false") boolean tracePropagation,
@DefaultValue List<String> blockedCidrs) {}
// ... Pool, Timeout, Redirect, Request, Response, Authentication, Retry, Observability, Tls and
// Proxy copied verbatim from the deleted HttpClientsProperties, javadoc included.
}
```
- [ ] **Step 4: Run the settings test**
```bash
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformSettingsTest' --console=plain
```
Expected: PASS.
- [ ] **Step 5: Write the strict binder**
Create `.../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.java`:
```java
package dev.caskeleton.bootstrap.autoconfigure.httpclient;
import org.springframework.boot.context.properties.bind.BindHandler;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler;
import org.springframework.core.env.Environment;
/**
* Binds {@link HttpClientPlatformSettings} strictly, and only when asked.
*
* <p>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: <property path>` / `env: <ENV template>` pairs covering every leaf field of the settings
tree. Task 4's documentation verifier reads it.
**Why the per-client surface is not in `.env`.** `verifyEnvKeys` requires every `.env` key to be
referenced by an `application.yml` placeholder. A client is an indexed list element, so templating
one in `application.yml` would materialise `app.httpclient.clients[0]` in every deployment — with a
blank name, which the aggregate validation from Task 2 correctly refuses. The review reaches the same
conclusion: keep the enable key in `application.yml`, bind the detail from the environment directly,
and prove the surface is complete with a manifest and a reflection test rather than with a template
nobody can leave in place.
- [ ] **Step 1: Write the failing manifest parity test**
Create `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformEnvManifestTest.java`:
```java
package dev.caskeleton.bootstrap.autoconfigure.httpclient;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.RecordComponent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.BindHandler;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.handler.NoUnboundElementsBindHandler;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.env.SystemEnvironmentPropertySource;
/**
* Closes the loop between the settings tree and its documented environment surface.
*
* <p>"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.
*
* <p>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<String, String> derived = envTemplatesOf(HttpClientPlatformSettings.class, "APP_HTTPCLIENT");
assertThat(manifest().keySet())
.as(
"docs/registries/httpclient-env-fields.yaml must list exactly the leaf fields of "
+ "HttpClientPlatformSettings")
.containsExactlyInAnyOrderElementsOf(derived.keySet());
assertThat(manifest()).containsAllEntriesOf(derived);
}
@Test
@DisplayName("a client declared purely through environment variables binds strictly")
void aClientDeclaredThroughTheEnvironmentBinds() {
Map<String, Object> environmentVariables = new LinkedHashMap<>();
environmentVariables.put("APP_HTTPCLIENT_ENABLED", "true");
environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_NAME", "payment");
environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_BASE_URL", "https://payment.test");
environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0", "payment.test");
environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0", "443");
environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES", "1048576");
environmentVariables.put("APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID", "payment");
environmentVariables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME", "webhook");
environmentVariables.put("APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0", "https");
StandardEnvironment environment = new StandardEnvironment();
environment
.getPropertySources()
.addFirst(
new SystemEnvironmentPropertySource(
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, environmentVariables));
HttpClientPlatformSettings bound =
Binder.get(environment)
.bind(
HttpClientPlatformSettings.PREFIX,
Bindable.of(HttpClientPlatformSettings.class),
new NoUnboundElementsBindHandler(BindHandler.DEFAULT))
.orElseThrow(() -> new AssertionError("the declared environment bound to nothing"));
assertThat(bound.enabled()).isTrue();
assertThat(bound.clients()).singleElement().satisfies(client -> {
assertThat(client.name()).isEqualTo("payment");
assertThat(client.baseUrl()).isEqualTo("https://payment.test");
assertThat(client.allowedPorts()).containsExactly(443);
assertThat(client.request().maxBodyBytes()).isEqualTo(1048576L);
assertThat(client.tls().profileId()).isEqualTo("payment");
});
assertThat(bound.dynamicTargets())
.singleElement()
.satisfies(target -> assertThat(target.name()).isEqualTo("webhook"));
}
/** The shipped default must be off. */
@Test
@DisplayName("src/.env ships the platform disabled")
void theShippedEnvironmentKeepsThePlatformOff() {
assertThat(readEnvFile())
.anySatisfy(line -> assertThat(line.trim()).isEqualTo("APP_HTTPCLIENT_ENABLED=false"));
}
/**
* Walks the record tree and renders each leaf as the environment name an operator would set.
*
* <p>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<String, String> envTemplatesOf(Class<?> type, String prefix) {
Map<String, String> templates = new LinkedHashMap<>();
collect(type, prefix, "", templates);
return templates;
}
private static void collect(
Class<?> type, String envPrefix, String pathPrefix, Map<String, String> into) {
for (RecordComponent component : type.getRecordComponents()) {
String property = camelToKebab(component.getName());
String path = pathPrefix.isEmpty() ? property : pathPrefix + "." + property;
String env = envPrefix + "_" + camelToScreamingSnake(component.getName());
Class<?> componentType = component.getType();
if (componentType.isRecord()) {
collect(componentType, env, path, into);
continue;
}
if (List.class.isAssignableFrom(componentType)) {
Class<?> element = elementTypeOf(component);
if (element != null && element.isRecord()) {
collect(element, env + "_N", path + "[N]", into);
continue;
}
into.put(path + "[N]", env + "_N");
continue;
}
into.put(path, env);
}
}
private static Class<?> elementTypeOf(RecordComponent component) {
if (component.getGenericType()
instanceof java.lang.reflect.ParameterizedType parameterized
&& parameterized.getActualTypeArguments()[0] instanceof Class<?> element) {
return element;
}
return null;
}
private static String camelToKebab(String name) {
return name.replaceAll("([a-z0-9])([A-Z])", "$1-$2").toLowerCase(Locale.ROOT);
}
private static String camelToScreamingSnake(String name) {
return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase(Locale.ROOT);
}
/** Reads the manifest without a YAML parser: it is a flat two-key list by construction. */
private static Map<String, String> manifest() {
Map<String, String> entries = new LinkedHashMap<>();
String field = null;
for (String line : readLines(repositoryRoot().resolve("docs/registries/httpclient-env-fields.yaml"))) {
String trimmed = line.trim();
if (trimmed.startsWith("- field:")) {
field = trimmed.substring("- field:".length()).trim();
} else if (trimmed.startsWith("env:") && field != null) {
entries.put(field, trimmed.substring("env:".length()).trim());
field = null;
}
}
return entries;
}
private static List<String> readEnvFile() {
Path fromModule = Path.of(System.getProperty("user.dir")).resolve(".env");
return readLines(Files.exists(fromModule) ? fromModule : Path.of("..").resolve(".env"));
}
/** The module's working directory is {@code src/app-bootstrap} under Gradle. */
private static Path repositoryRoot() {
Path candidate = Path.of(System.getProperty("user.dir")).toAbsolutePath();
List<Path> tried = new ArrayList<>();
for (int depth = 0; depth < 4 && candidate != null; depth++) {
tried.add(candidate);
if (Files.exists(candidate.resolve("docs/registries/httpclient-env-fields.yaml"))) {
return candidate;
}
candidate = candidate.getParent();
}
throw new AssertionError("repository root not found from " + tried);
}
private static List<String> readLines(Path path) {
try {
return Files.readAllLines(path);
} catch (IOException exception) {
throw new UncheckedIOException(path + " could not be read", exception);
}
}
}
```
- [ ] **Step 2: Run it to verify it fails**
```bash
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformEnvManifestTest' --console=plain
```
Expected: FAIL — `docs/registries/httpclient-env-fields.yaml` does not exist, so
`repositoryRoot()` throws.
- [ ] **Step 3: Generate the manifest from the settings tree**
Run the derivation once and write its output. The quickest honest way is to let the test print it:
temporarily add `System.out.println(...)` over `derived` in
`theManifestAndTheSettingsTreeAgree`, run the single test, capture the output, and write it as
`docs/registries/httpclient-env-fields.yaml` in this shape (header plus one entry per leaf):
```yaml
# HTTP Client platform — Java field path to environment variable template.
#
# The SSOT is HttpClientPlatformSettings. HttpClientPlatformEnvManifestTest derives this list from
# the record tree and fails when the two disagree in either direction, so an added field with no
# entry and an entry whose field was renamed are both build failures.
#
# `N` is a list index, not a literal. `app.httpclient.clients[N].base-url` is set as
# APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first client.
#
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
# src/.env: it is the only key with a deployment-independent value. Everything below is per
# deployment and is set directly in the environment.
fields:
- field: enabled
env: APP_HTTPCLIENT_ENABLED
- field: clients[N].name
env: APP_HTTPCLIENT_CLIENTS_N_NAME
- field: clients[N].base-url
env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL
# ... one entry per leaf, in the order the derivation emits them
```
Then remove the temporary `println`.
- [ ] **Step 4: Add the master key to the three-way gate**
`src/.env` — append beside the other capability master switches:
```dotenv
# === HTTP Client platform (app.httpclient.*) ===
# Off by default. While false no HTTP client property is bound, no transport provider, connection
# pool, TLS context, credential or gateway is created, and no HTTP thread exists. Per-client
# settings are set directly in the environment; docs/registries/httpclient-env-fields.yaml is their
# registry.
APP_HTTPCLIENT_ENABLED=false
```
`src/app-bootstrap/src/main/resources/application.yml` — under `app:`:
```yaml
httpclient:
# Master switch for the outbound HTTP Client platform. Only this key lives here: the per-client
# surface is an indexed list whose element cannot be templated without materialising a nameless
# client in every deployment, so it is bound from the environment directly.
# docs/registries/httpclient-env-fields.yaml is the registry for those names.
enabled: ${APP_HTTPCLIENT_ENABLED:false}
```
`docs/registries/env-keys.yaml` — add an entry in the same shape as
`APP_FILESERVER_PLATFORM_ENABLED`:
```yaml
# === HTTP Client platform (app.httpclient.*) ===
- name: APP_HTTPCLIENT_ENABLED
# Master switch. While false the platform block is not bound at all: the auto-configuration that
# binds it is not processed, so no bean, pool, TLS context, credential, thread or gateway exists.
type: boolean
default: false
allowed_values: [true, false]
classification: public-config
required: false
reload_policy: restart-only
owner_branch: httpclient-platform-activation-boundary
validation: boolean_strict
```
- [ ] **Step 5: Run the manifest test and the env gate**
```bash
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformEnvManifestTest' --console=plain
cd src && ./gradlew verifyEnvKeys --console=plain
```
Expected: both PASS.
- [ ] **Step 6: Commit (human)**
```text
feat(config): declare the HTTP Client platform's environment surface
```
---
### Task 4: Migrate the existing tests, gate the actuator endpoint, update the docs
**Files:**
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
- Modify: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientAutoConfigurationTest.java`
- Modify: `.../test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/UnsafeStartupConfigurationTest.java`
- Modify: `docs/httpclient/configuration-reference.md`
- Modify: `scripts/verify-httpclient-docs.py`
**Interfaces:**
- Consumes: everything produced by Tasks 13.
- Produces: no new production types.
- [ ] **Step 1: Fix the gating test's stale contract**
In `OptionalAdapterBeanGatingTest`, replace the six `HttpClient*AutoConfiguration` entries in
`withUserConfiguration(...)` with nothing, and replace
```java
assertThat(context.getBean(ClientRuntimeRegistry.class).names()).isEmpty();
```
with
```java
// Not "an empty registry": with the platform off there is no registry, no transport
// provider and no gateway. An empty registry bean was the previous contract and it is
// what made the capability mandatory-with-a-switch rather than optional.
assertThat(context.getBeansOfType(ClientRuntimeRegistry.class)).isEmpty();
```
Add the platform to the runner as a real auto-configuration so the OFF path is exercised through the
same entry the application uses:
```java
.withConfiguration(
AutoConfigurations.of(
dev.caskeleton.bootstrap.autoconfigure.httpclient
.HttpClientPlatformAutoConfiguration.class))
```
- [ ] **Step 2: Route the two moved tests through the single entry**
In both `HttpClientAutoConfigurationTest` and `UnsafeStartupConfigurationTest`, replace the
multi-entry `AutoConfigurations.of(...)` with
`AutoConfigurations.of(HttpClientPlatformAutoConfiguration.class)`, add
`"app.httpclient.enabled=true"` to every runner that expects beans, convert every
`http-clients.<name>.<key>` property to `app.httpclient.clients[0].<key>` plus
`app.httpclient.clients[0].name=<name>`, convert every `http-dynamic-targets.<name>.<key>` to
`app.httpclient.dynamic-targets[0].<key>` plus a `name`, and add the `Clock` bean to the supporting
configuration (the platform no longer supplies one).
`UnsafeStartupConfigurationTest#anUnconfiguredDeploymentHoldsNoHttpRuntimeResources` asserted that an
enabled-but-empty platform yields an empty registry. That case is now
`HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS` and is covered in `HttpClientPlatformActivationTest`; delete it
here rather than restating it.
- [ ] **Step 3: Run the module**
```bash
cd src && ./gradlew :app-bootstrap:test --console=plain
```
Expected: PASS, no skips beyond the module's pre-existing ones.
- [ ] **Step 4: Update the configuration reference**
In `docs/httpclient/configuration-reference.md`: replace the `http-clients.<name>` prefix with
`app.httpclient.clients[N]`, replace `http-dynamic-targets.<name>` with
`app.httpclient.dynamic-targets[N]`, document `app.httpclient.enabled` as the master switch with its
`APP_HTTPCLIENT_ENABLED` form and the "off means nothing is bound" contract, and correct the default
protocol from "HTTP2+HTTP1" to `HTTP_1_1` — the code has always said `HTTP_1_1` and the document has
always said otherwise.
- [ ] **Step 5: Point the documentation verifier at the new type**
In `scripts/verify-httpclient-docs.py`, change the source of code-derived names from
`HttpClientsProperties.java` and `DynamicTargetProperties.java` to
`HttpClientPlatformSettings.java`, and extend it to walk nested records rather than only top-level
components.
```bash
python3 -B scripts/verify-httpclient-docs.py
```
Expected: exits 0 and reports a name count at least as large as the previous 90.
- [ ] **Step 6: Commit (human)**
```text
test(bootstrap): assert the HTTP Client platform's off state through its real entry point
```
---
## Verification
Run from `src/` after Task 4:
```bash
./gradlew :app-bootstrap:test --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyEnvKeys --console=plain
./gradlew verifyOneTypePerFile --console=plain
./gradlew :adapter:outbound:httpclient:test --console=plain
```
and from the repository root:
```bash
python3 -B scripts/verify-httpclient-docs.py
git diff --check
```
`:adapter:outbound:httpclient:test` is in the list because nothing in this plan should touch the
adapter leaf; a failure there means a change leaked across the boundary.
## Self-Review
**Spec coverage.** Review P0 #1 is Tasks 1, 2 and 4 (single scan-excluded entry, strict boolean
toggle, zero beans while off, `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`, actuator under the master flag via
`HttpClientManagementAutoConfiguration` being imported rather than scanned, and the three tests that
encoded the wrong OFF semantics rewritten). Review P0 #2 is Tasks 2 and 3 (indexed clients with the
name as a value, unknown-field rejection, duplicate and normalisation-collision rejection, the field
manifest, the reflection parity test, and the real `SystemEnvironmentPropertySource` binding test).
Two P0 #2 sub-items are deliberately **not** covered and belong to the settings-compiler plan that
follows: "every declared setting is used in `ValidatedClientPlan` or explicitly rejected", and
"`ResilienceRegistry.ofDefaults()`'s hidden circuit/rate/bulkhead policy is either configurable or
documented as a safe constant". Both need the compiler this plan does not build; recording them here
so the next plan starts from a known gap rather than rediscovering it.
**Placeholder scan.** No step says "add validation" or "handle edge cases" without the code. The one
elision is the ten nested settings records in Task 2 Step 3, which are an explicit verbatim copy of a
file the plan names and which the step's comment marks; reproducing 90 lines of unchanged record
declarations would have obscured the four types that actually change.
**Type consistency.** `HttpClientPlatformSettings.PREFIX` is `"app.httpclient"` in Task 2 and is what
Task 1's `@ConditionalOnProperty` and Task 3's binder test both reference.
`HttpClientProfileFactory.create(HttpClientPlatformSettings)` and
`toProfile(HttpClientPlatformSettings.ClientSettings)` are declared once in Task 2's Interfaces block
and used with those signatures in Step 6. `dynamicTargets` is the record component name throughout,
rendering as `app.httpclient.dynamic-targets[N]` in properties and
`APP_HTTPCLIENT_DYNAMIC_TARGETS_N_*` in the environment, which is what Task 3's binding test asserts.