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.config.store; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.util.*; 023 024import org.apache.juneau.config.format.*; 025import org.apache.juneau.config.internal.*; 026import org.apache.juneau.marshall.*; 027 028/** 029 * A {@link ConfigStore} decorator that layers <b>profile overlays</b> over a delegate store's base config. 030 * 031 * <p> 032 * Given a set of active profiles (e.g. {@code ["stage"]} or {@code ["base","cloud"]}) and a base config name 033 * {@code N}, a {@code read(N)} returns the base contents from the delegate with each active profile's 034 * {@code N-<profile>} overlay merged on top — profile wins over base, last-active-profile wins over earlier 035 * ones (see {@link ProfileMerge}). Reads for any name <i>other</i> than the configured base name pass straight 036 * through to the delegate. 037 * 038 * <p> 039 * <b>Live reload:</b> the store registers a listener on the delegate for the base name <i>and</i> each profile name, 040 * so a change to the base file or any active profile file re-runs the merge and re-fires the change to this store's 041 * own listeners (the {@link ConfigMap} sees a fresh merged contents and reloads). 042 * 043 * <p> 044 * The profile file name is derived from the base name by inserting {@code -<profile>} before the extension 045 * (e.g. base {@code my.cfg} + profile {@code stage} → {@code my-stage.cfg}); a base with no extension simply 046 * appends {@code -<profile>}. Writes, existence checks, and name resolution delegate to the wrapped store. 047 * 048 * @since 10.0.0 049 */ 050@SuppressWarnings({ 051 "resource" // Decorator owns the wrapped delegate store and closes it in close(); the delegate's lifecycle is managed here, not leaked. 052}) 053public class ProfileConfigStore extends ConfigStore { 054 055 /** 056 * Builder class. 057 */ 058 public static class Builder extends ConfigStore.Builder<Builder> { 059 060 ConfigStore delegate; 061 String baseName; 062 List<String> profiles = l(); 063 ConfigFormat format = IniConfigFormat.INSTANCE; 064 065 /** Constructor, default settings. */ 066 protected Builder() {} 067 068 /** 069 * Copy constructor. 070 * 071 * @param copyFrom The builder to copy from. Cannot be <jk>null</jk>. 072 */ 073 protected Builder(Builder copyFrom) { 074 super(reqnn("copyFrom", copyFrom)); 075 this.delegate = copyFrom.delegate; 076 this.baseName = copyFrom.baseName; 077 this.profiles = new ArrayList<>(copyFrom.profiles); 078 this.format = copyFrom.format; 079 } 080 081 /** 082 * Copy constructor. 083 * 084 * @param copyFrom The bean to copy from. Cannot be <jk>null</jk>. 085 */ 086 protected Builder(ProfileConfigStore copyFrom) { 087 super(reqnn("copyFrom", copyFrom)); 088 type(copyFrom.getClass()); 089 this.delegate = copyFrom.delegate; 090 this.baseName = copyFrom.baseName; 091 this.profiles = new ArrayList<>(copyFrom.profiles); 092 this.format = copyFrom.format; 093 } 094 095 /** 096 * Sets the wrapped delegate store that supplies the base + profile file contents. 097 * 098 * @param value The delegate store. 099 * @return This object. 100 */ 101 public Builder delegate(ConfigStore value) { delegate = value; return this; } 102 103 /** 104 * Sets the base config name whose {@code -<profile>} overlays are merged. 105 * 106 * @param value The base config name. 107 * @return This object. 108 */ 109 public Builder baseName(String value) { baseName = value; return this; } 110 111 /** 112 * Sets the active profiles, in activation order (last-active-profile wins). 113 * 114 * @param value 115 * The active profile names. 116 * <br>Can be <jk>null</jk> (treated as no active profiles). 117 * @return This object. 118 */ 119 public Builder profiles(List<String> value) { profiles = value == null ? l() : new ArrayList<>(value); return this; } 120 121 /** 122 * Sets the config format used to parse the base + profile files. 123 * 124 * @param value 125 * The format. 126 * <br>Can be <jk>null</jk> (defaults to {@link IniConfigFormat}). 127 * @return This object. 128 */ 129 public Builder format(ConfigFormat value) { format = value == null ? IniConfigFormat.INSTANCE : value; return this; } 130 131 @Override /* Overridden from Context.Builder<?> */ 132 public ProfileConfigStore build() { 133 return build(ProfileConfigStore.class); 134 } 135 136 @Override /* Overridden from Context.Builder<?> */ 137 public Builder copy() { 138 return new Builder(this); 139 } 140 } 141 142 /** 143 * Creates a new builder for this object. 144 * 145 * @return A new builder. 146 */ 147 public static Builder create() { 148 return new Builder(); 149 } 150 151 private final ConfigStore delegate; 152 private final String baseName; 153 private final List<String> profiles; 154 private final ConfigFormat format; 155 private final ConfigStoreListener reloadListener; 156 157 /** 158 * Constructor. 159 * 160 * @param builder The builder for this object. 161 * <br>Cannot be <jk>null</jk>. 162 */ 163 public ProfileConfigStore(Builder builder) { 164 super(builder); 165 this.delegate = reqnn("delegate", builder.delegate); 166 this.baseName = reqnn("baseName", builder.baseName); 167 this.profiles = List.copyOf(builder.profiles); 168 this.format = builder.format; 169 170 // Re-merge + re-notify whenever the base or any active profile file changes underneath us. 171 this.reloadListener = contents -> { 172 try { 173 update(baseName, mergedContents()); 174 } catch (IOException e) { // HTT: re-merge IO failure only on a real FileStore IO error mid-reload; not reproducible with in-memory stores. 175 throw new ConfigException(e, "Failed to re-merge profile overlays for '%s'", baseName); 176 } 177 }; 178 delegate.register(baseName, reloadListener); 179 for (var p : profiles) 180 delegate.register(profileName(p), reloadListener); 181 } 182 183 @Override /* Overridden from Context */ 184 public Builder copy() { 185 return new Builder(this); 186 } 187 188 @Override /* Overridden from Closeable */ 189 public void close() throws IOException { 190 delegate.unregister(baseName, reloadListener); 191 for (var p : profiles) 192 delegate.unregister(profileName(p), reloadListener); 193 delegate.close(); 194 } 195 196 @Override /* Overridden from ConfigStore */ 197 public boolean exists(String name) { 198 return delegate.exists(name); 199 } 200 201 @Override /* Overridden from ConfigStore */ 202 public String read(String name) throws IOException { 203 if (neq(name, baseName)) 204 return delegate.read(name); 205 return mergedContents(); 206 } 207 208 @Override /* Overridden from ConfigStore */ 209 public String write(String name, String expectedContents, String newContents) throws IOException { 210 return delegate.write(name, expectedContents, newContents); 211 } 212 213 @Override /* Overridden from ConfigStore */ 214 public String getNotifiedContents(String name, String written) throws IOException { 215 if (neq(name, baseName)) 216 return written; 217 return mergeWithOverlays(written); 218 } 219 220 /** 221 * Reads the base + each active profile from the delegate and merges them (profile-wins, last-active-wins). 222 */ 223 private String mergedContents() throws IOException { 224 return mergeWithOverlays(delegate.read(baseName)); 225 } 226 227 /** 228 * Merges the specified base text with the current contents of each active profile overlay. 229 */ 230 @SuppressWarnings({ 231 "java:S9391" // Loop body throws checked IOException, which a stream lambda cannot propagate. 232 }) 233 private String mergeWithOverlays(String base) throws IOException { 234 if (profiles.isEmpty()) 235 return base; 236 var overlays = new ArrayList<String>(profiles.size()); 237 for (var p : profiles) 238 overlays.add(delegate.read(profileName(p))); 239 return ProfileMerge.merge(delegate, baseName, base, overlays, format); 240 } 241 242 /** 243 * Derives the profile file name from the base name by inserting {@code -<profile>} before the extension. 244 * 245 * @param profile The profile name. 246 * @return The profile config name. 247 */ 248 String profileName(String profile) { 249 var dot = baseName.lastIndexOf('.'); 250 if (dot < 0) 251 return baseName + "-" + profile; 252 return baseName.substring(0, dot) + "-" + profile + baseName.substring(dot); 253 } 254}