001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.commons.settings; 018 019import java.util.*; 020import java.util.concurrent.*; 021 022/** 023 * A {@link PropertySource} decorator that adds <b>relaxed binding</b> over a delegate source. 024 * 025 * <p> 026 * When a property lookup misses against the delegate, this source retries with a small set of 027 * <i>canonical name variants</i> derived from the requested name, so a single logical key can be supplied in any of 028 * the common environment conventions and still resolve. This is the mechanism that lets a Kubernetes-style 029 * environment variable such as {@code MY_SECTION_MY_KEY} satisfy a lookup for {@code MySection/myKey}, or 030 * {@code MY_PROP} satisfy {@code my.prop} / {@code myProp}. 031 * 032 * <h5 class='section'>Candidate order</h5> 033 * <p> 034 * The requested name is always tried <b>verbatim first</b>, so exact-match behavior is preserved unchanged (an 035 * already-canonical key resolves with no transformation, and a source that defines the literal name wins). Only on a 036 * miss are the relaxed variants tried, in this order: 037 * <ol> 038 * <li>verbatim (the requested name, unchanged); 039 * <li><b>upper underscore</b> — every run of non-alphanumeric characters (and every lower→upper camel-case 040 * boundary) becomes a single {@code _}, then the whole thing is upper-cased. This is the dominant env-var form: 041 * {@code my.prop}/{@code my-prop}/{@code myProp}/{@code MySection/myKey} → {@code MY_PROP} / 042 * {@code MY_SECTION_MY_KEY}; 043 * <li><b>lower dotted</b> — the same separator collapse but joined with {@code .} and lower-cased 044 * ({@code MY_PROP} → {@code my.prop}), so a dotted-property lookup can be satisfied by an env-style key and 045 * vice-versa. 046 * </ol> 047 * 048 * <p> 049 * Because the boundary between "section" and "key" in a flat relaxed name (e.g. the underscores in 050 * {@code MY_SECTION_MY_KEY}) is inherently ambiguous, this decorator deliberately works in the 051 * <b>lookup-key → candidate-name</b> direction only (the well-defined direction): the caller asks for a known 052 * logical key and the decorator generates the environment-convention spellings to probe. It never tries to reverse a 053 * flat env name back into a {@code section/key} pair. 054 * 055 * <h5 class='section'>Usage</h5> 056 * <p class='bjava'> 057 * <jc>// Wrap the system-env source so env vars bind relaxedly.</jc> 058 * <jv>settings</jv> = Settings.<jsm>create</jsm>().addSource(<jk>new</jk> RelaxedPropertySource(Settings.<jsf>SYSTEM_ENV_SOURCE</jsf>)).build(); 059 * </p> 060 * 061 * @since 10.0.0 062 */ 063public class RelaxedPropertySource implements PropertySource { 064 065 private final PropertySource delegate; 066 067 /** 068 * Constructor. 069 * 070 * @param delegate The wrapped source that the generated candidate names are probed against. Must not be <jk>null</jk>. 071 */ 072 public RelaxedPropertySource(PropertySource delegate) { 073 this.delegate = Objects.requireNonNull(delegate, "delegate"); 074 } 075 076 @Override 077 public PropertyLookupResult get(String name) { 078 if (name == null) 079 return PropertyLookupResult.missing(); 080 for (var candidate : candidates(name)) { 081 var r = delegate.get(candidate); 082 if (r.isPresent()) 083 return r; 084 } 085 return PropertyLookupResult.missing(); 086 } 087 088 private static final ConcurrentHashMap<String,List<String>> CANDIDATES = new ConcurrentHashMap<>(); 089 090 /** 091 * Generates the ordered, de-duplicated list of candidate names for a requested name (verbatim first). 092 * 093 * <p> 094 * The result is <b>unmodifiable</b> and memoized per name, with the same soft cap and pass-through rule as 095 * {@link CachingPropertySource} (names beyond {@link CachingPropertySource#MAX_ENTRIES} are computed on every call). 096 * 097 * @param name The requested property name. Must not be <jk>null</jk>. 098 * @return The candidate names to probe, in priority order. Unmodifiable. 099 * @throws NullPointerException If <c>name</c> is <jk>null</jk>. 100 * @since 10.0.0 101 */ 102 public static List<String> candidates(String name) { 103 var cached = CANDIDATES.get(name); // throws NullPointerException for a null name 104 if (cached != null) 105 return cached; 106 var computed = compute(name); 107 if (CANDIDATES.size() < CachingPropertySource.MAX_ENTRIES) { 108 var previous = CANDIDATES.putIfAbsent(name, computed); 109 if (previous != null) 110 return previous; 111 } 112 return computed; 113 } 114 115 private static List<String> compute(String name) { 116 var out = new ArrayList<String>(3); 117 out.add(name); 118 var tokens = tokenize(name); 119 if (! tokens.isEmpty()) { 120 addIfNew(out, String.join("_", tokens).toUpperCase(Locale.ROOT)); 121 addIfNew(out, String.join(".", tokens).toLowerCase(Locale.ROOT)); 122 } 123 return List.copyOf(out); 124 } 125 126 /** 127 * Splits a name into lowercase-normalized word tokens, breaking on any non-alphanumeric run and on each 128 * lower→upper camel-case boundary. E.g. {@code "MySection/myKey"} → {@code [my, section, my, key]}. 129 */ 130 private static List<String> tokenize(String name) { 131 var tokens = new ArrayList<String>(); 132 var sb = new StringBuilder(); 133 char prev = 0; 134 for (var i = 0; i < name.length(); i++) { 135 var c = name.charAt(i); 136 if (! Character.isLetterOrDigit(c)) { 137 // Separator (., -, /, _, space, …): flush the current token. 138 if (! sb.isEmpty()) { 139 tokens.add(sb.toString()); 140 sb.setLength(0); 141 } 142 } else { 143 // camelCase boundary: lower/digit followed by upper starts a new token. 144 if (! sb.isEmpty() && Character.isUpperCase(c) && (Character.isLowerCase(prev) || Character.isDigit(prev))) { 145 tokens.add(sb.toString()); 146 sb.setLength(0); 147 } 148 sb.append(Character.toLowerCase(c)); 149 } 150 prev = c; 151 } 152 if (! sb.isEmpty()) 153 tokens.add(sb.toString()); 154 return tokens; 155 } 156 157 private static void addIfNew(List<String> out, String candidate) { 158 if (! out.contains(candidate)) 159 out.add(candidate); 160 } 161}