Add PHP 7.1 as a kind (#2415)

* Implement PHP 7.1 kind
* Add tests for PHP 7.1 action
* Add PHP action documentation

Build the Docker container from php:7.1-alpine and implement the HTTP
server using PHP's built in server.

Note that when using a zip file, the router requires that the `main`
function is stored in `index.php`.

Note about the runner:
The runner sets the exit code to 1 if it has set the last line of stdout
to a string suitable for presentation to the user. Therefore, if the
exit code is not one, then display a generic message.

If there's a runtime error in the action (i.e. not spotted by linter),
then looking for the main() function will find it. Render the error to
the logs so that the user knows what's happened.

Note about vendor folder in a PHP zip:
If the PHP vendor file has a vendor directory, then this directory needs
to be used rather than the one supplied in the action container.

To do this, we require src/vendor/autoload.php which will exist if the
zip file contains it. For the two cases where (1) zip file does not contain a
vendor folder, or (2) when running a non-binary code action, we move the
container's vendor folder into src/.
diff --git a/core/php7.1Action/Dockerfile b/core/php7.1Action/Dockerfile
new file mode 100644
index 0000000..c5b74ae
--- /dev/null
+++ b/core/php7.1Action/Dockerfile
@@ -0,0 +1,50 @@
+FROM php:7.1-alpine
+
+RUN \
+    
+    apk update && apk upgrade && \
+
+    # install dependencies
+   apk add \
+       postgresql-dev \
+       icu \
+       icu-libs \
+       icu-dev \
+       freetype-dev \
+       libjpeg-turbo-dev \
+       libpng-dev \
+       libxml2-dev \
+
+   && \
+
+   # install useful PHP extensions
+   docker-php-ext-install \
+       opcache \
+       mysqli \
+       pdo_mysql \
+       pdo_pgsql \
+       intl \
+       bcmath \
+       zip \
+       gd \
+       soap \
+
+   && \
+
+    # install Composer
+    cd /tmp && curl -sS https://getcomposer.org/installer | php  -- --install-dir=/usr/bin --filename=composer
+
+# create src directory to store action files
+RUN mkdir -p /action/src
+
+# install Composer dependencies
+COPY composer.json /action
+RUN cd /action && /usr/bin/composer install --no-plugins --no-scripts --prefer-dist --no-dev -o && rm composer.lock
+
+# copy required files
+COPY router.php /action
+COPY runner.php /action
+
+# Run webserver on port 8080
+EXPOSE 8080
+CMD [ "php", "-S", "0.0.0.0:8080", "-d", "expose_php=0", "-d", "html_errors=0", "-d", "error_reporting=E_ALL", "/action/router.php" ]
diff --git a/core/php7.1Action/build.gradle b/core/php7.1Action/build.gradle
new file mode 100644
index 0000000..1e4c161
--- /dev/null
+++ b/core/php7.1Action/build.gradle
@@ -0,0 +1,2 @@
+ext.dockerImageName = 'action-php-v7.1'
+apply from: '../../gradle/docker.gradle'
diff --git a/core/php7.1Action/composer.json b/core/php7.1Action/composer.json
new file mode 100644
index 0000000..5aaa8fc
--- /dev/null
+++ b/core/php7.1Action/composer.json
@@ -0,0 +1,11 @@
+{
+    "config": {
+        "platform": {
+            "php": "7.1"
+        }
+    },
+    "require": {
+        "guzzlehttp/guzzle": "^6.3",
+        "ramsey/uuid": "^3.6"
+    }
+}
diff --git a/core/php7.1Action/router.php b/core/php7.1Action/router.php
new file mode 100644
index 0000000..63e3a75
--- /dev/null
+++ b/core/php7.1Action/router.php
@@ -0,0 +1,342 @@
+<?php
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * router.php
+ *
+ * This file is the API client for the action. The controller POSTs /init to set up the action and
+ * then POSTs to /run to invoke it.
+ */
+
+// set up an output buffer to redirect any script output to stdout, rather than the default
+// php://output, so that it goes to the logs, not the HTTP client.
+ob_start(function ($data) {
+    file_put_contents("php://stdout", $data);
+    return '';
+}, 1, PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_FLUSHABLE | PHP_OUTPUT_HANDLER_REMOVABLE);
+
+const ACTION_SRC_FILENAME = 'index.php';
+const SRC_DIR  = __DIR__ . '/src';
+const ACTION_CONFIG_FILE = __DIR__. '/config.json';
+const ACTION_SRC_FILE = SRC_DIR . '/' . ACTION_SRC_FILENAME;
+const ACTION_RUNNER_FILE = __DIR__. '/runner.php';
+const TMP_ZIP_FILE = '/action.zip';
+
+// execute the revelant endpoint
+$result = route($_SERVER['REQUEST_URI']);
+
+// send response
+$body = json_encode((object)$result);
+header('Content-Type: application/json');
+header("Content-Length: " . mb_strlen($body));
+ob_end_clean();
+echo $body;
+exit;
+
+/**
+ * executes the relevant method for a given URL and return an array of data to send to the client
+ */
+function route(string $uri) : array
+{
+    try {
+        switch ($uri) {
+            case '/init':
+                return init();
+
+            case '/run':
+                return run();
+
+            default:
+                throw new RuntimeException('Unexpected call to ' . $_SERVER["REQUEST_URI"], 500);
+        }
+    } catch (Throwable $e) {
+        $code = $e->getCode() < 400 ? 500 : $e->getCode();
+
+        if ($code != 502) {
+            writeTo("php://stdout", 'Error: ' . $e->getMessage());
+        }
+        writeSentinels();
+
+        http_response_code($code);
+        return ['error' => $e->getMessage()];
+    }
+
+    return '';
+}
+
+/**
+ * Handle the /init endpoint
+ *
+ * This end point is called once per container creation. It gives us the code we need
+ * to run and the name of the function within that code that's the entry point. As PHP
+ * has a setup/teardown model, we store the function name to a config file for retrieval
+ * in the /run end point.
+ *
+ * @return array Data to return to the client
+ */
+function init() : array
+{
+    // data is POSTed to us as a JSON string
+    $post = file_get_contents('php://input');
+    $data = json_decode($post, true)['value'] ?? [];
+
+    $name = $data['name'] ?? '';         // action name
+    $main = $data['main'] ?? 'main';     // function to call (default: main)
+    $code = trim($data['code'] ?? '');   // source code to run
+    $binary = $data['binary'] ?? false;  // code is binary?
+
+    if (!$code) {
+        throw new RuntimeException("No code to execute");
+    }
+
+    if ($binary) {
+        // binary code is a zip file that's been base64 encoded, so unzip it
+        unzipString($code, SRC_DIR);
+
+        // if the zip file didn't contain a vendor directory, move our vendor into the src folder
+        if (! file_exists(SRC_DIR . '/vendor/autoload.php')) {
+            exec('mv ' . escapeshellarg(__DIR__ . '/vendor') . ' ' . escapeshellarg(SRC_DIR . '/vendor'));
+        }
+
+        // check that we have the expected action source file
+        if (! file_exists(ACTION_SRC_FILE)) {
+            throw new RuntimeException('Zipped actions must contain ' . ACTION_SRC_FILENAME . ' at the root.', 500);
+        }
+    } else {
+        // non-binary code is a text string, so save to disk
+        file_put_contents(ACTION_SRC_FILE, $code);
+
+        // move vendor folder into the src folder
+        exec('mv ' . escapeshellarg(__DIR__ . '/vendor') . ' ' . escapeshellarg(SRC_DIR . '/vendor'));
+    }
+
+    // is action file valid PHP? run `php -l` to find out
+    list($returnCode, $stdout, $stderr) = runPHP(['-l', '-f', ACTION_SRC_FILE]);
+    if ($returnCode != 0) {
+        writeTo("php://stderr", $stderr);
+        writeTo("php://stdout", $stdout);
+
+        $message = 'PHP syntax error in ' . ($binary ? ACTION_SRC_FILENAME : 'action.');
+        throw new RuntimeException($message, 500);
+    }
+
+    // does the action have the expected function name?
+    $testCode = 'require "' . ACTION_SRC_FILENAME . '"; exit((int)(! function_exists("' . $main .'")));';
+    list($returnCode, $stdout, $stderr) = runPHP(['-r', $testCode]);
+    if ($returnCode != 0) {
+        writeTo("php://stderr", $stderr);
+        writeTo("php://stdout", $stdout);
+        throw new RuntimeException("The function $main is missing.");
+    }
+
+    // write config file for use by /run
+    $config = [
+        'file' => ACTION_SRC_FILENAME,
+        'function' => $main,
+        'name' => $name,
+    ];
+    file_put_contents(ACTION_CONFIG_FILE, json_encode($config));
+
+    return ["OK" => true];
+}
+
+/**
+ * Handle the /run endpoint
+ *
+ * This end point is called once per action invocation. We load the function name from
+ * the config file and then invoke it. Note that as PHP writes to php://output, we
+ * capture in an output buffer and write the buffer to stdout for the OpenWhisk logs.
+ *
+ * @return array Data to return to the client
+ */
+function run() : array
+{
+    if (! file_exists(ACTION_SRC_FILE)) {
+        error_log('NO ACTION FILE: ' . ACTION_SRC_FILE);
+        throw new RuntimeException('!Could not find action file: ' . ACTION_SRC_FILENAME, 500);
+    }
+
+    // load config to pass to runner
+    if (! file_exists(ACTION_CONFIG_FILE)) {
+        error_log('NO CONFIG FILE: ' . ACTION_CONFIG_FILE);
+        throw new RuntimeException('Could not find config file', 500);
+    }
+    $config = file_get_contents(ACTION_CONFIG_FILE);
+    
+    // Extract the posted data
+    $post = json_decode(file_get_contents('php://input'), true);
+    if (!is_array($post)) {
+        $post = [];
+    }
+
+    // assign environment variables from the posted data
+    $env = array_filter($_ENV, function ($k) {
+        // only pass OpenWhisk environment variables to the action
+        return stripos($k, '__OW_') === 0;
+    }, ARRAY_FILTER_USE_KEY);
+    $env['PHP_VERSION'] = $_ENV['PHP_VERSION'];
+    foreach (['api_key', 'namespace', 'action_name', 'activation_id', 'deadline'] as $param) {
+        if (array_key_exists($param, $post)) {
+            $env['__OW_' . strtoupper($param)] = $post[$param];
+        }
+    }
+
+    // extract the function arguments from the posted data's "value" field
+    $args = '{}';
+    if (array_key_exists('value', $post) && is_array($post['value'])) {
+        $args = json_encode($post['value']);
+    }
+    $env['WHISK_INPUT'] = $args;
+
+    // run the action
+    list($returnCode, $stdout, $stderr) = runPHP(
+        ['-d', 'error_reporting=E_ALL', '-f', ACTION_RUNNER_FILE, '--', $config],
+        $args,
+        $env
+    );
+
+    // separate the esponse to send back to the client: it's the last line of stdout
+    $pos = strrpos($stdout, PHP_EOL);
+    if ($pos == false) {
+        // just one line of output
+        $lastLine = $stdout;
+        $stdout = '';
+    } else {
+        $pos++;
+        $lastLine = trim(substr($stdout, $pos));
+        $stdout = trim(substr($stdout, 0, $pos));
+    }
+
+    // write out the action's stderr and stdout
+    writeTo("php://stderr", $stderr);
+    writeTo("php://stdout", $stdout);
+
+    $output = json_decode($lastLine, true);
+    if ($returnCode != 0 || !is_array($output)) {
+        // an error occurred while running the action
+        // the return code will be 1 if the stdout is printable to the user
+        if ($returnCode != 1) {
+            // otherwise put out a generic message and send $lastLine to stdout
+            writeTo("php://stdout", $lastLine);
+            $lastLine = 'An error occurred running the action.';
+        }
+        throw new RuntimeException($lastLine, 502);
+    }
+
+    // write sentinels as action is completed
+    writeSentinels();
+
+    return $output;
+}
+
+/**
+ * Unzip a base64 encoded string to a directory
+ */
+function unzipString(string $b64Data, $dir): void
+{
+    file_put_contents(TMP_ZIP_FILE, base64_decode($b64Data));
+
+    $zip = new ZipArchive();
+    $res = $zip->open(TMP_ZIP_FILE);
+    if ($res !== true) {
+        $reasons = [
+            ZipArchive::ER_EXISTS => "File already exists.",
+            ZipArchive::ER_INCONS => "Zip archive inconsistent.",
+            ZipArchive::ER_INVAL => "Invalid argument.",
+            ZipArchive::ER_MEMORY => "Malloc failure.",
+            ZipArchive::ER_NOENT => "No such file.",
+            ZipArchive::ER_NOZIP => "Not a zip archive.",
+            ZipArchive::ER_OPEN => "Can't open file.",
+            ZipArchive::ER_READ => "Read error.",
+            ZipArchive::ER_SEEK => "Seek error.",
+        ];
+        $reason = $reasons[$res] ?? "Unknown error: $res.";
+        throw new RuntimeException("Failed to open zip file: $reason", 500);
+    }
+
+    $res = $zip->extractTo($dir . '/');
+    $zip->close();
+}
+
+/**
+ * Write the OpenWhisk sentinels to stdout and stderr so that it knows that we've finished
+ * writing data to them.
+ *
+ * @return void
+ */
+function writeSentinels() : void
+{
+    // write out sentinels as we've finished all log output
+    writeTo("php://stderr", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX");
+    writeTo("php://stdout", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX");
+}
+
+/**
+ * Run the PHP command in a separate process
+ *
+ * This ensures that if the action causes a fatal error, we can handle it.
+ *
+ * @param  array  $args  arguments to the PHP executable
+ * @param  string $stdin stdin to pass to the process
+ * @param  array  $env   environment variables to set for the process
+ * @return array         array containing [int return code, string stdout string stderr]
+ */
+function runPHP(array $args, string $stdin = '', array $env = []) : array
+{
+    $cmd = '/usr/local/bin/php ' . implode(' ', array_map('escapeshellarg', $args));
+
+    $process = proc_open(
+        $cmd,
+        [
+            0 => ['pipe', 'r'],
+            1 => ['pipe', 'w'],
+            2 => ['pipe', 'w'],
+        ],
+        $pipes,
+        SRC_DIR,
+        $env
+    );
+
+    // write to the process' stdin
+    $bytes = fwrite($pipes[0], $stdin);
+    fclose($pipes[0]);
+
+    // read the process' stdout
+    $stdout = stream_get_contents($pipes[1]);
+    fclose($pipes[1]);
+
+    // read the process' stderr
+    $stderr = stream_get_contents($pipes[2]);
+    fclose($pipes[2]);
+
+    // close process & get return code
+    $returnCode = proc_close($process);
+
+    // tidy up paths in any PHP stack traces
+    $stderr = str_replace(__DIR__ . '/', '', trim($stderr));
+    $stdout = str_replace(__DIR__ . '/', '', trim($stdout));
+
+    return [$returnCode, $stdout, $stderr];
+}
+
+function writeTo($pipe, $text)
+{
+    if ($text) {
+        file_put_contents($pipe, $text . PHP_EOL);
+    }
+}
diff --git a/core/php7.1Action/runner.php b/core/php7.1Action/runner.php
new file mode 100644
index 0000000..0e91747
--- /dev/null
+++ b/core/php7.1Action/runner.php
@@ -0,0 +1,69 @@
+<?php
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+/**
+ * runner.php
+ *
+ * This file runs the action code provided by the user. It is executed in the PHP CLI environment
+ * by router.php and will require the index.php file and call the main() function (or whatever has
+ * been configured).
+ *
+ * The configuration information is passed in as a JSON object as the first argument to this script
+ * and the OpenWhisk action argumentsare passed in as a JSON object via stdin.
+ */
+
+// read config from argv[1] and assign
+if ($argc != 2) {
+    file_put_contents("php://stderr", 'Expected a single config parameter');
+    exit(1);
+}
+
+$config = json_decode($argv[1], true);
+if (!is_array($config)) {
+    file_put_contents("php://stderr", "Invalid config: {$argv[1]}.");
+    exit(1);
+}
+
+$_actionFile = $config['file'] ?? 'index.php';
+$_functionName = $config['function'] ?? 'main';
+unset($argv[1], $config);
+
+// does the action file exist?
+if (! file_exists($_actionFile)) {
+    file_put_contents("php://stderr", "Could not find action file: $_actionFile.");
+    exit(1);
+}
+
+// run the action with arguments from stdin
+require __DIR__ . '/src/vendor/autoload.php';
+require $_actionFile;
+
+$result = $_functionName(json_decode(file_get_contents('php://stdin') ?? [], true));
+
+if (is_scalar($result)) {
+    file_put_contents("php://stderr", 'Result must be an array but has type "'
+        . gettype($result) . '": ' . (string)$result . "\n");
+    file_put_contents("php://stdout", 'The action did not return a dictionary.');
+    exit(1);
+} elseif (is_object($result) && method_exists($result, 'getArrayCopy')) {
+    $result = $result->getArrayCopy();
+}
+
+// cast result to an object for json_encode to ensure that an empty array becomes "{}"
+echo "\n";
+echo json_encode((object)$result);
diff --git a/settings.gradle b/settings.gradle
index 868fcd7..375b6de 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -10,6 +10,7 @@
 include 'core:swift3Action'
 include 'core:swift3.1.1Action'
 include 'core:javaAction'
+include 'core:php7.1Action'
 
 include 'tools:cli'
 
diff --git a/tests/src/test/scala/actionContainers/Php71ActionContainerTests.scala b/tests/src/test/scala/actionContainers/Php71ActionContainerTests.scala
new file mode 100644
index 0000000..bfb53bf
--- /dev/null
+++ b/tests/src/test/scala/actionContainers/Php71ActionContainerTests.scala
@@ -0,0 +1,496 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 actionContainers
+
+import org.junit.runner.RunWith
+import org.scalatest.junit.JUnitRunner
+
+import ActionContainer.withContainer
+import ResourceHelpers.ZipBuilder
+
+import common.WskActorSystem
+import spray.json._
+
+@RunWith(classOf[JUnitRunner])
+class Php71ActionContainerTests extends BasicActionRunnerTests with WskActorSystem {
+    // note: "out" will not be empty as the PHP web server outputs a message when
+    // it starts up
+    val enforceEmptyOutputStream = false
+
+    lazy val php71ContainerImageName = "action-php-v7.1"
+
+    override def withActionContainer(env: Map[String, String] = Map.empty)(code: ActionContainer => Unit) = {
+        withContainer(php71ContainerImageName, env)(code)
+    }
+
+    def withPhp71Container(code: ActionContainer => Unit) = withActionContainer()(code)
+
+    behavior of php71ContainerImageName
+
+    testEcho(Seq {
+        ("PHP", """
+          |<?php
+          |function main(array $args) : array {
+          |    echo 'hello stdout';
+          |    error_log('hello stderr');
+          |    return $args;
+          |}
+          """.stripMargin)
+    })
+
+    testNotReturningJson(
+        """
+        |<?php
+        |function main(array $args) {
+        |    return "not a json object";
+        |}
+        """.stripMargin)
+
+    testUnicode(Seq {
+        ("PHP", """
+         |<?php
+         |function main(array $args) : array {
+         |    $str = $args['delimiter'] . " ☃ " . $args['delimiter'];
+         |    echo $str . "\n";
+         |    return  ["winter" => $str];
+         |}
+         """.stripMargin.trim)
+    })
+
+    testEnv(Seq {
+        ("PHP", """
+         |<?php
+         |function main(array $args) : array {
+         |    return [
+         |       "env" => $_ENV,
+         |       "api_host" => $_ENV['__OW_API_HOST'],
+         |       "api_key" => $_ENV['__OW_API_KEY'],
+         |       "namespace" => $_ENV['__OW_NAMESPACE'],
+         |       "action_name" => $_ENV['__OW_ACTION_NAME'],
+         |       "activation_id" => $_ENV['__OW_ACTIVATION_ID'],
+         |       "deadline" => $_ENV['__OW_DEADLINE'],
+         |    ];
+         |}
+         """.stripMargin.trim)
+    }, enforceEmptyOutputStream)
+
+    it should "fail to initialize with bad code" in {
+        val (out, err) = withPhp71Container { c =>
+            val code = """
+                |<?php
+                | 10 PRINT "Hello world!"
+                | 20 GOTO 10
+            """.stripMargin
+
+            val (initCode, error) = c.init(initPayload(code))
+            initCode should not be (200)
+            error shouldBe a[Some[_]]
+            error.get shouldBe a[JsObject]
+            error.get.fields("error").toString should include("PHP syntax error")
+        }
+
+        // Somewhere, the logs should mention an error occurred.
+        checkStreams(out, err, {
+            case (o, e) =>
+                (o + e).toLowerCase should include("error")
+                (o + e).toLowerCase should include("syntax")
+        })
+    }
+
+    it should "fail to initialize with no code" in {
+        val (out, err) = withPhp71Container { c =>
+            val code = ""
+
+            val (initCode, error) = c.init(initPayload(code))
+
+            initCode should not be (200)
+            error shouldBe a[Some[_]]
+            error.get shouldBe a[JsObject]
+            error.get.fields("error").toString should include("No code to execute")
+        }
+    }
+
+    it should "return some error on action error" in {
+         val (out, err) = withPhp71Container { c =>
+            val code = """
+                |<?php
+                | function main(array $args) : array {
+                |     throw new Exception ("nooooo");
+                | }
+            """.stripMargin
+
+            val (initCode, _) = c.init(initPayload(code))
+            initCode should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+            runCode should not be (200)
+
+            runRes shouldBe defined
+            runRes.get.fields.get("error") shouldBe defined
+            // runRes.get.fields("error").toString.toLowerCase should include("nooooo")
+        }
+
+        // Somewhere, the logs should be the error text
+        checkStreams(out, err, {
+            case (o, e) =>
+                (o + e).toLowerCase should include("nooooo")
+        })
+
+    }
+
+    it should "support application errors" in {
+        withPhp71Container { c =>
+            val code = """
+                |<?php
+                | function main(array $args) : array {
+                |     return [ "error" => "sorry" ];
+                | }
+            """.stripMargin;
+
+            val (initCode, error) = c.init(initPayload(code))
+            initCode should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+            runCode should be(200) // action writer returning an error is OK
+
+            runRes shouldBe defined
+            runRes.get.fields.get("error") shouldBe defined
+            runRes.get.fields("error").toString.toLowerCase should include("sorry")
+        }
+    }
+
+    it should "fail gracefully when an action has a fatal error" in {
+        val (out, err) = withPhp71Container { c =>
+            val code = """
+                | <?php
+                | function main(array $args) : array {
+                |     eval("class Error {};");
+                |     return [ "hello" => "world" ];
+                | }
+            """.stripMargin;
+
+
+            val (initCode, _) = c.init(initPayload(code))
+            initCode should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+            runCode should be(502)
+
+            runRes shouldBe defined
+            runRes.get.fields.get("error") shouldBe defined
+            runRes.get.fields("error").toString should include("An error occurred running the action.")
+        }
+
+        // Somewhere, the logs should be the error text
+        checkStreams(out, err, {
+            case (o, e) =>
+                (o + e).toLowerCase should include("fatal error")
+        })
+    }
+
+    it should "suport returning a stdClass" in {
+        val (out, err) = withPhp71Container { c =>
+            val code = """
+                | <?php
+                | function main($params) {
+                |     $obj = new stdClass();
+                |     $obj->hello = 'world';
+                |     return $obj;
+                | }
+            """.stripMargin
+
+
+            val (initCode, _) = c.init(initPayload(code))
+            initCode should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+            runCode should be(200) // action writer returning an error is OK
+
+            runRes shouldBe defined
+            runRes.get.fields.get("hello") shouldBe defined
+            runRes.get.fields("hello").toString.toLowerCase should include("world")
+        }
+    }
+
+    it should "support returning an object with a getArrayCopy() method" in {
+        val (out, err) = withPhp71Container { c =>
+            val code = """
+                | <?php
+                | function main($params) {
+                |     $obj = new ArrayObject();
+                |     $obj['hello'] = 'world';
+                |     return $obj;
+                | }
+            """.stripMargin
+
+
+            val (initCode, _) = c.init(initPayload(code))
+            initCode should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+            runCode should be(200) // action writer returning an error is OK
+
+            runRes shouldBe defined
+            runRes.get.fields.get("hello") shouldBe defined
+            runRes.get.fields.get("hello") shouldBe Some(JsString("world"))
+        }
+    }
+
+    it should "support the documentation examples (1)" in {
+        val (out, err) = withPhp71Container { c =>
+            val code = """
+                | <?php
+                | function main($params) {
+                |     if ($params['payload'] == 0) {
+                |         return;
+                |     } else if ($params['payload'] == 1) {
+                |         return ['payload' => 'Hello, World!'] ;        // indicates normal completion
+                |     } else if ($params['payload'] == 2) {
+                |         return ['error' => 'payload must be 0 or 1'];  // indicates abnormal completion
+                |     }
+                | }
+            """.stripMargin
+
+            c.init(initPayload(code))._1 should be(200)
+
+            val (c1, r1) = c.run(runPayload(JsObject("payload" -> JsNumber(0))))
+            val (c2, r2) = c.run(runPayload(JsObject("payload" -> JsNumber(1))))
+            val (c3, r3) = c.run(runPayload(JsObject("payload" -> JsNumber(2))))
+
+            c1 should be(200)
+            r1 should be(Some(JsObject()))
+
+            c2 should be(200)
+            r2 should be(Some(JsObject("payload" -> JsString("Hello, World!"))))
+
+            c3 should be(200) // application error, not container or system
+            r3.get.fields.get("error") shouldBe Some(JsString("payload must be 0 or 1"))
+        }
+    }
+
+    it should "have Guzzle and Uuid packages available" in {
+        // GIVEN that it should "error when requiring a non-existent package" (see test above for this)
+        val (out, err) = withPhp71Container { c =>
+            val code = """
+                | <?php
+                | use Ramsey\Uuid\Uuid;
+                | use GuzzleHttp\Client;
+                | function main(array $args) {
+                |     Uuid::uuid4();
+                |     new Client();
+                | }
+            """.stripMargin
+
+            val (initCode, _) = c.init(initPayload(code))
+
+            initCode should be(200)
+
+            // WHEN I run an action that calls a Guzzle & a Uuid method
+            val (runCode, out) = c.run(runPayload(JsObject()))
+
+            // THEN it should pass only when these packages are available
+            runCode should be(200)
+        }
+    }
+
+    it should "support large-ish actions" in {
+        val thought = " I took the one less traveled by, and that has made all the difference."
+        val assignment = "    $x = \"" + thought + "\";\n"
+
+        val code = """
+            | <?php
+            | function main(array $args) {
+            |     $x = "hello";
+            """.stripMargin + (assignment * 7000) + """
+            |     $x = "world";
+            |     return [ "message" => $x ];
+            | }
+            """.stripMargin
+
+        // Lest someone should make it too easy.
+        code.length should be >= 500000
+
+        val (out, err) = withPhp71Container { c =>
+            c.init(initPayload(code))._1 should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+
+            runCode should be(200)
+            runRes.get.fields.get("message") shouldBe defined
+            runRes.get.fields.get("message") shouldBe Some(JsString("world"))
+        }
+    }
+
+    val exampleOutputDotPhp: String = """
+        | <?php
+        | function output($data) {
+        |     return ['result' => $data];
+        | }
+    """.stripMargin
+
+    it should "support zip-encoded packages" in {
+        val srcs = Seq(
+            Seq("output.php") -> exampleOutputDotPhp,
+            Seq("index.php") -> """
+                | <?php
+                | require __DIR__ . '/output.php';
+                | function main(array $args) {
+                |     $name = $args['name'] ?? 'stranger';
+                |     return output($name);
+                | }
+            """.stripMargin)
+
+        val code = ZipBuilder.mkBase64Zip(srcs)
+
+        val (out, err) = withPhp71Container { c =>
+
+            c.init(initPayload(code))._1 should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+
+            runCode should be(200)
+            runRes.get.fields.get("result") shouldBe defined
+            runRes.get.fields.get("result") shouldBe Some(JsString("stranger"))
+        }
+    }
+
+    it should "support replacing vendor in zip-encoded packages " in {
+        val srcs = Seq(
+            Seq("vendor/autoload.php") -> exampleOutputDotPhp,
+            Seq("index.php") -> """
+                | <?php
+                | function main(array $args) {
+                |     $name = $args['name'] ?? 'stranger';
+                |     return output($name);
+                | }
+            """.stripMargin)
+
+        val code = ZipBuilder.mkBase64Zip(srcs)
+
+        val (out, err) = withPhp71Container { c =>
+
+            c.init(initPayload(code))._1 should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+
+            runCode should be(200)
+            runRes.get.fields.get("result") shouldBe defined
+            runRes.get.fields.get("result") shouldBe Some(JsString("stranger"))
+        }
+    }
+
+    it should "fail gracefully on invalid zip files" in {
+        // Some text-file encoded to base64.
+        val code = "Q2VjaSBuJ2VzdCBwYXMgdW4gemlwLgo="
+
+        val (out, err) = withPhp71Container { c =>
+
+            val (initCode, error) = c.init(initPayload(code))
+            initCode should not be(200)
+            error shouldBe a[Some[_]]
+            error.get shouldBe a[JsObject]
+            error.get.fields("error").toString should include("Failed to open zip file")
+        }
+
+        // Somewhere, the logs should mention the failure
+        checkStreams(out, err, {
+            case (o, e) =>
+                (o + e).toLowerCase should include("error")
+                (o + e).toLowerCase should include("failed to open zip file")
+        })
+    }
+
+    it should "fail gracefully on valid zip files that are not actions" in {
+        val srcs = Seq(
+            Seq("hello") -> """
+                | Hello world!
+            """.stripMargin)
+
+        val code = ZipBuilder.mkBase64Zip(srcs)
+
+        val (out, err) = withPhp71Container { c =>
+            c.init(initPayload(code))._1 should not be (200)
+        }
+
+        checkStreams(out, err, {
+            case (o, e) =>
+                (o + e).toLowerCase should include("error")
+                (o + e).toLowerCase should include("zipped actions must contain index.php at the root.")
+        })
+    }
+
+    it should "fail gracefully on valid zip files with invalid code in index.php" in {
+        val (out, err) = withPhp71Container { c =>
+            val srcs = Seq(
+                Seq("index.php") -> """
+                    | <?php
+                    | 10 PRINT "Hello world!"
+                    | 20 GOTO 10
+                """.stripMargin)
+
+            val code = ZipBuilder.mkBase64Zip(srcs)
+
+            val (initCode, error) = c.init(initPayload(code))
+            initCode should not be (200)
+            error shouldBe a[Some[_]]
+            error.get shouldBe a[JsObject]
+            error.get.fields("error").toString should include("PHP syntax error in index.php")
+        }
+
+        // Somewhere, the logs should mention an error occurred.
+        checkStreams(out, err, {
+            case (o, e) =>
+                (o + e).toLowerCase should include("error")
+                (o + e).toLowerCase should include("syntax")
+        })
+    }
+
+    it should "support actions using non-default entry point" in {
+        val (out, err) = withPhp71Container { c =>
+            val code = """
+            | <?php
+            | function niam(array $args) {
+            |     return [result => "it works"];
+            | }
+            """.stripMargin
+
+            c.init(initPayload(code, main = "niam"))._1 should be(200)
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+            runRes.get.fields.get("result") shouldBe Some(JsString("it works"))
+        }
+    }
+
+    it should "support zipped actions using non-default entry point" in {
+        val srcs = Seq(
+            Seq("index.php") -> """
+                | <?php
+                | function niam(array $args) {
+                |     return [result => "it works"];
+                | }
+            """.stripMargin)
+
+        val code = ZipBuilder.mkBase64Zip(srcs)
+
+        withPhp71Container { c =>
+            c.init(initPayload(code, main = "niam"))._1 should be(200)
+
+            val (runCode, runRes) = c.run(runPayload(JsObject()))
+            runRes.get.fields.get("result") shouldBe Some(JsString("it works"))
+        }
+    }
+}