Skip to content

Unexpected constructor-based initialization of @ConfigurationProperties leads to inconsistent behavior #16038

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

Closed
axtavt opened this issue Feb 26, 2019 · 14 comments
Assignees
Labels
type: bug A general bug
Milestone

Comments

@axtavt
Copy link

axtavt commented Feb 26, 2019

Consider the following test case:

    @RunWith(SpringRunner.class)
    @SpringBootTest(classes = { BindingTest.Config.class })
    @EnableConfigurationProperties
    @TestPropertySource
    public class BindingTest {

        @Autowired FooProperties fooProperties;
    
        @Test
        public void shouldInjectBar() {
            assertThat(fooProperties.getBar()).isEqualTo("baz");
        }
    
        @Configuration
        public static class Config {
            @Bean
            public FooProperties fooProperties() {
                return new FooProperties();
            }
        }
    
        @ConfigurationProperties("foo")
        @Validated
        public static class FooProperties {
            @NotNull @Getter @Setter
            private String bar;
        
            public FooProperties() {}
        
            public FooProperties(String bar) { // (1)
                 this.bar = bar;
            }
        }
    }

BindingTest.properties:

foo.bar=baz
foo=unrelated # (2)

I expected fooProperties.bar to be populated, or, at least, @NotNull validation error to be thrown.

However, in presence of single argument constructor (1) and conflicting property (2) (it may be, for example, a totally unrelated system environment variable), I got the following:

  • fooProperties.bar is null
  • no validation error is thrown

This happens because Binder decides to use ObjectToObjectConverter to initialize FooProperties using its single argument constructor (despite the fact that FooProperties is explicitly created in @Bean method), creates new instance of FooProperties and applies validation to it, but ConfigurationPropertiesBindingPostProcessor ignores that new instance.

This applies to Spring Boot 2.0.x, 2.1.x, 2.2.x.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Feb 26, 2019
@wilkinsona
Copy link
Member

Thanks for the test case. For our future reference, here's a slightly modified version that is standalone and doesn't rely on Lombok:

package com.example.demo;

import static org.assertj.core.api.Assertions.assertThat;

import javax.validation.constraints.NotNull;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.validation.annotation.Validated;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = { BindingTest.Config.class })
@EnableConfigurationProperties
@TestPropertySource(properties = {"foo.bar=baz", "foo=unrelated"})
public class BindingTest {

    @Autowired
    FooProperties fooProperties;

    @Test
    public void shouldInjectBar() {
        assertThat(fooProperties.getBar()).isEqualTo("baz");
    }

    @Configuration
    public static class Config {

        @Bean
        public FooProperties fooProperties() {
            return new FooProperties();
        }

    }

    @ConfigurationProperties("foo")
    @Validated
    public static class FooProperties {

    	@NotNull
        private String bar;

        public FooProperties() {}

        public FooProperties(String bar) { // (1)
            this.bar = bar;
        }

        public String getBar() {
            return bar;
        }

        public void setBar(String bar) {
            this.bar = bar;
        }

    }

}

A few comments about the example:

It's unusual for a @ConfigurationProperties to have a constructor. They're generally expected to be JavaBeans with a default constructor and getters and setters. What purpose is the constructor serving?

Having both foo and foo.bar as properties will cause problems with YAML as the following cannot be parsed:

foo: unrelated
  bar: baz

We've made this mistake ourselves in the past and intend to correct it in 2.2. See #12510 for some details.

With that said, I think it makes sense for the binder to ignore a property named foo when binding to something that uses foo as its prefix. We should probably only bind properties that are descendants of foo and ignore foo itself.

@wilkinsona wilkinsona added type: bug A general bug and removed status: waiting-for-triage An issue we've not yet triaged labels Feb 26, 2019
@wilkinsona wilkinsona added this to the 2.1.x milestone Feb 26, 2019
@Raheela1024
Copy link
Contributor

@wilkinsona I want to work on it. Please let me know if I can start working on it ?

@snicoll
Copy link
Member

snicoll commented Mar 1, 2019

@Raheela1024 thank you for the offer. Please go ahead and let us know if you need assistance.

@mbhave
Copy link
Contributor

mbhave commented Mar 20, 2019

@Raheela1024 How's it going? Let us know if there is anything we can help with.

@Raheela1024
Copy link
Contributor

@mbhave Sorry i was busy but now looking into will update shortly.

@Raheela1024
Copy link
Contributor

@mbhave I need some help regarding the fix should we need to revert

"Renamed logging.file to logging.file.name"

or we need to fix into ConfigurationPropertiesBinder bind method.

@snicoll
Copy link
Member

snicoll commented Mar 25, 2019

@Raheela1024 I'd like to help but I don't see the link. We should not revert the change, it's part of an ongoing effort that a property name should not match the name of a group. Can you please expand a bit of what the problem is? You can share what you have on your fork.

@Raheela1024
Copy link
Contributor

@snicoll thanks for your reply. I have debugged the issue by changing test case into ConfigurationPropertiesAutoConfigurationTests file by setting environment as "foo:" and observed that in Binder class containsNoDescendantOf method returns true while I am not providing Descendant fields. Should I need to update this method i am in right direction or not ?

@mbhave
Copy link
Contributor

mbhave commented Mar 25, 2019

@Raheela1024 As @wilkinsona mentioned here, we want to ignore any property that matches the prefix of the @ConfigurationProperties bean that we're binding to. For example, we want to bind foo.* to something with a foo prefix, but to ignore a foo property if one is set. This would be a change in the Binder class. It looks like the change is a bit more involved than we originally thought as quite a few tests would need to be updated. We really appreciate your offer to help but we'd like to tackle this one ourselves.

@mbhave mbhave self-assigned this Mar 25, 2019
@mbhave mbhave added the for: team-attention An issue we'd like other members of the team to review label Apr 30, 2019
@philwebb philwebb self-assigned this May 8, 2019
@mbhave
Copy link
Contributor

mbhave commented May 10, 2019

We probably need to consider doing something so that this change only affects @ConfigurationProperties. A change in the binder looks like it would be a fundamental change to the binder's behavior that involves a lot of test changes.

@philwebb philwebb removed the for: team-attention An issue we'd like other members of the team to review label May 31, 2019
@philwebb philwebb modified the milestones: 2.1.x, 2.2.x Jun 10, 2020
@philwebb philwebb modified the milestones: 2.2.x, 2.3.x Dec 16, 2020
@philwebb philwebb modified the milestones: 2.3.x, 2.5.x, 2.5.0-M2 Jan 23, 2021
@philwebb
Copy link
Member

I've got a fix for this, but it includes API changes so I'm only going to apply it to 2.5

@philwebb philwebb reopened this Jan 26, 2021
@philwebb
Copy link
Member

Reopening to consider nested properties

@philwebb philwebb added the for: team-meeting An issue we'd like to discuss as a team to make progress label Jan 26, 2021
@philwebb philwebb removed the for: team-meeting An issue we'd like to discuss as a team to make progress label Feb 10, 2021
@mbhave
Copy link
Contributor

mbhave commented Feb 10, 2021

Example for nested properties

public class JavaBeanWithPublicConstructor {

	private String value;

	private Bar bar = new Bar();

	public JavaBeanWithPublicConstructor() {
	}

	public JavaBeanWithPublicConstructor(String value) {
		setValue(value);
	}

	public String getValue() {
		return this.value;
	}

	public void setValue(String value) {
		this.value = value;
	}

	public Bar getBar() {
		return bar;
	}

	public void setBar(Bar bar) {
		this.bar = bar;
	}

	static class Bar {
		private String baz;

		public Bar() {
		}

		public Bar(String baz) {
			this.baz = baz;
		}

		public String getBaz() {
			return baz;
		}

		public void setBaz(String baz) {
			this.baz = baz;
		}
	}
}

@philwebb
Copy link
Member

We'll look at nested ones in #25368

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: bug A general bug
Projects
None yet
Development

No branches or pull requests

7 participants