EnvironmentController.java

/*
 * Copyright 2013-2022 the original author or 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 org.springframework.cloud.config.server.environment;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.yaml.snakeyaml.DumperOptions.FlowStyle;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.nodes.Tag;

import org.springframework.cloud.config.environment.Environment;
import org.springframework.cloud.config.environment.EnvironmentMediaType;
import org.springframework.cloud.config.environment.PropertySource;
import org.springframework.cloud.config.server.support.PathUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import static org.springframework.cloud.config.server.support.EnvironmentPropertySource.prepareEnvironment;
import static org.springframework.cloud.config.server.support.EnvironmentPropertySource.resolvePlaceholders;

/**
 * @author Dave Syer
 * @author Spencer Gibb
 * @author Roy Clarkson
 * @author Bartosz Wojtkiewicz
 * @author Rafal Zukowski
 * @author Ivan Corrales Solera
 * @author Daniel Frey
 * @author Ian Bondoc
 * @author Chen Li
 *
 */
@RestController
@RequestMapping(method = RequestMethod.GET, path = "${spring.cloud.config.server.prefix:}")
public class EnvironmentController {

	private static final Log LOG = LogFactory.getLog(EnvironmentController.class);

	private EnvironmentRepository repository;

	private ObjectMapper objectMapper;

	private boolean stripDocument = true;

	private boolean acceptEmpty = true;

	public EnvironmentController(EnvironmentRepository repository) {
		this(repository, new ObjectMapper());
	}

	public EnvironmentController(EnvironmentRepository repository, ObjectMapper objectMapper) {
		this.repository = repository;
		this.objectMapper = objectMapper;
	}

	/**
	 * Flag to indicate that YAML documents which are not a map should be stripped of the
	 * "document" prefix that is added by Spring (to facilitate conversion to Properties).
	 * @param stripDocument the flag to set
	 */
	public void setStripDocumentFromYaml(boolean stripDocument) {
		this.stripDocument = stripDocument;
	}

	/**
	 * Flag to indicate that If HTTP 404 needs to be sent if Application is not Found.
	 * @param acceptEmpty the flag to set
	 */
	public void setAcceptEmpty(boolean acceptEmpty) {
		this.acceptEmpty = acceptEmpty;
	}

	@GetMapping(path = "/{name}/{profiles:(?!.*\\b\\.(?:ya?ml|properties|json)\\b).*}",
			produces = MediaType.APPLICATION_JSON_VALUE)
	public Environment defaultLabel(@PathVariable String name, @PathVariable String profiles) {
		return getEnvironment(name, profiles, null, false);
	}

	@GetMapping(path = "/{name}/{profiles:(?!.*\\b\\.(?:ya?ml|properties|json)\\b).*}",
			produces = EnvironmentMediaType.V2_JSON)
	public Environment defaultLabelIncludeOrigin(@PathVariable String name, @PathVariable String profiles) {
		return getEnvironment(name, profiles, null, true);
	}

	@GetMapping(path = "/{name}/{profiles}/{label:.*}", produces = MediaType.APPLICATION_JSON_VALUE)
	public Environment labelled(@PathVariable String name, @PathVariable String profiles, @PathVariable String label) {
		return getEnvironment(name, profiles, label, false);
	}

	@GetMapping(path = "/{name}/{profiles}/{label:.*}", produces = EnvironmentMediaType.V2_JSON)
	public Environment labelledIncludeOrigin(@PathVariable String name, @PathVariable String profiles,
			@PathVariable String label) {
		return getEnvironment(name, profiles, label, true);
	}

	public Environment getEnvironment(String name, String profiles, String label, boolean includeOrigin) {
		try {
			name = normalize(name);
			label = normalize(label);
			Environment environment = this.repository.findOne(name, profiles, label, includeOrigin);
			if (!this.acceptEmpty && (environment == null || environment.getPropertySources().isEmpty())) {
				throw new EnvironmentNotFoundException("Profile Not found");
			}
			return environment;
		}
		catch (Exception e) {
			LOG.warn(String.format("Error getting the Environment with name=%s profiles=%s label=%s includeOrigin=%b",
					name, profiles, label, includeOrigin), e);
			throw e;
		}
	}

	private String normalize(String part) {
		if (PathUtils.isInvalidEncodedLocation(part)) {
			throw new InvalidEnvironmentRequestException("Invalid request");
		}
		return Environment.normalize(part);
	}

	@GetMapping("/{name}-{profiles}.properties")
	public ResponseEntity<String> properties(@PathVariable String name, @PathVariable String profiles,
			@RequestParam(defaultValue = "true") boolean resolvePlaceholders) throws IOException {
		return labelledProperties(name, profiles, null, resolvePlaceholders);
	}

	@GetMapping("/{label}/{name}-{profiles}.properties")
	public ResponseEntity<String> labelledProperties(@PathVariable String name, @PathVariable String profiles,
			@PathVariable String label, @RequestParam(defaultValue = "true") boolean resolvePlaceholders)
			throws IOException {
		validateProfiles(profiles);
		Environment environment = labelled(name, profiles, label);
		Map<String, Object> properties = convertToProperties(environment);
		String propertiesString = getPropertiesString(properties);
		if (resolvePlaceholders) {
			propertiesString = resolvePlaceholders(prepareEnvironment(environment), propertiesString);
		}
		return getSuccess(propertiesString);
	}

	@GetMapping("{name}-{profiles}.json")
	public ResponseEntity<String> jsonProperties(@PathVariable String name, @PathVariable String profiles,
			@RequestParam(defaultValue = "true") boolean resolvePlaceholders) throws Exception {
		return labelledJsonProperties(name, profiles, null, resolvePlaceholders);
	}

	@GetMapping("/{label}/{name}-{profiles}.json")
	public ResponseEntity<String> labelledJsonProperties(@PathVariable String name, @PathVariable String profiles,
			@PathVariable String label, @RequestParam(defaultValue = "true") boolean resolvePlaceholders)
			throws Exception {
		validateProfiles(profiles);
		Environment environment = labelled(name, profiles, label);
		Map<String, Object> properties = convertToMap(environment);
		String json = this.objectMapper.writeValueAsString(properties);
		if (resolvePlaceholders) {
			json = resolvePlaceholders(prepareEnvironment(environment), json);
		}
		return getSuccess(json, MediaType.APPLICATION_JSON);
	}

	private String getPropertiesString(Map<String, Object> properties) {
		StringBuilder output = new StringBuilder();
		for (Entry<String, Object> entry : properties.entrySet()) {
			if (output.length() > 0) {
				output.append("\n");
			}
			output.append(entry.getKey()).append(": ").append(entry.getValue());
		}
		return output.toString();
	}

	@GetMapping({ "/{name}-{profiles}.yml", "/{name}-{profiles}.yaml" })
	public ResponseEntity<String> yaml(@PathVariable String name, @PathVariable String profiles,
			@RequestParam(defaultValue = "true") boolean resolvePlaceholders) throws Exception {
		return labelledYaml(name, profiles, null, resolvePlaceholders);
	}

	@GetMapping({ "/{label}/{name}-{profiles}.yml", "/{label}/{name}-{profiles}.yaml" })
	public ResponseEntity<String> labelledYaml(@PathVariable String name, @PathVariable String profiles,
			@PathVariable String label, @RequestParam(defaultValue = "true") boolean resolvePlaceholders)
			throws Exception {
		validateProfiles(profiles);
		Environment environment = labelled(name, profiles, label);
		Map<String, Object> result = convertToMap(environment);
		if (this.stripDocument && result.size() == 1 && result.keySet().iterator().next().equals("document")) {
			Object value = result.get("document");
			if (value instanceof Collection) {
				return getSuccess(new Yaml().dumpAs(value, Tag.SEQ, FlowStyle.BLOCK));
			}
			else {
				return getSuccess(new Yaml().dumpAs(value, Tag.STR, FlowStyle.BLOCK));
			}
		}
		String yaml = new Yaml().dumpAsMap(result);

		if (resolvePlaceholders) {
			yaml = resolvePlaceholders(prepareEnvironment(environment), yaml);
		}

		return getSuccess(yaml);
	}

	/**
	 * Method {@code convertToMap} converts an {@code Environment} to a nested Map which
	 * represents a yml/json structure.
	 * @param input the environment to be converted
	 * @return the nested map containing the environment's properties
	 */
	private Map<String, Object> convertToMap(Environment input) {
		// First use the current convertToProperties to get a flat Map from the
		// environment
		Map<String, Object> properties = convertToProperties(input);

		// The root map which holds all the first level properties
		Map<String, Object> rootMap = new LinkedHashMap<>();
		for (Map.Entry<String, Object> entry : properties.entrySet()) {
			String key = entry.getKey();
			Object value = entry.getValue();
			PropertyNavigator nav = new PropertyNavigator(key);
			nav.setMapValue(rootMap, value);
		}
		return rootMap;
	}

	@ExceptionHandler(RepositoryException.class)
	public void noSuchLabel(HttpServletResponse response) throws IOException {
		response.sendError(HttpStatus.NOT_FOUND.value());
	}

	@ExceptionHandler(IllegalArgumentException.class)
	public void illegalArgument(HttpServletResponse response) throws IOException {
		response.sendError(HttpStatus.BAD_REQUEST.value());
	}

	@ExceptionHandler(EnvironmentException.class)
	public void environmentException(HttpServletResponse response, EnvironmentException e) throws IOException {
		response.sendError(HttpStatus.INTERNAL_SERVER_ERROR.value(), e.getMessage());
	}

	private void validateProfiles(String profiles) {
		if (profiles.contains("-")) {
			throw new IllegalArgumentException(
					"Properties output not supported for name or profiles containing hyphens");
		}
	}

	private HttpHeaders getHttpHeaders(MediaType mediaType) {
		HttpHeaders httpHeaders = new HttpHeaders();
		httpHeaders.setContentType(mediaType);
		return httpHeaders;
	}

	private ResponseEntity<String> getSuccess(String body) {
		return new ResponseEntity<>(body, getHttpHeaders(MediaType.TEXT_PLAIN), HttpStatus.OK);
	}

	private ResponseEntity<String> getSuccess(String body, MediaType mediaType) {
		return new ResponseEntity<>(body, getHttpHeaders(mediaType), HttpStatus.OK);
	}

	private Map<String, Object> convertToProperties(Environment profiles) {
		List<PropertySource> sources = new ArrayList<>(profiles.getPropertySources());
		Collections.reverse(sources);
		Map<String, Object> combinedMap = new LinkedHashMap<>();
		Map<String, Map<String, Object>> arrayMap = new LinkedHashMap<>();
		for (PropertySource source : sources) {
			@SuppressWarnings("unchecked")
			Map<String, Object> value = (Map<String, Object>) source.getSource();
			Map<String, Map<String, Object>> currentArrayMap = new LinkedHashMap<>();

			for (Entry<String, Object> entry : value.entrySet()) {
				if (!entry.getKey().contains("[")) {
					combinedMap.put(entry.getKey(), entry.getValue());
				}
				else {
					String prefixKey = entry.getKey().substring(0, entry.getKey().indexOf("["));
					currentArrayMap.computeIfAbsent(prefixKey, k -> new LinkedHashMap<>()).put(entry.getKey(),
							entry.getValue());
				}
			}
			// Override array properties by prefix key
			arrayMap.putAll(currentArrayMap);
		}

		// Combine all unique keys for array values into the combined map
		for (Entry<String, Map<String, Object>> entry : arrayMap.entrySet()) {
			combinedMap.putAll(entry.getValue());
		}

		postProcessProperties(combinedMap);
		return combinedMap;
	}

	private void postProcessProperties(Map<String, Object> propertiesMap) {
		propertiesMap.keySet().removeIf(key -> key.equals("spring.profiles"));
	}

	/**
	 * Class {@code PropertyNavigator} is used to navigate through the property key and
	 * create necessary Maps and Lists making up the nested structure to finally set the
	 * property value at the leaf node.
	 * <p>
	 * The following rules in yml/json are implemented: <pre>
	 * 1. an array element can be:
	 *    - a value (leaf)
	 *    - a map
	 *    - a nested array
	 * 2. a map value can be:
	 *    - a value (leaf)
	 *    - a nested map
	 *    - an array
	 * </pre>
	 */
	private static final class PropertyNavigator {

		private final String propertyKey;

		// Supports keys like org.x and org.x.y like in boot logging
		private String prefix = "";

		private int currentPos;

		private NodeType valueType;

		private PropertyNavigator(String propertyKey) {
			this.propertyKey = propertyKey;
			this.currentPos = -1;
			this.valueType = NodeType.MAP;
		}

		@SuppressWarnings("unchecked")
		private void setMapValue(Map<String, Object> map, Object value) {
			String key = getKey();
			if (NodeType.MAP.equals(this.valueType)) {
				Map<String, Object> nestedMap;
				if (map.get(key) instanceof Map) {
					nestedMap = (Map<String, Object>) map.get(key);
				}
				else if (map.get(key) != null) {
					// not an object, set prefix for later
					prefix = key + ".";
					nestedMap = map;
				}
				else {
					// value of key is null
					nestedMap = new LinkedHashMap<>();
					map.put(key, nestedMap);
				}
				setMapValue(nestedMap, value);
			}
			else if (NodeType.ARRAY.equals(this.valueType)) {
				List<Object> list = (List<Object>) map.get(key);
				if (list == null) {
					list = new ArrayList<>();
					map.put(key, list);
				}
				setListValue(list, value);
			}
			else {
				// use compound prefix
				map.put(prefix + key, value);
			}
		}

		private void setListValue(List<Object> list, Object value) {
			int index = getIndex();
			// Fill missing elements if needed
			while (list.size() <= index) {
				list.add(null);
			}
			if (NodeType.MAP.equals(this.valueType)) {
				@SuppressWarnings("unchecked")
				Map<String, Object> map = (Map<String, Object>) list.get(index);
				if (map == null) {
					map = new LinkedHashMap<>();
					list.set(index, map);
				}
				setMapValue(map, value);
			}
			else if (NodeType.ARRAY.equals(this.valueType)) {
				@SuppressWarnings("unchecked")
				List<Object> nestedList = (List<Object>) list.get(index);
				if (nestedList == null) {
					nestedList = new ArrayList<>();
					list.set(index, nestedList);
				}
				setListValue(nestedList, value);
			}
			else {
				list.set(index, value);
			}
		}

		private int getIndex() {
			// Consider [
			int start = this.currentPos + 1;

			for (int i = start; i < this.propertyKey.length(); i++) {
				char c = this.propertyKey.charAt(i);
				if (c == ']') {
					this.currentPos = i;
					break;
				}
				else if (!Character.isDigit(c)) {
					throw new IllegalArgumentException("Invalid key: " + this.propertyKey);
				}
			}
			// If no closing ] or if '[]'
			if (this.currentPos < start || this.currentPos == start) {
				throw new IllegalArgumentException("Invalid key: " + this.propertyKey);
			}
			else {
				int index = Integer.parseInt(this.propertyKey.substring(start, this.currentPos));
				// Skip the closing ]
				this.currentPos++;
				if (this.currentPos == this.propertyKey.length()) {
					this.valueType = NodeType.LEAF;
				}
				else {
					switch (this.propertyKey.charAt(this.currentPos)) {
						case '.':
							this.valueType = NodeType.MAP;
							break;
						case '[':
							this.valueType = NodeType.ARRAY;
							break;
						default:
							throw new IllegalArgumentException("Invalid key: " + this.propertyKey);
					}
				}
				return index;
			}
		}

		private String getKey() {
			// Consider initial value or previous char '.' or '['
			int start = this.currentPos + 1;
			int openingBracketPosition = -1;
			for (int i = start; i < this.propertyKey.length(); i++) {
				char currentChar = this.propertyKey.charAt(i);
				if (currentChar == '.') {
					this.valueType = NodeType.MAP;
					this.currentPos = i;
					break;
				}
				else if (currentChar == '[') {
					openingBracketPosition = i;
				}
				else if (currentChar == ']') {
					String bracketContents = this.propertyKey.substring(openingBracketPosition + 1, i);
					try {
						Integer.parseInt(bracketContents);
						this.valueType = NodeType.ARRAY;
						this.currentPos = openingBracketPosition;
						break;
					}
					catch (NumberFormatException e) {
						// This means the key contains a [ and a ] but the contents were
						// not an integer so it's not an array
					}
				}
			}
			// If there's no delimiter then it's a key of a leaf
			if (this.currentPos < start) {
				this.currentPos = this.propertyKey.length();
				this.valueType = NodeType.LEAF;
				// Else if we encounter '..' or '.[' or start of the property is . or [
				// then it's invalid
			}
			else if (this.currentPos == start) {
				throw new IllegalArgumentException("Invalid key: " + this.propertyKey);
			}
			return this.propertyKey.substring(start, this.currentPos);
		}

		private enum NodeType {

			LEAF, MAP, ARRAY

		}

	}

}