Skip to content

Resolve placeholders at map level before YAML/JSON serialization#3235

Merged
ryanjbaxter merged 4 commits into
spring-cloud:4.3.xfrom
gabrielschulhof:fix/yaml-multiline-placeholder-resolution
May 28, 2026
Merged

Resolve placeholders at map level before YAML/JSON serialization#3235
ryanjbaxter merged 4 commits into
spring-cloud:4.3.xfrom
gabrielschulhof:fix/yaml-multiline-placeholder-resolution

Conversation

@gabrielschulhof
Copy link
Copy Markdown
Contributor

@gabrielschulhof gabrielschulhof commented May 22, 2026

Instead of resolving placeholders by string-manipulating serialized YAML/JSON output, resolve them in the property Map before passing to the serializer. This lets SnakeYAML and Jackson handle multiline values and escaping natively, eliminating the need for:

  • OutputFormat enum
  • resolveYamlPlaceholders / resolveAsBlockScalar / isStandalonePlaceholder
  • resolvePlaceholdersWithTransform with JsonStringEncoder
  • joinMultiLinePlaceholders

Add resolveMapPlaceholders() which recursively walks Maps and Lists, resolving ${...} in String values via PropertyPlaceholderHelper with key trimming (handles SnakeYAML-folded multi-line placeholders).

The text-based resolvePlaceholders() remains for .properties output.

Re: #3234

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes invalid YAML/JSON emitted by the Config Server when resolving placeholders that expand to multiline values, by making placeholder resolution output-format aware.

Changes:

  • Introduces EnvironmentPropertySource.OutputFormat and a format-aware resolvePlaceholders(...) overload.
  • Updates EnvironmentController YAML/JSON endpoints to resolve placeholders using the appropriate format behavior (YAML block scalars, JSON newline escaping).
  • Adds unit/integration tests covering multiline placeholder expansion in YAML and JSON responses.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/support/EnvironmentPropertySource.java Adds OutputFormat and new YAML/JSON-specific placeholder resolution logic (block scalars / JSON escaping).
spring-cloud-config-server/src/main/java/org/springframework/cloud/config/server/environment/EnvironmentController.java Routes YAML/JSON output through the new format-aware placeholder resolution.
spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/support/EnvironmentPropertySourceTest.java Adds focused unit tests for YAML block scalar and JSON escaping behavior.
spring-cloud-config-server/src/test/java/org/springframework/cloud/config/server/environment/EnvironmentControllerTests.java Adds controller-level tests verifying resolved multiline values round-trip correctly in YAML/JSON.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@gabrielschulhof gabrielschulhof force-pushed the fix/yaml-multiline-placeholder-resolution branch from 28af6c1 to 655a49c Compare May 26, 2026 19:57
@ryanjbaxter
Copy link
Copy Markdown
Contributor

I think it would be better to try and resolve these placeholders before we convert it to a string.

For example in labelledYaml we could do something like

Map<String, Object> result = convertToMap(environment);
if (resolvePlaceholders) {
   resolveMapPlaceholders(result, prepareEnvironment(environment)); 
}
String yaml = new Yaml().dumpAsMap(result);
private static void resolveMapPlaceholders(Map<String, Object> map, StandardEnvironment env) {
    for (Map.Entry<String, Object> entry : map.entrySet()) {
        Object value = entry.getValue();
        if (value instanceof String) {
            entry.setValue(resolvePlaceholders(env, (String) value));
        } else if (value instanceof Map) {
            resolveMapPlaceholders((Map<String, Object>) value, env);
        } else if (value instanceof List) {
            resolveListPlaceholders((List<Object>) value, env);
        }
    }
}

@gabrielschulhof gabrielschulhof force-pushed the fix/yaml-multiline-placeholder-resolution branch 3 times, most recently from 5ced426 to e1e6556 Compare May 26, 2026 22:15
@gabrielschulhof gabrielschulhof changed the title Fix multiline placeholder resolution in YAML and JSON output Resolve placeholders at map level before YAML/JSON serialization May 26, 2026
@gabrielschulhof
Copy link
Copy Markdown
Contributor Author

@ryanjbaxter I figured it would be better to do the resolution when the data is still structured, but I wasn't sure. Thanks for the steer!

Copy link
Copy Markdown
Contributor

@ryanjbaxter ryanjbaxter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a test for default values (${key:default}) and ignoreUnresolvablePlaceholders=true with a key ${MISSING} in EnvironmentPropertySourceTest?

I think these changes should target the 4.3.x branch and I can merge it forward into main as well.

Instead of resolving placeholders by string-manipulating serialized
YAML/JSON output, resolve them in the property Map before passing to
the serializer. This lets SnakeYAML and Jackson handle multiline values
and escaping natively, eliminating the need for:
- OutputFormat enum
- resolveYamlPlaceholders / resolveAsBlockScalar / isStandalonePlaceholder
- resolvePlaceholdersWithTransform with JsonStringEncoder
- joinMultiLinePlaceholders

Add resolveMapPlaceholders() which recursively walks Maps and Lists,
resolving ${...} in String values via PropertyPlaceholderHelper with
key trimming (handles SnakeYAML-folded multi-line placeholders).

The text-based resolvePlaceholders() remains for .properties output.

Re: spring-cloud#3234
Signed-off-by: Gabriel Schulhof <gschulhof@auction.com>
@gabrielschulhof gabrielschulhof force-pushed the fix/yaml-multiline-placeholder-resolution branch from e1e6556 to b7ac585 Compare May 27, 2026 17:18
@gabrielschulhof gabrielschulhof changed the base branch from main to 4.3.x May 27, 2026 17:19
Signed-off-by: Gabriel Schulhof <gschulhof@auction.com>
@gabrielschulhof gabrielschulhof force-pushed the fix/yaml-multiline-placeholder-resolution branch from b7ac585 to df93bd7 Compare May 27, 2026 17:19
@gabrielschulhof
Copy link
Copy Markdown
Contributor Author

@ryanjbaxter I have addressed your review comments. Please take another look!

public class EnvironmentPropertySource extends PropertySource<Environment> {

// Use PropertyPlaceholderHelper directly to trim whitespace from keys
private static final PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", ":", null, true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use snake yaml casing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ryanjbaxter I'm sorry! I don't quite understand. Where should I use such casing?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I meant upper snake case, ie. PROPERTY_PLACEHOLDER_HELPER

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying! Fixed.


// Verify YAML is valid and values are resolved
Map<String, Object> reparsed = new Yaml().load(yaml);
java.util.List<Object> items = (java.util.List<Object>) reparsed.get("items");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use imports

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about that! Forgot to commit. These should all be using imports now.

@Test
public void defaultValueUsedWhenKeyMissing() {
Environment environment = new Environment("test", "default");
java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use imports

environment.add(new PropertySource("one", map));
StandardEnvironment prepared = prepareEnvironment(environment);

Map<String, Object> input = new java.util.LinkedHashMap<>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use imports

@Test
public void defaultValueOverriddenWhenKeyExists() {
Environment environment = new Environment("test", "default");
java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use imports

environment.add(new PropertySource("one", map));
StandardEnvironment prepared = prepareEnvironment(environment);

Map<String, Object> input = new java.util.LinkedHashMap<>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use imports

@Test
public void unresolvablePlaceholderLeftIntact() {
Environment environment = new Environment("test", "default");
java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use imports

environment.add(new PropertySource("one", map));
StandardEnvironment prepared = prepareEnvironment(environment);

Map<String, Object> input = new java.util.LinkedHashMap<>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use imports

Signed-off-by: Gabriel Schulhof <gschulhof@auction.com>
Signed-off-by: Gabriel Schulhof <gschulhof@auction.com>
@ryanjbaxter ryanjbaxter merged commit c8364f7 into spring-cloud:4.3.x May 28, 2026
2 checks passed
@ryanjbaxter ryanjbaxter linked an issue May 28, 2026 that may be closed by this pull request
@gabrielschulhof gabrielschulhof deleted the fix/yaml-multiline-placeholder-resolution branch May 28, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reference to multiline string rendered incorrectly in output

4 participants