V2 Endpoint Environment Controls
Purpose
V2 endpoints are under active development. Until the full split-tender implementation passes production readiness review, all /v2/** paths must be:
- Blocked (503) in
stageandprod— intentional hard block; V2 is not ready for production traffic. - Available (2xx) in
dev,test,reg, andperf— lower environments are the development sandbox. - Returning 501 for individual endpoints that are routed but not yet implemented — stubs that are registered in the router but whose business logic is in progress.
This is a cross-cutting, platform-wide standard. Every V2 endpoint in every service must follow it.
Never remove the @V2NotImplemented annotation from a stub without a completed, reviewed, and tested implementation. Never disable or override the V2EndpointFilter configuration. Any change that exposes unimplemented V2 endpoints in stage or prod is a production incident.
Environment Access Matrix
| Environment | V2 Endpoints Available | Behaviour |
|---|---|---|
dev | ✅ Yes | Implemented endpoints accessible; stubs return 501 |
test | ✅ Yes | Implemented endpoints accessible; stubs return 501 |
reg | ✅ Yes | Implemented endpoints accessible; stubs return 501 |
perf | ✅ Yes | Implemented endpoints accessible; stubs return 501 |
stage | ❌ No | All /v2/** paths return 503 |
prod | ❌ No | All /v2/** paths return 503 |
For the consumer-facing description of this matrix (what API callers receive), see V2 API Environment Availability.
Architecture — Three Independent Layers
The access control model uses three independently operating layers:
Incoming /v2/ request
│
▼
┌───────────────────────────────────────────────────────────┐
│ Layer 1: Istio VirtualService (gateway / infrastructure)│
│ stage / prod → HTTPFaultInjection.Abort, 100%, HTTP 503 │
│ lower envs → pass through to cluster │
└───────────────────────────────────────────────────────────┘
│ (lower envs only)
▼
┌───────────────────────────────────────────── ──────────────┐
│ Layer 2: V2EndpointFilter @Order(-200) (application) │
│ stage / prod → 503 JSON response │
│ lower envs → pass through to next filter │
└───────────────────────────────────────────────────────────┘
│ (lower envs only)
▼
┌───────────────────────────────────────────────────────────┐
│ Layer 3: V2NotImplementedFilter @Order(-100) (app) │
│ Handler annotated @V2NotImplemented → 501 JSON response │
│ Handler not annotated → pass through │
└───────────────────────────────────────────────────────────┘
│ (implemented endpoints only)
▼
Controller handler executes normally
Layers 1 and 2 are redundant by design (defense-in-depth). If Istio is misconfigured or bypassed, V2EndpointFilter still prevents access. If V2EndpointFilter is somehow bypassed, Istio blocks at the gateway.
Implementation — Application Filters
Both filters are provided by wallet-event-commons and auto-registered. Services do not write any filter code — they only annotate stub methods.
V2EndpointFilter — Environment Block
// com.optum.wallet.common.v2.webfilter.V2EndpointFilter
@Order(-200)
public class V2EndpointFilter implements WebFilter {
public static final String V2_PATH_PREFIX = "/v2/";
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
final String path = exchange.getRequest().getURI().getPath();
if (!path.startsWith(V2_PATH_PREFIX)) {
return chain.filter(exchange); // V1 paths: always pass through
}
if (environment.matchesProfiles("stage | prod")) {
// Short-circuit — chain never called
return writeErrorResponse(exchange, 503, "V2_ENDPOINT_UNAVAILABLE", ...);
}
return chain.filter(exchange); // Lower envs: pass through
}
}
Key behaviour:
| Detail | Value |
|---|---|
| Path prefix matched | /v2/ (requires trailing slash — /v2 bare is not matched) |
| Profiles that trigger block | stage OR prod (Spring profile expression "stage | prod") |
| HTTP method treatment | All methods blocked equally, including OPTIONS (CORS preflight) |
| Filter order | @Order(-200) — runs before Spring Security, tracing, and all other filters |
V2NotImplementedFilter — Per-Endpoint Stub Block
// com.optum.wallet.common.v2.webfilter.V2NotImplementedFilter
@Order(-100)
public class V2NotImplementedFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// Resolves the handler for this request, then checks for @V2NotImplemented
// Uses defaultIfEmpty(sentinel) to avoid the Mono<Void> always-empty trap
return handlerMappingProvider.getObject()
.getHandler(exchange)
.defaultIfEmpty(NO_HANDLER_SENTINEL)
.flatMap(handler -> {
if (handler == NO_HANDLER_SENTINEL) return chain.filter(exchange);
V2NotImplemented annotation = resolveAnnotation(handler);
if (annotation != null) {
return writeErrorResponse(exchange, 501, "V2_ENDPOINT_NOT_IMPLEMENTED", annotation.reason());
}
return chain.filter(exchange);
});
}
}
Key behaviour:
| Detail | Value |
|---|---|
| Only reached | In lower environments (stage/prod already short-circuited by V2EndpointFilter) |
| Annotation resolution | Looks up the matched handler method via RequestMappingHandlerMapping |
| No handler found | Passes through (returns to chain; 404 is handled elsewhere) |
| Filter order | @Order(-100) — runs after env check, before Spring Security |
Filter Ordering
Spring WebFilter beans execute in ascending @Order value — lower number = earlier execution.
@Order(-200) V2EndpointFilter ← runs first
@Order(-100) V2NotImplementedFilter ← runs second
@Order(-100) Spring Security ← default Spring Security order
@Order(0+) Tracing, CORS, etc.
Negative orders (-200, -100) guarantee execution before all Spring framework defaults. If V2EndpointFilter short-circuits at -200, V2NotImplementedFilter at -100 is never reached.
Opt-In Registration — @EnableV2EndpointControls
Both filters are provided by wallet-event-commons. Services register them by adding the @EnableV2EndpointControls annotation to their main application class — the same pattern used by @EnableScheduling, @EnableWebFlux, and other Spring meta-annotations.
// Every CCG reactive service that exposes V2 endpoints
@SpringBootApplication
@EnableWebFlux
@EnableScheduling
@EnableV2EndpointControls // ← explicit opt-in
public class PaymentApplication { ... }
This imports V2EndpointConfiguration, which registers both filter beans:
// com.optum.wallet.common.v2.webfilter.V2EndpointConfiguration
@Configuration
@ConditionalOnWebApplication(type = REACTIVE)
public class V2EndpointConfiguration {
@Bean @Order(-200)
public V2EndpointFilter v2EndpointFilter(Environment environment, ObjectMapper objectMapper) {
return new V2EndpointFilter(environment, objectMapper);
}
@Bean @Order(-100)
public V2NotImplementedFilter v2NotImplementedFilter(
ObjectMapper objectMapper,
@Qualifier("requestMappingHandlerMapping") ObjectProvider<RequestMappingHandlerMapping> handlerMappingProvider) {
return new V2NotImplementedFilter(objectMapper, handlerMappingProvider);
}
}
Services do not write any filter code — they only add the annotation to their main class and annotate stub methods.
The original implementation used AutoConfiguration.imports (classpath-based silent registration). This was replaced with an explicit annotation so that:
- Every service consciously opts in — a missing annotation is a visible omission, not a silent gap.
- The registration is greppable:
grep -r @EnableV2EndpointControlsfinds all participating services immediately. - No future service accidentally picks up the filters as a transitive classpath side-effect.
The @ConditionalOnWebApplication(REACTIVE) guard means V2EndpointConfiguration is a no-op if imported in a non-WebFlux service. The annotation is still harmless to add, but it has no effect.
How to Declare a Stub Endpoint
When implementing a V2 controller method that is routed but not yet implemented, follow this pattern:
Step 1 — Annotate the handler method
@Operation(summary = "Capture Split-Tender Payment")
@PostMapping("/{paymentId}/capture")
@V2NotImplemented(reason = "Capture for split-tender V2 is under active development and not yet available for consumption.")
public Mono<ResponseEntity<?>> capturePayment(
@RequestHeader("X-Merchant-Id") UUID merchantId,
@PathVariable UUID paymentId) {
return Mono.just(ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build()); // unreachable — filter intercepts first
}
Step 2 — Write a meaningful reason
The reason value appears verbatim in the detail field of the 501 response body seen by API consumers. It must:
| Rule | Example |
|---|---|
| Explain what is not yet available | "Capture for split-tender V2 is under active development." |
| Be specific to the endpoint | ❌ Generic "Not implemented" — ❌ |
| Be < 200 characters | Keep it concise |
Step 3 — The method body must satisfy the return type
The method body is unreachable (the filter returns before Spring dispatches to the handler), but it must compile and satisfy the return type contract. Return a NOT_IMPLEMENTED response:
return Mono.just(ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build()); // unreachable — V2NotImplementedFilter intercepts
Step 4 — Update the endpoint status table
Add or update the endpoint in the V2 API Environment Availability page, marking it as 🔧 Stub (501).
How to Promote a Stub to Implemented
When an endpoint is ready for active consumption, remove the annotation and implement the handler:
- Delete
@V2NotImplemented(reason = "...")from the method. - Replace
return Mono.empty();with the real implementation. - Update the V2 API Environment Availability endpoint table from
🔧 Stub (501)to✅ Implemented. - Ensure the PR includes the test coverage described in Testing Requirements below.
Testing Requirements
Every service must have integration tests covering both the environment block and the stub behaviour. These tests must not be skipped, and they must use real Spring context startup.
Required test classes
| Test class | Scope | Key assertions |
|---|---|---|
V2BlockedEnvironmentIT | @ActiveProfiles("stage") | All V2 routes return 503; V1 routes unaffected |
V2BlockedEnvironmentProdIT | @ActiveProfiles("prod") | Same assertions for prod profile |
V2NotImplementedFilterIT | No profile (or dev/test) | Stub endpoints return 501; implemented endpoints return 2xx |
V2LowerEnvProfileIT | @ActiveProfiles("dev") | Explicit proof that dev does NOT trigger 503 |
Minimum assertions per test
// Blocked environment — verify 503 and body title
@Test
void createPayment_inStageEnvironment_returns503() {
webTestClient.post().uri("/v2/payments")
.bodyValue(validRequest)
.exchange()
.expectStatus().isEqualTo(503)
.expectBody()
.jsonPath("$.title").isEqualTo("V2_ENDPOINT_UNAVAILABLE");
}
// Stub endpoint — verify 501 and body title in lower env
@Test
void capturePayment_inLowerEnvironment_returns501() {
webTestClient.post().uri("/v2/payments/{id}/capture", paymentId)
.exchange()
.expectStatus().isEqualTo(501)
.expectBody()
.jsonPath("$.title").isEqualTo("V2_ENDPOINT_NOT_IMPLEMENTED");
}
// Implemented endpoint — verify it is NOT blocked
@Test
void createPayment_inLowerEnvironment_isNotBlocked() {
webTestClient.post().uri("/v2/payments")
.bodyValue(validRequest)
.exchange()
.expectStatus().isNotEqualTo(503)
.expectStatus().isNotEqualTo(501);
}
Infrastructure — Istio Fault Injection
The gateway-level block is declared in the V2 VirtualService Helm chart (ccg-api-v2) in common-checkout-infra:
# api/v2/chart/templates/virtualservice.yaml
http:
- match:
- uri:
prefix: "/payments"
{{- if .Values.blockV2 }}
fault:
abort:
percentage:
value: 100
httpStatus: 503
{{- end }}
route:
- destination:
host: payment-service
port:
number: 8080
rewrite:
uri: /v2/payments
The blockV2 flag is set per environment:
# values-stage.yaml / values-prod.yaml
blockV2: true
# values-dev.yaml / values-test.yaml / values-reg.yaml / values-perf.yaml
blockV2: false
This means the Istio block fires before the request reaches the cluster. The application-level V2EndpointFilter serves as a secondary defense in case of misconfiguration or direct in-cluster traffic.
Related
- V2 API Environment Availability — consumer-facing version of this information
- Commons Library —
wallet-event-commons—v2/webfilterpackage details - CC-22996 — V2 Endpoint Environment Controls