From 2fba8e5a27d405d213166f1d7bb06df0f4a4b4ec Mon Sep 17 00:00:00 2001 From: iamsb97 Date: Wed, 27 Aug 2025 01:37:03 +0530 Subject: [PATCH 1/3] Support for reading env from .env files --- .../env/DotEnvPropertySourceLoader.java | 82 +++++++++++++++++++ ...micronaut.context.env.PropertySourceLoader | 1 + .../env/DotEnvPropertySourceLoaderSpec.groovy | 42 ++++++++++ 3 files changed, 125 insertions(+) create mode 100644 inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java create mode 100644 inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy diff --git a/inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java b/inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java new file mode 100644 index 00000000000..c95a45d7a06 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java @@ -0,0 +1,82 @@ +/* + * Copyright 2017-2025 original authors + * + * 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 + * + * https://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.micronaut.context.env; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +public class DotEnvPropertySourceLoader extends AbstractPropertySourceLoader { + + /** + * File extension for property source loader. + */ + public static final String FILE_EXTENSION = "env"; + + public DotEnvPropertySourceLoader() { + } + + public DotEnvPropertySourceLoader(boolean logEnabled) { super(logEnabled); } + + @Override + public Set getExtensions() { return Collections.singleton(FILE_EXTENSION); } + + @Override + protected void processInput(String name, InputStream input, Map finalMap) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) { + String line; + while ((line = reader.readLine()) != null) { + parseLine(line, finalMap); + } + } + } + + private void parseLine(String line, Map finalMap) { + line = line.trim(); + + if (line.isEmpty() || line.startsWith("#")) { return; } + + int delimIndex = line.indexOf('='); + if (delimIndex <= 0) { return; } + + String key = line.substring(0, delimIndex).trim(); + String val = line.substring(delimIndex+1).trim(); + + if (key.isEmpty() || val.isEmpty()) { return; } + + key = processKey(key); + val = processVal(val); + + finalMap.put(key, val); + } + + private String processKey(String key) { + return key.toLowerCase().replace('_', '.'); + } + + private String processVal(String val) { + if (val.length() >= 2) { + if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith("\"") && val.endsWith("\""))) { + return val.substring(1, val.length()-1); + } + } + return val; + } +} diff --git a/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader b/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader index 143dab73679..8e77cde96b3 100644 --- a/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader +++ b/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader @@ -1,2 +1,3 @@ io.micronaut.context.env.yaml.YamlPropertySourceLoader io.micronaut.context.env.PropertiesPropertySourceLoader +io.micronaut.context.env.DotEnvPropertySourceLoader diff --git a/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy b/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy new file mode 100644 index 00000000000..131d7196993 --- /dev/null +++ b/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy @@ -0,0 +1,42 @@ +package io.micronaut.context.env + +import io.micronaut.core.io.service.ServiceDefinition +import io.micronaut.core.io.service.SoftServiceLoader +import spock.lang.Specification + +class DotEnvPropertySourceLoaderSpec extends Specification { + + void "test dot-env property source loader"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + return Optional.of(new ByteArrayInputStream('''\ +HIBERNATE_CACHE_QUERIES=false +DATASOURCE_POOLED = true +DATASOURCE_DRIVER-CLASS-NAME="org.h2.Driver" +'''.bytes)) + } + } + + when: + env.start() + + then: + env.get("hibernate.cache.queries", Boolean).get() == false + env.get("datasource.pooled", Boolean).get() == true + env.get("datasource.driver-class-name", String).get() == 'org.h2.Driver' + + } +} From 67c55faa4ca234435691ecda375ab0fae1c1f2a5 Mon Sep 17 00:00:00 2001 From: iamsb97 Date: Sun, 12 Oct 2025 20:07:51 +0530 Subject: [PATCH 2/3] Improve .env support integration --- .../env/AbstractPropertySourceLoader.java | 2 +- .../env/DotEnvPropertySourceLoader.java | 82 ---- .../env/dotenv/DotEnvParseContext.java | 80 +++ .../context/env/dotenv/DotEnvParser.java | 289 +++++++++++ .../dotenv/DotEnvPropertySourceLoader.java | 86 ++++ .../env/dotenv/DotEnvVariableResolver.java | 105 ++++ ...micronaut.context.env.PropertySourceLoader | 2 +- .../env/DotEnvPropertySourceLoaderSpec.groovy | 464 +++++++++++++++++- 8 files changed, 1021 insertions(+), 89 deletions(-) delete mode 100644 inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java create mode 100644 inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParseContext.java create mode 100644 inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParser.java create mode 100644 inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvPropertySourceLoader.java create mode 100644 inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvVariableResolver.java diff --git a/inject/src/main/java/io/micronaut/context/env/AbstractPropertySourceLoader.java b/inject/src/main/java/io/micronaut/context/env/AbstractPropertySourceLoader.java index b8168307297..627c0763173 100644 --- a/inject/src/main/java/io/micronaut/context/env/AbstractPropertySourceLoader.java +++ b/inject/src/main/java/io/micronaut/context/env/AbstractPropertySourceLoader.java @@ -127,7 +127,7 @@ public int getOrder() { }; } - private Map loadProperties(ResourceLoader resourceLoader, String qualifiedName, String fileName) { + protected Map loadProperties(ResourceLoader resourceLoader, String qualifiedName, String fileName) { Optional config = readInput(resourceLoader, fileName); if (config.isPresent()) { log.debug("Found PropertySource for file name: {}", fileName); diff --git a/inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java b/inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java deleted file mode 100644 index c95a45d7a06..00000000000 --- a/inject/src/main/java/io/micronaut/context/env/DotEnvPropertySourceLoader.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2017-2025 original authors - * - * 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 - * - * https://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.micronaut.context.env; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.Collections; -import java.util.Map; -import java.util.Set; - -public class DotEnvPropertySourceLoader extends AbstractPropertySourceLoader { - - /** - * File extension for property source loader. - */ - public static final String FILE_EXTENSION = "env"; - - public DotEnvPropertySourceLoader() { - } - - public DotEnvPropertySourceLoader(boolean logEnabled) { super(logEnabled); } - - @Override - public Set getExtensions() { return Collections.singleton(FILE_EXTENSION); } - - @Override - protected void processInput(String name, InputStream input, Map finalMap) throws IOException { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) { - String line; - while ((line = reader.readLine()) != null) { - parseLine(line, finalMap); - } - } - } - - private void parseLine(String line, Map finalMap) { - line = line.trim(); - - if (line.isEmpty() || line.startsWith("#")) { return; } - - int delimIndex = line.indexOf('='); - if (delimIndex <= 0) { return; } - - String key = line.substring(0, delimIndex).trim(); - String val = line.substring(delimIndex+1).trim(); - - if (key.isEmpty() || val.isEmpty()) { return; } - - key = processKey(key); - val = processVal(val); - - finalMap.put(key, val); - } - - private String processKey(String key) { - return key.toLowerCase().replace('_', '.'); - } - - private String processVal(String val) { - if (val.length() >= 2) { - if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith("\"") && val.endsWith("\""))) { - return val.substring(1, val.length()-1); - } - } - return val; - } -} diff --git a/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParseContext.java b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParseContext.java new file mode 100644 index 00000000000..724494beed9 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParseContext.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017-2025 original authors + * + * 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 + * + * https://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.micronaut.context.env.dotenv; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Context object that holds the state during .env file parsing and resolution. + * Contains both resolved variables (no dependencies) and unresolved variables (with dependencies). + */ +final class DotEnvParseContext { + + private final Map resolvedVars = new HashMap<>(); + private final Map unresolvedVars = new HashMap<>(); + + void addResolvedVariable(String key, String value) { + resolvedVars.put(key, value); + } + + void addVariableDependency(String key, String referencedVar) { + unresolvedVars.computeIfAbsent(key, k -> new UnresolvedVariable()) + .addDependency(referencedVar); + } + + void setUnresolvedValue(String key, String value) { + UnresolvedVariable var = unresolvedVars.get(key); + if (var != null) { + var.setValue(value); + } + } + + boolean hasUnresolvedVariable(String key) { + return unresolvedVars.containsKey(key); + } + + Map getResolvedVars() { + return resolvedVars; + } + + Map getUnresolvedVars() { + return unresolvedVars; + } + + static class UnresolvedVariable { + private String value; + private final List dependencies = new ArrayList<>(); + + void setValue(String value) { + this.value = value; + } + + void addDependency(String varName) { + dependencies.add(varName); + } + + String getValue() { + return value; + } + + List getDependencies() { + return dependencies; + } + } +} diff --git a/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParser.java b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParser.java new file mode 100644 index 00000000000..9e0ddd4b3b6 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvParser.java @@ -0,0 +1,289 @@ +/* + * Copyright 2017-2025 original authors + * + * 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 + * + * https://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.micronaut.context.env.dotenv; + +import org.slf4j.Logger; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.regex.Pattern; + +/** + * Parser for .env file format. + * Handles quotes, escape sequences, comments, and variable references. + */ +final class DotEnvParser { + + private static final Pattern VALID_KEY_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$"); + private static final char NO_QUOTE = '\0'; + private Logger log; + + DotEnvParser() {} + + DotEnvParser(Logger log) { + this.log = log; + } + + /** + * Parses an input stream containing .env format data. + * + * @param input the input stream to parse + * @return parse context containing resolved and unresolved variables + * @throws IOException if an I/O error occurs + */ + DotEnvParseContext parse(InputStream input) throws IOException { + DotEnvParseContext context = new DotEnvParseContext(); + char quoteChar = NO_QUOTE; + boolean isMultiLined = false; + String key = null; + StringBuilder val = new StringBuilder(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) { + int lineNumber = 0; + + String line; + while ((line = reader.readLine()) != null) { + lineNumber++; + + try { + int delimIndex; + + if (!isMultiLined) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + delimIndex = line.indexOf('='); + if (delimIndex <= 0) { + continue; + } + + String originalKey = line.substring(0, delimIndex).trim(); + key = processKey(originalKey); + if (key.isEmpty()) { + if (log != null) { + log.warn("Skipping invalid key '{}' at line {}", originalKey, lineNumber); + } + continue; + } + } else { + val.append('\n'); + delimIndex = -1; + } + + quoteChar = processValue(line, delimIndex + 1, val, quoteChar, key, context); + isMultiLined = quoteChar != NO_QUOTE; + + if (!isMultiLined && !val.isEmpty()) { + String value = val.toString(); + if (context.hasUnresolvedVariable(key)) { + context.setUnresolvedValue(key, value); + } else { + context.addResolvedVariable(key, value); + } + val.setLength(0); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Line " + lineNumber + ": " + e.getMessage(), e); + } + } + } + + return context; + } + + private String processKey(String key) { + if (!key.isEmpty() && !VALID_KEY_PATTERN.matcher(key).matches()) { return ""; } + return key.toLowerCase().replace('_', '.'); + } + + /** + * Processes a value starting from the given position. + * + * @param line the current line being parsed + * @param begin starting position in the line + * @param result buffer to append processed characters + * @param quoteChar current quote context + * @param key the key for this value + * @param context parse context for tracking variables + * @return the quote character state after processing + */ + private char processValue(String line, int begin, StringBuilder result, char quoteChar, + String key, DotEnvParseContext context) { + int lastChar = -1; + + for (int i = begin; i < line.length(); i++) { + char c = line.charAt(i); + switch (c) { + case ' ': + if (!result.isEmpty() || quoteChar != NO_QUOTE) { result.append(c); } + break; + case '"': + if (quoteChar == NO_QUOTE) { quoteChar = '"'; } + else if (quoteChar == '"') { quoteChar = NO_QUOTE; } + else { result.append(c); } + lastChar = result.length(); + break; + case '\'': + if (quoteChar == NO_QUOTE) { quoteChar = '\''; } + else if (quoteChar == '\'') { quoteChar = NO_QUOTE; } + else { result.append(c); } + lastChar = result.length(); + break; + case '\\': + i = processEscape(line, i, result, quoteChar); + lastChar = result.length(); + break; + case '$': + i = processVariable(line, i, result, quoteChar, key, context); + if (i > line.length()) { return NO_QUOTE; } + lastChar = result.length(); + break; + case '#': + if (quoteChar == NO_QUOTE) { + if (lastChar > 0 && lastChar < result.length()) { + result.delete(lastChar, result.length()); + } + return quoteChar; + } + result.append(c); + lastChar = result.length(); + break; + default: + result.append(c); + lastChar = result.length(); + break; + } + } + + if (quoteChar == NO_QUOTE && lastChar > 0 && lastChar < result.length()) { + result.delete(lastChar, result.length()); + } + + return quoteChar; + } + + /** + * Processes a variable reference ($VAR or ${VAR}). + * + * @param line the current line + * @param index position of the $ character + * @param result buffer to append normalized variable reference + * @param quoteChar current quote context + * @param key the key being processed + * @param context parse context to track variable dependencies + * @return new index position after processing + */ + private int processVariable(String line, int index, StringBuilder result, char quoteChar, + String key, DotEnvParseContext context) { + boolean braceUsed = false; + StringBuilder varName = new StringBuilder(); + + if (quoteChar == '\'') { + result.append('$'); + return index; + } + + int next = index + 1; + if (next < line.length() && line.charAt(next) == '{') { + braceUsed = true; + index = next; + } + + for (int i = index + 1; i < line.length(); i++) { + char c = line.charAt(i); + if (braceUsed && c == '}') { + if (!varName.isEmpty()) { + context.addVariableDependency(key, varName.toString()); + result.append("${").append(varName).append("}"); + } + return i; + } + if (!Character.isLetterOrDigit(c) && c != '_') { + if (!braceUsed && !varName.isEmpty()) { + context.addVariableDependency(key, varName.toString()); + result.append("${").append(varName).append("}"); + return i - 1; + } else { + throw new IllegalArgumentException( + "Malformed variable reference starting with '" + varName + + "' - variables must contain only letters, digits, and underscores" + ); + } + } + varName.append(c); + } + + if (braceUsed) { + throw new IllegalArgumentException( + "Unclosed variable brace: ${" + varName + " - missing closing '}'" + ); + } + + if (!varName.isEmpty()) { + context.addVariableDependency(key, varName.toString()); + result.append("${").append(varName).append("}"); + } + + return line.length() - 1; + } + + private int processEscape(String line, int index, StringBuilder result, char quoteChar) { + char c = line.charAt(index); + + if (quoteChar == '\'') { + result.append(c); + return index; + } + + int next = index + 1; + if (next < line.length()) { + char escaped = line.charAt(next); + return switch (escaped) { + case 'n', 't', 'r' -> { + if (quoteChar == '"') { + result.append(getEscapeChar(escaped)); + } else { + result.append(escaped); + } + yield index + 1; + } + case '$' -> { + result.append("\\$"); + yield index + 1; + } + default -> { + result.append(escaped); + yield index + 1; + } + }; + } + + return index; + } + + private char getEscapeChar(char escaped) { + return switch (escaped) { + case 'n' -> '\n'; + case 't' -> '\t'; + case 'r' -> '\r'; + default -> escaped; + }; + } +} diff --git a/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvPropertySourceLoader.java b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvPropertySourceLoader.java new file mode 100644 index 00000000000..50ced827849 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvPropertySourceLoader.java @@ -0,0 +1,86 @@ +/* + * Copyright 2017-2025 original authors + * + * 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 + * + * https://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.micronaut.context.env.dotenv; + +import io.micronaut.context.env.AbstractPropertySourceLoader; +import io.micronaut.context.env.ActiveEnvironment; +import io.micronaut.context.env.PropertySource; +import io.micronaut.core.io.ResourceLoader; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Loads properties from .env files. + * Supports variable substitution, escape sequences, and multiple quote types. + * + * @author Your Name + * @since 4.x + */ +public class DotEnvPropertySourceLoader extends AbstractPropertySourceLoader { + + /** + * File extension for .env files. + */ + public static final String FILE_EXTENSION = "env"; + private final DotEnvParser parser; + private final DotEnvVariableResolver resolver; + + public DotEnvPropertySourceLoader() { + parser = new DotEnvParser(); + resolver = new DotEnvVariableResolver(); + } + + public DotEnvPropertySourceLoader(boolean logEnabled) { + super(logEnabled); + parser = new DotEnvParser(log); + resolver = new DotEnvVariableResolver(log); + } + + @Override + public Set getExtensions() { return Collections.singleton(FILE_EXTENSION); } + + @Override + public Optional loadEnv(String resourceName, ResourceLoader resourceLoader, ActiveEnvironment activeEnvironment) { + int order = getOrder() + 1 + activeEnvironment.getPriority(); + if (isEnabled()) { + Set extensions = getExtensions(); + for (String ext : extensions) { + String fileName = resourceName + "." + ext + "." + activeEnvironment.getName(); + Map finalMap = loadProperties(resourceLoader, fileName, fileName); + + if (!finalMap.isEmpty()) { + return Optional.of( + createPropertySource(fileName, finalMap, order, PropertySource.Origin.of(fileName)) + ); + } + } + } + + return Optional.empty(); + } + + @Override + protected void processInput(String name, InputStream input, Map finalMap) throws IOException { + DotEnvParseContext context = parser.parse(input); + resolver.resolveVariables(context); + finalMap.putAll(context.getResolvedVars()); + } +} diff --git a/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvVariableResolver.java b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvVariableResolver.java new file mode 100644 index 00000000000..475f2097cb8 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/env/dotenv/DotEnvVariableResolver.java @@ -0,0 +1,105 @@ +/* + * Copyright 2017-2025 original authors + * + * 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 + * + * https://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.micronaut.context.env.dotenv; + +import org.slf4j.Logger; + +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Resolves variable references in .env values using depth-first search. + * Handles system environment variable fallback and circular dependency detection. + */ +final class DotEnvVariableResolver { + + private static final Pattern VALID_KEY_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$"); + private Logger log; + + DotEnvVariableResolver() {} + + DotEnvVariableResolver(Logger log) { this.log = log; } + + /** + * Resolves all variables in the parse context. + * Uses DFS to handle chained variable references. + * + * @param context the parse context containing resolved and unresolved variables + */ + void resolveVariables(DotEnvParseContext context) { + Map resolvedVars = context.getResolvedVars(); + Map unresolvedVars = context.getUnresolvedVars(); + Map checked = new HashMap<>(); + + // Create a copy to avoid concurrent modification + Map unresolvedCopy = new HashMap<>(unresolvedVars); + + for (Map.Entry entry : unresolvedCopy.entrySet()) { + String key = entry.getKey(); + DotEnvParseContext.UnresolvedVariable var = entry.getValue(); + resolveVariable(key, var, checked, resolvedVars, unresolvedVars); + } + + // Clean up escaped dollar signs + for (Map.Entry entry : resolvedVars.entrySet()) { + String value = entry.getValue(); + resolvedVars.put(entry.getKey(), value.replace("\\$", "$")); + } + } + + private void resolveVariable(String key, DotEnvParseContext.UnresolvedVariable var, + Map checked, + Map resolvedVars, + Map unresolvedVars) { + String value = var.getValue(); + + for (String dependency : var.getDependencies()) { + String processedKey = normalizeKey(dependency); + String toReplace = "${" + dependency + "}"; + + if (!checked.containsKey(dependency)) { + checked.put(dependency, true); + + if (resolvedVars.containsKey(processedKey)) { + // Variable already resolved + value = value.replace(toReplace, resolvedVars.get(processedKey)); + } else if (unresolvedVars.containsKey(processedKey)) { + // Recursively resolve dependency + resolveVariable(processedKey, unresolvedVars.get(processedKey), checked, resolvedVars, unresolvedVars); + value = value.replace(toReplace, resolvedVars.get(processedKey)); + } else { + // Check system environment as fallback + String envValue = System.getenv(dependency); + value = value.replace(toReplace, envValue != null ? envValue : ""); + } + } else { + if (log != null) { + log.warn("Circular dependency detected for variable '{}', replacing with empty string", dependency); + } + value = value.replace(toReplace, ""); + } + } + + resolvedVars.put(key, value); + unresolvedVars.remove(key); + } + + private String normalizeKey(String key) { + if (!key.isEmpty() && !VALID_KEY_PATTERN.matcher(key).matches()) { return ""; } + return key.toLowerCase().replace('_', '.'); + } +} diff --git a/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader b/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader index 8e77cde96b3..b176f367146 100644 --- a/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader +++ b/inject/src/main/resources/META-INF/services/io.micronaut.context.env.PropertySourceLoader @@ -1,3 +1,3 @@ io.micronaut.context.env.yaml.YamlPropertySourceLoader io.micronaut.context.env.PropertiesPropertySourceLoader -io.micronaut.context.env.DotEnvPropertySourceLoader +io.micronaut.context.env.dotenv.DotEnvPropertySourceLoader diff --git a/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy b/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy index 131d7196993..452f9f96670 100644 --- a/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy +++ b/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy @@ -1,12 +1,13 @@ package io.micronaut.context.env +import io.micronaut.context.env.dotenv.DotEnvPropertySourceLoader import io.micronaut.core.io.service.ServiceDefinition import io.micronaut.core.io.service.SoftServiceLoader import spock.lang.Specification class DotEnvPropertySourceLoaderSpec extends Specification { - void "test dot-env property source loader"() { + void "test basic properties"() { given: def serviceDefinition = Mock(ServiceDefinition) serviceDefinition.isPresent() >> true @@ -22,11 +23,61 @@ class DotEnvPropertySourceLoaderSpec extends Specification { @Override Optional getResourceAsStream(String path) { - return Optional.of(new ByteArrayInputStream('''\ + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' HIBERNATE_CACHE_QUERIES=false -DATASOURCE_POOLED = true -DATASOURCE_DRIVER-CLASS-NAME="org.h2.Driver" +DATASOURCE_POOLED=true +DATASOURCE_DRIVER_CLASS_NAME=org.h2.Driver '''.bytes)) + } else if (path.endsWith(".env")) { + return Optional.of(new ByteArrayInputStream(''' +HIBERNATE_CACHE_QUERIES=false +DATASOURCE_POOLED=true +DATASOURCE_DRIVER_CLASS_NAME=org.postgres.Driver +DATASOURCE_USERNAME=sa +'''.bytes)) + } + + return Optional.empty() + } + } + + when: + env.start() + + then: + env.get("hibernate.cache.queries", Boolean).get() == false + env.get("datasource.pooled", Boolean).get() == true + env.get("datasource.driver.class.name", String).get() == 'org.h2.Driver' + env.get("datasource.username", String).get() == 'sa' + + } + + void "test quoted properties"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +HIBERNATE_CACHE_QUERIES="false" +DATASOURCE_POOLED='true' +DATASOURCE_DRIVER_CLASS_NAME='org.h2.Driver' +'''.bytes)) + } + + return Optional.empty(); } } @@ -36,7 +87,410 @@ DATASOURCE_DRIVER-CLASS-NAME="org.h2.Driver" then: env.get("hibernate.cache.queries", Boolean).get() == false env.get("datasource.pooled", Boolean).get() == true - env.get("datasource.driver-class-name", String).get() == 'org.h2.Driver' + env.get("datasource.driver.class.name", String).get() == 'org.h2.Driver' + + } + + void "test escaped characters in double quotes"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY1="VAL1\\nVAL2" +KEY2="VAL1\\tVAL2" +KEY3="VAL1\\rVAL2" +KEY4="VAL1\\$KEY1" +KEY5="VAL1\\\\VAL2" +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("key1", String).get() == "VAL1\nVAL2" + env.get("key2", String).get() == "VAL1\tVAL2" + env.get("key3", String).get() == "VAL1\rVAL2" + env.get("key4", String).get() == "VAL1\$KEY1" + env.get("key5", String).get() == "VAL1\\VAL2" + } + + void "test variable substitution with braces"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +BASE_URL=https://api.example.com +API_VERSION=v1 +FULL_URL=\${BASE_URL}/\${API_VERSION}/users +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("base.url", String).get() == "https://api.example.com" + env.get("api.version", String).get() == "v1" + env.get("full.url", String).get() == "https://api.example.com/v1/users" + } + + void "test variable substitution without braces"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +TESTHOME=/home/user +TESTPATH=\$TESTHOME/bin:\$TESTHOME/local +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("testhome", String).get() == "/home/user" + env.get("testpath", String).get() == "/home/user/bin:/home/user/local" + } + + void "test chained variable substitution"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY_A=value_a +KEY_B=\${KEY_A}_b +KEY_C=\${KEY_B}_c +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("key.a", String).get() == "value_a" + env.get("key.b", String).get() == "value_a_b" + env.get("key.c", String).get() == "value_a_b_c" + } + + void "test multiline values with double quotes"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +MULTILINE="line1 +line2 +line3" +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("multiline", String).get() == "line1\nline2\nline3" + } + + void "test comments"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY1=value1 +KEY2=value2 # inline comment +KEY3="value with # not a comment" +'''.bytes)) + } + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("key1", String).get() == "value1" + env.get("key2", String).get() == "value2" + env.get("key3", String).get() == "value with # not a comment" + } + + void "test single quotes preserve literals"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY1='\\n\\t\\r not processed' +KEY2='\$VAR not substituted' +KEY3='literal "quotes" inside' +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("key1", String).get() == '\\n\\t\\r not processed' + env.get("key2", String).get() == '\$VAR not substituted' + env.get("key3", String).get() == 'literal "quotes" inside' + } + + void "test empty values and whitespace"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +EMPTY= +SPACES_AROUND = value +QUOTED_SPACES=" spaces " +'''.bytes)) + } + + return Optional.empty(); + } + } + when: + env.start() + + then: + !env.containsProperty("empty"); + env.get("spaces.around", String).get() == "value" + env.get("quoted.spaces", String).get() == " spaces " } + + void "test mixed quotes"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY1="double with 'single' inside" +KEY2='single with "double" inside' +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("key1", String).get() == "double with 'single' inside" + env.get("key2", String).get() == 'single with "double" inside' + } + + void "test invalid keys are skipped"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +VALID_KEY=value1 +123_INVALID=value2 +DASHED-KEY=value3 +ANOTHER_VALID=value4 +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("valid.key", String).get() == "value1" + env.get("another.valid", String).get() == "value4" + !env.containsProperty("123.invalid") + !env.containsProperty("dashed-key") + } + + void "test undefined variable substitution"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY=prefix_\${UNDEFINED_VAR}_suffix +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + env.get("key", String).get() == "prefix__suffix" + } + } From 6a008b00b11929a1028ecec548cbd4d214cef17a Mon Sep 17 00:00:00 2001 From: iamsb97 Date: Wed, 15 Oct 2025 04:50:33 +0530 Subject: [PATCH 3/3] Update propertySource documentation and add tests for exceptions --- .../env/DotEnvPropertySourceLoaderSpec.groovy | 68 +++++++++++++++++++ .../docs/guide/config/propertySource.adoc | 32 ++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy b/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy index 452f9f96670..3939666cf83 100644 --- a/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy +++ b/inject/src/test/groovy/io/micronaut/context/env/DotEnvPropertySourceLoaderSpec.groovy @@ -493,4 +493,72 @@ KEY=prefix_\${UNDEFINED_VAR}_suffix env.get("key", String).get() == "prefix__suffix" } + void "test malformed key in variable substitution"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY=prefix_\${MALFORMED-KEY}_suffix +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + def e = thrown(IllegalArgumentException) + e.message == "Line 2: Malformed variable reference starting with 'MALFORMED' - variables must contain only letters, digits, and underscores" + } + + void "test unclosed braces in variable substitution"() { + given: + def serviceDefinition = Mock(ServiceDefinition) + serviceDefinition.isPresent() >> true + serviceDefinition.load() >> new DotEnvPropertySourceLoader() + + Environment env = new DefaultEnvironment({ ["test"] }) { + @Override + protected SoftServiceLoader readPropertySourceLoaders() { + GroovyClassLoader gcl = new GroovyClassLoader() + gcl.addURL(DotEnvPropertySourceLoader.getResource("/META-INF/services/io.micronaut.context.env.PropertySourceLoader")) + return new SoftServiceLoader(PropertySourceLoader, gcl) + } + + @Override + Optional getResourceAsStream(String path) { + if (path.endsWith(".env.test")) { + return Optional.of(new ByteArrayInputStream(''' +KEY=prefix_\${UNCLOSED_BRACES +'''.bytes)) + } + + return Optional.empty(); + } + } + + when: + env.start() + + then: + def e = thrown(IllegalArgumentException) + e.message == "Line 2: Unclosed variable brace: \${UNCLOSED_BRACES - missing closing '}'" + } + } diff --git a/src/main/docs/guide/config/propertySource.adoc b/src/main/docs/guide/config/propertySource.adoc index d577cfbbe67..d083a30443d 100644 --- a/src/main/docs/guide/config/propertySource.adoc +++ b/src/main/docs/guide/config/propertySource.adoc @@ -16,19 +16,47 @@ Micronaut framework by default contains `PropertySourceLoader` implementations t . Java System Properties . OS environment variables . Configuration files loaded in order from the system property 'micronaut.config.files' or the environment variable `MICRONAUT_CONFIG_FILES`. The value can be a comma-separated list of paths with the last file having precedence. The files can be referenced from: -.. the file system as an absolute path (without any prefix), +.. the file system as an absolute path (without any prefix), .. the classpath with a `classpath:` prefix. . Environment-specific properties from `application-{environment}.{extension}` . Application-specific properties from `application.{extension}` NOTE: 'micronaut.config.files' will be ignored in bootstrap.yml or application.yml. Loading additional configuration files from a configuration file is not supported -TIP: `.properties`, `.json`, `.yml` are supported out of the box. For Groovy users `.groovy` is supported as well. +TIP: `.properties`, `.json`, `.yml` and `.env` are supported out of the box. For Groovy users `.groovy` is supported as well. Note that if you want full control of where your application loads configuration from you can disable the default `PropertySourceLoader` implementations listed above by calling the `enableDefaultPropertySources(false)` method of the api:context.ApplicationContextBuilder[] interface when starting your application. In this case only explicit api:context.env.PropertySource[] instances that you add via the `propertySources(..)` method of the api:context.ApplicationContextBuilder[] interface will be used. +==== Loading Properties from .env Files + +Micronaut supports loading configuration from `.env` files. Place `.env` files in `src/main/resources/`. + +.Example .env file +[source,properties] +---- +# Database Configuration +DATABASE_URL=jdbc:postgresql://localhost:5432/mydb +API_KEY=secret-key + +# Variable substitution +BASE_URL=https://api.example.com +API_ENDPOINT=${BASE_URL}/v1 +---- + +The `.env` format supports: + +* Comments with `#` +* Single and double quotes +* Escape sequences (`\n`, `\t`, `\r`) in double quotes +* Variable substitution with `${VAR}` or `$VAR` +* Multiline values in quotes + +Keys are normalized to Micronaut's property format (e.g., `DATABASE_URL` becomes `database.url`). + +Environment-specific files (`.env.dev`, `.env.prod`) are also supported and override the base `.env` file. + === Supplying Configuration via Command Line Configuration can be supplied at the command line using Gradle or our Maven plugin. For example: