Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[2.x] Add config enum mapping support #5788

Merged
merged 1 commit into from
Jan 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions config/config/src/main/java/io/helidon/config/BuilderImpl.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2017, 2021 Oracle and/or its affiliates.
* Copyright (c) 2017, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -463,7 +463,12 @@ static List<ConfigParser> buildParsers(boolean servicesEnabled, List<ConfigParse

// this is a unit test method
static ConfigMapperManager buildMappers(MapperProviders userDefinedProviders) {
return buildMappers(new ArrayList<>(), userDefinedProviders, false);
return buildMappers(userDefinedProviders, false);
}

// unit test method
static ConfigMapperManager buildMappers(MapperProviders userDefinedProviders, boolean mapperServicesEnabled) {
return buildMappers(new ArrayList<>(), userDefinedProviders, mapperServicesEnabled);
}

static ConfigMapperManager buildMappers(List<PrioritizedMapperProvider> prioritizedMappers,
Expand Down Expand Up @@ -493,6 +498,7 @@ static ConfigMapperManager buildMappers(List<PrioritizedMapperProvider> prioriti
private static void loadMapperServices(List<PrioritizedMapperProvider> providers) {
HelidonServiceLoader.builder(ServiceLoader.load(ConfigMapperProvider.class))
.defaultPriority(ConfigMapperProvider.PRIORITY)
.addService(new EnumMapperProvider(), EnumMapperProvider.PRIORITY)
.build()
.forEach(mapper -> providers.add(new HelidonMapperWrapper(mapper, Priorities.find(mapper, 100))));
}
Expand Down
111 changes: 111 additions & 0 deletions config/config/src/main/java/io/helidon/config/EnumMapperProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.helidon.config;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;

import io.helidon.config.spi.ConfigMapperProvider;

/**
* Built-in mapper for {@code enum}s.
*
* <p>
* This mapper attempts to match strings in the config source to enum values as follows:
* <ul>
* <li>The mapper treats hyphens ('-') in config strings as underscores when comparing to enum value names.</li>
* <li>If the matcher finds a <em>case-sensitive</em> match with an enum value name, then that enum value matches.</li>
* <li>If the matcher finds exactly one <em>case-insensitive</em> match, that enum value matches.</li>
* <li>If the matcher finds no matches or multiple matches, throw a
* {@link io.helidon.config.ConfigMappingException} with a message explaining the problem.</li>
* </ul>
* These conversions are intended to maximize ease-of-use for authors of config sources so the values need not be
* upper-cased nor punctuated with underscores rather than the more conventional (in config at least) hyphen.
* </p>
* <p>
* The only hardship this imposes is if a confusingly-designed enum has values which differ only in case <em>and</em> the
* string in the config source does not exactly match one of the enum value names. In such cases
* the mapper will be unable to choose which enum value matches an ambiguous string. A developer faced with this
* problem can simply provide her own explicit config mapping for that enum, for instance as a function parameter to
* {@code Config#as}.
* </p>
*
*/
class EnumMapperProvider implements ConfigMapperProvider {

/**
* Priority with which the enum mapper provider is added to the collection of providers (user- and Helidon-provided).
*/
static final int PRIORITY = 10000;

@Override
public Map<Class<?>, Function<Config, ?>> mappers() {
return Map.of();
}

@Override
public <T> Optional<Function<Config, T>> mapper(Class<T> type) {
if (!type.isEnum()) {
return Optional.empty();
}

return Optional.of(enumMapper((Class<Enum<?>>) type));
}

private <T> Function<Config, T> enumMapper(Class<Enum<?>> enumType) {
return config -> {
if (!config.hasValue() || !config.exists()) {
throw MissingValueException.create(config.key());
}
if (!config.isLeaf()) {
throw new ConfigMappingException(config.key(), enumType, "config node must be a leaf but is not");
}
String value = config.asString().get();
String convertedValue = value.replace('-', '_');
List<Enum<?>> caseInsensitiveMatches = new ArrayList<>();
for (Enum<?> candidate : enumType.getEnumConstants()) {
// Check for an exact match first, with or without hyphen conversion.
if (candidate.name().equals(convertedValue) || candidate.name().equals(value)) {
return (T) candidate;
}
if (candidate.name().equalsIgnoreCase(value) || candidate.name().equalsIgnoreCase(convertedValue)) {
caseInsensitiveMatches.add(candidate);
}
}
if (caseInsensitiveMatches.size() == 1) {
return (T) caseInsensitiveMatches.get(0);
}

String problem;
if (caseInsensitiveMatches.size() == 0) {
problem = "no match";
} else {
problem = "ambiguous matches with " + caseInsensitiveMatches;
}

throw new ConfigMappingException(config.key(),
enumType,
String.format("cannot map value '%s' to enum values %s: %s",
value,
Arrays.asList(enumType.getEnumConstants()),
problem));
};
}
}
118 changes: 117 additions & 1 deletion config/config/src/test/java/io/helidon/config/ConfigMappersTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2017, 2020 Oracle and/or its affiliates.
* Copyright (c) 2017, 2023 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -57,6 +57,10 @@

import org.junit.jupiter.api.Test;

import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.hasEntry;
import static org.hamcrest.Matchers.hasItems;
Expand Down Expand Up @@ -242,6 +246,96 @@ public void testBuiltinMappersSubNodeProperties() throws MalformedURLException {
assertThat(key2Properties, hasEntry("key2.key23", "value23"));
}

@Test
void testBuiltinEnumMapper() {
ConfigMapperManager manager = BuilderImpl.buildMappers(ConfigMapperManager.MapperProviders.create(), true);
Config config = createEnumConfig();
MyType type = manager.map(config.get("type-a"), MyType.class);
assertThat("Lower-case mapping", type, is(MyType.A));

type = manager.map(config.get("type-A"), MyType.class);
assertThat("Upper-case mapping", type, is(MyType.A));

type = manager.map(config.get("type-special-b"), MyType.class);
assertThat("Mixed-clase and hyphen mapping", type, is(MyType.SPECIAL_B));

type = manager.map(config.get("type-special_b"), MyType.class); // <-- That's an underscore, not a hyphen.
assertThat("Mixed-clase and no hyphen mapping", type, is(MyType.SPECIAL_B));

ConfigMappingException e = assertThrows(ConfigMappingException.class,
() -> manager.map(config.get("type-absent"), MyType.class),
"Unmatched enum value");
assertThat("Unmapped enum value error message", e.getMessage(), containsString("no match"));

assertThrows(MissingValueException.class,
() -> manager.map(config.get("missing-value"), MyType.class),
"Missing value in config");
}

@Test
void testBuiltInEnumMapperWithConfusingEnum() {
ConfigMapperManager manager = BuilderImpl.buildMappers(ConfigMapperManager.MapperProviders.create(), true);

Config confusingConfig = createConfusingEnumConfig();

ConfusingType type = manager.map(confusingConfig.get("type-RED"), ConfusingType.class);
assertThat("Exact match to RED", type, is(ConfusingType.RED));

type = manager.map(confusingConfig.get("type-Red"), ConfusingType.class);
assertThat("Exact match to Red", type, is(ConfusingType.Red));

type = manager.map(confusingConfig.get("type-blue"), ConfusingType.class);
assertThat("Case-insensitive match to BLUE", type, is(ConfusingType.BLUE));

ConfigMappingException e = assertThrows(ConfigMappingException.class,
() -> manager.map(confusingConfig.get("type-red"), ConfusingType.class),
"Ambiguous enum value");
assertThat("Ambiguous value error message", e.getMessage(), containsString("ambiguous"));
}

@Test
void testEnumUsingAs() {
Config config = createEnumConfig();
MyType myType = config.get("type-a").as(MyType.class).orElse(null);
assertThat("'as' mapping of 'a'", myType, allOf(notNullValue(), is(MyType.A)));

myType = config.get("type-A").as(MyType.class).orElse(null);
assertThat("'as' mapping of 'A'", myType, allOf(notNullValue(), is(MyType.A)));

assertThrows(MissingValueException.class,
() -> Config.empty().as(MyType.class).get(),
"Missing value in config");
}

@Test
void testEnumUsingAsWithConfusingEnum() {
Config config = createConfusingEnumConfig();

ConfusingType cType = config.get("type-Red").as(ConfusingType.class).orElse(null);
assertThat("'as' mapping of 'Red'", cType, allOf(notNullValue(), is(ConfusingType.Red)));

cType = config.get("type-RED").as(ConfusingType.class).orElse(null);
assertThat("'as' mapping of 'RED'", cType, allOf(notNullValue(), is(ConfusingType.RED)));

cType = config.get("type-blue").as(ConfusingType.class).orElse(null);
assertThat("'as' mapping of 'blue'", cType, allOf(notNullValue(), is(ConfusingType.BLUE)));


ConfigMappingException e = assertThrows(ConfigMappingException.class,
() -> config.get("type-red")
.as(ConfusingType.class)
.ifPresent((ConfusingType t) -> {}),
"Ambiguous value failure using 'as'");
assertThat("Ambiguous value error message using 'as'", e.getMessage(), containsString("ambiguous"));

e = assertThrows(ConfigMappingException.class,
() -> config.get("type-absent")
.as(ConfusingType.class)
.ifPresent((ConfusingType t) -> {}),
"Unmatched value failure using 'as'");
assertThat("Unmatched value error message using 'as'", e.getMessage(), containsString("no match"));
}

private Config createConfig() {
return Config.builder()
.sources(ConfigSources.create(Map.of(
Expand All @@ -256,4 +350,26 @@ private Config createConfig() {
.build();
}

public enum MyType {A, B, SPECIAL_B};

public enum ConfusingType {RED, Red, BLUE};

private Config createEnumConfig() {
return Config.just(ConfigSources.create(Map.of(
"type-a", "a",
"type-A", "A",
"type-special-b", "Special-b",
"type-special_b", "Special_b",
"type-absent", "absent"
)));
}

private Config createConfusingEnumConfig() {
return Config.just(ConfigSources.create(Map.of(
"type-RED", "RED",
"type-Red", "Red",
"type-red", "red",
"type-blue", "blue",
"type-absent", "not-there")));
}
}
75 changes: 73 additions & 2 deletions docs/se/config/04_property-mapping.adoc
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
///////////////////////////////////////////////////////////////////////////////

Copyright (c) 2018, 2021 Oracle and/or its affiliates.
Copyright (c) 2018, 2023 Oracle and/or its affiliates.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -25,7 +25,7 @@

Although config values are originally text, you can use the config system's
built-in conversions or add your own to translate text
into Java primitive types and simple objects (such as `Double`) and to
into Java primitive types and simple objects (such as `Double`), into `enum` values, and to
express parts of the config tree as complex types (`List`, `Map`, and
custom types specific to your application). This section introduces how to
use the built-in mappings and your own custom ones to convert to simple and
Expand Down Expand Up @@ -109,6 +109,76 @@ Your application can use `Config.as` to interpret the value as a `BigDecimal`:
BigDecimal initialId = config.get("bl.initial-id").as(BigDecimal.class);


== Converting Configuration to `enum` Values
Configuration can automatically map `Config` nodes to most `enum` types.

Your application code simply passes the enum class type to `config.as(Class<? extends T> type)`.
The built-in `enum` converter attempts to match the string value in the config node to the name of one of the values declared for that specific `enum` in the Java code.

=== Matching `enum` Names
The conversion applies the following algorithm to match config values to `enum` names, stopping as soon as it finds a match:

1. Select an exact match if one exists.
2. Treat hyphens (`-`) in config strings as underscores (`_`) and select an otherwise exact match if one exists.
3. Select a _case-insensitive_ match (with or without hyphen substitution) if there is _exactly one_ such match.
4. Finding no match or multiple case-insensitive matches, throw a `ConfigMappingException`.

=== Example
The following example illustrates how to use the built-in `enum` conversion feature. The example code builds a simple `Config` tree itself which contains simple test data; normally your application would load the config from a file or some other location.

[source,java]
----
class Example {

enum Color {RED, YELLOW, BLUE_GREEN};

void convert() {
Config config = Config.just(ConfigSources.create(Map.of("house.tint", "blue-green",
"car.color", "Red",
"warning", "YELLOW")));

Color house = config.get("house.tint") // <1>
.as(Color.class) // <2>
.get(); // <3>
Color car = config.get("car.color")
.as(Color.class)
.get(); // <4>
Color warning = config.get("warning")
.as(Color.class)
.get(); // <5>
}
}
----
<1> Retrieve the `Config` object corresponding to the key `house.tint`.
<2> Indicate that, when the value in that `Config` object is converted, Helidon should convert it to a `Color` `enum` value.
<3> Convert and retrieve the value.
+
The conversion triggered by invoking `get()` matches the string `blue-green`--expressed in lower case and with a hyphen -- to `Color.BLUE_GREEN` using the conversion rules described earlier.
<4> The config key `car.color` locates the mixed-case string `Red` which the converter matches to `Color.RED`.
<5> The config key `warning` locates `YELLOW` which the converter matches exactly to `Color.YELLOW`.

=== Why use heuristics in matching strings to `enum` values?
Short answer: ease-of-use.

Users composing config sources often adopt a style with hyphens within words to improve readability and lower-case keys and values.
With that style in mind, users typing an `enum` value into a config source might accidentally enter a hyphen instead of an underscore or use lower case instead of upper case. Users might even _prefer_ to make these changes so they can follow their preferred config style.

With the heuristics, Helidon allows users to adopt a common config style and prevents unnecessary runtime exceptions--and user frustration--from inconsequential typos.

Remember:

* Helidon always finds exact matches unambiguously, without relying on the heuristics.
In our `Color` example the text `BLUE_GREEN` always maps to `Color.BLUE_GREEN`.
* Because hyphens cannot appear in a valid Java `enum` value name, interpreting them as underscores during `enum` conversion introduces no ambiguity.

Only in the following unusual sitatuation are the heuristics unable to unambiguously match a string to an `enum` value:

* The `enum` has values which differ _only_ in their case (such as `Red` and `RED`), _and_
* The string in the config source is not an exact match with an `enum` value name (such as `red`).

If your application must deal with such cases, write your own function which maps a `Config` node to the correct `enum` value, resolving the ambiguities however makes sense in your use case.
Your code tells config to use that function instead of the built-in `enum` conversion when it converts values. A xref:customConfigAs[later section] describes this technique which works for all types, not only `enum` types.

== Converting Configuration to Complex Types

The <<se/config/03_hierarchical-features.adoc,hierarchical features>> section describes
Expand Down Expand Up @@ -143,6 +213,7 @@ Here are two approaches that will always work without requiring changes
to the target class. For both approaches, you write your own conversion function.
The difference is in how your application triggers the use of that mapper.

[[customConfigAs]]
==== Use Custom Mapper Explicitly: `Config.as` method
Any time your application has a `Config` instance to map to the target class
it invokes `Config.as` passing an instance of the corresponding conversion function:
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/native-image/se-1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ curl -i -H "Accept: application/json" http://localhost:7076/metrics
# Should return ALL TESTS PASSED! after passing all webclient tests
curl -i http://localhost:7076/wc/test

# Should return 200 status and plain text RED as the response content
curl -i http://localhost:7076/color



# Should return: Upgrade: websocket
curl \
--include \
Expand Down
Loading