From 30a840eb7c85a5043085a48df10af9b8a1804867 Mon Sep 17 00:00:00 2001 From: Matheus Cruz Date: Fri, 7 Aug 2026 12:20:24 -0300 Subject: [PATCH] Unify HTTP/gRPC client resolution with OIDC progressive specificity cascade Signed-off-by: Matheus Cruz --- ...31-align-http-grpc-resolution-with-oidc.md | 166 ++++++++++++++++++ .../FaultToleranceCircuitBreakerTest.java | 33 ++-- .../FaultToleranceRetryTest.java | 31 ++-- .../flow/config/ClientConfigCascade.java | 63 +++++++ .../flow/config/ClientNamingConvention.java | 74 ++++++++ .../flow/config/FlowHttpConfig.java | 90 ++++------ .../providers/FaultToleranceProvider.java | 2 +- .../flow/providers/HttpClientProvider.java | 19 +- .../flow/providers/RoutingNameResolver.java | 28 ++- .../flow/providers/WorkflowTaskContext.java | 8 +- .../recorders/WorkflowApplicationCreator.java | 9 +- .../flow/config/ClientConfigCascadeTest.java | 136 ++++++++++++++ .../config/ClientNamingConventionTest.java | 72 ++++++++ .../providers/RoutingNameResolverTest.java | 130 ++++++++++++++ docs/modules/ROOT/nav.adoc | 1 + docs/modules/ROOT/pages/grpc.adoc | 58 +++--- docs/modules/ROOT/pages/http-client.adoc | 53 +++++- .../ROOT/pages/includes/quarkus-flow.adoc | 35 +--- .../includes/quarkus-flow_quarkus.flow.adoc | 35 +--- .../ROOT/pages/migration-0.15-to-0.16.adoc | 161 +++++++++++++++++ .../flow/config/FlowGrpcConfig.java | 11 +- .../flow/providers/GrpcChannelProvider.java | 38 +--- .../GrpcChannelProviderPriorityOrderTest.java | 117 +++++++----- .../flow/oidc/OidcNamingConvention.java | 124 ++----------- .../oidc/registry/OidcConfigResolver.java | 150 ++-------------- 25 files changed, 1107 insertions(+), 537 deletions(-) create mode 100644 adr/2026-07-31-align-http-grpc-resolution-with-oidc.md create mode 100644 core/runtime/src/main/java/io/quarkiverse/flow/config/ClientConfigCascade.java create mode 100644 core/runtime/src/main/java/io/quarkiverse/flow/config/ClientNamingConvention.java create mode 100644 core/runtime/src/test/java/io/quarkiverse/flow/config/ClientConfigCascadeTest.java create mode 100644 core/runtime/src/test/java/io/quarkiverse/flow/config/ClientNamingConventionTest.java create mode 100644 core/runtime/src/test/java/io/quarkiverse/flow/providers/RoutingNameResolverTest.java create mode 100644 docs/modules/ROOT/pages/migration-0.15-to-0.16.adoc diff --git a/adr/2026-07-31-align-http-grpc-resolution-with-oidc.md b/adr/2026-07-31-align-http-grpc-resolution-with-oidc.md new file mode 100644 index 000000000..edef2e15c --- /dev/null +++ b/adr/2026-07-31-align-http-grpc-resolution-with-oidc.md @@ -0,0 +1,166 @@ +# Align HTTP and gRPC Client Resolution with OIDC + +**Status:** Proposed +**Date:** 2026-07-31 +**Deciders:** Quarkus Flow Core Team +**Supersedes:** Partially implements [ADR 2026-07-07 Unified Client Naming Pattern](./2026-07-07-unified-client-naming-pattern.md) + +## Context + +ADR 2026-07-07 established a unified client naming pattern with progressive specificity for HTTP, gRPC, and OIDC. +The OIDC module (`OidcConfigResolver`) has been fully implemented following that pattern, +but the HTTP and gRPC modules remain on their legacy resolution logic. + +### Current State + +**OIDC (reference implementation)** — `OidcConfigResolver` + `OidcNamingConvention` + +Accepts `WorkflowDefinitionId` (namespace, name, version) and resolves through 7 levels: + +| Priority | Key pattern | Example | +|----------|-------------|---------| +| 1 | `namespace:name:version.task.taskName` | `acme:orders:1.0.0.task.payment` | +| 2 | `namespace:name.task.taskName` | `acme:orders.task.payment` | +| 3 | `name.task.taskName` | `orders.task.payment` | +| 4 | `namespace:name:version` | `acme:orders:1.0.0` | +| 5 | `namespace:name` | `acme:orders` | +| 6 | `name` | `orders` | +| 7 | `authPolicyName` (OIDC-specific) | `keycloak` | + +Config shape: flat `Map client()` under `quarkus.flow.oidc`. + +**HTTP** — `RoutingNameResolver` + +Accepts plain `String workflowName` (only `getDocument().getName()`, no namespace or version). Resolves through 2 levels: + +| Priority | Key pattern | Example | +|----------|-------------|---------| +| 1 | `workflow..task..name` | `workflow.orders.task.payment.name` | +| 2 | `workflow..name` | `workflow.orders.name` | + +Config shape: nested `Map workflow()` under `quarkus.flow.http.client`. + +**gRPC** — `GrpcChannelProvider.resolveClientName` + +Accepts `WorkflowDefinitionId` but resolves through only 3 override levels plus 1 existence fallback: + +| Priority | Key pattern | Example | +|----------|-------------|---------| +| 1 | `namespace:name:version:taskName` | `acme:grpcGreeting:0.0.1:greet` | +| 2 | `namespace:name:version` | `acme:grpcGreeting:0.0.1` | +| 3 | `namespace:name` | `acme:grpcGreeting` | +| 4 | default channel `flowGrpc` (existence check) | — | + +Config shape: flat `Map client()` under `quarkus.flow.grpc`. + +### Problems + +1. **Missing specificity levels** — HTTP has no namespace/version awareness at all. gRPC is missing task-level medium/short and workflow-level short keys. +2. **Inconsistent key format** — gRPC uses `:` as the task separator (`ns:name:ver:task`), while OIDC uses `.task.` (`ns:name:ver.task.taskName`). The `.task.` separator is unambiguous because workflow names/versions should not contain `.task.`. +3. **HTTP uses plain strings** — `RoutingNameResolver` and `HttpClientProvider.clientFor` receive `String workflowName` extracted from `getDocument().getName()`, discarding namespace and version. `WorkflowDefinitionId` is readily available at both call sites (`WorkflowApplicationCreator:233`, `FaultToleranceProvider:65`) via `workflowContextData.definition().id()` but is not used. +4. **Different config shapes** — HTTP uses a nested map structure (`workflow → task`), while OIDC and gRPC use flat maps with composite keys. This forces users to learn different config patterns for the same conceptual operation. +5. **`RoutingNameResolver` has zero test coverage** — any refactoring of the HTTP resolver is effectively untested today. + +## Decision + +Align HTTP and gRPC client resolution to follow the same progressive specificity cascade implemented by `OidcConfigResolver`, as specified in ADR 2026-07-07. + +### Resolution Cascade + +All three modules will share the same 6-level resolution order (most to least specific): + +| Priority | Scope | Key format | +|----------|-------|------------| +| 1 | Task full | `namespace:name:version.task.taskName` | +| 2 | Task medium | `namespace:name.task.taskName` | +| 3 | Task short | `name.task.taskName` | +| 4 | Workflow full | `namespace:name:version` | +| 5 | Workflow medium | `namespace:name` | +| 6 | Workflow short | `name` | + +OIDC retains its additional level 7 (`authPolicyName`). gRPC retains its default `flowGrpc` channel fallback after the 6 override levels. + +### Config Shape + +HTTP retains `workflow()` as the config property name with a flat `Map` map. gRPC uses `client()`. The nested `WorkflowRoutingConfig` / `TaskRoutingConfig` structure is replaced with a flat map keyed by composite identifiers, but the `workflow` property name is preserved for backward compatibility. + +### Key Format + +All modules will use `:` as the namespace/version separator and `.task.` as the task separator, matching `OidcNamingConvention`. + +### API Surface + +`HttpClientProvider.clientFor` and `RoutingNameResolver.resolveName` (or their replacement) will accept `WorkflowDefinitionId` instead of plain `String workflowName`. Call sites in `WorkflowApplicationCreator` and `FaultToleranceProvider` will be updated to pass `definition().id()`. + +### Shared Naming Convention + +`OidcNamingConvention` will be extracted to a shared location (e.g., `core/runtime`) and reused by HTTP, gRPC, and OIDC. The class contains only static key-generation methods with no OIDC-specific logic — the `Oidc` prefix is a historical artifact. + +## Changes by Module + +### `core/runtime` (HTTP) + +- **Config**: Keep `FlowHttpConfig.workflow()` as the config property name (flat `Map`). `FlowHttpConfig` retains `named()` for actual HTTP client definitions and all other HTTP-specific settings. No property rename needed — backward compatibility is preserved. +- **Resolver**: Rewrite `RoutingNameResolver` (or replace it) to accept `WorkflowDefinitionId` and follow the 6-level cascade using the shared naming convention. +- **Call sites**: Update `WorkflowApplicationCreator.injectHttpClientProvider` (line 233) and `FaultToleranceProvider` (line 65) to pass `workflowContextData.definition().id()` instead of `getDocument().getName()`. +- **Tests**: Add unit tests for the resolver covering all 6 specificity levels, null/blank task names, and fallback behavior. + +### `grpc/runtime` + +- **Config**: `FlowGrpcConfig.client()` already uses a flat map — no structural change needed. Only the documented key format changes (`:` task separator → `.task.` task separator). +- **Resolver**: Update `GrpcChannelProvider.resolveClientName` to use the shared naming convention and add the 3 missing levels (task medium, task short, workflow short). The default `flowGrpc` channel fallback remains after the 6 override levels. +- **Tests**: Update `GrpcChannelProviderPriorityOrderTest` to cover all 6 override levels plus the default channel fallback. + +### `oidc/runtime` + +- **No changes** to `OidcConfigResolver` or its resolution logic. +- **Naming convention**: `OidcNamingConvention` moves to `core/runtime` and is renamed (e.g., `ClientNamingConvention`). The OIDC module updates its imports. + +## Consequences + +### Benefits + +1. **Consistency** — All three client types follow the same resolution order and key format. Users learn once. +2. **Full identity resolution for HTTP** — HTTP gains namespace and version awareness, enabling multi-tenant and version-specific routing. +3. **Simpler mental model** — One flat map with composite keys instead of two different config shapes. +4. **Shared code** — Single naming convention utility eliminates duplication and prevents future divergence. + +### Drawbacks + +1. **No breaking change for HTTP config** — The `workflow` property name is preserved. Only the internal resolution logic changes; existing `quarkus.flow.http.client.workflow..*` properties continue to work. +2. **Breaking change for gRPC keys** — The task separator changes from `:` to `.task.`, requiring config migration for anyone using task-level gRPC overrides (the `namespace:name:version:taskName` format). +3. **Quoting in config files** — Composite keys containing `:` must be quoted in `application.properties` (e.g., `"acme\:orders\:1.0.0"`). This is standard SmallRye Config behavior but is less ergonomic than the short `name`-only key. + +## Migration + +### HTTP + +No property rename needed — the `workflow` key is preserved: + +| Before | After | +|--------|-------| +| `quarkus.flow.http.client.workflow.orders.name=secureA` | No change | +| `quarkus.flow.http.client.workflow."orders.task.payment".name=secureB` | No change | + +### gRPC + +| Before | After | +|--------|-------| +| `quarkus.flow.grpc.client."org.acme:grpcGreeting:0.0.1:greet".name=x` | `quarkus.flow.grpc.client."org.acme:grpcGreeting:0.0.1.task.greet".name=x` | +| `quarkus.flow.grpc.client."org.acme:grpcGreeting:0.0.1".name=y` | No change (workflow-level keys unchanged) | +| `quarkus.flow.grpc.client."org.acme:grpcGreeting".name=z` | No change (medium keys unchanged) | +| — | `quarkus.flow.grpc.client.grpcGreeting.name=z` (new: short key now supported) | + +## Verification + +1. Run existing gRPC tests: `mvn test -f grpc/runtime/pom.xml` +2. Run existing OIDC tests: `mvn test -f oidc/runtime/pom.xml` +3. Run full build with integration tests: `mvn clean install -DskipITs=false` +4. Verify examples that use HTTP/gRPC routing still work after config migration + +## Related + +- [ADR 2026-07-07 Unified Client Naming Pattern](./2026-07-07-unified-client-naming-pattern.md) — the parent design this ADR implements +- [ADR 2026-07-06 OIDC Client Delegation](./2026-07-06-oidc-client-delegation.md) — the OIDC implementation that serves as the reference +- `OidcConfigResolver` — reference implementation of the 7-level cascade +- `OidcNamingConvention` — key generation utility to be shared diff --git a/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceCircuitBreakerTest.java b/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceCircuitBreakerTest.java index 2619e8ab4..5b13b83d3 100644 --- a/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceCircuitBreakerTest.java +++ b/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceCircuitBreakerTest.java @@ -12,12 +12,17 @@ import io.quarkiverse.flow.config.FlowMetricsConfig; import io.quarkiverse.flow.providers.FaultToleranceProvider; import io.quarkiverse.flow.providers.WorkflowTaskContext; +import io.serverlessworkflow.impl.WorkflowDefinitionId; import io.serverlessworkflow.impl.WorkflowModel; import io.smallrye.config.SmallRyeConfigBuilder; import io.smallrye.faulttolerance.api.TypedGuard; public class FaultToleranceCircuitBreakerTest { + private static WorkflowDefinitionId wfId(String name) { + return new WorkflowDefinitionId("default", name, "0.0.1"); + } + @Test void should_open_circuit_breaker_after_failure_threshold() { FlowHttpConfig flowHttpConfig = new SmallRyeConfigBuilder() @@ -39,7 +44,7 @@ void should_open_circuit_breaker_after_failure_threshold() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("payment", "process")); + .guardFor(new WorkflowTaskContext(wfId("payment"), "process")); AtomicInteger callCount = new AtomicInteger(0); @@ -79,7 +84,7 @@ void should_not_open_circuit_breaker_when_disabled() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("payment", "process")); + .guardFor(new WorkflowTaskContext(wfId("payment"), "process")); AtomicInteger callCount = new AtomicInteger(0); @@ -123,7 +128,7 @@ void should_configure_circuit_breaker_at_workflow_level() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("payment", "process")); + .guardFor(new WorkflowTaskContext(wfId("payment"), "process")); AtomicInteger callCount = new AtomicInteger(0); @@ -147,7 +152,7 @@ void should_configure_circuit_breaker_at_task_level() { .addDiscoveredConverters() .withMapping(FlowHttpConfig.class) .withDefaultValue("quarkus.flow.http.client.resilience.circuit-breaker.enabled", "false") - .withDefaultValue("quarkus.flow.http.client.workflow.payment.task.validate.name", "paymentValidator") + .withDefaultValue("quarkus.flow.http.client.workflow.\"payment.task.validate\".name", "paymentValidator") .withDefaultValue("quarkus.flow.http.client.named.paymentValidator.resilience.circuit-breaker.enabled", "true") .withDefaultValue( "quarkus.flow.http.client.named.paymentValidator.resilience.circuit-breaker.request-volume-threshold", @@ -167,7 +172,7 @@ void should_configure_circuit_breaker_at_task_level() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("payment", "validate")); + .guardFor(new WorkflowTaskContext(wfId("payment"), "validate")); AtomicInteger callCount = new AtomicInteger(0); @@ -206,7 +211,7 @@ void should_respect_custom_failure_ratio() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("order", "create")); + .guardFor(new WorkflowTaskContext(wfId("order"), "create")); AtomicInteger callCount = new AtomicInteger(0); @@ -244,7 +249,7 @@ void should_remain_closed_when_below_failure_threshold() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("inventory", "check")); + .guardFor(new WorkflowTaskContext(wfId("inventory"), "check")); AtomicInteger callCount = new AtomicInteger(0); @@ -299,7 +304,7 @@ void should_not_open_circuit_before_request_volume_threshold() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("shipping", "calculate")); + .guardFor(new WorkflowTaskContext(wfId("shipping"), "calculate")); AtomicInteger callCount = new AtomicInteger(0); @@ -348,7 +353,7 @@ void should_open_circuit_with_100_percent_failure_ratio() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("notification", "send")); + .guardFor(new WorkflowTaskContext(wfId("notification"), "send")); AtomicInteger callCount = new AtomicInteger(0); @@ -400,7 +405,7 @@ void should_handle_mixed_success_and_failure_calls() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("user", "register")); + .guardFor(new WorkflowTaskContext(wfId("user"), "register")); AtomicInteger callCount = new AtomicInteger(0); @@ -426,7 +431,7 @@ void should_override_default_circuit_breaker_with_task_specific_config() { .withDefaultValue("quarkus.flow.http.client.resilience.circuit-breaker.enabled", "true") .withDefaultValue("quarkus.flow.http.client.resilience.circuit-breaker.request-volume-threshold", "10") .withDefaultValue("quarkus.flow.http.client.resilience.circuit-breaker.failure-ratio", "0.5") - .withDefaultValue("quarkus.flow.http.client.workflow.billing.task.charge.name", "billingCharger") + .withDefaultValue("quarkus.flow.http.client.workflow.\"billing.task.charge\".name", "billingCharger") .withDefaultValue("quarkus.flow.http.client.named.billingCharger.resilience.circuit-breaker.enabled", "true") .withDefaultValue( "quarkus.flow.http.client.named.billingCharger.resilience.circuit-breaker.request-volume-threshold", @@ -446,7 +451,7 @@ void should_override_default_circuit_breaker_with_task_specific_config() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("billing", "charge")); + .guardFor(new WorkflowTaskContext(wfId("billing"), "charge")); AtomicInteger callCount = new AtomicInteger(0); @@ -484,7 +489,7 @@ void should_handle_all_successful_calls_without_opening_circuit() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("analytics", "track")); + .guardFor(new WorkflowTaskContext(wfId("analytics"), "track")); AtomicInteger callCount = new AtomicInteger(0); @@ -535,7 +540,7 @@ void should_handle_edge_case_with_minimum_request_volume() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("cache", "invalidate")); + .guardFor(new WorkflowTaskContext(wfId("cache"), "invalidate")); AtomicInteger callCount = new AtomicInteger(0); diff --git a/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceRetryTest.java b/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceRetryTest.java index 2f0499425..1589ebf06 100644 --- a/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceRetryTest.java +++ b/core/deployment/src/test/java/io/quarkiverse/flow/deployment/test/faulttolerance/FaultToleranceRetryTest.java @@ -15,12 +15,17 @@ import io.quarkiverse.flow.config.FlowMetricsConfig; import io.quarkiverse.flow.providers.FaultToleranceProvider; import io.quarkiverse.flow.providers.WorkflowTaskContext; +import io.serverlessworkflow.impl.WorkflowDefinitionId; import io.serverlessworkflow.impl.WorkflowModel; import io.smallrye.config.SmallRyeConfigBuilder; import io.smallrye.faulttolerance.api.TypedGuard; public class FaultToleranceRetryTest { + private static WorkflowDefinitionId wfId(String name) { + return WorkflowDefinitionId.fromName(name); + } + @Test void should_create_type_guard_with_retry_enabled_and_five_retries() { @@ -37,7 +42,7 @@ void should_create_type_guard_with_retry_enabled_and_five_retries() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("any", "any")); + .guardFor(new WorkflowTaskContext(wfId("any"), "any")); AtomicInteger atomicInteger = new AtomicInteger(0); @@ -71,7 +76,7 @@ void should_not_retry_when_retry_is_disabled() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("any", "any")); + .guardFor(new WorkflowTaskContext(wfId("any"), "any")); AtomicInteger atomicInteger = new AtomicInteger(0); @@ -106,7 +111,7 @@ void should_retry_one_time_more_when_max_retries_is_one() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("any", "any")); + .guardFor(new WorkflowTaskContext(wfId("any"), "any")); AtomicInteger atomicInteger = new AtomicInteger(0); @@ -129,7 +134,7 @@ void should_disable_retry_for_workflow_level() { FlowHttpConfig flowHttpConfig = new SmallRyeConfigBuilder() .addDiscoveredConverters() .withMapping(FlowHttpConfig.class) - .withDefaultValue("quarkus.flow.http.client.resilience.retry.enabled", "true") // default is true + .withDefaultValue("quarkus.flow.http.client.resilience.retry.enabled", "true") .withDefaultValue("quarkus.flow.http.client.resilience.retry.max-retries", "1") .withDefaultValue("quarkus.flow.http.client.workflow.transfer.name", "transferClient") .withDefaultValue("quarkus.flow.http.client.named.transferClient.resilience.retry.enabled", "false") @@ -143,7 +148,7 @@ void should_disable_retry_for_workflow_level() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("transfer", "any")); + .guardFor(new WorkflowTaskContext(wfId("transfer"), "any")); AtomicInteger atomicInteger = new AtomicInteger(0); @@ -166,14 +171,14 @@ void should_configure_the_max_retries_at_task_level() { FlowHttpConfig flowHttpConfig = new SmallRyeConfigBuilder() .addDiscoveredConverters() .withMapping(FlowHttpConfig.class) - .withDefaultValue("quarkus.flow.http.client.resilience.retry.enabled", "true") // default is true + .withDefaultValue("quarkus.flow.http.client.resilience.retry.enabled", "true") .withDefaultValue("quarkus.flow.http.client.resilience.retry.max-retries", "1") - .withDefaultValue("quarkus.flow.http.client.workflow.transfer.task.notify.name", "transferNotifier") - .withDefaultValue("quarkus.flow.http.client.workflow.transfer.name", "transferNotifierWorkflowLevel") // workflow-level + .withDefaultValue("quarkus.flow.http.client.workflow.\"transfer.task.notify\".name", "transferNotifier") + .withDefaultValue("quarkus.flow.http.client.workflow.transfer.name", "transferNotifierWorkflowLevel") .withDefaultValue("quarkus.flow.http.client.named.transferNotifierWorkflowLevel.resilience.retry.enabled", "false") .withDefaultValue("quarkus.flow.http.client.named.transferNotifier.resilience.retry.enabled", "true") - .withDefaultValue("quarkus.flow.http.client.named.transferNotifier.resilience.retry.max-retries", "2") // overrides the default + .withDefaultValue("quarkus.flow.http.client.named.transferNotifier.resilience.retry.max-retries", "2") .build().getConfigMapping(FlowHttpConfig.class); FlowMetricsConfig flowMetricsConfig = new SmallRyeConfigBuilder() @@ -184,7 +189,7 @@ void should_configure_the_max_retries_at_task_level() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("transfer", "notify")); + .guardFor(new WorkflowTaskContext(wfId("transfer"), "notify")); AtomicInteger atomicInteger = new AtomicInteger(0); @@ -221,7 +226,7 @@ void should_apply_delay_between_retries() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> guard = sut - .guardFor(new WorkflowTaskContext("transfer", "notify")); + .guardFor(new WorkflowTaskContext(wfId("transfer"), "notify")); List executionTimes = new CopyOnWriteArrayList<>(); @@ -262,7 +267,7 @@ void should_retry_when_throwing_a_configured_exception() { FaultToleranceProvider sut = new FaultToleranceProvider(flowHttpConfig, flowMetricsConfig); TypedGuard> typeGuard = sut - .guardFor(new WorkflowTaskContext("any", "any")); + .guardFor(new WorkflowTaskContext(wfId("any"), "any")); AtomicInteger atomicInteger = new AtomicInteger(0); @@ -283,4 +288,4 @@ static class MyCustomException extends RuntimeException { } -} \ No newline at end of file +} diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/config/ClientConfigCascade.java b/core/runtime/src/main/java/io/quarkiverse/flow/config/ClientConfigCascade.java new file mode 100644 index 000000000..21473dece --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/flow/config/ClientConfigCascade.java @@ -0,0 +1,63 @@ +package io.quarkiverse.flow.config; + +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Stream; + +import io.serverlessworkflow.impl.WorkflowDefinitionId; + +/** + * Generic 6-level cascade resolver for client configuration overrides. + *

+ * Resolution order (most to least specific): + *

    + *
  1. {@code namespace:name:version.task.taskName} — task full
  2. + *
  3. {@code namespace:name.task.taskName} — task medium
  4. + *
  5. {@code name.task.taskName} — task short
  6. + *
  7. {@code namespace:name:version} — workflow full
  8. + *
  9. {@code namespace:name} — workflow medium
  10. + *
  11. {@code name} — workflow short
  12. + *
+ * + * @see ClientNamingConvention + */ +public final class ClientConfigCascade { + + private ClientConfigCascade() { + } + + /** + * Walks the 6-level cascade and returns the first non-null result from {@code lookup}. + * + * @param the type returned by the lookup (e.g. {@code String}, {@code Duration}) + * @param lookup maps a config key to a value, returning {@code null} when the key is absent + * @param workflowId the workflow identity + * @param taskName the task name (maybe {@code null} or blank to skip task-level keys) + * @return the first non-null lookup result, or {@code null} if no key matches + */ + public static T resolve(Function lookup, WorkflowDefinitionId workflowId, String taskName) { + return cascadeKeys(workflowId, taskName) + .map(lookup) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private static Stream cascadeKeys(WorkflowDefinitionId workflowId, String taskName) { + Stream workflowKeys = Stream.of( + ClientNamingConvention.workflowKeyFull(workflowId), + ClientNamingConvention.workflowKeyMedium(workflowId), + ClientNamingConvention.workflowKeyShort(workflowId)); + + if (taskName == null || taskName.isBlank()) { + return workflowKeys; + } + + return Stream.concat( + Stream.of( + ClientNamingConvention.taskKeyFull(workflowId, taskName), + ClientNamingConvention.taskKeyMedium(workflowId, taskName), + ClientNamingConvention.taskKeyShort(workflowId, taskName)), + workflowKeys); + } +} diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/config/ClientNamingConvention.java b/core/runtime/src/main/java/io/quarkiverse/flow/config/ClientNamingConvention.java new file mode 100644 index 000000000..7da67ca29 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/flow/config/ClientNamingConvention.java @@ -0,0 +1,74 @@ +package io.quarkiverse.flow.config; + +import java.util.Objects; + +import io.serverlessworkflow.impl.WorkflowDefinitionId; + +/** + * Centralized naming conventions for client configuration keys. + *

+ * Implements the unified client naming pattern from ADR 2026-07-07. + * Used by HTTP, gRPC, and OIDC modules for consistent key generation. + *

+ * Key format: {@code ":"} separates namespace/name/version, + * {@code ".task."} separates the workflow identity from the task name. + */ +public final class ClientNamingConvention { + + static final String SEPARATOR = ":"; + static final String TASK_SEGMENT = ".task."; + + private ClientNamingConvention() { + } + + /** + * Full task-level key: {@code namespace:name:version.task.taskName}. + */ + public static String taskKeyFull(WorkflowDefinitionId workflowId, String taskName) { + Objects.requireNonNull(workflowId, "workflowId is null"); + Objects.requireNonNull(taskName, "taskName is null"); + return workflowId.toString(SEPARATOR) + TASK_SEGMENT + taskName; + } + + /** + * Medium task-level key (no version): {@code namespace:name.task.taskName}. + */ + public static String taskKeyMedium(WorkflowDefinitionId workflowId, String taskName) { + Objects.requireNonNull(workflowId, "workflowId is null"); + Objects.requireNonNull(taskName, "taskName is null"); + return workflowId.namespace() + SEPARATOR + workflowId.name() + TASK_SEGMENT + taskName; + } + + /** + * Short task-level key (name only): {@code name.task.taskName}. + */ + public static String taskKeyShort(WorkflowDefinitionId workflowId, String taskName) { + Objects.requireNonNull(workflowId, "workflowId is null"); + Objects.requireNonNull(taskName, "taskName is null"); + return workflowId.name() + TASK_SEGMENT + taskName; + } + + /** + * Full workflow-level key: {@code namespace:name:version}. + */ + public static String workflowKeyFull(WorkflowDefinitionId workflowId) { + Objects.requireNonNull(workflowId, "workflowId is null"); + return workflowId.toString(SEPARATOR); + } + + /** + * Medium workflow-level key (no version): {@code namespace:name}. + */ + public static String workflowKeyMedium(WorkflowDefinitionId workflowId) { + Objects.requireNonNull(workflowId, "workflowId is null"); + return workflowId.namespace() + SEPARATOR + workflowId.name(); + } + + /** + * Short workflow-level key (name only): {@code name}. + */ + public static String workflowKeyShort(WorkflowDefinitionId workflowId) { + Objects.requireNonNull(workflowId, "workflowId is null"); + return workflowId.name(); + } +} diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/config/FlowHttpConfig.java b/core/runtime/src/main/java/io/quarkiverse/flow/config/FlowHttpConfig.java index 718e9b387..a012bc348 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/config/FlowHttpConfig.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/config/FlowHttpConfig.java @@ -38,10 +38,22 @@ * quarkus.flow.http.client.workflow.myFlow.name = secureA * *

- * Task-level routing: + * Task-level routing (short key — dotted composite key): * *

- * quarkus.flow.http.client.workflow.myFlow.task.fetchCustomers.name = secureB
+ * quarkus.flow.http.client.workflow."myFlow.task.fetchCustomers".name = secureB
+ * 
+ *

+ * Medium key (namespaced): + * + *

+ * quarkus.flow.http.client.workflow."acme\:orders".name = secureA
+ * 
+ *

+ * Full key (versioned): + * + *

+ * quarkus.flow.http.client.workflow."acme\:orders\:1.0.0".name = secureA
  * 
*/ @ConfigMapping(prefix = "quarkus.flow.http.client") @@ -69,73 +81,31 @@ public interface FlowHttpConfig extends HttpClientConfig { Map named(); /** - * Workflow-level HTTP client routing configuration. + * Per-workflow/task HTTP client routing overrides, keyed by composite identifier. *

- * Each entry is keyed by the workflow id and maps to: - * - *

-     * quarkus.flow.http.client.workflow.<workflowName>.name=<clientName>
-     * quarkus.flow.http.client.workflow.<workflowName>.task.<taskName>.name=<clientName>
-     * 
+ * Keys follow the unified naming convention: + *
    + *
  • {@code } — workflow-level short (99% use case)
  • + *
  • {@code ":"} — workflow-level medium
  • + *
  • {@code "::"} — workflow-level full
  • + *
  • {@code ".task."} — task-level short
  • + *
  • {@code ":.task."} — task-level medium
  • + *
  • {@code "::.task."} — task-level full
  • + *
* - * @return the map of workflow routing configurations + * @return the map of workflow/task routing configurations */ - Map workflow(); + Map workflow(); /** - * Routing configuration for a single workflow. - *

- * Allows selecting a default client for the workflow and - * overriding it on a per-task basis. + * Routes a workflow or task to a named HTTP client. */ - interface WorkflowRoutingConfig { + interface ClientOverrideConfig { /** - * Client name to use for all HTTP/OpenAPI tasks in this workflow - * when there is no task-level override. - *

- * Property: - * - *

-         * quarkus.flow.http.client.workflow.<workflowName>.name=<clientName>
-         * 
- * - * @return the client name for this workflow, if configured - */ - Optional name(); - - /** - * Per-task client overrides for this workflow. - *

- * Each entry is keyed by the task name and maps to: - * - *

-         * quarkus.flow.http.client.workflow.<workflowName>.task.<taskName>.name=<clientName>
-         * 
- * - * @return the map of per-task routing configurations - */ - Map task(); - } - - /** - * Routing configuration for a single workflow task. - *

- * Allows selecting a specific client for one task within - * a workflow. - */ - interface TaskRoutingConfig { - - /** - * Client name to use for this specific task. - *

- * Property: - * - *

-         * quarkus.flow.http.client.workflow.<workflowName>.task.<taskName>.name=<clientName>
-         * 
+ * The named HTTP client to use, configured under {@code quarkus.flow.http.client.named.}. * - * @return the client name for this task, if configured + * @return the client name, if configured */ Optional name(); } diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/providers/FaultToleranceProvider.java b/core/runtime/src/main/java/io/quarkiverse/flow/providers/FaultToleranceProvider.java index fc95561ad..9e3d2a1a9 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/providers/FaultToleranceProvider.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/providers/FaultToleranceProvider.java @@ -62,7 +62,7 @@ public FaultToleranceProvider(FlowHttpConfig flowHttpConfig, FlowMetricsConfig f } public TypedGuard> guardFor(WorkflowTaskContext ctx) { - String guardName = this.routingNameResolver.resolveName(ctx.workflowName(), ctx.taskName()); + String guardName = this.routingNameResolver.resolveName(ctx.workflowId(), ctx.taskName()); if (guardName == null) { return getOrCreateDefaultGuard(ctx); } diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/providers/HttpClientProvider.java b/core/runtime/src/main/java/io/quarkiverse/flow/providers/HttpClientProvider.java index 0432b12dc..6796883bf 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/providers/HttpClientProvider.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/providers/HttpClientProvider.java @@ -37,6 +37,7 @@ import io.quarkus.arc.Arc; import io.quarkus.proxy.ProxyConfiguration; import io.quarkus.proxy.ProxyConfigurationRegistry; +import io.serverlessworkflow.impl.WorkflowDefinitionId; /** * Registry of JAX-RS {@link Client} instances used by Quarkus Flow HTTP/OpenAPI tasks. @@ -107,21 +108,15 @@ void destroy() { /** * Resolve the {@link Client} to use for the given workflow and task. *

- * Resolution order: - *

    - *
  1. Task-level override: - * {@code quarkus.flow.http.client.workflow..task..name}
  2. - *
  3. Workflow-level default: - * {@code quarkus.flow.http.client.workflow..name}
  4. - *
  5. Fallback to the global default client ({@link FlowHttpConfig})
  6. - *
+ * Uses the 6-level progressive specificity cascade (ADR 2026-07-07). + * Falls back to the global default client when no override matches. * - * @param workflowId workflow id (as defined in the DSL / {@code Workflow#id()}) - * @param taskId task logical id (e.g. {@code "fetchCustomers"}) + * @param workflowId the workflow identity (namespace, name, version) + * @param taskName task logical name (e.g. {@code "fetchCustomers"}) * @return a cached {@link Client} instance */ - public Client clientFor(String workflowId, String taskId) { - final String clientName = routingNameResolver.resolveName(workflowId, taskId); + public Client clientFor(WorkflowDefinitionId workflowId, String taskName) { + final String clientName = routingNameResolver.resolveName(workflowId, taskName); if (clientName == null) { return getOrCreateDefaultClient(); } diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/providers/RoutingNameResolver.java b/core/runtime/src/main/java/io/quarkiverse/flow/providers/RoutingNameResolver.java index 30e6e168e..50ab3b958 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/providers/RoutingNameResolver.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/providers/RoutingNameResolver.java @@ -1,8 +1,10 @@ package io.quarkiverse.flow.providers; -import java.util.Optional; +import java.util.Map; +import io.quarkiverse.flow.config.ClientConfigCascade; import io.quarkiverse.flow.config.FlowHttpConfig; +import io.serverlessworkflow.impl.WorkflowDefinitionId; public class RoutingNameResolver { @@ -12,22 +14,16 @@ public RoutingNameResolver(FlowHttpConfig flowHttpConfig) { this.flowHttpConfig = flowHttpConfig; } - public String resolveName(String workflowName, String taskName) { - final FlowHttpConfig.WorkflowRoutingConfig wfCfg = flowHttpConfig.workflow().get(workflowName); - if (wfCfg == null) { - return null; - } + public String resolveName(WorkflowDefinitionId workflowId, String taskName) { + Map overrides = flowHttpConfig.workflow(); + return ClientConfigCascade.resolve(key -> overrideName(overrides, key), workflowId, taskName); + } - if (taskName != null && !taskName.isBlank()) { - final FlowHttpConfig.TaskRoutingConfig taskCfg = wfCfg.task().get(taskName); - if (taskCfg != null) { - Optional taskClient = taskCfg.name(); - if (taskClient.isPresent() && !taskClient.get().isBlank()) { - return taskClient.get(); - } - } + private static String overrideName(Map overrides, String key) { + FlowHttpConfig.ClientOverrideConfig override = overrides.get(key); + if (override != null && override.name().isPresent()) { + return override.name().get(); } - - return wfCfg.name().orElse(null); + return null; } } diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/providers/WorkflowTaskContext.java b/core/runtime/src/main/java/io/quarkiverse/flow/providers/WorkflowTaskContext.java index d02b78f5a..eb2e107ca 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/providers/WorkflowTaskContext.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/providers/WorkflowTaskContext.java @@ -1,4 +1,10 @@ package io.quarkiverse.flow.providers; -public record WorkflowTaskContext(String workflowName, String taskName) { +import io.serverlessworkflow.impl.WorkflowDefinitionId; + +public record WorkflowTaskContext(WorkflowDefinitionId workflowId, String taskName) { + + public String workflowName() { + return workflowId.name(); + } } diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java b/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java index 772c0f5e7..663895ab0 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java @@ -231,9 +231,7 @@ private void injectConfigManager(final Builder builder) { private void injectHttpClientProvider(final Builder builder) { LOG.debug("Flow: Bound HttpClientProvider bean: {}", httpClientProvider.getClass().getName()); builder.withAdditionalObject(HttpClientResolver.HTTP_CLIENT_PROVIDER, ((workflowContextData, taskContextData) -> { - final String workflowName = workflowContextData.definition().workflow().getDocument().getName(); - final String taskName = taskContextData.taskName(); - return httpClientProvider.clientFor(workflowName, taskName); + return httpClientProvider.clientFor(workflowContextData.definition().id(), taskContextData.taskName()); })); } @@ -253,10 +251,9 @@ private void injectFaultTolerance(Builder builder) { @Override public CallableTask build(CallableTask delegate) { return (workflowContext, taskContext, input) -> { - String workflowName = workflowContext.definition().workflow().getDocument().getName(); - String taskName = taskContext.taskName(); TypedGuard> guard = faultToleranceProvider - .guardFor(new WorkflowTaskContext(workflowName, taskName)); + .guardFor(new WorkflowTaskContext(workflowContext.definition().id(), + taskContext.taskName())); return guard.get(() -> delegate.apply(workflowContext, taskContext, input)).toCompletableFuture(); }; diff --git a/core/runtime/src/test/java/io/quarkiverse/flow/config/ClientConfigCascadeTest.java b/core/runtime/src/test/java/io/quarkiverse/flow/config/ClientConfigCascadeTest.java new file mode 100644 index 000000000..35deae5c1 --- /dev/null +++ b/core/runtime/src/test/java/io/quarkiverse/flow/config/ClientConfigCascadeTest.java @@ -0,0 +1,136 @@ +package io.quarkiverse.flow.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.serverlessworkflow.impl.WorkflowDefinitionId; + +class ClientConfigCascadeTest { + + private static final WorkflowDefinitionId ID = new WorkflowDefinitionId("acme", "orders", "1.0.0"); + + @Test + @DisplayName("task full key wins over all other levels") + void task_full_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders:1.0.0.task.payment", "taskFull"); + overrides.put("acme:orders.task.payment", "taskMedium"); + overrides.put("orders.task.payment", "taskShort"); + overrides.put("acme:orders:1.0.0", "wfFull"); + overrides.put("acme:orders", "wfMedium"); + overrides.put("orders", "wfShort"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, "payment"); + + assertThat(result).isEqualTo("taskFull"); + } + + @Test + @DisplayName("task medium key wins when full is absent") + void task_medium_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders.task.payment", "taskMedium"); + overrides.put("orders.task.payment", "taskShort"); + overrides.put("acme:orders:1.0.0", "wfFull"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, "payment"); + + assertThat(result).isEqualTo("taskMedium"); + } + + @Test + @DisplayName("task short key wins when full and medium are absent") + void task_short_wins() { + Map overrides = new HashMap<>(); + overrides.put("orders.task.payment", "taskShort"); + overrides.put("acme:orders:1.0.0", "wfFull"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, "payment"); + + assertThat(result).isEqualTo("taskShort"); + } + + @Test + @DisplayName("workflow full key wins when no task overrides exist") + void workflow_full_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders:1.0.0", "wfFull"); + overrides.put("acme:orders", "wfMedium"); + overrides.put("orders", "wfShort"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, "payment"); + + assertThat(result).isEqualTo("wfFull"); + } + + @Test + @DisplayName("workflow medium key wins when full is absent") + void workflow_medium_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders", "wfMedium"); + overrides.put("orders", "wfShort"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, "payment"); + + assertThat(result).isEqualTo("wfMedium"); + } + + @Test + @DisplayName("workflow short key is the last fallback") + void workflow_short_wins() { + Map overrides = new HashMap<>(); + overrides.put("orders", "wfShort"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, "payment"); + + assertThat(result).isEqualTo("wfShort"); + } + + @Test + @DisplayName("returns null when nothing matches") + void no_match_returns_null() { + String result = ClientConfigCascade.resolve(key -> null, ID, "payment"); + + assertThat(result).isNull(); + } + + @Test + @DisplayName("null task name skips task-level keys") + void null_task_name_skips_task_keys() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders:1.0.0.task.payment", "taskFull"); + overrides.put("acme:orders:1.0.0", "wfFull"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, null); + + assertThat(result).isEqualTo("wfFull"); + } + + @Test + @DisplayName("blank task name skips task-level keys") + void blank_task_name_skips_task_keys() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders:1.0.0.task.payment", "taskFull"); + overrides.put("orders", "wfShort"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, " "); + + assertThat(result).isEqualTo("wfShort"); + } + + @Test + @DisplayName("workflow-level keys work without task name") + void workflow_only_resolution() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders", "wfMedium"); + + String result = ClientConfigCascade.resolve(overrides::get, ID, null); + + assertThat(result).isEqualTo("wfMedium"); + } +} diff --git a/core/runtime/src/test/java/io/quarkiverse/flow/config/ClientNamingConventionTest.java b/core/runtime/src/test/java/io/quarkiverse/flow/config/ClientNamingConventionTest.java new file mode 100644 index 000000000..aebfbef89 --- /dev/null +++ b/core/runtime/src/test/java/io/quarkiverse/flow/config/ClientNamingConventionTest.java @@ -0,0 +1,72 @@ +package io.quarkiverse.flow.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.serverlessworkflow.impl.WorkflowDefinitionId; + +class ClientNamingConventionTest { + + private static final WorkflowDefinitionId ID = new WorkflowDefinitionId("acme", "orders", "1.0.0"); + + @Test + @DisplayName("taskKeyFull produces namespace:name:version.task.taskName") + void task_key_full() { + assertThat(ClientNamingConvention.taskKeyFull(ID, "payment")) + .isEqualTo("acme:orders:1.0.0.task.payment"); + } + + @Test + @DisplayName("taskKeyMedium produces namespace:name.task.taskName") + void task_key_medium() { + assertThat(ClientNamingConvention.taskKeyMedium(ID, "payment")) + .isEqualTo("acme:orders.task.payment"); + } + + @Test + @DisplayName("taskKeyShort produces name.task.taskName") + void task_key_short() { + assertThat(ClientNamingConvention.taskKeyShort(ID, "payment")) + .isEqualTo("orders.task.payment"); + } + + @Test + @DisplayName("workflowKeyFull produces namespace:name:version") + void workflow_key_full() { + assertThat(ClientNamingConvention.workflowKeyFull(ID)) + .isEqualTo("acme:orders:1.0.0"); + } + + @Test + @DisplayName("workflowKeyMedium produces namespace:name") + void workflow_key_medium() { + assertThat(ClientNamingConvention.workflowKeyMedium(ID)) + .isEqualTo("acme:orders"); + } + + @Test + @DisplayName("workflowKeyShort produces name") + void workflow_key_short() { + assertThat(ClientNamingConvention.workflowKeyShort(ID)) + .isEqualTo("orders"); + } + + @Test + @DisplayName("null workflowId throws NullPointerException") + void null_workflow_id_throws() { + assertThatThrownBy(() -> ClientNamingConvention.taskKeyFull(null, "t")) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> ClientNamingConvention.workflowKeyFull(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("null taskName throws NullPointerException") + void null_task_name_throws() { + assertThatThrownBy(() -> ClientNamingConvention.taskKeyFull(ID, null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/core/runtime/src/test/java/io/quarkiverse/flow/providers/RoutingNameResolverTest.java b/core/runtime/src/test/java/io/quarkiverse/flow/providers/RoutingNameResolverTest.java new file mode 100644 index 000000000..d73b17e0c --- /dev/null +++ b/core/runtime/src/test/java/io/quarkiverse/flow/providers/RoutingNameResolverTest.java @@ -0,0 +1,130 @@ +package io.quarkiverse.flow.providers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.quarkiverse.flow.config.FlowHttpConfig; +import io.serverlessworkflow.impl.WorkflowDefinitionId; + +class RoutingNameResolverTest { + + private static final WorkflowDefinitionId WORKFLOW = new WorkflowDefinitionId("acme", "orders", "1.0.0"); + + private static FlowHttpConfig.ClientOverrideConfig override(String name) { + FlowHttpConfig.ClientOverrideConfig cfg = mock(FlowHttpConfig.ClientOverrideConfig.class); + when(cfg.name()).thenReturn(Optional.ofNullable(name)); + return cfg; + } + + private RoutingNameResolver resolver(Map overrides) { + FlowHttpConfig config = mock(FlowHttpConfig.class); + when(config.workflow()).thenReturn(overrides); + return new RoutingNameResolver(config); + } + + @Test + @DisplayName("task full key wins over all other levels") + void task_full_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders:1.0.0.task.payment", override("taskFull")); + overrides.put("acme:orders.task.payment", override("taskMedium")); + overrides.put("orders.task.payment", override("taskShort")); + overrides.put("acme:orders:1.0.0", override("wfFull")); + overrides.put("acme:orders", override("wfMedium")); + overrides.put("orders", override("wfShort")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, "payment")).isEqualTo("taskFull"); + } + + @Test + @DisplayName("task medium key wins when full is absent") + void task_medium_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders.task.payment", override("taskMedium")); + overrides.put("orders", override("wfShort")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, "payment")).isEqualTo("taskMedium"); + } + + @Test + @DisplayName("task short key wins when full and medium are absent") + void task_short_wins() { + Map overrides = new HashMap<>(); + overrides.put("orders.task.payment", override("taskShort")); + overrides.put("acme:orders:1.0.0", override("wfFull")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, "payment")).isEqualTo("taskShort"); + } + + @Test + @DisplayName("workflow full key wins when no task overrides") + void workflow_full_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders:1.0.0", override("wfFull")); + overrides.put("acme:orders", override("wfMedium")); + overrides.put("orders", override("wfShort")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, "payment")).isEqualTo("wfFull"); + } + + @Test + @DisplayName("workflow medium key wins when full is absent") + void workflow_medium_wins() { + Map overrides = new HashMap<>(); + overrides.put("acme:orders", override("wfMedium")); + overrides.put("orders", override("wfShort")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, "payment")).isEqualTo("wfMedium"); + } + + @Test + @DisplayName("workflow short key is the last fallback") + void workflow_short_wins() { + Map overrides = new HashMap<>(); + overrides.put("orders", override("wfShort")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, "payment")).isEqualTo("wfShort"); + } + + @Test + @DisplayName("returns null when no overrides match") + void no_match_returns_null() { + assertThat(resolver(Map.of()).resolveName(WORKFLOW, "payment")).isNull(); + } + + @Test + @DisplayName("null task name skips task-level keys") + void null_task_skips_task_keys() { + Map overrides = new HashMap<>(); + overrides.put("orders.task.payment", override("taskShort")); + overrides.put("orders", override("wfShort")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, null)).isEqualTo("wfShort"); + } + + @Test + @DisplayName("blank task name skips task-level keys") + void blank_task_skips_task_keys() { + Map overrides = new HashMap<>(); + overrides.put("orders", override("wfShort")); + + assertThat(resolver(overrides).resolveName(WORKFLOW, " ")).isEqualTo("wfShort"); + } + + @Test + @DisplayName("override with empty name is ignored") + void override_with_empty_name_is_ignored() { + Map overrides = new HashMap<>(); + overrides.put("orders", override(null)); + + assertThat(resolver(overrides).resolveName(WORKFLOW, "payment")).isNull(); + } +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 50f7cf124..93c518393 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -47,6 +47,7 @@ ** xref:idempotency-correlation.adoc[Idempotency and Correlation] * Migration Guides +** xref:migration-0.15-to-0.16.adoc[0.15.x to 0.16.0] ** xref:migration-0.14-to-0.15.adoc[0.14.x to 0.15.0] ** xref:migration-0.13-to-0.14.adoc[0.13.x to 0.14.0] diff --git a/docs/modules/ROOT/pages/grpc.adoc b/docs/modules/ROOT/pages/grpc.adoc index 039022c45..2fa77d3ea 100644 --- a/docs/modules/ROOT/pages/grpc.adoc +++ b/docs/modules/ROOT/pages/grpc.adoc @@ -18,17 +18,28 @@ It connects the Serverless Workflow gRPC executor to Quarkus gRPC channels, so y == Channel resolution -The module resolves the Quarkus gRPC named channel to use for each workflow task, following this order: +The module resolves the Quarkus gRPC named channel to use for each workflow task. +When multiple routing rules match, the most specific one wins: + +1. **Task-level full** (most specific) — `quarkus.flow.grpc.client."\:\:.task.".name` +2. **Task-level medium** — `quarkus.flow.grpc.client."\:.task.".name` +3. **Task-level short** — `quarkus.flow.grpc.client.".task.".name` +4. **Workflow-level full** — `quarkus.flow.grpc.client."\:\:".name` +5. **Workflow-level medium** — `quarkus.flow.grpc.client."\:".name` +6. **Workflow-level short** — `quarkus.flow.grpc.client..name` +7. **Default channel** — if a Quarkus gRPC client named `flowGrpc` exists, it is used for all workflows +8. **SDK fallback** — no routing configured; the SDK falls back to the host and port declared in the workflow itself -1. **Task-level override**: `quarkus.flow.grpc.client.":".name=` -2. **Workflow-level override**: `quarkus.flow.grpc.client."".name=` -3. **Versionless override**: `quarkus.flow.grpc.client.":".name=` — applied to **all versions** of the workflow. -4. **Workflow ID as client name**: If a Quarkus gRPC client matching the workflow ID exists, it is used. -5. **Default channel**: If a Quarkus gRPC client named `flowGrpc` exists, it is used for all workflows. -6. **SDK fallback**: If none of the above match, the SDK falls back to the host and port declared in the workflow itself. +[NOTE] +==== +**Progressive specificity:** The resolver tries keys from most to least specific: first task-level (full, medium, short), then workflow-level (full, medium, short). Within each group, it goes from longest (namespace:name:version) to shortest (name only). +==== -The workflow ID is `namespace:name:version` (e.g. `org.acme:grpcGreeting:0.0.1`). -The versionless key is `namespace:name` (e.g. `org.acme:grpcGreeting`). +[IMPORTANT] +==== +Colons in the composite key must be escaped as `\:` in `application.properties` files. +For example, for a workflow with namespace `org.acme` and name `grpcGreeting`, use `"org.acme\:grpcGreeting"`. +==== == Configure a default channel @@ -51,22 +62,17 @@ quarkus.grpc.clients.myService.host=my-grpc-server.example.com quarkus.grpc.clients.myService.port=443 quarkus.grpc.clients.myService.plain-text=false -quarkus.flow.grpc.client."org.acme:grpcGreeting:0.0.1".name=myService -``` - -== Configure a channel for all versions of a workflow +# Short key — route by workflow name only (simplest) +quarkus.flow.grpc.client.grpcGreeting.name=myService -To route every version of a workflow to the same gRPC server, use the versionless key `namespace:name` (omit the version): +# Medium key — route by namespace and name (all versions) +quarkus.flow.grpc.client."org.acme\:grpcGreeting".name=myService -```properties -quarkus.grpc.clients.myService.host=my-grpc-server.example.com -quarkus.grpc.clients.myService.port=443 -quarkus.grpc.clients.myService.plain-text=false - -quarkus.flow.grpc.client."org.acme:grpcGreeting".name=myService +# Full key — route a specific version +quarkus.flow.grpc.client."org.acme\:grpcGreeting\:0.0.1".name=myService ``` -A version-specific override (`org.acme:grpcGreeting:0.0.1`) always takes precedence over the versionless one (`org.acme:grpcGreeting`). +A more specific key always takes precedence: full (`org.acme:grpcGreeting:0.0.1`) wins over medium (`org.acme:grpcGreeting`), which wins over short (`grpcGreeting`). == Configure per-task channels @@ -74,8 +80,14 @@ If a single workflow orchestrates multiple gRPC servers, use task-level override Each gRPC task in the workflow must have a unique name. ```properties -quarkus.flow.grpc.client."org.acme:orderWorkflow:0.0.1".name=orderService -quarkus.flow.grpc.client."org.acme:orderWorkflow:0.0.1:checkInventory".name=inventoryService +# Workflow-level default +quarkus.flow.grpc.client."org.acme\:orderWorkflow\:0.0.1".name=orderService + +# Task-level override (uses .task. separator) +quarkus.flow.grpc.client."org.acme\:orderWorkflow\:0.0.1.task.checkInventory".name=inventoryService + +# Short task key — by workflow name and task only +quarkus.flow.grpc.client."orderWorkflow.task.checkInventory".name=inventoryService ``` [NOTE] diff --git a/docs/modules/ROOT/pages/http-client.adoc b/docs/modules/ROOT/pages/http-client.adoc index 8fcb48c02..399dcbdf1 100644 --- a/docs/modules/ROOT/pages/http-client.adoc +++ b/docs/modules/ROOT/pages/http-client.adoc @@ -68,21 +68,58 @@ quarkus.flow.http.client.named.internal.connection-pool-size=100 == 4. Route workflows and tasks to clients -Routing is configured under the `workflow` section: +Routing is configured under the `workflow` section. +Keys are composite identifiers that encode namespace, name, version, and optional task name. + +=== Workflow-level routing (most common) [source,properties] ---- -# Use "secureA" for all HTTP/OpenAPI tasks in the workflow "order-flow" +# Short key — route by workflow name only (99% use case) quarkus.flow.http.client.workflow.order-flow.name=secureA -# Override only the "fetchCustomers" task within "order-flow" to use "internal" -quarkus.flow.http.client.workflow.order-flow.task.fetchCustomers.name=internal +# Medium key — route by namespace and name +quarkus.flow.http.client.workflow."acme\:order-flow".name=secureA + +# Full key — route by namespace, name and version +quarkus.flow.http.client.workflow."acme\:order-flow\:1.0.0".name=secureA +---- + +=== Task-level routing + +[source,properties] +---- +# Short key — route a specific task by workflow name +quarkus.flow.http.client.workflow."order-flow.task.fetchCustomers".name=internal + +# Medium key — with namespace +quarkus.flow.http.client.workflow."acme\:order-flow.task.fetchCustomers".name=internal + +# Full key — with namespace and version +quarkus.flow.http.client.workflow."acme\:order-flow\:1.0.0.task.fetchCustomers".name=internal ---- -Resolution order for a call: -. Task-level override. -. Workflow-level default. -. Fallback to the global default client. +=== Resolution order + +When multiple routing rules match, the most specific one wins: + +1. **Task-level full** (most specific) — `quarkus.flow.http.client.workflow."\:\:.task.".name` +2. **Task-level medium** — `quarkus.flow.http.client.workflow."\:.task.".name` +3. **Task-level short** — `quarkus.flow.http.client.workflow.".task.".name` +4. **Workflow-level full** — `quarkus.flow.http.client.workflow."\:\:".name` +5. **Workflow-level medium** — `quarkus.flow.http.client.workflow."\:".name` +6. **Workflow-level short** — `quarkus.flow.http.client.workflow..name` +7. **Default fallback** — no routing configured; the global default client is used + +[NOTE] +==== +**Progressive specificity:** The resolver tries keys from most to least specific: first task-level (full, medium, short), then workflow-level (full, medium, short). Within each group, it goes from longest (namespace:name:version) to shortest (name only). +==== + +[IMPORTANT] +==== +Colons in the composite key must be escaped as `\:` in `application.properties` files. For example, for a workflow with namespace `acme` and name `order-flow`, use `"acme\:order-flow"`. +==== [IMPORTANT] It is highly recommended that you keep HTTP-related task names unique within your workflow to avoid routing conflicts. diff --git a/docs/modules/ROOT/pages/includes/quarkus-flow.adoc b/docs/modules/ROOT/pages/includes/quarkus-flow.adoc index afae399a8..9024028be 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-flow.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-flow.adoc @@ -3465,13 +3465,7 @@ endif::add-copy-button-to-config-props[] [.description] -- -Client name to use for all HTTP/OpenAPI tasks in this workflow when there is no task-level override. - -Property: - -``` -quarkus.flow.http.client.workflow..name= -``` +The named HTTP client to use, configured under `quarkus.flow.http.client.named.`. ifdef::add-copy-button-to-env-var[] @@ -3484,33 +3478,6 @@ endif::add-copy-button-to-env-var[] |string | -a| [[quarkus-flow_quarkus-flow-http-client-workflow-workflow-task-task-name]] [.property-path]##link:#quarkus-flow_quarkus-flow-http-client-workflow-workflow-task-task-name[`+++quarkus.flow.http.client.workflow."workflow".task."task".name+++`]## -ifdef::add-copy-button-to-config-props[] -config_property_copy_button:+++quarkus.flow.http.client.workflow."workflow".task."task".name+++[] -endif::add-copy-button-to-config-props[] - - -[.description] --- -Client name to use for this specific task. - -Property: - -``` -quarkus.flow.http.client.workflow..task..name= -``` - - -ifdef::add-copy-button-to-env-var[] -Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_HTTP_CLIENT_WORKFLOW__WORKFLOW__TASK__TASK__NAME+++[] -endif::add-copy-button-to-env-var[] -ifndef::add-copy-button-to-env-var[] -Environment variable: `+++QUARKUS_FLOW_HTTP_CLIENT_WORKFLOW__WORKFLOW__TASK__TASK__NAME+++` -endif::add-copy-button-to-env-var[] --- -|string -| - |=== ifndef::no-duration-note[] diff --git a/docs/modules/ROOT/pages/includes/quarkus-flow_quarkus.flow.adoc b/docs/modules/ROOT/pages/includes/quarkus-flow_quarkus.flow.adoc index afae399a8..9024028be 100644 --- a/docs/modules/ROOT/pages/includes/quarkus-flow_quarkus.flow.adoc +++ b/docs/modules/ROOT/pages/includes/quarkus-flow_quarkus.flow.adoc @@ -3465,13 +3465,7 @@ endif::add-copy-button-to-config-props[] [.description] -- -Client name to use for all HTTP/OpenAPI tasks in this workflow when there is no task-level override. - -Property: - -``` -quarkus.flow.http.client.workflow..name= -``` +The named HTTP client to use, configured under `quarkus.flow.http.client.named.`. ifdef::add-copy-button-to-env-var[] @@ -3484,33 +3478,6 @@ endif::add-copy-button-to-env-var[] |string | -a| [[quarkus-flow_quarkus-flow-http-client-workflow-workflow-task-task-name]] [.property-path]##link:#quarkus-flow_quarkus-flow-http-client-workflow-workflow-task-task-name[`+++quarkus.flow.http.client.workflow."workflow".task."task".name+++`]## -ifdef::add-copy-button-to-config-props[] -config_property_copy_button:+++quarkus.flow.http.client.workflow."workflow".task."task".name+++[] -endif::add-copy-button-to-config-props[] - - -[.description] --- -Client name to use for this specific task. - -Property: - -``` -quarkus.flow.http.client.workflow..task..name= -``` - - -ifdef::add-copy-button-to-env-var[] -Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_HTTP_CLIENT_WORKFLOW__WORKFLOW__TASK__TASK__NAME+++[] -endif::add-copy-button-to-env-var[] -ifndef::add-copy-button-to-env-var[] -Environment variable: `+++QUARKUS_FLOW_HTTP_CLIENT_WORKFLOW__WORKFLOW__TASK__TASK__NAME+++` -endif::add-copy-button-to-env-var[] --- -|string -| - |=== ifndef::no-duration-note[] diff --git a/docs/modules/ROOT/pages/migration-0.15-to-0.16.adoc b/docs/modules/ROOT/pages/migration-0.15-to-0.16.adoc new file mode 100644 index 000000000..ba8f8e473 --- /dev/null +++ b/docs/modules/ROOT/pages/migration-0.15-to-0.16.adoc @@ -0,0 +1,161 @@ += Migration Guide: 0.15.x to 0.16.0 +include::includes/attributes.adoc[] + +Quarkus Flow 0.16.0 unifies the HTTP and gRPC client resolution with a single progressive specificity cascade. +All three client protocols (HTTP, gRPC, OIDC) now share the same key format and the same 6-level resolution order. + +This is a **configuration breaking change**: task-level routing keys use a new format in `application.properties`. + +== Why the change? + +In 0.15.1, HTTP, gRPC, and OIDC each had different configuration shapes: + +* **HTTP** used nested maps (`workflow..task..name`), supporting only 2 levels (task, then workflow). +* **gRPC** used flat composite keys with colons as task separators (`:::`), supporting 3 levels. +* **OIDC** had its own naming convention, separate from both. + +This inconsistency made configuration confusing and prevented simple name-only keys for gRPC. + +0.16.0 introduces a unified `ClientNamingConvention` and `ClientConfigCascade` shared by all three protocols, with a consistent `.task.` separator and support for namespace-aware, version-aware routing at all levels. + +== Resolution cascade + +All protocols now resolve client names using the same 6-level progressive specificity cascade. +The first match wins: + +[cols="1,2,3",options="header"] +|=== +| Priority | Level | Key format + +| 1 | Task (full) | `namespace:name:version.task.taskName` +| 2 | Task (medium) | `namespace:name.task.taskName` +| 3 | Task (short) | `name.task.taskName` +| 4 | Workflow (full) | `namespace:name:version` +| 5 | Workflow (medium) | `namespace:name` +| 6 | Workflow (short) | `name` +|=== + +If no task name is provided, priorities 1-3 are skipped. + +== What changed + +=== HTTP client routing + +[cols="1,1",options="header"] +|=== +| Before (0.15.1) | After (0.16.0) + +| Nested map: `workflow..task..name` +| Flat composite key: `workflow.".task.".name` + +| Workflow-level only by name +| Workflow-level by name, namespace:name, or namespace:name:version + +| 2-level resolution (task, then workflow) +| 6-level resolution cascade +|=== + +=== gRPC client routing + +[cols="1,1",options="header"] +|=== +| Before (0.15.1) | After (0.16.0) + +| Task separator was `:` (colon) +| Task separator is `.task.` + +| No short (name-only) key +| Short key supported (``) + +| 3-level resolution + 2 fallbacks +| 6-level resolution + 2 fallbacks +|=== + +== Step-by-step migration + +=== 1. Update HTTP task-level routing properties + +Workflow-level HTTP properties using only the workflow name are **unchanged**: + +[source,properties] +---- +# No change needed +quarkus.flow.http.client.workflow.orders.name=secureA +---- + +Task-level HTTP properties must change from nested format to flat composite key format: + +[source,properties] +---- +# Before (0.15.1) — nested task sub-key +quarkus.flow.http.client.workflow.orders.task.payment.name=secureB + +# After (0.16.0) — flat composite key with .task. separator +quarkus.flow.http.client.workflow."orders.task.payment".name=secureB +---- + +=== 2. Update gRPC task-level routing properties + +Workflow-level gRPC properties are **unchanged**: + +[source,properties] +---- +# No change needed +quarkus.flow.grpc.client."org.acme:grpcGreeting:0.0.1".name=my-channel +quarkus.flow.grpc.client."org.acme:grpcGreeting".name=my-channel +---- + +Task-level gRPC properties must replace the trailing colon with `.task.`: + +[source,properties] +---- +# Before (0.15.1) — colon task separator +quarkus.flow.grpc.client."org.acme:grpcGreeting:0.0.1:greet".name=my-channel + +# After (0.16.0) — .task. separator +quarkus.flow.grpc.client."org.acme:grpcGreeting:0.0.1.task.greet".name=my-channel +---- + +=== 3. Verify + +Rebuild your project and run your tests: + +[source,bash] +---- +mvn clean verify +---- + +If you have unrecognized configuration key warnings at startup, check for any remaining old-format properties. + +== New capabilities + +=== Name-only keys (short keys) + +Both HTTP and gRPC now accept simple name-only keys. +This covers the common case where there is no namespace or version ambiguity: + +[source,properties] +---- +# Works for both HTTP and gRPC +quarkus.flow.http.client.workflow.orders.name=secureA +quarkus.flow.grpc.client.orders.name=my-channel +---- + +=== Namespace and version-aware routing + +When multiple namespaces define workflows with the same name, use a more specific key: + +[source,properties] +---- +# Medium — namespace:name +quarkus.flow.http.client.workflow."acme:orders".name=secureA + +# Full — namespace:name:version +quarkus.flow.http.client.workflow."acme:orders:1.0.0".name=secureA +---- + +The same applies to gRPC and OIDC. + +== No other behavioral changes + +Workflow execution semantics, the Java DSL, lifecycle events, persistence, and messaging are unchanged. \ No newline at end of file diff --git a/grpc/runtime/src/main/java/io/quarkiverse/flow/config/FlowGrpcConfig.java b/grpc/runtime/src/main/java/io/quarkiverse/flow/config/FlowGrpcConfig.java index 462a98690..6f72b06b5 100644 --- a/grpc/runtime/src/main/java/io/quarkiverse/flow/config/FlowGrpcConfig.java +++ b/grpc/runtime/src/main/java/io/quarkiverse/flow/config/FlowGrpcConfig.java @@ -16,11 +16,14 @@ public interface FlowGrpcConfig { /** * gRPC client name overrides keyed by workflow or task identifier. *

- * Keys can be: + * Keys follow the unified naming convention: *

    - *
  • {@code :::} — task-level override
  • - *
  • {@code ::} — workflow-level override
  • - *
  • {@code :} — versionless override, applied to all versions of the workflow
  • + *
  • {@code } — workflow-level short (99% use case)
  • + *
  • {@code ":"} — workflow-level medium
  • + *
  • {@code "::"} — workflow-level full
  • + *
  • {@code ".task."} — task-level short
  • + *
  • {@code ":.task."} — task-level medium
  • + *
  • {@code "::.task."} — task-level full
  • *
* * @return the map of client overrides diff --git a/grpc/runtime/src/main/java/io/quarkiverse/flow/providers/GrpcChannelProvider.java b/grpc/runtime/src/main/java/io/quarkiverse/flow/providers/GrpcChannelProvider.java index dbdb7e4f5..29bfd0075 100644 --- a/grpc/runtime/src/main/java/io/quarkiverse/flow/providers/GrpcChannelProvider.java +++ b/grpc/runtime/src/main/java/io/quarkiverse/flow/providers/GrpcChannelProvider.java @@ -10,6 +10,8 @@ import org.slf4j.LoggerFactory; import io.grpc.Channel; +import io.quarkiverse.flow.config.ClientConfigCascade; +import io.quarkiverse.flow.config.ClientNamingConvention; import io.quarkiverse.flow.config.FlowGrpcConfig; import io.quarkiverse.flow.recorders.WorkflowApplicationBuilderCustomizer; import io.quarkus.arc.Arc; @@ -30,8 +32,6 @@ public class GrpcChannelProvider implements WorkflowApplicationBuilderCustomizer static final String DEFAULT_CHANNEL_NAME = "flowGrpc"; - private static final String KEY_SEPARATOR = ":"; - private static final Logger LOG = LoggerFactory.getLogger(GrpcChannelProvider.class); private final FlowGrpcConfig config = ConfigProvider.getConfig() @@ -56,41 +56,21 @@ private String resolveClientName(WorkflowDefinitionId workflowId, String taskNam static String resolveClientName(Map overrides, Predicate channelExists, WorkflowDefinitionId workflowId, String taskName) { - String workflowKey = workflowId.toString(KEY_SEPARATOR); - - // 1. Task-level override: namespace:name:version:taskName - if (taskName != null && !taskName.isBlank()) { - String taskOverride = overrideName(overrides, workflowKey + KEY_SEPARATOR + taskName); - if (taskOverride != null) { - return taskOverride; - } - } - - // 2. Workflow-level override: namespace:name:version - String workflowOverride = overrideName(overrides, workflowKey); - if (workflowOverride != null) { - return workflowOverride; - } - - // 3. Versionless workflow override: namespace:name (applies to all versions) - String versionlessOverride = overrideName(overrides, workflowId.namespace() + KEY_SEPARATOR + workflowId.name()); - if (versionlessOverride != null) { - return versionlessOverride; - } - // 4. Workflow ID as client name - if (channelExists.test(workflowKey)) { - return workflowKey; + // 6-level cascade (task full → task medium → task short → workflow full → workflow medium → workflow short) + String cascadeResult = ClientConfigCascade.resolve(key -> overrideName(overrides, key), workflowId, taskName); + if (cascadeResult != null) { + return cascadeResult; } - // 5. Default channel + // gRPC-specific: default channel if (channelExists.test(DEFAULT_CHANNEL_NAME)) { return DEFAULT_CHANNEL_NAME; } - // 6. SDK fallback + // SDK fallback LOG.debug("No Quarkus gRPC client configured for workflow '{}'; SDK will use its default channel", - workflowKey); + ClientNamingConvention.workflowKeyFull(workflowId)); return null; } diff --git a/grpc/runtime/src/test/java/io/quarkiverse/flow/providers/GrpcChannelProviderPriorityOrderTest.java b/grpc/runtime/src/test/java/io/quarkiverse/flow/providers/GrpcChannelProviderPriorityOrderTest.java index ead9fa3fb..18044b5cf 100644 --- a/grpc/runtime/src/test/java/io/quarkiverse/flow/providers/GrpcChannelProviderPriorityOrderTest.java +++ b/grpc/runtime/src/test/java/io/quarkiverse/flow/providers/GrpcChannelProviderPriorityOrderTest.java @@ -20,79 +20,112 @@ class GrpcChannelProviderPriorityOrderTest { private static final WorkflowDefinitionId WORKFLOW = new WorkflowDefinitionId("org.acme", "grpcGreeting", "0.0.1"); - private static final String TASK_KEY = "org.acme:grpcGreeting:0.0.1:greet"; - private static final String WORKFLOW_KEY = "org.acme:grpcGreeting:0.0.1"; - private static final String VERSIONLESS_KEY = "org.acme:grpcGreeting"; + private static final String TASK_FULL_KEY = "org.acme:grpcGreeting:0.0.1.task.greet"; + private static final String TASK_MEDIUM_KEY = "org.acme:grpcGreeting.task.greet"; + private static final String TASK_SHORT_KEY = "grpcGreeting.task.greet"; + private static final String WORKFLOW_FULL_KEY = "org.acme:grpcGreeting:0.0.1"; + private static final String WORKFLOW_MEDIUM_KEY = "org.acme:grpcGreeting"; + private static final String WORKFLOW_SHORT_KEY = "grpcGreeting"; private static FlowGrpcConfig.ClientOverrideConfig override(String name) { return () -> Optional.ofNullable(name); } - /** No Quarkus gRPC clients are registered. */ private static final Predicate NO_CHANNELS = name -> false; - /** Treats the given names as registered Quarkus gRPC clients. */ private static Predicate channels(String... names) { return Set.of(names)::contains; } @Nested - @DisplayName("config overrides") + @DisplayName("config overrides — 6-level cascade") class ConfigOverrides { @Test - @DisplayName("A task-level override takes precedence over workflow, versionless and channel-existence rules") - void task_override_wins_over_everything() { + @DisplayName("task full key wins over all other levels") + void task_full_wins() { Map overrides = Map.of( - TASK_KEY, override("taskClient"), - WORKFLOW_KEY, override("workflowClient"), - VERSIONLESS_KEY, override("versionlessClient")); + TASK_FULL_KEY, override("taskFullClient"), + TASK_MEDIUM_KEY, override("taskMediumClient"), + TASK_SHORT_KEY, override("taskShortClient"), + WORKFLOW_FULL_KEY, override("wfFullClient"), + WORKFLOW_MEDIUM_KEY, override("wfMediumClient"), + WORKFLOW_SHORT_KEY, override("wfShortClient")); - String resolved = resolveClientName(overrides, channels(WORKFLOW_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); + String resolved = resolveClientName(overrides, channels(WORKFLOW_FULL_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, + "greet"); - assertThat(resolved).isEqualTo("taskClient"); + assertThat(resolved).isEqualTo("taskFullClient"); } @Test - @DisplayName("A version-specific workflow override takes precedence over the versionless override") - void workflow_override_wins_over_versionless() { + @DisplayName("task medium key wins when full is absent") + void task_medium_wins() { Map overrides = Map.of( - WORKFLOW_KEY, override("workflowClient"), - VERSIONLESS_KEY, override("versionlessClient")); + TASK_MEDIUM_KEY, override("taskMediumClient"), + TASK_SHORT_KEY, override("taskShortClient"), + WORKFLOW_FULL_KEY, override("wfFullClient")); - String resolved = resolveClientName(overrides, channels(WORKFLOW_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); + String resolved = resolveClientName(overrides, NO_CHANNELS, WORKFLOW, "greet"); - assertThat(resolved).isEqualTo("workflowClient"); + assertThat(resolved).isEqualTo("taskMediumClient"); } @Test - @DisplayName("The versionless override is applied when no version-specific override exists") - void versionless_override_applies_when_no_version_specific_override() { + @DisplayName("task short key wins when full and medium are absent") + void task_short_wins() { Map overrides = Map.of( - VERSIONLESS_KEY, override("versionlessClient")); + TASK_SHORT_KEY, override("taskShortClient"), + WORKFLOW_FULL_KEY, override("wfFullClient")); - String resolved = resolveClientName(overrides, channels(WORKFLOW_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); + String resolved = resolveClientName(overrides, NO_CHANNELS, WORKFLOW, "greet"); - assertThat(resolved).isEqualTo("versionlessClient"); + assertThat(resolved).isEqualTo("taskShortClient"); } @Test - @DisplayName("An explicit versionless override wins over the workflow-id-named client and the default channel") - void versionless_override_wins_over_workflow_id_named_client_and_default() { + @DisplayName("workflow full key wins when no task overrides") + void workflow_full_wins() { Map overrides = Map.of( - VERSIONLESS_KEY, override("versionlessClient")); + WORKFLOW_FULL_KEY, override("wfFullClient"), + WORKFLOW_MEDIUM_KEY, override("wfMediumClient"), + WORKFLOW_SHORT_KEY, override("wfShortClient")); - // Both the workflow-id-named client and the default channel exist, but the explicit override wins. - String resolved = resolveClientName(overrides, channels(WORKFLOW_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); + String resolved = resolveClientName(overrides, channels(WORKFLOW_FULL_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, + "greet"); - assertThat(resolved).isEqualTo("versionlessClient"); + assertThat(resolved).isEqualTo("wfFullClient"); } @Test - @DisplayName("An override whose client name is absent is ignored and resolution continues") + @DisplayName("workflow medium key wins when full is absent") + void workflow_medium_wins() { + Map overrides = Map.of( + WORKFLOW_MEDIUM_KEY, override("wfMediumClient"), + WORKFLOW_SHORT_KEY, override("wfShortClient")); + + String resolved = resolveClientName(overrides, channels(WORKFLOW_FULL_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, + "greet"); + + assertThat(resolved).isEqualTo("wfMediumClient"); + } + + @Test + @DisplayName("workflow short key is the last override level") + void workflow_short_wins() { + Map overrides = Map.of( + WORKFLOW_SHORT_KEY, override("wfShortClient")); + + String resolved = resolveClientName(overrides, channels(DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); + + assertThat(resolved).isEqualTo("wfShortClient"); + } + + @Test + @DisplayName("an override whose client name is absent is ignored and resolution continues") void override_with_empty_name_is_ignored() { Map overrides = Map.of( - VERSIONLESS_KEY, override(null)); + WORKFLOW_SHORT_KEY, override(null)); String resolved = resolveClientName(overrides, channels(DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); @@ -105,23 +138,15 @@ void override_with_empty_name_is_ignored() { class ChannelExistenceFallbacks { @Test - @DisplayName("A client named after the workflow id wins over the default channel") - void workflow_id_named_client_wins_over_default_channel() { - String resolved = resolveClientName(Map.of(), channels(WORKFLOW_KEY, DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); - - assertThat(resolved).isEqualTo(WORKFLOW_KEY); - } - - @Test - @DisplayName("The default channel is used when no workflow-id-named client exists") - void default_channel_used_when_no_workflow_id_named_client() { + @DisplayName("the default channel is used when no override matches") + void default_channel_used_when_no_overrides() { String resolved = resolveClientName(Map.of(), channels(DEFAULT_CHANNEL_NAME), WORKFLOW, "greet"); assertThat(resolved).isEqualTo(DEFAULT_CHANNEL_NAME); } @Test - @DisplayName("Resolution returns null (SDK fallback) when no override or channel matches") + @DisplayName("resolution returns null (SDK fallback) when no override or channel matches") void sdk_fallback_returns_null_when_nothing_matches() { String resolved = resolveClientName(Map.of(), NO_CHANNELS, WORKFLOW, "greet"); @@ -130,11 +155,11 @@ void sdk_fallback_returns_null_when_nothing_matches() { } @Test - @DisplayName("A null task name skips the task-level override lookup and falls through to the workflow override") + @DisplayName("a null task name skips the task-level override lookup and falls through to the workflow override") void null_task_name_skips_task_level_override() { Map overrides = Map.of( - TASK_KEY, override("taskClient"), - WORKFLOW_KEY, override("workflowClient")); + TASK_FULL_KEY, override("taskClient"), + WORKFLOW_FULL_KEY, override("workflowClient")); String resolved = resolveClientName(overrides, NO_CHANNELS, WORKFLOW, null); diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/OidcNamingConvention.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/OidcNamingConvention.java index f56e93627..ac999fa1d 100644 --- a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/OidcNamingConvention.java +++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/OidcNamingConvention.java @@ -2,150 +2,64 @@ import java.util.Objects; +import io.quarkiverse.flow.config.ClientNamingConvention; import io.serverlessworkflow.api.types.TaskItem; import io.serverlessworkflow.api.types.Workflow; import io.serverlessworkflow.impl.WorkflowDefinitionId; /** - * Centralized naming conventions for OIDC client names and configuration keys. + * OIDC-specific naming conventions that delegate to {@link ClientNamingConvention}. *

- * Implements the unified client naming pattern from ADR 2026-07-07. - *

- * Client names (internal storage): {@code namespace:name:version.task.taskName} - *
- * Config keys (user-facing routing): Progressive specificity from short to full + * Retains the DSL-level overload {@link #clientName(Workflow, TaskItem)} for + * extracting identity from workflow/task objects. */ public final class OidcNamingConvention { - private static final String SEPARATOR = ":"; - private static final String TASK_SEGMENT = ".task."; - private OidcNamingConvention() { } - // ========== Client Names & Full Config Keys ========== - /** - * Generates full OIDC client name for inline task authentication. - *

- * This is also the full task-level config key per ADR 2026-07-07. - *

- * Format: {@code namespace:name:version.task.taskName} - *

- * Example: {@code acme:orders:1.0.0.task.payment} + * Generates full OIDC client name from DSL workflow and task objects. * * @param workflow the workflow * @param taskItem the task item - * @return composite client name (also used as full task config key) + * @return composite client name: {@code namespace:name:version.task.taskName} */ public static String clientName(Workflow workflow, TaskItem taskItem) { Objects.requireNonNull(workflow, "workflow is null"); Objects.requireNonNull(taskItem, "taskItem is null"); final WorkflowDefinitionId id = WorkflowDefinitionId.of(workflow); - return clientName(id, taskItem.getName()); + return ClientNamingConvention.taskKeyFull(id, taskItem.getName()); } - /** - * Generates full OIDC client name for inline task authentication. - *

- * This is also the full task-level config key per ADR 2026-07-07. - *

- * Format: {@code namespace:name:version.task.taskName} - *

- * Example: {@code acme:orders:1.0.0.task.payment} - * - * @param workflowId the workflow definition ID - * @param taskName the task name - * @return composite client name (also used as full task config key) - */ + /** @see ClientNamingConvention#taskKeyFull(WorkflowDefinitionId, String) */ public static String clientName(WorkflowDefinitionId workflowId, String taskName) { - Objects.requireNonNull(workflowId, "workflowId is null"); - Objects.requireNonNull(taskName, "taskName is null"); - - return workflowId.toString(SEPARATOR) + TASK_SEGMENT + taskName; + return ClientNamingConvention.taskKeyFull(workflowId, taskName); } - // ========== Progressive Config Keys (User-Facing Routing) ========== - - /** - * Task-level config key with medium specificity (no version). - *

- * Format: {@code namespace:name.task.taskName} - *

- * Example: {@code acme:orders.task.payment} - * - * @param workflowId the workflow definition ID - * @param taskName the task name - * @return medium task config key - */ + /** @see ClientNamingConvention#taskKeyMedium(WorkflowDefinitionId, String) */ public static String taskConfigKeyMedium(WorkflowDefinitionId workflowId, String taskName) { - Objects.requireNonNull(workflowId, "workflowId is null"); - Objects.requireNonNull(taskName, "taskName is null"); - - return workflowId.namespace() + SEPARATOR + workflowId.name() + TASK_SEGMENT + taskName; + return ClientNamingConvention.taskKeyMedium(workflowId, taskName); } - /** - * Task-level config key with short specificity (name only). - *

- * Format: {@code name.task.taskName} - *

- * Example: {@code orders.task.payment} - * - * @param workflowId the workflow definition ID - * @param taskName the task name - * @return short task config key - */ + /** @see ClientNamingConvention#taskKeyShort(WorkflowDefinitionId, String) */ public static String taskConfigKeyShort(WorkflowDefinitionId workflowId, String taskName) { - Objects.requireNonNull(workflowId, "workflowId is null"); - Objects.requireNonNull(taskName, "taskName is null"); - - return workflowId.name() + TASK_SEGMENT + taskName; + return ClientNamingConvention.taskKeyShort(workflowId, taskName); } - /** - * Workflow-level config key with full specificity. - *

- * Format: {@code namespace:name:version} - *

- * Example: {@code acme:orders:1.0.0} - * - * @param workflowId the workflow definition ID - * @return full workflow config key - */ + /** @see ClientNamingConvention#workflowKeyFull(WorkflowDefinitionId) */ public static String workflowConfigKeyFull(WorkflowDefinitionId workflowId) { - Objects.requireNonNull(workflowId, "workflowId is null"); - return workflowId.toString(SEPARATOR); + return ClientNamingConvention.workflowKeyFull(workflowId); } - /** - * Workflow-level config key with medium specificity (no version). - *

- * Format: {@code namespace:name} - *

- * Example: {@code acme:orders} - * - * @param workflowId the workflow definition ID - * @return medium workflow config key - */ + /** @see ClientNamingConvention#workflowKeyMedium(WorkflowDefinitionId) */ public static String workflowConfigKeyMedium(WorkflowDefinitionId workflowId) { - Objects.requireNonNull(workflowId, "workflowId is null"); - return workflowId.namespace() + SEPARATOR + workflowId.name(); + return ClientNamingConvention.workflowKeyMedium(workflowId); } - /** - * Workflow-level config key with short specificity (name only). - *

- * Format: {@code name} - *

- * Example: {@code orders} - * - * @param workflowId the workflow definition ID - * @return short workflow config key - */ + /** @see ClientNamingConvention#workflowKeyShort(WorkflowDefinitionId) */ public static String workflowConfigKeyShort(WorkflowDefinitionId workflowId) { - Objects.requireNonNull(workflowId, "workflowId is null"); - return workflowId.name(); + return ClientNamingConvention.workflowKeyShort(workflowId); } } diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/registry/OidcConfigResolver.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/registry/OidcConfigResolver.java index 8422ce139..eecea577d 100644 --- a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/registry/OidcConfigResolver.java +++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/registry/OidcConfigResolver.java @@ -7,8 +7,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import io.quarkiverse.flow.config.ClientConfigCascade; import io.quarkiverse.flow.oidc.FlowOidcConfig; -import io.quarkiverse.flow.oidc.OidcNamingConvention; import io.quarkus.arc.Unremovable; import io.quarkus.oidc.client.runtime.OidcClientConfig; import io.quarkus.oidc.client.runtime.OidcClientsConfig; @@ -81,53 +81,14 @@ private static Duration overrideConnectionTimeout(Map resolveOidcClientName(WorkflowDefinitionId workflowId, String taskName, String authPolicyName) { Map clients = config.client(); - // Task-level overrides (progressive specificity) - if (taskName != null && !taskName.isBlank()) { - // 1. Task-level full: namespace:name:version.task.taskName - String taskFull = overrideName(clients, OidcNamingConvention.clientName(workflowId, taskName)); - if (taskFull != null) { - validateClientName(taskFull); - return Optional.of(taskFull); - } - - // 2. Task-level medium: namespace:name.task.taskName - String taskMedium = overrideName(clients, OidcNamingConvention.taskConfigKeyMedium(workflowId, taskName)); - if (taskMedium != null) { - validateClientName(taskMedium); - return Optional.of(taskMedium); - } - - // 3. Task-level short: name.task.taskName - String taskShort = overrideName(clients, OidcNamingConvention.taskConfigKeyShort(workflowId, taskName)); - if (taskShort != null) { - validateClientName(taskShort); - return Optional.of(taskShort); - } - } - - // Workflow-level overrides (progressive specificity) - // 4. Workflow-level full: namespace:name:version - String workflowFull = overrideName(clients, OidcNamingConvention.workflowConfigKeyFull(workflowId)); - if (workflowFull != null) { - validateClientName(workflowFull); - return Optional.of(workflowFull); - } - - // 5. Workflow-level medium: namespace:name - String workflowMedium = overrideName(clients, OidcNamingConvention.workflowConfigKeyMedium(workflowId)); - if (workflowMedium != null) { - validateClientName(workflowMedium); - return Optional.of(workflowMedium); + // 6-level cascade (task full → task medium → task short → workflow full → workflow medium → workflow short) + String cascadeResult = ClientConfigCascade.resolve(key -> overrideName(clients, key), workflowId, taskName); + if (cascadeResult != null) { + validateClientName(cascadeResult); + return Optional.of(cascadeResult); } - // 6. Workflow-level short: name - String workflowShort = overrideName(clients, OidcNamingConvention.workflowConfigKeyShort(workflowId)); - if (workflowShort != null) { - validateClientName(workflowShort); - return Optional.of(workflowShort); - } - - // 7. Named authentication policy (e.g. use("keycloak")) + // Level 7: Named authentication policy (OIDC-specific) if (authPolicyName != null && !authPolicyName.isBlank()) { String namedName = overrideName(clients, authPolicyName); if (namedName != null) { @@ -136,7 +97,6 @@ public Optional resolveOidcClientName(WorkflowDefinitionId workflowId, S } } - // 8. No override — DSL fallback return Optional.empty(); } @@ -152,49 +112,14 @@ public Optional resolveOidcClientName(WorkflowDefinitionId workflowId, S public Duration resolveCreationTimeout(WorkflowDefinitionId workflowId, String taskName, String authPolicyName) { Map clients = config.client(); - // Task-level overrides (progressive specificity) - if (taskName != null && !taskName.isBlank()) { - // 1. Task-level full - Duration taskFull = overrideCreationTimeout(clients, OidcNamingConvention.clientName(workflowId, taskName)); - if (taskFull != null) { - return taskFull; - } - - // 2. Task-level medium - Duration taskMedium = overrideCreationTimeout(clients, - OidcNamingConvention.taskConfigKeyMedium(workflowId, taskName)); - if (taskMedium != null) { - return taskMedium; - } - - // 3. Task-level short - Duration taskShort = overrideCreationTimeout(clients, - OidcNamingConvention.taskConfigKeyShort(workflowId, taskName)); - if (taskShort != null) { - return taskShort; - } + // 6-level cascade + Duration cascadeResult = ClientConfigCascade.resolve(key -> overrideCreationTimeout(clients, key), workflowId, + taskName); + if (cascadeResult != null) { + return cascadeResult; } - // Workflow-level overrides (progressive specificity) - // 4. Workflow-level full - Duration workflowFull = overrideCreationTimeout(clients, OidcNamingConvention.workflowConfigKeyFull(workflowId)); - if (workflowFull != null) { - return workflowFull; - } - - // 5. Workflow-level medium - Duration workflowMedium = overrideCreationTimeout(clients, OidcNamingConvention.workflowConfigKeyMedium(workflowId)); - if (workflowMedium != null) { - return workflowMedium; - } - - // 6. Workflow-level short - Duration workflowShort = overrideCreationTimeout(clients, OidcNamingConvention.workflowConfigKeyShort(workflowId)); - if (workflowShort != null) { - return workflowShort; - } - - // 7. Named authentication policy + // Level 7: Named authentication policy if (authPolicyName != null && !authPolicyName.isBlank()) { Duration namedTimeout = overrideCreationTimeout(clients, authPolicyName); if (namedTimeout != null) { @@ -202,7 +127,6 @@ public Duration resolveCreationTimeout(WorkflowDefinitionId workflowId, String t } } - // 8. No override — global default return config.creationTimeout(); } @@ -221,49 +145,14 @@ public Duration resolveCreationTimeout(WorkflowDefinitionId workflowId, String t public Duration resolveConnectionTimeout(WorkflowDefinitionId workflowId, String taskName, String authPolicyName) { Map clients = config.client(); - // Task-level overrides (progressive specificity) - if (taskName != null && !taskName.isBlank()) { - // 1. Task-level full - Duration taskFull = overrideConnectionTimeout(clients, OidcNamingConvention.clientName(workflowId, taskName)); - if (taskFull != null) { - return taskFull; - } - - // 2. Task-level medium - Duration taskMedium = overrideConnectionTimeout(clients, - OidcNamingConvention.taskConfigKeyMedium(workflowId, taskName)); - if (taskMedium != null) { - return taskMedium; - } - - // 3. Task-level short - Duration taskShort = overrideConnectionTimeout(clients, - OidcNamingConvention.taskConfigKeyShort(workflowId, taskName)); - if (taskShort != null) { - return taskShort; - } - } - - // Workflow-level overrides (progressive specificity) - // 4. Workflow-level full - Duration workflowFull = overrideConnectionTimeout(clients, OidcNamingConvention.workflowConfigKeyFull(workflowId)); - if (workflowFull != null) { - return workflowFull; - } - - // 5. Workflow-level medium - Duration workflowMedium = overrideConnectionTimeout(clients, OidcNamingConvention.workflowConfigKeyMedium(workflowId)); - if (workflowMedium != null) { - return workflowMedium; - } - - // 6. Workflow-level short - Duration workflowShort = overrideConnectionTimeout(clients, OidcNamingConvention.workflowConfigKeyShort(workflowId)); - if (workflowShort != null) { - return workflowShort; + // 6-level cascade + Duration cascadeResult = ClientConfigCascade.resolve(key -> overrideConnectionTimeout(clients, key), workflowId, + taskName); + if (cascadeResult != null) { + return cascadeResult; } - // 7. Named authentication policy + // Level 7: Named authentication policy if (authPolicyName != null && !authPolicyName.isBlank()) { Duration namedTimeout = overrideConnectionTimeout(clients, authPolicyName); if (namedTimeout != null) { @@ -271,7 +160,6 @@ public Duration resolveConnectionTimeout(WorkflowDefinitionId workflowId, String } } - // 8. No override — global default return config.connectionTimeout(); }