christophd commented on code in PR #27440: URL: https://github.com/apache/camel/pull/27440#discussion_r4198606999
########## components/camel-openfeature/src/main/java/org/apache/camel/language/openfeature/OpenFeatureLanguage.java: ########## @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.language.openfeature; + +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.Expression; +import org.apache.camel.Predicate; +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.openfeature.OpenFeatureConstants; +import org.apache.camel.component.openfeature.OpenFeatureEndpoint; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.annotations.Language; +import org.apache.camel.support.ExpressionAdapter; +import org.apache.camel.support.ExpressionToPredicateAdapter; +import org.apache.camel.support.LanguageSupport; + +/** Evaluates a feature flag as a boolean predicate for use in EIP constructs such as filter, choice, and validate. */ +@Language(value = "openfeature", modelName = "language") +@Metadata(title = "OpenFeature", description = "Evaluate a feature flag as a boolean predicate", + label = "language,cloud", firstVersion = "4.23.0") +public class OpenFeatureLanguage extends LanguageSupport { + + private String endpoint = "openfeature:flags"; + + @Override + public Predicate createPredicate(String expression) { + return createPredicate(expression, null); + } + + @SuppressWarnings("unchecked") + @Override + public Predicate createPredicate(String expression, Object[] properties) { + validateExpression(expression); + Evaluation answer = new Evaluation( + expression, + property(String.class, properties, 0, endpoint), + property(String.class, properties, 1, null), + property(Map.class, properties, 2, null), + property(String.class, properties, 3, "boolean")); + if (getCamelContext() != null) { + answer.init(getCamelContext()); + } + return ExpressionToPredicateAdapter.toPredicate(answer); + } + + @Override + public Expression createExpression(String expression) { + return createExpression(expression, new Object[] {}); + } + + @SuppressWarnings("unchecked") + @Override + public ExpressionAdapter createExpression(String expression, Object[] properties) { + validateExpression(expression); + Evaluation answer = new Evaluation( + expression, + property(String.class, properties, 0, endpoint), + property(String.class, properties, 1, null), + property(Map.class, properties, 2, null), + property(String.class, properties, 3, "variant")); + if (getCamelContext() != null) { + answer.init(getCamelContext()); + } + return answer; + } + + public boolean validateExpression(String expression) { + if (expression == null || expression.isBlank()) { + throw new IllegalArgumentException("OpenFeature flag key must not be null or blank"); + } + return true; + } + + public String getEndpoint() { + return endpoint; + } + + /** Managed endpoint URI. Defaults to openfeature:flags, inheriting camel.component.openfeature settings. */ + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + private static final class Evaluation extends ExpressionAdapter { + private final String flagKey; + private final String endpointUri; + private final String targetingKey; + private final Map<String, Object> contextMap; + private OpenFeatureEndpoint endpoint; + private boolean useFlagKeyHeader; + private String evaluationType; + + private Evaluation(String flagKey, String endpointUri, String targetingKey, + Map<String, Object> contextMap, String evaluationType) { + this.flagKey = flagKey; + this.endpointUri = endpointUri; + this.targetingKey = targetingKey; + this.contextMap = contextMap; + this.evaluationType = evaluationType; + } + + @Override + public void init(CamelContext context) { + super.init(context); + if (endpointUri == null || !endpointUri.startsWith("openfeature:")) { + throw new IllegalArgumentException("OpenFeature language endpoint must be an openfeature: URI"); + } + String uri = endpointUri; + if (uri.contains("flagKey=")) { + useFlagKeyHeader = true; + } else { + uri += (uri.contains("?") ? "&" : "?") + "flagKey=" + flagKey; + useFlagKeyHeader = false; + } + endpoint = context.getEndpoint(uri, OpenFeatureEndpoint.class); + } + + @Override + public <T> T evaluate(Exchange exchange, Class<T> type) { + if (Boolean.class.isAssignableFrom(type)) { + evaluationType = "boolean"; Review Comment: Fixed. `evaluationType` is now `final`. `evaluate(Exchange, Class<T>)` uses a local `localEvalType` variable instead of mutating the shared field, so concurrent callers and later calls are not affected. ########## components/camel-openfeature/src/main/java/org/apache/camel/component/openfeature/OpenFeatureEndpoint.java: ########## @@ -0,0 +1,295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.openfeature; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.MutableContext; +import org.apache.camel.Category; +import org.apache.camel.Consumer; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.Producer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriEndpoint; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriPath; +import org.apache.camel.support.DefaultEndpoint; +import org.apache.camel.support.ResourceHelper; + +/** Evaluate feature flags using the OpenFeature specification with flagd. */ +@UriEndpoint(firstVersion = "4.23.0", scheme = "openfeature", title = "OpenFeature", syntax = "openfeature:domain", + producerOnly = true, category = { Category.CORE }) +public class OpenFeatureEndpoint extends DefaultEndpoint { + + private static final String DEFAULT_PROVIDER_BEAN = "flags"; + + @UriPath + @Metadata(required = true) + private String domain; + + @UriParam + private OpenFeatureConfiguration configuration; + + private volatile Client client; + private volatile FeatureProvider resolvedProvider; + private volatile File tempFlagFile; + private volatile boolean ownedProvider; + + public OpenFeatureEndpoint(String uri, OpenFeatureComponent component, String domain, + OpenFeatureConfiguration configuration) { + super(uri, component); + this.domain = domain; + this.configuration = configuration; + } + + @Override + public OpenFeatureComponent getComponent() { + return (OpenFeatureComponent) super.getComponent(); + } + + @Override + public Producer createProducer() { + return new OpenFeatureProducer(this); + } + + @Override + public Consumer createConsumer(Processor processor) { + throw new UnsupportedOperationException("OpenFeature is producer only"); + } + + @Override + protected void doStart() throws Exception { + super.doStart(); + configuration.validate(); + + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("domain must not be blank"); + } + + FeatureProvider provider = resolveProvider(); + resolvedProvider = provider; + client = getComponent().registerProviderAndGetClient(domain, provider); + } + + @Override + protected void doStop() throws Exception { + client = null; + + if (ownedProvider && resolvedProvider != null) { + resolvedProvider.shutdown(); + } + resolvedProvider = null; + + File tmp = tempFlagFile; + tempFlagFile = null; + if (tmp != null) { + Files.deleteIfExists(tmp.toPath()); + } + + super.doStop(); + } + + @SuppressWarnings("unchecked") + MutableContext buildContext(Exchange exchange) { + MutableContext ctx = new MutableContext(); + + // 1. CamelOpenFeatureEvaluationContext header + Object contextHeader = exchange.getMessage().getHeader(OpenFeatureConstants.EVALUATION_CONTEXT); + if (contextHeader == null) { + contextHeader = exchange.getProperty(OpenFeatureConstants.EVALUATION_CONTEXT); + } + if (contextHeader instanceof Map) { + addMapToContext(ctx, (Map<String, Object>) contextHeader); + } else { + // Fallback to body map entries + Object body = exchange.getMessage().getBody(); + if (body instanceof Map) { + addMapToContext(ctx, (Map<String, Object>) body); + } + } + + // 2. CamelOpenFeatureTargetingKey header or exchange property + String targetingKey = exchange.getMessage().getHeader(OpenFeatureConstants.TARGETING_KEY, String.class); + if (targetingKey == null) { + targetingKey = exchange.getProperty(OpenFeatureConstants.TARGETING_KEY, String.class); + } + if (targetingKey != null) { + ctx.setTargetingKey(targetingKey); + } + + return ctx; + } + + private static void addMapToContext(MutableContext ctx, Map<String, Object> map) { + for (Map.Entry<String, Object> entry : map.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + if ("targetingKey".equals(key)) { + ctx.setTargetingKey(value); + } else { + ctx.add(key, value); + } + } + } + + String resolveFlagKey(Exchange exchange) { + String key = exchange.getMessage().getHeader(OpenFeatureConstants.FLAG_KEY, String.class); + if (key == null) { + key = exchange.getProperty(OpenFeatureConstants.FLAG_KEY, String.class); + } + if (key == null) { + key = configuration.getFlagKey(); + } + if (key == null || key.isBlank()) { + throw new IllegalArgumentException( + "No flag key specified. Set flagKey on the endpoint or provide it via the " + + OpenFeatureConstants.FLAG_KEY + " header."); + } + return key; + } + + public Object evaluate(Exchange exchange) { + Client c = client; + if (c == null) { + throw new IllegalStateException("OpenFeature endpoint is not started"); + } + String flagKey = resolveFlagKey(exchange); + MutableContext ctx = buildContext(exchange); + + if (configuration.isBooleanEvaluation(exchange, ctx)) { + boolean defaultVal = Boolean.parseBoolean(configuration.getDefaultValue()); + return c.getBooleanValue(flagKey, defaultVal, ctx); + } else { + return c.getStringValue(flagKey, configuration.getDefaultValue(), ctx); + } + } + + private FeatureProvider resolveProvider() throws IOException { + // 1. Explicit provider bean reference + String providerRef = configuration.getProvider(); + if (providerRef != null) { + String beanName = providerRef.startsWith("#") ? providerRef.substring(1) : providerRef; + FeatureProvider provider = getCamelContext().getRegistry() + .lookupByNameAndType(beanName, FeatureProvider.class); + if (provider == null) { + throw new IllegalArgumentException( + "No FeatureProvider bean found in the registry with name: " + beanName); + } + ownedProvider = false; + return provider; + } + + // 2. Registry lookup for default bean + FeatureProvider defaultProvider = getCamelContext().getRegistry() + .lookupByNameAndType(DEFAULT_PROVIDER_BEAN, FeatureProvider.class); + if (defaultProvider != null) { + ownedProvider = false; + return defaultProvider; + } + + // 3. Fallback to FlagdProvider + ownedProvider = true; + return createFlagdProvider(); + } + + private FeatureProvider createFlagdProvider() throws IOException { + if (configuration.getFlags() != null) { + return createFileProvider(configuration.getFlags()); + } + if (configuration.getFlagsResource() != null) { + String content = loadResource(configuration.getFlagsResource()); + return createFileProvider(content); + } + if (configuration.getHost() != null) { + FlagdOptions options = FlagdOptions.builder() + .host(configuration.getHost()) + .port(configuration.getPort()) + .build(); + return new FlagdProvider(options); + } + throw new IllegalArgumentException( + "No provider found. Set a provider bean reference, register a '" + DEFAULT_PROVIDER_BEAN + + "' bean of type FeatureProvider, or configure flags, flagsResource, or host for the default flagd provider."); + } + + private FlagdProvider createFileProvider(String flagContent) throws IOException { + File tmp = File.createTempFile("camel-openfeature-", ".json"); Review Comment: Fixed both. Uses `Files.createTempFile` for owner-only permissions (CWE-732). For local file paths (`file:` URI or absolute path), a new `createFileProviderFromPath` method passes the path directly to flagd's `offlineFlagSourcePath`, keeping live reload working. The temp-file copy is now only used for `classpath:`, inline flags, and remote resources. ########## components/camel-openfeature/src/main/java/org/apache/camel/component/openfeature/OpenFeatureEndpoint.java: ########## @@ -0,0 +1,295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.openfeature; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.MutableContext; +import org.apache.camel.Category; +import org.apache.camel.Consumer; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.Producer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriEndpoint; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriPath; +import org.apache.camel.support.DefaultEndpoint; +import org.apache.camel.support.ResourceHelper; + +/** Evaluate feature flags using the OpenFeature specification with flagd. */ +@UriEndpoint(firstVersion = "4.23.0", scheme = "openfeature", title = "OpenFeature", syntax = "openfeature:domain", + producerOnly = true, category = { Category.CORE }) Review Comment: Fixed. Changed to `category = { Category.CLOUD }` and added `headersClass = OpenFeatureConstants.class`. Added three new header constants: `EVALUATION_VARIANT`, `EVALUATION_REASON`, `EVALUATION_ERROR_CODE`. Removed the hand-written headers table from the adoc since the catalog now auto-generates it. ########## components/camel-openfeature/src/main/java/org/apache/camel/component/openfeature/OpenFeatureEndpoint.java: ########## @@ -0,0 +1,295 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.openfeature; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.MutableContext; +import org.apache.camel.Category; +import org.apache.camel.Consumer; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.Producer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriEndpoint; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriPath; +import org.apache.camel.support.DefaultEndpoint; +import org.apache.camel.support.ResourceHelper; + +/** Evaluate feature flags using the OpenFeature specification with flagd. */ +@UriEndpoint(firstVersion = "4.23.0", scheme = "openfeature", title = "OpenFeature", syntax = "openfeature:domain", + producerOnly = true, category = { Category.CORE }) +public class OpenFeatureEndpoint extends DefaultEndpoint { + + private static final String DEFAULT_PROVIDER_BEAN = "flags"; + + @UriPath + @Metadata(required = true) + private String domain; + + @UriParam + private OpenFeatureConfiguration configuration; + + private volatile Client client; + private volatile FeatureProvider resolvedProvider; + private volatile File tempFlagFile; + private volatile boolean ownedProvider; + + public OpenFeatureEndpoint(String uri, OpenFeatureComponent component, String domain, + OpenFeatureConfiguration configuration) { + super(uri, component); + this.domain = domain; + this.configuration = configuration; + } + + @Override + public OpenFeatureComponent getComponent() { + return (OpenFeatureComponent) super.getComponent(); + } + + @Override + public Producer createProducer() { + return new OpenFeatureProducer(this); + } + + @Override + public Consumer createConsumer(Processor processor) { + throw new UnsupportedOperationException("OpenFeature is producer only"); + } + + @Override + protected void doStart() throws Exception { + super.doStart(); + configuration.validate(); + + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("domain must not be blank"); + } + + FeatureProvider provider = resolveProvider(); + resolvedProvider = provider; + client = getComponent().registerProviderAndGetClient(domain, provider); + } + + @Override + protected void doStop() throws Exception { + client = null; + + if (ownedProvider && resolvedProvider != null) { + resolvedProvider.shutdown(); + } + resolvedProvider = null; + + File tmp = tempFlagFile; + tempFlagFile = null; + if (tmp != null) { + Files.deleteIfExists(tmp.toPath()); + } + + super.doStop(); + } + + @SuppressWarnings("unchecked") + MutableContext buildContext(Exchange exchange) { + MutableContext ctx = new MutableContext(); + + // 1. CamelOpenFeatureEvaluationContext header + Object contextHeader = exchange.getMessage().getHeader(OpenFeatureConstants.EVALUATION_CONTEXT); + if (contextHeader == null) { + contextHeader = exchange.getProperty(OpenFeatureConstants.EVALUATION_CONTEXT); + } + if (contextHeader instanceof Map) { + addMapToContext(ctx, (Map<String, Object>) contextHeader); + } else { + // Fallback to body map entries + Object body = exchange.getMessage().getBody(); + if (body instanceof Map) { + addMapToContext(ctx, (Map<String, Object>) body); + } + } + + // 2. CamelOpenFeatureTargetingKey header or exchange property + String targetingKey = exchange.getMessage().getHeader(OpenFeatureConstants.TARGETING_KEY, String.class); + if (targetingKey == null) { + targetingKey = exchange.getProperty(OpenFeatureConstants.TARGETING_KEY, String.class); + } + if (targetingKey != null) { + ctx.setTargetingKey(targetingKey); + } + + return ctx; + } + + private static void addMapToContext(MutableContext ctx, Map<String, Object> map) { + for (Map.Entry<String, Object> entry : map.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + if ("targetingKey".equals(key)) { + ctx.setTargetingKey(value); + } else { + ctx.add(key, value); + } + } + } + + String resolveFlagKey(Exchange exchange) { + String key = exchange.getMessage().getHeader(OpenFeatureConstants.FLAG_KEY, String.class); + if (key == null) { + key = exchange.getProperty(OpenFeatureConstants.FLAG_KEY, String.class); + } + if (key == null) { + key = configuration.getFlagKey(); + } + if (key == null || key.isBlank()) { + throw new IllegalArgumentException( + "No flag key specified. Set flagKey on the endpoint or provide it via the " + + OpenFeatureConstants.FLAG_KEY + " header."); + } + return key; + } + + public Object evaluate(Exchange exchange) { + Client c = client; + if (c == null) { + throw new IllegalStateException("OpenFeature endpoint is not started"); + } + String flagKey = resolveFlagKey(exchange); + MutableContext ctx = buildContext(exchange); + + if (configuration.isBooleanEvaluation(exchange, ctx)) { + boolean defaultVal = Boolean.parseBoolean(configuration.getDefaultValue()); + return c.getBooleanValue(flagKey, defaultVal, ctx); Review Comment: Fixed. The producer now uses `getBooleanDetails`/`getStringDetails` and exposes `variant`, `reason`, and `errorCode` as response headers (`CamelOpenFeatureEvaluationVariant`, `CamelOpenFeatureEvaluationReason`, `CamelOpenFeatureEvaluationErrorCode`). When `errorCode` is non-null, a WARN is logged with the flag key and error details. Added `testEvaluationDetailsHeaders` test verifying headers are set on a successful evaluation. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
