The review found one defect shape repeated across the platform: surfaces that were declared, bound, and documented, but that nothing read. An operator configuring fullUrlRecording, bodyLogging, retry.policy, validatedDnsPinning, timeout.dns, or any of ten declared metric names got a guarantee the code never delivered. Every such surface is now in exactly one of three states -- wired for real, rejected at startup, or registered in a test-enforced gap list with its reason. No silent no-ops remain. P0: - Activate the platform from bootstrap behind app.httpclient.enabled, with a single auto-configuration importing the nine child configurations. - Give the platform a strict, repository-level ENV contract: 74 leaf fields derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected. - Route typed HTTP service clients through the call kernel via KernelHttpExchangeAdapter, so they stop bypassing platform policy. - Pin dynamic-target DNS resolution to the socket for the life of a call, closing the resolve-then-connect TOCTOU / rebinding window. - Actually transmit the idempotency key, and make retry eligibility depend on transmission rather than on merely holding one. - Reject reactive authentication and reactive redirect at startup instead of declaring support that does not function. - Fix the Reactor-only Stable contract row so the lane stops failing. - Stop advertising HTTP/3 on a transport that negotiates HTTP/1. P1 covers execution and retry accounting, redirect security (per-hop target guarding, sensitive-header stripping, 303 body handling), runtime rotation and transport resource ownership keyed by generation, dynamic-target hardening (subdomain matching, global-unicast classification, strict CIDR parsing), protocol intent, pool and timeout wiring, streaming and body limits, observability parity, and OAuth single-flight refresh on a bounded pool with a bounded wait. P2 covers configuration and documentation drift, the Gradle check wiring for the four hermetic lanes, and the CI gate matrix. Two test-quality defects surfaced while closing these: the HTTP/2 stream saturation test ran against cleartext HTTP/1.1 while asserting nothing about the protocol, and an OAuth contention test slept on a latch that could fire before the callers it meant to observe. Both now assert what their names claim. Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check (checkstyle, spotless, spotbugs, and the four hermetic lanes), verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile, verifyDependencyLocks, the documentation and gate-matrix verifiers, and the performance lane against a real TLS+ALPN HTTP/2 server. Not executed, and tracked rather than claimed: Docker/Toxiproxy fault injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2 distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS integration, and a whole-repository check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1334 lines
59 KiB
Markdown
1334 lines
59 KiB
Markdown
# 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 1–3.
|
||
- 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.
|