Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optional property in ConfigMapping becomes mandatory #36150

Closed
juan-antonio-alba-db opened this issue Sep 26, 2023 · 13 comments
Closed

Optional property in ConfigMapping becomes mandatory #36150

juan-antonio-alba-db opened this issue Sep 26, 2023 · 13 comments
Labels
area/config kind/bug Something isn't working

Comments

@juan-antonio-alba-db
Copy link

juan-antonio-alba-db commented Sep 26, 2023

Describe the bug

Migrating to 3.2.6.Final from 2.16.8 we have detected that Optional properties in a ConfigMapping are becoming mandatory

ERROR [io.qua.run.Application] (Quarkus Main Thread) Failed to start application (with profile [dev]): java.lang.RuntimeException: Failed to start quarkus
	at io.quarkus.runner.ApplicationImpl.doStart(Unknown Source)
	at io.quarkus.runtime.Application.start(Application.java:101)
	at io.quarkus.runtime.ApplicationLifecycleManager.run(ApplicationLifecycleManager.java:111)
	at io.quarkus.runtime.Quarkus.run(Quarkus.java:71)
	at io.quarkus.runtime.Quarkus.run(Quarkus.java:44)
	at io.quarkus.runtime.Quarkus.run(Quarkus.java:124)
	at io.quarkus.runner.GeneratedMain.main(Unknown Source)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
	at io.quarkus.runner.bootstrap.StartupActionImpl$1.run(StartupActionImpl.java:104)
	at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: io.smallrye.config.ConfigValidationException: Configuration validation failed:
	java.util.NoSuchElementException: SRCFG00014: The config property connection.server.host is required but it could not be found in any config source
	at io.smallrye.config.ConfigMappingProvider.mapConfigurationInternal(ConfigMappingProvider.java:1003)
	at io.smallrye.config.ConfigMappingProvider.lambda$mapConfiguration$3(ConfigMappingProvider.java:959)
	at io.smallrye.config.SecretKeys.doUnlocked(SecretKeys.java:28)
	at io.smallrye.config.ConfigMappingProvider.mapConfiguration(ConfigMappingProvider.java:959)
	at io.smallrye.config.ConfigMappings.mapConfiguration(ConfigMappings.java:91)
	at io.smallrye.config.SmallRyeConfigBuilder.build(SmallRyeConfigBuilder.java:630)
	at io.quarkus.runtime.generated.Config.readConfig(Unknown Source)
	at io.quarkus.deployment.steps.RuntimeConfigSetup.deploy(Unknown Source)  

Expected behavior

Optional property should not be mandatory to start up the quarkus application

Actual behavior

Error is raised because the property is not provided

How to Reproduce?

Reproduce

  1. Create ConfigMapping like in a quarkus application
@ConfigMapping(prefix = "connection")
public interface Connection {
    Optional<Server> server();
}
  1. Create Server interface to declare some properties
public interface Server {
    String host();

   @WithDefault("8080")
    String port();
}
  1. Make use of the Connection in the quarkus app, and try to start up the application which will raise the error

  2. The problem appears when adding @WithDefault("8080") , in case of removing this annotation the error disappears but then there is not default.

Output of uname -a or ver

No response

Output of java -version

java version "17.0.7" 2023-04-18 LTS

GraalVM version (if different from Java)

No response

Quarkus version or git rev

3.2.6.Final

Build tool (ie. output of mvnw --version or gradlew --version)

Maven home: /tools/apache-maven-3.8.2 Java version: 17.0.7, vendor: Oracle Corporation, runtime:

Additional information

No response

@geoand
Copy link
Contributor

geoand commented Sep 26, 2023

cc @radcortez

@geoand
Copy link
Contributor

geoand commented Sep 26, 2023

@juan-antonio-alba-db have you also tried with Quarkus 3.4.1?

@juan-antonio-alba-db
Copy link
Author

juan-antonio-alba-db commented Sep 26, 2023

Yep, still happening in this version

[INFO] -------------------------< my-groupId:my-test >-------------------------
[INFO] Building my-test 1.0.0-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- quarkus-maven-plugin:3.4.1:dev (default-cli) @ my-test ---
[INFO] Invoking resources:2.6:resources (default-resources) @ my-test
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Copying 2 resources
[INFO] Invoking quarkus:3.4.1:generate-code (default) @ my-test
[INFO] Invoking compiler:3.11.0:compile (default-compile) @ my-test
[INFO] Nothing to compile - all classes are up to date
[INFO] Invoking resources:2.6:testResources (default-testResources) @ my-test
[INFO] Invoking quarkus:3.4.1:generate-code-tests (default) @ my-test
[INFO] Invoking compiler:3.11.0:testCompile (default-testCompile) @ my-test
[INFO] Nothing to compile - all classes are up to date


--- Help improve Quarkus ---

Listening for transport dt_socket at address: 5005
2023-09-26 12:15:14,680 ERROR [io.qua.run.Application] (Quarkus Main Thread) Failed to start application (with profile [dev]): java.lang.RuntimeException: Failed to start quarkus
at io.quarkus.runner.ApplicationImpl.doStart(Unknown Source)
at io.quarkus.runtime.Application.start(Application.java:101)
at io.quarkus.runtime.ApplicationLifecycleManager.run(ApplicationLifecycleManager.java:111)
at io.quarkus.runtime.Quarkus.run(Quarkus.java:71)
at io.quarkus.runtime.Quarkus.run(Quarkus.java:44)
at io.quarkus.runtime.Quarkus.run(Quarkus.java:124)
at io.quarkus.runner.GeneratedMain.main(Unknown Source)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at io.quarkus.runner.bootstrap.StartupActionImpl$1.run(StartupActionImpl.java:113)
at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: io.smallrye.config.ConfigValidationException: Configuration validation failed:
java.util.NoSuchElementException: SRCFG00014: The config property config.server.host is required but it could not be found in any config source

@manofthepeace
Copy link
Contributor

I had that discussion with @radcortez a little while back about this. This is actually the desired behaviour. The optional does get validated because of theWithDefault("8080"), which does actually set the value in the optional object, which in fact does not make it optional anymore. removing the default would make it work ok. To alleviate that you can set the port one level up outside the optional and use @WithName so set it to the right structure.

see discussion here; smallrye/smallrye-config#945 (comment)

@radcortez
Copy link
Member

I had that discussion with @radcortez a little while back about this. This is actually the desired behaviour. The optional does get validated because of theWithDefault("8080"), which does actually set the value in the optional object, which in fact does not make it optional anymore. removing the default would make it work ok. To alleviate that you can set the port one level up outside the optional and use @WithName so set it to the right structure.

see discussion here; smallrye/smallrye-config#945 (comment)

Correct! Thank you for the explanation @manofthepeace :)

What marks the Optional as being mandatory is the WithDefault annotation. In a previous version, the @WithDefault was only applied to complete the structure, if some other field in the Optional tree was also set, but this prevented retrieving the default. If we think about it, an Optional and a default are opposing concepts, so a @WithDefault in a Optional triggers the build of the Config tree.

@radcortez radcortez closed this as not planned Won't fix, can't repro, duplicate, stale Sep 26, 2023
@marcelstoer
Copy link
Contributor

marcelstoer commented Nov 22, 2023

If we think about it, an Optional and a default are opposing concepts

I did - and came to a different conclusion.

This is a major issue for us. We look at this from a top-down perspective in terms of the config tree. Given the OP's example, we see this as "If the connection.server branch is missing, ignore anything that defined its shape or form". Hence, we expect @WithDefault to be only effective if connection.server is defined somewhere. If the config branch is not there, it's irrelevant what it would look like if it were there.

We are currently brooding about strategies to mitigate the pain of migrating to v3.

@marcelstoer
Copy link
Contributor

Btw, if you work with Kotlin you might be able to work around this with the following beast. Fighting ugliness with even more ugliness.

interface Server {
  fun host(): String
  @WithName("port")
  fun portWithoutDefault(): Optional<Int>
}

fun Server.port(): Int = this.portWithoutDefault().orElse(8080)

@marcelstoer
Copy link
Contributor

To complement my short snippet above here's a full reproducer including our ugly workaround with two unit tests in Kotlin.

import io.smallrye.config.ConfigMapping
import io.smallrye.config.SmallRyeConfigBuilder
import io.smallrye.config.WithName
import io.smallrye.config.source.yaml.YamlConfigSource
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.Optional

class ConfigMappingTest {
    @Test
    fun `should map config despite missing child branch`() {
        // given

        // when
        val parent: Parent = SmallRyeConfigBuilder()
            .withSources(
                YamlConfigSource(
                    "test", """
                            parent:
                              name: parent
                            """
                )
            )
            .addDefaultInterceptors()
            .withMapping(Parent::class.java)
            .build().getConfigMapping(Parent::class.java)

        // then
        assertThat(parent.child()).isEmpty
    }
    @Test
    fun `should map config with default child age`() {
        // given

        // when
        val parent: Parent = SmallRyeConfigBuilder()
            .withSources(
                YamlConfigSource(
                    "test", """
                            parent:
                              name: parent
                              child:
                                name: child
                            """
                )
            )
            .addDefaultInterceptors()
            .withMapping(Parent::class.java)
            .build().getConfigMapping(Parent::class.java)

        // then
        assertThat(parent.child().get()._age()).isEmpty
        assertThat(parent.child().get().age()).isEqualTo(12)
        assertThat(parent.child().get().hobbies()).isEmpty()
    }
}

@ConfigMapping(prefix = "parent")
interface Parent {
    fun name(): String
    fun child(): Optional<Child>

    interface Child {
        fun name(): String

        // This fails due to https://github.com/quarkusio/quarkus/issues/36150.
//        @WithDefault("12")
//        fun age(): Int

        @WithName("age")
        fun _age(): Optional<Int>

        // Contrary to what you might expect, you do NOT get an empty set if no properties are configured.
        // If you need to support that, you have to map to an Optional<Set<String>>.
//        fun hobbies(): Set<String>

        @WithName("hobbies")
        fun _hobbies(): Optional<Set<String>>

        // Maps are different from lists and sets (i.e. collections for indexed properties).
        // If no properties are configured, you'll get an empty map!
        fun grades(): Map<String, Float>
    }
}

fun Parent.Child.age(): Int = this._age().orElse(12)
fun Parent.Child.hobbies(): Set<String> = this._hobbies().orElse(setOf())

Thinking about this some more, it occurred to me that maybe, just maybe, this "Optional + WithDefault" combination does not work as we expect for the same reason #845 is an open feature request. Both this and #845 affect use cases where you need to treat a whole branch in your config tree as optional. They may or may not be there - usually based on the value of some other property that you evaluate in the application.

Maybe this is not seen as a viable use case?

@radcortez
Copy link
Member

This was changed in smallrye/smallrye-config#919.

There are a few motivations behind the change:

  • Make it consistent with every other source. Before the change, it was possible to retrieve values by their direct names, but not names that required the list of names (for instance, in the case of a Map).
  • Allow a Map to have a default value for a group.

Thinking about this some more, it occurred to me that maybe, just maybe, this "Optional + WithDefault" combination does not work as we expect for the same reason smallrye/smallrye-config#845

These are separate things. It is possible to implement such a feature (with the same restrictions).

Maybe this is not seen as a viable use case?

I guess the issue here is how we look at the @WithDefault annotation. In practice, this just creates another source of configuration, and it is not only limited to providing a mapping default, meaning that you can retrieve the default mapping configuration programmatically. I've seen cases (and we also use it on Quarkus), where we use an expression to reference to another mapping configuration, which may or may not contain a default.

I still think that Optional with @WithDefault doesn't make much sense. Unless you clear the value on a higher ordinal source, the Optional is always going to be non-empty. If the Optional default is non-empty, then why do we need to Optional in the first place?

In this case, if the host is the only Optional part, shouldn't this represent the case better?

interface Connection {
    @WithParentName
    Optional<Host> host();

    @WithDefault("8080")
    int port();

    interface Host {
        String host();
    }
}

@marcelstoer
Copy link
Contributor

In this case, if the host is the only Optional part....

Indeed, for this simple example your proposed alternative is fine.

My use case, however, is the same one discussed in #845: making entire branches of the config tree optional. A real-life but anonymized example of the YAML file:

my-app:
  # feature A is entirely optional, if disabled this whole branch is irrelevant
  feature-a: 
    behavior-x:
      type: ABC
      validation:
        enabled: false
      claims:
        a: b
        c: d
        roles: lol
    behavior-z:
      strategy:
        header:
          foo: bar
          fuu: baz
    identity-token:
      name: whatever
      transport: whatever
    run-as:
      foo: bar
  feature-b:
    # etc.

In the feature-a branch some properties have a default value, others don't. Some installations use "feature A", others don't.

  • If you use/enable "feature A" (different story), you may want to take advantage of the default values.
  • If you don't use "feature A", there is no need for the config branch feature-a to appear anywhere in the configuration.

@radcortez
Copy link
Member

As an example, we use this on Quarkus to create a default datasource if none is found set by the user, but we don't want to force the user to set some property to trigger the optional creation.

Also, in our case, if we don't use "feature A" the config is not included in the application. In those cases, you even get a warning stating that the configuration is unknown. Could that be a solution for you?

At the moment, I need help finding an easy solution to support both scenarios with your structure. Yes, it worked before, but it required us to create the configuration programmatically, not to force the user to set a random property for the default datasource.

I'm happy to discuss possible solutions for this.

@merlante
Copy link
Contributor

merlante commented Jul 23, 2024

Ran into this one myself like a brick wall. It probably makes total sense if you're working on the implementation, but as a user, I expected the same thing as @marcelstoer. They are many use cases where, for example, an authentication config group would be optional, but if populated by any attribute, the required and @WithDefault validation rules would be expected to kick in (but not before).

Example:

@ConfigMapping(prefix = "relations-api")
public interface Config {
    enum AuthMode {
        DISABLED,
        OIDC_CLIENT_CREDENTIALS
    }

    @WithDefault("false")
    boolean isSecureClients();
    String targetUrl();

    @WithName("authn")
    Optional<AuthenticationConfig> authenticationConfig();

    interface AuthenticationConfig {
        @WithDefault("disabled")
        AuthMode mode();
        @WithName("client")
        Optional<OIDCClientCredentialsConfig> clientCredentialsConfig();
    }

     interface OIDCClientCredentialsConfig {
        String issuer();
        @WithName("id")
        String clientId();
        @WithName("secret")
        String clientSecret();
        Optional<String[]> scope();
        @WithDefault("org.project_kessel.relations.client.authn.oidc.client.nimbus.NimbusOIDCClientCredentialsMinter")
        String OIDCClientCredentialsMinterImplementation();
    }
}

Just because I have a @WithDefault specified in the OIDCClientCredentialsConfig group, should not mean that all of the config for OIDCClientCredentialsConfig should be required, in my opinion.

At the very least, this should be documented somewhere.

@radcortez
Copy link
Member

Just because I have a @WithDefault specified in the OIDCClientCredentialsConfig group, should not mean that all of the config for OIDCClientCredentialsConfig should be required, in my opinion.

We did that because with the previous behavior, retrieving the default value was only possible if another property of the tree was specified.

It also had some inconsistencies... so I assume, that if the user explicitly set one of the defaults or an optional tree (but nothing else), should we trigger the creation or not? Right now, the rule is fairly simple: if a value definition exists (no matter where) it always triggers the tree creation.

I'm open to discuss other rules. The challenge is that selectively creating the tree depending on where the value definition exists, may cause additional confusion to users.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/config kind/bug Something isn't working
Projects
None yet
Development

No branches or pull requests

6 participants