Skip to content

Commit

Permalink
Infer the mapper based on the file extension (#176)
Browse files Browse the repository at this point in the history
  • Loading branch information
oswaldobapvicjr authored Jun 27, 2024
1 parent e654bc5 commit 790ff57
Show file tree
Hide file tree
Showing 8 changed files with 393 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@

package net.obvj.confectory;

import java.util.Objects;

import org.apache.commons.lang3.StringUtils;
import static org.apache.commons.lang3.StringUtils.*;

import net.obvj.confectory.mapper.DynamicMapper;
import net.obvj.confectory.mapper.Mapper;
import net.obvj.confectory.source.DynamicSource;
import net.obvj.confectory.source.Source;
Expand All @@ -31,7 +30,7 @@
* For example:
*
* <blockquote>
*
* <b><i>Example 1: Combining a source and a mapper (type-safe):</i></b>
* <pre>
* {@code Configuration<Properties> config = new ConfigurationBuilder<Properties>()}
* {@code .source(new ClasspathFileSource<>("my.properties"))}
Expand All @@ -43,6 +42,18 @@
* </pre>
*
* </blockquote>
* <blockquote>
* <b><i>Example 2: Building a generic configuration</i></b>
* (the actual mapper will be inferred based on the source file extension):
* <pre>
* {@code Configuration<?> config = Configuration.builder()}
* {@code .source("my.properties")}
* {@code .lazy()}
* {@code .build();}
* </pre>
*
* </blockquote>
*
* @param <T> the target configuration type
*
Expand All @@ -55,6 +66,7 @@ public class ConfigurationBuilder<T> implements ConfigurationMetadataRetriever<T
{
private String namespace;
private int precedence;
private String path;
private Source<T> source;
private Mapper<T> mapper;
private boolean optional;
Expand Down Expand Up @@ -145,12 +157,16 @@ public ConfigurationBuilder<T> source(Source<T> source)
*/
public ConfigurationBuilder<T> source(String path)
{
this.path = path;
this.source = SourceFactory.dynamicSource(path);
return this;
}

/**
* Defines the {@link Mapper} of the new {@code Configuration}.
* <p>
* <b>Note:</b> Since 2.6.0, if not specified, a default mapper will be defined
* dynamically, based on the source file extension, if present.
*
* @param mapper the {@link Mapper} to be set; <strong>not</strong> null
* @return a reference to this same {@code ConfigurationBuilder} for chained calls
Expand Down Expand Up @@ -237,21 +253,42 @@ public ConfigurationBuilder<T> bean(T bean)
* Builds the target {@code Configuration}.
*
* @return a new {@link Configuration} object
* @throws NullPointerException if either the {@code Source} or {@code Mapper}
* configuration parameters are missing
* @throws IllegalStateException if the {@code Source} parameter is missing; or the
* {@code Mapper} is missing; or the {@code Mapper}
* could not be inferred
* @throws ConfigurationSourceException in the event of a failure loading the
* configuration source
*/
public Configuration<T> build()
{
Objects.requireNonNull(source, "The configuration source must not be null");
Objects.requireNonNull(mapper, "The configuration mapper must not be null");
requireNonNullForBuild(source, "The configuration source must not be null");
evaluateMapper();

namespace = StringUtils.defaultString(namespace);
namespace = defaultString(namespace);

return new Configuration<>(this);
}

/**
* Secure the existence of a {@link Mapper}. Try to infer one if not specified.
*
* @throws IllegalStateException if the {@link Mapper} is null and could not be inferred
* @since 2.6.0
*/
private void evaluateMapper()
{
if (mapper == null)
{
// Try to infer based on the file extension if present
String extension = substringAfterLast(path, ".");
if (isEmpty(extension))
{
throw new IllegalStateException("The mapper could not be inferred. Please specify a concrete mapper.");
}
mapper = (Mapper<T>) new DynamicMapper(extension);
}
}

@Override
public String getNamespace()
{
Expand Down Expand Up @@ -293,4 +330,13 @@ public T getBean()
return bean;
}

private static <T> T requireNonNullForBuild(T object, String message)
{
if (object == null)
{
throw new IllegalStateException(message);
}
return object;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright 2024 obvj.net
*
* 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 net.obvj.confectory.mapper;

import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.function.Supplier;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import net.obvj.confectory.internal.helper.ConfigurationHelper;
import net.obvj.confectory.util.Exceptions;

/**
* A dynamic {@link Mapper} which automatically assigns a concrete mapping class
* (from the default core mappers) depending on a given file extension:
* <ul>
* <li><code>"ini"</code> assigns the {@link INIToJSONObjectMapper}
* <li><code>"json"</code> assigns the {@link JSONObjectMapper}
* <li><code>"properties"</code> assigns the {@link PropertiesMapper}
* <li><code>"txt"</code> assigns the {@link StringMapper}
* <li><code>"xml"</code> assigns the {@link DocumentMapper}
* </ul>
*
* @author oswaldo.bapvic.jr (Oswaldo Junior)
* @since 2.6.0
*/
public final class DynamicMapper extends AbstractBeanMapper<Object> implements Mapper<Object>
{
private static final Map<String, Supplier<Mapper<?>>> MAPPERS_BY_EXTENSION = Map.of(
"ini", INIToJSONObjectMapper::new,
"json", JSONObjectMapper::new,
"properties", PropertiesMapper::new,
"txt", StringMapper::new,
"xml", DocumentMapper::new
);

private static final Logger LOGGER = LoggerFactory.getLogger(DynamicMapper.class);

private final Mapper<Object> actualMapper;

/**
* Builds a new dynamic configuration mapper for the specified extension
*
* @param extension the file extension; not null
* @throws IllegalArgumentException if the extension is empty or unknown
*/
public DynamicMapper(String extension)
{
this.actualMapper = findMapper(extension);
}

private static Mapper<Object> findMapper(String extension)
{
Supplier<Mapper<?>> supplier = MAPPERS_BY_EXTENSION.get(StringUtils.lowerCase(extension));
if (supplier == null)
{
throw Exceptions.illegalArgument(
"No default mapper available for the extension: \"%s\"", extension);
}
return (Mapper<Object>) supplier.get();
}

@Override
public Object apply(InputStream inputStream) throws IOException
{
LOGGER.debug("Applying mapper {}", actualMapper.getClass());
return actualMapper.apply(inputStream);
}

@Override
public ConfigurationHelper<Object> configurationHelper(Object bean)
{
return actualMapper.configurationHelper(bean);
}

/**
* @return The actual mapper
*/
public Mapper<Object> getActualMapper()
{
return actualMapper;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static net.obvj.junit.utils.matchers.AdvancedMatchers.containsAll;
import static net.obvj.junit.utils.matchers.AdvancedMatchers.throwsException;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.Mockito.mock;
Expand All @@ -31,7 +32,9 @@
import org.mockito.junit.jupiter.MockitoExtension;

import net.obvj.confectory.internal.helper.ConfigurationHelper;
import net.obvj.confectory.mapper.DynamicMapper;
import net.obvj.confectory.mapper.Mapper;
import net.obvj.confectory.mapper.PropertiesMapper;
import net.obvj.confectory.source.AbstractSource;
import net.obvj.confectory.source.DynamicSource;
import net.obvj.confectory.source.Source;
Expand Down Expand Up @@ -97,20 +100,31 @@ void constructor_exisitingConfiguration_presetBuilder()
}

@Test
void build_nullSource_nullPointerException()
void build_nullSource_illegalStateException()
{
ConfigurationBuilder<Object> builder = new ConfigurationBuilder<>();
assertThat(() -> builder.build(),
throwsException(NullPointerException.class).withMessage("The configuration source must not be null"));
throwsException(IllegalStateException.class).withMessage("The configuration source must not be null"));
}

@Test
void build_nullMapper_nullPointerException()
void build_nullMapperAndCouldNotBeInferred_illegalStateException()
{
ConfigurationBuilder<Object> builder = new ConfigurationBuilder<>()
.source(source);
assertThat(() -> builder.build(),
throwsException(NullPointerException.class).withMessage("The configuration mapper must not be null"));
throwsException(IllegalStateException.class).withMessage("The mapper could not be inferred. Please specify a concrete mapper."));
}

@Test
void build_nullMapperButCouldBeInferred_dynamicMapperAssignedBasedOnExtension()
{
ConfigurationBuilder<Object> builder = new ConfigurationBuilder<>()
.source("testfiles/my.properties").lazy();
Configuration<?> config = builder.build();

DynamicMapper assignedMapper = (DynamicMapper) config.getMapper();
assertThat(assignedMapper.getActualMapper().getClass(), is(PropertiesMapper.class));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright 2024 obvj.net
*
* 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 net.obvj.confectory.mapper;

import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;

import java.io.ByteArrayInputStream;
import java.io.IOException;

import org.junit.jupiter.api.Test;

import net.obvj.junit.utils.matchers.AdvancedMatchers;

/**
* Unit tests for the {@link DynamicMapper}.
*
* @author oswaldo.bapvic.jr
* @since 2.6.0
*/
class DynamicMapperTest
{
static final String TEST_INI_CONTENT = "[web]\nhost=localhost\nport=1910";
static final String TEST_PROPERTIES_CONTENT = "web.host=localhost\nweb.port=1910";
static final String TEST_JSON_CONTENT = "{\"web\":{\"host\":\"localhost\",\"port\":1910}}";
static final String TEST_TXT_CONTENT = "localhost:1910";
static final String TEST_XML_CONTENT = "<web><host>localhost</host><port>1910</port></web>";

private static ByteArrayInputStream asInputStream(String string)
{
return new ByteArrayInputStream(string.getBytes());
}

@Test
void apply_ini_loadedSuccessfully() throws IOException
{
Mapper<Object> mapper = new DynamicMapper("INI");
Object bean = mapper.apply(asInputStream(TEST_INI_CONTENT));
assertThat(mapper.configurationHelper(bean).getString("web.host"), equalTo("localhost"));
}

@Test
void apply_json_loadedSuccessfully() throws IOException
{
Mapper<Object> mapper = new DynamicMapper("JSON");
Object bean = mapper.apply(asInputStream(TEST_JSON_CONTENT));
assertThat(mapper.configurationHelper(bean).getString("$.web.host"), equalTo("localhost"));
}

@Test
void apply_properties_loadedSuccessfully() throws IOException
{
Mapper<Object> mapper = new DynamicMapper("PROPERTIES");
Object bean = mapper.apply(asInputStream(TEST_PROPERTIES_CONTENT));
assertThat(mapper.configurationHelper(bean).getInteger("web.port"), equalTo(1910));
}

@Test
void apply_txt_loadedSuccessfully() throws IOException
{
Mapper<Object> mapper = new DynamicMapper("TXT");
Object bean = mapper.apply(asInputStream(TEST_TXT_CONTENT));
assertThat(mapper.configurationHelper(bean).getAsString(), equalTo("localhost:1910"));
}

@Test
void apply_xml_loadedSuccessfully() throws IOException
{
Mapper<Object> mapper = new DynamicMapper("XML");
Object bean = mapper.apply(asInputStream(TEST_XML_CONTENT));
assertThat(mapper.configurationHelper(bean).getInteger("/web/port"), equalTo(1910));
}

@Test
void constructor_unknown_illegalArgument()
{
assertThat(() -> new DynamicMapper("unknown"),
AdvancedMatchers.throwsException(IllegalArgumentException.class)
.withMessage("No default mapper available for the extension: \"unknown\""));
}

}
Loading

0 comments on commit 790ff57

Please sign in to comment.