src/StackFormation/Preprocessor.php
<?php
namespace StackFormation;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
class Preprocessor
{
const MAX_JS_FILE_INCLUDE_SIZE = 4096;
public function processJson($json, $basePath)
{
if (!is_string($json)) {
throw new \InvalidArgumentException('Expected json string');
}
// TODO: refactor to use a pipeline
$json = $this->stripComments($json);
$json = $this->parseRefInDoubleQuotedStrings($json);
$json = $this->expandPort($json);
$json = $this->injectFilecontent($json, $basePath);
$json = $this->base64encodedJson($json);
$json = $this->split($json);
$json = $this->replaceFnGetAttr($json);
$json = $this->replaceRef($json);
$json = $this->replaceMarkers($json);
return $json;
}
protected function stripComments($json)
{
// there's a problem with '"http://example.com"' being converted to '"http:'
// $json = preg_replace('~//[^\r\n]*|/\*.*?\*/~s', '', $json);
// there's a problem with "arn:aws:s3:::my-bucket/*"
// $json = preg_replace('~/\*.*?\*/~s', '', $json);
// quick workaround: don't allow quotes
$json = preg_replace('~/\*[^"]*?\*/~s', '', $json);
return $json;
}
protected function parseRefInDoubleQuotedStrings($json)
{
$json = preg_replace_callback(
'/"([^"]*){Ref:(.+?)}([^"]*)"/',
function ($matches) {
$snippet = $matches[0];
$snippet = trim($snippet, '"');
$pieces = preg_split('/({Ref:.+})/U', $snippet, -1, PREG_SPLIT_DELIM_CAPTURE);
$processedPieces = [];
foreach ($pieces as $piece) {
if (empty($piece)) {
continue;
}
if (substr($piece, 0, 5) == '{Ref:') {
$processedPieces[] = preg_replace('/{Ref:(.+)}/', '{"Ref":"$1"}', $piece);
} else {
$processedPieces[] = '"' . $piece . '"';
}
}
return '{"Fn::Join": ["", [' . implode(', ', $processedPieces) . ']]}';
},
$json
);
return $json;
}
protected function replaceMarkers($json)
{
$markers = [
'###TIMESTAMP###' => date(\DateTime::ISO8601),
];
$json = str_replace(array_keys($markers), array_values($markers), $json);
$json = preg_replace_callback(
'/###ENV:([^#:]+)###/',
function ($matches) {
if (!getenv($matches[1])) {
throw new \Exception("Environment variable '{$matches[1]}' not found");
}
return getenv($matches[1]);
},
$json
);
return $json;
}
protected function expandPort($jsonString)
{
return preg_replace('/([\{,]\s*)"Port"\s*:\s*"(\d+)"/', '\1"FromPort": "\2", "ToPort": "\2"', $jsonString);
}
protected function injectFilecontent($jsonString, $basePath)
{
$jsonString = preg_replace_callback(
'/(\s*)(.*){\s*"Fn::FileContent(Unpretty|TrimLines|Minify)?"\s*:\s*"(.+?)"\s*}/',
function (array $matches) use ($basePath) {
$file = $basePath . '/' . end($matches);
if (!is_file($file)) {
throw new FileNotFoundException("File '$file' not found");
}
$ext = pathinfo($file, PATHINFO_EXTENSION);
if ($matches[3] == 'Minify' && $ext != 'js') {
throw new \Exception('Fn::FileContentMinify is only supported for *.js files. (File: ' . $file . ')');
}
$fileContent = file_get_contents($file);
$fileContent = $this->injectInclude($fileContent, dirname(realpath($file)));
if ($ext === 'js') {
if ($matches[3] == 'Minify') {
$fileContent = \JShrink\Minifier::minify($fileContent, ['flaggedComments' => false]);
}
$size = strlen($fileContent);
if ($size > self::MAX_JS_FILE_INCLUDE_SIZE) {
// this is assuming you are uploading an inline JS file to AWS Lambda
throw new \Exception(sprintf("JS file is larger than %s bytes (actual size: %s bytes)", self::MAX_JS_FILE_INCLUDE_SIZE, $size));
}
}
// TODO: this isn't optimal. Why are we processing this here in between?
$fileContent = $this->base64encodedJson($fileContent);
$lines = explode("\n", $fileContent);
foreach ($lines as $key => &$line) {
if ($matches[3] == 'TrimLines') {
$line = trim($line);
if (empty($line)) {
unset($lines[$key]);
}
}
$line .= "\n";
}
if ($matches[3] == 'Unpretty') {
$result = ' {"Fn::Join": ["", ' . json_encode(array_values($lines)) . ']}';
} else {
$result = ' {"Fn::Join": ["", ' . json_encode(array_values($lines), JSON_PRETTY_PRINT) . ']}';
}
$whitespace = trim($matches[1], "\n");
$result = str_replace("\n", "\n" . $whitespace, $result);
return $matches[1] . $matches[2] . $result;
},
$jsonString
);
return $jsonString;
}
protected function split($jsonString)
{
return preg_replace_callback(
'/(\s*)(.*){\s*"Fn::Split"\s*:\s*\[\s*"(.*?)"\s*,\s*"(.*?)"\s*\]\s*}/',
function (array $matches) {
if (empty($matches[3])) {
throw new \Exception('Delimiter cannot be empty');
}
if (empty($matches[4])) {
throw new \Exception('String cannot be empty');
}
$pieces = explode($matches[3], $matches[4]);
return $matches[1] . $matches[2] . '["' . implode('", "', $pieces).'"]';
},
$jsonString
);
}
protected function injectInclude($string, $basePath)
{
return preg_replace_callback(
'/###INCLUDE:(.+)/',
function (array $matches) use ($basePath) {
$file = $basePath . '/' . $matches[1];
# Parse ENV vars in file names...
$file = $this->replaceMarkers($file);
if (!is_file($file)) {
throw new FileNotFoundException("File $file not found");
}
$fileContent = file_get_contents($file);
$fileContent = trim($fileContent);
return $fileContent;
},
$string
);
}
protected function replaceRef($jsonString)
{
return preg_replace('/\{\s*Ref\s*:\s*([a-zA-Z0-9:]+?)\s*\}/', '", {"Ref": "$1"}, "', $jsonString);
}
/**
* @param $jsonString
* @return mixed
*/
protected function base64encodedJson($jsonString)
{
$jsonString = preg_replace_callback(
'/###JSON###(.+?)######/',
function (array $m) {
return '", ' . base64_decode($m[1]) . ', "';
},
$jsonString
);
return $jsonString;
}
/**
* transforms {Fn::GetAtt:[resource,attribute]} to inline statement
*
* @param $jsonstring
* @return mixed
*/
protected function replaceFnGetAttr($jsonstring)
{
return preg_replace('/\{\s*Fn\s*::\s*GetAtt\s*:\s*\[\s*([a-zA-Z0-9:]+?)\s*,\s*([a-zA-Z0-9:]+?)\s*\]\s*\}/',
'", {"Fn::GetAtt": ["$1", "$2"]} ,"', $jsonstring);
}
}