Three new article modules: configuration binding, profiles and config data, Spring AOP
configuration-properties/ @ConfigurationProperties vs @Value on Spring Boot 4.1.1. The relaxed-binding matrix is generated by binding each spelling rather than transcribed, and re-checked against real processes -- the in-process probe was wrong twice before it was right. Records the three findings that came out of it: @Value does get relaxed resolution inside Spring Boot (Boot attaches ConfigurationPropertySources), the configuration processor silently stops generating metadata on JDK 23+ when declared as a plain dependency, and @Valid is not what makes nested constraints run. profiles-and-config/ Precedence, profiles, spring.config.import and config trees. /precedence reports every source holding a property in rank order with file and line, which turns "my profile file had no effect" into a two-line answer. Also pins the counterintuitive one: an imported file outranks the file that imported it. spring-aop/ Designators, proxy types, and aspects that do not fire. One advice per supported designator so the reference table is generated from real matches; all fourteen unsupported designators fed to the parser. Two corrections to the reference documentation: unsupported designators throw UnsupportedPointcutPrimitiveException (extends RuntimeException, not IllegalArgumentException), and spring-boot-starter-aop was renamed to spring-boot-starter-aspectj in Boot 4. 19 contract tests across the three modules, 15 captured transcripts, all regenerated by scripts/run-all.sh. Verified on Spring Boot 4.1.1, Spring Framework 7.0.9, JDK 25.0.4.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gip4srpzMwjgoba6uEfbr5
This commit is contained in:
76
configuration-properties/README.md
Normal file
76
configuration-properties/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# @ConfigurationProperties vs @Value
|
||||
|
||||
Companion project for [**@ConfigurationProperties vs @Value in Spring Boot 4**](https://ankurm.com/)
|
||||
on ankurm.com.
|
||||
|
||||
Every table and transcript quoted in that article was produced by running something here.
|
||||
Three of them were produced twice, because the first answer was wrong — see
|
||||
[chapter 2](docs/02-relaxed-binding.md).
|
||||
|
||||
## Versions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Spring Boot | 4.1.1 |
|
||||
| Spring Framework | 7.0.9 |
|
||||
| JDK | Eclipse Temurin 25.0.4.1 (LTS) |
|
||||
| Validation | Hibernate Validator via `spring-boot-starter-validation` |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn -DskipTests package
|
||||
./scripts/run-all.sh # regenerate every transcript in docs/output/
|
||||
mvn test # 7 contract tests
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
| Profile | What it does |
|
||||
|---|---|
|
||||
| *(none)* | binds `demo.*` from `application.yaml` |
|
||||
| `probe` | runs the relaxed-binding matrix and exits |
|
||||
| `envprobe` | reports one property as seen by the binder and by `@Value` |
|
||||
| `badvalidation` | values that violate every constraint, so startup fails |
|
||||
| `csvlist` | the recipients list as a comma-separated string |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /diag/bound` | the bound objects, and the same values via `@Value` |
|
||||
| `GET /diag/origin?name=` | every source holding a property, in precedence order, with file and line |
|
||||
|
||||
Both are diagnostics. Delete them before shipping.
|
||||
|
||||
## Documentation
|
||||
|
||||
1. [Two mechanisms, not two styles](docs/01-two-mechanisms.md)
|
||||
2. [Relaxed binding, measured](docs/02-relaxed-binding.md)
|
||||
3. [Getting the bean registered](docs/03-registration.md)
|
||||
4. [Records, constructor binding and defaults](docs/04-records-and-defaults.md)
|
||||
5. [Validation](docs/05-validation.md)
|
||||
6. [When `@Value` is still the right answer](docs/06-when-value-still-wins.md)
|
||||
7. [IDE metadata, and the JDK 23 change that silently breaks it](docs/07-ide-metadata.md)
|
||||
8. [Diagnosing a value](docs/08-diagnosing-a-value.md)
|
||||
|
||||
## Captured output
|
||||
|
||||
| File | Produced by |
|
||||
|---|---|
|
||||
| [`00-versions.txt`](docs/output/00-versions.txt) | `scripts/demo-versions.sh` |
|
||||
| [`01-relaxed-matrix.txt`](docs/output/01-relaxed-matrix.txt) | `scripts/demo-relaxed-matrix.sh` |
|
||||
| [`02-env-var-binding.txt`](docs/output/02-env-var-binding.txt) | `scripts/demo-env-binding.sh` |
|
||||
| [`03-value-vs-binding.txt`](docs/output/03-value-vs-binding.txt) | `scripts/demo-value-vs-binding.sh` |
|
||||
| [`04-validation-failure.txt`](docs/output/04-validation-failure.txt) | `scripts/demo-validation.sh` |
|
||||
| [`05-metadata-generation.txt`](docs/output/05-metadata-generation.txt) | `scripts/demo-metadata-generation.sh` |
|
||||
|
||||
## Three findings worth the trip
|
||||
|
||||
- **`@Value` gets relaxed binding inside Spring Boot.** The universal claim that it does not is
|
||||
a statement about the Spring Framework; Boot attaches a property source that changes it.
|
||||
- **The configuration processor silently stops on JDK 23+** when declared as a plain
|
||||
dependency, so IDE auto-completion quietly dies while the build stays green.
|
||||
- **`@Valid` is not what makes nested constraints run.** Boot's `ValidationBindHandler`
|
||||
validates every object it binds.
|
||||
49
configuration-properties/docs/01-two-mechanisms.md
Normal file
49
configuration-properties/docs/01-two-mechanisms.md
Normal file
@@ -0,0 +1,49 @@
|
||||
[Index](../README.md) · [Relaxed binding →](02-relaxed-binding.md)
|
||||
|
||||
# 1. Two mechanisms, not two styles
|
||||
|
||||
`@Value` and `@ConfigurationProperties` are often presented as a matter of taste. They are not.
|
||||
They are two different mechanisms that happen to read from the same `Environment`, and almost
|
||||
every difference in behaviour follows from that.
|
||||
|
||||
## `@Value` is placeholder resolution
|
||||
|
||||
`@Value("${demo.mail.host}")` is a string. Spring's `PropertySourcesPlaceholderConfigurer`
|
||||
resolves it against the environment during bean post-processing, converts the result to the
|
||||
field's type, and assigns it. There is no model of what a property *is* — only a name, a
|
||||
lookup and a conversion.
|
||||
|
||||
Consequences:
|
||||
|
||||
- The property name lives in a string literal. Renaming a property is a text search.
|
||||
- One field, one lookup. There is no way to express "these six properties belong together".
|
||||
- Defaulting is `:` inside the placeholder, and nothing else.
|
||||
- There is no validation step, because there is no object to validate.
|
||||
- SpEL is available: `#{...}` can compute rather than look up.
|
||||
|
||||
## `@ConfigurationProperties` is binding
|
||||
|
||||
`Binder` walks a target type, works out which properties it needs, asks the
|
||||
`ConfigurationPropertySource`s for each one, converts, and constructs the object. The target
|
||||
type is the model.
|
||||
|
||||
Consequences:
|
||||
|
||||
- The property names are derived from the type. Renaming a component renames the property.
|
||||
- Nested types, lists and maps bind, because the binder knows the shape it is filling.
|
||||
- Defaults are `@DefaultValue`, applied per component during construction.
|
||||
- The bound object can be validated as an object, once, at startup.
|
||||
- No SpEL. The binder maps values; it does not compute them.
|
||||
|
||||
## The one that surprises people
|
||||
|
||||
Because both read from the same environment, the *relaxed* rules ought to differ — and in the
|
||||
Spring Framework alone they do. Inside Spring Boot they largely do not, because Boot attaches
|
||||
an extra property source that gives placeholder resolution the binder's name matching.
|
||||
[Chapter 2](02-relaxed-binding.md) has the measured matrix.
|
||||
|
||||
## Which to use
|
||||
|
||||
Use `@ConfigurationProperties` for anything that is a group of settings — which is most
|
||||
things. Reach for `@Value` when you need a single value in a place where a type would be
|
||||
ceremony, or when you need SpEL. [Chapter 6](06-when-value-still-wins.md) is the honest list.
|
||||
62
configuration-properties/docs/02-relaxed-binding.md
Normal file
62
configuration-properties/docs/02-relaxed-binding.md
Normal file
@@ -0,0 +1,62 @@
|
||||
[← Two mechanisms](01-two-mechanisms.md) · [Index](../README.md) · [Registration →](03-registration.md)
|
||||
|
||||
# 2. Relaxed binding, measured
|
||||
|
||||
Everything here is generated by [`RelaxedBindingProbe`](../src/main/java/com/ankurm/configprops/web/RelaxedBindingProbe.java)
|
||||
and re-checked against real processes by [`demo-env-binding.sh`](../scripts/demo-env-binding.sh).
|
||||
Transcripts: [`01-relaxed-matrix.txt`](output/01-relaxed-matrix.txt),
|
||||
[`02-env-var-binding.txt`](output/02-env-var-binding.txt).
|
||||
|
||||
## The canonical form
|
||||
|
||||
Every property has one canonical name: lower case, words separated by `-`, levels separated by
|
||||
`.`. For `RelaxedProperties.apiKey` under prefix `demo.relaxed`, that is
|
||||
`demo.relaxed.api-key`.
|
||||
|
||||
## What matches it
|
||||
|
||||
| Spelling | Source | Binder | `${}` plain Spring | `${}` in Spring Boot |
|
||||
|---|---|---|---|---|
|
||||
| `demo.relaxed.api-key` | file / `-D` | bound | bound | bound |
|
||||
| `demo.relaxed.apiKey` | file / `-D` | bound | MISS | bound |
|
||||
| `demo.relaxed.api_key` | file / `-D` | bound | MISS | bound |
|
||||
| `demo.relaxed.APIKEY` | file / `-D` | bound | MISS | bound |
|
||||
| `DEMO.RELAXED.API-KEY` | file / `-D` | bound | MISS | bound |
|
||||
| `demo.relaxed.apikey` | file / `-D` | bound | MISS | bound |
|
||||
| `demo.relaxed.api.key` | file / `-D` | **MISS** | MISS | **MISS** |
|
||||
| `DEMO_RELAXED_API_KEY` | env var | bound | bound | bound |
|
||||
| `DEMO_RELAXED_APIKEY` | env var | bound | MISS | bound |
|
||||
| `demo_relaxed_api_key` | env var | bound | bound | bound |
|
||||
|
||||
## Reading it
|
||||
|
||||
**Dashes, underscores and case are noise.** `ConfigurationPropertyName` compares elements in a
|
||||
"uniform" form with separators removed and case folded, so `api-key`, `apiKey`, `api_key` and
|
||||
`apikey` are the same name.
|
||||
|
||||
**A dot is not noise.** `demo.relaxed.api.key` is four name elements; `demo.relaxed.api-key` is
|
||||
three. Nothing relaxed will ever join them. This is the one spelling that genuinely fails, and
|
||||
it is the one people write when they are guessing.
|
||||
|
||||
**`@Value` gets relaxed resolution too — but only inside Spring Boot.** The widely repeated
|
||||
claim that `@Value` does not support relaxed binding is a statement about the Spring Framework.
|
||||
Spring Boot calls `ConfigurationPropertySources.attach(environment)` while preparing every
|
||||
environment, which inserts a property source that resolves placeholders through the binder's
|
||||
name matching. Remove that call and the middle column is what you get.
|
||||
|
||||
That is worth knowing in both directions: you can rely on it in a Boot application, and you
|
||||
cannot rely on it in a plain `ApplicationContext`, a standalone `Environment` or a test that
|
||||
builds one by hand.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Both `DEMO_RELAXED_API_KEY` and `DEMO_RELAXED_APIKEY` work. Prefer the first: `_` for every
|
||||
separator, upper case throughout. It is the form the binder generates when it goes looking, so
|
||||
it is the one that cannot depend on the reverse mapping.
|
||||
|
||||
A harness note that cost a wrong finding here: Spring Boot decides to apply environment-variable
|
||||
name mapping by comparing the property source's *name* against
|
||||
`StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME`, not by checking its type. A
|
||||
`SystemEnvironmentPropertySource` registered under any other name is mapped with the ordinary
|
||||
rules. A test that builds one by hand and names it something descriptive will report that
|
||||
underscore spellings do not bind, and will be wrong.
|
||||
45
configuration-properties/docs/03-registration.md
Normal file
45
configuration-properties/docs/03-registration.md
Normal file
@@ -0,0 +1,45 @@
|
||||
[← Relaxed binding](02-relaxed-binding.md) · [Index](../README.md) · [Records and defaults →](04-records-and-defaults.md)
|
||||
|
||||
# 3. Getting the bean registered
|
||||
|
||||
`@ConfigurationProperties` on a class does nothing on its own. The class has to become a bean,
|
||||
and there are three ways to arrange that:
|
||||
|
||||
| How | Where it goes | When to use it |
|
||||
|---|---|---|
|
||||
| `@ConfigurationPropertiesScan` | on the application class | most applications; scans a package |
|
||||
| `@EnableConfigurationProperties(Foo.class)` | on any `@Configuration` class | libraries, or when you want the list explicit |
|
||||
| `@Component` | on the properties class itself | works, but mixes a stereotype into a value type |
|
||||
|
||||
This project uses `@ConfigurationPropertiesScan`, on
|
||||
[`ConfigBindingApplication`](../src/main/java/com/ankurm/configprops/ConfigBindingApplication.java).
|
||||
|
||||
## The failure
|
||||
|
||||
If none of the three is present, the class compiles, the application starts, and the bean does
|
||||
not exist. Injecting it fails with `NoSuchBeanDefinitionException` — which is at least loud.
|
||||
|
||||
The quiet version is worse: with constructor binding, a properties *record* that is never
|
||||
registered simply never appears, and if the only thing that used it was optional, nothing
|
||||
complains at all.
|
||||
|
||||
## Constructor binding and `@Autowired` do not mix
|
||||
|
||||
A type bound through its constructor is built by the binder, not by the container. It cannot
|
||||
have collaborators injected into that constructor, because every parameter is treated as a
|
||||
property to bind. If a properties type needs a collaborator, it is not a properties type.
|
||||
|
||||
## `@ConfigurationProperties` on an `@Bean` method
|
||||
|
||||
Legal, and useful when the type comes from a library you cannot annotate:
|
||||
|
||||
```java
|
||||
@Bean
|
||||
@ConfigurationProperties(prefix = "demo.thirdparty")
|
||||
ThirdPartyConfig thirdPartyConfig() {
|
||||
return new ThirdPartyConfig();
|
||||
}
|
||||
```
|
||||
|
||||
This uses setter binding, not constructor binding — the object already exists by the time the
|
||||
binder sees it.
|
||||
62
configuration-properties/docs/04-records-and-defaults.md
Normal file
62
configuration-properties/docs/04-records-and-defaults.md
Normal file
@@ -0,0 +1,62 @@
|
||||
[← Registration](03-registration.md) · [Index](../README.md) · [Validation →](05-validation.md)
|
||||
|
||||
# 4. Records, constructor binding and defaults
|
||||
|
||||
See [`MailProperties`](../src/main/java/com/ankurm/configprops/props/MailProperties.java) and
|
||||
the bound output in [`03-value-vs-binding.txt`](output/03-value-vs-binding.txt).
|
||||
|
||||
## Records need no annotation
|
||||
|
||||
A record has exactly one canonical constructor, so the binder uses it. `@ConstructorBinding`
|
||||
is only needed to pick between candidates when a type has more than one constructor — and
|
||||
since Spring Boot 3 it goes on the *constructor*, not the type.
|
||||
|
||||
Constructor binding gives you immutability, which matters more than it sounds: a mutable
|
||||
`@ConfigurationProperties` bean is a singleton that anything can write to.
|
||||
|
||||
## Defaults
|
||||
|
||||
A record cannot have field initialisers, so the default has to be attached to the component:
|
||||
|
||||
```java
|
||||
public record MailProperties(
|
||||
String host, // no default: null if absent
|
||||
@DefaultValue("587") int port,
|
||||
@DefaultValue("30s") Duration timeout,
|
||||
@DefaultValue RetryProperties retries, // nested, with its own defaults
|
||||
@DefaultValue List<String> recipients, // empty list, not null
|
||||
@DefaultValue Map<String, String> headers) {
|
||||
}
|
||||
```
|
||||
|
||||
`@DefaultValue` with no argument on a nested type means "construct it with its own defaults"
|
||||
rather than "bind it to null". Same for collections: you get an empty one instead of `null`,
|
||||
which removes a class of `NullPointerException` from startup code.
|
||||
|
||||
A component with neither a value nor a `@DefaultValue` binds to `null` for a reference type. A
|
||||
`record` component of primitive type with no value and no default fails the bind.
|
||||
|
||||
## The whole-object rule
|
||||
|
||||
If *nothing* under the prefix is present, the binder returns no result at all — not an object
|
||||
full of defaults. `BindResult.isBound()` is `false` and `get()` throws. Defaults apply to
|
||||
components of an object that is being constructed; they do not cause one to be constructed.
|
||||
|
||||
For a bean registered through `@ConfigurationPropertiesScan` this is invisible, because Spring
|
||||
Boot binds with a target that always constructs. It shows up as soon as you call `Binder`
|
||||
yourself, which is why it is here.
|
||||
|
||||
## Lists
|
||||
|
||||
| Written as | `@ConfigurationProperties` | `@Value` |
|
||||
|---|---|---|
|
||||
| YAML block list | binds | does not see it |
|
||||
| `a,b,c` string | binds | binds |
|
||||
| `foo[0]`, `foo[1]` | binds | does not see it |
|
||||
|
||||
The middle row is the only shape both understand, and it is why comma-separated lists persist
|
||||
in configuration long after they stopped being pleasant to read.
|
||||
|
||||
One thing to know about list overriding: a list is bound from the highest-precedence source
|
||||
that contains the property, *entirely*. Lists do not merge across sources. A source that sets
|
||||
three elements replaces a lower source's two; it does not append to them.
|
||||
55
configuration-properties/docs/05-validation.md
Normal file
55
configuration-properties/docs/05-validation.md
Normal file
@@ -0,0 +1,55 @@
|
||||
[← Records and defaults](04-records-and-defaults.md) · [Index](../README.md) · [When @Value wins →](06-when-value-still-wins.md)
|
||||
|
||||
# 5. Validation
|
||||
|
||||
Transcript: [`04-validation-failure.txt`](output/04-validation-failure.txt).
|
||||
Type: [`ValidatedProperties`](../src/main/java/com/ankurm/configprops/props/ValidatedProperties.java).
|
||||
|
||||
This is the capability `@Value` does not have at all, and the reason to prefer binding for
|
||||
anything that can be misconfigured.
|
||||
|
||||
## What it takes
|
||||
|
||||
1. `spring-boot-starter-validation` on the classpath. Without a validator implementation,
|
||||
`@Validated` is a no-op — nothing is checked and nothing says so.
|
||||
2. `@Validated` on the properties type.
|
||||
3. JSR-380 constraints on the components.
|
||||
|
||||
## What you get
|
||||
|
||||
A bad value becomes a startup failure that names the property, the offending value, the file
|
||||
and line it came from, and the constraint it broke — all of them at once, not one per restart:
|
||||
|
||||
```
|
||||
Property: demo.validated.port
|
||||
Value: "99999"
|
||||
Origin: class path resource [application-badvalidation.yaml] - 6:11
|
||||
Reason: must be less than or equal to 65535
|
||||
```
|
||||
|
||||
The alternative is a `NumberFormatException` in a request handler at 3am.
|
||||
|
||||
## `@Valid` on nested types is not what makes them validate
|
||||
|
||||
The rule "annotate nested properties with `@Valid` or their constraints are ignored" is widely
|
||||
repeated and does not apply here. It is true of ordinary bean validation, where cascading is
|
||||
opt-in. Spring Boot's `ValidationBindHandler` validates *every object the binder finishes
|
||||
constructing*, nested ones included.
|
||||
|
||||
`BindingContractTests.nestedConstraintsFireWithoutValid` pins this: a nested record carrying
|
||||
`@Max(100)` and no `@Valid` anywhere in the type still fails the bind at 4000. That test was
|
||||
originally written to assert the opposite and failed, which is how it ended up documented here.
|
||||
|
||||
Keep writing `@Valid` if you like — it is harmless and it is what a reader expects. Just do not
|
||||
believe it is load-bearing.
|
||||
|
||||
## Ordering
|
||||
|
||||
The violation order in the failure report is not stable between runs. Do not write a test that
|
||||
asserts on it.
|
||||
|
||||
## Custom validation
|
||||
|
||||
For rules a constraint annotation cannot express — "if `mode` is `remote` then `endpoint` is
|
||||
required" — implement `Validator` and expose it as a bean named `configurationPropertiesValidator`.
|
||||
It runs at bind time with the same reporting.
|
||||
42
configuration-properties/docs/06-when-value-still-wins.md
Normal file
42
configuration-properties/docs/06-when-value-still-wins.md
Normal file
@@ -0,0 +1,42 @@
|
||||
[← Validation](05-validation.md) · [Index](../README.md) · [IDE metadata →](07-ide-metadata.md)
|
||||
|
||||
# 6. When `@Value` is still the right answer
|
||||
|
||||
Binding wins most of the time, and this project is largely an argument for it. The exceptions
|
||||
are real though, and pretending otherwise makes the advice easy to dismiss.
|
||||
|
||||
## SpEL
|
||||
|
||||
`@Value` evaluates SpEL; the binder does not. If the value has to be *computed*, this is the
|
||||
only one of the two that can do it:
|
||||
|
||||
```java
|
||||
@Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}")
|
||||
private int computedThreads;
|
||||
```
|
||||
|
||||
There is no `@ConfigurationProperties` equivalent. The binder maps a value; it does not derive
|
||||
one. (You can always compute in a compact constructor instead, which is usually clearer.)
|
||||
|
||||
## A single value in a class that is not about configuration
|
||||
|
||||
A `@Component` that needs one feature flag does not benefit from a properties type. The type
|
||||
would exist to hold one field and would be injected in one place.
|
||||
|
||||
The line is roughly: if you would name the type after the group of settings and the name would
|
||||
be meaningful, use binding. If you would have to invent a name, use `@Value`.
|
||||
|
||||
## Reading someone else's property
|
||||
|
||||
`@Value("${server.port}")` reads a property owned by Spring Boot. Declaring your own type for
|
||||
it would imply you own it.
|
||||
|
||||
## What is *not* a good reason
|
||||
|
||||
- **"It's less code."** For one property, marginally. For four, the properties type is
|
||||
shorter and it is also checked.
|
||||
- **"I need a default."** Both support defaults.
|
||||
- **"@Value is faster."** Neither is measurable next to a database call, and both happen once
|
||||
at startup.
|
||||
- **"Relaxed binding doesn't work with @Value."** Inside Spring Boot, it does —
|
||||
[chapter 2](02-relaxed-binding.md).
|
||||
88
configuration-properties/docs/07-ide-metadata.md
Normal file
88
configuration-properties/docs/07-ide-metadata.md
Normal file
@@ -0,0 +1,88 @@
|
||||
[← When @Value wins](06-when-value-still-wins.md) · [Index](../README.md) · [Diagnosing a value →](08-diagnosing-a-value.md)
|
||||
|
||||
# 7. IDE metadata, and the JDK 23 change that silently breaks it
|
||||
|
||||
Transcript: [`05-metadata-generation.txt`](output/05-metadata-generation.txt).
|
||||
|
||||
## What the metadata is
|
||||
|
||||
`spring-boot-configuration-processor` is an annotation processor. At compile time it reads your
|
||||
`@ConfigurationProperties` types and writes `META-INF/spring-configuration-metadata.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": [
|
||||
{ "name": "demo.mail", "type": "com.ankurm.configprops.props.MailProperties" }
|
||||
],
|
||||
"properties": [
|
||||
{ "name": "demo.mail.port", "type": "java.lang.Integer", "defaultValue": 587 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
That file is what makes property names auto-complete in an IDE, and what shows the Javadoc on
|
||||
a record component as hover documentation. Nothing at runtime reads it.
|
||||
|
||||
## The failure
|
||||
|
||||
Declaring the processor as a dependency — the way every tutorial written before 2024 shows —
|
||||
stops working on JDK 23 and later:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Absent any processor-related command-line option, `-proc:none` is now javac's default. JDK 21
|
||||
began printing an informative message when implicit annotation processing was detected, and
|
||||
JDK 23 turned the policy off, with the stated goal of making builds robust against processors
|
||||
landing on the classpath unintentionally. A processor that is only on the classpath is now
|
||||
simply not run.
|
||||
|
||||
The build still succeeds. The jar is still valid. The application behaves identically. The only
|
||||
symptom is that property auto-completion quietly stops working, which is the kind of thing
|
||||
people blame on the IDE.
|
||||
|
||||
Two compilations of identical sources with the identical processor jar:
|
||||
|
||||
```
|
||||
A) javac -cp <deps>:spring-boot-configuration-processor.jar -d a $SOURCES
|
||||
spring-configuration-metadata.json files produced: 0
|
||||
|
||||
B) javac -proc:full -cp <deps>:spring-boot-configuration-processor.jar -d b $SOURCES
|
||||
spring-configuration-metadata.json files produced: 1
|
||||
```
|
||||
|
||||
## The fix
|
||||
|
||||
Declare it as an annotation processor path. That makes Maven pass `--processor-path`, and an
|
||||
explicit processor option re-enables processing -- the new default only applies when javac is
|
||||
given nothing to go on:
|
||||
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<version>${project.parent.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
`-proc:full` also works and is a smaller change, but it restores the old policy for every
|
||||
processor on the classpath, which is the behaviour that was turned off for a reason.
|
||||
|
||||
## Checking
|
||||
|
||||
`ls target/classes/META-INF/spring-configuration-metadata.json`. If it is not there, the
|
||||
processor did not run. Add `@ConfigurationProperties` metadata to your build's definition of
|
||||
done, because nothing else will tell you.
|
||||
57
configuration-properties/docs/08-diagnosing-a-value.md
Normal file
57
configuration-properties/docs/08-diagnosing-a-value.md
Normal file
@@ -0,0 +1,57 @@
|
||||
[← IDE metadata](07-ide-metadata.md) · [Index](../README.md)
|
||||
|
||||
# 8. Diagnosing a value
|
||||
|
||||
Endpoint: [`BindingDiagnosticsEndpoint`](../src/main/java/com/ankurm/configprops/web/BindingDiagnosticsEndpoint.java).
|
||||
|
||||
"The property is set but the application does not see it" is the most common configuration bug,
|
||||
and it is hard for one reason: a value carries no visible provenance. Spring Boot tracks it
|
||||
anyway — every property loaded from a file or a config tree carries an `Origin` naming the
|
||||
resource and the line.
|
||||
|
||||
```
|
||||
$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'
|
||||
{
|
||||
"property": "demo.mail.host",
|
||||
"effectiveValue": "smtp.example.com",
|
||||
"candidatesInPrecedenceOrder": [
|
||||
{
|
||||
"source": "OriginTrackedMapPropertySource {name='Config resource ... [application.yaml]'}",
|
||||
"value": "smtp.example.com",
|
||||
"origin": "class path resource [application.yaml] - 15:11"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Line 15, column 11. Not "somewhere in your configuration".
|
||||
|
||||
## How it works
|
||||
|
||||
```java
|
||||
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
|
||||
ConfigurationProperty property =
|
||||
source.getConfigurationProperty(ConfigurationPropertyName.of(name));
|
||||
if (property != null) {
|
||||
// property.getValue(), property.getOrigin(), source.getUnderlyingSource()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Iterating `ConfigurationPropertySources.get(...)` gives you the sources in precedence order.
|
||||
The first hit is the winner; everything after it exists and lost. A property with several
|
||||
holders is exactly what "my change had no effect" looks like from the inside — and the
|
||||
[profiles project](../../profiles-and-config) is entirely about that case.
|
||||
|
||||
## Delete it before shipping
|
||||
|
||||
This endpoint prints whatever a mounted secret contains. If you want it permanently, put it
|
||||
behind the management port and authentication, or use Actuator's `/actuator/env`, which does
|
||||
the same job with sanitisation built in.
|
||||
|
||||
## Without an endpoint
|
||||
|
||||
- `--debug` prints the auto-configuration report, not property origins.
|
||||
- Setting `logging.level.org.springframework.boot.context.config=TRACE` logs which config data
|
||||
resources were loaded, in order — useful when the question is "was my file read at all".
|
||||
- In a test, inject `Environment` and inspect `getPropertySources()` directly.
|
||||
7
configuration-properties/docs/output/00-versions.txt
Normal file
7
configuration-properties/docs/output/00-versions.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
== versions ==
|
||||
openjdk version "25.0.4.1" 2026-08-18 LTS
|
||||
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
|
||||
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
|
||||
|
||||
|
||||
spring-boot-starter-parent: 4.1.1
|
||||
22
configuration-properties/docs/output/01-relaxed-matrix.txt
Normal file
22
configuration-properties/docs/output/01-relaxed-matrix.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
== relaxed binding matrix ==
|
||||
canonical property: demo.relaxed.api-key
|
||||
|
||||
SPELLING SOURCE BINDER ${} plain ${} boot
|
||||
--------------------------------------------------------------
|
||||
demo.relaxed.api-key map bound bound bound
|
||||
demo.relaxed.apiKey map bound MISS bound
|
||||
demo.relaxed.api_key map bound MISS bound
|
||||
demo.relaxed.APIKEY map bound MISS bound
|
||||
DEMO.RELAXED.API-KEY map bound MISS bound
|
||||
demo.relaxed.apikey map bound MISS bound
|
||||
demo.relaxed.api.key map MISS MISS MISS
|
||||
DEMO_RELAXED_API_KEY env bound bound bound
|
||||
DEMO_RELAXED_APIKEY env bound MISS bound
|
||||
demo_relaxed_api_key env bound bound bound
|
||||
DEMO.RELAXED.API-KEY env bound bound bound
|
||||
|
||||
BINDER = what @ConfigurationProperties sees (Binder)
|
||||
${} plain = placeholder resolution against a bare StandardEnvironment
|
||||
${} boot = the same, after ConfigurationPropertySources.attach(env),
|
||||
which Spring Boot performs on every environment it prepares
|
||||
bound = the value arrived; MISS = the property was not found
|
||||
43
configuration-properties/docs/output/02-env-var-binding.txt
Normal file
43
configuration-properties/docs/output/02-env-var-binding.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
== real process, real environment: binding demo.relaxed.api-key ==
|
||||
|
||||
canonical property : demo.relaxed.api-key
|
||||
(MISS) means the property was not found and the declared default was used
|
||||
|
||||
--- as an operating-system environment variable ---
|
||||
|
||||
$ DEMO_RELAXED_API_KEY=secret-value java -jar target/configuration-properties-1.0.0.jar
|
||||
env-var-set : DEMO_RELAXED_API_KEY=secret-value
|
||||
@ConfigurationProps: secret-value
|
||||
@Value : secret-value
|
||||
Environment.get : secret-value
|
||||
|
||||
$ DEMO_RELAXED_APIKEY=secret-value java -jar target/configuration-properties-1.0.0.jar
|
||||
env-var-set : DEMO_RELAXED_APIKEY=secret-value
|
||||
@ConfigurationProps: secret-value
|
||||
@Value : secret-value
|
||||
Environment.get : secret-value
|
||||
|
||||
--- as a JVM system property (-D), i.e. the 'map' rows of the matrix ---
|
||||
|
||||
$ java -Ddemo.relaxed.api-key=secret-value -jar target/configuration-properties-1.0.0.jar
|
||||
@ConfigurationProps: secret-value
|
||||
@Value : secret-value
|
||||
Environment.get : secret-value
|
||||
|
||||
$ java -Ddemo.relaxed.apiKey=secret-value -jar target/configuration-properties-1.0.0.jar
|
||||
@ConfigurationProps: secret-value
|
||||
@Value : secret-value
|
||||
Environment.get : secret-value
|
||||
|
||||
$ java -Ddemo.relaxed.apikey=secret-value -jar target/configuration-properties-1.0.0.jar
|
||||
@ConfigurationProps: secret-value
|
||||
@Value : secret-value
|
||||
Environment.get : secret-value
|
||||
|
||||
$ java -Ddemo.relaxed.api.key=secret-value -jar target/configuration-properties-1.0.0.jar
|
||||
@ConfigurationProps: (unset)
|
||||
@Value : (MISS)
|
||||
Environment.get : (MISS)
|
||||
|
||||
The last one is not a spelling of the property. api.key is two name elements;
|
||||
api-key is one. Nothing relaxed will ever join them.
|
||||
79
configuration-properties/docs/output/03-value-vs-binding.txt
Normal file
79
configuration-properties/docs/output/03-value-vs-binding.txt
Normal file
@@ -0,0 +1,79 @@
|
||||
== @ConfigurationProperties vs @Value, same application, same application.yaml ==
|
||||
|
||||
$ curl -s localhost:8080/diag/bound
|
||||
{
|
||||
"mail": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"timeout": "PT45S",
|
||||
"retries": {
|
||||
"maxAttempts": 5,
|
||||
"backoff": "PT1.5S"
|
||||
},
|
||||
"recipients": [
|
||||
"ops@example.com",
|
||||
"oncall@example.com"
|
||||
],
|
||||
"headers": {
|
||||
"X-Env": "demo",
|
||||
"X-Team": "platform"
|
||||
}
|
||||
},
|
||||
"validated": {
|
||||
"name": "demo-service",
|
||||
"port": 8443,
|
||||
"endpoint": "https://api.example.com",
|
||||
"pool": {
|
||||
"size": 25
|
||||
}
|
||||
},
|
||||
"fromValueAnnotation": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"timeout": "PT45S",
|
||||
"recipients": [],
|
||||
"poolSize": 25,
|
||||
"computedThreads": 4
|
||||
}
|
||||
}
|
||||
|
||||
demo.mail.recipients is a YAML block list. The binder produced both elements.
|
||||
@Value produced an empty list -- placeholder resolution has no concept of a YAML
|
||||
sequence, so ${demo.mail.recipients:} fell through to its own empty default.
|
||||
|
||||
== the same list written as a comma-separated string, at higher precedence ==
|
||||
$ ./scripts/run.sh "" --demo.mail.recipients=x@e.com,y@e.com,z@e.com
|
||||
binder : ['x@e.com', 'y@e.com', 'z@e.com']
|
||||
@Value : ['x@e.com', 'y@e.com', 'z@e.com']
|
||||
|
||||
Both see it. A comma-separated string is the one list shape @Value understands, and
|
||||
the command-line source outranks the YAML file for the binder as well.
|
||||
|
||||
== where did that value come from? ==
|
||||
$ curl -s 'localhost:8080/diag/origin?name=demo.mail.recipients'
|
||||
{
|
||||
"property": "demo.mail.recipients",
|
||||
"effectiveValue": "x@e.com,y@e.com,z@e.com",
|
||||
"candidatesInPrecedenceOrder": [
|
||||
{
|
||||
"source": "SimpleCommandLinePropertySource {name='commandLineArgs'}",
|
||||
"value": "x@e.com,y@e.com,z@e.com",
|
||||
"origin": "\"demo.mail.recipients\" from property source \"commandLineArgs\""
|
||||
}
|
||||
],
|
||||
"shadowedBy": null
|
||||
}
|
||||
|
||||
$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'
|
||||
{
|
||||
"property": "demo.mail.host",
|
||||
"effectiveValue": "smtp.example.com",
|
||||
"candidatesInPrecedenceOrder": [
|
||||
{
|
||||
"source": "OriginTrackedMapPropertySource {name='Config resource 'class path resource [application.yaml]' via location 'optional:classpath:/''}",
|
||||
"value": "smtp.example.com",
|
||||
"origin": "class path resource [application.yaml] from configuration-properties-1.0.0.jar - 15:11"
|
||||
}
|
||||
],
|
||||
"shadowedBy": null
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
== startup with demo.validated.* deliberately out of range ==
|
||||
$ java -jar target/configuration-properties-1.0.0.jar --spring.profiles.active=badvalidation
|
||||
|
||||
APPLICATION FAILED TO START
|
||||
***************************
|
||||
|
||||
Description:
|
||||
|
||||
Binding to target com.ankurm.configprops.props.ValidatedProperties failed:
|
||||
|
||||
Property: demo.validated.pool.size
|
||||
Value: "4000"
|
||||
Reason: must be less than or equal to 100
|
||||
|
||||
Property: demo.validated.endpoint
|
||||
Value: "ftp://files.example.com"
|
||||
Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 8:15
|
||||
Reason: must match "https?://.*"
|
||||
|
||||
Property: demo.validated.port
|
||||
Value: "99999"
|
||||
Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 6:11
|
||||
Reason: must be less than or equal to 65535
|
||||
|
||||
Property: demo.validated.name
|
||||
Value: " "
|
||||
Origin: class path resource [application-badvalidation.yaml] from configuration-properties-1.0.0.jar - 4:11
|
||||
Reason: must not be blank
|
||||
|
||||
|
||||
Action:
|
||||
|
||||
Update your application's configuration
|
||||
@@ -0,0 +1,68 @@
|
||||
== is the annotation processor discovered? ==
|
||||
|
||||
openjdk version "25.0.4.1" 2026-08-18 LTS
|
||||
processor jar: spring-boot-configuration-processor-4.1.1.jar
|
||||
|
||||
A) processor on the classpath, javac defaults -- what an <optional> dependency gives you
|
||||
$ javac -cp <deps>:spring-boot-configuration-processor.jar -d a $SOURCES
|
||||
spring-configuration-metadata.json files produced: 0
|
||||
|
||||
B) identical, plus -proc:full
|
||||
$ javac -proc:full -cp <deps>:spring-boot-configuration-processor.jar -d b $SOURCES
|
||||
spring-configuration-metadata.json files produced: 1
|
||||
|
||||
Both compilations succeed. Only one of them has metadata.
|
||||
|
||||
== what this project's pom does instead ==
|
||||
The processor is declared as an <annotationProcessorPath> on maven-compiler-plugin,
|
||||
which puts it on javac's --processor-path where discovery is not disabled:
|
||||
|
||||
$ mvn clean package && ls target/classes/META-INF/
|
||||
spring-configuration-metadata.json
|
||||
|
||||
== the generated metadata, first entries ==
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"name": "demo.mail",
|
||||
"type": "com.ankurm.configprops.props.MailProperties",
|
||||
"sourceType": "com.ankurm.configprops.props.MailProperties"
|
||||
},
|
||||
{
|
||||
"name": "demo.mail.retries",
|
||||
"type": "com.ankurm.configprops.props.MailProperties$RetryProperties",
|
||||
"sourceType": "com.ankurm.configprops.props.MailProperties",
|
||||
"sourceMethod": "retries()"
|
||||
},
|
||||
{
|
||||
"name": "demo.relaxed",
|
||||
"type": "com.ankurm.configprops.props.RelaxedProperties",
|
||||
"sourceType": "com.ankurm.configprops.props.RelaxedProperties"
|
||||
},
|
||||
{
|
||||
"name": "demo.validated",
|
||||
"type": "com.ankurm.configprops.props.ValidatedProperties",
|
||||
"sourceType": "com.ankurm.configprops.props.ValidatedProperties"
|
||||
},
|
||||
{
|
||||
"name": "demo.validated.pool",
|
||||
"type": "com.ankurm.configprops.props.ValidatedProperties$Pool",
|
||||
"sourceType": "com.ankurm.configprops.props.ValidatedProperties",
|
||||
"sourceMethod": "pool()"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "demo.mail.headers",
|
||||
"type": "java.util.Map<java.lang.String,java.lang.String>",
|
||||
"description": "Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.",
|
||||
"sourceType": "com.ankurm.configprops.props.MailProperties"
|
||||
},
|
||||
{
|
||||
"name": "demo.mail.host",
|
||||
"type": "java.lang.String",
|
||||
"description": "SMTP host. No default: absent means the application must not start.",
|
||||
"sourceType": "com.ankurm.configprops.props.MailProperties"
|
||||
},
|
||||
{
|
||||
"name": "demo.mail.port",
|
||||
75
configuration-properties/pom.xml
Normal file
75
configuration-properties/pom.xml
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>configuration-properties</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>configuration-properties</name>
|
||||
<description>@ConfigurationProperties vs @Value: binding, validation and relaxed rules</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<!-- Supplies the jakarta.validation API plus Hibernate Validator. Without it,
|
||||
@Validated on a @ConfigurationProperties class is silently a no-op:
|
||||
no validator is present, so nothing is checked. See docs/05-validation.md. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!-- The configuration processor is declared HERE, as an annotationProcessorPath,
|
||||
and deliberately NOT as an <optional> dependency.
|
||||
|
||||
On JDK 23 and later javac no longer discovers annotation processors on the
|
||||
classpath (that discovery was deprecated in JDK 21 and switched off in 23).
|
||||
A processor declared only as a dependency is therefore never run: the build
|
||||
still succeeds, the jar is still valid, and META-INF/spring-configuration-
|
||||
metadata.json is silently absent. The only visible symptom is that property
|
||||
auto-completion stops working in the IDE.
|
||||
|
||||
docs/output/05-metadata-generation.txt is the A/B that proves it. -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<version>${project.parent.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
52
configuration-properties/scripts/demo-env-binding.sh
Executable file
52
configuration-properties/scripts/demo-env-binding.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# The matrix re-asked the expensive way: one real JVM per spelling, the value supplied by the
|
||||
# operating system or by -D, and read back through both @ConfigurationProperties and @Value.
|
||||
#
|
||||
# This script exists because the in-process matrix was wrong twice before it was right. A
|
||||
# synthesised property source is not a running Spring Boot application, and when the two
|
||||
# disagree the running application wins.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
|
||||
RUN=(java -jar "$JAR" --spring.profiles.active=envprobe
|
||||
--spring.main.web-application-type=none --spring.main.banner-mode=off
|
||||
--logging.level.root=OFF)
|
||||
|
||||
run_env() {
|
||||
echo "\$ $1=secret-value java -jar $JAR"
|
||||
env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY "$1=secret-value" "${RUN[@]}" 2>&1 \
|
||||
| clean | grep -E "^(env-var-set|@Config|@Value|Environment)"
|
||||
echo
|
||||
}
|
||||
|
||||
run_sysprop() {
|
||||
echo "\$ java -D$1=secret-value -jar $JAR"
|
||||
env -u DEMO_RELAXED_API_KEY -u DEMO_RELAXED_APIKEY \
|
||||
java "-D$1=secret-value" -jar "$JAR" --spring.profiles.active=envprobe \
|
||||
--spring.main.web-application-type=none --spring.main.banner-mode=off \
|
||||
--logging.level.root=OFF 2>&1 \
|
||||
| clean | grep -E "^(@Config|@Value|Environment)"
|
||||
echo
|
||||
}
|
||||
|
||||
{
|
||||
echo "== real process, real environment: binding demo.relaxed.api-key =="
|
||||
echo
|
||||
echo "canonical property : demo.relaxed.api-key"
|
||||
echo "(MISS) means the property was not found and the declared default was used"
|
||||
echo
|
||||
echo "--- as an operating-system environment variable ---"
|
||||
echo
|
||||
run_env DEMO_RELAXED_API_KEY
|
||||
run_env DEMO_RELAXED_APIKEY
|
||||
echo "--- as a JVM system property (-D), i.e. the 'map' rows of the matrix ---"
|
||||
echo
|
||||
run_sysprop demo.relaxed.api-key
|
||||
run_sysprop demo.relaxed.apiKey
|
||||
run_sysprop demo.relaxed.apikey
|
||||
run_sysprop demo.relaxed.api.key
|
||||
echo "The last one is not a spelling of the property. api.key is two name elements;"
|
||||
echo "api-key is one. Nothing relaxed will ever join them."
|
||||
} > docs/output/02-env-var-binding.txt 2>&1
|
||||
cat docs/output/02-env-var-binding.txt
|
||||
51
configuration-properties/scripts/demo-metadata-generation.sh
Executable file
51
configuration-properties/scripts/demo-metadata-generation.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Does spring-boot-configuration-processor actually run?
|
||||
#
|
||||
# Two builds of the SAME sources with the SAME processor jar, differing only in how the
|
||||
# processor is declared to the compiler. On JDK 23+ that difference decides whether
|
||||
# META-INF/spring-configuration-metadata.json exists at all -- and neither build fails.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
|
||||
WORK="${TMPDIR:-/tmp}/configprops-metadata-ab"
|
||||
rm -rf "$WORK"; mkdir -p "$WORK/a" "$WORK/b"
|
||||
|
||||
PROC_JAR=$(find ~/.m2/repository/org/springframework/boot/spring-boot-configuration-processor \
|
||||
-name 'spring-boot-configuration-processor-*.jar' | sort | tail -1)
|
||||
CP=$("$MVN" -B -o -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout 2>/dev/null | tail -1)
|
||||
|
||||
SOURCES=$(find src/main/java -name '*.java')
|
||||
|
||||
{
|
||||
echo "== is the annotation processor discovered? =="
|
||||
echo
|
||||
java -version 2>&1 | clean | head -1
|
||||
echo "processor jar: $(basename "$PROC_JAR")"
|
||||
echo
|
||||
echo "A) processor on the classpath, javac defaults -- what an <optional> dependency gives you"
|
||||
echo "\$ javac -cp <deps>:spring-boot-configuration-processor.jar -d a \$SOURCES"
|
||||
javac -nowarn -cp "$PROC_JAR:$CP" -d "$WORK/a" $SOURCES 2>&1 | clean | head -3 || true
|
||||
echo " spring-configuration-metadata.json files produced: \
|
||||
$(find "$WORK/a" -name 'spring-configuration-metadata.json' | wc -l)"
|
||||
echo
|
||||
echo "B) identical, plus -proc:full"
|
||||
echo "\$ javac -proc:full -cp <deps>:spring-boot-configuration-processor.jar -d b \$SOURCES"
|
||||
javac -nowarn -proc:full -cp "$PROC_JAR:$CP" -d "$WORK/b" $SOURCES 2>&1 | clean | head -3 || true
|
||||
echo " spring-configuration-metadata.json files produced: \
|
||||
$(find "$WORK/b" -name 'spring-configuration-metadata.json' | wc -l)"
|
||||
echo
|
||||
echo "Both compilations succeed. Only one of them has metadata."
|
||||
echo
|
||||
echo "== what this project's pom does instead =="
|
||||
echo "The processor is declared as an <annotationProcessorPath> on maven-compiler-plugin,"
|
||||
echo "which puts it on javac's --processor-path where discovery is not disabled:"
|
||||
echo
|
||||
echo "\$ mvn clean package && ls target/classes/META-INF/"
|
||||
ls target/classes/META-INF/ 2>/dev/null || echo "(run mvn package first)"
|
||||
echo
|
||||
echo "== the generated metadata, first entries =="
|
||||
python3 -m json.tool target/classes/META-INF/spring-configuration-metadata.json 2>/dev/null \
|
||||
| head -45
|
||||
} > docs/output/05-metadata-generation.txt 2>&1
|
||||
cat docs/output/05-metadata-generation.txt
|
||||
9
configuration-properties/scripts/demo-relaxed-matrix.sh
Executable file
9
configuration-properties/scripts/demo-relaxed-matrix.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# The relaxed-binding matrix, generated by binding each spelling rather than by hand.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
java -jar "$JAR" --spring.profiles.active=probe --spring.main.web-application-type=none \
|
||||
2>&1 | clean | sed -n '/== relaxed binding matrix ==/,/MISS = the property/p' \
|
||||
> docs/output/01-relaxed-matrix.txt
|
||||
cat docs/output/01-relaxed-matrix.txt
|
||||
22
configuration-properties/scripts/demo-validation.sh
Executable file
22
configuration-properties/scripts/demo-validation.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# What a failing @Validated @ConfigurationProperties actually prints at startup.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== startup with demo.validated.* deliberately out of range =="
|
||||
echo "\$ java -jar $JAR --spring.profiles.active=badvalidation"
|
||||
echo
|
||||
java -jar "$JAR" --spring.profiles.active=badvalidation \
|
||||
--spring.main.web-application-type=none 2>&1 | clean \
|
||||
| sed -n '/APPLICATION FAILED TO START/,/^Update your application/p'
|
||||
echo
|
||||
echo
|
||||
echo "All four violations are reported at once, each with the file and line that supplied"
|
||||
echo "the value. The process refused to start rather than serving traffic with a pool size"
|
||||
echo "of 4000."
|
||||
echo
|
||||
echo "Note demo.validated.pool.size has no Origin line. It is a nested record reached"
|
||||
echo "through @Valid, and the binder tracks origins per bound property, not per constraint."
|
||||
} > docs/output/04-validation-failure.txt 2>&1
|
||||
cat docs/output/04-validation-failure.txt
|
||||
34
configuration-properties/scripts/demo-value-vs-binding.sh
Executable file
34
configuration-properties/scripts/demo-value-vs-binding.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Side by side: what the binder produced and what @Value produced, from one running process.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== @ConfigurationProperties vs @Value, same application, same application.yaml =="
|
||||
echo
|
||||
scripts/run.sh > /dev/null
|
||||
echo "\$ curl -s localhost:8080/diag/bound"
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" | python3 -m json.tool
|
||||
echo
|
||||
echo "demo.mail.recipients is a YAML block list. The binder produced both elements."
|
||||
echo "@Value produced an empty list -- placeholder resolution has no concept of a YAML"
|
||||
echo "sequence, so \${demo.mail.recipients:} fell through to its own empty default."
|
||||
echo
|
||||
echo "== the same list written as a comma-separated string, at higher precedence =="
|
||||
scripts/run.sh "" --demo.mail.recipients=x@e.com,y@e.com,z@e.com > /dev/null
|
||||
echo "\$ ./scripts/run.sh \"\" --demo.mail.recipients=x@e.com,y@e.com,z@e.com"
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/diag/bound" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(" binder :", d["mail"]["recipients"]); print(" @Value :", d["fromValueAnnotation"]["recipients"])'
|
||||
echo
|
||||
echo "Both see it. A comma-separated string is the one list shape @Value understands, and"
|
||||
echo "the command-line source outranks the YAML file for the binder as well."
|
||||
echo
|
||||
echo "== where did that value come from? =="
|
||||
echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.recipients'"
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.recipients" | python3 -m json.tool
|
||||
echo
|
||||
echo "\$ curl -s 'localhost:8080/diag/origin?name=demo.mail.host'"
|
||||
curl -s "http://127.0.0.1:${APP_PORT}/diag/origin?name=demo.mail.host" | python3 -m json.tool
|
||||
scripts/stop.sh
|
||||
} > docs/output/03-value-vs-binding.txt 2>&1
|
||||
cat docs/output/03-value-vs-binding.txt
|
||||
16
configuration-properties/scripts/demo-versions.sh
Executable file
16
configuration-properties/scripts/demo-versions.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exact versions every other transcript in this directory was produced against.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
{
|
||||
echo "== versions =="
|
||||
java -version 2>&1 | clean
|
||||
echo
|
||||
"$MVN" -B -o -q dependency:tree 2>/dev/null \
|
||||
| grep -E "spring-boot:jar|spring-core:jar|spring-context:jar|hibernate-validator:jar|jakarta.validation-api:jar" \
|
||||
| sed 's/^\[INFO\] //' || true
|
||||
echo
|
||||
echo "spring-boot-starter-parent: $(grep -A2 '<artifactId>spring-boot-starter-parent' pom.xml | grep '<version>' | sed 's/.*<version>\(.*\)<\/version>.*/\1/')"
|
||||
} > docs/output/00-versions.txt 2>&1
|
||||
cat docs/output/00-versions.txt
|
||||
13
configuration-properties/scripts/env.sh
Executable file
13
configuration-properties/scripts/env.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
|
||||
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
MVN="${MVN:-mvn}"
|
||||
JAR="target/configuration-properties-1.0.0.jar"
|
||||
APP_MAIN="com.ankurm.configprops.ConfigBindingApplication"
|
||||
APP_PORT="${APP_PORT:-8080}"
|
||||
|
||||
# Strip environment noise that is an artefact of the machine, not of Spring:
|
||||
# the JVM prints a JAVA_TOOL_OPTIONS banner to stderr on every launch when a proxy
|
||||
# truststore is configured, and it would otherwise end up in every committed transcript.
|
||||
clean() { grep -v "Picked up JAVA_TOOL_OPTIONS" | grep -v "^OpenJDK 64-Bit Server VM warning"; }
|
||||
16
configuration-properties/scripts/run-all.sh
Executable file
16
configuration-properties/scripts/run-all.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate every transcript under docs/output/.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
|
||||
"$MVN" -B -q package -DskipTests
|
||||
|
||||
for demo in versions relaxed-matrix env-binding value-vs-binding validation metadata-generation; do
|
||||
echo "=== $demo ==="
|
||||
"scripts/demo-$demo.sh" > /dev/null
|
||||
done
|
||||
scripts/stop.sh
|
||||
echo
|
||||
echo "regenerated:"
|
||||
ls -1 docs/output/
|
||||
34
configuration-properties/scripts/run.sh
Executable file
34
configuration-properties/scripts/run.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the application and block until it answers. Extra arguments are passed to the app,
|
||||
# so a scenario can add --demo.mail.recipients=a,b,c without a new profile.
|
||||
# ./scripts/run.sh # defaults
|
||||
# ./scripts/run.sh csvlist # a profile
|
||||
# ./scripts/run.sh "" --demo.x=y # no profile, one override
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/env.sh
|
||||
PROFILES="${1:-}"; shift || true
|
||||
LOG="${LOG:-/tmp/configprops-demo.log}"
|
||||
PIDFILE="${PIDFILE:-target/app.pid}"
|
||||
|
||||
scripts/stop.sh
|
||||
|
||||
ARGS=(-jar "$JAR")
|
||||
[ -n "$PROFILES" ] && ARGS+=("--spring.profiles.active=$PROFILES")
|
||||
ARGS+=("$@")
|
||||
|
||||
setsid nohup java "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
|
||||
echo $! > "$PIDFILE"
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${APP_PORT}/diag/bound" || true)
|
||||
[ "$code" = "200" ] && exit 0
|
||||
# If the JVM died -- most often because the port was still held -- fail fast and loudly
|
||||
# instead of letting curl answer from a process started by an earlier scenario.
|
||||
kill -0 "$(cat "$PIDFILE")" 2>/dev/null || { echo "JVM exited during startup:" >&2
|
||||
tail -25 "$LOG" >&2; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
echo "application did not answer; tail of $LOG:" >&2
|
||||
tail -40 "$LOG" >&2
|
||||
exit 1
|
||||
28
configuration-properties/scripts/stop.sh
Executable file
28
configuration-properties/scripts/stop.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stop the demo application.
|
||||
#
|
||||
# This uses a PID file rather than a pattern match, deliberately. `pkill -f spring-boot`
|
||||
# matches the shell that is running the script and takes the terminal with it. Even a
|
||||
# careful-looking `ps | grep '[c]onfiguration-properties'` matches the shell's own command
|
||||
# line whenever that string appears in the command you just typed -- which it does, because
|
||||
# you typed the jar name. Killing a recorded PID cannot misfire.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
PIDFILE="${PIDFILE:-target/app.pid}"
|
||||
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
pid=$(cat "$PIDFILE")
|
||||
# Confirm the PID is still ours before signalling it: PIDs are reused.
|
||||
if [ -n "$pid" ] && grep -qa "configuration-properties" "/proc/$pid/cmdline" 2>/dev/null; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$PIDFILE"
|
||||
fi
|
||||
|
||||
# Killing the process is not the same as the socket closing, and a stale listener looks
|
||||
# exactly like your configuration change having had no effect.
|
||||
for _ in $(seq 1 40); do
|
||||
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi
|
||||
sleep 0.25
|
||||
done
|
||||
exec 3<&- 2>/dev/null || true
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.configprops;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
/**
|
||||
* Companion application for the ankurm.com article
|
||||
* "@ConfigurationProperties vs @Value in Spring Boot 4".
|
||||
*
|
||||
* <p>{@code @ConfigurationPropertiesScan} is what registers the {@code @ConfigurationProperties}
|
||||
* types in {@code com.ankurm.configprops.props} as beans. Without it — and without
|
||||
* {@code @EnableConfigurationProperties} or a stereotype annotation on each type — the
|
||||
* classes compile, the application starts, and the beans simply do not exist. That is the first
|
||||
* entry in the failure gallery: see {@code docs/03-registration.md}.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class ConfigBindingApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ConfigBindingApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.configprops.props;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* Constructor binding with a record — the shape this article recommends.
|
||||
*
|
||||
* <p>A record has exactly one canonical constructor, so Spring Boot uses constructor binding
|
||||
* without any annotation. {@code @ConstructorBinding} is only needed to disambiguate when a
|
||||
* type has more than one candidate constructor.
|
||||
*
|
||||
* <p>A record cannot declare field initialisers, so "default value" has to be expressed with
|
||||
* {@link DefaultValue}. Leaving it off does not give you {@code null} for a primitive-like
|
||||
* type — it gives you a binding failure. See {@code docs/04-records-and-defaults.md}.
|
||||
*
|
||||
* @param host SMTP host. No default: absent means the application must not start.
|
||||
* @param port SMTP port, defaulted rather than left null.
|
||||
* @param timeout Bound from an ISO-8601 duration or a suffixed form such as {@code 30s}.
|
||||
* @param retries Nested record, bound from {@code demo.mail.retries.*}.
|
||||
* @param recipients Bound from a YAML list, an indexed property list, or a comma-separated string.
|
||||
* @param headers Bound from arbitrary sub-keys under {@code demo.mail.headers.*}.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "demo.mail")
|
||||
public record MailProperties(
|
||||
String host,
|
||||
@DefaultValue("587") int port,
|
||||
@DefaultValue("30s") Duration timeout,
|
||||
@DefaultValue RetryProperties retries,
|
||||
@DefaultValue List<String> recipients,
|
||||
@DefaultValue Map<String, String> headers) {
|
||||
|
||||
/**
|
||||
* Nested record. {@code @DefaultValue} on the enclosing component means this is
|
||||
* instantiated with its own defaults when {@code demo.mail.retries.*} is absent entirely,
|
||||
* rather than binding to {@code null}.
|
||||
*/
|
||||
public record RetryProperties(
|
||||
@DefaultValue("3") int maxAttempts,
|
||||
@DefaultValue("2s") Duration backoff) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.configprops.props;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* One property, bound by the relaxed binder, used to prove which spellings actually work.
|
||||
*
|
||||
* <p>The canonical form of this property is {@code demo.relaxed.api-key}. The relaxed binder
|
||||
* accepts several spellings of that name; {@code @Value} accepts exactly one. The matrix in
|
||||
* {@code docs/02-relaxed-binding.md} is generated by
|
||||
* {@code com.ankurm.configprops.web.RelaxedBindingProbe}, not written by hand.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "demo.relaxed")
|
||||
public record RelaxedProperties(@DefaultValue("(unset)") String apiKey) {
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.configprops.props;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Validation is the capability {@code @Value} does not have at all.
|
||||
*
|
||||
* <p>{@code @Validated} plus JSR-380 constraints turns a bad value into a startup failure with
|
||||
* a readable report, instead of a {@code NumberFormatException} somewhere in a request three
|
||||
* hours later. The failure text this produces is captured in
|
||||
* {@code docs/output/04-validation-failure.txt}.
|
||||
*
|
||||
* <p>{@code @Valid} on the nested component is written here because it is conventional, but
|
||||
* on this path it is not what makes the nested constraints run. Spring Boot's
|
||||
* {@code ValidationBindHandler} validates every object the binder finishes constructing, so
|
||||
* {@code demo.validated.pool.size} is checked with or without it. The rule that nested types
|
||||
* need {@code @Valid} comes from ordinary bean validation, where cascading is opt-in, and it
|
||||
* gets repeated about {@code @ConfigurationProperties} where it does not apply.
|
||||
* {@code BindingContractTests.nestedConstraintsFireWithoutValid} pins this. See
|
||||
* {@code docs/05-validation.md}.
|
||||
*/
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "demo.validated")
|
||||
public record ValidatedProperties(
|
||||
@NotBlank String name,
|
||||
@Min(1) @Max(65535) int port,
|
||||
@Pattern(regexp = "https?://.*") @DefaultValue("http://localhost") String endpoint,
|
||||
@Valid @DefaultValue Pool pool) {
|
||||
|
||||
public record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.configprops.props.MailProperties;
|
||||
import com.ankurm.configprops.props.ValidatedProperties;
|
||||
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.boot.origin.Origin;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Prints where a value actually came from, instead of only what it is.
|
||||
*
|
||||
* <p>"The property is set but the application does not see it" is the single most common
|
||||
* configuration bug, and the reason it is hard is that a value carries no visible provenance.
|
||||
* Spring Boot tracks it anyway: every property loaded from a file or a config tree carries an
|
||||
* {@link Origin} naming the file and the line. This endpoint surfaces it.
|
||||
*
|
||||
* <p>Documented in {@code docs/08-diagnosing-a-value.md}. Delete it before shipping — it
|
||||
* will happily print a password that came from a mounted secret.
|
||||
*/
|
||||
@RestController
|
||||
public class BindingDiagnosticsEndpoint {
|
||||
|
||||
private final ConfigurableEnvironment environment;
|
||||
private final MailProperties mail;
|
||||
private final ValidatedProperties validated;
|
||||
private final ValueHolder valueHolder;
|
||||
|
||||
public BindingDiagnosticsEndpoint(ConfigurableEnvironment environment, MailProperties mail,
|
||||
ValidatedProperties validated, ValueHolder valueHolder) {
|
||||
this.environment = environment;
|
||||
this.mail = mail;
|
||||
this.validated = validated;
|
||||
this.valueHolder = valueHolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every property source that can supply the given name, in precedence order, with the
|
||||
* value each one holds and where that value was written.
|
||||
*
|
||||
* <p>The first row is the winner. Rows below it are values that exist and lose —
|
||||
* which is what a "my change had no effect" bug looks like from the inside.
|
||||
*/
|
||||
@GetMapping("/diag/origin")
|
||||
public Map<String, Object> origin(
|
||||
@RequestParam(defaultValue = "demo.mail.host") String name) {
|
||||
|
||||
ConfigurationPropertyName propertyName = ConfigurationPropertyName.of(name);
|
||||
List<Map<String, Object>> candidates = new ArrayList<>();
|
||||
|
||||
for (ConfigurationPropertySource source : ConfigurationPropertySources.get(environment)) {
|
||||
var property = source.getConfigurationProperty(propertyName);
|
||||
if (property == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("source", String.valueOf(source.getUnderlyingSource()));
|
||||
row.put("value", String.valueOf(property.getValue()));
|
||||
row.put("origin", String.valueOf(property.getOrigin()));
|
||||
candidates.add(row);
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("property", name);
|
||||
result.put("effectiveValue", environment.getProperty(name));
|
||||
result.put("candidatesInPrecedenceOrder", candidates);
|
||||
result.put("shadowedBy", candidates.size() > 1 ? candidates.getFirst().get("source") : null);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The bound objects, so the article can show a record's contents rather than describe them. */
|
||||
@GetMapping("/diag/bound")
|
||||
public Map<String, Object> bound() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("mail", mail);
|
||||
result.put("validated", validated);
|
||||
|
||||
Map<String, Object> fromValue = new LinkedHashMap<>();
|
||||
fromValue.put("host", valueHolder.host());
|
||||
fromValue.put("port", valueHolder.port());
|
||||
fromValue.put("timeout", valueHolder.timeout().toString());
|
||||
fromValue.put("recipients", valueHolder.recipients());
|
||||
fromValue.put("poolSize", valueHolder.poolSize());
|
||||
fromValue.put("computedThreads", valueHolder.computedThreads());
|
||||
result.put("fromValueAnnotation", fromValue);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import com.ankurm.configprops.props.RelaxedProperties;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The same question as {@link RelaxedBindingProbe}, but asked of a real process with a real
|
||||
* operating-system environment variable rather than a synthesised property source.
|
||||
*
|
||||
* <p>{@code scripts/demo-env-binding.sh} runs this once per spelling. It exists because the
|
||||
* synthesised answer was surprising enough to be worth re-checking against reality: the
|
||||
* spelling most articles recommend does not bind.
|
||||
*
|
||||
* <p>{@code @Value} here carries a default so that a miss is reported rather than crashing
|
||||
* the process — the difference between the two columns is the point.
|
||||
*/
|
||||
@Component
|
||||
@Profile("envprobe")
|
||||
public class RealEnvironmentProbe implements CommandLineRunner {
|
||||
|
||||
private final RelaxedProperties bound;
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${demo.relaxed.api-key:(MISS)}")
|
||||
private String viaValue;
|
||||
|
||||
public RealEnvironmentProbe(RelaxedProperties bound, Environment environment) {
|
||||
this.bound = bound;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
System.out.println("env-var-set : " + describeEnv());
|
||||
System.out.println("@ConfigurationProps: " + bound.apiKey());
|
||||
System.out.println("@Value : " + viaValue);
|
||||
System.out.println("Environment.get : "
|
||||
+ environment.getProperty("demo.relaxed.api-key", "(MISS)"));
|
||||
}
|
||||
|
||||
private String describeEnv() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
System.getenv().forEach((k, v) -> {
|
||||
if (k.toUpperCase().startsWith("DEMO_RELAXED") || k.toUpperCase().startsWith("DEMO.")) {
|
||||
sb.append(k).append('=').append(v).append(' ');
|
||||
}
|
||||
});
|
||||
return sb.isEmpty() ? "(none)" : sb.toString().trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.configprops.props.RelaxedProperties;
|
||||
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.boot.context.properties.bind.BindResult;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.env.SystemEnvironmentPropertySource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Generates the relaxed-binding matrix in {@code docs/02-relaxed-binding.md} by actually
|
||||
* binding each spelling, rather than by quoting the reference documentation.
|
||||
*
|
||||
* <p>For every spelling of the canonical property {@code demo.relaxed.api-key} the probe asks
|
||||
* three questions, and the three answers are not the same:
|
||||
*
|
||||
* <ol>
|
||||
* <li><strong>BINDER</strong> — does {@link Binder}, the engine behind
|
||||
* {@code @ConfigurationProperties}, resolve it?</li>
|
||||
* <li><strong>${} plain</strong> — does {@code ${demo.relaxed.api-key}} resolve against
|
||||
* a bare {@link StandardEnvironment}? This is placeholder resolution as the Spring
|
||||
* Framework alone defines it.</li>
|
||||
* <li><strong>${} boot</strong> — does the same placeholder resolve once
|
||||
* {@link ConfigurationPropertySources#attach} has been called? Spring Boot calls it on
|
||||
* every environment it prepares, which quietly gives {@code @Value} relaxed resolution
|
||||
* that plain Spring does not have.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>That third column is the one worth knowing about. "{@code @Value} does not support relaxed
|
||||
* binding" is repeated everywhere and is true of the Spring Framework; inside a Spring Boot
|
||||
* application it is not, and the difference is one method call made on your behalf during
|
||||
* environment preparation.
|
||||
*
|
||||
* <p>Two harness details were paid for in wrong answers and are worth stating:
|
||||
* the system-environment source must be <em>named</em>
|
||||
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME} or Boot maps it with the
|
||||
* ordinary rules, and a bare {@code StandardEnvironment} is not what a Boot application runs
|
||||
* with. Both produced plausible, publishable, incorrect results before being checked against a
|
||||
* real process by {@code scripts/demo-env-binding.sh}.
|
||||
*
|
||||
* <p>Activated by the {@code probe} profile so it does not run during the other demos.
|
||||
*/
|
||||
@Component
|
||||
@Profile("probe")
|
||||
public class RelaxedBindingProbe implements CommandLineRunner {
|
||||
|
||||
/** The one canonical name. Everything else in the matrix is a spelling of this. */
|
||||
private static final String CANONICAL = "demo.relaxed.api-key";
|
||||
|
||||
private static final List<String> MAP_SPELLINGS = List.of(
|
||||
"demo.relaxed.api-key", // kebab-case, the canonical form
|
||||
"demo.relaxed.apiKey", // camelCase
|
||||
"demo.relaxed.api_key", // underscore notation
|
||||
"demo.relaxed.APIKEY", // upper case, no separator
|
||||
"DEMO.RELAXED.API-KEY", // upper case with separators
|
||||
"demo.relaxed.apikey", // no separator at all
|
||||
"demo.relaxed.api.key"); // dot as separator -- a DIFFERENT property
|
||||
|
||||
private static final List<String> ENV_SPELLINGS = List.of(
|
||||
"DEMO_RELAXED_API_KEY", // underscore for every separator -- the safe form
|
||||
"DEMO_RELAXED_APIKEY", // word joined up
|
||||
"demo_relaxed_api_key", // lower case underscores
|
||||
"DEMO.RELAXED.API-KEY"); // dots and dashes, which most shells reject anyway
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
System.out.println("== relaxed binding matrix ==");
|
||||
System.out.println("canonical property: " + CANONICAL);
|
||||
System.out.println();
|
||||
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n",
|
||||
"SPELLING", "SOURCE", "BINDER", "${} plain", "${} boot");
|
||||
System.out.println("-".repeat(62));
|
||||
|
||||
for (String spelling : MAP_SPELLINGS) {
|
||||
report(spelling, "map", () -> mapEnvironment(spelling));
|
||||
}
|
||||
for (String spelling : ENV_SPELLINGS) {
|
||||
report(spelling, "env", () -> envEnvironment(spelling));
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.println("BINDER = what @ConfigurationProperties sees (Binder)");
|
||||
System.out.println("${} plain = placeholder resolution against a bare StandardEnvironment");
|
||||
System.out.println("${} boot = the same, after ConfigurationPropertySources.attach(env),");
|
||||
System.out.println(" which Spring Boot performs on every environment it prepares");
|
||||
System.out.println("bound = the value arrived; MISS = the property was not found");
|
||||
}
|
||||
|
||||
private void report(String spelling, String kind,
|
||||
java.util.function.Supplier<StandardEnvironment> factory) {
|
||||
|
||||
StandardEnvironment forBinder = factory.get();
|
||||
BindResult<RelaxedProperties> result =
|
||||
Binder.get(forBinder).bind("demo.relaxed", RelaxedProperties.class);
|
||||
String binder = result.isBound() && !"(unset)".equals(result.get().apiKey())
|
||||
? "bound" : "MISS";
|
||||
|
||||
String plain = placeholder(factory.get());
|
||||
|
||||
StandardEnvironment attached = factory.get();
|
||||
ConfigurationPropertySources.attach(attached);
|
||||
String boot = placeholder(attached);
|
||||
|
||||
System.out.printf("%-24s %-6s %-8s %-10s %-10s%n", spelling, kind, binder, plain, boot);
|
||||
}
|
||||
|
||||
private String placeholder(StandardEnvironment environment) {
|
||||
try {
|
||||
environment.resolveRequiredPlaceholders("${" + CANONICAL + "}");
|
||||
return "bound";
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return "MISS";
|
||||
}
|
||||
}
|
||||
|
||||
/** An environment whose only property source behaves like a properties or YAML file. */
|
||||
private StandardEnvironment mapEnvironment(String spelling) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(
|
||||
new MapPropertySource("probe", Map.of(spelling, "from-" + spelling)));
|
||||
return environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* An environment whose only property source is a {@link SystemEnvironmentPropertySource}.
|
||||
*
|
||||
* <p>The source <strong>must</strong> carry the name
|
||||
* {@link StandardEnvironment#SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME}: Spring Boot decides
|
||||
* which name mapper applies by comparing the source's name against that constant, not by
|
||||
* checking its type. A {@code SystemEnvironmentPropertySource} called anything else is
|
||||
* mapped with the ordinary rules and the underscore spellings stop resolving.
|
||||
*/
|
||||
private StandardEnvironment envEnvironment(String spelling) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
Map<String, Object> entries = new LinkedHashMap<>();
|
||||
entries.put(spelling, "from-" + spelling);
|
||||
environment.getPropertySources().addFirst(new SystemEnvironmentPropertySource(
|
||||
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, entries));
|
||||
return environment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ankurm.configprops.web;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* The {@code @Value} side of the comparison, written the way people actually write it.
|
||||
*
|
||||
* <p>Everything here works. The point of the class is what it costs to make it work, and what
|
||||
* it still cannot do:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Every field repeats the property name as a string literal. Rename the property and the
|
||||
* compiler says nothing.</li>
|
||||
* <li>The name has to be the exact canonical form. {@code ${demo.mail.apiKey}} does not find
|
||||
* {@code demo.mail.api-key} — proved by
|
||||
* {@link RelaxedBindingProbe}.</li>
|
||||
* <li>There is no validation. {@code :-1} below is accepted silently.</li>
|
||||
* <li>A missing property without a {@code :default} is a startup failure whose message names
|
||||
* the field, not the property's purpose.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>What {@code @Value} does have that binding does not: SpEL. {@code #{...}} can compute a
|
||||
* value; {@code @ConfigurationProperties} only maps one. That is the honest reason to keep it.
|
||||
* See {@code docs/06-when-value-still-wins.md}.
|
||||
*/
|
||||
@Component
|
||||
public class ValueHolder {
|
||||
|
||||
/** Exact canonical name required -- no relaxed spelling is accepted here. */
|
||||
@Value("${demo.mail.host}")
|
||||
private String host;
|
||||
|
||||
/** The ":" default is the only defaulting mechanism @Value has. */
|
||||
@Value("${demo.mail.port:587}")
|
||||
private int port;
|
||||
|
||||
/** Conversion works: the ConversionService is shared with the binder. */
|
||||
@Value("${demo.mail.timeout:30s}")
|
||||
private Duration timeout;
|
||||
|
||||
/**
|
||||
* A comma-separated string splits into a List. A YAML block list does NOT --
|
||||
* that is one of the concrete gaps captured in docs/output/03-value-vs-binding.txt.
|
||||
*/
|
||||
@Value("${demo.mail.recipients:}")
|
||||
private List<String> recipients;
|
||||
|
||||
/** No constraint is applied. A negative pool size is simply a negative pool size. */
|
||||
@Value("${demo.validated.pool.size:-1}")
|
||||
private int poolSize;
|
||||
|
||||
/** SpEL -- the capability @ConfigurationProperties genuinely does not have. */
|
||||
@Value("#{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}")
|
||||
private int computedThreads;
|
||||
|
||||
public String host() { return host; }
|
||||
public int port() { return port; }
|
||||
public Duration timeout() { return timeout; }
|
||||
public List<String> recipients() { return recipients; }
|
||||
public int poolSize() { return poolSize; }
|
||||
public int computedThreads() { return computedThreads; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
demo:
|
||||
validated:
|
||||
# @NotBlank -- blank is exactly what it rejects.
|
||||
name: " "
|
||||
# @Min(1) @Max(65535)
|
||||
port: 99999
|
||||
# @Pattern(regexp = "https?://.*")
|
||||
endpoint: "ftp://files.example.com"
|
||||
pool:
|
||||
# @Min(1) @Max(100) on a nested record. Only checked because the component
|
||||
# carries @Valid; without it this passes.
|
||||
size: 4000
|
||||
@@ -0,0 +1,7 @@
|
||||
# Overrides the YAML block list in application.yaml with a comma-separated string.
|
||||
# demo.csvlist.loaded is a marker so the transcript can prove this file was read.
|
||||
demo:
|
||||
csvlist:
|
||||
loaded: "yes"
|
||||
mail:
|
||||
recipients: "ops@example.com,oncall@example.com,sre@example.com"
|
||||
37
configuration-properties/src/main/resources/application.yaml
Normal file
37
configuration-properties/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
spring:
|
||||
application:
|
||||
name: configuration-properties
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.ankurm: INFO
|
||||
|
||||
demo:
|
||||
mail:
|
||||
host: smtp.example.com
|
||||
# port omitted on purpose: @DefaultValue("587") supplies it.
|
||||
timeout: 45s
|
||||
retries:
|
||||
max-attempts: 5
|
||||
backoff: 1500ms
|
||||
# A YAML block list. @ConfigurationProperties binds this; @Value("${demo.mail.recipients}")
|
||||
# does not -- see docs/output/03-value-vs-binding.txt.
|
||||
recipients:
|
||||
- ops@example.com
|
||||
- oncall@example.com
|
||||
headers:
|
||||
X-Env: demo
|
||||
X-Team: platform
|
||||
# demo.relaxed.api-key is deliberately NOT set here. scripts/demo-env-binding.sh supplies
|
||||
# it as an operating-system environment variable, one spelling at a time, and a value in
|
||||
# this file would mask the env var and make every row of the matrix say "bound".
|
||||
validated:
|
||||
name: demo-service
|
||||
port: 8443
|
||||
endpoint: https://api.example.com
|
||||
pool:
|
||||
size: 25
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.ankurm.configprops;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
import com.ankurm.configprops.props.MailProperties;
|
||||
import com.ankurm.configprops.props.RelaxedProperties;
|
||||
import com.ankurm.configprops.props.ValidatedProperties;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.BindException;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
import org.springframework.boot.context.properties.bind.validation.BindValidationException;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Contract tests for the claims the article makes. Each one pins a behaviour that would
|
||||
* otherwise be an assertion in prose.
|
||||
*/
|
||||
class BindingContractTests {
|
||||
|
||||
private static Binder binderFor(Map<String, Object> properties) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("test", properties));
|
||||
return Binder.get(environment);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("kebab-case, camelCase, underscores and no separator are all the same property")
|
||||
void relaxedSpellingsAreEquivalent() {
|
||||
for (String spelling : new String[] {
|
||||
"demo.relaxed.api-key", "demo.relaxed.apiKey",
|
||||
"demo.relaxed.api_key", "demo.relaxed.apikey", "demo.relaxed.APIKEY" }) {
|
||||
RelaxedProperties bound = binderFor(Map.of(spelling, "v"))
|
||||
.bind("demo.relaxed", RelaxedProperties.class).get();
|
||||
assertThat(bound.apiKey()).as(spelling).isEqualTo("v");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a dot is a name separator, so api.key is a different property from api-key")
|
||||
void dotIsNotAWordSeparator() {
|
||||
// Nothing under the prefix matches, so the bind produces no result at all. Note this
|
||||
// is *not* an all-defaults object: @DefaultValue applies to a component of an object
|
||||
// that is being constructed, and here no object is constructed.
|
||||
assertThat(binderFor(Map.of("demo.relaxed.api.key", "v"))
|
||||
.bind("demo.relaxed", RelaxedProperties.class).isBound()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a record binds through its canonical constructor with no annotation")
|
||||
void recordConstructorBinding() {
|
||||
MailProperties bound = binderFor(Map.of(
|
||||
"demo.mail.host", "smtp.test",
|
||||
"demo.mail.retries.max-attempts", "9"))
|
||||
.bind("demo.mail", MailProperties.class).get();
|
||||
|
||||
assertThat(bound.host()).isEqualTo("smtp.test");
|
||||
assertThat(bound.port()).as("@DefaultValue supplied it").isEqualTo(587);
|
||||
assertThat(bound.timeout()).isEqualTo(Duration.ofSeconds(30));
|
||||
assertThat(bound.retries().maxAttempts()).isEqualTo(9);
|
||||
assertThat(bound.retries().backoff()).as("nested default").isEqualTo(Duration.ofSeconds(2));
|
||||
assertThat(bound.recipients()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a component with no value and no @DefaultValue binds to null, not to a failure")
|
||||
void absentPropertyWithoutDefault() {
|
||||
MailProperties bound = binderFor(Map.of("demo.mail.port", "25"))
|
||||
.bind("demo.mail", MailProperties.class).get();
|
||||
assertThat(bound.host()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("@Validated turns a bad value into a BindValidationException")
|
||||
void validationRejectsBadValues() {
|
||||
assertThatExceptionOfType(BindException.class)
|
||||
.isThrownBy(() -> binderFor(Map.of(
|
||||
"demo.validated.name", "ok",
|
||||
"demo.validated.port", "99999"))
|
||||
.bind("demo.validated", org.springframework.boot.context.properties.bind
|
||||
.Bindable.of(ValidatedProperties.class),
|
||||
new org.springframework.boot.context.properties.bind.validation
|
||||
.ValidationBindHandler(validator()))
|
||||
.get())
|
||||
.withCauseInstanceOf(BindValidationException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contradicts a widely repeated rule.
|
||||
*
|
||||
* <p>The advice "put {@code @Valid} on nested {@code @ConfigurationProperties} types or
|
||||
* their constraints are ignored" is true of ordinary bean validation, where cascading is
|
||||
* opt-in. It is not true of the binder. {@code ValidationBindHandler} validates every
|
||||
* object it finishes binding, nested ones included, so the constraint below fires with no
|
||||
* {@code @Valid} anywhere in the type.
|
||||
*
|
||||
* <p>This test was originally written to assert the opposite and failed, which is how the
|
||||
* article came to say the opposite of what most of its neighbours say.
|
||||
*/
|
||||
@Test
|
||||
@DisplayName("nested constraints are evaluated even without @Valid")
|
||||
void nestedConstraintsFireWithoutValid() {
|
||||
assertThatExceptionOfType(BindException.class)
|
||||
.isThrownBy(() -> binderFor(Map.of("demo.unchecked.pool.size", "4000"))
|
||||
.bind("demo.unchecked", org.springframework.boot.context.properties.bind
|
||||
.Bindable.of(Unchecked.class),
|
||||
new org.springframework.boot.context.properties.bind.validation
|
||||
.ValidationBindHandler(validator()))
|
||||
.get())
|
||||
.withCauseInstanceOf(BindValidationException.class)
|
||||
.withMessageContaining("demo.unchecked");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Boot's attached property source gives ${} the same relaxed rules as the binder")
|
||||
void attachGivesPlaceholdersRelaxedResolution() {
|
||||
StandardEnvironment plain = new StandardEnvironment();
|
||||
plain.getPropertySources()
|
||||
.addFirst(new MapPropertySource("test", Map.of("demo.relaxed.apiKey", "v")));
|
||||
|
||||
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
|
||||
.as("plain Spring: no relaxed placeholder resolution")
|
||||
.isEqualTo("MISS");
|
||||
|
||||
ConfigurationPropertySources.attach(plain);
|
||||
assertThat(plain.resolvePlaceholders("${demo.relaxed.api-key:MISS}"))
|
||||
.as("Spring Boot attaches this source on every environment it prepares")
|
||||
.isEqualTo("v");
|
||||
}
|
||||
|
||||
private static LocalValidatorFactoryBean validator() {
|
||||
LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
|
||||
factory.afterPropertiesSet();
|
||||
return factory;
|
||||
}
|
||||
|
||||
/** Deliberately missing {@code @Valid} on the nested component. */
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "demo.unchecked")
|
||||
record Unchecked(@DefaultValue Pool pool) {
|
||||
record Pool(@Min(1) @Max(100) @DefaultValue("10") int size) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user