001/*
002 * Copyright (C) 2022-2023 The Prometheus jmx_exporter Authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package io.prometheus.jmx.common.yaml;
018
019import java.util.LinkedHashMap;
020import java.util.Map;
021import java.util.Optional;
022import java.util.function.Supplier;
023import java.util.regex.Pattern;
024
025/**
026 * Class to implement a MapAccessor to work with nested Maps / values
027 */
028@SuppressWarnings("unchecked")
029public class YamlMapAccessor {
030
031    private final Map<Object, Object> map;
032
033    /**
034     * Constructor
035     *
036     * @param map map
037     */
038    public YamlMapAccessor(Map<Object, Object> map) {
039        if (map == null) {
040            throw new IllegalArgumentException("Map is null");
041        }
042
043        this.map = map;
044    }
045
046    /**
047     * Method to determine if a path exists
048     *
049     * @param path path
050     * @return true if the path exists (but could be null), false otherwise
051     */
052    public boolean containsPath(String path) {
053        if (path == null || path.trim().isEmpty()) {
054            throw new IllegalArgumentException(
055                    String.format("path [%s] is invalid", path));
056        }
057
058        path = validatePath(path);
059        if (path.equals("/")) {
060            return true;
061        }
062
063        String[] pathTokens = path.split(Pattern.quote("/"));
064        Map<Object, Object> subMap = map;
065
066        for (int i = 1; i < pathTokens.length; i++) {
067            try {
068                if (subMap.containsKey(pathTokens[i])) {
069                    subMap = (Map<Object, Object>) subMap.get(pathTokens[i]);
070                } else {
071                    return false;
072                }
073            } catch (NullPointerException e) {
074                return false;
075            } catch (ClassCastException e) {
076                return false;
077            }
078        }
079
080        return true;
081    }
082
083    /**
084     * Method to get a path Object
085     *
086     * @param path path
087     * @return an Optional containing the path Object or an empty Optional if the path doesn't exist
088     */
089    public Optional<Object> get(String path) {
090        if (path == null || path.trim().isEmpty()) {
091            throw new IllegalArgumentException(
092                    String.format("path [%s] is invalid", path));
093        }
094
095        path = validatePath(path);
096        if (path.equals("/")) {
097            return Optional.of(map);
098        }
099
100        String[] pathTokens = path.split(Pattern.quote("/"));
101        Object object = map;
102
103        for (int i = 1; i < pathTokens.length; i++) {
104            try {
105                object = resolve(pathTokens[i], object);
106            } catch (NullPointerException e) {
107                return Optional.ofNullable(null);
108            } catch (ClassCastException e) {
109                return Optional.ofNullable(null);
110            }
111        }
112
113        return Optional.ofNullable(object);
114    }
115
116    /**
117     * Method to get a path Object or create an Object using the Supplier
118     * <p>
119     * parent paths will be created if required
120     *
121     * @param path path
122     * @return an Optional containing the path Object or Optional created by the Supplier
123     */
124    public Optional<Object> getOrCreate(String path, Supplier<Object> supplier) {
125        if (path == null || path.trim().isEmpty()) {
126            throw new IllegalArgumentException(
127                    String.format("path [%s] is invalid", path));
128        }
129
130        path = validatePath(path);
131        if (path.equals("/")) {
132            return Optional.of(map);
133        }
134
135        if (supplier == null) {
136            throw new IllegalArgumentException("supplier is null");
137        }
138
139        String[] pathTokens = path.split(Pattern.quote("/"));
140        Object previous = map;
141        Object current = null;
142
143        for (int i = 1; i < pathTokens.length; i++) {
144            try {
145                current = resolve(pathTokens[i], previous);
146                if (current == null) {
147                    if ((i + 1) == pathTokens.length) {
148                        Object object = supplier.get();
149                        ((Map<String, Object>) previous).put(pathTokens[i], object);
150                        return Optional.of(object);
151                    } else {
152                        current = new LinkedHashMap<String, Object>();
153                        ((Map<String, Object>) previous).put(pathTokens[i], current);
154                    }
155                }
156                previous = current;
157            } catch (NullPointerException e) {
158                return Optional.ofNullable(null);
159            } catch (ClassCastException e) {
160                if ((i + 1) == pathTokens.length) {
161                    throw new IllegalArgumentException(
162                            String.format("path [%s] isn't a Map", flatten(pathTokens, 1, i)));
163                }
164                return Optional.ofNullable(null);
165            }
166        }
167
168        return Optional.ofNullable(current);
169    }
170
171    /**
172     * Method to get a path Object, throwing an RuntimeException created by the Supplier if the path doesn't exist
173     *
174     * @param path path
175     * @return an Optional containing the path Object
176     */
177    public Optional<Object> getOrThrow(String path, Supplier<? extends RuntimeException> supplier) {
178        if (path == null || path.trim().isEmpty()) {
179            throw new IllegalArgumentException(
180                    String.format("path [%s] is invalid", path));
181        }
182
183        if (supplier == null) {
184            throw new IllegalArgumentException("supplier is null");
185        }
186
187        path = validatePath(path);
188        if (path.equals("/")) {
189            return Optional.of(map);
190        }
191
192        String[] pathTokens = path.split(Pattern.quote("/"));
193        Object object = map;
194
195        for (int i = 1; i < pathTokens.length; i++) {
196            try {
197                object = resolve(pathTokens[i], object);
198            } catch (NullPointerException e) {
199                throw supplier.get();
200            } catch (ClassCastException e) {
201                throw supplier.get();
202            }
203
204            if (object == null && i < pathTokens.length) {
205                throw supplier.get();
206            }
207        }
208
209        return Optional.ofNullable(object);
210    }
211
212    /**
213     * Method to get a MapAccessor backed by an empty Map
214     *
215     * @return the return value
216     */
217    public static YamlMapAccessor empty() {
218        return new YamlMapAccessor(new LinkedHashMap<>());
219    }
220
221    /**
222     * Method to validate a path
223     *
224     * @param path path
225     * @return the return value
226     */
227    private String validatePath(String path) {
228        if (path == null) {
229            throw new IllegalArgumentException("path is null");
230        }
231
232        if (path.equals("/")) {
233            return path;
234        }
235
236        path = path.trim();
237
238        if (path.isEmpty()) {
239            throw new IllegalArgumentException("path is empty");
240        }
241
242        if (!path.startsWith("/")) {
243            throw new IllegalArgumentException(String.format("path [%s] is invalid", path));
244        }
245
246        if (path.endsWith("/")) {
247            throw new IllegalArgumentException(String.format("path [%s] is invalid", path));
248        }
249
250        return path;
251    }
252
253    /**
254     * Method to resolve a path token to an Object
255     *
256     * @param pathToken pathToken
257     * @param object object
258     * @return the return value
259     * @param <T> the return type
260     */
261    private <T> T resolve(String pathToken, Object object) {
262        return (T) ((Map<String, Object>) object).get(pathToken);
263    }
264
265    /**
266     * Method to flatten an array of path tokens to a path
267     *
268     * @param pathTokens pathTokens
269     * @param begin begin
270     * @param end end
271     * @return the return value
272     */
273    private String flatten(String[] pathTokens, int begin, int end) {
274        StringBuilder stringBuilder = new StringBuilder();
275        for (int i = begin; i < end; i++) {
276            stringBuilder.append("/").append(pathTokens[i]);
277        }
278
279        return stringBuilder.toString();
280    }
281}