/*
* Copyright (c) 2016 ingenieux Labs
*
* 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
*
* http://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 br.com.ingenieux.mojo.apigateway;
import com.google.common.collect.Lists;
import com.amazonaws.services.apigateway.model.CreateDeploymentRequest;
import com.amazonaws.services.apigateway.model.CreateDeploymentResult;
import com.amazonaws.services.apigateway.model.CreateRestApiRequest;
import com.amazonaws.services.apigateway.model.CreateRestApiResult;
import com.amazonaws.services.apigateway.model.PutMode;
import com.amazonaws.services.apigateway.model.PutRestApiRequest;
import com.amazonaws.services.apigateway.model.PutRestApiResult;
import com.amazonaws.services.identitymanagement.AmazonIdentityManagementClient;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.flipkart.zjsonpatch.JsonPatch;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.text.StrSubstitutor;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import java.io.File;
import java.io.FileInputStream;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import br.com.ingenieux.mojo.apigateway.util.Unthrow;
import br.com.ingenieux.mojo.aws.util.RoleResolver;
import static java.lang.String.format;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toSet;
import static org.apache.commons.lang.StringUtils.defaultString;
import static org.apache.commons.lang.StringUtils.isBlank;
import static org.apache.commons.lang.StringUtils.isEmpty;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
@Mojo(name = "create-or-update", requiresProject = true)
public class CreateOrUpdateMojo extends AbstractAPIGatewayMojo {
/**
* Regex to Find Parameter Keys (when importing)
*/
public static final Pattern KEY_PARAM_REGEX = Pattern.compile("^apigateway\\.param\\.(.+)$");
/**
* Regex to Find Stage Parameters
*/
public static final Pattern KEY_STAGE_VARIABLE_REGEX = Pattern.compile("^apigateway\\.stage\\.([^\\.]{3,}).(.+)$");
private static final String STR_TEMPLATE_LAMBDA_METHOD = loadResourceAsString("lambda-method.json");
private static final String STR_TEMPLATE_CORS_METHOD =
loadResourceAsString("cors-method.json");
private static String loadResourceAsString(String name) {
try {
return IOUtils.toString(CreateOrUpdateMojo.class.getClassLoader().getResource("templates/" + name));
} catch (Exception exc) {
throw new IllegalStateException("While loading template: " + name, exc);
}
}
private static final Pattern PATTERN_PARAMETER = Pattern.compile("\\{(\\w+)\\}");
/**
* Rest API Description
*/
@Parameter(property = "apigateway.restApiDescription", required = false, defaultValue = "API for ${project.artifactId}")
protected String restApiDescription;
/**
* Stage Name Description
*/
@Parameter(property = "apigateway.stageNameDescription", required = true, defaultValue = "${apigateway.stageName} stage")
protected String stageNameDescription;
/**
* Stage Deployment Description
*/
@Parameter(property = "apigateway.stageDeploymentDescription", required = true, defaultValue = "Updated by apigateway-maven-plugin")
protected String deploymentDescription;
/**
* Deployment File to Use (Swagger)
*/
@Parameter(
property = "apigateway.deploymentFile",
required = true,
defaultValue = "${project.build.outputDirectory}/META-INF/apigateway/apigateway-swagger.json"
)
protected File deploymentFile;
/**
* Deployment File to Use (Lambda)
*/
@Parameter(property = "apigateway.lambdasFile", defaultValue = "${project.build.outputDirectory}/META-INF/lambda-definitions.json")
protected File lambdasFile;
/**
* Cache Cluster Enabled (when creating a new stage)
*/
@Parameter(property = "apigateway.cacheClusterEnabled", defaultValue = "false")
protected Boolean cacheClusterEnabled;
/**
* Cache Cluster Size (when creating a new stage)
*/
@Parameter(property = "apigateway.cacheClusterSize")
protected String cacheClusterSize;
/**
* New Stage Definitions
*/
@Parameter(required = false)
protected Map<String, Map<String, String>> stageVariables = new LinkedHashMap<>();
/**
* Overwrite Definitions (otherwise, merge)
*/
@Parameter(property = "apigateway.overwriteDefinitions", required = true, defaultValue = "false")
protected Boolean overwriteDefinitions;
/**
* Overwrite Definitions (otherwise, merge)
*/
@Parameter(property = "apigateway.overwriteDefinitions", required = true, defaultValue = "false")
protected Boolean skipInterpolate;
/**
* Parameters
*/
@Parameter(property = "apigateway.parameters", required = false)
protected Map<String, String> parameters = new HashMap<>();
/**
* Remove Conflicting Declarations? Disable for advanced usage
*/
@Parameter(property = "apigateway.removeConflicting", required = true, defaultValue = "true")
protected boolean removeConflicting;
/**
* Resulting Body to use
*/
protected String body;
private RoleResolver roleResolver;
private List<LambdaDefinition> lambdaDefinitions = Collections.emptyList();
private ObjectNode templateChildNode;
private ObjectNode templateOptionsNode;
private ObjectNode swaggerDefinition;
@Override
protected Object executeInternal() throws Exception {
this.roleResolver = new RoleResolver(createServiceFor(AmazonIdentityManagementClient.class));
initConstants();
initProperties();
loadLambdaDefinitions();
createOrUpdateRestApi();
loadAndInterpolateSwaggerFile();
importDefinitions();
cleanupPermissions();
CreateDeploymentResult result = deploy();
return result;
}
private void initConstants() throws Exception {
try {
this.templateChildNode = (ObjectNode) objectMapper.readTree(STR_TEMPLATE_LAMBDA_METHOD);
} catch (Exception exc) {
getLog().warn("While building template credentials node:", exc);
}
try {
this.templateChildNode.with("x-amazon-apigateway-integration").put("credentials", roleResolver.lookupRoleGlob("*/apigateway-lambda-invoker"));
} catch (Exception exc) {
getLog().warn("While building template credentials node:", exc);
}
try {
this.templateOptionsNode = (ObjectNode) objectMapper.readTree(STR_TEMPLATE_CORS_METHOD);
} catch (Exception exc) {
getLog().warn("While building template credentials node:", exc);
}
}
private CreateDeploymentResult deploy() {
/**
* Step #1: Doing the stage itself
*/
CreateDeploymentRequest req =
new CreateDeploymentRequest()
.withRestApiId(restApiId)
.withStageName(stageName)
.withDescription(deploymentDescription)
.withStageName(stageName)
.withStageDescription(stageNameDescription)
.withCacheClusterEnabled(cacheClusterEnabled)
.withCacheClusterSize(cacheClusterSize)
.withVariables(getStageVariables(stageName));
getLog().info("Creating Deployment Request: " + req);
CreateDeploymentResult result = getService().createDeployment(req);
getLog().info("Deployment Request Result: " + result);
return result;
}
private Map<String, String> getStageVariables(String stageName) {
if (stageVariables.containsKey(stageName)) {
return stageVariables.get(stageName);
}
return Collections.emptyMap();
}
private void cleanupPermissions() {
}
private void initProperties() {
final Properties props = getProperties();
props
.entrySet()
.stream()
.map(e -> KEY_PARAM_REGEX.matcher(e.getKey() + ""))
.filter(m -> m.matches())
.forEach(
m -> {
String k = m.group(1);
String v = curProject.getProperties().getProperty(m.group(0), "");
if (isEmpty(v) && parameters.containsKey(k)) {
getLog().info("Removing parameter " + k);
parameters.remove(k);
} else {
getLog().info("Updating parameter " + k + ": " + v);
parameters.put(k, v);
}
});
props
.entrySet()
.stream()
.map(e -> KEY_STAGE_VARIABLE_REGEX.matcher(e.getKey() + ""))
.filter(m -> m.matches())
.forEach(
m -> {
String env = m.group(1);
String k = m.group(2);
String v = curProject.getProperties().getProperty(m.group(0), "");
Map<String, String> stageVariablesForEnv = stageVariables.get(env);
if (null == stageVariablesForEnv) {
stageVariablesForEnv = new LinkedHashMap<String, String>();
stageVariables.put(env, stageVariablesForEnv);
}
if (isEmpty(v) && stageVariablesForEnv.containsKey(k)) {
getLog().info("Removing stage variable " + k);
stageVariablesForEnv.remove(k);
} else {
getLog().info("Updating stage variable " + k + ": " + v);
stageVariablesForEnv.put(k, v);
}
});
if (props.containsKey("apigateway.restApiId")) {
restApiId = props.getProperty("apigateway.restApiId");
}
if (props.containsKey("apigateway.restApiName")) {
restApiName = props.getProperty("apigateway.restApiName");
}
// if (props.containsKey("apigateway.restApiHost")) {
// restApiHost = props.getProperty("apigateway.restApiHost");
// }
}
protected void loadLambdaDefinitions() throws Exception {
if (null != lambdasFile && lambdasFile.exists()) {
getLog().info("Loading lambdas from " + lambdasFile.getPath());
Map<String, LambdaDefinition> defs = objectMapper.readValue(lambdasFile, new TypeReference<Map<String, LambdaDefinition>>() {
});
this.lambdaDefinitions = defs.values().stream().filter(x -> null != x.getApi()).collect(Collectors.toList());
this.lambdaDefinitions.forEach(x -> x.getApi().methodType = x.getApi().methodType.toLowerCase());
}
}
private PutRestApiResult importDefinitions() {
getLog().info("Uploading definitions update (overwrite mode?: " + overwriteDefinitions + ")");
final PutRestApiResult result =
getService()
.putRestApi(
new PutRestApiRequest()
.withRestApiId(restApiId)
.withMode(this.overwriteDefinitions ? PutMode.Overwrite : PutMode.Merge)
.withBody(ByteBuffer.wrap(body.getBytes(DEFAULT_CHARSET)))
.withParameters(parameters));
getLog().debug("result: " + result);
return result;
}
private void loadAndInterpolateSwaggerFile() throws Exception {
String accountId = roleResolver.getAccountId();
String deploymentFileContents = IOUtils.toString(new FileInputStream(deploymentFile));
boolean bYamlFile = (deploymentFile.getName().endsWith(".yaml"));
getLog().info("Loaded deploymentFile contents from " + deploymentFile.getPath());
// TODO: Consider PluginParameterExpressionEvaluator
deploymentFileContents = new StrSubstitutor(getProperties()).replace(deploymentFileContents);
// IMPROVE THIS
if (!skipInterpolate) {
deploymentFileContents = deploymentFileContents.replaceAll("\\Qarn:aws:iam:::role/\\E", "arn:aws:iam::" + accountId + ":role/");
deploymentFileContents =
deploymentFileContents.replaceAll("\\Qarn:aws:lambda:\\E[\\w\\-]*:\\d*:", "arn:aws:lambda:" + regionName + ":" + accountId + ":");
}
getLog().debug("Contents: " + deploymentFileContents);
swaggerDefinition = (ObjectNode) (bYamlFile ? YAML_OBJECT_MAPPER : objectMapper).readTree(deploymentFileContents);
swaggerDefinition.with("info").put("title", restApiName).put("description", restApiDescription);
mergeLambdas(swaggerDefinition);
ObjectNode objectNode = ObjectNode.class.cast(swaggerDefinition);
this.body = objectMapper.writeValueAsString(objectNode);
getLog().debug("Final body content: " + this.body);
}
protected void mergeLambdas(ObjectNode swaggerDefinition) throws Exception {
getLog().info("Loaded " + lambdaDefinitions.size() + " active lambda definitions.");
if (lambdaDefinitions.isEmpty() || skipInterpolate) {
getLog().info("Skipping interpolation.");
return;
}
ObjectNode pathNode = swaggerDefinition.with("paths");
for (LambdaDefinition d : lambdaDefinitions) {
removeConflictingDeclarations(d, pathNode);
ObjectNode parentNode = templateChildNode.deepCopy();
parentNode.with("x-amazon-apigateway-integration").put("httpMethod", "POST").put("uri", getUriFor(d));
final ArrayNode parametersNode = parentNode.putArray("parameters");
for (String parameterName : findParametersFor(d.getApi().getPath())) {
ObjectNode parameterNode = objectMapper.createObjectNode();
parameterNode.put("name", parameterName);
parameterNode.put("in", "path");
parameterNode.put("required", true);
parameterNode.put("type", "string");
parametersNode.add(parameterNode);
}
ObjectNode result = parentNode;
{
ArrayNode patches = getPatches(d.getApi());
if (null != patches) {
result = (ObjectNode) JsonPatch.apply(patches, parentNode);
}
}
pathNode.with(d.getApi().getPath()).with(d.getApi().getMethodType()).removeAll();
pathNode.with(d.getApi().getPath()).with(d.getApi().getMethodType()).setAll(result);
}
Map<String, Set<String>> corsPaths =
lambdaDefinitions
.stream()
.filter(x -> x.api.isCorsEnabled())
.map(x -> x.getApi())
.collect(groupingBy(x -> x.getPath(), mapping(k -> k.getMethodType(), toSet())));
for (Map.Entry<String, Set<String>> e : corsPaths.entrySet()) {
String supportedMethods = format("'%s'", StringUtils.join(e.getValue().iterator(), ","));
ObjectNode optionsNode = pathNode.with(e.getKey()).with("options");
optionsNode.setAll(templateOptionsNode);
optionsNode
.with("x-amazon-apigateway-integration")
.with("responses")
.with("default")
.with("responseParameters")
.put("method.response.header.Access-Control-Allow-Methods", supportedMethods);
}
}
private ArrayNode getPatches(LambdaDefinition.Api api) {
if (null == api.getPatches() || 0 == api.getPatches().length) return null;
ArrayNode result = objectMapper.createArrayNode();
result.addAll(
Arrays.stream(api.getPatches())
.map(
p ->
Unthrow.wrap(
patch -> {
ObjectNode resultNode = objectMapper.createObjectNode();
resultNode.put("op", patch.getOp().toLowerCase());
resultNode.put("path", patch.getPath());
// TODO: Interpolate patch.value
if (isNotBlank(patch.getValue())) {
JsonNode value = null;
String sourceValue = patch.getValue();
if (-1 != "[{\"".indexOf(sourceValue.charAt(0))) {
value = objectMapper.readTree(sourceValue);
} else {
value = resultNode.textNode(sourceValue);
}
resultNode.set("value", value);
} else if (isNotBlank(patch.getFrom())) {
JsonNode value = null;
String sourceValue = patch.getFrom();
if (-1 != "[{\"".indexOf(sourceValue.charAt(0))) {
value = objectMapper.readTree(sourceValue);
} else {
value = resultNode.textNode(sourceValue);
}
resultNode.set("from", value);
}
return resultNode;
},
p))
.collect(Collectors.toList()));
return result;
}
private String getUriFor(LambdaDefinition d) {
// "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1::function:do_validateBot/invocations"
return format("arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/%s/invocations", d.getArn());
}
private void removeConflictingDeclarations(LambdaDefinition d, ObjectNode pathNode) {
if (!removeConflicting) return;
String normalizedPath = normalizePath(d.getApi().getPath());
outer:
do {
for (String path : Lists.newArrayList(pathNode.fieldNames())) {
String normalizedExistingPath = normalizePath(path);
boolean hasMatchingPath = normalizedExistingPath.equals(normalizedPath);
if (hasMatchingPath) {
getLog().info("Renaming possibly conflicting path " + path + " to " + d.getApi().getPath() + " (overrides will apply)");
ObjectNode childNodesFromExisting = pathNode.with(path);
pathNode.with(d.getApi().getPath()).setAll(childNodesFromExisting);
pathNode.remove(path);
continue outer;
}
}
} while (false);
}
private String normalizePath(String path) {
return path.replaceAll("\\{\\w+\\}", "{}");
}
protected Set<String> findParametersFor(String path) {
Set<String> result = new LinkedHashSet<>();
Matcher m = PATTERN_PARAMETER.matcher(path);
while (m.find()) {
result.add(m.group(1));
}
return result;
}
private void createOrUpdateRestApi() throws Exception {
super.lookupIds();
if (isBlank(restApiId)) {
CreateRestApiRequest req = new CreateRestApiRequest();
req.withName(restApiName);
req.withDescription(defaultString(restApiDescription));
final CreateRestApiResult result = getService().createRestApi(req);
getLog().info("Created restApi " + req + ": " + result);
super.lookupIds();
}
}
}