- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md). 빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다. - public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성. 이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다. - 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
59 KiB
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.jsonis 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-bootstrapdepend 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). verifyEnvKeysenforces a three-way lock-step betweensrc/.env,src/app-bootstrap/src/main/resources/application.ymlanddocs/registries/env-keys.yaml: every required (${VAR}with no default) placeholder must exist in.env; every.envkey must be referenced by someapplication.ymlplaceholder; everyAPP_key in.envmust be registered inenv-keys.yaml. OnlyAPP_HTTPCLIENT_ENABLEDgoes 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;truewith no clients → startup failure carrying the codeHTTPCLIENT_ACTIVE_WITHOUT_CLIENTS;- the actuator endpoint is registered only under the master flag.
- toggle missing or
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 theClockbean) - 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 aHttpClientPlatformSettings httpClientPlatformSettings(Environment)bean method to it. Task 4 relies onHttpClientPlatformSettings.PREFIXbeing"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.autoconfigure.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:
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
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
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:
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
Clockbean
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:
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:
dev.caskeleton.bootstrap.autoconfigure.httpclient.HttpClientPlatformAutoConfiguration
- Step 7: Run the activation test
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
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:
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:
HttpClientPlatformAutoConfigurationfrom Task 1. - Produces:
HttpClientPlatformSettings— record withboolean enabled,List<ClientSettings> clients,List<DynamicTargetSettings> dynamicTargets; constantString PREFIX = "app.httpclient".HttpClientPlatformSettings.ClientSettings—String nameplus every field previously onHttpClientsProperties.ClientProperties, same names and defaults.HttpClientPlatformSettings.DynamicTargetSettings—String nameplus every field previously onDynamicTargetProperties.PolicyProperties.HttpClientPlatformSettingsBinder.bind(Environment)→HttpClientPlatformSettings, package-private static.HttpClientProfileFactory.create(HttpClientPlatformSettings)→Map<ClientProfileName, ClientProfile>, replacingcreate(HttpClientsProperties).HttpClientProfileFactory.toProfile(HttpClientPlatformSettings.ClientSettings)→ClientProfile, replacingtoProfile(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:
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
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:
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
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformSettingsTest' --console=plain
Expected: PASS.
- Step 5: Write the strict binder
Create .../autoconfigure/httpclient/HttpClientPlatformSettingsBinder.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:
@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:
@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
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)
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:
HttpClientPlatformSettingsfrom 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:
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
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):
# 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:
# === 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::
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:
# === 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
cd src && ./gradlew :app-bootstrap:test --tests '*HttpClientPlatformEnvManifestTest' --console=plain
cd src && ./gradlew verifyEnvKeys --console=plain
Expected: both PASS.
- Step 6: Commit (human)
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
assertThat(context.getBean(ClientRuntimeRegistry.class).names()).isEmpty();
with
// 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:
.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
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.
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)
test(bootstrap): assert the HTTP Client platform's off state through its real entry point
Verification
Run from src/ after Task 4:
./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:
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.